# Payments (the 2026 subsystem)

> > Scope: how the CLASSIC checkout takes money — the provider contract, the payment ledger, the > state machine, settlement, logging, the admin surfaces, and …

Source: CBX documentation, version 3.x (released). Canonical page: https://docs.configbox.at/docs/technical/payments. Last updated 2026-08-15.

---
> **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

1. **A payment is a first-class record.** `#__configbox_payments` holds 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.

2. **Every fact is an event.** `#__configbox_payment_events` is 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
   (`KLog` channel `payments`) mirrors errors, and nothing about a payment is ever knowable only
   from a log file on disk.

3. **One state-writer.** Only `ConfigboxPaymentService` changes a payment's state. It enforces the
   legal transitions, verifies amounts against the order, deduplicates webhooks (`dedup_key` UNIQUE —
   replay-safe by schema, not by discipline), fires the observer events, and applies the order
   side-effect. Providers never touch the DB.

4. **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.

5. **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=paid` does 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: `verifyReturn` answers `pend`, 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
- `instructions` flow → 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 (`providersetting` type, declared dynamically by
  the model from the registry) bound to that select by `appliesWhen` — 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`). Legacy `connector_name` methods 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 `secret` are **encrypted at rest** by `ConfigboxSecretStore`
  (sodium secretbox, `enc:v1:` prefix; key derived from the host platform's secret via
  `KenedoPlatform::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-record` is 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)

```php
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()`.

```js
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:

- **`clientConfig` is 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); `verifyReturn` establishes the money
  state. `onSettled` is 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
1. Amounts are **minor units** everywhere; `toMinorUnits()` owns the zero-decimal table.
2. 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.
3. `settled` is 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.

1. One directory: `provider.php` (+ `client.js` for onsite). No edits outside it except the
   roster row in this document.
2. `getConfigFields()` declares every setting (typed; secrets as `type: secret`; `required`
   on exactly the fields `isConfigured()` checks — advisory marker, never a save blocker), and
   `isConfigured()` names what is missing — the method form renders both without custom PHP
   (each field becomes a real property row; see §8/8b). Declaring a field `secret` buys 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.
3. Every form factor it claims maps to a §3 row; anything novel updates §3 FIRST.
4. `begin()` returns a Step; on API failure it returns `fail()` with an operator-readable message
   and logs the exchange (redacted) — it never throws raw HTTP errors at the customer.
5. 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>`).
6. 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).
7. 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 via `seedProviderPaymentMethod`; unpublish in teardown.
8. 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.
9. 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-data` blob (§7) gains `payment_provider`, `payment_flow` and
  `payment_attempts` — GTM maps them as custom parameters;
- the `.cbx-payment-result` container carries `data-outcome`, `data-order-id`, `data-provider`,
  `data-flow`, `data-attempt` and `data-test-mode` on 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.
