generated from coulomb/repo-seed
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:
121
projects/coulomb-pricing/observatory/ledger.py
Normal file
121
projects/coulomb-pricing/observatory/ledger.py
Normal file
@@ -0,0 +1,121 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
|
||||
from .models import (
|
||||
Budget,
|
||||
ExpenseRecord,
|
||||
MembershipRecord,
|
||||
MonthlyPlatformCost,
|
||||
PaymentRecord,
|
||||
)
|
||||
|
||||
TWOPLACES = Decimal("0.01")
|
||||
|
||||
|
||||
def _quantize(value: Decimal) -> Decimal:
|
||||
return value.quantize(TWOPLACES, 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"))
|
||||
raise ValueError(f"unsupported expense currency: {currency}")
|
||||
|
||||
|
||||
def aggregate_infrastructure_by_period(
|
||||
expenses: list[ExpenseRecord],
|
||||
fx_rates: dict[str, Decimal],
|
||||
reporting_currency: str = "EUR",
|
||||
) -> dict[str, Decimal]:
|
||||
if reporting_currency != "EUR":
|
||||
raise ValueError("only EUR reporting currency is supported")
|
||||
|
||||
totals: dict[str, Decimal] = {}
|
||||
for record in expenses:
|
||||
if record.cost_class != "infrastructure":
|
||||
continue
|
||||
totals[record.period] = totals.get(record.period, Decimal("0")) + convert_to_eur(
|
||||
record.amount, record.currency, fx_rates
|
||||
)
|
||||
return {period: _quantize(total) for period, total in totals.items()}
|
||||
|
||||
|
||||
def payment_processing_by_period(payments: list[PaymentRecord]) -> dict[str, Decimal]:
|
||||
totals: dict[str, Decimal] = {}
|
||||
for record in payments:
|
||||
totals[record.period] = totals.get(record.period, Decimal("0")) + record.fees_amount
|
||||
return {period: _quantize(total) for period, total in totals.items()}
|
||||
|
||||
|
||||
def _period_sort_key(period: str) -> tuple[int, int]:
|
||||
year, month = period.split("-")
|
||||
return int(year), int(month)
|
||||
|
||||
|
||||
def periods_from_budget_through_latest(
|
||||
budget: Budget,
|
||||
expenses: list[ExpenseRecord],
|
||||
payments: list[PaymentRecord],
|
||||
) -> list[str]:
|
||||
candidates = {budget.started}
|
||||
candidates.update(record.period for record in expenses)
|
||||
candidates.update(record.period for record in payments)
|
||||
latest = max(candidates, key=_period_sort_key)
|
||||
periods: list[str] = []
|
||||
year, month = map(int, budget.started.split("-"))
|
||||
end_year, end_month = map(int, latest.split("-"))
|
||||
while (year, month) <= (end_year, end_month):
|
||||
periods.append(f"{year}-{month:02d}")
|
||||
month += 1
|
||||
if month > 12:
|
||||
month = 1
|
||||
year += 1
|
||||
return periods
|
||||
|
||||
|
||||
def active_members_for_period(period: str, members: list[MembershipRecord]) -> int:
|
||||
period_start = f"{period}-01"
|
||||
count = 0
|
||||
for member in members:
|
||||
if member.joined_at[:7] > period:
|
||||
continue
|
||||
if member.churned_at and member.churned_at[:7] < period:
|
||||
continue
|
||||
if member.status == "active" or (
|
||||
member.churned_at and member.churned_at[:7] >= period
|
||||
):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def build_monthly_ledger(
|
||||
budget: Budget,
|
||||
expenses: list[ExpenseRecord],
|
||||
payments: list[PaymentRecord],
|
||||
members: list[MembershipRecord],
|
||||
fx_rates: dict[str, Decimal],
|
||||
) -> list[MonthlyPlatformCost]:
|
||||
infrastructure = aggregate_infrastructure_by_period(expenses, fx_rates)
|
||||
processing = payment_processing_by_period(payments)
|
||||
payment_by_period = {record.period: record for record in payments}
|
||||
rows: list[MonthlyPlatformCost] = []
|
||||
|
||||
for period in periods_from_budget_through_latest(budget, expenses, payments):
|
||||
payment = payment_by_period.get(period)
|
||||
rows.append(
|
||||
MonthlyPlatformCost(
|
||||
period=period,
|
||||
infrastructure_cost=infrastructure.get(period, Decimal("0.00")),
|
||||
payment_processing_cost=processing.get(period, Decimal("0.00")),
|
||||
active_members=(
|
||||
payment.member_count
|
||||
if payment and payment.member_count
|
||||
else active_members_for_period(period, members)
|
||||
),
|
||||
gross_revenue=payment.gross_amount if payment else Decimal("0.00"),
|
||||
)
|
||||
)
|
||||
return rows
|
||||
Reference in New Issue
Block a user