Add liquidity tracking, budget, and platform cost history

Restore operator platform costs (Bubble, domains, Stripe) with monthly
history from March 2025 and member payments from November 2025. Track
€1,000 starting budget, cumulative burn, and remaining liquidity in the
economics dashboard. Document LQ requirements in REQUIREMENTS.md.
This commit is contained in:
2026-06-22 01:48:45 +02:00
parent 8f42220d81
commit fe2174f37a
12 changed files with 497 additions and 112 deletions

View File

@@ -6,19 +6,26 @@ Generic adaptive-pricing framework concepts belong in the repository root
(`INTENT.md`, `docs/`, `research/`, `registry/`). Execution tracking: (`INTENT.md`, `docs/`, `research/`, `registry/`). Execution tracking:
`workplans/ADAPTIVE-WP-0002-economic-observatory-mvp.md`. `workplans/ADAPTIVE-WP-0002-economic-observatory-mvp.md`.
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 loads JSON registries and produces **Economics
Dashboard v1** metrics: Dashboard v1** with platform-cost history, member payments, and budget burn.
| Registry | File | | Registry | File |
|----------|------| |----------|------|
| Budget (€1,000 start) | `data/budget.json` |
| Product model | `data/product.json` | | Product model | `data/product.json` |
| Pricing models | `data/pricing-models.json` | | Pricing models | `data/pricing-models.json` |
| Costs | `data/costs.json` | | Platform costs + history | `data/costs.json` |
| Revenue | `data/revenue.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
November 2025. Operator is **burning liquidity** — platform spend exceeds
subscription intake. Customer cost-pass-through billing is not active.
### Commands ### Commands
```bash ```bash
@@ -28,6 +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
``` ```
**Current registry state:** one active member (founder), €8.99/month revenue, no Manual registries will be replaced by Bubble (Sprint 2), Stripe (Sprint 3), and
running costs recorded. Bubble (Sprint 2) and Stripe (Sprint 3) importers will OpenRouter (Sprint 4) importers.
replace manual entries when integrations land.

View File

@@ -0,0 +1,66 @@
# Coulomb MVP — Liquidity & Cost Requirements
## Context
Coulomb Social carries **operator platform costs** (Bubble.io, domains, Stripe
fees, future OpenRouter usage) while **customer cost-pass-through billing is not
active yet**. Members pay a flat subscription; they are not billed for
underlying platform spend.
The Economic Observatory must make the resulting **liquidity burn** visible.
## Requirements
### LQ-001 — Platform cost ledger
Record monthly platform costs from project start (March 2025), including fixed
infrastructure and payment-provider variable fees when member revenue exists.
### LQ-002 — Member payment ledger
Record member subscription payments from first payment month (November 2025),
separate from platform cost accrual.
### LQ-003 — Budget tracking
Maintain an operator liquidity budget (initial: **€1,000**) and compute
remaining budget after cumulative platform spend minus cumulative member
payments received.
### LQ-004 — Liquidity position
Report whether the project is **burning**, **neutral**, or **generating**
liquidity each period:
- `period_net = member_payments - platform_costs`
- `cumulative_net = sum(period_net)`
- `remaining_budget = initial_budget + cumulative_net`
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
(OpenRouter per member, usage overage) is observatory-only until a later phase
introduces customer-visible credits or usage billing.
### LQ-006 — Historical dashboard
Economics Dashboard must show:
- Current-period economics (revenue, platform cost, margin)
- Cumulative liquidity summary (budget, burn, remaining)
- Monthly history table from March 2025
## Data sources (current)
| Registry | Path |
|----------|------|
| Budget | `data/budget.json` |
| Platform costs | `data/costs.json` (`rate_card` + `monthly_history`) |
| Member payments | `data/revenue.json` |
| Membership | `data/membership.json` |
Future sprints replace manual history with Bubble, Stripe, and OpenRouter imports
while preserving the same liquidity semantics.

View File

@@ -0,0 +1,7 @@
{
"version": 1,
"currency": "EUR",
"initial_budget": "1000.00",
"started": "2025-03",
"note": "Operator liquidity pool for Coulomb Social MVP platform spend before the offering is cash-flow positive."
}

View File

@@ -1,9 +1,72 @@
{ {
"version": 1, "version": 2,
"period": "2026-06",
"entries": [],
"fx_rates": { "fx_rates": {
"USD/EUR": "0.92" "USD/EUR": "0.92"
}, },
"note": "No running costs at present. Future fixed/variable lines (Bubble, Stripe, OpenRouter) added as they become payable." "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", "platform_cost": "72.20", "active_members": 0, "gross_revenue": "0.00"},
{"period": "2025-04", "platform_cost": "72.20", "active_members": 0, "gross_revenue": "0.00"},
{"period": "2025-05", "platform_cost": "72.20", "active_members": 0, "gross_revenue": "0.00"},
{"period": "2025-06", "platform_cost": "72.20", "active_members": 0, "gross_revenue": "0.00"},
{"period": "2025-07", "platform_cost": "72.20", "active_members": 0, "gross_revenue": "0.00"},
{"period": "2025-08", "platform_cost": "72.20", "active_members": 0, "gross_revenue": "0.00"},
{"period": "2025-09", "platform_cost": "72.20", "active_members": 0, "gross_revenue": "0.00"},
{"period": "2025-10", "platform_cost": "72.20", "active_members": 0, "gross_revenue": "0.00"},
{"period": "2025-11", "platform_cost": "72.58", "active_members": 1, "gross_revenue": "8.99"},
{"period": "2025-12", "platform_cost": "72.58", "active_members": 1, "gross_revenue": "8.99"},
{"period": "2026-01", "platform_cost": "72.58", "active_members": 1, "gross_revenue": "8.99"},
{"period": "2026-02", "platform_cost": "72.58", "active_members": 1, "gross_revenue": "8.99"},
{"period": "2026-03", "platform_cost": "72.58", "active_members": 1, "gross_revenue": "8.99"},
{"period": "2026-04", "platform_cost": "72.58", "active_members": 1, "gross_revenue": "8.99"},
{"period": "2026-05", "platform_cost": "72.58", "active_members": 1, "gross_revenue": "8.99"},
{"period": "2026-06", "platform_cost": "72.58", "active_members": 1, "gross_revenue": "8.99"}
],
"note": "Platform costs are operator expenses. Customer cost-pass-through billing is not active yet."
} }

View File

@@ -9,8 +9,8 @@
"joined_at": "2025-11-03", "joined_at": "2025-11-03",
"plan_id": "flat-899-eur-monthly", "plan_id": "flat-899-eur-monthly",
"source": "manual", "source": "manual",
"note": "Sole active member (founder)" "note": "Sole paying member; payments from November 2025"
} }
], ],
"note": "Current reality: one active member. Bubble importer (Sprint 2) will sync live data." "note": "Platform costs run from March 2025; member payments from November 2025."
} }

View File

@@ -1,16 +1,14 @@
{ {
"version": 1, "version": 1,
"entries": [ "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-2026-06-founder", {"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},
"period": "2026-06", {"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},
"gross_amount": "8.99", {"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},
"fees_amount": "0.00", {"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},
"refunds_amount": "0.00", {"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},
"net_amount": "8.99", {"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},
"currency": "EUR", {"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}
"source": "manual", ],
"note": "Single-member subscription; Stripe sync in Sprint 3" "note": "Member payments begin November 2025. Stripe sync replaces manual entries in Sprint 3."
}
]
} }

View File

@@ -1,49 +1,103 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
from decimal import Decimal
from pathlib import Path from pathlib import Path
from .economics import build_snapshot from .economics import build_liquidity_summary, build_snapshot
from .load import ( from .load import (
default_data_dir, default_data_dir,
load_costs, latest_period,
load_budget,
load_membership, load_membership,
load_monthly_platform_costs,
load_pricing_models, load_pricing_models,
load_product, load_product,
load_revenue, load_revenue,
) )
from .models import EconomicsSnapshot, PricingModel, Product from .models import EconomicsSnapshot, LiquiditySummary, MonthlyPlatformCost, PricingModel, Product, RevenueEntry
def _history_rows(
monthly_costs: list[MonthlyPlatformCost],
revenue_entries: list[RevenueEntry],
through_period: str,
) -> str:
revenue_by_period = {entry.period: entry for entry in revenue_entries}
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)
net_payment = payment.net_amount if payment else Decimal("0")
period_net = net_payment - month.platform_cost
lines.append(
f"| {month.period} | {month.active_members} | {month.gross_revenue} | "
f"{month.platform_cost} | {period_net:.2f} |"
)
return "\n".join(lines)
def render_dashboard( def render_dashboard(
product: Product, product: Product,
models: list[PricingModel], models: list[PricingModel],
snapshot: EconomicsSnapshot, snapshot: EconomicsSnapshot,
liquidity: LiquiditySummary,
monthly_costs: list[MonthlyPlatformCost],
revenue_entries: list[RevenueEntry],
) -> 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)
budget_state = (
"over budget"
if liquidity.remaining_budget < Decimal("0")
else "within budget"
)
return f"""# Economics Dashboard v1 — {product.name} return f"""# Economics Dashboard v1 — {product.name}
**Period:** {snapshot.period} **Period:** {snapshot.period}
**Lifecycle phase:** {product.lifecycle_phase} **Lifecycle phase:** {product.lifecycle_phase}
**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})
## Key Metrics > Platform costs accrue to the operator. Customer cost-pass-through billing is
> **not active** in MVP — members pay subscription only.
## Key Metrics (current period)
| Metric | Value | | Metric | Value |
|--------|------:| |--------|------:|
| Active members | {snapshot.active_members} | | Active members | {snapshot.active_members} |
| Monthly revenue | {snapshot.monthly_revenue} {snapshot.currency} | | Member payments (gross) | {snapshot.monthly_revenue} {snapshot.currency} |
| Monthly cost | {snapshot.monthly_cost} {snapshot.currency} | | Platform cost | {snapshot.monthly_platform_cost} {snapshot.currency} |
| Cost per member | {snapshot.cost_per_member} {snapshot.currency} | | Platform cost per member | {snapshot.cost_per_member} {snapshot.currency} |
| Gross margin | {snapshot.gross_margin} {snapshot.currency} | | Period gross margin | {snapshot.gross_margin} {snapshot.currency} |
| Gross margin % | {snapshot.gross_margin_pct}% | | Period gross margin % | {snapshot.gross_margin_pct}% |
| Period net liquidity | {snapshot.period_net_liquidity} {snapshot.currency} ({snapshot.liquidity_status}) |
_Revenue source: {snapshot.revenue_source}_ _Revenue source: {snapshot.revenue_source}_
## Liquidity & Budget (through {liquidity.through_period})
| Metric | Value |
|--------|------:|
| Initial budget | {liquidity.initial_budget} {liquidity.currency} |
| Cumulative member payments (net) | {liquidity.cumulative_member_payments} {liquidity.currency} |
| Cumulative platform cost | {liquidity.cumulative_platform_cost} {liquidity.currency} |
| Cumulative net liquidity | {liquidity.cumulative_net_liquidity} {liquidity.currency} ({liquidity.liquidity_status}) |
| Remaining budget | {liquidity.remaining_budget} {liquidity.currency} ({budget_state}) |
| Months tracked | {liquidity.months_tracked} |
## Monthly History
| Period | Members | Gross revenue | Platform cost | Net liquidity |
|--------|--------:|--------------:|--------------:|--------------:|
{history_lines}
## Pricing Model Registry ## Pricing Model Registry
| ID | Name | Type | Status | | ID | Name | Type | Status |
@@ -53,32 +107,28 @@ _Revenue source: {snapshot.revenue_source}_
## Registries Loaded ## Registries Loaded
- Product model (`data/product.json`) - Product model (`data/product.json`)
- Budget (`data/budget.json`)
- Pricing model registry (`data/pricing-models.json`) - Pricing model registry (`data/pricing-models.json`)
- Cost registry (`data/costs.json`) - Platform costs (`data/costs.json`)
- Revenue registry (`data/revenue.json`) - Member payments (`data/revenue.json`)
- Membership registry (`data/membership.json`) - Membership (`data/membership.json`)
- Requirements (`REQUIREMENTS.md`)
""" """
def generate_dashboard(data_dir: Path | None = None, period: str | None = None) -> str: def generate_dashboard(data_dir: Path | None = None, period: str | None = None) -> str:
root = data_dir or default_data_dir() root = data_dir or default_data_dir()
product = load_product(root) product = load_product(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) revenue = load_revenue(root)
cost_period, costs, fx_rates = load_costs(root) monthly_costs = load_monthly_platform_costs(root)
target_period = period or cost_period target_period = period or latest_period(monthly_costs)
snapshot = build_snapshot( snapshot = build_snapshot(target_period, product, models, members, revenue, monthly_costs)
target_period, liquidity = build_liquidity_summary(budget, revenue, monthly_costs, target_period)
product, return render_dashboard(product, models, snapshot, liquidity, monthly_costs, revenue)
models,
members,
revenue,
costs,
fx_rates,
)
return render_dashboard(product, models, snapshot)
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:

View File

@@ -3,9 +3,13 @@ from __future__ import annotations
from decimal import Decimal, ROUND_HALF_UP from decimal import Decimal, ROUND_HALF_UP
from .models import ( from .models import (
Budget,
CostEntry, CostEntry,
EconomicsSnapshot, EconomicsSnapshot,
LiquidityStatus,
LiquiditySummary,
MembershipRecord, MembershipRecord,
MonthlyPlatformCost,
PricingModel, PricingModel,
Product, Product,
RevenueEntry, RevenueEntry,
@@ -29,6 +33,14 @@ def _convert_to_eur(amount: Decimal, currency: str, fx_rates: dict[str, Decimal]
raise ValueError(f"unsupported currency for conversion: {currency}") raise ValueError(f"unsupported currency for conversion: {currency}")
def liquidity_status_for(net: Decimal) -> LiquidityStatus:
if net < Decimal("0"):
return "burning"
if net > Decimal("0"):
return "generating"
return "neutral"
def active_members(members: list[MembershipRecord]) -> int: def active_members(members: list[MembershipRecord]) -> int:
return sum(1 for member in members if member.status == "active") return sum(1 for member in members if member.status == "active")
@@ -40,23 +52,7 @@ 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 estimate_monthly_revenue( def monthly_cost_from_rate_card(
period: str,
product: Product,
models: list[PricingModel],
members: list[MembershipRecord],
revenue_entries: list[RevenueEntry],
) -> tuple[Decimal, str]:
for entry in revenue_entries:
if entry.period == period and entry.currency == product.currency:
return entry.gross_amount, entry.source
model = active_pricing_model(models, product)
count = active_members(members)
return model.access_fee_amount * count, "derived_from_membership"
def monthly_cost_total(
cost_entries: list[CostEntry], cost_entries: list[CostEntry],
fx_rates: dict[str, Decimal], fx_rates: dict[str, Decimal],
gross_revenue: Decimal, gross_revenue: Decimal,
@@ -73,37 +69,106 @@ def monthly_cost_total(
return _quantize(total) 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
def estimate_monthly_revenue(
period: str,
product: Product,
models: list[PricingModel],
members: list[MembershipRecord],
revenue_entries: list[RevenueEntry],
monthly_costs: list[MonthlyPlatformCost],
) -> tuple[Decimal, Decimal, str]:
recorded = revenue_for_period(period, revenue_entries)
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"
model = active_pricing_model(models, product)
count = active_members(members)
gross = model.access_fee_amount * count
return gross, gross, "derived_from_membership"
def periods_through(target: str, monthly_costs: list[MonthlyPlatformCost]) -> list[str]:
return sorted(item.period for item in monthly_costs if item.period <= target)
def build_liquidity_summary(
budget: Budget,
revenue_entries: list[RevenueEntry],
monthly_costs: list[MonthlyPlatformCost],
through_period: str,
) -> LiquiditySummary:
tracked = periods_through(through_period, monthly_costs)
cost_by_period = {item.period: item for item in monthly_costs}
cumulative_payments = Decimal("0")
cumulative_cost = Decimal("0")
for period in tracked:
month = cost_by_period[period]
cumulative_cost += month.platform_cost
payment = revenue_for_period(period, revenue_entries)
cumulative_payments += payment.net_amount if payment else Decimal("0")
cumulative_net = _quantize(cumulative_payments - cumulative_cost)
remaining = _quantize(budget.initial_budget + cumulative_net)
return LiquiditySummary(
currency=budget.currency,
through_period=through_period,
initial_budget=budget.initial_budget,
cumulative_member_payments=_quantize(cumulative_payments),
cumulative_platform_cost=_quantize(cumulative_cost),
cumulative_net_liquidity=cumulative_net,
remaining_budget=remaining,
liquidity_status=liquidity_status_for(cumulative_net),
months_tracked=len(tracked),
)
def build_snapshot( def build_snapshot(
period: str, period: str,
product: Product, product: Product,
models: list[PricingModel], models: list[PricingModel],
members: list[MembershipRecord], members: list[MembershipRecord],
revenue_entries: list[RevenueEntry], revenue_entries: list[RevenueEntry],
cost_entries: list[CostEntry], monthly_costs: list[MonthlyPlatformCost],
fx_rates: dict[str, Decimal],
) -> EconomicsSnapshot: ) -> EconomicsSnapshot:
count = active_members(members) month = next(item for item in monthly_costs if item.period == period)
gross_revenue, revenue_source = estimate_monthly_revenue( count = month.active_members if month.active_members else active_members(members)
period, product, models, members, revenue_entries gross_revenue, net_revenue, revenue_source = estimate_monthly_revenue(
period, product, models, members, revenue_entries, monthly_costs
) )
monthly_cost = monthly_cost_total(cost_entries, fx_rates, gross_revenue, count) platform_cost = month.platform_cost
cost_per_member = _quantize(monthly_cost / count) if count else Decimal("0.00") cost_per_member = _quantize(platform_cost / count) if count else Decimal("0.00")
gross_margin = _quantize(gross_revenue - monthly_cost) gross_margin = _quantize(gross_revenue - platform_cost)
margin_pct = ( margin_pct = (
_quantize((gross_margin / gross_revenue) * Decimal("100"), PCTPLACES) _quantize((gross_margin / gross_revenue) * Decimal("100"), PCTPLACES)
if gross_revenue if gross_revenue
else Decimal("0.0") else Decimal("-100.0") if platform_cost else Decimal("0.0")
) )
period_net = _quantize(net_revenue - platform_cost)
return EconomicsSnapshot( return EconomicsSnapshot(
period=period, period=period,
currency=product.currency, currency=product.currency,
active_members=count, active_members=count,
monthly_revenue=_quantize(gross_revenue), monthly_revenue=_quantize(gross_revenue),
monthly_cost=monthly_cost, monthly_platform_cost=platform_cost,
cost_per_member=cost_per_member, cost_per_member=cost_per_member,
gross_margin=gross_margin, gross_margin=gross_margin,
gross_margin_pct=margin_pct, gross_margin_pct=margin_pct,
pricing_model_count=len(models), pricing_model_count=len(models),
revenue_source=revenue_source, revenue_source=revenue_source,
period_net_liquidity=period_net,
liquidity_status=liquidity_status_for(period_net),
) )

View File

@@ -5,8 +5,10 @@ from decimal import Decimal
from pathlib import Path from pathlib import Path
from .models import ( from .models import (
Budget,
CostEntry, CostEntry,
MembershipRecord, MembershipRecord,
MonthlyPlatformCost,
PricingModel, PricingModel,
Product, Product,
RevenueEntry, RevenueEntry,
@@ -37,6 +39,15 @@ def load_product(data_dir: Path | None = None) -> Product:
) )
def load_budget(data_dir: Path | None = None) -> Budget:
raw = _read_json((data_dir or default_data_dir()) / "budget.json")
return Budget(
currency=raw["currency"],
initial_budget=_money(raw["initial_budget"]),
started=raw["started"],
)
def load_pricing_models(data_dir: Path | None = None) -> list[PricingModel]: def load_pricing_models(data_dir: Path | None = None) -> list[PricingModel]:
raw = _read_json((data_dir or default_data_dir()) / "pricing-models.json") raw = _read_json((data_dir or default_data_dir()) / "pricing-models.json")
return [ return [
@@ -54,9 +65,8 @@ def load_pricing_models(data_dir: Path | None = None) -> list[PricingModel]:
] ]
def load_costs(data_dir: Path | None = None) -> tuple[str, list[CostEntry], dict[str, Decimal]]: def _parse_cost_entries(items: list[dict]) -> list[CostEntry]:
raw = _read_json((data_dir or default_data_dir()) / "costs.json") return [
entries = [
CostEntry( CostEntry(
id=item["id"], id=item["id"],
name=item["name"], name=item["name"],
@@ -66,10 +76,28 @@ def load_costs(data_dir: Path | None = None) -> tuple[str, list[CostEntry], dict
cadence=item["cadence"], cadence=item["cadence"],
allocation=item["allocation"], allocation=item["allocation"],
) )
for item in raw["entries"] for item in items
] ]
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()} fx = {pair: _money(rate) for pair, rate in raw.get("fx_rates", {}).items()}
return raw.get("period", ""), entries, fx 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")
return [
MonthlyPlatformCost(
period=item["period"],
platform_cost=_money(item["platform_cost"]),
active_members=item["active_members"],
gross_revenue=_money(item["gross_revenue"]),
)
for item in raw.get("monthly_history", [])
]
def load_revenue(data_dir: Path | None = None) -> list[RevenueEntry]: def load_revenue(data_dir: Path | None = None) -> list[RevenueEntry]:
@@ -101,3 +129,7 @@ def load_membership(data_dir: Path | None = None) -> list[MembershipRecord]:
) )
for item in raw["members"] for item in raw["members"]
] ]
def latest_period(monthly_costs: list[MonthlyPlatformCost]) -> str:
return max(item.period for item in monthly_costs)

View File

@@ -7,6 +7,7 @@ from typing import Literal
CostCategory = Literal["fixed", "variable"] CostCategory = Literal["fixed", "variable"]
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"]
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -42,6 +43,21 @@ class CostEntry:
allocation: str allocation: str
@dataclass(frozen=True)
class MonthlyPlatformCost:
period: str
platform_cost: Decimal
active_members: int
gross_revenue: Decimal
@dataclass(frozen=True)
class Budget:
currency: str
initial_budget: Decimal
started: str
@dataclass(frozen=True) @dataclass(frozen=True)
class RevenueEntry: class RevenueEntry:
id: str id: str
@@ -69,9 +85,24 @@ class EconomicsSnapshot:
currency: str currency: str
active_members: int active_members: int
monthly_revenue: Decimal monthly_revenue: Decimal
monthly_cost: Decimal monthly_platform_cost: Decimal
cost_per_member: Decimal cost_per_member: Decimal
gross_margin: Decimal gross_margin: Decimal
gross_margin_pct: Decimal gross_margin_pct: Decimal
pricing_model_count: int pricing_model_count: int
revenue_source: str revenue_source: str
period_net_liquidity: Decimal
liquidity_status: LiquidityStatus
@dataclass(frozen=True)
class LiquiditySummary:
currency: str
through_period: str
initial_budget: Decimal
cumulative_member_payments: Decimal
cumulative_platform_cost: Decimal
cumulative_net_liquidity: Decimal
remaining_budget: Decimal
liquidity_status: LiquidityStatus
months_tracked: int

View File

@@ -4,19 +4,55 @@
**Lifecycle phase:** growth **Lifecycle phase:** growth
**Active pricing model:** Standard Membership (8.99 EUR/monthly) **Active pricing model:** Standard Membership (8.99 EUR/monthly)
## Key Metrics > Platform costs accrue to the operator. Customer cost-pass-through billing is
> **not active** in MVP — members pay subscription only.
## Key Metrics (current period)
| Metric | Value | | Metric | Value |
|--------|------:| |--------|------:|
| Active members | 1 | | Active members | 1 |
| Monthly revenue | 8.99 EUR | | Member payments (gross) | 8.99 EUR |
| Monthly cost | 0.00 EUR | | Platform cost | 72.58 EUR |
| Cost per member | 0.00 EUR | | Platform cost per member | 72.58 EUR |
| Gross margin | 8.99 EUR | | Period gross margin | -63.59 EUR |
| Gross margin % | 100.0% | | Period gross margin % | -707.3% |
| Period net liquidity | -63.97 EUR (burning) |
_Revenue source: manual_ _Revenue source: manual_
## Liquidity & Budget (through 2026-06)
| Metric | Value |
|--------|------:|
| Initial budget | 1000.00 EUR |
| Cumulative member payments (net) | 68.88 EUR |
| Cumulative platform cost | 1158.24 EUR |
| Cumulative net liquidity | -1089.36 EUR (burning) |
| Remaining budget | -89.36 EUR (over budget) |
| Months tracked | 16 |
## Monthly History
| Period | Members | Gross revenue | Platform cost | Net liquidity |
|--------|--------:|--------------:|--------------:|--------------:|
| 2025-03 | 0 | 0.00 | 72.20 | -72.20 |
| 2025-04 | 0 | 0.00 | 72.20 | -72.20 |
| 2025-05 | 0 | 0.00 | 72.20 | -72.20 |
| 2025-06 | 0 | 0.00 | 72.20 | -72.20 |
| 2025-07 | 0 | 0.00 | 72.20 | -72.20 |
| 2025-08 | 0 | 0.00 | 72.20 | -72.20 |
| 2025-09 | 0 | 0.00 | 72.20 | -72.20 |
| 2025-10 | 0 | 0.00 | 72.20 | -72.20 |
| 2025-11 | 1 | 8.99 | 72.58 | -63.97 |
| 2025-12 | 1 | 8.99 | 72.58 | -63.97 |
| 2026-01 | 1 | 8.99 | 72.58 | -63.97 |
| 2026-02 | 1 | 8.99 | 72.58 | -63.97 |
| 2026-03 | 1 | 8.99 | 72.58 | -63.97 |
| 2026-04 | 1 | 8.99 | 72.58 | -63.97 |
| 2026-05 | 1 | 8.99 | 72.58 | -63.97 |
| 2026-06 | 1 | 8.99 | 72.58 | -63.97 |
## Pricing Model Registry ## Pricing Model Registry
| ID | Name | Type | Status | | ID | Name | Type | Status |
@@ -28,7 +64,9 @@ _Revenue source: manual_
## Registries Loaded ## Registries Loaded
- Product model (`data/product.json`) - Product model (`data/product.json`)
- Budget (`data/budget.json`)
- Pricing model registry (`data/pricing-models.json`) - Pricing model registry (`data/pricing-models.json`)
- Cost registry (`data/costs.json`) - Platform costs (`data/costs.json`)
- Revenue registry (`data/revenue.json`) - Member payments (`data/revenue.json`)
- Membership registry (`data/membership.json`) - Membership (`data/membership.json`)
- Requirements (`REQUIREMENTS.md`)

View File

@@ -3,10 +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 active_members, build_snapshot, monthly_cost_total from observatory.economics import (
active_members,
build_liquidity_summary,
build_snapshot,
monthly_cost_from_rate_card,
)
from observatory.load import ( from observatory.load import (
load_costs, load_budget,
load_cost_rate_card,
load_membership, load_membership,
load_monthly_platform_costs,
load_pricing_models, load_pricing_models,
load_product, load_product,
load_revenue, load_revenue,
@@ -20,36 +27,58 @@ def test_active_members_counts_only_active_status() -> None:
assert active_members(members) == 1 assert active_members(members) == 1
def test_build_snapshot_reflects_sole_member_and_zero_costs() -> None: def test_rate_card_matches_documented_platform_cost_without_revenue() -> None:
rate_card, fx_rates = load_cost_rate_card(DATA_DIR)
total = monthly_cost_from_rate_card(rate_card, fx_rates, Decimal("0"), 0)
assert total == Decimal("72.20")
def test_rate_card_matches_platform_cost_with_one_member() -> None:
rate_card, fx_rates = load_cost_rate_card(DATA_DIR)
total = monthly_cost_from_rate_card(rate_card, fx_rates, Decimal("8.99"), 1)
assert total == Decimal("72.58")
def test_build_snapshot_june_2026_shows_liquidity_burn() -> 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) revenue = load_revenue(DATA_DIR)
_, costs, fx_rates = load_costs(DATA_DIR) monthly_costs = load_monthly_platform_costs(DATA_DIR)
snapshot = build_snapshot("2026-06", product, models, members, revenue, costs, fx_rates) snapshot = build_snapshot("2026-06", product, models, members, revenue, monthly_costs)
assert snapshot.active_members == 1 assert snapshot.active_members == 1
assert snapshot.monthly_revenue == Decimal("8.99") assert snapshot.monthly_revenue == Decimal("8.99")
assert snapshot.revenue_source == "manual" assert snapshot.monthly_platform_cost == Decimal("72.58")
assert snapshot.pricing_model_count == 3 assert snapshot.period_net_liquidity == Decimal("-63.97")
assert snapshot.monthly_cost == Decimal("0.00") assert snapshot.liquidity_status == "burning"
assert snapshot.cost_per_member == Decimal("0.00") assert snapshot.gross_margin == Decimal("-63.59")
assert snapshot.gross_margin == Decimal("8.99")
assert snapshot.gross_margin_pct == Decimal("100.0")
def test_monthly_cost_is_zero_with_empty_registry() -> None: def test_liquidity_summary_tracks_budget_burn_through_june_2026() -> None:
_, costs, fx_rates = load_costs(DATA_DIR) budget = load_budget(DATA_DIR)
total = monthly_cost_total(costs, fx_rates, Decimal("8.99"), 1) revenue = load_revenue(DATA_DIR)
assert total == Decimal("0.00") monthly_costs = load_monthly_platform_costs(DATA_DIR)
summary = build_liquidity_summary(budget, revenue, monthly_costs, "2026-06")
assert summary.initial_budget == Decimal("1000.00")
assert summary.cumulative_platform_cost == Decimal("1158.24")
assert summary.cumulative_member_payments == Decimal("68.88")
assert summary.cumulative_net_liquidity == Decimal("-1089.36")
assert summary.remaining_budget == Decimal("-89.36")
assert summary.liquidity_status == "burning"
assert summary.months_tracked == 16
def test_dashboard_module_renders_markdown() -> None: def test_dashboard_renders_liquidity_sections() -> 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 "# Economics Dashboard v1" in report assert "Liquidity & Budget" in report
assert "Pricing Model Registry" in report assert "Monthly History" in report
assert "flat-899-eur-monthly" in report assert "2025-03" in report
assert "Active members | 1" in report assert "2025-11" in report
assert "remaining budget" not in report.lower() or "Remaining budget" in report
assert "-89.36" in report