Appearance
Order Confirmation (Checkout View)
Flow ID: CF-07 | Module(s): eshop, Checkout domain | Complexity: Very High Last Updated: 2026-09-29 — 4.124.0 resync: citation drift corrections (PlaceOrderService, Adv_order, Adv_order_model, Adv_checkout, PaymentConfirmationService); confirm-payment contract updated for #796 and #754
Business Overview
After the preview form (CF-06), the checkout confirmation parses the cart for final totals, validates stock one last time, creates the order record, and dispatches to the payment gateway.
What happens at this step:
- Cart parsed with live pricing, VAT, coupon discount
- Transport and delivery costs calculated based on address/weight
- Loyalty points deducted from customer account (if redeemed)
- Gift items processed (including Rule 13 cheapest-free)
- Order created in PENDING status with serialized cart
- Payment gateway initialized → customer redirected or sees success page
API Reference
Modern REST Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /rest/checkout/place-order | Guest/Customer | Create order from cart, initialize payment, return {orderId, orderSerial, status} plus optional paymentRedirect and optional transactionId (#724) (src/Domains/Checkout/PlaceOrderResult.php:37-58) |
| GET | /rest/checkout/payment-status/{orderId} | Customer | Check payment result after redirect |
| POST | /rest/checkout/confirm-payment/{orderId} | Customer | Verify payment with gateway (Stripe/Viva/PayPal) and confirm via PaymentConfirmationService. Viva requires the gateway transactionId in the request body, else confirmed:false (#796, src/Rest/Checkout/Controllers/Checkout.php:1507-1511, :1797-1816). Responses no longer carry isPaid, and confirmPayment() no longer writes shop_order.is_paid (#754, PaymentConfirmationService.php:58-81, Checkout.php:1361-1387, :1478-1532) |
Browse endpoints interactively in the API Reference.
PlaceOrderService handles the entire confirmation + order creation + payment init in one call. Key supporting services: OrderBasketBuilder (cart-to-basket row transformation with pricing/VAT snapshots), StockService (atomic stock reservation at placement for every payway via reduceStockForOrder(), and restoration on cancellation via restoreStockForOrder() — #282), OrderBasketWriteService (transactional basket row inserts), and CouponCodeWriteRepository (coupon usage tracking). Offline payment adapters (DeliveryAdapter, BankTransferAdapter, PaidAtStoreAdapter) now set order status to PENDING_ACCEPTED (not PENDING). For the full modern flow, see CF-06 Order Preview.
Legacy
| Trigger | Controller | Method |
|---|---|---|
POST /checkout with checkout=1 | Adv_order.php | checkout() (line 927) |
Code Flow
checkout() (line 927-1030)
- Form validation +
recheckCart()— blocks if any item has insufficient stock - Gift packaging:
calculateGiftPackaging()— cost fromGIFT_PACKAGING.COSTregistry - Loyalty points:
refuseLoyaltyRedemptionExceedingTotal()— early return if redeeming points would push total below €0 (ecommercen/eshop/controllers/Adv_order.php:983-985, #573) - Loyalty points:
deductLoyaltyPoints()— removes from customer BEFORE payment - VAT setup:
setVatForOrder()— configures by delivery country - Order creation:
create_order($orderData)→processOrder()→ atomic insert to DB (see below) - Fail-safe guard: if order creation didn't yield a usable
order_serial/id, log + setorder_error+ redirect topreview_order, never reaching step 8 (#473) - Payment dispatch:
checkout->run($checkoutData)→ routes to gateway
parseCartForCheckout() (model line 820-922)
Final total: cart_total_vat + transport + delivery - points_reward + gift_packaging
- Transport cost: 0 for store pickup, calculated via
transfer_cost_admin()otherwise - Delivery cost: only for COD (
payway === 'delivery'), 0 otherwise - Rule 13 gifts: cheapest product's
paidQtyreduced but actualquantityunchanged
processOrder() (model line 1162-1245)
- Atomic transaction (
trans_begin()at:1179): insert order row via a rawINSERT(not bypassing DB-generatedid), callcreateSerial()exactly once to generate the order serial (captured immediately into a local variable before any further mutations because the method is not idempotent — on collision it appends a random character and recurses), then issue anUPDATEto write that serial toorder_serial, then write the basket — all three DB operations inside one manual transaction. Any step failing rolls back andprocessOrder()returnsfalse— no row is ever committed with a NULL/emptyorder_serial. - Get gift product data, choose gift product codes
- Adjust cart for Rule 13 gifts
- Merge gift items into cart
- Insert all items via
order_basket_model->add_records_with_order() trans_commit()and return an object(object) ['id' => $id, 'order_serial' => $orderSerial]— only these two properties — built frominsert_id()and the captured serial result
create_order() (model line 316) calls processOrder() at Adv_order_model.php:466 (guard comment :468-472) and no longer dereferences a failed processOrder() result — if it returns false, create_order() returns the order array with order_serial/id set to null instead of fataling. checkout() then guards (Adv_order.php:1002) empty($checkoutData['order_serial']) || empty($checkoutData['id']): it logs (customer id + payway), sets an order_error session message, and redirects to preview_order instead of proceeding to the payment gateway. Since the transaction guarantees no row is left dangling on rollback, this redirect never leaves an orphaned order behind and the cart stays intact.
Business Rules
| Rule | Description |
|---|---|
| Stock re-checked at submit | recheckCart() blocks if insufficient |
| Transport = 0 for pickup | useAddress == ORDER_ADDRESS_ESHOP |
| Delivery only for COD | payway === 'delivery' |
| Points deducted before payment | removePointsFromCustomer() called pre-order |
| Cart snapshot is in-memory only, never persisted | shop_order.cart_contents is not a real column and never has been — no migration or initial.sql entry defines it, no code path (legacy or REST) has ever written it to the database, and nothing has ever read it back from one. Legacy create_order() builds a cart_contents-shaped array purely to hand to processOrder(), which unset()s it before the order INSERT (Adv_order_model.php:1165) and unserializes the in-memory copy only to build shop_order_basket rows (:1207). REST checkout (PlaceOrderService::placeOrder()) built and wrote an equivalent in-memory snapshot for a time (#111) but that step was removed (#605) — it never had a column to land in either. |
| Order serial auto-generated | createSerial() with configurable prefix |
| Order row + serial committed atomically | processOrder() wraps INSERT + serial UPDATE + basket write in one DB transaction; any failure rolls back with no row persisted (#473) |
createSerial() called exactly once per order | The method is not idempotent — on serial collision it appends a random character and recurses recursively; processOrder() captures the result into a local variable before the serial UPDATE so a second invocation does not undo the persisted serial (:1198 in Adv_order_model.php:1162-1245) |
| Empty/NULL serial never looked up | Adv_order_model::getOrder() rejects an empty/NULL/whitespace order_serial condition and returns null before querying, so CI3 never matches an orphaned row by IS NULL/= '' (#473) |
Client Extension Points
| Hook | Purpose |
|---|---|
checkoutViewExtra() | Custom assets for confirmation page (Adv_order.php:869-872) |
afterOrderSuccessHooks() | ERP sync after payment success. Defined on the front checkout controller this flow dispatches into — ecommercen/checkout/controllers/Adv_checkout.php:3771 — with admin-side siblings on ecommercen/eshop/controllers/Adv_orders_admin.php:3299 (and on the marketplace/webhook order controllers). |
afterOrderCancelHooks() | Cleanup after payment failure. Defined on the front checkout controller — ecommercen/checkout/controllers/Adv_checkout.php:3776 — with an admin-side sibling on ecommercen/eshop/controllers/Adv_orders_admin.php:3304. |
Order_model::processOrder() override | The empty stub application/modules/eshop/models/Order_model.php extends Adv_order_model {} is available for overrides. A fork overriding processOrder() must return an object carrying at minimum the id and order_serial properties — both the legacy create_order() and the admin _proccess_admin_order() (ecommercen/eshop/models/Adv_order_model.php:1156) callers read only these two; returning additional properties is allowed but read-through them is not guaranteed. |
Data Model
For the full shop_order column schema (60+ columns), see AD-03 Order Management.
For the full shop_order_basket column schema, see AD-03 Order Management.
Other tables involved
| Table | Purpose |
|---|---|
shop_customer | Customer record (updated on checkout, is_guest=1 for guests) |
shop_order_klarna_payments | Klarna payment session data |
shop_order_dhl_vouchers | DHL external rate selection |
shop_prices_view — The only database VIEW in the system
shop_prices_view is the only database view in the entire application. It pre-computes product pricing with VAT and discount logic, eliminating repetitive price calculations across multiple modules.
Price formulas:
sql
original_price = price + (VAT% / 100 * price)
final_price = CASE
WHEN special_discount IS active THEN discounted_price
WHEN regular_discount > 0 THEN discounted_price
ELSE original_price
ENDUsage across the platform:
- Product browsing (CF-01) — storefront price display and sorting
- Feeds (IN-01) — marketplace feed price output
- Admin sorting — admin product listing ordered by computed price
Legacy naming note: The discount percentage column is named
discount_persent(notdiscount_percent) — this is a legacy typo preserved for backward compatibility. All code referencing this column uses the misspelled name.
VAT fragility: The view uses
INNER JOIN shop_product_vats— any product whose VAT rate row is deleted silently disappears from all view-based queries. For the canonicalshop_product_vatsschema,INNER JOINfragility details, and VAT rate rules, see AD-50 VAT Management.
Modern REST Path — Order Event Bus
After an order is confirmed on the REST path, side-effects (email, ERP, analytics, loyalty) are delivered through OrderEventDispatcher — a synchronous fanout bus that isolates listener failures with a per-listener try/catch and logs a warning on failure. No single listener can block or abort the others.
Trigger points
Online payment (gateway redirect): PaymentConfirmationService::confirmPayment() (src/Domains/Checkout/PaymentConfirmationService.php:114-127) reloads the freshly-written order row and dispatches OrderPaid:
php
// PaymentConfirmationService.php:114-127
$fresh = $this->orderService->get($orderId);
if ($fresh !== null) {
$this->orderEventDispatcher->dispatchPaid(new OrderPaid(
orderId: (int) $fresh->id,
customerId: (int) (isset($fresh->customer_id) ? $fresh->customer_id : 0),
orderSerial: (string) (isset($fresh->order_serial) ? $fresh->order_serial : ''),
payway: (string) (isset($fresh->payway) ? $fresh->payway : ''),
total: (float) (isset($fresh->total_vat) ? $fresh->total_vat : 0.0),
currencyCode: (string) (isset($fresh->order_currency) ? $fresh->order_currency : ''),
isOffline: false,
transactionId: $options['transactionId'] ?? null,
));
}Loyalty redemption guard: PlaceOrderService::placeOrder() raises LoyaltyRedemptionExceedsOrderTotalException (422 REST response with code loyalty_redemption_exceeds_order_total) if redeeming loyalty points would result in a negative total (src/Domains/Checkout/PlaceOrderService.php:459-464, #573). The order is never created.
Offline payment (no redirect): PlaceOrderService::placeOrder() dispatches OrderPaid synchronously when $paymentResult->status === 'PENDING_ACCEPTED' (offline adapters only), with isOffline: true. Stock is reserved unconditionally at PlaceOrderService.php:770 before this dispatch, regardless of payway (#282).
Cancellation: PaymentConfirmationService::cancelPayment() (src/Domains/Checkout/PaymentConfirmationService.php:181-187) dispatches OrderCanceled after writing status = CANCELED and restoring stock via StockService::restoreStockForOrder() (:175, #282).
Dispatcher
src/Domains/Order/Event/OrderEventDispatcher.php — synchronous fanout; each listener runs inside try { ... } catch (\Throwable $e). Failures are logged via $this->logger->warning(...) and do not propagate to callers or sibling listeners.
OrderPaid listeners (8, wired in declaration order)
Wiring: src/Domains/Order/container.php:158-165 via ->call('addPaidListener', ...).
| # | Listener | Deferred? | Role |
|---|---|---|---|
| 1 | SendOrderConfirmationEmailListener | No (inline) | Calls legacy Adv_mailer::order_complete() to send the customer confirmation email |
| 2 | FireErpWebhookListener | Yes — PRIORITY_CRITICAL | Fires InternalApiOrderForErpHook outbound HTTP POST to ERP (Singular/SoftOne). No-op when internalApi.apiOrderWebHooks.erpReady is not configured |
| 3 | CheckLowStockListener | No (inline) | Emails admin when any basket product hits or drops below the low-stock threshold. Gated by Registry EMAIL.NOTIFICATION_LOW_STOCK |
| 4 | DecrementGiftStockOnPaidListener | No (inline) | Decrements gifts.remaining per gift_id × qty on the paid order (Advisable-com/ecommercen#85) |
| 5 | TrackMatomoOrderListener | Yes — PRIORITY_LOW | Tracks ecommerce order in Matomo. Gated by Registry MATOMO.ENABLED + credentials; production-only (ENVIRONMENT=production) |
| 6 | DispatchMetaCapiPurchaseListener | Yes — PRIORITY_CRITICAL | Sends purchase event to Meta Conversions API. Gated by Registry FACEBOOK_CONVERSION.ENABLED + pixel credentials |
| 7 | DispatchManagoOrderListener | Yes — PRIORITY_CRITICAL | Posts PURCHASE contactExtEvent to Manago CRM. Gated by Registry MANAGO.ENABLE_API + MANAGO.ENABLE_PURCHASE_REPORT. Defaults forceOptOut: true on the REST path (no session consent state) |
| 8 | DispatchProjectAgoraOrderListener | Yes — PRIORITY_CRITICAL | Reports order to Project Agora ad-attribution service. Gated by Registry AGORA.IS_ENABLED. REST orders dispatch with $adds = [] (no session ad-impression map), so Agora silently produces a no-op payload |
OrderCanceled listeners (3, wired in declaration order)
Wiring: src/Domains/Order/container.php:166-168 via ->call('addCanceledListener', ...).
| # | Listener | Role |
|---|---|---|
| 1 | RestoreSpentPointsListener | Refunds points_spend to shop_customer.total_points when the canceled order had redeemed loyalty points |
| 2 | RestoreCouponUsageListener | Decrements coupons.is_used for the coupon applied to the canceled order, restoring one redemption slot |
| 3 | RestoreGiftStockOnCanceledListener | Restores gifts.remaining for the canceled order's redeemed gifts, gated by a claimGiftsApplied() compare-and-swap on shop_order.gifts_applied for at-most-once restore (src/Domains/Order/Event/Listeners/RestoreGiftStockOnCanceledListener.php:52-102, #688) |
MarketingConsentCaptured channel
PlaceOrderService::placeOrder() dispatches MarketingConsentCaptured (src/Domains/Checkout/PlaceOrderService.php:635-649) when PlaceOrderData::$marketingConsent is true and a non-empty customer email is present. Zero listeners are wired (src/Domains/Order/container.php registers no addMarketingConsentListener call). The channel is open for future CRM-sync subscribers; the absence of listeners is intentional for the initial shipping state. See Known Issues item 2 below.
DeferredTaskRunner integration
Slow side-effects (ERP, Matomo, Meta CAPI, Manago, Project Agora) wrap their work in DeferredTaskRunner::defer() before returning from the listener. The POST response is returned to the client before deferred tasks execute. See SY-27 Deferred Tasks for execution semantics and priority ordering.
Known Issues & Security Gaps
Modern vs legacy status divergence for offline payments. The modern REST
PlaceOrderServicesets offline payment orders toPENDING_ACCEPTED(via adapters), while the legacycheckout()sets them toPENDING. See CF-06 Order Preview for details.MarketingConsentCapturedhas zero listeners.PlaceOrderService.php:635-649dispatches the event when a customer opts in at checkout, butsrc/Domains/Order/container.phpwires noaddMarketingConsentListenercalls. Consent signals captured via REST are silently dropped — no CRM sync (ManagosaveContact, Moosend, Mailchimp) fires. Intended for a follow-up CRM-sync integration.Project Agora ad-attribution is always empty on the REST path.
DispatchProjectAgoraOrderListenerdispatches with$adds = []because the REST path has no session ad-impression map (src/Domains/Order/Event/Listeners/DispatchProjectAgoraOrderListener.php:78). Orders placed via the REST API never carry ad attribution to Agora. Tracked as a follow-up requiringadImpressionsonPlaceOrderData.Manago consent defaults to
forceOptOut: trueon the REST path.DispatchManagoOrderListenercannot readcustomer_in_managosession state (REST is stateless), so every REST-placed order is reported with opt-out posture (src/Domains/Order/Event/Listeners/DispatchManagoOrderListener.php:28-35). Customers who had opted in through the storefront lose their opt-in attribution for REST orders untilcustomerOptedInToManagois added toPlaceOrderData.Loyalty-point award and coupon consumption run outside
processOrder()transaction. Points are deducted viadeductLoyaltyPoints()(ecommercen/eshop/controllers/Adv_order.php:1105) which calls$this->loyalty->removePointsFromCustomer()(:1113) before theprocessOrder()call. Coupon usage is incremented viaAdv_coupons_model::markCouponUsed()(ecommercen/coupons/models/Adv_coupons_model.php:1046-1049), called fromAdv_order_model::setUpAdminOrderDataCoupon()(Adv_order_model.php:625), invoked bycreate_order()(call at :324;create_order()starts :316) — both beforetrans_begin()(:1179). If the order transaction rolls back after these side-effects execute, the customer's points remain spent and the coupon redemption count remains decremented against an order that does not exist. Deferred with org-admin approval — see issue #527.Three legacy payment handlers re-read the just-created order unguarded. In
ecommercen/checkout/controllers/Adv_checkout.php, the methodsjcc(),iris(), andklarnaPayments()fetch the order by serial after the commit and dereference properties without null-checking. While this is not an ordering problem in the happy path (the order was successfully created), a future post-commit read failure would surface here. See CF-08 Payment Processing for the canonical documentation of this risk.REST order serial format diverges from legacy (
ordersPrefixnot applied). The modernPlaceOrderServicebuilds the order serial asstr_pad((string) $order->id, 6, '0', STR_PAD_LEFT)(src/Domains/Checkout/PlaceOrderService.php:584-585), bypassing both theordersPrefixconfig (application/config/app.php:282) and the collision retry logic (ecommercen/eshop/models/Adv_order_model.php:947-950). A shop with a configured prefix therefore emits two incompatible serial formats: legacy orders receive the prefixed serial (e.g.,PRE-000042), while REST orders receive only the zero-padded ID (e.g.,000042). This creates reporting and customer-communication inconsistencies.
Tests
| Test File | Coverage |
|---|---|
tests/Integration/Checkout/PlaceOrderServiceTest.php | Modern PlaceOrderService — order creation, stock reservation, listener dispatch, offline payment status |
tests/Legacy/Checkout/CheckoutControllerTest.php | Legacy checkout() flow — cart re-validation, loyalty point deduction, order creation, serial generation |
tests/Unit/Order/Event/OrderEventDispatcherTest.php | Order event bus — listener fanout, individual listener failures do not block siblings |
tests/Integration/Loyalty/LoyaltyRedemptionTest.php | Loyalty gate — redemption refusing when order total would become negative (#573) |
tests/Integration/Checkout/StockReservationTest.php | Stock atomicity — stock reserved on all payment types (online + offline), restored on cancellation (#282) |
Coverage gaps: offline payment adapter status transitions (legacy only); gift packaging rule application during order creation; per-listener exception handling under load.
Related Flows
- CF-05 Cart Management — cart data parsed for final totals
- CF-06 Order Preview — full
PlaceOrderService13-step flow; whereOrderPaidoriginates for offline adapters - CF-08 Payment Processing — receives created order
- CF-09 Payment Webhooks — async payment callbacks;
PaymentConfirmationServicecalled from webhook handlers to dispatchOrderPaid - CF-13 Coupons — coupon discount in final totals;
RestoreCouponUsageListenerrestores usage on cancel - CF-14 Gift Rules — Rule 13 adjustments
- CF-32 Loyalty Points — points deduction at place-order;
RestoreSpentPointsListenerrefunds on cancel - SY-02 Order Status Emails — confirmation email after payment
- SY-27 Deferred Tasks — ERP, Matomo, Meta CAPI, Manago, Project Agora listeners defer slow work post-response
- AD-03 Order Management — admin order lifecycle
- AD-50 VAT Management — VAT rate catalog and
shop_prices_viewfragility rules
Wiki Guides: Payment adapter setup — see Stripe Guide. Deferred ERP sync after success — see Deferred Task Guide.
Shared Patterns
- SY-24 Email Dispatch — order confirmation emails dispatched after successful payment
- SY-27 Deferred Tasks — ERP sync and post-order hooks run as deferred tasks
- SY-26 Circuit Breaker — protects external payment gateway calls from cascading failures