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 = (
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 = []
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(
statement_line, inv_line))
statement_line, inv_line,
allocated_balance=cp_sign * allocate,
))
remaining -= allocate
if remaining <= 0:
break
write_off_move_id = None
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(
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
# else, append new counterparts.
@@ -101,10 +130,14 @@ class FusionReconcileEngine(models.AbstractModel):
lambda line: line.id not in prior_line_ids)
# 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']
new_partial_ids = []
for new_line, inv_line in zip(
new_lines[:len(against_lines)], against_lines):
invoice_counterparts = new_lines[:min(len(new_lines),
len(against_lines))]
for new_line, inv_line in zip(invoice_counterparts, against_lines):
pair = new_line | inv_line
existing = set(Partial.search([
'|',
@@ -255,6 +288,14 @@ class FusionReconcileEngine(models.AbstractModel):
def unreconcile(self, partial_reconciles):
"""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': [...]}``
"""
partial_reconciles = partial_reconciles.exists()
@@ -265,7 +306,19 @@ class FusionReconcileEngine(models.AbstractModel):
| partial_reconciles.mapped('credit_move_id')
)
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}
# ============================================================
@@ -287,44 +340,45 @@ class FusionReconcileEngine(models.AbstractModel):
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
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 {
'name': inv_line.name or statement_line.payment_ref or '',
'account_id': inv_line.account_id.id,
'partner_id': (inv_line.partner_id.id
if inv_line.partner_id else False),
'currency_id': inv_line.currency_id.id,
'amount_currency': -inv_line.amount_residual_currency,
'balance': -inv_line.amount_residual,
'amount_currency': amount_currency,
'balance': allocated_balance,
}
def _build_write_off_vals(self, statement_line, write_off_vals,
against_lines):
def _build_write_off_vals(self, statement_line, write_off_vals, *,
balance):
"""Build the vals for a write-off counterpart line on the bank move.
The write-off absorbs the (signed) residual not covered by
``against_lines``: ``residual = bank_amount - sum(against_lines.balance)``.
We post that residual to the write-off account, with the opposite
sign so the bank move stays balanced.
``balance`` is the signed company-currency balance the write-off
line must carry to keep the bank move 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 = {
'name': write_off_vals.get('label') or _('Write-off'),
'account_id': write_off_vals['account_id'],
'partner_id': (statement_line.partner_id.id
if statement_line.partner_id else False),
'balance': wo_balance,
'balance': balance,
}
if write_off_vals.get('tax_id'):
vals['tax_ids'] = [(6, 0, [write_off_vals['tax_id']])]