Checkout module

Module id: checkout. Dependencies: cart, catalog, customer.

What it adds:

  • The /checkout flow: address + payment-method picker → gateway hand- off → return / webhook → "thank you" page.
  • Order entities + an admin Orders tab.
  • Order receipts to the customer + admin notifications to a configured recipient list.
  • Invoices and credit notes as PDF, previewed on the order, created and sent by hand (see Invoices and credit notes).
  • Payment gateways: Cash on delivery, Mollie, Stripe — each with its own per-website credentials.
  • Per-website settings (checkout_settings) for shipping, recipients, payment-method enable + credentials, order-channel mail-from.

With the delivery moments module on, the checkout form also asks for a delivery day and time window, in a fieldset after the shipping options.

Of all the backend modules, this is the one with the most operator configuration. Every section below maps to a card in the Module settings → Checkout sub-tab.

What's per-website — CheckoutSettings

One row per website, lazy-created the first time you save the panel. Read paths treat "no row" as "all defaults" — a tenant who never opens the panel still loads the public site, but with shipping = 0 and zero payment methods enabled (so the picker has nothing to offer).

Column Type Default What it controls
shipping_flat_cents int 0 Flat shipping fee in cents on every order. 0 = free.
free_shipping_threshold_cents int (nullable) null Gross-subtotal threshold above which shipping is waived. null = waiver disabled.
require_shipping_selection bool false Refuse an order the shipping module quoted no option for, instead of falling back to the flat fee. For a business that delivers itself inside a set of postcodes.

With the shipping module on, prices live in its rate table and the flat rate is only what an address no rate matches is charged. The panel then shows the flat rate and threshold only while such orders are accepted; with Only accept orders we can deliver on, they are hidden, since nothing is charged at them. | order_notification_email | string(500) | null | Comma-separated admin recipients for the "new order" mail. null = no admin mail. | | vacation_from | date (nullable) | null | First closed day, inclusive. See Holiday closure. | | vacation_until | date (nullable) | null | Last closed day, inclusive. Both ends or neither — one alone changes nothing. | | vacation_announce_days | int | 14 | How many days ahead the storefront starts warning. 0 = only during the closure. Clamped to 365. | | vacation_message | json | {} | Optional per-locale sentence ({"nl": "…", "en": "…"}) replacing the generated one. | | payment_methods | json | {} | Per-gateway slot map: {enabled, …credentials}. See Payment methods. | | mail_from_email | string(191) | null | Order-channel From: override. Falls back to website default. | | mail_from_name | string(128) | null | Display name on the order-channel From: header. |

Realistic example — a Dutch boutique

Module settings → Checkout:

Shipping
────────
Flat rate (cents):              595          # €5.95
Free shipping threshold (cents): 5000        # waive over €50.00 gross

Order notifications
───────────────────
Recipients: orders@example.nl, warehouse@example.nl

Order mail sender
─────────────────
From email: orders@example.nl
From name:  Example Webshop

Payment methods
───────────────
Cash on delivery  [✓ enabled]
Mollie            [✓ enabled]   api_key = live_AbCdEfGh1234…
Stripe            [  enabled]

Saving creates the checkout_settings row. Behaviour:

  • An order for €40 → shipping €5.95, total €45.95.
  • An order for €60 → shipping waived (gross subtotal clears the €50 threshold).
  • On payment success: customer gets an "Order confirmation" mail from Example Webshop <orders@example.nl>; orders@ and warehouse@ each get an "Admin notification" mail.
  • The picker shows COD + Mollie. Stripe is hidden because enabled is off; even with creds in place, the toggle is the master switch.

Free-shipping math

The threshold is compared against gross subtotal (subtotal + tax). That matches the prices customers see on product pages in tax-inclusive markets like the EU. Set the threshold in those same gross cents (e.g. 5000 for €50.00 gross).

To always charge shipping, leave the threshold blank. To always waive (free shipping site-wide), set the flat rate to 0 and don't bother with the threshold.

