Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
9 changes: 8 additions & 1 deletion src/epoint/_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,14 +277,21 @@ 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",
{
"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,
},
)

Expand Down
11 changes: 10 additions & 1 deletion src/epoint/aio.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
13 changes: 12 additions & 1 deletion src/epoint/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
20 changes: 20 additions & 0 deletions tests/test_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
Expand Down
47 changes: 47 additions & 0 deletions tests/test_client_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
30 changes: 30 additions & 0 deletions tests/test_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading