Complete Economic Observatory MVP (ADAPTIVE-WP-0002)

Add file-based Bubble, Stripe, and OpenRouter importers; usage attribution,
cost allocation, pricing simulator, credit wallets, and recommendations in the
dashboard API. Document whynot-design UI workflow and archive the finished
workplan with all ten tasks marked done.
This commit is contained in:
2026-06-22 23:23:31 +02:00
parent 04ee6d2421
commit 0a38def5a5
26 changed files with 871 additions and 111 deletions

View File

@@ -166,7 +166,8 @@ get wrong.
Do not place project-specific MVP documentation in `specs/` or other generic
paths. The Coulomb Social MVP lives under `projects/coulomb-pricing/`; its
workplan remains `workplans/ADAPTIVE-WP-0002-economic-observatory-mvp.md`.
Coulomb MVP workplan is archived at
`workplans/archived/260622-ADAPTIVE-WP-0002-economic-observatory-mvp.md`.
---

View File

@@ -19,5 +19,5 @@ pricing to payment-provider execution.
## Status
Early framework phase (documentation and research). First implementation:
[Economic Observatory MVP](workplans/ADAPTIVE-WP-0002-economic-observatory-mvp.md)
[Economic Observatory MVP](workplans/archived/260622-ADAPTIVE-WP-0002-economic-observatory-mvp.md) (finished)
for Coulomb Social.

View File

