# -*- 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}