Skip to content

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:

  1. Modern REST Checkout — src/Rest/Checkout/Controllers/Checkout.php with PlaceOrderService
  2. 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 ​

MethodPathAuthDescription
POST/rest/checkout/shippingGuest/CustomerThreshold-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/validateGuest/CustomerValidate coupon against cart
POST/rest/checkout/totalsGuest/CustomerCalculate 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-orderGuest/CustomerPlace 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-methodsGuestReturns 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}CustomerCheck payment status
POST/rest/checkout/confirm-payment/{orderId}CustomerVerify payment with gateway and confirm
POST/rest/checkout/cancel-payment/{orderId}CustomerCancel an awaiting-gateway order; ownership-checked; dispatches OrderCanceled (rest_routes.php:1756, lang-prefixed :1764; controller cancelPayment() Checkout.php:1642-1694, OpenAPI :1628)

Browse endpoints interactively in the API Reference.

Legacy Storefront ​

URLControllerMethod
/preview_orderAdv_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)

  1. 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)
  2. Resolve customer ID — create the guest customer only now (resolveGuestCustomer(), PlaceOrderService.php:212-217). It writes date_registered => time() — a unix int; the pre-#798 datetime string threw before any row existed (PlaceOrderService.php:856-859)
  3. Load cart items 3b. Match gifts via GiftMatcher and apply outcomes to yield $giftDiscount/$netGiftDiscount (src/Domains/Checkout/PlaceOrderService.php:241-254) 3c. Yield $giftDiscount and applyGiftOutcome for totals calculation
  4. Calculate totals with coupon
  5. 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)
  6. Resolve order currency via resolveOrderCurrency() — looks up the cart's currency_code against CurrencyRepository using matchOne(new Filter('code', $cartCurrencyCode)); falls back to the first active currency (Filter('active', 1)) if the cart code is absent or unmatched; throws RuntimeException if no active currency is configured at all. The resolved CurrencyEntity populates order_currency, currency_id, and currency_rate on the order data. Citation: src/Domains/Checkout/PlaceOrderService.php (resolveOrderCurrency()) 7b. Check loyalty redemption exceeds payable via exceedsPayable() (#573) and throw LoyaltyRedemptionExceedsOrderTotalException if true — mapped to HTTP 422 in the REST layer (src/Domains/Checkout/PlaceOrderService.php:459-464)
  7. Create order in database from $orderData (with order_currency, currency_id, and currency_rate populated, resolved at step 6). No cart snapshot is built or written — the former buildCartContentsSnapshot() step and its cart_contents assignment were removed in #605; shop_order.cart_contents was never a real column, so nothing is lost. Basket rows are written separately at step 10, from OrderBasketBuilder'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's shop_customer row (#837), mirroring the storefront's update_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)
  8. Generate and assign order serial
  9. Write basket rows via OrderBasketWriteService (createForOrder()) — rows were already built by OrderBasketBuilder earlier (~step 3) (src/Domains/Checkout/PlaceOrderService.php:589)
  10. Persist carrier-specific transporter data via CarrierDataDispatcher (see Carrier Data Persistence below)
  11. Mark coupon as used via CouponCodeWriteRepository::incrementUsed() 12b. Dispatch MarketingConsentCaptured event if marketingConsent is true and a customer email exists (src/Domains/Checkout/PlaceOrderService.php:635-649)
  12. Clear the cart
  13. Initialize payment via PaymentInitializerFactory
  14. 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 to PaymentConfirmationService::confirmPayment()) has been removed. OrderPaid is dispatched immediately only when $paymentResult->status === 'PENDING_ACCEPTED' (offline payways: delivery, bank_transfer, paid_at_store); online/redirect payways and PayByBank (both PENDING) dispatch OrderPaid from confirmPayment() once the gateway confirms (PlaceOrderService.php:772-789). Abandoned PENDING orders are restored by the AdvCancelIncompleteOrders cron; explicit cancellation calls cancelPayment() which restores stock via StockService::restoreStockForOrder().
  15. Return order ID + optional redirect URL + optional transactionId (#724; omitted when empty, the bank payment code for paybybank) (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)

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

Address Modes ​

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

VAT by Country ​

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


Domain Layer ​

Modern Domain (src/Domains/...) ​

New Checkout domain services (added in 4.99.6):

ServiceFileResponsibility
OrderBasketBuildersrc/Domains/Checkout/OrderBasketBuilder.phpTransforms cart items into order basket rows with resolved pricing, VAT, discounts, and loyalty points
ShippingCalculatorsrc/Domains/Checkout/ShippingCalculator.phpThreshold-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
CartWeightCalculatorsrc/Domains/Cart/CartWeightCalculator.phpComputes 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()
LoyaltyRedemptionsrc/Domains/Checkout/LoyaltyRedemption.phpOwns loyalty redemption maths shared by Checkout::totals() (quote) and PlaceOrderService::placeOrder() (charge), so they never disagree. Includes exceedsPayable() guard (#573) and debit operation
GiftMatchersrc/Domains/Checkout/Gift/GiftMatcher.phpEvaluates gift eligibility rules against cart and matches applicable gifts, yielding discount and packaging outcomes for the quote and charge
GiftPackagingResolversrc/Domains/Checkout/Gift/GiftPackagingResolver.phpResolves gift packaging cost from registry configuration; called by both quote and charge paths
Product\Pricing\PriceResolversrc/Domains/Product/Pricing/PriceResolver.phpThe 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\DiscountResolversrc/Domains/Product/Pricing/DiscountResolver.phpShared 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\VatResolversrc/Domains/Product/Pricing/VatResolver.phpResolves 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
StockServicesrc/Domains/Checkout/StockService.phpAtomic stock reduction/restoration using raw SQL expressions (stock - qty) to avoid race conditions
PaymentConfirmationServicesrc/Domains/Checkout/PaymentConfirmationService.phpIdempotent payment confirmation — updates order to PAID, does NOT reduce stock (reserved at placement by PlaceOrderService for all payways, #282); dispatches OrderPaid; handles cancellation with stock restoration via StockService::restoreStockForOrder() and dispatches OrderCanceled
OrderBasketWriteServicesrc/Domains/Order/OrderBasket/WriteService.phpWrites basket rows for an order in a single transaction via createForOrder()
StockService::reduceStockForOrder()src/Domains/Checkout/StockService.php:27-50Reads shop_order_basket, decrements product_codes.stock by qty per row in a DB transaction.
StockService::restoreStockForOrder()src/Domains/Checkout/StockService.php:58-81Reads shop_order_basket, increments product_codes.stock by qty per row in a DB transaction. Inverse of reduceStockForOrder().
OrderBasket\WriteDatasrc/Domains/Order/OrderBasket/WriteData.phpDTO mapping 15 shop_order_basket columns. fromArray() at :26-45; toArray() at :47-72. 73 lines.
OrderBasket\Repository\WriteRepositorysrc/Domains/Order/OrderBasket/Repository/WriteRepository.phpWrite repository targeting shop_order_basket (11 lines).
CarrierDataDispatchersrc/Domains/Checkout/Carrier/CarrierDataDispatcher.phpRoutes transporter-specific payload from PlaceOrderData::$transporterExternalData to the matching persister after order creation
OrderEventDispatchersrc/Domains/Order/Event/OrderEventDispatcher.phpSynchronous fan-out to OrderPaid / OrderCanceled listeners; each listener wrapped in try/catch

Carrier Data Persistence ​

After PlaceOrderService::placeOrder() creates the order and basket rows, it calls CarrierDataDispatcher::dispatchForOrder($orderId, $transporterId, $data->transporterExternalData) (src/Domains/Checkout/PlaceOrderService.php: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 on eshop_calculated_cost == 0; writes shop_order_dhl_vouchers via DhlVoucher\WriteService
  • AsapDataPersister — class_name='ASAP', gated on eshop_calculated_cost == 0; writes shop_order_asap_data via AsapData\WriteService
  • SmartPointPersister — class_name ∈ smartPointsDataManage config keys; no cost gate; writes shop_order_smart_point via SmartPoint\WriteService

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() calls LoyaltyRedemption::resolve() (PlaceOrderService.php:370-373), passing null instead of the resolved $customerId when $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 same resolve() (src/Rest/Checkout/Controllers/Checkout.php:817), so a client that posts the same redeemPoints to both /rest/checkout/totals and /place-order sees the identical discount in each response (pointsSpend/pointsCash in 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 pays PRICE_PER_KG * ceil(($cartWeight - $weightLimit) / 1000). The $checkFree replace-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 global ESHOP.TRANS_COST_LIMIT Registry 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 to 0 on 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 whose transporters_options_pricing isn't fully populated.

    Since #643, that same display-only ESHOP.TRANS_COST_LIMIT value is reachable over REST as shipping.freeShippingThreshold on GET /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 from POST /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 legacy AdvTransporters::customTransportCost(), #389).
  • applyTransportSurcharge() (:383-393) — called once on every serviced path, including the free-shipping branches where $price is 0.

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/...) ​

FileResponsibility
src/Rest/Checkout/Controllers/Checkout.phpREST 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/...) ​

FileResponsibility
ecommercen/eshop/controllers/Adv_order.phpLegacy 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 the APP_REST_API_ENABLED=true environment variable. When false (default), rest_routes.php is not loaded and all REST endpoints return 404. See REST API Modules guide for details.

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

Client Extension Points ​

Legacy Hooks ​

HookPurpose
previewExtraPostData() (line 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 ​

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

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

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

  4. COD is incompatible with store pickup; COD with SmartPoint-only transporters is conditional: callback_paywayCheckValidation() (ecommercen/eshop/controllers/Adv_order.php:1470-1483) blocks the delivery (COD) + store-pickup (ORDER_ADDRESS_ESHOP) combination unconditionally. paid_at_store is unavailable when cartProductsTotal <= minCartTotalAmountForPaidAtStore. The SmartPoint-only half of the rule is conditional: COD is locked out only when getIsSmartPointOnlyTransporter && !getSelectedTransporterDeliveryOption (assets/vue/mixins/checkoutPage.js:457-461); when the transporter's DELIVERY_OPTION flag is on, COD stays available. This is no longer client-side only (#338): the server enforces it via callback_paywayTransporterCheckValidation on the payway field in both previewValidation() and checkoutValidationRules() (ecommercen/eshop/controllers/Adv_order.php:520-524, :907-911, callback :1493). Enforced by client-side check #9 in validateForm() and server-side callbacks in previewValidation() (ecommercen/eshop/controllers/Adv_order.php:505-652).

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

  6. Invoice type switches five B2B fields from optional to required: When paymerch === 'invoice', afm, doy, profession, company, and company_address become required. callback_checkPaymerch strictly enforces paymerch is 'invoice' or 'receipt' — any other value is rejected (ecommercen/eshop/controllers/Adv_order.php:previewValidation()). Since #760, the modern REST checkout enforces the identical rule at PlaceOrderService::placeOrder() step 0b, refusing with invoice_identity_incomplete (422) when any of the five fields is blank after trimming — see Invoice Type below and the place-order row in API Reference.

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

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

  9. Cart, preview, checkout and payment-return pages are not indexed (noindex): Adv_order::defaultRender() (ecommercen/eshop/controllers/Adv_order.php:61-66) and Adv_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:

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

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

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

Auto-selection logic: When the transporter list changes, AdvOrderTransportersMultiple checks if the previously selected transporter still exists in the new list; if not, it falls back to the first available. AdvOrderTransportersSingle always auto-selects and auto-emits. Both components 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 -- orchestrator
  • assets/main/vue/AdvOrderTransportersSingle.vue -- single-option variant
  • assets/main/vue/AdvOrderTransportersMultiple.vue -- multi-option variant
  • assets/main/vue/AdvOrderTransportersExternal.vue -- external rate sub-selector
  • assets/vue/store/actions.js -- getTransporterData, getExternalTransporterCost, asapExternalServices
  • assets/vue/api/index.js -- getTransporterData() API call

Smart Point / Pickup Point Selection ​

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

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

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

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

ValueLabelBehavior
0AllBoth home delivery and pickup point available
1Delivery to my placeHome delivery only (smart points hidden)
2Pick up from selling pointPickup only (home delivery radio hidden, COD disabled 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_nameWidget componentSelection component
BOXNOWBoxNowWidgetSmartPointWidgetSelection
SKROUTZSkroutzLastMileWidgetSmartPointWidgetSelection

Both widget components follow the same pattern:

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

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

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

Data source: In the legacy checkout, the smart-point list is hydrated server-side by Adv_order::smartPointsInitialize() (ecommercen/eshop/controllers/Adv_order.php: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 wrapper
  • assets/main/vue/SmartPoint/SmartPointMap.vue -- map + point list UI
  • assets/main/vue/SmartPoint/SmartPointSelection.vue -- home/pickup radio buttons
  • assets/main/vue/SmartPoint/SmartPointMixin.js -- shared logic mixin
  • assets/main/vue/SmartPoint/SmartPointModelDTO.js -- point data transfer object
  • assets/main/vue/SmartPoint/Widgets/SmartPointWidget.vue -- dynamic widget resolver
  • assets/main/vue/SmartPoint/Widgets/SmartPointWidget.js -- vendor script injection class
  • assets/main/vue/SmartPoint/Widgets/BoxNowWidget.vue -- BoxNow iframe widget
  • assets/main/vue/SmartPoint/Widgets/SkroutzLastMileWidget.vue -- Skroutz iframe widget
  • assets/main/vue/SmartPoint/Widgets/SmartPointWidgetSelection.vue -- shared selection UI for widgets

Gift Packaging ​

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

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

UI flow:

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

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

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

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

File: assets/main/vue/CheckoutGiftPackaging.vue


VAT Validation API ​

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

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

Vuex flow:

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

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

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

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

Response format:

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

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

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

Files:

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

Checkout Form Specification ​

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

1. Form Fields ​

Customer Details (Billing) ​

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

Shipping Address (Conditional) ​

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

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

Notes ​

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

Gift Packaging (Conditional) ​

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

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

Invoice / B2B Fields (Conditional) ​

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

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

Hidden Fields ​

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

Additional POST fields merged into order data ​

These are collected in Adv_order.php::preview() :294-317 and passed to checkoutView():

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

2. Payment Methods ​

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

Gateway Table ​

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

Client-Side Availability Logic (isPayWayOptionAvailable) ​

javascript
// 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_payments case at assets/vue/mixins/checkoutPage.js:462-472 uses a compound condition that includes storeId and transporterId checks in addition to the country check. The simplified return country !== '' shown in earlier versions of this doc does not reflect the full guard. Refer to the source directly for the authoritative logic.

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 when getIsSmartPointOnlyTransporter && !getSelectedTransporterDeliveryOption.
  • setPayWayAfterTransporterUpdate() at lines 458-464: force-swaps away from COD when the condition is true and payWay is 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:

  1. Authorize button: The customer clicks the authorize button rendered inside KlarnaWidget.vue, which calls Klarna.Payments.authorize({}, params, callback) (assets/main/vue/KlarnaWidget.vue:68-79).

  2. 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 emit authorize-klarna-payment-event with the full response (assets/main/vue/KlarnaWidget.vue:68-79).

    The success signal is approved === true plus the presence of authorization_token. show_form is 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 used res.show_form === false).

  3. Event listener: CheckoutPage.vue binds the event via v-on:authorize-klarna-payment-event="setKlarnaPaymentAuthorization" (assets/main/vue/CheckoutPage.vue:697), which calls setKlarnaPaymentAuthorization(data) in checkoutPage.js:627-639.

  4. Vuex commit and auto-submit: setKlarnaPaymentAuthorization commits the authorization data to Vuex via klarnaPaymentAuthorizationMutation. The stored shape is {authorization_token, initialized, approved, error} — show_form is no longer persisted (assets/vue/store/mutations.js:260-268). Immediately after committing, the method auto-clicks the submit button when data.approved === true && data.authorization_token && this.termsChecked (assets/vue/mixins/checkoutPage.js:627-639).

  5. Token delivery to backend: The authorization_token value is written into the hidden klarnaAuthorizationToken form 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 via getAuthorizationToken() at ecommercen/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.apcoInstallments has entries
  • piraeus: when previewOrderData.piraeusInstallments.installments has > 1 entry AND orderTotal >= minimumOrderTotal
  • eurobank: when previewOrderData.eurobankInstallments.installments has > 1 entry AND orderTotal >= minimumOrderTotal

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

Viva Wallet Sub-Methods ​

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


3. Shipping & Delivery ​

Delivery Modes ​

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

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

Transporter AJAX Loading ​

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

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

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

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

External Rate Transporters (e.g., ASAP) ​

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

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

SmartPoint Selection ​

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


4. Coupon System ​

Frontend Flow (CheckoutCoupon.vue) ​

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

Server-Side Validation (callback_checkCoupon) ​

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

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

5. Invoice Type ​

Radio Selection ​

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

Invoice Fields When paymerch === 'invoice' ​

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

VAT Number Validation ​

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

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

checkPaymerch Callback ​

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

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:

  1. previewOrderData.loyalty.pointSystemIsEnabled is truthy (registry: POINT_SYSTEM.IS_ENABLED)
  2. previewOrderData.loyalty.customerTotalPoints > 0
  3. previewOrderData.loyalty.customerCash > 0
  4. customerCash < payableBeforePoints AND remainder > 0 on 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, customerCash is subtracted from orderTotal
  • Server rule: trim only (the redemption amount is recalculated server-side, not trusted from POST)

7. Newsletter Checkboxes ​

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

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

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


8. Terms Checkbox ​

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

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


9. Order Total Formula ​

The final order total is computed client-side as two separate computed props in checkoutPage.js (:271-277):

payableBeforePoints (:271-273):

javascript
payableBeforePoints = cartProductsTotal
                    - couponDiscount
                    + deliveryCost
                    + transportationCost
                    + giftPackagingCost

orderTotal (:274-277):

javascript
orderTotal = this.payableBeforePoints - customerPointsCash

Where:

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

Overweight cost is displayed as a separate line item (overweightCost) but is already included in transporter.overweightCost which is part of the transporter cost calculation. 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:

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

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

Server-Side Validation (CodeIgniter form_validation) ​

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

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

Key callbacks:

  • postalCheck($postal, $country) -- country-specific postal code format validation
  • email_check($email, $isGuest) -- prevents registration with existing email (skipped for guests)
  • isValidTransporter() -- verifies the selected transporter is available for the customer's address via Transporters::isAvailable()
  • isValidSmartPointTransporter() -- ensures SmartPoint-only transporters have a selected point
  • paywayCheckValidation() -- blocks COD + store pickup combination
  • paywayTransporterCheckValidation($payWay) -- blocks COD with a transporter that does not accept it (smart-point-only with DELIVERY_OPTION off) via AdvTransporters::isCashOnDeliveryAllowed(); runs on both previewValidation() and checkoutValidationRules() (Adv_order.php:1493-1517)
  • validateMinimumCartTotalForPaidAtStore() -- enforces minimum cart total for in-store payment

Data Model ​

Key tables touched during checkout preview ​

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

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

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


Known Issues & Security Gaps ​

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

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

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

  4. resolveGuestCustomer() attaches orders to registered accounts by email without authentication. RESOLVED (commit 3597ad539, #3) — src/Domains/Checkout/PlaceOrderService.php:837-847 now rejects checkout when a matched customer has has_access=1 or lacks a valid email, throwing a RuntimeException.

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

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

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

  8. 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: legacy callback_paywayTransporterCheckValidation on the payway rule of both previewValidation() (ecommercen/eshop/controllers/Adv_order.php:520-524) and checkoutValidationRules() (:907-911; callback paywayTransporterCheckValidation at :1493-1517), via AdvTransporters::isCashOnDeliveryAllowed() (ecommercen/libraries/AdvTransporters.php:368); REST gate 0c in PlaceOrderService::placeOrder() (src/Domains/Checkout/PlaceOrderService.php:189-196), 422 payway_not_allowed_for_transporter. POST /rest/checkout/shipping publishes the verdict per transporter as codAllowed. Original text: The rule that a smart-point-only transporter cannot take cash-on-delivery (and the new DELIVERY_OPTION override of it) is enforced only in the storefront Vue (assets/vue/mixins/checkoutPage.js:457-461 and :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 only DELIVERY_OPTION_TYPE === '2' and never reads the DELIVERY_OPTION flag. A direct form POST can therefore place a COD order for a smart-point-only transporter regardless of DELIVERY_OPTION. This is a pre-existing gap that the conditional DELIVERY_OPTION relaxation makes explicit.

  9. /rest/checkout/totals emits a NEGATIVE total when loyalty redemption exceeds payable. Checkout::totals() calculates 'total' => round($payableBeforePoints - $loyalty['cash'], 2) at src/Rest/Checkout/Controllers/Checkout.php:875 with no floor and no refusal check, so a cart where the redemption exceeds the payable amount is quoted a negative total in the response — while the identical /place-order POST rejects the order with HTTP 422 loyalty_redemption_exceeds_order_total. The loyalty.wouldExceed flag (:853-857) is computed from redeemableCash, 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 quoted total verbatim shows a negative figure that contradicts the server-side rejection.


Tests ​

Test fileWhat it covers
tests/Unit/Checkout/PlaceOrderServiceTest.phpUnit 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.phpParsing and filtering of PlaceOrderData from request payload, including FILTER_VALIDATE_BOOLEAN on redeemPoints, marketingConsent, and gift-packaging fields
tests/Unit/Domains/Checkout/LoyaltyRedemptionTest.phpLoyaltyRedemption::resolve() and exceedsPayable() logic (#573), rounding, and debit operation
tests/Unit/Domains/Checkout/Gift/GiftMatcherTest.phpGift eligibility rule matching and discount/packaging outcome calculation
tests/Unit/Domains/Checkout/OrderBasketBuilderGiftTest.phpOrderBasketBuilder with gift items and packaging cost
tests/Unit/Rest/Checkout/Controllers/CheckoutEndpointSmokeTest.phpREST endpoint integration smoke tests for /totals, /place-order, and error responses
tests/Unit/Checkout/GuestCheckoutTest.phpGuest checkout flow — account attachment by email, is_guest flag, and random password assignment
tests/Unit/Checkout/CouponValidatorTest.phpCoupon validation rules — existence checks, usage limits per coupon and per customer, minimum order total, product/category eligibility
tests/Unit/Checkout/PaymentInitializerTest.phpPaymentInitializerFactory — adapter selection and payment initialization per gateway key
tests/Unit/Checkout/ShippingCalculatorTest.phpShipping cost calculation — transporter selection, external rate fetching; 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.phpCartWeightCalculator (#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.phpOrderBasketBuilder — cart item to basket row transformation including pricing, VAT, discounts, and loyalty point resolution
tests/Unit/Domains/Checkout/PaymentConfirmationServiceTest.phpPaymentConfirmationService — idempotent confirmation (does NOT reduce stock, #282), cancellation with stock restoration via restoreStockForOrder() (#282), and OrderPaid/OrderCanceled event dispatch
tests/Unit/Domains/Checkout/StockServiceTest.phpStockService — atomic stock reduction and restoration using raw SQL expressions to prevent race conditions
tests/Unit/Domains/Order/OrderBasket/WriteServiceTest.phpOrderBasket\WriteService — createForOrder() transactional basket row inserts
tests/Unit/Domains/Order/OrderBasket/ServiceTest.phpOrderBasket\Service — read-side basket queries and associated business logic
tests/Integration/Domains/Order/OrderBasket/RepositoryTest.phpIntegration tests for OrderBasket\Repository — DB-backed query correctness against a real schema
tests/Integration/Domains/Order/OrderBasket/ServiceTest.phpIntegration tests for OrderBasket\Service — end-to-end service behaviour against a real database

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


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

Shared Patterns ​