diff --git a/fusion_accounting_bank_rec/models/fusion_reconcile_engine.py b/fusion_accounting_bank_rec/models/fusion_reconcile_engine.py index 606b16a4..88f0432f 100644 --- a/fusion_accounting_bank_rec/models/fusion_reconcile_engine.py +++ b/fusion_accounting_bank_rec/models/fusion_reconcile_engine.py @@ -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']])]