Fix sundry creditors ageing with signed FIFO reconstruction
This commit is contained in:
@@ -269,9 +269,10 @@ 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 in the selected FY. For trade-creditor
|
balance plus all creditor movements in the selected FY. Ageing is then
|
||||||
ageing, that closing control is allocated to actual Purchase voucher credits
|
reconstructed forward in transaction order: genuine Purchase credits create
|
||||||
from newest to oldest, which is the year-end result of FIFO settlement.
|
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:
|
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,73 +473,22 @@ def sundry_creditors_aging(
|
|||||||
if closing_balance <= 0.009:
|
if closing_balance <= 0.009:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# FIFO purchase-bill reconstruction.
|
# True forward FIFO over genuine payable lots. Receipt vouchers are
|
||||||
#
|
# settlement of sales/debtor balances and must never become purchase
|
||||||
# The selected-FY closing balance above is the control total and already
|
# bills merely because the party-ledger line is on the CR side. The
|
||||||
# includes every debit/credit movement in the party ledger (payments,
|
# payable queue therefore consists of opening-credit and Purchase
|
||||||
# receipts, sales adjustments, journals, etc.). For ageing of TRADE
|
# credits only. All DR movements (payments, sales offsets, debit notes,
|
||||||
# CREDITOR BILLS, however, only genuine Purchase voucher credits create
|
# journals, etc.) clear the oldest payable lots first. A debit excess is
|
||||||
# purchase-bill lots. Under FIFO, settlements clear the oldest purchase
|
# carried forward and offsets later Purchase credits before a new bill
|
||||||
# bills first, so the year-end closing balance is represented by the
|
# lot is created. Receipt/non-purchase credits first reduce that debit
|
||||||
# newest purchase bills working backwards from FY end. Treating every
|
# carry; any residual is tracked separately as a non-purchase credit
|
||||||
# CR movement as a bill (for example a Receipt credit in a mixed
|
# balance so the ledger closing still reconciles without mislabelling it
|
||||||
# purchase/sales ledger) gives a false bill date and wrong ageing.
|
# as an outstanding purchase bill.
|
||||||
closing_lots: list[dict[str, Any]] = []
|
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:
|
if opening_balance > 0.009:
|
||||||
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:
|
|
||||||
closing_lots.append({
|
closing_lots.append({
|
||||||
"party_name": party_name,
|
"party_name": party_name,
|
||||||
"source": "Opening / brought forward",
|
"source": "Opening / brought forward",
|
||||||
@@ -546,13 +496,125 @@ def sundry_creditors_aging(
|
|||||||
"voucher_type": "Opening",
|
"voucher_type": "Opening",
|
||||||
"voucher_number": "",
|
"voucher_number": "",
|
||||||
"reference": "",
|
"reference": "",
|
||||||
"original_credit": amount_to_allocate,
|
"original_credit": opening_balance,
|
||||||
"remaining": amount_to_allocate,
|
"remaining": opening_balance,
|
||||||
"outstanding_at_fy_end": amount_to_allocate,
|
"outstanding_at_fy_end": 0.0,
|
||||||
"paid_subsequently": 0.0,
|
"paid_subsequently": 0.0,
|
||||||
"final_payment_date": "",
|
"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)
|
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:
|
if abs(fifo_total - closing_balance) > 0.01:
|
||||||
raise AccountingMirrorError(
|
raise AccountingMirrorError(
|
||||||
@@ -671,9 +733,9 @@ def sundry_creditors_aging(
|
|||||||
"parties": parties,
|
"parties": parties,
|
||||||
"details": details,
|
"details": details,
|
||||||
"allocation_basis": (
|
"allocation_basis": (
|
||||||
"FIFO from dated Accounting Mirror movements; historical closing is "
|
"Forward FIFO from dated Accounting Mirror movements; Purchase credits create "
|
||||||
"reconstructed from ledger opening plus selected-FY movements, "
|
"payable lots, debit-side movements clear the oldest balances first, and Receipt/"
|
||||||
"and debit-side settlements clear the oldest outstanding lots first"
|
"other credits are retained separately instead of being labelled as purchase bills"
|
||||||
),
|
),
|
||||||
"current_mirror": current.get("mirror") or {},
|
"current_mirror": current.get("mirror") or {},
|
||||||
"follow_up_mirror": later.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 >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.</p>
|
<p class="mt-1 max-w-4xl text-sm text-slate-500">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.</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>
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
|
|
||||||
<div class="rounded-2xl border border-slate-200 bg-white">
|
<div class="rounded-2xl border border-slate-200 bg-white">
|
||||||
<div class="border-b border-slate-100 px-4 py-3 text-xs text-slate-500">
|
<div class="border-b border-slate-100 px-4 py-3 text-xs text-slate-500">
|
||||||
Click a <strong>party name</strong> to see all surviving FIFO lots, or click the <strong>≤180</strong> / <strong>>180</strong> 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 <strong>party name</strong> to see all surviving FIFO components, or click the <strong>≤180</strong> / <strong>>180</strong> 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.
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="min-w-full text-sm">
|
<table class="min-w-full text-sm">
|
||||||
@@ -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 Purchase Bill Drill-down</div>
|
<div class="font-semibold text-slate-900">{{ row.party_name }} · FIFO Outstanding Drill-down</div>
|
||||||
<div class="mt-1 text-xs text-slate-500">Closing ₹ {{ '%.2f'|format(row.closing_balance) }} · ≤180 ₹ {{ '%.2f'|format(row.within_180) }} · >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) }} · >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. 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.
|
<strong>Note:</strong> “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.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
Reference in New Issue
Block a user