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

92 lines
3.7 KiB
Python

"""
Internal bucket accounting for the month-end close.
uploaded_total = receivable_orders + paid_orders + transfers_total
**This identity is not a control and must never be presented as one.** It is a tautology:
`uploaded_total` is accumulated from the same record stream that fills the three buckets, and
every record lands in exactly one of them, so it is one sum written twice. It cannot detect a
misclassified settlement, a wrong FX rate, an unmapped column, or an entire file that failed
to parse (a file yielding no rows contributes zero to both sides). It reported "Reconciled" on
the Jan-2026 close while the group receivable was understated by USD 444,658.44.
It is kept as a cheap assert that the bucketing code itself is self-consistent. The controls
that can actually fail live in `core/controls.py`, and they are what the dashboard reports.
Closing receivable:
final_receivable_usd = Σ_marketplace ROUND(reserve + additional_sales) x fx
+ manual_adjustments
Status is "Reconciled" when |identity difference| and |vs-expected difference|
are within the rounding tolerance (default USD 0.01).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from .settlements import AggregationResult, Classification, RECEIVABLE_ACCOUNT_TYPES
from .receivable import ReceivableResult
DEFAULT_TOLERANCE = 0.01
@dataclass
class Reconciliation:
uploaded_total: float = 0.0
receivable_orders: float = 0.0 # Σ order_total over receivable settlements (all accts)
paid_orders: float = 0.0 # Σ order_total over paid settlements
transfers_total: float = 0.0 # Σ total over transfer rows
reserve_total: float = 0.0
manual_adjustments: float = 0.0
final_receivable_usd: float = 0.0
identity_difference: float = 0.0
expected_receivable: float | None = None
expected_difference: float | None = None
tolerance: float = DEFAULT_TOLERANCE
status: str = "Reconciled"
notes: list[str] = field(default_factory=list)
@property
def reconciled(self) -> bool:
return self.status == "Reconciled"
def reconcile(
agg: AggregationResult,
cls: Classification,
receivable: ReceivableResult,
manual_adjustments: float = 0.0,
expected_receivable: float | None = None,
tolerance: float = DEFAULT_TOLERANCE,
) -> Reconciliation:
r = Reconciliation(tolerance=tolerance, manual_adjustments=manual_adjustments,
expected_receivable=expected_receivable)
r.uploaded_total = agg.uploaded_total
for key, st in agg.settlements.items():
if st.status == "receivable":
r.receivable_orders += st.order_total
else:
r.paid_orders += st.order_total
r.transfers_total += st.transfer_total
r.reserve_total = sum(m.reserve for m in receivable.marketplaces.values())
r.final_receivable_usd = receivable.total_usd + manual_adjustments
reconstructed = r.receivable_orders + r.paid_orders + r.transfers_total
r.identity_difference = r.uploaded_total - reconstructed
if abs(r.identity_difference) > tolerance:
r.status = "Unreconciled"
r.notes.append(
f"Uploaded total {r.uploaded_total:,.2f} != "
f"receivable+paid+transfers {reconstructed:,.2f} "
f"(diff {r.identity_difference:,.2f})."
)
if expected_receivable is not None:
r.expected_difference = r.final_receivable_usd - expected_receivable
if abs(r.expected_difference) > tolerance:
r.status = "Unreconciled"
r.notes.append(
f"Computed receivable {r.final_receivable_usd:,.2f} vs expected "
f"{expected_receivable:,.2f} (diff {r.expected_difference:,.2f})."
)
return r