diff --git a/apps/api/src/epoint_sandbox/api/cards.py b/apps/api/src/epoint_sandbox/api/cards.py index e8638e4..e8335fe 100644 --- a/apps/api/src/epoint_sandbox/api/cards.py +++ b/apps/api/src/epoint_sandbox/api/cards.py @@ -176,7 +176,7 @@ async def execute_pay( signed: SignedRequestDep, session: SessionDep, ) -> dict[str, Any]: - signed.require("card_id", "order_id", "amount", "currency") + signed.require("language", "card_id", "order_id", "amount", "currency") card = _load_card(session, signed) trace_id = _trace(request) transaction = _charge_saved_card( @@ -191,7 +191,9 @@ async def split_execute_pay( signed: SignedRequestDep, session: SessionDep, ) -> dict[str, Any]: - signed.require("card_id", "order_id", "amount", "currency", "split_user", "split_amount") + signed.require( + "language", "card_id", "order_id", "amount", "currency", "split_user", "split_amount" + ) card = _load_card(session, signed) trace_id = _trace(request) diff --git a/apps/api/src/epoint_sandbox/api/contract.py b/apps/api/src/epoint_sandbox/api/contract.py index 5ada779..656bf05 100644 --- a/apps/api/src/epoint_sandbox/api/contract.py +++ b/apps/api/src/epoint_sandbox/api/contract.py @@ -154,6 +154,12 @@ "/api/1/b2b/payment": "not exercised against production", "/api/1/b2b/payment/{order_id}": "not exercised against production", "/api/1/card-registration": "redirect_url is undocumented; confirm against production", + "/api/1/card-registration-with-pay": ( + "the documented response carries rrn, bank_response and operation_code 200, " + "which only exist once the customer has paid, so it reads as the callback " + "payload rather than the response; those fields are withheld until a " + "production capture settles it" + ), "/api/1/payment-change-sum": ( "docs omit transaction where siblings include it; the sandbox withholds it as " "the safe direction, but production may send it" @@ -163,4 +169,8 @@ "the sandbox accepts any language value; whether production validates it " "against az, en and ru is unknown" ), + "/api/1/reverse": ( + "message is documented but the production wording is unknown, so an empty " + "string is returned to match the shape" + ), } diff --git a/apps/api/src/epoint_sandbox/api/epoint.py b/apps/api/src/epoint_sandbox/api/epoint.py index ff1f3b8..a739307 100644 --- a/apps/api/src/epoint_sandbox/api/epoint.py +++ b/apps/api/src/epoint_sandbox/api/epoint.py @@ -23,6 +23,8 @@ def _redirect_response( body: dict[str, Any] = { "status": "success", "redirect_url": payments.checkout_url(transaction), + # Documented on every one of these. The production wording is unknown. + "message": "", "trace_id": trace_id, } if include_transaction: diff --git a/apps/api/src/epoint_sandbox/api/extras.py b/apps/api/src/epoint_sandbox/api/extras.py index c401f81..a98be19 100644 --- a/apps/api/src/epoint_sandbox/api/extras.py +++ b/apps/api/src/epoint_sandbox/api/extras.py @@ -68,6 +68,7 @@ async def installment_request( "status": "success", "redirect_url": payments.checkout_url(transaction), "transaction": transaction.transaction_id, + "message": "", "trace_id": trace_id, } @@ -99,6 +100,7 @@ async def wallet_payment( "status": "success", "redirect_url": payments.checkout_url(transaction), "transaction": transaction.transaction_id, + "message": "", "trace_id": trace_id, } @@ -118,6 +120,7 @@ async def token_widget( trace_id=trace_id, allowed_currencies=AZN_ONLY, default_currency="AZN", + require_language=False, ) base = get_settings().public_base_url return { diff --git a/apps/api/src/epoint_sandbox/api/invoices.py b/apps/api/src/epoint_sandbox/api/invoices.py index 19f5aa4..b6667a4 100644 --- a/apps/api/src/epoint_sandbox/api/invoices.py +++ b/apps/api/src/epoint_sandbox/api/invoices.py @@ -76,19 +76,19 @@ def _serialise(invoice: Invoice) -> dict[str, Any]: @router.post("/create") async def create(request: Request, signed: SignedRequestDep, session: SessionDep) -> dict[str, Any]: - signed.require("sum", "period_from", "period_to") + signed.require("sum", "display", "save_as_template", "period_from", "period_to") invoice = Invoice(merchant_id=signed.merchant.id, total=0) _apply(invoice, signed) session.add(invoice) session.flush() - return {"status": "success", "id": invoice.id, "trace_id": _trace(request)} + return {"status": "success", "id": invoice.id, "message": "", "trace_id": _trace(request)} @router.post("/update") async def update(request: Request, signed: SignedRequestDep, session: SessionDep) -> dict[str, Any]: - signed.require("sum", "period_from", "period_to") + signed.require("sum", "display", "save_as_template", "period_from", "period_to") invoice = _load(session, signed) if invoice.status is not InvoiceStatus.WAITING: @@ -96,13 +96,18 @@ async def update(request: Request, signed: SignedRequestDep, session: SessionDep _apply(invoice, signed) session.flush() - return {"status": "success", "trace_id": _trace(request)} + return {"status": "success", "message": "", "trace_id": _trace(request)} @router.post("/view") async def view(request: Request, signed: SignedRequestDep, session: SessionDep) -> dict[str, Any]: invoice = _load(session, signed) - return {"status": "success", "invoice": _serialise(invoice), "trace_id": _trace(request)} + return { + "status": "success", + "invoice": _serialise(invoice), + "message": "", + "trace_id": _trace(request), + } @router.post("/list") @@ -124,6 +129,7 @@ async def list_invoices( return { "status": "success", "invoices": [_serialise(i) for i in rows], + "message": "", "trace_id": _trace(request), } @@ -141,7 +147,7 @@ async def send_sms( recipient=str(signed.get("phone")), trace_id=_trace(request), ) - return {"status": "success", "trace_id": _trace(request)} + return {"status": "success", "message": "", "trace_id": _trace(request)} @router.post("/send-email") @@ -157,4 +163,4 @@ async def send_email( recipient=str(signed.get("email")), trace_id=_trace(request), ) - return {"status": "success", "trace_id": _trace(request)} + return {"status": "success", "message": "", "trace_id": _trace(request)} diff --git a/apps/api/src/epoint_sandbox/api/money.py b/apps/api/src/epoint_sandbox/api/money.py index 0cb316b..5a98976 100644 --- a/apps/api/src/epoint_sandbox/api/money.py +++ b/apps/api/src/epoint_sandbox/api/money.py @@ -30,7 +30,7 @@ async def refund_request( signed: SignedRequestDep, session: SessionDep, ) -> dict[str, Any]: - signed.require("card_id", "order_id", "amount", "currency") + signed.require("language", "card_id", "order_id", "amount", "currency") trace_id = _trace(request) card = session.scalar( @@ -108,7 +108,7 @@ async def reverse( signed: SignedRequestDep, session: SessionDep, ) -> dict[str, Any]: - signed.require("transaction", "currency") + signed.require("language", "transaction", "currency") trace_id = _trace(request) payments.validate_currency(str(signed.get("currency")), AZN_ONLY) @@ -144,7 +144,7 @@ async def reverse( ) callbacks.deliver(session, original) - return {"status": "success", "trace_id": trace_id} + return {"status": "success", "message": "", "trace_id": trace_id} @router.post("/pre-auth-complete") diff --git a/apps/api/src/epoint_sandbox/services/payments.py b/apps/api/src/epoint_sandbox/services/payments.py index ed89b1d..2a1b958 100644 --- a/apps/api/src/epoint_sandbox/services/payments.py +++ b/apps/api/src/epoint_sandbox/services/payments.py @@ -40,8 +40,12 @@ def create_transaction( trace_id: str, allowed_currencies: set[str] = SUPPORTED_CURRENCIES, default_currency: str | None = None, + require_language: bool = True, ) -> Transaction: required = ["amount", "order_id"] if default_currency else ["amount", "currency", "order_id"] + # Documented required everywhere except token/widget, so refuse rather than default it. + if require_language: + required.append("language") request.require(*required) amount = parse_amount(request.get("amount")) diff --git a/apps/api/tests/test_contract.py b/apps/api/tests/test_contract.py index 46d6c2b..ae56d24 100644 --- a/apps/api/tests/test_contract.py +++ b/apps/api/tests/test_contract.py @@ -249,3 +249,126 @@ def test_error_bodies_carry_only_epoint_fields(client, merchant): body = client.post("/api/1/request", data={"data": data, "signature": signature}).json() assert set(body) <= {"status", "message", "code", "trace_id"} + + +# Fields the docs list that the sandbox cannot produce at this point in the flow. +WITHHELD: dict[str, set[str]] = { + # Docs omit `transaction` here where every sibling includes it. Withheld until confirmed. + "/api/1/payment-change-sum": {"transaction"}, + # The documented "response" carries rrn and operation_code 200, which only exist once the + # customer has paid. It reads as the callback payload, mislabelled. Needs a production capture. + "/api/1/card-registration-with-pay": { + "amount", + "bank_response", + "bank_transaction", + "card_mask", + "card_name", + "code", + "operation_code", + "order_id", + "other_attr", + "rrn", + }, +} + + +def assert_documented_fields_present(path: str, body: dict) -> None: + """The other direction: a documented field the sandbox omits reads as undefined.""" + missing = ( + set(DOCUMENTED_RESPONSE_FIELDS.get(path, set())) - set(body) - WITHHELD.get(path, set()) + ) + assert not missing, ( + f"{path} omits documented field(s) {sorted(missing)}. " + "An integration reading them gets undefined here and a value in production." + ) + + +@pytest.mark.parametrize( + "path", + [ + "/api/1/request", + "/api/1/payment-request", + "/api/1/amex-request", + "/api/1/payment-change-sum", + "/api/1/pre-auth-request", + ], +) +def test_payment_family_returns_every_documented_field(client, merchant, path): + body = call(client, merchant, path, {**PAYMENT, "order_id": f"p{path[-7:]}"}) + assert_documented_fields_present(path, body) + + +def test_split_request_returns_every_documented_field(client, merchants): + body = call( + client, + merchants[0], + "/api/1/split-request", + { + **PAYMENT, + "order_id": "p-split", + "split_user": merchants[1].public_key, + "split_amount": "10.00", + }, + ) + assert_documented_fields_present("/api/1/split-request", body) + + +def test_reverse_returns_every_documented_field(client, merchant): + body = call(client, merchant, "/api/1/request", {**PAYMENT, "order_id": "p-rev"}) + pay_checkout(client, token_of(body)) + reversed_body = call( + client, + merchant, + "/api/1/reverse", + {"language": "en", "transaction": body["transaction"], "currency": "AZN"}, + ) + assert_documented_fields_present("/api/1/reverse", reversed_body) + + +def test_wallet_and_installment_return_every_documented_field(client, merchant): + wallet = call( + client, + merchant, + "/api/1/wallet/payment", + {**PAYMENT, "order_id": "p-wallet", "wallet_id": "wallet_epul"}, + ) + assert_documented_fields_present("/api/1/wallet/payment", wallet) + + installment = call( + client, + merchant, + "/api/1/installment-request", + {**PAYMENT, "order_id": "p-inst", "installment_card_id": "1", "installment_month": 3}, + ) + assert_documented_fields_present("/api/1/installment-request", installment) + + +INVOICE = { + "sum": "25.00", + "display": 1, + "save_as_template": 0, + "period_from": "2026-01-01", + "period_to": "2026-12-31", + "phone": "+994501234567", + "email": "buyer@example.com", +} + + +def test_invoice_endpoints_return_every_documented_field(client, merchant): + created = call(client, merchant, "/api/1/invoices/create", INVOICE) + assert_documented_fields_present("/api/1/invoices/create", created) + + invoice_id = created["id"] + for path, extra in [ + ("/api/1/invoices/view", {"id": invoice_id}), + ("/api/1/invoices/list", {}), + ("/api/1/invoices/send-sms", {"id": invoice_id, "phone": "+994501234567"}), + ("/api/1/invoices/send-email", {"id": invoice_id, "email": "buyer@example.com"}), + ]: + assert_documented_fields_present(path, call(client, merchant, path, extra)) + + +def test_withheld_fields_are_recorded_as_unverified(): + """Withholding is only defensible if the gap is declared.""" + for path in WITHHELD: + assert path in UNVERIFIED, f"{path} withholds documented fields without saying why" diff --git a/apps/api/tests/test_required_params.py b/apps/api/tests/test_required_params.py new file mode 100644 index 0000000..0838941 --- /dev/null +++ b/apps/api/tests/test_required_params.py @@ -0,0 +1,215 @@ +"""Refuse what production refuses. + +A sandbox that accepts a payload production rejects is the dangerous direction: the +developer never learns their request is wrong until they switch over. Every field here +is marked Required on developer.epoint.az. +""" + +import pytest + +from epoint_sandbox.services import magic_cards +from tests.helpers import call, enable_features, pay_checkout, token_of + + +@pytest.fixture(autouse=True) +def _granted(client, merchant): + enable_features(client, merchant) + + +@pytest.fixture +def active_card(client, merchant): + body = call(client, merchant, "/api/1/card-registration", {"language": "en"}) + pay_checkout(client, token_of(body), magic_cards.SUCCESS_CARD) + return body["card_id"] + + +@pytest.fixture +def settled(client, merchant): + body = call( + client, + merchant, + "/api/1/request", + {"amount": "80.00", "currency": "AZN", "language": "en", "order_id": "req-seed"}, + ) + pay_checkout(client, token_of(body)) + return body["transaction"] + + +@pytest.fixture +def invoice(client, merchant): + return call( + client, + merchant, + "/api/1/invoices/create", + { + "sum": "10.00", + "display": 1, + "save_as_template": 0, + "period_from": "2026-01-01", + "period_to": "2026-12-31", + }, + )["id"] + + +PAYMENT = {"amount": "10.00", "currency": "AZN", "language": "en"} + +PAYMENT_FAMILY = [ + "/api/1/request", + "/api/1/payment-request", + "/api/1/amex-request", + "/api/1/payment-change-sum", + "/api/1/pre-auth-request", +] + + +def refused(body: dict, field: str) -> None: + assert body.get("status") in ("error", "failed"), ( + f"accepted a payload with no {field!r}, which production documents as required. " + "A developer would never learn their request is incomplete." + ) + assert field in body.get("message", ""), f"the refusal does not name {field!r}" + + +@pytest.mark.parametrize("path", PAYMENT_FAMILY) +@pytest.mark.parametrize("field", ["amount", "currency", "language", "order_id"]) +def test_payment_family_requires_its_documented_fields(client, merchant, path, field): + payload = {**PAYMENT, "order_id": f"r{path[-6:]}{field}"} + del payload[field] + refused(call(client, merchant, path, payload), field) + + +@pytest.mark.parametrize( + "field", ["amount", "currency", "language", "order_id", "split_user", "split_amount"] +) +def test_split_request_requires_its_documented_fields(client, merchants, field): + payload = { + **PAYMENT, + "order_id": f"rsplit{field}", + "split_user": merchants[1].public_key, + "split_amount": "3.00", + } + del payload[field] + refused(call(client, merchants[0], "/api/1/split-request", payload), field) + + +@pytest.mark.parametrize("field", ["language", "card_id", "order_id", "amount", "currency"]) +def test_execute_pay_requires_its_documented_fields(client, merchant, active_card, field): + payload = { + "language": "en", + "card_id": active_card, + "order_id": f"rexec{field}", + "amount": "5.00", + "currency": "AZN", + } + del payload[field] + refused(call(client, merchant, "/api/1/execute-pay", payload), field) + + +@pytest.mark.parametrize("field", ["language", "card_id", "order_id", "amount", "currency"]) +def test_refund_request_requires_its_documented_fields(client, merchant, active_card, field): + payload = { + "language": "en", + "card_id": active_card, + "order_id": f"rrefund{field}", + "amount": "1.00", + "currency": "AZN", + } + del payload[field] + refused(call(client, merchant, "/api/1/refund-request", payload), field) + + +@pytest.mark.parametrize("field", ["language", "transaction", "currency"]) +def test_reverse_requires_its_documented_fields(client, merchant, settled, field): + payload = {"language": "en", "transaction": settled, "currency": "AZN"} + del payload[field] + refused(call(client, merchant, "/api/1/reverse", payload), field) + + +@pytest.mark.parametrize("field", ["wallet_id", "amount", "currency", "order_id", "language"]) +def test_wallet_payment_requires_its_documented_fields(client, merchant, field): + payload = {**PAYMENT, "order_id": f"rwallet{field}", "wallet_id": "wallet_epul"} + del payload[field] + refused(call(client, merchant, "/api/1/wallet/payment", payload), field) + + +@pytest.mark.parametrize( + "field", ["amount", "currency", "language", "order_id", "installment_card_id"] +) +def test_installment_request_requires_its_documented_fields(client, merchant, field): + payload = { + **PAYMENT, + "order_id": f"rinst{field}", + "installment_card_id": "1", + "installment_month": 3, + } + del payload[field] + refused(call(client, merchant, "/api/1/installment-request", payload), field) + + +@pytest.mark.parametrize("field", ["language", "order_id", "amount", "currency"]) +def test_register_and_pay_requires_its_documented_fields(client, merchant, field): + payload = { + "language": "en", + "order_id": f"rregpay{field}", + "amount": "10.00", + "currency": "AZN", + } + del payload[field] + refused(call(client, merchant, "/api/1/card-registration-with-pay", payload), field) + + +@pytest.mark.parametrize( + "field", ["sum", "display", "save_as_template", "period_from", "period_to"] +) +def test_invoice_create_requires_its_documented_fields(client, merchant, field): + payload = { + "sum": "10.00", + "display": 1, + "save_as_template": 0, + "period_from": "2026-01-01", + "period_to": "2026-12-31", + } + del payload[field] + refused(call(client, merchant, "/api/1/invoices/create", payload), field) + + +@pytest.mark.parametrize("field", ["sum", "display", "save_as_template"]) +def test_invoice_update_requires_its_documented_fields(client, merchant, invoice, field): + payload = { + "id": invoice, + "sum": "12.00", + "display": 1, + "save_as_template": 0, + "period_from": "2026-01-01", + "period_to": "2026-12-31", + } + del payload[field] + refused(call(client, merchant, "/api/1/invoices/update", payload), field) + + +def test_zero_is_a_value_not_an_omission(client, merchant): + """display=0 and save_as_template=0 are meaningful, not missing.""" + body = call( + client, + merchant, + "/api/1/invoices/create", + { + "sum": "10.00", + "display": 0, + "save_as_template": 0, + "period_from": "2026-01-01", + "period_to": "2026-12-31", + }, + ) + assert body["status"] == "success" + + +def test_the_widget_does_not_require_language(client, merchant): + """Its doc page lists only public_key, amount, order_id and description.""" + body = call( + client, + merchant, + "/api/1/token/widget", + {"amount": "10.00", "order_id": "rwidget", "description": "no language"}, + ) + assert body["status"] == "success" diff --git a/scripts/extract_contracts.py b/scripts/extract_contracts.py index 678ca2d..ac283f7 100644 --- a/scripts/extract_contracts.py +++ b/scripts/extract_contracts.py @@ -62,6 +62,15 @@ UNVERIFIED = { "/api/1/card-registration": "redirect_url is undocumented; confirm against production", + "/api/1/card-registration-with-pay": ( + "the documented response carries rrn, bank_response and operation_code 200, which " + "only exist once the customer has paid, so it reads as the callback payload rather " + "than the response; those fields are withheld until a production capture settles it" + ), + "/api/1/reverse": ( + "message is documented but the production wording is unknown, so an empty string " + "is returned to match the shape" + ), "/api/1/payment-change-sum": ( "docs omit transaction where siblings include it; the sandbox withholds it " "as the safe direction, but production may send it"