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 backendcod_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