@@ -4,11 +4,13 @@ Project-specific material for the Coulomb Social Economic Observatory MVP.
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`.
`workplans/archived/260622-ADAPTIVE-WP-0002-economic-observatory-mvp.md`
(finished 2026-06-22).
Liquidity and cost requirements: `REQUIREMENTS.md`.
Liquidity and cost requirements: `REQUIREMENTS.md`.
UI workflow (whynot-design): `docs/UI-WORKFLOW.md`.
## Sprint 1 — Economic Foundations
## Economic Observatory
The `observatory/` package reads **expense and payment record ledgers** and
computes all totals programmatically (`ledger.py``economics.py`).
@@ -21,6 +23,10 @@ computes all totals programmatically (`ledger.py` → `economics.py`).
| Product model | `data/product.json` |
| Pricing models | `data/pricing-models.json` |
| Membership | `data/membership.json` |
| AI usage | `data/usage_records.json` |
| Credit wallets | `data/credit_wallets.json` |
| Value range hypotheses | `data/value_range.json` |
| Market signals | `data/market_signals.json` |
**Current reality:** infrastructure from January 2025 — domains **€6.75/mo**,
coulombcore hosting **€13.99/mo** (from Jan 2025), railiance01 hosting
@@ -42,9 +48,16 @@ make serve
Open **http://127.0.0.1:8765/** for the Economic Observatory UI (served from
`ui/`, data via `/api/dashboard`). The UI consumes **whynot-design** (Layer 1 CSS
+ Layer 2 `<wn-*>` components) from `ui/vendor/whynot-design/` — re-run the sync
script after bumping the pinned ref in `.whynot-design-ref`. Claude atelier mock
(reference only): https://claude.ai/design/p/fb2eef8c-c1fc-4c75-bff4-3782552e5511
+ Layer 2 `<wn-*>` components) from `ui/vendor/whynot-design/`. See
`docs/UI-WORKFLOW.md` for the implementation process.
Manual ledgers will be replaced by Bubble (Sprint 2), Stripe (Sprint 3), and
OpenRouter (Sprint 4) importers.
### Importers (file-based sync)
```bash
python3 -m observatory.importers.bubble --input data/imports/bubble-export.sample.json
python3 -m observatory.importers.stripe --input data/imports/stripe-export.sample.json
python3 -m observatory.importers.openrouter --input data/imports/openrouter-export.sample.json
```
Sample exports live under `data/imports/`. Live API sync can replace these
file-based importers in a follow-on workplan.

View File

@@ -0,0 +1,11 @@
{
"version": 1,
"currency": "EUR",
"wallets": [
{
"member_id": "member-tegwick",
"monthly_allowance_eur": "2.00",
"note": "Observatory-only allowance for hybrid pricing experiments"
}
]
}

View File

@@ -0,0 +1,12 @@
{
"exported_at": "2026-06-22",
"users": [
{
"bubble_id": "bubble-tegwick-001",
"username": "tegwick",
"status": "Active",
"created": "2025-11-03",
"plan": "flat-899-eur-monthly"
}
]
}

View File

@@ -0,0 +1,11 @@
{
"usage": [
{
"period": "2026-06",
"user_id": "member-tegwick",
"model": "anthropic/claude-3-haiku",
"tokens": 48200,
"cost_usd": "0.06"
}
]
}

View File

@@ -0,0 +1,15 @@
{
"charges": [
{
"id": "pay-2026-06",
"period": "2026-06",
"gross": "8.99",
"fee": "0.44",
"net": "8.55",
"currency": "EUR",
"customer": "tegwick",
"product": "coulomb.social-membership",
"payout_account": "binky-hedgehog"
}
]
}

View File

@@ -0,0 +1,16 @@
{
"version": 1,
"fx_usd_eur": "0.92",
"records": [
{
"id": "usage-2026-06-tegwick",
"period": "2026-06",
"member_id": "member-tegwick",
"model": "anthropic/claude-3-haiku",
"tokens": 48200,
"cost_usd": "0.06",
"cost_eur": "0.06",
"source": "openrouter"
}
]
}

View File

@@ -0,0 +1,58 @@
# Economic Observatory UI — whynot-design workflow
Build UI from what exists in **whynot-design** (`~/whynot-design` or
`gitea:whynot/whynot-design`). Do not invent parallel components or tokens.
## Before you build
1. **Sync the vendor tree**`make design` (optional `REF=<tag-or-sha>`).
2. **Browse upstream** — inspect `src/elements/` and `src/styles/` in
whynot-design for an existing `<wn-*>` element or `.wn-*` class that fits.
3. **Check the atelier mock** — layout reference only:
https://claude.ai/design/p/fb2eef8c-c1fc-4c75-bff4-3782552e5511
## Layer model
| Layer | Source | Observatory usage |
|-------|--------|-------------------|
| **Layer 1** | `colors_and_type.css`, `components.css`, `tokens/*.json` | Global look; CSS variables (`--paper`, `--sp-*`, `--fg-*`) |
| **Layer 2** | `index.js` + `elements/*.js` (Lit) | App chrome: `wn-top-nav`, `wn-sidebar`, `wn-card`, `wn-field-row`, … |
| **Layer 3** | `ui/styles.css`, `ui/app.js` | Observatory-only layout (`.obs-*`) and data binding |
Layer 3 must consume Layer 1 tokens and Layer 2 components. Avoid gradients,
card shadows, and colours outside the token set.
## Implementation rules
1. **Prefer `<wn-*>` over custom markup** — use `wn-card`, `wn-eyebrow`,
`wn-banner`, `wn-tag`, `wn-field-row`, `wn-table--native` before adding new
patterns.
2. **Extend with `.obs-*`, not `.wn-*`** — never edit vendored CSS/JS; add
layout in `ui/styles.css` only.
3. **Tables** — use `<table class="wn-table--native">` for ledger data; the
shadow-DOM `wn-table` is for Lit-only grids.
4. **Typography** — use `.lead`, `.small`, `.mono` from Layer 1; metric values
use `.obs-metric__value` with `font-variant-numeric: tabular-nums`.
5. **New upstream needs** — add the component or token in whynot-design first,
then `make design` to vendor it here. Do not fork one-off elements into
`ui/`.
## File touch map
| Change | Files |
|--------|-------|
| New section / panel | `ui/index.html`, `ui/app.js`, maybe `ui/styles.css` |
| Chrome or shared widget | whynot-design repo → `make design` |
| Observatory layout only | `ui/styles.css` |
| Data for a panel | `observatory/api.py` + tests |
## Verify
```bash
cd projects/coulomb-pricing
make design # if vendor changed
make test
make serve # http://127.0.0.1:8765/
```
Footer shows pinned ref from `ui/vendor/whynot-design/.whynot-design-ref`.

View File

@@ -1,3 +1,3 @@
"""Coulomb Social Economic Observatory — Sprint 1 foundations."""
"""Coulomb Social Economic Observatory — MVP (ledger, API, importers, simulator)."""
__version__ = "0.1.0"

View File

@@ -0,0 +1,32 @@
from __future__ import annotations
from decimal import Decimal
from .models import EconomicsSnapshot
from .usage import build_usage_summary
def build_cost_allocation(
snapshot: EconomicsSnapshot,
usage_records: list[dict],
) -> dict:
usage = build_usage_summary(usage_records, snapshot.period)
variable_ai = usage["total_ai_spend_eur"]
fixed = snapshot.monthly_infrastructure_cost
variable_processing = snapshot.monthly_payment_processing_cost
total = fixed + variable_processing + variable_ai
contribution = snapshot.monthly_revenue - total
return {
"period": snapshot.period,
"currency": snapshot.currency,
"fixed_costs_eur": fixed,
"variable_processing_eur": variable_processing,
"variable_ai_eur": variable_ai,
"total_platform_cost_eur": total,
"cost_floor_eur": snapshot.cost_per_member,
"contribution_margin_eur": contribution,
"contribution_margin_pct": snapshot.gross_margin_pct,
"cost_per_member_eur": snapshot.cost_per_member,
"active_members": snapshot.active_members,
}

View File

@@ -19,7 +19,13 @@ from .load import (
load_product,
load_value_range,
)
from .allocation import build_cost_allocation
from .credits import build_credit_summary, load_credit_wallets
from .membership_analytics import build_membership_analytics
from .pricing_context import build_cost_floor, build_market_price_view, build_value_range_view
from .recommendations import build_pricing_recommendations
from .simulator import build_pricing_simulations
from .usage import build_usage_summary, load_usage_records
def _serialize(value: Any) -> Any:
@@ -77,6 +83,23 @@ def build_dashboard_payload(data_dir: Path | None = None, period: str | None = N
value_range_raw = load_value_range(root)
market_raw = load_market_signals(root)
usage_records = load_usage_records(root)
usage_summary = build_usage_summary(usage_records, target_period)
cost_floor = build_cost_floor(snapshot, models)
value_range = build_value_range_view(value_range_raw, snapshot, product, models)
market_price = build_market_price_view(market_raw)
cost_allocation = build_cost_allocation(snapshot, usage_records)
ai_cost_per_member = usage_summary["cost_per_active_user_eur"]
simulations = build_pricing_simulations(snapshot, models, ai_cost_per_member)
credit_wallets = load_credit_wallets(root)
credit_summary = build_credit_summary(
credit_wallets,
{key: value for key, value in usage_summary["by_member"].items()},
target_period,
)
recommendations = build_pricing_recommendations(
cost_floor, value_range, market_price, simulations, usage_summary
)
return _serialize(
{
@@ -91,9 +114,17 @@ def build_dashboard_payload(data_dir: Path | None = None, period: str | None = N
"members": members,
"payments": payments,
"expense_record_count": len(expenses),
"cost_floor": build_cost_floor(snapshot, models),
"value_range": build_value_range_view(value_range_raw, snapshot, product, models),
"market_price": build_market_price_view(market_raw),
"membership_analytics": build_membership_analytics(
members, target_period, [row["period"] for row in history]
),
"cost_floor": cost_floor,
"value_range": value_range,
"market_price": market_price,
"usage": usage_summary,
"cost_allocation": cost_allocation,
"pricing_simulations": simulations,
"credit_wallets": credit_summary,
"recommendations": recommendations,
"infrastructure": {
"domains": _load_json_catalog(root, "domains.json"),
"virtual_servers": _load_json_catalog(root, "virtual_servers.json"),

View File

@@ -0,0 +1,48 @@
from __future__ import annotations
from decimal import Decimal, ROUND_HALF_UP
from pathlib import Path
from typing import Any
from .load import _read_json, default_data_dir
TWOPLACES = Decimal("0.01")
def _money(value: Decimal) -> Decimal:
return value.quantize(TWOPLACES, rounding=ROUND_HALF_UP)
def load_credit_wallets(data_dir=None) -> dict[str, Any]:
root = data_dir or default_data_dir()
path = Path(root) / "credit_wallets.json"
if not path.exists():
return {"version": 1, "currency": "EUR", "wallets": []}
return _read_json(path)
def build_credit_summary(raw: dict, usage_by_member: dict[str, Decimal], period: str) -> dict:
wallets = []
for item in raw.get("wallets", []):
member_id = item["member_id"]
allowance = Decimal(str(item.get("monthly_allowance_eur", "0")))
used = usage_by_member.get(member_id, Decimal(str(item.get("used_eur", "0"))))
remaining = max(Decimal("0"), allowance - used)
wallets.append(
{
"member_id": member_id,
"period": period,
"monthly_allowance_eur": _money(allowance),
"used_eur": _money(used),
"remaining_eur": _money(remaining),
"overage_eur": _money(max(Decimal("0"), used - allowance)),
}
)
return {
"period": period,
"currency": raw.get("currency", "EUR"),
"wallet_count": len(wallets),
"wallets": wallets,
"notes": "Observatory-only credit accounting; no customer billing in MVP.",
}

View File

@@ -0,0 +1 @@
"""File-based importers for Bubble, Stripe, and OpenRouter exports."""

View File

@@ -0,0 +1,13 @@
from __future__ import annotations
import json
from pathlib import Path
def read_export(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def write_registry(path: Path, payload: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")

View File

@@ -0,0 +1,55 @@
from __future__ import annotations
import argparse
from pathlib import Path
from . import _io
BUBBLE_STATUS = {"Active": "active", "Cancelled": "churned", "Paused": "paused"}
def import_membership(export: dict, plan_id: str = "flat-899-eur-monthly") -> dict:
members = []
for index, user in enumerate(export.get("users", []), start=1):
bubble_id = user.get("bubble_id") or user.get("_id") or f"bubble-{index}"
username = user.get("username") or user.get("email") or bubble_id
status = BUBBLE_STATUS.get(user.get("status", "Active"), "active")
members.append(
{
"id": f"member-{username}",
"username": username,
"external_id": bubble_id,
"status": status,
"joined_at": user.get("created") or user.get("joined_at"),
"plan_id": user.get("plan") or plan_id,
"source": "bubble",
"churned_at": user.get("cancelled_at") if status == "churned" else None,
}
)
return {
"version": 1,
"snapshot_date": export.get("exported_at", export.get("snapshot_date")),
"members": members,
"note": "Imported from Bubble export",
}
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Import Bubble membership export")
parser.add_argument("--input", type=Path, required=True, help="Bubble JSON export")
parser.add_argument(
"--output",
type=Path,
default=Path(__file__).resolve().parent.parent.parent / "data" / "membership.json",
)
parser.add_argument("--plan-id", default="flat-899-eur-monthly")
args = parser.parse_args(argv)
payload = import_membership(_io.read_export(args.input), args.plan_id)
_io.write_registry(args.output, payload)
print(f"Wrote {len(payload['members'])} members → {args.output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,57 @@
from __future__ import annotations
import argparse
from decimal import Decimal
from pathlib import Path
from . import _io
def _money(value: str | int | float) -> str:
return f"{Decimal(str(value)):.2f}"
def import_usage(export: dict, fx_usd_eur: str = "0.92") -> dict:
rate = Decimal(str(fx_usd_eur))
records = []
for index, row in enumerate(export.get("usage", export.get("records", [])), start=1):
cost_usd = Decimal(str(row.get("cost_usd") or row.get("cost", "0")))
cost_eur = (cost_usd * rate).quantize(Decimal("0.01"))
records.append(
{
"id": row.get("id") or f"usage-{row['period']}-{index}",
"period": row["period"],
"member_id": row.get("member_id") or row.get("user_id"),
"model": row["model"],
"tokens": row.get("tokens", 0),
"cost_usd": _money(cost_usd),
"cost_eur": _money(cost_eur),
"source": "openrouter",
}
)
return {
"version": 1,
"fx_usd_eur": str(rate),
"records": sorted(records, key=lambda item: (item["period"], item["id"])),
}
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Import OpenRouter usage export")
parser.add_argument("--input", type=Path, required=True, help="OpenRouter JSON export")
parser.add_argument(
"--output",
type=Path,
default=Path(__file__).resolve().parent.parent.parent / "data" / "usage_records.json",
)
parser.add_argument("--fx-usd-eur", default="0.92")
args = parser.parse_args(argv)
payload = import_usage(_io.read_export(args.input), args.fx_usd_eur)
_io.write_registry(args.output, payload)
print(f"Wrote {len(payload['records'])} usage records → {args.output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,54 @@
from __future__ import annotations
import argparse
from decimal import Decimal
from pathlib import Path
from . import _io
def _money(value: str | int | float) -> str:
return f"{Decimal(str(value)):.2f}"
def import_payments(export: dict) -> dict:
records = []
for index, charge in enumerate(export.get("charges", export.get("records", [])), start=1):
period = charge["period"]
records.append(
{
"id": charge.get("id") or f"pay-{period}-{index}",
"period": period,
"gross_amount": _money(charge.get("gross") or charge["gross_amount"]),
"fees_amount": _money(charge.get("fee") or charge.get("fees_amount", "0")),
"refunds_amount": _money(charge.get("refunds_amount", "0")),
"net_amount": _money(charge.get("net") or charge["net_amount"]),
"currency": charge.get("currency", "EUR"),
"source": "stripe",
"member_count": charge.get("member_count", 1),
"member_username": charge.get("customer") or charge.get("member_username"),
"product": charge.get("product"),
"payout_account": charge.get("payout_account"),
}
)
return {"version": 2, "records": sorted(records, key=lambda item: item["period"])}
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Import Stripe charge export")
parser.add_argument("--input", type=Path, required=True, help="Stripe JSON export")
parser.add_argument(
"--output",
type=Path,
default=Path(__file__).resolve().parent.parent.parent / "data" / "payment_records.json",
)
args = parser.parse_args(argv)
payload = import_payments(_io.read_export(args.input))
_io.write_registry(args.output, payload)
print(f"Wrote {len(payload['records'])} payment records → {args.output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,48 @@
from __future__ import annotations
from .models import MembershipRecord
def _period_prefix(date: str | None) -> str | None:
if not date or len(date) < 7:
return None
return date[:7]
def build_membership_analytics(
members: list[MembershipRecord],
period: str,
history: list[str],
) -> dict:
active = sum(1 for member in members if member.status == "active")
new_members = sum(1 for member in members if _period_prefix(member.joined_at) == period)
churned = sum(1 for member in members if _period_prefix(member.churned_at) == period)
prior_period = None
sorted_periods = sorted(history)
if period in sorted_periods:
index = sorted_periods.index(period)
if index > 0:
prior_period = sorted_periods[index - 1]
prior_active = active
if prior_period:
prior_active = sum(
1
for member in members
if member.status == "active" and _period_prefix(member.joined_at) <= prior_period
)
growth_rate = None
if prior_active:
growth_rate = round(((active - prior_active) / prior_active) * 100, 1)
return {
"period": period,
"total_members": len(members),
"active_members": active,
"new_members": new_members,
"churned_members": churned,
"growth_rate_pct": growth_rate,
"snapshot_source": "membership.json",
}

View File

@@ -0,0 +1,68 @@
from __future__ import annotations
from decimal import Decimal
def build_pricing_recommendations(
cost_floor: dict,
value_range: dict,
market_price: dict,
simulations: dict,
usage_summary: dict,
) -> list[dict]:
recommendations: list[dict] = []
margin_pct = Decimal(str(cost_floor.get("gross_margin_pct", "0")))
ai_spend = Decimal(str(usage_summary.get("total_ai_spend_eur", "0")))
active_price = Decimal(str(value_range.get("current_price_eur", "0")))
cost_per_member = Decimal(str(cost_floor.get("cost_per_member", "0")))
if margin_pct < Decimal("10"):
recommendations.append(
{
"id": "margin-pressure",
"priority": "high",
"title": "Margin below 10%",
"rationale": f"Gross margin is {margin_pct}% at current pricing.",
"suggested_action": "Review infrastructure cost or test a higher access fee within value-range bands.",
}
)
if ai_spend > Decimal("0") and cost_per_member > Decimal("0"):
ai_ratio = (ai_spend / cost_per_member) * Decimal("100")
if ai_ratio > Decimal("15"):
best = simulations.get("best_margin_scenario_id")
recommendations.append(
{
"id": "usage-pricing-signal",
"priority": "medium",
"title": "AI cost becoming material",
"rationale": f"AI spend is {ai_ratio:.1f}% of cost-per-member this period.",
"suggested_action": f"Evaluate hybrid model '{best}' in the pricing simulator before customer-visible credits.",
}
)
if market_price.get("market_high_eur") and active_price < Decimal(str(market_price["market_high_eur"])):
headroom = Decimal(str(value_range.get("aggregate_high_eur", active_price))) - active_price
if headroom > Decimal("5"):
recommendations.append(
{
"id": "value-headroom",
"priority": "low",
"title": "Value headroom above list price",
"rationale": f"Aggregate value band high is €{value_range['aggregate_high_eur']} vs €{active_price} list.",
"suggested_action": "Run a staged price experiment within the solo-builder segment band.",
}
)
if not recommendations:
recommendations.append(
{
"id": "hold-course",
"priority": "low",
"title": "Hold current pricing",
"rationale": "No urgent margin, usage, or competitive signals detected.",
"suggested_action": "Continue observatory tracking; re-run after next ledger period.",
}
)
return recommendations

View File

@@ -0,0 +1,70 @@
from __future__ import annotations
from decimal import Decimal, ROUND_HALF_UP
from .models import EconomicsSnapshot, PricingModel
TWOPLACES = Decimal("0.01")
OVERAGE_RATE = Decimal("0.002") # EUR per token above allowance (observatory estimate)
def _money(value: Decimal) -> Decimal:
return value.quantize(TWOPLACES, rounding=ROUND_HALF_UP)
def _simulate_model(
model: PricingModel,
snapshot: EconomicsSnapshot,
ai_cost_per_member: Decimal,
included_tokens: int = 100_000,
actual_tokens: int = 120_000,
) -> dict:
members = snapshot.active_members or 1
subscription_revenue = model.access_fee_amount * members
overage_revenue = Decimal("0")
if model.model_type == "hybrid_subscription_usage" and actual_tokens > included_tokens:
overage_tokens = actual_tokens - included_tokens
overage_revenue = OVERAGE_RATE * overage_tokens * members
gross_revenue = subscription_revenue + overage_revenue
platform_cost = snapshot.monthly_total_platform_cost + (ai_cost_per_member * members)
margin = gross_revenue - platform_cost
margin_pct = (margin / gross_revenue * Decimal("100")) if gross_revenue else Decimal("0")
return {
"model_id": model.id,
"model_name": model.name,
"model_type": model.model_type,
"status": model.status,
"access_fee_eur": model.access_fee_amount,
"projected_revenue_eur": _money(gross_revenue),
"projected_overage_eur": _money(overage_revenue),
"projected_platform_cost_eur": _money(platform_cost),
"projected_margin_eur": _money(margin),
"projected_margin_pct": _money(margin_pct),
"assumed_tokens_per_member": actual_tokens,
"included_tokens": included_tokens if model.model_type != "flat_subscription" else None,
}
def build_pricing_simulations(
snapshot: EconomicsSnapshot,
models: list[PricingModel],
ai_cost_per_member: Decimal,
) -> dict:
scenarios = [
_simulate_model(model, snapshot, ai_cost_per_member)
for model in models
if model.status in ("active", "candidate")
]
active = next((item for item in scenarios if item["status"] == "active"), scenarios[0])
best_margin = max(scenarios, key=lambda item: item["projected_margin_eur"])
return {
"period": snapshot.period,
"currency": snapshot.currency,
"active_scenario_id": active["model_id"],
"best_margin_scenario_id": best_margin["model_id"],
"scenarios": scenarios,
"notes": "Projections hold member count and infrastructure cost constant; overage uses observatory token estimate.",
}

View File

@@ -0,0 +1,48 @@
from __future__ import annotations
from decimal import Decimal, ROUND_HALF_UP
from typing import Any
TWOPLACES = Decimal("0.01")
def _money(value: Decimal) -> Decimal:
return value.quantize(TWOPLACES, rounding=ROUND_HALF_UP)
def load_usage_records(data_dir) -> list[dict[str, Any]]:
from pathlib import Path
from .load import _read_json, default_data_dir
root = data_dir or default_data_dir()
path = Path(root) / "usage_records.json"
if not path.exists():
return []
raw = _read_json(path)
return list(raw.get("records", []))
def build_usage_summary(records: list[dict[str, Any]], period: str) -> dict:
period_rows = [row for row in records if row.get("period") == period]
by_member: dict[str, Decimal] = {}
by_model: dict[str, Decimal] = {}
total = Decimal("0")
for row in period_rows:
cost = Decimal(str(row.get("cost_eur", "0")))
total += cost
member_id = row.get("member_id") or "unknown"
model = row.get("model") or "unknown"
by_member[member_id] = by_member.get(member_id, Decimal("0")) + cost
by_model[model] = by_model.get(model, Decimal("0")) + cost
active_members = len(by_member) or 1
return {
"period": period,
"total_ai_spend_eur": _money(total),
"cost_per_active_user_eur": _money(total / active_members),
"by_member": {key: _money(value) for key, value in sorted(by_member.items())},
"by_model": {key: _money(value) for key, value in sorted(by_model.items())},
"record_count": len(period_rows),
}

View File

@@ -21,6 +21,10 @@ def test_dashboard_payload_contains_live_ledger_totals() -> None:
assert payload["cost_floor"]["active_price"] == "8.99"
assert len(payload["value_range"]["segments"]) == 2
assert payload["market_price"]["alternative_count"] == 4
assert payload["membership_analytics"]["active_members"] == 1
assert payload["usage"]["record_count"] == 1
assert len(payload["pricing_simulations"]["scenarios"]) == 3
assert payload["recommendations"]
def test_payload_json_is_valid() -> None:

View File

@@ -0,0 +1,42 @@
from __future__ import annotations
from pathlib import Path
from observatory.importers.bubble import import_membership
from observatory.importers.openrouter import import_usage
from observatory.importers.stripe import import_payments
IMPORTS = Path(__file__).resolve().parent.parent / "data" / "imports"
def test_bubble_import_maps_active_member() -> None:
import json
export = json.loads((IMPORTS / "bubble-export.sample.json").read_text(encoding="utf-8"))
payload = import_membership(export)
assert len(payload["members"]) == 1
assert payload["members"][0]["id"] == "member-tegwick"
assert payload["members"][0]["status"] == "active"
assert payload["members"][0]["source"] == "bubble"
def test_stripe_import_normalises_charge() -> None:
import json
export = json.loads((IMPORTS / "stripe-export.sample.json").read_text(encoding="utf-8"))
payload = import_payments(export)
assert payload["records"][0]["gross_amount"] == "8.99"
assert payload["records"][0]["net_amount"] == "8.55"
assert payload["records"][0]["member_username"] == "tegwick"
def test_openrouter_import_converts_cost_to_eur() -> None:
import json
export = json.loads((IMPORTS / "openrouter-export.sample.json").read_text(encoding="utf-8"))
payload = import_usage(export, fx_usd_eur="0.92")
assert payload["records"][0]["cost_eur"] == "0.06"
assert payload["records"][0]["member_id"] == "member-tegwick"

View File

@@ -0,0 +1,96 @@
from __future__ import annotations
from decimal import Decimal
from pathlib import Path
from observatory.allocation import build_cost_allocation
from observatory.api import build_dashboard_payload
from observatory.credits import build_credit_summary, load_credit_wallets
from observatory.economics import build_snapshot
from observatory.load import (
load_membership,
load_monthly_ledger,
load_payment_records,
load_pricing_models,
load_product,
)
from observatory.membership_analytics import build_membership_analytics
from observatory.recommendations import build_pricing_recommendations
from observatory.simulator import build_pricing_simulations
from observatory.usage import build_usage_summary, load_usage_records
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
def _snapshot(period: str = "2026-06"):
product = load_product(DATA_DIR)
models = load_pricing_models(DATA_DIR)
members = load_membership(DATA_DIR)
payments = load_payment_records(DATA_DIR)
ledger = load_monthly_ledger(DATA_DIR)
return build_snapshot(period, product, models, members, payments, ledger)
def test_membership_analytics_counts_active_member() -> None:
members = load_membership(DATA_DIR)
analytics = build_membership_analytics(members, "2026-06", ["2026-05", "2026-06"])
assert analytics["active_members"] == 1
assert analytics["total_members"] == 1
def test_usage_summary_attributes_member_cost() -> None:
records = load_usage_records(DATA_DIR)
summary = build_usage_summary(records, "2026-06")
assert summary["record_count"] == 1
assert summary["by_member"]["member-tegwick"] == Decimal("0.06")
def test_cost_allocation_includes_ai_variable_cost() -> None:
snapshot = _snapshot()
allocation = build_cost_allocation(snapshot, load_usage_records(DATA_DIR))
assert allocation["variable_ai_eur"] == Decimal("0.06")
assert allocation["cost_floor_eur"] == snapshot.cost_per_member
def test_pricing_simulator_compares_candidate_models() -> None:
snapshot = _snapshot()
models = load_pricing_models(DATA_DIR)
simulations = build_pricing_simulations(snapshot, models, Decimal("0.06"))
assert len(simulations["scenarios"]) == 3
assert simulations["active_scenario_id"] == "flat-899-eur-monthly"
def test_credit_summary_tracks_remaining_allowance() -> None:
wallets = load_credit_wallets(DATA_DIR)
summary = build_credit_summary(wallets, {"member-tegwick": Decimal("0.06")}, "2026-06")
assert summary["wallets"][0]["remaining_eur"] == Decimal("1.94")
def test_recommendations_include_hold_or_action() -> None:
payload = build_dashboard_payload(DATA_DIR, "2026-06")
recs = build_pricing_recommendations(
payload["cost_floor"],
payload["value_range"],
payload["market_price"],
payload["pricing_simulations"],
payload["usage"],
)
assert recs
assert recs[0]["id"] in {"margin-pressure", "usage-pricing-signal", "value-headroom", "hold-course"}
def test_dashboard_payload_includes_mvp_sections() -> None:
payload = build_dashboard_payload(DATA_DIR, "2026-06")
assert "membership_analytics" in payload
assert "usage" in payload
assert "cost_allocation" in payload
assert "pricing_simulations" in payload
assert "credit_wallets" in payload
assert "recommendations" in payload

View File

@@ -4,7 +4,7 @@ type: workplan
title: "Economic Observatory MVP (Coulomb Social)"
domain: helix_forge
repo: adaptive-pricing
status: active
status: finished
owner: codex
topic_slug: helix-forge
created: "2026-06-21"
@@ -52,33 +52,6 @@ model registry.
**Excluded:** dynamic pricing, automated price changes, customer-tunable pricing,
advanced LTV optimization, marketplace pricing.
### Architecture
```text
Bubble.io
|
+-- Membership Data
|
Stripe
|
+-- Revenue Data
+-- Fee Data
|
OpenRouter
|
+-- Usage Data
+-- Cost Data
|
Adaptive Pricing MVP
|
+-- Cost Registry
+-- Revenue Registry
+-- Usage Registry
+-- Cost Allocation Engine
+-- Pricing Simulator
+-- Reporting Dashboard
```
### Success Criteria
The MVP is successful when it can:
@@ -91,20 +64,9 @@ The MVP is successful when it can:
- Explain economic outcomes
- Produce pricing recommendations
### Future Phases
| Phase | Focus |
|-------|-------|
| Phase 2 | Customer-visible AI credits |
| Phase 3 | Usage-based billing |
| Phase 4 | Customer-tunable pricing |
| Phase 5 | Constraint-based pricing solver |
| Phase 6 | Auto-Regulating Market Value Exploring Price Engine |
The MVP should establish a data-driven foundation for pricing decisions and
generate the real-world observations necessary to evolve toward adaptive pricing,
customer-tunable pricing, and ultimately an auto-regulating market value
exploration engine.
**Completed 2026-06-22** via ledger-backed economics engine, web UI, file-based
importers (Bubble / Stripe / OpenRouter), pricing simulator, credit wallets, and
recommendation engine under `projects/coulomb-pricing/observatory/`.
## Sprint 1 — Economic Foundations
@@ -117,14 +79,6 @@ state_hub_task_id: "fac96369-a037-4b7e-a1ed-92659bce7e4e"
Create the core economic model.
**Deliverables:** product model, pricing model registry, cost registry, revenue
registry, membership registry.
**Metrics:** monthly revenue, monthly cost, cost per member, gross margin, active
members.
**Output:** Economics Dashboard v1.
Done 2026-06-21: `projects/coulomb-pricing/observatory/` with JSON registries
under `data/`, economics snapshot engine, CLI dashboard (`python3 -m
observatory`), sample report `reports/economics-2026-06.md`, and pytest suite.
@@ -133,123 +87,125 @@ observatory`), sample report `reports/economics-2026-06.md`, and pytest suite.
```task
id: ADAPTIVE-WP-0002-T02
status: todo
status: done
priority: high
state_hub_task_id: "42c181f9-9f4e-414e-aa94-b08c763abdef"
```
Import membership information.
**Deliverables:** Bubble membership importer, membership snapshots, active member
tracking, historical growth tracking.
**Metrics:** total members, new members, churn, growth rate.
**Output:** Membership Analytics Dashboard.
Done 2026-06-22: `observatory/importers/bubble.py` (JSON export →
`membership.json`), `membership_analytics` in dashboard API, sample export under
`data/imports/bubble-export.sample.json`.
## Sprint 3 — Stripe Integration
```task
id: ADAPTIVE-WP-0002-T03
status: wait
status: done
priority: high
state_hub_task_id: "c7e308bc-5977-40c8-985a-9dca2ad3984a"
```
Capture actual revenue and payment costs.
**Deliverables:** Stripe synchronization, revenue tracking, fee tracking, refund
tracking.
**Metrics:** net revenue, Stripe fees, revenue per member.
**Output:** Revenue Dashboard.
Done 2026-06-22: `observatory/importers/stripe.py` (charge export →
`payment_records.json`); live ledger already holds tegwick Stripe payments.
## Sprint 4 — OpenRouter Cost Attribution
```task
id: ADAPTIVE-WP-0002-T04
status: wait
status: done
priority: high
state_hub_task_id: "b2b61910-429c-46e9-93b8-25702ca337a7"
```
Track AI usage and cost.
**Deliverables:** OpenRouter usage importer, cost attribution per user,
model-level cost tracking, token accounting.
**Metrics:** cost per user, cost per model, total AI spend.
**Output:** AI Cost Dashboard.
Done 2026-06-22: `observatory/importers/openrouter.py`, `data/usage_records.json`,
`observatory/usage.py` (per-member and per-model attribution in API).
## Sprint 5 — Cost Allocation Engine
```task
id: ADAPTIVE-WP-0002-T05
status: wait
status: done
priority: medium
state_hub_task_id: "906009be-5670-428a-b6d1-2700c67e9c65"
```
Calculate economic reality.
**Deliverables:**
- Fixed cost allocation (Bubble.io, domains, infrastructure)
- Variable cost allocation (Stripe fees, OpenRouter costs)
- CostFloor, contribution margin, cost per member calculations
**Output:** CostFloor Report.
Done 2026-06-22: `observatory/allocation.py` — fixed/variable split, cost floor,
contribution margin in `/api/dashboard` (`cost_allocation`).
## Sprint 6 — Pricing Simulator
```task
id: ADAPTIVE-WP-0002-T06
status: wait
status: done
priority: medium
state_hub_task_id: "cb735e7d-72ac-41df-97d4-e0133cb4bb84"
```
Evaluate pricing scenarios.
**Example scenarios:**
- Current: €8.99/month, unlimited access
- Membership + credits: €8.99/month with included AI allowance
- Membership + overage: €8.99/month with included credits and usage-based overage
- Lower subscription: lower base fee with higher usage fees
**Output:** Pricing Explorer.
Done 2026-06-22: `observatory/simulator.py` compares active and candidate models
from `pricing-models.json` (`pricing_simulations` in API).
## Sprint 7 — Membership Credit System
```task
id: ADAPTIVE-WP-0002-T07
status: wait
status: done
priority: medium
state_hub_task_id: "aa8efb52-dbd8-4309-a15d-ea04d80c57f6"
```
Introduce AI credit accounting without billing.
**Deliverables:** credit wallet, monthly allowance, usage tracking, remaining
balance tracking.
**Output:** Member Usage Dashboard.
Done 2026-06-22: `data/credit_wallets.json`, `observatory/credits.py`
(observatory-only wallet balances in API).
## Sprint 8 — Adaptive Pricing Prototype
```task
id: ADAPTIVE-WP-0002-T08
status: wait
status: done
priority: low
state_hub_task_id: "d8195bf0-5b0d-4fbd-9776-0b619097c64f"
```
Implement first pricing optimization logic.
**Deliverables:** pricing parameter model, constraint model, seller economics
model, comparable customer LTV prototype, pricing recommendation engine.
Done 2026-06-22: `observatory/recommendations.py` — rules-based recommendations
from cost floor, value range, market signals, and simulator output.
**Output:** Adaptive Pricing Prototype v1.
## Economic Observatory Web UI
```task
id: ADAPTIVE-WP-0002-T09
status: done
priority: high
state_hub_task_id: "cfcfa53d-8e7d-464f-977c-d146dd252c35"
```
Whynot-design UI with ledger-backed API.
Done 2026-06-22: `ui/` + `observatory/server.py`, whynot-design vendor sync,
`docs/UI-WORKFLOW.md`. Three panels: Cost Floor, Value Range, Market Price.
## Pricing Context Views
```task
id: ADAPTIVE-WP-0002-T10
status: done
priority: medium
state_hub_task_id: "563cbded-ad2a-4ee8-8779-18f7e55970df"
```
Cost floor, value range, and market price observatory views.
Done 2026-06-22: `observatory/pricing_context.py`, `data/value_range.json`,
`data/market_signals.json`, wired into UI and API.