diff --git a/CHANGELOG.md b/CHANGELOG.md index 94f0bbf6..35689be0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # CHANGELOG +## Next Release + +- Removes the deprecated, unusable `add_credit_card` function + - Stripe has disabled the ability to pass plain credit card details over the wire and now requires using [Stripe.js/Elements/Checkout](https://support.stripe.com/questions/card-tokenization-restrictions-using-publishable-keys). Follow the [Decentralized (EasyPost-Manage Billing) Guide](https://docs.easypost.com/guides/get-started-with-forge/easypost-managed-billing-guide#referralcustomer-billing-management) for more details on the new flow to use. + - Makes `referral_customer.retrieve_easypost_stripe_api_key` public to help facilitate adding credit cards using Stripe.js + ## v10.7.0 (2026-06-25) - Adds `params` to `request_pin` ensuring users can pass `easypost_details` to the call. diff --git a/easypost/services/referral_customer_service.py b/easypost/services/referral_customer_service.py index 17a3c533..236715ba 100644 --- a/easypost/services/referral_customer_service.py +++ b/easypost/services/referral_customer_service.py @@ -4,15 +4,10 @@ Optional, ) -import requests - from easypost.constant import ( _FILTERS_KEY, - SEND_STRIPE_DETAILS_ERROR, - TIMEOUT, ) from easypost.easypost_object import convert_to_easypost_object -from easypost.errors import ExternalApiError from easypost.models import User from easypost.requestor import ( RequestMethod, @@ -95,40 +90,6 @@ def get_next_page( return self.all(**params) - def add_credit_card( - self, - referral_api_key: str, - number: str, - expiration_month: int, - expiration_year: int, - cvc: str, - priority: str = "primary", - ) -> dict[str, Any]: - """Add a credit card to EasyPost for a ReferralCustomer without needing a Stripe account. - - This function requires the ReferralCustomer User's API key. - """ - easypost_stripe_api_key = self._retrieve_easypost_stripe_api_key() - - try: - stripe_token = self._create_stripe_token( - number, - expiration_month, - expiration_year, - cvc, - easypost_stripe_api_key, - ) - except Exception: - raise ExternalApiError(message=SEND_STRIPE_DETAILS_ERROR) - - response = self._create_easypost_credit_card( - referral_api_key, - stripe_token.get("id", ""), - priority=priority, - ) - - return convert_to_easypost_object(response) - def add_credit_card_from_stripe( self, referral_api_key: str, @@ -187,7 +148,7 @@ def add_bank_account_from_stripe( return convert_to_easypost_object(response) - def _retrieve_easypost_stripe_api_key(self) -> str: + def retrieve_easypost_stripe_api_key(self) -> str: """Retrieve EasyPost's Stripe public API key.""" public_key = Requestor(self._client).request( method=RequestMethod.GET, @@ -195,65 +156,3 @@ def _retrieve_easypost_stripe_api_key(self) -> str: ) return public_key.get("public_key", "") - - def _create_stripe_token( - self, - number: str, - expiration_month: int, - expiration_year: int, - cvc: str, - easypost_stripe_key: str, - ) -> dict[str, Any]: - """Get credit card token from Stripe.""" - headers = { - # This Stripe endpoint only accepts URL form encoded bodies - "Content-type": "application/x-www-form-urlencoded", - } - - credit_card_dict = { - "card": { - "number": number, - "exp_month": expiration_month, - "exp_year": expiration_year, - "cvc": cvc, - } - } - - form_encoded_params = Requestor.form_encode_params(credit_card_dict) - url = "https://api.stripe.com/v1/tokens" - - stripe_response = requests.post( - url, - params=form_encoded_params, - headers=headers, - auth=requests.auth.HTTPBasicAuth(easypost_stripe_key, ""), - timeout=TIMEOUT, - ) - - return stripe_response.json() - - def _create_easypost_credit_card( - self, - referral_api_key: str, - stripe_object_id: str, - priority: str = "primary", - ) -> dict[str, Any]: - """Submit Stripe credit card token to EasyPost.""" - params = { - "credit_card": { - "stripe_object_id": stripe_object_id, - "priority": priority, - } - } - - # Override the API key to use the referral's for this single request - referral_client = deepcopy(self._client) - referral_client.api_key = referral_api_key - - response = Requestor(referral_client).request( - method=RequestMethod.POST, - params=params, - url="/credit_cards", - ) - - return response diff --git a/justfile b/justfile index 3f9a19da..69ed6a83 100644 --- a/justfile +++ b/justfile @@ -5,7 +5,7 @@ TEST_DIR := "tests" # Build the project for release build: - {{VIRTUAL_BIN}}/python -m build + uv build # Clean the project clean: diff --git a/pyproject.toml b/pyproject.toml index fbda4c02..c6dac99a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,6 @@ classifiers = [ dependencies = ["requests >= 2.4.3"] optional-dependencies = { dev = [ "bandit == 1.8.*", - "build == 1.2.*", "mypy == 1.15.*", "pdoc == 15.*", "pytest == 8.*", diff --git a/tests/cassettes/test_referral_customer_add_credit_card.yaml b/tests/cassettes/test_referral_customer_add_credit_card.yaml deleted file mode 100644 index 117e8dc6..00000000 --- a/tests/cassettes/test_referral_customer_add_credit_card.yaml +++ /dev/null @@ -1,212 +0,0 @@ -interactions: -- request: - body: null - headers: - Accept: - - '*/*' - Accept-Encoding: - - gzip, deflate - Connection: - - keep-alive - authorization: - - - user-agent: - - - method: GET - uri: https://api.easypost.com/v2/partners/stripe_public_key - response: - body: - string: '{"public_key": "pk_x3JSr5eOVWNTLRej8cZDde9VQ0AT5"}' - headers: - cache-control: - - private, no-cache, no-store - content-length: - - '49' - content-type: - - application/json; charset=utf-8 - expires: - - '0' - pragma: - - no-cache - referrer-policy: - - strict-origin-when-cross-origin - strict-transport-security: - - max-age=31536000; includeSubDomains; preload - transfer-encoding: - - chunked - x-backend: - - easypost - x-content-type-options: - - nosniff - x-download-options: - - noopen - x-ep-request-uuid: - - 8e8eb62567c9fc1ae2b97b760018a860 - x-frame-options: - - SAMEORIGIN - x-node: - - bigweb58nuq - x-permitted-cross-domain-policies: - - none - x-proxied: - - intlb4nuq 51d74985a2 - - extlb1nuq 99aac35317 - x-runtime: - - '0.022642' - x-version-label: - - easypost-202503061913-8f39069a2d-master - x-xss-protection: - - 1; mode=block - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - '*/*' - Accept-Encoding: - - gzip, deflate - Connection: - - keep-alive - Content-Length: - - '0' - Content-type: - - application/x-www-form-urlencoded - authorization: - - - user-agent: - - - method: POST - uri: https://api.stripe.com/v1/tokens?card%5Bexp_month%5D=05&card%5Bexp_year%5D=2028 - response: - body: - string: '{"error": {"message": "This integration surface is unsupported for - publishable key tokenization. To enable this surface, please go to your dashboard - (https://dashboard.stripe.com/settings/integration). See https://support.stripe.com/questions/card-tokenization-restrictions-using-publishable-keys - for more details", "request_log_url": "https://dashboard.stripe.com/logs/req_h4qLymbw74w7cU?t=1741290522", - "type": "invalid_request_error"}}' - headers: - Access-Control-Allow-Credentials: - - 'true' - Access-Control-Allow-Methods: - - GET, HEAD, PUT, PATCH, POST, DELETE - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Request-Id, Stripe-Manage-Version, Stripe-Should-Retry, X-Stripe-External-Auth-Required, - X-Stripe-Privileged-Session-Required - Access-Control-Max-Age: - - '300' - Cache-Control: - - no-cache, no-store - Connection: - - keep-alive - Content-Length: - - '458' - Content-Security-Policy: - - base-uri 'none'; default-src 'none'; form-action 'none'; frame-ancestors 'none'; - img-src 'self'; script-src 'self' 'report-sample'; style-src 'self'; worker-src - 'none'; upgrade-insecure-requests; report-uri https://q.stripe.com/csp-violation?q=0Vy5Ta2pFgzW2Ya4hl8lPGzkEN1C3IyDpB204-q3EAArRvHI8ALkzEjhMEmUL0t__fht0crtWzoPxzs_ - Content-Type: - - application/json - Cross-Origin-Opener-Policy-Report-Only: - - same-origin; report-to="coop" - Date: - - Thu, 06 Mar 2025 19:48:42 GMT - Idempotency-Key: - - 4f8c8607-a9d0-4c8e-a5be-cd519b399771 - Original-Request: - - req_h4qLymbw74w7cU - Report-To: - - '{"group":"coop","max_age":8640,"endpoints":[{"url":"https://q.stripe.com/coop-report"}],"include_subdomains":true}' - Reporting-Endpoints: - - coop="https://q.stripe.com/coop-report" - Request-Id: - - req_h4qLymbw74w7cU - Server: - - nginx - Strict-Transport-Security: - - max-age=63072000; includeSubDomains; preload - Stripe-Should-Retry: - - 'false' - Stripe-Version: - - '2020-08-27' - Vary: - - Origin - X-Stripe-Priority-Routing-Enabled: - - 'true' - X-Stripe-Routing-Context-Priority-Tier: - - livemode-critical - X-Wc: - - AB - status: - code: 400 - message: Bad Request -- request: - body: '{"credit_card": {"stripe_object_id": "", "priority": "primary"}}' - headers: - Accept: - - '*/*' - Accept-Encoding: - - gzip, deflate - Connection: - - keep-alive - Content-Length: - - '64' - Content-Type: - - application/json - authorization: - - - user-agent: - - - method: POST - uri: https://api.easypost.com/v2/credit_cards - response: - body: - string: '{"error": {"code": "BAD_REQUEST", "message": "One of payment_method_reference - or payment_method_id must be provided", "errors": []}}' - headers: - cache-control: - - private, no-cache, no-store - content-length: - - '126' - content-type: - - application/json; charset=utf-8 - expires: - - '0' - pragma: - - no-cache - referrer-policy: - - strict-origin-when-cross-origin - strict-transport-security: - - max-age=31536000; includeSubDomains; preload - transfer-encoding: - - chunked - x-backend: - - easypost - x-content-type-options: - - nosniff - x-download-options: - - noopen - x-ep-request-uuid: - - 8e8eb62867c9fc1ae2b97b780018a8da - x-frame-options: - - SAMEORIGIN - x-node: - - bigweb57nuq - x-permitted-cross-domain-policies: - - none - x-proxied: - - intlb3nuq 51d74985a2 - - extlb1nuq 99aac35317 - x-runtime: - - '0.078448' - x-version-label: - - easypost-202503061913-8f39069a2d-master - x-xss-protection: - - 1; mode=block - status: - code: 400 - message: Bad Request -version: 1 diff --git a/tests/cassettes/test_referral_customer_retrieve_easypost_stripe_api_key.yaml b/tests/cassettes/test_referral_customer_retrieve_easypost_stripe_api_key.yaml new file mode 100644 index 00000000..b1b060fa --- /dev/null +++ b/tests/cassettes/test_referral_customer_retrieve_easypost_stripe_api_key.yaml @@ -0,0 +1,67 @@ +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + authorization: + - + user-agent: + - + method: GET + uri: https://api.easypost.com/v2/partners/stripe_public_key + response: + body: + string: '{"public_key": "pk_x3JSr5eOVWNTLRej8cZDde9VQ0AT5"}' + headers: + cache-control: + - private, no-cache, no-store + content-length: + - '49' + content-type: + - application/json; charset=utf-8 + easypost-api-version: + - '2015-01-01' + expires: + - '0' + pragma: + - no-cache + referrer-policy: + - strict-origin-when-cross-origin + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + transfer-encoding: + - chunked + vary: + - EasyPost-Api-Version + x-backend: + - easypost + x-content-type-options: + - nosniff + x-download-options: + - noopen + x-ep-request-uuid: + - 91d0a0fa6ab4301ce2b97bb400af2fd1 + x-frame-options: + - SAMEORIGIN + x-node: + - web103azw + x-permitted-cross-domain-policies: + - none + x-proxied: + - intlb3azw f29267e751 + - extlb2azw 07fb4d8f06 + x-runtime: + - '0.029468' + x-version-label: + - easypost-202609231716-41b3efb4ec-main + x-xss-protection: + - 1; mode=block + status: + code: 200 + message: OK +version: 1 diff --git a/tests/test_referral_customer.py b/tests/test_referral_customer.py index d39b15f9..d7990b87 100644 --- a/tests/test_referral_customer.py +++ b/tests/test_referral_customer.py @@ -1,5 +1,4 @@ import os -from unittest.mock import patch import pytest @@ -74,58 +73,6 @@ def test_referral_get_next_page(partner_user_prod_client, page_size): raise Exception(_TEST_FAILED_INTENTIONALLY_ERROR) -@pytest.mark.skip("flow is deprecated, cannot easily be tested due to Stripe changes") -# PyVCR is having troubles matching the body of the form-encoded data here, override the default -@pytest.mark.vcr( - match_on=[ - "headers", - "method", - "query", - "uri", - ] -) -def test_referral_customer_add_credit_card(partner_user_prod_client, credit_card_details): - """This test requires a partner customer's production API key via PARTNER_USER_PROD_API_KEY - as well as one of that customer's referral's production API keys via REFERRAL_CUSTOMER_PROD_API_KEY. - """ - added_credit_card = partner_user_prod_client.referral_customer.add_credit_card( - referral_api_key=REFERRAL_CUSTOMER_PROD_API_KEY, - number=credit_card_details["number"], - expiration_month=credit_card_details["expiration_month"], - expiration_year=credit_card_details["expiration_year"], - cvc=credit_card_details["cvc"], - ) - - assert str.startswith(added_credit_card.id, "pm_") - assert added_credit_card.last4 == "6170" - - -@patch("easypost.services.referral_customer_service.ReferralCustomerService._retrieve_easypost_stripe_api_key") -@patch( - "easypost.services.referral_customer_service.ReferralCustomerService._create_stripe_token", - side_effect=Exception(), -) -def test_referral_add_credit_card_error( - mock_stripe_token, - mock_easypost_key, - credit_card_details, - partner_user_prod_client, -): - """This test requires a partner customer's production API key via PARTNER_USER_PROD_API_KEY - as well as one of that customer's referral's production API keys via REFERRAL_CUSTOMER_PROD_API_KEY. - """ - with pytest.raises(Exception) as error: - _ = partner_user_prod_client.referral_customer.add_credit_card( - referral_api_key=REFERRAL_CUSTOMER_PROD_API_KEY, - number=credit_card_details["number"], - expiration_month=credit_card_details["expiration_month"], - expiration_year=credit_card_details["expiration_year"], - cvc=credit_card_details["cvc"], - ) - - assert str(error.value) == "Could not send card details to Stripe, please try again later." - - @pytest.mark.vcr() def test_referral_customer_add_credit_card_from_stripe(partner_user_prod_client, credit_card_details, billing): """This test requires a referral customer's production API key via REFERRAL_CUSTOMER_PROD_API_KEY. @@ -159,3 +106,12 @@ def test_referral_customer_add_bank_account_from_stripe(partner_user_prod_client assert ( str(error.value) == "account_holder_name must be present when creating a Financial Connections payment method" ) + + +@pytest.mark.vcr() +def test_referral_customer_retrieve_easypost_stripe_api_key(partner_user_prod_client): + """This test requires a partner customer's production API key via PARTNER_USER_PROD_API_KEY.""" + public_key = partner_user_prod_client.referral_customer.retrieve_easypost_stripe_api_key() + + assert isinstance(public_key, str) + assert public_key.startswith("pk_")