Skip to content

Order Confirmation (Checkout View)

Flow ID: CF-07 | Module(s): eshop, Checkout domain | Complexity: Very High Last Updated: 2026-05-29

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() → insert to DB
  6. 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 801-843)

  1. Insert order to DB, generate serial via createSerial()
  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()

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

Client Extension Points

HookPurpose
checkoutViewExtra() (line 811)Custom assets for confirmation page
afterOrderSuccessHooks()ERP sync after payment success. Note: This is an admin-side hook defined in Adv_orders_admin.php, not a front-checkout hook.
afterOrderCancelHooks()Cleanup after payment failure. Note: This is an admin-side hook defined in Adv_orders_admin.php, not a front-checkout hook.

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

Wiring: src/Domains/Order/container.php:125-132 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
4TrackMatomoOrderListenerYes — PRIORITY_LOWTracks ecommerce order in Matomo. Gated by Registry MATOMO.ENABLED + credentials; production-only (ENVIRONMENT=production)
5DispatchMetaCapiPurchaseListenerYes — PRIORITY_CRITICALSends purchase event to Meta Conversions API. Gated by Registry FACEBOOK_CONVERSION.ENABLED + pixel credentials
6DispatchManagoOrderListenerYes — 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)
7DispatchProjectAgoraOrderListenerYes — 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:133-134 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