From fe2174f37a427cd8baaa7d14fb24998c6969fcf3 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 22 Jun 2026 01:48:45 +0200 Subject: [PATCH] Add liquidity tracking, budget, and platform cost history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- projects/coulomb-pricing/README.md | 18 ++- projects/coulomb-pricing/REQUIREMENTS.md | 66 ++++++++++ projects/coulomb-pricing/data/budget.json | 7 ++ projects/coulomb-pricing/data/costs.json | 71 ++++++++++- projects/coulomb-pricing/data/membership.json | 4 +- projects/coulomb-pricing/data/revenue.json | 22 ++-- .../coulomb-pricing/observatory/dashboard.py | 98 +++++++++++---- .../coulomb-pricing/observatory/economics.py | 119 ++++++++++++++---- projects/coulomb-pricing/observatory/load.py | 44 ++++++- .../coulomb-pricing/observatory/models.py | 35 +++++- .../reports/economics-2026-06.md | 56 +++++++-- .../coulomb-pricing/tests/test_economics.py | 69 +++++++--- 12 files changed, 497 insertions(+), 112 deletions(-) create mode 100644 projects/coulomb-pricing/REQUIREMENTS.md create mode 100644 projects/coulomb-pricing/data/budget.json diff --git a/projects/coulomb-pricing/README.md b/projects/coulomb-pricing/README.md index 12f8a11..d3568bb 100644 --- a/projects/coulomb-pricing/README.md +++ b/projects/coulomb-pricing/README.md @@ -6,19 +6,26 @@ Generic adaptive-pricing framework concepts belong in the repository root (`INTENT.md`, `docs/`, `research/`, `registry/`). Execution tracking: `workplans/ADAPTIVE-WP-0002-economic-observatory-mvp.md`. +Liquidity and cost requirements: `REQUIREMENTS.md`. + ## Sprint 1 — Economic Foundations 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 | |----------|------| +| Budget (€1,000 start) | `data/budget.json` | | Product model | `data/product.json` | | Pricing models | `data/pricing-models.json` | -| Costs | `data/costs.json` | -| Revenue | `data/revenue.json` | +| Platform costs + history | `data/costs.json` | +| Member payments | `data/revenue.json` | | Membership | `data/membership.json` | +**Current reality:** platform costs from March 2025; sole member payments from +November 2025. Operator is **burning liquidity** — platform spend exceeds +subscription intake. Customer cost-pass-through billing is not active. + ### Commands ```bash @@ -28,6 +35,5 @@ python3 -m observatory --period 2026-06 python3 -m observatory --period 2026-06 --output reports/economics-2026-06.md ``` -**Current registry state:** one active member (founder), €8.99/month revenue, no -running costs recorded. Bubble (Sprint 2) and Stripe (Sprint 3) importers will -replace manual entries when integrations land. \ No newline at end of file +Manual registries will be replaced by Bubble (Sprint 2), Stripe (Sprint 3), and +OpenRouter (Sprint 4) importers. \ No newline at end of file diff --git a/projects/coulomb-pricing/REQUIREMENTS.md b/projects/coulomb-pricing/REQUIREMENTS.md new file mode 100644 index 0000000..f9ce3d4 --- /dev/null +++ b/projects/coulomb-pricing/REQUIREMENTS.md @@ -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. \ No newline at end of file diff --git a/projects/coulomb-pricing/data/budget.json b/projects/coulomb-pricing/data/budget.json new file mode 100644 index 0000000..169c4be --- /dev/null +++ b/projects/coulomb-pricing/data/budget.json @@ -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." +} \ No newline at end of file diff --git a/projects/coulomb-pricing/data/costs.json b/projects/coulomb-pricing/data/costs.json index a84a5d8..480220c 100644 --- a/projects/coulomb-pricing/data/costs.json +++ b/projects/coulomb-pricing/data/costs.json @@ -1,9 +1,72 @@ { - "version": 1, - "period": "2026-06", - "entries": [], + "version": 2, "fx_rates": { "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." } \ No newline at end of file diff --git a/projects/coulomb-pricing/data/membership.json b/projects/coulomb-pricing/data/membership.json index c2af85a..b2950e1 100644 --- a/projects/coulomb-pricing/data/membership.json +++ b/projects/coulomb-pricing/data/membership.json @@ -9,8 +9,8 @@ "joined_at": "2025-11-03", "plan_id": "flat-899-eur-monthly", "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." } \ No newline at end of file diff --git a/projects/coulomb-pricing/data/revenue.json b/projects/coulomb-pricing/data/revenue.json index d125a52..f85fec1 100644 --- a/projects/coulomb-pricing/data/revenue.json +++ b/projects/coulomb-pricing/data/revenue.json @@ -1,16 +1,14 @@ { "version": 1, "entries": [ - { - "id": "rev-2026-06-founder", - "period": "2026-06", - "gross_amount": "8.99", - "fees_amount": "0.00", - "refunds_amount": "0.00", - "net_amount": "8.99", - "currency": "EUR", - "source": "manual", - "note": "Single-member subscription; Stripe sync in Sprint 3" - } - ] + {"id": "rev-2025-11", "period": "2025-11", "gross_amount": "8.99", "fees_amount": "0.38", "refunds_amount": "0.00", "net_amount": "8.61", "currency": "EUR", "source": "manual", "member_count": 1}, + {"id": "rev-2025-12", "period": "2025-12", "gross_amount": "8.99", "fees_amount": "0.38", "refunds_amount": "0.00", "net_amount": "8.61", "currency": "EUR", "source": "manual", "member_count": 1}, + {"id": "rev-2026-01", "period": "2026-01", "gross_amount": "8.99", "fees_amount": "0.38", "refunds_amount": "0.00", "net_amount": "8.61", "currency": "EUR", "source": "manual", "member_count": 1}, + {"id": "rev-2026-02", "period": "2026-02", "gross_amount": "8.99", "fees_amount": "0.38", "refunds_amount": "0.00", "net_amount": "8.61", "currency": "EUR", "source": "manual", "member_count": 1}, + {"id": "rev-2026-03", "period": "2026-03", "gross_amount": "8.99", "fees_amount": "0.38", "refunds_amount": "0.00", "net_amount": "8.61", "currency": "EUR", "source": "manual", "member_count": 1}, + {"id": "rev-2026-04", "period": "2026-04", "gross_amount": "8.99", "fees_amount": "0.38", "refunds_amount": "0.00", "net_amount": "8.61", "currency": "EUR", "source": "manual", "member_count": 1}, + {"id": "rev-2026-05", "period": "2026-05", "gross_amount": "8.99", "fees_amount": "0.38", "refunds_amount": "0.00", "net_amount": "8.61", "currency": "EUR", "source": "manual", "member_count": 1}, + {"id": "rev-2026-06", "period": "2026-06", "gross_amount": "8.99", "fees_amount": "0.38", "refunds_amount": "0.00", "net_amount": "8.61", "currency": "EUR", "source": "manual", "member_count": 1} + ], + "note": "Member payments begin November 2025. Stripe sync replaces manual entries in Sprint 3." } \ No newline at end of file diff --git a/projects/coulomb-pricing/observatory/dashboard.py b/projects/coulomb-pricing/observatory/dashboard.py index c26248a..ea7bf3d 100644 --- a/projects/coulomb-pricing/observatory/dashboard.py +++ b/projects/coulomb-pricing/observatory/dashboard.py @@ -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: diff --git a/projects/coulomb-pricing/observatory/economics.py b/projects/coulomb-pricing/observatory/economics.py index c765bc0..1584a81 100644 --- a/projects/coulomb-pricing/observatory/economics.py +++ b/projects/coulomb-pricing/observatory/economics.py @@ -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), ) \ No newline at end of file diff --git a/projects/coulomb-pricing/observatory/load.py b/projects/coulomb-pricing/observatory/load.py index 56fe9a8..99282b3 100644 --- a/projects/coulomb-pricing/observatory/load.py +++ b/projects/coulomb-pricing/observatory/load.py @@ -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"] - ] \ No newline at end of file + ] + + +def latest_period(monthly_costs: list[MonthlyPlatformCost]) -> str: + return max(item.period for item in monthly_costs) \ No newline at end of file diff --git a/projects/coulomb-pricing/observatory/models.py b/projects/coulomb-pricing/observatory/models.py index 7314ce9..2f176d8 100644 --- a/projects/coulomb-pricing/observatory/models.py +++ b/projects/coulomb-pricing/observatory/models.py @@ -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 \ No newline at end of file + 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 \ No newline at end of file diff --git a/projects/coulomb-pricing/reports/economics-2026-06.md b/projects/coulomb-pricing/reports/economics-2026-06.md index 05e8bc6..f4294b9 100644 --- a/projects/coulomb-pricing/reports/economics-2026-06.md +++ b/projects/coulomb-pricing/reports/economics-2026-06.md @@ -4,19 +4,55 @@ **Lifecycle phase:** growth **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 | |--------|------:| | Active members | 1 | -| Monthly revenue | 8.99 EUR | -| Monthly cost | 0.00 EUR | -| Cost per member | 0.00 EUR | -| Gross margin | 8.99 EUR | -| Gross margin % | 100.0% | +| Member payments (gross) | 8.99 EUR | +| Platform cost | 72.58 EUR | +| Platform cost per member | 72.58 EUR | +| Period gross margin | -63.59 EUR | +| Period gross margin % | -707.3% | +| Period net liquidity | -63.97 EUR (burning) | _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 | ID | Name | Type | Status | @@ -28,7 +64,9 @@ _Revenue source: manual_ ## 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`) diff --git a/projects/coulomb-pricing/tests/test_economics.py b/projects/coulomb-pricing/tests/test_economics.py index 42ce7c6..2de0b14 100644 --- a/projects/coulomb-pricing/tests/test_economics.py +++ b/projects/coulomb-pricing/tests/test_economics.py @@ -3,10 +3,17 @@ from __future__ import annotations from decimal import Decimal 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 ( - load_costs, + load_budget, + load_cost_rate_card, load_membership, + load_monthly_platform_costs, load_pricing_models, load_product, load_revenue, @@ -20,36 +27,58 @@ def test_active_members_counts_only_active_status() -> None: 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) models = load_pricing_models(DATA_DIR) members = load_membership(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.monthly_revenue == Decimal("8.99") - assert snapshot.revenue_source == "manual" - assert snapshot.pricing_model_count == 3 - assert snapshot.monthly_cost == Decimal("0.00") - assert snapshot.cost_per_member == Decimal("0.00") - assert snapshot.gross_margin == Decimal("8.99") - assert snapshot.gross_margin_pct == Decimal("100.0") + assert snapshot.monthly_platform_cost == Decimal("72.58") + assert snapshot.period_net_liquidity == Decimal("-63.97") + assert snapshot.liquidity_status == "burning" + assert snapshot.gross_margin == Decimal("-63.59") -def test_monthly_cost_is_zero_with_empty_registry() -> None: - _, costs, fx_rates = load_costs(DATA_DIR) - total = monthly_cost_total(costs, fx_rates, Decimal("8.99"), 1) - assert total == Decimal("0.00") +def test_liquidity_summary_tracks_budget_burn_through_june_2026() -> None: + budget = load_budget(DATA_DIR) + revenue = load_revenue(DATA_DIR) + 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 report = generate_dashboard(DATA_DIR, "2026-06") - assert "# Economics Dashboard v1" in report - assert "Pricing Model Registry" in report - assert "flat-899-eur-monthly" in report - assert "Active members | 1" in report \ No newline at end of file + assert "Liquidity & Budget" in report + assert "Monthly History" in report + assert "2025-03" in report + assert "2025-11" in report + assert "remaining budget" not in report.lower() or "Remaining budget" in report + assert "-89.36" in report \ No newline at end of file