Refactor economics to expense-record ledger with correct Bubble cost

Replace pre-aggregated costs.json with expense_records.json (48 line-item
records) and payment_records.json. All monthly and cumulative totals are
computed deterministically in observatory/ledger.py. Correct Bubble.io to
$32/mo (since Feb 2025) — infrastructure €69.44/mo not €72.20.
This commit is contained in:
2026-06-22 02:03:22 +02:00
parent ea2c2c6403
commit 31db9f8f31
12 changed files with 843 additions and 299 deletions

View File

@@ -10,21 +10,21 @@ Liquidity and cost requirements: `REQUIREMENTS.md`.
## Sprint 1 — Economic Foundations ## Sprint 1 — Economic Foundations
The `observatory/` package loads JSON registries and produces **Economics The `observatory/` package reads **expense and payment record ledgers** and
Dashboard v1** with platform-cost history, member payments, and budget burn. computes all totals programmatically (`ledger.py``economics.py`).
| Registry | File | | Ledger | File |
|----------|------| |--------|------|
| Budget (€1,000 start) | `data/budget.json` | | Budget (€1,000 start) | `data/budget.json` |
| Expense records | `data/expense_records.json` |
| Payment records | `data/payment_records.json` |
| Product model | `data/product.json` | | Product model | `data/product.json` |
| Pricing models | `data/pricing-models.json` | | Pricing models | `data/pricing-models.json` |
| Platform costs + history | `data/costs.json` |
| Member payments | `data/revenue.json` |
| Membership | `data/membership.json` | | Membership | `data/membership.json` |
**Current reality:** platform costs from March 2025; sole member payments from **Current reality:** platform costs from March 2025 (Bubble.io **$32/mo** since
November 2025. Operator is **burning liquidity** — platform spend exceeds Feb 2025); sole member payments from November 2025. Operator is **burning
subscription intake. Customer cost-pass-through billing is not active. liquidity**. Customer cost-pass-through billing is not active.
### Commands ### Commands
@@ -35,5 +35,5 @@ python3 -m observatory --period 2026-06
python3 -m observatory --period 2026-06 --output reports/economics-2026-06.md python3 -m observatory --period 2026-06 --output reports/economics-2026-06.md
``` ```
Manual registries will be replaced by Bubble (Sprint 2), Stripe (Sprint 3), and Manual ledgers will be replaced by Bubble (Sprint 2), Stripe (Sprint 3), and
OpenRouter (Sprint 4) importers. OpenRouter (Sprint 4) importers.

View File

@@ -11,15 +11,20 @@ The Economic Observatory must make the resulting **liquidity burn** visible.
## Requirements ## Requirements
### LQ-001 — Platform cost ledger ### LQ-001 — Expense record ledger (source of truth)
Record monthly platform costs from project start (March 2025), including fixed Capture every operator expense as an individual record in
infrastructure and payment-provider variable fees when member revenue exists. `data/expense_records.json`. Fields: `period`, `vendor`, `amount`, `currency`,
`cost_class`, `source`.
### LQ-002 — Member payment ledger **Never store hand-calculated monthly or cumulative totals in data files.**
All aggregations must be computed programmatically by `observatory/ledger.py`.
Record member subscription payments from first payment month (November 2025), ### LQ-002 — Payment record ledger
separate from platform cost accrual.
Capture member subscription payments in `data/payment_records.json` with gross,
fees, and net amounts per period. Payment-processing totals are summed from
`fees_amount` — not duplicated as expense records.
### LQ-003 — Budget tracking ### LQ-003 — Budget tracking
@@ -36,18 +41,15 @@ liquidity each period:
- `cumulative_net = sum(period_net)` - `cumulative_net = sum(period_net)`
- `remaining_budget = initial_budget + cumulative_net` - `remaining_budget = initial_budget + cumulative_net`
**No double-counting:** payment-processing fees (Stripe) are deducted from net **No double-counting:** payment-processing fees are deducted from net member
member payments. They are tracked separately for economics reporting but must payments. They are summed separately for economics reporting but must **not** be
**not** be subtracted again in the liquidity formula. subtracted again in the liquidity formula.
- `total_platform_cost = infrastructure_cost + payment_processing_cost` (for - `total_platform_cost = infrastructure_cost + payment_processing_cost` (for
gross-margin economics vs gross revenue) gross-margin economics vs gross revenue)
- `cumulative_total_platform_cost` is informational; liquidity burn uses - `cumulative_total_platform_cost` is informational; liquidity burn uses
`cumulative_infrastructure_cost` only `cumulative_infrastructure_cost` only
Negative remaining budget means the MVP has consumed more liquidity than the
allocated budget.
### LQ-005 — No customer cost billing (MVP boundary) ### LQ-005 — No customer cost billing (MVP boundary)
Do not allocate platform costs to customer invoices in MVP. Cost attribution Do not allocate platform costs to customer invoices in MVP. Cost attribution
@@ -60,16 +62,22 @@ Economics Dashboard must show:
- Current-period economics (revenue, platform cost, margin) - Current-period economics (revenue, platform cost, margin)
- Cumulative liquidity summary (budget, burn, remaining) - Cumulative liquidity summary (budget, burn, remaining)
- Monthly history table from March 2025 - Monthly history table computed from ledgers
### LQ-007 — Deterministic calculation engine
All economics and liquidity figures must be produced by the Python observatory
package (`ledger.py`, `economics.py`). LLM or manual arithmetic must not be the
source of aggregated totals.
## Data sources (current) ## Data sources (current)
| Registry | Path | | Ledger | Path |
|----------|------| |--------|------|
| Budget | `data/budget.json` | | Budget | `data/budget.json` |
| Platform costs | `data/costs.json` (`rate_card` + `monthly_history`) | | Expense records | `data/expense_records.json` |
| Member payments | `data/revenue.json` | | Payment records | `data/payment_records.json` |
| Membership | `data/membership.json` | | Membership | `data/membership.json` |
Future sprints replace manual history with Bubble, Stripe, and OpenRouter imports Future sprints replace manual records with Bubble, Stripe, and OpenRouter imports
while preserving the same liquidity semantics. while preserving the same ledger schema and calculation rules.

View File

