Skip to content

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.transactionId is nullable → a payment is not guaranteed a settlement transaction (pending/failed/unsettled payments may have none).
  • Many-payments-to-one-transaction: Transaction.payments Payment[] (relation PaymentSettlement) — splits/retries can settle multiple payments to one transaction.
  • Refunds are transactions, not payments: Transaction.refundedPaymentId + Transaction.stripeRefundId (relation PaymentRefunds).
  • Credits/adjustments are transactions with no payment: unit ∈ {USD_CENTS, CREDITS} (AmountUnit), type ∈ {CREDIT, DEBIT} (TransactionType), reason (TransactionReason, e.g. STAFF_ADJUSTMENT), optional balanceId → 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 paymentstransactions, 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

  1. Exact collapse correctness — verify against real data how often Payment.transactionId is null and how often a transaction has >1 payment, so collapse neither double-counts nor drops events.
  2. Unified pagination across two source tables — the main build cost; the endpoint owns it (do not push to the client).
  3. Stripe richness inline vs drill-in — method/charge details live on the Stripe charge; surfacing inline re-raises the stripe.charges RLS question (see docs/ops/admin/audit-logs.md note about RLS on stripe.*). Likely: summary fields on the row, full detail on drill-in.
  4. 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.