ADR

ADR-017: Financial Ledger & Transaction Model

Last updated: 2026-02-01 | Decisions

ADR-017: Financial Ledger & Transaction Model

Status

Proposed — Pending engineering team review

Context

The transaction service uses a single-table JSON payment log — no debit/credit ledger. Financial reporting is limited, refund/chargeback auditing is difficult, and coin balance discrepancies are possible. This is P0 tech debt that becomes more urgent as the platform grows.

Current State

Component Current Gap
Transaction model Single table with JSON payment log No double-entry bookkeeping. Cannot reconcile balances.
Wallet 3 tables, coin balance tracked Balance is a running total — no audit trail of debits/credits
Stripe integration Payment records in stripe-db Not cross-referenced with wallet transactions
Refunds Handled in purchase-request-bpm No structured refund ledger — state tracked in BPM, not in financial records
Reporting Limited to raw Stripe data + JSON logs Cannot generate P&L, revenue recognition, or payout reconciliation

Impact

Decision

Implement double-entry bookkeeping in the consolidated payment-service, with a proper ledger model that records every financial movement as a balanced debit/credit pair.

Ledger Model

-- Core ledger table
CREATE TABLE ledger_entry (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    transaction_id  UUID NOT NULL,          -- Groups debit/credit pairs
    account_id      UUID NOT NULL,          -- References account (wallet, revenue, escrow, etc.)
    account_type    VARCHAR(50) NOT NULL,   -- WALLET, REVENUE, ESCROW, STRIPE_SETTLEMENT, REFUND
    entry_type      VARCHAR(10) NOT NULL,   -- DEBIT or CREDIT
    amount          BIGINT NOT NULL,        -- Amount in smallest unit (cents/coins)
    currency        VARCHAR(3) NOT NULL,    -- USD, COIN, etc.
    description     TEXT,
    reference_type  VARCHAR(50),            -- PURCHASE, REFUND, PAYOUT, ADJUSTMENT
    reference_id    UUID,                   -- Links to purchase, refund, payout record
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    created_by      UUID                    -- Keycloak user ID or SYSTEM
);

-- Every transaction must balance: SUM(debits) = SUM(credits) per transaction_id
-- Enforced via application-level validation + periodic reconciliation job

-- Account balance is always derived: SUM(credits) - SUM(debits) for that account
CREATE VIEW account_balance AS
SELECT account_id, account_type, currency,
       SUM(CASE WHEN entry_type = 'CREDIT' THEN amount ELSE 0 END) -
       SUM(CASE WHEN entry_type = 'DEBIT' THEN amount ELSE 0 END) AS balance
FROM ledger_entry
GROUP BY account_id, account_type, currency;

Account Types

Account Purpose Debit Means Credit Means
WALLET Expert’s coin/currency balance Coins spent or withdrawn Coins earned from sales
REVENUE Platform revenue Refund issued Sale completed (platform fee)
ESCROW Funds held pending fulfillment Released to expert Purchase payment received
STRIPE_SETTLEMENT Stripe payment tracking Refund to Stripe Payment from Stripe
REFUND Refund tracking Refund issued to fan Refund requested

Financial Flow Example: Shoutout Purchase

Fan pays $50 for shoutout:
  DEBIT  STRIPE_SETTLEMENT  $50  (money received from Stripe)
  CREDIT ESCROW             $50  (held until fulfillment)

Celebrity delivers shoutout:
  DEBIT  ESCROW             $50  (release from escrow)
  CREDIT WALLET             $40  (expert payout - 80%)
  CREDIT REVENUE            $10  (platform fee - 20%)

Fan requests refund (before delivery):
  DEBIT  ESCROW             $50  (release from escrow)
  CREDIT STRIPE_SETTLEMENT  $50  (refund to Stripe)
  DEBIT  REFUND             $50  (refund tracking)
  CREDIT REFUND             $50  (refund completed)

Every state has balanced entries. Balance is always derivable from the ledger. No running totals that can drift.

Migration Approach

  1. Add ledger tables to payment-service (commerce_db per ADR-005) alongside existing transaction table
  2. Dual-write: new transactions write to both old JSON log and new ledger
  3. Backfill: generate ledger entries from historical Stripe webhook data + existing transaction records
  4. Reconcile: compare wallet balances (old model) vs ledger-derived balances
  5. Switch: once reconciled, derive all balances from ledger. Old transaction table becomes read-only archive.
  6. Reporting: build financial reports from ledger (revenue, payouts, refunds, escrow)

Hypothesis Background

Primary: Double-entry bookkeeping provides the financial integrity and auditability the platform needs as transaction volume and product types grow.

Alternative: Keep current model, add reconciliation checks only. - Not fully rejected for MVP — if transaction volume stays low, the JSON log + periodic Stripe reconciliation may be sufficient. But this creates increasing tech debt as volume grows.

Falsifiability Criteria

Evidence Quality

Evidence Assurance
Single-table JSON transaction model L2 (verified from code)
Wallet running total, no audit trail L1 (code analysis, not production data verified)
Stripe webhook data available for backfill L1 (webhooks exist, completeness not verified)
Actual transaction volume L0 (need production data — H8)
Current balance discrepancy rate L0 (unknown)

Overall: L0 (WLNK capped by unknown transaction volume and balance discrepancy rate)

Bounded Validity

Consequences

Positive: - Complete audit trail for every financial movement - Balances always derivable and reconcilable - Enables financial reporting (revenue, payouts, escrow, refunds) - Supports future compliance requirements (PCI, financial auditing) - Eliminates coin balance drift risk

Negative: - Additional complexity in payment flow (dual-write during migration) - Backfill effort depends on historical data quality - Over-engineering risk if transaction volume is very low


Decision date: 2026-02-01 Review by: 2026-08-01