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 = "", follow_up_end: str = "",
limit: int = 50000, limit: int = 50000,
) -> dict[str, Any]: ) -> 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. FIFO means debit-side settlements consume the oldest creditor balance first.
Accordingly this routine performs a transparent FIFO allocation over the actual At a reporting date, the unpaid closing balance is therefore represented by
creditor-ledger movements. Debit-side payments, sales and credit adjustments the *latest* credit-side additions, with any residual carried from opening.
settle the oldest credit balance first.
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 from datetime import date as _date, timedelta as _timedelta
current = _creditor_movements( current = _creditor_movements(
node_code=node_code, accounting_payload=accounting_payload, node_code=node_code,
from_date=fy_start, to_date=fy_end, limit=limit, accounting_payload=accounting_payload,
from_date=fy_start,
to_date=fy_end,
limit=limit,
) )
later = {"ledgers": [], "rows": []} later = {"ledgers": [], "rows": []}
if follow_up_accounting_payload is not None and follow_up_start and follow_up_end: if follow_up_accounting_payload is not None and follow_up_start and follow_up_end:
later = _creditor_movements( later = _creditor_movements(
node_code=node_code, accounting_payload=follow_up_accounting_payload, node_code=node_code,
from_date=follow_up_start, to_date=follow_up_end, limit=limit, 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_start_date = _date.fromisoformat(fy_start)
fy_end_date = _date.fromisoformat(fy_end) fy_end_date = _date.fromisoformat(fy_end)
opening_date = fy_start_date - _timedelta(days=1) 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 [] def _money(value: Any) -> float:
later_rows = later.get("rows") or [] 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]]] = {} 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() name = str(row.get("ledger_name") or "").strip()
if name: if name:
by_ledger.setdefault(name, []).append(row) by_ledger.setdefault(name.casefold(), []).append(row)
later_by_ledger: dict[str, list[dict[str, Any]]] = {} 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() name = str(row.get("ledger_name") or "").strip()
if name: if name:
later_by_ledger.setdefault(name, []).append(row) later_by_ledger.setdefault(name.casefold(), []).append(row)
parties: list[dict[str, Any]] = [] parties: list[dict[str, Any]] = []
details: 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): party_names = sorted(
remaining = max(0.0, float(amount or 0)) {
for lot in lots: str(x.get("ledger_name") or "").strip()
if remaining <= 0.009: 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 break
available = float(lot.get("remaining") or 0) available = max(0.0, _money(lot.get("remaining")))
if available <= 0.009: if available <= 0.009:
continue continue
applied = min(available, remaining) applied = round(min(available, remaining_payment), 2)
lot["remaining"] = round(available - applied, 2) lot["remaining"] = round(available - applied, 2)
remaining = round(remaining - applied, 2) lot["paid_subsequently"] = round(_money(lot.get("paid_subsequently")) + applied, 2)
if track_later and lot.get("outstanding_at_fy_end", 0) > 0: remaining_payment = round(remaining_payment - applied, 2)
eligible = max(0.0, float(lot["outstanding_at_fy_end"]) - float(lot.get("paid_subsequently") or 0)) if _money(lot.get("remaining")) <= 0.009:
later_applied = min(eligible, applied) lot["final_payment_date"] = paid_on
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
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))): for row in sorted(
amount = abs(float(row.get("amount") or 0)) later_by_ledger.get(key, []),
if amount <= 0.009: key=lambda r: (_row_date(r), int(r.get("line_no") or 0)),
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))):
if str(row.get("dr_cr") or "").upper() == "DR": 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: 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 age_days = (fy_end_date - lot_date).days
bucket = ">180 Days" if age_days > 180 else "≤180 Days" bucket = ">180 Days" if age_days > 180 else "≤180 Days"
outstanding = round(float(lot["outstanding_at_fy_end"]), 2) outstanding = _money(lot.get("outstanding_at_fy_end"))
paid = round(min(outstanding, float(lot.get("paid_subsequently") or 0)), 2) paid = round(min(outstanding, _money(lot.get("paid_subsequently"))), 2)
after = round(max(0.0, outstanding - paid), 2) after = round(max(0.0, outstanding - paid), 2)
if bucket == ">180 Days": if bucket == ">180 Days":
over_180 += outstanding over_180 = round(over_180 + outstanding, 2)
paid_later += paid paid_later = round(paid_later + paid, 2)
still_unpaid += after still_unpaid = round(still_unpaid + after, 2)
over_180_lots.append(lot)
else: else:
within_180 += outstanding within_180 = round(within_180 + outstanding, 2)
if lot.get("final_payment_date"):
final_dates.append(str(lot["final_payment_date"]))
details.append({ 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, k: lot.get(k)
"paid_subsequently": paid, "balance_after_follow_up": after, "allocation_basis": "FIFO from Accounting Mirror movements", 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({ parties.append({
"party_name": party_name, "within_180": round(within_180,2), "over_180": round(over_180,2), "party_name": party_name,
"closing_balance": closing, "over_180_paid_later": round(paid_later,2), "within_180": within_180,
"over_180_still_unpaid": round(still_unpaid,2), "final_payment_date": max(final_dates) if final_dates else "", "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 = { summary = {
"party_count": len(parties), "party_count": len(parties),
"total_closing": round(sum(x["closing_balance"] 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), "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": 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_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), "over_180_still_unpaid": round(sum(x["over_180_still_unpaid"] for x in parties), 2),
} }
return { return {
"summary": summary, "parties": parties, "details": details, "summary": summary,
"allocation_basis": "FIFO from Accounting Mirror movements; debit-side payments/sales/credit adjustments settle oldest balances first", "parties": parties,
"current_mirror": current.get("mirror") or {}, "follow_up_mirror": later.get("mirror") or {}, "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 {},
} }
@@ -5,7 +5,7 @@
<div> <div>
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-brand-600">Accounting · Audit Analysis</p> <p class="text-xs font-semibold uppercase tracking-[0.16em] text-brand-600">Accounting · Audit Analysis</p>
<h1 class="mt-1 text-2xl font-semibold text-slate-900">Sundry Creditors – 180 Day Analysis</h1> <h1 class="mt-1 text-2xl font-semibold text-slate-900">Sundry Creditors – 180 Day Analysis</h1>
<p class="mt-1 max-w-4xl text-sm text-slate-500">Analyses closing Sundry Creditors into ≤180 days and &gt;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.</p> <p class="mt-1 max-w-4xl text-sm text-slate-500">Analyses closing Sundry Creditors into ≤180 days and &gt;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.</p>
</div> </div>
<a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back to Tally Accounting</a> <a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back to Tally Accounting</a>
</div> </div>
@@ -58,7 +58,7 @@
</table> </table>
</div> </div>
<details class="rounded-2xl border border-slate-200 bg-white" open><summary class="cursor-pointer px-5 py-4 font-semibold text-slate-800">FIFO bill / balance detail ({{ analysis.details|length }})</summary> <details class="rounded-2xl border border-slate-200 bg-white" open><summary class="cursor-pointer px-5 py-4 font-semibold text-slate-800">FIFO closing-balance detail ({{ analysis.details|length }})</summary>
<div class="overflow-x-auto border-t border-slate-100"><table class="min-w-full text-xs"><thead class="bg-slate-50 text-left font-semibold uppercase tracking-wide text-slate-600"><tr><th class="px-3 py-2">Party</th><th class="px-3 py-2">Source</th><th class="px-3 py-2">Date</th><th class="px-3 py-2">Voucher</th><th class="px-3 py-2 text-right">Original</th><th class="px-3 py-2 text-right">FY Closing</th><th class="px-3 py-2 text-right">Age</th><th class="px-3 py-2">Bucket</th><th class="px-3 py-2 text-right">Paid Later</th><th class="px-3 py-2 text-right">Follow-up Balance</th><th class="px-3 py-2">Final Paid</th></tr></thead><tbody class="divide-y divide-slate-100">{% for row in analysis.details %}<tr><td class="px-3 py-2">{{ row.party_name }}</td><td class="px-3 py-2">{{ row.source }}</td><td class="px-3 py-2">{{ row.voucher_date }}</td><td class="px-3 py-2">{{ row.voucher_type }} {{ row.voucher_number }}</td><td class="px-3 py-2 text-right">{{ '%.2f'|format(row.original_credit) }}</td><td class="px-3 py-2 text-right">{{ '%.2f'|format(row.outstanding_at_fy_end) }}</td><td class="px-3 py-2 text-right">{{ row.age_days }}</td><td class="px-3 py-2 {% if row.age_bucket == '>180 Days' %}font-semibold text-rose-700{% endif %}">{{ row.age_bucket }}</td><td class="px-3 py-2 text-right">{{ '%.2f'|format(row.paid_subsequently) }}</td><td class="px-3 py-2 text-right">{{ '%.2f'|format(row.balance_after_follow_up) }}</td><td class="px-3 py-2">{{ row.final_payment_date or '—' }}</td></tr>{% endfor %}</tbody></table></div> <div class="overflow-x-auto border-t border-slate-100"><table class="min-w-full text-xs"><thead class="bg-slate-50 text-left font-semibold uppercase tracking-wide text-slate-600"><tr><th class="px-3 py-2">Party</th><th class="px-3 py-2">Source</th><th class="px-3 py-2">Date</th><th class="px-3 py-2">Voucher</th><th class="px-3 py-2 text-right">Original</th><th class="px-3 py-2 text-right">FY Closing</th><th class="px-3 py-2 text-right">Age</th><th class="px-3 py-2">Bucket</th><th class="px-3 py-2 text-right">Paid Later</th><th class="px-3 py-2 text-right">Follow-up Balance</th><th class="px-3 py-2">Final Paid</th></tr></thead><tbody class="divide-y divide-slate-100">{% for row in analysis.details %}<tr><td class="px-3 py-2">{{ row.party_name }}</td><td class="px-3 py-2">{{ row.source }}</td><td class="px-3 py-2">{{ row.voucher_date }}</td><td class="px-3 py-2">{{ row.voucher_type }} {{ row.voucher_number }}</td><td class="px-3 py-2 text-right">{{ '%.2f'|format(row.original_credit) }}</td><td class="px-3 py-2 text-right">{{ '%.2f'|format(row.outstanding_at_fy_end) }}</td><td class="px-3 py-2 text-right">{{ row.age_days }}</td><td class="px-3 py-2 {% if row.age_bucket == '>180 Days' %}font-semibold text-rose-700{% endif %}">{{ row.age_bucket }}</td><td class="px-3 py-2 text-right">{{ '%.2f'|format(row.paid_subsequently) }}</td><td class="px-3 py-2 text-right">{{ '%.2f'|format(row.balance_after_follow_up) }}</td><td class="px-3 py-2">{{ row.final_payment_date or '—' }}</td></tr>{% endfor %}</tbody></table></div>
</details> </details>
{% endif %} {% endif %}