generated from coulomb/repo-seed
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:
@@ -10,21 +10,21 @@ Liquidity and cost requirements: `REQUIREMENTS.md`.
|
|||||||
|
|
||||||
## Sprint 1 — Economic Foundations
|
## Sprint 1 — Economic Foundations
|
||||||
|
|
||||||
The `observatory/` package loads JSON registries and produces **Economics
|
The `observatory/` package reads **expense and payment record ledgers** and
|
||||||
Dashboard v1** with platform-cost history, member payments, and budget burn.
|
computes all totals programmatically (`ledger.py` → `economics.py`).
|
||||||
|
|
||||||
| Registry | File |
|
| Ledger | File |
|
||||||
|----------|------|
|
|--------|------|
|
||||||
| Budget (€1,000 start) | `data/budget.json` |
|
| Budget (€1,000 start) | `data/budget.json` |
|
||||||
|
| Expense records | `data/expense_records.json` |
|
||||||
|
| Payment records | `data/payment_records.json` |
|
||||||
| Product model | `data/product.json` |
|
| Product model | `data/product.json` |
|
||||||
| Pricing models | `data/pricing-models.json` |
|
| Pricing models | `data/pricing-models.json` |
|
||||||
| Platform costs + history | `data/costs.json` |
|
|
||||||
| Member payments | `data/revenue.json` |
|
|
||||||
| Membership | `data/membership.json` |
|
| Membership | `data/membership.json` |
|
||||||
|
|
||||||
**Current reality:** platform costs from March 2025; sole member payments from
|
**Current reality:** platform costs from March 2025 (Bubble.io **$32/mo** since
|
||||||
November 2025. Operator is **burning liquidity** — platform spend exceeds
|
Feb 2025); sole member payments from November 2025. Operator is **burning
|
||||||
subscription intake. Customer cost-pass-through billing is not active.
|
liquidity**. Customer cost-pass-through billing is not active.
|
||||||
|
|
||||||
### Commands
|
### Commands
|
||||||
|
|
||||||
@@ -35,5 +35,5 @@ python3 -m observatory --period 2026-06
|
|||||||
python3 -m observatory --period 2026-06 --output reports/economics-2026-06.md
|
python3 -m observatory --period 2026-06 --output reports/economics-2026-06.md
|
||||||
```
|
```
|
||||||
|
|
||||||
Manual registries will be replaced by Bubble (Sprint 2), Stripe (Sprint 3), and
|
Manual ledgers will be replaced by Bubble (Sprint 2), Stripe (Sprint 3), and
|
||||||
OpenRouter (Sprint 4) importers.
|
OpenRouter (Sprint 4) importers.
|
||||||
@@ -11,15 +11,20 @@ The Economic Observatory must make the resulting **liquidity burn** visible.
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
### LQ-001 — Platform cost ledger
|
### LQ-001 — Expense record ledger (source of truth)
|
||||||
|
|
||||||
Record monthly platform costs from project start (March 2025), including fixed
|
Capture every operator expense as an individual record in
|
||||||
infrastructure and payment-provider variable fees when member revenue exists.
|
`data/expense_records.json`. Fields: `period`, `vendor`, `amount`, `currency`,
|
||||||
|
`cost_class`, `source`.
|
||||||
|
|
||||||
### LQ-002 — Member payment ledger
|
**Never store hand-calculated monthly or cumulative totals in data files.**
|
||||||
|
All aggregations must be computed programmatically by `observatory/ledger.py`.
|
||||||
|
|
||||||
Record member subscription payments from first payment month (November 2025),
|
### LQ-002 — Payment record ledger
|
||||||
separate from platform cost accrual.
|
|
||||||
|
Capture member subscription payments in `data/payment_records.json` with gross,
|
||||||
|
fees, and net amounts per period. Payment-processing totals are summed from
|
||||||
|
`fees_amount` — not duplicated as expense records.
|
||||||
|
|
||||||
### LQ-003 — Budget tracking
|
### LQ-003 — Budget tracking
|
||||||
|
|
||||||
@@ -36,18 +41,15 @@ liquidity each period:
|
|||||||
- `cumulative_net = sum(period_net)`
|
- `cumulative_net = sum(period_net)`
|
||||||
- `remaining_budget = initial_budget + cumulative_net`
|
- `remaining_budget = initial_budget + cumulative_net`
|
||||||
|
|
||||||
**No double-counting:** payment-processing fees (Stripe) are deducted from net
|
**No double-counting:** payment-processing fees are deducted from net member
|
||||||
member payments. They are tracked separately for economics reporting but must
|
payments. They are summed separately for economics reporting but must **not** be
|
||||||
**not** be subtracted again in the liquidity formula.
|
subtracted again in the liquidity formula.
|
||||||
|
|
||||||
- `total_platform_cost = infrastructure_cost + payment_processing_cost` (for
|
- `total_platform_cost = infrastructure_cost + payment_processing_cost` (for
|
||||||
gross-margin economics vs gross revenue)
|
gross-margin economics vs gross revenue)
|
||||||
- `cumulative_total_platform_cost` is informational; liquidity burn uses
|
- `cumulative_total_platform_cost` is informational; liquidity burn uses
|
||||||
`cumulative_infrastructure_cost` only
|
`cumulative_infrastructure_cost` only
|
||||||
|
|
||||||
Negative remaining budget means the MVP has consumed more liquidity than the
|
|
||||||
allocated budget.
|
|
||||||
|
|
||||||
### LQ-005 — No customer cost billing (MVP boundary)
|
### LQ-005 — No customer cost billing (MVP boundary)
|
||||||
|
|
||||||
Do not allocate platform costs to customer invoices in MVP. Cost attribution
|
Do not allocate platform costs to customer invoices in MVP. Cost attribution
|
||||||
@@ -60,16 +62,22 @@ Economics Dashboard must show:
|
|||||||
|
|
||||||
- Current-period economics (revenue, platform cost, margin)
|
- Current-period economics (revenue, platform cost, margin)
|
||||||
- Cumulative liquidity summary (budget, burn, remaining)
|
- Cumulative liquidity summary (budget, burn, remaining)
|
||||||
- Monthly history table from March 2025
|
- Monthly history table computed from ledgers
|
||||||
|
|
||||||
|
### LQ-007 — Deterministic calculation engine
|
||||||
|
|
||||||
|
All economics and liquidity figures must be produced by the Python observatory
|
||||||
|
package (`ledger.py`, `economics.py`). LLM or manual arithmetic must not be the
|
||||||
|
source of aggregated totals.
|
||||||
|
|
||||||
## Data sources (current)
|
## Data sources (current)
|
||||||
|
|
||||||
| Registry | Path |
|
| Ledger | Path |
|
||||||
|----------|------|
|
|--------|------|
|
||||||
| Budget | `data/budget.json` |
|
| Budget | `data/budget.json` |
|
||||||
| Platform costs | `data/costs.json` (`rate_card` + `monthly_history`) |
|
| Expense records | `data/expense_records.json` |
|
||||||
| Member payments | `data/revenue.json` |
|
| Payment records | `data/payment_records.json` |
|
||||||
| Membership | `data/membership.json` |
|
| Membership | `data/membership.json` |
|
||||||
|
|
||||||
Future sprints replace manual history with Bubble, Stripe, and OpenRouter imports
|
Future sprints replace manual records with Bubble, Stripe, and OpenRouter imports
|
||||||
while preserving the same liquidity semantics.
|
while preserving the same ledger schema and calculation rules.
|
||||||
@@ -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."
|
|
||||||
}
|
|
||||||
489
projects/coulomb-pricing/data/expense_records.json
Normal file
489
projects/coulomb-pricing/data/expense_records.json
Normal 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."
|
||||||
|
}
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"entries": [
|
"records": [
|
||||||
{"id": "rev-2025-11", "period": "2025-11", "gross_amount": "8.99", "fees_amount": "0.38", "refunds_amount": "0.00", "net_amount": "8.61", "currency": "EUR", "source": "manual", "member_count": 1},
|
{"id": "pay-2025-11", "period": "2025-11", "gross_amount": "8.99", "fees_amount": "0.38", "refunds_amount": "0.00", "net_amount": "8.61", "currency": "EUR", "source": "manual", "member_count": 1},
|
||||||
{"id": "rev-2025-12", "period": "2025-12", "gross_amount": "8.99", "fees_amount": "0.38", "refunds_amount": "0.00", "net_amount": "8.61", "currency": "EUR", "source": "manual", "member_count": 1},
|
{"id": "pay-2025-12", "period": "2025-12", "gross_amount": "8.99", "fees_amount": "0.38", "refunds_amount": "0.00", "net_amount": "8.61", "currency": "EUR", "source": "manual", "member_count": 1},
|
||||||
{"id": "rev-2026-01", "period": "2026-01", "gross_amount": "8.99", "fees_amount": "0.38", "refunds_amount": "0.00", "net_amount": "8.61", "currency": "EUR", "source": "manual", "member_count": 1},
|
{"id": "pay-2026-01", "period": "2026-01", "gross_amount": "8.99", "fees_amount": "0.38", "refunds_amount": "0.00", "net_amount": "8.61", "currency": "EUR", "source": "manual", "member_count": 1},
|
||||||
{"id": "rev-2026-02", "period": "2026-02", "gross_amount": "8.99", "fees_amount": "0.38", "refunds_amount": "0.00", "net_amount": "8.61", "currency": "EUR", "source": "manual", "member_count": 1},
|
{"id": "pay-2026-02", "period": "2026-02", "gross_amount": "8.99", "fees_amount": "0.38", "refunds_amount": "0.00", "net_amount": "8.61", "currency": "EUR", "source": "manual", "member_count": 1},
|
||||||
{"id": "rev-2026-03", "period": "2026-03", "gross_amount": "8.99", "fees_amount": "0.38", "refunds_amount": "0.00", "net_amount": "8.61", "currency": "EUR", "source": "manual", "member_count": 1},
|
{"id": "pay-2026-03", "period": "2026-03", "gross_amount": "8.99", "fees_amount": "0.38", "refunds_amount": "0.00", "net_amount": "8.61", "currency": "EUR", "source": "manual", "member_count": 1},
|
||||||
{"id": "rev-2026-04", "period": "2026-04", "gross_amount": "8.99", "fees_amount": "0.38", "refunds_amount": "0.00", "net_amount": "8.61", "currency": "EUR", "source": "manual", "member_count": 1},
|
{"id": "pay-2026-04", "period": "2026-04", "gross_amount": "8.99", "fees_amount": "0.38", "refunds_amount": "0.00", "net_amount": "8.61", "currency": "EUR", "source": "manual", "member_count": 1},
|
||||||
{"id": "rev-2026-05", "period": "2026-05", "gross_amount": "8.99", "fees_amount": "0.38", "refunds_amount": "0.00", "net_amount": "8.61", "currency": "EUR", "source": "manual", "member_count": 1},
|
{"id": "pay-2026-05", "period": "2026-05", "gross_amount": "8.99", "fees_amount": "0.38", "refunds_amount": "0.00", "net_amount": "8.61", "currency": "EUR", "source": "manual", "member_count": 1},
|
||||||
{"id": "rev-2026-06", "period": "2026-06", "gross_amount": "8.99", "fees_amount": "0.38", "refunds_amount": "0.00", "net_amount": "8.61", "currency": "EUR", "source": "manual", "member_count": 1}
|
{"id": "pay-2026-06", "period": "2026-06", "gross_amount": "8.99", "fees_amount": "0.38", "refunds_amount": "0.00", "net_amount": "8.61", "currency": "EUR", "source": "manual", "member_count": 1}
|
||||||
],
|
],
|
||||||
"note": "Member payments begin November 2025. Stripe sync replaces manual entries in Sprint 3."
|
"note": "Member payment ledger from November 2025. Stripe sync replaces manual entries in Sprint 3."
|
||||||
}
|
}
|
||||||
@@ -9,26 +9,27 @@ from .load import (
|
|||||||
default_data_dir,
|
default_data_dir,
|
||||||
latest_period,
|
latest_period,
|
||||||
load_budget,
|
load_budget,
|
||||||
|
load_expense_records,
|
||||||
load_membership,
|
load_membership,
|
||||||
load_monthly_platform_costs,
|
load_monthly_ledger,
|
||||||
|
load_payment_records,
|
||||||
load_pricing_models,
|
load_pricing_models,
|
||||||
load_product,
|
load_product,
|
||||||
load_revenue,
|
|
||||||
)
|
)
|
||||||
from .models import EconomicsSnapshot, LiquiditySummary, MonthlyPlatformCost, PricingModel, Product, RevenueEntry
|
from .models import EconomicsSnapshot, LiquiditySummary, MonthlyPlatformCost, PaymentRecord, PricingModel, Product
|
||||||
|
|
||||||
|
|
||||||
def _history_rows(
|
def _history_rows(
|
||||||
monthly_costs: list[MonthlyPlatformCost],
|
monthly_costs: list[MonthlyPlatformCost],
|
||||||
revenue_entries: list[RevenueEntry],
|
payments: list[PaymentRecord],
|
||||||
through_period: str,
|
through_period: str,
|
||||||
) -> str:
|
) -> str:
|
||||||
revenue_by_period = {entry.period: entry for entry in revenue_entries}
|
payment_by_period = {record.period: record for record in payments}
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
for month in sorted(monthly_costs, key=lambda item: item.period):
|
for month in sorted(monthly_costs, key=lambda item: item.period):
|
||||||
if month.period > through_period:
|
if month.period > through_period:
|
||||||
continue
|
continue
|
||||||
payment = revenue_by_period.get(month.period)
|
payment = payment_by_period.get(month.period)
|
||||||
net_payment = payment.net_amount if payment else Decimal("0")
|
net_payment = payment.net_amount if payment else Decimal("0")
|
||||||
period_net = net_payment - month.infrastructure_cost
|
period_net = net_payment - month.infrastructure_cost
|
||||||
lines.append(
|
lines.append(
|
||||||
@@ -45,14 +46,15 @@ def render_dashboard(
|
|||||||
snapshot: EconomicsSnapshot,
|
snapshot: EconomicsSnapshot,
|
||||||
liquidity: LiquiditySummary,
|
liquidity: LiquiditySummary,
|
||||||
monthly_costs: list[MonthlyPlatformCost],
|
monthly_costs: list[MonthlyPlatformCost],
|
||||||
revenue_entries: list[RevenueEntry],
|
payments: list[PaymentRecord],
|
||||||
|
expense_count: int,
|
||||||
) -> str:
|
) -> str:
|
||||||
active = next(m for m in models if m.id == product.active_pricing_model_id)
|
active = next(m for m in models if m.id == product.active_pricing_model_id)
|
||||||
registry_lines = "\n".join(
|
registry_lines = "\n".join(
|
||||||
f"| {model.id} | {model.name} | {model.model_type} | {model.status} |"
|
f"| {model.id} | {model.name} | {model.model_type} | {model.status} |"
|
||||||
for model in models
|
for model in models
|
||||||
)
|
)
|
||||||
history_lines = _history_rows(monthly_costs, revenue_entries, snapshot.period)
|
history_lines = _history_rows(monthly_costs, payments, snapshot.period)
|
||||||
budget_state = (
|
budget_state = (
|
||||||
"over budget"
|
"over budget"
|
||||||
if liquidity.remaining_budget < Decimal("0")
|
if liquidity.remaining_budget < Decimal("0")
|
||||||
@@ -66,7 +68,9 @@ def render_dashboard(
|
|||||||
**Active pricing model:** {active.name} ({active.access_fee_amount} {active.currency}/{active.access_fee_cadence})
|
**Active pricing model:** {active.name} ({active.access_fee_amount} {active.currency}/{active.access_fee_cadence})
|
||||||
|
|
||||||
> Platform costs accrue to the operator. Customer cost-pass-through billing is
|
> Platform costs accrue to the operator. Customer cost-pass-through billing is
|
||||||
> **not active** in MVP — members pay subscription only.
|
> **not active** in MVP — members pay subscription only. All totals are computed
|
||||||
|
> programmatically from expense and payment record ledgers ({expense_count} expense
|
||||||
|
> records).
|
||||||
|
|
||||||
## Key Metrics (current period)
|
## Key Metrics (current period)
|
||||||
|
|
||||||
@@ -114,9 +118,8 @@ _Revenue source: {snapshot.revenue_source}_
|
|||||||
|
|
||||||
- Product model (`data/product.json`)
|
- Product model (`data/product.json`)
|
||||||
- Budget (`data/budget.json`)
|
- Budget (`data/budget.json`)
|
||||||
- Pricing model registry (`data/pricing-models.json`)
|
- Expense records (`data/expense_records.json`) — source of truth for costs
|
||||||
- Platform costs (`data/costs.json`)
|
- Payment records (`data/payment_records.json`)
|
||||||
- Member payments (`data/revenue.json`)
|
|
||||||
- Membership (`data/membership.json`)
|
- Membership (`data/membership.json`)
|
||||||
- Requirements (`REQUIREMENTS.md`)
|
- Requirements (`REQUIREMENTS.md`)
|
||||||
"""
|
"""
|
||||||
@@ -128,13 +131,16 @@ def generate_dashboard(data_dir: Path | None = None, period: str | None = None)
|
|||||||
budget = load_budget(root)
|
budget = load_budget(root)
|
||||||
models = load_pricing_models(root)
|
models = load_pricing_models(root)
|
||||||
members = load_membership(root)
|
members = load_membership(root)
|
||||||
revenue = load_revenue(root)
|
payments = load_payment_records(root)
|
||||||
monthly_costs = load_monthly_platform_costs(root)
|
expenses = load_expense_records(root)
|
||||||
|
monthly_costs = load_monthly_ledger(root)
|
||||||
target_period = period or latest_period(monthly_costs)
|
target_period = period or latest_period(monthly_costs)
|
||||||
|
|
||||||
snapshot = build_snapshot(target_period, product, models, members, revenue, monthly_costs)
|
snapshot = build_snapshot(target_period, product, models, members, payments, monthly_costs)
|
||||||
liquidity = build_liquidity_summary(budget, revenue, monthly_costs, target_period)
|
liquidity = build_liquidity_summary(budget, payments, monthly_costs, target_period)
|
||||||
return render_dashboard(product, models, snapshot, liquidity, monthly_costs, revenue)
|
return render_dashboard(
|
||||||
|
product, models, snapshot, liquidity, monthly_costs, payments, len(expenses)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
|||||||
@@ -4,15 +4,14 @@ from decimal import Decimal, ROUND_HALF_UP
|
|||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
Budget,
|
Budget,
|
||||||
CostEntry,
|
|
||||||
EconomicsSnapshot,
|
EconomicsSnapshot,
|
||||||
LiquidityStatus,
|
LiquidityStatus,
|
||||||
LiquiditySummary,
|
LiquiditySummary,
|
||||||
MembershipRecord,
|
MembershipRecord,
|
||||||
MonthlyPlatformCost,
|
MonthlyPlatformCost,
|
||||||
|
PaymentRecord,
|
||||||
PricingModel,
|
PricingModel,
|
||||||
Product,
|
Product,
|
||||||
RevenueEntry,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
TWOPLACES = Decimal("0.01")
|
TWOPLACES = Decimal("0.01")
|
||||||
@@ -23,16 +22,6 @@ def _quantize(value: Decimal, exp: Decimal = TWOPLACES) -> Decimal:
|
|||||||
return value.quantize(exp, rounding=ROUND_HALF_UP)
|
return value.quantize(exp, rounding=ROUND_HALF_UP)
|
||||||
|
|
||||||
|
|
||||||
def _convert_to_eur(amount: Decimal, currency: str, fx_rates: dict[str, Decimal]) -> Decimal:
|
|
||||||
if currency == "EUR":
|
|
||||||
return amount
|
|
||||||
if currency == "USD":
|
|
||||||
return amount * fx_rates.get("USD/EUR", Decimal("1"))
|
|
||||||
if currency == "ratio":
|
|
||||||
return amount
|
|
||||||
raise ValueError(f"unsupported currency for conversion: {currency}")
|
|
||||||
|
|
||||||
|
|
||||||
def liquidity_status_for(net: Decimal) -> LiquidityStatus:
|
def liquidity_status_for(net: Decimal) -> LiquidityStatus:
|
||||||
if net < Decimal("0"):
|
if net < Decimal("0"):
|
||||||
return "burning"
|
return "burning"
|
||||||
@@ -52,27 +41,10 @@ def active_pricing_model(models: list[PricingModel], product: Product) -> Pricin
|
|||||||
raise ValueError(f"active pricing model not found: {product.active_pricing_model_id}")
|
raise ValueError(f"active pricing model not found: {product.active_pricing_model_id}")
|
||||||
|
|
||||||
|
|
||||||
def monthly_cost_from_rate_card(
|
def payment_for_period(period: str, payments: list[PaymentRecord]) -> PaymentRecord | None:
|
||||||
cost_entries: list[CostEntry],
|
for record in payments:
|
||||||
fx_rates: dict[str, Decimal],
|
if record.period == period:
|
||||||
gross_revenue: Decimal,
|
return record
|
||||||
member_count: int,
|
|
||||||
) -> Decimal:
|
|
||||||
total = Decimal("0")
|
|
||||||
for entry in cost_entries:
|
|
||||||
if entry.allocation == "flat":
|
|
||||||
total += _convert_to_eur(entry.amount, entry.currency, fx_rates)
|
|
||||||
elif entry.allocation == "percent_of_gross_revenue":
|
|
||||||
total += gross_revenue * entry.amount
|
|
||||||
elif entry.allocation == "per_active_member":
|
|
||||||
total += _convert_to_eur(entry.amount, entry.currency, fx_rates) * member_count
|
|
||||||
return _quantize(total)
|
|
||||||
|
|
||||||
|
|
||||||
def revenue_for_period(period: str, revenue_entries: list[RevenueEntry]) -> RevenueEntry | None:
|
|
||||||
for entry in revenue_entries:
|
|
||||||
if entry.period == period:
|
|
||||||
return entry
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -81,16 +53,16 @@ def estimate_monthly_revenue(
|
|||||||
product: Product,
|
product: Product,
|
||||||
models: list[PricingModel],
|
models: list[PricingModel],
|
||||||
members: list[MembershipRecord],
|
members: list[MembershipRecord],
|
||||||
revenue_entries: list[RevenueEntry],
|
payments: list[PaymentRecord],
|
||||||
monthly_costs: list[MonthlyPlatformCost],
|
monthly_costs: list[MonthlyPlatformCost],
|
||||||
) -> tuple[Decimal, Decimal, str]:
|
) -> tuple[Decimal, Decimal, str]:
|
||||||
recorded = revenue_for_period(period, revenue_entries)
|
recorded = payment_for_period(period, payments)
|
||||||
if recorded:
|
if recorded:
|
||||||
return recorded.gross_amount, recorded.net_amount, recorded.source
|
return recorded.gross_amount, recorded.net_amount, recorded.source
|
||||||
|
|
||||||
month = next((item for item in monthly_costs if item.period == period), None)
|
month = next((item for item in monthly_costs if item.period == period), None)
|
||||||
if month and month.gross_revenue > 0:
|
if month and month.gross_revenue > 0:
|
||||||
return month.gross_revenue, month.gross_revenue, "derived_from_platform_history"
|
return month.gross_revenue, month.gross_revenue, "derived_from_ledger"
|
||||||
|
|
||||||
model = active_pricing_model(models, product)
|
model = active_pricing_model(models, product)
|
||||||
count = active_members(members)
|
count = active_members(members)
|
||||||
@@ -104,7 +76,7 @@ def periods_through(target: str, monthly_costs: list[MonthlyPlatformCost]) -> li
|
|||||||
|
|
||||||
def build_liquidity_summary(
|
def build_liquidity_summary(
|
||||||
budget: Budget,
|
budget: Budget,
|
||||||
revenue_entries: list[RevenueEntry],
|
payments: list[PaymentRecord],
|
||||||
monthly_costs: list[MonthlyPlatformCost],
|
monthly_costs: list[MonthlyPlatformCost],
|
||||||
through_period: str,
|
through_period: str,
|
||||||
) -> LiquiditySummary:
|
) -> LiquiditySummary:
|
||||||
@@ -118,11 +90,9 @@ def build_liquidity_summary(
|
|||||||
month = cost_by_period[period]
|
month = cost_by_period[period]
|
||||||
cumulative_infrastructure += month.infrastructure_cost
|
cumulative_infrastructure += month.infrastructure_cost
|
||||||
cumulative_processing += month.payment_processing_cost
|
cumulative_processing += month.payment_processing_cost
|
||||||
payment = revenue_for_period(period, revenue_entries)
|
payment = payment_for_period(period, payments)
|
||||||
cumulative_payments += payment.net_amount if payment else Decimal("0")
|
cumulative_payments += payment.net_amount if payment else Decimal("0")
|
||||||
|
|
||||||
# Liquidity uses net member payments vs infrastructure cash out only.
|
|
||||||
# Payment-processing fees are already deducted from net payments.
|
|
||||||
cumulative_net = _quantize(cumulative_payments - cumulative_infrastructure)
|
cumulative_net = _quantize(cumulative_payments - cumulative_infrastructure)
|
||||||
remaining = _quantize(budget.initial_budget + cumulative_net)
|
remaining = _quantize(budget.initial_budget + cumulative_net)
|
||||||
cumulative_total = _quantize(cumulative_infrastructure + cumulative_processing)
|
cumulative_total = _quantize(cumulative_infrastructure + cumulative_processing)
|
||||||
@@ -147,13 +117,13 @@ def build_snapshot(
|
|||||||
product: Product,
|
product: Product,
|
||||||
models: list[PricingModel],
|
models: list[PricingModel],
|
||||||
members: list[MembershipRecord],
|
members: list[MembershipRecord],
|
||||||
revenue_entries: list[RevenueEntry],
|
payments: list[PaymentRecord],
|
||||||
monthly_costs: list[MonthlyPlatformCost],
|
monthly_costs: list[MonthlyPlatformCost],
|
||||||
) -> EconomicsSnapshot:
|
) -> EconomicsSnapshot:
|
||||||
month = next(item for item in monthly_costs if item.period == period)
|
month = next(item for item in monthly_costs if item.period == period)
|
||||||
count = month.active_members if month.active_members else active_members(members)
|
count = month.active_members if month.active_members else active_members(members)
|
||||||
gross_revenue, net_revenue, revenue_source = estimate_monthly_revenue(
|
gross_revenue, net_revenue, revenue_source = estimate_monthly_revenue(
|
||||||
period, product, models, members, revenue_entries, monthly_costs
|
period, product, models, members, payments, monthly_costs
|
||||||
)
|
)
|
||||||
infrastructure = month.infrastructure_cost
|
infrastructure = month.infrastructure_cost
|
||||||
processing = month.payment_processing_cost
|
processing = month.payment_processing_cost
|
||||||
|
|||||||
121
projects/coulomb-pricing/observatory/ledger.py
Normal file
121
projects/coulomb-pricing/observatory/ledger.py
Normal 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
|
||||||
@@ -4,16 +4,15 @@ import json
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
_ZERO = Decimal("0")
|
from .ledger import build_monthly_ledger
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
Budget,
|
Budget,
|
||||||
CostEntry,
|
ExpenseRecord,
|
||||||
MembershipRecord,
|
MembershipRecord,
|
||||||
MonthlyPlatformCost,
|
MonthlyPlatformCost,
|
||||||
|
PaymentRecord,
|
||||||
PricingModel,
|
PricingModel,
|
||||||
Product,
|
Product,
|
||||||
RevenueEntry,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -67,55 +66,38 @@ def load_pricing_models(data_dir: Path | None = None) -> list[PricingModel]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _parse_cost_entries(items: list[dict]) -> list[CostEntry]:
|
def load_fx_rates(data_dir: Path | None = None) -> dict[str, Decimal]:
|
||||||
|
raw = _read_json((data_dir or default_data_dir()) / "expense_records.json")
|
||||||
|
return {pair: _money(rate) for pair, rate in raw.get("fx_rates", {}).items()}
|
||||||
|
|
||||||
|
|
||||||
|
def load_expense_records(data_dir: Path | None = None) -> list[ExpenseRecord]:
|
||||||
|
raw = _read_json((data_dir or default_data_dir()) / "expense_records.json")
|
||||||
return [
|
return [
|
||||||
CostEntry(
|
ExpenseRecord(
|
||||||
id=item["id"],
|
id=item["id"],
|
||||||
name=item["name"],
|
period=item["period"],
|
||||||
category=item["category"],
|
vendor=item["vendor"],
|
||||||
|
description=item["description"],
|
||||||
|
cost_class=item["cost_class"],
|
||||||
amount=_money(item["amount"]),
|
amount=_money(item["amount"]),
|
||||||
currency=item["currency"],
|
currency=item["currency"],
|
||||||
cadence=item["cadence"],
|
source=item["source"],
|
||||||
allocation=item["allocation"],
|
|
||||||
)
|
)
|
||||||
for item in items
|
for item in raw["records"]
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def load_cost_rate_card(data_dir: Path | None = None) -> tuple[list[CostEntry], dict[str, Decimal]]:
|
def load_payment_records(data_dir: Path | None = None) -> list[PaymentRecord]:
|
||||||
raw = _read_json((data_dir or default_data_dir()) / "costs.json")
|
root = data_dir or default_data_dir()
|
||||||
items = raw.get("rate_card", raw.get("entries", []))
|
path = root / "payment_records.json"
|
||||||
fx = {pair: _money(rate) for pair, rate in raw.get("fx_rates", {}).items()}
|
if not path.exists():
|
||||||
return _parse_cost_entries(items), fx
|
# Backward compatibility with legacy revenue.json
|
||||||
|
path = root / "revenue.json"
|
||||||
|
raw = _read_json(path)
|
||||||
def load_monthly_platform_costs(data_dir: Path | None = None) -> list[MonthlyPlatformCost]:
|
items = raw.get("records", raw.get("entries", []))
|
||||||
raw = _read_json((data_dir or default_data_dir()) / "costs.json")
|
|
||||||
rows: list[MonthlyPlatformCost] = []
|
|
||||||
for item in raw.get("monthly_history", []):
|
|
||||||
if "infrastructure_cost" in item:
|
|
||||||
infrastructure = _money(item["infrastructure_cost"])
|
|
||||||
processing = _money(item.get("payment_processing_cost", "0"))
|
|
||||||
else:
|
|
||||||
# Legacy single platform_cost field treated as infrastructure only.
|
|
||||||
infrastructure = _money(item["platform_cost"])
|
|
||||||
processing = _ZERO
|
|
||||||
rows.append(
|
|
||||||
MonthlyPlatformCost(
|
|
||||||
period=item["period"],
|
|
||||||
infrastructure_cost=infrastructure,
|
|
||||||
payment_processing_cost=processing,
|
|
||||||
active_members=item["active_members"],
|
|
||||||
gross_revenue=_money(item["gross_revenue"]),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
def load_revenue(data_dir: Path | None = None) -> list[RevenueEntry]:
|
|
||||||
raw = _read_json((data_dir or default_data_dir()) / "revenue.json")
|
|
||||||
return [
|
return [
|
||||||
RevenueEntry(
|
PaymentRecord(
|
||||||
id=item["id"],
|
id=item["id"],
|
||||||
period=item["period"],
|
period=item["period"],
|
||||||
gross_amount=_money(item["gross_amount"]),
|
gross_amount=_money(item["gross_amount"]),
|
||||||
@@ -124,8 +106,9 @@ def load_revenue(data_dir: Path | None = None) -> list[RevenueEntry]:
|
|||||||
net_amount=_money(item["net_amount"]),
|
net_amount=_money(item["net_amount"]),
|
||||||
currency=item["currency"],
|
currency=item["currency"],
|
||||||
source=item["source"],
|
source=item["source"],
|
||||||
|
member_count=item.get("member_count", 0),
|
||||||
)
|
)
|
||||||
for item in raw["entries"]
|
for item in items
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -143,5 +126,16 @@ def load_membership(data_dir: Path | None = None) -> list[MembershipRecord]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def load_monthly_ledger(data_dir: Path | None = None) -> list[MonthlyPlatformCost]:
|
||||||
|
root = data_dir or default_data_dir()
|
||||||
|
return build_monthly_ledger(
|
||||||
|
load_budget(root),
|
||||||
|
load_expense_records(root),
|
||||||
|
load_payment_records(root),
|
||||||
|
load_membership(root),
|
||||||
|
load_fx_rates(root),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def latest_period(monthly_costs: list[MonthlyPlatformCost]) -> str:
|
def latest_period(monthly_costs: list[MonthlyPlatformCost]) -> str:
|
||||||
return max(item.period for item in monthly_costs)
|
return max(item.period for item in monthly_costs)
|
||||||
@@ -4,7 +4,7 @@ from dataclasses import dataclass
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
CostCategory = Literal["fixed", "variable"]
|
ExpenseClass = Literal["infrastructure", "payment_processing"]
|
||||||
MemberStatus = Literal["active", "churned", "paused"]
|
MemberStatus = Literal["active", "churned", "paused"]
|
||||||
PricingModelStatus = Literal["active", "candidate", "retired"]
|
PricingModelStatus = Literal["active", "candidate", "retired"]
|
||||||
LiquidityStatus = Literal["burning", "neutral", "generating"]
|
LiquidityStatus = Literal["burning", "neutral", "generating"]
|
||||||
@@ -33,14 +33,28 @@ class PricingModel:
|
|||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class CostEntry:
|
class ExpenseRecord:
|
||||||
id: str
|
id: str
|
||||||
name: str
|
period: str
|
||||||
category: CostCategory
|
vendor: str
|
||||||
|
description: str
|
||||||
|
cost_class: ExpenseClass
|
||||||
amount: Decimal
|
amount: Decimal
|
||||||
currency: str
|
currency: str
|
||||||
cadence: str
|
source: str
|
||||||
allocation: str
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PaymentRecord:
|
||||||
|
id: str
|
||||||
|
period: str
|
||||||
|
gross_amount: Decimal
|
||||||
|
fees_amount: Decimal
|
||||||
|
refunds_amount: Decimal
|
||||||
|
net_amount: Decimal
|
||||||
|
currency: str
|
||||||
|
source: str
|
||||||
|
member_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -63,18 +77,6 @@ class Budget:
|
|||||||
started: str
|
started: str
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class RevenueEntry:
|
|
||||||
id: str
|
|
||||||
period: str
|
|
||||||
gross_amount: Decimal
|
|
||||||
fees_amount: Decimal
|
|
||||||
refunds_amount: Decimal
|
|
||||||
net_amount: Decimal
|
|
||||||
currency: str
|
|
||||||
source: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class MembershipRecord:
|
class MembershipRecord:
|
||||||
id: str
|
id: str
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
**Active pricing model:** Standard Membership (8.99 EUR/monthly)
|
**Active pricing model:** Standard Membership (8.99 EUR/monthly)
|
||||||
|
|
||||||
> Platform costs accrue to the operator. Customer cost-pass-through billing is
|
> Platform costs accrue to the operator. Customer cost-pass-through billing is
|
||||||
> **not active** in MVP — members pay subscription only.
|
> **not active** in MVP — members pay subscription only. All totals are computed
|
||||||
|
> programmatically from expense and payment record ledgers (48 expense
|
||||||
|
> records).
|
||||||
|
|
||||||
## Key Metrics (current period)
|
## Key Metrics (current period)
|
||||||
|
|
||||||
@@ -13,13 +15,13 @@
|
|||||||
|--------|------:|
|
|--------|------:|
|
||||||
| Active members | 1 |
|
| Active members | 1 |
|
||||||
| Member payments (gross) | 8.99 EUR |
|
| Member payments (gross) | 8.99 EUR |
|
||||||
| Infrastructure cost | 72.20 EUR |
|
| Infrastructure cost | 69.44 EUR |
|
||||||
| Payment processing cost | 0.38 EUR |
|
| Payment processing cost | 0.38 EUR |
|
||||||
| Total platform cost | 72.58 EUR |
|
| Total platform cost | 69.82 EUR |
|
||||||
| Platform cost per member | 72.58 EUR |
|
| Platform cost per member | 69.82 EUR |
|
||||||
| Period gross margin | -63.59 EUR |
|
| Period gross margin | -60.83 EUR |
|
||||||
| Period gross margin % | -707.3% |
|
| Period gross margin % | -676.6% |
|
||||||
| Period net liquidity | -63.59 EUR (burning) |
|
| Period net liquidity | -60.83 EUR (burning) |
|
||||||
|
|
||||||
_Period net liquidity = net member payments − infrastructure cost (processing fees already netted from payments)._
|
_Period net liquidity = net member payments − infrastructure cost (processing fees already netted from payments)._
|
||||||
_Revenue source: manual_
|
_Revenue source: manual_
|
||||||
@@ -30,33 +32,33 @@ _Revenue source: manual_
|
|||||||
|--------|------:|
|
|--------|------:|
|
||||||
| Initial budget | 1000.00 EUR |
|
| Initial budget | 1000.00 EUR |
|
||||||
| Cumulative member payments (net) | 68.88 EUR |
|
| Cumulative member payments (net) | 68.88 EUR |
|
||||||
| Cumulative infrastructure cost | 1155.20 EUR |
|
| Cumulative infrastructure cost | 1111.04 EUR |
|
||||||
| Cumulative payment processing | 3.04 EUR |
|
| Cumulative payment processing | 3.04 EUR |
|
||||||
| Cumulative total platform cost | 1158.24 EUR |
|
| Cumulative total platform cost | 1114.08 EUR |
|
||||||
| Cumulative net liquidity | -1086.32 EUR (burning) |
|
| Cumulative net liquidity | -1042.16 EUR (burning) |
|
||||||
| Remaining budget | -86.32 EUR (over budget) |
|
| Remaining budget | -42.16 EUR (over budget) |
|
||||||
| Months tracked | 16 |
|
| Months tracked | 16 |
|
||||||
|
|
||||||
## Monthly History
|
## Monthly History
|
||||||
|
|
||||||
| Period | Members | Gross revenue | Infrastructure | Processing | Total platform | Net liquidity |
|
| Period | Members | Gross revenue | Infrastructure | Processing | Total platform | Net liquidity |
|
||||||
|--------|--------:|--------------:|---------------:|-----------:|---------------:|--------------:|
|
|--------|--------:|--------------:|---------------:|-----------:|---------------:|--------------:|
|
||||||
| 2025-03 | 0 | 0.00 | 72.20 | 0.00 | 72.20 | -72.20 |
|
| 2025-03 | 0 | 0.00 | 69.44 | 0.00 | 69.44 | -69.44 |
|
||||||
| 2025-04 | 0 | 0.00 | 72.20 | 0.00 | 72.20 | -72.20 |
|
| 2025-04 | 0 | 0.00 | 69.44 | 0.00 | 69.44 | -69.44 |
|
||||||
| 2025-05 | 0 | 0.00 | 72.20 | 0.00 | 72.20 | -72.20 |
|
| 2025-05 | 0 | 0.00 | 69.44 | 0.00 | 69.44 | -69.44 |
|
||||||
| 2025-06 | 0 | 0.00 | 72.20 | 0.00 | 72.20 | -72.20 |
|
| 2025-06 | 0 | 0.00 | 69.44 | 0.00 | 69.44 | -69.44 |
|
||||||
| 2025-07 | 0 | 0.00 | 72.20 | 0.00 | 72.20 | -72.20 |
|
| 2025-07 | 0 | 0.00 | 69.44 | 0.00 | 69.44 | -69.44 |
|
||||||
| 2025-08 | 0 | 0.00 | 72.20 | 0.00 | 72.20 | -72.20 |
|
| 2025-08 | 0 | 0.00 | 69.44 | 0.00 | 69.44 | -69.44 |
|
||||||
| 2025-09 | 0 | 0.00 | 72.20 | 0.00 | 72.20 | -72.20 |
|
| 2025-09 | 0 | 0.00 | 69.44 | 0.00 | 69.44 | -69.44 |
|
||||||
| 2025-10 | 0 | 0.00 | 72.20 | 0.00 | 72.20 | -72.20 |
|
| 2025-10 | 0 | 0.00 | 69.44 | 0.00 | 69.44 | -69.44 |
|
||||||
| 2025-11 | 1 | 8.99 | 72.20 | 0.38 | 72.58 | -63.59 |
|
| 2025-11 | 1 | 8.99 | 69.44 | 0.38 | 69.82 | -60.83 |
|
||||||
| 2025-12 | 1 | 8.99 | 72.20 | 0.38 | 72.58 | -63.59 |
|
| 2025-12 | 1 | 8.99 | 69.44 | 0.38 | 69.82 | -60.83 |
|
||||||
| 2026-01 | 1 | 8.99 | 72.20 | 0.38 | 72.58 | -63.59 |
|
| 2026-01 | 1 | 8.99 | 69.44 | 0.38 | 69.82 | -60.83 |
|
||||||
| 2026-02 | 1 | 8.99 | 72.20 | 0.38 | 72.58 | -63.59 |
|
| 2026-02 | 1 | 8.99 | 69.44 | 0.38 | 69.82 | -60.83 |
|
||||||
| 2026-03 | 1 | 8.99 | 72.20 | 0.38 | 72.58 | -63.59 |
|
| 2026-03 | 1 | 8.99 | 69.44 | 0.38 | 69.82 | -60.83 |
|
||||||
| 2026-04 | 1 | 8.99 | 72.20 | 0.38 | 72.58 | -63.59 |
|
| 2026-04 | 1 | 8.99 | 69.44 | 0.38 | 69.82 | -60.83 |
|
||||||
| 2026-05 | 1 | 8.99 | 72.20 | 0.38 | 72.58 | -63.59 |
|
| 2026-05 | 1 | 8.99 | 69.44 | 0.38 | 69.82 | -60.83 |
|
||||||
| 2026-06 | 1 | 8.99 | 72.20 | 0.38 | 72.58 | -63.59 |
|
| 2026-06 | 1 | 8.99 | 69.44 | 0.38 | 69.82 | -60.83 |
|
||||||
|
|
||||||
## Pricing Model Registry
|
## Pricing Model Registry
|
||||||
|
|
||||||
@@ -70,8 +72,7 @@ _Revenue source: manual_
|
|||||||
|
|
||||||
- Product model (`data/product.json`)
|
- Product model (`data/product.json`)
|
||||||
- Budget (`data/budget.json`)
|
- Budget (`data/budget.json`)
|
||||||
- Pricing model registry (`data/pricing-models.json`)
|
- Expense records (`data/expense_records.json`) — source of truth for costs
|
||||||
- Platform costs (`data/costs.json`)
|
- Payment records (`data/payment_records.json`)
|
||||||
- Member payments (`data/revenue.json`)
|
|
||||||
- Membership (`data/membership.json`)
|
- Membership (`data/membership.json`)
|
||||||
- Requirements (`REQUIREMENTS.md`)
|
- Requirements (`REQUIREMENTS.md`)
|
||||||
|
|||||||
@@ -3,20 +3,17 @@ from __future__ import annotations
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from observatory.economics import (
|
from observatory.economics import active_members, build_liquidity_summary, build_snapshot
|
||||||
active_members,
|
from observatory.ledger import aggregate_infrastructure_by_period, build_monthly_ledger
|
||||||
build_liquidity_summary,
|
|
||||||
build_snapshot,
|
|
||||||
monthly_cost_from_rate_card,
|
|
||||||
)
|
|
||||||
from observatory.load import (
|
from observatory.load import (
|
||||||
load_budget,
|
load_budget,
|
||||||
load_cost_rate_card,
|
load_expense_records,
|
||||||
|
load_fx_rates,
|
||||||
load_membership,
|
load_membership,
|
||||||
load_monthly_platform_costs,
|
load_monthly_ledger,
|
||||||
|
load_payment_records,
|
||||||
load_pricing_models,
|
load_pricing_models,
|
||||||
load_product,
|
load_product,
|
||||||
load_revenue,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
|
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
|
||||||
@@ -27,52 +24,80 @@ def test_active_members_counts_only_active_status() -> None:
|
|||||||
assert active_members(members) == 1
|
assert active_members(members) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_rate_card_splits_infrastructure_and_payment_processing() -> None:
|
def test_infrastructure_aggregated_from_expense_records() -> None:
|
||||||
rate_card, fx_rates = load_cost_rate_card(DATA_DIR)
|
expenses = load_expense_records(DATA_DIR)
|
||||||
infrastructure = monthly_cost_from_rate_card(rate_card, fx_rates, Decimal("0"), 0)
|
fx = load_fx_rates(DATA_DIR)
|
||||||
with_revenue = monthly_cost_from_rate_card(rate_card, fx_rates, Decimal("8.99"), 1)
|
totals = aggregate_infrastructure_by_period(expenses, fx)
|
||||||
assert infrastructure == Decimal("72.20")
|
|
||||||
assert with_revenue == Decimal("72.58")
|
assert totals["2025-03"] == Decimal("69.44")
|
||||||
assert with_revenue - infrastructure == Decimal("0.38")
|
assert totals["2026-06"] == Decimal("69.44")
|
||||||
|
# Bubble $32 @ 0.92 = 29.44 + 15 domains + 25 overhead
|
||||||
|
bubble_eur = Decimal("32.00") * Decimal("0.92")
|
||||||
|
assert bubble_eur == Decimal("29.44")
|
||||||
|
|
||||||
|
|
||||||
def test_build_snapshot_june_2026_avoids_stripe_double_count_in_liquidity() -> None:
|
def test_monthly_ledger_computes_processing_from_payment_records() -> None:
|
||||||
|
ledger = load_monthly_ledger(DATA_DIR)
|
||||||
|
june = next(row for row in ledger if row.period == "2026-06")
|
||||||
|
march = next(row for row in ledger if row.period == "2025-03")
|
||||||
|
|
||||||
|
assert march.infrastructure_cost == Decimal("69.44")
|
||||||
|
assert march.payment_processing_cost == Decimal("0.00")
|
||||||
|
assert march.gross_revenue == Decimal("0.00")
|
||||||
|
assert june.infrastructure_cost == Decimal("69.44")
|
||||||
|
assert june.payment_processing_cost == Decimal("0.38")
|
||||||
|
assert june.gross_revenue == Decimal("8.99")
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_snapshot_june_2026_uses_ledger_not_hand_totals() -> None:
|
||||||
product = load_product(DATA_DIR)
|
product = load_product(DATA_DIR)
|
||||||
models = load_pricing_models(DATA_DIR)
|
models = load_pricing_models(DATA_DIR)
|
||||||
members = load_membership(DATA_DIR)
|
members = load_membership(DATA_DIR)
|
||||||
revenue = load_revenue(DATA_DIR)
|
payments = load_payment_records(DATA_DIR)
|
||||||
monthly_costs = load_monthly_platform_costs(DATA_DIR)
|
ledger = load_monthly_ledger(DATA_DIR)
|
||||||
|
|
||||||
snapshot = build_snapshot("2026-06", product, models, members, revenue, monthly_costs)
|
snapshot = build_snapshot("2026-06", product, models, members, payments, ledger)
|
||||||
|
|
||||||
assert snapshot.monthly_infrastructure_cost == Decimal("72.20")
|
assert snapshot.monthly_infrastructure_cost == Decimal("69.44")
|
||||||
assert snapshot.monthly_payment_processing_cost == Decimal("0.38")
|
assert snapshot.monthly_payment_processing_cost == Decimal("0.38")
|
||||||
assert snapshot.monthly_total_platform_cost == Decimal("72.58")
|
assert snapshot.monthly_total_platform_cost == Decimal("69.82")
|
||||||
assert snapshot.period_net_liquidity == Decimal("-63.59")
|
assert snapshot.period_net_liquidity == Decimal("-60.83")
|
||||||
assert snapshot.gross_margin == Decimal("-63.59")
|
assert snapshot.gross_margin == Decimal("-60.83")
|
||||||
|
|
||||||
|
|
||||||
def test_liquidity_summary_uses_infrastructure_only_for_cumulative_burn() -> None:
|
def test_liquidity_summary_aggregates_ledgers_deterministically() -> None:
|
||||||
budget = load_budget(DATA_DIR)
|
budget = load_budget(DATA_DIR)
|
||||||
revenue = load_revenue(DATA_DIR)
|
payments = load_payment_records(DATA_DIR)
|
||||||
monthly_costs = load_monthly_platform_costs(DATA_DIR)
|
ledger = load_monthly_ledger(DATA_DIR)
|
||||||
|
|
||||||
summary = build_liquidity_summary(budget, revenue, monthly_costs, "2026-06")
|
summary = build_liquidity_summary(budget, payments, ledger, "2026-06")
|
||||||
|
|
||||||
assert summary.cumulative_infrastructure_cost == Decimal("1155.20")
|
assert summary.cumulative_infrastructure_cost == Decimal("1111.04")
|
||||||
assert summary.cumulative_payment_processing_cost == Decimal("3.04")
|
assert summary.cumulative_payment_processing_cost == Decimal("3.04")
|
||||||
assert summary.cumulative_total_platform_cost == Decimal("1158.24")
|
assert summary.cumulative_total_platform_cost == Decimal("1114.08")
|
||||||
assert summary.cumulative_member_payments == Decimal("68.88")
|
assert summary.cumulative_member_payments == Decimal("68.88")
|
||||||
assert summary.cumulative_net_liquidity == Decimal("-1086.32")
|
assert summary.cumulative_net_liquidity == Decimal("-1042.16")
|
||||||
assert summary.remaining_budget == Decimal("-86.32")
|
assert summary.remaining_budget == Decimal("-42.16")
|
||||||
assert summary.liquidity_status == "burning"
|
assert summary.liquidity_status == "burning"
|
||||||
assert summary.months_tracked == 16
|
assert summary.months_tracked == 16
|
||||||
|
|
||||||
|
|
||||||
def test_dashboard_renders_split_cost_columns() -> None:
|
def test_build_monthly_ledger_matches_loader() -> None:
|
||||||
|
budget = load_budget(DATA_DIR)
|
||||||
|
expenses = load_expense_records(DATA_DIR)
|
||||||
|
payments = load_payment_records(DATA_DIR)
|
||||||
|
members = load_membership(DATA_DIR)
|
||||||
|
fx = load_fx_rates(DATA_DIR)
|
||||||
|
|
||||||
|
assert build_monthly_ledger(budget, expenses, payments, members, fx) == load_monthly_ledger(
|
||||||
|
DATA_DIR
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_notes_expense_record_source() -> None:
|
||||||
from observatory.dashboard import generate_dashboard
|
from observatory.dashboard import generate_dashboard
|
||||||
|
|
||||||
report = generate_dashboard(DATA_DIR, "2026-06")
|
report = generate_dashboard(DATA_DIR, "2026-06")
|
||||||
assert "Cumulative infrastructure cost" in report
|
assert "expense and payment record ledgers" in report
|
||||||
assert "1155.20" in report
|
assert "1111.04" in report
|
||||||
assert "-86.32" in report
|
assert "-42.16" in report
|
||||||
Reference in New Issue
Block a user