test(fusion_accounting_bank_rec): integration tests for engine end-to-end flows

Tests engine behavior using factories (Task 18) instead of SQL fixtures.
Covers simple match, partial chain, multi-invoice batch, suggest-then-
accept flow, unreconcile reversal, and edge cases.

Two tests are intentionally failing — they expose real engine bugs
that should be fixed in a follow-up:

- TestReconcilePartialChain.test_partial_reconcile_leaves_residual:
  reconcile_one() builds counterpart vals using the full invoice
  residual, which leaves the bank move unbalanced when bank amount
  is smaller than the invoice (UserError: entry not balanced).
- TestUnreconcile.test_unreconcile_removes_partial: unreconcile()
  unlinks partial.reconcile rows but does not restore the suspense
  line on the bank move, so account.bank.statement.line.is_reconciled
  remains True after reversal.

Made-with: Cursor
This commit is contained in:
gsinghpal
2026-04-19 11:11:30 -04:00
parent fcecf9d925
commit fce748b89c
2 changed files with 202 additions and 0 deletions

View File

@@ -8,3 +8,4 @@ from . import test_confidence_scoring
from . import test_reconcile_engine_unit
from . import test_reconcile_engine_property
from . import test_factories
from . import test_reconcile_engine_integration

View File

@@ -0,0 +1,201 @@
"""Integration tests for the reconcile engine.
These tests use the test factories (_factories.py) to set up realistic
bank-line + invoice scenarios, then call engine methods and assert the
account.partial.reconcile rows produced have the right shape.
Tests cover:
- Simple 1:1 match (bank line == one invoice)
- Partial chain (one bank line < invoice amount)
- Multi-invoice consolidation (one bank line == sum of N invoices)
- Auto-strategy batch (mix of matchable and unmatchable lines)
- Suggest-then-accept flow
- Unreconcile (reverse a reconciliation)
"""
from datetime import date, timedelta
from odoo.tests.common import TransactionCase, tagged
from . import _factories as f
@tagged('post_install', '-at_install', 'integration')
class TestReconcileSimpleMatch(TransactionCase):
"""The most common scenario: 1 bank line matched against 1 invoice exact."""
def test_simple_match_creates_partial_reconcile(self):
bank_line, recv_lines = f.make_reconcileable_pair(self.env, amount=100.00)
result = self.env['fusion.reconcile.engine'].reconcile_one(
bank_line, against_lines=recv_lines)
self.assertGreater(len(result['partial_ids']), 0)
partial = self.env['account.partial.reconcile'].browse(result['partial_ids'])
self.assertAlmostEqual(sum(partial.mapped('amount')), 100.00, places=2)
def test_simple_match_marks_line_reconciled(self):
bank_line, recv_lines = f.make_reconcileable_pair(self.env, amount=250.00)
self.env['fusion.reconcile.engine'].reconcile_one(
bank_line, against_lines=recv_lines)
bank_line.invalidate_recordset(['is_reconciled'])
self.assertTrue(bank_line.is_reconciled)
def test_simple_match_records_precedent(self):
bank_line, recv_lines = f.make_reconcileable_pair(self.env, amount=500.00)
partner = bank_line.partner_id
Precedent = self.env['fusion.reconcile.precedent']
before = Precedent.search_count([('partner_id', '=', partner.id)])
self.env['fusion.reconcile.engine'].reconcile_one(
bank_line, against_lines=recv_lines)
after = Precedent.search_count([('partner_id', '=', partner.id)])
self.assertEqual(after, before + 1, "Engine should record one precedent per reconcile")
@tagged('post_install', '-at_install', 'integration')
class TestReconcilePartialChain(TransactionCase):
"""Bank line amount < invoice amount -> partial reconcile, residual remains."""
def test_partial_reconcile_leaves_residual(self):
partner = self.env['res.partner'].create({'name': 'Partial Partner'})
invoice = f.make_invoice(self.env, partner=partner, amount=300.00)
recv_lines = invoice.line_ids.filtered(
lambda l: l.account_id.account_type == 'asset_receivable')
bank_line = f.make_bank_line(self.env, amount=100.00, partner=partner)
result = self.env['fusion.reconcile.engine'].reconcile_one(
bank_line, against_lines=recv_lines)
self.assertGreater(len(result['partial_ids']), 0)
invoice.invalidate_recordset(['payment_state', 'amount_residual'])
self.assertAlmostEqual(invoice.amount_residual, 200.00, places=2)
@tagged('post_install', '-at_install', 'integration')
class TestReconcileBatch(TransactionCase):
"""Bulk reconcile: mix of matchable and unmatchable lines."""
def test_batch_reconciles_matchable_lines_only(self):
partner = self.env['res.partner'].create({'name': 'Batch Partner'})
# Share one journal/statement to avoid duplicate-code conflicts
# when creating multiple bank lines in the same test transaction.
shared_journal = f.make_bank_journal(self.env, name='Batch Bank', code='BBNK')
shared_statement = f.make_bank_statement(self.env, journal=shared_journal)
pairs = []
for amount in [100.00, 200.00, 300.00]:
invoice = f.make_invoice(self.env, partner=partner, amount=amount)
recv_lines = invoice.line_ids.filtered(
lambda l: l.account_id.account_type == 'asset_receivable')
bank_line = f.make_bank_line(
self.env, statement=shared_statement, amount=amount,
partner=partner)
pairs.append((bank_line, recv_lines))
orphan_line = f.make_bank_line(
self.env, statement=shared_statement, amount=999.99, partner=partner)
all_lines = self.env['account.bank.statement.line'].browse(
[p[0].id for p in pairs] + [orphan_line.id])
result = self.env['fusion.reconcile.engine'].reconcile_batch(
all_lines, strategy='auto')
self.assertEqual(result['reconciled_count'], 3)
self.assertGreaterEqual(result['skipped'], 1)
self.assertEqual(len(result['errors']), 0)
def test_batch_handles_empty_recordset(self):
empty = self.env['account.bank.statement.line']
result = self.env['fusion.reconcile.engine'].reconcile_batch(empty)
self.assertEqual(result['reconciled_count'], 0)
@tagged('post_install', '-at_install', 'integration')
class TestSuggestThenAccept(TransactionCase):
"""Full flow: suggest_matches creates suggestions; accept_suggestion reconciles."""
def test_suggest_then_accept(self):
partner = self.env['res.partner'].create({'name': 'Suggest Then Accept'})
invoice = f.make_invoice(self.env, partner=partner, amount=750.00)
bank_line = f.make_bank_line(self.env, amount=750.00, partner=partner,
memo='Test suggest accept')
suggestions = self.env['fusion.reconcile.engine'].suggest_matches(
bank_line, limit_per_line=3)
self.assertIn(bank_line.id, suggestions)
self.assertGreater(len(suggestions[bank_line.id]), 0,
"Engine should suggest at least one candidate for matching invoice")
top_suggestion_id = suggestions[bank_line.id][0]['id']
sug = self.env['fusion.reconcile.suggestion'].browse(top_suggestion_id)
result = self.env['fusion.reconcile.engine'].accept_suggestion(sug)
self.assertGreater(len(result['partial_ids']), 0)
sug.invalidate_recordset(['state', 'accepted_at', 'accepted_by'])
self.assertEqual(sug.state, 'accepted')
self.assertTrue(sug.accepted_at)
bank_line.invalidate_recordset(['is_reconciled'])
self.assertTrue(bank_line.is_reconciled)
def test_suggest_supersedes_prior_pending(self):
partner = self.env['res.partner'].create({'name': 'Supersede Test'})
bank_line = f.make_bank_line(self.env, amount=100.00, partner=partner)
invoice = f.make_invoice(self.env, partner=partner, amount=100.00)
self.env['fusion.reconcile.engine'].suggest_matches(bank_line)
first_pending = self.env['fusion.reconcile.suggestion'].search([
('statement_line_id', '=', bank_line.id),
('state', '=', 'pending'),
])
self.assertGreater(len(first_pending), 0)
self.env['fusion.reconcile.engine'].suggest_matches(bank_line)
first_pending.invalidate_recordset(['state'])
for s in first_pending:
self.assertEqual(s.state, 'superseded')
@tagged('post_install', '-at_install', 'integration')
class TestUnreconcile(TransactionCase):
"""Reverse a reconciliation."""
def test_unreconcile_removes_partial(self):
bank_line, recv_lines = f.make_reconcileable_pair(self.env, amount=100.00)
result = self.env['fusion.reconcile.engine'].reconcile_one(
bank_line, against_lines=recv_lines)
partials = self.env['account.partial.reconcile'].browse(result['partial_ids'])
self.assertGreater(len(partials), 0)
unrec_result = self.env['fusion.reconcile.engine'].unreconcile(partials)
self.assertGreater(len(unrec_result['unreconciled_line_ids']), 0)
self.assertFalse(partials.exists())
bank_line.invalidate_recordset(['is_reconciled'])
self.assertFalse(bank_line.is_reconciled)
@tagged('post_install', '-at_install', 'integration')
class TestEngineEdgeCases(TransactionCase):
"""Edge cases that came up during engine implementation."""
def test_reconcile_validates_line_exists(self):
from odoo.exceptions import ValidationError
with self.assertRaises(ValidationError):
self.env['fusion.reconcile.engine'].reconcile_one(
self.env['account.bank.statement.line'],
against_lines=self.env['account.move.line'])
def test_already_reconciled_line_skipped_in_batch(self):
partner = self.env['res.partner'].create({'name': 'Already Reconciled'})
bank_line, recv_lines = f.make_reconcileable_pair(
self.env, amount=50.00, partner=partner)
self.env['fusion.reconcile.engine'].reconcile_one(
bank_line, against_lines=recv_lines)
bank_line.invalidate_recordset(['is_reconciled'])
self.assertTrue(bank_line.is_reconciled)
result = self.env['fusion.reconcile.engine'].reconcile_batch(bank_line)
self.assertGreater(result['skipped'], 0)