453 lines
21 KiB
Python
453 lines
21 KiB
Python
# -*- coding: utf-8 -*-
|
|
|
|
import logging
|
|
import requests
|
|
from dateutil.relativedelta import relativedelta
|
|
from requests.exceptions import RequestException, Timeout
|
|
|
|
from odoo import api, fields, models, tools, _
|
|
from odoo.exceptions import UserError, ValidationError, RedirectWarning
|
|
from odoo.tools import SQL
|
|
from odoo.tools.misc import formatLang
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class AccountJournal(models.Model):
|
|
_inherit = "account.journal"
|
|
|
|
def __get_bank_statements_available_sources(self):
|
|
rslt = super(AccountJournal, self).__get_bank_statements_available_sources()
|
|
rslt.append(("online_sync", _("Online Synchronization")))
|
|
return rslt
|
|
|
|
next_link_synchronization = fields.Datetime("Online Link Next synchronization", related='account_online_link_id.next_refresh')
|
|
expiring_synchronization_date = fields.Date(related='account_online_link_id.expiring_synchronization_date')
|
|
expiring_synchronization_due_day = fields.Integer(compute='_compute_expiring_synchronization_due_day')
|
|
account_online_account_id = fields.Many2one('account.online.account', copy=False, ondelete='set null', index='btree_not_null')
|
|
account_online_link_id = fields.Many2one('account.online.link', related='account_online_account_id.account_online_link_id', readonly=True, store=True)
|
|
account_online_link_state = fields.Selection(related="account_online_link_id.state", readonly=True)
|
|
renewal_contact_email = fields.Char(related="account_online_link_id.renewal_contact_email", readonly=False)
|
|
online_sync_fetching_status = fields.Selection(related="account_online_account_id.fetching_status", readonly=True)
|
|
|
|
def write(self, vals):
|
|
# When changing the bank_statement_source, unlink the connection if there is any
|
|
if 'bank_statements_source' in vals and vals.get('bank_statements_source') != 'online_sync':
|
|
for journal in self:
|
|
if journal.bank_statements_source == 'online_sync':
|
|
# unlink current connection
|
|
vals['account_online_account_id'] = False
|
|
journal.account_online_link_id.has_unlinked_accounts = True
|
|
return super().write(vals)
|
|
|
|
@api.depends('expiring_synchronization_date')
|
|
def _compute_expiring_synchronization_due_day(self):
|
|
for record in self:
|
|
if record.expiring_synchronization_date:
|
|
due_day_delta = record.expiring_synchronization_date - fields.Date.context_today(record)
|
|
record.expiring_synchronization_due_day = due_day_delta.days
|
|
else:
|
|
record.expiring_synchronization_due_day = 0
|
|
|
|
def _fill_bank_cash_dashboard_data(self, dashboard_data):
|
|
super()._fill_bank_cash_dashboard_data(dashboard_data)
|
|
# Caching data to avoid one call per journal
|
|
self.browse(list(dashboard_data.keys())).fetch(['type', 'account_online_account_id'])
|
|
for journal_id, journal_data in dashboard_data.items():
|
|
journal = self.browse(journal_id)
|
|
journal_data['display_connect_bank_in_dashboard'] = journal.type in ('bank', 'credit') \
|
|
and not journal.account_online_account_id \
|
|
and journal.company_id.id == self.env.company.id
|
|
|
|
@api.constrains('account_online_account_id')
|
|
def _check_account_online_account_id(self):
|
|
for journal in self:
|
|
if len(journal.account_online_account_id.journal_ids) > 1:
|
|
raise ValidationError(_('You cannot have two journals associated with the same Online Account.'))
|
|
|
|
def _fetch_online_transactions(self):
|
|
for journal in self:
|
|
try:
|
|
journal.account_online_link_id._pop_connection_state_details(journal=journal)
|
|
journal.manual_sync()
|
|
# for cron jobs it is usually recommended committing after each iteration,
|
|
# so that a later error or job timeout doesn't discard previous work
|
|
self.env.cr.commit()
|
|
except (UserError, RedirectWarning):
|
|
# We need to rollback here otherwise the next iteration will still have the error when trying to commit
|
|
self.env.cr.rollback()
|
|
|
|
def fetch_online_sync_favorite_institutions(self):
|
|
company = self.sudo().company_id if len(self) == 1 else self.env.company
|
|
timeout = int(self.env['ir.config_parameter'].sudo().get_param('account_online_synchronization.request_timeout')) or 60
|
|
endpoint_url = self.env['account.online.link']._get_odoofin_url('/proxy/v2/get_dashboard_institutions')
|
|
params = {'country': company.account_fiscal_country_id.code, 'limit': 28}
|
|
try:
|
|
resp = requests.get(endpoint_url, params=params, timeout=timeout)
|
|
resp_dict = resp.json()
|
|
for institution in resp_dict:
|
|
if institution['picture'].startswith('/'):
|
|
institution['picture'] = self.env['account.online.link']._get_odoofin_url(institution['picture'])
|
|
return resp_dict
|
|
except (Timeout, ConnectionError, RequestException, ValueError) as e:
|
|
_logger.warning(e)
|
|
return []
|
|
|
|
@api.model
|
|
def _cron_fetch_waiting_online_transactions(self):
|
|
""" This method is only called when the user fetch transactions asynchronously.
|
|
We only fetch transactions on synchronizations that are in "waiting" status.
|
|
Once the synchronization is done, the status is changed for "done".
|
|
We have to that to avoid having too much logic in the same cron function to do
|
|
2 different things. This cron should only be used for asynchronous fetchs.
|
|
"""
|
|
|
|
# 'limit_time_real_cron' and 'limit_time_real' default respectively to -1 and 120.
|
|
# Manual fallbacks applied for non-POSIX systems where this key is disabled (set to None).
|
|
limit_time = tools.config['limit_time_real_cron'] or -1
|
|
if limit_time <= 0:
|
|
limit_time = tools.config['limit_time_real'] or 120
|
|
journals = self.search([
|
|
'|',
|
|
('online_sync_fetching_status', 'in', ('planned', 'waiting')),
|
|
'&',
|
|
('online_sync_fetching_status', '=', 'processing'),
|
|
('account_online_link_id.last_refresh', '<', fields.Datetime.now() - relativedelta(seconds=limit_time)),
|
|
])
|
|
journals.with_context(cron=True)._fetch_online_transactions()
|
|
|
|
@api.model
|
|
def _cron_fetch_online_transactions(self):
|
|
""" This method is called by the cron (by default twice a day) to fetch (for all journals)
|
|
the new transactions.
|
|
"""
|
|
journals = self.search([('account_online_account_id', '!=', False)])
|
|
journals.with_context(cron=True)._fetch_online_transactions()
|
|
|
|
@api.model
|
|
def _cron_send_reminder_email(self):
|
|
for journal in self.search([('account_online_account_id', '!=', False)]):
|
|
if journal.expiring_synchronization_due_day in {1, 3, 15}:
|
|
journal.action_send_reminder()
|
|
|
|
def manual_sync(self):
|
|
self.ensure_one()
|
|
if self.account_online_link_id:
|
|
account = self.account_online_account_id
|
|
return self.account_online_link_id._fetch_transactions(accounts=account)
|
|
|
|
def unlink(self):
|
|
"""
|
|
Override of the unlink method.
|
|
That's useful to unlink account.online.account too.
|
|
"""
|
|
if self.account_online_account_id:
|
|
self.account_online_account_id.unlink()
|
|
return super(AccountJournal, self).unlink()
|
|
|
|
def action_configure_bank_journal(self):
|
|
"""
|
|
Override the "action_configure_bank_journal" and change the flow for the
|
|
"Configure" button in dashboard.
|
|
"""
|
|
self.ensure_one()
|
|
return self.env['account.online.link'].action_new_synchronization()
|
|
|
|
def action_open_account_online_link(self):
|
|
self.ensure_one()
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': self.account_online_link_id.name,
|
|
'res_model': 'account.online.link',
|
|
'target': 'main',
|
|
'view_mode': 'form',
|
|
'views': [[False, 'form']],
|
|
'res_id': self.account_online_link_id.id,
|
|
}
|
|
|
|
def action_extend_consent(self):
|
|
"""
|
|
Extend the consent of the user by redirecting him to update his credentials
|
|
"""
|
|
self.ensure_one()
|
|
return self.account_online_link_id._open_iframe(
|
|
mode='updateCredentials',
|
|
include_param={
|
|
'account_online_identifier': self.account_online_account_id.online_identifier,
|
|
},
|
|
)
|
|
|
|
def action_reconnect_online_account(self):
|
|
self.ensure_one()
|
|
return self.account_online_link_id.action_reconnect_account()
|
|
|
|
def action_send_reminder(self):
|
|
self.ensure_one()
|
|
self._portal_ensure_token()
|
|
template = self.env.ref('account_online_synchronization.email_template_sync_reminder')
|
|
subtype = self.env.ref('account_online_synchronization.bank_sync_consent_renewal', raise_if_not_found=False)
|
|
if not subtype:
|
|
subtype = self.env.ref('mail.mt_comment')
|
|
self.message_post_with_source(source_ref=template, subtype_id=subtype.id)
|
|
|
|
def action_open_missing_transaction_wizard(self):
|
|
""" This method allows to open the wizard to fetch the missing
|
|
transactions and the pending ones.
|
|
Depending on where the function is called, we'll receive
|
|
one journal or none of them.
|
|
If we receive more or less than one journal, we do not set
|
|
it on the wizard, the user should select it by himself.
|
|
|
|
:return: An action opening the wizard.
|
|
"""
|
|
journal_id = None
|
|
if len(self) == 1:
|
|
if not self.account_online_account_id or self.account_online_link_state != 'connected':
|
|
raise UserError(_("You can't find missing transactions for a journal that isn't connected."))
|
|
|
|
journal_id = self.id
|
|
|
|
wizard = self.env['account.missing.transaction.wizard'].create({'journal_id': journal_id})
|
|
return {
|
|
'name': _("Find Missing Transactions"),
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'account.missing.transaction.wizard',
|
|
'res_id': wizard.id,
|
|
'views': [(False, 'form')],
|
|
'target': 'new',
|
|
}
|
|
|
|
def action_open_duplicate_transaction_wizard(self, from_date=None):
|
|
""" This method allows to open the wizard to find duplicate transactions.
|
|
:param from_date: date from with we must check for duplicates.
|
|
|
|
:return: An action opening the wizard.
|
|
"""
|
|
wizard = self.env['account.duplicate.transaction.wizard'].create({
|
|
'journal_id': self.id if len(self) == 1 else None,
|
|
**({'date': from_date} if from_date else {}),
|
|
})
|
|
return wizard._get_records_action(name=_("Find Duplicate Transactions"))
|
|
|
|
def _has_duplicate_transactions(self, date_from):
|
|
""" Has any transaction with
|
|
- same amount &
|
|
- same date &
|
|
- same account number
|
|
We do not check on online_transaction_identifier because this is called after the fetch
|
|
where transitions would already have been filtered on existing online_transaction_identifier.
|
|
|
|
:param date_from: date from with we must check for duplicates.
|
|
"""
|
|
self.env.cr.execute(SQL.join(SQL(''), [
|
|
self._get_duplicate_amount_date_account_transactions_query(date_from),
|
|
SQL('LIMIT 1'),
|
|
]))
|
|
return bool(self.env.cr.rowcount)
|
|
|
|
def _get_duplicate_transactions(self, date_from):
|
|
"""Find all transaction with
|
|
- same amount &
|
|
- same date &
|
|
- same account number
|
|
or
|
|
- same transaction id
|
|
|
|
:param date_from: date from with we must check for duplicates.
|
|
"""
|
|
query = SQL.join(SQL(''), [
|
|
self._get_duplicate_amount_date_account_transactions_query(date_from),
|
|
SQL('UNION'),
|
|
self._get_duplicate_online_transaction_identifier_transactions_query(date_from),
|
|
SQL('ORDER BY ids'),
|
|
])
|
|
return [res[0] for res in self.env.execute_query(query)]
|
|
|
|
def _get_provider_duplicate_transactions(self, date_from):
|
|
"""
|
|
Find all transaction with that have a "duplicated: True" in the transaction details.
|
|
"""
|
|
statement_lines = self.env.execute_query(
|
|
SQL('''
|
|
SELECT st_line.id
|
|
FROM account_bank_statement_line st_line
|
|
JOIN account_move move ON move.id = st_line.move_id
|
|
WHERE st_line.journal_id = %(journal_id)s
|
|
AND move.date >= %(date_from)s
|
|
AND st_line.transaction_details::JSONB->>'duplicated' = 'true';
|
|
''',
|
|
journal_id=self.id,
|
|
date_from=date_from,
|
|
)
|
|
)
|
|
return [res[0] for res in statement_lines]
|
|
|
|
def _get_duplicate_amount_date_account_transactions_query(self, date_from):
|
|
self.ensure_one()
|
|
return SQL('''
|
|
SELECT ARRAY_AGG(st_line.id ORDER BY st_line.id) AS ids
|
|
FROM account_bank_statement_line st_line
|
|
JOIN account_move move ON move.id = st_line.move_id
|
|
WHERE st_line.journal_id = %(journal_id)s AND move.date >= %(date_from)s
|
|
GROUP BY st_line.currency_id, st_line.amount, st_line.account_number, move.date
|
|
HAVING count(st_line.id) > 1
|
|
''',
|
|
journal_id=self.id,
|
|
date_from=date_from,
|
|
)
|
|
|
|
def _get_duplicate_online_transaction_identifier_transactions_query(self, date_from):
|
|
return SQL('''
|
|
SELECT ARRAY_AGG(st_line.id ORDER BY st_line.id) AS ids
|
|
FROM account_bank_statement_line st_line
|
|
JOIN account_move move ON move.id = st_line.move_id
|
|
WHERE st_line.journal_id = %(journal_id)s AND
|
|
move.date >= %(prior_date)s AND
|
|
st_line.online_transaction_identifier IS NOT NULL
|
|
GROUP BY st_line.online_transaction_identifier
|
|
HAVING count(st_line.id) > 1 AND BOOL_OR(move.date >= %(date_from)s) -- at least one date is > date_from
|
|
''',
|
|
journal_id=self.id,
|
|
date_from=date_from,
|
|
prior_date=date_from - relativedelta(months=3), # allow 1 of duplicate statements to be older than "from" date
|
|
)
|
|
|
|
def action_open_dashboard_asynchronous_action(self):
|
|
""" This method allows to open action asynchronously
|
|
during the fetching process.
|
|
When a user clicks on the Fetch Transactions button in
|
|
the dashboard, we fetch the transactions asynchronously
|
|
and save connection state details on the synchronization.
|
|
This action allows the user to open the action saved in
|
|
the connection state details.
|
|
"""
|
|
self.ensure_one()
|
|
|
|
if not self.account_online_account_id:
|
|
raise UserError(_("You can only execute this action for bank-synchronized journals."))
|
|
|
|
connection_state_details = self.account_online_link_id._pop_connection_state_details(journal=self)
|
|
if connection_state_details and connection_state_details.get('action'):
|
|
if connection_state_details.get('error_type') == 'redirect_warning':
|
|
self.env.cr.commit()
|
|
raise RedirectWarning(connection_state_details['error_message'], connection_state_details['action'], _('Report Issue'))
|
|
else:
|
|
return connection_state_details['action']
|
|
|
|
return {'type': 'ir.actions.client', 'tag': 'soft_reload'}
|
|
|
|
def _get_journal_dashboard_data_batched(self):
|
|
dashboard_data = super()._get_journal_dashboard_data_batched()
|
|
for journal in self.filtered(lambda j: j.type in ('bank', 'credit')):
|
|
if journal.account_online_account_id:
|
|
if journal.company_id.id not in self.env.companies.ids:
|
|
continue
|
|
connection_state_details = journal.account_online_link_id._get_connection_state_details(journal=journal)
|
|
if not connection_state_details and journal.account_online_account_id.fetching_status in ('waiting', 'processing'):
|
|
connection_state_details = {'status': 'fetching'}
|
|
dashboard_data[journal.id]['connection_state_details'] = connection_state_details
|
|
dashboard_data[journal.id]['show_sync_actions'] = journal.account_online_link_id.show_sync_actions
|
|
return dashboard_data
|
|
|
|
def get_related_connection_state_details(self):
|
|
""" This method allows JS widget to get the last connection state details
|
|
It's useful if the user wasn't on the dashboard when we send the message
|
|
by websocket that the asynchronous flow is finished.
|
|
In case we don't have a connection state details and if the fetching
|
|
status is set on "waiting" or "processing". We're returning that the sync
|
|
is currently fetching.
|
|
"""
|
|
self.ensure_one()
|
|
connection_state_details = self.account_online_link_id._get_connection_state_details(journal=self)
|
|
if not connection_state_details and self.account_online_account_id.fetching_status in ('waiting', 'processing'):
|
|
connection_state_details = {'status': 'fetching'}
|
|
return connection_state_details
|
|
|
|
def _consume_connection_state_details(self):
|
|
self.ensure_one()
|
|
if self.account_online_link_id and self.env.user.has_group('account.group_account_manager'):
|
|
# In case we have a bank synchronization connected to the journal
|
|
# we want to remove the last connection state because it means that we
|
|
# have "mark as read" this state, and we don't want to display it again to
|
|
# the user.
|
|
self.account_online_link_id._pop_connection_state_details(journal=self)
|
|
|
|
def open_action(self):
|
|
# Extends 'account_accountant'
|
|
if not self.env.context.get('action_name') and self.type == 'bank' and self.bank_statements_source == 'online_sync':
|
|
self._consume_connection_state_details()
|
|
return self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget(
|
|
default_context={'search_default_journal_id': self.id},
|
|
)
|
|
return super().open_action()
|
|
|
|
def action_open_reconcile(self):
|
|
# Extends 'account_accountant'
|
|
self._consume_connection_state_details()
|
|
return super().action_open_reconcile()
|
|
|
|
def action_open_bank_transactions(self):
|
|
# Extends 'account_accountant'
|
|
self._consume_connection_state_details()
|
|
return super().action_open_bank_transactions()
|
|
|
|
def action_open_pending_bank_statement_lines(self):
|
|
''' Show the pending transactions bank statement lines.
|
|
:return: An action showing pending transactions bank statement lines.
|
|
'''
|
|
self.ensure_one()
|
|
account = self.account_online_account_id
|
|
pendings = account._retrieve_transactions(
|
|
date=fields.Datetime.now() - relativedelta(days=7),
|
|
transactions_type='pending',
|
|
)
|
|
if not pendings:
|
|
return {
|
|
'type': 'ir.actions.client',
|
|
'tag': 'display_notification',
|
|
'params': {
|
|
'title': _('No Pending Transactions'),
|
|
'type': 'warning',
|
|
'message': _('There are no pending transactions for this journal.'),
|
|
},
|
|
}
|
|
|
|
values = [{**pending, 'state': 'pending'} for pending in pendings]
|
|
pending_transactions = self.env['account.bank.statement.line.transient'].create(values)
|
|
return {
|
|
'name': _("Pending Transactions"),
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'account.bank.statement.line.transient',
|
|
'view_mode': 'list',
|
|
'views': [(False, 'list')],
|
|
'domain': [('id', 'in', pending_transactions.ids)],
|
|
'context': {
|
|
'has_manual_entries': False,
|
|
'has_cancelled_entries': False,
|
|
'is_fetch_before_creation': False,
|
|
'search_default_filter_posted': False,
|
|
'disable_import': True,
|
|
},
|
|
}
|
|
|
|
@api.model
|
|
def _toggle_asynchronous_fetching_cron(self):
|
|
cron = self.env.ref('account_online_synchronization.online_sync_cron_waiting_synchronization', raise_if_not_found=False)
|
|
if cron:
|
|
cron.sudo().toggle(model=self._name, domain=[('account_online_account_id', '!=', False)])
|
|
|
|
def get_total_journal_amount(self):
|
|
# EXTENDS account_accountant
|
|
info_data = super().get_total_journal_amount()
|
|
|
|
available_balance = ''
|
|
if self.exists() and any(
|
|
company in self.company_id._accessible_branches() for company in self.env.companies):
|
|
if self.account_online_account_id.available_balance:
|
|
available_balance = formatLang(
|
|
self.env,
|
|
self.account_online_account_id.available_balance,
|
|
currency_obj=self.currency_id or self.company_id.sudo().currency_id,
|
|
)
|
|
return {**info_data, 'available_balance_amount': available_balance}
|