When fusion_accounting_bank_rec is installed, match_bank_line_to_payments and auto_reconcile_bank_lines now use fusion.reconcile.engine via the BankRecAdapter, gaining precedent recording, AI suggestion superseding, and shared validation. Legacy paths preserved for Enterprise/Community- only installs (engine model absent -> fall back to set_line_bank_statement_line and _try_auto_reconcile_statement_lines). Also wraps engine.reconcile_batch's per-line loop in a savepoint so a single bad line's DB error (e.g. check-constraint violation) no longer poisons the whole batch transaction; the existing per-line try/except now isolates failures as originally intended. Made-with: Cursor
1151 lines
44 KiB
Python
1151 lines
44 KiB
Python
import logging
|
|
from datetime import datetime
|
|
from odoo import fields
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
def get_unreconciled_bank_lines(env, params):
|
|
"""Return unreconciled bank lines for a journal/company.
|
|
|
|
Routed through the bank_rec data adapter so the result shape is identical
|
|
whether the install profile is fusion-native, Enterprise, or pure Community.
|
|
"""
|
|
from ..data_adapters import get_adapter
|
|
adapter = get_adapter(env, 'bank_rec')
|
|
rows = adapter.list_unreconciled(
|
|
journal_id=int(params['journal_id']) if params.get('journal_id') else None,
|
|
limit=int(params.get('limit', 50)),
|
|
date_from=params.get('date_from'),
|
|
date_to=params.get('date_to'),
|
|
min_amount=float(params['min_amount']) if params.get('min_amount') else None,
|
|
company_id=env.company.id,
|
|
)
|
|
return {
|
|
'count': len(rows),
|
|
'total_amount': sum(abs(r['amount']) for r in rows),
|
|
'lines': [{
|
|
'id': r['id'],
|
|
'date': str(r['date']) if r['date'] else '',
|
|
'payment_ref': r['payment_ref'] or '',
|
|
'partner_name': r['partner_name'] or '',
|
|
'amount': r['amount'],
|
|
'journal': r['journal_name'],
|
|
} for r in rows],
|
|
}
|
|
|
|
|
|
def get_unreconciled_receipts(env, params):
|
|
account_code = params.get('account_code', '1122')
|
|
accounts = env['account.account'].search([
|
|
('code', '=like', f'{account_code}%'),
|
|
('company_ids', 'in', env.company.id),
|
|
])
|
|
domain = [
|
|
('account_id', 'in', accounts.ids),
|
|
('reconciled', '=', False),
|
|
('parent_state', '=', 'posted'),
|
|
('company_id', '=', env.company.id),
|
|
]
|
|
lines = env['account.move.line'].search(domain, order='date desc')
|
|
return {
|
|
'count': len(lines),
|
|
'total_amount': sum(abs(l.amount_residual) for l in lines),
|
|
'lines': [{
|
|
'id': l.id,
|
|
'date': str(l.date),
|
|
'ref': l.ref or l.move_id.name,
|
|
'partner': l.partner_id.name if l.partner_id else '',
|
|
'amount_residual': l.amount_residual,
|
|
} for l in lines],
|
|
}
|
|
|
|
|
|
def match_bank_line_to_payments(env, params):
|
|
st_line_id = int(params['statement_line_id'])
|
|
move_line_ids = [int(x) for x in params['move_line_ids']]
|
|
st_line = env['account.bank.statement.line'].browse(st_line_id)
|
|
if not st_line.exists():
|
|
return {'error': 'Statement line not found'}
|
|
# Phase 1 Task 23: route through engine when available
|
|
if 'fusion.reconcile.engine' in env.registry:
|
|
cands = env['account.move.line'].browse(move_line_ids).exists()
|
|
if not cands:
|
|
return {'error': 'No valid move_line_ids'}
|
|
env['fusion.reconcile.engine'].reconcile_one(
|
|
st_line, against_lines=cands)
|
|
st_line.invalidate_recordset(['is_reconciled'])
|
|
else:
|
|
st_line.set_line_bank_statement_line(move_line_ids)
|
|
return {
|
|
'status': 'matched',
|
|
'statement_line_id': st_line_id,
|
|
'matched_move_lines': move_line_ids,
|
|
'is_reconciled': st_line.is_reconciled,
|
|
}
|
|
|
|
|
|
def auto_reconcile_bank_lines(env, params):
|
|
company_id = params.get('company_id', env.company.id)
|
|
lines = env['account.bank.statement.line'].search([
|
|
('is_reconciled', '=', False),
|
|
('company_id', '=', int(company_id)),
|
|
])
|
|
before_count = len(lines)
|
|
# Phase 1 Task 23: route through engine when available
|
|
if 'fusion.reconcile.engine' in env.registry:
|
|
env['fusion.reconcile.engine'].reconcile_batch(
|
|
lines, strategy='auto')
|
|
else:
|
|
lines._try_auto_reconcile_statement_lines(company_id=int(company_id))
|
|
still_unreconciled = env['account.bank.statement.line'].search([
|
|
('is_reconciled', '=', False),
|
|
('company_id', '=', int(company_id)),
|
|
])
|
|
reconciled_count = before_count - len(still_unreconciled)
|
|
return {
|
|
'status': 'completed',
|
|
'lines_before': before_count,
|
|
'lines_reconciled': reconciled_count,
|
|
'lines_remaining': len(still_unreconciled),
|
|
}
|
|
|
|
|
|
def apply_reconcile_model(env, params):
|
|
model_id = int(params['model_id'])
|
|
st_line_id = int(params['statement_line_id'])
|
|
reco_model = env['account.reconcile.model'].browse(model_id)
|
|
st_line = env['account.bank.statement.line'].browse(st_line_id)
|
|
if not reco_model.exists() or not st_line.exists():
|
|
return {'error': 'Model or statement line not found'}
|
|
_liquidity_lines, suspense_lines, _other_lines = st_line._seek_for_lines()
|
|
residual = sum(l.amount_residual for l in suspense_lines) if suspense_lines else st_line.amount
|
|
write_off_vals = reco_model._get_write_off_move_lines_dict(st_line, residual)
|
|
if write_off_vals:
|
|
line_ids_create_command = [(0, 0, vals) for vals in write_off_vals]
|
|
st_line.move_id.write({'line_ids': line_ids_create_command})
|
|
return {
|
|
'status': 'applied',
|
|
'model': reco_model.name,
|
|
'write_off_lines': len(write_off_vals) if write_off_vals else 0,
|
|
}
|
|
|
|
|
|
def unmatch_bank_line(env, params):
|
|
st_line_id = int(params['statement_line_id'])
|
|
st_line = env['account.bank.statement.line'].browse(st_line_id)
|
|
if not st_line.exists():
|
|
return {'error': 'Statement line not found'}
|
|
st_line.action_unreconcile_entry()
|
|
return {'status': 'unmatched', 'statement_line_id': st_line_id}
|
|
|
|
|
|
def get_reconcile_suggestions(env, params):
|
|
st_line_id = int(params['statement_line_id'])
|
|
st_line = env['account.bank.statement.line'].browse(st_line_id)
|
|
if not st_line.exists():
|
|
return {'error': 'Statement line not found'}
|
|
models = env['account.reconcile.model'].search([
|
|
('company_id', '=', env.company.id),
|
|
])
|
|
return {
|
|
'models': [{
|
|
'id': m.id,
|
|
'name': m.name,
|
|
'trigger': m.trigger if hasattr(m, 'trigger') else 'manual',
|
|
} for m in models],
|
|
}
|
|
|
|
|
|
def sum_payments_by_date(env, params):
|
|
"""Sum payment/journal activity for a date range.
|
|
IMPORTANT: Always pass journal_ids to filter to specific journals.
|
|
Without journal_ids, returns totals across ALL journals which is
|
|
almost never what you want for reconciliation."""
|
|
date_from = params.get('date_from')
|
|
date_to = params.get('date_to')
|
|
if not date_from or not date_to:
|
|
return {'error': 'date_from and date_to are required'}
|
|
journal_ids = params.get('journal_ids', [])
|
|
domain = [
|
|
('parent_state', '=', 'posted'),
|
|
('company_id', '=', env.company.id),
|
|
('date', '>=', date_from),
|
|
('date', '<=', date_to),
|
|
]
|
|
scope = 'all journals'
|
|
if journal_ids:
|
|
jids = [int(j) for j in journal_ids]
|
|
domain.append(('journal_id', 'in', jids))
|
|
journals = env['account.journal'].browse(jids)
|
|
scope = ', '.join(j.name for j in journals if j.exists())
|
|
else:
|
|
# Without journal filter, include a warning and break down by journal
|
|
pass
|
|
|
|
lines = env['account.move.line'].search(domain)
|
|
total_debit = sum(l.debit for l in lines)
|
|
total_credit = sum(l.credit for l in lines)
|
|
|
|
result = {
|
|
'date_from': date_from,
|
|
'date_to': date_to,
|
|
'total_debit': total_debit,
|
|
'total_credit': total_credit,
|
|
'net': total_debit - total_credit,
|
|
'line_count': len(lines),
|
|
'scope': scope,
|
|
}
|
|
|
|
# If no journal filter, add per-journal breakdown so AI doesn't
|
|
# mistake company-wide totals for a specific journal's activity
|
|
if not journal_ids:
|
|
result['warning'] = (
|
|
'No journal_ids filter was provided. These totals are across ALL '
|
|
'journals in the company. To get card payment totals, pass the '
|
|
'specific card/POS journal IDs.'
|
|
)
|
|
journal_totals = {}
|
|
for l in lines:
|
|
jname = l.journal_id.name
|
|
if jname not in journal_totals:
|
|
journal_totals[jname] = {'debit': 0.0, 'credit': 0.0, 'count': 0}
|
|
journal_totals[jname]['debit'] += l.debit
|
|
journal_totals[jname]['credit'] += l.credit
|
|
journal_totals[jname]['count'] += 1
|
|
result['by_journal'] = [
|
|
{'journal': jn, 'debit': v['debit'], 'credit': v['credit'], 'count': v['count']}
|
|
for jn, v in sorted(journal_totals.items(), key=lambda x: -x[1]['debit'])
|
|
][:15]
|
|
|
|
return result
|
|
|
|
|
|
def get_bank_line_details(env, params):
|
|
"""Get full details of a single bank statement line plus matching suggestions."""
|
|
line_id = int(params['line_id'])
|
|
line = env['account.bank.statement.line'].browse(line_id)
|
|
if not line.exists():
|
|
return {'error': 'Bank statement line not found'}
|
|
|
|
result = {
|
|
'id': line.id,
|
|
'date': str(line.date),
|
|
'payment_ref': line.payment_ref or '',
|
|
'partner_name': line.partner_name or (line.partner_id.name if line.partner_id else ''),
|
|
'partner_id': line.partner_id.id if line.partner_id else None,
|
|
'amount': line.amount,
|
|
'journal': line.journal_id.name,
|
|
'journal_id': line.journal_id.id,
|
|
'is_reconciled': line.is_reconciled,
|
|
'existing_bills': [],
|
|
'suggested_partner': None,
|
|
}
|
|
|
|
# Search for existing vendor bills matching amount ± $0.50 and date ± 3 days
|
|
abs_amount = abs(line.amount)
|
|
from datetime import timedelta as td
|
|
date_from = line.date - td(days=3)
|
|
date_to = line.date + td(days=3)
|
|
matching_bills = env['account.move'].search([
|
|
('move_type', '=', 'in_invoice'),
|
|
('state', '=', 'posted'),
|
|
('amount_total', '>=', abs_amount - 0.50),
|
|
('amount_total', '<=', abs_amount + 0.50),
|
|
('date', '>=', str(date_from)),
|
|
('date', '<=', str(date_to)),
|
|
('company_id', '=', env.company.id),
|
|
], limit=5)
|
|
for bill in matching_bills:
|
|
result['existing_bills'].append({
|
|
'id': bill.id,
|
|
'name': bill.name,
|
|
'partner': bill.partner_id.name if bill.partner_id else '',
|
|
'amount_total': bill.amount_total,
|
|
'date': str(bill.date),
|
|
'payment_state': bill.payment_state,
|
|
})
|
|
|
|
# Try to suggest a partner from payment_ref keyword
|
|
if line.payment_ref and not line.partner_id:
|
|
# Extract meaningful words from payment_ref (skip common banking terms)
|
|
skip_words = {'misc', 'payment', 'online', 'banking', 'pad', 'business',
|
|
'deposit', 'cheque', 'transfer', 'e-transfer', 'sent', 'autodeposit'}
|
|
words = [w for w in line.payment_ref.split() if len(w) > 2 and w.lower() not in skip_words]
|
|
for word in words[:3]:
|
|
partners = env['res.partner'].search([
|
|
('name', 'ilike', word),
|
|
('supplier_rank', '>', 0),
|
|
], limit=3)
|
|
if partners:
|
|
result['suggested_partner'] = {
|
|
'id': partners[0].id,
|
|
'name': partners[0].name,
|
|
'match_word': word,
|
|
}
|
|
break
|
|
|
|
return result
|
|
|
|
|
|
def check_recurring_pattern(env, params):
|
|
"""Check if a bank line matches a known recurring payment pattern.
|
|
Returns the historical coding (account, HST, partner, reconcile model) if found."""
|
|
line_id = params.get('line_id')
|
|
payment_ref = params.get('payment_ref', '')
|
|
amount = params.get('amount')
|
|
|
|
# If line_id provided, get the ref and amount from the line
|
|
if line_id:
|
|
line = env['account.bank.statement.line'].browse(int(line_id))
|
|
if line.exists():
|
|
payment_ref = line.payment_ref or ''
|
|
amount = line.amount
|
|
|
|
if not payment_ref:
|
|
return {'match': False, 'reason': 'No payment reference to match'}
|
|
|
|
# Search cached patterns by keyword
|
|
patterns = env['fusion.recurring.pattern'].search([
|
|
('company_id', '=', env.company.id),
|
|
])
|
|
|
|
best_match = None
|
|
for pat in patterns:
|
|
if not pat.ref_keyword:
|
|
continue
|
|
# Check if the pattern keyword appears in the payment_ref
|
|
if pat.ref_keyword.lower()[:30] in payment_ref.lower():
|
|
# If amount matches too, it's a strong match
|
|
if amount and pat.amount_is_fixed and abs(pat.amount - amount) < 0.01:
|
|
best_match = pat
|
|
break
|
|
# Keyword-only match (amount may vary)
|
|
if not best_match or pat.occurrences > best_match.occurrences:
|
|
best_match = pat
|
|
|
|
if not best_match:
|
|
return {'match': False, 'payment_ref': payment_ref}
|
|
|
|
result = {
|
|
'match': True,
|
|
'pattern_id': best_match.id,
|
|
'pattern_name': best_match.name,
|
|
'occurrences': best_match.occurrences,
|
|
'first_seen': str(best_match.first_seen) if best_match.first_seen else '',
|
|
'last_seen': str(best_match.last_seen) if best_match.last_seen else '',
|
|
'expense_account_id': best_match.expense_account_id.id if best_match.expense_account_id else None,
|
|
'expense_account_code': best_match.expense_account_code or '',
|
|
'expense_account_name': best_match.expense_account_id.name if best_match.expense_account_id else '',
|
|
'has_hst': best_match.has_hst,
|
|
'partner_id': best_match.partner_id.id if best_match.partner_id else None,
|
|
'partner_name': best_match.partner_id.name if best_match.partner_id else '',
|
|
'action_note': best_match.action_note or '',
|
|
'amount_is_fixed': best_match.amount_is_fixed,
|
|
}
|
|
if best_match.reconcile_model_id:
|
|
result['reconcile_model_id'] = best_match.reconcile_model_id.id
|
|
result['reconcile_model_name'] = best_match.reconcile_model_id.name
|
|
|
|
return result
|
|
|
|
|
|
def match_internal_transfers(env, params):
|
|
"""[Tier 3] Find and match inter-account transfers between two bank journals.
|
|
Matches exact amounts within a date window. Only matches when there is exactly
|
|
ONE candidate on each side (no ambiguous matches). Requires user approval.
|
|
|
|
Typical use: Scotia Current Account ↔ Scotia Visa payments."""
|
|
journal_a_id = int(params['journal_a_id']) # e.g., Scotia Current (50)
|
|
journal_b_id = int(params['journal_b_id']) # e.g., Scotia Visa (51)
|
|
date_from = params.get('date_from', '2025-01-01')
|
|
date_to = params.get('date_to', '2025-03-31')
|
|
max_days_apart = int(params.get('max_days_apart', 2))
|
|
|
|
# Get unreconciled positive lines from both journals
|
|
# (transfers show as positive on the RECEIVING side)
|
|
lines_a = env['account.bank.statement.line'].search([
|
|
('is_reconciled', '=', False),
|
|
('journal_id', '=', journal_a_id),
|
|
('company_id', '=', env.company.id),
|
|
])
|
|
lines_a = lines_a.filtered(
|
|
lambda l: l.move_id.date >= fields.Date.from_string(date_from)
|
|
and l.move_id.date <= fields.Date.from_string(date_to)
|
|
and l.amount > 0 # money coming IN on this account
|
|
)
|
|
|
|
lines_b = env['account.bank.statement.line'].search([
|
|
('is_reconciled', '=', False),
|
|
('journal_id', '=', journal_b_id),
|
|
('company_id', '=', env.company.id),
|
|
])
|
|
lines_b = lines_b.filtered(
|
|
lambda l: l.move_id.date >= fields.Date.from_string(date_from)
|
|
and l.move_id.date <= fields.Date.from_string(date_to)
|
|
and l.amount > 0 # money coming IN on this account
|
|
)
|
|
|
|
matched_pairs = []
|
|
used_a = set()
|
|
used_b = set()
|
|
|
|
# For each line in A, find exact-amount match in B within date window
|
|
for la in sorted(lines_a, key=lambda l: l.move_id.date):
|
|
if la.id in used_a:
|
|
continue
|
|
candidates = []
|
|
for lb in lines_b:
|
|
if lb.id in used_b:
|
|
continue
|
|
if abs(la.amount - lb.amount) < 0.01:
|
|
days = abs((la.move_id.date - lb.move_id.date).days)
|
|
if days <= max_days_apart:
|
|
candidates.append(lb)
|
|
# Only match if EXACTLY ONE candidate — skip ambiguous
|
|
if len(candidates) == 1:
|
|
lb = candidates[0]
|
|
matched_pairs.append({
|
|
'line_a_id': la.id,
|
|
'line_a_date': str(la.move_id.date),
|
|
'line_a_ref': la.payment_ref or '',
|
|
'line_a_journal': la.journal_id.name,
|
|
'line_b_id': lb.id,
|
|
'line_b_date': str(lb.move_id.date),
|
|
'line_b_ref': lb.payment_ref or '',
|
|
'line_b_journal': lb.journal_id.name,
|
|
'amount': la.amount,
|
|
'days_apart': abs((la.move_id.date - lb.move_id.date).days),
|
|
})
|
|
used_a.add(la.id)
|
|
used_b.add(lb.id)
|
|
|
|
if not matched_pairs:
|
|
return {
|
|
'status': 'no_matches',
|
|
'message': 'No unambiguous transfer pairs found.',
|
|
'lines_a_checked': len(lines_a),
|
|
'lines_b_checked': len(lines_b),
|
|
}
|
|
|
|
# If this is just a dry-run check (no execute flag), return the pairs for review
|
|
if not params.get('execute', False):
|
|
return {
|
|
'status': 'pairs_found',
|
|
'count': len(matched_pairs),
|
|
'pairs': matched_pairs,
|
|
'message': f'Found {len(matched_pairs)} unambiguous transfer pairs. Set execute=true to reconcile them.',
|
|
}
|
|
|
|
# Execute: create internal transfer journal entries to reconcile both sides
|
|
reconciled = []
|
|
for pair in matched_pairs:
|
|
try:
|
|
line_a = env['account.bank.statement.line'].browse(pair['line_a_id'])
|
|
line_b = env['account.bank.statement.line'].browse(pair['line_b_id'])
|
|
|
|
# Create an internal transfer payment
|
|
payment = env['account.payment'].create({
|
|
'payment_type': 'outbound',
|
|
'partner_type': 'supplier',
|
|
'partner_id': env.company.partner_id.id, # Self as partner for internal transfer
|
|
'amount': pair['amount'],
|
|
'journal_id': journal_a_id,
|
|
'destination_journal_id': journal_b_id,
|
|
'date': line_a.move_id.date,
|
|
'ref': f'Internal Transfer: {pair["line_a_ref"]} ↔ {pair["line_b_ref"]}',
|
|
'is_internal_transfer': True,
|
|
})
|
|
payment.action_post()
|
|
|
|
# Now match the payment's move lines to the bank statement lines
|
|
# The payment creates lines on both journals' outstanding accounts
|
|
for move_line in payment.move_id.line_ids:
|
|
if move_line.journal_id.id == journal_a_id and not move_line.reconciled:
|
|
try:
|
|
line_a.set_line_bank_statement_line(move_line.ids)
|
|
except Exception:
|
|
pass
|
|
# Check paired transfer for the other side
|
|
if payment.paired_internal_transfer_payment_id:
|
|
paired = payment.paired_internal_transfer_payment_id
|
|
for move_line in paired.move_id.line_ids:
|
|
if move_line.journal_id.id == journal_b_id and not move_line.reconciled:
|
|
try:
|
|
line_b.set_line_bank_statement_line(move_line.ids)
|
|
except Exception:
|
|
pass
|
|
|
|
reconciled.append({
|
|
'line_a_id': pair['line_a_id'],
|
|
'line_b_id': pair['line_b_id'],
|
|
'amount': pair['amount'],
|
|
'payment_id': payment.id,
|
|
'status': 'reconciled',
|
|
})
|
|
except Exception as e:
|
|
_logger.error("Failed to reconcile transfer pair %s: %s", pair, e)
|
|
reconciled.append({
|
|
'line_a_id': pair['line_a_id'],
|
|
'line_b_id': pair['line_b_id'],
|
|
'amount': pair['amount'],
|
|
'status': 'error',
|
|
'error': str(e),
|
|
})
|
|
|
|
return {
|
|
'status': 'executed',
|
|
'total_pairs': len(matched_pairs),
|
|
'reconciled': len([r for r in reconciled if r['status'] == 'reconciled']),
|
|
'errors': len([r for r in reconciled if r['status'] == 'error']),
|
|
'details': reconciled,
|
|
}
|
|
|
|
|
|
def find_unreconciled_cheques(env, params):
|
|
"""Find unreconciled cheque bank lines and classify as payroll vs non-payroll
|
|
by checking if the amount matches an existing payroll liability entry."""
|
|
PAYROLL_ACCT = 433 # 2201 Payroll Liabilities
|
|
journal_id = int(params.get('journal_id', 50)) # Default Scotia Current
|
|
limit = int(params.get('limit', 50))
|
|
|
|
AML = env['account.move.line'].sudo()
|
|
BSL = env['account.bank.statement.line'].sudo()
|
|
|
|
# Build set of known payroll liability amounts
|
|
payroll_amounts = set()
|
|
for aml in AML.search([
|
|
('account_id', '=', PAYROLL_ACCT),
|
|
('parent_state', '=', 'posted'),
|
|
('credit', '>', 0),
|
|
]):
|
|
payroll_amounts.add(round(aml.credit, 2))
|
|
|
|
cheque_lines = BSL.search([
|
|
('journal_id', '=', journal_id),
|
|
('is_reconciled', '=', False),
|
|
('payment_ref', 'ilike', 'cheque'),
|
|
('amount', '<', 0),
|
|
('company_id', '=', env.company.id),
|
|
], limit=limit, order='move_id desc')
|
|
|
|
payroll = []
|
|
non_payroll = []
|
|
for line in cheque_lines:
|
|
amt = round(abs(line.amount), 2)
|
|
entry = {
|
|
'id': line.id,
|
|
'date': str(line.move_id.date),
|
|
'ref': line.payment_ref or '',
|
|
'amount': amt,
|
|
'journal': line.journal_id.name,
|
|
}
|
|
if amt in payroll_amounts:
|
|
entry['type'] = 'payroll'
|
|
payroll.append(entry)
|
|
else:
|
|
entry['type'] = 'non_payroll'
|
|
non_payroll.append(entry)
|
|
|
|
return {
|
|
'count': len(cheque_lines),
|
|
'payroll_count': len(payroll),
|
|
'non_payroll_count': len(non_payroll),
|
|
'payroll': payroll,
|
|
'non_payroll': non_payroll,
|
|
}
|
|
|
|
|
|
def reconcile_payroll_cheques(env, params):
|
|
"""Reconcile payroll cheque bank lines by applying the Payroll Cheque Clearing
|
|
reconcile model. Only reconciles cheques whose amount matches an existing
|
|
payroll liability entry on account 2201. Non-payroll cheques are skipped.
|
|
|
|
Params:
|
|
journal_id (int): Bank journal ID (default 50 = Scotia Current)
|
|
line_ids (list): Optional list of specific bank line IDs to reconcile.
|
|
If not provided, reconciles all matching payroll cheques.
|
|
"""
|
|
PAYROLL_ACCT = 433
|
|
journal_id = int(params.get('journal_id', 50))
|
|
|
|
AML = env['account.move.line'].sudo()
|
|
BSL = env['account.bank.statement.line'].sudo()
|
|
RecModel = env['account.reconcile.model'].sudo()
|
|
|
|
model = RecModel.search([
|
|
('name', 'ilike', 'Payroll Cheque'),
|
|
('company_id', '=', env.company.id),
|
|
], limit=1)
|
|
if not model:
|
|
return {'error': 'No "Payroll Cheque Clearing" reconcile model found. Create one first.'}
|
|
|
|
# Get lines to process
|
|
if params.get('line_ids'):
|
|
cheque_lines = BSL.browse([int(x) for x in params['line_ids']])
|
|
cheque_lines = cheque_lines.filtered(lambda l: not l.is_reconciled)
|
|
else:
|
|
cheque_lines = BSL.search([
|
|
('journal_id', '=', journal_id),
|
|
('is_reconciled', '=', False),
|
|
('payment_ref', 'ilike', 'cheque'),
|
|
('amount', '<', 0),
|
|
('company_id', '=', env.company.id),
|
|
])
|
|
|
|
# Filter post-lock-date
|
|
lock = env.company.fiscalyear_lock_date
|
|
if lock:
|
|
cheque_lines = cheque_lines.filtered(lambda l: l.move_id.date > lock)
|
|
|
|
# Filter to payroll-only amounts
|
|
payroll_amounts = set()
|
|
for aml in AML.search([
|
|
('account_id', '=', PAYROLL_ACCT),
|
|
('parent_state', '=', 'posted'),
|
|
('credit', '>', 0),
|
|
]):
|
|
payroll_amounts.add(round(aml.credit, 2))
|
|
|
|
payroll_lines = cheque_lines.filtered(
|
|
lambda l: round(abs(l.amount), 2) in payroll_amounts
|
|
)
|
|
skipped = len(cheque_lines) - len(payroll_lines)
|
|
|
|
if not payroll_lines:
|
|
return {
|
|
'status': 'nothing_to_do',
|
|
'message': f'No payroll cheques to reconcile ({skipped} non-payroll cheques skipped)',
|
|
}
|
|
|
|
try:
|
|
model._apply_reconcile_models(payroll_lines)
|
|
env.cr.commit()
|
|
except Exception as e:
|
|
return {'error': f'Reconciliation failed: {e}'}
|
|
|
|
still = payroll_lines.filtered(lambda l: not l.is_reconciled)
|
|
reconciled = len(payroll_lines) - len(still)
|
|
|
|
return {
|
|
'status': 'completed',
|
|
'reconciled': reconciled,
|
|
'still_unreconciled': len(still),
|
|
'non_payroll_skipped': skipped,
|
|
'message': f'Reconciled {reconciled} payroll cheques. {skipped} non-payroll cheques skipped.',
|
|
}
|
|
|
|
|
|
def _extract_partner_from_ref(env, payment_ref):
|
|
"""Extract a partner from a bank line payment_ref using keyword matching."""
|
|
if not payment_ref:
|
|
return None
|
|
skip_words = {
|
|
'misc', 'payment', 'online', 'banking', 'pad', 'business', 'deposit',
|
|
'cheque', 'transfer', 'e-transfer', 'sent', 'autodeposit', 'credit',
|
|
'debit', 'memo', 'free', 'interac', 'from', 'the', 'and', 'for',
|
|
'miscellaneous', 'bill', 'correction', 'adjustment', 'other',
|
|
}
|
|
# Strip common suffixes like colons and split
|
|
clean_ref = payment_ref.replace(':', ' ').replace('-', ' ')
|
|
words = [w for w in clean_ref.split() if len(w) > 2 and w.lower() not in skip_words]
|
|
# Try progressively shorter phrases
|
|
for n in range(min(len(words), 4), 0, -1):
|
|
for i in range(len(words) - n + 1):
|
|
phrase = ' '.join(words[i:i+n])
|
|
partners = env['res.partner'].search([
|
|
('name', 'ilike', phrase),
|
|
('company_id', 'in', [env.company.id, False]),
|
|
], limit=3)
|
|
if partners:
|
|
return partners[0]
|
|
# Fallback: try each word individually with supplier/customer rank
|
|
for word in words:
|
|
if len(word) < 4:
|
|
continue
|
|
partners = env['res.partner'].search([
|
|
('name', 'ilike', word),
|
|
('company_id', 'in', [env.company.id, False]),
|
|
'|', ('customer_rank', '>', 0), ('supplier_rank', '>', 0),
|
|
], limit=3)
|
|
if partners:
|
|
return partners[0]
|
|
return None
|
|
|
|
|
|
def _find_best_subset(candidates, target, max_items=8):
|
|
"""Find the subset of candidates whose amounts sum closest to target.
|
|
Returns (aml_ids, total) for the best combination."""
|
|
items = candidates[:max_items]
|
|
if not items:
|
|
return [], 0.0
|
|
best_ids = []
|
|
best_total = 0.0
|
|
best_diff = abs(target)
|
|
n = len(items)
|
|
# Brute force all subsets (2^n, max 256)
|
|
for mask in range(1, 1 << n):
|
|
subset_ids = []
|
|
subset_total = 0.0
|
|
for j in range(n):
|
|
if mask & (1 << j):
|
|
subset_ids.append(items[j]['aml_id'])
|
|
subset_total += items[j]['amount_residual']
|
|
diff = abs(subset_total - target)
|
|
if diff < best_diff:
|
|
best_diff = diff
|
|
best_ids = subset_ids
|
|
best_total = subset_total
|
|
if diff < 0.01:
|
|
break # Exact match found
|
|
return best_ids, round(best_total, 2)
|
|
|
|
|
|
def suggest_bank_line_matches(env, params):
|
|
"""Find candidate journal items (invoices/bills) that could match a bank statement line.
|
|
Scores and ranks matches, finds best subset-sum combination.
|
|
Returns data for a reconciliation-mode fusion-table."""
|
|
line_id = int(params['statement_line_id'])
|
|
line = env['account.bank.statement.line'].browse(line_id)
|
|
if not line.exists():
|
|
return {'error': 'Bank statement line not found'}
|
|
if line.is_reconciled:
|
|
return {'error': 'Bank statement line is already reconciled'}
|
|
|
|
AML = env['account.move.line'].sudo()
|
|
bank_amount = abs(line.amount)
|
|
line_date = line.move_id.date
|
|
is_incoming = line.amount > 0 # positive = customer payment, negative = vendor payment
|
|
from datetime import timedelta as td
|
|
|
|
# Determine partner
|
|
partner = line.partner_id
|
|
if not partner:
|
|
partner = _extract_partner_from_ref(env, line.payment_ref)
|
|
|
|
# Base domain common to all searches
|
|
base_domain = [
|
|
('parent_state', '=', 'posted'),
|
|
('reconciled', '=', False),
|
|
('company_id', '=', env.company.id),
|
|
('display_type', 'not in', ('line_section', 'line_subsection', 'line_note')),
|
|
('statement_line_id', '=', False),
|
|
]
|
|
|
|
# --- PRIORITY 1: Outstanding payments/receipts on bank journal accounts ---
|
|
# These are registered payments waiting to be matched to bank lines.
|
|
# For incoming bank lines → look for outstanding receipts (credit on outstanding account)
|
|
# For outgoing bank lines → look for outstanding payments (debit on outstanding account)
|
|
outstanding_acct_ids = env['account.account'].search([
|
|
('name', 'ilike', 'outstanding'),
|
|
('company_ids', 'in', env.company.id),
|
|
]).ids
|
|
outstanding_amls = AML
|
|
if outstanding_acct_ids:
|
|
os_domain = base_domain + [('account_id', 'in', outstanding_acct_ids)]
|
|
if is_incoming:
|
|
os_domain.append(('amount_residual', '>', 0)) # Debit residual on outstanding receipts
|
|
else:
|
|
os_domain.append(('amount_residual', '<', 0)) # Credit residual on outstanding payments
|
|
if partner:
|
|
outstanding_amls = AML.search(os_domain + [('partner_id', '=', partner.id)], limit=30)
|
|
if not outstanding_amls:
|
|
outstanding_amls = AML.search(os_domain, limit=30)
|
|
else:
|
|
outstanding_amls = AML.search(os_domain, limit=30)
|
|
|
|
# --- PRIORITY 2: Open invoices/bills (receivable/payable accounts) ---
|
|
inv_domain = list(base_domain)
|
|
if is_incoming:
|
|
inv_domain.append(('account_id.account_type', '=', 'asset_receivable'))
|
|
inv_domain.append(('amount_residual', '>', 0))
|
|
else:
|
|
inv_domain.append(('account_id.account_type', '=', 'liability_payable'))
|
|
inv_domain.append(('amount_residual', '<', 0))
|
|
inv_domain.append(('date', '>=', str(line_date - td(days=90))))
|
|
inv_domain.append(('date', '<=', str(line_date + td(days=30))))
|
|
|
|
invoice_amls = AML
|
|
if partner:
|
|
invoice_amls = AML.search(inv_domain + [('partner_id', '=', partner.id)], limit=30)
|
|
if not invoice_amls:
|
|
invoice_amls = AML.search(inv_domain, limit=30)
|
|
else:
|
|
invoice_amls = AML.search(inv_domain, limit=30)
|
|
|
|
# Merge: outstanding payments first (priority), then invoices/bills
|
|
combined = outstanding_amls | invoice_amls
|
|
|
|
# Score and format candidates
|
|
outstanding_ids = set(outstanding_amls.ids) if outstanding_amls else set()
|
|
candidates = []
|
|
seen_ids = set()
|
|
for aml in combined:
|
|
if aml.id in seen_ids:
|
|
continue
|
|
seen_ids.add(aml.id)
|
|
residual = abs(aml.amount_residual)
|
|
score = 0
|
|
reasons = []
|
|
is_payment = aml.id in outstanding_ids
|
|
|
|
# Source type: payment entries get a boost (preferred match)
|
|
if is_payment:
|
|
score += 15
|
|
reasons.append('payment')
|
|
|
|
# Amount scoring
|
|
if abs(residual - bank_amount) < 0.01:
|
|
score += 40
|
|
reasons.append('exact amount')
|
|
elif residual <= bank_amount * 1.05:
|
|
score += 20
|
|
reasons.append('close amount')
|
|
|
|
# Partner scoring
|
|
if partner and aml.partner_id.id == partner.id:
|
|
score += 25
|
|
reasons.append('partner')
|
|
elif partner and aml.partner_id and partner.name and aml.partner_id.name:
|
|
p1_words = set(partner.name.upper().split())
|
|
p2_words = set(aml.partner_id.name.upper().split())
|
|
if p1_words & p2_words:
|
|
score += 10
|
|
reasons.append('partial partner')
|
|
|
|
# Date proximity scoring
|
|
days_apart = abs((aml.date - line_date).days)
|
|
if days_apart <= 3:
|
|
score += 15
|
|
reasons.append(f'{days_apart}d')
|
|
elif days_apart <= 7:
|
|
score += 10
|
|
elif days_apart <= 14:
|
|
score += 5
|
|
|
|
# Reference matching
|
|
if line.payment_ref and aml.move_id.ref:
|
|
if any(w.upper() in (aml.move_id.ref or '').upper()
|
|
for w in line.payment_ref.split() if len(w) > 3):
|
|
score += 10
|
|
reasons.append('ref match')
|
|
|
|
# Determine entry type label
|
|
entry_type = 'payment' if is_payment else 'invoice'
|
|
if aml.move_id.move_type == 'in_invoice':
|
|
entry_type = 'bill'
|
|
elif aml.move_id.move_type == 'out_invoice':
|
|
entry_type = 'invoice'
|
|
elif aml.move_id.move_type in ('in_refund', 'out_refund'):
|
|
entry_type = 'credit note'
|
|
elif aml.payment_id:
|
|
entry_type = 'payment'
|
|
|
|
candidates.append({
|
|
'aml_id': aml.id,
|
|
'move_id': aml.move_id.id,
|
|
'name': aml.move_id.name or '',
|
|
'ref': aml.move_id.ref or '',
|
|
'partner': aml.partner_id.name if aml.partner_id else '',
|
|
'partner_id': aml.partner_id.id if aml.partner_id else None,
|
|
'date': str(aml.date),
|
|
'amount_total': abs(aml.balance),
|
|
'amount_residual': residual,
|
|
'account': aml.account_id.code if hasattr(aml.account_id, 'code') else '',
|
|
'type': entry_type,
|
|
'score': score,
|
|
'reasons': ', '.join(reasons) if reasons else '',
|
|
})
|
|
|
|
# Sort by score descending
|
|
candidates.sort(key=lambda c: -c['score'])
|
|
|
|
# Find best subset-sum combination
|
|
best_combo_ids, best_combo_total = _find_best_subset(candidates, bank_amount)
|
|
|
|
# Mark which candidates are in the best combination
|
|
for c in candidates:
|
|
c['in_best_combo'] = c['aml_id'] in best_combo_ids
|
|
|
|
return {
|
|
'bank_line': {
|
|
'id': line.id,
|
|
'date': str(line_date),
|
|
'ref': line.payment_ref or '',
|
|
'amount': line.amount,
|
|
'abs_amount': bank_amount,
|
|
'journal': line.journal_id.name,
|
|
'partner': partner.name if partner else '',
|
|
'partner_id': partner.id if partner else None,
|
|
'direction': 'incoming' if is_incoming else 'outgoing',
|
|
},
|
|
'candidates': candidates[:20],
|
|
'best_combination': best_combo_ids,
|
|
'best_combination_total': best_combo_total,
|
|
'is_exact_match': abs(best_combo_total - bank_amount) < 0.01,
|
|
'count': len(candidates),
|
|
}
|
|
|
|
|
|
def search_matching_entries(env, params):
|
|
"""Search open journal items by query (invoice/bill number, amount, or partner name).
|
|
Used by the reconciliation table search bar via direct RPC."""
|
|
query = (params.get('query') or '').strip()
|
|
line_id = params.get('statement_line_id')
|
|
if not query:
|
|
return {'candidates': []}
|
|
|
|
AML = env['account.move.line'].sudo()
|
|
|
|
# Search across receivable, payable, AND outstanding accounts
|
|
outstanding_acct_ids = env['account.account'].search([
|
|
('name', 'ilike', 'outstanding'),
|
|
('company_ids', 'in', env.company.id),
|
|
]).ids
|
|
domain = [
|
|
('parent_state', '=', 'posted'),
|
|
('reconciled', '=', False),
|
|
('company_id', '=', env.company.id),
|
|
('display_type', 'not in', ('line_section', 'line_subsection', 'line_note')),
|
|
'|',
|
|
('account_id.account_type', 'in', ('asset_receivable', 'liability_payable')),
|
|
('account_id', 'in', outstanding_acct_ids),
|
|
]
|
|
|
|
# Try as amount first
|
|
try:
|
|
amount = float(query.replace('$', '').replace(',', ''))
|
|
amount_domain = domain + [
|
|
'|',
|
|
'&', ('amount_residual', '>=', amount - 0.50), ('amount_residual', '<=', amount + 0.50),
|
|
'&', ('amount_residual', '>=', -amount - 0.50), ('amount_residual', '<=', -amount + 0.50),
|
|
]
|
|
amls = AML.search(amount_domain, limit=15)
|
|
if amls:
|
|
return {'candidates': _format_aml_candidates(amls)}
|
|
except ValueError:
|
|
pass
|
|
|
|
# Search by move name (invoice/bill number)
|
|
name_amls = AML.search(domain + [('move_id.name', 'ilike', query)], limit=15)
|
|
if name_amls:
|
|
return {'candidates': _format_aml_candidates(name_amls)}
|
|
|
|
# Search by move ref
|
|
ref_amls = AML.search(domain + [('move_id.ref', 'ilike', query)], limit=15)
|
|
if ref_amls:
|
|
return {'candidates': _format_aml_candidates(ref_amls)}
|
|
|
|
# Search by partner name
|
|
partner_amls = AML.search(domain + [('partner_id.name', 'ilike', query)], limit=15)
|
|
return {'candidates': _format_aml_candidates(partner_amls)}
|
|
|
|
|
|
def _format_aml_candidates(amls):
|
|
"""Format AMLs as candidate dicts for the reconciliation table."""
|
|
return [{
|
|
'aml_id': aml.id,
|
|
'move_id': aml.move_id.id,
|
|
'name': aml.move_id.name or '',
|
|
'ref': aml.move_id.ref or '',
|
|
'partner': aml.partner_id.name if aml.partner_id else '',
|
|
'partner_id': aml.partner_id.id if aml.partner_id else None,
|
|
'date': str(aml.date),
|
|
'amount_total': abs(aml.balance),
|
|
'amount_residual': abs(aml.amount_residual),
|
|
'account': aml.account_id.code if hasattr(aml.account_id, 'code') else '',
|
|
'score': 0,
|
|
'reasons': 'manual search',
|
|
'in_best_combo': False,
|
|
} for aml in amls]
|
|
|
|
|
|
# ============================================================
|
|
# Phase 1 Bank Reconciliation: engine-backed tools
|
|
#
|
|
# These five tools wrap the fusion.reconcile.engine 6-method API via the
|
|
# bank_rec data adapter (or the engine directly when the adapter does not
|
|
# expose a wrapper). They give the AI chat the same reconciliation surface
|
|
# a human gets in the OWL bank-rec UI.
|
|
# ============================================================
|
|
|
|
|
|
def fusion_suggest_matches(env, params):
|
|
"""Compute and persist AI suggestions for one or more bank statement lines.
|
|
|
|
Wraps ``BankRecAdapter.suggest_matches`` -> ``fusion.reconcile.engine``.
|
|
"""
|
|
raw_ids = params.get('statement_line_ids')
|
|
if not raw_ids:
|
|
return {'error': 'statement_line_ids is required'}
|
|
statement_line_ids = [int(x) for x in raw_ids]
|
|
limit_per_line = int(params.get('limit_per_line', 3))
|
|
|
|
from ..data_adapters import get_adapter
|
|
adapter = get_adapter(env, 'bank_rec')
|
|
raw = adapter.suggest_matches(
|
|
statement_line_ids=statement_line_ids,
|
|
limit_per_line=limit_per_line,
|
|
company_id=env.company.id,
|
|
) or {}
|
|
|
|
suggestions = {}
|
|
total = 0
|
|
for line_id, sug_list in raw.items():
|
|
out = []
|
|
for s in sug_list:
|
|
out.append({
|
|
'suggestion_id': s.get('id'),
|
|
'candidate_id': s.get('candidate_id'),
|
|
'confidence': s.get('confidence'),
|
|
'reasoning': s.get('reasoning') or '',
|
|
'rank': s.get('rank'),
|
|
})
|
|
total += 1
|
|
suggestions[line_id] = out
|
|
return {'suggestions': suggestions, 'count': total}
|
|
|
|
|
|
def fusion_accept_suggestion(env, params):
|
|
"""Accept a fusion.reconcile.suggestion: reconciles the bank line against
|
|
the suggestion's proposed move lines and marks the suggestion accepted.
|
|
|
|
Wraps ``BankRecAdapter.accept_suggestion``.
|
|
"""
|
|
if not params.get('suggestion_id'):
|
|
return {'error': 'suggestion_id is required'}
|
|
suggestion_id = int(params['suggestion_id'])
|
|
suggestion = env['fusion.reconcile.suggestion'].browse(suggestion_id)
|
|
if not suggestion.exists():
|
|
return {'error': 'Suggestion not found'}
|
|
|
|
from ..data_adapters import get_adapter
|
|
adapter = get_adapter(env, 'bank_rec')
|
|
result = adapter.accept_suggestion(suggestion_id) or {}
|
|
statement_line = suggestion.statement_line_id
|
|
return {
|
|
'status': 'accepted',
|
|
'suggestion_id': suggestion_id,
|
|
'partial_ids': list(result.get('partial_ids') or []),
|
|
'is_reconciled': bool(statement_line.is_reconciled),
|
|
}
|
|
|
|
|
|
def fusion_reconcile_bank_line(env, params):
|
|
"""Manually reconcile a bank statement line against a set of journal items.
|
|
|
|
Routes through ``fusion.reconcile.engine.reconcile_one`` so behaviour
|
|
matches the OWL widget and ``fusion_accept_suggestion``. Use this for
|
|
direct AI-initiated matches that did not come from an AI suggestion.
|
|
"""
|
|
if not params.get('statement_line_id'):
|
|
return {'error': 'statement_line_id is required'}
|
|
raw_against = params.get('against_move_line_ids')
|
|
if not raw_against:
|
|
return {'error': 'against_move_line_ids is required'}
|
|
|
|
st_line_id = int(params['statement_line_id'])
|
|
aml_ids = [int(x) for x in raw_against]
|
|
statement_line = env['account.bank.statement.line'].browse(st_line_id)
|
|
if not statement_line.exists():
|
|
return {'error': 'Statement line not found'}
|
|
against_lines = env['account.move.line'].browse(aml_ids).exists()
|
|
if not against_lines:
|
|
return {'error': 'No valid against_move_line_ids'}
|
|
|
|
result = env['fusion.reconcile.engine'].reconcile_one(
|
|
statement_line, against_lines=against_lines)
|
|
return {
|
|
'status': 'reconciled',
|
|
'statement_line_id': st_line_id,
|
|
'partial_ids': list(result.get('partial_ids') or []),
|
|
'is_reconciled': bool(statement_line.is_reconciled),
|
|
}
|
|
|
|
|
|
def fusion_unreconcile(env, params):
|
|
"""Reverse a reconciliation by partial_reconcile_ids.
|
|
|
|
Wraps ``BankRecAdapter.unreconcile``. Works in fusion, Enterprise, and
|
|
Community installs (the adapter falls back to a standalone path when
|
|
fusion_accounting_bank_rec is not loaded).
|
|
"""
|
|
raw_ids = params.get('partial_reconcile_ids')
|
|
if not raw_ids:
|
|
return {'error': 'partial_reconcile_ids is required'}
|
|
partial_ids = [int(x) for x in raw_ids]
|
|
|
|
from ..data_adapters import get_adapter
|
|
adapter = get_adapter(env, 'bank_rec')
|
|
result = adapter.unreconcile(partial_ids) or {}
|
|
unreconciled_line_ids = list(result.get('unreconciled_line_ids') or [])
|
|
return {
|
|
'status': 'unreconciled',
|
|
'unreconciled_line_ids': unreconciled_line_ids,
|
|
'count': len(unreconciled_line_ids),
|
|
}
|
|
|
|
|
|
def fusion_get_pending_suggestions(env, params):
|
|
"""List pending fusion.reconcile.suggestion rows.
|
|
|
|
Optional filters: ``statement_line_id``, ``min_confidence`` (default 0.0),
|
|
``limit`` (default 50). Only returns suggestions in the ``pending`` state
|
|
for the current company.
|
|
"""
|
|
domain = [
|
|
('company_id', '=', env.company.id),
|
|
('state', '=', 'pending'),
|
|
]
|
|
if params.get('statement_line_id'):
|
|
domain.append(
|
|
('statement_line_id', '=', int(params['statement_line_id'])))
|
|
min_confidence = float(params.get('min_confidence') or 0.0)
|
|
if min_confidence > 0.0:
|
|
domain.append(('confidence', '>=', min_confidence))
|
|
limit = int(params.get('limit', 50))
|
|
|
|
Suggestion = env['fusion.reconcile.suggestion'].sudo()
|
|
records = Suggestion.search(
|
|
domain, limit=limit, order='confidence desc, id desc')
|
|
rows = []
|
|
for s in records:
|
|
st_line = s.statement_line_id
|
|
rows.append({
|
|
'id': s.id,
|
|
'statement_line_id': st_line.id if st_line else None,
|
|
'statement_line_ref': (
|
|
st_line.payment_ref or '' if st_line else ''),
|
|
'candidate_ids': s.proposed_move_line_ids.ids,
|
|
'confidence': s.confidence,
|
|
'rank': s.rank,
|
|
'reasoning': s.reasoning or '',
|
|
'state': s.state,
|
|
})
|
|
return {'count': len(rows), 'suggestions': rows}
|
|
|
|
|
|
TOOLS = {
|
|
'get_unreconciled_bank_lines': get_unreconciled_bank_lines,
|
|
'get_unreconciled_receipts': get_unreconciled_receipts,
|
|
'match_bank_line_to_payments': match_bank_line_to_payments,
|
|
'auto_reconcile_bank_lines': auto_reconcile_bank_lines,
|
|
'apply_reconcile_model': apply_reconcile_model,
|
|
'unmatch_bank_line': unmatch_bank_line,
|
|
'get_reconcile_suggestions': get_reconcile_suggestions,
|
|
'sum_payments_by_date': sum_payments_by_date,
|
|
'get_bank_line_details': get_bank_line_details,
|
|
'check_recurring_pattern': check_recurring_pattern,
|
|
'match_internal_transfers': match_internal_transfers,
|
|
'find_unreconciled_cheques': find_unreconciled_cheques,
|
|
'reconcile_payroll_cheques': reconcile_payroll_cheques,
|
|
'suggest_bank_line_matches': suggest_bank_line_matches,
|
|
'search_matching_entries': search_matching_entries,
|
|
# Phase 1 engine-backed tools
|
|
'fusion_suggest_matches': fusion_suggest_matches,
|
|
'fusion_accept_suggestion': fusion_accept_suggestion,
|
|
'fusion_reconcile_bank_line': fusion_reconcile_bank_line,
|
|
'fusion_unreconcile': fusion_unreconcile,
|
|
'fusion_get_pending_suggestions': fusion_get_pending_suggestions,
|
|
}
|