generated from coulomb/repo-seed
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:
@@ -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.
|
|
||||||
66
projects/coulomb-pricing/REQUIREMENTS.md
Normal file
66
projects/coulomb-pricing/REQUIREMENTS.md
Normal 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.
|
||||||
7
projects/coulomb-pricing/data/budget.json
Normal file
7
projects/coulomb-pricing/data/budget.json
Normal 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."
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
}
|
}
|
||||||
@@ -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."
|
||||||
}
|
}
|
||||||
@@ -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."
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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`)
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user