@@ -1,72 +0,0 @@
{
"version": 2,
"fx_rates": {
"USD/EUR": "0.92"
},
"rate_card": [
{
"id": "bubble-subscription",
"name": "Bubble.io platform",
"category": "fixed",
"amount": "35.00",
"currency": "USD",
"cadence": "monthly",
"allocation": "flat"
},
{
"id": "domains",
"name": "Domains",
"category": "fixed",
"amount": "15.00",
"currency": "EUR",
"cadence": "monthly",
"allocation": "flat"
},
{
"id": "operational-overhead",
"name": "Operational overhead",
"category": "fixed",
"amount": "25.00",
"currency": "EUR",
"cadence": "monthly",
"allocation": "flat"
},
{
"id": "stripe-percentage",
"name": "Stripe percentage fee",
"category": "variable",
"amount": "0.015",
"currency": "ratio",
"cadence": "per_transaction",
"allocation": "percent_of_gross_revenue"
},
{
"id": "stripe-fixed",
"name": "Stripe fixed fee",
"category": "variable",
"amount": "0.25",
"currency": "EUR",
"cadence": "per_transaction",
"allocation": "per_active_member"
}
],
"monthly_history": [
{"period": "2025-03", "infrastructure_cost": "72.20", "payment_processing_cost": "0.00", "active_members": 0, "gross_revenue": "0.00"},
{"period": "2025-04", "infrastructure_cost": "72.20", "payment_processing_cost": "0.00", "active_members": 0, "gross_revenue": "0.00"},
{"period": "2025-05", "infrastructure_cost": "72.20", "payment_processing_cost": "0.00", "active_members": 0, "gross_revenue": "0.00"},
{"period": "2025-06", "infrastructure_cost": "72.20", "payment_processing_cost": "0.00", "active_members": 0, "gross_revenue": "0.00"},
{"period": "2025-07", "infrastructure_cost": "72.20", "payment_processing_cost": "0.00", "active_members": 0, "gross_revenue": "0.00"},
{"period": "2025-08", "infrastructure_cost": "72.20", "payment_processing_cost": "0.00", "active_members": 0, "gross_revenue": "0.00"},
{"period": "2025-09", "infrastructure_cost": "72.20", "payment_processing_cost": "0.00", "active_members": 0, "gross_revenue": "0.00"},
{"period": "2025-10", "infrastructure_cost": "72.20", "payment_processing_cost": "0.00", "active_members": 0, "gross_revenue": "0.00"},
{"period": "2025-11", "infrastructure_cost": "72.20", "payment_processing_cost": "0.38", "active_members": 1, "gross_revenue": "8.99"},
{"period": "2025-12", "infrastructure_cost": "72.20", "payment_processing_cost": "0.38", "active_members": 1, "gross_revenue": "8.99"},
{"period": "2026-01", "infrastructure_cost": "72.20", "payment_processing_cost": "0.38", "active_members": 1, "gross_revenue": "8.99"},
{"period": "2026-02", "infrastructure_cost": "72.20", "payment_processing_cost": "0.38", "active_members": 1, "gross_revenue": "8.99"},
{"period": "2026-03", "infrastructure_cost": "72.20", "payment_processing_cost": "0.38", "active_members": 1, "gross_revenue": "8.99"},
{"period": "2026-04", "infrastructure_cost": "72.20", "payment_processing_cost": "0.38", "active_members": 1, "gross_revenue": "8.99"},
{"period": "2026-05", "infrastructure_cost": "72.20", "payment_processing_cost": "0.38", "active_members": 1, "gross_revenue": "8.99"},
{"period": "2026-06", "infrastructure_cost": "72.20", "payment_processing_cost": "0.38", "active_members": 1, "gross_revenue": "8.99"}
],
"note": "Infrastructure costs are operator cash outflows. Payment processing is tracked separately and already deducted from net member payments — do not double-count in liquidity."
}

View File

