fin-hub/src/fin_hub/money.py

81 lines
2.5 KiB
Python

"""Shared financial-domain validation and decimal arithmetic."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from decimal import Decimal, InvalidOperation, ROUND_HALF_EVEN
from fin_hub.attribution import ClientAttribution
MONEY_QUANTUM = Decimal("0.01")
MONEY_ROUNDING = ROUND_HALF_EVEN
def money(value: Decimal | str | int | float, *, non_negative: bool = False) -> Decimal:
try:
normalized = Decimal(str(value)).quantize(MONEY_QUANTUM, rounding=MONEY_ROUNDING)
except (InvalidOperation, ValueError) as exc:
raise ValueError("money must be a finite decimal value") from exc
if not normalized.is_finite():
raise ValueError("money must be a finite decimal value")
if non_negative and normalized < 0:
raise ValueError("money cannot be negative")
return normalized
def money_minor(value: Decimal | str | int | float, *, non_negative: bool = False) -> int:
return int(money(value, non_negative=non_negative) / MONEY_QUANTUM)
def minor_money(value: int) -> Decimal:
return (Decimal(value) * MONEY_QUANTUM).quantize(MONEY_QUANTUM)
def currency_code(value: str) -> str:
normalized = value.strip().upper()
if len(normalized) != 3 or not normalized.isascii() or not normalized.isalpha():
raise ValueError("currency must be a three-letter ASCII code")
return normalized
def reporting_month(value: str) -> str:
if len(value) != 7 or value[4] != "-":
raise ValueError("period_month must use YYYY-MM")
try:
date.fromisoformat(f"{value}-01")
except ValueError as exc:
raise ValueError("period_month must use YYYY-MM") from exc
return value
@dataclass(frozen=True)
class EngagementPriceTerms:
attribution: ClientAttribution
period_month: str
amount: Decimal
currency: str
source: str
@classmethod
def validate(
cls,
*,
client_id: str,
application_id: str,
app_instance_id: str,
period_month: str,
amount: Decimal | str | int | float,
currency: str,
source: str,
) -> "EngagementPriceTerms":
normalized_source = source.strip()
if not normalized_source:
raise ValueError("source is required")
return cls(
attribution=ClientAttribution(client_id, application_id, app_instance_id),
period_month=reporting_month(period_month),
amount=money(amount, non_negative=True),
currency=currency_code(currency),
source=normalized_source,
)