# Fusion Accounting - Cash Basis Reporting # Copyright (C) 2026 Nexa Systems Inc. (https://nexasystems.ca) # Original implementation for the Fusion Accounting module. # # Alternative report handler that uses payment dates instead of invoice # dates for recognizing revenue and expenses, supporting the cash basis # accounting method. import logging from odoo import api, fields, models, _ from odoo.tools import SQL, Query, float_is_zero _logger = logging.getLogger(__name__) class FusionCashBasisReport(models.AbstractModel): """Cash basis report custom handler. Unlike the standard accrual-based reporting, cash basis reports recognise revenue when payment is received and expenses when payment is made, regardless of when the invoice was issued. This handler: - Replaces the invoice/bill date with the payment reconciliation date - Filters transactions to only include those with matching payments - Provides a toggle in report options to switch between accrual and cash basis views """ _name = 'account.cash.basis.report.handler' _inherit = 'account.report.custom.handler' _description = 'Cash Basis Report Custom Handler' # ------------------------------------------------------------------ # Options Initializer # ------------------------------------------------------------------ def _custom_options_initializer(self, report, options, previous_options): """Add cash-basis specific options to the report.""" super()._custom_options_initializer(report, options, previous_options=previous_options) # Add the cash basis toggle options['fusion_cash_basis'] = previous_options.get('fusion_cash_basis', True) # Restrict to journals that support cash basis report._init_options_journals( options, previous_options=previous_options, additional_journals_domain=[('type', 'in', ('sale', 'purchase', 'bank', 'cash', 'general'))], ) # ------------------------------------------------------------------ # Dynamic Lines # ------------------------------------------------------------------ def _dynamic_lines_generator(self, report, options, all_column_groups_expression_totals, warnings=None): """Generate report lines based on cash basis (payment date) accounting. Returns a list of (sequence, line_dict) tuples for the report engine. """ output_lines = [] if not options.get('fusion_cash_basis', True): # Fallback to standard accrual-based processing return output_lines cash_data = self._compute_cash_basis_data(report, options) # ---- Revenue Section ---- revenue_total = sum(d['amount'] for d in cash_data.get('revenue', [])) output_lines.append((0, self._build_section_line( report, options, 'revenue', _("Cash Revenue"), revenue_total, ))) for entry in sorted(cash_data.get('revenue', []), key=lambda e: e.get('date', '')): output_lines.append((1, self._build_detail_line(report, options, entry))) # ---- Expense Section ---- expense_total = sum(d['amount'] for d in cash_data.get('expense', [])) output_lines.append((0, self._build_section_line( report, options, 'expense', _("Cash Expenses"), expense_total, ))) for entry in sorted(cash_data.get('expense', []), key=lambda e: e.get('date', '')): output_lines.append((1, self._build_detail_line(report, options, entry))) # ---- Net Cash Income ---- net_total = revenue_total - abs(expense_total) output_lines.append((0, self._build_section_line( report, options, 'net_income', _("Net Cash Income"), net_total, ))) return output_lines # ------------------------------------------------------------------ # Data Computation # ------------------------------------------------------------------ def _compute_cash_basis_data(self, report, options): """Compute cash basis amounts grouped by revenue/expense. Queries reconciled payments to find the actual cash dates for recognised amounts. :returns: dict with keys ``revenue`` and ``expense``, each containing a list of entry dicts with amount, date, account, and partner information. """ result = {'revenue': [], 'expense': []} company_ids = [c['id'] for c in options.get('companies', [{'id': self.env.company.id}])] date_from = options.get('date', {}).get('date_from') date_to = options.get('date', {}).get('date_to') if not date_from or not date_to: return result # Query: find all payment reconciliation entries within the period query = """ SELECT aml.id AS line_id, aml.account_id, aa.name AS account_name, aa.code AS account_code, aml.partner_id, rp.name AS partner_name, apr.max_date AS cash_date, CASE WHEN aa.account_type IN ('income', 'income_other') THEN aml.credit - aml.debit ELSE aml.debit - aml.credit END AS amount FROM account_move_line aml JOIN account_account aa ON aa.id = aml.account_id LEFT JOIN res_partner rp ON rp.id = aml.partner_id JOIN account_move am ON am.id = aml.move_id JOIN ( SELECT apr2.debit_move_id, apr2.credit_move_id, apr2.max_date FROM account_partial_reconcile apr2 WHERE apr2.max_date >= %s AND apr2.max_date <= %s ) apr ON (apr.debit_move_id = aml.id OR apr.credit_move_id = aml.id) WHERE am.state = 'posted' AND am.company_id IN %s AND aa.account_type IN ( 'income', 'income_other', 'expense', 'expense_direct_cost', 'expense_depreciation' ) ORDER BY apr.max_date, aa.code """ self.env.cr.execute(query, (date_from, date_to, tuple(company_ids))) rows = self.env.cr.dictfetchall() seen_lines = set() for row in rows: # Avoid counting the same line twice if partially reconciled if row['line_id'] in seen_lines: continue seen_lines.add(row['line_id']) entry = { 'line_id': row['line_id'], 'account_id': row['account_id'], 'account_name': row['account_name'], 'account_code': row['account_code'] or '', 'partner_id': row['partner_id'], 'partner_name': row['partner_name'] or '', 'date': str(row['cash_date']), 'amount': row['amount'] or 0.0, } account_type = self.env['account.account'].browse( row['account_id'] ).account_type if account_type in ('income', 'income_other'): result['revenue'].append(entry) else: result['expense'].append(entry) return result # ------------------------------------------------------------------ # Line Builders # ------------------------------------------------------------------ def _build_section_line(self, report, options, section_id, name, total): """Build a section header line for the report. :param section_id: unique identifier for the section :param name: display name of the section :param total: aggregated monetary total :returns: line dict compatible with the report engine """ columns = report._build_column_dict(total, options, figure_type='monetary') return { 'id': report._get_generic_line_id(None, None, markup=f'fusion_cb_{section_id}'), 'name': name, 'level': 1, 'columns': [columns], 'unfoldable': False, 'unfolded': False, } def _build_detail_line(self, report, options, entry): """Build a detail line for a single cash-basis entry. :param entry: dict with amount, account_code, account_name, etc. :returns: line dict compatible with the report engine """ name = f"{entry['account_code']} {entry['account_name']}" if entry.get('partner_name'): name += f" - {entry['partner_name']}" columns = report._build_column_dict( entry['amount'], options, figure_type='monetary', ) return { 'id': report._get_generic_line_id( 'account.move.line', entry['line_id'], markup='fusion_cb_detail', ), 'name': name, 'level': 3, 'columns': [columns], 'caret_options': 'account.move.line', 'unfoldable': False, }