@@ -0,0 +1,489 @@
{
"version": 1,
"fx_rates": {
"USD/EUR": "0.92"
},
"records": [
{
"id": "exp-bubble-2025-03",
"period": "2025-03",
"vendor": "bubble.io",
"description": "Bubble.io Personal plan ($32/mo since Feb 2025)",
"cost_class": "infrastructure",
"amount": "32.00",
"currency": "USD",
"source": "manual"
},
{
"id": "exp-domains-2025-03",
"period": "2025-03",
"vendor": "domains",
"description": "Domain registrations",
"cost_class": "infrastructure",
"amount": "15.00",
"currency": "EUR",
"source": "manual"
},
{
"id": "exp-overhead-2025-03",
"period": "2025-03",
"vendor": "operational-overhead",
"description": "Operational overhead",
"cost_class": "infrastructure",
"amount": "25.00",
"currency": "EUR",
"source": "manual"
},
{
"id": "exp-bubble-2025-04",
"period": "2025-04",
"vendor": "bubble.io",
"description": "Bubble.io Personal plan ($32/mo since Feb 2025)",
"cost_class": "infrastructure",
"amount": "32.00",
"currency": "USD",
"source": "manual"
},
{
"id": "exp-domains-2025-04",
"period": "2025-04",
"vendor": "domains",
"description": "Domain registrations",
"cost_class": "infrastructure",
"amount": "15.00",
"currency": "EUR",
"source": "manual"
},
{
"id": "exp-overhead-2025-04",
"period": "2025-04",
"vendor": "operational-overhead",
"description": "Operational overhead",
"cost_class": "infrastructure",
"amount": "25.00",
"currency": "EUR",
"source": "manual"
},
{
"id": "exp-bubble-2025-05",
"period": "2025-05",
"vendor": "bubble.io",
"description": "Bubble.io Personal plan ($32/mo since Feb 2025)",
"cost_class": "infrastructure",
"amount": "32.00",
"currency": "USD",
"source": "manual"
},
{
"id": "exp-domains-2025-05",
"period": "2025-05",
"vendor": "domains",
"description": "Domain registrations",
"cost_class": "infrastructure",
"amount": "15.00",
"currency": "EUR",
"source": "manual"
},
{
"id": "exp-overhead-2025-05",
"period": "2025-05",
"vendor": "operational-overhead",
"description": "Operational overhead",
"cost_class": "infrastructure",
"amount": "25.00",
"currency": "EUR",
"source": "manual"
},
{
"id": "exp-bubble-2025-06",
"period": "2025-06",
"vendor": "bubble.io",
"description": "Bubble.io Personal plan ($32/mo since Feb 2025)",
"cost_class": "infrastructure",
"amount": "32.00",
"currency": "USD",
"source": "manual"
},
{
"id": "exp-domains-2025-06",
"period": "2025-06",
"vendor": "domains",
"description": "Domain registrations",
"cost_class": "infrastructure",
"amount": "15.00",
"currency": "EUR",
"source": "manual"
},
{
"id": "exp-overhead-2025-06",
"period": "2025-06",
"vendor": "operational-overhead",
"description": "Operational overhead",
"cost_class": "infrastructure",
"amount": "25.00",
"currency": "EUR",
"source": "manual"
},
{
"id": "exp-bubble-2025-07",
"period": "2025-07",
"vendor": "bubble.io",
"description": "Bubble.io Personal plan ($32/mo since Feb 2025)",
"cost_class": "infrastructure",
"amount": "32.00",
"currency": "USD",
"source": "manual"
},
{
"id": "exp-domains-2025-07",
"period": "2025-07",
"vendor": "domains",
"description": "Domain registrations",
"cost_class": "infrastructure",
"amount": "15.00",
"currency": "EUR",
"source": "manual"
},
{
"id": "exp-overhead-2025-07",
"period": "2025-07",
"vendor": "operational-overhead",
"description": "Operational overhead",
"cost_class": "infrastructure",
"amount": "25.00",
"currency": "EUR",
"source": "manual"
},
{
"id": "exp-bubble-2025-08",
"period": "2025-08",
"vendor": "bubble.io",
"description": "Bubble.io Personal plan ($32/mo since Feb 2025)",
"cost_class": "infrastructure",
"amount": "32.00",
"currency": "USD",
"source": "manual"
},
{
"id": "exp-domains-2025-08",
"period": "2025-08",
"vendor": "domains",
"description": "Domain registrations",
"cost_class": "infrastructure",
"amount": "15.00",
"currency": "EUR",
"source": "manual"
},
{
"id": "exp-overhead-2025-08",
"period": "2025-08",
"vendor": "operational-overhead",
"description": "Operational overhead",
"cost_class": "infrastructure",
"amount": "25.00",
"currency": "EUR",
"source": "manual"
},
{
"id": "exp-bubble-2025-09",
"period": "2025-09",
"vendor": "bubble.io",
"description": "Bubble.io Personal plan ($32/mo since Feb 2025)",
"cost_class": "infrastructure",
"amount": "32.00",
"currency": "USD",
"source": "manual"
},
{
"id": "exp-domains-2025-09",
"period": "2025-09",
"vendor": "domains",
"description": "Domain registrations",
"cost_class": "infrastructure",
"amount": "15.00",
"currency": "EUR",
"source": "manual"
},
{
"id": "exp-overhead-2025-09",
"period": "2025-09",
"vendor": "operational-overhead",
"description": "Operational overhead",
"cost_class": "infrastructure",
"amount": "25.00",
"currency": "EUR",
"source": "manual"
},
{
"id": "exp-bubble-2025-10",
"period": "2025-10",
"vendor": "bubble.io",
"description": "Bubble.io Personal plan ($32/mo since Feb 2025)",
"cost_class": "infrastructure",
"amount": "32.00",
"currency": "USD",
"source": "manual"
},
{
"id": "exp-domains-2025-10",
"period": "2025-10",
"vendor": "domains",
"description": "Domain registrations",
"cost_class": "infrastructure",
"amount": "15.00",
"currency": "EUR",
"source": "manual"
},
{
"id": "exp-overhead-2025-10",
"period": "2025-10",
"vendor": "operational-overhead",
"description": "Operational overhead",
"cost_class": "infrastructure",
"amount": "25.00",
"currency": "EUR",
"source": "manual"
},
{
"id": "exp-bubble-2025-11",
"period": "2025-11",
"vendor": "bubble.io",
"description": "Bubble.io Personal plan ($32/mo since Feb 2025)",
"cost_class": "infrastructure",
"amount": "32.00",
"currency": "USD",
"source": "manual"
},
{
"id": "exp-domains-2025-11",
"period": "2025-11",
"vendor": "domains",
"description": "Domain registrations",
"cost_class": "infrastructure",
"amount": "15.00",
"currency": "EUR",
"source": "manual"
},
{
"id": "exp-overhead-2025-11",
"period": "2025-11",
"vendor": "operational-overhead",
"description": "Operational overhead",
"cost_class": "infrastructure",
"amount": "25.00",
"currency": "EUR",
"source": "manual"
},
{
"id": "exp-bubble-2025-12",
"period": "2025-12",
"vendor": "bubble.io",
"description": "Bubble.io Personal plan ($32/mo since Feb 2025)",
"cost_class": "infrastructure",
"amount": "32.00",
"currency": "USD",
"source": "manual"
},
{
"id": "exp-domains-2025-12",
"period": "2025-12",
"vendor": "domains",
"description": "Domain registrations",
"cost_class": "infrastructure",
"amount": "15.00",
"currency": "EUR",
"source": "manual"
},
{
"id": "exp-overhead-2025-12",
"period": "2025-12",
"vendor": "operational-overhead",
"description": "Operational overhead",
"cost_class": "infrastructure",
"amount": "25.00",
"currency": "EUR",
"source": "manual"
},
{
"id": "exp-bubble-2026-01",
"period": "2026-01",
"vendor": "bubble.io",
"description": "Bubble.io Personal plan ($32/mo since Feb 2025)",
"cost_class": "infrastructure",
"amount": "32.00",
"currency": "USD",
"source": "manual"
},
{
"id": "exp-domains-2026-01",
"period": "2026-01",
"vendor": "domains",
"description": "Domain registrations",
"cost_class": "infrastructure",
"amount": "15.00",
"currency": "EUR",
"source": "manual"
},
{
"id": "exp-overhead-2026-01",
"period": "2026-01",
"vendor": "operational-overhead",
"description": "Operational overhead",
"cost_class": "infrastructure",
"amount": "25.00",
"currency": "EUR",
"source": "manual"
},
{
"id": "exp-bubble-2026-02",
"period": "2026-02",
"vendor": "bubble.io",
"description": "Bubble.io Personal plan ($32/mo since Feb 2025)",
"cost_class": "infrastructure",
"amount": "32.00",
"currency": "USD",
"source": "manual"
},
{
"id": "exp-domains-2026-02",
"period": "2026-02",
"vendor": "domains",
"description": "Domain registrations",
"cost_class": "infrastructure",
"amount": "15.00",
"currency": "EUR",
"source": "manual"
},
{
"id": "exp-overhead-2026-02",
"period": "2026-02",
"vendor": "operational-overhead",
"description": "Operational overhead",
"cost_class": "infrastructure",
"amount": "25.00",
"currency": "EUR",
"source": "manual"
},
{
"id": "exp-bubble-2026-03",
"period": "2026-03",
"vendor": "bubble.io",
"description": "Bubble.io Personal plan ($32/mo since Feb 2025)",
"cost_class": "infrastructure",
"amount": "32.00",
"currency": "USD",
"source": "manual"
},
{
"id": "exp-domains-2026-03",
"period": "2026-03",
"vendor": "domains",
"description": "Domain registrations",
"cost_class": "infrastructure",
"amount": "15.00",
"currency": "EUR",
"source": "manual"
},
{
"id": "exp-overhead-2026-03",
"period": "2026-03",
"vendor": "operational-overhead",
"description": "Operational overhead",
"cost_class": "infrastructure",
"amount": "25.00",
"currency": "EUR",
"source": "manual"
},
{
"id": "exp-bubble-2026-04",
"period": "2026-04",
"vendor": "bubble.io",
"description": "Bubble.io Personal plan ($32/mo since Feb 2025)",
"cost_class": "infrastructure",
"amount": "32.00",
"currency": "USD",
"source": "manual"
},
{
"id": "exp-domains-2026-04",
"period": "2026-04",
"vendor": "domains",
"description": "Domain registrations",
"cost_class": "infrastructure",
"amount": "15.00",
"currency": "EUR",
"source": "manual"
},
{
"id": "exp-overhead-2026-04",
"period": "2026-04",
"vendor": "operational-overhead",
"description": "Operational overhead",
"cost_class": "infrastructure",
"amount": "25.00",
"currency": "EUR",
"source": "manual"
},
{
"id": "exp-bubble-2026-05",
"period": "2026-05",
"vendor": "bubble.io",
"description": "Bubble.io Personal plan ($32/mo since Feb 2025)",
"cost_class": "infrastructure",
"amount": "32.00",
"currency": "USD",
"source": "manual"
},
{
"id": "exp-domains-2026-05",
"period": "2026-05",
"vendor": "domains",
"description": "Domain registrations",
"cost_class": "infrastructure",
"amount": "15.00",
"currency": "EUR",
"source": "manual"
},
{
"id": "exp-overhead-2026-05",
"period": "2026-05",
"vendor": "operational-overhead",
"description": "Operational overhead",
"cost_class": "infrastructure",
"amount": "25.00",
"currency": "EUR",
"source": "manual"
},
{
"id": "exp-bubble-2026-06",
"period": "2026-06",
"vendor": "bubble.io",
"description": "Bubble.io Personal plan ($32/mo since Feb 2025)",
"cost_class": "infrastructure",
"amount": "32.00",
"currency": "USD",
"source": "manual"
},
{
"id": "exp-domains-2026-06",
"period": "2026-06",
"vendor": "domains",
"description": "Domain registrations",
"cost_class": "infrastructure",
"amount": "15.00",
"currency": "EUR",
"source": "manual"
},
{
"id": "exp-overhead-2026-06",
"period": "2026-06",
"vendor": "operational-overhead",
"description": "Operational overhead",
"cost_class": "infrastructure",
"amount": "25.00",
"currency": "EUR",
"source": "manual"
}
],
"note": "Source-of-truth expense ledger. Aggregations are computed by observatory/ledger.py \u2014 never hand-edited totals."
}

View File

