diff --git a/app/modules/accounting/accounting_mirror_service.py b/app/modules/accounting/accounting_mirror_service.py index bd40a49..57813fe 100644 --- a/app/modules/accounting/accounting_mirror_service.py +++ b/app/modules/accounting/accounting_mirror_service.py @@ -269,9 +269,10 @@ 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 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. + balance plus all creditor movements in the selected FY. Ageing is then + reconstructed forward in transaction order: genuine Purchase credits create + payable lots, debit-side movements clear the oldest payable lots first, and + Receipt/other credits are never mislabelled as purchase bills. 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,73 +473,22 @@ def sundry_creditors_aging( if closing_balance <= 0.009: continue - # 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. + # 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]] = [] + 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 - - 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: + if opening_balance > 0.009: closing_lots.append({ "party_name": party_name, "source": "Opening / brought forward", @@ -546,13 +496,125 @@ def sundry_creditors_aging( "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 _is_purchase_voucher(row: dict[str, Any]) -> bool: + return "purchase" in str(row.get("voucher_type") or "").strip().casefold() + + def _settle_oldest(lots: list[dict[str, Any]], amount: float) -> float: + remaining = max(0.0, _money(amount)) + for lot in lots: + if remaining <= 0.009: + break + available = max(0.0, _money(lot.get("remaining"))) + if available <= 0.009: + continue + applied = round(min(available, remaining), 2) + lot["remaining"] = round(available - applied, 2) + 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: + amount = _money(amount) + if amount <= 0.009: + return + target.append({ + "party_name": party_name, + "source": source, + "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": "", + }) + + 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 == "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) + if remaining > 0.009: + debit_carry = round(debit_carry + remaining, 2) + continue + + 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. + residual = amount + if debit_carry > 0.009: + offset = round(min(debit_carry, residual), 2) + debit_carry = round(debit_carry - offset, 2) + residual = round(residual - offset, 2) + 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") + + # 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 + ] + for lot in closing_lots + non_purchase_credit_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) + 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." + ) + + # Keep all surviving credit-side components in date order for ageing. + # Non-purchase credits remain visibly distinguished in the drill-down; + # they are not represented as purchase bills. + closing_lots = sorted( + closing_lots + non_purchase_credit_lots, + key=lambda lot: ( + _parse_date(lot.get("voucher_date")) or opening_fallback_date, + str(lot.get("voucher_number") or ""), + ), + ) + 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( @@ -671,9 +733,9 @@ def sundry_creditors_aging( "parties": parties, "details": details, "allocation_basis": ( - "FIFO from dated Accounting Mirror movements; historical closing is " - "reconstructed from ledger opening plus selected-FY movements, " - "and debit-side settlements clear the oldest outstanding lots first" + "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" ), "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 5b56441..6f95bee 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. The selected-FY closing balance and ageing are reconstructed from the Accounting Mirror 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.

+

Analyses closing Sundry Creditors into ≤180 days and >180 days from the local Accounting Mirror. The selected-FY closing balance is reconstructed from the ledger opening plus selected-FY movements. FIFO ageing is performed forward: Purchase credits create payable lots and debit-side payments/sales/adjustments clear the oldest payable lots first. Receipt/other credits are not presented as purchase bills. Bill-reference allocations are not yet stored in the .act mirror.

Back to Tally Accounting @@ -51,7 +51,7 @@
- Click a party name to see all surviving FIFO lots, or click the ≤180 / >180 amount to drill directly into that age bucket. Purchase vouchers, opening/brought-forward balances and subsequent-payment status are shown from the same FIFO calculation used by the summary. + Click a party name to see all surviving FIFO components, or click the ≤180 / >180 amount to drill directly into that age bucket. Genuine Purchase vouchers and opening/brought-forward balances are shown as payable lots; any surviving Receipt/other credit is separately labelled as Other credit / advance and is never presented as a purchase bill.
@@ -81,7 +81,7 @@
-
{{ row.party_name }} · FIFO Purchase Bill Drill-down
+
{{ row.party_name }} · FIFO Outstanding Drill-down
Closing ₹ {{ '%.2f'|format(row.closing_balance) }} · ≤180 ₹ {{ '%.2f'|format(row.within_180) }} · >180 ₹ {{ '%.2f'|format(row.over_180) }}
@@ -122,7 +122,7 @@
- Note: “Opening / brought forward” represents an old balance for which the originating purchase voucher is outside the selected FY/mirror history. Purchase-voucher rows are FIFO-derived outstanding purchase bills from the same closing-balance control used in the party summary; Receipt/Sales/other credit movements are not presented as purchase bills. These are not Tally bill-reference allocations. + Note: “Opening / brought forward” represents an old balance whose originating bill is outside the selected FY/mirror history. “Purchase voucher” rows are FIFO-derived outstanding purchase bills. “Other credit / advance” is disclosed separately only when a non-purchase credit remains in the ledger closing after FIFO netting; it is not represented as a purchase bill. These are not Tally bill-reference allocations.