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.

277 lines
9.5 KiB

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