@@ -1,14 +1,14 @@
{ {
"version": 1, "version": 1,
"entries": [ "records": [
{"id": "rev-2025-11", "period": "2025-11", "gross_amount": "8.99", "fees_amount": "0.38", "refunds_amount": "0.00", "net_amount": "8.61", "currency": "EUR", "source": "manual", "member_count": 1}, {"id": "pay-2025-11", "period": "2025-11", "gross_amount": "8.99", "fees_amount": "0.38", "refunds_amount": "0.00", "net_amount": "8.61", "currency": "EUR", "source": "manual", "member_count": 1},
{"id": "rev-2025-12", "period": "2025-12", "gross_amount": "8.99", "fees_amount": "0.38", "refunds_amount": "0.00", "net_amount": "8.61", "currency": "EUR", "source": "manual", "member_count": 1}, {"id": "pay-2025-12", "period": "2025-12", "gross_amount": "8.99", "fees_amount": "0.38", "refunds_amount": "0.00", "net_amount": "8.61", "currency": "EUR", "source": "manual", "member_count": 1},
{"id": "rev-2026-01", "period": "2026-01", "gross_amount": "8.99", "fees_amount": "0.38", "refunds_amount": "0.00", "net_amount": "8.61", "currency": "EUR", "source": "manual", "member_count": 1}, {"id": "pay-2026-01", "period": "2026-01", "gross_amount": "8.99", "fees_amount": "0.38", "refunds_amount": "0.00", "net_amount": "8.61", "currency": "EUR", "source": "manual", "member_count": 1},
{"id": "rev-2026-02", "period": "2026-02", "gross_amount": "8.99", "fees_amount": "0.38", "refunds_amount": "0.00", "net_amount": "8.61", "currency": "EUR", "source": "manual", "member_count": 1}, {"id": "pay-2026-02", "period": "2026-02", "gross_amount": "8.99", "fees_amount": "0.38", "refunds_amount": "0.00", "net_amount": "8.61", "currency": "EUR", "source": "manual", "member_count": 1},
{"id": "rev-2026-03", "period": "2026-03", "gross_amount": "8.99", "fees_amount": "0.38", "refunds_amount": "0.00", "net_amount": "8.61", "currency": "EUR", "source": "manual", "member_count": 1}, {"id": "pay-2026-03", "period": "2026-03", "gross_amount": "8.99", "fees_amount": "0.38", "refunds_amount": "0.00", "net_amount": "8.61", "currency": "EUR", "source": "manual", "member_count": 1},
{"id": "rev-2026-04", "period": "2026-04", "gross_amount": "8.99", "fees_amount": "0.38", "refunds_amount": "0.00", "net_amount": "8.61", "currency": "EUR", "source": "manual", "member_count": 1}, {"id": "pay-2026-04", "period": "2026-04", "gross_amount": "8.99", "fees_amount": "0.38", "refunds_amount": "0.00", "net_amount": "8.61", "currency": "EUR", "source": "manual", "member_count": 1},
{"id": "rev-2026-05", "period": "2026-05", "gross_amount": "8.99", "fees_amount": "0.38", "refunds_amount": "0.00", "net_amount": "8.61", "currency": "EUR", "source": "manual", "member_count": 1}, {"id": "pay-2026-05", "period": "2026-05", "gross_amount": "8.99", "fees_amount": "0.38", "refunds_amount": "0.00", "net_amount": "8.61", "currency": "EUR", "source": "manual", "member_count": 1},
{"id": "rev-2026-06", "period": "2026-06", "gross_amount": "8.99", "fees_amount": "0.38", "refunds_amount": "0.00", "net_amount": "8.61", "currency": "EUR", "source": "manual", "member_count": 1} {"id": "pay-2026-06", "period": "2026-06", "gross_amount": "8.99", "fees_amount": "0.38", "refunds_amount": "0.00", "net_amount": "8.61", "currency": "EUR", "source": "manual", "member_count": 1}
], ],
"note": "Member payments begin November 2025. Stripe sync replaces manual entries in Sprint 3." "note": "Member payment ledger from November 2025. Stripe sync replaces manual entries in Sprint 3."
} }

View File

