From 31db9f8f3171d6b0ed8066f349481979bd814e38 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 22 Jun 2026 02:03:22 +0200 Subject: [PATCH] Refactor economics to expense-record ledger with correct Bubble cost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- projects/coulomb-pricing/README.md | 20 +- projects/coulomb-pricing/REQUIREMENTS.md | 46 +- projects/coulomb-pricing/data/costs.json | 72 --- .../coulomb-pricing/data/expense_records.json | 489 ++++++++++++++++++ .../{revenue.json => payment_records.json} | 20 +- .../coulomb-pricing/observatory/dashboard.py | 40 +- .../coulomb-pricing/observatory/economics.py | 54 +- .../coulomb-pricing/observatory/ledger.py | 121 +++++ projects/coulomb-pricing/observatory/load.py | 84 ++- .../coulomb-pricing/observatory/models.py | 38 +- .../reports/economics-2026-06.md | 61 +-- .../coulomb-pricing/tests/test_economics.py | 97 ++-- 12 files changed, 843 insertions(+), 299 deletions(-) delete mode 100644 projects/coulomb-pricing/data/costs.json create mode 100644 projects/coulomb-pricing/data/expense_records.json rename projects/coulomb-pricing/data/{revenue.json => payment_records.json} (63%) create mode 100644 projects/coulomb-pricing/observatory/ledger.py diff --git a/projects/coulomb-pricing/README.md b/projects/coulomb-pricing/README.md index d3568bb..cd395f2 100644 --- a/projects/coulomb-pricing/README.md +++ b/projects/coulomb-pricing/README.md @@ -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. \ No newline at end of file diff --git a/projects/coulomb-pricing/REQUIREMENTS.md b/projects/coulomb-pricing/REQUIREMENTS.md index 9bc7af3..78cf767 100644 --- a/projects/coulomb-pricing/REQUIREMENTS.md +++ b/projects/coulomb-pricing/REQUIREMENTS.md @@ -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. \ No newline at end of file +Future sprints replace manual records with Bubble, Stripe, and OpenRouter imports +while preserving the same ledger schema and calculation rules. \ No newline at end of file diff --git a/projects/coulomb-pricing/data/costs.json b/projects/coulomb-pricing/data/costs.json deleted file mode 100644 index 4edf9af..0000000 --- a/projects/coulomb-pricing/data/costs.json +++ /dev/null @@ -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." -} \ No newline at end of file diff --git a/projects/coulomb-pricing/data/expense_records.json b/projects/coulomb-pricing/data/expense_records.json new file mode 100644 index 0000000..137efc5 --- /dev/null +++ b/projects/coulomb-pricing/data/expense_records.json @@ -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." +} diff --git a/projects/coulomb-pricing/data/revenue.json b/projects/coulomb-pricing/data/payment_records.json similarity index 63% rename from projects/coulomb-pricing/data/revenue.json rename to projects/coulomb-pricing/data/payment_records.json index f85fec1..e893eb1 100644 --- a/projects/coulomb-pricing/data/revenue.json +++ b/projects/coulomb-pricing/data/payment_records.json @@ -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." } \ No newline at end of file diff --git a/projects/coulomb-pricing/observatory/dashboard.py b/projects/coulomb-pricing/observatory/dashboard.py index 28489fd..830c817 100644 --- a/projects/coulomb-pricing/observatory/dashboard.py +++ b/projects/coulomb-pricing/observatory/dashboard.py @@ -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: diff --git a/projects/coulomb-pricing/observatory/economics.py b/projects/coulomb-pricing/observatory/economics.py index 28d79a5..80b98fa 100644 --- a/projects/coulomb-pricing/observatory/economics.py +++ b/projects/coulomb-pricing/observatory/economics.py @@ -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 diff --git a/projects/coulomb-pricing/observatory/ledger.py b/projects/coulomb-pricing/observatory/ledger.py new file mode 100644 index 0000000..0c50b9e --- /dev/null +++ b/projects/coulomb-pricing/observatory/ledger.py @@ -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 \ No newline at end of file diff --git a/projects/coulomb-pricing/observatory/load.py b/projects/coulomb-pricing/observatory/load.py index bbc9296..1b12386 100644 --- a/projects/coulomb-pricing/observatory/load.py +++ b/projects/coulomb-pricing/observatory/load.py @@ -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) \ No newline at end of file diff --git a/projects/coulomb-pricing/observatory/models.py b/projects/coulomb-pricing/observatory/models.py index ea33d3e..112d3c9 100644 --- a/projects/coulomb-pricing/observatory/models.py +++ b/projects/coulomb-pricing/observatory/models.py @@ -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 diff --git a/projects/coulomb-pricing/reports/economics-2026-06.md b/projects/coulomb-pricing/reports/economics-2026-06.md index 10919a6..b620ee9 100644 --- a/projects/coulomb-pricing/reports/economics-2026-06.md +++ b/projects/coulomb-pricing/reports/economics-2026-06.md @@ -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`) diff --git a/projects/coulomb-pricing/tests/test_economics.py b/projects/coulomb-pricing/tests/test_economics.py index d06089d..90da84b 100644 --- a/projects/coulomb-pricing/tests/test_economics.py +++ b/projects/coulomb-pricing/tests/test_economics.py @@ -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 \ No newline at end of file + assert "expense and payment record ledgers" in report + assert "1111.04" in report + assert "-42.16" in report \ No newline at end of file