81 lines
2.5 KiB
Python
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,
|
|
)
|