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:
24
AGENTS.md
24
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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
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.
|
||||
64
projects/coulomb-pricing/data/costs.json
Normal file
64
projects/coulomb-pricing/data/costs.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
40
projects/coulomb-pricing/data/membership.json
Normal file
40
projects/coulomb-pricing/data/membership.json
Normal file
@@ -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"
|
||||
}
|
||||
39
projects/coulomb-pricing/data/pricing-models.json
Normal file
39
projects/coulomb-pricing/data/pricing-models.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
12
projects/coulomb-pricing/data/product.json
Normal file
12
projects/coulomb-pricing/data/product.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
16
projects/coulomb-pricing/data/revenue.json
Normal file
16
projects/coulomb-pricing/data/revenue.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
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
|
||||
34
projects/coulomb-pricing/reports/economics-2026-06.md
Normal file
34
projects/coulomb-pricing/reports/economics-2026-06.md
Normal file
@@ -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`)
|
||||
6
projects/coulomb-pricing/tests/conftest.py
Normal file
6
projects/coulomb-pricing/tests/conftest.py
Normal file
@@ -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))
|
||||
57
projects/coulomb-pricing/tests/test_economics.py
Normal file
57
projects/coulomb-pricing/tests/test_economics.py
Normal file
@@ -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
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user