@@ -9,26 +9,27 @@ from .load import (
default_data_dir, default_data_dir,
latest_period, latest_period,
load_budget, load_budget,
load_expense_records,
load_membership, load_membership,
load_monthly_platform_costs, load_monthly_ledger,
load_payment_records,
load_pricing_models, load_pricing_models,
load_product, load_product,
load_revenue,
) )
from .models import EconomicsSnapshot, LiquiditySummary, MonthlyPlatformCost, PricingModel, Product, RevenueEntry from .models import EconomicsSnapshot, LiquiditySummary, MonthlyPlatformCost, PaymentRecord, PricingModel, Product
def _history_rows( def _history_rows(
monthly_costs: list[MonthlyPlatformCost], monthly_costs: list[MonthlyPlatformCost],
revenue_entries: list[RevenueEntry], payments: list[PaymentRecord],
through_period: str, through_period: str,
) -> str: ) -> str:
revenue_by_period = {entry.period: entry for entry in revenue_entries} payment_by_period = {record.period: record for record in payments}
lines: list[str] = [] lines: list[str] = []
for month in sorted(monthly_costs, key=lambda item: item.period): for month in sorted(monthly_costs, key=lambda item: item.period):
if month.period > through_period: if month.period > through_period:
continue continue
payment = revenue_by_period.get(month.period) payment = payment_by_period.get(month.period)
net_payment = payment.net_amount if payment else Decimal("0") net_payment = payment.net_amount if payment else Decimal("0")
period_net = net_payment - month.infrastructure_cost period_net = net_payment - month.infrastructure_cost
lines.append( lines.append(
@@ -45,14 +46,15 @@ def render_dashboard(
snapshot: EconomicsSnapshot, snapshot: EconomicsSnapshot,
liquidity: LiquiditySummary, liquidity: LiquiditySummary,
monthly_costs: list[MonthlyPlatformCost], monthly_costs: list[MonthlyPlatformCost],
revenue_entries: list[RevenueEntry], payments: list[PaymentRecord],
expense_count: int,
) -> str: ) -> str:
active = next(m for m in models if m.id == product.active_pricing_model_id) active = next(m for m in models if m.id == product.active_pricing_model_id)
registry_lines = "\n".join( registry_lines = "\n".join(
f"| {model.id} | {model.name} | {model.model_type} | {model.status} |" f"| {model.id} | {model.name} | {model.model_type} | {model.status} |"
for model in models for model in models
) )
history_lines = _history_rows(monthly_costs, revenue_entries, snapshot.period) history_lines = _history_rows(monthly_costs, payments, snapshot.period)
budget_state = ( budget_state = (
"over budget" "over budget"
if liquidity.remaining_budget < Decimal("0") if liquidity.remaining_budget < Decimal("0")
@@ -66,7 +68,9 @@ def render_dashboard(
**Active pricing model:** {active.name} ({active.access_fee_amount} {active.currency}/{active.access_fee_cadence}) **Active pricing model:** {active.name} ({active.access_fee_amount} {active.currency}/{active.access_fee_cadence})
> Platform costs accrue to the operator. Customer cost-pass-through billing is > Platform costs accrue to the operator. Customer cost-pass-through billing is
> **not active** in MVP — members pay subscription only. > **not active** in MVP — members pay subscription only. All totals are computed
> programmatically from expense and payment record ledgers ({expense_count} expense
> records).
## Key Metrics (current period) ## Key Metrics (current period)
@@ -114,9 +118,8 @@ _Revenue source: {snapshot.revenue_source}_
- Product model (`data/product.json`) - Product model (`data/product.json`)
- Budget (`data/budget.json`) - Budget (`data/budget.json`)
- Pricing model registry (`data/pricing-models.json`) - Expense records (`data/expense_records.json`) — source of truth for costs
- Platform costs (`data/costs.json`) - Payment records (`data/payment_records.json`)
- Member payments (`data/revenue.json`)
- Membership (`data/membership.json`) - Membership (`data/membership.json`)
- Requirements (`REQUIREMENTS.md`) - Requirements (`REQUIREMENTS.md`)
""" """
@@ -128,13 +131,16 @@ def generate_dashboard(data_dir: Path | None = None, period: str | None = None)
budget = load_budget(root) budget = load_budget(root)
models = load_pricing_models(root) models = load_pricing_models(root)
members = load_membership(root) members = load_membership(root)
revenue = load_revenue(root) payments = load_payment_records(root)
monthly_costs = load_monthly_platform_costs(root) expenses = load_expense_records(root)
monthly_costs = load_monthly_ledger(root)
target_period = period or latest_period(monthly_costs) target_period = period or latest_period(monthly_costs)
snapshot = build_snapshot(target_period, product, models, members, revenue, monthly_costs) snapshot = build_snapshot(target_period, product, models, members, payments, monthly_costs)
liquidity = build_liquidity_summary(budget, revenue, monthly_costs, target_period) liquidity = build_liquidity_summary(budget, payments, monthly_costs, target_period)
return render_dashboard(product, models, snapshot, liquidity, monthly_costs, revenue) return render_dashboard(
product, models, snapshot, liquidity, monthly_costs, payments, len(expenses)
)
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:

View File

@@ -4,15 +4,14 @@ from decimal import Decimal, ROUND_HALF_UP
from .models import ( from .models import (
Budget, Budget,
CostEntry,
EconomicsSnapshot, EconomicsSnapshot,
LiquidityStatus, LiquidityStatus,
LiquiditySummary, LiquiditySummary,
MembershipRecord, MembershipRecord,
MonthlyPlatformCost, MonthlyPlatformCost,
PaymentRecord,
PricingModel, PricingModel,
Product, Product,
RevenueEntry,
) )
TWOPLACES = Decimal("0.01") TWOPLACES = Decimal("0.01")
@@ -23,16 +22,6 @@ def _quantize(value: Decimal, exp: Decimal = TWOPLACES) -> Decimal:
return value.quantize(exp, rounding=ROUND_HALF_UP) return value.quantize(exp, rounding=ROUND_HALF_UP)
def _convert_to_eur(amount: Decimal, currency: str, fx_rates: dict[str, Decimal]) -> Decimal:
if currency == "EUR":
return amount
if currency == "USD":
return amount * fx_rates.get("USD/EUR", Decimal("1"))
if currency == "ratio":
return amount
raise ValueError(f"unsupported currency for conversion: {currency}")
def liquidity_status_for(net: Decimal) -> LiquidityStatus: def liquidity_status_for(net: Decimal) -> LiquidityStatus:
if net < Decimal("0"): if net < Decimal("0"):
return "burning" return "burning"
@@ -52,27 +41,10 @@ def active_pricing_model(models: list[PricingModel], product: Product) -> Pricin
raise ValueError(f"active pricing model not found: {product.active_pricing_model_id}") raise ValueError(f"active pricing model not found: {product.active_pricing_model_id}")
def monthly_cost_from_rate_card( def payment_for_period(period: str, payments: list[PaymentRecord]) -> PaymentRecord | None:
cost_entries: list[CostEntry], for record in payments:
fx_rates: dict[str, Decimal], if record.period == period:
gross_revenue: Decimal, return record
member_count: int,
) -> Decimal:
total = Decimal("0")
for entry in cost_entries:
if entry.allocation == "flat":
total += _convert_to_eur(entry.amount, entry.currency, fx_rates)
elif entry.allocation == "percent_of_gross_revenue":
total += gross_revenue * entry.amount
elif entry.allocation == "per_active_member":
total += _convert_to_eur(entry.amount, entry.currency, fx_rates) * member_count
return _quantize(total)
def revenue_for_period(period: str, revenue_entries: list[RevenueEntry]) -> RevenueEntry | None:
for entry in revenue_entries:
if entry.period == period:
return entry
return None return None
@@ -81,16 +53,16 @@ def estimate_monthly_revenue(
product: Product, product: Product,
models: list[PricingModel], models: list[PricingModel],
members: list[MembershipRecord], members: list[MembershipRecord],
revenue_entries: list[RevenueEntry], payments: list[PaymentRecord],
monthly_costs: list[MonthlyPlatformCost], monthly_costs: list[MonthlyPlatformCost],
) -> tuple[Decimal, Decimal, str]: ) -> tuple[Decimal, Decimal, str]:
recorded = revenue_for_period(period, revenue_entries) recorded = payment_for_period(period, payments)
if recorded: if recorded:
return recorded.gross_amount, recorded.net_amount, recorded.source return recorded.gross_amount, recorded.net_amount, recorded.source
month = next((item for item in monthly_costs if item.period == period), None) month = next((item for item in monthly_costs if item.period == period), None)
if month and month.gross_revenue > 0: if month and month.gross_revenue > 0:
return month.gross_revenue, month.gross_revenue, "derived_from_platform_history" return month.gross_revenue, month.gross_revenue, "derived_from_ledger"
model = active_pricing_model(models, product) model = active_pricing_model(models, product)
count = active_members(members) count = active_members(members)
@@ -104,7 +76,7 @@ def periods_through(target: str, monthly_costs: list[MonthlyPlatformCost]) -> li
def build_liquidity_summary( def build_liquidity_summary(
budget: Budget, budget: Budget,
revenue_entries: list[RevenueEntry], payments: list[PaymentRecord],
monthly_costs: list[MonthlyPlatformCost], monthly_costs: list[MonthlyPlatformCost],
through_period: str, through_period: str,
) -> LiquiditySummary: ) -> LiquiditySummary:
@@ -118,11 +90,9 @@ def build_liquidity_summary(
month = cost_by_period[period] month = cost_by_period[period]
cumulative_infrastructure += month.infrastructure_cost cumulative_infrastructure += month.infrastructure_cost
cumulative_processing += month.payment_processing_cost cumulative_processing += month.payment_processing_cost
payment = revenue_for_period(period, revenue_entries) payment = payment_for_period(period, payments)
cumulative_payments += payment.net_amount if payment else Decimal("0") cumulative_payments += payment.net_amount if payment else Decimal("0")
# Liquidity uses net member payments vs infrastructure cash out only.
# Payment-processing fees are already deducted from net payments.
cumulative_net = _quantize(cumulative_payments - cumulative_infrastructure) cumulative_net = _quantize(cumulative_payments - cumulative_infrastructure)
remaining = _quantize(budget.initial_budget + cumulative_net) remaining = _quantize(budget.initial_budget + cumulative_net)
cumulative_total = _quantize(cumulative_infrastructure + cumulative_processing) cumulative_total = _quantize(cumulative_infrastructure + cumulative_processing)
@@ -147,13 +117,13 @@ def build_snapshot(
product: Product, product: Product,
models: list[PricingModel], models: list[PricingModel],
members: list[MembershipRecord], members: list[MembershipRecord],
revenue_entries: list[RevenueEntry], payments: list[PaymentRecord],
monthly_costs: list[MonthlyPlatformCost], monthly_costs: list[MonthlyPlatformCost],
) -> EconomicsSnapshot: ) -> EconomicsSnapshot:
month = next(item for item in monthly_costs if item.period == period) month = next(item for item in monthly_costs if item.period == period)
count = month.active_members if month.active_members else active_members(members) count = month.active_members if month.active_members else active_members(members)
gross_revenue, net_revenue, revenue_source = estimate_monthly_revenue( gross_revenue, net_revenue, revenue_source = estimate_monthly_revenue(
period, product, models, members, revenue_entries, monthly_costs period, product, models, members, payments, monthly_costs
) )
infrastructure = month.infrastructure_cost infrastructure = month.infrastructure_cost
processing = month.payment_processing_cost processing = month.payment_processing_cost

View File

@@ -0,0 +1,121 @@
from __future__ import annotations
from decimal import Decimal, ROUND_HALF_UP
from .models import (
Budget,
ExpenseRecord,
MembershipRecord,
MonthlyPlatformCost,
PaymentRecord,
)
TWOPLACES = Decimal("0.01")
def _quantize(value: Decimal) -> Decimal:
return value.quantize(TWOPLACES, rounding=ROUND_HALF_UP)
def convert_to_eur(amount: Decimal, currency: str, fx_rates: dict[str, Decimal]) -> Decimal:
if currency == "EUR":
return amount
if currency == "USD":
return amount * fx_rates.get("USD/EUR", Decimal("1"))
raise ValueError(f"unsupported expense currency: {currency}")
def aggregate_infrastructure_by_period(
expenses: list[ExpenseRecord],
fx_rates: dict[str, Decimal],
reporting_currency: str = "EUR",
) -> dict[str, Decimal]:
if reporting_currency != "EUR":
raise ValueError("only EUR reporting currency is supported")
totals: dict[str, Decimal] = {}
for record in expenses:
if record.cost_class != "infrastructure":
continue
totals[record.period] = totals.get(record.period, Decimal("0")) + convert_to_eur(
record.amount, record.currency, fx_rates
)
return {period: _quantize(total) for period, total in totals.items()}
def payment_processing_by_period(payments: list[PaymentRecord]) -> dict[str, Decimal]:
totals: dict[str, Decimal] = {}
for record in payments:
totals[record.period] = totals.get(record.period, Decimal("0")) + record.fees_amount
return {period: _quantize(total) for period, total in totals.items()}
def _period_sort_key(period: str) -> tuple[int, int]:
year, month = period.split("-")
return int(year), int(month)
def periods_from_budget_through_latest(
budget: Budget,
expenses: list[ExpenseRecord],
payments: list[PaymentRecord],
) -> list[str]:
candidates = {budget.started}
candidates.update(record.period for record in expenses)
candidates.update(record.period for record in payments)
latest = max(candidates, key=_period_sort_key)
periods: list[str] = []
year, month = map(int, budget.started.split("-"))
end_year, end_month = map(int, latest.split("-"))
while (year, month) <= (end_year, end_month):
periods.append(f"{year}-{month:02d}")
month += 1
if month > 12:
month = 1
year += 1
return periods
def active_members_for_period(period: str, members: list[MembershipRecord]) -> int:
period_start = f"{period}-01"
count = 0
for member in members:
if member.joined_at[:7] > period:
continue
if member.churned_at and member.churned_at[:7] < period:
continue
if member.status == "active" or (
member.churned_at and member.churned_at[:7] >= period
):
count += 1
return count
def build_monthly_ledger(
budget: Budget,
expenses: list[ExpenseRecord],
payments: list[PaymentRecord],
members: list[MembershipRecord],
fx_rates: dict[str, Decimal],
) -> list[MonthlyPlatformCost]:
infrastructure = aggregate_infrastructure_by_period(expenses, fx_rates)
processing = payment_processing_by_period(payments)
payment_by_period = {record.period: record for record in payments}
rows: list[MonthlyPlatformCost] = []
for period in periods_from_budget_through_latest(budget, expenses, payments):
payment = payment_by_period.get(period)
rows.append(
MonthlyPlatformCost(
period=period,
infrastructure_cost=infrastructure.get(period, Decimal("0.00")),
payment_processing_cost=processing.get(period, Decimal("0.00")),
active_members=(
payment.member_count
if payment and payment.member_count
else active_members_for_period(period, members)
),
gross_revenue=payment.gross_amount if payment else Decimal("0.00"),
)
)
return rows

View File

@@ -4,16 +4,15 @@ import json
from decimal import Decimal from decimal import Decimal
from pathlib import Path from pathlib import Path
_ZERO = Decimal("0") from .ledger import build_monthly_ledger
from .models import ( from .models import (
Budget, Budget,
CostEntry, ExpenseRecord,
MembershipRecord, MembershipRecord,
MonthlyPlatformCost, MonthlyPlatformCost,
PaymentRecord,
PricingModel, PricingModel,
Product, Product,
RevenueEntry,
) )
@@ -67,55 +66,38 @@ def load_pricing_models(data_dir: Path | None = None) -> list[PricingModel]:
] ]
def _parse_cost_entries(items: list[dict]) -> list[CostEntry]: def load_fx_rates(data_dir: Path | None = None) -> dict[str, Decimal]:
raw = _read_json((data_dir or default_data_dir()) / "expense_records.json")
return {pair: _money(rate) for pair, rate in raw.get("fx_rates", {}).items()}
def load_expense_records(data_dir: Path | None = None) -> list[ExpenseRecord]:
raw = _read_json((data_dir or default_data_dir()) / "expense_records.json")
return [ return [
CostEntry( ExpenseRecord(
id=item["id"], id=item["id"],
name=item["name"], period=item["period"],
category=item["category"], vendor=item["vendor"],
description=item["description"],
cost_class=item["cost_class"],
amount=_money(item["amount"]), amount=_money(item["amount"]),
currency=item["currency"], currency=item["currency"],
cadence=item["cadence"], source=item["source"],
allocation=item["allocation"],
) )
for item in items for item in raw["records"]
] ]
def load_cost_rate_card(data_dir: Path | None = None) -> tuple[list[CostEntry], dict[str, Decimal]]: def load_payment_records(data_dir: Path | None = None) -> list[PaymentRecord]:
raw = _read_json((data_dir or default_data_dir()) / "costs.json") root = data_dir or default_data_dir()
items = raw.get("rate_card", raw.get("entries", [])) path = root / "payment_records.json"
fx = {pair: _money(rate) for pair, rate in raw.get("fx_rates", {}).items()} if not path.exists():
return _parse_cost_entries(items), fx # Backward compatibility with legacy revenue.json
path = root / "revenue.json"
raw = _read_json(path)
def load_monthly_platform_costs(data_dir: Path | None = None) -> list[MonthlyPlatformCost]: items = raw.get("records", raw.get("entries", []))
raw = _read_json((data_dir or default_data_dir()) / "costs.json")
rows: list[MonthlyPlatformCost] = []
for item in raw.get("monthly_history", []):
if "infrastructure_cost" in item:
infrastructure = _money(item["infrastructure_cost"])
processing = _money(item.get("payment_processing_cost", "0"))
else:
# Legacy single platform_cost field treated as infrastructure only.
infrastructure = _money(item["platform_cost"])
processing = _ZERO
rows.append(
MonthlyPlatformCost(
period=item["period"],
infrastructure_cost=infrastructure,
payment_processing_cost=processing,
active_members=item["active_members"],
gross_revenue=_money(item["gross_revenue"]),
)
)
return rows
def load_revenue(data_dir: Path | None = None) -> list[RevenueEntry]:
raw = _read_json((data_dir or default_data_dir()) / "revenue.json")
return [ return [
RevenueEntry( PaymentRecord(
id=item["id"], id=item["id"],
period=item["period"], period=item["period"],
gross_amount=_money(item["gross_amount"]), gross_amount=_money(item["gross_amount"]),
@@ -124,8 +106,9 @@ def load_revenue(data_dir: Path | None = None) -> list[RevenueEntry]:
net_amount=_money(item["net_amount"]), net_amount=_money(item["net_amount"]),
currency=item["currency"], currency=item["currency"],
source=item["source"], source=item["source"],
member_count=item.get("member_count", 0),
) )
for item in raw["entries"] for item in items
] ]
@@ -143,5 +126,16 @@ def load_membership(data_dir: Path | None = None) -> list[MembershipRecord]:
] ]
def load_monthly_ledger(data_dir: Path | None = None) -> list[MonthlyPlatformCost]:
root = data_dir or default_data_dir()
return build_monthly_ledger(
load_budget(root),
load_expense_records(root),
load_payment_records(root),
load_membership(root),
load_fx_rates(root),
)
def latest_period(monthly_costs: list[MonthlyPlatformCost]) -> str: def latest_period(monthly_costs: list[MonthlyPlatformCost]) -> str:
return max(item.period for item in monthly_costs) return max(item.period for item in monthly_costs)

