From d82cea27919b3044f27d9bcc0d84056a27554805 Mon Sep 17 00:00:00 2001 From: A R R R Associates Date: Thu, 10 Sep 2026 16:34:57 +0530 Subject: [PATCH] Fix sundry creditors FIFO to clear opening balance first --- .../accounting/accounting_mirror_service.py | 98 ++++++++----------- 1 file changed, 43 insertions(+), 55 deletions(-) diff --git a/app/modules/accounting/accounting_mirror_service.py b/app/modules/accounting/accounting_mirror_service.py index 57813fe..0e29802 100644 --- a/app/modules/accounting/accounting_mirror_service.py +++ b/app/modules/accounting/accounting_mirror_service.py @@ -473,23 +473,22 @@ def sundry_creditors_aging( if closing_balance <= 0.009: continue - # True forward FIFO over genuine payable lots. Receipt vouchers are - # settlement of sales/debtor balances and must never become purchase - # bills merely because the party-ledger line is on the CR side. The - # payable queue therefore consists of opening-credit and Purchase - # credits only. All DR movements (payments, sales offsets, debit notes, - # journals, etc.) clear the oldest payable lots first. A debit excess is - # carried forward and offsets later Purchase credits before a new bill - # lot is created. Receipt/non-purchase credits first reduce that debit - # carry; any residual is tracked separately as a non-purchase credit - # balance so the ledger closing still reconciles without mislabelling it - # as an outstanding purchase bill. - closing_lots: list[dict[str, Any]] = [] - non_purchase_credit_lots: list[dict[str, Any]] = [] + # Strict signed forward FIFO. The opening credit balance is the first + # liability lot and therefore every debit-side settlement clears it before + # any later purchase/credit lot. Once the opening lot is exhausted, the + # same debit continues against subsequent credit lots in chronological + # order. If a debit exceeds all credit lots, the excess becomes a debit + # carry and offsets the next credit before that credit can create a new + # outstanding lot. This mirrors the user's FIFO requirement exactly. + # + # Non-purchase credits are retained only when they genuinely survive in + # the signed party balance (for example an advance/other credit). They are + # never labelled as purchase bills in the drill-down. + credit_lots: list[dict[str, Any]] = [] debit_carry = round(max(0.0, -opening_balance), 2) if opening_balance > 0.009: - closing_lots.append({ + credit_lots.append({ "party_name": party_name, "source": "Opening / brought forward", "voucher_date": opening_fallback_date.isoformat(), @@ -507,6 +506,7 @@ def sundry_creditors_aging( return "purchase" in str(row.get("voucher_type") or "").strip().casefold() def _settle_oldest(lots: list[dict[str, Any]], amount: float) -> float: + """Apply a debit against the oldest surviving credit lots first.""" remaining = max(0.0, _money(amount)) for lot in lots: if remaining <= 0.009: @@ -519,11 +519,11 @@ def sundry_creditors_aging( remaining = round(remaining - applied, 2) return remaining - def _append_credit_lot(target: list[dict[str, Any]], row: dict[str, Any], amount: float, source: str) -> None: + def _append_credit_lot(row: dict[str, Any], amount: float, source: str) -> None: amount = _money(amount) if amount <= 0.009: return - target.append({ + credit_lots.append({ "party_name": party_name, "source": source, "voucher_date": str(row.get("voucher_date") or ""), @@ -544,15 +544,10 @@ def sundry_creditors_aging( continue if side == "DR": - # Any debit in the same party ledger reduces the oldest credit - # balance first. This deliberately includes Sales vouchers, as - # requested for mixed purchase/sales ledgers. - remaining = _settle_oldest(closing_lots, amount) - if remaining > 0.009: - # If no purchase payable remains, a debit can also consume a - # prior non-purchase credit/advance before becoming a debit - # carry for later purchases/receipts. - remaining = _settle_oldest(non_purchase_credit_lots, remaining) + # FIFO rule: opening balance is physically the first entry in + # credit_lots, so payments/debit-side adjustments necessarily + # exhaust opening first and then move to subsequent credits. + remaining = _settle_oldest(credit_lots, amount) if remaining > 0.009: debit_carry = round(debit_carry + remaining, 2) continue @@ -560,9 +555,8 @@ def sundry_creditors_aging( if side != "CR": continue - # Every credit first offsets any debit/advance/receivable carried - # forward in the same ledger. Only the residual can form a credit - # balance at that point in time. + # A prior excess debit is settled before this credit can become a + # fresh outstanding lot. Only the residual credit survives. residual = amount if debit_carry > 0.009: offset = round(min(debit_carry, residual), 2) @@ -571,37 +565,31 @@ def sundry_creditors_aging( if residual <= 0.009: continue - if _is_purchase_voucher(row): - _append_credit_lot(closing_lots, row, residual, "Purchase voucher") - else: - # Receipt / journal / other CR rows are retained only as - # non-purchase credit balances. They are never displayed as - # purchase bills. This prevents the earlier defect where - # Receipt vouchers themselves were aged as creditor bills. - _append_credit_lot(non_purchase_credit_lots, row, residual, "Other credit / advance") + source = "Purchase voucher" if _is_purchase_voucher(row) else "Other credit / advance" + _append_credit_lot(row, residual, source) - # Freeze surviving lots. Purchase/opening lots drive the purchase-bill - # drill-down; surviving non-purchase credits are included separately so - # the summary remains tied to the actual selected-FY closing balance. - closing_lots = [lot for lot in closing_lots if _money(lot.get("remaining")) > 0.009] - non_purchase_credit_lots = [ - lot for lot in non_purchase_credit_lots if _money(lot.get("remaining")) > 0.009 + # Freeze the year-end signed FIFO position. The queue order has never + # changed, so any surviving lots are precisely the oldest-to-newest + # credit components after opening and all FY settlements have been + # applied. + closing_lots = [ + lot for lot in credit_lots + if _money(lot.get("remaining")) > 0.009 ] - for lot in closing_lots + non_purchase_credit_lots: + for lot in closing_lots: lot["outstanding_at_fy_end"] = _money(lot.get("remaining")) - # If the ledger closes in credit, no debit carry should remain. Small - # rounding differences are tolerated; a material mismatch is surfaced - # instead of silently shifting amounts between ageing buckets. - purchase_total = round(sum(_money(lot.get("remaining")) for lot in closing_lots), 2) - other_credit_total = round(sum(_money(lot.get("remaining")) for lot in non_purchase_credit_lots), 2) - reconstructed_credit = round(purchase_total + other_credit_total - debit_carry, 2) + reconstructed_credit = round( + sum(_money(lot.get("remaining")) for lot in closing_lots) - debit_carry, + 2, + ) if abs(reconstructed_credit - closing_balance) > 0.01: raise AccountingMirrorError( f"FIFO movement reconstruction does not reconcile for {party_name}: " - f"selected-FY closing {closing_balance:.2f}, purchase/opening lots {purchase_total:.2f}, " - f"other credit {other_credit_total:.2f}, debit carry {debit_carry:.2f}. " - "Refresh the Accounting Mirror for the selected FY and retry." + f"selected-FY closing {closing_balance:.2f}, surviving credit lots " + f"{sum(_money(lot.get('remaining')) for lot in closing_lots):.2f}, " + f"debit carry {debit_carry:.2f}. Refresh the Accounting Mirror " + "for the selected FY and retry." ) # Keep all surviving credit-side components in date order for ageing. @@ -733,9 +721,9 @@ def sundry_creditors_aging( "parties": parties, "details": details, "allocation_basis": ( - "Forward FIFO from dated Accounting Mirror movements; Purchase credits create " - "payable lots, debit-side movements clear the oldest balances first, and Receipt/" - "other credits are retained separately instead of being labelled as purchase bills" + "Strict forward FIFO from dated Accounting Mirror movements; opening credit is cleared first, " + "then subsequent credit lots are cleared chronologically. Purchase vouchers remain " + "identified separately from other surviving credits/advances." ), "current_mirror": current.get("mirror") or {}, "follow_up_mirror": later.get("mirror") or {},