79 lines
2.8 KiB
Python
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)
|