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.
Settings schema (payment_methods on OnlineOrderingSetting)
json
{
"cod_enabled": false,
"cod_available_for": "both",
"invoice_enabled": false,
"invoice_require_vat_match": false
}| Field | Type | Default | Description |
|---|---|---|---|
cod_enabled | bool | false | Enable Cash on Delivery for this location |
cod_available_for | "both" / "pickup" / "delivery" | "both" | Which dining options COD is available for |
invoice_enabled | bool | false | Enable Invoice payment (Mplus-only, section hidden for non-Mplus tenants) |
invoice_require_vat_match | bool | false | When true, Invoice orders require the VAT to match an existing POS relation. When false (default/lenient), Invoice orders go through even without a relation match. |
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 === trueAND the customer's dining option matchescod_available_for - 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 -- two identification paths
Path A: VAT number (guest or logged-in)
- Customer selects Invoice payment
- Types their VAT number in the checkout field
- Frontend debounces (500ms, min 8 chars) and calls
POST /online-ordering/{slug}/{locationId}/validate-vat - Backend queries the local
MplusRelationcache byvat_number_normalized - If found: "Found: [Company Name]" shown, company auto-filled
- If not found + strict mode on: submit blocked
- If not found + lenient mode: submit allowed without relation
Path B: Email auto-fill (logged-in customer)
- Customer logs into OO
- Backend resolves their email against
MplusRelationviaRelationSyncService::findAndLinkRelation - Result returned on the
/customer/userresponse asmplus_relationblock - Frontend pre-fills VAT + company name, shows "Billed to [Company] (VAT ...)" card
- No live
/validate-vatcall needed
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
RelationService::resolveInvoiceRelation(VAT first, email fallback) - Runs capacity check + inventory validation (same as paid orders)
- Sets
sent_atviaOrderCapacityService::setTransactionSentAtByOrderCapacity(honors scheduled-order-to-POS config) - Marks transaction
Complete - 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.
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
POST /online-ordering/{slug}/{locationId}/validate-vat
Public, rate-limited (throttle:20,1). Validates a VAT number against the local MplusRelation cache.
Request: { "vat_number": "BE0123456789" }
Response (match): { "found": true, "company_name": "Acme Corp", "vat_number": "BE0123456789" }
Response (no match): { "found": false, "company_name": null, "vat_number": "BE0123456789" }
Response (non-Mplus location): { "found": false, "reason": "pos_not_configured" }
Never returns relation_number (POS-internal).
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)RelationService::findRelationByVatNumberqueries onvat_number_normalized- 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 | findRelationByVatNumber(), resolveInvoiceRelation() |
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/Controllers/Api/OnlineOrderingController.php | validateVatNumber() |
app/Http/Requests/OnlineOrdering/CreateIntentRequest.php | VAT validation rules + conditional withValidator |
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, validateVatNumber action |
src/stores/app.ts | isPaymentMethodAvailable, invoiceRequiresVatMatch getters |
src/stores/auth.ts | mplusRelation persistence from /me response |
src/api/services/transaction-services.ts | validateVatNumber API call |
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