Appearance
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:
- Modern REST Checkout —
src/Rest/Checkout/Controllers/Checkout.phpwithPlaceOrderService - Legacy Checkout —
ecommercen/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
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /rest/checkout/shipping | Guest/Customer | Calculate available shipping options |
| POST | /rest/checkout/coupon/validate | Guest/Customer | Validate coupon against cart |
| POST | /rest/checkout/totals | Guest/Customer | Calculate full totals (items + shipping + coupon) |
| POST | /rest/checkout/place-order | Guest/Customer | Place order from cart |
| GET | /rest/checkout/payment-methods | Guest | List active payment gateways for the current store (rest_routes.php:1709; controller Checkout.php:464) |
| GET | /rest/checkout/payment-status/{orderId} | Customer | Check payment status |
| POST | /rest/checkout/confirm-payment/{orderId} | Customer | Verify payment with gateway and confirm |
| POST | /rest/checkout/cancel-payment/{orderId} | Customer | Cancel 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
| URL | Controller | Method |
|---|---|---|
/preview_order | Adv_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:
- Resolve guest customer (create if needed)
- Validate cart
- Load cart items
- Calculate totals with coupon
- Validate shipping selection
- Resolve order currency via
resolveOrderCurrency()— looks up the cart'scurrency_codeagainstCurrencyRepositoryusingmatchOne(new Filter('code', $cartCurrencyCode)); falls back to the first active currency (Filter('active', 1)) if the cart code is absent or unmatched; throwsRuntimeExceptionif no active currency is configured at all. The resolvedCurrencyEntitypopulatesorder_currency,currency_id, andcurrency_rateon the order data. Citation:src/Domains/Checkout/PlaceOrderService.php(resolveOrderCurrency()) - Build the
cart_contentssnapshot viabuildCartContentsSnapshot()— serializes the legacy-shaped array thatgetCartFromUnserializedContents()(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 anoptionssub-array. The result is stored inshop_order.cart_contentsviaserialize(). Citation:src/Domains/Checkout/PlaceOrderService.php(buildCartContentsSnapshot()) - Create order in database (with
order_currency,currency_id,currency_rate, andcart_contentsall populated) (src/Domains/Checkout/PlaceOrderService.php:198) - Generate and assign order serial
- Write basket rows via
OrderBasketWriteService(createForOrder()) — rows were already built byOrderBasketBuilderearlier (~step 7b) (src/Domains/Checkout/PlaceOrderService.php:209) - Persist carrier-specific transporter data via
CarrierDataDispatcher(see Carrier Data Persistence below) - Mark coupon as used via
CouponCodeWriteRepository::incrementUsed() - Clear the cart
- Initialize payment via
PaymentInitializerFactory - 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 toPaymentConfirmationService::confirmPayment()) has been removed.OrderPaidis dispatched immediately only when$paymentResult->status === 'PENDING_ACCEPTED'(offline payways: delivery, bank_transfer, paid_at_store); online/redirect payways and PayByBank (bothPENDING) dispatchOrderPaidfromconfirmPayment()once the gateway confirms (PlaceOrderService.php:315-326). AbandonedPENDINGorders are restored by theAdvCancelIncompleteOrderscron; explicit cancellation callscancelPayment()which restores stock viaStockService::restoreStockForOrder(). - Dispatch
MarketingConsentCapturedevent ifmarketingConsentis true and a customer email exists (src/Domains/Checkout/PlaceOrderService.php:249-263) - 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 PENDING → CANCELED, 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)
- Cart recheck:
recheckOrder()validates stock - VAT init:
setVatForOrder()based on delivery country (non-EU = 0% VAT) - Customer login (optional): Existing customer can log in mid-checkout
- Form validation:
previewValidation()— billing, shipping, payment, transport, coupon - Customer create/update: Guest gets random password +
is_guest=1 - Gift selection: User-selected gifts from eligible rules
- Order data assembly: Merge all POST fields + address routing + invoice + packaging
- Newsletter signups: Manago, Moosend, Apifon, Mailchimp (consent-based)
- Transition:
checkoutView()→ final totals + payment form
Address Modes
| Value | Constant | Behavior |
|---|---|---|
'0' | ORDER_ADDRESS_SHIPPING | Separate billing + shipping |
'1' | ORDER_ADDRESS_BILLING | Billing copied to shipping |
'2' | ORDER_ADDRESS_ESHOP | Store 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):
| Service | File | Responsibility |
|---|---|---|
OrderBasketBuilder | src/Domains/Checkout/OrderBasketBuilder.php | Transforms cart items into order basket rows with resolved pricing, VAT, discounts, and loyalty points |
StockService | src/Domains/Checkout/StockService.php | Atomic stock reduction/restoration using raw SQL expressions (stock - qty) to avoid race conditions |
PaymentConfirmationService | src/Domains/Checkout/PaymentConfirmationService.php | Idempotent 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 |
OrderBasketWriteService | src/Domains/Order/OrderBasket/WriteService.php | Writes basket rows for an order in a single transaction via createForOrder() |
StockService::reduceStockForOrder() | src/Domains/Checkout/StockService.php:27-50 | Reads shop_order_basket, decrements product_codes.stock by qty per row in a DB transaction. |
StockService::restoreStockForOrder() | src/Domains/Checkout/StockService.php:58-81 | Reads shop_order_basket, increments product_codes.stock by qty per row in a DB transaction. Inverse of reduceStockForOrder(). |
OrderBasket\WriteData | src/Domains/Order/OrderBasket/WriteData.php | DTO mapping 15 shop_order_basket columns. fromArray() at :26-45; toArray() at :47-72. 73 lines. |
OrderBasket\Repository\WriteRepository | src/Domains/Order/OrderBasket/Repository/WriteRepository.php | Write repository targeting shop_order_basket (11 lines). |
CarrierDataDispatcher | src/Domains/Checkout/Carrier/CarrierDataDispatcher.php | Routes transporter-specific payload from PlaceOrderData::$transporterExternalData to the matching persister after order creation |
OrderEventDispatcher | src/Domains/Order/Event/OrderEventDispatcher.php | Synchronous 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/):
DhlVoucherPersister—class_name='DHL', gated oneshop_calculated_cost == 0; writesshop_order_dhl_vouchersviaDhlVoucher\WriteServiceAsapDataPersister—class_name='ASAP', gated oneshop_calculated_cost == 0; writesshop_order_asap_dataviaAsapData\WriteServiceSmartPointPersister—class_name ∈ smartPointsDataManageconfig keys; no cost gate; writesshop_order_smart_pointviaSmartPoint\WriteService
Marketing Consent
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/...)
| File | Responsibility |
|---|---|
src/Rest/Checkout/Controllers/Checkout.php | REST 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/...)
| File | Responsibility |
|---|---|
ecommercen/eshop/controllers/Adv_order.php | Legacy 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 theAPP_REST_API_ENABLED=trueenvironment variable. Whenfalse(default),rest_routes.phpis not loaded and all REST endpoints return 404. See REST API Modules guide for details.
| Key | Purpose |
|---|---|
enableOrderVatManipulation | Master switch for address-based VAT (see AD-50 VAT Management for default and implications) |
GIFT_PACKAGING.ENABLED / COST | Gift wrapping option |
minCartTotalAmountForPaidAtStore | Minimum for store pickup payment |
APP_REST_API_ENABLED | Environment variable — must be true to load rest_routes.php; all /rest/* endpoints return 404 when false |
EMAIL.NOTIFICATION_LOW_STOCK | Gates low-stock admin email on order paid |
MATOMO.ENABLED | Gates deferred Matomo ecommerce tracking on order paid |
FACEBOOK_CONVERSION.ENABLED | Gates deferred Meta CAPI Purchase event on order paid |
MANAGO.ENABLE_API / MANAGO.ENABLE_PURCHASE_REPORT | Gates deferred Manago PURCHASE report on order paid |
Client Extension Points
Legacy Hooks
| Hook | Purpose |
|---|---|
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
Address mode determines shipping field requirements:
useAddresscontrols whether separate shipping fields are required.'0'(ORDER_ADDRESS_SHIPPING) requires allsendto_*fields;'1'(ORDER_ADDRESS_BILLING) copies billing to shipping;'2'(ORDER_ADDRESS_ESHOP) sets store pickup with no shipping address. Enforced by theorderAddressCI validator and client-side check #4 invalidateForm()(ecommercen/eshop/controllers/Adv_order.php:495-638).Non-EU delivery removes VAT entirely: When the delivery country is outside the EU,
setVatForOrder()sets VAT to 0%. Controlled by theVatForOrdersingleton and theenableOrderVatManipulationregistry key (master switch). See AD-50 VAT Management for full mechanics (ecommercen/eshop/controllers/Adv_order.php:preview()).Coupon is re-validated on form submit, not only on AJAX preview:
callback_checkCouponre-runscoupons_model->isValidCoupon()against theCouponCheckConfig()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).COD is incompatible with store pickup and SmartPoint-only transporters:
callback_paywayCheckValidation()blocks thedelivery(COD) + store-pickup combination.paid_at_storeis unavailable whencartProductsTotal <= minCartTotalAmountForPaidAtStore. Enforced by client-side check #9 invalidateForm()and server-side callbacks inpreviewValidation()(ecommercen/eshop/controllers/Adv_order.php:495-638).Stock is reserved at placement for every payway (#282):
PlaceOrderService::placeOrder()callsStockService::reduceStockForOrder()unconditionally after payment initialization (src/Domains/Checkout/PlaceOrderService.php:307). The prior model — where adapters withsupportsRedirect() === truedeferred reduction toPaymentConfirmationService::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 viaStockService::restoreStockForOrder()(:156). AbandonedPENDINGorders are restored by theAdvCancelIncompleteOrderscron. The per-product negative-stock allowance is the opt-out.Invoice type switches five B2B fields from optional to required: When
paymerch === 'invoice',afm,doy,profession,company, andcompany_addressbecome required.callback_checkPaymerchstrictly enforcespaymerchis'invoice'or'receipt'— any other value is rejected (ecommercen/eshop/controllers/Adv_order.php:previewValidation()).Terms acceptance is client-side only: The
accept_termscheckbox is checked byvalidateForm()client-side but is not registered in CIform_validationserver rules (assets/vue/mixins/checkoutPage.js:335-423).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 aRuntimeExceptionand blocks the checkout. A valid email is also required (src/Domains/Checkout/PlaceOrderService.php:350-360, commit3597ad539).
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:
| Component | Renders when | Behavior |
|---|---|---|
AdvOrderTransportersSingle | Exactly 1 transporter available | Hidden input auto-selects the only option |
AdvOrderTransportersMultiple | 2+ transporters available | Radio 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-- orchestratorassets/main/vue/AdvOrderTransportersSingle.vue-- single-option variantassets/main/vue/AdvOrderTransportersMultiple.vue-- multi-option variantassets/main/vue/AdvOrderTransportersExternal.vue-- external rate sub-selectorassets/vue/store/actions.js--getTransporterData,getExternalTransporterCost,asapExternalServicesassets/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:
| Condition | Component | Description |
|---|---|---|
Smart points enabled, useTransporterWidget is false | SmartPointDefault | Google Maps modal with point list |
Smart points enabled, useTransporterWidget is true | SmartPointWidget | Third-party widget (BoxNow or Skroutz) |
Transporter delivery option types (configured per transporter in admin):
| Value | Label | Behavior |
|---|---|---|
0 | All | Both home delivery and pickup point available |
1 | Delivery to my place | Home delivery only (smart points hidden) |
2 | Pick up from selling point | Pickup 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_name | Widget component | Selection component |
|---|---|---|
BOXNOW | BoxNowWidget | SmartPointWidgetSelection |
SKROUTZ | SkroutzLastMileWidget | SmartPointWidgetSelection |
Both widget components follow the same pattern:
SmartPointWidget.js(class) dynamically injects the vendor's external script into<head>with a configuration object- The vendor iframe renders inside a styled container (
#boxnowmapor#skroutzLockerMap) - The vendor's
afterSelectcallback fires a Vue root event ({class_name}_AfterSelect) - The widget component listens for that event, maps the vendor payload to a
SmartPointModelDTO, and dispatchessetSelectedSmartPointto Vuex - 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 wrapperassets/main/vue/SmartPoint/SmartPointMap.vue-- map + point list UIassets/main/vue/SmartPoint/SmartPointSelection.vue-- home/pickup radio buttonsassets/main/vue/SmartPoint/SmartPointMixin.js-- shared logic mixinassets/main/vue/SmartPoint/SmartPointModelDTO.js-- point data transfer objectassets/main/vue/SmartPoint/Widgets/SmartPointWidget.vue-- dynamic widget resolverassets/main/vue/SmartPoint/Widgets/SmartPointWidget.js-- vendor script injection classassets/main/vue/SmartPoint/Widgets/BoxNowWidget.vue-- BoxNow iframe widgetassets/main/vue/SmartPoint/Widgets/SkroutzLastMileWidget.vue-- Skroutz iframe widgetassets/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:
- 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"). - Greeting card checkbox -- Appears only when gift packaging is checked. Toggles inclusion of a greeting card.
- 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:
validateVataction clears previous state (setVatError(false),setEmptyVatData)- Calls
api.validateVat()which POSTs to/api/validateVat - On success: sets
setVatData(populatescompany_name,company_address,company_doy,profession,json_invoicein customer data) orsetVatError(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):
| Country | Service | Details |
|---|---|---|
Greece (GR) with GSIS credentials | GSIS SOAP API | Calls 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 credentials | EU VIES fallback | Falls back to VIES; if VIES also fails, uses offline checksum validation (mod-11 algorithm on 9-digit AFM). |
| Other EU countries | EU VIES SOAP API | Calls checkVat on ec.europa.eu/taxation_customs/vies. Returns name and address only. |
| Non-EU countries | Skipped | Auto-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-- triggersvalidateVaton keyup/changeassets/vue/store/actions.js--validateVataction (debounced, 800ms)assets/vue/api/index.js--validateVat()POST to/api/validateVatassets/vue/store/mutations.js--setVatData,setVatError,setEmptyVatDataapplication/modules/api/controllers/Api_vat.php-- CI route controllerecommercen/api/controllers/AdvApiVatController.php-- request handlerecommercen/eshop/libraries/AdvVatValidate.php-- SOAP validation logic (VIES + GSIS)application/modules/eshop/libraries/VatValidate.php-- application-level wrapper (extendsAdvVatValidate)
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 name | HTML type | Required | Server validation rules | Notes |
|---|---|---|---|---|---|
| 1 | name | text | Yes | trim|mb_strtoupper|convert_accented_characters|required | Auto-uppercased with accent conversion |
| 2 | surname | text | Yes | trim|mb_strtoupper|convert_accented_characters|required | Auto-uppercased with accent conversion |
| 3 | landphone | text | Yes | trim|required | Phone number; @input sanitizer strips non-numeric chars |
| 4 | mobilephone | text | No | trim | Secondary phone; same sanitizer |
| 5 | mail | Yes (guests) | trim|required|valid_email|callback_email_check[{isGuest}] | Hidden when customer is logged in. email_check prevents duplicate registrations (skipped for guests) | |
| 6 | password | password | Yes (register) | trim|required | Only shown when is_guest === '0' and not logged in |
| 7 | password_retype | password | Yes (register) | trim|required|matches[password] | Must match password |
| 8 | city | text | Yes | trim|required | |
| 9 | postal | text | Yes | trim|required|callback_postalCheck[{country}] | Country-specific format validation |
| 10 | address | text | Yes | trim|required | |
| 11 | region | text | No | trim | |
| 12 | country | select | Yes | trim|required | Dropdown from countryAndCountyData.country. Changing triggers VAT revalidation and transporter reload |
| 13 | county | select | Yes | trim|required | Filtered 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 name | HTML type | Required when separate | Server validation rules |
|---|---|---|---|---|
| 14 | sendto_name | text | Yes | trim|mb_strtoupper|convert_accented_characters|required |
| 15 | sendto_surname | text | Yes | trim|mb_strtoupper|convert_accented_characters|required |
| 16 | sendto_landphone | text | Yes | trim|required |
| 17 | sendto_mobilephone | text | No | trim |
| 18 | sendto_city | text | Yes | trim|required |
| 19 | sendto_postal | text | Yes | trim|required|callback_postalCheck[{sendto_country}] |
| 20 | sendto_address | text | Yes | trim|required |
| 21 | sendto_region | text | No | trim |
| 22 | sendto_country | select | Yes | trim|required |
| 23 | sendto_county | select | Yes | trim|required |
Notes
| # | Field name | HTML type | Required | Notes |
|---|---|---|---|---|
| 24 | customer_notes | textarea (2 rows) | No | Free-text order notes |
| 25 | courier_notes | textarea (2 rows) | No | Delivery instructions for the courier |
Gift Packaging (Conditional)
Shown only when GIFT_PACKAGING.ENABLED registry key is truthy. See Gift Packaging section above.
| # | Field name | HTML type | Required | Server validation |
|---|---|---|---|---|
| 26 | gift_packaging | checkbox | Conditional | trim|required (only validated when POST value is present) |
| 27 | gift_packaging_message | textarea | Conditional | trim|required (only validated when POST value is present) |
Invoice / B2B Fields (Conditional)
Required only when paymerch === 'invoice'. See Invoice Type section below.
| # | Field name | HTML type | Required when invoice | Server validation |
|---|---|---|---|---|
| 28 | paymerch | radio (receipt / invoice) | Yes | trim|required|callback_checkPaymerch (must be 'invoice' or 'receipt') |
| 29 | afm | text | Yes | trim|required |
| 30 | doy | text | Yes | trim|required |
| 31 | profession | text | Yes | trim|required |
| 32 | company | text | Yes | trim|required |
| 33 | company_address | text | Yes | trim|required |
| 34 | json_invoice | hidden | No | Auto-populated by VAT validation response |
Hidden Fields
| # | Field name | Source | Purpose |
|---|---|---|---|
| 35 | useAddress | Computed ('0' / '1' / '2') | trim|required|orderAddress -- determines address mode |
| 36 | order_total | v-model="cartProductsTotal" | Cart subtotal for server-side minimum checks |
| 37 | order_currency | getSelectedCurrency.code | Active currency code (e.g. EUR) |
| 38 | currency_id | getSelectedCurrency.id | Currency DB id |
| 39 | currency_rate | getSelectedCurrency.rate | Exchange rate relative to base currency |
| 40 | klarnaAuthorizationToken | Klarna widget | Populated after Klarna authorization |
| 41 | klarnaPaymentSession | Klarna widget | Klarna session ID |
| 42 | is_guest | radio ('0' / '1') | Guest vs register toggle (hidden when logged in) |
| 43 | sameaddress | checkbox | '1' when billing = shipping |
| 44 | preview_submit | submit button | value="true" -- triggers server-side processing |
| 45 | smartPointJsonData | SmartPoint component | JSON-serialized pickup point data |
| 46 | transporterExternalRate | External transporter component | JSON-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():
| Field | Purpose |
|---|---|
coupon_code | Applied coupon code |
payway | Selected payment gateway key |
store | Selected store ID for pickup |
deliverytime | Preferred delivery time |
installments | Payment installment count |
transport_id | Selected 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
| # | Key | Type | Installments | Availability constraints |
|---|---|---|---|---|
| 1 | delivery | Offline (COD) | No | Disabled when a SmartPoint-only transporter is selected (getIsSmartPointOnlyTransporter). Shows COD surcharge inline if deliveryCostVerbal > 0 |
| 2 | bank_transfer | Offline | No | Always available when enabled |
| 3 | paid_at_store | Offline (in-store) | No | Disabled when cartProductsTotal <= minCartTotalAmountForPaidAtStore. Selecting it forces store pickup address mode and hides courier selection |
| 4 | alpha | Online (Alpha Bank) | No | Requires ALPHABANK.VERSION, ID, SSK, SUBMIT |
| 5 | apcopay | Online (ApcoPay/Piraeus) | Yes (dropdown) | Installment dropdown shown when APCOPAY.USE_INSTALLMENT_OPTIONS is enabled |
| 6 | eurobank | Online (Eurobank) | Yes (dropdown) | Installment dropdown shown when installments count > 1 AND orderTotal >= minimumOrderTotal |
| 7 | ethniki | Online (NBG) | No | Requires ETHNIKI.PUBLIC_KEY, PRIVATE_KEY |
| 8 | ethniki_ee | Online (NBG e-Commerce) | No | Requires ETHNIKI_EE.HOST, DIRECT_API_KEY, MERCHANT_ID |
| 9 | piraeus | Online (Piraeus Bank) | Yes (dropdown) | Installment dropdown shown when installments count > 1 AND orderTotal >= minimumOrderTotal |
| 10 | jcc | Online (JCC Payment Systems) | No | Requires JCC.USERNAME, PASSWORD |
| 11 | iris | Online (IRIS Payments) | No | Requires IRIS.USERNAME, PASSWORD, CUSTOMER_CODE, CHECK_DIGIT |
| 12 | stripe | Online (Stripe) | No | Requires STRIPE.PUBLISHABLE_KEY, SECRET_KEY |
| 13 | vivawallet | Online (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() |
| 14 | paypal | Online (PayPal Classic) | No | Requires PAYPAL.USERNAME, PASSWORD, SIGNATURE |
| 15 | paypaladvanced | Online (PayPal Advanced/Checkout) | No | Requires PAYPALADVANCED.CLIENT_ID, CLIENT_SECRET |
| 16 | paybybank | Online (Pay By Bank) | No | Requires PAYBYBANK.API_KEY, API_URL |
| 17 | klarna_payments | Online (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 |
| 18 | proxypay | Online (ProxyPay) | No | Legacy 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_paymentscase atassets/vue/mixins/checkoutPage.js:436-446uses a compound condition that includes storeId and transporterId checks in addition to the country check. The simplifiedreturn 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.apcoInstallmentshas entries - piraeus: when
previewOrderData.piraeusInstallments.installmentshas > 1 entry ANDorderTotal >= minimumOrderTotal - eurobank: when
previewOrderData.eurobankInstallments.installmentshas > 1 entry ANDorderTotal >= 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
| Mode | Radio value | useAddress | Behavior |
|---|---|---|---|
| Courier delivery | isCourierAddress = '1' | '0' (shipping) or '1' (billing) | Transporter selector shown; SmartPoint available |
| Store pickup | isCourierAddress = '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:
| Trigger | Source field |
|---|---|
| Country | orderCountry (billing or shipping depending on sameShipping) |
| County | orderCounty |
| Postal code | orderPostal (whitespace stripped) |
| Cart total | cartTotalWithDiscount |
| Cart weight | cartProductWeightTotal |
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)
- Customer types coupon code into the input field
- Each keystroke fires
updateCoupon()which:- Aborts any in-flight request via
AbortController - Starts a 300ms debounce timer (
setTimeout) - After debounce, dispatches
checkCouponVuex action
- Aborts any in-flight request via
- The action POSTs to
POST /order/preview_couponwith{ coupon, invoice, useAddress, billingCountry, shippingCountry } - Response returns
{ discount: <number> }which updatespreviewOrderData.couponDiscount - 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):
- Empty coupon code passes (optional field)
- VAT context is set from current form state (
useAddress,paymerch,country,sendto_country) coupons_model->isValidCoupon()checks againstCouponCheckConfig()rules:- Coupon existence and active status
- Usage limits (per-coupon, per-customer)
- Minimum order total
- Product/category eligibility
- Customer audience membership
- On failure, sets error message
'preview.order.invalid.coupon'
5. Invoice Type
Radio Selection
| Value | Label | Behavior |
|---|---|---|
receipt | Receipt | Default. Invoice fields hidden, afm/doy/profession/company/company_address validated as trim only |
invoice | Invoice | Invoice 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:
previewOrderData.loyalty.pointSystemIsEnabledis truthy (registry:POINT_SYSTEM.IS_ENABLED)previewOrderData.loyalty.customerTotalPoints > 0previewOrderData.loyalty.customerCash > 0previewOrderData.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,customerCashis subtracted fromorderTotal - Server rule:
trimonly (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.
| # | Provider | Checkbox name | Display condition (registry) | Extra condition |
|---|---|---|---|---|
| 1 | Manago | register_manago_newsletter | MANAGO.ENABLE_API + MANAGO.ENABLE_NEWSLETTER_REGISTER | Hidden if logged-in customer already in Manago (customer_in_manago session flag) |
| 2 | Moosend | register_moosend_newsletter | MOOSEND.ENABLED + MOOSEND.API_KEY + MOOSEND.CHECKOUT_LIST_ID | Hidden if logged-in customer already in Moosend (customer_in_moosend session flag) |
| 3 | Apifon | register_apifon_newsletter | APIFON.ENABLED + APIFON.SUBSCRIBE_TO_LIST_ENABLED + APIFON.SUBSCRIBER_LIST_ID | Always shown when conditions met |
| 4 | Mailchimp | register_mailchimp_newsletter | MAILCHIMP.ENABLED + MAILCHIMP.API_KEY + MAILCHIMP.SERVER_PREFIX + MAILCHIMP.CHECKOUT_LIST | Always 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
| Field | HTML type | Required | Notes |
|---|---|---|---|
accept_terms | checkbox | Yes (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
+ giftPackagingCostWhere:
cartProductsTotal-- sum of all cart item prices (with VAT, after per-item discounts)couponDiscount--parseFloat(previewOrderData.couponDiscount) || 0from AJAX validationdeliveryCost--transporter.deliveryCostwhenpayway === 'delivery'(COD surcharge), else0transportationCost--transporter.transportationCostfor eshop-calculated transporters;getSelectedTransporterExternalRate.pricefor external-rate transporters;0for store pickup orpaid_at_storecustomerPointsCash--previewOrderData.loyalty.customerCashwhenredeemPointscheckbox is checked, else0giftPackagingCost--config.giftPackaging.costwhengetGiftPackagingis truthy, else0
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:
| # | Check | Error key | Condition |
|---|---|---|---|
| 1 | Store selection | localErrors.store | Store pickup mode + multiple stores + no store selected |
| 2 | Transporter selection | localErrors.transporter | Courier mode + no transporter selected |
| 3 | External rate missing | localErrors.transporter | Courier mode + external-rate transporter + no rate fetched |
| 4 | Shipping address completeness | localErrors.shipping | Separate shipping mode + any of sendto_name/surname/landphone/city/postal/address/country/county empty |
| 5 | Invoice fields completeness | localErrors.invoice | Invoice type + any of company_afm/company_doy/company_name/company_address/profession empty |
| 6 | Invoice type validity | localErrors.invoice | invoiceType is neither 'invoice' nor 'receipt' |
| 7 | Gift selection validity | localErrors.gifts | giftsValidation() returns false (gift rule requires selection but none made) |
| 8 | SmartPoint selection | localErrors.smartPoint | SmartPoint-only transporter selected but no point chosen, OR SmartPoint mode active with no point selected |
| 9 | Payment/delivery combo | localErrors.payWayDeliveryComboDisallowed | paid_at_store + store pickup combo disallowed, OR delivery (COD) + store pickup |
| 10 | Required HTML fields | localErrors[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 group | Fields | Callbacks |
|---|---|---|
| Billing | name, surname, address, city, postal, landphone, county, country | postalCheck[{country}] on postal |
| Identity | mail, password, password_retype | email_check[{isGuest}] on mail; matches[password] on password_retype |
| Invoice | afm, doy, profession, company, company_address | Required only when paymerch === 'invoice' |
| Payment | payway, paymerch | callback_checkPaymerch |
| Transport | transport_id | callback_isValidTransporter, callback_isValidSmartPointTransporter (only when useAddress != ORDER_ADDRESS_ESHOP) |
| Store | store | callback_validateMinimumCartTotalForPaidAtStore, in_list[{activeStoreIds}], callback_paywayCheckValidation (required when paid_at_store or store pickup) |
| Address mode | useAddress | Custom orderAddress validator |
| Shipping | sendto_* (10 fields) | Required only when useAddress === ORDER_ADDRESS_SHIPPING; postalCheck on sendto_postal |
| Coupon | coupon_code | callback_checkCoupon (re-validates against cart, audiences, limits) |
| SmartPoint | smartPointJsonData | callback_smartPointJsonDataCheck (valid JSON with content) |
| Gift packaging | gift_packaging, gift_packaging_message | Required only when POST value is present |
| Loyalty | redeemPoints | trim only (amount recalculated server-side) |
Key callbacks:
postalCheck($postal, $country)-- country-specific postal code format validationemail_check($email, $isGuest)-- prevents registration with existing email (skipped for guests)isValidTransporter()-- verifies the selected transporter is available for the customer's address viaTransporters::isAvailable()isValidSmartPointTransporter()-- ensures SmartPoint-only transporters have a selected pointpaywayCheckValidation()-- blocks COD + store pickup combinationvalidateMinimumCartTotalForPaidAtStore()-- enforces minimum cart total for in-store payment
Data Model
Key tables touched during checkout preview
| Table | Purpose |
|---|---|
shop_customer | Customer record (created or updated during checkout; is_guest=1 for guest checkout) |
shop_customer_addresses | Billing and shipping addresses (saved for returning customers) |
shop_transporters | Shipping transporter definitions (cost rules, smart point config, external rates) |
shop_transporter_costs | Weight/destination-based shipping cost tiers |
coupons / coupon_rules | Coupon validation at checkout (discount type, min order, usage limits) |
gifts / gift_requirements | Gift eligibility rules evaluated against cart contents |
shop_stores | Store pickup locations (for ORDER_ADDRESS_ESHOP mode) |
shop_order_dhl_vouchers | DHL-specific voucher data written by DhlVoucherPersister |
shop_order_asap_data | ASAP-specific carrier data written by AsapDataPersister |
shop_order_smart_point | Smart-point pickup selection written by SmartPointPersister |
Legacy path: order creation happens in the next step — see CF-07 Order Confirmation for the
shop_ordertable schema.Modern REST path:
PlaceOrderService::placeOrder()creates theshop_orderrecord directly within the same request (src/Domains/Checkout/PlaceOrderService.php:198) and writesshop_order_basketrows viaOrderBasketWriteService::createForOrder()(src/Domains/Checkout/PlaceOrderService.php:209). For theshop_orderandshop_order_baskettable schemas, see CF-07 Order Confirmation.
Known Issues & Security Gaps
Offline adapters set
PENDING_ACCEPTED; legacy setsPENDING. As of 4.99.6,DeliveryAdapter,BankTransferAdapter, andPaidAtStoreAdapterreturnPENDING_ACCEPTEDinstead ofPENDING. The legacy checkout flow still setsPENDINGfor 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).RESOLVED (commitOrderBasketBuildersilently drops unresolvable cart items;PlaceOrderServicedoes not check basket row count.9d019d1c9, #29) — Guard atsrc/Domains/Checkout/PlaceOrderService.php:182-189checkscount($basketRows) !== count($items)and throws aRuntimeExceptionidentifying the missingproduct_code_idvalues.Orphaned-order window between
shop_orderinsert and basket transaction: Theshop_orderrecord is created atsrc/Domains/Checkout/PlaceOrderService.php:198outside thecreateForOrder()DB transaction (which wraps inserts atsrc/Domains/Order/OrderBasket/WriteService.php:55-64). A crash between steps 8 and 10 leaves ashop_orderrow with zero basket rows and no items. Re-cite:src/Domains/Checkout/PlaceOrderService.php:198(order create) and:209(basket create).RESOLVED (commitresolveGuestCustomer()attaches orders to registered accounts by email without authentication.3597ad539, #3) —src/Domains/Checkout/PlaceOrderService.php:354-360now rejects checkout when a matched customer hashas_access=1, throwing aRuntimeException. A valid email is also required (:350-352).RESOLVED (commitcancelPayment()has no caller in the modern REST layer.5eb2eddbe, #30) —POST /rest/checkout/cancel-payment/{orderId}registered atrest_routes.php:1713(and lang-prefixed at:1721), handled bysrc/Rest/Checkout/Controllers/Checkout.php:495-547.PaymentConfirmationService::confirmPayment()idempotency guard includesPENDING_ACCEPTEDunnecessarily —PENDING_ACCEPTEDis the terminal status for offline payments, which never enterPENDING. The guard is harmless but suggests the idempotency design may not be fully considered. Citation:src/Domains/Checkout/PaymentConfirmationService.php:46-48.MarketingConsentCapturedevent fires but zero listeners are wired —PlaceOrderService.php:252-262dispatchesMarketingConsentCapturedon every order wheremarketingConsent=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 file | What it covers |
|---|---|
tests/Unit/Checkout/PlaceOrderServiceTest.php | Unit tests for PlaceOrderService — guest resolution, order creation, basket building, coupon marking, and payment initialization |
tests/Unit/Checkout/GuestCheckoutTest.php | Guest checkout flow — account attachment by email, is_guest flag, and random password assignment |
tests/Unit/Checkout/CouponValidatorTest.php | Coupon validation rules — existence checks, usage limits per coupon and per customer, minimum order total, product/category eligibility |
tests/Unit/Checkout/PaymentInitializerTest.php | PaymentInitializerFactory — adapter selection and payment initialization per gateway key |
tests/Unit/Checkout/ShippingCalculatorTest.php | Shipping cost calculation — transporter selection, external rate fetching, and overweight cost |
tests/Unit/Domains/Checkout/OrderBasketBuilderTest.php | OrderBasketBuilder — cart item to basket row transformation including pricing, VAT, discounts, and loyalty point resolution |
tests/Unit/Domains/Checkout/PaymentConfirmationServiceTest.php | PaymentConfirmationService — idempotent confirmation (does NOT reduce stock, #282), cancellation with stock restoration via restoreStockForOrder() (#282), and OrderPaid/OrderCanceled event dispatch |
tests/Unit/Domains/Checkout/StockServiceTest.php | StockService — atomic stock reduction and restoration using raw SQL expressions to prevent race conditions |
tests/Unit/Domains/Order/OrderBasket/WriteServiceTest.php | OrderBasket\WriteService — createForOrder() transactional basket row inserts |
tests/Unit/Domains/Order/OrderBasket/ServiceTest.php | OrderBasket\Service — read-side basket queries and associated business logic |
tests/Integration/Domains/Order/OrderBasket/RepositoryTest.php | Integration tests for OrderBasket\Repository — DB-backed query correctness against a real schema |
tests/Integration/Domains/Order/OrderBasket/ServiceTest.php | Integration 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/.
Related Flows
- CF-05 Cart Management — cart feeds into preview
- CF-07 Order Confirmation — next step
- CF-08 Payment Processing — payment after confirmation
- CF-09 Payment Webhooks — async payment callbacks
- CF-10 Customer Auth — login/register during checkout
- CF-13 Coupons — coupon validation at checkout
- CF-14 Gift Rules — gift selection during checkout
- CF-32 Loyalty Points — points redemption at checkout
- AD-06 Transporter Admin — shipping method configuration
- AD-50 VAT Management — VAT rate configuration applied during checkout
- AD-54 POS / Phone Orders — admin-side order creation that parallels this checkout flow
Wiki Guides: Stripe payment adapter configuration — see Stripe Guide. External rate transporters may use circuit breakers — see Circuit Breaker Guide.
Shared Patterns
- SY-24 Email Dispatch — newsletter signup dispatches triggered from checkout
- SY-26 Circuit Breaker — protects external service calls (VAT validation, external transporter rates)
- SY-20 Geolocation & Shipping Zones — how transporter availability is resolved by address