Skip to content

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 ​

MethodPathAuthDescription
POST/rest/checkout/place-orderGuest/CustomerCreate 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}CustomerCheck payment result after redirect
POST/rest/checkout/confirm-payment/{orderId}CustomerVerify 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 ​

TriggerControllerMethod
POST /checkout with checkout=1Adv_order.phpcheckout() (line 927)

Code Flow ​

checkout() (line 927-1030) ​

  1. Form validation + recheckCart() — blocks if any item has insufficient stock
  2. Gift packaging: calculateGiftPackaging() — cost from GIFT_PACKAGING.COST registry
  3. Loyalty points: refuseLoyaltyRedemptionExceedingTotal() — early return if redeeming points would push total below €0 (ecommercen/eshop/controllers/Adv_order.php:983-985, #573)
  4. Loyalty points: deductLoyaltyPoints() — removes from customer BEFORE payment
  5. VAT setup: setVatForOrder() — configures by delivery country
  6. Order creation: create_order($orderData) → processOrder() → atomic insert to DB (see below)
  7. Fail-safe guard: if order creation didn't yield a usable order_serial/id, log + set order_error + redirect to preview_order, never reaching step 8 (#473)
  8. 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 paidQty reduced but actual quantity unchanged

processOrder() (model line 1162-1245) ​

  1. Atomic transaction (trans_begin() at :1179): insert order row via a raw INSERT (not bypassing DB-generated id), call createSerial() 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 an UPDATE to write that serial to order_serial, then write the basket — all three DB operations inside one manual transaction. Any step failing rolls back and processOrder() returns false — no row is ever committed with a NULL/empty order_serial.
  2. Get gift product data, choose gift product codes
  3. Adjust cart for Rule 13 gifts
  4. Merge gift items into cart
  5. Insert all items via order_basket_model->add_records_with_order()
  6. trans_commit() and return an object (object) ['id' => $id, 'order_serial' => $orderSerial] — only these two properties — built from insert_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 ​

RuleDescription
Stock re-checked at submitrecheckCart() blocks if insufficient
Transport = 0 for pickupuseAddress == ORDER_ADDRESS_ESHOP
Delivery only for CODpayway === 'delivery'
Points deducted before paymentremovePointsFromCustomer() called pre-order
Cart snapshot is in-memory only, never persistedshop_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-generatedcreateSerial() with configurable prefix
Order row + serial committed atomicallyprocessOrder() wraps INSERT + serial UPDATE + basket write in one DB transaction; any failure rolls back with no row persisted (#473)
createSerial() called exactly once per orderThe 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 upAdv_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 ​

HookPurpose
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() overrideThe 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 ​

TablePurpose
shop_customerCustomer record (updated on checkout, is_guest=1 for guests)
shop_order_klarna_paymentsKlarna payment session data
shop_order_dhl_vouchersDHL 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
END

Usage 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 (not discount_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 canonical shop_product_vats schema, INNER JOIN fragility 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', ...).

#ListenerDeferred?Role
1SendOrderConfirmationEmailListenerNo (inline)Calls legacy Adv_mailer::order_complete() to send the customer confirmation email
2FireErpWebhookListenerYes — PRIORITY_CRITICALFires InternalApiOrderForErpHook outbound HTTP POST to ERP (Singular/SoftOne). No-op when internalApi.apiOrderWebHooks.erpReady is not configured
3CheckLowStockListenerNo (inline)Emails admin when any basket product hits or drops below the low-stock threshold. Gated by Registry EMAIL.NOTIFICATION_LOW_STOCK
4DecrementGiftStockOnPaidListenerNo (inline)Decrements gifts.remaining per gift_id × qty on the paid order (Advisable-com/ecommercen#85)
5TrackMatomoOrderListenerYes — PRIORITY_LOWTracks ecommerce order in Matomo. Gated by Registry MATOMO.ENABLED + credentials; production-only (ENVIRONMENT=production)
6DispatchMetaCapiPurchaseListenerYes — PRIORITY_CRITICALSends purchase event to Meta Conversions API. Gated by Registry FACEBOOK_CONVERSION.ENABLED + pixel credentials
7DispatchManagoOrderListenerYes — PRIORITY_CRITICALPosts 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)
8DispatchProjectAgoraOrderListenerYes — PRIORITY_CRITICALReports 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', ...).

#ListenerRole
1RestoreSpentPointsListenerRefunds points_spend to shop_customer.total_points when the canceled order had redeemed loyalty points
2RestoreCouponUsageListenerDecrements coupons.is_used for the coupon applied to the canceled order, restoring one redemption slot
3RestoreGiftStockOnCanceledListenerRestores 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 ​

  1. Modern vs legacy status divergence for offline payments. The modern REST PlaceOrderService sets offline payment orders to PENDING_ACCEPTED (via adapters), while the legacy checkout() sets them to PENDING. See CF-06 Order Preview for details.

  2. MarketingConsentCaptured has zero listeners. PlaceOrderService.php:635-649 dispatches the event when a customer opts in at checkout, but src/Domains/Order/container.php wires no addMarketingConsentListener calls. Consent signals captured via REST are silently dropped — no CRM sync (Manago saveContact, Moosend, Mailchimp) fires. Intended for a follow-up CRM-sync integration.

  3. Project Agora ad-attribution is always empty on the REST path. DispatchProjectAgoraOrderListener dispatches 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 requiring adImpressions on PlaceOrderData.

  4. Manago consent defaults to forceOptOut: true on the REST path. DispatchManagoOrderListener cannot read customer_in_manago session 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 until customerOptedInToManago is added to PlaceOrderData.

  5. Loyalty-point award and coupon consumption run outside processOrder() transaction. Points are deducted via deductLoyaltyPoints() (ecommercen/eshop/controllers/Adv_order.php:1105) which calls $this->loyalty->removePointsFromCustomer() (:1113) before the processOrder() call. Coupon usage is incremented via Adv_coupons_model::markCouponUsed() (ecommercen/coupons/models/Adv_coupons_model.php:1046-1049), called from Adv_order_model::setUpAdminOrderDataCoupon() (Adv_order_model.php:625), invoked by create_order() (call at :324; create_order() starts :316) — both before trans_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.

  6. Three legacy payment handlers re-read the just-created order unguarded. In ecommercen/checkout/controllers/Adv_checkout.php, the methods jcc(), iris(), and klarnaPayments() 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.

  7. REST order serial format diverges from legacy (ordersPrefix not applied). The modern PlaceOrderService builds the order serial as str_pad((string) $order->id, 6, '0', STR_PAD_LEFT) (src/Domains/Checkout/PlaceOrderService.php:584-585), bypassing both the ordersPrefix config (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 FileCoverage
tests/Integration/Checkout/PlaceOrderServiceTest.phpModern PlaceOrderService — order creation, stock reservation, listener dispatch, offline payment status
tests/Legacy/Checkout/CheckoutControllerTest.phpLegacy checkout() flow — cart re-validation, loyalty point deduction, order creation, serial generation
tests/Unit/Order/Event/OrderEventDispatcherTest.phpOrder event bus — listener fanout, individual listener failures do not block siblings
tests/Integration/Loyalty/LoyaltyRedemptionTest.phpLoyalty gate — redemption refusing when order total would become negative (#573)
tests/Integration/Checkout/StockReservationTest.phpStock 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.


Wiki Guides: Payment adapter setup — see Stripe Guide. Deferred ERP sync after success — see Deferred Task Guide.

Shared Patterns ​