Skip to content

Order Preview (Checkout Form)

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

Business Context

The checkout form is where customers enter billing/shipping addresses, select payment and shipping methods, apply coupons, choose gifts, and optionally register. It's the most complex controller method in the codebase (~220 lines).

Ecommercen has two parallel checkout implementations:

  1. Modern REST Checkoutsrc/Rest/Checkout/Controllers/Checkout.php with PlaceOrderService
  2. Legacy Checkoutecommercen/eshop/controllers/Adv_order.php::preview()

What customers do at this step:

  • Enter billing address (name, address, city, postal, county, country, phone)
  • Optionally enter separate shipping address or select store pickup
  • Choose payment method (18 options) and shipping transporter
  • Apply coupon codes
  • Select gift products (if eligible)
  • For invoice: provide AFM, DOY, company, profession
  • Register or checkout as guest
  • Subscribe to newsletters (Manago, Moosend, Apifon, Mailchimp)

API Reference

Modern REST Endpoints

MethodPathAuthDescription
POST/rest/checkout/shippingGuest/CustomerCalculate available shipping options
POST/rest/checkout/coupon/validateGuest/CustomerValidate coupon against cart
POST/rest/checkout/totalsGuest/CustomerCalculate full totals (items + shipping + coupon)
POST/rest/checkout/place-orderGuest/CustomerPlace order from cart
GET/rest/checkout/payment-methodsGuestList active payment gateways for the current store (rest_routes.php:1709; controller Checkout.php:464)
GET/rest/checkout/payment-status/{orderId}CustomerCheck payment status
POST/rest/checkout/confirm-payment/{orderId}CustomerVerify payment with gateway and confirm
POST/rest/checkout/cancel-payment/{orderId}CustomerCancel an awaiting-gateway order; ownership-checked; dispatches OrderCanceled (rest_routes.php:1713; controller Checkout.php:495-547)

Browse endpoints interactively in the API Reference.

Legacy Storefront

URLControllerMethod
/preview_orderAdv_order.php (eshop/order/preview, routes.php:308-309)preview()

Code Flow (Modern REST)

File: src/Rest/Checkout/Controllers/Checkout.php (656 lines)