Holiday closure

Module settings → Checkout → Holiday closure. For the fortnight in August when nobody is there to post parcels.

The shop keeps selling. Checkout stays open for the whole closure — closing it costs the sale outright, where a customer who is told the parcel leaves on the 28th usually still buys. The entire mechanism is about making sure nobody can pay without having read that sentence.

Holiday closure
───────────────
First day closed:                 2026-08-10
Last day closed:                  2026-08-27
Warn customers this many days ahead: 14
Message (nl):                     (blank — use the generated sentence)

The panel shows the resulting state back to you: "Closing 10 August — 27 August, back on 28 August", or, once it has started, "Closed today. Orders placed now ship from 28 August."

Both dates or neither. One end filled in without the other, or a range that ends before it starts, saves and looks configured but changes nothing on the storefront. The API refuses each by name, and the entity fails closed behind it, so a half-typed form can never shut the shop's shipping promise. The dates stay on the row after the closure ends (shops reuse last year's as a starting point) and simply go quiet.

What the customer sees, and when

From vacation_announce_days before the first closed day, a banner appears on the cart and at the top of the checkout, plus anywhere the theme calls cms_vacation_notice(). The wording changes tense once the closure starts:

  • Upcoming — "We are closed from 10 August to 27 August. Orders placed in that time are shipped from 28 August."
  • Running — "We are closed until 27 August. You can still order — your parcel is shipped from 28 August."

Both come from the bundled string packs, in the language of the page being rendered. Fill in Message for a locale to replace it wholesale with the shop's own copy ("the workshop is shut for stocktaking") — it is printed verbatim, so put the date in it yourself if you want one.

The lead time is a field rather than a constant because two weeks is right for a webshop posting parcels and far too short for one building furniture to order. 0 means say nothing until the closure actually starts.

Orders taken during a closure

An order placed while the shop is closed is stamped with dispatch_from: the date the checkout page had just promised. It is frozen, not recomputed — a shop that afterwards extends its break must not silently move a date it already gave someone.

It shows up in three places: on the order confirmation email (the mail the customer digs up in week two wondering where the parcel is), on the Orders list row, and on the order detail view. On the reopening morning the backlog is worked through in exactly that order, which is why it is on the list row and not two clicks away.

Payment methods

Three gateway implementations ship today. Each must be enabled and have its required credentials filled before it appears in the checkout picker. Half-configured gateways (enabled + missing creds) are hard-rejected at save time with a field-level error.

Hand-off to the provider. Placing an order (or "Continue payment" on the order-in-progress page) does not redirect to the provider: it answers with a short "You are being taken to the payment page" page that moves on by itself, with a link for a browser that does not. A browser checks every redirect after a form submit against the site's CSP form-action, and a payment opened on one method goes straight on to that method's own page (pay.ideal.nl for iDEAL, a bank, PayPal, Stripe Checkout), which Chrome then blocked: the customer stayed on /checkout with a pending order. The hand-off page works for every provider without listing each one in the policy. It is not a theme template.

cod — Cash on delivery

Property Value
Required fields (none)
Behaviour Synchronous. No redirect. Order is recorded as paid the moment the customer submits — admins reconcile cash later.
Webhook n/a

Useful as the only gateway for boutiques that accept payment in person or by phone, and as a smoke-test stand-in for live gateways during local development.

mollie — Mollie (iDEAL, cards, Bancontact, SEPA, …)

Property Value
Required fields api_key
Behaviour Redirect + webhook. The checkout lists each method activated on the website's Mollie account; the pick is sent to Mollie, which opens that method directly.
Webhook POST /checkout/webhook/mollie

Methods. The checkout asks Mollie which methods are activated on the account (iDEAL, Bancontact, cards, PayPal, pay-later, ...) and offers each as its own choice, with Mollie's logo and Mollie's name for it in the site's language. Switching a method on or off in the Mollie dashboard shows up at checkout within 15 minutes. A method is left off when the amount due is outside its minimum or maximum (pay-later methods have a minimum). The pick is stored on the order (shown on the customer's order page and in the order notification mail) and sent to Mollie, so the customer goes straight to their bank, Wero or the card form.

