Add per-client billing basis export

This commit is contained in:
tegwick 2026-08-11 14:39:48 +02:00
parent b75ad06b3a
commit fcec177ded
10 changed files with 506 additions and 11 deletions

View file

@ -8,6 +8,7 @@ from datetime import date
from decimal import Decimal, ROUND_FLOOR
from pathlib import Path
from fin_hub.attribution import parse_client_attribution_key
from fin_hub.money import MONEY_QUANTUM, minor_money, money_minor
from fin_hub.schemas.exchange import AllocationEvidence
from fin_hub.services.exchange import _ensure_planning_schema
@ -28,6 +29,7 @@ class AllocatedTarget:
@dataclass(frozen=True)
class AllocationReport:
allocation_id: str
revision_of: str | None
method: str
financial_fact_ids: tuple[str, ...]
period_start: date
@ -108,6 +110,15 @@ def shared_cost_allocations(*, ledger_path: Path | None = None) -> list[Allocati
).fetchone()
if fact is None:
raise ValueError(f"allocation references missing/current fact {fact_id}")
if fact["cost_attribution_key"] is not None:
try:
parse_client_attribution_key(fact["cost_attribution_key"])
except ValueError:
pass
else:
raise ValueError(
f"allocation cannot reallocate directly attributed fact {fact_id}"
)
if fact["currency"] != allocation.currency:
raise ValueError(f"allocation currency does not match fact {fact_id}")
if allocation.environment and fact["environment"] != allocation.environment:
@ -145,6 +156,7 @@ def shared_cost_allocations(*, ledger_path: Path | None = None) -> list[Allocati
reports.append(
AllocationReport(
allocation_id=allocation.record_id,
revision_of=allocation.revision_of,
method=allocation.method,
financial_fact_ids=tuple(allocation.financial_fact_ids),
period_start=allocation.period_start,

View file

@ -0,0 +1,288 @@
"""Idempotent per-client billing-basis reporting (never invoice generation)."""
from __future__ import annotations
import hashlib
from calendar import monthrange
from dataclasses import asdict, dataclass
from datetime import date
from decimal import Decimal
from pathlib import Path
from fin_hub.attribution import parse_client_attribution_key
from fin_hub.money import minor_money
from fin_hub.services.allocation import AllocationReport, shared_cost_allocations
from fin_hub.services.ledger import _connect, default_ledger_path
@dataclass(frozen=True)
class BillingBasisRecord:
billing_basis_id: str
cost_attribution_key: str
client_id: str
application_id: str
app_instance_id: str
period_month: str
currency: str
price_id: str
price_revision_of: str | None
price_source: str
revenue: Decimal
direct_cost: Decimal
allocated_cost: Decimal
total_cost: Decimal
margin: Decimal
financial_fact_ids: tuple[str, ...]
financial_fact_corrections: tuple[tuple[str, str], ...]
allocation_ids: tuple[str, ...]
allocation_revisions: tuple[tuple[str, str], ...]
provenance: tuple[str, ...]
def as_dict(self) -> dict:
return asdict(self)
@dataclass(frozen=True)
class BillingBasisException:
reason: str
cost_attribution_key: str | None
period_month: str
currency: str
amount: Decimal
evidence_ids: tuple[str, ...]
def as_dict(self) -> dict:
return asdict(self)
@dataclass(frozen=True)
class BillingBasisExport:
schema_version: str
artifact_type: str
records: tuple[BillingBasisRecord, ...]
exceptions: tuple[BillingBasisException, ...]
disclaimer: str
def as_dict(self) -> dict:
return asdict(self)
def _stable_id(*parts: object) -> str:
payload = "\x1f".join(str(part) for part in parts)
return "billing-basis:" + hashlib.sha256(payload.encode("utf-8")).hexdigest()
def _allocation_month(report: AllocationReport) -> str:
if report.period_start.year != report.period_end.year or report.period_start.month != report.period_end.month:
raise ValueError(
f"allocation {report.allocation_id} spans multiple billing months"
)
expected_end = date(
report.period_start.year,
report.period_start.month,
monthrange(report.period_start.year, report.period_start.month)[1],
)
if report.period_start.day != 1 or report.period_end != expected_end:
raise ValueError(
f"allocation {report.allocation_id} must cover a complete billing month"
)
return report.period_start.strftime("%Y-%m")
def build_billing_basis(*, ledger_path: Path | None = None) -> BillingBasisExport:
"""Build deterministic client-period reporting with explicit omissions."""
ledger = ledger_path or default_ledger_path()
allocations = shared_cost_allocations(ledger_path=ledger)
with _connect(ledger) as conn:
price_rows = conn.execute(
"SELECT * FROM engagement_prices WHERE is_current = 1 "
"ORDER BY period_month, cost_attribution_key, currency"
).fetchall()
fact_rows = conn.execute(
"SELECT * FROM ledger_entries WHERE is_current = 1 ORDER BY financial_fact_id"
).fetchall()
prices = {
(row["cost_attribution_key"], row["period_month"], row["currency"]): row
for row in price_rows
}
facts_by_id = {row["financial_fact_id"]: row for row in fact_rows}
direct: dict[tuple[str, str, str], list] = {}
for fact in fact_rows:
key = fact["cost_attribution_key"]
if key is None:
continue
try:
parse_client_attribution_key(key)
except ValueError:
continue
direct.setdefault((key, fact["period_month"], fact["currency"]), []).append(fact)
allocated: dict[tuple[str, str, str], list[tuple[AllocationReport, Decimal]]] = {}
exceptions: list[BillingBasisException] = []
for allocation in allocations:
period = _allocation_month(allocation)
for target in allocation.targets:
try:
parse_client_attribution_key(target.target_key)
except ValueError:
exceptions.append(
BillingBasisException(
reason="non_client_allocation_target",
cost_attribution_key=target.target_key,
period_month=period,
currency=allocation.currency,
amount=target.amount,
evidence_ids=(allocation.allocation_id,),
)
)
continue
allocated.setdefault(
(target.target_key, period, allocation.currency), []
).append((allocation, target.amount))
if allocation.residual_amount:
exceptions.append(
BillingBasisException(
reason="unattributed_allocation_residual",
cost_attribution_key=None,
period_month=period,
currency=allocation.currency,
amount=allocation.residual_amount,
evidence_ids=(allocation.allocation_id,),
)
)
cost_keys = set(direct) | set(allocated)
for key in sorted(cost_keys - set(prices)):
facts = direct.get(key, [])
allocation_parts = allocated.get(key, [])
amount_minor = sum(int(row["amount_minor"]) for row in facts) + sum(
int(amount / Decimal("0.01")) for _allocation, amount in allocation_parts
)
exceptions.append(
BillingBasisException(
reason="missing_price_basis",
cost_attribution_key=key[0],
period_month=key[1],
currency=key[2],
amount=minor_money(amount_minor),
evidence_ids=tuple(
sorted(
[row["financial_fact_id"] for row in facts]
+ [allocation.allocation_id for allocation, _amount in allocation_parts]
)
),
)
)
records: list[BillingBasisRecord] = []
for key, price in sorted(prices.items()):
attribution = parse_client_attribution_key(key[0])
facts = direct.get(key, [])
allocation_parts = allocated.get(key, [])
direct_minor = sum(int(row["amount_minor"]) for row in facts)
allocated_minor = sum(
int(amount / Decimal("0.01")) for _allocation, amount in allocation_parts
)
revenue_minor = int(price["amount_minor"])
allocated_fact_ids = {
fact_id
for allocation, _amount in allocation_parts
for fact_id in allocation.financial_fact_ids
}
all_evidence_facts = [
*facts,
*(facts_by_id[fact_id] for fact_id in sorted(allocated_fact_ids)),
]
fact_ids = tuple(
sorted({row["financial_fact_id"] for row in all_evidence_facts})
)
correction_pairs = tuple(
sorted(
(row["financial_fact_id"], row["correction_of"])
for row in all_evidence_facts
if row["correction_of"]
)
)
allocation_ids = tuple(
sorted(allocation.allocation_id for allocation, _amount in allocation_parts)
)
allocation_revisions = tuple(
sorted(
(allocation.allocation_id, allocation.revision_of)
for allocation, _amount in allocation_parts
if allocation.revision_of
)
)
provenance = tuple(
sorted(
{price["source"]}
| {
row["correction_source"] or row["source_document_id"]
for row in all_evidence_facts
}
| {
evidence
for allocation, _amount in allocation_parts
for evidence in allocation.source_evidence
}
)
)
total_minor = direct_minor + allocated_minor
basis_id = _stable_id(
key,
price["id"],
price["revision_of"],
fact_ids,
correction_pairs,
allocation_ids,
allocation_revisions,
direct_minor,
allocated_minor,
revenue_minor,
)
records.append(
BillingBasisRecord(
billing_basis_id=basis_id,
cost_attribution_key=key[0],
client_id=attribution.client_id,
application_id=attribution.application_id,
app_instance_id=attribution.app_instance_id,
period_month=key[1],
currency=key[2],
price_id=price["id"],
price_revision_of=price["revision_of"],
price_source=price["source"],
revenue=minor_money(revenue_minor),
direct_cost=minor_money(direct_minor),
allocated_cost=minor_money(allocated_minor),
total_cost=minor_money(total_minor),
margin=minor_money(revenue_minor - total_minor),
financial_fact_ids=fact_ids,
financial_fact_corrections=correction_pairs,
allocation_ids=allocation_ids,
allocation_revisions=allocation_revisions,
provenance=provenance,
)
)
return BillingBasisExport(
schema_version="0.1",
artifact_type="billing_basis_report",
records=tuple(records),
exceptions=tuple(
sorted(
exceptions,
key=lambda item: (
item.period_month,
item.currency,
item.reason,
item.cost_attribution_key or "",
),
)
),
disclaimer=(
"Reporting basis only; not an invoice, bookkeeping record, payment request, "
"or evidence of payment."
),
)