diff --git a/README.md b/README.md index e2d2335..1ea2776 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,24 @@ those two sets. `SETTLED_STATUSES` is what `ok` checks, and `USABLE_CARD_STATUSES` is the set a card has to be in before you can charge it. +## Saving a card without a payment + +`register_card` saves a card with no charge, for example when the first period is free and later +ones are charged with `charge_saved_card`. Keep the `card_id` it returns and send the customer to +its `redirect_url`. + +```python +registration = client.register_card( + description="Card for renewals", + success_redirect_url="https://shop.example/card/saved", + error_redirect_url="https://shop.example/card/failed", +) +store_card(registration["card_id"]) +``` + +The customer comes back to `success_redirect_url` or `error_redirect_url`, or to the client's own +defaults when neither is given. A card can be charged once `get_card_status` reports it `active`. + ## Methods | Group | Methods | diff --git a/src/epoint/_endpoints.py b/src/epoint/_endpoints.py index 1fb00f9..2081177 100644 --- a/src/epoint/_endpoints.py +++ b/src/epoint/_endpoints.py @@ -277,7 +277,12 @@ def split_charge_saved_card( def register_card( - d: Defaults, description: str | None, language: str | None, for_payouts: bool + d: Defaults, + description: str | None, + language: str | None, + for_payouts: bool, + success_redirect_url: str | None = None, + error_redirect_url: str | None = None, ) -> Call: return Call( "/api/1/card-registration", @@ -285,6 +290,8 @@ def register_card( "language": language or d.language, "refund": 1 if for_payouts else 0, "description": description, + "success_redirect_url": success_redirect_url or d.success_redirect_url, + "error_redirect_url": error_redirect_url or d.error_redirect_url, }, ) diff --git a/src/epoint/aio.py b/src/epoint/aio.py index 7dab58f..cd6c587 100644 --- a/src/epoint/aio.py +++ b/src/epoint/aio.py @@ -296,10 +296,19 @@ async def register_card( description: str | None = None, language: str | None = None, for_payouts: bool = False, + success_redirect_url: str | None = None, + error_redirect_url: str | None = None, ) -> Response: """Start card registration. The customer confirms it on the checkout page.""" return await self._post( - *ep.register_card(self._defaults, description, language, for_payouts) + *ep.register_card( + self._defaults, + description, + language, + for_payouts, + success_redirect_url, + error_redirect_url, + ) ) async def register_card_and_pay( diff --git a/src/epoint/client.py b/src/epoint/client.py index cc6246e..d517ebd 100644 --- a/src/epoint/client.py +++ b/src/epoint/client.py @@ -297,9 +297,20 @@ def register_card( description: str | None = None, language: str | None = None, for_payouts: bool = False, + success_redirect_url: str | None = None, + error_redirect_url: str | None = None, ) -> Response: """Start card registration. The customer confirms it on the checkout page.""" - return self._post(*ep.register_card(self._defaults, description, language, for_payouts)) + return self._post( + *ep.register_card( + self._defaults, + description, + language, + for_payouts, + success_redirect_url, + error_redirect_url, + ) + ) def register_card_and_pay( self, diff --git a/tests/test_async.py b/tests/test_async.py index a035baf..734f513 100644 --- a/tests/test_async.py +++ b/tests/test_async.py @@ -39,6 +39,26 @@ def handler(request: httpx.Request) -> httpx.Response: assert seen["payload"]["amount"] == "12.50" +async def test_card_registration_sends_the_redirect_urls(): + seen = {} + + def handler(request: httpx.Request) -> httpx.Response: + form = httpx.QueryParams(request.content.decode()) + seen["payload"] = json.loads(base64.b64decode(form["data"])) + return httpx.Response(200, json={"status": "success", "card_id": "ce1"}) + + async with async_client_with(handler) as client: + result = await client.register_card( + description="async", + success_redirect_url="https://shop.test/ok", + error_redirect_url="https://shop.test/fail", + ) + + assert result["card_id"] == "ce1" + assert seen["payload"]["success_redirect_url"] == "https://shop.test/ok" + assert seen["payload"]["error_redirect_url"] == "https://shop.test/fail" + + async def test_error_status_raises(): def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json={"status": "error", "message": "bad", "code": "100"}) diff --git a/tests/test_client_unit.py b/tests/test_client_unit.py index 478f416..3d813bd 100644 --- a/tests/test_client_unit.py +++ b/tests/test_client_unit.py @@ -165,3 +165,50 @@ def test_a_raw_status_string_still_reads_as_ok(): assert Response({"status": "success"}).ok assert not Response({"status": "failed"}).ok + + +def test_card_registration_sends_the_redirect_urls_it_is_given(): + seen = {} + + def handler(request: httpx.Request) -> httpx.Response: + payload, _ = decode_sent(request) + seen.update(payload) + return httpx.Response(200, json={"status": "success", "card_id": "ce1"}) + + client_with(handler).register_card( + success_redirect_url="https://shop.test/ok", + error_redirect_url="https://shop.test/fail", + ) + assert seen["success_redirect_url"] == "https://shop.test/ok" + assert seen["error_redirect_url"] == "https://shop.test/fail" + assert seen["refund"] == 0 + + +def test_card_registration_falls_back_to_the_client_redirect_urls(): + seen = {} + + def handler(request: httpx.Request) -> httpx.Response: + payload, _ = decode_sent(request) + seen.clear() + seen.update(payload) + return httpx.Response(200, json={"status": "success", "card_id": "ce1"}) + + client = EpointClient( + PUBLIC_KEY, + PRIVATE_KEY, + base_url="https://epoint.test", + success_redirect_url="https://shop.test/default-ok", + error_redirect_url="https://shop.test/default-fail", + transport=httpx.Client(transport=httpx.MockTransport(handler)), + ) + client.register_card() + assert seen["success_redirect_url"] == "https://shop.test/default-ok" + assert seen["error_redirect_url"] == "https://shop.test/default-fail" + + client.register_card(success_redirect_url="https://shop.test/ok") + assert seen["success_redirect_url"] == "https://shop.test/ok" + assert seen["error_redirect_url"] == "https://shop.test/default-fail" + + client_with(handler).register_card() + assert "success_redirect_url" not in seen + assert "error_redirect_url" not in seen diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py index 8d06a30..aecf6d0 100644 --- a/tests/test_sandbox.py +++ b/tests/test_sandbox.py @@ -169,3 +169,33 @@ def test_a_card_status_from_the_sandbox_is_in_the_enum(client, pay): card = client.get_card_status(registered["card_id"]) assert card.get("status") == CardStatus.ACTIVE assert card.get("status") in set(CardStatus) + + +def test_card_registration_returns_the_customer_to_the_given_urls(client): + approved = client.register_card( + description="redirect check", + success_redirect_url="https://shop.test/card-ok", + error_redirect_url="https://shop.test/card-fail", + ) + declined = client.register_card( + description="redirect check", + success_redirect_url="https://shop.test/card-ok", + error_redirect_url="https://shop.test/card-fail", + ) + + def submit(redirect_url: str, card: str) -> httpx.Response: + token = redirect_url.rstrip("/").rsplit("/", 1)[-1].split("?")[0] + return httpx.post( + f"{SANDBOX_URL}/checkout/{token}", + data={"card_number": card, "card_holder": "TESTER", "month": "12", "year": "30"}, + follow_redirects=False, + ) + + ok = submit(approved.redirect_url, "4111111111111111") + failed = submit(declined.redirect_url, "4000000000000116") + + assert ok.status_code == 303 + assert ok.headers["location"] == "https://shop.test/card-ok" + assert failed.status_code == 303 + assert failed.headers["location"] == "https://shop.test/card-fail" + assert client.get_card_status(approved["card_id"]).get("status") == CardStatus.ACTIVE diff --git a/uv.lock b/uv.lock index 97684cb..7226ab2 100644 --- a/uv.lock +++ b/uv.lock @@ -193,7 +193,7 @@ toml = [ [[package]] name = "epoint" -version = "0.2.0" +version = "0.2.1" source = { editable = "." } dependencies = [ { name = "httpx" },