Appearance
Order Preview (Checkout Form)
Flow ID: CF-06 | Module(s): eshop, Checkout domain | Complexity: Very High Last Updated: 2026-09-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 | Threshold-aware shipping price list (#568) — free-shipping threshold, TRANS_FREE_ALL, and the overweight per-kg surcharge are applied per transporter; also returns overweightCost (display-only) and deliveryCost (COD surcharge, gated on an optional payWay request field). Each transporter also carries a codAllowed bool (#338) — whether it accepts cash on delivery, the same verdict place-order enforces (src/Rest/Checkout/Controllers/Checkout.php:257-263, src/Domains/Checkout/ShippingCalculator.php:255-258) |
| POST | /rest/checkout/coupon/validate | Guest/Customer | Validate coupon against cart |
| POST | /rest/checkout/totals | Guest/Customer | Calculate full totals (items + shipping + delivery cost + coupon + loyalty-points redemption + gift matching); runs gift-matching rules and emits giftDiscount, giftPackagingCost, payableBeforePoints, and a loyalty decision block (preview() + wouldExceed) in the response (src/Rest/Checkout/Controllers/Checkout.php:859-876, gift quoting :727-737 and :902-934, decision block :853-857); the coupon is deducted from the gross subtotal BEFORE the shipping threshold is evaluated (#568) |
| POST | /rest/checkout/place-order | Guest/Customer | Place order from cart; returns 422 with error code loyalty_redemption_exceeds_order_total when loyalty redemption exceeds the payable total (#573, src/Rest/Checkout/Controllers/Checkout.php:1089-1097 (OpenAPI), catch arm :1219); also returns 422 with error code payway_not_available (#673) when payway is outside the merchant's METHODS.PAYWAY_EXTERNAL selection (Checkout.php:1078-1087 (OpenAPI), catch arm :1180, exception src/Domains/Checkout/Exceptions/PaywayNotAvailableException.php:51) — PaywayNotAvailableException MUST be caught before the \RuntimeException arm, of which it is a subclass; also returns 422 with error code invoice_identity_incomplete (#760) when wantsInvoice is true and any of afm/doy/profession/company/companyAddress is blank after trimming — raised at PlaceOrderService::placeOrder() step 0b (src/Domains/Checkout/PlaceOrderService.php:160-187, missingInvoiceIdentityFields() at :1026-1045), exception src/Domains/Checkout/Exceptions/InvoiceIdentityIncompleteException.php, REST catch arm Checkout.php:1241-1265 (also caught before the \RuntimeException arm) — see Invoice Type below, which the modern REST layer now enforces at parity with legacy; also returns 422 with error code payway_not_allowed_for_transporter (#338) when the payway is offered but not with the chosen transporter (cash on delivery with a smart-point-only transporter whose DELIVERY_OPTION is off) — raised at gate 0c (PlaceOrderService.php:189-196, PaywayNotAllowedForTransporterException, code at src/Domains/Checkout/Exceptions/PaywayNotAllowedForTransporterException.php:28), REST catch arm Checkout.php:1204-1218, body carries error.payway and error.transportId. The 201 body carries an optional transactionId (#724) — omitted when empty; for paybybank it is the bank payment code (src/Domains/Checkout/PlaceOrderResult.php:38-55, forwarded at PlaceOrderService.php:803-812) |
| GET | /rest/checkout/payment-methods | Guest | Returns the payways the merchant SELECTED for external frontends — METHODS.PAYWAY_EXTERNAL (#672) intersected with the adapters PaymentInitializerFactory actually registers (#673), returned in the merchant's SAVED order, FAIL-CLOSED (an unset or empty key returns an EMPTY list — it does not fall back to METHODS.PAYWAY) (application/config/rest_routes.php:1752, locale twin :1760; controller paymentMethods() Checkout.php:1555-1587, OpenAPI :1534) |
| 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:1756, lang-prefixed :1764; controller cancelPayment() Checkout.php:1642-1694, OpenAPI :1628) |
Browse endpoints interactively in the API Reference.
Legacy Storefront
| URL | Controller | Method |
|---|---|---|
/preview_order | Adv_order.php (eshop/order/preview, routes.php:305-306) | preview() |
Code Flow (Modern REST)
File: src/Rest/Checkout/Controllers/Checkout.php (1947 lines)
PlaceOrderService (src/Domains/Checkout/PlaceOrderService.php) handles: 0. Refuse a payway the merchant did not select for external frontends (#673) — PaywayNotAvailableException if payway is not in externalPayways() (METHODS.PAYWAY_EXTERNAL ∩ registered adapters). Position is load-bearing: this is the FIRST statement of placeOrder(), ahead of even the guest-customer insert, because the method runs in NO transaction — a refusal raised later (e.g. at the payment-adapter call, step 14) would leave an orphaned PENDING shop_order row (src/Domains/Checkout/PlaceOrderService.php:136-157) 0c. Refuse a payway the chosen transporter does not accept (#338) — throws PaywayNotAllowedForTransporterException (REST 422 payway_not_allowed_for_transporter) when CashOnDeliveryPolicy::allowsPayway() is false for the payway/transporter pair; placed ahead of the guest-customer insert for the same no-transaction reason as step 0 (src/Domains/Checkout/PlaceOrderService.php:189-196)
- Resolve and validate the cart via
resolveCart()— BEFORE any guest customer exists (#798).resolveCart()treats a non-null customer id as conclusive, so creating the guest first handed it a brand-new id that owns no cart, made the cart-token branch unreachable and ended every guest order in "Cart not found"; looking the cart up first also means a token matching nothing is refused without leaving an orphan customer row (src/Domains/Checkout/PlaceOrderService.php:198-210) - Resolve customer ID — create the guest customer only now (
resolveGuestCustomer(),PlaceOrderService.php:212-217). It writesdate_registered => time()— a unix int; the pre-#798 datetime string threw before any row existed (PlaceOrderService.php:856-859) - Load cart items 3b. Match gifts via
GiftMatcherand apply outcomes to yield$giftDiscount/$netGiftDiscount(src/Domains/Checkout/PlaceOrderService.php:241-254) 3c. Yield$giftDiscountandapplyGiftOutcomefor totals calculation - Calculate totals with coupon
- Validate shipping selection — the calculator is priced on the ship-to address,
$shippingAddr = $data->shippingAddress ?? $data->billingAddress(#808; previously the billing address) (src/Domains/Checkout/PlaceOrderService.php:294-309) - 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()) 7b. Check loyalty redemption exceeds payable viaexceedsPayable()(#573) and throwLoyaltyRedemptionExceedsOrderTotalExceptionif true — mapped to HTTP 422 in the REST layer (src/Domains/Checkout/PlaceOrderService.php:459-464) - Create order in database from
$orderData(withorder_currency,currency_id, andcurrency_ratepopulated, resolved at step 6). No cart snapshot is built or written — the formerbuildCartContentsSnapshot()step and itscart_contentsassignment were removed in #605;shop_order.cart_contentswas never a real column, so nothing is lost. Basket rows are written separately at step 10, fromOrderBasketBuilder's output, not from anything assembled here (src/Domains/Checkout/PlaceOrderService.php:565) 8b. Signed-in customers only —CheckoutCustomerDetailsWriter::save()writes the checkout details onto the customer'sshop_customerrow (#837), mirroring the storefront'supdate_customer(); it runs once the order row exists and before payment, logs and swallows its own failure, and guests are skipped (src/Domains/Checkout/PlaceOrderService.php:571-581) - Generate and assign order serial
- Write basket rows via
OrderBasketWriteService(createForOrder()) — rows were already built byOrderBasketBuilderearlier (~step 3) (src/Domains/Checkout/PlaceOrderService.php:589) - Persist carrier-specific transporter data via
CarrierDataDispatcher(see Carrier Data Persistence below) - Mark coupon as used via
CouponCodeWriteRepository::incrementUsed()12b. DispatchMarketingConsentCapturedevent ifmarketingConsentis true and a customer email exists (src/Domains/Checkout/PlaceOrderService.php:635-649) - Clear the cart
- Initialize payment via
PaymentInitializerFactory - Reserve stock at placement for every payway —
StockService::reduceStockForOrder()is called unconditionally (src/Domains/Checkout/PlaceOrderService.php:770, #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:772-789). AbandonedPENDINGorders are restored by theAdvCancelIncompleteOrderscron; explicit cancellation callscancelPayment()which restores stock viaStockService::restoreStockForOrder(). - Return order ID + optional redirect URL + optional
transactionId(#724; omitted when empty, the bank payment code forpaybybank) (src/Domains/Checkout/PlaceOrderService.php:803-812,src/Domains/Checkout/PlaceOrderResult.php:38-55)
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:116-128). This service is idempotent -- calling it on an already-paid order returns true without side effects. The confirm-payment response no longer carries isPaid (#754) — status is the verdict and confirmed only says whether this call verified the payment with the gateway (src/Rest/Checkout/Controllers/Checkout.php:1482, :1527).
Gateway scope for confirm-payment: The internal verifyWithGateway() helper (src/Rest/Checkout/Controllers/Checkout.php:1712-1739) only handles stripe, vivawallet, and paypaladvanced. Requesting confirmation for any other payway returns false — the endpoint silently no-ops for unsupported gateways.
Viva Wallet needs a transactionId (#796): confirming a vivawallet order requires {"transactionId": ...} in the request body (accepted as transactionId or transaction_id); without it verification fails closed and the response is confirmed:false (src/Rest/Checkout/Controllers/Checkout.php:1507-1511, :1797-1816). Viva's own answer is then checked for an orderCode match against the order's stored tran_ticket, a settled status (F or C), and the amount (Checkout.php:1837-1913).
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:1741-1755).
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:565). 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:139-190) 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() (:175, #282 — stock was reserved at placement for every payway), and dispatches the OrderCanceled event (src/Domains/Checkout/PaymentConfirmationService.php:181-187). 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:116-128) after writing PAID status. For offline adapters (delivery, bank_transfer, paid_at_store), PlaceOrderService dispatches it directly when $paymentResult->status === 'PENDING_ACCEPTED' (PlaceOrderService.php:772-789). On cancellation, OrderCanceled is dispatched (PaymentConfirmationService.php:181-187).
OrderEventDispatcher fans out synchronously to 8 OrderPaid listeners (confirmation email, ERP, low-stock, gift stock decrement, Matomo, Meta CAPI, Manago, Project Agora) and 3 OrderCanceled listeners (loyalty restore, coupon restore, gift stock restore) (src/Domains/Order/container.php:157-168). MarketingConsentCaptured fires (PlaceOrderService.php:635-649) but has no wired listeners yet (intended for future CRM-sync).
See CF-07 Order Confirmation 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:770). 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:772-789).
Code Flow (Legacy)
File: ecommercen/eshop/controllers/Adv_order.php::preview() (line 192-409)
- 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 |
ShippingCalculator | src/Domains/Checkout/ShippingCalculator.php | Threshold-aware shipping cost calculator (#568) — port of legacy AdvTransporters::transportCost()/deliveryCost(): free-shipping threshold, overweight per-kg surcharge, delivery cost, and two protected client-override hooks. Shared by Checkout::shipping() and Checkout::totals() and PlaceOrderService::placeOrder() so the price list, quote, and charge never disagree. See Shipping Cost Calculation below |
CartWeightCalculator | src/Domains/Cart/CartWeightCalculator.php | Computes total cart weight in grams (#568) — the modern port of the weight accumulation in Adv_order_model::baseParseCartContents(); feeds ShippingCalculator's overweight branch. Deliberately a separate collaborator from CartTotalsCalculator so a fork override cannot silently zero the surcharge. Shared by /rest/checkout/shipping and /rest/checkout/totals and PlaceOrderService::placeOrder() |
LoyaltyRedemption | src/Domains/Checkout/LoyaltyRedemption.php | Owns loyalty redemption maths shared by Checkout::totals() (quote) and PlaceOrderService::placeOrder() (charge), so they never disagree. Includes exceedsPayable() guard (#573) and debit operation |
GiftMatcher | src/Domains/Checkout/Gift/GiftMatcher.php | Evaluates gift eligibility rules against cart and matches applicable gifts, yielding discount and packaging outcomes for the quote and charge |
GiftPackagingResolver | src/Domains/Checkout/Gift/GiftPackagingResolver.php | Resolves gift packaging cost from registry configuration; called by both quote and charge paths |
Product\Pricing\PriceResolver | src/Domains/Product/Pricing/PriceResolver.php | The single pricing entry point (#563, phase 3 of epic #566) — composes DiscountResolver and VatResolver in a fixed order (discount on the NET price first, then VAT; the reverse rounds at a different point and drifts by cents) and returns a UnitPrice carrying both bases. OrderBasketBuilder::buildRow() and Advisable\Domains\Cart\CartTotalsCalculator both price through the same composition, so shop_order_basket.price matches the cart-preview subtotal for the same product, in either basis. |
Product\Pricing\DiscountResolver | src/Domains/Product/Pricing/DiscountResolver.php | Shared catalogue-discount resolver (#476), consumed by PriceResolver — reproduces the legacy new_discount rule: OTHER.ENABLE_SPECIAL_DISCOUNTS gates any special discount; an active window requires both special_from/special_to non-NULL and non-empty with now strictly between them; inside the window the swap to special_discount_percent is unconditional (a 0% special overrides a non-zero discount_persent, it does not fall through). Deliberately VAT-unaware — its output is NET (VAT-exclusive); VAT is applied afterwards by VatResolver |
Product\Pricing\VatResolver | src/Domains/Product/Pricing/VatResolver.php | Resolves the effective VAT rate and converts NET → GROSS (#563) — a byte-for-byte reproduction of legacy's applyVatWithoutFormat(). The rate is read off the product's vat relation and routed through the VatForOrder client-override singleton (the same seam Adv_product_parser_model::setPrices() uses), so a fork's invoiceVat() policy applies to REST orders too. Core ships enableOrderVatManipulation = false, making this a pass-through by default. Known gap: the REST checkout never calls VatForOrder::setInvoice() / setDeliverAreaType() / setChargeAreaType() (unlike Adv_order::setVatForOrder() and AdvApiCartController), so it resolves through an unconfigured singleton and always gets the pass-through receipt rate — a no-op on a default install, but a fork that enables enableOrderVatManipulation could charge VAT on a REST order shipping outside the EU that the legacy storefront zero-rates. Not fixed by #563 |
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:598-602). 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:78), parsed from marketingConsent/marketing_consent via FILTER_VALIDATE_BOOLEAN (:170-174). When true and a customer email exists, PlaceOrderService.php:635-649 dispatches MarketingConsentCaptured event (with ipAddress) before the payment redirect — mirrors legacy Adv_order::previewOrder() ordering (#269, commit 52ece123d).
Loyalty-Points Redemption at Checkout
PlaceOrderData::$redeemPoints (src/Domains/Checkout/PlaceOrderData.php:45) is a boolean, accepted as redeemPoints or redeem_points via FILTER_VALIDATE_BOOLEAN (:140-143) — the client can only opt in to "spend my points"; it can no longer name an amount. The former pointsSpend integer request field is retired (#495) — it subtracted a point COUNT straight off the money total, a latent ~1000× over-discount, and never wrote shop_order.points_reward.
Advisable\Domains\Checkout\LoyaltyRedemption (src/Domains/Checkout/LoyaltyRedemption.php) owns the redemption maths and is the one collaborator shared by the charge and the quote, so they can never disagree:
- Charge —
PlaceOrderService::placeOrder()callsLoyaltyRedemption::resolve()(PlaceOrderService.php:370-373), passingnullinstead of the resolved$customerIdwhen$data->isGuest()(PlaceOrderData::isGuest(),:116) is true, so a guest checkout is an explicit no-op rather than one that happens to net out at a zero balance. - Quote —
Checkout::totals()calls the sameresolve()(src/Rest/Checkout/Controllers/Checkout.php:817), so a client that posts the sameredeemPointsto both/rest/checkout/totalsand/place-ordersees the identical discount in each response (pointsSpend/pointsCashin the totals payload,Checkout.php:868-869).
LoyaltyRedemption::resolve(?int $customerId, bool $redeem) (LoyaltyRedemption.php:95-134) returns a zero redemption (no discount, no debit, no columns written) when not redeeming, guest, POINT_SYSTEM.IS_ENABLED is off, or ESHOP.SPEND_POINTS <= 0 — a missing ratio fails closed rather than falling back to a 1:1 assumption. Otherwise points = floor(balance / SPEND_POINTS) * SPEND_POINTS and cash = (points / SPEND_POINTS) * REWARD_CASH (:321-328). The ratio is never 1:1 (a real shop runs e.g. 518 points ⇒ €8) — cash, not points, is what comes off the total.
#573 refusal guard: Before subtracting loyalty redemption from the order total, PlaceOrderService::placeOrder() checks whether the redemption exceeds the payable amount via $this->loyaltyRedemption->exceedsPayable($loyalty['cash'], $payableBeforePoints) (src/Domains/Checkout/PlaceOrderService.php:459-464). When the redemption >= payable, the service throws LoyaltyRedemptionExceedsOrderTotalException before any mutation, and the REST layer maps this to HTTP 422 with error code loyalty_redemption_exceeds_order_total (src/Rest/Checkout/Controllers/Checkout.php:1089-1097 OpenAPI, catch arm :1219). The predicate checks cash >= payableBeforePoints with a rounding backstop and requires cash > 0 (src/Domains/Checkout/LoyaltyRedemption.php:242-249). Legacy equivalent Adv_order::refuseLoyaltyRedemptionExceedingTotal() at ecommercen/eshop/controllers/Adv_order.php:1042-1100 enforces the same gate.
PlaceOrderService::placeOrder() subtracts $loyalty['cash'] from the order total only after the guard passes (:430), writes the point COUNT to shop_order.points_spend and the cash VALUE to shop_order.points_reward (:543-544), then — after the order header is persisted, guarded on points > 0 && $customerId !== null — debits the balance via LoyaltyRedemption::debit() (PlaceOrderService.php:620-622 → LoyaltyRedemption.php:302-309). The debit is a single atomic GREATEST(total_points - N, 0) SQL decrement (Customer\Customer\Repository\WriteRepository::decrementPoints(), :30), replacing a prior read-modify-write and closing a lost-update race between concurrent checkouts by the same customer. Legacy Adv_loyalty::savePointsToCustomer() permits the column to go negative; the GREATEST(..., 0) clamp is a deliberate, documented deviation.
Deliberately not gated on cash > 0: a shop with the point system on but REWARD_CASH = 0 still debits the balance and writes points_spend/points_reward while discounting €0 — strict legacy parity, not a bug.
The loyalty section of GET /rest/storefront-config (Advisable\Domains\StorefrontConfig\Loyalty\LoyaltyConfigResolver::resolve(), src/Domains/StorefrontConfig/Loyalty/LoyaltyConfigResolver.php:55-67) exposes spendPoints and rewardCashPerUnit so a headless client can render the ratio before checkout. It deliberately carries no enabled key — that gate is POINT_SYSTEM.IS_ENABLED, already the loyalty boolean on GET /rest/features.
Restoration on cancel is unchanged by this work — still RestoreSpentPointsListener reading shop_order.points_spend (#84, commit 3597ad539).
This is the redeeming stage only. Earning (points snapshotted per basket row at order creation) and awarding (two cron jobs plus two admin actions, off the checkout entirely) are untouched by #495 — see CF-32 Loyalty Points for the full three-stage picture and REST parity detail.
Shipping Cost Calculation (Modern REST)
Until #568, ShippingCalculator::calculate() took a float $cartTotal parameter and never read it — every REST-quoted and REST-charged shipping figure was the raw transporters_options_pricing row cost, with none of the free-shipping threshold, weight surcharge, or delivery-cost logic the legacy storefront applies to the same cart. Carts above the threshold were overcharged; heavy carts undercharged the shop. ShippingCalculator::calculate() (src/Domains/Checkout/ShippingCalculator.php:185-192) now ports legacy AdvTransporters::transportCost() / deliveryCost() branch for branch, taking two new trailing parameters — float $cartWeight = 0.0 and ?string $payWay = null — so a pre-#568 4-argument call site still works.
Single collaborator, three callers. Checkout::shipping() (src/Rest/Checkout/Controllers/Checkout.php:280-337), Checkout::totals() (the shipping-calc block within it, :753-784), and PlaceOrderService::placeOrder() (src/Domains/Checkout/PlaceOrderService.php:277-325) all call the same ShippingCalculator::calculate() — the last one pricing on the ship-to address, $data->shippingAddress ?? $data->billingAddress (#808; it was billing before, :294-309) — so the price list, the checkout-totals quote, and the amount actually charged can never disagree.
Free-shipping threshold and weight surcharge (ShippingCalculator::resolveChargedCost(), :271-341):
TRANS_COST_LIMIT— cart total STRICTLY above it sets$checkFree.TRANS_FREE_ALL— when truthy, a cart below the weight limit ships free even without clearing the threshold.WEIGHT_LIMIT/PRICE_PER_KG— a cart over the weight limit paysPRICE_PER_KG * ceil(($cartWeight - $weightLimit) / 1000). The$checkFreereplace-vs-add distinction: a cart already above the free-shipping threshold pays the overweight top-up instead of the base price; one below it pays base plus the top-up.All four values are read per-transporter and per-country from
transporters_options_pricing(ShippingCalculator::fetchOptionRows()/extractPricingControls(),:470-498) — never from the globalESHOP.TRANS_COST_LIMITRegistry key, which is display-only and powers the storefront's "free shipping over €X" promo banner. A transporter/country with no options row fails open to0on every key (ShippingCalculator::control(),:460-463), which means an unconfigured transporter ships free — legacy parity, not a bug, but worth checking before deploying a shop whosetransporters_options_pricingisn't fully populated.Since #643, that same display-only
ESHOP.TRANS_COST_LIMITvalue is reachable over REST asshipping.freeShippingThresholdonGET /rest/storefront-config(Advisable\Domains\StorefrontConfig\Shipping\ShippingConfigResolver::resolve(),src/Domains/StorefrontConfig/Shipping/ShippingConfigResolver.php). It is a promo-banner display figure only — a client renders it and nothing else; the shipping cost actually charged always comes fromPOST /rest/checkout/shipping/POST /rest/checkout/totals, i.e. the per-transporter, per-country values one bullet above. A cart clearing the banner threshold can still be charged shipping.
Cart weight comes from Advisable\Domains\Cart\CartWeightCalculator::calculate() (src/Domains/Cart/CartWeightCalculator.php:63-72), the modern port of the $totalWeight += $prItem->weight * quantity accumulation in Adv_order_model::baseParseCartContents() (ecommercen/eshop/models/Adv_order_model.php:568). It is a separate collaborator from CartTotalsCalculator so that a client fork overriding the totals calculator cannot silently zero the overweight surcharge.
Delivery cost (cash-on-delivery surcharge, ShippingCalculator::resolveDeliveryCost(), ShippingCalculator.php:445-452) is a separate money line, never folded into the shipping cost. It is non-zero only when payWay === 'delivery' and is waived once the cart total reaches DELIVERY_COST_MIN_FREE. PlaceOrderService persists it to shop_order.delivery_cost (PlaceOrderService.php:481) and adds it to shop_order.total_vat only ($deliveryCost is folded into $payableBeforePoints and the total at PlaceOrderService.php:423-430, written as total_vat at :479) — never to shop_order.total, which stays the NET items-only accounting column (#563).
Coupon ordering — deducted before the threshold. Legacy order of operations (Adv_order_model::create_order() deducts coupon_value from cart_total_vat before calling transfer_cost_admin()) is reproduced in PlaceOrderService::placeOrder() ($thresholdBase = $totals['subtotal'] - $couponDiscount, PlaceOrderService.php:288) and in Checkout::totals() ($thresholdBase = $itemTotals['subtotal'] - $couponDiscount, Checkout.php:751). A coupon can therefore never earn free shipping, and it can cost the customer free shipping they would otherwise have had.
Collect-at-store and cash-on-delivery zeroing. $collectAtStore is derived server-side from $payWay === 'paid_at_store' (ShippingCalculator.php:199) so a client cannot opt itself into free shipping by lying about the payway; a collect-at-store cart is charged neither shipping nor delivery cost.
overweightCost is output-only, display-duplicate. ShippingCalculator::resolveOverweightCost() (:405-430) computes the surcharge portion as a standalone figure — already included in cost/shippingCost — so a headless client can render an "includes €X overweight" line the way legacy's AdvApiTransportersController does. It is never accepted as a request field and must never be summed into a total; PlaceOrderService and Checkout::totals() both project it onto the response without adding it to $total (PlaceOrderService.php:329 comment, Checkout.php:777-778 comment).
Client-override seams. ShippingCalculator exposes two protected hooks a client fork can override instead of redeclaring calculate() wholesale:
customTransportCost()(:359-368) — a non-null return short-circuits the entire threshold/overweight block (port of legacyAdvTransporters::customTransportCost(), #389).applyTransportSurcharge()(:383-393) — called once on every serviced path, including the free-shipping branches where$priceis0.
Options-leak security fix. resolveOptions()'s predecessor serialized every transporters_options_pricing row onto the wire as a selectable {id, name, extraCost} option — so the pricing-control keys (TRANS_COST_LIMIT, WEIGHT_LIMIT, PRICE_PER_KG, TRANS_FREE_ALL, DELIVERY_COST, DELIVERY_COST_MIN_FREE, MIN_ORDER_AMOUNT) reached headless clients as purchasable pseudo-options. ShippingCalculator::extractSelectableOptions() (:508-527) now denylists them via PRICING_CONTROL_KEYS (:77-85); genuine shop-defined extras (insurance, Saturday delivery, …) are unaffected.
REST Layer (src/Rest/...)
| File | Responsibility |
|---|---|
src/Rest/Checkout/Controllers/Checkout.php | REST checkout controller (1947 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 192–409) 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 411) | Add extra POST fields to order data |
previewExtras() (line 449) | Custom assets (maps API, no-cache headers) |
previewValidation() (line 505) | 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.
ShippingCalculator additionally exposes two protected hooks purpose-built for client forks (#568): customTransportCost() and applyTransportSurcharge(). A fork needing custom pricing logic should override one of these rather than redeclaring calculate() wholesale — see Shipping Cost Calculation (Modern REST) above.
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:505-652).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:1544).COD is incompatible with store pickup; COD with SmartPoint-only transporters is conditional:
callback_paywayCheckValidation()(ecommercen/eshop/controllers/Adv_order.php:1470-1483) blocks thedelivery(COD) + store-pickup (ORDER_ADDRESS_ESHOP) combination unconditionally.paid_at_storeis unavailable whencartProductsTotal <= minCartTotalAmountForPaidAtStore. The SmartPoint-only half of the rule is conditional: COD is locked out only whengetIsSmartPointOnlyTransporter && !getSelectedTransporterDeliveryOption(assets/vue/mixins/checkoutPage.js:457-461); when the transporter'sDELIVERY_OPTIONflag is on, COD stays available. This is no longer client-side only (#338): the server enforces it viacallback_paywayTransporterCheckValidationon thepaywayfield in bothpreviewValidation()andcheckoutValidationRules()(ecommercen/eshop/controllers/Adv_order.php:520-524,:907-911, callback:1493). Enforced by client-side check #9 invalidateForm()and server-side callbacks inpreviewValidation()(ecommercen/eshop/controllers/Adv_order.php:505-652).Stock is reserved at placement for every payway (#282):
PlaceOrderService::placeOrder()callsStockService::reduceStockForOrder()unconditionally after payment initialization (src/Domains/Checkout/PlaceOrderService.php:770). 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:101-106).cancelPayment()restores stock viaStockService::restoreStockForOrder()(:175). 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()). Since #760, the modern REST checkout enforces the identical rule atPlaceOrderService::placeOrder()step 0b, refusing withinvoice_identity_incomplete(422) when any of the five fields is blank after trimming — see Invoice Type below and theplace-orderrow in API Reference.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:361-449).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:833-872, commit3597ad539).Cart, preview, checkout and payment-return pages are not indexed (noindex):
Adv_order::defaultRender()(ecommercen/eshop/controllers/Adv_order.php:61-66) andAdv_checkout::defaultRender()(ecommercen/checkout/controllers/Adv_checkout.php:2365-2367) both set$this->render['dofollow'] = false, so the pages they render are served without search-engine indexing.
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 trigger setPayWayAfterTransporterUpdate() (via setTransporterId() at assets/vue/mixins/checkoutPage.js:518-521), which force-swaps away from COD when the selected transporter is SmartPoint-only and its DELIVERY_OPTION flag is off (getIsSmartPointOnlyTransporter && !getSelectedTransporterDeliveryOption). When DELIVERY_OPTION is on, COD remains available.
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 unless the transporter's separate DELIVERY_OPTION boolean is on — note: deliveryOptionType and deliveryOption are distinct properties) |
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:68-95), 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() :294-317 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 AND the transporter's DELIVERY_OPTION flag is off (getIsSmartPointOnlyTransporter && !getSelectedTransporterDeliveryOption). 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
// assets/vue/mixins/checkoutPage.js:450-474
switch (payWayClass) {
case 'paid_at_store':
if (!this.isCartTotalAbovePaidAtStoreMinCartTotalLimit) {
return false
}
break
case 'delivery':
if (this.getIsSmartPointOnlyTransporter && !this.getSelectedTransporterDeliveryOption) {
return false
}
break
case 'klarna_payments':
// Compound condition — see note below
}Note (Klarna condition): The
klarna_paymentscase atassets/vue/mixins/checkoutPage.js:462-472uses 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.
Per-Transporter DELIVERY_OPTION Override (BoxNow COD for Smart Points)
When a transporter is SmartPoint-only (getIsSmartPointOnlyTransporter, i.e. deliveryOptionType === '2'), COD is normally locked out. A per-transporter boolean flag DELIVERY_OPTION overrides this lockout: when true, COD remains available even for a SmartPoint-only transporter.
The checkout enforces this via the getSelectedTransporterDeliveryOption Vuex getter (assets/vue/store/getters.js:409-422), which reads deliveryOption from the selected transporter. The guard appears in two places in assets/vue/mixins/checkoutPage.js:
isPayWayOptionAvailable()at line 432-436: hides the COD radio button whengetIsSmartPointOnlyTransporter && !getSelectedTransporterDeliveryOption.setPayWayAfterTransporterUpdate()at lines 458-464: force-swaps away from COD when the condition is true andpayWayis currently'delivery'.
getSelectedTransporterDeliveryOption is registered in the mixin's mapGetters (assets/vue/mixins/checkoutPage.js:152).
For the admin checkbox that sets DELIVERY_OPTION, the protectData() server-side hydration path, BoxNowConfig wiring, label key, and all known issues for this flag, see AD-06 Transporter Admin §Business Rule 12 and §Provider-Specific Settings Keys.
Klarna Authorization Flow
Klarna authorization is entirely client-side and must complete before the customer can submit the checkout form. The flow is:
Authorize button: The customer clicks the authorize button rendered inside
KlarnaWidget.vue, which callsKlarna.Payments.authorize({}, params, callback)(assets/main/vue/KlarnaWidget.vue:68-79).Callback gate: On callback, the component checks
res.approved === true && res.authorization_token. If either condition is not met — that is, if the authorization was declined or the token is absent — the "authorization missing" alert is shown and the flow stops. Only when both conditions are satisfied does the component emitauthorize-klarna-payment-eventwith the full response (assets/main/vue/KlarnaWidget.vue:68-79).The success signal is
approved === trueplus the presence ofauthorization_token.show_formis a Klarna UI hint that indicates whether the Klarna widget should remain visible; it is not a success signal and is not part of the gate condition (prior to commit 55da40a522, the gate incorrectly usedres.show_form === false).Event listener:
CheckoutPage.vuebinds the event viav-on:authorize-klarna-payment-event="setKlarnaPaymentAuthorization"(assets/main/vue/CheckoutPage.vue:697), which callssetKlarnaPaymentAuthorization(data)incheckoutPage.js:627-639.Vuex commit and auto-submit:
setKlarnaPaymentAuthorizationcommits the authorization data to Vuex viaklarnaPaymentAuthorizationMutation. The stored shape is{authorization_token, initialized, approved, error}—show_formis no longer persisted (assets/vue/store/mutations.js:260-268). Immediately after committing, the method auto-clicks the submit button whendata.approved === true && data.authorization_token && this.termsChecked(assets/vue/mixins/checkoutPage.js:627-639).Token delivery to backend: The
authorization_tokenvalue is written into the hiddenklarnaAuthorizationTokenform field (row #40 in the Hidden Fields table above) and submitted with the form POST. The legacy backend also falls back to the DB-stored callback token viagetAuthorizationToken()atecommercen/checkout/controllers/Adv_checkout.php:3787. See CF-08 Payment Processing for the full server-side Klarna flow.
Finalize note: The Klarna authorize() callback can return finalize_required: true, which would require a subsequent Klarna.Payments.finalize() call before the token is valid. This path is not currently handled — the integration hardcodes payment_method_category: 'pay_now', which does not typically trigger finalization. Tracked as a follow-up in issue #303.
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 1544):
- 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.
Modern REST Parity (#760)
Until #760, POST /rest/checkout/place-order had no equivalent to this legacy rule: wantsInvoice=true with blank afm/doy/profession/company/companyAddress was accepted. PlaceOrderService::placeOrder() now refuses the request at step 0b via missingInvoiceIdentityFields() (src/Domains/Checkout/PlaceOrderService.php:160-187, :1026-1045), throwing InvoiceIdentityIncompleteException (src/Domains/Checkout/Exceptions/InvoiceIdentityIncompleteException.php), caught in the REST controller (src/Rest/Checkout/Controllers/Checkout.php:1241-1265) and mapped to HTTP 422 with error.code = invoice_identity_incomplete. Fields are compared AFTER TRIMMING, matching legacy's trim|required semantics. See the place-order row in API Reference for the full error shape.
The same commit series also fixed a pre-existing REST-only bug: the invoice/receipt choice was being written to shop_order.gen_tax_service (a courier field) instead of shop_order.paymerch. PlaceOrderService now writes paymerch correctly ('invoice' or 'receipt') and, on the receipt branch, clears afm/doy/company/profession/company_address to '' (PlaceOrderService.php:509-513,536) for legacy parity.
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 > 0customerCash < payableBeforePointsANDremainder > 0on the rounded residual — mirrors the server-side #573 guard (assets/vue/mixins/checkoutPage.js:157-179), coupon- and gift-packaging-aware
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 as two separate computed props in checkoutPage.js (:271-277):
payableBeforePoints (:271-273):
javascript
payableBeforePoints = cartProductsTotal
- couponDiscount
+ deliveryCost
+ transportationCost
+ giftPackagingCostorderTotal (:274-277):
javascript
orderTotal = this.payableBeforePoints - customerPointsCashWhere:
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. This client-side formula has always applied the free-shipping threshold and overweight surcharge server-side via legacy AdvTransporters::transportCost(); the modern REST checkout only gained parity with #568 — see Shipping Cost Calculation (Modern REST) above.
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 336-424) 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 505-652) 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_paywayTransporterCheckValidation on payway (#338; Adv_order.php:520-524, :907-911); callback_checkPaymerch on paymerch |
| 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 combinationpaywayTransporterCheckValidation($payWay)-- blocks COD with a transporter that does not accept it (smart-point-only withDELIVERY_OPTIONoff) viaAdvTransporters::isCashOnDeliveryAllowed(); runs on bothpreviewValidation()andcheckoutValidationRules()(Adv_order.php:1493-1517)validateMinimumCartTotalForPaidAtStore()-- 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:565) and writesshop_order_basketrows viaOrderBasketWriteService::createForOrder()(src/Domains/Checkout/PlaceOrderService.php:589). 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:550-562(step 7c) checkscount($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:565outside 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:565(order create) and:589(basket create).RESOLVED (commitresolveGuestCustomer()attaches orders to registered accounts by email without authentication.3597ad539, #3) —src/Domains/Checkout/PlaceOrderService.php:837-847now rejects checkout when a matched customer hashas_access=1or lacks a valid email, throwing aRuntimeException.RESOLVED (commitcancelPayment()has no caller in the modern REST layer.5eb2eddbe, #30) —POST /rest/checkout/cancel-payment/{orderId}registered atrest_routes.php:1756(and lang-prefixed at:1764), handled bycancelPayment()atsrc/Rest/Checkout/Controllers/Checkout.php:1642-1694.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:635-649dispatchesMarketingConsentCapturedon 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.COD / smart-point-only lockout is enforced client-side only.RESOLVED (#338) — the rule now lives in one place,CashOnDeliveryPolicy::permits()(src/Domains/Checkout/CashOnDeliveryPolicy.php), and is enforced server-side on both entry points: legacycallback_paywayTransporterCheckValidationon thepaywayrule of bothpreviewValidation()(ecommercen/eshop/controllers/Adv_order.php:520-524) andcheckoutValidationRules()(:907-911; callbackpaywayTransporterCheckValidationat:1493-1517), viaAdvTransporters::isCashOnDeliveryAllowed()(ecommercen/libraries/AdvTransporters.php:368); REST gate 0c inPlaceOrderService::placeOrder()(src/Domains/Checkout/PlaceOrderService.php:189-196), 422payway_not_allowed_for_transporter.POST /rest/checkout/shippingpublishes the verdict per transporter ascodAllowed. Original text: The rule that a smart-point-only transporter cannot take cash-on-delivery (and the newDELIVERY_OPTIONoverride of it) is enforced only in the storefront Vue (assets/vue/mixins/checkoutPage.js:457-461and:458-464). Server-side,paywayCheckValidation()(ecommercen/eshop/controllers/Adv_order.php:1470-1483) blocks COD only for store-pickup (ORDER_ADDRESS_ESHOP);isValidSmartPointTransporter()(Adv_order.php:1828) →getIsSmartPointOnlyTransporter()(ecommercen/libraries/AdvTransporters.php:354-359) checks onlyDELIVERY_OPTION_TYPE === '2'and never reads theDELIVERY_OPTIONflag. A direct form POST can therefore place a COD order for a smart-point-only transporter regardless ofDELIVERY_OPTION. This is a pre-existing gap that the conditionalDELIVERY_OPTIONrelaxation makes explicit./rest/checkout/totalsemits a NEGATIVEtotalwhen loyalty redemption exceeds payable.Checkout::totals()calculates'total' => round($payableBeforePoints - $loyalty['cash'], 2)atsrc/Rest/Checkout/Controllers/Checkout.php:875with no floor and no refusal check, so a cart where the redemption exceeds the payable amount is quoted a negativetotalin the response — while the identical/place-orderPOST rejects the order with HTTP 422loyalty_redemption_exceeds_order_total. Theloyalty.wouldExceedflag (:853-857) is computed fromredeemableCash, not from the in-play$loyalty['cash'], so it advertises the condition but the money field stays negative. This creates a client-side rendering gap: a frontend that renders the quotedtotalverbatim shows a negative figure that contradicts the server-side rejection.
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; since #568 also covers the free-shipping threshold evaluated against the gross post-coupon subtotal, a coupon costing the customer free shipping, delivery cost added to the gross total and persisted separately, the overweight surcharge never double-added, and the cart weight + payway forwarded to ShippingCalculator |
tests/Unit/Domains/Checkout/PlaceOrderDataTest.php | Parsing and filtering of PlaceOrderData from request payload, including FILTER_VALIDATE_BOOLEAN on redeemPoints, marketingConsent, and gift-packaging fields |
tests/Unit/Domains/Checkout/LoyaltyRedemptionTest.php | LoyaltyRedemption::resolve() and exceedsPayable() logic (#573), rounding, and debit operation |
tests/Unit/Domains/Checkout/Gift/GiftMatcherTest.php | Gift eligibility rule matching and discount/packaging outcome calculation |
tests/Unit/Domains/Checkout/OrderBasketBuilderGiftTest.php | OrderBasketBuilder with gift items and packaging cost |
tests/Unit/Rest/Checkout/Controllers/CheckoutEndpointSmokeTest.php | REST endpoint integration smoke tests for /totals, /place-order, and error responses |
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; since #568 also covers the free-shipping threshold (TRANS_COST_LIMIT) and TRANS_FREE_ALL, the weight surcharge (WEIGHT_LIMIT/PRICE_PER_KG) including the $checkFree replace-vs-add distinction, delivery cost and its DELIVERY_COST_MIN_FREE waiver, collect-at-store zeroing, the free (0) postal-code overweight-only case, the pricing-control-keys options-leak fix, and the customTransportCost()/applyTransportSurcharge() client hooks |
tests/Unit/Cart/CartWeightCalculatorTest.php | CartWeightCalculator (#568) — cart weight accumulated in grams from the hydrated productCode.product.weight relation, and the repository-lookup fallback for unhydrated cart lines |
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