Fix sundry creditors FIFO to clear opening balance first
This commit is contained in:
@@ -473,23 +473,22 @@ def sundry_creditors_aging(
|
|||||||
if closing_balance <= 0.009:
|
if closing_balance <= 0.009:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# True forward FIFO over genuine payable lots. Receipt vouchers are
|
# Strict signed forward FIFO. The opening credit balance is the first
|
||||||
# settlement of sales/debtor balances and must never become purchase
|
# liability lot and therefore every debit-side settlement clears it before
|
||||||
# bills merely because the party-ledger line is on the CR side. The
|
# any later purchase/credit lot. Once the opening lot is exhausted, the
|
||||||
# payable queue therefore consists of opening-credit and Purchase
|
# same debit continues against subsequent credit lots in chronological
|
||||||
# credits only. All DR movements (payments, sales offsets, debit notes,
|
# order. If a debit exceeds all credit lots, the excess becomes a debit
|
||||||
# journals, etc.) clear the oldest payable lots first. A debit excess is
|
# carry and offsets the next credit before that credit can create a new
|
||||||
# carried forward and offsets later Purchase credits before a new bill
|
# outstanding lot. This mirrors the user's FIFO requirement exactly.
|
||||||
# lot is created. Receipt/non-purchase credits first reduce that debit
|
#
|
||||||
# carry; any residual is tracked separately as a non-purchase credit
|
# Non-purchase credits are retained only when they genuinely survive in
|
||||||
# balance so the ledger closing still reconciles without mislabelling it
|
# the signed party balance (for example an advance/other credit). They are
|
||||||
# as an outstanding purchase bill.
|
# never labelled as purchase bills in the drill-down.
|
||||||
closing_lots: list[dict[str, Any]] = []
|
credit_lots: list[dict[str, Any]] = []
|
||||||
non_purchase_credit_lots: list[dict[str, Any]] = []
|
|
||||||
debit_carry = round(max(0.0, -opening_balance), 2)
|
debit_carry = round(max(0.0, -opening_balance), 2)
|
||||||
|
|
||||||
if opening_balance > 0.009:
|
if opening_balance > 0.009:
|
||||||
closing_lots.append({
|
credit_lots.append({
|
||||||
"party_name": party_name,
|
"party_name": party_name,
|
||||||
"source": "Opening / brought forward",
|
"source": "Opening / brought forward",
|
||||||
"voucher_date": opening_fallback_date.isoformat(),
|
"voucher_date": opening_fallback_date.isoformat(),
|
||||||
@@ -507,6 +506,7 @@ def sundry_creditors_aging(
|
|||||||
return "purchase" in str(row.get("voucher_type") or "").strip().casefold()
|
return "purchase" in str(row.get("voucher_type") or "").strip().casefold()
|
||||||
|
|
||||||
def _settle_oldest(lots: list[dict[str, Any]], amount: float) -> float:
|
def _settle_oldest(lots: list[dict[str, Any]], amount: float) -> float:
|
||||||
|
"""Apply a debit against the oldest surviving credit lots first."""
|
||||||
remaining = max(0.0, _money(amount))
|
remaining = max(0.0, _money(amount))
|
||||||
for lot in lots:
|
for lot in lots:
|
||||||
if remaining <= 0.009:
|
if remaining <= 0.009:
|
||||||
@@ -519,11 +519,11 @@ def sundry_creditors_aging(
|
|||||||
remaining = round(remaining - applied, 2)
|
remaining = round(remaining - applied, 2)
|
||||||
return remaining
|
return remaining
|
||||||
|
|
||||||
def _append_credit_lot(target: list[dict[str, Any]], row: dict[str, Any], amount: float, source: str) -> None:
|
def _append_credit_lot(row: dict[str, Any], amount: float, source: str) -> None:
|
||||||
amount = _money(amount)
|
amount = _money(amount)
|
||||||
if amount <= 0.009:
|
if amount <= 0.009:
|
||||||
return
|
return
|
||||||
target.append({
|
credit_lots.append({
|
||||||
"party_name": party_name,
|
"party_name": party_name,
|
||||||
"source": source,
|
"source": source,
|
||||||
"voucher_date": str(row.get("voucher_date") or ""),
|
"voucher_date": str(row.get("voucher_date") or ""),
|
||||||
@@ -544,15 +544,10 @@ def sundry_creditors_aging(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if side == "DR":
|
if side == "DR":
|
||||||
# Any debit in the same party ledger reduces the oldest credit
|
# FIFO rule: opening balance is physically the first entry in
|
||||||
# balance first. This deliberately includes Sales vouchers, as
|
# credit_lots, so payments/debit-side adjustments necessarily
|
||||||
# requested for mixed purchase/sales ledgers.
|
# exhaust opening first and then move to subsequent credits.
|
||||||
remaining = _settle_oldest(closing_lots, amount)
|
remaining = _settle_oldest(credit_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:
|
if remaining > 0.009:
|
||||||
debit_carry = round(debit_carry + remaining, 2)
|
debit_carry = round(debit_carry + remaining, 2)
|
||||||
continue
|
continue
|
||||||
@@ -560,9 +555,8 @@ def sundry_creditors_aging(
|
|||||||
if side != "CR":
|
if side != "CR":
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Every credit first offsets any debit/advance/receivable carried
|
# A prior excess debit is settled before this credit can become a
|
||||||
# forward in the same ledger. Only the residual can form a credit
|
# fresh outstanding lot. Only the residual credit survives.
|
||||||
# balance at that point in time.
|
|
||||||
residual = amount
|
residual = amount
|
||||||
if debit_carry > 0.009:
|
if debit_carry > 0.009:
|
||||||
offset = round(min(debit_carry, residual), 2)
|
offset = round(min(debit_carry, residual), 2)
|
||||||
@@ -571,37 +565,31 @@ def sundry_creditors_aging(
|
|||||||
if residual <= 0.009:
|
if residual <= 0.009:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if _is_purchase_voucher(row):
|
source = "Purchase voucher" if _is_purchase_voucher(row) else "Other credit / advance"
|
||||||
_append_credit_lot(closing_lots, row, residual, "Purchase voucher")
|
_append_credit_lot(row, residual, source)
|
||||||
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
|
# Freeze the year-end signed FIFO position. The queue order has never
|
||||||
# drill-down; surviving non-purchase credits are included separately so
|
# changed, so any surviving lots are precisely the oldest-to-newest
|
||||||
# the summary remains tied to the actual selected-FY closing balance.
|
# credit components after opening and all FY settlements have been
|
||||||
closing_lots = [lot for lot in closing_lots if _money(lot.get("remaining")) > 0.009]
|
# applied.
|
||||||
non_purchase_credit_lots = [
|
closing_lots = [
|
||||||
lot for lot in non_purchase_credit_lots if _money(lot.get("remaining")) > 0.009
|
lot for lot in credit_lots
|
||||||
|
if _money(lot.get("remaining")) > 0.009
|
||||||
]
|
]
|
||||||
for lot in closing_lots + non_purchase_credit_lots:
|
for lot in closing_lots:
|
||||||
lot["outstanding_at_fy_end"] = _money(lot.get("remaining"))
|
lot["outstanding_at_fy_end"] = _money(lot.get("remaining"))
|
||||||
|
|
||||||
# If the ledger closes in credit, no debit carry should remain. Small
|
reconstructed_credit = round(
|
||||||
# rounding differences are tolerated; a material mismatch is surfaced
|
sum(_money(lot.get("remaining")) for lot in closing_lots) - debit_carry,
|
||||||
# instead of silently shifting amounts between ageing buckets.
|
2,
|
||||||
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:
|
if abs(reconstructed_credit - closing_balance) > 0.01:
|
||||||
raise AccountingMirrorError(
|
raise AccountingMirrorError(
|
||||||
f"FIFO movement reconstruction does not reconcile for {party_name}: "
|
f"FIFO movement reconstruction does not reconcile for {party_name}: "
|
||||||
f"selected-FY closing {closing_balance:.2f}, purchase/opening lots {purchase_total:.2f}, "
|
f"selected-FY closing {closing_balance:.2f}, surviving credit lots "
|
||||||
f"other credit {other_credit_total:.2f}, debit carry {debit_carry:.2f}. "
|
f"{sum(_money(lot.get('remaining')) for lot in closing_lots):.2f}, "
|
||||||
"Refresh the Accounting Mirror for the selected FY and retry."
|
f"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.
|
# Keep all surviving credit-side components in date order for ageing.
|
||||||
@@ -733,9 +721,9 @@ def sundry_creditors_aging(
|
|||||||
"parties": parties,
|
"parties": parties,
|
||||||
"details": details,
|
"details": details,
|
||||||
"allocation_basis": (
|
"allocation_basis": (
|
||||||
"Forward FIFO from dated Accounting Mirror movements; Purchase credits create "
|
"Strict forward FIFO from dated Accounting Mirror movements; opening credit is cleared first, "
|
||||||
"payable lots, debit-side movements clear the oldest balances first, and Receipt/"
|
"then subsequent credit lots are cleared chronologically. Purchase vouchers remain "
|
||||||
"other credits are retained separately instead of being labelled as purchase bills"
|
"identified separately from other surviving credits/advances."
|
||||||
),
|
),
|
||||||
"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 {},
|
||||||
|
|||||||
Reference in New Issue
Block a user