Skip to content

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.vue is wrapped in v-if="!isProduction" (:92-97, where isProduction is import.meta.env.VITE_ENV === 'production'), so no production merchant can see or enable it. The code comment records why: the cod_available_for plumbing 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": ""
}
FieldTypeDefaultDescription
cod_enabledboolfalseEnable Cash on Delivery for this location
pos_cod_payment_codestring""Merchant-configured POS-side payment code applied downstream for COD
invoice_enabledboolfalseEnable Invoice payment (Mplus-only, section hidden for non-Mplus tenants)
mplus_invoice_payment_codestring""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') returns cfg.cod_enabled === true and 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.

  1. Customer logs into online ordering
  2. Backend resolves their email against MplusRelation via RelationSyncService::findAndLinkRelation
  3. Result is returned on the /customer/user response as an mplus_relation block
  4. 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:

  1. OnlineOrderingOrchestrator::storePayment detects PaymentMethodOptions::isDeferred()
  2. Skips Stripe/Viva payment snapshot creation
  3. For Invoice: resolves the POS relation via OnlineOrderingOrchestrator::resolveInvoiceBusinessRelation() (:1562), which tries RelationService::findBusinessRelationByNumber() using the relation number already linked on the CustomerMerchant, then falls back to RelationService::findBusinessRelationByEmail(). The order is relation-number first, then email — VAT is never the lookup key.
  4. Runs capacity check + inventory validation (same as paid orders)
  5. Sets sent_at via OrderCapacityService::setTransactionSentAtByOrderCapacity (honors scheduled-order-to-POS config)
  6. Sets order_status = Complete; sets status via deferredStatusForPaymentMethod() (OnlineOrderingOrchestrator.php:1502) — Complete for COD, but Awaiting Invoice for Invoice. Invoice orders only reach Complete when ProcessInvoicePaidJob fires after the Stripe invoice is paid.
  7. Dispatches via PaymentCaptureService::processOrderJob (same path as paid orders)
  8. Returns { status: 'deferred_payment', payment_method, transaction_id, redirect_url }
  9. 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.

  1. An Invoice order lands on status Awaiting Invoice (not Complete).
  2. The merchant collects one or more such orders into a Stripe invoice via InvoiceCollectionOrchestrator / app/Services/Payment/Invoicing/StripeInvoicingService.
  3. When the customer pays, ProcessInvoicePaidJob flips the collected transactions to Complete.
  4. ProcessInvoiceVoidedJob handles 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:

FieldTypePurpose
payment_method?string'Bancontact', 'Card', 'CashOnDelivery', or 'Invoice'
vat_number?stringCustomer's VAT number (Invoice orders)
company_name?stringCompany name (Invoice orders)
pos_relation_number?intPOS 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:

  • MplusRelation stores vat_number (raw from Mplus) and vat_number_normalized (canonical form)
  • Relation lookup is by relation number then email; vat_number_normalized is 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) ​

FilePurpose
app/Enums/PaymentMethodOptions.phpEnum: Bancontact, Card, CashOnDelivery, Invoice + isDeferred()
app/RawModels/Transaction.phppayment_method, vat_number, company_name, pos_relation_number
app/Services/Orchestrators/OnlineOrderingOrchestrator.phpDeferred payment branch in storePayment()
app/Services/MplusKassa/RelationService.phpfindBusinessRelationByNumber() (:735), findBusinessRelationByEmail() (:645)
app/Services/Orchestrators/OnlineOrderingOrchestrator.phpresolveInvoiceBusinessRelation() (:1562), deferredStatusForPaymentMethod() (:1502)
app/Services/MplusKassa/OrderSyncService.phpSkips payment for deferred orders
app/Services/BackOffice/MplusKassaIntegrationService.phpcreateMplusKassaOrder -- payments: null for deferred
app/Services/Common/AbstractKassanetService.phpSkips GetKassanetBillJob for deferred
app/Http/Requests/OnlineOrdering/CreateIntentRequest.phpValidates payment_method against PaymentMethodOptions (:277). No VAT rules — VAT and company come from the resolved relation, never from the request
app/Console/Commands/BackfillMplusVatNormalization.phpBackfill vat_number_normalized

Backoffice (upvendo-backoffice) ​

FilePurpose
src/views/online/online-ordering/forms/Checkout.vuePayment methods section (COD + Invoice toggles, COD scope)
src/store/modules/onlineOrdering.tspayment_methods block in store default

Online Ordering (zestidoo-online-ordering) ​

FilePurpose
src/components/CheckoutPage.vueDynamic payment selector, Invoice VAT UX, email auto-fill, deferred submit
src/stores/transaction.tscheckoutForm fields
src/stores/app.tsisPaymentMethodAvailable getter (there is no invoiceRequiresVatMatch)
src/stores/auth.tsmplusRelation persistence from /me response

Deployment Checklist ​

  1. Backend first -- deploy, then run php artisan mplus:backfill-vat-normalization on production
  2. Backoffice second -- deploy, then merchant enables COD/Invoice in Online Ordering settings
  3. Online ordering last -- deploy after backoffice so the merchant has configured the toggles before customers see them