2026-06-22 01:32:48 +02:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import argparse
|
2026-06-22 01:48:45 +02:00
|
|
|
|
from decimal import Decimal
|
2026-06-22 01:32:48 +02:00
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
2026-06-22 01:48:45 +02:00
|
|
|
|
from .economics import build_liquidity_summary, build_snapshot
|
2026-06-22 01:32:48 +02:00
|
|
|
|
from .load import (
|
|
|
|
|
|
default_data_dir,
|
2026-06-22 01:48:45 +02:00
|
|
|
|
latest_period,
|
|
|
|
|
|
load_budget,
|
2026-06-22 02:03:22 +02:00
|
|
|
|
load_expense_records,
|
2026-06-22 01:32:48 +02:00
|
|
|
|
load_membership,
|
2026-06-22 02:03:22 +02:00
|
|
|
|
load_monthly_ledger,
|
|
|
|
|
|
load_payment_records,
|
2026-06-22 01:32:48 +02:00
|
|
|
|
load_pricing_models,
|
|
|
|
|
|
load_product,
|
|
|
|
|
|
)
|
2026-06-22 02:03:22 +02:00
|
|
|
|
from .models import EconomicsSnapshot, LiquiditySummary, MonthlyPlatformCost, PaymentRecord, PricingModel, Product
|
2026-06-22 01:48:45 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _history_rows(
|
|
|
|
|
|
monthly_costs: list[MonthlyPlatformCost],
|
2026-06-22 02:03:22 +02:00
|
|
|
|
payments: list[PaymentRecord],
|
2026-06-22 01:48:45 +02:00
|
|
|
|
through_period: str,
|
|
|
|
|
|
) -> str:
|
2026-06-22 02:03:22 +02:00
|
|
|
|
payment_by_period = {record.period: record for record in payments}
|
2026-06-22 01:48:45 +02:00
|
|
|
|
lines: list[str] = []
|
|
|
|
|
|
for month in sorted(monthly_costs, key=lambda item: item.period):
|
|
|
|
|
|
if month.period > through_period:
|
|
|
|
|
|
continue
|
2026-06-22 02:03:22 +02:00
|
|
|
|
payment = payment_by_period.get(month.period)
|
2026-06-22 01:48:45 +02:00
|
|
|
|
net_payment = payment.net_amount if payment else Decimal("0")
|
2026-06-22 01:51:53 +02:00
|
|
|
|
period_net = net_payment - month.infrastructure_cost
|
2026-06-22 01:48:45 +02:00
|
|
|
|
lines.append(
|
|
|
|
|
|
f"| {month.period} | {month.active_members} | {month.gross_revenue} | "
|
2026-06-22 01:51:53 +02:00
|
|
|
|
f"{month.infrastructure_cost} | {month.payment_processing_cost} | "
|
|
|
|
|
|
f"{month.total_platform_cost} | {period_net:.2f} |"
|
2026-06-22 01:48:45 +02:00
|
|
|
|
)
|
|
|
|
|
|
return "\n".join(lines)
|
2026-06-22 01:32:48 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def render_dashboard(
|
|
|
|
|
|
product: Product,
|
|
|
|
|
|
models: list[PricingModel],
|
|
|
|
|
|
snapshot: EconomicsSnapshot,
|
2026-06-22 01:48:45 +02:00
|
|
|
|
liquidity: LiquiditySummary,
|
|
|
|
|
|
monthly_costs: list[MonthlyPlatformCost],
|
2026-06-22 02:03:22 +02:00
|
|
|
|
payments: list[PaymentRecord],
|
|
|
|
|
|
expense_count: int,
|
2026-06-22 01:32:48 +02:00
|
|
|
|
) -> str:
|
|
|
|
|
|
active = next(m for m in models if m.id == product.active_pricing_model_id)
|
|
|
|
|
|
registry_lines = "\n".join(
|
|
|
|
|
|
f"| {model.id} | {model.name} | {model.model_type} | {model.status} |"
|
|
|
|
|
|
for model in models
|
|
|
|
|
|
)
|
2026-06-22 02:03:22 +02:00
|
|
|
|
history_lines = _history_rows(monthly_costs, payments, snapshot.period)
|
2026-06-22 01:48:45 +02:00
|
|
|
|
budget_state = (
|
|
|
|
|
|
"over budget"
|
|
|
|
|
|
if liquidity.remaining_budget < Decimal("0")
|
|
|
|
|
|
else "within budget"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-06-22 01:32:48 +02:00
|
|
|
|
return f"""# Economics Dashboard v1 — {product.name}
|
|
|
|
|
|
|
|
|
|
|
|
**Period:** {snapshot.period}
|
|
|
|
|
|
**Lifecycle phase:** {product.lifecycle_phase}
|
|
|
|
|
|
**Active pricing model:** {active.name} ({active.access_fee_amount} {active.currency}/{active.access_fee_cadence})
|
|
|
|
|
|
|
2026-06-22 01:48:45 +02:00
|
|
|
|
> Platform costs accrue to the operator. Customer cost-pass-through billing is
|
2026-06-22 02:03:22 +02:00
|
|
|
|
> **not active** in MVP — members pay subscription only. All totals are computed
|
|
|
|
|
|
> programmatically from expense and payment record ledgers ({expense_count} expense
|
|
|
|
|
|
> records).
|
2026-06-22 01:48:45 +02:00
|
|
|
|
|
|
|
|
|
|
## Key Metrics (current period)
|
2026-06-22 01:32:48 +02:00
|
|
|
|
|
|
|
|
|
|
| Metric | Value |
|
|
|
|
|
|
|--------|------:|
|
|
|
|
|
|
| Active members | {snapshot.active_members} |
|
2026-06-22 01:48:45 +02:00
|
|
|
|
| Member payments (gross) | {snapshot.monthly_revenue} {snapshot.currency} |
|
2026-06-22 01:51:53 +02:00
|
|
|
|
| Infrastructure cost | {snapshot.monthly_infrastructure_cost} {snapshot.currency} |
|
|
|
|
|
|
| Payment processing cost | {snapshot.monthly_payment_processing_cost} {snapshot.currency} |
|
|
|
|
|
|
| Total platform cost | {snapshot.monthly_total_platform_cost} {snapshot.currency} |
|
2026-06-22 01:48:45 +02:00
|
|
|
|
| Platform cost per member | {snapshot.cost_per_member} {snapshot.currency} |
|
|
|
|
|
|
| Period gross margin | {snapshot.gross_margin} {snapshot.currency} |
|
|
|
|
|
|
| Period gross margin % | {snapshot.gross_margin_pct}% |
|
|
|
|
|
|
| Period net liquidity | {snapshot.period_net_liquidity} {snapshot.currency} ({snapshot.liquidity_status}) |
|
2026-06-22 01:32:48 +02:00
|
|
|
|
|
2026-06-22 01:51:53 +02:00
|
|
|
|
_Period net liquidity = net member payments − infrastructure cost (processing fees already netted from payments)._
|
2026-06-22 01:32:48 +02:00
|
|
|
|
_Revenue source: {snapshot.revenue_source}_
|
|
|
|
|
|
|
2026-06-22 01:48:45 +02:00
|
|
|
|
## Liquidity & Budget (through {liquidity.through_period})
|
|
|
|
|
|
|
|
|
|
|
|
| Metric | Value |
|
|
|
|
|
|
|--------|------:|
|
|
|
|
|
|
| Initial budget | {liquidity.initial_budget} {liquidity.currency} |
|
|
|
|
|
|
| Cumulative member payments (net) | {liquidity.cumulative_member_payments} {liquidity.currency} |
|
2026-06-22 01:51:53 +02:00
|
|
|
|
| Cumulative infrastructure cost | {liquidity.cumulative_infrastructure_cost} {liquidity.currency} |
|
|
|
|
|
|
| Cumulative payment processing | {liquidity.cumulative_payment_processing_cost} {liquidity.currency} |
|
|
|
|
|
|
| Cumulative total platform cost | {liquidity.cumulative_total_platform_cost} {liquidity.currency} |
|
2026-06-22 01:48:45 +02:00
|
|
|
|
| Cumulative net liquidity | {liquidity.cumulative_net_liquidity} {liquidity.currency} ({liquidity.liquidity_status}) |
|
|
|
|
|
|
| Remaining budget | {liquidity.remaining_budget} {liquidity.currency} ({budget_state}) |
|
|
|
|
|
|
| Months tracked | {liquidity.months_tracked} |
|
|
|
|
|
|
|
|
|
|
|
|
## Monthly History
|
|
|
|
|
|
|
2026-06-22 01:51:53 +02:00
|
|
|
|
| Period | Members | Gross revenue | Infrastructure | Processing | Total platform | Net liquidity |
|
|
|
|
|
|
|--------|--------:|--------------:|---------------:|-----------:|---------------:|--------------:|
|
2026-06-22 01:48:45 +02:00
|
|
|
|
{history_lines}
|
|
|
|
|
|
|
2026-06-22 01:32:48 +02:00
|
|
|
|
## Pricing Model Registry
|
|
|
|
|
|
|
|
|
|
|
|
| ID | Name | Type | Status |
|
|
|
|
|
|
|----|------|------|--------|
|
|
|
|
|
|
{registry_lines}
|
|
|
|
|
|
|
|
|
|
|
|
## Registries Loaded
|
|
|
|
|
|
|
|
|
|
|
|
- Product model (`data/product.json`)
|
2026-06-22 01:48:45 +02:00
|
|
|
|
- Budget (`data/budget.json`)
|
2026-06-22 02:03:22 +02:00
|
|
|
|
- Expense records (`data/expense_records.json`) — source of truth for costs
|
2026-06-22 02:36:42 +02:00
|
|
|
|
- Infrastructure catalog (`data/infrastructure/`) — domain, VPS, and Stripe reference data
|
2026-06-22 02:03:22 +02:00
|
|
|
|
- Payment records (`data/payment_records.json`)
|
2026-06-22 01:48:45 +02:00
|
|
|
|
- Membership (`data/membership.json`)
|
|
|
|
|
|
- Requirements (`REQUIREMENTS.md`)
|
2026-06-22 01:32:48 +02:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def generate_dashboard(data_dir: Path | None = None, period: str | None = None) -> str:
|
|
|
|
|
|
root = data_dir or default_data_dir()
|
|
|
|
|
|
product = load_product(root)
|
2026-06-22 01:48:45 +02:00
|
|
|
|
budget = load_budget(root)
|
2026-06-22 01:32:48 +02:00
|
|
|
|
models = load_pricing_models(root)
|
|
|
|
|
|
members = load_membership(root)
|
2026-06-22 02:03:22 +02:00
|
|
|
|
payments = load_payment_records(root)
|
|
|
|
|
|
expenses = load_expense_records(root)
|
|
|
|
|
|
monthly_costs = load_monthly_ledger(root)
|
2026-06-22 01:48:45 +02:00
|
|
|
|
target_period = period or latest_period(monthly_costs)
|
|
|
|
|
|
|
2026-06-22 02:03:22 +02:00
|
|
|
|
snapshot = build_snapshot(target_period, product, models, members, payments, monthly_costs)
|
|
|
|
|
|
liquidity = build_liquidity_summary(budget, payments, monthly_costs, target_period)
|
|
|
|
|
|
return render_dashboard(
|
|
|
|
|
|
product, models, snapshot, liquidity, monthly_costs, payments, len(expenses)
|
|
|
|
|
|
)
|
2026-06-22 01:32:48 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
|
|
|
|
parser = argparse.ArgumentParser(description="Coulomb Social Economics Dashboard v1")
|
|
|
|
|
|
parser.add_argument("--data-dir", type=Path, default=None, help="Registry data directory")
|
|
|
|
|
|
parser.add_argument("--period", default=None, help="Reporting period (YYYY-MM)")
|
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
|
"--output",
|
|
|
|
|
|
type=Path,
|
|
|
|
|
|
default=None,
|
|
|
|
|
|
help="Write Markdown report to this path (default: stdout only)",
|
|
|
|
|
|
)
|
|
|
|
|
|
args = parser.parse_args(argv)
|
|
|
|
|
|
|
|
|
|
|
|
report = generate_dashboard(args.data_dir, args.period)
|
|
|
|
|
|
if args.output:
|
|
|
|
|
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
args.output.write_text(report, encoding="utf-8")
|
|
|
|
|
|
print(f"Wrote {args.output}")
|
|
|
|
|
|
else:
|
|
|
|
|
|
print(report)
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
raise SystemExit(main())
|