Fix sundry creditors ageing using purchase bill FIFO

This commit is contained in:
A R R R Associates
2026-09-10 14:40:40 +05:30
parent 37f6ef898c
commit 960eb87fca
2 changed files with 77 additions and 90 deletions
@@ -269,9 +269,9 @@ def sundry_creditors_aging(
"""Age closing Sundry Creditors using FIFO from the Accounting Mirror. """Age closing Sundry Creditors using FIFO from the Accounting Mirror.
The selected-FY closing is reconstructed from the mirror's ledger opening The selected-FY closing is reconstructed from the mirror's ledger opening
balance plus all creditor movements available up to the selected FY end. balance plus all creditor movements in the selected FY. For trade-creditor
FIFO means the oldest credit lots are settled first; therefore the closing ageing, that closing control is allocated to actual Purchase voucher credits
unpaid balance is reconstructed from the newest surviving credit lots. from newest to oldest, which is the year-end result of FIFO settlement.
Voucher dates are normalized before ageing. This is intentionally strict: Voucher dates are normalized before ageing. This is intentionally strict:
a dated movement that cannot be parsed is not silently treated as an old 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: if closing_balance <= 0.009:
continue continue
# True forward FIFO reconstruction. The selected-FY opening balance is # FIFO purchase-bill reconstruction.
# the oldest outstanding lot. Every creditor-increasing movement creates #
# a new dated lot; every creditor-reducing movement settles the oldest lot # The selected-FY closing balance above is the control total and already
# first. The lots that survive at FY end are therefore the actual FIFO # includes every debit/credit movement in the party ledger (payments,
# composition of the closing balance. # 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]] = [] closing_lots: list[dict[str, Any]] = []
# A Sundry Creditor ledger can legitimately open with either a credit def _is_purchase_voucher(row: dict[str, Any]) -> bool:
# balance (amount payable) or a debit balance (advance / sales-side net voucher_type = str(row.get("voucher_type") or "").strip().casefold()
# receivable in the same party ledger). The earlier FIFO code created # Tally voucher types can be renamed/extended (e.g. "Purchase GST").
# a lot only for a positive opening balance and silently discarded a # The standard and derived Purchase types retain "purchase" in the
# debit opening balance. That inflated the surviving creditor lots by # exported voucher type name.
# exactly the unconsumed debit opening. Preserve both sides: a credit return "purchase" in voucher_type
# 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: 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({ closing_lots.append({
"party_name": party_name, "party_name": party_name,
"source": "Opening / brought forward", "source": "Opening / brought forward",
@@ -497,81 +546,19 @@ def sundry_creditors_aging(
"voucher_type": "Opening", "voucher_type": "Opening",
"voucher_number": "", "voucher_number": "",
"reference": "", "reference": "",
"original_credit": opening_balance, "original_credit": amount_to_allocate,
"remaining": opening_balance, "remaining": amount_to_allocate,
"outstanding_at_fy_end": 0.0, "outstanding_at_fy_end": amount_to_allocate,
"paid_subsequently": 0.0, "paid_subsequently": 0.0,
"final_payment_date": "", "final_payment_date": "",
}) })
def settle_fifo(lots: list[dict[str, Any]], amount: float) -> float: fifo_total = round(sum(_money(lot.get("outstanding_at_fy_end")) for lot in closing_lots), 2)
"""Settle oldest payable lots and return any excess debit amount. if abs(fifo_total - closing_balance) > 0.01:
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:
raise AccountingMirrorError( raise AccountingMirrorError(
f"FIFO movement reconstruction does not reconcile for {party_name}: " f"Purchase-bill FIFO reconstruction does not reconcile for {party_name}: "
f"selected-FY closing {closing_balance:.2f}, FIFO lots {fifo_total:.2f}, " f"selected-FY closing {closing_balance:.2f}, allocated lots {fifo_total:.2f}. "
f"debit carry {debit_carry:.2f}. Refresh the Accounting Mirror for the " "Refresh the Accounting Mirror for the selected FY and retry."
"selected FY and retry."
) )
closing_lots = sorted( closing_lots = sorted(
@@ -81,7 +81,7 @@
<div class="rounded-xl border border-slate-200 bg-white"> <div class="rounded-xl border border-slate-200 bg-white">
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-slate-100 px-4 py-3"> <div class="flex flex-wrap items-center justify-between gap-3 border-b border-slate-100 px-4 py-3">
<div> <div>
<div class="font-semibold text-slate-900">{{ row.party_name }} · FIFO Outstanding Bill Drill-down</div> <div class="font-semibold text-slate-900">{{ row.party_name }} · FIFO Purchase Bill Drill-down</div>
<div class="mt-1 text-xs text-slate-500">Closing ₹ {{ '%.2f'|format(row.closing_balance) }} · ≤180 ₹ {{ '%.2f'|format(row.within_180) }} · &gt;180 ₹ {{ '%.2f'|format(row.over_180) }}</div> <div class="mt-1 text-xs text-slate-500">Closing ₹ {{ '%.2f'|format(row.closing_balance) }} · ≤180 ₹ {{ '%.2f'|format(row.within_180) }} · &gt;180 ₹ {{ '%.2f'|format(row.over_180) }}</div>
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
@@ -122,7 +122,7 @@
</table> </table>
</div> </div>
<div class="border-t border-slate-100 px-4 py-3 text-xs text-slate-500"> <div class="border-t border-slate-100 px-4 py-3 text-xs text-slate-500">
<strong>Note:</strong> “Opening / brought forward” represents an old balance for which the originating purchase voucher is outside the selected FY/mirror history. Voucher rows are FIFO-derived outstanding lots from the same calculation used in the party summary; they are not Tally bill-reference allocations. <strong>Note:</strong> “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.
</div> </div>
</div> </div>
</td> </td>