Account Activity Timeline (staff detail pages) — design spec
Status: deferred / future (not built). Captures the decision + model exploration so we can resume.
Problem
The staff User detail page shows a Payments table and a Transactions table. They overlap: a single money event (e.g. a $50 lockout charge) appears in both — once as a Payment (Stripe-rich) and once as its ledger Transaction. Staff see two rows for one event.
Decision
- Keep the data model split.
payments= Stripe money-in projection (charge, invoice, method, refunds, lifecycle status).transactions= the ledger / source of truth for balance movement (money and credits, adjustments, comps, refunds). Do not merge them in the DB — payments carry Stripe richness we want to keep clean and out of the ledger; the ledger covers movements that are not payments at all. - Unify them in the UI as a single "Account activity" timeline (one economic event = one row). Replaces both the Payments and Transactions tables. Keep it distinct from the Logs section (audit trail — different concern).
- Build a proper backend endpoint (
GET /users/[id]/activity), not a client-side merge. Rationale: payment activity must be 100% accurate; client merging two paginated streams is imprecise at page boundaries. - Future: rows must support click-through to entity detail pages, so each row carries a typed ref
{ kind, id }for routing.
Model exploration (why union, not backbone-enrich)
apps/api/prisma/schema.prisma:
Payment.transactionIdis nullable → a payment is not guaranteed a settlement transaction (pending/failed/unsettled payments may have none).- Many-payments-to-one-transaction:
Transaction.payments Payment[](relationPaymentSettlement) — splits/retries can settle multiple payments to one transaction. - Refunds are transactions, not payments:
Transaction.refundedPaymentId+Transaction.stripeRefundId(relationPaymentRefunds). - Credits/adjustments are transactions with no payment:
unit ∈ {USD_CENTS, CREDITS}(AmountUnit),type ∈ {CREDIT, DEBIT}(TransactionType),reason(TransactionReason, e.g. STAFF_ADJUSTMENT), optionalbalanceId → CreditBalance.
Consequence:
- "Transaction as backbone, enriched with payments" is insufficient — it would miss payments that have no transaction (unsettled/pending/failed), breaking the 100%-accurate-payments requirement.
- It must be a true union of payments ∪ transactions, with collapse rules so a settled money-in event isn't double-counted.
Proposed /activity shape (sketch)
Server-side: build the union, collapse, sort by createdAt, paginate (offset, per ADR-019 list conventions).
Row kinds:
- Payment (canonical money-in): absorbs its settlement transaction (via payment.transactionId). Carries Stripe detail: status, method (card/bank — see saved-methods work), charge, invoice, refund summary. Ref → payment / its reservation|waitlist|creditBalance|organization.
- Refund: a transaction with refundedPaymentId/stripeRefundId. Ref → the refunded payment.
- Credit/adjustment: a standalone transaction with no payment (credit grant/consumption, manual balance adjustment). Ref → creditBalance (if balanceId) or transaction.
Collapse rules (must be exact — payments are money-truth):
- A payment with a transactionId → one row; the linked transaction is absorbed, not emitted separately.
- A transaction settled by multiple payments (many-to-one) → emit one row per payment, or one grouped row; decide during build (split/retry is the edge case).
- A payment with no transactionId → still emitted (lifecycle states matter).
- Standalone transactions (no settlement payment, not a refund-of-payment already shown) → own rows.
Row fields: date · description (humanized) · amount (+unit: "$50.00" | "5 credits") · status · method (payments) · ref {kind,id} + drill-in for Stripe detail.
Open questions / risks to resolve at build time
- Exact collapse correctness — verify against real data how often
Payment.transactionIdis null and how often a transaction has >1 payment, so collapse neither double-counts nor drops events. - Unified pagination across two source tables — the main build cost; the endpoint owns it (do not push to the client).
- Stripe richness inline vs drill-in — method/charge details live on the Stripe charge; surfacing inline re-raises the
stripe.chargesRLS question (seedocs/ops/admin/audit-logs.mdnote about RLS onstripe.*). Likely: summary fields on the row, full detail on drill-in. - Click-through targets — which detail pages exist for each ref kind (payment, reservation, waitlist, credit balance) before wiring links.
Sequence
Not today. Build after the User → Resource → Reservation detail-page pattern is locked, so the timeline is designed once and reused.