Files

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}