If Mollie cannot be reached, the checkout falls back to one "Mollie" choice and Mollie's own page asks for the method, as before; it tries again a minute later. The gateway's display-name override applies to that single choice only.

Apple Pay is listed when it is activated on the Mollie account, but shown only in a browser that can pay with it (Safari on a device that supports Apple Pay, over HTTPS); everywhere else the choice is hidden. It is always listed last.

Use a test_… key on staging and a live_… key in production — Mollie keys the sandbox by prefix, no separate setting needed. Configure the webhook URL in your Mollie dashboard so async iDEAL flows (which often complete after the customer's already returned) flip the order to paid in the background.

stripe — Credit card (Stripe Checkout)

Property Value
Required fields secret_key, webhook_secret
Behaviour Redirect (Stripe Checkout) + webhook. webhook_secret is required to verify the Stripe-Signature HMAC.
Webhook POST /checkout/webhook/stripe

Use sk_test_… on staging, sk_live_… in production. Add the webhook URL in Stripe → Developers → Webhooks; configure events checkout.session.completed + checkout.session.async_payment_succeeded

  • checkout.session.async_payment_failed. Stripe gives you a whsec_… value — paste that into webhook_secret.

Order receipts + admin notifications

Triggered by CheckoutService::markPaid() (which fires from both the return-URL handler and the webhook). Two messenger messages dispatch asynchronously:

Message Handler What it does
SendOrderConfirmation SendOrderConfirmationHandler Calls OrderMailer::sendCustomerConfirmation($order).
SendOrderAdminNotification SendOrderAdminNotificationHandler Reads recipients from CheckoutSettings.orderNotificationEmail, calls OrderMailer::sendAdminNotification($order, $recipients).

Both use the OrderMailer From-resolution chain: CheckoutSettings.mailFromEmail → Website.mailFromEmail → null. Null = drop + log ERROR (the order itself still completes; only the notification fails).

The customer mail is one message → one recipient (order.email). The admin mail is one message → N recipients (the parsed list from the comma/space/semicolon-separated field).

Theme helpers (when checkout is enabled)

The Checkout sandbox contributor exposes only entity getters today, no Twig functions. The order is handed to the template via the controller's render context; you walk it directly:

{# checkout/thanks.html.twig — already rendered by the controller. #}
<h1>Bedankt voor je bestelling</h1>
<p>Bestelnummer: <code>{{ order.id|slice(0, 8)|upper }}</code></p>
<p>Status: {{ order.status.label }}</p>

<table>
    {% for item in order.items %}
        <tr>
            <td>{{ item.nameAtOrder }} × {{ item.quantity }}</td>
            <td>{{ item.lineGrossCents|cms_money(order.currency) }}</td>
        </tr>
    {% endfor %}
</table>
<p>Totaal: <strong>{{ order.totalCents|cms_money(order.currency) }}</strong></p>

Allowed entity getters listed in ../../twig-helpers.md#checkout-module-checkout. Everything outside that list is a sandbox error at render time, and three of them are easy to reach for:

  • order.status is a backed enum, not a string. Print order.status.label ("Awaiting payment", "Paid"). The raw value is not reachable: order.status.value and .name both raise SecurityNotAllowedPropertyError (the sandbox allows no property access at all), and a bare {{ order.status }} fatals because a backed enum has no __toString. So there is currently no way to build a status--paid modifier class from Twig.
  • Line items carry nameAtOrder / skuAtOrder, not nameSnapshot.

Orders taxed by the VAT engine are shown incl. VAT, and the voucher comes off before VAT. order.pricesIncludeVat says which kind an order is. For those orders print each line as item.lineGrossBeforeDiscountCents (incl. VAT, before the voucher), then order.goodsGrossCents, the voucher, shipping and the total, and under it the VAT the total includes, per rate, from order.vatBreakdown (each entry has rate_bp, tax_cents and treatment; INTRA_COMMUNITY_SUPPLY is a 0% sale to a business abroad). The default thanks.html.twig does exactly this and is the template to copy from.

cms_money comes from the catalog contributor, so it is only present when catalog is enabled, which checkout requires anyway.

The convenience helpers cms_orders / cms_order are tagged "land with commit #7" in the cms-core source — they don't exist yet. Order listings on /account/orders are rendered by the Customer module's controller, which passes the orders into the template directly.

Keeping the checkout summary current

The summary on the checkout page is drawn by the server. Two things keep it honest when the customer edits something that changes the total (a VAT number that makes a business order VAT-free, another country or postcode, another shipping option):

  • Live, for themes that opt in. Every checkout page loads assets/checkout_live.js. When the VAT number, the delivery country or postcode, the shipping choice or the delivery day (delivery_date) changes, it posts the form to /checkout/refresh, which redraws the page without placing anything, and swaps every element marked data-cms-checkout-live="<name>" for the element of the same name in the redrawn page. The default template marks its totals as summary and the VAT number's message as vat-number-message, and the picker of the delivery moments module marks itself as delivery-slot (a new day shows its own windows, a pickup option hides the picker); a theme adds the same attribute to its own summary container (and, if it wants, a wrapper around the VAT number's error). Fields are watched on change, not per keystroke. A theme that marks nothing is unaffected.
  • Always, on submit. If the VAT number was changed after the page was drawn and that changes the total, pressing the order button redraws the page with the new total and a note under the VAT number instead of placing the order. The next press places it. No theme change needed.

A theme with its own script that updates a total inside a marked region (a shipping picker, say) should look its elements up when it needs them, not keep references from page load: the region is replaced, not patched.

Public routes

Route Method Path Purpose
cms_checkout GET/POST /checkout Picker + submit
cms_checkout_refresh POST /checkout/refresh The checkout page redrawn for the posted form, nothing placed (see above)
cms_checkout_return GET /checkout/return/{uuid} Gateway return URL (verifies + redirects to thanks)
cms_checkout_thanks GET /orders/{id}/thank-you Thank-you page (uuid is the secret — no auth required)
cms_checkout_webhook_mollie POST /checkout/webhook/mollie Mollie posts here
cms_checkout_webhook_stripe POST /checkout/webhook/stripe Stripe posts here (verified by Stripe-Signature)
cms_checkout_address_suggest GET /checkout/address/suggest Address autocomplete proxy (see below)
cms_checkout_address_details GET /checkout/address/details Resolves a picked suggestion to address fields

Lines that can no longer be bought

/checkout checks every cart line before it draws the form, and again when the form is submitted, before anything is validated or placed. A line fails when its product or its variant is unpublished, or when the product is restricted to logged-in customers or to customer groups and this visitor may not see it. The shopper is sent back to /cart with the line named ("Dealer price list" is no longer available. Remove it from your cart to place your order.), the same way a stock shortfall is answered, and no order is created. Once the line is removed the checkout opens as usual.

This catches what went into a cart while it could still be bought: a product unpublished or restricted since, a customer taken out of a group, a cart a guest filled before /cart/add checked access. An order already placed and waiting for payment is not re-checked when it is resumed: its lines were frozen when it was placed.

Address autocomplete

The checkout template can offer Google-backed address autocomplete via the address_autocomplete per-page module. The browser never holds a Google key — the JS calls the two same-origin endpoints above, which proxy to Google server-side.

It's a platform feature, not an operator toggle: the two endpoints return 404 unless the checkout module is on AND a platform-wide GOOGLE_PLACES_API_KEY is configured (the same key the Google-reviews sync uses, set in cms-public's environment by ops). When the key is absent, the JS feature-detects the 404 and silently falls back to plain typing — so checkout keeps working with no key, just without suggestions. Requests are per-IP rate-limited and billed per autocomplete session (one token per session, not per keystroke).

