From a1a4aa972faf90b1f4d7d940815ff2eed08fc60f Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 22 Jun 2026 01:32:48 +0200 Subject: [PATCH] Implement ADAPTIVE-WP-0002 Sprint 1 economic foundations Add Coulomb observatory package with JSON registries (product, pricing models, costs, revenue, membership), economics snapshot engine, Economics Dashboard v1 CLI, sample report, and pytest coverage. Complete T01 and queue Sprint 2 Bubble.io integration. --- AGENTS.md | 24 ++-- projects/coulomb-pricing/README.md | 32 ++++- projects/coulomb-pricing/data/costs.json | 64 ++++++++++ projects/coulomb-pricing/data/membership.json | 40 +++++++ .../coulomb-pricing/data/pricing-models.json | 39 +++++++ projects/coulomb-pricing/data/product.json | 12 ++ projects/coulomb-pricing/data/revenue.json | 16 +++ .../coulomb-pricing/observatory/__init__.py | 3 + .../coulomb-pricing/observatory/__main__.py | 3 + .../coulomb-pricing/observatory/dashboard.py | 107 +++++++++++++++++ .../coulomb-pricing/observatory/economics.py | 109 ++++++++++++++++++ projects/coulomb-pricing/observatory/load.py | 103 +++++++++++++++++ .../coulomb-pricing/observatory/models.py | 77 +++++++++++++ .../reports/economics-2026-06.md | 34 ++++++ projects/coulomb-pricing/tests/conftest.py | 6 + .../coulomb-pricing/tests/test_economics.py | 57 +++++++++ ...APTIVE-WP-0002-economic-observatory-mvp.md | 2 +- 17 files changed, 709 insertions(+), 19 deletions(-) create mode 100644 projects/coulomb-pricing/data/costs.json create mode 100644 projects/coulomb-pricing/data/membership.json create mode 100644 projects/coulomb-pricing/data/pricing-models.json create mode 100644 projects/coulomb-pricing/data/product.json create mode 100644 projects/coulomb-pricing/data/revenue.json create mode 100644 projects/coulomb-pricing/observatory/__init__.py create mode 100644 projects/coulomb-pricing/observatory/__main__.py create mode 100644 projects/coulomb-pricing/observatory/dashboard.py create mode 100644 projects/coulomb-pricing/observatory/economics.py create mode 100644 projects/coulomb-pricing/observatory/load.py create mode 100644 projects/coulomb-pricing/observatory/models.py create mode 100644 projects/coulomb-pricing/reports/economics-2026-06.md create mode 100644 projects/coulomb-pricing/tests/conftest.py create mode 100644 projects/coulomb-pricing/tests/test_economics.py 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" ```