Checkout
P1 decision (ADR-012): the frontend renders cart and checkout; WooCommerce computes everything through the Store API. Landing pages carry the same checkout as an island, and every live payment method must be reachable from it. No subscriptions on landing pages.
Why not redirect to the Woo checkout: landing pages already embed the checkout today
([woocommerce_checkout] under a quantity selector in nf-platform), so the integrated flow is
existing production behaviour, and the theme renders no public checkout page.
Cart engine
The cart is WC()->cart, reached through the Store API (/wc/store/v1) with a Cart-Token.
nf-commerce extends it (ExtendSchema, hooks, a few Store-API-scoped endpoints) and never
re-implements cart math, so bundles, tiers, landing discounts, coupons, tax, shipping, OrderLimit,
BannedUsers and lockers keep working unchanged.
Plugin side (Commerce/): ExtendSchema landing_tier, gift_product, nf.return_to,
nf.consent / nf.td_sid / nf.td_ses; landing allocator, gift and free-shipping ports; PayPal
bridge; UPN service; return-to hop; idempotent order creation keyed on PSP transaction_id;
signed webhooks; nf/order/created → server-side purchase event once.
Cart-Token lifecycle
Cart and checkout are the reason app/api/{cart,checkout} route handlers exist at all: Set-Cookie
and the per-token mutex below are HTTP semantics a Server Component cannot do. Reads that don’t
need that — catalog, capabilities — go through lib/api/lib/commerce directly.
- Minted by
GET /wc/store/v1/cart, returned in theCart-Tokenresponse header. JWT signed with the store’s salt, so a token from one store is rejected by every other. Expiry 48 h (wc_session_expiration). Rotates on every response. - The BFF stores it in an httpOnly cookie
nf_carton the public host;Max-Ageis taken from the token’sexp, and the cookie is rewritten from every Store API response. - On 401/403 the BFF re-mints (GET
/cart) transparently and re-applies the cart. - The browser never sees the token (invariant 8). The BFF holds a per-token mutex around
POST api/checkout. - Store API rate limiting:
rate_limit_checkoutruns withproxy_supporton and the BFF forwarding the shopper IP inX-Forwarded-For; without proxy support WooCommerce would see a handful of pod IPs and throttle a whole market.
Landing page with integrated checkout
/{landing-slug} lives in (store): cached, generic, identical for everyone. The server render
never reads searchParams or cookies. Tier display prices come from nf/v1 (Woo-computed).
<LandingCheckout> island receives ids only as props:
- Tier / quantity →
POST api/cart→ Store APIcart/add-itemwithlanding_tierin extension data (woocommerce_store_api_add_to_cart_data) →nf-commerceapplies today’s landing allocator; the server rejects subscription SKUs on landings. Totals come from Woo. - Address + shipping →
cart/update-customer,cart/select-shipping-rate. - Payment — list from Store API
payment_methods; the provider registry renders per gateway id (table below). - Return — every gateway comes back through WP
order-receivedonwp.{market}; the theme 302s to_nf_return_to(below). - Thank-you state inline; AfterPurchase upsell slot in Phase 4.
Per-gateway flow
| Gateway id | Browser | POST api/checkout → Store API /checkout | After |
|---|---|---|---|
stripe | Stripe.js Payment Element in the island | payment_method: stripe, payment_data: wc-stripe-payment-method (+ express_checkout_type for wallets) | If payment_details.payment_intent_secret is returned → stripe.handleNextAction → top-level navigate to payment_details.verification_endpoint on the WP host → Stripe finalises → order-received → 302 hop. Otherwise paid inline. |
ppcp-gateway (official woocommerce-paypal-payments) | PayPal JS SDK buttons: createOrder → BFF → nf-commerce Store-API-scoped bridge /wc/store/v1/nf/paypal/create-order; onApprove → checkout | payment_data: {paypal_order_id, funding_source} | Paid inline. Fallback if the bridge slips: ppcp-gateway without an order id → redirect to PayPal → return through order-received. webtoffee eh_paypal_express and paypal-duplikat-fix retire. |
cod | nothing | payment_method: cod | Order placed; inline thank-you. |
bacs + UPN | nothing | payment_method: bacs | Island calls nf/v1/orders/{id}/upn (IBAN/receiver from market config, reference, PNG) and renders the slip inline. E-mail attachment unchanged. UPN is a BACS decorator, not a gateway — no payment-method shim. |
mollie_wc_gateway_mbway | none until redirect | payment_method: mollie_wc_gateway_mbway | payment_result.redirect_url → top-level redirect on every device; island state in sessionStorage keyed by order id. Return through order-received. Failure stays on theme order-pay with a back link. |
bankart_payment_gateway_flik_payments | none until redirect | payment_method: bankart_payment_gateway_flik_payments | payment_result.redirect_url → top-level redirect. Bankart ships AbstractPaymentMethodType classes already — no shim. Cancel/error hit wc_get_checkout_url(), filtered to the frontend /checkout. |
payment_data keys are not documented by WooCommerce per gateway; Phase 3 captures them and pins
them as fixtures in contracts/fixtures/payments/ (one payment_data and one payment_details
fixture per gateway). Gate matrix: below.
Return hop
Every gateway returns to WP order-received on wp.{market}: the cart is cleared there and the
Stripe/Bankart return handlers run. The theme, at template_redirect priority > 20, 302s to
_nf_return_to, which nf-commerce stored on the order from checkout extensions.nf.return_to
(/{landing-slug}?order={id}&key={key} for landings, /order/{id}?key={key} otherwise).
woocommerce_get_checkout_urlis filtered to the frontend/checkout(Bankart cancel/error).- Cloudflare bypasses cache when the query contains
order=orkey=; the(store)server render still never reads them. The island readslocation.search, callsapi/orders/{id}(order key + billing e-mail,no-store) and renders paid / pending (polls) / failed inline. - The BFF empties the cart on success;
nf-commerceempties the session cart onwoocommerce_payment_completefor the draft order.
Shopper identity on the order
The BFF forwards X-Forwarded-For, User-Agent, Accept-Language; WP trusts them only from the
frontend cluster’s egress (CIDR allowlist or Access service token). customer_ip_address and
customer_user_agent on every order must be the shopper’s, not the pod’s — Stripe Radar, PayPal
risk, OrderLimit, BannedUsers and geo tax defaults all key on them (invariant 10, checked per
gateway in the Phase 3 gate matrix below).
PCI control
Card data exists only inside Stripe and PayPal iframes. Target classification is SAQ A under PCI SSC FAQ 1588 (2025): embedded Stripe/PayPal iframes keep SAQ A when the page has script protections or the TPSP confirms them in writing. Stripe.js and the PayPal SDK do not support SRI, so the control set is:
- written TPSP confirmation from Stripe and PayPal, requested in Phase −1;
- host-allowlist CSP with
report-toon every page hosting the island; - per-market script inventory committed in the repo;
- SRI on our own chunks;
- edge nonce injection (small Worker) held as a Phase 3 option if the QSA requires it.
Each gateway’s resulting SAQ class is a column in the Phase 3 gate matrix below.
F4 amendment — subscriptions
Subscription creation in all four gateway plugins (stripe-subscriptions, paypal-subscriptions,
mollie-subscription, cod-subscriptions) is coupled to the classic checkout
(woocommerce_thankyou, woocommerce_checkout_process, template_redirect). Through Store API
/checkout those hooks never fire, so COD and Mollie subscriptions would silently never be
created and Stripe subscriptions could lack a saved payment method.
Amendment: each of the four plugins gets a woocommerce_store_api_checkout_order_processed (or
status-transition) listener and reads its save-payment-method flag from Store API payment_data.
Renewals are untouched — they never pass through checkout. Phase 3 gate: a subscription record
(_stripe_sub_db_id, _mollie_sub_db_id, _ppsub_subscription_db_id, _cod_sub_id) created
via Store API checkout per plugin, plus a forced renewal of that new subscription. Landing pages
reject subscription SKUs server-side, so this affects /checkout only.
Phase 3 gate — gateway matrix
Exit gate for Phase 3, done together with the landing checkout, not as a separate phase (owner decision 2026-09-08). One row per gateway enabled on SI today, one column per fact. A cell is either verified with a fixture committed, or a blocker with owner and date. No third state.
payment_data and payment_details fixtures per gateway land in contracts/fixtures/payments/
during Phase 3 as this matrix fills in.
| Gateway | Order created | Paid | Declined | Cancelled | Webhook received | Return path | payment_data/payment_details fixture | Shopper IP/UA on order | SAQ class |
|---|---|---|---|---|---|---|---|---|---|
stripe | todo | todo | todo | todo | todo | todo | todo | todo | todo |
ppcp-gateway | todo | todo | todo | todo | todo | todo | todo | todo | todo |
cod | todo | todo | todo | todo | todo | todo | todo | todo | todo |
bacs + UPN | todo | todo | todo | todo | todo | todo | todo | todo | todo |
mollie_wc_gateway_mbway | todo | todo | todo | todo | todo | todo | todo | todo | todo |
bankart_payment_gateway_flik_payments | todo | todo | todo | todo | todo | todo | todo | todo | todo |
Known failure classes the design answers
| Failure | Answer |
|---|---|
| Cross-origin “cart always empty” (cookies, nonces) | Cart-Token via BFF; no browser → WP call. |
| Nonce or token baked into cached HTML | (store) renders nothing request-specific; token lives in the httpOnly cookie. |
| PSP callback 301’d by the theme | wc-api, /wp-json/paypal/*, /wp-json/mollie/* exempt from the route map and from Access; Phase 0 curl gate wc-api/x → 200. |
| Webhook races on order status | Idempotent order creation on transaction_id; pending state polls api/orders/{id}. |
| One pod IP for all shoppers | XFF/UA forwarding, trusted from cluster egress only. |
| Double order books during cutover | Host-level cutover (DNS flip); path split only as read-only dark launch with ordering disabled. |