Public-side templates (themes can override)

Default Override at
@GsidCmsCore/checkout/index.html.twig @website/checkout/index.html.twig
@GsidCmsCore/checkout/pending.html.twig @website/checkout/pending.html.twig
@GsidCmsCore/checkout/thanks.html.twig @website/checkout/thanks.html.twig
@GsidCmsCore/checkout/resume.html.twig @website/checkout/resume.html.twig
@GsidCmsCore/checkout/_vacation.html.twig (closure banner) @website/checkout/_vacation.html.twig
@GsidCmsCore/checkout/_delivery_slot.html.twig (delivery moment picker, see delivery-slot.md) @website/checkout/_delivery_slot.html.twig
@GsidCmsCore/checkout/packing_slip.html.twig (admin print) @website/checkout/packing_slip.html.twig
@GsidCmsCore/invoice/invoice.html.twig (invoice and credit note PDF) @website/invoice/invoice.html.twig
@GsidCmsCore/checkout/email/order_confirmation.html.twig (+ .txt.twig) @website/checkout/email/order_confirmation.html.twig (+ .txt.twig)
@GsidCmsCore/checkout/email/order_admin.html.twig (+ .txt.twig) @website/checkout/email/order_admin.html.twig (+ .txt.twig)

Both HTML + text variants of each email ship; provide both when overriding.

