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.

559 lines
22 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 base64 import b64encode
  5. from datetime import datetime
  6. from dateutil.relativedelta import relativedelta
  7. import dateutil.parser
  8. from decimal import Decimal
  9. import itertools
  10. import json
  11. import pytz
  12. from urllib.error import HTTPError
  13. from urllib.parse import urlencode
  14. import urllib.request
  15. from odoo import models, api, _
  16. from odoo.exceptions import UserError
  17. PAYPAL_API_BASE = 'https://api.paypal.com'
  18. TRANSACTIONS_SCOPE = 'https://uri.paypal.com/services/reporting/search/read'
  19. EVENT_DESCRIPTIONS = {
  20. 'T0000': _('General PayPal-to-PayPal payment'),
  21. 'T0001': _('MassPay payment'),
  22. 'T0002': _('Subscription payment'),
  23. 'T0003': _('Pre-approved payment (BillUser API)'),
  24. 'T0004': _('eBay auction payment'),
  25. 'T0005': _('Direct payment API'),
  26. 'T0006': _('PayPal Checkout APIs'),
  27. 'T0007': _('Website payments standard payment'),
  28. 'T0008': _('Postage payment to carrier'),
  29. 'T0009': _('Gift certificate payment, purchase of gift certificate'),
  30. 'T0010': _('Third-party auction payment'),
  31. 'T0011': _('Mobile payment, made through a mobile phone'),
  32. 'T0012': _('Virtual terminal payment'),
  33. 'T0013': _('Donation payment'),
  34. 'T0014': _('Rebate payments'),
  35. 'T0015': _('Third-party payout'),
  36. 'T0016': _('Third-party recoupment'),
  37. 'T0017': _('Store-to-store transfers'),
  38. 'T0018': _('PayPal Here payment'),
  39. 'T0019': _('Generic instrument-funded payment'),
  40. 'T0100': _('General non-payment fee'),
  41. 'T0101': _('Website payments. Pro account monthly fee'),
  42. 'T0102': _('Foreign bank withdrawal fee'),
  43. 'T0103': _('WorldLink check withdrawal fee'),
  44. 'T0104': _('Mass payment batch fee'),
  45. 'T0105': _('Check withdrawal'),
  46. 'T0106': _('Chargeback processing fee'),
  47. 'T0107': _('Payment fee'),
  48. 'T0108': _('ATM withdrawal'),
  49. 'T0109': _('Auto-sweep from account'),
  50. 'T0110': _('International credit card withdrawal'),
  51. 'T0111': _('Warranty fee for warranty purchase'),
  52. 'T0112': _('Gift certificate expiration fee'),
  53. 'T0113': _('Partner fee'),
  54. 'T0200': _('General currency conversion'),
  55. 'T0201': _('User-initiated currency conversion'),
  56. 'T0202': _('Currency conversion required to cover negative balance'),
  57. 'T0300': _('General funding of PayPal account'),
  58. 'T0301': _('PayPal balance manager funding of PayPal account'),
  59. 'T0302': _('ACH funding for funds recovery from account balance'),
  60. 'T0303': _('Electronic funds transfer (EFT)'),
  61. 'T0400': _('General withdrawal from PayPal account'),
  62. 'T0401': _('AutoSweep'),
  63. 'T0500': _('General PayPal debit card transaction'),
  64. 'T0501': _('Virtual PayPal debit card transaction'),
  65. 'T0502': _('PayPal debit card withdrawal to ATM'),
  66. 'T0503': _('Hidden virtual PayPal debit card transaction'),
  67. 'T0504': _('PayPal debit card cash advance'),
  68. 'T0505': _('PayPal debit authorization'),
  69. 'T0600': _('General credit card withdrawal'),
  70. 'T0700': _('General credit card deposit'),
  71. 'T0701': _('Credit card deposit for negative PayPal account balance'),
  72. 'T0800': _('General bonus'),
  73. 'T0801': _('Debit card cash back bonus'),
  74. 'T0802': _('Merchant referral account bonus'),
  75. 'T0803': _('Balance manager account bonus'),
  76. 'T0804': _('PayPal buyer warranty bonus'),
  77. 'T0805': _(
  78. 'PayPal protection bonus, payout for PayPal buyer protection, payout '
  79. 'for full protection with PayPal buyer credit.'
  80. ),
  81. 'T0806': _('Bonus for first ACH use'),
  82. 'T0807': _('Credit card security charge refund'),
  83. 'T0808': _('Credit card cash back bonus'),
  84. 'T0900': _('General incentive or certificate redemption'),
  85. 'T0901': _('Gift certificate redemption'),
  86. 'T0902': _('Points incentive redemption'),
  87. 'T0903': _('Coupon redemption'),
  88. 'T0904': _('eBay loyalty incentive'),
  89. 'T0905': _('Offers used as funding source'),
  90. 'T1000': _('Bill pay transaction'),
  91. 'T1100': _('General reversal'),
  92. 'T1101': _('Reversal of ACH withdrawal transaction'),
  93. 'T1102': _('Reversal of debit card transaction'),
  94. 'T1103': _('Reversal of points usage'),
  95. 'T1104': _('Reversal of ACH deposit'),
  96. 'T1105': _('Reversal of general account hold'),
  97. 'T1106': _('Payment reversal, initiated by PayPal'),
  98. 'T1107': _('Payment refund, initiated by merchant'),
  99. 'T1108': _('Fee reversal'),
  100. 'T1109': _('Fee refund'),
  101. 'T1110': _('Hold for dispute investigation'),
  102. 'T1111': _('Cancellation of hold for dispute resolution'),
  103. 'T1112': _('MAM reversal'),
  104. 'T1113': _('Non-reference credit payment'),
  105. 'T1114': _('MassPay reversal transaction'),
  106. 'T1115': _('MassPay refund transaction'),
  107. 'T1116': _('Instant payment review (IPR) reversal'),
  108. 'T1117': _('Rebate or cash back reversal'),
  109. 'T1118': _('Generic instrument/Open Wallet reversals (seller side)'),
  110. 'T1119': _('Generic instrument/Open Wallet reversals (buyer side)'),
  111. 'T1200': _('General account adjustment'),
  112. 'T1201': _('Chargeback'),
  113. 'T1202': _('Chargeback reversal'),
  114. 'T1203': _('Charge-off adjustment'),
  115. 'T1204': _('Incentive adjustment'),
  116. 'T1205': _('Reimbursement of chargeback'),
  117. 'T1207': _('Chargeback re-presentment rejection'),
  118. 'T1208': _('Chargeback cancellation'),
  119. 'T1300': _('General authorization'),
  120. 'T1301': _('Reauthorization'),
  121. 'T1302': _('Void of authorization'),
  122. 'T1400': _('General dividend'),
  123. 'T1500': _('General temporary hold'),
  124. 'T1501': _('Account hold for open authorization'),
  125. 'T1502': _('Account hold for ACH deposit'),
  126. 'T1503': _('Temporary hold on available balance'),
  127. 'T1600': _('PayPal buyer credit payment funding'),
  128. 'T1601': _('BML credit, transfer from BML'),
  129. 'T1602': _('Buyer credit payment'),
  130. 'T1603': _('Buyer credit payment withdrawal, transfer to BML'),
  131. 'T1700': _('General withdrawal to non-bank institution'),
  132. 'T1701': _('WorldLink withdrawal'),
  133. 'T1800': _('General buyer credit payment'),
  134. 'T1801': _('BML withdrawal, transfer to BML'),
  135. 'T1900': _('General adjustment without business-related event'),
  136. 'T2000': _('General intra-account transfer'),
  137. 'T2001': _('Settlement consolidation'),
  138. 'T2002': _('Transfer of funds from payable'),
  139. 'T2003': _('Transfer to external GL entity'),
  140. 'T2101': _('General hold'),
  141. 'T2102': _('General hold release'),
  142. 'T2103': _('Reserve hold'),
  143. 'T2104': _('Reserve release'),
  144. 'T2105': _('Payment review hold'),
  145. 'T2106': _('Payment review release'),
  146. 'T2107': _('Payment hold'),
  147. 'T2108': _('Payment hold release'),
  148. 'T2109': _('Gift certificate purchase'),
  149. 'T2110': _('Gift certificate redemption'),
  150. 'T2111': _('Funds not yet available'),
  151. 'T2112': _('Funds available'),
  152. 'T2113': _('Blocked payments'),
  153. 'T2201': _('Transfer to and from a credit-card-funded restricted balance'),
  154. 'T3000': _('Generic instrument/Open Wallet transaction'),
  155. 'T5000': _('Deferred disbursement, funds collected for disbursement'),
  156. 'T5001': _('Delayed disbursement, funds disbursed'),
  157. 'T9700': _('Account receivable for shipping'),
  158. 'T9701': _('Funds payable: PayPal-provided funds that must be paid back'),
  159. 'T9702': _(
  160. 'Funds receivable: PayPal-provided funds that are being paid back'
  161. ),
  162. 'T9800': _('Display only transaction'),
  163. 'T9900': _('Other'),
  164. }
  165. NO_DATA_FOR_DATE_AVAIL_MSG = 'Data for the given start date is not available.'
  166. class OnlineBankStatementProviderPayPal(models.Model):
  167. _inherit = 'online.bank.statement.provider'
  168. @api.model
  169. def _get_available_services(self):
  170. return super()._get_available_services() + [
  171. ('paypal', 'PayPal.com'),
  172. ]
  173. @api.multi
  174. def _obtain_statement_data(self, date_since, date_until):
  175. self.ensure_one()
  176. if self.service != 'paypal':
  177. return super()._obtain_statement_data(
  178. date_since,
  179. date_until,
  180. ) # pragma: no cover
  181. currency = (
  182. self.currency_id or self.company_id.currency_id
  183. ).name
  184. if date_since.tzinfo:
  185. date_since = date_since.astimezone(pytz.utc).replace(tzinfo=None)
  186. if date_until.tzinfo:
  187. date_until = date_until.astimezone(pytz.utc).replace(tzinfo=None)
  188. if date_since < datetime.utcnow() - relativedelta(years=3):
  189. raise UserError(_(
  190. 'PayPal allows retrieving transactions only up to 3 years in '
  191. 'the past. Please import older transactions manually. See '
  192. 'https://www.paypal.com/us/smarthelp/article/why-can\'t-i'
  193. '-access-transaction-history-greater-than-3-years-ts2241'
  194. ))
  195. token = self._paypal_get_token()
  196. transactions = self._paypal_get_transactions(
  197. token,
  198. currency,
  199. date_since,
  200. date_until
  201. )
  202. if not transactions:
  203. balance = self._paypal_get_balance(
  204. token,
  205. currency,
  206. date_since
  207. )
  208. return [], {
  209. 'balance_start': balance,
  210. 'balance_end_real': balance,
  211. }
  212. # Normalize transactions, sort by date, and get lines
  213. transactions = list(sorted(
  214. transactions,
  215. key=lambda transaction: self._paypal_get_transaction_date(
  216. transaction
  217. )
  218. ))
  219. lines = list(itertools.chain.from_iterable(map(
  220. lambda x: self._paypal_transaction_to_lines(x),
  221. transactions
  222. )))
  223. first_transaction = transactions[0]
  224. first_transaction_id = \
  225. first_transaction['transaction_info']['transaction_id']
  226. first_transaction_date = self._paypal_get_transaction_date(
  227. first_transaction
  228. )
  229. first_transaction = self._paypal_get_transaction(
  230. token,
  231. first_transaction_id,
  232. first_transaction_date
  233. )
  234. if not first_transaction:
  235. raise UserError(_('Failed to resolve transaction %s (%s)') % (
  236. first_transaction_id,
  237. first_transaction_date
  238. ))
  239. balance_start = self._paypal_get_transaction_ending_balance(
  240. first_transaction
  241. )
  242. balance_start -= self._paypal_get_transaction_total_amount(
  243. first_transaction
  244. )
  245. balance_start -= self._paypal_get_transaction_fee_amount(
  246. first_transaction
  247. )
  248. last_transaction = transactions[-1]
  249. last_transaction_id = \
  250. last_transaction['transaction_info']['transaction_id']
  251. last_transaction_date = self._paypal_get_transaction_date(
  252. last_transaction
  253. )
  254. last_transaction = self._paypal_get_transaction(
  255. token,
  256. last_transaction_id,
  257. last_transaction_date
  258. )
  259. if not last_transaction:
  260. raise UserError(_('Failed to resolve transaction %s (%s)') % (
  261. last_transaction_id,
  262. last_transaction_date
  263. ))
  264. balance_end = self._paypal_get_transaction_ending_balance(
  265. last_transaction
  266. )
  267. return lines, {
  268. 'balance_start': balance_start,
  269. 'balance_end_real': balance_end,
  270. }
  271. @api.model
  272. def _paypal_preparse_transaction(self, transaction):
  273. date = dateutil.parser.parse(
  274. self._paypal_get_transaction_date(transaction)
  275. ).astimezone(pytz.utc).replace(tzinfo=None)
  276. transaction['transaction_info']['transaction_updated_date'] = date
  277. return transaction
  278. @api.model
  279. def _paypal_transaction_to_lines(self, data):
  280. transaction = data['transaction_info']
  281. payer = data['payer_info']
  282. transaction_id = transaction['transaction_id']
  283. event_code = transaction['transaction_event_code']
  284. date = self._paypal_get_transaction_date(data)
  285. total_amount = self._paypal_get_transaction_total_amount(data)
  286. fee_amount = self._paypal_get_transaction_fee_amount(data)
  287. transaction_subject = transaction.get('transaction_subject')
  288. transaction_note = transaction.get('transaction_note')
  289. invoice = transaction.get('invoice_id')
  290. payer_name = payer.get('payer_name', {})
  291. payer_email = payer_name.get('email_address')
  292. if invoice:
  293. invoice = _('Invoice %s') % invoice
  294. note = transaction_id
  295. if transaction_subject or transaction_note:
  296. note = '%s: %s' % (
  297. note,
  298. transaction_subject or transaction_note
  299. )
  300. if payer_email:
  301. note += ' (%s)' % payer_email
  302. unique_import_id = '%s-%s' % (
  303. transaction_id,
  304. int(date.timestamp()),
  305. )
  306. name = invoice \
  307. or transaction_subject \
  308. or transaction_note \
  309. or EVENT_DESCRIPTIONS.get(event_code) \
  310. or ''
  311. line = {
  312. 'name': name,
  313. 'amount': str(total_amount),
  314. 'date': date,
  315. 'note': note,
  316. 'unique_import_id': unique_import_id,
  317. }
  318. payer_full_name = payer_name.get('full_name') or \
  319. payer_name.get('alternate_full_name')
  320. if payer_full_name:
  321. line.update({
  322. 'partner_name': payer_full_name,
  323. })
  324. lines = [line]
  325. if fee_amount:
  326. lines += [{
  327. 'name': _('Fee for %s') % (name or transaction_id),
  328. 'amount': str(fee_amount),
  329. 'date': date,
  330. 'partner_name': 'PayPal',
  331. 'unique_import_id': '%s-FEE' % unique_import_id,
  332. 'note': _('Transaction fee for %s') % note,
  333. }]
  334. return lines
  335. @api.multi
  336. def _paypal_get_token(self):
  337. self.ensure_one()
  338. data = self._paypal_retrieve(
  339. (self.api_base or PAYPAL_API_BASE) + '/v1/oauth2/token',
  340. (self.username, self.password),
  341. data=urlencode({
  342. 'grant_type': 'client_credentials',
  343. }).encode('utf-8')
  344. )
  345. if 'scope' not in data or TRANSACTIONS_SCOPE not in data['scope']:
  346. raise UserError(_(
  347. 'PayPal App features are configured incorrectly!'
  348. ))
  349. if 'token_type' not in data or data['token_type'] != 'Bearer':
  350. raise UserError(_('Invalid token type!'))
  351. if 'access_token' not in data:
  352. raise UserError(_(
  353. 'Failed to acquire token using Client ID and Secret!'
  354. ))
  355. return data['access_token']
  356. @api.multi
  357. def _paypal_get_balance(self, token, currency, as_of_timestamp):
  358. self.ensure_one()
  359. url = (self.api_base or PAYPAL_API_BASE) \
  360. + '/v1/reporting/balances?currency_code=%s&as_of_time=%s' % (
  361. currency,
  362. as_of_timestamp.isoformat() + 'Z',
  363. )
  364. data = self._paypal_retrieve(url, token)
  365. available_balance = data['balances'][0].get('available_balance')
  366. if not available_balance:
  367. return Decimal()
  368. return Decimal(available_balance['value'])
  369. @api.multi
  370. def _paypal_get_transaction(self, token, transaction_id, timestamp):
  371. self.ensure_one()
  372. transaction_date = timestamp.isoformat() + 'Z'
  373. url = (self.api_base or PAYPAL_API_BASE) \
  374. + '/v1/reporting/transactions' \
  375. + (
  376. '?start_date=%s'
  377. '&end_date=%s'
  378. '&fields=all'
  379. ) % (
  380. transaction_date,
  381. transaction_date,
  382. )
  383. data = self._paypal_retrieve(url, token)
  384. transactions = data['transaction_details']
  385. for transaction in transactions:
  386. if transaction['transaction_info']['transaction_id'] != \
  387. transaction_id:
  388. continue
  389. return transaction
  390. return None
  391. @api.multi
  392. def _paypal_get_transactions(self, token, currency, since, until):
  393. self.ensure_one()
  394. # NOTE: Not more than 31 days in a row
  395. # NOTE: start_date <= date <= end_date, thus check every transaction
  396. interval_step = relativedelta(days=31)
  397. interval_start = since
  398. transactions = []
  399. while interval_start < until:
  400. interval_end = min(interval_start + interval_step, until)
  401. page = 1
  402. total_pages = None
  403. while total_pages is None or page <= total_pages:
  404. url = (self.api_base or PAYPAL_API_BASE) \
  405. + '/v1/reporting/transactions' \
  406. + (
  407. '?transaction_currency=%s'
  408. '&start_date=%s'
  409. '&end_date=%s'
  410. '&fields=all'
  411. '&balance_affecting_records_only=Y'
  412. '&page_size=500'
  413. '&page=%d'
  414. % (
  415. currency,
  416. interval_start.isoformat() + 'Z',
  417. interval_end.isoformat() + 'Z',
  418. page,
  419. ))
  420. # NOTE: Workaround for INVALID_REQUEST (see ROADMAP.rst)
  421. invalid_data_workaround = self.env.context.get(
  422. 'test_account_bank_statement_import_online_paypal_monday',
  423. interval_start.weekday() == 0 and (
  424. datetime.utcnow() - interval_start
  425. ).total_seconds() < 28800
  426. )
  427. data = self.with_context(
  428. invalid_data_workaround=invalid_data_workaround,
  429. )._paypal_retrieve(url, token)
  430. interval_transactions = map(
  431. lambda transaction: self._paypal_preparse_transaction(
  432. transaction
  433. ),
  434. data['transaction_details']
  435. )
  436. transactions += list(filter(
  437. lambda transaction:
  438. interval_start <= self._paypal_get_transaction_date(
  439. transaction
  440. ) < interval_end,
  441. interval_transactions
  442. ))
  443. total_pages = data['total_pages']
  444. page += 1
  445. interval_start += interval_step
  446. return transactions
  447. @api.model
  448. def _paypal_get_transaction_date(self, transaction):
  449. # NOTE: CSV reports from PayPal use this date, search as well
  450. return transaction['transaction_info']['transaction_updated_date']
  451. @api.model
  452. def _paypal_get_transaction_total_amount(self, transaction):
  453. transaction_amount = \
  454. transaction['transaction_info'].get('transaction_amount')
  455. if not transaction_amount:
  456. return Decimal()
  457. return Decimal(transaction_amount['value'])
  458. @api.model
  459. def _paypal_get_transaction_fee_amount(self, transaction):
  460. fee_amount = transaction['transaction_info'].get('fee_amount')
  461. if not fee_amount:
  462. return Decimal()
  463. return Decimal(fee_amount['value'])
  464. @api.model
  465. def _paypal_get_transaction_ending_balance(self, transaction):
  466. # NOTE: 'available_balance' instead of 'ending_balance' as per CSV file
  467. transaction_amount = \
  468. transaction['transaction_info'].get('available_balance')
  469. if not transaction_amount:
  470. return Decimal()
  471. return Decimal(transaction_amount['value'])
  472. @api.model
  473. def _paypal_decode_error(self, content):
  474. if 'name' in content:
  475. return UserError('%s: %s' % (
  476. content['name'],
  477. content.get('message', _('Unknown error')),
  478. ))
  479. if 'error' in content:
  480. return UserError('%s: %s' % (
  481. content['error'],
  482. content.get('error_description', _('Unknown error')),
  483. ))
  484. return None
  485. @api.model
  486. def _paypal_retrieve(self, url, auth, data=None):
  487. try:
  488. with self._paypal_urlopen(url, auth, data) as response:
  489. content = response.read().decode('utf-8')
  490. except HTTPError as e:
  491. content = json.loads(e.read().decode('utf-8'))
  492. # NOTE: Workaround for INVALID_REQUEST (see ROADMAP.rst)
  493. if self.env.context.get('invalid_data_workaround') \
  494. and content.get('name') == 'INVALID_REQUEST' \
  495. and content.get('message') == NO_DATA_FOR_DATE_AVAIL_MSG:
  496. return {
  497. 'transaction_details': [],
  498. 'page': 1,
  499. 'total_items': 0,
  500. 'total_pages': 0,
  501. }
  502. raise self._paypal_decode_error(content) or e
  503. return json.loads(content)
  504. @api.model
  505. def _paypal_urlopen(self, url, auth, data=None):
  506. if not auth:
  507. raise UserError(_('No authentication specified!'))
  508. request = urllib.request.Request(url, data=data)
  509. if isinstance(auth, tuple):
  510. request.add_header(
  511. 'Authorization',
  512. 'Basic %s' % str(
  513. b64encode(('%s:%s' % (auth[0], auth[1])).encode('utf-8')),
  514. 'utf-8'
  515. )
  516. )
  517. elif isinstance(auth, str):
  518. request.add_header(
  519. 'Authorization',
  520. 'Bearer %s' % auth
  521. )
  522. else:
  523. raise UserError(_('Unknown authentication specified!'))
  524. return urllib.request.urlopen(request)