diff --git a/app/modules/accounting/accounting_mirror_service.py b/app/modules/accounting/accounting_mirror_service.py index e852efc..bd40a49 100644 --- a/app/modules/accounting/accounting_mirror_service.py +++ b/app/modules/accounting/accounting_mirror_service.py @@ -269,9 +269,9 @@ def sundry_creditors_aging( """Age closing Sundry Creditors using FIFO from the Accounting Mirror. The selected-FY closing is reconstructed from the mirror's ledger opening - balance plus all creditor movements available up to the selected FY end. - FIFO means the oldest credit lots are settled first; therefore the closing - unpaid balance is reconstructed from the newest surviving credit lots. + balance plus all creditor movements in the selected FY. For trade-creditor + ageing, that closing control is allocated to actual Purchase voucher credits + from newest to oldest, which is the year-end result of FIFO settlement. Voucher dates are normalized before ageing. This is intentionally strict: a dated movement that cannot be parsed is not silently treated as an old @@ -472,24 +472,73 @@ def sundry_creditors_aging( if closing_balance <= 0.009: continue - # 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. + # FIFO purchase-bill reconstruction. + # + # The selected-FY closing balance above is the control total and already + # includes every debit/credit movement in the party ledger (payments, + # receipts, sales adjustments, journals, etc.). For ageing of TRADE + # CREDITOR BILLS, however, only genuine Purchase voucher credits create + # purchase-bill lots. Under FIFO, settlements clear the oldest purchase + # bills first, so the year-end closing balance is represented by the + # newest purchase bills working backwards from FY end. Treating every + # CR movement as a bill (for example a Receipt credit in a mixed + # purchase/sales ledger) gives a false bill date and wrong ageing. 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) + def _is_purchase_voucher(row: dict[str, Any]) -> bool: + voucher_type = str(row.get("voucher_type") or "").strip().casefold() + # Tally voucher types can be renamed/extended (e.g. "Purchase GST"). + # The standard and derived Purchase types retain "purchase" in the + # exported voucher type name. + return "purchase" in voucher_type - if opening_balance > 0.009: + purchase_rows = [ + row for row in fy_rows + if str(row.get("dr_cr") or "").strip().upper() == "CR" + and _is_purchase_voucher(row) + and _money(row.get("amount")) > 0.009 + ] + + # Allocate the closing balance to the newest purchase bills first. This + # is the direct outstanding-lot consequence of FIFO settlement: oldest + # purchases are deemed paid first, newest purchases remain outstanding. + amount_to_allocate = closing_balance + for row in sorted( + purchase_rows, + key=lambda r: ( + r.get("_parsed_date") or opening_fallback_date, + int(r.get("line_no") or 0), + ), + reverse=True, + ): + if amount_to_allocate <= 0.009: + break + original_credit = _money(row.get("amount")) + outstanding = round(min(original_credit, amount_to_allocate), 2) + if outstanding <= 0.009: + continue + closing_lots.append({ + "party_name": party_name, + "source": "Purchase 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": original_credit, + "remaining": outstanding, + "outstanding_at_fy_end": outstanding, + "paid_subsequently": 0.0, + "final_payment_date": "", + }) + amount_to_allocate = round(amount_to_allocate - outstanding, 2) + + # Any closing amount not represented by Purchase vouchers in the + # selected FY is brought forward / outside the selected FY purchase + # history. It remains part of the control total and is aged as old. + # This also keeps the report honest when a party closing balance contains + # non-purchase credits that cannot be tied to a purchase bill from the + # current mirror period. + if amount_to_allocate > 0.009: closing_lots.append({ "party_name": party_name, "source": "Opening / brought forward", @@ -497,81 +546,19 @@ def sundry_creditors_aging( "voucher_type": "Opening", "voucher_number": "", "reference": "", - "original_credit": opening_balance, - "remaining": opening_balance, - "outstanding_at_fy_end": 0.0, + "original_credit": amount_to_allocate, + "remaining": amount_to_allocate, + "outstanding_at_fy_end": amount_to_allocate, "paid_subsequently": 0.0, "final_payment_date": "", }) - 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: - 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) - return remaining_payment - - 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": - # 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", - "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": - 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. - 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) - fifo_net = round(fifo_total - debit_carry, 2) - if abs(fifo_net - historical_closing) > 0.01 or abs(fifo_total - closing_balance) > 0.01: + fifo_total = round(sum(_money(lot.get("outstanding_at_fy_end")) 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}, " - f"debit carry {debit_carry:.2f}. Refresh the Accounting Mirror for the " - "selected FY and retry." + f"Purchase-bill FIFO reconstruction does not reconcile for {party_name}: " + f"selected-FY closing {closing_balance:.2f}, allocated lots {fifo_total:.2f}. " + "Refresh the Accounting Mirror for the selected FY and retry." ) closing_lots = sorted( diff --git a/app/modules/accounting/templates/accounting/sundry_creditors_aging.html b/app/modules/accounting/templates/accounting/sundry_creditors_aging.html index 1f658b8..5b56441 100644 --- a/app/modules/accounting/templates/accounting/sundry_creditors_aging.html +++ b/app/modules/accounting/templates/accounting/sundry_creditors_aging.html @@ -81,7 +81,7 @@