Appearance
Alternative Payment Methods: Cash on Delivery + Invoice
Overview
Online ordering supports two alternative payment methods beyond the standard Bancontact/Card:
- Cash on Delivery (COD) -- customer pays cash at pickup or delivery. Universal across all POS providers (Mplus + Kassanet).
- Invoice -- B2B customer is billed later via their existing POS relation. Mplus-only. Customer is identified by VAT number or by their logged-in email matching a synced relation.
Both are "deferred payment" methods -- they skip online payment collection (Stripe/Viva) entirely. Orders are pushed to the POS as UNPAID/OPEN. The merchant settles manually when cash is received or the invoice is paid.
Backoffice Configuration
Payment method settings live under Online Ordering > Checkout > Payment methods in the backoffice.
Cash on Delivery is not live in production. The whole COD block in
Checkout.vueis wrapped inv-if="!isProduction"(:92-97, whereisProductionisimport.meta.env.VITE_ENV === 'production'), so no production merchant can see or enable it. The code comment records why: thecod_available_forplumbing was never fully wired — the backend ignores the field and online ordering does not scope availability by dining option. Only the Invoice toggle is merchant-configurable in production, and only on Mplus locations.
Settings schema (payment_methods on OnlineOrderingSetting)
json
{
"cod_enabled": false,
"pos_cod_payment_code": "",
"invoice_enabled": false,
"mplus_invoice_payment_code": ""
}| Field | Type | Default | Description |
|---|---|---|---|
cod_enabled | bool | false | Enable Cash on Delivery for this location |
pos_cod_payment_code | string | "" | Merchant-configured POS-side payment code applied downstream for COD |
invoice_enabled | bool | false | Enable Invoice payment (Mplus-only, section hidden for non-Mplus tenants) |
mplus_invoice_payment_code | string | "" | Merchant-configured Mplus payment code applied downstream for Invoice |
Constants.php:668-673 defines exactly these four keys and StoreOnlineOrderingRequest:233-237 validates exactly these four. cod_available_for and invoice_require_vat_match do not exist in the backend — cod_available_for survives only as a back-office store default that the backend ignores.
The Invoice section only renders when the location is on Mplus (getAvailableInHouseChannelKeys.includes('mpluskassa')).
Online Ordering (Customer-Facing)
Payment method selector
The checkout page dynamically shows payment methods based on the location's payment_methods config:
- Bancontact and Card -- always available (unless explicitly disabled)
- CashOnDelivery -- shown when
cod_enabled === true. The storefront does not scope COD by dining option:isPaymentMethodAvailable('CashOnDelivery')returnscfg.cod_enabled === trueand nothing more. - Invoice -- shown when
invoice_enabled === true
COD label
The COD label adapts to the dining option:
- Delivery: "Pay on delivery" / "Betalen bij levering"
- Pickup: "Pay on pickup" / "Betalen bij ophalen"
Invoice flow -- login-required relation match
Invoice is login-only. There is no VAT input in the storefront checkout, no validate-vat route anywhere in routes/, and no validateVatNumber() method on OnlineOrderingController. Any guest/VAT-entry path described in older revisions of this doc did not ship.
- Customer logs into online ordering
- Backend resolves their email against
MplusRelationviaRelationSyncService::findAndLinkRelation - Result is returned on the
/customer/userresponse as anmplus_relationblock - Frontend pre-fills VAT + company name and shows a "Billed to [Company] (VAT ...)" card
The VAT number and company name always come from the resolved relation, never from the request.
Deferred payment submit flow
When the customer submits with CashOnDelivery or Invoice:
OnlineOrderingOrchestrator::storePaymentdetectsPaymentMethodOptions::isDeferred()- Skips Stripe/Viva payment snapshot creation
- For Invoice: resolves the POS relation via
OnlineOrderingOrchestrator::resolveInvoiceBusinessRelation()(:1562), which triesRelationService::findBusinessRelationByNumber()using the relation number already linked on theCustomerMerchant, then falls back toRelationService::findBusinessRelationByEmail(). The order is relation-number first, then email — VAT is never the lookup key. - Runs capacity check + inventory validation (same as paid orders)
- Sets
sent_atviaOrderCapacityService::setTransactionSentAtByOrderCapacity(honors scheduled-order-to-POS config) - Sets
order_status=Complete; setsstatusviadeferredStatusForPaymentMethod()(OnlineOrderingOrchestrator.php:1502) —Completefor COD, butAwaiting Invoicefor Invoice. Invoice orders only reachCompletewhenProcessInvoicePaidJobfires after the Stripe invoice is paid. - Dispatches via
PaymentCaptureService::processOrderJob(same path as paid orders) - Returns
{ status: 'deferred_payment', payment_method, transaction_id, redirect_url } - Frontend skips the Stripe/Bancontact redirect and navigates directly to the thank-you page
Backend -- POS Order Push
Mplus
Deferred orders are pushed via createOrderV3 with payments: null, prepay: false. The order arrives on the Mplus register as UNPAID/OPEN. The merchant settles it on the POS when cash is received or the invoice is paid.
For Invoice orders, $order->relationNumber is set from $transaction->getPosRelationNumber() so Mplus knows which B2B customer the order belongs to.
Kassanet (Hendrickx / Vanhoutte)
Deferred orders skip the GetKassanetBillJob + PayKassanetBillJob chain. The order is created via createOrder() but payBill() is never called. The order sits OPEN on the Kassanet register.
Important: orders are NOT paid on the POS
Unlike Bancontact/Card orders (which are pushed as fully paid with a WEBSHOP payment method), deferred orders carry no payment information to the POS. This is intentional -- the POS is the source of truth for when cash is received or the invoice is settled.
Invoice Collection (Stripe)
Invoice orders do not terminate at "merchant settles manually on the POS". Production has a full Stripe-invoice collection subsystem on top of them.
- An Invoice order lands on status
Awaiting Invoice(notComplete). - The merchant collects one or more such orders into a Stripe invoice via
InvoiceCollectionOrchestrator/app/Services/Payment/Invoicing/StripeInvoicingService. - When the customer pays,
ProcessInvoicePaidJobflips the collected transactions toComplete. ProcessInvoiceVoidedJobhandles voided invoices.
Supporting pieces: the Invoice RawModel with InvoiceRepository and InvoiceFactory, the InvoiceStatuses enum, the back-office invoice-collection.php routes and the invoiceCollection.ts store. The whole surface is feature-gated.
Transaction Fields
Four fields added to Transaction:
| Field | Type | Purpose |
|---|---|---|
payment_method | ?string | 'Bancontact', 'Card', 'CashOnDelivery', or 'Invoice' |
vat_number | ?string | Customer's VAT number (Invoice orders) |
company_name | ?string | Company name (Invoice orders) |
pos_relation_number | ?int | POS relation ID, set when a relation is resolved |
API Endpoints
Customer /me response -- mplus_relation block
When an authenticated OO customer's email matches a synced MplusRelation:
json
{
"mplus_relation": {
"relation_number": 12345,
"company_name": "Acme Corp",
"vat_number": "BE0123456789"
}
}Otherwise null.
VAT Normalization
VAT numbers are normalized (uppercased, whitespace stripped) on both sync and lookup to ensure reliable matching:
MplusRelationstoresvat_number(raw from Mplus) andvat_number_normalized(canonical form)- Relation lookup is by relation number then email;
vat_number_normalizedis stored on the relation but is not the invoice lookup key - Static helper:
MplusRelation::normalizeVat(?string $vat): ?string - Sparse compound index on
(integration_id, vat_number_normalized) - Backfill command:
php artisan mplus:backfill-vat-normalization [vendorId] [--dry-run]
Key Files
Backend (upvendo-backend)
| File | Purpose |
|---|---|
app/Enums/PaymentMethodOptions.php | Enum: Bancontact, Card, CashOnDelivery, Invoice + isDeferred() |
app/RawModels/Transaction.php | payment_method, vat_number, company_name, pos_relation_number |
app/Services/Orchestrators/OnlineOrderingOrchestrator.php | Deferred payment branch in storePayment() |
app/Services/MplusKassa/RelationService.php | findBusinessRelationByNumber() (:735), findBusinessRelationByEmail() (:645) |
app/Services/Orchestrators/OnlineOrderingOrchestrator.php | resolveInvoiceBusinessRelation() (:1562), deferredStatusForPaymentMethod() (:1502) |
app/Services/MplusKassa/OrderSyncService.php | Skips payment for deferred orders |
app/Services/BackOffice/MplusKassaIntegrationService.php | createMplusKassaOrder -- payments: null for deferred |
app/Services/Common/AbstractKassanetService.php | Skips GetKassanetBillJob for deferred |
app/Http/Requests/OnlineOrdering/CreateIntentRequest.php | Validates payment_method against PaymentMethodOptions (:277). No VAT rules — VAT and company come from the resolved relation, never from the request |
app/Console/Commands/BackfillMplusVatNormalization.php | Backfill vat_number_normalized |
Backoffice (upvendo-backoffice)
| File | Purpose |
|---|---|
src/views/online/online-ordering/forms/Checkout.vue | Payment methods section (COD + Invoice toggles, COD scope) |
src/store/modules/onlineOrdering.ts | payment_methods block in store default |
Online Ordering (zestidoo-online-ordering)
| File | Purpose |
|---|---|
src/components/CheckoutPage.vue | Dynamic payment selector, Invoice VAT UX, email auto-fill, deferred submit |
src/stores/transaction.ts | checkoutForm fields |
src/stores/app.ts | isPaymentMethodAvailable getter (there is no invoiceRequiresVatMatch) |
src/stores/auth.ts | mplusRelation persistence from /me response |
Deployment Checklist
- Backend first -- deploy, then run
php artisan mplus:backfill-vat-normalizationon production - Backoffice second -- deploy, then merchant enables COD/Invoice in Online Ordering settings
- Online ordering last -- deploy after backoffice so the merchant has configured the toggles before customers see them