@GsidCmsCore/checkout/gateway_redirect.html.twig, the hand-off to the payment provider (see "Payment methods"), is the one checkout page a theme cannot override.

The Orders admin

Sidebar → Orders. The badge on the menu item counts paid orders not yet fulfilled. The list refreshes itself about every 20 seconds while the tab is visible, so new orders appear without a reload. It shows every order on the active website with:

  • Order id (uuid prefix shown).
  • Customer email.
  • Status (pending, paid, cancelled, refunded, …).
  • Total.
  • Created at, and — only on an order taken during a holiday closure — the day it was promised to ship.
  • The delivery moment, on an order that has one (see delivery-slot.md).

Click a row for the detail view. It has four tabs:

  • Order: items snapshot, totals breakdown, payment method + reference, shipment, shipping + billing addresses (with the buyer's VAT number), the per-order note thread and payment timeline. For an order taxed by the VAT engine, a VAT card shows the decision it was charged under, and a 0% sale to a business abroad gets a Proof of transport card.

  • Packing slip: the slip, previewed (see Packing slips).

  • Invoice: once the order is paid (see Invoices and credit notes).

  • Emails: the order's communication history: every email sent or tried about it since 29 September 2026 (confirmation, the shop's new-order mail, status mails, invoices, gift cards, review request), with recipient, language, who caused it, and the body exactly as sent. A failed one says why (no mail-from, no deliverable address, mail server down).

    Send again sends the order confirmation, or the status email of the step the order is at, as it would be written now. The result is a new row, sent or failed with the reason; the old row stays. A status email for a step the order has moved past is not offered (it would be out of date), nor anything on a bol.com order (bol emails its own customers).

Operators can manually mark cancelled / refunded — useful when a COD order doesn't get paid or a Stripe refund is processed externally. There's no "force charge a card" button — refunds / captures go through the gateway dashboard.

Delete order (admins only) removes an order that was never paid: a test order, or a checkout the customer walked away from. It is offered on pending and cancelled orders; paid, refunded and bol.com orders stay, they are the bookkeeping. Before deleting, the payment provider is asked about the payment: while it is still open (the customer may be on the iDEAL page right now; an open iDEAL payment expires after 15 minutes), or when the provider reports it paid, the order is not deleted and the admin is told why. A pending order is cancelled first, so its reserved stock, discount use and gift card hold come back. The order's lines, payment history, emails and notes are deleted with it; this cannot be undone.

Packing slips

