Skip to content

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

  1. All bookkeeping migrations deployed through 20260318160000, plus:
  2. 20260717090000 (accountant-report views: bookkeeping_transactions, bookkeeping_transfers, bookkeeping_mg0_balance, daily grain on bookkeeping_revenue_summary)
  3. 20260717180000 (bookkeeping_transactions gains failure_code/failure_message/expected_debit_date; PR #899)
  4. 20260717122138 (connect_balance_snapshots table + analytics.bookkeeping_connect_balances view; PR #900)
  5. configure-metabase-metadata.py run after any of the above (see Metadata section)
  6. For the Revenue tab specifically: ADR-027 phase 1 (PRs #919, #920, #921: L2's report_category/service_period_start/service_month, the analytics.bookkeeping_recognition_events view, 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_ledger filtered to charge/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 by service_period_start, falling back to event_date for charges without one (e.g. hourly). Category is L2's report_category (Monthly already absorbs prorated and waitlist_redemption there), 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, excluding deposit_redemption (that's the same dollar already counted as waitlist_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_reversal carry the redemption's location, or Unattributed when none resolves; Credit Adjustment, Deposit Breakage, and Credit Breakage always 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 nonzero platform_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_lines synthesizes the revenue exactly once from the invoice lines, and one receivable per invoice (at the invoice's amount_due) appears in analytics.bookkeeping_receivables with 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 the Bad 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 in add-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's amount_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 carries member_name/member_email/amount_usd for 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 the bookkeeping_stripe_ledger materialized 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_billing surcharge 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_balances exposes 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_cents platform-signed (negative = cash left MG0) rather than flipping to a location-perspective read. purchase_type comes from transfer_metadata->>'purchaseType'; transfer_source_charge_id links 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) for credits_% event types) instead of bookkeeping_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

  • *_cents columns: use Metabase column_settings with "number_style": "currency", "currency": "USD", "scale": 0.01
  • Credit balance columns are counts, not cents: plain number formatting
  • Filter on location_name (text), not location_id (UUID): bookkeeper usability
  • Revenue tab scalars are plain scalar cards 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

  1. Run configure-metabase-metadata.py: all bookkeeping tables discovered and fields tagged, including bookkeeping_connect_balances
  2. create-bookkeeping-dashboard.py is historical (see Files), not part of routine verification. For the Revenue tab, run sync-service-period-cards.py (bare) to confirm the live model (1424) and bridge (1423) queries match MODEL_SQL/BRIDGE_SQL; --push after 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-running create-bookkeeping-dashboard.py loses the live top-of-list reorder, see the Global Filters note).
  3. Run add-bookkeeping-balance-tab.py: Money Movements tab, 3 tabs total. add-bookkeeping-memberships-tab.py is not run — Memberships is retired.
  4. Run update-bookkeeping-daily-filter.py if the Date filter needs re-widening after a dashboard recreate
  5. 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: run sync-bookkeeping-clone.py --dry-run then 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
  6. Open in Metabase:
  7. 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
  8. 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)
  9. 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
  10. 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)
  11. 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)
  12. 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
  13. Memberships tab is absent from both dashboards
  14. 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
  15. No Insurance, Waitlist detail, Failed/Pending, Migrations Remaining, or units-denominated Credits activity cards anywhere on either dashboard
  16. CB Solutions accesses dashboard 18; COR Accounting accesses dashboard 34 — verify each firm's permissions group is scoped to the matching collection