Finance-Accounts/ar-aging-app/backend/app/core/money.py

79 lines
2.8 KiB
Python

"""
Currency-safe aggregation.
Adding two different currencies is never a rounding problem — it is a wrong number, and a
silent one. It happened here: the Reconciliation Control summed each marketplace's closing
balance in its own local currency (USD + EUR + GBP + PLN + SEK + CAD + AUD as one figure),
understating the Jan-2026 close by USD 444,658.44 against the correctly-converted total, and
that understated figure was what gated Finance sign-off.
`Total` makes the mistake impossible to repeat: every amount must be added with its currency,
and mixing codes raises instead of quietly producing a plausible number.
"""
from __future__ import annotations
USD = "USD"
class CurrencyMismatch(ValueError):
"""Raised when amounts in different currencies would be added together."""
class Total:
"""
An accumulator that refuses to mix currencies.
t = Total() # USD-converting accumulator
t.add_converted(230089.62, 1.185665)
t = Total(currency="EUR") # single-currency accumulator
t.add(100.0, "EUR") # ok
t.add(100.0, "GBP") # raises CurrencyMismatch
"""
__slots__ = ("currency", "_value", "_locked")
def __init__(self, currency: str | None = None):
self.currency = currency
self._value = 0.0
self._locked = currency is not None
def add(self, amount: float | None, currency: str | None) -> "Total":
"""Add an amount stated in `currency`. The first add fixes the accumulator's currency."""
cur = (currency or USD).strip().upper()
if self.currency is None:
self.currency = cur
elif cur != self.currency:
raise CurrencyMismatch(
f"refusing to add {cur} to a {self.currency} total "
f"(convert to a common currency first)"
)
self._value += float(amount or 0.0)
return self
def add_converted(self, amount: float | None, fx_rate: float | None) -> "Total":
"""Add a local amount converted to USD at `fx_rate`. Only valid on a USD total."""
if self.currency is None:
self.currency = USD
elif self.currency != USD:
raise CurrencyMismatch(
f"add_converted() produces USD but this total is {self.currency}"
)
self._value += float(amount or 0.0) * float(fx_rate if fx_rate is not None else 1.0)
return self
@property
def value(self) -> float:
return round(self._value, 2)
def __float__(self) -> float:
return self.value
def __repr__(self) -> str:
return f"Total({self.value:,.2f} {self.currency or '?'})"
def to_usd(amount: float | None, fx_rate: float | None) -> float:
"""Convert one local amount to USD."""
return round(float(amount or 0.0) * float(fx_rate if fx_rate is not None else 1.0), 2)