234 lines
9.0 KiB
Python
234 lines
9.0 KiB
Python
# 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,
|
|
}
|