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

83 lines
3.1 KiB
Python

"""
Reconciliation of the month-end close.
Internal identity (always holds and proves nothing was silently dropped):
uploaded_total = receivable_orders + paid_orders + transfers_total
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