Fix sundry creditors FIFO ageing from accounting mirror

This commit is contained in:
A R R R Associates
2026-09-09 16:17:08 +05:30
parent 8ecf0141a0
commit 4073a93386
2 changed files with 225 additions and 95 deletions
@@ -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 {},
}