diff --git a/app/modules/accounting/accounting_mirror_service.py b/app/modules/accounting/accounting_mirror_service.py index e387605..a75efc9 100644 --- a/app/modules/accounting/accounting_mirror_service.py +++ b/app/modules/accounting/accounting_mirror_service.py @@ -472,57 +472,81 @@ def sundry_creditors_aging( if closing_balance <= 0.009: continue - credit_rows = [ - row for row in rows - if str(row.get("dr_cr") or "").upper() == "CR" - and _money(row.get("amount")) > 0.009 - ] + # True forward FIFO reconstruction. The selected-FY opening balance is + # the oldest outstanding lot. Every creditor-increasing movement creates + # a new dated lot; every creditor-reducing movement settles the oldest lot + # first. The lots that survive at FY end are therefore the actual FIFO + # composition of the closing balance. + closing_lots: list[dict[str, Any]] = [] - # FIFO reverse reconstruction. FIFO settlements clear oldest balances, - # so the newest credit lots are the ones that survive at the reporting date. - amount_to_allocate = closing_balance - lots_newest_first: list[dict[str, Any]] = [] - for row in reversed(credit_rows): - if amount_to_allocate <= 0.009: - break - source_amount = _money(row.get("amount")) - allocated = round(min(source_amount, amount_to_allocate), 2) - if allocated <= 0.009: - continue - lots_newest_first.append({ - "party_name": party_name, - "source": "Voucher", - "voucher_date": str(row.get("voucher_date") or ""), - "voucher_type": str(row.get("voucher_type") or ""), - "voucher_number": str(row.get("voucher_number") or ""), - "reference": str(row.get("reference") or ""), - "original_credit": source_amount, - "remaining": allocated, - "outstanding_at_fy_end": allocated, - "paid_subsequently": 0.0, - "final_payment_date": "", - }) - amount_to_allocate = round(amount_to_allocate - allocated, 2) - - # If the closing cannot be fully represented by dated credit rows available - # in this mirror, the residual is a genuine brought-forward component. - if amount_to_allocate > 0.009: - lots_newest_first.append({ + if opening_balance > 0.009: + closing_lots.append({ "party_name": party_name, "source": "Opening / brought forward", "voucher_date": opening_fallback_date.isoformat(), "voucher_type": "Opening", "voucher_number": "", "reference": "", - "original_credit": amount_to_allocate, - "remaining": amount_to_allocate, - "outstanding_at_fy_end": amount_to_allocate, + "original_credit": opening_balance, + "remaining": opening_balance, + "outstanding_at_fy_end": 0.0, "paid_subsequently": 0.0, "final_payment_date": "", }) + def settle_fifo(lots: list[dict[str, Any]], amount: float) -> None: + remaining_payment = max(0.0, _money(amount)) + for lot in lots: + if remaining_payment <= 0.009: + break + available = max(0.0, _money(lot.get("remaining"))) + if available <= 0.009: + continue + applied = round(min(available, remaining_payment), 2) + lot["remaining"] = round(available - applied, 2) + remaining_payment = round(remaining_payment - applied, 2) + + for row in fy_rows: + side = str(row.get("dr_cr") or "").strip().upper() + amount = _money(row.get("amount")) + if amount <= 0.009: + continue + if side == "CR": + closing_lots.append({ + "party_name": party_name, + "source": "Voucher", + "voucher_date": str(row.get("voucher_date") or ""), + "voucher_type": str(row.get("voucher_type") or ""), + "voucher_number": str(row.get("voucher_number") or ""), + "reference": str(row.get("reference") or ""), + "original_credit": amount, + "remaining": amount, + "outstanding_at_fy_end": 0.0, + "paid_subsequently": 0.0, + "final_payment_date": "", + }) + elif side == "DR": + settle_fifo(closing_lots, amount) + + # Remove fully settled lots and freeze the surviving FY-end outstanding + # amounts before any optional follow-up-year settlement is applied. + closing_lots = [ + lot for lot in closing_lots + if _money(lot.get("remaining")) > 0.009 + ] + for lot in closing_lots: + lot["outstanding_at_fy_end"] = _money(lot.get("remaining")) + + fifo_total = round(sum(_money(lot.get("remaining")) for lot in closing_lots), 2) + if abs(fifo_total - closing_balance) > 0.01: + raise AccountingMirrorError( + f"FIFO movement reconstruction does not reconcile for {party_name}: " + f"selected-FY closing {closing_balance:.2f}, FIFO lots {fifo_total:.2f}. " + "Refresh the Accounting Mirror for the selected FY and retry." + ) + closing_lots = sorted( - lots_newest_first, + closing_lots, key=lambda lot: ( _parse_date(lot.get("voucher_date")) or opening_fallback_date, str(lot.get("voucher_number") or ""), @@ -633,7 +657,7 @@ def sundry_creditors_aging( "allocation_basis": ( "FIFO from dated Accounting Mirror movements; historical closing is " "reconstructed from ledger opening plus selected-FY movements, " - "and the newest surviving credit lots form the unpaid closing balance" + "and debit-side settlements clear the oldest outstanding lots first" ), "current_mirror": current.get("mirror") or {}, "follow_up_mirror": later.get("mirror") or {},