Skip to content

Service-Period Revenue Recognition

Spec written 2026-07-23 from the dashboard-18 revision round while the rulings were fresh. Decision records: ADR-027 (extends ADR-025's cash/accrual split), ADR-029 (receivables for failed charges, ruled 2026-07-23, built 2026-07-24). Status: phase 1 (data layer) DEPLOYED, phase 2 (dash 18 service-basis Revenue tab) LIVE as of 2026-07-23, receivables (ADR-029) built 2026-07-24; dash 34/COR waits until the very end; phase 3 (settlements tab + deferral rollforward) next. CB Solutions (main firm, all locations, dashboard 18; COR covers the MG4/5/6/8/9 subset via dashboard 34) reviews but the accrual basis is decided (Paul, 2026-07-22) - direction, not a question.

Problem

The bookkeeping dashboard's cash lens approximates accrual with display tricks, and the seams showed during the 2026-07 revision round:

  • Retroactive mutation. Transfer amounts allocate back onto charge rows, so a deposit charged in June shows its payout on the June row when the transfer executes in July. Closed months change after the fact; a month-end snapshot at day grain is unreproducible.
  • Deferred money counted as revenue at receipt. Waitlist deposits and credit purchases book as revenue when cash arrives. The "Waitlist Discount" contra column exists only to stop the first invoice from double-counting the deposit; MG0's credit-fee income cannot join pivot totals without double-counting the purchase cash.
  • Computed fees. The old "Stripe Platform Fee" column was a net-minus-transferred residual. Stripe bills the real number as dated stripe_fee debits.
  • No settlement story. Transfers have no execution-dated view; "what moved this month" is answerable only from the raw All Transfers table.

Accountants close on actual posted dollars per period, on a service-period basis (Paul, 2026-07-22). The acceptance test, verbatim from Aaron (2026-07-23): transfers get their own dated events, revenue rows never mutate retroactively, and a month-end snapshot by day grain is stable and reproducible at any later date.

Rulings incorporated

Ruling Source
Revenue basis = service period; capture all information (charged + posted + service) Paul, 2026-07-22
Waitlist deposits non-refundable; transfer to location only at redemption Ruling 2026-07-17, business/payments, business/waitlist
Deposits expire to platform breakage income at 12 months; obligations = deposits under 12 months Paul, 2026-07-22 (income_forfeiture vocabulary in ADR-025)
Post-expiry honor is a staff judgment call booked as a new $100 credit expensed to marketing; the old liability stays dead Paul, 2026-07-22
Credit balances expire at 18 months, per purchase, soonest-first consumption Standing policy, business/credits
Free/comped bookings move NO money: promo-credit redemptions and comped bookings transfer nothing to locations (location-borne); purchased-credit redemptions keep the ~90c payout and the 10% MG0 fee Paul 2026-07-23 FINAL (reverses the same-day top-up-funded ruling; recorded as ADR-028 no-money-movement-free-hourly via PR 918; impl PR 917 reopened)
Revenue tab is revenue-only; settlement lives elsewhere Aaron, 2026-07-23
Subledger records cash movements only; accrual recognition is derived in the bookkeeping layer ADR-025 amendment, 2026-07-22
Bounced/failed charges: textbook accrual - revenue counts regardless of payment status; a RECEIVABLES ledger tracks what members owe (bounces become receivables, not contra-revenue; uncollectible balances become bad-debt expense). Supersedes the interim contra-at-occurrence treatment for bounces; refunds stay contra (service actually reversed) Paul, 2026-07-23

Recognition mapping (SHARED WITH CB SOLUTIONS FOR REVIEW)

Category At cash receipt Recognition event(s) Service period
Monthly (incl. prorations) Revenue Charge (with dated contra rows for later reversals/refunds) Stripe invoice line period, booked to the period's month. Billing anchors on the 1st, so no line covers more than one month in practice (a proration's exclusive end timestamp lands on the next 1st but covers zero next-month service); any line spanning >35 days is flagged as a never-seen Stripe shape
Insurance Revenue Charge Invoice line period
Hourly Revenue Charge Reservation slot date
Waitlist deposit Deferred (obligation) Redemption: prepayment application, no revenue event (the booking's own revenue is full price). Expiry at 12 months: breakage revenue (income_forfeiture), dated at the anniversary Redemption: n/a (movements side). Breakage: expiry date
Credit purchase Deferred (obligation; purchased origin only; promo grants involve no cash and create no obligation or ledger rows) Redemption: booking revenue at the hosting location (redeemed value) plus MG0 credit-fee income (the 10% cut, purchased origin only), dated at the booking's service date. Expiry at 18 months: breakage revenue dated at expiry. Promo redemptions and comped bookings: no recognition rows, no movements (final 2026-07-23 ruling). Cancellations (ruled 2026-07-24): cancelled before service WITH credits restored = no rows at all (never earned); sub-24h forfeit (credits kept) = revenue stays; cancelled after service = contra pair dated at occurrence Redemption: booking's service date. Breakage: expiry date
Refund Contra-revenue dated when the refund occurs, attributed to the original charge's category (displayed as labeled contra, e.g. Monthly (Refunded)); closed periods never restate Occurrence date
Bounced / failed payment BUILT (ADR-029, 2026-07-24): no revenue reversal - the failure creates a receivable (analytics.bookkeeping_receivables, arises at first failed charge). Cleared by a successful retry (the retry's rows carry the same service months, so month totals hold and the receivable disappears) or written off case-by-case by staff (bookkeeping.ts writeoff-receivable), which books a labeled negative Bad Debt (Written Off) dated at the ruling while the revenue stays. Never-succeeded invoices (no cash event at all) earn synthetic revenue rows from their invoice lines while owed. Deferral-type bounces (deposits, credit purchases) are never receivables - that cash was never revenue Service period unchanged by payment failure; bad debt at ruling date
Stripe platform fees Not revenue: platform expense from actual stripe_fee debits, dated at debit; lives on the settlements/movements side Debit date
Unattributed (legacy) Interim: revenue at charge month Resolved by the manual audit (Juan); no automated fallback Charge month until attributed

Settled while drafting (2026-07-23, Aaron): invoice lines are single-month in practice (see Monthly row), so period-month booking needs no allocation policy; deposit breakage and credit breakage are separate categories because obligations must stay itemized.

Receivables questions resolved (2026-07-24): writeoff trigger = case-by-case staff ruling (Paul confirmed, matches operational behavior); receivable arises at first failed charge (keeps the ledger charge-derived); the interim contra months were restated in one disclosed migration (change-in-principle; the no-restatement guarantee resumes after cutover, and the verification gate proves the before/after delta reconciles to the receivables it created). All three dates (charged, posted, service) ship on every line per Paul's capture-everything directive, so a cleared-cash lens is always derivable; whether it appears as a Revenue-tab toggle or stays in the bridge card is a display choice made from usage feedback, not a blocker.

Design

Ledger changes (L2 and below)

  • posted_date on every row, from stripe.balance_transactions.attrs->>'available_on' (deterministic at creation; already synced).
  • service_period_start / service_period_end / service_month per the mapping table. Sources: invoice line period (monthly, insurance, prorations), reservation slot (hourly), expiry/redemption dates (deferred types). First build step: verify invoice-line period coverage on prod.
  • report_category: the folded display category as a real column, shared by the Category filter, pivot, and charts — one folding, never three.
  • Recognition events for deferred types derived at read time from waitlists (paid, redeemed, cancelled, expiry timestamps) and the credits transactions ledger (grants, consumption steps with origin per ADR-026, expiries). No stored postings (ADR-025).
  • Same DROP-CASCADE rebuild contract as existing L3 views (bookkeeping_liabilities and friends must be recreated by any L1/L2 rebuild).

Revenue tab (re-based)

  • Pivot on service_month/day via the existing Grain toggle; categories from report_category; measures: Units, Gross, Stripe Transaction Fees, Net Revenue.
  • The Waitlist Discount column disappears (prepayment application is movements-side). Monthly revenue = full price. Deposit and credit-purchase categories leave the pivot; in their place: recognition categories (redeemed credit revenue at locations, MG0 Credit Fee, breakage) with real amounts in the standard measures.
  • Bridge card per period: earned (service) / charged / posted / deferred-balance movement. This is the answer to every "why doesn't this match the bank" question.

Settlements tab (new, pivot-style, execution-dated)

Rows dated when money moved, by location: charge transfers, deposit payouts at redemption, credit-redemption payouts (purchased-origin), surcharge withholds, transfer reversals, platform fee debits, top-ups. Includes the surcharge-recoupment-vs-billed check (withheld 0.7% vs actual Billing Usage debits). Absorbs the interim Stripe Platform Fees scalar currently parked on Money Movements.

Breakage sweeps

Deposit-expiry (12mo) and credit-expiry (18mo) sweeps produce dated income_forfeiture recognition events (vocabulary shared with the ADR-025 subledger — one vocabulary, never two). Display/recognition first; automatic payout of recognized breakage to the MG0 bank account is deferred (interacts with the reserve gate, still in log-only mode). Obligations views (bookkeeping_liabilities, MG0 Balance Breakdown card) filter to unexpired deposits (<12mo) and unexpired purchased credits as part of this work.

Acceptance criteria

  1. Transfers appear only as execution-dated events; no revenue row carries settlement amounts.
  2. A closed day's revenue rows are byte-stable: re-running any view for a past period returns identical results regardless of later redemptions, transfers, or reversals.
  3. Month-end snapshot by day grain reproducible at any later date (the accountant test).
  4. Old-lens vs new-lens reconciliation is penny-exact at cutover: lifetime cash totals agree, and the bridge explains every per-period difference.

Phases

  1. Verify + migrate: prod invoice-period coverage check; L2 migration (three dates, report_category); recognition + settlements views; penny-exact reconciliation harness; attribution overlay table seeded from the frozen legacy adjudications (apps/api/scripts/seed-data/legacy-attribution-adjudications.json: all 81 legacy unattributed charges, evidence dig + Aaron rulings 2026-07-23, keyed by charge id).
  2. Revenue tab re-base: pivot/scalars/charts on service basis; bridge card; retire the discount column and deferred-as-revenue categories.
  3. Settlements tab: execution-dated pivot + recoupment check; move the platform-fee scalar home; Money Movements historic series wiring (platform_daily_snapshot, per-type obligation columns).
  4. Breakage: expiry sweeps, income_forfeiture events, obligation-view age filters, business-docs alignment for the credit-breakage accounting treatment.

Each phase ships independently; phase 1 blocks 2-4.

Non-goals

The written subledger (ADR-025, separately sequenced); automatic breakage payout to bank; connected-account history ingestion; the legacy attribution cleanup (parallel work, feeds data quality here).