generated from coulomb/repo-seed
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:
3
projects/coulomb-pricing/observatory/__init__.py
Normal file
3
projects/coulomb-pricing/observatory/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Coulomb Social Economic Observatory — Sprint 1 foundations."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
3
projects/coulomb-pricing/observatory/__main__.py
Normal file
3
projects/coulomb-pricing/observatory/__main__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .dashboard import main
|
||||
|
||||
raise SystemExit(main())
|
||||
107
projects/coulomb-pricing/observatory/dashboard.py
Normal file
107
projects/coulomb-pricing/observatory/dashboard.py
Normal 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())
|
||||
109
projects/coulomb-pricing/observatory/economics.py
Normal file
109
projects/coulomb-pricing/observatory/economics.py
Normal 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,
|
||||
)
|
||||
103
projects/coulomb-pricing/observatory/load.py
Normal file
103
projects/coulomb-pricing/observatory/load.py
Normal 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"]
|
||||
]
|
||||
77
projects/coulomb-pricing/observatory/models.py
Normal file
77
projects/coulomb-pricing/observatory/models.py
Normal 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
|
||||
Reference in New Issue
Block a user