You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

436 lines
16 KiB

  1. # Copyright 2019-2020 Brainbean Apps (https://brainbeanapps.com)
  2. # Copyright 2019-2020 Dataplug (https://dataplug.io)
  3. # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
  4. from datetime import datetime
  5. from dateutil.relativedelta import relativedelta, MO
  6. from decimal import Decimal
  7. from html import escape
  8. import logging
  9. from pytz import timezone, utc
  10. from sys import exc_info
  11. from odoo import models, fields, api, _
  12. from odoo.addons.base.models.res_bank import sanitize_account_number
  13. from odoo.addons.base.models.res_partner import _tz_get
  14. _logger = logging.getLogger(__name__)
  15. class OnlineBankStatementProvider(models.Model):
  16. _name = 'online.bank.statement.provider'
  17. _inherit = ['mail.thread']
  18. _description = 'Online Bank Statement Provider'
  19. company_id = fields.Many2one(
  20. related='journal_id.company_id',
  21. store=True,
  22. )
  23. active = fields.Boolean()
  24. name = fields.Char(
  25. string='Name',
  26. compute='_compute_name',
  27. store=True,
  28. )
  29. journal_id = fields.Many2one(
  30. comodel_name='account.journal',
  31. required=True,
  32. readonly=True,
  33. ondelete='cascade',
  34. domain=[
  35. ('type', '=', 'bank'),
  36. ],
  37. )
  38. currency_id = fields.Many2one(
  39. related='journal_id.currency_id',
  40. )
  41. account_number = fields.Char(
  42. related='journal_id.bank_account_id.sanitized_acc_number'
  43. )
  44. tz = fields.Selection(
  45. selection=_tz_get,
  46. string='Timezone',
  47. default=lambda self: self.env.context.get('tz'),
  48. help=(
  49. 'Timezone to convert transaction timestamps to prior being'
  50. ' saved into a statement.'
  51. ),
  52. )
  53. service = fields.Selection(
  54. selection=lambda self: self._selection_service(),
  55. required=True,
  56. readonly=True,
  57. )
  58. interval_type = fields.Selection(
  59. selection=[
  60. ('minutes', 'Minute(s)'),
  61. ('hours', 'Hour(s)'),
  62. ('days', 'Day(s)'),
  63. ('weeks', 'Week(s)'),
  64. ],
  65. default='hours',
  66. required=True,
  67. )
  68. interval_number = fields.Integer(
  69. string='Scheduled update interval',
  70. default=1,
  71. required=True,
  72. )
  73. update_schedule = fields.Char(
  74. string='Update Schedule',
  75. compute='_compute_update_schedule',
  76. )
  77. last_successful_run = fields.Datetime(
  78. string='Last successful pull',
  79. )
  80. next_run = fields.Datetime(
  81. string='Next scheduled pull',
  82. default=fields.Datetime.now,
  83. required=True,
  84. )
  85. statement_creation_mode = fields.Selection(
  86. selection=[
  87. ('daily', 'Daily statements'),
  88. ('weekly', 'Weekly statements'),
  89. ('monthly', 'Monthly statements'),
  90. ],
  91. default='daily',
  92. required=True,
  93. )
  94. api_base = fields.Char()
  95. origin = fields.Char()
  96. username = fields.Char()
  97. password = fields.Char()
  98. key = fields.Binary()
  99. certificate = fields.Binary()
  100. passphrase = fields.Char()
  101. certificate_public_key = fields.Text()
  102. certificate_private_key = fields.Text()
  103. certificate_chain = fields.Text()
  104. _sql_constraints = [
  105. (
  106. 'journal_id_uniq',
  107. 'UNIQUE(journal_id)',
  108. 'Only one online banking statement provider per journal!'
  109. ),
  110. (
  111. 'valid_interval_number',
  112. 'CHECK(interval_number > 0)',
  113. 'Scheduled update interval must be greater than zero!'
  114. )
  115. ]
  116. @api.model
  117. def _get_available_services(self):
  118. """Hook for extension"""
  119. return []
  120. @api.model
  121. def _selection_service(self):
  122. return self._get_available_services() + [('dummy', 'Dummy')]
  123. @api.model
  124. def values_service(self):
  125. return self._get_available_services()
  126. @api.multi
  127. @api.depends('service')
  128. def _compute_name(self):
  129. for provider in self:
  130. provider.name = list(filter(
  131. lambda x: x[0] == provider.service,
  132. self._selection_service()
  133. ))[0][1]
  134. @api.multi
  135. @api.depends('active', 'interval_type', 'interval_number')
  136. def _compute_update_schedule(self):
  137. for provider in self:
  138. if not provider.active:
  139. provider.update_schedule = _('Inactive')
  140. continue
  141. provider.update_schedule = _('%(number)s %(type)s') % {
  142. 'number': provider.interval_number,
  143. 'type': list(filter(
  144. lambda x: x[0] == provider.interval_type,
  145. self._fields['interval_type'].selection
  146. ))[0][1],
  147. }
  148. @api.multi
  149. def _pull(self, date_since, date_until):
  150. AccountBankStatement = self.env['account.bank.statement']
  151. is_scheduled = self.env.context.get('scheduled')
  152. if is_scheduled:
  153. AccountBankStatement = AccountBankStatement.with_context(
  154. tracking_disable=True,
  155. )
  156. AccountBankStatementLine = self.env['account.bank.statement.line']
  157. for provider in self:
  158. provider_tz = timezone(provider.tz) if provider.tz else utc
  159. statement_date_since = provider._get_statement_date_since(
  160. date_since
  161. )
  162. while statement_date_since < date_until:
  163. statement_date_until = (
  164. statement_date_since + provider._get_statement_date_step()
  165. )
  166. try:
  167. data = provider._obtain_statement_data(
  168. statement_date_since,
  169. statement_date_until
  170. )
  171. except:
  172. if is_scheduled:
  173. _logger.warning(
  174. 'Online Bank Statement Provider "%s" failed to'
  175. ' obtain statement data since %s until %s' % (
  176. provider.name,
  177. statement_date_since,
  178. statement_date_until,
  179. ),
  180. exc_info=True,
  181. )
  182. provider.message_post(
  183. body=_(
  184. 'Failed to obtain statement data for period '
  185. 'since %s until %s: %s. See server logs for '
  186. 'more details.'
  187. ) % (
  188. statement_date_since,
  189. statement_date_until,
  190. escape(str(exc_info()[1])) or _('N/A')
  191. ),
  192. subject=_(
  193. 'Issue with Online Bank Statement Provider'
  194. ),
  195. )
  196. break
  197. raise
  198. statement_date = provider._get_statement_date(
  199. statement_date_since,
  200. statement_date_until,
  201. )
  202. if not data:
  203. data = ([], {})
  204. lines_data, statement_values = data
  205. if not lines_data:
  206. lines_data = []
  207. if not statement_values:
  208. statement_values = {}
  209. statement = AccountBankStatement.search([
  210. ('journal_id', '=', provider.journal_id.id),
  211. ('state', '=', 'open'),
  212. ('date', '=', statement_date),
  213. ], limit=1)
  214. if not statement:
  215. statement_values.update({
  216. 'name': provider.journal_id.sequence_id.with_context(
  217. ir_sequence_date=statement_date,
  218. ).next_by_id(),
  219. 'journal_id': provider.journal_id.id,
  220. 'date': statement_date,
  221. })
  222. statement = AccountBankStatement.with_context(
  223. journal_id=provider.journal_id.id,
  224. ).create(
  225. # NOTE: This is needed since create() alters values
  226. statement_values.copy()
  227. )
  228. filtered_lines = []
  229. for line_values in lines_data:
  230. date = line_values['date']
  231. if not isinstance(date, datetime):
  232. date = fields.Datetime.from_string(date)
  233. if date.tzinfo is None:
  234. date = date.replace(tzinfo=utc)
  235. date = date.astimezone(utc).replace(tzinfo=None)
  236. if date < statement_date_since:
  237. if 'balance_start' in statement_values:
  238. statement_values['balance_start'] = (
  239. Decimal(
  240. statement_values['balance_start']
  241. ) + Decimal(
  242. line_values['amount']
  243. )
  244. )
  245. continue
  246. elif date >= statement_date_until:
  247. if 'balance_end_real' in statement_values:
  248. statement_values['balance_end_real'] = (
  249. Decimal(
  250. statement_values['balance_end_real']
  251. ) - Decimal(
  252. line_values['amount']
  253. )
  254. )
  255. continue
  256. date = date.replace(tzinfo=utc)
  257. date = date.astimezone(provider_tz).replace(tzinfo=None)
  258. line_values['date'] = date
  259. unique_import_id = line_values.get('unique_import_id')
  260. if unique_import_id:
  261. unique_import_id = provider._generate_unique_import_id(
  262. unique_import_id
  263. )
  264. line_values.update({
  265. 'unique_import_id': unique_import_id,
  266. })
  267. if AccountBankStatementLine.sudo().search(
  268. [('unique_import_id', '=', unique_import_id)],
  269. limit=1):
  270. continue
  271. bank_account_number = line_values.get('account_number')
  272. if bank_account_number:
  273. line_values.update({
  274. 'account_number': (
  275. self._sanitize_bank_account_number(
  276. bank_account_number
  277. )
  278. ),
  279. })
  280. filtered_lines.append(line_values)
  281. statement_values.update({
  282. 'line_ids': [[0, False, line] for line in filtered_lines],
  283. })
  284. if 'balance_start' in statement_values:
  285. statement_values['balance_start'] = float(
  286. statement_values['balance_start']
  287. )
  288. if 'balance_end_real' in statement_values:
  289. statement_values['balance_end_real'] = float(
  290. statement_values['balance_end_real']
  291. )
  292. statement.write(statement_values)
  293. statement_date_since = statement_date_until
  294. if is_scheduled:
  295. provider._schedule_next_run()
  296. @api.multi
  297. def _schedule_next_run(self):
  298. self.ensure_one()
  299. self.last_successful_run = self.next_run
  300. self.next_run += self._get_next_run_period()
  301. @api.multi
  302. def _get_statement_date_since(self, date):
  303. self.ensure_one()
  304. date = date.replace(
  305. hour=0,
  306. minute=0,
  307. second=0,
  308. microsecond=0,
  309. )
  310. if self.statement_creation_mode == 'daily':
  311. return date
  312. elif self.statement_creation_mode == 'weekly':
  313. return date + relativedelta(weekday=MO(-1))
  314. elif self.statement_creation_mode == 'monthly':
  315. return date.replace(
  316. day=1,
  317. )
  318. @api.multi
  319. def _get_statement_date_step(self):
  320. self.ensure_one()
  321. if self.statement_creation_mode == 'daily':
  322. return relativedelta(
  323. days=1,
  324. hour=0,
  325. minute=0,
  326. second=0,
  327. microsecond=0,
  328. )
  329. elif self.statement_creation_mode == 'weekly':
  330. return relativedelta(
  331. weeks=1,
  332. weekday=MO,
  333. hour=0,
  334. minute=0,
  335. second=0,
  336. microsecond=0,
  337. )
  338. elif self.statement_creation_mode == 'monthly':
  339. return relativedelta(
  340. months=1,
  341. day=1,
  342. hour=0,
  343. minute=0,
  344. second=0,
  345. microsecond=0,
  346. )
  347. @api.multi
  348. def _get_statement_date(self, date_since, date_until):
  349. self.ensure_one()
  350. # NOTE: Statement date is treated by Odoo as start of period. Details
  351. # - addons/account/models/account_journal_dashboard.py
  352. # - def get_line_graph_datas()
  353. tz = timezone(self.tz) if self.tz else utc
  354. date_since = date_since.replace(tzinfo=utc).astimezone(tz)
  355. return date_since.date()
  356. @api.multi
  357. def _generate_unique_import_id(self, unique_import_id):
  358. self.ensure_one()
  359. return (
  360. self.account_number and self.account_number + '-' or ''
  361. ) + str(self.journal_id.id) + '-' + unique_import_id
  362. @api.multi
  363. def _sanitize_bank_account_number(self, bank_account_number):
  364. """Hook for extension"""
  365. self.ensure_one()
  366. return sanitize_account_number(bank_account_number)
  367. @api.multi
  368. def _get_next_run_period(self):
  369. self.ensure_one()
  370. if self.interval_type == 'minutes':
  371. return relativedelta(minutes=self.interval_number)
  372. elif self.interval_type == 'hours':
  373. return relativedelta(hours=self.interval_number)
  374. elif self.interval_type == 'days':
  375. return relativedelta(days=self.interval_number)
  376. elif self.interval_type == 'weeks':
  377. return relativedelta(weeks=self.interval_number)
  378. @api.model
  379. def _scheduled_pull(self):
  380. _logger.info('Scheduled pull of online bank statements...')
  381. providers = self.search([
  382. ('active', '=', True),
  383. ('next_run', '<=', fields.Datetime.now()),
  384. ])
  385. if providers:
  386. _logger.info('Pulling online bank statements of: %s' % ', '.join(
  387. providers.mapped('journal_id.name')
  388. ))
  389. for provider in providers.with_context({'scheduled': True}):
  390. date_since = (
  391. provider.last_successful_run
  392. ) if provider.last_successful_run else (
  393. provider.next_run - provider._get_next_run_period()
  394. )
  395. date_until = provider.next_run
  396. provider._pull(date_since, date_until)
  397. _logger.info('Scheduled pull of online bank statements complete.')
  398. @api.multi
  399. def _obtain_statement_data(
  400. self, date_since, date_until
  401. ):
  402. """Hook for extension"""
  403. # Check tests/online_bank_statement_provider_dummy.py for reference
  404. self.ensure_one()
  405. return []