Appearance
Order Confirmation (Checkout View)
Flow ID: CF-07 | Module(s): eshop, Checkout domain | Complexity: Very High Last Updated: 2026-07-21 — 4.119.0 release resync: OrderPaid listener list corrected to 8;
afterOrder*Hooksre-attributed to the front checkout controller
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 order ID + redirect URL |
| 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 |
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 865) |
Code Flow
checkout() (line 865-933)
- Form validation +
recheckCart()— blocks if any item has insufficient stock - Gift packaging:
calculateGiftPackaging()— cost fromGIFT_PACKAGING.COSTregistry - 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 7 (#473) - Payment dispatch:
checkout->run($checkoutData)→ routes to gateway
parseCartForCheckout() (model line 636-725)
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 821-893)
- Atomic transaction (
trans_begin()): insert order row, then setorder_serial(viacreateSerial(), which derives the serial frominsert_id()and so cannot be precomputed before the INSERT), then write the basket — all three inside one manual DB transaction (Advisable-com/ecommercen#473). 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 the freshly-inserted order row
create_order() (model line 288) 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() (line 915-932) then guards 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 serialized for audit | Stored in shop_order.cart_contents |
| 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) |
| 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() (line 811) | Custom assets for confirmation page |
afterOrderSuccessHooks() | ERP sync after payment success. Defined on the front checkout controller this flow dispatches into — ecommercen/checkout/controllers/Adv_checkout.php:3404 — with admin-side siblings on Adv_orders_admin.php:3104 (and on the marketplace/webhook order controllers). |
afterOrderCancelHooks() | Cleanup after payment failure. Defined on the front checkout controller — ecommercen/checkout/controllers/Adv_checkout.php:3409 — with an admin-side sibling on Adv_orders_admin.php:3109. |
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_payment | Klarna payment session data |
shop_order_dhl_voucher | 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:95-108) reloads the freshly-written order row and dispatches OrderPaid:
php
// PaymentConfirmationService.php:95-108
$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,
));
}Offline payment (no redirect): PlaceOrderService::placeOrder() (src/Domains/Checkout/PlaceOrderService.php:315-326) dispatches OrderPaid synchronously when $paymentResult->status === 'PENDING_ACCEPTED' (offline adapters only), with isOffline: true. Stock is reserved unconditionally at :307 before this dispatch, regardless of payway (#282).
Cancellation: PaymentConfirmationService::cancelPayment() (src/Domains/Checkout/PaymentConfirmationService.php:162-168) dispatches OrderCanceled after writing status = CANCELED and restoring stock via StockService::restoreStockForOrder() (:156, #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:148-155 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 (2, wired in declaration order)
Wiring: src/Domains/Order/container.php:156-157 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 |
MarketingConsentCaptured channel
PlaceOrderService::placeOrder() dispatches MarketingConsentCaptured (src/Domains/Checkout/PlaceOrderService.php:252-261) 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 Known Issues for details.MarketingConsentCapturedhas zero listeners.PlaceOrderService.php:252-261dispatches 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.
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