Payments (the 2026 subsystem)
- Version
- 4.0 preview
- Updated
Scope: how the CLASSIC checkout takes money — the provider contract, the payment ledger, the state machine, settlement, logging, the admin surfaces, and the tracking contract · Status: v1 landed 2026-08; §3 (form factors) and §11 (client contract) are the v1.1 spec · Replaces:
psp_connectors/+helpers/psp.php(deprecated, still routed for existing installs; no new connectors there)
This subsystem was designed from requirements, not from the old connector layer, and not from
Commerce 2's payment stack. It serves the classic order flow (#__cbcheckout_order_records). The
core is deliberately order-system-agnostic — one small binding class (ConfigboxPaymentOrderBinding)
is the only place that knows about cbcheckout; a Commerce 2 binding could be added later without
touching providers.
1. The five ideas
-
A payment is a first-class record.
#__configbox_paymentsholds one row per payment attempt against an order: provider, flow, state, amount in minor units, currency, the provider's object reference, the bank-transfer reference. An order can have several attempts; the ledger shows them all. -
Every fact is an event.
#__configbox_payment_eventsis append-only: every state change, every API call and response, every webhook (verified or refused), every admin action, with redacted payloads. This is the logging story — the admin timeline renders it, the file log (KLogchannelpayments) mirrors errors, and nothing about a payment is ever knowable only from a log file on disk. -
One state-writer. Only
ConfigboxPaymentServicechanges a payment's state. It enforces the legal transitions, verifies amounts against the order, deduplicates webhooks (dedup_keyUNIQUE — replay-safe by schema, not by discipline), fires the observer events, and applies the order side-effect. Providers never touch the DB. -
Providers declare, the framework renders. A provider is one self-contained directory: identity +
getConfigFields()(declarative — the admin form renders itself) + the flow it runs + the settlement channels it supports (+ a client module when the flow needs page JS). No bridge templates, no per-connector admin PHP. -
The customer's money state is never taken from the request. A return URL carries ids, never outcomes; the service asks the provider to verify against the PSP (or the signed webhook says so). A forged
?state=paiddoes nothing.
2. Flows (what the customer experiences)
A flow is the customer-visible shape of a payment. There are exactly three, and every PSP form factor in §3 maps onto one of them — new form factors get a mapping, not a fourth flow, until one genuinely cannot be expressed.
| Flow | Meaning | Certification | v1 providers |
|---|---|---|---|
redirect | we send the browser to the PSP's page and it comes back | none (SAQ A) | stripe, demopay, trustly |
onsite | the payment UI is ON our page, but rendered by the PSP's JS (tokenized; the PAN never touches us) | none (SAQ A) | stripe (Payment Element), demopay |
instructions | no interactive PSP step: we show payment instructions + a structured reference; settlement arrives later | none | swissqrbill, demopay |
3. Form factors — the taxonomy this framework must stay pluggable for
"Stripe-style hosted checkout" is one point in a larger space. The space, as the common US/CA/EU PSPs actually ship it, and how each point maps onto the framework:
| # | Form factor | Real-world examples | Flow | Step returned by begin() | Authoritative settlement | Notes |
|---|---|---|---|---|---|---|
| F1 | Hosted payment page | Stripe Checkout, PayPal Standard, Mollie hosted, Adyen HPP | redirect | redirect(url) | return-verify, webhook confirms | The baseline. Return is a hint, verification is the fact. |
| F2 | On-site fields (PSP-rendered) | Stripe Payment Element, Adyen Drop-in, Braintree Hosted Fields | onsite | onsite(clientConfig) | return-verify (same-page confirm) + webhook | Fields are the PSP's iframes; our page only hosts them. §11 governs the JS side. |
| F3 | Wallet buttons | Apple Pay / Google Pay via PSP JS, PayPal Buttons | onsite | onsite(clientConfig) with wallets listed | same as F2 | A wallet is an onsite presentment, not a new flow: the PSP's JS renders a button instead of fields; confirmation still resolves to a provider reference we verify. |
| F4 | Bank-redirect methods | iDEAL, Bancontact, EPS, Przelewy24 (usually via Mollie/Stripe/Adyen) | redirect | redirect(url) | webhook only | The return can arrive before the money is final, or never. The result page shows pending; the webhook settles. |
| F5 | Delayed vouchers | OXXO, Boleto, Multibanco (via a PSP) | instructions | instructions(data) where the PSP generated the data | webhook (days later) | Instructions + webhook is a legal pairing — "instructions" says what the customer sees, not who settles. |
| F6 | Pure transfer instructions | Swiss QR-bill, plain SEPA reference | instructions | instructions(data) we generated ourselves | manual (admin), statement import later | No PSP exists. The admin ledger is the settlement channel. |
| F7 | Server-to-server rails | Trustly, other open-banking APIs | redirect | redirect(url) from a signed API call | signed notification (webhook) + signed ack | The ack body itself may need signing — WebhookResult.ackBody exists for exactly this. |
| F8 | Pay-later / invoice | Klarna, Affirm | redirect | redirect(url) | webhook | In scope as a payment; post-purchase order management (captures on shipment etc.) is explicitly v2. |
Rules the taxonomy fixes:
- Flow says what the customer sees; channel says who settles. Any flow may pair with any channel set (F5 broke the old assumption that instructions ⇒ manual). The provider declares its channels; the framework never infers them from the flow.
- Wallets are not a flow. They ride the onsite step's
clientConfig; a provider that offers them lists them there and its client module renders them. No wallet-specific server code paths. - A pending return is a first-class outcome. F4/F5 make "customer came back but nothing is
settled" the NORMAL case:
verifyReturnanswerspend, the result page shows the pending state (with instructions when the step carried them), and the webhook finishes the job. The result page must never treat pending as an error. - The fake PSP tracks the taxonomy. DemoPay MUST implement every form factor the framework claims to support, so every mapping has certification-free E2E coverage: F1 (hosted page), F2 (onsite fields), F3 (the demo wallet button — one-touch, same charge path), F4 (the async 0341 card: return proves nothing, the webhook settles), F5 (the voucher: instructions + webhook resolving by transfer reference), F6 (manual settle), and a signed-webhook rail standing in for F7. A form factor DemoPay cannot simulate is a form factor the framework does not yet support — adding the simulation is part of adding the factor.
4. States and transitions
created ──► pending ──► settled ──► refunded
│ │ ▲
│ ├──┘ (poll/webhook may re-report pending)
│ ├─► failed
│ └─► expired
└─► failed / canceled
Enforced in ConfigboxPaymentService::transition(). Anything else is refused and logged as an
error event. settled additionally requires the amount check: provider-reported minor units ==
payment row's minor units, else the payment goes to state failed with reason amount_mismatch
and the ORDER goes to status 9 (Incorrect amount paid) — the old vocabulary already had the right
word for it.
5. Order lifecycle (the breaking change)
Placing an order no longer sets it to “Ordered”. The status vocabulary gains
15 = Awaiting payment (observers/Orders.php). placeOrder() sets 15. From there:
- payment settles → order status 3 (Paid) — automatic, payment-driven
instructionsflow → order stays 15 until the settlement channel fires (webhook for F5, admin for F6)- 2 (Ordered) is no longer written by any automatic path; it is the shop's own acknowledgement step (admin, or a fulfilment integration)
- amount mismatch → 9, cancel/expiry → stays 15 (visible in the ledger as failed attempts)
Existing installs: migration 3.8.6 changes no historical rows; only new checkouts behave the new
way. The old psp_connectors IPN path still writes 3/9 as before until a shop migrates its methods.
6. Settlement channels
- return — browser lands on
paymentgateway.handleReturn&payment_id=…, gated by the per-payment nonce; service asks the provider to verify (e.g. retrieve the Checkout Session / PaymentIntent). Never trusted by itself. - webhook —
paymentgateway.webhook&provider_key=…, unauthenticated route, provider verifies the signature (HMAC/Stripe-Signature/RSA per provider), resolves the payment from the payload, deduplicates by event id, then transitions. The ack terminates the request (ack()cleans buffers, sets the status, dies) — the host platform's render pipeline stamps its own 200 otherwise, and a webhook's status code is contract: 400 = refused, retry; 200 = delivered, stop. - poll — providers that support it can be re-queried from the admin detail page (“Check with
provider”), and by
configbox:payments:poll(CLI) for pending payments older than N minutes — the backstop for lost returns and undelivered webhooks. - manual — admin settles/fails a payment in the ledger with a note; that is the designed path for F6 bank transfers, not a workaround.
7. Ecommerce tracking contract
Server: KenedoObserver events — onCbxPaymentCreated($payment),
onCbxPaymentStateChanged($payment, $oldState, $newState), onCbxOrderPaid($order, $payment).
The GoogleAnalytics observer keys off these instead of raw status writes.
Client: the payment-result page (and only it) emits ONE <script type="application/json" id="cbx-purchase-data"> blob — order id/number, value, currency, per-line items — only when the
order's payment is settled, and only once ever: emitting sets
#__cbcheckout_order_records.tracking_fired_at atomically, so a refresh, a revisit, or two racing
tabs cannot double-fire a GA4 purchase. Integrations read the blob; they never scrape the page.
8. Admin surfaces
- Payments ledger (
adminpayments): filterable list (state, provider, test-mode, date, order number); row → detail with the full event timeline, provider references, and actions: Mark settled, Mark failed, Add note, Check with provider (poll-capable providers). Actions are call-read-decide JS (configbox/adminPayments); a refused transition shows its reason instead of dressing up as success. - Payment methods (existing commercial config: surcharges, country/group assignment — kept):
the form leads with a Provider select, and every provider config field from
getConfigFields()is a REAL Kenedo property (providersettingtype, declared dynamically by the model from the registry) bound to that select byappliesWhen— switching the provider swaps the visible fields live, no save-first. Inputs are provider-namespaced (psetting_<provider>_<name>) because hidden rows still post and providers share field names (test_mode). Legacyconnector_namemethods keep working and are labeled legacy; the connector select hides itself once a provider is chosen. A method carries EITHER a provider OR a connector, never both. Each provider block ends with its own verdict (getConfigurationProblem()on the decrypted settings): a warning naming the missing piece, or an explicit green configured state.
8b. Provider settings at rest — one writer, encrypted secrets
Settings live as an ini blob in the method row's params column, and
ConfigboxProviderSettings is the one reader and one writer — the form save, the registry's
provider-facing read and the E2E seeding task all go through it, so the rules cannot drift:
- Fields a provider declares as
secretare encrypted at rest byConfigboxSecretStore(sodium secretbox,enc:v1:prefix; key derived from the host platform's secret viaKenedoPlatform::getSecret(), so a database dump alone cannot decrypt — it takes the config file too; a platform without a secret falls back to a generated key in system vars, weaker and documented as such). Plaintext is tolerated on read and becomes encrypted on the next write — pre-encryption blobs migrate themselves, no script. - The form never echoes a stored secret: the field renders empty with a value is stored
placeholder (the template sees only a presence flag, never the value), the serialized
data-recordis redacted (redactRecordForClient), and a blank submitted secret means UNCHANGED while a typed one replaces. The no-echo field and keep-on-blank save are one design — shipping only half of it makes every save wipe the API key. - Decryption failure (site secret changed, tampering) logs loudly and yields '' rather than handing ciphertext to a PSP client.
Pinned by tests/specs/backend/payments-method-form.spec.ts: provider-first layout, live field
switching, no secret anywhere in the markup, enc:v1: in the stored blob, keep-on-blank and
replace-on-type.
- Recent events (
adminpaymentlog): the last N events across all payments — the “what just happened” view when a webhook misbehaves.
9. Provider roster
| Key | Region rationale | Form factors | Settlement | Status |
|---|---|---|---|---|
demopay | the fake — dev, demos, E2E; simulates success/decline/async/challenge, signed webhooks | F1, F2, F5/F6; F7-style signed rail | return + webhook + manual | shipped; F1 + webhooks fully E2E-tested, F2 coverage open |
stripe | US + Canada + Europe in one; hosted Checkout AND on-site Payment Element | F1, F2 (F3 via Element config) | return-verify + webhook + poll | shipped; F1 live-verified 2026-08-10 against real api.stripe.com |
swissqrbill | Switzerland: THE bank-transfer standard (Swiss QR-bill); ISO-11649 RF creditor reference generated per order | F6 | manual (camt.053 import: future) | shipped + E2E-tested incl. the QR image (SPC payload through vendored chillerlan/php-qrcode 5.0.5 — MIT + Apache-2.0 portions — Swiss cross composed in; camt.053 import still future) |
trustly | Scandinavia's common bank-transfer rail (SE origin, pan-Nordic/EU open banking) | F7 | signed notification + signed ack | wired to their documented API; unverified — needs merchant credentials |
mollie | EU breadth in one integration: iDEAL, Bancontact, EPS, SEPA, cards (F1+F4+F5 through one API) | F1, F4, F5 | return-verify (fetch-back — their webhook carries no signature BY DESIGN; authenticity is the API fetch) + webhook + poll | shipped 2026-08-10; opt-in E2E awaits a test key |
paypal | ubiquity, US + EU | F1 (F3 buttons later) | capture-on-return (the capture IS verification + money movement) + poll; webhook deliberately v2 (needs their per-delivery verification API) | shipped 2026-08-10; opt-in E2E awaits sandbox credentials |
authorizenet | US + Canada traditional/B2B merchants; Accept Hosted (their SAQ A path) | F1 via the POST bridge (token by form POST) | webhook (HMAC-SHA512 over raw body, X-ANET-Signature; resolves via invoiceNumber = order id) + poll (incl. an unsettled-list scan while no transaction id exists) | wired 2026-08-15; unverified — self-serve sandbox pending |
klarna | pay now / pay later, DACH + Nordics + US; hosted payment page wrapping a Payments session | F1 (which Klarna products show is Klarna-side) | capture-on-return (authorization token → Klarna order, our amount asserted) + poll via Order Management; webhook v2 | wired 2026-08-15; unverified — playground credentials pending |
postfinance | Switzerland's house solution (PostFinance Card, TWINT, e-finance, cards) on the wallee-built checkout.postfinance.ch | F1 | return-verify + poll (transaction read; COMPLETED/FULFILL = paid) + webhook by FETCH-BACK (the Mollie model — payload names the id, facts come from the API) | wired 2026-08-15; unverified — test space pending |
adyen | Europe's volume leader; Hosted Checkout via the Sessions API (no Drop-in) | F1 | return-verify (session result endpoint) + webhook (HMAC-SHA256 per NotificationRequestItem, resolves via merchantReference = order id); no independent poll | wired 2026-08-15; unverified — test account pending |
saferpay | Worldline's gateway: FR/DE/CH/Benelux incumbent | F1 | assert-and-capture on return AND on poll (PaymentPage/Assert then Transaction/Capture — the token is the truth, not the query string) | wired 2026-08-15; unverified — test.saferpay.com sandbox pending |
nexi | Nexi/Nets group: Italy + the Nordics through one hosted checkout | F1 | return-verify + poll (payment fetch; charged amount = paid, auto-charge on) + optional webhook keyed by a shop-chosen Authorization secret | wired 2026-08-15; unverified — portal test keys pending |
payu | Poland's #1 and the CEE workhorse (BLIK, bank transfers) | F1 | webhook (OpenPayU-Signature, MD5/SHA-256 over body+second key) + poll; amounts are minor units natively | wired 2026-08-15; unverified — self-serve sandbox pending |
square | USA's small-merchant giant; Payment Links (Checkout API) | F1 | return-verify + poll (order fetch: tender present and nothing due = paid) + webhook (HMAC-SHA256 over notification-URL+body) | wired 2026-08-15; unverified — sandbox tokens pending |
Never in scope: anything that would put a PAN through CBX (SAQ D). On-site card entry is always the PSP's own JS rendering into our page (SAQ A stays true by construction, not by review).
10. What providers implement (server side)
abstract class ConfigboxPaymentProvider {
abstract public function getKey(); // 'stripe'
abstract public function getTitle();
abstract public function getFlow($settings); // redirect|onsite|instructions
abstract public function getConfigFields(); // declarative admin form
abstract public function isConfigured($settings);
public function supportsWebhook() { return false; }
public function supportsPolling() { return false; }
/** AMD module id for the onsite flow's page JS, or '' (see §11). */
public function getClientModuleId() { return ''; }
/** @return ConfigboxPaymentStep redirect(url) | post(url, fields) | onsite(clientConfig) | instructions(data) | fail(msg) */
abstract public function begin(ConfigboxPaymentContext $ctx);
/** @return ConfigboxPaymentOutcome settle/pend/fail/none + providerRef + reportedMinorAmount + logPayload */
public function verifyReturn(ConfigboxPaymentContext $ctx, array $request) { return ConfigboxPaymentOutcome::none(); }
public function handleWebhook($rawBody, array $headers, $settings) { return ConfigboxPaymentWebhookResult::ignored(); }
public function poll(ConfigboxPaymentRecord $payment, $settings) { return ConfigboxPaymentOutcome::none(); }
}
ConfigboxPaymentContext carries order id/number, minor amount, currency, customer name/email,
language, settings, and absolute, unencoded URLs (return/cancel/webhook) — built by
ConfigboxPaymentUrls, which exists because KLink::getRoute($url, true)'s second argument means
encode, and handing PSPs relative, entity-encoded URLs is how the old Stripe flow was broken from
day one (Stripe refused with url_invalid; see 2026-08-08 diary).
post(url, fields) exists for hosted pages that take their one-time page token by form POST
(Accept Hosted is the archetype): the checkout sends the customer to the gateway's session-gated
BRIDGE endpoint, which renders a self-submitting form aimed at the PSP. And a webhook whose events
never carry the reference we stored (Authorize.net mints its transaction id only when the customer
pays; Adyen notifies with its psp reference, not the session id) resolves the payment ITSELF via
the order reference it planted at begin (invoiceNumber / merchantReference = order id) and sets
WebhookResult.paymentId; the dispatcher verifies the id belongs to the provider before applying.
Discovery is by directory scan (payments/providers/<key>/provider.php, plus the customization
dir), so a client install can add a provider without touching the component.
11. What providers implement (client side — the onsite contract)
The onsite flow's page JS is a provider-owned AMD module, declared by getClientModuleId()
(convention: configbox/payments/<key>, file assets/javascript/payments/<key>.js — the
configbox AMD prefix maps to the assets/javascript directory, so no loader config is needed;
install-added providers use the configbox/custom prefix and the customization assets dir. ES2021,
cbj from AMD, never a global). configbox/paymentResult requires the module on demand from the
result page's onsite slot and calls mount().
define(['cbj'], function(cbj) {
return {
/**
* Render the PSP's payment UI into `container`.
* @param {HTMLElement} container the result page's onsite slot
* @param {Object} clientConfig EXACTLY what the Step carried — publishable data only
* @param {Object} hooks { onSettled(providerRef), onFailed(message), returnUrl }
*/
mount: function(container, clientConfig, hooks) { /* provider-specific */ }
};
});
Non-negotiables:
clientConfigis a publishable-data whitelist. The service persists the Step's clientConfig into payment meta and re-serves it on revisit; nothing secret may ever be in it (redact()patterns apply to it too, as a tripwire — a secret in clientConfig is a bug, not a config style).- The PSP's SDK loads from the PSP, on demand. The client module injects the PSP's script
(their CDN, their SRI where offered) only when
mount()runs. We never bundle, proxy, or pin a PSP SDK — that is their compliance surface, not ours. - Confirmation resolves server-side. Whatever the SDK reports, the module's job ends at
navigating to
hooks.returnUrl(the nonce-gated return);verifyReturnestablishes the money state.onSettledis presentation-only — it must never be the thing that marks anything paid. - Wallets ride the same mount. A provider offering Apple/Google Pay lists them in
clientConfig.wallets; the module renders the buttons. No separate wallet flow exists.
12. Invariants (the best-practice core — each one is tested or testable)
Money
- Amounts are minor units everywhere;
toMinorUnits()owns the zero-decimal table. - A zero-amount or currency-less payment is refused at creation (
forOrder()throws) — a zero-amount payment "succeeds" at every PSP and verifies against itself. settledis unreachable without the amount+currency check against the provider's own report.
Trust
4. Return URLs are gated by a per-payment nonce minted at creation; the query string is data about
which payment to check, never evidence of its outcome.
5. Webhooks are authenticated by the provider's signature scheme; a refusal answers 400 (a real
PSP retries; a prober learns nothing), a verified event about an unknown payment answers 200
(4xx would put the PSP into permanent redelivery over something that is not its mistake).
6. Webhook replay safety lives in the schema (dedup_key UNIQUE), not in provider discipline.
7. The PAN never touches CBX (SAQ A by construction); secrets never reach the client
(clientConfig whitelist, §11); stored payloads pass redact().
Robustness
8. Webhook acks terminate the request — the host's render pipeline must never restamp the status.
9. Every pending payment has a recovery path that needs no customer: webhook, poll, or the admin
ledger. A provider with none of the three cannot claim async settlement.
10. begin() on a payment that already holds a pending provider session must not silently mint a
second charge path — reuse or supersede, and log which.
11. Test mode is a per-method setting stamped onto every payment row and visible in the ledger —
test money never looks like real money in the admin.
Lifecycle 12. Order status 2 (Ordered) is never written by payment code. 15 → 3 is payment-driven; 9 is the amount-mismatch signal; cancel/expiry keep 15. 13. The purchase-tracking blob fires exactly once per order, enforced by an atomic UPDATE, and only from the settled result page.
13. Adding a provider — the conformance checklist
A provider PR is reviewable against this list; “it works in my checkout” is not a criterion.
- One directory:
provider.php(+client.jsfor onsite). No edits outside it except the roster row in this document. getConfigFields()declares every setting (typed; secrets astype: secret;requiredon exactly the fieldsisConfigured()checks — advisory marker, never a save blocker), andisConfigured()names what is missing — the method form renders both without custom PHP (each field becomes a real property row; see §8/8b). Declaring a fieldsecretbuys the whole hygiene package for free: encrypted at rest, never echoed, keep-on-blank. A provider whose webhook is dashboard-registered adds$this->webhookUrlInfoField()so the form shows the admin the exact URL to register.- Every form factor it claims maps to a §3 row; anything novel updates §3 FIRST.
begin()returns a Step; on API failure it returnsfail()with an operator-readable message and logs the exchange (redacted) — it never throws raw HTTP errors at the customer.- Settlement: at least one no-customer channel for async factors (invariant 9); webhook handlers
verify the signature over the raw body, resolve the payment by provider reference, and set a
deterministic
dedup_key(<key>:<event-id>). - Amount honesty: outcomes carry the PSP-reported amount in minor units — never echo the payment row's own figures back at the verifier (DemoPay once did; the check was vacuous).
- E2E: journeys against DemoPay prove the framework path; the provider itself gets an opt-in
spec (skipped without credentials) driving its real test mode, with credentials resolved via
resolveSecret()(env →.claude/secrets/credentials.env) — never on a command line, never in the repo. Runtime seeding viaseedProviderPaymentMethod; unpublish in teardown. - Third-party markup gets its own page-object helper with the brittleness note — when it breaks, suspect the PSP's UI before the pipeline, and fix the helper, not the assertions.
- A row in §9 with region rationale, form factors, channels, and honest status.
14. Logging rules
Every event payload passes ConfigboxPaymentEvents::redact(): Authorization headers,
*_key, *_secret, client_secret, full card fields → «redacted». Bodies are stored as
received otherwise — a webhook dispute is settled by reading the timeline, not by re-asking the PSP.
payments KLog channel gets errors + signature refusals; the DB gets everything.
15. The post-payment journey (v1.2)
What happens after the PSP answers is a designed surface, not an afterthought: the customer is never stranded, and the analytics funnel gets stable URLs to hang goals on.
Outcome URLs. The payment-result page routes under an outcome vocabulary that the router
recognises globally (router.php), so every journey ends on a pretty, funnel-stable path:
| Real payment state | Outcome word | Typical URL |
|---|---|---|
settled (and refunded) | thank-you | /…/thank-you/1234.html |
created / pending | payment-pending | /…/payment-pending/1234.html |
failed / canceled / expired | payment-failed | /…/payment-failed/1234.html |
A funnel goal is "path contains /thank-you/" — no query-string gymnastics. Rules:
- The URL is presentation, never evidence (invariant 4 applies): the view derives the REAL
outcome from the payment row and canonical-redirects when the requested outcome does not
match — a bookmarked
/thank-you/for an unpaid order lands on/payment-pending/, so the funnel only ever counts a thank-you hit when money actually settled. - The onsite flow's card form lives under
/payment-pending/— in funnel terms it IS the payment step. Legacy connector orders (no ledger row) keep their old result URL untouched.
The thank-you page carries machine-readable metadata. Two layers, both PII-free:
- the fire-once
#cbx-purchase-datablob (§7) gainspayment_provider,payment_flowandpayment_attempts— GTM maps them as custom parameters; - the
.cbx-payment-resultcontainer carriesdata-outcome,data-order-id,data-provider,data-flow,data-attemptanddata-test-modeon EVERY outcome page, so selector-based triggers can fire per step without parsing anything.
Failure returns to the checkout, in the state the customer left it. The payment-failed
page's action is Back to checkout: it links to the cart page, which — when the session's order
is placed-but-payable (status 15/9) with a terminally dead latest payment, and the cart holds no
new positions — reopens the checkout for that order automatically: address saved and shown,
delivery and payment selections still stored, panels open. From there the customer retries the
same method or switches to another one — re-placing runs the normal placeOrder() path, so
method surcharges recalculate honestly and begin() mints a new attempt row on the same
ledger; the dead attempt stays visible in the admin timeline. New cart content takes precedence
over the resume: fresh purchase intent wins, and the parked order stays reachable via its result
URL. There is no separate retry endpoint — the checkout IS the retry.
Pending resolves itself. For async factors (F4/F5, webhook-settled), the payment-pending
page polls paymentgateway.status (session-gated, answers {state, outcome, url}) every few
seconds for a bounded window; when the webhook settles the payment, the page forwards itself to
/thank-you/ — the customer watches the confirmation arrive instead of refreshing. Polling is
enabled only when the provider has a no-customer settlement channel (invariant 9's flag reused).
16. Deliberate non-goals (v1) and the v2 direction
- Refunds and partial captures: the state machine reserves
refunded; v2 models refunds as child rows on the ledger (a refund is a payment-shaped fact with a sign), driven through the same service/channel machinery. Nothing in v1 may make that harder. - Post-purchase order management (Klarna captures on shipment, disputes/chargebacks): out of scope until refunds exist; chargebacks will arrive as webhooks and land on the timeline even now.
- Statement import (camt.053) for F6: the transfer reference is already ISO-11649 precisely so an importer can match mechanically.
- Saved payment methods / subscriptions: out of scope; nothing stores reusable tokens.