From 4073a9338637a8d0534c2efc3ff03dd269a3461f Mon Sep 17 00:00:00 2001 From: A R R R Associates Date: Wed, 9 Sep 2026 16:17:08 +0530 Subject: [PATCH] Fix sundry creditors FIFO ageing from accounting mirror --- .../accounting/accounting_mirror_service.py | 316 ++++++++++++------ .../accounting/sundry_creditors_aging.html | 4 +- 2 files changed, 225 insertions(+), 95 deletions(-) diff --git a/app/modules/accounting/accounting_mirror_service.py b/app/modules/accounting/accounting_mirror_service.py index 0fd5f32..2eda17e 100644 --- a/app/modules/accounting/accounting_mirror_service.py +++ b/app/modules/accounting/accounting_mirror_service.py @@ -266,144 +266,274 @@ def sundry_creditors_aging( follow_up_end: str = "", limit: int = 50000, ) -> dict[str, Any]: - """Age Sundry Creditors at FY close and optionally trace later settlement. + """Age closing Sundry Creditors using FIFO from the Accounting Mirror. - The v3 Accounting Mirror does not yet persist Tally bill-allocation references. - Accordingly this routine performs a transparent FIFO allocation over the actual - creditor-ledger movements. Debit-side payments, sales and credit adjustments - settle the oldest credit balance first. + FIFO means debit-side settlements consume the oldest creditor balance first. + At a reporting date, the unpaid closing balance is therefore represented by + the *latest* credit-side additions, with any residual carried from opening. + + We reconstruct that closing composition backwards from the authoritative + ledger closing balance. This is algebraically the same FIFO result as a + complete forward replay, but it is materially safer for an extracted mirror: + it cannot mis-age a closing balance merely because the opening balance is a + brought-forward aggregate rather than individual bills. + + Tally bill-allocation references are not yet persisted in the .act mirror, so + this remains a transparent FIFO analysis rather than Agst Ref/New Ref matching. """ from datetime import date as _date, timedelta as _timedelta current = _creditor_movements( - node_code=node_code, accounting_payload=accounting_payload, - from_date=fy_start, to_date=fy_end, limit=limit, + node_code=node_code, + accounting_payload=accounting_payload, + from_date=fy_start, + to_date=fy_end, + limit=limit, ) later = {"ledgers": [], "rows": []} if follow_up_accounting_payload is not None and follow_up_start and follow_up_end: later = _creditor_movements( - node_code=node_code, accounting_payload=follow_up_accounting_payload, - from_date=follow_up_start, to_date=follow_up_end, limit=limit, + node_code=node_code, + accounting_payload=follow_up_accounting_payload, + from_date=follow_up_start, + to_date=follow_up_end, + limit=limit, + ) + + if current.get("truncated"): + raise AccountingMirrorError( + "Sundry Creditor movement extraction reached the safety limit. " + "Increase the mirror query limit before relying on this analysis." + ) + if later.get("truncated"): + raise AccountingMirrorError( + "Follow-up Sundry Creditor movement extraction reached the safety limit. " + "Increase the mirror query limit before relying on the subsequent-payment analysis." ) fy_start_date = _date.fromisoformat(fy_start) fy_end_date = _date.fromisoformat(fy_end) opening_date = fy_start_date - _timedelta(days=1) - ledger_meta = {str(x.get("ledger_name") or "").casefold(): x for x in (current.get("ledgers") or [])} - current_rows = current.get("rows") or [] - later_rows = later.get("rows") or [] + + def _money(value: Any) -> float: + try: + return round(float(value or 0), 2) + except (TypeError, ValueError): + return 0.0 + + def _row_date(row: dict[str, Any]) -> str: + return str(row.get("voucher_date") or "").strip() + + ledger_meta = { + str(x.get("ledger_name") or "").casefold(): x + for x in (current.get("ledgers") or []) + if str(x.get("ledger_name") or "").strip() + } by_ledger: dict[str, list[dict[str, Any]]] = {} - for row in current_rows: + for row in current.get("rows") or []: name = str(row.get("ledger_name") or "").strip() if name: - by_ledger.setdefault(name, []).append(row) + by_ledger.setdefault(name.casefold(), []).append(row) + later_by_ledger: dict[str, list[dict[str, Any]]] = {} - for row in later_rows: + for row in later.get("rows") or []: name = str(row.get("ledger_name") or "").strip() if name: - later_by_ledger.setdefault(name, []).append(row) + later_by_ledger.setdefault(name.casefold(), []).append(row) parties: list[dict[str, Any]] = [] details: list[dict[str, Any]] = [] - for party_name in sorted({str(x.get("ledger_name") or "").strip() for x in current.get("ledgers") or [] if str(x.get("ledger_name") or "").strip()}, key=str.casefold): - meta = ledger_meta.get(party_name.casefold()) or {} - opening_raw = float(meta.get("opening_balance") or 0) - opening_credit = max(0.0, opening_raw) - lots: list[dict[str, Any]] = [] - if opening_credit > 0.009: - lots.append({ - "party_name": party_name, "source": "Opening Balance", "voucher_date": opening_date.isoformat(), - "voucher_type": "Opening", "voucher_number": "", "reference": "", - "original_credit": opening_credit, "remaining": opening_credit, - "outstanding_at_fy_end": 0.0, "paid_subsequently": 0.0, "final_payment_date": "", - }) - def settle(amount: float, paid_on: str, track_later: bool = False): - remaining = max(0.0, float(amount or 0)) - for lot in lots: - if remaining <= 0.009: + party_names = sorted( + { + str(x.get("ledger_name") or "").strip() + for x in (current.get("ledgers") or []) + if str(x.get("ledger_name") or "").strip() + }, + key=str.casefold, + ) + + for party_name in party_names: + key = party_name.casefold() + meta = ledger_meta.get(key) or {} + + # Sundry Creditor liability balances in the canonical mirror are credit + # balances (positive). Debit/zero closing balances are deliberately not + # reported as closing creditors. + closing_balance = max(0.0, _money(meta.get("closing_balance"))) + if closing_balance <= 0.009: + continue + + rows = by_ledger.get(key, []) + credit_rows = [ + row for row in rows + if str(row.get("dr_cr") or "").upper() == "CR" and _money(row.get("amount")) > 0.009 + ] + + # Reverse reconstruction of the closing balance under FIFO: + # oldest balances are settled first, therefore the newest credits remain. + amount_to_allocate = closing_balance + lots_newest_first: list[dict[str, Any]] = [] + for row in sorted( + credit_rows, + key=lambda r: (_row_date(r), int(r.get("line_no") or 0)), + reverse=True, + ): + 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": _row_date(row), + "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) + + # Anything not represented by current-year credits is necessarily a + # brought-forward closing component and is old at this FY close. + if amount_to_allocate > 0.009: + lots_newest_first.append({ + "party_name": party_name, + "source": "Opening / brought forward", + "voucher_date": opening_date.isoformat(), + "voucher_type": "Opening", + "voucher_number": "", + "reference": "", + "original_credit": amount_to_allocate, + "remaining": amount_to_allocate, + "outstanding_at_fy_end": amount_to_allocate, + "paid_subsequently": 0.0, + "final_payment_date": "", + }) + amount_to_allocate = 0.0 + + # FIFO settlement in the follow-up period must consume the oldest FY-end + # lots first. Later credits are new liabilities and do not change which + # FY-end lot a FIFO debit settles. + closing_lots = sorted( + lots_newest_first, + key=lambda lot: (str(lot.get("voucher_date") or ""), str(lot.get("voucher_number") or "")), + ) + + def settle_follow_up(amount: float, paid_on: str) -> None: + remaining_payment = max(0.0, _money(amount)) + for lot in closing_lots: + if remaining_payment <= 0.009: break - available = float(lot.get("remaining") or 0) + available = max(0.0, _money(lot.get("remaining"))) if available <= 0.009: continue - applied = min(available, remaining) + applied = round(min(available, remaining_payment), 2) lot["remaining"] = round(available - applied, 2) - remaining = round(remaining - applied, 2) - if track_later and lot.get("outstanding_at_fy_end", 0) > 0: - eligible = max(0.0, float(lot["outstanding_at_fy_end"]) - float(lot.get("paid_subsequently") or 0)) - later_applied = min(eligible, applied) - if later_applied > 0: - lot["paid_subsequently"] = round(float(lot.get("paid_subsequently") or 0) + later_applied, 2) - if float(lot["outstanding_at_fy_end"]) - float(lot["paid_subsequently"]) <= 0.009: - lot["final_payment_date"] = paid_on + lot["paid_subsequently"] = round(_money(lot.get("paid_subsequently")) + applied, 2) + remaining_payment = round(remaining_payment - applied, 2) + if _money(lot.get("remaining")) <= 0.009: + lot["final_payment_date"] = paid_on - for row in sorted(by_ledger.get(party_name, []), key=lambda r: (str(r.get("voucher_date") or ""), int(r.get("line_no") or 0))): - amount = abs(float(row.get("amount") or 0)) - if amount <= 0.009: - continue - side = str(row.get("dr_cr") or "").upper() - if side == "CR": - 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(amount, str(row.get("voucher_date") or ""), False) - - closing_lots = [lot for lot in lots if float(lot.get("remaining") or 0) > 0.009] - if not closing_lots: - continue - for lot in closing_lots: - lot["outstanding_at_fy_end"] = round(float(lot["remaining"]), 2) - - # Trace subsequent debit settlements only. Later credits create new liabilities - # and must not alter the settlement status of FY-end lots. - for row in sorted(later_by_ledger.get(party_name, []), key=lambda r: (str(r.get("voucher_date") or ""), int(r.get("line_no") or 0))): + for row in sorted( + later_by_ledger.get(key, []), + key=lambda r: (_row_date(r), int(r.get("line_no") or 0)), + ): if str(row.get("dr_cr") or "").upper() == "DR": - settle(abs(float(row.get("amount") or 0)), str(row.get("voucher_date") or ""), True) + settle_follow_up(_money(row.get("amount")), _row_date(row)) + + within_180 = 0.0 + over_180 = 0.0 + paid_later = 0.0 + still_unpaid = 0.0 + over_180_lots: list[dict[str, Any]] = [] - within_180 = over_180 = paid_later = still_unpaid = 0.0 - final_dates: list[str] = [] for lot in closing_lots: - lot_date = _date.fromisoformat(str(lot["voucher_date"])) + try: + lot_date = _date.fromisoformat(str(lot.get("voucher_date") or "")) + except ValueError: + # A malformed/missing date must not be classified as current. + lot_date = opening_date age_days = (fy_end_date - lot_date).days bucket = ">180 Days" if age_days > 180 else "≤180 Days" - outstanding = round(float(lot["outstanding_at_fy_end"]), 2) - paid = round(min(outstanding, float(lot.get("paid_subsequently") or 0)), 2) + outstanding = _money(lot.get("outstanding_at_fy_end")) + paid = round(min(outstanding, _money(lot.get("paid_subsequently"))), 2) after = round(max(0.0, outstanding - paid), 2) + if bucket == ">180 Days": - over_180 += outstanding - paid_later += paid - still_unpaid += after + over_180 = round(over_180 + outstanding, 2) + paid_later = round(paid_later + paid, 2) + still_unpaid = round(still_unpaid + after, 2) + over_180_lots.append(lot) else: - within_180 += outstanding - if lot.get("final_payment_date"): - final_dates.append(str(lot["final_payment_date"])) + within_180 = round(within_180 + outstanding, 2) + details.append({ - **{k: lot.get(k) for k in ("party_name","source","voucher_date","voucher_type","voucher_number","reference","original_credit","final_payment_date")}, - "outstanding_at_fy_end": outstanding, "age_days": age_days, "age_bucket": bucket, - "paid_subsequently": paid, "balance_after_follow_up": after, "allocation_basis": "FIFO from Accounting Mirror movements", + **{ + k: lot.get(k) + for k in ( + "party_name", "source", "voucher_date", "voucher_type", + "voucher_number", "reference", "original_credit", "final_payment_date", + ) + }, + "outstanding_at_fy_end": outstanding, + "age_days": age_days, + "age_bucket": bucket, + "paid_subsequently": paid, + "balance_after_follow_up": after, + "allocation_basis": "FIFO reconstructed from Accounting Mirror closing balance", }) - closing = round(within_180 + over_180, 2) + + # Date on which the party's >180 FY-end component became fully paid. + final_payment_date = "" + if over_180_lots and all(_money(lot.get("remaining")) <= 0.009 for lot in over_180_lots): + dates = [str(lot.get("final_payment_date") or "") for lot in over_180_lots if lot.get("final_payment_date")] + if dates: + final_payment_date = max(dates) + + reconstructed = round(within_180 + over_180, 2) + # Maintain the mirror ledger closing balance as the authoritative control. + variance = round(closing_balance - reconstructed, 2) + if abs(variance) > 0.01: + raise AccountingMirrorError( + f"FIFO reconstruction does not reconcile for {party_name}: " + f"mirror closing {closing_balance:.2f}, reconstructed {reconstructed:.2f}." + ) + parties.append({ - "party_name": party_name, "within_180": round(within_180,2), "over_180": round(over_180,2), - "closing_balance": closing, "over_180_paid_later": round(paid_later,2), - "over_180_still_unpaid": round(still_unpaid,2), "final_payment_date": max(final_dates) if final_dates else "", + "party_name": party_name, + "within_180": within_180, + "over_180": over_180, + "closing_balance": closing_balance, + "over_180_paid_later": paid_later, + "over_180_still_unpaid": still_unpaid, + "final_payment_date": final_payment_date, }) summary = { "party_count": len(parties), - "total_closing": round(sum(x["closing_balance"] for x in parties),2), - "within_180": round(sum(x["within_180"] for x in parties),2), - "over_180": round(sum(x["over_180"] for x in parties),2), - "over_180_paid_later": round(sum(x["over_180_paid_later"] for x in parties),2), - "over_180_still_unpaid": round(sum(x["over_180_still_unpaid"] for x in parties),2), + "total_closing": round(sum(x["closing_balance"] for x in parties), 2), + "within_180": round(sum(x["within_180"] for x in parties), 2), + "over_180": round(sum(x["over_180"] for x in parties), 2), + "over_180_paid_later": round(sum(x["over_180_paid_later"] for x in parties), 2), + "over_180_still_unpaid": round(sum(x["over_180_still_unpaid"] for x in parties), 2), } return { - "summary": summary, "parties": parties, "details": details, - "allocation_basis": "FIFO from Accounting Mirror movements; debit-side payments/sales/credit adjustments settle oldest balances first", - "current_mirror": current.get("mirror") or {}, "follow_up_mirror": later.get("mirror") or {}, + "summary": summary, + "parties": parties, + "details": details, + "allocation_basis": ( + "FIFO reconstructed from the Accounting Mirror closing balance; " + "newest credit-side additions form the unpaid closing balance and " + "debit-side settlements are deemed to clear the oldest balance first" + ), + "current_mirror": current.get("mirror") or {}, + "follow_up_mirror": later.get("mirror") or {}, } diff --git a/app/modules/accounting/templates/accounting/sundry_creditors_aging.html b/app/modules/accounting/templates/accounting/sundry_creditors_aging.html index 349bdea..f53f216 100644 --- a/app/modules/accounting/templates/accounting/sundry_creditors_aging.html +++ b/app/modules/accounting/templates/accounting/sundry_creditors_aging.html @@ -5,7 +5,7 @@

Accounting · Audit Analysis

Sundry Creditors – 180 Day Analysis

-

Analyses closing Sundry Creditors into ≤180 days and >180 days from the local Accounting Mirror. Settlements use FIFO because bill-reference allocations are not yet stored in the .act mirror. Debit-side payments, sales and credit adjustments in the same creditor ledger reduce the oldest outstanding balance first.

+

Analyses closing Sundry Creditors into ≤180 days and >180 days from the local Accounting Mirror. The closing balance is reconstructed on FIFO: debit-side settlements clear the oldest balances first, so the newest credit-side additions form the unpaid closing balance. Bill-reference allocations are not yet stored in the .act mirror.

Back to Tally Accounting @@ -58,7 +58,7 @@ -
FIFO bill / balance detail ({{ analysis.details|length }}) +
FIFO closing-balance detail ({{ analysis.details|length }})
{% for row in analysis.details %}{% endfor %}
PartySourceDateVoucherOriginalFY ClosingAgeBucketPaid LaterFollow-up BalanceFinal Paid
{{ row.party_name }}{{ row.source }}{{ row.voucher_date }}{{ row.voucher_type }} {{ row.voucher_number }}{{ '%.2f'|format(row.original_credit) }}{{ '%.2f'|format(row.outstanding_at_fy_end) }}{{ row.age_days }}{{ row.age_bucket }}{{ '%.2f'|format(row.paid_subsequently) }}{{ '%.2f'|format(row.balance_after_follow_up) }}{{ row.final_payment_date or '—' }}
{% endif %}