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
6 changes: 4 additions & 2 deletions apps/api/src/epoint_sandbox/api/cards.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)

Expand Down
10 changes: 10 additions & 0 deletions apps/api/src/epoint_sandbox/api/contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
),
}
2 changes: 2 additions & 0 deletions apps/api/src/epoint_sandbox/api/epoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/epoint_sandbox/api/extras.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand Down Expand Up @@ -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,
}

Expand All @@ -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 {
Expand Down
20 changes: 13 additions & 7 deletions apps/api/src/epoint_sandbox/api/invoices.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,33 +76,38 @@ 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:
raise EpointError(f"Invoice {invoice.id} is {invoice.status.value} and cannot be changed")

_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")
Expand All @@ -124,6 +129,7 @@ async def list_invoices(
return {
"status": "success",
"invoices": [_serialise(i) for i in rows],
"message": "",
"trace_id": _trace(request),
}

Expand All @@ -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")
Expand All @@ -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)}
6 changes: 3 additions & 3 deletions apps/api/src/epoint_sandbox/api/money.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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")
Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/epoint_sandbox/services/payments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
123 changes: 123 additions & 0 deletions apps/api/tests/test_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading
Loading