View File

@@ -4,7 +4,7 @@ from dataclasses import dataclass
from decimal import Decimal from decimal import Decimal
from typing import Literal from typing import Literal
CostCategory = Literal["fixed", "variable"] ExpenseClass = Literal["infrastructure", "payment_processing"]
MemberStatus = Literal["active", "churned", "paused"] MemberStatus = Literal["active", "churned", "paused"]
PricingModelStatus = Literal["active", "candidate", "retired"] PricingModelStatus = Literal["active", "candidate", "retired"]
LiquidityStatus = Literal["burning", "neutral", "generating"] LiquidityStatus = Literal["burning", "neutral", "generating"]
@@ -33,14 +33,28 @@ class PricingModel:
@dataclass(frozen=True) @dataclass(frozen=True)
class CostEntry: class ExpenseRecord:
id: str id: str
name: str period: str
category: CostCategory vendor: str
description: str
cost_class: ExpenseClass
amount: Decimal amount: Decimal
currency: str currency: str
cadence: str source: str
allocation: str
@dataclass(frozen=True)
class PaymentRecord:
id: str
period: str
gross_amount: Decimal
fees_amount: Decimal
refunds_amount: Decimal
net_amount: Decimal
currency: str
source: str
member_count: int = 0
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -63,18 +77,6 @@ class Budget:
started: str started: str
@dataclass(frozen=True)
class RevenueEntry:
id: str
period: str
gross_amount: Decimal
fees_amount: Decimal
refunds_amount: Decimal
net_amount: Decimal
currency: str
source: str
@dataclass(frozen=True) @dataclass(frozen=True)
class MembershipRecord: class MembershipRecord:
id: str id: str

