fix(fusion_accounting_bank_rec): partial-reconcile balance + unreconcile suspense restore

Two engine bugs caught by Task 19's integration tests:

1. Partial reconcile (bank_amount < invoice_residual) was creating an
   unbalanced bank move. Counterpart balance now clamped to
   min(remaining_bank_amount, abs(invoice_residual)) so the move stays
   balanced; Odoo's reconcile() handles the resulting partial. The
   counterpart's amount_currency is scaled proportionally so multi-
   currency lines stay consistent.

2. Unreconcile only removed account.partial.reconcile rows but didn't
   restore the suspense line on the bank move, leaving is_reconciled=True
   after unreconcile. Now delegates to V19's standard
   account.bank.statement.line.action_undo_reconciliation for any
   affected bank line, which both deletes partials and restores the
   suspense state in one shot.

Made-with: Cursor
This commit is contained in:
gsinghpal
2026-04-19 11:14:43 -04:00
parent fce748b89c
commit 8be0caa474

View File

@@ -73,16 +73,45 @@ class FusionReconcileEngine(models.AbstractModel):
liquidity_lines, suspense_lines, other_lines = ( liquidity_lines, suspense_lines, other_lines = (
statement_line._seek_for_lines()) statement_line._seek_for_lines())
# Build the new counterpart lines that replace suspense. # The bank move must stay balanced after we rewrite line_ids.
# Liquidity sums to +bank_amount (or -bank_amount for outbound), so
# the new counterparts must sum to the inverse. We allocate the
# available bank amount across against_lines, clamped to each
# invoice's residual; any leftover goes to the write-off line (or
# raises if no write-off was requested).
liq_balance = sum(liquidity_lines.mapped('balance'))
# Available counterpart balance (positive magnitude) = abs(liq_balance)
remaining = abs(liq_balance)
# Counterparts mirror liquidity: opposite sign of liq_balance.
cp_sign = -1 if liq_balance >= 0 else 1
new_counterpart_vals = [] new_counterpart_vals = []
for inv_line in against_lines: for inv_line in against_lines:
inv_residual = inv_line.amount_residual
# Clamp so we never write more than the invoice residual nor more
# than what the bank line can pay.
allocate = min(remaining, abs(inv_residual))
new_counterpart_vals.append(self._build_counterpart_vals( new_counterpart_vals.append(self._build_counterpart_vals(
statement_line, inv_line)) statement_line, inv_line,
allocated_balance=cp_sign * allocate,
))
remaining -= allocate
if remaining <= 0:
break
write_off_move_id = None write_off_move_id = None
if write_off_vals: if write_off_vals:
# Write-off absorbs whatever the against_lines didn't cover.
wo_balance = cp_sign * remaining
# If user passed an explicit amount and there are no against_lines,
# honour the explicit amount (covers the pure write-off case).
if (write_off_vals.get('amount') is not None
and not against_lines):
wo_balance = -write_off_vals['amount']
new_counterpart_vals.append(self._build_write_off_vals( new_counterpart_vals.append(self._build_write_off_vals(
statement_line, write_off_vals, against_lines)) statement_line, write_off_vals, balance=wo_balance,
))
remaining = 0
# Replace the bank move line_ids: keep liquidity, drop everything # Replace the bank move line_ids: keep liquidity, drop everything
# else, append new counterparts. # else, append new counterparts.
@@ -101,10 +130,14 @@ class FusionReconcileEngine(models.AbstractModel):
lambda line: line.id not in prior_line_ids) lambda line: line.id not in prior_line_ids)
# Reconcile each new counterpart with its matched invoice line. # Reconcile each new counterpart with its matched invoice line.
# The first N new lines correspond to the first N against_lines
# (where N may be < len(against_lines) if the bank amount ran out).
# Any trailing new line is a write-off and has no invoice pair.
Partial = self.env['account.partial.reconcile'] Partial = self.env['account.partial.reconcile']
new_partial_ids = [] new_partial_ids = []
for new_line, inv_line in zip( invoice_counterparts = new_lines[:min(len(new_lines),
new_lines[:len(against_lines)], against_lines): len(against_lines))]
for new_line, inv_line in zip(invoice_counterparts, against_lines):
pair = new_line | inv_line pair = new_line | inv_line
existing = set(Partial.search([ existing = set(Partial.search([
'|', '|',
@@ -255,6 +288,14 @@ class FusionReconcileEngine(models.AbstractModel):
def unreconcile(self, partial_reconciles): def unreconcile(self, partial_reconciles):
"""Reverse a reconciliation. Handles full vs. partial chains. """Reverse a reconciliation. Handles full vs. partial chains.
Because ``reconcile_one`` rewrites the bank move's suspense line into
one or more counterpart lines, simply deleting the
``account.partial.reconcile`` rows is not enough — the bank move
would still look reconciled (no suspense line, no residual). We
delegate to V19's standard ``account.bank.statement.line.
action_undo_reconciliation`` for any affected bank line, which
clears the partials AND restores the original suspense state.
Returns: ``{'unreconciled_line_ids': [...]}`` Returns: ``{'unreconciled_line_ids': [...]}``
""" """
partial_reconciles = partial_reconciles.exists() partial_reconciles = partial_reconciles.exists()
@@ -265,7 +306,19 @@ class FusionReconcileEngine(models.AbstractModel):
| partial_reconciles.mapped('credit_move_id') | partial_reconciles.mapped('credit_move_id')
) )
line_ids = all_lines.ids line_ids = all_lines.ids
partial_reconciles.unlink() # Find any bank statement lines whose move owns one of these journal
# items; route them through the standard undo flow which both
# deletes the partials and restores the suspense line.
affected_bank_lines = self.env['account.bank.statement.line'].search([
('move_id', 'in', all_lines.mapped('move_id').ids),
])
if affected_bank_lines:
affected_bank_lines.action_undo_reconciliation()
# Anything still hanging around (rare — non-bank-line reconciles)
# gets a direct unlink as a fallback.
remaining = partial_reconciles.exists()
if remaining:
remaining.unlink()
return {'unreconciled_line_ids': line_ids} return {'unreconciled_line_ids': line_ids}
# ============================================================ # ============================================================
@@ -287,44 +340,45 @@ class FusionReconcileEngine(models.AbstractModel):
lock=lock_date, lock=lock_date,
)) ))
def _build_counterpart_vals(self, statement_line, inv_line): def _build_counterpart_vals(self, statement_line, inv_line, *,
allocated_balance):
"""Build the vals for one counterpart line that mirrors an invoice """Build the vals for one counterpart line that mirrors an invoice
line on the bank move.""" line on the bank move.
``allocated_balance`` is the signed company-currency balance to write
on the counterpart. It is clamped (by the caller) so that the bank
move stays balanced and no invoice gets over-paid. We scale
``amount_currency`` proportionally for multi-currency lines.
"""
inv_residual = inv_line.amount_residual
if inv_residual:
scale = abs(allocated_balance) / abs(inv_residual)
else:
scale = 1.0
amount_currency = -inv_line.amount_residual_currency * scale
return { return {
'name': inv_line.name or statement_line.payment_ref or '', 'name': inv_line.name or statement_line.payment_ref or '',
'account_id': inv_line.account_id.id, 'account_id': inv_line.account_id.id,
'partner_id': (inv_line.partner_id.id 'partner_id': (inv_line.partner_id.id
if inv_line.partner_id else False), if inv_line.partner_id else False),
'currency_id': inv_line.currency_id.id, 'currency_id': inv_line.currency_id.id,
'amount_currency': -inv_line.amount_residual_currency, 'amount_currency': amount_currency,
'balance': -inv_line.amount_residual, 'balance': allocated_balance,
} }
def _build_write_off_vals(self, statement_line, write_off_vals, def _build_write_off_vals(self, statement_line, write_off_vals, *,
against_lines): balance):
"""Build the vals for a write-off counterpart line on the bank move. """Build the vals for a write-off counterpart line on the bank move.
The write-off absorbs the (signed) residual not covered by ``balance`` is the signed company-currency balance the write-off
``against_lines``: ``residual = bank_amount - sum(against_lines.balance)``. line must carry to keep the bank move balanced.
We post that residual to the write-off account, with the opposite
sign so the bank move stays balanced.
""" """
bank_amount = statement_line.amount
already_covered = sum(
-line.amount_residual for line in against_lines)
residual = bank_amount - already_covered
# The counterpart on the bank move must offset the liquidity line,
# so its balance is -residual.
wo_balance = -residual
# If the user explicitly passed an amount, prefer it (overrides).
if write_off_vals.get('amount') is not None and not against_lines:
wo_balance = -write_off_vals['amount']
vals = { vals = {
'name': write_off_vals.get('label') or _('Write-off'), 'name': write_off_vals.get('label') or _('Write-off'),
'account_id': write_off_vals['account_id'], 'account_id': write_off_vals['account_id'],
'partner_id': (statement_line.partner_id.id 'partner_id': (statement_line.partner_id.id
if statement_line.partner_id else False), if statement_line.partner_id else False),
'balance': wo_balance, 'balance': balance,
} }
if write_off_vals.get('tax_id'): if write_off_vals.get('tax_id'):
vals['tax_ids'] = [(6, 0, [write_off_vals['tax_id']])] vals['tax_ids'] = [(6, 0, [write_off_vals['tax_id']])]