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.

435 lines
15 KiB

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