diff --git a/app/modules/accounting/accounting_mirror_service.py b/app/modules/accounting/accounting_mirror_service.py index a75efc9..e852efc 100644 --- a/app/modules/accounting/accounting_mirror_service.py +++ b/app/modules/accounting/accounting_mirror_service.py @@ -479,6 +479,16 @@ def sundry_creditors_aging( # composition of the closing balance. closing_lots: list[dict[str, Any]] = [] + # A Sundry Creditor ledger can legitimately open with either a credit + # balance (amount payable) or a debit balance (advance / sales-side net + # receivable in the same party ledger). The earlier FIFO code created + # a lot only for a positive opening balance and silently discarded a + # debit opening balance. That inflated the surviving creditor lots by + # exactly the unconsumed debit opening. Preserve both sides: a credit + # opening becomes the oldest payable lot; a debit opening becomes a + # carry-forward that offsets the earliest future creditor credits. + debit_carry = round(max(0.0, -opening_balance), 2) + if opening_balance > 0.009: closing_lots.append({ "party_name": party_name, @@ -494,7 +504,13 @@ def sundry_creditors_aging( "final_payment_date": "", }) - def settle_fifo(lots: list[dict[str, Any]], amount: float) -> None: + def settle_fifo(lots: list[dict[str, Any]], amount: float) -> float: + """Settle oldest payable lots and return any excess debit amount. + + Excess debit is not discarded. In a mixed purchase/sales party + ledger it represents an advance / receivable that must offset the + next creditor-increasing credit before a new payable lot is born. + """ remaining_payment = max(0.0, _money(amount)) for lot in lots: if remaining_payment <= 0.009: @@ -505,6 +521,7 @@ def sundry_creditors_aging( applied = round(min(available, remaining_payment), 2) lot["remaining"] = round(available - applied, 2) remaining_payment = round(remaining_payment - applied, 2) + return remaining_payment for row in fy_rows: side = str(row.get("dr_cr") or "").strip().upper() @@ -512,6 +529,14 @@ def sundry_creditors_aging( if amount <= 0.009: continue if side == "CR": + # Consume any brought-forward / accumulated debit balance first. + # Only the residual credit creates a creditor FIFO lot. + if debit_carry > 0.009: + offset = round(min(debit_carry, amount), 2) + debit_carry = round(debit_carry - offset, 2) + amount = round(amount - offset, 2) + if amount <= 0.009: + continue closing_lots.append({ "party_name": party_name, "source": "Voucher", @@ -526,7 +551,9 @@ def sundry_creditors_aging( "final_payment_date": "", }) elif side == "DR": - settle_fifo(closing_lots, amount) + excess_debit = settle_fifo(closing_lots, amount) + if excess_debit > 0.009: + debit_carry = round(debit_carry + excess_debit, 2) # Remove fully settled lots and freeze the surviving FY-end outstanding # amounts before any optional follow-up-year settlement is applied. @@ -538,11 +565,13 @@ def sundry_creditors_aging( 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: + fifo_net = round(fifo_total - debit_carry, 2) + if abs(fifo_net - historical_closing) > 0.01 or 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." + f"selected-FY closing {closing_balance:.2f}, FIFO lots {fifo_total:.2f}, " + f"debit carry {debit_carry:.2f}. Refresh the Accounting Mirror for the " + "selected FY and retry." ) closing_lots = sorted(