Inkress ADRsarchitecture decisions
Overview / Payments / Checkout
ADR 0012

Payment links and hosted checkout are server-owned intents with a server-authoritative amount

AcceptedPayments / Checkout

Context

A buyer pays either through a payment link a merchant shares, or through the hosted checkout on a storefront. In both, the client (a browser, possibly a hostile one) is the thing that submits the pay request — so the central risk is the same as discounts (ADR 0002): if the client can name the amount, the price is forgeable. The amount a card is charged has to be decided by the server and be tamper-proof by the time it reaches the gateway.

There is also a lifecycle question for hosted checkout: does an order exist before payment (so every abandoned cart is a junk order), or only after?

Decision

Represent "an intent to pay X to merchant M" as a server-owned object that fixes the amount server-side; the client only ever references it by an opaque id and can never set the amount.

  • Payment link. Api.Purchase.PaymentLink is a merchant-owned, publicly payable handle keyed by an opaque uid, carrying a fixed total, currency, expiry, and optional usage_limit. total, uid, and currency are schema-immutable after creation. It is redeemed at public routes under /api/v1/payments/link/:uid.
  • Hosted checkout is session-first; the order is created after payment. SessionBasedCheckout creates a payment_sessions row holding the server-computed, fee'd total first (page /checkouts/:id); the durable order is materialised only once the provider confirms payment, so abandoned checkouts never become orders. (The older order-first path still exists, where an invoice-style link is created from an already-created order.)
  • The amount is server-authoritative, in layers. (1) It is stored server-side and referenced only by opaque id — never carried in a client field or URL parameter. (2) PaymentLink.total and Order.total are schema-immutable. (3) The figure actually charged is the server-recomputed fees.customer_total from the same TransactionCalculator fee-group engine as discounts (ADR 0002). (4) For the CDE card flow, that amount is HMAC-signed into a short-lived checkout-intent (<amount@2dp>.<currency>.<expiry>[.<ref>.<mode>], ~10-minute TTL) that the CDE re-verifies byte-for-byte before charging, so a tampered blob is rejected. (5) Settlement is then corroborated against the gateway server-side (ADR 0011) rather than trusting the client. (Note: there is no post-settlement numeric re-check of the charged amount against the order — see ADR 0011.)

Consequences

  • Positive: across payment links and hosted checkout, the buyer can only pay the amount the server decided; tampering fails at verification, and the signed-intent canonical is the single contract the edge and the API must agree on.
  • Positive: session-first checkout keeps the orders table clean — an order means a real, paid (or genuinely pending) purchase, not an abandoned cart; it also composes with discounts, which re-resolve at order-create (ADR 0002).
  • Neutral / to revisit: the signed checkout-intent couples the CDE and commerce-api to one exact canonical string; it must stay byte-identical on both sides, and the signing secret is a shared deploy-time secret. The verifier is intentionally lenient about optional fields so a one-sided rollout/rollback never breaks the money path.
  • Neutral / to revisit: two order lifecycles coexist (order-first for links, session-first for hosted checkout); which one applies depends on the entry path.

Alternatives considered

  • Client sends the amount (validate only shape): rejected — identical to the discount tampering risk (ADR 0002); the price would be forgeable. The amount is server-decided and, for cards, server-signed.
  • Amount in a URL query parameter or hidden form field: rejected — tamperable and leaky (it would put money in logs/history); the amount lives server-side behind an opaque id.
  • Create the order up front for hosted checkout: rejected — every abandoned cart becomes a junk order to reconcile and sweep; a session holds the priced intent and the order is materialised on confirmed payment instead.
← 0011 Card settlement is server-authoritative, confirmed from the gateway, not the browser 0013 Order status is a flat integer enum with idempotent compare-and-set transitions →