From ee44dc30598aad09eb1cd24e0656e30e7b28a8cb Mon Sep 17 00:00:00 2001 From: martian56 Date: Mon, 19 Oct 2026 19:25:52 +0400 Subject: [PATCH] feat: let register_card send the customer back to redirect URLs Epoint's card-registration takes success_redirect_url and error_redirect_url, and the sandbox honours them, but register_card never sent them, so a card saved without a payment could not bring the customer back to the merchant's page. Both arguments are now accepted by the sync and async clients and fall back to the client defaults, as the pay methods already do. --- README.md | 18 +++++++++++++++ src/epoint/_endpoints.py | 9 +++++++- src/epoint/aio.py | 11 ++++++++- src/epoint/client.py | 13 ++++++++++- tests/test_async.py | 20 +++++++++++++++++ tests/test_client_unit.py | 47 +++++++++++++++++++++++++++++++++++++++ tests/test_sandbox.py | 30 +++++++++++++++++++++++++ uv.lock | 2 +- 8 files changed, 146 insertions(+), 4 deletions(-) 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" },