Mollie Integration β
This document specifies the Mollie Marketplaces integration for operator-to-passenger payment processing within the BusFlow platform.
Related docs: PRODUCT_payments-and-billing.md Β· schema-commerce.md Β§payments Β· ADR-009
WARNING
Review status: This spec was initially drafted during the L2 Checkout & Payment review. Sections marked [TBD β product decision] require explicit sign-off before implementation. Mollie API details (event names, endpoints, OAuth scopes) illustrate the process based on Mollie's public documentation; validate them at implementation time.
1. Architecture Overview β
BusFlow uses Mollie Marketplaces to route passenger payments to individual operator sub-accounts. The platform acts as the marketplace owner (collecting platform fees), while each Operator tenant has a linked Mollie sub-account which receives payouts.
Passenger β Mollie Checkout β Platform Fee Split β Operator Sub-Account β PayoutGrounded in: PRODUCT_payments-and-billing.md Β§1 β "Mollie provides a high trust factor in Europe, natively supporting DACH payment methods. Its Marketplace routing allows for delayed payouts (acting as an escrow for bus trips)."
NOTE
Bank Statement Imports (MT940/CAMT): Automated bank statement reconciliation (planned for Phase 2) happens completely outside of the Mollie gateway. PRODUCT_payments-and-billing.md is the Single Source of Truth for bank import architectures.
2. Sub-Account Provisioning (Operator Onboarding) β
Each Operator requires a Mollie sub-account before accepting payments. Mollie-specific configuration resides in the operator_integrations table (not directly on operators), scoped by integration type. See schema-backoffice.md Β§operator_integrations.
Flow β
| Step | Actor | Action |
|---|---|---|
| 1 | Operator | Completes Nhost signup β Operator.status = ONBOARDING |
| 2 | System | Creates Mollie sub-account via Mollie Connect (OAuth) |
| 3 | Mollie | Returns organization identifier + onboarding URL |
| 4 | System | Stores Mollie organization identifier + onboarding status in the operator_integrations table (type = MOLLIE) |
| 5 | Operator | Completes Mollie KYC onboarding (identity, bank account, UBO β required by EU AML regulations) |
| 6 | Mollie | Fires onboarding webhook β System updates integration config β Operator ready to accept payments |
NOTE
[TBD β implementation detail] The team must validate the exact Mollie Connect API flow (OAuth scopes, redirect URLs, onboarding webhook events) against Mollie documentation during implementation.
2.1 Integration Config (Mollie) β
The system stores Mollie-specific fields in the operator_integrations table under type = 'MOLLIE' with a JSONB config column:
interface MollieIntegrationConfig {
organization_id: string; // Mollie sub-account ID
onboarding_status: 'PENDING' | 'COMPLETED' | 'NEEDS_DATA';
// Additional Mollie-specific fields as needed at implementation
}2.2 Operator Payment Config β
The following fields remain on the operators table (or in operator-level config), as they are platform concerns β not Mollie-specific:
| Field | Type | Description |
|---|---|---|
deposit_config | JSONB | Operator-level deposit rules (see Β§3) |
final_payment_config | JSONB | Operator-level final payment timing (see Β§4.2) |
NOTE
deposit_config and final_payment_config are tenant-level defaults. Operators can override both per TourTemplate.
3. Deposit Configuration β
The system resolves the deposit percentage via a cascading lookup. The exact override levels remain a product/legal decision; the important invariant is that the resolved rule is snapshotted on the concrete departure and booking so later default changes do not silently alter already published or accepted terms.
tour_templates.deposit_config(if set β use this)operators.deposit_config(tenant default)- System default: 20% (Governed by German travel law Β§ 651a BGB, which generally accepts a maximum advance payment of 20% of the travel price).
NOTE
Template-level deposit overrides are a convenience hypothesis, not yet a hard product invariant. A safer minimum is central operator/system defaults, immutable TourDeparture snapshots, and immutable Booking snapshots captured at checkout.
JSONB Structure β
interface DepositConfig {
percentage: number; // e.g., 20 (= 20%)
type: 'PERCENTAGE' | 'FIXED';
min_amount: number | null; // Optional floor
}NOTE
The system default of 20% serves as a legally safe fallback. Future versions will introduce a Compliance Rule Engine to actively warn operators if they configure deposits exceeding jurisdictional limits (e.g., >20% under BGB). See compliance-rule-engine.md.
4. Checkout-to-Payment Pipeline β
4.1 B2C Payment Flow β
| Step | Entity | State | Mollie Action |
|---|---|---|---|
| 1 | CheckoutSession | ACTIVE | β |
| 2 | Booking | DRAFT | β |
| 3 | Payment (DEPOSIT) | PENDING | Create Mollie payment with deposit amount + marketplace routing |
| 4 | Passenger | β | Redirect to Mollie Checkout (hosted payment page) |
| 5 | Mollie webhook | payment completed | NestJS receives callback |
| 6 | Payment (DEPOSIT) | COMPLETED | provider_transaction_id + payment_method saved |
| 7 | Booking | DEPOSIT_PAID | BookingConfirmed event emitted β CostingSheet locks |
| 8 | CheckoutSession | CONVERTED | β |
| 9 | SeatReservation | CONFIRMED | β |
| 10 | Ticket | conditional | If ticket_issuance_trigger = DEPOSIT_PAID (operator default): ticket issued now. If FULLY_PAID: deferred to final payment. See ADR-014. |
Grounded in: ADR-009 β
DEPOSIT_PAIDasBookingConfirmedtrigger. Full booking lifecycle in booking-lifecycle-protocol.md.
4.2 Final Payment Flow β
The final payment reminder timing is operator-configurable via final_payment_config:
interface FinalPaymentConfig {
reminder_days_before_start: number; // e.g., 30 β first reminder at start_date - N days
escalation_days_before_start: number; // e.g., 14 β second reminder / escalation
flag_days_before_start: number; // e.g., 7 β flag for dispatcher review
}Resolution cascade: tour_templates.final_payment_config β operators.final_payment_config β System default: 28 days ({ reminder_days_before_start: 28, escalation_days_before_start: 14, flag_days_before_start: 7 }). Industry standard for DACH package tours.
| Step | Trigger | Action |
|---|---|---|
| 1 | Hasura Scheduled Trigger final_payment_reminder | Daily scan: for each booking in DEPOSIT_PAID, checks if start_date - reminder_days_before_start β€ today |
| 2 | FinalPaymentDue event | Communications sends Magic Link via configured channels |
| 3 | Passenger clicks Magic Link | Opens passenger portal showing outstanding balance |
| 4 | Payment (FINAL_PAYMENT) | PENDING β Mollie Checkout |
| 5 | Mollie webhook: payment completed | Payment.status = COMPLETED |
| 6 | Booking | FULLY_PAID |
IMPORTANT
If the system does not receive the final payment by escalation_days_before_start, it sends a second reminder. If it still does not receive the payment by flag_days_before_start, it flags the booking for dispatcher review (manual follow-up or cancellation). We do not implement automatic cancellation in V1.
4.3 B2B Payment Flow β
NOTE
[TBD β product decision] B2B agencies, schools, clubs, public bodies, and other institutional buyers may bypass B2C deposit rules and book on agency invoice or negotiated payment terms (see booking-widget/user-journeys.md Journey 3). The B2B payment flow, commission handling, approval process, credit risk, and invoice-based booking mechanism are out of scope for this spec and Management will define them separately.
B2B invoice bookings must not be treated as a minor variant of the B2C checkout pipeline. They may require separate booking states, payment terms, buyer contacts, purchase-order references, credit limits, cancellation rules, and channel commission handling.
5. Webhook β State Transition Mapping β
A dedicated NestJS webhook handler receives all Mollie webhooks.
NOTE
The Mollie event names below illustrate the process based on Mollie's webhook documentation. The team must validate the actual event names, payload structure, and delivery guarantees against the Mollie API at implementation time. The state transitions (which Booking/Payment status follows which payment outcome) constitute the authoritative part of this table.
NOTE
For the complete processPaymentWebhook NestJS handler contract (input, guards, idempotency, cascade sequences), see booking-lifecycle-protocol.md Β§processPaymentWebhook.
Payment Webhooks β
| Payment Outcome | Payment Type | Payment Status | Booking Status | Side Effects |
|---|---|---|---|---|
| Payment succeeded | DEPOSIT | β COMPLETED | β DEPOSIT_PAID | Emit BookingConfirmed; lock CostingSheet; confirm SeatReservations; issue Tickets (if ticket_issuance_trigger = DEPOSIT_PAID) |
| Payment succeeded | FINAL_PAYMENT | β COMPLETED | β FULLY_PAID | Update FinancialLedger realized revenue; issue Tickets (if ticket_issuance_trigger = FULLY_PAID) |
| Payment failed | any | β FAILED | β (no change) | Emit PaymentFailed β Communications sends retry link |
| Payment expired | any | β FAILED | β (no change) | If DEPOSIT: release SeatReservations, expire CheckoutSession. If FINAL_PAYMENT: flag for dispatcher review. |
| Payment canceled | any | β FAILED | β (no change) | Same as expired |
Refund Webhooks β
| Refund Outcome | Payment Status | Booking Status | Side Effects |
|---|---|---|---|
| Refund settled | β REFUNDED | β REFUNDED (if full) / unchanged (if partial) | Create Payment row with type = REFUND or PARTIAL_REFUND, negative amount. The operator's Mollie sub-account balance funds the refunds. Update FinancialLedger. |
Onboarding Webhooks β
| Onboarding Outcome | Integration Impact |
|---|---|
| Onboarding completed | Update operator_integrations config: onboarding_status = COMPLETED, enable payment acceptance |
| Needs additional data | Flag operator for data completion |
6. Marketplace Routing (Split Payments) β
Every payment call includes Mollie Marketplace routing to split funds between the BusFlow platform and the operator sub-account.
6.1 Platform Fee Calculation Source β
Every payment operation requires a platform fee value for marketplace routing. The system resolves this via a cascading lookup:
operators.platform_fee_config: A JSONB column defining the negotiated rate for the specific operator (e.g.,{ percentage: 1.5, fixed_cents: 30 }).- Global Platform Config: If the operator column is null, the system uses the default platform fee defined in the global configuration (seeded during operator onboarding).
This approach allows Busflow to safely negotiate custom rates with enterprise operators while maintaining a standard rate for self-serve users.
6.2 Fee Pass-Through Option (B2C) β
The system supports a Fee Pass-Through toggle within the operator settings.
- Disabled (Default): The operator absorbs the platform fee. The passenger pays the exact travel price; Busflow deducts its commission via the Mollie routing split before paying out the operator.
- Enabled: The platform fee is added as a surcharge on top of the travel price at checkout. The passenger pays the fee directly. This requires clear disclosure in the booking widget to comply with consumer protection laws (e.g., PAngV - Preisangabenverordnung).
BusFlow does not manage payout timing directly. Operators configure their payout preferences in their Mollie dashboard (linked from the BusFlow backoffice).
7. Error Handling & Idempotency β
| Concern | Strategy |
|---|---|
| Idempotency | All webhook handlers are idempotent. provider_transaction_id (unique constraint on payments table) prevents duplicate processing. |
| Retry delivery | Mollie retries webhook delivery with exponential backoff over ~8 hours (per Mollie documentation). A daily payment_reconciliation_sweep Scheduled Trigger catches any missed webhooks beyond this window (see booking-lifecycle-protocol.md Β§payment_reconciliation_sweep). |
| Verification | Handlers verify payment status via Mollie API before applying state transitions β do not trust webhook body alone. |
| Dead letters | Failed webhook processing follows the error handling pattern in workflow-orchestration.md. |
Failure Scenarios β
| Scenario | Handling |
|---|---|
| Webhook arrives before checkout completes | [TBD β implementation detail] Queue and process when Booking exists. |
| Mollie API unavailable at payment creation | Return error to frontend, retry with backoff. [TBD β retry strategy] |
| Double webhook delivery | Idempotency guard on provider_transaction_id |
| Partial marketplace routing failure | Log, alert ops, manual reconciliation via Mollie dashboard |
8. Payment Service Contracts β
Service-level operations for payment creation. All operations include Mollie Marketplace routing (Β§6) and the payment_type discriminator in Mollie metadata for webhook routing. For the webhook handler contract, see booking-lifecycle-protocol.md Β§processPaymentWebhook.
Booking Payment Operations β
| Operation | Key Inputs | Output | Notes |
|---|---|---|---|
| Create deposit payment | booking_id, tenant_id, amount (from DepositConfig Β§3), currency | Mollie checkout URL | Amount calculated from DepositConfig cascade. Includes marketplace routing (Β§6). |
| Create final payment link | booking_id, amount_remaining (= total_amount β completed deposits) | Checkout URL (sent via Magic Link Β§4.2) | Same routing as deposit. |
| Create partial refund | Payment reference, amount, description | β | Refunds against operator sub-account balance. |
8.1 Onboard Payment Link Contracts β
The following contracts support the OnboardSale β Commerce Payment Link Bridge flow. The Operations sync handler calls the onboard checkout operation when processing an OnboardSale with payment_method = PAYMENT_LINK. See schema-operations.md Β§onboard_sales for the full sync handler flow and edge states.
| Operation | Key Inputs | Output | Notes |
|---|---|---|---|
| Create onboard checkout session | tenant_id, onboard_sale_id, tour_offering_id, item_description, amount, currency, passenger_contact (phone and/or email) | checkout_session_id, checkout_url, expires_at | Default expiry: 60 min. Operator-configurable via operator_settings.onboard_payment_link_ttl_minutes (default: 60, min: 15, max: 180). Idempotent on onboard_sale_id β returns existing non-expired session if one exists (crash-gap recovery, see schema-operations.md Β§onboard_sales). |
Passenger contact is passed as transient input (not persisted on OnboardSale). Primary: phone (WhatsApp/SMS). Fallback: email.
Cancellation Contract β
| Operation | Input | Output | Side Effects |
|---|---|---|---|
| Cancel onboard checkout | checkout_session_id, tenant_id, reason | β | Expires session, cancels Mollie payment. If already paid β initiates refund. |
Called by the Operations void handler when the driver voids an OnboardSale.
NOTE
Uses the standard Mollie Payments API (/v2/payments) β consistent with the booking checkout pattern. The checkout_url from the response is shareable via WhatsApp/SMS. Alternative: Mollie Payment Links API (/v2/payment-links) generates dedicated shareable URLs with built-in expiry β consider if the standard checkout_url expiry behavior proves insufficient.
NOTE
Passenger contact resolution: Primary path: ticket_id β commerce.tickets β passengers (email/phone) for upsells to existing passengers. Fallback: driver-entered phone number (transient input passed in passenger_contact, not persisted on OnboardSale) for non-ticketed sales.