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.
This commit is contained in:
2026-06-22 01:32:48 +02:00
parent d648a3263d
commit a1a4aa972f
17 changed files with 709 additions and 19 deletions

View File

@@ -0,0 +1,3 @@
"""Coulomb Social Economic Observatory — Sprint 1 foundations."""
__version__ = "0.1.0"

View File

@@ -0,0 +1,3 @@
from .dashboard import main
raise SystemExit(main())

View File

@@ -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())

View File

@@ -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,
)

View File

@@ -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"]
]

View File

@@ -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