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.
billingrecords,paymentsexecutes,platformis unrelated. This required a corrective note onplatform.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'spayment_intentnever had this CHECK either — only two separate enum CHECKs with no combined pairing enforcement, meaningsource_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), mirroringtax.tax_calculation/billing.ar_charge's own source-pair CHECK exactly. - DR-C —
terminal_readeris NEW, closing a real logged gap, not scope creep.OPEN_ITEMSrow 185 named this exact trigger ("when terminal hardware integration is built") afterpos.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 ontopos.register, since a physical Stripe Terminal reader is inherently a Stripe/Payments concern.printer_config/drawer_configremain genuinely out of scope (non-Stripe hardware). - DR-D — the idempotency design, checked specifically against the same-day
billing.ar_chargeNULL-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 hitbilling.ar_chargethe same day.source_refis deliberately NOT unique (v1's own decision, re-confirmed) — multi-tender and partial-payment scenarios require multiplepayment_intentrows 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.disputecould reach a terminal status ('won'/'lost'/'closed') with no resolution/evidence trail, andpayments.stripe_connect_accountcould be flaggedcharges_enabled/payouts_enabledwhile onboarding was still incomplete. Closed viachk_dispute_terminal_requires_evidence(terminal status requiresresolved_at+evidence_submitted_at+payment_intent_idall NOT NULL) andchk_stripe_connect_account_enabled_requires_complete(charges_enabled OR payouts_enabledrequiresonboarding_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.processoradded as a minimal-now de-primitivization anchor. New columnprocessor(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 otherstripe_*columns across 6 modules, 2 table renames, and this CHECK's own widening are deferred until a second payment processor is integrated (seeOPEN_ITEMS.md).stripe_event_dead_letterandstripe_event_logwere considered for Item 6's UUIDv7 PK widening but explicitly EXCLUDED — both mutate fields in place post-insert despite lackingupdated_at, so neither is genuinely append-only.payment_intent.iditself is correctly UNCHANGED (stillgen_random_uuid()) — it's a mutable ledger, not append-only. Column count: 158 → 159 (payment_intent32 → 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_ref→pos.sale_payment.id/orders.order_payment.id/billing.ar_payment.id— plain uuid, no FK, validated bychk_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'sPaymentsService. SeeCROSS_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_idall remain plain TEXT, populated (not FK'd) oncePaymentsServiceexists.billing.ap_payment.stripe_payment_intent_idstays 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.