Refactor economics to expense-record ledger with correct Bubble cost

Replace pre-aggregated costs.json with expense_records.json (48 line-item
records) and payment_records.json. All monthly and cumulative totals are
computed deterministically in observatory/ledger.py. Correct Bubble.io to
$32/mo (since Feb 2025) — infrastructure €69.44/mo not €72.20.
This commit is contained in:
2026-06-22 02:03:22 +02:00
parent ea2c2c6403
commit 31db9f8f31
12 changed files with 843 additions and 299 deletions

View File

@@ -4,16 +4,15 @@ import json
from decimal import Decimal
from pathlib import Path
_ZERO = Decimal("0")
from .ledger import build_monthly_ledger
from .models import (
Budget,
CostEntry,
ExpenseRecord,
MembershipRecord,
MonthlyPlatformCost,
PaymentRecord,
PricingModel,
Product,
RevenueEntry,
)
@@ -67,55 +66,38 @@ def load_pricing_models(data_dir: Path | None = None) -> list[PricingModel]:
]
def _parse_cost_entries(items: list[dict]) -> list[CostEntry]:
def load_fx_rates(data_dir: Path | None = None) -> dict[str, Decimal]:
raw = _read_json((data_dir or default_data_dir()) / "expense_records.json")
return {pair: _money(rate) for pair, rate in raw.get("fx_rates", {}).items()}
def load_expense_records(data_dir: Path | None = None) -> list[ExpenseRecord]:
raw = _read_json((data_dir or default_data_dir()) / "expense_records.json")
return [
CostEntry(
ExpenseRecord(
id=item["id"],
name=item["name"],
category=item["category"],
period=item["period"],
vendor=item["vendor"],
description=item["description"],
cost_class=item["cost_class"],
amount=_money(item["amount"]),
currency=item["currency"],
cadence=item["cadence"],
allocation=item["allocation"],
source=item["source"],
)
for item in items
for item in raw["records"]
]
def load_cost_rate_card(data_dir: Path | None = None) -> tuple[list[CostEntry], dict[str, Decimal]]:
raw = _read_json((data_dir or default_data_dir()) / "costs.json")
items = raw.get("rate_card", raw.get("entries", []))
fx = {pair: _money(rate) for pair, rate in raw.get("fx_rates", {}).items()}
return _parse_cost_entries(items), fx
def load_monthly_platform_costs(data_dir: Path | None = None) -> list[MonthlyPlatformCost]:
raw = _read_json((data_dir or default_data_dir()) / "costs.json")
rows: list[MonthlyPlatformCost] = []
for item in raw.get("monthly_history", []):
if "infrastructure_cost" in item:
infrastructure = _money(item["infrastructure_cost"])
processing = _money(item.get("payment_processing_cost", "0"))
else:
# Legacy single platform_cost field treated as infrastructure only.
infrastructure = _money(item["platform_cost"])
processing = _ZERO
rows.append(
MonthlyPlatformCost(
period=item["period"],
infrastructure_cost=infrastructure,
payment_processing_cost=processing,
active_members=item["active_members"],
gross_revenue=_money(item["gross_revenue"]),
)
)
return rows
def load_revenue(data_dir: Path | None = None) -> list[RevenueEntry]:
raw = _read_json((data_dir or default_data_dir()) / "revenue.json")
def load_payment_records(data_dir: Path | None = None) -> list[PaymentRecord]:
root = data_dir or default_data_dir()
path = root / "payment_records.json"
if not path.exists():
# Backward compatibility with legacy revenue.json
path = root / "revenue.json"
raw = _read_json(path)
items = raw.get("records", raw.get("entries", []))
return [
RevenueEntry(
PaymentRecord(
id=item["id"],
period=item["period"],
gross_amount=_money(item["gross_amount"]),
@@ -124,8 +106,9 @@ def load_revenue(data_dir: Path | None = None) -> list[RevenueEntry]:
net_amount=_money(item["net_amount"]),
currency=item["currency"],
source=item["source"],
member_count=item.get("member_count", 0),
)
for item in raw["entries"]
for item in items
]
@@ -143,5 +126,16 @@ def load_membership(data_dir: Path | None = None) -> list[MembershipRecord]:
]
def load_monthly_ledger(data_dir: Path | None = None) -> list[MonthlyPlatformCost]:
root = data_dir or default_data_dir()
return build_monthly_ledger(
load_budget(root),
load_expense_records(root),
load_payment_records(root),
load_membership(root),
load_fx_rates(root),
)
def latest_period(monthly_costs: list[MonthlyPlatformCost]) -> str:
return max(item.period for item in monthly_costs)