PlaceOrderService (src/Domains/Checkout/PlaceOrderService.php) handles:

  1. Resolve guest customer (create if needed)
  2. Validate cart
  3. Load cart items
  4. Calculate totals with coupon
  5. Validate shipping selection
  6. Resolve order currency via resolveOrderCurrency() — looks up the cart's currency_code against CurrencyRepository using matchOne(new Filter('code', $cartCurrencyCode)); falls back to the first active currency (Filter('active', 1)) if the cart code is absent or unmatched; throws RuntimeException if no active currency is configured at all. The resolved CurrencyEntity populates order_currency, currency_id, and currency_rate on the order data. Citation: src/Domains/Checkout/PlaceOrderService.php (resolveOrderCurrency())
  7. Build the cart_contents snapshot via buildCartContentsSnapshot() — serializes the legacy-shaped array that getCartFromUnserializedContents() (ecommercen/helpers/eshop_helper.php:94) expects. Each row carries: productCodeId, vat_rate_captured, price, discount_price, discount_string, original_price, gift_id, points, quantity, subtotal, plus an options sub-array. The result is stored in shop_order.cart_contents via serialize(). Citation: src/Domains/Checkout/PlaceOrderService.php (buildCartContentsSnapshot())
  8. Create order in database (with order_currency, currency_id, currency_rate, and cart_contents all populated) (src/Domains/Checkout/PlaceOrderService.php:198)
  9. Generate and assign order serial
  10. Write basket rows via OrderBasketWriteService (createForOrder()) — rows were already built by OrderBasketBuilder earlier (~step 7b) (src/Domains/Checkout/PlaceOrderService.php:209)
  11. Persist carrier-specific transporter data via CarrierDataDispatcher (see Carrier Data Persistence below)
  12. Mark coupon as used via CouponCodeWriteRepository::incrementUsed()
  13. Clear the cart
  14. Initialize payment via PaymentInitializerFactory
  15. Reserve stock at placement for every payway — StockService::reduceStockForOrder() is called unconditionally (src/Domains/Checkout/PlaceOrderService.php:307, #282). The previous per-payway gate (deferring reduction for online/redirect adapters to PaymentConfirmationService::confirmPayment()) has been removed. OrderPaid is dispatched immediately only when $paymentResult->status === 'PENDING_ACCEPTED' (offline payways: delivery, bank_transfer, paid_at_store); online/redirect payways and PayByBank (both PENDING) dispatch OrderPaid from confirmPayment() once the gateway confirms (PlaceOrderService.php:315-326). Abandoned PENDING orders are restored by the AdvCancelIncompleteOrders cron; explicit cancellation calls cancelPayment() which restores stock via StockService::restoreStockForOrder().
  16. Dispatch MarketingConsentCaptured event if marketingConsent is true and a customer email exists (src/Domains/Checkout/PlaceOrderService.php:249-263)
  17. Return order ID + optional redirect URL

The confirmPayment endpoint (POST /rest/checkout/confirm-payment/{orderId}) verifies payment status with the gateway (Stripe, VivaWallet, or PayPal) and then calls PaymentConfirmationService::confirmPayment() to finalize the order and dispatch OrderPaid via OrderEventDispatcher (src/Domains/Checkout/PaymentConfirmationService.php:95-108). This service is idempotent -- calling it on an already-paid order returns true without side effects.

Gateway scope for confirm-payment: The internal verifyWithGateway() helper (src/Rest/Checkout/Controllers/Checkout.php:552-579) only handles stripe, vivawallet, and paypaladvanced. Requesting confirmation for any other payway returns false — the endpoint silently no-ops for unsupported gateways.

Stripe stores a Checkout Session ID in tran_ticket: For Stripe, tran_ticket holds the Checkout Session ID (not a Payment Intent ID). The confirm-payment endpoint works exclusively with the Stripe Checkout Sessions flow (src/Rest/Checkout/Controllers/Checkout.php:581-600).

createForOrder() transactional semantics: OrderBasketWriteService::createForOrder() wraps basket-row inserts in a DB transaction via writeRepository->transactional() (src/Domains/Order/OrderBasket/WriteService.php:55-64). However, the shop_order record itself is created outside this transaction (src/Domains/Checkout/PlaceOrderService.php:198). A failure inside the transaction leaves the shop_order row in place with zero basket rows — the order is orphaned with no items.

cancelPayment() method: PaymentConfirmationService::cancelPayment(int $orderId, string $metaData = '') (src/Domains/Checkout/PaymentConfirmationService.php:120-172) is the inverse counterpart to confirmPayment(). It is idempotent — an already-CANCELED order returns true. The method transitions orders from PENDINGCANCELED, sets canceled_date, restores stock via StockService::restoreStockForOrder() (:156, #282 — stock was reserved at placement for every payway), and dispatches the OrderCanceled event (src/Domains/Checkout/PaymentConfirmationService.php:162-168). Exposed via POST /rest/checkout/cancel-payment/{orderId} — see CF-09 Payment Webhooks for the webhook-driven caller context.

Order Event Bus

PaymentConfirmationService dispatches OrderPaid (src/Domains/Checkout/PaymentConfirmationService.php:95-108) after writing PAID status. For offline adapters (delivery, bank_transfer, paid_at_store), PlaceOrderService dispatches it directly when $paymentResult->status === 'PENDING_ACCEPTED' (PlaceOrderService.php:315-326). On cancellation, OrderCanceled is dispatched (PaymentConfirmationService.php:162-168).

OrderEventDispatcher fans out synchronously to 7 OrderPaid listeners (confirmation email, ERP, low-stock, Matomo, Meta CAPI, Manago, Project Agora) and 2 OrderCanceled listeners (loyalty restore, coupon restore). MarketingConsentCaptured fires (PlaceOrderService.php:252-262) but has no wired listeners yet (intended for future CRM-sync).

See CF-07 Order Confirmation — Modern REST Path for the full listener chain with file:line citations.

Payment Adapter Pattern

19 modern adapter registrations (18 distinct classes — see CF-08 Payment Processing for the full table). As of #282, stock is reserved at placement for all adapters unconditionally (src/Domains/Checkout/PlaceOrderService.php:307). The former per-adapter gate that deferred reduction for adapters with supportsRedirect() === true has been removed. OrderPaid is dispatched at placement only for offline adapters that return PENDING_ACCEPTED (delivery, bank_transfer, paid_at_store); online adapters dispatch it later from PaymentConfirmationService::confirmPayment() after gateway verification (PlaceOrderService.php:315-326).


Code Flow (Legacy)

File: ecommercen/eshop/controllers/Adv_order.php::preview() (line 182-399)

  1. Cart recheck: recheckOrder() validates stock
  2. VAT init: setVatForOrder() based on delivery country (non-EU = 0% VAT)
  3. Customer login (optional): Existing customer can log in mid-checkout
  4. Form validation: previewValidation() — billing, shipping, payment, transport, coupon
  5. Customer create/update: Guest gets random password + is_guest=1
  6. Gift selection: User-selected gifts from eligible rules
  7. Order data assembly: Merge all POST fields + address routing + invoice + packaging
  8. Newsletter signups: Manago, Moosend, Apifon, Mailchimp (consent-based)
  9. Transition: checkoutView() → final totals + payment form

Address Modes

ValueConstantBehavior
'0'ORDER_ADDRESS_SHIPPINGSeparate billing + shipping
'1'ORDER_ADDRESS_BILLINGBilling copied to shipping
'2'ORDER_ADDRESS_ESHOPStore pickup (shipping empty)

VAT by Country

Non-EU delivery = VAT removed entirely (0%). EU = full VAT. Controlled by VatForOrder singleton. For the full AdvVatForOrder mechanics, area enums, dead-code branches, and the enableOrderVatManipulation flag, see AD-50 VAT Management.


Domain Layer

Modern Domain (src/Domains/...)

New Checkout domain services (added in 4.99.6):

ServiceFileResponsibility
OrderBasketBuildersrc/Domains/Checkout/OrderBasketBuilder.phpTransforms cart items into order basket rows with resolved pricing, VAT, discounts, and loyalty points
StockServicesrc/Domains/Checkout/StockService.phpAtomic stock reduction/restoration using raw SQL expressions (stock - qty) to avoid race conditions
PaymentConfirmationServicesrc/Domains/Checkout/PaymentConfirmationService.phpIdempotent payment confirmation — updates order to PAID, does NOT reduce stock (reserved at placement by PlaceOrderService for all payways, #282); dispatches OrderPaid; handles cancellation with stock restoration via StockService::restoreStockForOrder() and dispatches OrderCanceled
OrderBasketWriteServicesrc/Domains/Order/OrderBasket/WriteService.phpWrites basket rows for an order in a single transaction via createForOrder()
StockService::reduceStockForOrder()src/Domains/Checkout/StockService.php:27-50Reads shop_order_basket, decrements product_codes.stock by qty per row in a DB transaction.
StockService::restoreStockForOrder()src/Domains/Checkout/StockService.php:58-81Reads shop_order_basket, increments product_codes.stock by qty per row in a DB transaction. Inverse of reduceStockForOrder().
OrderBasket\WriteDatasrc/Domains/Order/OrderBasket/WriteData.phpDTO mapping 15 shop_order_basket columns. fromArray() at :26-45; toArray() at :47-72. 73 lines.
OrderBasket\Repository\WriteRepositorysrc/Domains/Order/OrderBasket/Repository/WriteRepository.phpWrite repository targeting shop_order_basket (11 lines).
CarrierDataDispatchersrc/Domains/Checkout/Carrier/CarrierDataDispatcher.phpRoutes transporter-specific payload from PlaceOrderData::$transporterExternalData to the matching persister after order creation
OrderEventDispatchersrc/Domains/Order/Event/OrderEventDispatcher.phpSynchronous fan-out to OrderPaid / OrderCanceled listeners; each listener wrapped in try/catch

Carrier Data Persistence

After PlaceOrderService::placeOrder() creates the order and basket rows, it calls CarrierDataDispatcher::dispatchForOrder($orderId, $transporterId, $data->transporterExternalData) (src/Domains/Checkout/PlaceOrderService.php:218-222). The dispatcher (src/Domains/Checkout/Carrier/CarrierDataDispatcher.php:36-56) routes the transporterExternalData blob from PlaceOrderData::$transporterExternalData (:41) to the first matching persister by transporter class_name. Permissive: no-ops on missing data or unmatched transporter.

Three persisters (src/Domains/Checkout/Carrier/Persisters/):

  • DhlVoucherPersisterclass_name='DHL', gated on eshop_calculated_cost == 0; writes shop_order_dhl_vouchers via DhlVoucher\WriteService
  • AsapDataPersisterclass_name='ASAP', gated on eshop_calculated_cost == 0; writes shop_order_asap_data via AsapData\WriteService
  • SmartPointPersisterclass_name ∈ smartPointsDataManage config keys; no cost gate; writes shop_order_smart_point via SmartPoint\WriteService

PlaceOrderData::$marketingConsent (src/Domains/Checkout/PlaceOrderData.php:63), parsed from marketingConsent/marketing_consent via FILTER_VALIDATE_BOOLEAN (:111-114). When true and a customer email exists, PlaceOrderService.php:249-263 dispatches MarketingConsentCaptured event (with ipAddress) before the payment redirect — mirrors legacy Adv_order::previewOrder() ordering (#269, commit 52ece123d).

Loyalty-Points Redemption at Checkout

PlaceOrderData::$pointsSpend (src/Domains/Checkout/PlaceOrderData.php:30), validated by resolvePointsSpend() (src/Domains/Checkout/PlaceOrderService.php:439-465 — throws on insufficient balance, silent no-op for guests). Subtracted from total at :136, written to shop_order.points_spend (:165), debited via debitCustomerPoints() at :234-236. Restoration on cancel handled by RestoreSpentPointsListener (#84, commit 3597ad539).

REST Layer (src/Rest/...)

FileResponsibility
src/Rest/Checkout/Controllers/Checkout.phpREST checkout controller (656 lines): exposes /rest/checkout/shipping, /rest/checkout/coupon/validate, /rest/checkout/totals, /rest/checkout/place-order, /rest/checkout/payment-methods, /rest/checkout/payment-status/{orderId}, /rest/checkout/confirm-payment/{orderId}, and /rest/checkout/cancel-payment/{orderId}.

Legacy Layer (ecommercen/...)

FileResponsibility
ecommercen/eshop/controllers/Adv_order.phpLegacy checkout controller. preview() (lines 182–399) orchestrates cart recheck, VAT init, form validation, guest customer creation, gift selection, newsletter signups, and handoff to checkoutView().

Configuration

Important: All REST checkout endpoints (and all /rest/* routes) require the APP_REST_API_ENABLED=true environment variable. When false (default), rest_routes.php is not loaded and all REST endpoints return 404. See REST API Modules guide for details.

KeyPurpose
enableOrderVatManipulationMaster switch for address-based VAT (see AD-50 VAT Management for default and implications)
GIFT_PACKAGING.ENABLED / COSTGift wrapping option
minCartTotalAmountForPaidAtStoreMinimum for store pickup payment
APP_REST_API_ENABLEDEnvironment variable — must be true to load rest_routes.php; all /rest/* endpoints return 404 when false
EMAIL.NOTIFICATION_LOW_STOCKGates low-stock admin email on order paid
MATOMO.ENABLEDGates deferred Matomo ecommerce tracking on order paid
FACEBOOK_CONVERSION.ENABLEDGates deferred Meta CAPI Purchase event on order paid
MANAGO.ENABLE_API / MANAGO.ENABLE_PURCHASE_REPORTGates deferred Manago PURCHASE report on order paid

Client Extension Points

Legacy Hooks

HookPurpose
previewExtraPostData() (line 401)Add extra POST fields to order data
previewExtras() (line 439)Custom assets (maps API, no-cache headers)
previewValidation() (line 495)Override form validation rules

Override PlaceOrderService, PaymentInitializerFactory, OrderBasketBuilder, StockService, PaymentConfirmationService, or CarrierDataDispatcher via DI container for custom checkout logic. See Domain Layer for the full service inventory.


Business Rules

  1. Address mode determines shipping field requirements: useAddress controls whether separate shipping fields are required. '0' (ORDER_ADDRESS_SHIPPING) requires all sendto_* fields; '1' (ORDER_ADDRESS_BILLING) copies billing to shipping; '2' (ORDER_ADDRESS_ESHOP) sets store pickup with no shipping address. Enforced by the orderAddress CI validator and client-side check #4 in validateForm() (ecommercen/eshop/controllers/Adv_order.php:495-638).

  2. Non-EU delivery removes VAT entirely: When the delivery country is outside the EU, setVatForOrder() sets VAT to 0%. Controlled by the VatForOrder singleton and the enableOrderVatManipulation registry key (master switch). See AD-50 VAT Management for full mechanics (ecommercen/eshop/controllers/Adv_order.php:preview()).

  3. Coupon is re-validated on form submit, not only on AJAX preview: callback_checkCoupon re-runs coupons_model->isValidCoupon() against the CouponCheckConfig() rules: existence and active status, per-coupon and per-customer usage limits, minimum order total, product/category eligibility, and customer audience membership (ecommercen/eshop/controllers/Adv_order.php:1343).

  4. COD is incompatible with store pickup and SmartPoint-only transporters: callback_paywayCheckValidation() blocks the delivery (COD) + store-pickup combination. paid_at_store is unavailable when cartProductsTotal <= minCartTotalAmountForPaidAtStore. Enforced by client-side check #9 in validateForm() and server-side callbacks in previewValidation() (ecommercen/eshop/controllers/Adv_order.php:495-638).

  5. Stock is reserved at placement for every payway (#282): PlaceOrderService::placeOrder() calls StockService::reduceStockForOrder() unconditionally after payment initialization (src/Domains/Checkout/PlaceOrderService.php:307). The prior model — where adapters with supportsRedirect() === true deferred reduction to PaymentConfirmationService::confirmPayment() — has been removed to achieve anti-overselling parity with the legacy storefront and POS. PaymentConfirmationService::confirmPayment() does not reduce stock (src/Domains/Checkout/PaymentConfirmationService.php:82-87). cancelPayment() restores stock via StockService::restoreStockForOrder() (:156). Abandoned PENDING orders are restored by the AdvCancelIncompleteOrders cron. The per-product negative-stock allowance is the opt-out.

  6. Invoice type switches five B2B fields from optional to required: When paymerch === 'invoice', afm, doy, profession, company, and company_address become required. callback_checkPaymerch strictly enforces paymerch is 'invoice' or 'receipt' — any other value is rejected (ecommercen/eshop/controllers/Adv_order.php:previewValidation()).

  7. Terms acceptance is client-side only: The accept_terms checkbox is checked by validateForm() client-side but is not registered in CI form_validation server rules (assets/vue/mixins/checkoutPage.js:335-423).

  8. Guest email matching a registered account with access is REJECTED: If a guest checks out with an email that matches an existing customer who has has_access=1, resolveGuestCustomer() throws a RuntimeException and blocks the checkout. A valid email is also required (src/Domains/Checkout/PlaceOrderService.php:350-360, commit 3597ad539).


Vue Frontend Features

Transporter Selector

The shipping transporter selection is a Vue 2 component tree that dynamically renders based on how many transporters are available for the customer's address.

Entry point: AdvOrderTransporters.vue receives country, county, postal, and city as props and delegates to one of two sub-components:

ComponentRenders whenBehavior
AdvOrderTransportersSingleExactly 1 transporter availableHidden input auto-selects the only option
AdvOrderTransportersMultiple2+ transporters availableRadio button list with name + cost display

How transporters load: On mount (and whenever country, county, postal, cartTotalWithDiscount, or cartProductWeightTotal change), the component calls getTransporterData(), which POSTs to /{lang}/api/transporters/getAvailableTransporters with the customer's address, cart total, and weight. The response populates the Vuex getTransporters getter.

External rate transporters (e.g., ASAP): When a transporter has external pricing (getIsExternalTransporterCost returns true), the AdvOrderTransportersExternal sub-component renders inside the selected transporter's block. It shows a secondary radio list of service tiers (product name + price) fetched via the getExternalTransporterCost action. If only one rate exists, it auto-selects. The selected rate is serialized as JSON into a hidden transporterExternalRate input. A loading spinner displays while rates are being fetched.

Auto-selection logic: When the transporter list changes, AdvOrderTransportersMultiple checks if the previously selected transporter still exists in the new list; if not, it falls back to the first available. AdvOrderTransportersSingle always auto-selects and auto-emits. Both components also disable COD payment when a smart-point-only transporter is selected.

Files:

  • assets/main/vue/AdvOrderTransporters.vue -- orchestrator
  • assets/main/vue/AdvOrderTransportersSingle.vue -- single-option variant
  • assets/main/vue/AdvOrderTransportersMultiple.vue -- multi-option variant
  • assets/main/vue/AdvOrderTransportersExternal.vue -- external rate sub-selector
  • assets/vue/store/actions.js -- getTransporterData, getExternalTransporterCost, asapExternalServices
  • assets/vue/api/index.js -- getTransporterData() API call

Smart Point / Pickup Point Selection

Smart Points allow customers to select a pickup locker or point instead of (or in addition to) home delivery. The system supports two rendering paths: a default Google Maps modal and third-party widgets (BoxNow, Skroutz Last Mile).

Entry point: SmartPoints.vue renders inside each transporter option (both Single and Multiple variants). It checks getSelectedTransporterSmartPointEnable and branches:

ConditionComponentDescription
Smart points enabled, useTransporterWidget is falseSmartPointDefaultGoogle Maps modal with point list
Smart points enabled, useTransporterWidget is trueSmartPointWidgetThird-party widget (BoxNow or Skroutz)

Transporter delivery option types (configured per transporter in admin):

ValueLabelBehavior
0AllBoth home delivery and pickup point available
1Delivery to my placeHome delivery only (smart points hidden)
2Pick up from selling pointPickup only (home delivery radio hidden, COD disabled)

Default path (Google Maps):

SmartPointDefault opens a GenericModal containing SmartPointMap, which uses a googleMapMixin to render a Google Maps instance with clustered markers. The left panel lists available smart points searchable by postal code. Selecting a point highlights it in both the list and on the map. The layout is responsive: desktop uses a side-by-side grid (1fr list / 3fr map), tablet stacks vertically, and mobile adds an expand/collapse toggle button.

SmartPointSelection presents the delivery choice radio buttons:

  • "Deliver to my home" -- unchecks smart point, restores normal delivery
  • "Pick up from {transporter}" -- opens the map modal to select a point

When a point is selected, its data is serialized as JSON into a hidden smartPointJsonData input for form submission. An error message displays if a smart-point-only transporter is selected but no point has been chosen.

Widget path (BoxNow / Skroutz):

SmartPointWidget.vue dynamically resolves the correct widget component via a componentMap:

class_nameWidget componentSelection component
BOXNOWBoxNowWidgetSmartPointWidgetSelection
SKROUTZSkroutzLastMileWidgetSmartPointWidgetSelection

Both widget components follow the same pattern:

  1. SmartPointWidget.js (class) dynamically injects the vendor's external script into <head> with a configuration object
  2. The vendor iframe renders inside a styled container (#boxnowmap or #skroutzLockerMap)
  3. The vendor's afterSelect callback fires a Vue root event ({class_name}_AfterSelect)
  4. The widget component listens for that event, maps the vendor payload to a SmartPointModelDTO, and dispatches setSelectedSmartPoint to Vuex
  5. A toast notification confirms the selection

BoxNow supports Greece (GR), Cyprus (CY), and Bulgaria (BG) via country-specific CDN URLs. Skroutz Last Mile currently targets Greece only.

SmartPointModelDTO is a data transfer object with fields: id, name, address, country, latitude, longitude, postalCode, city, image, note, title, type, email, workingHours, phone.

Data source: In the legacy checkout, the smart-point list is hydrated server-side by Adv_order::smartPointsInitialize() (ecommercen/eshop/controllers/Adv_order.php:58-85), which aggregates all active transporters that expose smart points and merges them into the rendered page state before Vue mounts. As of 4.101.0 (commit 063684019), modern storefront consumers (e.g., Velora) can fetch the same catalog per-transporter via GET /rest/transporter/{transporterId}/smart-point (guest auth) without depending on server-side hydration — see AD-06 §SmartPoint Catalog and IN-09 §Smart Point Catalog API.

PHP-side counterpart: Advisable\SmartPoints\DTO\SmartPointDTO, exposed via src/Rest/Transporter/Resources/SmartPoint/Resource.php. The PHP DTO includes two additional fields not currently surfaced in the JS object: stationDestination and stationBranchDestination.

SmartPointMixin provides shared logic used by both Single/Multiple transporter components and the widget selection: shouldEnableSmartPointSelection(), shouldEnableSmartPointSelectionWidget(), resolveDefaultMapInitialization(), transporterSmartPointsApiError(), and smartPointClickEvent(). It routes between the map modal and widget paths based on useTransporterWidget.

Files:

  • assets/main/vue/SmartPoint/SmartPoints.vue -- entry point (branches default vs widget)
  • assets/main/vue/SmartPoint/SmartPointDefault.vue -- Google Maps modal wrapper
  • assets/main/vue/SmartPoint/SmartPointMap.vue -- map + point list UI
  • assets/main/vue/SmartPoint/SmartPointSelection.vue -- home/pickup radio buttons
  • assets/main/vue/SmartPoint/SmartPointMixin.js -- shared logic mixin
  • assets/main/vue/SmartPoint/SmartPointModelDTO.js -- point data transfer object
  • assets/main/vue/SmartPoint/Widgets/SmartPointWidget.vue -- dynamic widget resolver
  • assets/main/vue/SmartPoint/Widgets/SmartPointWidget.js -- vendor script injection class
  • assets/main/vue/SmartPoint/Widgets/BoxNowWidget.vue -- BoxNow iframe widget
  • assets/main/vue/SmartPoint/Widgets/SkroutzLastMileWidget.vue -- Skroutz iframe widget
  • assets/main/vue/SmartPoint/Widgets/SmartPointWidgetSelection.vue -- shared selection UI for widgets

Gift Packaging

Component: CheckoutGiftPackaging.vue -- a conditional checkout section for optional gift wrapping with a greeting card.

Visibility: The component only renders when config.giftPackaging.enable is truthy (controlled by the GIFT_PACKAGING.ENABLED registry key).

UI flow:

  1. Gift packaging checkbox -- Toggles gift wrapping on/off. If a cost is configured (config.giftPackaging.cost > 0), it displays the surcharge in the customer's selected currency (e.g., "+ 3.00 EUR").
  2. Greeting card checkbox -- Appears only when gift packaging is checked. Toggles inclusion of a greeting card.
  3. Greeting card message textarea -- Appears only when both gift packaging and greeting card are checked. Free-text message field.

State management: Uses Vuex actions setGiftPackaging and setGiftPackagingMessage with corresponding getters getGiftPackaging and getGiftPackagingMessage. Unchecking gift packaging automatically clears the greeting card checkbox and message. Unchecking the greeting card clears the message.

Form fields submitted: gift_packaging (checkbox), gift_packaging_message (textarea text).

Configuration: GIFT_PACKAGING.ENABLED and GIFT_PACKAGING.COST registry keys (also listed in the Configuration table above).

File: assets/main/vue/CheckoutGiftPackaging.vue


VAT Validation API

Real-time VAT number validation during checkout, triggered when the customer enters a VAT number (AFM) in the invoice section or changes their billing country.

Frontend trigger: In CheckoutPage.vue, the VAT input field fires validateVat() on @keyup, and the country dropdown fires it on @change. Both pass { country, vat }. The Vuex action debounces the call by 800ms to avoid excessive API requests while typing.

Vuex flow:

  1. validateVat action clears previous state (setVatError(false), setEmptyVatData)
  2. Calls api.validateVat() which POSTs to /api/validateVat
  3. On success: sets setVatData (populates company_name, company_address, company_doy, profession, json_invoice in customer data) or setVatError(true) if invalid

Backend routing: /api/validateVat maps to Api_vat controller (application/modules/api/controllers/Api_vat.php), which extends AdvApiVatController (ecommercen/api/controllers/AdvApiVatController.php).

Validation logic (AdvVatValidate in ecommercen/eshop/libraries/AdvVatValidate.php):

CountryServiceDetails
Greece (GR) with GSIS credentialsGSIS SOAP APICalls rgWsPublicAfmMethod on www1.gsis.gr with WS-Security auth. Returns company name, address, DOY, profession, legal status, activity codes. Requires VAT_CHECKER.VAT_USERNAME, VAT_CHECKER.VAT_PASSWORD, VAT_CHECKER.VAT_CALLER_VAT registry keys.
Greece (GR) without GSIS credentialsEU VIES fallbackFalls back to VIES; if VIES also fails, uses offline checksum validation (mod-11 algorithm on 9-digit AFM).
Other EU countriesEU VIES SOAP APICalls checkVat on ec.europa.eu/taxation_customs/vies. Returns name and address only.
Non-EU countriesSkippedAuto-returns valid: true with no data lookup.

Response format:

json
{
  "result": {
    "valid": true,
    "data": {
      "name": "Company Name",
      "address": "Street Address",
      "doy": "Tax Office",
      "profession": "Activity Description",
      "json_invoice": { ... }
    }
  }
}

When validation succeeds, the invoice form fields (company name, address, DOY, profession) are auto-populated from the response. When it fails, a VAT error state is set in the store.

Registry configuration: VAT_CHECKER.ENABLED must be true for GSIS integration. The VIES fallback and offline Greek checksum work regardless.

Files:

  • assets/main/vue/CheckoutPage.vue -- triggers validateVat on keyup/change
  • assets/vue/store/actions.js -- validateVat action (debounced, 800ms)
  • assets/vue/api/index.js -- validateVat() POST to /api/validateVat
  • assets/vue/store/mutations.js -- setVatData, setVatError, setEmptyVatData
  • application/modules/api/controllers/Api_vat.php -- CI route controller
  • ecommercen/api/controllers/AdvApiVatController.php -- request handler
  • ecommercen/eshop/libraries/AdvVatValidate.php -- SOAP validation logic (VIES + GSIS)
  • application/modules/eshop/libraries/VatValidate.php -- application-level wrapper (extends AdvVatValidate)

Checkout Form Specification

This section provides the complete specification of the legacy checkout form (CheckoutPage.vue + Adv_order.php::preview()), covering every field, validation rule, AJAX interaction, and business constraint discovered through Playwright testing and source analysis.

1. Form Fields

Customer Details (Billing)

#Field nameHTML typeRequiredServer validation rulesNotes
1nametextYestrim|mb_strtoupper|convert_accented_characters|requiredAuto-uppercased with accent conversion
2surnametextYestrim|mb_strtoupper|convert_accented_characters|requiredAuto-uppercased with accent conversion
3landphonetextYestrim|requiredPhone number; @input sanitizer strips non-numeric chars
4mobilephonetextNotrimSecondary phone; same sanitizer
5mailemailYes (guests)trim|required|valid_email|callback_email_check[{isGuest}]Hidden when customer is logged in. email_check prevents duplicate registrations (skipped for guests)
6passwordpasswordYes (register)trim|requiredOnly shown when is_guest === '0' and not logged in
7password_retypepasswordYes (register)trim|required|matches[password]Must match password
8citytextYestrim|required
9postaltextYestrim|required|callback_postalCheck[{country}]Country-specific format validation
10addresstextYestrim|required
11regiontextNotrim
12countryselectYestrim|requiredDropdown from countryAndCountyData.country. Changing triggers VAT revalidation and transporter reload
13countyselectYestrim|requiredFiltered by selected country

Shipping Address (Conditional)

These fields are required only when useAddress === '0' (ORDER_ADDRESS_SHIPPING, i.e. separate shipping address). They are hidden when sameaddress is checked or store pickup is selected.

#Field nameHTML typeRequired when separateServer validation rules
14sendto_nametextYestrim|mb_strtoupper|convert_accented_characters|required
15sendto_surnametextYestrim|mb_strtoupper|convert_accented_characters|required
16sendto_landphonetextYestrim|required
17sendto_mobilephonetextNotrim
18sendto_citytextYestrim|required
19sendto_postaltextYestrim|required|callback_postalCheck[{sendto_country}]
20sendto_addresstextYestrim|required
21sendto_regiontextNotrim
22sendto_countryselectYestrim|required
23sendto_countyselectYestrim|required

Notes

#Field nameHTML typeRequiredNotes
24customer_notestextarea (2 rows)NoFree-text order notes
25courier_notestextarea (2 rows)NoDelivery instructions for the courier

Gift Packaging (Conditional)

Shown only when GIFT_PACKAGING.ENABLED registry key is truthy. See Gift Packaging section above.

#Field nameHTML typeRequiredServer validation
26gift_packagingcheckboxConditionaltrim|required (only validated when POST value is present)
27gift_packaging_messagetextareaConditionaltrim|required (only validated when POST value is present)

Invoice / B2B Fields (Conditional)

Required only when paymerch === 'invoice'. See Invoice Type section below.

#Field nameHTML typeRequired when invoiceServer validation
28paymerchradio (receipt / invoice)Yestrim|required|callback_checkPaymerch (must be 'invoice' or 'receipt')
29afmtextYestrim|required
30doytextYestrim|required
31professiontextYestrim|required
32companytextYestrim|required
33company_addresstextYestrim|required
34json_invoicehiddenNoAuto-populated by VAT validation response

Hidden Fields

#Field nameSourcePurpose
35useAddressComputed ('0' / '1' / '2')trim|required|orderAddress -- determines address mode
36order_totalv-model="cartProductsTotal"Cart subtotal for server-side minimum checks
37order_currencygetSelectedCurrency.codeActive currency code (e.g. EUR)
38currency_idgetSelectedCurrency.idCurrency DB id
39currency_rategetSelectedCurrency.rateExchange rate relative to base currency
40klarnaAuthorizationTokenKlarna widgetPopulated after Klarna authorization
41klarnaPaymentSessionKlarna widgetKlarna session ID
42is_guestradio ('0' / '1')Guest vs register toggle (hidden when logged in)
43sameaddresscheckbox'1' when billing = shipping
44preview_submitsubmit buttonvalue="true" -- triggers server-side processing
45smartPointJsonDataSmartPoint componentJSON-serialized pickup point data
46transporterExternalRateExternal transporter componentJSON-serialized rate selection

Additional POST fields merged into order data

These are collected in Adv_order.php::preview() line 285-308 and passed to checkoutView():

FieldPurpose
coupon_codeApplied coupon code
paywaySelected payment gateway key
storeSelected store ID for pickup
deliverytimePreferred delivery time
installmentsPayment installment count
transport_idSelected transporter ID

2. Payment Methods

The platform supports 18 base payment gateways (plus Viva Wallet sub-methods). Each is enabled/disabled per shop via the METHODS.PAYWAY registry array. The list rendered in the frontend comes from orderPayWaysForVue('PAYWAY', 'METHODS').

Gateway Table

#KeyTypeInstallmentsAvailability constraints
1deliveryOffline (COD)NoDisabled when a SmartPoint-only transporter is selected (getIsSmartPointOnlyTransporter). Shows COD surcharge inline if deliveryCostVerbal > 0
2bank_transferOfflineNoAlways available when enabled
3paid_at_storeOffline (in-store)NoDisabled when cartProductsTotal <= minCartTotalAmountForPaidAtStore. Selecting it forces store pickup address mode and hides courier selection
4alphaOnline (Alpha Bank)NoRequires ALPHABANK.VERSION, ID, SSK, SUBMIT
5apcopayOnline (ApcoPay/Piraeus)Yes (dropdown)Installment dropdown shown when APCOPAY.USE_INSTALLMENT_OPTIONS is enabled
6eurobankOnline (Eurobank)Yes (dropdown)Installment dropdown shown when installments count > 1 AND orderTotal >= minimumOrderTotal
7ethnikiOnline (NBG)NoRequires ETHNIKI.PUBLIC_KEY, PRIVATE_KEY
8ethniki_eeOnline (NBG e-Commerce)NoRequires ETHNIKI_EE.HOST, DIRECT_API_KEY, MERCHANT_ID
9piraeusOnline (Piraeus Bank)Yes (dropdown)Installment dropdown shown when installments count > 1 AND orderTotal >= minimumOrderTotal
10jccOnline (JCC Payment Systems)NoRequires JCC.USERNAME, PASSWORD
11irisOnline (IRIS Payments)NoRequires IRIS.USERNAME, PASSWORD, CUSTOMER_CODE, CHECK_DIGIT
12stripeOnline (Stripe)NoRequires STRIPE.PUBLISHABLE_KEY, SECRET_KEY
13vivawalletOnline (Viva Wallet)Yes (dropdown)Requires VIVAWALLET.MERCHANT_ID, API_KEY. Can expose sub-methods (credit card, e-banking, IRIS, etc.) via VIVAWALLET.PAYMENT_PARAMETERS. Viva sub-method keys are mapped via getVivaEnabledPaymentMethods()
14paypalOnline (PayPal Classic)NoRequires PAYPAL.USERNAME, PASSWORD, SIGNATURE
15paypaladvancedOnline (PayPal Advanced/Checkout)NoRequires PAYPALADVANCED.CLIENT_ID, CLIENT_SECRET
16paybybankOnline (Pay By Bank)NoRequires PAYBYBANK.API_KEY, API_URL
17klarna_paymentsOnline (Klarna)No (BNPL)Disabled when no country is selected (neither billing nor shipping). Requires Klarna authorization widget flow before submit. klarnaAuthorizationToken and klarnaPaymentSession hidden fields must be populated
18proxypayOnline (ProxyPay)NoLegacy gateway; may not appear in modern deployments

Client-Side Availability Logic (isPayWayOptionAvailable)

javascript
// From assets/vue/mixins/checkoutPage.js
switch (payWayClass) {
  case 'paid_at_store':
    return cartProductsTotal > minCartTotalAmountForPaidAtStore
  case 'delivery':
    return !getIsSmartPointOnlyTransporter   // COD incompatible with SmartPoint-only
  case 'klarna_payments':
    // Compound condition — see note below
}

Note (Klarna condition): The klarna_payments case at assets/vue/mixins/checkoutPage.js:436-446 uses a compound condition that includes storeId and transporterId checks in addition to the country check. The simplified return country !== '' shown in earlier versions of this doc does not reflect the full guard. Refer to the source directly for the authoritative logic.

Installment Logic

For apcopay, piraeus, and eurobank, the installment <select> dropdown appears inline below the radio button. It is conditionally shown:

  • apcopay: when previewOrderData.apcoInstallments has entries
  • piraeus: when previewOrderData.piraeusInstallments.installments has > 1 entry AND orderTotal >= minimumOrderTotal
  • eurobank: when previewOrderData.eurobankInstallments.installments has > 1 entry AND orderTotal >= minimumOrderTotal

The selected value is submitted as installments in the POST data.

Viva Wallet Sub-Methods

When VIVAWALLET.PAYMENT_PARAMETERS is configured, Viva Wallet exposes up to 20 sub-methods as separate radio buttons (e.g., vivawallet_credit_card, vivawallet_e_banking, vivawallet_iris, vivawallet_pay_by_bank). On submit, the selected sub-method key is stored in viva_payway and the main payway is normalized to 'vivawallet'.


3. Shipping & Delivery

Delivery Modes

ModeRadio valueuseAddressBehavior
Courier deliveryisCourierAddress = '1''0' (shipping) or '1' (billing)Transporter selector shown; SmartPoint available
Store pickupisCourierAddress = '0''2' (store)Store selector shown (single auto-select, multi radio list). COD payment disabled. Transport fields hidden

Store pickup is disabled when payway === 'delivery' (COD) or when cartTotal <= minCartTotalAmountForPaidAtStore.

Transporter AJAX Loading

The AdvOrderTransporters.vue component triggers getTransporterData() whenever any of these change:

TriggerSource field
CountryorderCountry (billing or shipping depending on sameShipping)
CountyorderCounty
Postal codeorderPostal (whitespace stripped)
Cart totalcartTotalWithDiscount
Cart weightcartProductWeightTotal

API call: POST /{lang}/api/transporters/getAvailableTransporters with { country, county, postal, totalWithVat, weightTotal }.

Auto-selection: When the transporter list changes, the previously selected transporter is retained if still available; otherwise falls back to the first option. Single-transporter results auto-select via hidden input.

External Rate Transporters (e.g., ASAP)

When transporter.eshop_calculated_cost === "0", the AdvOrderTransportersExternal sub-component fires getExternalTransporterCost() to fetch live rates from the third-party carrier's API. The debounced response populates a secondary radio list of service tiers (product name + price). The selected rate is serialized as JSON into the transporterExternalRate hidden input.

If external rates fail to load, the transporter selection shows an error and blocks submission via client-side validation (localErrors.transporter).

SmartPoint Selection

See the Smart Point / Pickup Point Selection section above for full details. Key form impact: the smartPointJsonData hidden field is populated with the selected point's JSON, and server-side validation via callback_smartPointJsonDataCheck ensures it contains valid JSON.


4. Coupon System

Frontend Flow (CheckoutCoupon.vue)

  1. Customer types coupon code into the input field
  2. Each keystroke fires updateCoupon() which:
    • Aborts any in-flight request via AbortController
    • Starts a 300ms debounce timer (setTimeout)
    • After debounce, dispatches checkCoupon Vuex action
  3. The action POSTs to POST /order/preview_coupon with { coupon, invoice, useAddress, billingCountry, shippingCountry }
  4. Response returns { discount: <number> } which updates previewOrderData.couponDiscount
  5. UI shows success (green alert), error (warning alert), or loading spinner based on state

Server-Side Validation (callback_checkCoupon)

On form submit, the server re-validates the coupon via checkCoupon() in Adv_order.php (line 1343):

  1. Empty coupon code passes (optional field)
  2. VAT context is set from current form state (useAddress, paymerch, country, sendto_country)
  3. coupons_model->isValidCoupon() checks against CouponCheckConfig() rules:
    • Coupon existence and active status
    • Usage limits (per-coupon, per-customer)
    • Minimum order total
    • Product/category eligibility
    • Customer audience membership
  4. On failure, sets error message 'preview.order.invalid.coupon'

5. Invoice Type

Radio Selection

ValueLabelBehavior
receiptReceiptDefault. Invoice fields hidden, afm/doy/profession/company/company_address validated as trim only
invoiceInvoiceInvoice fields shown and all become required. Triggers VAT validation. Server adds callback_checkPaymerch validation

Invoice Fields When paymerch === 'invoice'

All five fields (afm, doy, profession, company, company_address) switch from trim to trim|required. The json_invoice hidden field is auto-populated by the VAT validation API response.

VAT Number Validation

See the VAT Validation API section above for the complete GSIS/VIES/mod-11 flow.

Summary: AFM keyup (debounced 800ms) or country change triggers POST /api/validateVat. On success, doy, company, company_address, profession, and json_invoice are auto-populated from the API response. On failure, a VAT error alert is shown.

checkPaymerch Callback

Server-side callback ensures paymerch is strictly 'invoice' or 'receipt' -- rejects any other value.


6. Loyalty Points

Visibility Conditions

The points block (#customerPointsRedeem) renders only when ALL conditions are met:

  1. previewOrderData.loyalty.pointSystemIsEnabled is truthy (registry: POINT_SYSTEM.IS_ENABLED)
  2. previewOrderData.loyalty.customerTotalPoints > 0
  3. previewOrderData.loyalty.customerCash > 0
  4. previewOrderData.loyalty.customerCash < cartProductsTotal (cannot redeem more than cart value)

UI

  • Info text: Shows total points, redeemable points, and cash equivalent (e.g., "You have 500 points. You can redeem 300 points worth 15.00EUR")
  • Checkbox: redeemPoints (value '1'). When checked, customerCash is subtracted from orderTotal
  • Server rule: trim only (the redemption amount is recalculated server-side, not trusted from POST)

7. Newsletter Checkboxes

Four provider checkboxes are rendered dynamically from the newsletterOptions array. Each appears only when its displayKey is truthy in previewOrderData.

#ProviderCheckbox nameDisplay condition (registry)Extra condition
1Managoregister_manago_newsletterMANAGO.ENABLE_API + MANAGO.ENABLE_NEWSLETTER_REGISTERHidden if logged-in customer already in Manago (customer_in_manago session flag)
2Moosendregister_moosend_newsletterMOOSEND.ENABLED + MOOSEND.API_KEY + MOOSEND.CHECKOUT_LIST_IDHidden if logged-in customer already in Moosend (customer_in_moosend session flag)
3Apifonregister_apifon_newsletterAPIFON.ENABLED + APIFON.SUBSCRIBE_TO_LIST_ENABLED + APIFON.SUBSCRIBER_LIST_IDAlways shown when conditions met
4Mailchimpregister_mailchimp_newsletterMAILCHIMP.ENABLED + MAILCHIMP.API_KEY + MAILCHIMP.SERVER_PREFIX + MAILCHIMP.CHECKOUT_LISTAlways shown when conditions met

All four use the same label: t('preview.order.newsletter_register'). Server-side subscription happens only on successful form validation in preview(), and each provider has independent condition checks before calling its API.


8. Terms Checkbox

FieldHTML typeRequiredNotes
accept_termscheckboxYes (HTML required + client-side check)Not server-validated via form_validation; enforced by client-side validateForm() which checks requiredEl.checked for this specific field

The label includes a link to the terms page: <a target="_blank" :href="t('customer.register.terms.link.href')">.


9. Order Total Formula

The final order total is computed client-side in checkoutPage.js (line 248-250):

javascript
orderTotal = cartProductsTotal
           - couponDiscount
           + deliveryCost
           + transportationCost
           - customerPointsCash
           + giftPackagingCost

Where:

  • cartProductsTotal -- sum of all cart item prices (with VAT, after per-item discounts)
  • couponDiscount -- parseFloat(previewOrderData.couponDiscount) || 0 from AJAX validation
  • deliveryCost -- transporter.deliveryCost when payway === 'delivery' (COD surcharge), else 0
  • transportationCost -- transporter.transportationCost for eshop-calculated transporters; getSelectedTransporterExternalRate.price for external-rate transporters; 0 for store pickup or paid_at_store
  • customerPointsCash -- previewOrderData.loyalty.customerCash when redeemPoints checkbox is checked, else 0
  • giftPackagingCost -- config.giftPackaging.cost when getGiftPackaging is truthy, else 0

Overweight cost is displayed as a separate line item (overweightCost) but is already included in transporter.overweightCost which is part of the transporter cost calculation.

All displayed values are multiplied by getSelectedCurrency.rate for multi-currency rendering.


10. Validation

Validation happens in two stages: client-side in Vue before form submission, then server-side in CodeIgniter form_validation.

Client-Side Validation (validateForm, 10 checks)

The validateForm() method in checkoutPage.js (line 335-423) performs these checks in order:

#CheckError keyCondition
1Store selectionlocalErrors.storeStore pickup mode + multiple stores + no store selected
2Transporter selectionlocalErrors.transporterCourier mode + no transporter selected
3External rate missinglocalErrors.transporterCourier mode + external-rate transporter + no rate fetched
4Shipping address completenesslocalErrors.shippingSeparate shipping mode + any of sendto_name/surname/landphone/city/postal/address/country/county empty
5Invoice fields completenesslocalErrors.invoiceInvoice type + any of company_afm/company_doy/company_name/company_address/profession empty
6Invoice type validitylocalErrors.invoiceinvoiceType is neither 'invoice' nor 'receipt'
7Gift selection validitylocalErrors.giftsgiftsValidation() returns false (gift rule requires selection but none made)
8SmartPoint selectionlocalErrors.smartPointSmartPoint-only transporter selected but no point chosen, OR SmartPoint mode active with no point selected
9Payment/delivery combolocalErrors.payWayDeliveryComboDisallowedpaid_at_store + store pickup combo disallowed, OR delivery (COD) + store pickup
10Required HTML fieldslocalErrors[fieldName]Iterates all [required] elements in the form; checks accept_terms via .checked, all others via .value.trim() !== ''

If any errors are found, a GenericModal (CheckoutErrors) opens showing all errors, and e.preventDefault() blocks submission.

Server-Side Validation (CodeIgniter form_validation)

After client-side passes, the form POSTs and previewValidation() (line 495-638) sets up CI rules:

Rule groupFieldsCallbacks
Billingname, surname, address, city, postal, landphone, county, countrypostalCheck[{country}] on postal
Identitymail, password, password_retypeemail_check[{isGuest}] on mail; matches[password] on password_retype
Invoiceafm, doy, profession, company, company_addressRequired only when paymerch === 'invoice'
Paymentpayway, paymerchcallback_checkPaymerch
Transporttransport_idcallback_isValidTransporter, callback_isValidSmartPointTransporter (only when useAddress != ORDER_ADDRESS_ESHOP)
Storestorecallback_validateMinimumCartTotalForPaidAtStore, in_list[{activeStoreIds}], callback_paywayCheckValidation (required when paid_at_store or store pickup)
Address modeuseAddressCustom orderAddress validator
Shippingsendto_* (10 fields)Required only when useAddress === ORDER_ADDRESS_SHIPPING; postalCheck on sendto_postal
Couponcoupon_codecallback_checkCoupon (re-validates against cart, audiences, limits)
SmartPointsmartPointJsonDatacallback_smartPointJsonDataCheck (valid JSON with content)
Gift packaginggift_packaging, gift_packaging_messageRequired only when POST value is present
LoyaltyredeemPointstrim only (amount recalculated server-side)

Key callbacks:

  • postalCheck($postal, $country) -- country-specific postal code format validation
  • email_check($email, $isGuest) -- prevents registration with existing email (skipped for guests)
  • isValidTransporter() -- verifies the selected transporter is available for the customer's address via Transporters::isAvailable()
  • isValidSmartPointTransporter() -- ensures SmartPoint-only transporters have a selected point
  • paywayCheckValidation() -- blocks COD + store pickup combination
  • validateMinimumCartTotalForPaidAtStore() -- enforces minimum cart total for in-store payment

Data Model

Key tables touched during checkout preview

TablePurpose
shop_customerCustomer record (created or updated during checkout; is_guest=1 for guest checkout)
shop_customer_addressesBilling and shipping addresses (saved for returning customers)
shop_transportersShipping transporter definitions (cost rules, smart point config, external rates)
shop_transporter_costsWeight/destination-based shipping cost tiers
coupons / coupon_rulesCoupon validation at checkout (discount type, min order, usage limits)
gifts / gift_requirementsGift eligibility rules evaluated against cart contents
shop_storesStore pickup locations (for ORDER_ADDRESS_ESHOP mode)
shop_order_dhl_vouchersDHL-specific voucher data written by DhlVoucherPersister
shop_order_asap_dataASAP-specific carrier data written by AsapDataPersister
shop_order_smart_pointSmart-point pickup selection written by SmartPointPersister

Legacy path: order creation happens in the next step — see CF-07 Order Confirmation for the shop_order table schema.

Modern REST path: PlaceOrderService::placeOrder() creates the shop_order record directly within the same request (src/Domains/Checkout/PlaceOrderService.php:198) and writes shop_order_basket rows via OrderBasketWriteService::createForOrder() (src/Domains/Checkout/PlaceOrderService.php:209). For the shop_order and shop_order_basket table schemas, see CF-07 Order Confirmation.


Known Issues & Security Gaps

  1. Offline adapters set PENDING_ACCEPTED; legacy sets PENDING. As of 4.99.6, DeliveryAdapter, BankTransferAdapter, and PaidAtStoreAdapter return PENDING_ACCEPTED instead of PENDING. The legacy checkout flow still sets PENDING for these same payment methods. This means the modern REST checkout and the legacy checkout produce different initial order statuses for offline payments. Downstream status-dependent logic (e.g., cancellation jobs, email triggers) should be verified against both values. Idempotency guard includes both statuses (src/Domains/Checkout/PaymentConfirmationService.php:46-48).

  2. OrderBasketBuilder silently drops unresolvable cart items; PlaceOrderService does not check basket row count. RESOLVED (commit 9d019d1c9, #29) — Guard at src/Domains/Checkout/PlaceOrderService.php:182-189 checks count($basketRows) !== count($items) and throws a RuntimeException identifying the missing product_code_id values.

  3. Orphaned-order window between shop_order insert and basket transaction: The shop_order record is created at src/Domains/Checkout/PlaceOrderService.php:198 outside the createForOrder() DB transaction (which wraps inserts at src/Domains/Order/OrderBasket/WriteService.php:55-64). A crash between steps 8 and 10 leaves a shop_order row with zero basket rows and no items. Re-cite: src/Domains/Checkout/PlaceOrderService.php:198 (order create) and :209 (basket create).

  4. resolveGuestCustomer() attaches orders to registered accounts by email without authentication. RESOLVED (commit 3597ad539, #3) — src/Domains/Checkout/PlaceOrderService.php:354-360 now rejects checkout when a matched customer has has_access=1, throwing a RuntimeException. A valid email is also required (:350-352).

  5. cancelPayment() has no caller in the modern REST layer. RESOLVED (commit 5eb2eddbe, #30) — POST /rest/checkout/cancel-payment/{orderId} registered at rest_routes.php:1713 (and lang-prefixed at :1721), handled by src/Rest/Checkout/Controllers/Checkout.php:495-547.

  6. PaymentConfirmationService::confirmPayment() idempotency guard includes PENDING_ACCEPTED unnecessarilyPENDING_ACCEPTED is the terminal status for offline payments, which never enter PENDING. The guard is harmless but suggests the idempotency design may not be fully considered. Citation: src/Domains/Checkout/PaymentConfirmationService.php:46-48.

  7. MarketingConsentCaptured event fires but zero listeners are wiredPlaceOrderService.php:252-262 dispatches MarketingConsentCaptured on every order where marketingConsent=true, but no concrete listeners are registered in the container. The event is a no-op fanout currently. Intended for future CRM-sync use; tracked as a follow-up.


Tests

Test fileWhat it covers
tests/Unit/Checkout/PlaceOrderServiceTest.phpUnit tests for PlaceOrderService — guest resolution, order creation, basket building, coupon marking, and payment initialization
tests/Unit/Checkout/GuestCheckoutTest.phpGuest checkout flow — account attachment by email, is_guest flag, and random password assignment
tests/Unit/Checkout/CouponValidatorTest.phpCoupon validation rules — existence checks, usage limits per coupon and per customer, minimum order total, product/category eligibility
tests/Unit/Checkout/PaymentInitializerTest.phpPaymentInitializerFactory — adapter selection and payment initialization per gateway key
tests/Unit/Checkout/ShippingCalculatorTest.phpShipping cost calculation — transporter selection, external rate fetching, and overweight cost
tests/Unit/Domains/Checkout/OrderBasketBuilderTest.phpOrderBasketBuilder — cart item to basket row transformation including pricing, VAT, discounts, and loyalty point resolution
tests/Unit/Domains/Checkout/PaymentConfirmationServiceTest.phpPaymentConfirmationService — idempotent confirmation (does NOT reduce stock, #282), cancellation with stock restoration via restoreStockForOrder() (#282), and OrderPaid/OrderCanceled event dispatch
tests/Unit/Domains/Checkout/StockServiceTest.phpStockService — atomic stock reduction and restoration using raw SQL expressions to prevent race conditions
tests/Unit/Domains/Order/OrderBasket/WriteServiceTest.phpOrderBasket\WriteServicecreateForOrder() transactional basket row inserts
tests/Unit/Domains/Order/OrderBasket/ServiceTest.phpOrderBasket\Service — read-side basket queries and associated business logic
tests/Integration/Domains/Order/OrderBasket/RepositoryTest.phpIntegration tests for OrderBasket\Repository — DB-backed query correctness against a real schema
tests/Integration/Domains/Order/OrderBasket/ServiceTest.phpIntegration tests for OrderBasket\Service — end-to-end service behaviour against a real database

Coverage gap: The legacy Adv_order.php::preview() path (form validation, newsletter signups, VAT init, address modes) has no test coverage under tests/Legacy/ or tests/Unit/.


Wiki Guides: Stripe payment adapter configuration — see Stripe Guide. External rate transporters may use circuit breakers — see Circuit Breaker Guide.

Shared Patterns