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.

278 lines
9.6 KiB

  1. # Copyright 2019 Brainbean Apps (https://brainbeanapps.com)
  2. # Copyright 2019 Dataplug (https://dataplug.io)
  3. # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
  4. from dateutil.relativedelta import relativedelta
  5. import dateutil.parser
  6. from decimal import Decimal
  7. import itertools
  8. import json
  9. import pytz
  10. import urllib.parse
  11. import urllib.request
  12. from odoo import models, api, _
  13. from odoo.exceptions import UserError
  14. TRANSFERWISE_API_BASE = 'https://api.transferwise.com'
  15. class OnlineBankStatementProviderTransferwise(models.Model):
  16. _inherit = 'online.bank.statement.provider'
  17. @api.model
  18. def values_transferwise_profile(self):
  19. api_base = self.env.context.get('api_base') or TRANSFERWISE_API_BASE
  20. api_key = self.env.context.get('api_key')
  21. if not api_key:
  22. return []
  23. try:
  24. url = api_base + '/v1/profiles'
  25. data = self._transferwise_retrieve(url, api_key)
  26. except:
  27. return []
  28. return list(map(
  29. lambda entry: (
  30. str(entry['id']),
  31. '%s %s (personal)' % (
  32. entry['details']['firstName'],
  33. entry['details']['lastName'],
  34. )
  35. if entry['type'] == 'personal'
  36. else entry['details']['name']
  37. ),
  38. data
  39. ))
  40. @api.model
  41. def _get_available_services(self):
  42. return super()._get_available_services() + [
  43. ('transferwise', 'TransferWise.com'),
  44. ]
  45. @api.multi
  46. def _obtain_statement_data(self, date_since, date_until):
  47. self.ensure_one()
  48. if self.service != 'transferwise':
  49. return super()._obtain_statement_data(
  50. date_since,
  51. date_until,
  52. ) # pragma: no cover
  53. api_base = self.api_base or TRANSFERWISE_API_BASE
  54. api_key = self.password
  55. currency = (
  56. self.currency_id or self.company_id.currency_id
  57. ).name
  58. if date_since.tzinfo:
  59. date_since = date_since.astimezone(pytz.utc).replace(tzinfo=None)
  60. if date_until.tzinfo:
  61. date_until = date_until.astimezone(pytz.utc).replace(tzinfo=None)
  62. # Get corresponding balance by currency
  63. url = api_base + '/v1/borderless-accounts?profileId=%s' % (
  64. self.origin,
  65. )
  66. data = self._transferwise_retrieve(url, api_key)
  67. borderless_account = data[0]['id']
  68. balance = list(filter(
  69. lambda balance: balance['currency'] == currency,
  70. data[0]['balances']
  71. ))
  72. if not balance:
  73. return None
  74. # Notes on /statement endpoint:
  75. # - intervalStart <= date < intervalEnd
  76. # Get starting balance
  77. starting_balance_timestamp = date_since.isoformat() + 'Z'
  78. url = api_base + (
  79. '/v1/borderless-accounts/%s/statement.json' +
  80. '?currency=%s&intervalStart=%s&intervalEnd=%s'
  81. ) % (
  82. borderless_account,
  83. currency,
  84. starting_balance_timestamp,
  85. starting_balance_timestamp,
  86. )
  87. data = self._transferwise_retrieve(url, api_key)
  88. balance_start = data['endOfStatementBalance']['value']
  89. # Get statements, using 469 days (around 1 year 3 month) as step.
  90. interval_step = relativedelta(days=469)
  91. interval_start = date_since
  92. interval_end = date_until
  93. transactions = []
  94. balance_end = None
  95. while interval_start < interval_end:
  96. url = api_base + (
  97. '/v1/borderless-accounts/%s/statement.json' +
  98. '?currency=%s&intervalStart=%s&intervalEnd=%s'
  99. ) % (
  100. borderless_account,
  101. currency,
  102. interval_start.isoformat() + 'Z',
  103. min(
  104. interval_start + interval_step, interval_end
  105. ).isoformat() + 'Z',
  106. )
  107. data = self._transferwise_retrieve(url, api_key)
  108. transactions += data['transactions']
  109. balance_end = data['endOfStatementBalance']['value']
  110. interval_start += interval_step
  111. if balance_end is None:
  112. raise UserError(_('Ending balance unavailable'))
  113. # Normalize transactions' date, sort by it, and get lines
  114. transactions = map(
  115. lambda transaction: self._transferwise_preparse_transaction(
  116. transaction
  117. ),
  118. transactions
  119. )
  120. lines = list(itertools.chain.from_iterable(map(
  121. lambda x: self._transferwise_transaction_to_lines(x),
  122. sorted(
  123. transactions,
  124. key=lambda transaction: transaction['date']
  125. )
  126. )))
  127. return lines, {
  128. 'balance_start': balance_start,
  129. 'balance_end_real': balance_end,
  130. }
  131. @api.model
  132. def _transferwise_preparse_transaction(self, transaction):
  133. transaction['date'] = dateutil.parser.parse(
  134. transaction['date']
  135. ).replace(tzinfo=None)
  136. return transaction
  137. @api.model
  138. def _transferwise_transaction_to_lines(self, transaction):
  139. reference_number = transaction['referenceNumber']
  140. details = transaction.get('details', {})
  141. exchange_details = transaction.get('exchangeDetails')
  142. recipient = details.get('recipient')
  143. total_fees = transaction.get('totalFees')
  144. date = transaction['date']
  145. payment_reference = details.get('paymentReference')
  146. description = details.get('description')
  147. note = reference_number
  148. if description:
  149. note = '%s: %s' % (
  150. note,
  151. description
  152. )
  153. amount = transaction['amount']
  154. amount_value = amount.get('value', 0)
  155. fees_value = total_fees.get('value', Decimal()).copy_abs()
  156. if amount_value.is_signed():
  157. fees_value = fees_value.copy_negate()
  158. amount_value -= fees_value
  159. unique_import_id = '%s-%s-%s' % (
  160. transaction['type'],
  161. reference_number,
  162. int(date.timestamp()),
  163. )
  164. line = {
  165. 'name': payment_reference or description or '',
  166. 'amount': str(amount_value),
  167. 'date': date,
  168. 'note': note,
  169. 'unique_import_id': unique_import_id,
  170. }
  171. if recipient:
  172. if 'name' in recipient:
  173. line.update({
  174. 'partner_name': recipient['name'],
  175. })
  176. if 'bankAccount' in recipient:
  177. line.update({
  178. 'account_number': recipient['bankAccount'],
  179. })
  180. elif 'merchant' in details:
  181. merchant = details['merchant']
  182. if 'name' in merchant:
  183. line.update({
  184. 'partner_name': merchant['name'],
  185. })
  186. else:
  187. if 'senderName' in details:
  188. line.update({
  189. 'partner_name': details['senderName'],
  190. })
  191. if 'senderAccount' in details:
  192. line.update({
  193. 'account_number': details['senderAccount'],
  194. })
  195. if exchange_details:
  196. to_amount = exchange_details['toAmount']
  197. from_amount = exchange_details['fromAmount']
  198. other_amount_value = (
  199. to_amount['value']
  200. if to_amount['currency'] != amount['currency']
  201. else from_amount['value']
  202. )
  203. other_currency_name = (
  204. to_amount['currency']
  205. if to_amount['currency'] != amount['currency']
  206. else from_amount['currency']
  207. )
  208. other_amount_value = other_amount_value.copy_abs()
  209. if amount_value.is_signed():
  210. other_amount_value = other_amount_value.copy_negate()
  211. other_currency = self.env['res.currency'].search(
  212. [('name', '=', other_currency_name)],
  213. limit=1
  214. )
  215. if other_amount_value and other_currency:
  216. line.update({
  217. 'amount_currency': str(other_amount_value),
  218. 'currency_id': other_currency.id,
  219. })
  220. lines = [line]
  221. if fees_value:
  222. lines += [{
  223. 'name': _('Fee for %s') % reference_number,
  224. 'amount': str(fees_value),
  225. 'date': date,
  226. 'partner_name': 'TransferWise',
  227. 'unique_import_id': '%s-FEE' % unique_import_id,
  228. 'note': _('Transaction fee for %s') % reference_number,
  229. }]
  230. return lines
  231. @api.model
  232. def _transferwise_validate(self, content):
  233. content = json.loads(content, parse_float=Decimal)
  234. if 'error' in content and content['error']:
  235. raise UserError(
  236. content['error_description']
  237. if 'error_description' in content
  238. else 'Unknown error'
  239. )
  240. return content
  241. @api.model
  242. def _transferwise_retrieve(self, url, api_key):
  243. with self._transferwise_urlopen(url, api_key) as response:
  244. content = response.read().decode(
  245. response.headers.get_content_charset()
  246. )
  247. return self._transferwise_validate(content)
  248. @api.model
  249. def _transferwise_urlopen(self, url, api_key):
  250. if not api_key:
  251. raise UserError(_('No API key specified!'))
  252. request = urllib.request.Request(url)
  253. request.add_header(
  254. 'Authorization',
  255. 'Bearer %s' % api_key
  256. )
  257. return urllib.request.urlopen(request)