payments — Module Spec

1. Purpose

payments executes money movement via Stripe Connect — module #19, following billing (module #18, which records what's owed/settled). The tenant's customers pay the tenant; payments is the execution/integration layer for that money flow (POS Terminal card taps, online charges, refunds, payouts to the merchant's bank). v1 had a real, locked Payments module (8 tables / 112 cols, locked 2026-06-10) — all 8 preserved, plus 1 NEW table (terminal_reader, closing a hardware-pairing gap pos's own build cut).

2. Ownership

Owns — 9 tables, 159 columns:

Table Cols Tier Role
stripe_connect_account 16 LIGHT Per-tenant Stripe Connect account state
payment_intent 33 FULL Central execution ledger — the source seam lives here
payment_refund 21 FULL Refund execution — the clearest C8 boundary in this module
payout 20 FULL Stripe-to-tenant-bank payout reference
dispute 21 FULL Chargeback lifecycle
payment_method 15 LIGHT Saved card-on-file (Stripe is the vault)
stripe_event_log 8 ZERO Webhook idempotency log
stripe_event_dead_letter 10 ZERO Failed-webhook re-drive queue
terminal_reader 15 LIGHT, NEW Physical Stripe Terminal reader registry
Total 159

Does NOT own: what's owed/settled (billing's domain — payment_intent.source_ref only references it); vendor A/P payments (billing.ap_payment.stripe_payment_intent_id stays permanently unwired — Stripe Connect is incoming-only, v1's own deliberate scope boundary, re-confirmed); Vrida's own SaaS-subscription revenue (platform's domain — a direct Stripe integration, structurally unrelated to Connect, never conflated); tax computation (tax's domain, untouched by this module).

3. Layer & Dependencies

The execution/integration layer beneath the sell path's money flow. Depends on: platform (tenant, and the subscription column-comment clarification), identity (actor), crm (customer), multi_loc (site), shared (currency), pos (sale_payment, register — the Terminal seam), orders (order_payment), billing (ar_payment). Depended on by: none yet (schema-only, no service layer, and nothing downstream references payments tables directly — the seam runs the other direction, payment_intent.source_ref points down at the source tables).

4. Capabilities — honest Part D framing

Payments is the most deterministic module built so far — Stripe executes, webhooks record. The single hardest rule in the entire AI Capability Plane applies directly here: C8 (Financial actions are independently controlled and reversible).

Never-allowed, stated plainly: no agent may ever set payment_intent.status='succeeded', submit a charge to Stripe, or execute a payment_refund without a preceding human approval. Charge execution is system/human-triggered only (automation_source reflects the deterministic trigger, never an agent decision). Refund execution requires human approval when agent-drafted (review_status='pending' gates it). Agents only ever flag: fraud/anomaly on payment_intent, reconciliation breaks on payout, chargeback/evidence-drafting on dispute, retry suggestions on failed intents — never resolve, refund, or re-execute anything themselves.

No PaymentsService exists yet — schema-only build.

5. Service Contract — PaymentsService

Not built this pass. Binding future requirements: on Stripe webhook, write status back to pos.sale_payment.status / orders.order_payment.status / billing.ar_payment.status (service-layer only, not FK-enforced — v1's own design, re-confirmed); check payment_refund.amount_cents <= payment_intent.total_charged_cents - refunded_amount_cents before submitting a refund to Stripe (not DB-enforced, v1's own honest disclosure); never submit a charge or refund to Stripe while the relevant row's review_status='pending'.

6. Build Requirements (binding)

DR-1 — chk_payment_intent_source_pair must gate every insert

PaymentsService MUST never attempt to write a source_module/source_type combination outside the 3 valid pairs (pos/sale_payment, orders/order_payment, billing/ar_payment) — DB-enforced, but the service must not rely on the DB to catch a logic error it should never produce in the first place.

DR-2 — idempotency keys are mandatory for every outgoing charge/refund

PaymentsService MUST pass a stable idempotency_key on every charge/refund request so a client retry hits Stripe once, never twice. The double-charge guard (the two-partial-unique idempotency indexes) is the last line of defense, not the primary mechanism — the primary mechanism is a correctly-generated, stable key from the caller.

DR-3 — terminal_reader.stripe_reader_id UNIQUE must be respected at pairing time

PaymentsService MUST check for an existing terminal_reader row before registering a new one for the same Stripe reader ID (DB-enforced, but the service should surface a clear "already paired" error rather than a raw constraint violation).

7. Design Rationale (DR-A…D)

  • DR-A — the money boundary, designed once before either module's tables. billing records, payments executes, platform is unrelated. This required a corrective note on platform.subscription.status's own column comment (folded into this build's migration), since the existing wording ambiguously suggested a single "PaymentsService" spans both Vrida's own SaaS billing and this module's tenant-facing execution layer — it does not, and never should.
  • DR-B — chk_payment_intent_source_pair, a genuine fix, not a v1-preserved feature. v1's payment_intent never had this CHECK either — only two separate enum CHECKs with no combined pairing enforcement, meaning source_module='pos' + source_type='ar_payment' was silently insertable in the original v1 design too. Caught by independent adversarial verification during the design-phase pass (not this build), mirroring tax.tax_calculation/billing.ar_charge's own source-pair CHECK exactly.
  • DR-C — terminal_reader is NEW, closing a real logged gap, not scope creep. OPEN_ITEMS row 185 named this exact trigger ("when terminal hardware integration is built") after pos.register's own v1 hardware-pairing config (stripe_reader_id, printer_config, drawer_config) was cut with no v2 equivalent. Only the Stripe-Terminal-relevant piece (stripe_reader_id) is restored, and it's restored here — a Payments-owned asset — not back onto pos.register, since a physical Stripe Terminal reader is inherently a Stripe/Payments concern. printer_config/drawer_config remain genuinely out of scope (non-Stripe hardware).
  • DR-D — the idempotency design, checked specifically against the same-day billing.ar_charge NULL-distinctness bug, and found clean. payment_intent's two partial uniques are each single-nullable-column with a matching WHERE clause — no second nullable column lurking in either tuple, unlike the bug that hit billing.ar_charge the same day. source_ref is deliberately NOT unique (v1's own decision, re-confirmed) — multi-tender and partial-payment scenarios require multiple payment_intent rows against one source.
  • DR-E — Remediation Plan Phase 1 (2026-07-08): two fail-open gaps closed with new CHECKs. A cross-cutting senior-architect review found payments.dispute could reach a terminal status ('won'/'lost'/'closed') with no resolution/evidence trail, and payments.stripe_connect_account could be flagged charges_enabled/payouts_enabled while onboarding was still incomplete. Closed via chk_dispute_terminal_requires_evidence (terminal status requires resolved_at + evidence_submitted_at + payment_intent_id all NOT NULL) and chk_stripe_connect_account_enabled_requires_complete (charges_enabled OR payouts_enabled requires onboarding_status='complete'). No column/table count change. Full cross-module record: PROJECT_DECISIONS #37.
  • DR-F — Remediation Plan Phase 2 (2026-07-08): payment_intent.processor added as a minimal-now de-primitivization anchor. New column processor (text, NOT NULL, DEFAULT 'stripe', CHECK constrained to ('stripe') only for now) — Item 7 of the plan. Deliberately NOT the full vendor abstraction; the 19 other stripe_* columns across 6 modules, 2 table renames, and this CHECK's own widening are deferred until a second payment processor is integrated (see OPEN_ITEMS.md). stripe_event_dead_letter and stripe_event_log were considered for Item 6's UUIDv7 PK widening but explicitly EXCLUDED — both mutate fields in place post-insert despite lacking updated_at, so neither is genuinely append-only. payment_intent.id itself is correctly UNCHANGED (still gen_random_uuid()) — it's a mutable ledger, not append-only. Column count: 158 → 159 (payment_intent 32 → 33 cols), the only count change in this phase. Full cross-module record: PROJECT_DECISIONS #38.

8. Agent Authority Mapping

No new authority mechanism — pure consumer of identity.agent_duty_grant. Permission codes (all draft_only, logged to ai.agent_execution, target_module='payments'): payments:payment_intent:flag_anomaly, payments:payment_intent:suggest_retry, payments:payout:flag_reconciliation_break, payments:dispute:draft_evidence, payments:payment_refund:propose_refund (the one permission with a real C8 execution boundary behind it). The cumulative agent spend-ceiling deferral (already logged at identity/ai/pricing/purchasing) continues to hold here for the clearest possible reason: there is no may_act_alone money-moving action anywhere in this module to cap — an agent's "authority" is entirely about what it may propose, never what it may spend or execute.

9. Cross-Module Seams

  • payments → pos/orders/billing (source seam, polymorphic): payment_intent.source_refpos.sale_payment.id / orders.order_payment.id / billing.ar_payment.id — plain uuid, no FK, validated by chk_payment_intent_source_pair. No reciprocal column on any of the 3 target tables.
  • payments → pos (Terminal seam): terminal_reader.register_id → pos.register.id (nullable, real FK).
  • payments → crm/shared/multi_loc/identity: customer_id, currency_code, site_id, *_actor_id — standard.
  • payments → platform (the money-boundary clarification): platform.subscription.status's column comment corrected at this build to distinguish Vrida's own Stripe billing from this module's PaymentsService. See CROSS_MODULE_CONTRACTS.md's Platform↔payments row, reconciled at this build (it already named this exact trigger).
  • The forward-ref fills: pos.sale_payment.stripe_payment_intent_id, orders.order_payment.stripe_payment_intent_id, billing.ar_payment.stripe_payment_intent_id all remain plain TEXT, populated (not FK'd) once PaymentsService exists. billing.ap_payment.stripe_payment_intent_id stays permanently unwired (deliberate scope boundary).

10. Deferred / Future Items

No PaymentsService; the status-writeback to pos/orders/billing is documented, not built; card-expiry alert job/index (v1's own deferred item); billing.ap_payment.stripe_payment_intent_id permanently unwired (not a deferral — a confirmed, permanent scope boundary).

11. v1 Exclusions Re-Confirmed

All 8 v1 tables preserved, zero columns dropped. chk_payment_intent_source_pair is a genuine addition (v1 never had it either — see DR-B), not a restoration. terminal_reader is the one NEW table, justified against an already-logged OPEN_ITEMS trigger (see DR-C), not an invented capability.

Last modified: Jul 8, 2026, 10:10 AM PT
On this page
Esc