Design Rationale — Payments

Non-obvious design choices for the payments module — the WHY behind each decision.

Payments (locked 2026-06-10)

Many payment_intent rows per source_ref — no unique constraint on source_ref

Decision: payment_intent.source_ref is NOT unique. Multiple payment_intent rows can reference the same source record (e.g. the same pos.sale_payment.id).

Why: Partial payments and multi-tender sales require multiple charge attempts against a single source record. A customer paying a $500 A/R balance in three $150 + $150 + $200 installments creates three payment_intent rows, all pointing at the same billing.ar_payment.id. A unique constraint would break this.

Rejected: One-intent-per-source — can't model partial payments or retried charges without overwriting the original record, destroying the audit trail.

Guard: Do not add UNIQUE (source_ref). The index on (source_module, source_ref) supports the fan-in query efficiently without blocking multiple intents.


stripe_event_id global unique — NOT tenant-scoped

Decision: UNIQUE (stripe_event_id) on stripe_event_log is unconditional — no WHERE tenant_id = ?, no WHERE deleted_at IS NULL.

Why: Stripe event.id values are globally unique across all Stripe accounts and all tenants. Webhooks arrive at the Stripe platform level before Vrida resolves which tenant they belong to. tenant_id is nullable briefly after insert. A tenant-scoped unique would allow the same Stripe event to be inserted twice (once with tenant_id = NULL, once with the resolved tenant ID), defeating the idempotency guarantee.

Guard: Do NOT make this unique partial or tenant-scoped. This is the one deliberate deviation from the "all uniques are tenant-scoped" convention. Any future refactor that "fixes" this to WHERE tenant_id IS NOT NULL will break webhook dedup.


tip_amount_cents and application_fee_amount_cents as separate columns

Decision: Three separate money columns on payment_intent: amount_cents (charge excluding tip), tip_amount_cents, application_fee_amount_cents. Total to customer = amount_cents + tip_amount_cents (enforced by CHECK). Application fee is NOT added to the charge.

Why: Reporting requires three distinct figures — what the customer was charged for goods/services, what they added as a tip, and what Vrida earns as its platform fee. Rolling any of these together loses the distinction permanently.

Rejected: A single total_cents column — can't report tip rate, application fee revenue, or net-to-tenant without back-calculation that becomes impossible if the fee formula changes.


Offline-charge-fail lifecycle on payment_intent

Decision: payment_intent.origin = 'offline' + status tracks the full queued→executing→succeeded/failed arc on the same row. failure_reason records the terminal failure message.

Why: POS is offline-first. A sale can be completed at the till while the terminal is offline; on reconnect PaymentsService executes the charge. If the card is declined after the customer has already left, the failure must be recorded with enough detail for staff to follow up. synced_at records when the offline intent reached Stripe; failure_reason records the Stripe decline code.

Guard: This is the riskiest flow in the system. The status = 'failed' + failure_reason path MUST write back to pos.sale_payment.status via PaymentsService. If the writeback is skipped, the POS sale shows a completed tender while the charge never succeeded. Do not shortcut the origin = 'offline'executingfailed arc.


stripe_event_log and stripe_event_dead_letter are insert-once, NOT append-only

Decision: Both tables are labeled "insert-once" rather than "append-only." Both mutate columns after the initial insert.

Why: The webhook-idempotency pattern is: (1) insert the event row on receipt, (2) process the event, (3) update status and processed_at to record outcome. This is not append-only — the row is updated. Calling it "append-only" would imply no mutations, leading future engineers to expect immutability that doesn't exist, or to "fix" the update logic.

stripe_event_dead_letter goes further: retry_count, last_retry_at, and resolved_at all mutate in place — one row per failed event across all retry attempts.

Guard: Do not "fix" the mutable status / processed_at columns on stripe_event_log by treating the row as immutable and inserting a new completion row. The global unique on stripe_event_id would block the second insert. The pattern is: one row per event, updated for outcome.


Incoming-only scope — vendor A/P excluded from Payments

Decision: payments handles only incoming money (tenant's customers → tenant via Stripe Connect). Vendor A/P payments (billing.ap_payment) are NOT wired to Payments.

Why: Vendor payments are manual (check, ACH, wire) in v1.0. Stripe Connect is for receiving customer payments, not for paying vendors. The boundary is clean: money IN flows through Payments; money OUT flows through Billing's A/P tables.

Guard: Do not wire billing.ap_payment.stripe_payment_intent_id to PaymentsService without a deliberate scope-expansion decision. That column is a nullable text seam for the rare card-paid vendor; it exists in case the flow is added later but is not driven today.


payment_method stores display metadata only — no raw card data

Decision: payment_method stores stripe_payment_method_id (Stripe's vault reference), last4, and brand. No card number, no CVV, no full expiry.

Why: PCI-DSS. Stripe is the card vault. last4 and brand are explicitly display-safe values that Stripe provides for UI display; they carry no security risk. Storing anything beyond these fields would make Vrida a card processor in scope for PCI audit.

Guard: last4 and brand are safe to store and display. No other card fields ever enter Vrida's database. This is not an oversight — it is a compliance boundary.


Last modified: Jun 17, 2026, 8:37 PM PT
On this page
Esc