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

@@ -1,49 +1,103 @@
from __future__ import annotations
import argparse
from decimal import Decimal
from pathlib import Path
from .economics import build_snapshot
from .economics import build_liquidity_summary, build_snapshot
from .load import (
default_data_dir,
load_costs,
latest_period,
load_budget,
load_membership,
load_monthly_platform_costs,
load_pricing_models,
load_product,
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(
product: Product,
models: list[PricingModel],
snapshot: EconomicsSnapshot,
liquidity: LiquiditySummary,
monthly_costs: list[MonthlyPlatformCost],
revenue_entries: list[RevenueEntry],
) -> str:
active = next(m for m in models if m.id == product.active_pricing_model_id)
registry_lines = "\n".join(
f"| {model.id} | {model.name} | {model.model_type} | {model.status} |"
for model in models
)
history_lines = _history_rows(monthly_costs, revenue_entries, snapshot.period)
budget_state = (
"over budget"
if liquidity.remaining_budget < Decimal("0")
else "within budget"
)
return f"""# Economics Dashboard v1 — {product.name}
**Period:** {snapshot.period}
**Lifecycle phase:** {product.lifecycle_phase}
**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 |
|--------|------:|
| Active members | {snapshot.active_members} |
| Monthly revenue | {snapshot.monthly_revenue} {snapshot.currency} |
| Monthly cost | {snapshot.monthly_cost} {snapshot.currency} |
| Cost per member | {snapshot.cost_per_member} {snapshot.currency} |
| Gross margin | {snapshot.gross_margin} {snapshot.currency} |
| Gross margin % | {snapshot.gross_margin_pct}% |
| Member payments (gross) | {snapshot.monthly_revenue} {snapshot.currency} |
| Platform cost | {snapshot.monthly_platform_cost} {snapshot.currency} |
| Platform cost per member | {snapshot.cost_per_member} {snapshot.currency} |
| Period gross margin | {snapshot.gross_margin} {snapshot.currency} |
| Period gross margin % | {snapshot.gross_margin_pct}% |
| Period net liquidity | {snapshot.period_net_liquidity} {snapshot.currency} ({snapshot.liquidity_status}) |
_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
| ID | Name | Type | Status |
@@ -53,32 +107,28 @@ _Revenue source: {snapshot.revenue_source}_
## Registries Loaded
- Product model (`data/product.json`)
- Budget (`data/budget.json`)
- Pricing model registry (`data/pricing-models.json`)
- Cost registry (`data/costs.json`)
- Revenue registry (`data/revenue.json`)
- Membership registry (`data/membership.json`)
- Platform costs (`data/costs.json`)
- Member payments (`data/revenue.json`)
- Membership (`data/membership.json`)
- Requirements (`REQUIREMENTS.md`)
"""
def generate_dashboard(data_dir: Path | None = None, period: str | None = None) -> str:
root = data_dir or default_data_dir()
product = load_product(root)
budget = load_budget(root)
models = load_pricing_models(root)
members = load_membership(root)
revenue = load_revenue(root)
cost_period, costs, fx_rates = load_costs(root)
target_period = period or cost_period
monthly_costs = load_monthly_platform_costs(root)
target_period = period or latest_period(monthly_costs)
snapshot = build_snapshot(
target_period,
product,
models,
members,
revenue,
costs,
fx_rates,
)
return render_dashboard(product, models, snapshot)
snapshot = build_snapshot(target_period, product, models, members, revenue, monthly_costs)
liquidity = build_liquidity_summary(budget, revenue, monthly_costs, target_period)
return render_dashboard(product, models, snapshot, liquidity, monthly_costs, revenue)
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 .models import (
Budget,
CostEntry,
EconomicsSnapshot,
LiquidityStatus,
LiquiditySummary,
MembershipRecord,
MonthlyPlatformCost,
PricingModel,
Product,
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}")
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:
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}")
def estimate_monthly_revenue(
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(
def monthly_cost_from_rate_card(
cost_entries: list[CostEntry],
fx_rates: dict[str, Decimal],
gross_revenue: Decimal,
@@ -73,37 +69,106 @@ def monthly_cost_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(
period: str,
product: Product,
models: list[PricingModel],
members: list[MembershipRecord],
revenue_entries: list[RevenueEntry],
cost_entries: list[CostEntry],
fx_rates: dict[str, Decimal],
monthly_costs: list[MonthlyPlatformCost],
) -> EconomicsSnapshot:
count = active_members(members)
gross_revenue, revenue_source = estimate_monthly_revenue(
period, product, models, members, revenue_entries
month = next(item for item in monthly_costs if item.period == period)
count = month.active_members if month.active_members else active_members(members)
gross_revenue, net_revenue, revenue_source = estimate_monthly_revenue(
period, product, models, members, revenue_entries, monthly_costs
)
monthly_cost = monthly_cost_total(cost_entries, fx_rates, gross_revenue, count)
cost_per_member = _quantize(monthly_cost / count) if count else Decimal("0.00")
gross_margin = _quantize(gross_revenue - monthly_cost)
platform_cost = month.platform_cost
cost_per_member = _quantize(platform_cost / count) if count else Decimal("0.00")
gross_margin = _quantize(gross_revenue - platform_cost)
margin_pct = (
_quantize((gross_margin / gross_revenue) * Decimal("100"), PCTPLACES)
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(
period=period,
currency=product.currency,
active_members=count,
monthly_revenue=_quantize(gross_revenue),
monthly_cost=monthly_cost,
monthly_platform_cost=platform_cost,
cost_per_member=cost_per_member,
gross_margin=gross_margin,
gross_margin_pct=margin_pct,
pricing_model_count=len(models),
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 .models import (
Budget,
CostEntry,
MembershipRecord,
MonthlyPlatformCost,
PricingModel,
Product,
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]:
raw = _read_json((data_dir or default_data_dir()) / "pricing-models.json")
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]]:
raw = _read_json((data_dir or default_data_dir()) / "costs.json")
entries = [
def _parse_cost_entries(items: list[dict]) -> list[CostEntry]:
return [
CostEntry(
id=item["id"],
name=item["name"],
@@ -66,10 +76,28 @@ def load_costs(data_dir: Path | None = None) -> tuple[str, list[CostEntry], dict
cadence=item["cadence"],
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()}
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]:
@@ -100,4 +128,8 @@ def load_membership(data_dir: Path | None = None) -> list[MembershipRecord]:
churned_at=item.get("churned_at"),
)
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"]
MemberStatus = Literal["active", "churned", "paused"]
PricingModelStatus = Literal["active", "candidate", "retired"]
LiquidityStatus = Literal["burning", "neutral", "generating"]
@dataclass(frozen=True)
@@ -42,6 +43,21 @@ class CostEntry:
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)
class RevenueEntry:
id: str
@@ -69,9 +85,24 @@ class EconomicsSnapshot:
currency: str
active_members: int
monthly_revenue: Decimal
monthly_cost: Decimal
monthly_platform_cost: Decimal
cost_per_member: Decimal
gross_margin: Decimal
gross_margin_pct: Decimal
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