Skip to content

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*Hooks re-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

MethodPathAuthDescription
POST/rest/checkout/place-orderGuest/CustomerCreate order from cart, initialize payment, return order ID + redirect URL
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

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 865)

Code Flow

checkout() (line 865-933)

  1. Form validation + recheckCart() — blocks if any item has insufficient stock
  2. Gift packaging: calculateGiftPackaging() — cost from GIFT_PACKAGING.COST registry
  3. Loyalty points: deductLoyaltyPoints() — removes from customer BEFORE payment
  4. VAT setup: setVatForOrder() — configures by delivery country
  5. Order creation: create_order($orderData)processOrder() → atomic insert to DB (see below)
  6. 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 7 (#473)
  7. 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 paidQty reduced but actual quantity unchanged

processOrder() (model line 821-893)

  1. Atomic transaction (trans_begin()): insert order row, then set order_serial (via createSerial(), which derives the serial from insert_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 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 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

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 serialized for auditStored in shop_order.cart_contents
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)
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() (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

TablePurpose
shop_customerCustomer record (updated on checkout, is_guest=1 for guests)
shop_order_klarna_paymentKlarna payment session data
shop_order_dhl_voucherDHL 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: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', ...).

#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 (2, wired in declaration order)

Wiring: src/Domains/Order/container.php:156-157 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

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

  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 Known Issues for details.

  2. MarketingConsentCaptured has zero listeners. PlaceOrderService.php:252-261 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.


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

Shared Patterns