generated from coulomb/repo-seed
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:
@@ -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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
11
projects/coulomb-pricing/data/credit_wallets.json
Normal file
11
projects/coulomb-pricing/data/credit_wallets.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"usage": [
|
||||
{
|
||||
"period": "2026-06",
|
||||
"user_id": "member-tegwick",
|
||||
"model": "anthropic/claude-3-haiku",
|
||||
"tokens": 48200,
|
||||
"cost_usd": "0.06"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
16
projects/coulomb-pricing/data/usage_records.json
Normal file
16
projects/coulomb-pricing/data/usage_records.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
58
projects/coulomb-pricing/docs/UI-WORKFLOW.md
Normal file
58
projects/coulomb-pricing/docs/UI-WORKFLOW.md
Normal 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`.
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Coulomb Social Economic Observatory — Sprint 1 foundations."""
|
||||
"""Coulomb Social Economic Observatory — MVP (ledger, API, importers, simulator)."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
32
projects/coulomb-pricing/observatory/allocation.py
Normal file
32
projects/coulomb-pricing/observatory/allocation.py
Normal 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,
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
48
projects/coulomb-pricing/observatory/credits.py
Normal file
48
projects/coulomb-pricing/observatory/credits.py
Normal 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.",
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""File-based importers for Bubble, Stripe, and OpenRouter exports."""
|
||||
13
projects/coulomb-pricing/observatory/importers/_io.py
Normal file
13
projects/coulomb-pricing/observatory/importers/_io.py
Normal 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")
|
||||
55
projects/coulomb-pricing/observatory/importers/bubble.py
Normal file
55
projects/coulomb-pricing/observatory/importers/bubble.py
Normal 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())
|
||||
57
projects/coulomb-pricing/observatory/importers/openrouter.py
Normal file
57
projects/coulomb-pricing/observatory/importers/openrouter.py
Normal 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())
|
||||
54
projects/coulomb-pricing/observatory/importers/stripe.py
Normal file
54
projects/coulomb-pricing/observatory/importers/stripe.py
Normal 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())
|
||||
48
projects/coulomb-pricing/observatory/membership_analytics.py
Normal file
48
projects/coulomb-pricing/observatory/membership_analytics.py
Normal 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",
|
||||
}
|
||||
68
projects/coulomb-pricing/observatory/recommendations.py
Normal file
68
projects/coulomb-pricing/observatory/recommendations.py
Normal 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
|
||||
70
projects/coulomb-pricing/observatory/simulator.py
Normal file
70
projects/coulomb-pricing/observatory/simulator.py
Normal 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.",
|
||||
}
|
||||
48
projects/coulomb-pricing/observatory/usage.py
Normal file
48
projects/coulomb-pricing/observatory/usage.py
Normal 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),
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
42
projects/coulomb-pricing/tests/test_importers.py
Normal file
42
projects/coulomb-pricing/tests/test_importers.py
Normal 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"
|
||||
96
projects/coulomb-pricing/tests/test_mvp_sprints.py
Normal file
96
projects/coulomb-pricing/tests/test_mvp_sprints.py
Normal 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
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user