View File

@@ -5,7 +5,9 @@
**Active pricing model:** Standard Membership (8.99 EUR/monthly) **Active pricing model:** Standard Membership (8.99 EUR/monthly)
> Platform costs accrue to the operator. Customer cost-pass-through billing is > Platform costs accrue to the operator. Customer cost-pass-through billing is
> **not active** in MVP — members pay subscription only. > **not active** in MVP — members pay subscription only. All totals are computed
> programmatically from expense and payment record ledgers (48 expense
> records).
## Key Metrics (current period) ## Key Metrics (current period)
@@ -13,13 +15,13 @@
|--------|------:| |--------|------:|
| Active members | 1 | | Active members | 1 |
| Member payments (gross) | 8.99 EUR | | Member payments (gross) | 8.99 EUR |
| Infrastructure cost | 72.20 EUR | | Infrastructure cost | 69.44 EUR |
| Payment processing cost | 0.38 EUR | | Payment processing cost | 0.38 EUR |
| Total platform cost | 72.58 EUR | | Total platform cost | 69.82 EUR |
| Platform cost per member | 72.58 EUR | | Platform cost per member | 69.82 EUR |
| Period gross margin | -63.59 EUR | | Period gross margin | -60.83 EUR |
| Period gross margin % | -707.3% | | Period gross margin % | -676.6% |
| Period net liquidity | -63.59 EUR (burning) | | Period net liquidity | -60.83 EUR (burning) |
_Period net liquidity = net member payments infrastructure cost (processing fees already netted from payments)._ _Period net liquidity = net member payments infrastructure cost (processing fees already netted from payments)._
_Revenue source: manual_ _Revenue source: manual_
@@ -30,33 +32,33 @@ _Revenue source: manual_
|--------|------:| |--------|------:|
| Initial budget | 1000.00 EUR | | Initial budget | 1000.00 EUR |
| Cumulative member payments (net) | 68.88 EUR | | Cumulative member payments (net) | 68.88 EUR |
| Cumulative infrastructure cost | 1155.20 EUR | | Cumulative infrastructure cost | 1111.04 EUR |
| Cumulative payment processing | 3.04 EUR | | Cumulative payment processing | 3.04 EUR |
| Cumulative total platform cost | 1158.24 EUR | | Cumulative total platform cost | 1114.08 EUR |
| Cumulative net liquidity | -1086.32 EUR (burning) | | Cumulative net liquidity | -1042.16 EUR (burning) |
| Remaining budget | -86.32 EUR (over budget) | | Remaining budget | -42.16 EUR (over budget) |
| Months tracked | 16 | | Months tracked | 16 |
## Monthly History ## Monthly History
| Period | Members | Gross revenue | Infrastructure | Processing | Total platform | Net liquidity | | Period | Members | Gross revenue | Infrastructure | Processing | Total platform | Net liquidity |
|--------|--------:|--------------:|---------------:|-----------:|---------------:|--------------:| |--------|--------:|--------------:|---------------:|-----------:|---------------:|--------------:|
| 2025-03 | 0 | 0.00 | 72.20 | 0.00 | 72.20 | -72.20 | | 2025-03 | 0 | 0.00 | 69.44 | 0.00 | 69.44 | -69.44 |
| 2025-04 | 0 | 0.00 | 72.20 | 0.00 | 72.20 | -72.20 | | 2025-04 | 0 | 0.00 | 69.44 | 0.00 | 69.44 | -69.44 |
| 2025-05 | 0 | 0.00 | 72.20 | 0.00 | 72.20 | -72.20 | | 2025-05 | 0 | 0.00 | 69.44 | 0.00 | 69.44 | -69.44 |
| 2025-06 | 0 | 0.00 | 72.20 | 0.00 | 72.20 | -72.20 | | 2025-06 | 0 | 0.00 | 69.44 | 0.00 | 69.44 | -69.44 |
| 2025-07 | 0 | 0.00 | 72.20 | 0.00 | 72.20 | -72.20 | | 2025-07 | 0 | 0.00 | 69.44 | 0.00 | 69.44 | -69.44 |
| 2025-08 | 0 | 0.00 | 72.20 | 0.00 | 72.20 | -72.20 | | 2025-08 | 0 | 0.00 | 69.44 | 0.00 | 69.44 | -69.44 |
| 2025-09 | 0 | 0.00 | 72.20 | 0.00 | 72.20 | -72.20 | | 2025-09 | 0 | 0.00 | 69.44 | 0.00 | 69.44 | -69.44 |
| 2025-10 | 0 | 0.00 | 72.20 | 0.00 | 72.20 | -72.20 | | 2025-10 | 0 | 0.00 | 69.44 | 0.00 | 69.44 | -69.44 |
| 2025-11 | 1 | 8.99 | 72.20 | 0.38 | 72.58 | -63.59 | | 2025-11 | 1 | 8.99 | 69.44 | 0.38 | 69.82 | -60.83 |
| 2025-12 | 1 | 8.99 | 72.20 | 0.38 | 72.58 | -63.59 | | 2025-12 | 1 | 8.99 | 69.44 | 0.38 | 69.82 | -60.83 |
| 2026-01 | 1 | 8.99 | 72.20 | 0.38 | 72.58 | -63.59 | | 2026-01 | 1 | 8.99 | 69.44 | 0.38 | 69.82 | -60.83 |
| 2026-02 | 1 | 8.99 | 72.20 | 0.38 | 72.58 | -63.59 | | 2026-02 | 1 | 8.99 | 69.44 | 0.38 | 69.82 | -60.83 |
| 2026-03 | 1 | 8.99 | 72.20 | 0.38 | 72.58 | -63.59 | | 2026-03 | 1 | 8.99 | 69.44 | 0.38 | 69.82 | -60.83 |
| 2026-04 | 1 | 8.99 | 72.20 | 0.38 | 72.58 | -63.59 | | 2026-04 | 1 | 8.99 | 69.44 | 0.38 | 69.82 | -60.83 |
| 2026-05 | 1 | 8.99 | 72.20 | 0.38 | 72.58 | -63.59 | | 2026-05 | 1 | 8.99 | 69.44 | 0.38 | 69.82 | -60.83 |
| 2026-06 | 1 | 8.99 | 72.20 | 0.38 | 72.58 | -63.59 | | 2026-06 | 1 | 8.99 | 69.44 | 0.38 | 69.82 | -60.83 |
## Pricing Model Registry ## Pricing Model Registry
@@ -70,8 +72,7 @@ _Revenue source: manual_
- Product model (`data/product.json`) - Product model (`data/product.json`)
- Budget (`data/budget.json`) - Budget (`data/budget.json`)
- Pricing model registry (`data/pricing-models.json`) - Expense records (`data/expense_records.json`) — source of truth for costs
- Platform costs (`data/costs.json`) - Payment records (`data/payment_records.json`)
- Member payments (`data/revenue.json`)
- Membership (`data/membership.json`) - Membership (`data/membership.json`)
- Requirements (`REQUIREMENTS.md`) - Requirements (`REQUIREMENTS.md`)

