Skip to content

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 the Cart-Token response 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_cart on the public host; Max-Age is taken from the token’s exp, 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_checkout runs with proxy_support on and the BFF forwarding the shopper IP in X-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:

  1. Tier / quantityPOST api/cart → Store API cart/add-item with landing_tier in extension data (woocommerce_store_api_add_to_cart_data) → nf-commerce applies today’s landing allocator; the server rejects subscription SKUs on landings. Totals come from Woo.
  2. Address + shippingcart/update-customer, cart/select-shipping-rate.
  3. Payment — list from Store API payment_methods; the provider registry renders per gateway id (table below).
  4. Return — every gateway comes back through WP order-received on wp.{market}; the theme 302s to _nf_return_to (below).
  5. Thank-you state inline; AfterPurchase upsell slot in Phase 4.

Per-gateway flow

Gateway idBrowserPOST api/checkout → Store API /checkoutAfter
stripeStripe.js Payment Element in the islandpayment_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 → checkoutpayment_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.
codnothingpayment_method: codOrder placed; inline thank-you.
bacs + UPNnothingpayment_method: bacsIsland 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_mbwaynone until redirectpayment_method: mollie_wc_gateway_mbwaypayment_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_paymentsnone until redirectpayment_method: bankart_payment_gateway_flik_paymentspayment_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_url is filtered to the frontend /checkout (Bankart cancel/error).
  • Cloudflare bypasses cache when the query contains order= or key=; the (store) server render still never reads them. The island reads location.search, calls api/orders/{id} (order key + billing e-mail, no-store) and renders paid / pending (polls) / failed inline.
  • The BFF empties the cart on success; nf-commerce empties the session cart on woocommerce_payment_complete for 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-to on 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.

GatewayOrder createdPaidDeclinedCancelledWebhook receivedReturn pathpayment_data/payment_details fixtureShopper IP/UA on orderSAQ class
stripetodotodotodotodotodotodotodotodotodo
ppcp-gatewaytodotodotodotodotodotodotodotodotodo
codtodotodotodotodotodotodotodotodotodo
bacs + UPNtodotodotodotodotodotodotodotodotodo
mollie_wc_gateway_mbwaytodotodotodotodotodotodotodotodotodo
bankart_payment_gateway_flik_paymentstodotodotodotodotodotodotodotodotodo

Known failure classes the design answers

FailureAnswer
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 themewc-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 statusIdempotent order creation on transaction_id; pending state polls api/orders/{id}.
One pod IP for all shoppersXFF/UA forwarding, trusted from cluster egress only.
Double order books during cutoverHost-level cutover (DNS flip); path split only as read-only dark launch with ordering disabled.