Add per-client billing basis export
This commit is contained in:
parent
b75ad06b3a
commit
fcec177ded
10 changed files with 506 additions and 11 deletions
|
|
@ -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,
|
||||
|
|
|
|||
288
src/fin_hub/services/billing.py
Normal file
288
src/fin_hub/services/billing.py
Normal 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."
|
||||
),
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue