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.

Settings schema (payment_methods on OnlineOrderingSetting)

json
{
  "cod_enabled": false,
  "cod_available_for": "both",
  "invoice_enabled": false,
  "invoice_require_vat_match": false
}
FieldTypeDefaultDescription
cod_enabledboolfalseEnable Cash on Delivery for this location
cod_available_for"both" / "pickup" / "delivery""both"Which dining options COD is available for
invoice_enabledboolfalseEnable Invoice payment (Mplus-only, section hidden for non-Mplus tenants)
invoice_require_vat_matchboolfalseWhen 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 === true AND the customer's dining option matches cod_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)

  1. Customer selects Invoice payment
  2. Types their VAT number in the checkout field
  3. Frontend debounces (500ms, min 8 chars) and calls POST /online-ordering/{slug}/{locationId}/validate-vat
  4. Backend queries the local MplusRelation cache by vat_number_normalized
  5. If found: "Found: [Company Name]" shown, company auto-filled
  6. If not found + strict mode on: submit blocked
  7. If not found + lenient mode: submit allowed without relation

Path B: Email auto-fill (logged-in customer)

  1. Customer logs into OO
  2. Backend resolves their email against MplusRelation via RelationSyncService::findAndLinkRelation
  3. Result returned on the /customer/user response as mplus_relation block
  4. Frontend pre-fills VAT + company name, shows "Billed to [Company] (VAT ...)" card
  5. No live /validate-vat call needed

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 RelationService::resolveInvoiceRelation (VAT first, email fallback)
  4. Runs capacity check + inventory validation (same as paid orders)
  5. Sets sent_at via OrderCapacityService::setTransactionSentAtByOrderCapacity (honors scheduled-order-to-POS config)
  6. Marks transaction Complete
  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.


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

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:

  • MplusRelation stores vat_number (raw from Mplus) and vat_number_normalized (canonical form)
  • RelationService::findRelationByVatNumber queries on vat_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)

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.phpfindRelationByVatNumber(), resolveInvoiceRelation()
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/Controllers/Api/OnlineOrderingController.phpvalidateVatNumber()
app/Http/Requests/OnlineOrdering/CreateIntentRequest.phpVAT validation rules + conditional withValidator
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, validateVatNumber action
src/stores/app.tsisPaymentMethodAvailable, invoiceRequiresVatMatch getters
src/stores/auth.tsmplusRelation persistence from /me response
src/api/services/transaction-services.tsvalidateVatNumber API call

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