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
The `observatory/` package loads JSON registries and produces **Economics
Dashboard v1** with platform-cost history, member payments, and budget burn.
The `observatory/` package reads **expense and payment record ledgers** and
computes all totals programmatically (`ledger.py``economics.py`).
| Registry | File |
|----------|------|
| Ledger | File |
|--------|------|
| Budget (€1,000 start) | `data/budget.json` |
| Expense records | `data/expense_records.json` |
| Payment records | `data/payment_records.json` |
| Product model | `data/product.json` |
| Pricing models | `data/pricing-models.json` |
| Platform costs + history | `data/costs.json` |
| Member payments | `data/revenue.json` |
| Membership | `data/membership.json` |
**Current reality:** platform costs from March 2025; sole member payments from
November 2025. Operator is **burning liquidity** — platform spend exceeds
subscription intake. Customer cost-pass-through billing is not active.
**Current reality:** platform costs from March 2025 (Bubble.io **$32/mo** since
Feb 2025); sole member payments from November 2025. Operator is **burning
liquidity**. Customer cost-pass-through billing is not active.
### Commands
@@ -35,5 +35,5 @@ python3 -m observatory --period 2026-06
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.

View File

@@ -11,15 +11,20 @@ The Economic Observatory must make the resulting **liquidity burn** visible.
## 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
infrastructure and payment-provider variable fees when member revenue exists.
Capture every operator expense as an individual record in
`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),
separate from platform cost accrual.
### LQ-002 — Payment record ledger
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
@@ -36,18 +41,15 @@ liquidity each period:
- `cumulative_net = sum(period_net)`
- `remaining_budget = initial_budget + cumulative_net`
**No double-counting:** payment-processing fees (Stripe) are deducted from net
member payments. They are tracked separately for economics reporting but must
**not** be subtracted again in the liquidity formula.
**No double-counting:** payment-processing fees are deducted from net member
payments. They are summed separately for economics reporting but must **not** be
subtracted again in the liquidity formula.
- `total_platform_cost = infrastructure_cost + payment_processing_cost` (for
gross-margin economics vs gross revenue)
- `cumulative_total_platform_cost` is informational; liquidity burn uses
`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)
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)
- 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)
| Registry | Path |
|----------|------|
| Ledger | Path |
|--------|------|
| Budget | `data/budget.json` |
| Platform costs | `data/costs.json` (`rate_card` + `monthly_history`) |
| Member payments | `data/revenue.json` |
| Expense records | `data/expense_records.json` |
| Payment records | `data/payment_records.json` |
| Membership | `data/membership.json` |
Future sprints replace manual history with Bubble, Stripe, and OpenRouter imports
while preserving the same liquidity semantics.
Future sprints replace manual records with Bubble, Stripe, and OpenRouter imports
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,
"entries": [
{"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": "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": "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": "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": "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": "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": "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": "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}
"records": [
{"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": "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": "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": "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": "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": "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": "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": "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,
latest_period,
load_budget,
load_expense_records,
load_membership,
load_monthly_platform_costs,
load_monthly_ledger,
load_payment_records,
load_pricing_models,
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(
monthly_costs: list[MonthlyPlatformCost],
revenue_entries: list[RevenueEntry],
payments: list[PaymentRecord],
through_period: 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] = []
for month in sorted(monthly_costs, key=lambda item: item.period):
if month.period > through_period:
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")
period_net = net_payment - month.infrastructure_cost
lines.append(
@@ -45,14 +46,15 @@ def render_dashboard(
snapshot: EconomicsSnapshot,
liquidity: LiquiditySummary,
monthly_costs: list[MonthlyPlatformCost],
revenue_entries: list[RevenueEntry],
payments: list[PaymentRecord],
expense_count: int,
) -> str:
active = next(m for m in models if m.id == product.active_pricing_model_id)
registry_lines = "\n".join(
f"| {model.id} | {model.name} | {model.model_type} | {model.status} |"
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 = (
"over budget"
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})
> 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)
@@ -114,9 +118,8 @@ _Revenue source: {snapshot.revenue_source}_
- Product model (`data/product.json`)
- Budget (`data/budget.json`)
- Pricing model registry (`data/pricing-models.json`)
- Platform costs (`data/costs.json`)
- Member payments (`data/revenue.json`)
- Expense records (`data/expense_records.json`) — source of truth for costs
- Payment records (`data/payment_records.json`)
- Membership (`data/membership.json`)
- Requirements (`REQUIREMENTS.md`)
"""
@@ -128,13 +131,16 @@ def generate_dashboard(data_dir: Path | None = None, period: str | None = None)
budget = load_budget(root)
models = load_pricing_models(root)
members = load_membership(root)
revenue = load_revenue(root)
monthly_costs = load_monthly_platform_costs(root)
payments = load_payment_records(root)
expenses = load_expense_records(root)
monthly_costs = load_monthly_ledger(root)
target_period = period or latest_period(monthly_costs)
snapshot = build_snapshot(target_period, product, models, members, revenue, monthly_costs)
liquidity = build_liquidity_summary(budget, revenue, monthly_costs, target_period)
return render_dashboard(product, models, snapshot, liquidity, monthly_costs, revenue)
snapshot = build_snapshot(target_period, product, models, members, payments, monthly_costs)
liquidity = build_liquidity_summary(budget, payments, monthly_costs, target_period)
return render_dashboard(
product, models, snapshot, liquidity, monthly_costs, payments, len(expenses)
)
def main(argv: list[str] | None = None) -> int:

View File

@@ -4,15 +4,14 @@ from decimal import Decimal, ROUND_HALF_UP
from .models import (
Budget,
CostEntry,
EconomicsSnapshot,
LiquidityStatus,
LiquiditySummary,
MembershipRecord,
MonthlyPlatformCost,
PaymentRecord,
PricingModel,
Product,
RevenueEntry,
)
TWOPLACES = Decimal("0.01")
@@ -23,16 +22,6 @@ def _quantize(value: Decimal, exp: Decimal = TWOPLACES) -> Decimal:
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:
if net < Decimal("0"):
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}")
def monthly_cost_from_rate_card(
cost_entries: list[CostEntry],
fx_rates: dict[str, Decimal],
gross_revenue: Decimal,
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
def payment_for_period(period: str, payments: list[PaymentRecord]) -> PaymentRecord | None:
for record in payments:
if record.period == period:
return record
return None
@@ -81,16 +53,16 @@ def estimate_monthly_revenue(
product: Product,
models: list[PricingModel],
members: list[MembershipRecord],
revenue_entries: list[RevenueEntry],
payments: list[PaymentRecord],
monthly_costs: list[MonthlyPlatformCost],
) -> tuple[Decimal, Decimal, str]:
recorded = revenue_for_period(period, revenue_entries)
recorded = payment_for_period(period, payments)
if recorded:
return recorded.gross_amount, recorded.net_amount, recorded.source
month = next((item for item in monthly_costs if item.period == period), None)
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)
count = active_members(members)
@@ -104,7 +76,7 @@ def periods_through(target: str, monthly_costs: list[MonthlyPlatformCost]) -> li
def build_liquidity_summary(
budget: Budget,
revenue_entries: list[RevenueEntry],
payments: list[PaymentRecord],
monthly_costs: list[MonthlyPlatformCost],
through_period: str,
) -> LiquiditySummary:
@@ -118,11 +90,9 @@ def build_liquidity_summary(
month = cost_by_period[period]
cumulative_infrastructure += month.infrastructure_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")
# 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)
remaining = _quantize(budget.initial_budget + cumulative_net)
cumulative_total = _quantize(cumulative_infrastructure + cumulative_processing)
@@ -147,13 +117,13 @@ def build_snapshot(
product: Product,
models: list[PricingModel],
members: list[MembershipRecord],
revenue_entries: list[RevenueEntry],
payments: list[PaymentRecord],
monthly_costs: list[MonthlyPlatformCost],
) -> EconomicsSnapshot:
month = next(item for item in monthly_costs if item.period == period)
count = month.active_members if month.active_members else active_members(members)
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
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 pathlib import Path
_ZERO = Decimal("0")
from .ledger import build_monthly_ledger
from .models import (
Budget,
CostEntry,
ExpenseRecord,
MembershipRecord,
MonthlyPlatformCost,
PaymentRecord,
PricingModel,
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 [
CostEntry(
ExpenseRecord(
id=item["id"],
name=item["name"],
category=item["category"],
period=item["period"],
vendor=item["vendor"],
description=item["description"],
cost_class=item["cost_class"],
amount=_money(item["amount"]),
currency=item["currency"],
cadence=item["cadence"],
allocation=item["allocation"],
source=item["source"],
)
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]]:
raw = _read_json((data_dir or default_data_dir()) / "costs.json")
items = raw.get("rate_card", raw.get("entries", []))
fx = {pair: _money(rate) for pair, rate in raw.get("fx_rates", {}).items()}
return _parse_cost_entries(items), fx
def load_monthly_platform_costs(data_dir: Path | None = None) -> list[MonthlyPlatformCost]:
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")
def load_payment_records(data_dir: Path | None = None) -> list[PaymentRecord]:
root = data_dir or default_data_dir()
path = root / "payment_records.json"
if not path.exists():
# Backward compatibility with legacy revenue.json
path = root / "revenue.json"
raw = _read_json(path)
items = raw.get("records", raw.get("entries", []))
return [
RevenueEntry(
PaymentRecord(
id=item["id"],
period=item["period"],
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"]),
currency=item["currency"],
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:
return max(item.period for item in monthly_costs)

View File

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

View File

@@ -5,7 +5,9 @@
**Active pricing model:** Standard Membership (8.99 EUR/monthly)
> 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)
@@ -13,13 +15,13 @@
|--------|------:|
| Active members | 1 |
| Member payments (gross) | 8.99 EUR |
| Infrastructure cost | 72.20 EUR |
| Infrastructure cost | 69.44 EUR |
| Payment processing cost | 0.38 EUR |
| Total platform cost | 72.58 EUR |
| Platform cost per member | 72.58 EUR |
| Period gross margin | -63.59 EUR |
| Period gross margin % | -707.3% |
| Period net liquidity | -63.59 EUR (burning) |
| Total platform cost | 69.82 EUR |
| Platform cost per member | 69.82 EUR |
| Period gross margin | -60.83 EUR |
| Period gross margin % | -676.6% |
| Period net liquidity | -60.83 EUR (burning) |
_Period net liquidity = net member payments infrastructure cost (processing fees already netted from payments)._
_Revenue source: manual_
@@ -30,33 +32,33 @@ _Revenue source: manual_
|--------|------:|
| Initial budget | 1000.00 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 total platform cost | 1158.24 EUR |
| Cumulative net liquidity | -1086.32 EUR (burning) |
| Remaining budget | -86.32 EUR (over budget) |
| Cumulative total platform cost | 1114.08 EUR |
| Cumulative net liquidity | -1042.16 EUR (burning) |
| Remaining budget | -42.16 EUR (over budget) |
| Months tracked | 16 |
## Monthly History
| Period | Members | Gross revenue | Infrastructure | Processing | Total platform | Net liquidity |
|--------|--------:|--------------:|---------------:|-----------:|---------------:|--------------:|
| 2025-03 | 0 | 0.00 | 72.20 | 0.00 | 72.20 | -72.20 |
| 2025-04 | 0 | 0.00 | 72.20 | 0.00 | 72.20 | -72.20 |
| 2025-05 | 0 | 0.00 | 72.20 | 0.00 | 72.20 | -72.20 |
| 2025-06 | 0 | 0.00 | 72.20 | 0.00 | 72.20 | -72.20 |
| 2025-07 | 0 | 0.00 | 72.20 | 0.00 | 72.20 | -72.20 |
| 2025-08 | 0 | 0.00 | 72.20 | 0.00 | 72.20 | -72.20 |
| 2025-09 | 0 | 0.00 | 72.20 | 0.00 | 72.20 | -72.20 |
| 2025-10 | 0 | 0.00 | 72.20 | 0.00 | 72.20 | -72.20 |
| 2025-11 | 1 | 8.99 | 72.20 | 0.38 | 72.58 | -63.59 |
| 2025-12 | 1 | 8.99 | 72.20 | 0.38 | 72.58 | -63.59 |
| 2026-01 | 1 | 8.99 | 72.20 | 0.38 | 72.58 | -63.59 |
| 2026-02 | 1 | 8.99 | 72.20 | 0.38 | 72.58 | -63.59 |
| 2026-03 | 1 | 8.99 | 72.20 | 0.38 | 72.58 | -63.59 |
| 2026-04 | 1 | 8.99 | 72.20 | 0.38 | 72.58 | -63.59 |
| 2026-05 | 1 | 8.99 | 72.20 | 0.38 | 72.58 | -63.59 |
| 2026-06 | 1 | 8.99 | 72.20 | 0.38 | 72.58 | -63.59 |
| 2025-03 | 0 | 0.00 | 69.44 | 0.00 | 69.44 | -69.44 |
| 2025-04 | 0 | 0.00 | 69.44 | 0.00 | 69.44 | -69.44 |
| 2025-05 | 0 | 0.00 | 69.44 | 0.00 | 69.44 | -69.44 |
| 2025-06 | 0 | 0.00 | 69.44 | 0.00 | 69.44 | -69.44 |
| 2025-07 | 0 | 0.00 | 69.44 | 0.00 | 69.44 | -69.44 |
| 2025-08 | 0 | 0.00 | 69.44 | 0.00 | 69.44 | -69.44 |
| 2025-09 | 0 | 0.00 | 69.44 | 0.00 | 69.44 | -69.44 |
| 2025-10 | 0 | 0.00 | 69.44 | 0.00 | 69.44 | -69.44 |
| 2025-11 | 1 | 8.99 | 69.44 | 0.38 | 69.82 | -60.83 |
| 2025-12 | 1 | 8.99 | 69.44 | 0.38 | 69.82 | -60.83 |
| 2026-01 | 1 | 8.99 | 69.44 | 0.38 | 69.82 | -60.83 |
| 2026-02 | 1 | 8.99 | 69.44 | 0.38 | 69.82 | -60.83 |
| 2026-03 | 1 | 8.99 | 69.44 | 0.38 | 69.82 | -60.83 |
| 2026-04 | 1 | 8.99 | 69.44 | 0.38 | 69.82 | -60.83 |
| 2026-05 | 1 | 8.99 | 69.44 | 0.38 | 69.82 | -60.83 |
| 2026-06 | 1 | 8.99 | 69.44 | 0.38 | 69.82 | -60.83 |
## Pricing Model Registry
@@ -70,8 +72,7 @@ _Revenue source: manual_
- Product model (`data/product.json`)
- Budget (`data/budget.json`)
- Pricing model registry (`data/pricing-models.json`)
- Platform costs (`data/costs.json`)
- Member payments (`data/revenue.json`)
- Expense records (`data/expense_records.json`) — source of truth for costs
- Payment records (`data/payment_records.json`)
- Membership (`data/membership.json`)
- Requirements (`REQUIREMENTS.md`)

View File

@@ -3,20 +3,17 @@ from __future__ import annotations
from decimal import Decimal
from pathlib import Path
from observatory.economics import (
active_members,
build_liquidity_summary,
build_snapshot,
monthly_cost_from_rate_card,
)
from observatory.economics import active_members, build_liquidity_summary, build_snapshot
from observatory.ledger import aggregate_infrastructure_by_period, build_monthly_ledger
from observatory.load import (
load_budget,
load_cost_rate_card,
load_expense_records,
load_fx_rates,
load_membership,
load_monthly_platform_costs,
load_monthly_ledger,
load_payment_records,
load_pricing_models,
load_product,
load_revenue,
)
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
def test_rate_card_splits_infrastructure_and_payment_processing() -> None:
rate_card, fx_rates = load_cost_rate_card(DATA_DIR)
infrastructure = monthly_cost_from_rate_card(rate_card, fx_rates, Decimal("0"), 0)
with_revenue = monthly_cost_from_rate_card(rate_card, fx_rates, Decimal("8.99"), 1)
assert infrastructure == Decimal("72.20")
assert with_revenue == Decimal("72.58")
assert with_revenue - infrastructure == Decimal("0.38")
def test_infrastructure_aggregated_from_expense_records() -> None:
expenses = load_expense_records(DATA_DIR)
fx = load_fx_rates(DATA_DIR)
totals = aggregate_infrastructure_by_period(expenses, fx)
assert totals["2025-03"] == Decimal("69.44")
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)
models = load_pricing_models(DATA_DIR)
members = load_membership(DATA_DIR)
revenue = load_revenue(DATA_DIR)
monthly_costs = load_monthly_platform_costs(DATA_DIR)
payments = load_payment_records(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_total_platform_cost == Decimal("72.58")
assert snapshot.period_net_liquidity == Decimal("-63.59")
assert snapshot.gross_margin == Decimal("-63.59")
assert snapshot.monthly_total_platform_cost == Decimal("69.82")
assert snapshot.period_net_liquidity == Decimal("-60.83")
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)
revenue = load_revenue(DATA_DIR)
monthly_costs = load_monthly_platform_costs(DATA_DIR)
payments = load_payment_records(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_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_net_liquidity == Decimal("-1086.32")
assert summary.remaining_budget == Decimal("-86.32")
assert summary.cumulative_net_liquidity == Decimal("-1042.16")
assert summary.remaining_budget == Decimal("-42.16")
assert summary.liquidity_status == "burning"
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
report = generate_dashboard(DATA_DIR, "2026-06")
assert "Cumulative infrastructure cost" in report
assert "1155.20" in report
assert "-86.32" in report
assert "expense and payment record ledgers" in report
assert "1111.04" in report
assert "-42.16" in report