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

166 lines
5.9 KiB
Python

"""
Receivable computation (per marketplace, per account type) built on the settlement
classification. Reproduces the workbook's `Detail` roll-forward:
Additional sales after last payment = SUMIFS(total, account_type) over receivable settlements
Net Closing Balance (Reserve) = Opening + Sales + Receipts + Refunds + Expenses (~0)
Receivable (local currency) = ROUND( reserve + additional_sales , 0 ) [marketplace level]
Receivable (USD) = Receivable_local x FX_rate
The `reserve` (aka Net Closing Balance) is a small Finance-maintained reconciliation
carry (opening balance + last-settlement reconciliation) that nets to ~0. It is an
optional per-(marketplace, account_type) input; the receivable base (additional_sales)
is computed exactly from the transaction files.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from .i18n import currency_for_region, default_fx_for_region
from .settlements import AggregationResult, Classification, RECEIVABLE_ACCOUNT_TYPES
# Aging bands used by the workbook. Amazon receivable is settled ~biweekly, so at
# month-end it is always "Current"; the day-based bands are kept for completeness.
AGING_BANDS = ("Current", "1-30", "31-60", "61-90", "91-Over")
@dataclass
class AccountReceivable:
marketplace: str
account_type: str
additional_sales: float = 0.0 # exact from files (SUMIFS)
reserve: float = 0.0 # optional Net Closing Balance input
settlement_ids: list[str] = field(default_factory=list)
row_count: int = 0
@property
def receivable_local_unrounded(self) -> float:
return self.reserve + self.additional_sales
@dataclass
class MarketplaceReceivable:
marketplace: str
currency: str = "USD"
fx_rate: float = 1.0
accounts: dict[str, AccountReceivable] = field(default_factory=dict)
@property
def additional_sales(self) -> float:
return sum(a.additional_sales for a in self.accounts.values())
@property
def reserve(self) -> float:
return sum(a.reserve for a in self.accounts.values())
@property
def receivable_local(self) -> int:
"""Marketplace-level rounded receivable (matches Detail!D11)."""
return round(self.reserve + self.additional_sales)
@property
def receivable_usd(self) -> float:
return self.receivable_local * self.fx_rate
def account_local(self, account_type: str) -> int:
a = self.accounts.get(account_type)
return round(a.receivable_local_unrounded) if a else 0
@dataclass
class ReceivableResult:
marketplaces: dict[str, MarketplaceReceivable] = field(default_factory=dict)
@property
def total_usd(self) -> float:
return sum(m.receivable_usd for m in self.marketplaces.values())
@property
def total_local_by_marketplace(self) -> dict[str, int]:
return {k: m.receivable_local for k, m in self.marketplaces.items()}
def _is_receivable_account(acct_type: str) -> bool:
return (acct_type or "").strip().lower() in RECEIVABLE_ACCOUNT_TYPES
def compute_receivable(
agg: AggregationResult,
cls: Classification,
reserves: dict[tuple[str, str], float] | None = None,
fx_rates: dict[str, float] | None = None,
currencies: dict[str, str] | None = None,
) -> ReceivableResult:
"""
reserves: (marketplace, account_type) -> Net Closing Balance (default 0.0)
fx_rates: marketplace -> rate to USD (default 1.0)
currencies: marketplace -> ISO currency (default 'USD')
"""
reserves = reserves or {}
fx_rates = fx_rates or {}
currencies = currencies or {}
result = ReceivableResult()
for key, st in agg.settlements.items():
mkt, acct, sid = key
# Only real order account types feed the receivable SUMIFS; transfers already
# excluded (order_total holds NON-transfer totals only).
if not _is_receivable_account(acct):
continue
if st.status != "receivable":
continue
m = result.marketplaces.get(mkt)
if m is None:
m = MarketplaceReceivable(
marketplace=mkt,
currency=currencies.get(mkt) or currency_for_region(mkt),
fx_rate=fx_rates.get(mkt, default_fx_for_region(mkt)),
)
result.marketplaces[mkt] = m
a = m.accounts.get(acct)
if a is None:
a = AccountReceivable(marketplace=mkt, account_type=acct)
m.accounts[acct] = a
a.additional_sales += st.order_total
a.row_count += st.row_count
a.settlement_ids.append(sid)
# Apply reserves (Net Closing Balance) per (marketplace, account_type).
for (mkt, acct), val in reserves.items():
m = result.marketplaces.get(mkt)
if m is None:
m = MarketplaceReceivable(
marketplace=mkt, currency=currencies.get(mkt, "USD"),
fx_rate=fx_rates.get(mkt, 1.0),
)
result.marketplaces[mkt] = m
a = m.accounts.get(acct)
if a is None:
a = AccountReceivable(marketplace=mkt, account_type=acct)
m.accounts[acct] = a
a.reserve = val
return result
def classify_aging(days_outstanding: int | None) -> str:
"""Day-based aging band (kept for completeness; Amazon receivable is 'Current')."""
if days_outstanding is None or days_outstanding <= 0:
return "Current"
if days_outstanding <= 30:
return "1-30"
if days_outstanding <= 60:
return "31-60"
if days_outstanding <= 90:
return "61-90"
return "91-Over"
def aging_summary(result: ReceivableResult, band: str = "Current") -> dict[str, dict[str, float]]:
"""Summary matrix marketplace -> {band: usd}. All Amazon receivable lands in 'Current'."""
out: dict[str, dict[str, float]] = {}
for mkt, m in result.marketplaces.items():
row = {b: 0.0 for b in AGING_BANDS}
row[band] = m.receivable_usd
out[mkt] = row
return out