The Packing slip tab on the order detail page previews the slip for one order, with Print and a With prices switch. On the list, tick the rows and press Packing slips to get one document with a page break per order — the shop prints the morning's parcels in a single job rather than a tab per box. Up to 100 orders at a time; the list shows 50 per page, and the selection resets when you filter or page, so what prints is always what you could see.

The slip carries the ship-to address, a tick column, SKU, item name, the per-line customer note, and the shipment (carrier, pickup location, track and trace). No money by default, because a packing slip is not an invoice; With prices adds the money columns for a shop that puts the slip in the box as a delivery note. Print is a normal browser print, so "Save as PDF" is there if a copy has to be filed.

It is printed in the website's own language (Dutch, English, German and French ship), not the language the order was placed in: the sheet is read by whoever packs the box, not by the customer. The addresses, SKUs and dates are of course the order's.

Printable in any status: a shop taking bank transfers packs before the money lands. An order that is not paid or fulfilled is flagged on the slip itself, so nothing ships on the strength of a printout alone.

Themes restyle it by mirroring checkout/packing_slip.html.twig under their own templates dir, same as any other core template.

Invoices and credit notes

The Invoice tab on a paid, fulfilled or refunded order shows the invoice as a PDF preview, marked as such and without a number. Nothing is numbered until someone presses Create invoice, which gives it the next number and stores it. It is not emailed: Send invoice on the issued invoice emails the PDF to the order's email address, a separate click. An invoice only for the books (a bol.com order, the accountant) is created and never sent.

  • Numbering: YYYYMM followed by a running number of at least two digits, per website and month: 20260901, 20260902, … Invoices and credit notes share the series, and a number is never reused.
  • Language: the order's language (English, Dutch, German and French ship), amounts included. An order nobody placed through the checkout, such as one pulled from bol.com, has the website's default language.
  • Contents: seller and buyer (with the buyer's VAT number), the lines excl. VAT with their rate, the voucher and the shipping as their own lines, VAT per rate, and the notes the sale needs ("intra-community supply", paid on). Orders taxed per product are invoiced as they were charged.
  • Credit note: on a refunded order with an invoice, Create credit note issues one that reverses the invoice in full, at the original rates. Like the invoice it is sent with its own Send credit note. The invoice itself stays.
  • Sending: each issued document has PDF, and Send until it has been emailed, Send again after. One not yet emailed shows as "not sent"; so does one whose send failed (no mail-from address, mail server down), ready to retry.
  • bol.com orders are invoiced the same way.
  • The customer finds issued invoices and credit notes on the order page in their account (see customer.md).

The seller's details come from the website's company details, the legal name and VAT number from the VAT profile when the shop has one. Themes restyle the PDF by mirroring invoice/invoice.html.twig; it is rendered by dompdf, so plain tables and inline CSS only (no flexbox, grid or web fonts), and nothing remote is fetched.

Things this module does NOT do

  • No discount codes of its own. Promo codes are a separate, optional module — see discount.md. With it enabled, the code is applied to the cart and frozen onto the order (discount_cents / discount_code / discount_type); without it, checkout charges full price.
  • No multi-shipping-method picker of its own. Without the shipping module, one flat rate per website with one optional waiver.
  • No tax-by-destination of its own. Tax comes from each product's tax class. The VAT engine module adds VAT by destination (Netherlands and Belgium), VAT on shipping and 0% for businesses abroad.
  • No partial refunds. A refund marks the whole order refunded; a credit note reverses the whole invoice.
  • No subscriptions / recurring billing. One-shot orders only.
  • No order editing beyond marking status. You can't change quantities, swap a product, or correct a shipping address after the order is placed (the snapshot is intentionally immutable for accounting integrity).
  • No real-time stock reservation across replicas. Stock is decremented at order-place time using a database-level constraint, but if two near-simultaneous orders for the last unit hit different pods, the second one sees the first's decrement and fails closed — good. Long-lived "hold for 15 minutes while customer pays" reservations exist (StockReservation entity) and are released when the cart expires.