diff --git a/AGENTS.md b/AGENTS.md index 3ef338b..5f279eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,24 +13,24 @@ ## Dev Workflow -The repository is in an **early framework phase**: Markdown documentation, research -notes, and capability registry YAML. No application runtime, package manifest, or -automated test suite exists yet. Executable implementation begins under -`workplans/ADAPTIVE-WP-0002-economic-observatory-mvp.md`. +Framework docs live at the repo root. The Coulomb MVP implementation lives in +`projects/coulomb-pricing/observatory/` (stdlib Python 3.11+, no runtime deps; +`pytest` for tests). Run MVP commands from `projects/coulomb-pricing/`. | Need | Command | |------|---------| -| Install | none — no runtime dependencies | -| Test | none configured yet | -| Lint / format | none configured — match surrounding Markdown style | -| Build | none — documentation-only repo | -| Run | none | +| Python | `python3` (3.11+) | +| Install | none — stdlib only; for tests: `pip install pytest` | +| Test (MVP) | `cd projects/coulomb-pricing && python3 -m pytest -q` | +| Test (repo) | same as MVP until other packages exist | +| Lint / format | none configured — match surrounding style | +| Build | none | +| Run: economics dashboard | `cd projects/coulomb-pricing && python3 -m observatory --period YYYY-MM` | | Workplan / hub sync | `cd ~/state-hub && make fix-consistency REPO=adaptive-pricing REPO_PATH=~/adaptive-pricing` | | Registry sanity | `grep -q '^version:' registry/indexes/capabilities.yaml && echo OK` | -**Verify a change before declaring it done:** run `make fix-consistency` (expect -PASS), and confirm edited docs stay aligned with `INTENT.md` and -`docs/ProductRequirementsDocument.md`. +**Verify a change before declaring it done:** run `python3 -m pytest` under +`projects/coulomb-pricing`, then `make fix-consistency` (expect PASS). --- diff --git a/projects/coulomb-pricing/README.md b/projects/coulomb-pricing/README.md index 1dec1ae..336b6d7 100644 --- a/projects/coulomb-pricing/README.md +++ b/projects/coulomb-pricing/README.md @@ -2,11 +2,31 @@ Project-specific material for the Coulomb Social Economic Observatory MVP. -This directory holds implementation artifacts, integrations, and documentation that -apply to the Coulomb deployment only. Generic adaptive-pricing framework concepts -belong in the repository root (`INTENT.md`, `docs/`, `research/`, `registry/`). +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`. -**Execution tracking:** `workplans/ADAPTIVE-WP-0002-economic-observatory-mvp.md` +## Sprint 1 — Economic Foundations -**Strategic positioning:** Adaptive Pricing is the pricing capability layer for -Coulomb offerings and related product ecosystems. \ No newline at end of file +The `observatory/` package loads JSON registries and produces **Economics +Dashboard v1** metrics: + +| Registry | File | +|----------|------| +| Product model | `data/product.json` | +| Pricing models | `data/pricing-models.json` | +| Costs | `data/costs.json` | +| Revenue | `data/revenue.json` | +| Membership | `data/membership.json` | + +### Commands + +```bash +cd projects/coulomb-pricing +python3 -m pytest -q +python3 -m observatory --period 2026-06 +python3 -m observatory --period 2026-06 --output reports/economics-2026-06.md +``` + +Seed data is used until Bubble (Sprint 2) and Stripe (Sprint 3) importers +replace manual entries. \ No newline at end of file diff --git a/projects/coulomb-pricing/data/costs.json b/projects/coulomb-pricing/data/costs.json new file mode 100644 index 0000000..0139597 --- /dev/null +++ b/projects/coulomb-pricing/data/costs.json @@ -0,0 +1,64 @@ +{ + "version": 1, + "period": "2026-06", + "entries": [ + { + "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" + }, + { + "id": "openrouter-ai", + "name": "OpenRouter AI consumption", + "category": "variable", + "amount": "0.00", + "currency": "EUR", + "cadence": "monthly", + "allocation": "flat", + "note": "Populated in Sprint 4 after usage import" + } + ], + "fx_rates": { + "USD/EUR": "0.92" + } +} \ No newline at end of file diff --git a/projects/coulomb-pricing/data/membership.json b/projects/coulomb-pricing/data/membership.json new file mode 100644 index 0000000..3d66d3c --- /dev/null +++ b/projects/coulomb-pricing/data/membership.json @@ -0,0 +1,40 @@ +{ + "version": 1, + "snapshot_date": "2026-06-21", + "members": [ + { + "id": "member-001", + "external_id": null, + "status": "active", + "joined_at": "2025-11-03", + "plan_id": "flat-899-eur-monthly", + "source": "seed" + }, + { + "id": "member-002", + "external_id": null, + "status": "active", + "joined_at": "2026-01-15", + "plan_id": "flat-899-eur-monthly", + "source": "seed" + }, + { + "id": "member-003", + "external_id": null, + "status": "active", + "joined_at": "2026-03-28", + "plan_id": "flat-899-eur-monthly", + "source": "seed" + }, + { + "id": "member-004", + "external_id": null, + "status": "churned", + "joined_at": "2025-08-10", + "churned_at": "2026-02-01", + "plan_id": "flat-899-eur-monthly", + "source": "seed" + } + ], + "note": "Seed data for Sprint 1; replaced by Bubble importer in Sprint 2" +} \ No newline at end of file diff --git a/projects/coulomb-pricing/data/pricing-models.json b/projects/coulomb-pricing/data/pricing-models.json new file mode 100644 index 0000000..5292c39 --- /dev/null +++ b/projects/coulomb-pricing/data/pricing-models.json @@ -0,0 +1,39 @@ +{ + "version": 1, + "models": [ + { + "id": "flat-899-eur-monthly", + "name": "Standard Membership", + "model_type": "flat_subscription", + "lifecycle_phase": "growth", + "currency": "EUR", + "access_fee_amount": "8.99", + "access_fee_cadence": "monthly", + "included_usage": "unlimited_repository_access", + "status": "active" + }, + { + "id": "membership-plus-credits", + "name": "Membership + AI Credits", + "model_type": "hybrid_subscription_usage", + "lifecycle_phase": "exploration", + "currency": "EUR", + "access_fee_amount": "8.99", + "access_fee_cadence": "monthly", + "included_usage": "monthly_ai_credit_allowance", + "status": "candidate" + }, + { + "id": "membership-plus-overage", + "name": "Membership + Overage", + "model_type": "hybrid_subscription_usage", + "lifecycle_phase": "exploration", + "currency": "EUR", + "access_fee_amount": "8.99", + "access_fee_cadence": "monthly", + "included_usage": "monthly_ai_credit_allowance", + "overage_meter": "openrouter_tokens", + "status": "candidate" + } + ] +} \ No newline at end of file diff --git a/projects/coulomb-pricing/data/product.json b/projects/coulomb-pricing/data/product.json new file mode 100644 index 0000000..5bfb55b --- /dev/null +++ b/projects/coulomb-pricing/data/product.json @@ -0,0 +1,12 @@ +{ + "id": "coulomb-social-membership", + "name": "Coulomb Social Membership", + "lifecycle_phase": "growth", + "currency": "EUR", + "description": "Repository and community access via monthly subscription.", + "active_pricing_model_id": "flat-899-eur-monthly", + "channels": { + "membership_platform": "bubble.io", + "payments": "stripe" + } +} \ No newline at end of file diff --git a/projects/coulomb-pricing/data/revenue.json b/projects/coulomb-pricing/data/revenue.json new file mode 100644 index 0000000..b4e8934 --- /dev/null +++ b/projects/coulomb-pricing/data/revenue.json @@ -0,0 +1,16 @@ +{ + "version": 1, + "entries": [ + { + "id": "rev-2026-06-estimate", + "period": "2026-06", + "gross_amount": "80.91", + "fees_amount": "2.46", + "refunds_amount": "0.00", + "net_amount": "78.45", + "currency": "EUR", + "source": "manual_estimate", + "note": "Seed estimate for Sprint 1; replaced by Stripe sync in Sprint 3" + } + ] +} \ No newline at end of file diff --git a/projects/coulomb-pricing/observatory/__init__.py b/projects/coulomb-pricing/observatory/__init__.py new file mode 100644 index 0000000..cddc47e --- /dev/null +++ b/projects/coulomb-pricing/observatory/__init__.py @@ -0,0 +1,3 @@ +"""Coulomb Social Economic Observatory — Sprint 1 foundations.""" + +__version__ = "0.1.0" \ No newline at end of file diff --git a/projects/coulomb-pricing/observatory/__main__.py b/projects/coulomb-pricing/observatory/__main__.py new file mode 100644 index 0000000..31d7646 --- /dev/null +++ b/projects/coulomb-pricing/observatory/__main__.py @@ -0,0 +1,3 @@ +from .dashboard import main + +raise SystemExit(main()) \ No newline at end of file diff --git a/projects/coulomb-pricing/observatory/dashboard.py b/projects/coulomb-pricing/observatory/dashboard.py new file mode 100644 index 0000000..c26248a --- /dev/null +++ b/projects/coulomb-pricing/observatory/dashboard.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import argparse +from pathlib import Path + +from .economics import build_snapshot +from .load import ( + default_data_dir, + load_costs, + load_membership, + load_pricing_models, + load_product, + load_revenue, +) +from .models import EconomicsSnapshot, PricingModel, Product + + +def render_dashboard( + product: Product, + models: list[PricingModel], + snapshot: EconomicsSnapshot, +) -> 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 + ) + 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 + +| 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}% | + +_Revenue source: {snapshot.revenue_source}_ + +## Pricing Model Registry + +| ID | Name | Type | Status | +|----|------|------|--------| +{registry_lines} + +## Registries Loaded + +- Product model (`data/product.json`) +- Pricing model registry (`data/pricing-models.json`) +- Cost registry (`data/costs.json`) +- Revenue registry (`data/revenue.json`) +- Membership registry (`data/membership.json`) +""" + + +def generate_dashboard(data_dir: Path | None = None, period: str | None = None) -> str: + root = data_dir or default_data_dir() + product = load_product(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 + + snapshot = build_snapshot( + target_period, + product, + models, + members, + revenue, + costs, + fx_rates, + ) + return render_dashboard(product, models, snapshot) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Coulomb Social Economics Dashboard v1") + parser.add_argument("--data-dir", type=Path, default=None, help="Registry data directory") + parser.add_argument("--period", default=None, help="Reporting period (YYYY-MM)") + parser.add_argument( + "--output", + type=Path, + default=None, + help="Write Markdown report to this path (default: stdout only)", + ) + args = parser.parse_args(argv) + + report = generate_dashboard(args.data_dir, args.period) + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(report, encoding="utf-8") + print(f"Wrote {args.output}") + else: + print(report) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/projects/coulomb-pricing/observatory/economics.py b/projects/coulomb-pricing/observatory/economics.py new file mode 100644 index 0000000..c765bc0 --- /dev/null +++ b/projects/coulomb-pricing/observatory/economics.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from decimal import Decimal, ROUND_HALF_UP + +from .models import ( + CostEntry, + EconomicsSnapshot, + MembershipRecord, + PricingModel, + Product, + RevenueEntry, +) + +TWOPLACES = Decimal("0.01") +PCTPLACES = Decimal("0.1") + + +def _quantize(value: Decimal, exp: Decimal = TWOPLACES) -> Decimal: + return value.quantize(exp, rounding=ROUND_HALF_UP) + + +def _convert_to_eur(amount: Decimal, currency: str, fx_rates: dict[str, Decimal]) -> Decimal: + if currency == "EUR": + return amount + if currency == "USD": + return amount * fx_rates.get("USD/EUR", Decimal("1")) + if currency == "ratio": + return amount + raise ValueError(f"unsupported currency for conversion: {currency}") + + +def active_members(members: list[MembershipRecord]) -> int: + return sum(1 for member in members if member.status == "active") + + +def active_pricing_model(models: list[PricingModel], product: Product) -> PricingModel: + for model in models: + if model.id == product.active_pricing_model_id: + return model + 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( + cost_entries: list[CostEntry], + fx_rates: dict[str, Decimal], + gross_revenue: Decimal, + member_count: int, +) -> Decimal: + total = Decimal("0") + for entry in cost_entries: + if entry.allocation == "flat": + total += _convert_to_eur(entry.amount, entry.currency, fx_rates) + elif entry.allocation == "percent_of_gross_revenue": + total += gross_revenue * entry.amount + elif entry.allocation == "per_active_member": + total += _convert_to_eur(entry.amount, entry.currency, fx_rates) * member_count + return _quantize(total) + + +def 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], +) -> EconomicsSnapshot: + count = active_members(members) + gross_revenue, revenue_source = estimate_monthly_revenue( + period, product, models, members, revenue_entries + ) + 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) + margin_pct = ( + _quantize((gross_margin / gross_revenue) * Decimal("100"), PCTPLACES) + if gross_revenue + else Decimal("0.0") + ) + + return EconomicsSnapshot( + period=period, + currency=product.currency, + active_members=count, + monthly_revenue=_quantize(gross_revenue), + monthly_cost=monthly_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, + ) \ No newline at end of file diff --git a/projects/coulomb-pricing/observatory/load.py b/projects/coulomb-pricing/observatory/load.py new file mode 100644 index 0000000..56fe9a8 --- /dev/null +++ b/projects/coulomb-pricing/observatory/load.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import json +from decimal import Decimal +from pathlib import Path + +from .models import ( + CostEntry, + MembershipRecord, + PricingModel, + Product, + RevenueEntry, +) + + +def _money(value: str | int | float) -> Decimal: + return Decimal(str(value)) + + +def _read_json(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + +def default_data_dir() -> Path: + return Path(__file__).resolve().parent.parent / "data" + + +def load_product(data_dir: Path | None = None) -> Product: + raw = _read_json((data_dir or default_data_dir()) / "product.json") + return Product( + id=raw["id"], + name=raw["name"], + lifecycle_phase=raw["lifecycle_phase"], + currency=raw["currency"], + description=raw["description"], + active_pricing_model_id=raw["active_pricing_model_id"], + ) + + +def load_pricing_models(data_dir: Path | None = None) -> list[PricingModel]: + raw = _read_json((data_dir or default_data_dir()) / "pricing-models.json") + return [ + PricingModel( + id=item["id"], + name=item["name"], + model_type=item["model_type"], + lifecycle_phase=item["lifecycle_phase"], + currency=item["currency"], + access_fee_amount=_money(item["access_fee_amount"]), + access_fee_cadence=item["access_fee_cadence"], + status=item["status"], + ) + for item in raw["models"] + ] + + +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 = [ + CostEntry( + id=item["id"], + name=item["name"], + category=item["category"], + amount=_money(item["amount"]), + currency=item["currency"], + cadence=item["cadence"], + allocation=item["allocation"], + ) + for item in raw["entries"] + ] + fx = {pair: _money(rate) for pair, rate in raw.get("fx_rates", {}).items()} + return raw.get("period", ""), entries, fx + + +def load_revenue(data_dir: Path | None = None) -> list[RevenueEntry]: + raw = _read_json((data_dir or default_data_dir()) / "revenue.json") + return [ + RevenueEntry( + id=item["id"], + period=item["period"], + gross_amount=_money(item["gross_amount"]), + fees_amount=_money(item["fees_amount"]), + refunds_amount=_money(item.get("refunds_amount", "0")), + net_amount=_money(item["net_amount"]), + currency=item["currency"], + source=item["source"], + ) + for item in raw["entries"] + ] + + +def load_membership(data_dir: Path | None = None) -> list[MembershipRecord]: + raw = _read_json((data_dir or default_data_dir()) / "membership.json") + return [ + MembershipRecord( + id=item["id"], + status=item["status"], + joined_at=item["joined_at"], + plan_id=item["plan_id"], + churned_at=item.get("churned_at"), + ) + for item in raw["members"] + ] \ No newline at end of file diff --git a/projects/coulomb-pricing/observatory/models.py b/projects/coulomb-pricing/observatory/models.py new file mode 100644 index 0000000..7314ce9 --- /dev/null +++ b/projects/coulomb-pricing/observatory/models.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal +from typing import Literal + +CostCategory = Literal["fixed", "variable"] +MemberStatus = Literal["active", "churned", "paused"] +PricingModelStatus = Literal["active", "candidate", "retired"] + + +@dataclass(frozen=True) +class Product: + id: str + name: str + lifecycle_phase: str + currency: str + description: str + active_pricing_model_id: str + + +@dataclass(frozen=True) +class PricingModel: + id: str + name: str + model_type: str + lifecycle_phase: str + currency: str + access_fee_amount: Decimal + access_fee_cadence: str + status: PricingModelStatus + + +@dataclass(frozen=True) +class CostEntry: + id: str + name: str + category: CostCategory + amount: Decimal + currency: str + cadence: str + allocation: str + + +@dataclass(frozen=True) +class RevenueEntry: + id: str + period: str + gross_amount: Decimal + fees_amount: Decimal + refunds_amount: Decimal + net_amount: Decimal + currency: str + source: str + + +@dataclass(frozen=True) +class MembershipRecord: + id: str + status: MemberStatus + joined_at: str + plan_id: str + churned_at: str | None = None + + +@dataclass(frozen=True) +class EconomicsSnapshot: + period: str + currency: str + active_members: int + monthly_revenue: Decimal + monthly_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 diff --git a/projects/coulomb-pricing/reports/economics-2026-06.md b/projects/coulomb-pricing/reports/economics-2026-06.md new file mode 100644 index 0000000..c2a2e6f --- /dev/null +++ b/projects/coulomb-pricing/reports/economics-2026-06.md @@ -0,0 +1,34 @@ +# Economics Dashboard v1 — Coulomb Social Membership + +**Period:** 2026-06 +**Lifecycle phase:** growth +**Active pricing model:** Standard Membership (8.99 EUR/monthly) + +## Key Metrics + +| Metric | Value | +|--------|------:| +| Active members | 3 | +| Monthly revenue | 80.91 EUR | +| Monthly cost | 74.16 EUR | +| Cost per member | 24.72 EUR | +| Gross margin | 6.75 EUR | +| Gross margin % | 8.3% | + +_Revenue source: manual_estimate_ + +## Pricing Model Registry + +| ID | Name | Type | Status | +|----|------|------|--------| +| flat-899-eur-monthly | Standard Membership | flat_subscription | active | +| membership-plus-credits | Membership + AI Credits | hybrid_subscription_usage | candidate | +| membership-plus-overage | Membership + Overage | hybrid_subscription_usage | candidate | + +## Registries Loaded + +- Product model (`data/product.json`) +- Pricing model registry (`data/pricing-models.json`) +- Cost registry (`data/costs.json`) +- Revenue registry (`data/revenue.json`) +- Membership registry (`data/membership.json`) diff --git a/projects/coulomb-pricing/tests/conftest.py b/projects/coulomb-pricing/tests/conftest.py new file mode 100644 index 0000000..e6ac727 --- /dev/null +++ b/projects/coulomb-pricing/tests/conftest.py @@ -0,0 +1,6 @@ +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) \ No newline at end of file diff --git a/projects/coulomb-pricing/tests/test_economics.py b/projects/coulomb-pricing/tests/test_economics.py new file mode 100644 index 0000000..5b9a892 --- /dev/null +++ b/projects/coulomb-pricing/tests/test_economics.py @@ -0,0 +1,57 @@ +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.load import ( + load_costs, + load_membership, + load_pricing_models, + load_product, + load_revenue, +) + +DATA_DIR = Path(__file__).resolve().parent.parent / "data" + + +def test_active_members_counts_only_active_status() -> None: + members = load_membership(DATA_DIR) + assert active_members(members) == 3 + + +def test_build_snapshot_uses_seed_revenue_for_period() -> 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) + + snapshot = build_snapshot("2026-06", product, models, members, revenue, costs, fx_rates) + + assert snapshot.active_members == 3 + assert snapshot.monthly_revenue == Decimal("80.91") + assert snapshot.revenue_source == "manual_estimate" + assert snapshot.pricing_model_count == 3 + assert snapshot.monthly_cost > Decimal("0") + assert snapshot.cost_per_member == (snapshot.monthly_cost / 3).quantize(Decimal("0.01")) + assert snapshot.gross_margin == (snapshot.monthly_revenue - snapshot.monthly_cost).quantize( + Decimal("0.01") + ) + + +def test_monthly_cost_includes_fixed_and_variable_components() -> None: + _, costs, fx_rates = load_costs(DATA_DIR) + total = monthly_cost_total(costs, fx_rates, Decimal("80.91"), 3) + # fixed: 35 USD -> 32.20 EUR + 15 EUR + 25 EUR = 72.20 + # variable: 1.5% of 80.91 + 0.25 * 3 = 1.21 + 0.75 = 1.96 + assert total == Decimal("74.16") + + +def test_dashboard_module_renders_markdown() -> 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 \ No newline at end of file diff --git a/workplans/ADAPTIVE-WP-0002-economic-observatory-mvp.md b/workplans/ADAPTIVE-WP-0002-economic-observatory-mvp.md index 1f76553..8cb72ed 100644 --- a/workplans/ADAPTIVE-WP-0002-economic-observatory-mvp.md +++ b/workplans/ADAPTIVE-WP-0002-economic-observatory-mvp.md @@ -133,7 +133,7 @@ observatory`), sample report `reports/economics-2026-06.md`, and pytest suite. ```task id: ADAPTIVE-WP-0002-T02 -status: wait +status: todo priority: high state_hub_task_id: "42c181f9-9f4e-414e-aa94-b08c763abdef" ```