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,15 +4,14 @@ from decimal import Decimal, ROUND_HALF_UP
from .models import (
Budget,
CostEntry,
EconomicsSnapshot,
LiquidityStatus,
LiquiditySummary,
MembershipRecord,
MonthlyPlatformCost,
PaymentRecord,
PricingModel,
Product,
RevenueEntry,
)
TWOPLACES = Decimal("0.01")
@@ -23,16 +22,6 @@ 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 liquidity_status_for(net: Decimal) -> LiquidityStatus:
if net < Decimal("0"):
return "burning"
@@ -52,27 +41,10 @@ def active_pricing_model(models: list[PricingModel], product: Product) -> Pricin
raise ValueError(f"active pricing model not found: {product.active_pricing_model_id}")
def monthly_cost_from_rate_card(
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 revenue_for_period(period: str, revenue_entries: list[RevenueEntry]) -> RevenueEntry | None:
for entry in revenue_entries:
if entry.period == period:
return entry
def payment_for_period(period: str, payments: list[PaymentRecord]) -> PaymentRecord | None:
for record in payments:
if record.period == period:
return record
return None
@@ -81,16 +53,16 @@ def estimate_monthly_revenue(
product: Product,
models: list[PricingModel],
members: list[MembershipRecord],
revenue_entries: list[RevenueEntry],
payments: list[PaymentRecord],
monthly_costs: list[MonthlyPlatformCost],
) -> tuple[Decimal, Decimal, str]:
recorded = revenue_for_period(period, revenue_entries)
recorded = payment_for_period(period, payments)
if recorded:
return recorded.gross_amount, recorded.net_amount, recorded.source
month = next((item for item in monthly_costs if item.period == period), None)
if month and month.gross_revenue > 0:
return month.gross_revenue, month.gross_revenue, "derived_from_platform_history"
return month.gross_revenue, month.gross_revenue, "derived_from_ledger"
model = active_pricing_model(models, product)
count = active_members(members)
@@ -104,7 +76,7 @@ def periods_through(target: str, monthly_costs: list[MonthlyPlatformCost]) -> li
def build_liquidity_summary(
budget: Budget,
revenue_entries: list[RevenueEntry],
payments: list[PaymentRecord],
monthly_costs: list[MonthlyPlatformCost],
through_period: str,
) -> LiquiditySummary:
@@ -118,11 +90,9 @@ def build_liquidity_summary(
month = cost_by_period[period]
cumulative_infrastructure += month.infrastructure_cost
cumulative_processing += month.payment_processing_cost
payment = revenue_for_period(period, revenue_entries)
payment = payment_for_period(period, payments)
cumulative_payments += payment.net_amount if payment else Decimal("0")
# Liquidity uses net member payments vs infrastructure cash out only.
# Payment-processing fees are already deducted from net payments.
cumulative_net = _quantize(cumulative_payments - cumulative_infrastructure)
remaining = _quantize(budget.initial_budget + cumulative_net)
cumulative_total = _quantize(cumulative_infrastructure + cumulative_processing)
@@ -147,13 +117,13 @@ def build_snapshot(
product: Product,
models: list[PricingModel],
members: list[MembershipRecord],
revenue_entries: list[RevenueEntry],
payments: list[PaymentRecord],
monthly_costs: list[MonthlyPlatformCost],
) -> EconomicsSnapshot:
month = next(item for item in monthly_costs if item.period == period)
count = month.active_members if month.active_members else active_members(members)
gross_revenue, net_revenue, revenue_source = estimate_monthly_revenue(
period, product, models, members, revenue_entries, monthly_costs
period, product, models, members, payments, monthly_costs
)
infrastructure = month.infrastructure_cost
processing = month.payment_processing_cost