Bookkeeping Dashboard: Metabase
Context
Layers 1–3 of the bookkeeping data pipeline are deployed and verified on production. analytics.bookkeeping_* views are live and granted to metabase_readonly. This doc specifies the Metabase "Bookkeeping" dashboard (id 18) and its location-scoped parity variant, "Stripe Bookkeeping (COR Accounting)" (id 34).
Two accounting firms, two dashboards. CB Solutions, the main accounting firm covering all locations, works in dashboard 18 ("Bookkeeping") directly — the same dashboard staff use; there is no separate view for them. COR Accounting, a smaller firm handling only a subset of buildings, uses dashboard 34, the parity variant locked to MG4/5/6/8/9 and kept up to date via sync-bookkeeping-clone.py. See the "Dashboard 34" section below.
The dashboard went through several rounds of restructuring on 2026-07-17: an 8-tab layout, then a 6-tab layout (Revenue, Memberships, Transactions, Transfers, Credits, MG0 Balance), then a consolidation to 3 tabs (Revenue, Memberships, Balances). Each round responded to accountant feedback that the prior layout had too many near-empty or redundant tabs. The Balances tab (renamed from MG0 Balance) absorbed Transfers, Credits' holder-level balances, and the credit-fee scalar; the Credits tab's units-denominated activity view (sold/redeemed counts, the stacked flow chart) was retired outright.
A further round of live edits landed after that (through 2026-07-23). Dashboard 18 is now 3 tabs again, but reshuffled: Revenue, Money Movements, Transactions. The Memberships tab is gone (see "Retired: Memberships tab" below) and All Transactions moved out of the Revenue tab into its own Transactions tab. Dashboard 34 mirrors this as Revenue, Balances, Transactions (its Balances tab is the same content as dash 18's Money Movements; tab titles were never part of the sync script's scope).
The same day, dashboard 18's Revenue tab cut over from the cash lens to a service-period basis (ADR-027 phase 2, docs/features/service-period-recognition/plan.md) and settled: every Revenue-tab card now reads Metabase model 1424 instead of bookkeeping_attributed_ledger directly, and the prior cash-basis cards are archived. See Tab 1, below, for the current shape. Dashboard 34 has not converted. It still shows the cash lens deliberately and is last in the bookkeeping-conversion queue (ruled 2026-07-23), so CB Solutions and COR never see divergent numbers mid-buildout. This doc describes the current state.
Prerequisites
- All bookkeeping migrations deployed through
20260318160000, plus: 20260717090000(accountant-report views:bookkeeping_transactions,bookkeeping_transfers,bookkeeping_mg0_balance, daily grain onbookkeeping_revenue_summary)20260717180000(bookkeeping_transactionsgainsfailure_code/failure_message/expected_debit_date; PR #899)20260717122138(connect_balance_snapshotstable +analytics.bookkeeping_connect_balancesview; PR #900)configure-metabase-metadata.pyrun after any of the above (see Metadata section)- For the Revenue tab specifically: ADR-027 phase 1 (PRs #919, #920, #921: L2's
report_category/service_period_start/service_month, theanalytics.bookkeeping_recognition_eventsview, and two dedupe/classification fixes caught by its verification gate), deployed and verified on prod 2026-07-23. Model 1424 reads both.
Dashboard: "Bookkeeping"
Standalone dashboard (Metabase dashboard id 18, separate from Owner Dashboard: bookkeeping is a distinct concern). 6 tabs, 24-column grid layout, full dashboard width, in order: Revenue, Receivable, Payable, Cash, Settlements, Transactions (phase-3 layout, 2026-07-24).
The tab system follows the statement triptych (Aaron ruling): Revenue is the income-statement flow; Receivable and Payable are balance-sheet positions, each reading scalars -> movement rollforward -> detail; Cash is bank-facing (what touched a bank - CB Solutions works only in bank statements); Settlements is Stripe-facing (the full Stripe-balance story, for staff and the future accounting firm, with a Diagnostics section at the bottom); Transactions is the money-in audit trail with its own Type filter.
- Receivable: Open A/R scalar + Receivables Aging (MBQL, Location/Date/Category wired) + A/R Rollforward (card 1443: monthly arisen/collected/written-off, closing gate-tied to open A/R).
- Payable: Outstanding Credits + Deposit Obligations (pending + parked; location-held deposits excluded - the location holds that cash) + Owed to Locations (untransferred charges) scalars; Deferred Revenue Rollforward (card 1436); Credit Balances and Obligations Detail (liabilities view itemization) as detail.
- Cash: Transfers Received by Location (each LLC's income, matches its bank statement; unlinked reversals disclosed) + MG0 Bank Activity (payouts/top-ups - empty until the first one lands).
- Settlements: Stripe Balance Rollforward (monthly buckets, closing gate-tied to the MG0 balance), Settlements by Kind, position cards (MG0 Available/Pending/Balance Breakdown, Connected Account Balances), All Transfers detail, Stripe Platform Fees scalar, Balance vs Obligations daily series, Platform Balance Over Time (card 1447, added 2026-07-25: full-history balance line from
analytics.bookkeeping_platform_balance_daily- a dense per-day running sum of ledger nets back to the first balance transaction, May 2025; wired to the Date filter and Grain toggle; Day grain = exact end-of-day balance, Month grain = average daily balance, since a level series has no meaningful sum; latest point ties the MG0 balance card exactly; the daily snapshot table could not source this - it only reaches back to mid-July 2026 - and remains a cross-check), then Diagnostics: Surcharge Recoupment (withheld vs billed - netting near zero monthly) and the reversal-linkage disclosure.
Statement-style cards (rollforwards, bank/location statements) are native SQL and deliberately unwired from the filter bar - they are whole-history artifacts read top-down; position scalars and the aging/detail tables respect the filters where wired.
Global Filters
Filter bar order: Location, Category, Date, Grain.
| Parameter | Type | Mapped To |
|---|---|---|
| Location | dropdown | location_name on every card whose source view carries it |
| Category | multi-select | Revenue tab's pivot (1425), pie (1430), and bar (1431) only, not the Revenue scalars, not yet Transactions (phase 3) |
| Date | date/all-options | event_date on views that have it (most bookkeeping views); month_start where a view has no event_date; defaults to Previous Month |
| Grain | temporal-unit | Revenue tab pivot's date breakout + Settlements' Platform Balance Over Time (1447); default Day (see Tab 1). No effect on the Revenue by Category charts |
Location values are a static list, not Metabase's field-values dropdown. natural_location_values() in create-bookkeeping-dashboard.py queries the distinct location_name values off bookkeeping_attributed_ledger and sorts them by parsed MG number (MG1..MG11) before appending anything that doesn't match (i.e. Metrognome). That trailing entry is relabeled MG0 Platform via a value/label pair in the script (["Metrognome", "MG0 Platform"] if v == "Metrognome" else v) — but on the live dashboard it's also been moved from the bottom of the list to the top, which the script's natural_location_values() sort key doesn't do (it still sorts Metrognome after every MGn). That reorder was a live Metabase edit, not a script change — re-running create-bookkeeping-dashboard.py will put MG0 Platform back at the bottom. Metabase's own field-values source only sorts alphabetically, which puts MG10 and MG11 between MG1 and MG2. The static list needs re-running (create-bookkeeping-dashboard.py) whenever a new location opens, since it isn't a live query the dropdown re-evaluates — but expect to re-apply the top-of-list reorder by hand afterward.
Category (revenue_type_param, multi-select) auto-sources its values from Metabase model 1424's own Category column (self-maintaining, so a new category never needs a filter-widget edit). It's wired only to the Revenue tab's pivot (1425), pie (1430), and bar (1431); it does not filter the Revenue scalars, and Transactions doesn't get its own category filter until phase 3 (see Tab 3, below). Values: Monthly, Insurance, Hourly, Credit Redemption, MG0 Credit Fee, Credit Adjustment, Deposit Breakage, Credit Breakage, Test Charge, plus the labeled contras (see Tab 1 for the full list and the naming convention).
The Date filter (time_range_param, date/all-options) accepts a single day, a range, or a relative period. Cards on views without event_date fall back to month_start. Defaults to Previous Month (changed from no default at the service-period cutover).
Grain (temporal-unit) toggles the Revenue tab pivot between monthly and daily rows; defaults to Day (changed from Month at the service-period cutover). Day grain reads the model's Service Date directly (period start for monthly dues, the reservation slot/occurrence date for hourly) with no amortization: a monthly due lumps entirely on the 1st rather than spreading across the days it covers. Both dashboards carry the parameter. It does not bind to the Revenue by Category charts, which are native SQL.
Tab 1: Revenue
Service-period basis, not cash. Every card on this tab reads Metabase model card 1424, "Service-Period Revenue [Model]"; nothing queries bookkeeping_attributed_ledger (L2) directly anymore. Cutover landed and settled 2026-07-23 (ADR-027 phase 2, docs/features/service-period-recognition/plan.md); the prior cash-basis cards, staff scalars 965/966/967/1264, MG0 Credit Fees scalar 1416, the old Revenue by Location pivot 971, and the category pie/bar 1418/1419, are archived in Metabase (retained for audit trail, no longer on any tab). Dashboard 34 has not converted; see "Dashboard 34" below.
The model's SQL (and the bridge's, see below) is version-controlled in analytics/scripts/sync-service-period-cards.py: edit the MODEL_SQL/BRIDGE_SQL constants there and run with --push (bare, it diffs against live instead). Ad hoc patching a card's query directly in the Metabase UI is retired, per the script's own header.
What the model does. It unions two shapes onto one row grain: Service Date, Location, Location Name, Category, Units, Gross, Stripe Transaction Fees, Net Revenue.
- Charges:
bookkeeping_attributed_ledgerfiltered tocharge/payment/refund/payment_refund/payment_failure_refund, excluding synthesized rows and excluding deposit/credit-purchase/waitlist-redemption lines entirely (those are deferrals, not revenue, and refunds of them leave revenue entirely too: a refunded deposit reverses the deferral, never revenue). Dated byservice_period_start, falling back toevent_datefor charges without one (e.g. hourly). Category is L2'sreport_category(Monthly already absorbsproratedandwaitlist_redemptionthere), with one override still living in the model rather than L2:report_category = 'test_charge'maps to "Test Charge" here, a phase-2 rider to migrate this fold into L2 itself pending (see the plan's "Phase-2 rider" note). - Recognition events:
bookkeeping_recognition_events, excludingdeposit_redemption(that's the same dollar already counted aswaitlist_redemption/Monthly above; excluding it here avoids double-counting). Dated at the event's own date: redemption date, or the 12- or 18-month breakage anniversary.credit_redemption/credit_redemption_reversalcarry the redemption's location, or Unattributed when none resolves;Credit Adjustment,Deposit Breakage, andCredit Breakagealways land on MG0 Platform, never location-attributed. A third union arm splits the MG0 platform-fee cut back out as its own row (category MG0 Credit Fee / MG0 Credit Fee (Reversed), location MG0 Platform) whenever a redemption or reversal carries a nonzeroplatform_fee_cents; promo and comp redemptions carry none, per the final ADR-028 no-money-movement ruling, so they never produce an MG0 Credit Fee row.
Categories: Monthly, Insurance, Hourly, Credit Redemption, MG0 Credit Fee, Credit Adjustment, Deposit Breakage, Credit Breakage, Test Charge, plus labeled negatives: Monthly (Refunded), Hourly (Refunded), Credit Redemption (Reversed), MG0 Credit Fee (Reversed), Bad Debt (Written Off). Convention: every negative names itself. A refund/payment_refund row looks up its original charge's category via refund_original_charge_id and appends " (Refunded)"; the mechanism is generic over whatever category the original charge carried and falls back to "Uncategorized (Refunded)" if no original charge resolves. The (Bounced) contra categories died in the ADR-029 restatement (2026-07-24): payment_failure_refund rows no longer enter the revenue lens at all - a bounce creates a receivable instead (see "Receivables" below). The pie (1430) excludes the negative-suffix categories, hence its caption, "Gross before Refunds & Bounces."
| Card | Type | Metric |
|---|---|---|
| 1424 | model (not a dashboard tile, the shared source every card below reads) | canonical rows: Service Date, Location, Location Name, Category, Units, Gross, Stripe Transaction Fees, Net Revenue |
| 1426 Gross | scalar | SUM(Gross) |
| 1427 Stripe Transaction Fees | scalar | SUM(Stripe Transaction Fees) |
| 1428 MG0 Credit Fees | scalar | SUM(Gross) restricted to the MG0 Credit Fee / MG0 Credit Fee (Reversed) categories |
| 1429 Net | scalar | SUM(Net Revenue); chain: Gross minus Stripe Transaction Fees minus MG0 Credit Fees = Net |
| 1425 Revenue by Location | pivot | MBQL on the model: rows Service Date (desc) by Location (asc), columns Category, values Units/Gross/Stripe Transaction Fees/Net Revenue |
| 1430 | pie | "Revenue by Category (Gross before Refunds & Bounces)" |
| 1431 | stacked bar | Revenue by Category over time |
| 1423 | bridge, bottom of tab | transition diagnostic, see below |
Row/coordinate positions aren't documented here: like the old category-chart block used to be, this whole set is hand-tuned live and repacked whenever a card above it changes size. Card order is the stable fact (scalars, then the pivot, then the pie/bar, then the bridge at the very bottom); grid math goes stale fast.
Location and Date filter every card on this tab. Category filters only the pivot, pie, and bar, not the four scalars (which always total the full filtered period regardless of category) and not yet Transactions (its own category filter is phase 3; see Tab 3, below). Grain defaults to Day (changed from Month) and binds only to the pivot's date breakout; day grain reads the model's Service Date directly (period start for monthly dues, the slot/occurrence date for hourly) with no amortization, so a monthly due lumps entirely on the 1st rather than spreading across the days it covers.
The pivot's Location column sorts correctly ascending without the dropdown workaround described under Global Filters above: the model zero-pads single-digit MG numbers in its own SQL (MG1 becomes MG01, etc., via regexp_replace) and relabels Metrognome to MG0 Platform directly, so ordering by the model's own Location column already reads MG01..MG11 then MG0 Platform. (The top-bar Location filter widget's static value list is unchanged; see Global Filters.)
Bridge: retired (2026-07-24, phase 3). Card 1423 is archived; its replacement is the Deferred Revenue Rollforward (card 1436) on the Payable tab - per month and deferral type, Opening + New Deferrals - Released - Breakage = Closing, at face value (deposits at deposit face, credits at $1/credit). Logic lives in analytics.bookkeeping_deferral_rollforward (view; metabase_readonly cannot read the stripe schema), canonical card SQL in sync-service-period-cards.py. Deposits are waitlists-scoped - legacy adjudicated deposit cash is excluded (its lifecycle is unknowable) and disclosed in the card description; refunds and bounces net out of New in the month the money went back. Both closings tie exactly to independently computed outstanding obligations, enforced by verify-service-period (rollforward tie checks). The Charged/Posted/Timing diagnostics the bridge carried live only in the gate now.
Receivables (ADR-029, built 2026-07-24). Textbook accrual for payment failures: revenue counts regardless of payment status, and a bounce creates a receivable instead of a contra. The model's mechanics:
- The grain is the invoice, not the charge attempt. Stripe retries an unpaid invoice as new charges on the same payment intent, and a late ACH return flips its charge to
status='failed'while the optimistic settlement and its full return stay behind as a net-zero cash pair (verified on prod 2026-07-24: all 44 bounce events sit on status-failed charges; one unpaid invoice carries up to 5 bounced attempts). - ALL invoiced bounce attempts leave the lens together with their contras (net-zero pairs, cash noise). For recovered invoices (29 of 33 lifetime) the successful retry's rows carry the service months, so recovered months are unchanged by the restatement. For unpaid invoices,
analytics.bookkeeping_receivable_invoice_linessynthesizes the revenue exactly once from the invoice lines, and one receivable per invoice (at the invoice'samount_due) appears inanalytics.bookkeeping_receivableswith aging from the first failure. - Staff rule uncollectible balances case-by-case (Paul, 2026-07-24) via
cd apps/api && pnpm tsx scripts/bookkeeping.ts writeoff-receivable --charge=<source_id> --ruled-by=<name> [--note=...] [--dry-run] --production; a writeoff books theBad Debt (Written Off)negative dated at the ruling while the revenue stays. - Invoices whose charges never succeeded at all (no cash event, invisible to the charge-derived ledger) follow the same path: synthetic revenue from lines, one receivable,
kind = never_succeeded. - Aging surface: a dedicated Receivables tab (Aaron ruling 2026-07-24; A/R is a work queue, not revenue) carries the Open A/R scalar and Receivables Aging table as MBQL questions on
analytics.bookkeeping_receivables(Metabase-metadata-synced; card ids inadd-receivables-cards.py's output). Both respect the Location filter (location_name), the Date filter (arose_at- note the dashboard's Previous Month default windows the list to recently-arisen debts; clear the Date filter to see the full queue), and the Category filter (category: the receivable's DOMINANT invoice line, largest summed amount, same Monthly/Insurance folding as the synthetic revenue lines; a mixed dues+insurance bill reads Monthly). The receivable amount is the invoice'samount_due(what the member still owes - customer-balance applications at finalization reduce it below the invoice total), while synthetic revenue stays at full line value; the difference is a liability release, not unearned revenue. The view carriesmember_name/member_email/amount_usdfor display. - Known property: a bounced charge with no Stripe invoice (one-off/hourly payment intents) has no retry linkage, so it never self-clears - a later re-collection arrives under a new payment intent and the receivable sits open until staff write it off or rule it. All current open receivables are invoice-linked.
The restatement (killing the historical (Bounced) contras and the bounced attempts' own revenue rows) was a one-time, disclosed change-in-principle; bookkeeping.ts verify-service-period checks 11-13 prove every invoiced bounce removal is revenue-neutral per charge and that the lifetime-earned delta equals exactly the open A/R it created, and the no-restatement guarantee resumes after the cutover.
Tab 2: Money Movements
Sources: analytics.bookkeeping_mg0_balance (platform cash position), analytics.bookkeeping_waitlist_balance, analytics.bookkeeping_credit_balances, analytics.bookkeeping_connect_balances (PR #900), analytics.bookkeeping_transfers, analytics.bookkeeping_attributed_ledger (credit-fee scalar). Built by add-bookkeeping-balance-tab.py.
This tab displays as Money Movements on dashboard 18; dashboard 34 still shows Balances — tab titles aren't part of sync-bookkeeping-clone.py's scope, only card queries. (The script that builds this tab, add-bookkeeping-balance-tab.py, also still names it "Balances" internally.)
| Row | Card | Display | W×H | Metric |
|---|---|---|---|---|
| 0 | MG0 Balance | scalar | 6×3 | SUM(balance_cents) |
| 0 | Available | scalar | 6×3 | SUM(available_cents) |
| 0 | Pending | scalar | 6×3 | SUM(pending_cents) |
| 0 | Waitlist Balance | scalar | 6×3 | SUM(gross_amount_cents), location-filterable |
| 3 | Outstanding Credits (all locations) | scalar | 6×3 | SUM(balance) WHERE status != 'expired' |
| 3 | Metrognome Credit Fees | scalar | 6×3 | −sum-where platform_fee_cents WHERE revenue_type = 'credit_redemption_transfer' |
| 3 | Transfer Count | scalar | 6×3 | COUNT(*) on bookkeeping_transfers |
| 3 | Transfer Amount | scalar | 6×3 | SUM(amount_cents), platform-signed |
| 6 | Connected Account Balances | table | 24×8 | all bookkeeping_connect_balances columns, location_name ASC |
| 14 | note | heading | 24×1 | "Amounts are platform-signed: negative = cash sent from MG0 to the location" |
| 15 | All Transfers | table | 24×10 | all bookkeeping_transfers columns, event_date DESC |
| 25 | Waitlist Balance | table | 24×8 | all bookkeeping_waitlist_balance columns, month DESC |
| 33 | Credit Balances (all locations) | table | 24×8 | all bookkeeping_credit_balances columns, balance DESC |
- MG0 Balance / Available / Pending come from
bookkeeping_mg0_balance, a single-row view (no location filter, sums the whole ledger): freshness bounded by thebookkeeping_stripe_ledgermaterialized view refresh (daily, via/cron/sync-stripe-and-refresh-views). - Waitlist Balance is deposit holdings the platform is sitting on (money held for members still on the waitlist), not revenue: it doesn't belong on the Revenue tab.
- Outstanding Credits and Credit Balances are holder-level and location-agnostic: credits have no location until redemption, so both cards are explicitly labeled "(all locations)" and carry no Location filter mapping.
- Metrognome Credit Fees is Metrognome's 10% cut on credit-redemption transfers, distinct from Stripe's own processing fee (Revenue tab's Stripe Transaction Fees, 1427) and from the
stripe_billingsurcharge withheld at transfer time, neither of which this cut is. It does carry Location and Date mappings, since credit redemptions are location-attributed. (The Revenue tab's own MG0 Credit Fees scalar, 1428, is this same cut expressed service-basis.) - Connected Account Balances (PR #900):
analytics.bookkeeping_connect_balancesexposes the latest daily snapshot of each location's Stripe Connect account balance (connect_balance_snapshots, populated by a nightly cron step, see [[infrastructure/analytics]]). Values run near zero because Connect accounts auto-pay-out daily; the card is there to show what's currently in transit, not to represent a meaningful standing balance. Per-account fetch failures isolate and report to Sentry without blocking the rest of the snapshot. - All Transfers keeps
amount_centsplatform-signed (negative = cash left MG0) rather than flipping to a location-perspective read.purchase_typecomes fromtransfer_metadata->>'purchaseType';transfer_source_charge_idlinks a transfer back to the charge that funded it. Column order matches the Transactions table convention: event/location/type/money fields, then identifiers trailing. - MG0 Balance Breakdown (card 1415, shared verbatim across both dashboards — no location dimension, so no clone-specific filter is needed): a native-SQL single-panel partition of the MG0 Stripe balance into cash on hand, owed out by liability type (waitlist deposits, unredeemed credits at payout basis, parked deposits, untransferred charges), and an unrestricted remainder. The credits line reads
analytics.platform_events(SUM(obligation_delta_cents)forcredits_%event types) instead ofbookkeeping_credit_balances, so it becomes purchased-credit-origin-only automatically once PR #917 (ADR-026) deploys — no card edit required. An operating-buffer line is deliberately omitted until one exists to show.
Tab 3: Transactions
Single card, moved here from the bottom of the Revenue tab: All Transactions, source bookkeeping_transactions (row-level, WHERE NOT is_synthesized, excludes Part C's charge-shaped stand-ins for transfers with no Payment FK, avoiding double-count against the Money Movements tab's transfer table), scoped to customer-facing transaction types (charge, payment, refund, payment_refund, payment_failure_refund), ordered event_date DESC. Column order: event/location/type fields, then money columns (gross_cents, fee_cents, net_cents), then payment_method_display/description/status fields, then failure_code/failure_message/expected_debit_date (PR #899: a declined or pending-ACH row shows its failure reason inline instead of needing a separate near-empty tab), then Stripe identifiers (location_id, user_id, balance_transaction_id, charge_id, stripe_invoice_id) trailing at the end. The reader reads left-to-right by what-happened before who/what-record-it-was.
Carries the same Location and Date filter mappings as the Revenue tab's cards. It does not yet carry the Category filter: Category is wired only to the Revenue tab's pivot/pie/bar (see Tab 1); giving Transactions its own category filter is phase 3 (docs/features/service-period-recognition/plan.md).
Retired: Memberships tab
Dashboard 18 had a Memberships tab (source analytics.bookkeeping_attributed_ledger joined to public.users, built by add-bookkeeping-memberships-tab.py) between the 2026-07-17 consolidation and the tab reshuffle that added the Transactions tab. It's no longer part of either dashboard's tab list — dashboard 34 never had it (DROP_TABS excluded it from every clone rebuild). The script still exists in the repo but isn't run against either live dashboard.
It held a single table card, "Membership Payments," one row per succeeded subscription invoice. Filters to transaction_type IN ('charge','payment'), charge_status = 'succeeded', stripe_invoice_id IS NOT NULL, billing_reason IN ('subscription_create','subscription_cycle','subscription_update','manual'), and explicitly excluded revenue_type = 'credit_purchase' (credit-purchase invoices aren't membership payments even if they happen to share a billing reason).
| Column | Source |
|---|---|
| Date | event_date |
| Location | location_name |
| Member | users.name, left join on attributed_ledger.user_id |
| Type | billing_reason mapped: subscription_create→signup, subscription_cycle→renewal, subscription_update→change, else (incl. manual)→rebill |
| Studio Price | sum-where gross_cents WHERE gross_cents > 0 AND revenue_type NOT IN ('insurance', 'waitlist_redemption') |
| Insurance | sum-where gross_cents WHERE revenue_type = 'insurance' |
| Waitlist Discount | sum-where gross_cents WHERE revenue_type = 'waitlist_redemption' |
| Other Credits | sum-where gross_cents WHERE gross_cents < 0 AND revenue_type NOT IN ('insurance', 'waitlist_redemption') |
| Net Paid | sum(gross_cents) |
The five money columns were mutually exclusive partitions of every line item on the invoice, so Studio Price + Insurance + Waitlist Discount + Other Credits = Net Paid on every row (cash actually collected).
Retired: units-denominated Credits activity
The old Credits tab's activity view (Credits Sold / Credits Redeemed scalars, a stacked bar chart of sold/redeemed/returned/renewed credit counts by month, and the bookkeeping_credit_activity detail table) was deliberately dropped in the final consolidation, not folded into Balances. Dollar flows already cover bookkeeping without it: credit purchases show up as revenue on the Transactions table, redemptions show up as transfers, and the outstanding liability shows up as the Outstanding Credits / Credit Balances cards on the Money Movements tab. bookkeeping_credit_activity still exists in the schema (granted to metabase_readonly) but no script builds cards against it.
Removed entirely (not on any tab)
- Insurance section cards: redundant with the pivot's Insurance-adjacent columns on Revenue and the Memberships tab's Insurance column.
- Waitlist Deposits row-level detail table: redundant with All Transactions; the aggregate scalar + balances table live on Balances.
- Failed / Pending cards: absorbed by All Transactions plus PR #899's failure columns.
- Migrations Remaining cards: the underlying view (
bookkeeping_migration_remaining) only ever counted 5 stale Yardi invite rows; the migration program completed in June 2026 and nothing new will ever show up there. - Credits activity view (sold/redeemed counts, flow chart): see above.
The bookkeeping_refunds, bookkeeping_failed_transactions, bookkeeping_credit_activity, and bookkeeping_migration_remaining views still exist in the schema (harmless, still granted to metabase_readonly), but no script builds cards against them. add-bookkeeping-transactions-tab.py, add-bookkeeping-transfers-tab.py, and add-bookkeeping-credits-tab.py were deleted outright in the consolidation (their content is now folded into Revenue and Balances); git history has them if the query logic is ever needed for reference. add-bookkeeping-waitlist-tab.py, add-bookkeeping-refunds-tab.py, and add-bookkeeping-failed-tab.py were deleted earlier in the 8-to-6-tab restructure, same reasoning.
Dashboard 34: "Stripe Bookkeeping (COR Accounting)"
COR Accounting's dashboard. COR is the smaller of the two accounting firms and handles only a subset of buildings — exactly what the MG4/5/6/8/9 location lock implements. The main firm (CB Solutions, all locations) does not use this dashboard; they work in dashboard 18 directly. A brief 2026-07-23 doc revision called dashboard 34's audience "unclear" — that overcorrected; the name and lock mean what they say.
This dashboard's Revenue tab is still the cash lens. Dashboard 18's Revenue tab moved to the service-period model 1424 on 2026-07-23 (see Tab 1, above), but dashboard 34 is deliberately last in the bookkeeping-conversion queue (ruled the same day), so CB Solutions and COR never see divergent numbers mid-buildout. Its Revenue by Category charts are still the archived-on-dash-18 clones (pie 1420, bar 1421), and their MG4/5/6/8/9 location lock is baked directly into their own SQL WHERE clause rather than a shared parameter mapping: sync-bookkeeping-clone.py can't auto-sync native SQL (not the pMBQL-stages shape it diffs), so any future edit to them is manual.
Built by create-bookkeeping-accountant-dashboard.py (clone Metabase dashboard id 34) for the initial clone and any full structural rebuild — a new tab, a new card, or a source-view column change. DROP_TABS = {"Credits", "Memberships"} drops Memberships from the clone, leaving it with Revenue, Balances, Transactions (three tabs, mirroring dashboard 18's Revenue/Money Movements/Transactions — Memberships is a no-op drop now that dashboard 18 doesn't have it either). DROP_TABLES = {"bookkeeping_credit_activity", "bookkeeping_credit_balances"} additionally drops any individual card sourced from those two tables even on a tab that otherwise survives: on the clone's Balances tab, the Outstanding Credits scalar and the Credit Balances detail table are both absent — pending a decision on whether dashboard 34's audience gets them. MG0 Balance, Waitlist Balance, Metrognome Credit Fees (it queries bookkeeping_attributed_ledger, not a credit table, so DROP_TABLES doesn't touch it), Connected Account Balances, and All Transfers all still render. LOCKED_MG_IDS = ["MG4", "MG5", "MG6", "MG8", "MG9"]: every card whose source view carries location_name gets a starts-with filter injected regardless of what's picked in the Location dropdown (the dropdown's own value list is also narrowed to just those five, so there's nothing else to pick). This isn't just headline cards: it's every location-bearing card across all three tabs, including the Revenue pivot, the category charts, the Transactions tab's detail table, and the Balances tab's transfer/connect-balance tables.
DROP_TABS still says "Credits" even though Credits no longer exists as a standalone tab on dashboard 18: the entry is a no-op guard, harmless to leave in case a future restructure reintroduces a Credits tab.
Routine card changes sync in place. analytics/scripts/sync-bookkeeping-clone.py (--dry-run to preview) copies dataset_query from each dashboard-18 card to its dashboard-34 counterpart via an explicit CARD_MAP (source card id → clone card id) — a card edited on Bookkeeping (a formula tweak, a filter change) reaches dashboard 34 by re-running this script, not by re-cloning the whole dashboard. It preserves dashboard 34's location-lock filter clauses: it strips volatile lib/uuid keys and structurally diffs the source and clone queries' filter lists, then merges any clone-only clause back in. It never touches visualization_settings, so hand edits made directly on dashboard 34 survive a sync. A card whose dataset_query isn't the pMBQL-stages shape (native SQL, legacy MBQL) is skipped with an explicit message rather than overwritten wholesale, which would silently drop the clone's filter clauses — this is why the Revenue by Category pie and bar charts (native SQL, MG4-9 lock baked into their own WHERE clause) print as unmapped and need a manual copy into dashboard 34's collection whenever the dashboard-18 version's SQL changes. A staff card not yet in CARD_MAP also prints as unmapped rather than being guessed at — it needs a one-time manual copy into the clone's collection, then a CARD_MAP entry. create-bookkeeping-accountant-dashboard.py's archive-and-recreate remains the only path for structural changes; that path still archives the prior clone and its cards, and the resulting dashboard id still churns — it's the exception now, not the routine path.
Manual one-time setup (Metabase admin UI, not scripted): create an "Accountant" permissions group, grant it View-only access to dashboard 18's collection and revoke all others, grant "No self-service" native-query access on all databases (blocks the SQL editor), then invite the accountant into the group. (An earlier revision of this doc had this pointed at the clone's collection — wrong, per the correction above.)
Metadata Configuration
Update analytics/scripts/configure-metabase-metadata.py:
EXPECTED_TABLES (bookkeeping views: the schema still carries views the dashboard no longer surfaces):
bookkeeping_revenue_summary
bookkeeping_credit_activity
bookkeeping_credit_balances
bookkeeping_waitlist_balance
bookkeeping_refunds
bookkeeping_failed_transactions
bookkeeping_liabilities
bookkeeping_transfers_by_location
bookkeeping_transactions
bookkeeping_transfers
bookkeeping_mg0_balance
bookkeeping_connect_balances
Field semantic types:
| Semantic Type | has_field_values |
Columns |
|---|---|---|
type/Currency |
— | gross_cents, fee_cents, net_cents, discount_cents, customer_balance_applied_cents, platform_fee_cents, transferred_cents, gross_amount_cents, net_amount_cents, balance_cents, available_cents, pending_cents |
type/CreationDate |
— | month_start, event_date, latest_activity_date, expected_debit_date, captured_at |
type/Category |
list |
revenue_type, status, segment, location_name, charge_status, original_revenue_type, purchase_type, failure_code |
type/FK |
— | location_id, user_id |
Implementation Notes
*_centscolumns: use Metabasecolumn_settingswith"number_style": "currency","currency": "USD","scale": 0.01- Credit balance columns are counts, not cents: plain number formatting
- Filter on
location_name(text), notlocation_id(UUID): bookkeeper usability - Revenue tab scalars are plain
scalarcards over the filtered period, not smartscalars: that's what keeps them reconciling penny-exact with the pivot - Pivot table supports CSV export natively in Metabase
Files
| Action | File |
|---|---|
| Modify | analytics/scripts/configure-metabase-metadata.py |
| Reference | analytics/scripts/create-bookkeeping-dashboard.py: marked historical at the dash-18 settle. The Revenue tab it built is superseded by model 1424 + cards 1425-1431 (below); Transactions tab is unaffected by the cutover but the script as a whole isn't re-run, treat as reference, not a build step |
| Modify | analytics/scripts/sync-service-period-cards.py: canonical SQL for the Revenue tab model (1424) and bridge (1423). Edit the MODEL_SQL/BRIDGE_SQL constants, run with --push; bare (no flag) diffs against live. No other path may edit these two cards |
| Reference | analytics/scripts/add-bookkeeping-memberships-tab.py: retired, not run against either live dashboard (see "Retired: Memberships tab" below) |
| Modify | analytics/scripts/add-bookkeeping-balance-tab.py: Money Movements tab (script still names it "Balances" internally) |
| Modify | analytics/scripts/sync-bookkeeping-clone.py: add a CARD_MAP entry for any newly cloned card; native-SQL cards (e.g. the category charts) aren't covered by the sync and need a manual copy |
| Reference | analytics/scripts/create-bookkeeping-accountant-dashboard.py (full reclone + lock, structural changes only) |
| Reference | analytics/scripts/update-bookkeeping-daily-filter.py |
| Dead | git log for add-bookkeeping-{transactions,transfers,credits,waitlist,refunds,failed}-tab.py: deleted, not run |
| Reference | analytics/DASHBOARDS.md (grid conventions) |
Verification
- Run
configure-metabase-metadata.py: all bookkeeping tables discovered and fields tagged, includingbookkeeping_connect_balances create-bookkeeping-dashboard.pyis historical (see Files), not part of routine verification. For the Revenue tab, runsync-service-period-cards.py(bare) to confirm the live model (1424) and bridge (1423) queries matchMODEL_SQL/BRIDGE_SQL;--pushafter any edit, and confirm the pushed query still runs to completion. For the Transactions tab, Location filter static list stays ordered MG1..MG11 then MG0 Platform (re-runningcreate-bookkeeping-dashboard.pyloses the live top-of-list reorder, see the Global Filters note).- Run
add-bookkeeping-balance-tab.py: Money Movements tab, 3 tabs total.add-bookkeeping-memberships-tab.pyis not run — Memberships is retired. - Run
update-bookkeeping-daily-filter.pyif the Date filter needs re-widening after a dashboard recreate - Structural clone changes only: run
create-bookkeeping-accountant-dashboard.py(new dashboard id, expect it to change), Credits and Memberships tabs absent (leaving Revenue/Balances/Transactions), every location-bearing card locked to MG4/5/6/8/9. Routine card-query changes: runsync-bookkeeping-clone.py --dry-runthen without the flag — dashboard id 34 stays stable, unmapped cards (including the native-SQL category charts) print instead of being guessed, clone-only filters and viz settings survive - Open in Metabase:
- Location dropdown lists MG1..MG11 then MG0 Platform, in that order; on dashboard 34 it only lists MG4/5/6/8/9 and picking anything else is not possible
- Category filter (values auto-sourced from model 1424) isolates a single display category (e.g.
Monthly (Refunded)) on the pivot/pie/bar; it has no effect on the four Revenue scalars or on Transactions (phase 3) - Date filter accepts a single day or a range, defaults to Previous Month; Grain toggles the pivot between month and day rows (defaults to Day), with no effect on the Revenue by Category charts
- Revenue scalar chain: Gross (1426) − Stripe Transaction Fees (1427) − MG0 Credit Fees (1428) = Net (1429); a refund with no traceable original charge shows as
Uncategorized (Refunded)rather than disappearing (bounces no longer enter the revenue lens per ADR-029) - Revenue by Category pie (1430) reconciles to the Gross scalar (1426) net of the negative-suffix categories it excludes ((Refunded), (Reversed), (Written Off), plus the now-inert (Bounced) filter); the bridge (1423) at the bottom of the tab shows Charged/Earned/Posted/timing columns for a past closed month unchanged run to run (no-mutation invariant)
- Transactions tab: All Transactions shows a declined or pending-ACH row's
failure_code/failure_message/expected_debit_date; a bounced ACH shows its charge plus a dated negative reversal line - Memberships tab is absent from both dashboards
- Money Movements (Balances on dashboard 34) tab: MG0 Balance matches the live Stripe balance as of the last sync; Waitlist Balance total matches current open (
ACTIVE/INVITED) deposits; Outstanding Credits matches current unredeemed balance and is labeled "(all locations)"; Connected Account Balances shows a row per location, near-zero values are expected; Metrognome Credit Fees is negative and distinct from Revenue's Stripe Transaction Fees (1427); MG0 Balance Breakdown partitions the Stripe balance into liability-type obligations plus an unrestricted remainder; the Revenue tab's MG0 Credit Fees scalar (1428) and MG0 Credit Fee category rows are the service-basis counterpart of this same 10% cut - No Insurance, Waitlist detail, Failed/Pending, Migrations Remaining, or units-denominated Credits activity cards anywhere on either dashboard
- CB Solutions accesses dashboard 18; COR Accounting accesses dashboard 34 — verify each firm's permissions group is scoped to the matching collection