View File

@@ -3,20 +3,17 @@ from __future__ import annotations
from decimal import Decimal from decimal import Decimal
from pathlib import Path from pathlib import Path
from observatory.economics import ( from observatory.economics import active_members, build_liquidity_summary, build_snapshot
active_members, from observatory.ledger import aggregate_infrastructure_by_period, build_monthly_ledger
build_liquidity_summary,
build_snapshot,
monthly_cost_from_rate_card,
)
from observatory.load import ( from observatory.load import (
load_budget, load_budget,
load_cost_rate_card, load_expense_records,
load_fx_rates,
load_membership, load_membership,
load_monthly_platform_costs, load_monthly_ledger,
load_payment_records,
load_pricing_models, load_pricing_models,
load_product, load_product,
load_revenue,
) )
DATA_DIR = Path(__file__).resolve().parent.parent / "data" DATA_DIR = Path(__file__).resolve().parent.parent / "data"
@@ -27,52 +24,80 @@ def test_active_members_counts_only_active_status() -> None:
assert active_members(members) == 1 assert active_members(members) == 1
def test_rate_card_splits_infrastructure_and_payment_processing() -> None: def test_infrastructure_aggregated_from_expense_records() -> None:
rate_card, fx_rates = load_cost_rate_card(DATA_DIR) expenses = load_expense_records(DATA_DIR)
infrastructure = monthly_cost_from_rate_card(rate_card, fx_rates, Decimal("0"), 0) fx = load_fx_rates(DATA_DIR)
with_revenue = monthly_cost_from_rate_card(rate_card, fx_rates, Decimal("8.99"), 1) totals = aggregate_infrastructure_by_period(expenses, fx)
assert infrastructure == Decimal("72.20")
assert with_revenue == Decimal("72.58") assert totals["2025-03"] == Decimal("69.44")
assert with_revenue - infrastructure == Decimal("0.38") assert totals["2026-06"] == Decimal("69.44")
# Bubble $32 @ 0.92 = 29.44 + 15 domains + 25 overhead
bubble_eur = Decimal("32.00") * Decimal("0.92")
assert bubble_eur == Decimal("29.44")
def test_build_snapshot_june_2026_avoids_stripe_double_count_in_liquidity() -> None: def test_monthly_ledger_computes_processing_from_payment_records() -> None:
ledger = load_monthly_ledger(DATA_DIR)
june = next(row for row in ledger if row.period == "2026-06")
march = next(row for row in ledger if row.period == "2025-03")
assert march.infrastructure_cost == Decimal("69.44")
assert march.payment_processing_cost == Decimal("0.00")
assert march.gross_revenue == Decimal("0.00")
assert june.infrastructure_cost == Decimal("69.44")
assert june.payment_processing_cost == Decimal("0.38")
assert june.gross_revenue == Decimal("8.99")
def test_build_snapshot_june_2026_uses_ledger_not_hand_totals() -> None:
product = load_product(DATA_DIR) product = load_product(DATA_DIR)
models = load_pricing_models(DATA_DIR) models = load_pricing_models(DATA_DIR)
members = load_membership(DATA_DIR) members = load_membership(DATA_DIR)
revenue = load_revenue(DATA_DIR) payments = load_payment_records(DATA_DIR)
monthly_costs = load_monthly_platform_costs(DATA_DIR) ledger = load_monthly_ledger(DATA_DIR)
snapshot = build_snapshot("2026-06", product, models, members, revenue, monthly_costs) snapshot = build_snapshot("2026-06", product, models, members, payments, ledger)
assert snapshot.monthly_infrastructure_cost == Decimal("72.20") assert snapshot.monthly_infrastructure_cost == Decimal("69.44")
assert snapshot.monthly_payment_processing_cost == Decimal("0.38") assert snapshot.monthly_payment_processing_cost == Decimal("0.38")
assert snapshot.monthly_total_platform_cost == Decimal("72.58") assert snapshot.monthly_total_platform_cost == Decimal("69.82")
assert snapshot.period_net_liquidity == Decimal("-63.59") assert snapshot.period_net_liquidity == Decimal("-60.83")
assert snapshot.gross_margin == Decimal("-63.59") assert snapshot.gross_margin == Decimal("-60.83")
def test_liquidity_summary_uses_infrastructure_only_for_cumulative_burn() -> None: def test_liquidity_summary_aggregates_ledgers_deterministically() -> None:
budget = load_budget(DATA_DIR) budget = load_budget(DATA_DIR)
revenue = load_revenue(DATA_DIR) payments = load_payment_records(DATA_DIR)
monthly_costs = load_monthly_platform_costs(DATA_DIR) ledger = load_monthly_ledger(DATA_DIR)
summary = build_liquidity_summary(budget, revenue, monthly_costs, "2026-06") summary = build_liquidity_summary(budget, payments, ledger, "2026-06")
assert summary.cumulative_infrastructure_cost == Decimal("1155.20") assert summary.cumulative_infrastructure_cost == Decimal("1111.04")
assert summary.cumulative_payment_processing_cost == Decimal("3.04") assert summary.cumulative_payment_processing_cost == Decimal("3.04")
assert summary.cumulative_total_platform_cost == Decimal("1158.24") assert summary.cumulative_total_platform_cost == Decimal("1114.08")
assert summary.cumulative_member_payments == Decimal("68.88") assert summary.cumulative_member_payments == Decimal("68.88")
assert summary.cumulative_net_liquidity == Decimal("-1086.32") assert summary.cumulative_net_liquidity == Decimal("-1042.16")
assert summary.remaining_budget == Decimal("-86.32") assert summary.remaining_budget == Decimal("-42.16")
assert summary.liquidity_status == "burning" assert summary.liquidity_status == "burning"
assert summary.months_tracked == 16 assert summary.months_tracked == 16
def test_dashboard_renders_split_cost_columns() -> None: def test_build_monthly_ledger_matches_loader() -> None:
budget = load_budget(DATA_DIR)
expenses = load_expense_records(DATA_DIR)
payments = load_payment_records(DATA_DIR)
members = load_membership(DATA_DIR)
fx = load_fx_rates(DATA_DIR)
assert build_monthly_ledger(budget, expenses, payments, members, fx) == load_monthly_ledger(
DATA_DIR
)
def test_dashboard_notes_expense_record_source() -> None:
from observatory.dashboard import generate_dashboard from observatory.dashboard import generate_dashboard
report = generate_dashboard(DATA_DIR, "2026-06") report = generate_dashboard(DATA_DIR, "2026-06")
assert "Cumulative infrastructure cost" in report assert "expense and payment record ledgers" in report
assert "1155.20" in report assert "1111.04" in report
assert "-86.32" in report assert "-42.16" in report