""" 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