From 9f504d76fbf91e8f1361d26115073a1128947340 Mon Sep 17 00:00:00 2001 From: Alexey Pelykh Date: Sat, 2 Nov 2019 06:40:43 +0000 Subject: [PATCH] [ADD] account_bank_statement_import_online_paypal --- .../__init__.py | 3 + .../__manifest__.py | 23 + .../models/__init__.py | 3 + .../online_bank_statement_provider_paypal.py | 517 ++++++++++++++++ .../readme/CONFIGURE.rst | 29 + .../readme/CONTRIBUTORS.rst | 1 + .../readme/DESCRIPTION.rst | 2 + .../readme/ROADMAP.rst | 7 + .../readme/USAGE.rst | 6 + .../tests/__init__.py | 3 + ...unt_bank_statement_import_online_paypal.py | 576 ++++++++++++++++++ .../views/online_bank_statement_provider.xml | 40 ++ 12 files changed, 1210 insertions(+) create mode 100644 account_bank_statement_import_online_paypal/__init__.py create mode 100644 account_bank_statement_import_online_paypal/__manifest__.py create mode 100644 account_bank_statement_import_online_paypal/models/__init__.py create mode 100644 account_bank_statement_import_online_paypal/models/online_bank_statement_provider_paypal.py create mode 100644 account_bank_statement_import_online_paypal/readme/CONFIGURE.rst create mode 100644 account_bank_statement_import_online_paypal/readme/CONTRIBUTORS.rst create mode 100644 account_bank_statement_import_online_paypal/readme/DESCRIPTION.rst create mode 100644 account_bank_statement_import_online_paypal/readme/ROADMAP.rst create mode 100644 account_bank_statement_import_online_paypal/readme/USAGE.rst create mode 100644 account_bank_statement_import_online_paypal/tests/__init__.py create mode 100644 account_bank_statement_import_online_paypal/tests/test_account_bank_statement_import_online_paypal.py create mode 100644 account_bank_statement_import_online_paypal/views/online_bank_statement_provider.xml diff --git a/account_bank_statement_import_online_paypal/__init__.py b/account_bank_statement_import_online_paypal/__init__.py new file mode 100644 index 0000000..31660d6 --- /dev/null +++ b/account_bank_statement_import_online_paypal/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import models diff --git a/account_bank_statement_import_online_paypal/__manifest__.py b/account_bank_statement_import_online_paypal/__manifest__.py new file mode 100644 index 0000000..0e9d050 --- /dev/null +++ b/account_bank_statement_import_online_paypal/__manifest__.py @@ -0,0 +1,23 @@ +# Copyright 2019 Brainbean Apps (https://brainbeanapps.com) +# Copyright 2019 Dataplug (https://dataplug.io) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + 'name': 'Online Bank Statements: PayPal.com', + 'version': '12.0.1.0.0', + 'author': + 'Brainbean Apps, ' + 'Dataplug, ' + 'Odoo Community Association (OCA)', + 'website': 'https://github.com/OCA/bank-statement-import/', + 'license': 'AGPL-3', + 'category': 'Accounting', + 'summary': 'Online bank statements for PayPal.com', + 'depends': [ + 'account_bank_statement_import_online', + ], + 'data': [ + 'views/online_bank_statement_provider.xml', + ], + 'installable': True, +} diff --git a/account_bank_statement_import_online_paypal/models/__init__.py b/account_bank_statement_import_online_paypal/models/__init__.py new file mode 100644 index 0000000..10e8660 --- /dev/null +++ b/account_bank_statement_import_online_paypal/models/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import online_bank_statement_provider_paypal diff --git a/account_bank_statement_import_online_paypal/models/online_bank_statement_provider_paypal.py b/account_bank_statement_import_online_paypal/models/online_bank_statement_provider_paypal.py new file mode 100644 index 0000000..fed83e0 --- /dev/null +++ b/account_bank_statement_import_online_paypal/models/online_bank_statement_provider_paypal.py @@ -0,0 +1,517 @@ +# Copyright 2019 Brainbean Apps (https://brainbeanapps.com) +# Copyright 2019 Dataplug (https://dataplug.io) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from base64 import b64encode +from datetime import datetime +from dateutil.relativedelta import relativedelta +import dateutil.parser +from decimal import Decimal +import itertools +import json +import pytz +from urllib.error import HTTPError +from urllib.parse import urlencode +import urllib.request + +from odoo import models, api, _ +from odoo.exceptions import UserError + + +PAYPAL_API_BASE = 'https://api.paypal.com' +TRANSACTIONS_SCOPE = 'https://uri.paypal.com/services/reporting/search/read' +EVENT_DESCRIPTIONS = { + 'T0000': _('General PayPal-to-PayPal payment'), + 'T0001': _('MassPay payment'), + 'T0002': _('Subscription payment'), + 'T0003': _('Pre-approved payment (BillUser API)'), + 'T0004': _('eBay auction payment'), + 'T0005': _('Direct payment API'), + 'T0006': _('PayPal Checkout APIs'), + 'T0007': _('Website payments standard payment'), + 'T0008': _('Postage payment to carrier'), + 'T0009': _('Gift certificate payment, purchase of gift certificate'), + 'T0010': _('Third-party auction payment'), + 'T0011': _('Mobile payment, made through a mobile phone'), + 'T0012': _('Virtual terminal payment'), + 'T0013': _('Donation payment'), + 'T0014': _('Rebate payments'), + 'T0015': _('Third-party payout'), + 'T0016': _('Third-party recoupment'), + 'T0017': _('Store-to-store transfers'), + 'T0018': _('PayPal Here payment'), + 'T0019': _('Generic instrument-funded payment'), + 'T0100': _('General non-payment fee'), + 'T0101': _('Website payments. Pro account monthly fee'), + 'T0102': _('Foreign bank withdrawal fee'), + 'T0103': _('WorldLink check withdrawal fee'), + 'T0104': _('Mass payment batch fee'), + 'T0105': _('Check withdrawal'), + 'T0106': _('Chargeback processing fee'), + 'T0107': _('Payment fee'), + 'T0108': _('ATM withdrawal'), + 'T0109': _('Auto-sweep from account'), + 'T0110': _('International credit card withdrawal'), + 'T0111': _('Warranty fee for warranty purchase'), + 'T0112': _('Gift certificate expiration fee'), + 'T0113': _('Partner fee'), + 'T0200': _('General currency conversion'), + 'T0201': _('User-initiated currency conversion'), + 'T0202': _('Currency conversion required to cover negative balance'), + 'T0300': _('General funding of PayPal account'), + 'T0301': _('PayPal balance manager funding of PayPal account'), + 'T0302': _('ACH funding for funds recovery from account balance'), + 'T0303': _('Electronic funds transfer (EFT)'), + 'T0400': _('General withdrawal from PayPal account'), + 'T0401': _('AutoSweep'), + 'T0500': _('General PayPal debit card transaction'), + 'T0501': _('Virtual PayPal debit card transaction'), + 'T0502': _('PayPal debit card withdrawal to ATM'), + 'T0503': _('Hidden virtual PayPal debit card transaction'), + 'T0504': _('PayPal debit card cash advance'), + 'T0505': _('PayPal debit authorization'), + 'T0600': _('General credit card withdrawal'), + 'T0700': _('General credit card deposit'), + 'T0701': _('Credit card deposit for negative PayPal account balance'), + 'T0800': _('General bonus'), + 'T0801': _('Debit card cash back bonus'), + 'T0802': _('Merchant referral account bonus'), + 'T0803': _('Balance manager account bonus'), + 'T0804': _('PayPal buyer warranty bonus'), + 'T0805': _( + 'PayPal protection bonus, payout for PayPal buyer protection, payout ' + 'for full protection with PayPal buyer credit.' + ), + 'T0806': _('Bonus for first ACH use'), + 'T0807': _('Credit card security charge refund'), + 'T0808': _('Credit card cash back bonus'), + 'T0900': _('General incentive or certificate redemption'), + 'T0901': _('Gift certificate redemption'), + 'T0902': _('Points incentive redemption'), + 'T0903': _('Coupon redemption'), + 'T0904': _('eBay loyalty incentive'), + 'T0905': _('Offers used as funding source'), + 'T1000': _('Bill pay transaction'), + 'T1100': _('General reversal'), + 'T1101': _('Reversal of ACH withdrawal transaction'), + 'T1102': _('Reversal of debit card transaction'), + 'T1103': _('Reversal of points usage'), + 'T1104': _('Reversal of ACH deposit'), + 'T1105': _('Reversal of general account hold'), + 'T1106': _('Payment reversal, initiated by PayPal'), + 'T1107': _('Payment refund, initiated by merchant'), + 'T1108': _('Fee reversal'), + 'T1109': _('Fee refund'), + 'T1110': _('Hold for dispute investigation'), + 'T1111': _('Cancellation of hold for dispute resolution'), + 'T1112': _('MAM reversal'), + 'T1113': _('Non-reference credit payment'), + 'T1114': _('MassPay reversal transaction'), + 'T1115': _('MassPay refund transaction'), + 'T1116': _('Instant payment review (IPR) reversal'), + 'T1117': _('Rebate or cash back reversal'), + 'T1118': _('Generic instrument/Open Wallet reversals (seller side)'), + 'T1119': _('Generic instrument/Open Wallet reversals (buyer side)'), + 'T1200': _('General account adjustment'), + 'T1201': _('Chargeback'), + 'T1202': _('Chargeback reversal'), + 'T1203': _('Charge-off adjustment'), + 'T1204': _('Incentive adjustment'), + 'T1205': _('Reimbursement of chargeback'), + 'T1207': _('Chargeback re-presentment rejection'), + 'T1208': _('Chargeback cancellation'), + 'T1300': _('General authorization'), + 'T1301': _('Reauthorization'), + 'T1302': _('Void of authorization'), + 'T1400': _('General dividend'), + 'T1500': _('General temporary hold'), + 'T1501': _('Account hold for open authorization'), + 'T1502': _('Account hold for ACH deposit'), + 'T1503': _('Temporary hold on available balance'), + 'T1600': _('PayPal buyer credit payment funding'), + 'T1601': _('BML credit, transfer from BML'), + 'T1602': _('Buyer credit payment'), + 'T1603': _('Buyer credit payment withdrawal, transfer to BML'), + 'T1700': _('General withdrawal to non-bank institution'), + 'T1701': _('WorldLink withdrawal'), + 'T1800': _('General buyer credit payment'), + 'T1801': _('BML withdrawal, transfer to BML'), + 'T1900': _('General adjustment without business-related event'), + 'T2000': _('General intra-account transfer'), + 'T2001': _('Settlement consolidation'), + 'T2002': _('Transfer of funds from payable'), + 'T2003': _('Transfer to external GL entity'), + 'T2101': _('General hold'), + 'T2102': _('General hold release'), + 'T2103': _('Reserve hold'), + 'T2104': _('Reserve release'), + 'T2105': _('Payment review hold'), + 'T2106': _('Payment review release'), + 'T2107': _('Payment hold'), + 'T2108': _('Payment hold release'), + 'T2109': _('Gift certificate purchase'), + 'T2110': _('Gift certificate redemption'), + 'T2111': _('Funds not yet available'), + 'T2112': _('Funds available'), + 'T2113': _('Blocked payments'), + 'T2201': _('Transfer to and from a credit-card-funded restricted balance'), + 'T3000': _('Generic instrument/Open Wallet transaction'), + 'T5000': _('Deferred disbursement, funds collected for disbursement'), + 'T5001': _('Delayed disbursement, funds disbursed'), + 'T9700': _('Account receivable for shipping'), + 'T9701': _('Funds payable: PayPal-provided funds that must be paid back'), + 'T9702': _( + 'Funds receivable: PayPal-provided funds that are being paid back' + ), + 'T9800': _('Display only transaction'), + 'T9900': _('Other'), +} + + +class OnlineBankStatementProviderPayPal(models.Model): + _inherit = 'online.bank.statement.provider' + + @api.model + def _get_available_services(self): + return super()._get_available_services() + [ + ('paypal', 'PayPal.com'), + ] + + @api.multi + def _obtain_statement_data(self, date_since, date_until): + self.ensure_one() + if self.service != 'paypal': + return super()._obtain_statement_data( + date_since, + date_until, + ) # pragma: no cover + + currency = ( + self.currency_id or self.company_id.currency_id + ).name + + if date_since.tzinfo: + date_since = date_since.astimezone(pytz.utc).replace(tzinfo=None) + if date_until.tzinfo: + date_until = date_until.astimezone(pytz.utc).replace(tzinfo=None) + + if date_since < datetime.utcnow() - relativedelta(years=3): + raise UserError(_( + 'PayPal allows retrieving transactions only up to 3 years in ' + 'the past. Please import older transactions manually. See ' + 'https://www.paypal.com/us/smarthelp/article/why-can\'t-i' + '-access-transaction-history-greater-than-3-years-ts2241' + )) + + token = self._paypal_get_token() + transactions = self._paypal_get_transactions( + token, + currency, + date_since, + date_until + ) + if not transactions: + return None + + # Normalize transactions, sort by date, and get lines + transactions = list(sorted( + transactions, + key=lambda transaction: self._paypal_get_transaction_date( + transaction + ) + )) + lines = list(itertools.chain.from_iterable(map( + lambda x: self._paypal_transaction_to_lines(x), + transactions + ))) + + first_transaction = transactions[0] + first_transaction_id = \ + first_transaction['transaction_info']['transaction_id'] + first_transaction_date = self._paypal_get_transaction_date( + first_transaction + ) + first_transaction = self._paypal_get_transaction( + token, + first_transaction_id, + first_transaction_date + ) + if not first_transaction: + raise UserError(_('Failed to resolve transaction %s (%s)') % ( + first_transaction_id, + first_transaction_date + )) + balance_start = self._paypal_get_transaction_ending_balance( + first_transaction + ) + balance_start -= self._paypal_get_transaction_total_amount( + first_transaction + ) + balance_start -= self._paypal_get_transaction_fee_amount( + first_transaction + ) + + last_transaction = transactions[-1] + last_transaction_id = \ + last_transaction['transaction_info']['transaction_id'] + last_transaction_date = self._paypal_get_transaction_date( + last_transaction + ) + last_transaction = self._paypal_get_transaction( + token, + last_transaction_id, + last_transaction_date + ) + if not last_transaction: + raise UserError(_('Failed to resolve transaction %s (%s)') % ( + last_transaction_id, + last_transaction_date + )) + balance_end = self._paypal_get_transaction_ending_balance( + last_transaction + ) + + return lines, { + 'balance_start': balance_start, + 'balance_end_real': balance_end, + } + + @api.model + def _paypal_preparse_transaction(self, transaction): + date = dateutil.parser.parse( + self._paypal_get_transaction_date(transaction) + ).astimezone(pytz.utc).replace(tzinfo=None) + transaction['transaction_info']['transaction_updated_date'] = date + return transaction + + @api.model + def _paypal_transaction_to_lines(self, data): + transaction = data['transaction_info'] + payer = data['payer_info'] + transaction_id = transaction['transaction_id'] + event_code = transaction['transaction_event_code'] + date = self._paypal_get_transaction_date(data) + total_amount = self._paypal_get_transaction_total_amount(data) + fee_amount = self._paypal_get_transaction_fee_amount(data) + transaction_subject = transaction.get('transaction_subject') + transaction_note = transaction.get('transaction_note') + invoice = transaction.get('invoice_id') + payer_name = payer.get('payer_name', {}) + payer_email = payer_name.get('email_address') + if invoice: + invoice = _('Invoice %s') % invoice + note = transaction_id + if transaction_subject or transaction_note: + note = '%s: %s' % ( + note, + transaction_subject or transaction_note + ) + if payer_email: + note += ' (%s)' % payer_email + unique_import_id = '%s-%s' % ( + transaction_id, + int(date.timestamp()), + ) + name = invoice \ + or transaction_subject \ + or transaction_note \ + or EVENT_DESCRIPTIONS.get(event_code) \ + or '' + line = { + 'name': name, + 'amount': str(total_amount), + 'date': date, + 'note': note, + 'unique_import_id': unique_import_id, + } + payer_full_name = payer_name.get('full_name') or \ + payer_name.get('alternate_full_name') + if payer_full_name: + line.update({ + 'partner_name': payer_full_name, + }) + lines = [line] + if fee_amount: + lines += [{ + 'name': _('Fee for %s') % (name or transaction_id), + 'amount': str(fee_amount), + 'date': date, + 'partner_name': 'PayPal', + 'unique_import_id': '%s-FEE' % unique_import_id, + 'note': _('Transaction fee for %s') % note, + }] + return lines + + @api.multi + def _paypal_get_token(self): + self.ensure_one() + data = self._paypal_retrieve( + (self.api_base or PAYPAL_API_BASE) + '/v1/oauth2/token', + (self.username, self.password), + data=urlencode({ + 'grant_type': 'client_credentials', + }).encode('utf-8') + ) + if 'scope' not in data or TRANSACTIONS_SCOPE not in data['scope']: + raise UserError(_( + 'PayPal App features are configured incorrectly!' + )) + if 'token_type' not in data or data['token_type'] != 'Bearer': + raise UserError(_('Invalid token type!')) + if 'access_token' not in data: + raise UserError(_( + 'Failed to acquire token using Client ID and Secret!' + )) + return data['access_token'] + + @api.multi + def _paypal_get_transaction(self, token, transaction_id, timestamp): + self.ensure_one() + transaction_date = timestamp.isoformat() + 'Z' + url = (self.api_base or PAYPAL_API_BASE) \ + + '/v1/reporting/transactions' \ + + ( + '?start_date=%s' + '&end_date=%s' + '&fields=all' + ) % ( + transaction_date, + transaction_date, + ) + data = self._paypal_retrieve(url, token) + transactions = data['transaction_details'] + for transaction in transactions: + if transaction['transaction_info']['transaction_id'] != \ + transaction_id: + continue + return transaction + return None + + @api.multi + def _paypal_get_transactions(self, token, currency, since, until): + self.ensure_one() + # NOTE: Not more than 31 days in a row + # NOTE: start_date <= date <= end_date, thus check every transaction + interval_step = relativedelta(days=31) + interval_start = since + transactions = [] + while interval_start < until: + interval_end = min(interval_start + interval_step, until) + page = 1 + total_pages = None + while total_pages is None or page <= total_pages: + url = (self.api_base or PAYPAL_API_BASE) \ + + '/v1/reporting/transactions' \ + + ( + '?transaction_currency=%s' + '&start_date=%s' + '&end_date=%s' + '&fields=all' + '&balance_affecting_records_only=Y' + '&page_size=500' + '&page=%d' + % ( + currency, + interval_start.isoformat() + 'Z', + interval_end.isoformat() + 'Z', + page, + )) + data = self._paypal_retrieve(url, token) + interval_transactions = map( + lambda transaction: self._paypal_preparse_transaction( + transaction + ), + data['transaction_details'] + ) + transactions += list(filter( + lambda transaction: + interval_start <= self._paypal_get_transaction_date( + transaction + ) < interval_end, + interval_transactions + )) + total_pages = data['total_pages'] + page += 1 + interval_start += interval_step + return transactions + + @api.model + def _paypal_get_transaction_date(self, transaction): + # NOTE: CSV reports from PayPal use this date, search as well + return transaction['transaction_info']['transaction_updated_date'] + + @api.model + def _paypal_get_transaction_total_amount(self, transaction): + transaction_amount = \ + transaction['transaction_info'].get('transaction_amount') + if not transaction_amount: + return Decimal() + return Decimal(transaction_amount['value']) + + @api.model + def _paypal_get_transaction_fee_amount(self, transaction): + fee_amount = transaction['transaction_info'].get('fee_amount') + if not fee_amount: + return Decimal() + return Decimal(fee_amount['value']) + + @api.model + def _paypal_get_transaction_ending_balance(self, transaction): + # NOTE: 'available_balance' instead of 'ending_balance' as per CSV file + transaction_amount = \ + transaction['transaction_info'].get('available_balance') + if not transaction_amount: + return Decimal() + return Decimal(transaction_amount['value']) + + @api.model + def _paypal_validate(self, content): + content = json.loads(content) + if 'error' in content and content['error']: + raise UserError( + content['error_description'] + if 'error_description' in content + else 'Unknown error' + ) + return content + + @api.model + def _paypal_retrieve(self, url, auth, data=None): + try: + with self._paypal_urlopen(url, auth, data) as response: + content = response.read().decode('utf-8') + except HTTPError as e: + content = self._paypal_validate( + e.read().decode('utf-8') + ) + if 'name' in content and content['name']: + raise UserError('%s: %s' % ( + content['name'], + content['error_description'] + if 'error_description' in content + else 'Unknown error', + )) + raise e + return self._paypal_validate(content) + + @api.model + def _paypal_urlopen(self, url, auth, data=None): + if not auth: + raise UserError(_('No authentication specified!')) + request = urllib.request.Request(url, data=data) + if isinstance(auth, tuple): + request.add_header( + 'Authorization', + 'Basic %s' % str( + b64encode(('%s:%s' % (auth[0], auth[1])).encode('utf-8')), + 'utf-8' + ) + ) + elif isinstance(auth, str): + request.add_header( + 'Authorization', + 'Bearer %s' % auth + ) + else: + raise UserError(_('Unknown authentication specified!')) + return urllib.request.urlopen(request) diff --git a/account_bank_statement_import_online_paypal/readme/CONFIGURE.rst b/account_bank_statement_import_online_paypal/readme/CONFIGURE.rst new file mode 100644 index 0000000..67b4418 --- /dev/null +++ b/account_bank_statement_import_online_paypal/readme/CONFIGURE.rst @@ -0,0 +1,29 @@ +To configure online bank statements provider: + +#. Go to *Invoicing > Configuration > Bank Accounts* +#. Open bank account to configure and edit it +#. Set *Bank Feeds* to *Online* +#. Select *PayPal.com* as online bank statements provider in + *Online Bank Statements (OCA)* section +#. Save the bank account +#. Click on provider and configure provider-specific settings. + +or, alternatively: + +#. Go to *Invoicing > Overview* +#. Open settings of the corresponding journal account +#. Switch to *Bank Account* tab +#. Set *Bank Feeds* to *Online* +#. Select *PayPal.com* as online bank statements provider in + *Online Bank Statements (OCA)* section +#. Save the bank account +#. Click on provider and configure provider-specific settings. + +To obtain *Client ID* and *Secret*: + +#. Open `PayPal Developer `_ +#. Go to *My Apps & Credentials* and switch to *Live* +#. Under *REST API apps*, click *Create App* to create new application (e.g. *Odoo*) +#. Copy *Client ID* and *Secret* to use during provider configuration +#. Under *Live App Settings*, uncheck all features except *Transaction Search* +#. Click Save diff --git a/account_bank_statement_import_online_paypal/readme/CONTRIBUTORS.rst b/account_bank_statement_import_online_paypal/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000..1c6a35a --- /dev/null +++ b/account_bank_statement_import_online_paypal/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Alexey Pelykh diff --git a/account_bank_statement_import_online_paypal/readme/DESCRIPTION.rst b/account_bank_statement_import_online_paypal/readme/DESCRIPTION.rst new file mode 100644 index 0000000..a2a7a3e --- /dev/null +++ b/account_bank_statement_import_online_paypal/readme/DESCRIPTION.rst @@ -0,0 +1,2 @@ +This module provides online bank statements from +`PayPal.com `_. diff --git a/account_bank_statement_import_online_paypal/readme/ROADMAP.rst b/account_bank_statement_import_online_paypal/readme/ROADMAP.rst new file mode 100644 index 0000000..d811420 --- /dev/null +++ b/account_bank_statement_import_online_paypal/readme/ROADMAP.rst @@ -0,0 +1,7 @@ +* Only transactions for the previous three years are retrieved, historical data + can be imported manually, see ``account_bank_statement_import_paypal``. See + `PayPal Help Center article `_ + for details. +* `PayPal Transaction Info `_ + defines extra fields like ``tip_amount``, ``shipping_amount``, etc. that + could be useful to be decomposed from a single transaction. diff --git a/account_bank_statement_import_online_paypal/readme/USAGE.rst b/account_bank_statement_import_online_paypal/readme/USAGE.rst new file mode 100644 index 0000000..03845f1 --- /dev/null +++ b/account_bank_statement_import_online_paypal/readme/USAGE.rst @@ -0,0 +1,6 @@ +To pull historical bank statements: + +#. Go to *Invoicing > Configuration > Bank Accounts* +#. Select specific bank accounts +#. Launch *Actions > Online Bank Statements Pull Wizard* +#. Configure date interval and click *Pull* diff --git a/account_bank_statement_import_online_paypal/tests/__init__.py b/account_bank_statement_import_online_paypal/tests/__init__.py new file mode 100644 index 0000000..ca7b0d9 --- /dev/null +++ b/account_bank_statement_import_online_paypal/tests/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import test_account_bank_statement_import_online_paypal diff --git a/account_bank_statement_import_online_paypal/tests/test_account_bank_statement_import_online_paypal.py b/account_bank_statement_import_online_paypal/tests/test_account_bank_statement_import_online_paypal.py new file mode 100644 index 0000000..fc7e814 --- /dev/null +++ b/account_bank_statement_import_online_paypal/tests/test_account_bank_statement_import_online_paypal.py @@ -0,0 +1,576 @@ +# Copyright 2019 Brainbean Apps (https://brainbeanapps.com) +# Copyright 2019 Dataplug (https://dataplug.io) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from datetime import datetime +from dateutil.relativedelta import relativedelta +from decimal import Decimal +import json +from unittest import mock + +from odoo.tests import common +from odoo import fields + +_module_ns = 'odoo.addons.account_bank_statement_import_online_paypal' +_provider_class = ( + _module_ns + + '.models.online_bank_statement_provider_paypal' + + '.OnlineBankStatementProviderPayPal' +) + + +class TestAccountBankAccountStatementImportOnlinePayPal( + common.TransactionCase +): + + def setUp(self): + super().setUp() + + self.now = fields.Datetime.now() + self.currency_eur = self.env.ref('base.EUR') + self.currency_usd = self.env.ref('base.USD') + self.AccountJournal = self.env['account.journal'] + self.OnlineBankStatementProvider = self.env[ + 'online.bank.statement.provider' + ] + self.AccountBankStatement = self.env['account.bank.statement'] + self.AccountBankStatementLine = self.env['account.bank.statement.line'] + + Provider = self.OnlineBankStatementProvider + self.paypal_parse_transaction = lambda payload: ( + Provider._paypal_transaction_to_lines( + Provider._paypal_preparse_transaction( + json.loads( + payload, + parse_float=Decimal, + ) + ) + ) + ) + self.mock_token = lambda: mock.patch( + _provider_class + '._paypal_get_token', + return_value='--TOKEN--', + ) + + def test_good_token(self): + journal = self.AccountJournal.create({ + 'name': 'Bank', + 'type': 'bank', + 'code': 'BANK', + 'currency_id': self.currency_eur.id, + 'bank_statements_source': 'online', + 'online_bank_statement_provider': 'paypal', + }) + + provider = journal.online_bank_statement_provider_id + mocked_response = json.loads("""{ + "scope": "https://uri.paypal.com/services/reporting/search/read", + "access_token": "---TOKEN---", + "token_type": "Bearer", + "app_id": "APP-1234567890", + "expires_in": 32400, + "nonce": "---NONCE---" +}""", parse_float=Decimal) + token = None + with mock.patch( + _provider_class + '._paypal_retrieve', + return_value=mocked_response, + ): + token = provider._paypal_get_token() + self.assertEqual(token, '---TOKEN---') + + def test_bad_token_scope(self): + journal = self.AccountJournal.create({ + 'name': 'Bank', + 'type': 'bank', + 'code': 'BANK', + 'currency_id': self.currency_eur.id, + 'bank_statements_source': 'online', + 'online_bank_statement_provider': 'paypal', + }) + + provider = journal.online_bank_statement_provider_id + mocked_response = json.loads("""{ + "scope": "openid https://uri.paypal.com/services/applications/webhooks", + "access_token": "---TOKEN---", + "token_type": "Bearer", + "app_id": "APP-1234567890", + "expires_in": 32400, + "nonce": "---NONCE---" +}""", parse_float=Decimal) + with mock.patch( + _provider_class + '._paypal_retrieve', + return_value=mocked_response, + ): + with self.assertRaises(Exception): + provider._paypal_get_token() + + def test_bad_token_type(self): + journal = self.AccountJournal.create({ + 'name': 'Bank', + 'type': 'bank', + 'code': 'BANK', + 'currency_id': self.currency_eur.id, + 'bank_statements_source': 'online', + 'online_bank_statement_provider': 'paypal', + }) + + provider = journal.online_bank_statement_provider_id + mocked_response = json.loads("""{ + "scope": "https://uri.paypal.com/services/reporting/search/read", + "access_token": "---TOKEN---", + "token_type": "NotBearer", + "app_id": "APP-1234567890", + "expires_in": 32400, + "nonce": "---NONCE---" +}""", parse_float=Decimal) + with mock.patch( + _provider_class + '._paypal_retrieve', + return_value=mocked_response, + ): + with self.assertRaises(Exception): + provider._paypal_get_token() + + def test_no_token(self): + journal = self.AccountJournal.create({ + 'name': 'Bank', + 'type': 'bank', + 'code': 'BANK', + 'currency_id': self.currency_eur.id, + 'bank_statements_source': 'online', + 'online_bank_statement_provider': 'paypal', + }) + + provider = journal.online_bank_statement_provider_id + mocked_response = json.loads("""{ + "scope": "https://uri.paypal.com/services/reporting/search/read", + "token_type": "Bearer", + "app_id": "APP-1234567890", + "expires_in": 32400, + "nonce": "---NONCE---" + }""", parse_float=Decimal) + with mock.patch( + _provider_class + '._paypal_retrieve', + return_value=mocked_response, + ): + with self.assertRaises(Exception): + provider._paypal_get_token() + + def test_empty_pull(self): + journal = self.AccountJournal.create({ + 'name': 'Bank', + 'type': 'bank', + 'code': 'BANK', + 'currency_id': self.currency_eur.id, + 'bank_statements_source': 'online', + 'online_bank_statement_provider': 'paypal', + }) + + provider = journal.online_bank_statement_provider_id + mocked_response = json.loads("""{ + "transaction_details": [], + "account_number": "1234567890", + "start_date": "2019-08-01T00:00:00+0000", + "end_date": "2019-08-01T00:00:00+0000", + "last_refreshed_datetime": "2019-09-01T00:00:00+0000", + "page": 1, + "total_items": 0, + "total_pages": 0 +}""", parse_float=Decimal) + with mock.patch( + _provider_class + '._paypal_retrieve', + return_value=mocked_response, + ), self.mock_token(): + data = provider._obtain_statement_data( + self.now - relativedelta(hours=1), + self.now, + ) + + self.assertIsNone(data) + + def test_ancient_pull(self): + journal = self.AccountJournal.create({ + 'name': 'Bank', + 'type': 'bank', + 'code': 'BANK', + 'currency_id': self.currency_eur.id, + 'bank_statements_source': 'online', + 'online_bank_statement_provider': 'paypal', + }) + + provider = journal.online_bank_statement_provider_id + mocked_response = json.loads("""{ + "transaction_details": [], + "account_number": "1234567890", + "start_date": "2019-08-01T00:00:00+0000", + "end_date": "2019-08-01T00:00:00+0000", + "last_refreshed_datetime": "2019-09-01T00:00:00+0000", + "page": 1, + "total_items": 0, + "total_pages": 0 +}""", parse_float=Decimal) + with mock.patch( + _provider_class + '._paypal_retrieve', + return_value=mocked_response, + ), self.mock_token(): + with self.assertRaises(Exception): + provider._obtain_statement_data( + self.now - relativedelta(years=5), + self.now, + ) + + def test_pull(self): + journal = self.AccountJournal.create({ + 'name': 'Bank', + 'type': 'bank', + 'code': 'BANK', + 'currency_id': self.currency_eur.id, + 'bank_statements_source': 'online', + 'online_bank_statement_provider': 'paypal', + }) + + provider = journal.online_bank_statement_provider_id + mocked_response = json.loads("""{ + "transaction_details": [{ + "transaction_info": { + "paypal_account_id": "1234567890", + "transaction_id": "1234567890", + "transaction_event_code": "T1234", + "transaction_initiation_date": "2019-08-01T00:00:00+0000", + "transaction_updated_date": "2019-08-01T00:00:00+0000", + "transaction_amount": { + "currency_code": "USD", + "value": "1000.00" + }, + "fee_amount": { + "currency_code": "USD", + "value": "-100.00" + }, + "transaction_status": "S", + "transaction_subject": "Payment for Invoice(s) 1", + "ending_balance": { + "currency_code": "USD", + "value": "900.00" + }, + "available_balance": { + "currency_code": "USD", + "value": "900.00" + }, + "invoice_id": "1" + }, + "payer_info": { + "account_id": "1234567890", + "email_address": "partner@example.com", + "address_status": "Y", + "payer_status": "N", + "payer_name": { + "alternate_full_name": "Acme, Inc." + }, + "country_code": "US" + }, + "shipping_info": {}, + "cart_info": {}, + "store_info": {}, + "auction_info": {}, + "incentive_info": {} + }, { + "transaction_info": { + "paypal_account_id": "1234567890", + "transaction_id": "1234567891", + "transaction_event_code": "T1234", + "transaction_initiation_date": "2019-08-02T00:00:00+0000", + "transaction_updated_date": "2019-08-02T00:00:00+0000", + "transaction_amount": { + "currency_code": "USD", + "value": "1000.00" + }, + "fee_amount": { + "currency_code": "USD", + "value": "-100.00" + }, + "transaction_status": "S", + "transaction_subject": "Payment for Invoice(s) 1", + "ending_balance": { + "currency_code": "USD", + "value": "900.00" + }, + "available_balance": { + "currency_code": "USD", + "value": "900.00" + }, + "invoice_id": "1" + }, + "payer_info": { + "account_id": "1234567890", + "email_address": "partner@example.com", + "address_status": "Y", + "payer_status": "N", + "payer_name": { + "alternate_full_name": "Acme, Inc." + }, + "country_code": "US" + }, + "shipping_info": {}, + "cart_info": {}, + "store_info": {}, + "auction_info": {}, + "incentive_info": {} + }], + "account_number": "1234567890", + "start_date": "2019-08-01T00:00:00+0000", + "end_date": "2019-08-02T00:00:00+0000", + "last_refreshed_datetime": "2019-09-01T00:00:00+0000", + "page": 1, + "total_items": 1, + "total_pages": 1 +}""", parse_float=Decimal) + with mock.patch( + _provider_class + '._paypal_retrieve', + return_value=mocked_response, + ), self.mock_token(): + data = provider._obtain_statement_data( + datetime(2019, 8, 1), + datetime(2019, 8, 2), + ) + + self.assertEqual(len(data[0]), 2) + self.assertEqual(data[0][0], { + 'date': datetime(2019, 8, 1), + 'amount': '1000.00', + 'name': 'Invoice 1', + 'note': '1234567890: Payment for Invoice(s) 1', + 'partner_name': 'Acme, Inc.', + 'unique_import_id': '1234567890-1564617600', + }) + self.assertEqual(data[0][1], { + 'date': datetime(2019, 8, 1), + 'amount': '-100.00', + 'name': 'Fee for Invoice 1', + 'note': 'Transaction fee for 1234567890: Payment for Invoice(s) 1', + 'partner_name': 'PayPal', + 'unique_import_id': '1234567890-1564617600-FEE', + }) + self.assertEqual(data[1], { + 'balance_start': 0.0, + 'balance_end_real': 900.0, + }) + + def test_transaction_parse_1(self): + lines = self.paypal_parse_transaction("""{ + "transaction_info": { + "paypal_account_id": "1234567890", + "transaction_id": "1234567890", + "transaction_event_code": "T1234", + "transaction_initiation_date": "2019-08-01T00:00:00+0000", + "transaction_updated_date": "2019-08-01T00:00:00+0000", + "transaction_amount": { + "currency_code": "USD", + "value": "1000.00" + }, + "fee_amount": { + "currency_code": "USD", + "value": "0.00" + }, + "transaction_status": "S", + "transaction_subject": "Payment for Invoice(s) 1", + "ending_balance": { + "currency_code": "USD", + "value": "1000.00" + }, + "available_balance": { + "currency_code": "USD", + "value": "1000.00" + }, + "invoice_id": "1" + }, + "payer_info": { + "account_id": "1234567890", + "email_address": "partner@example.com", + "address_status": "Y", + "payer_status": "N", + "payer_name": { + "alternate_full_name": "Acme, Inc." + }, + "country_code": "US" + }, + "shipping_info": {}, + "cart_info": {}, + "store_info": {}, + "auction_info": {}, + "incentive_info": {} +}""") + self.assertEqual(len(lines), 1) + self.assertEqual(lines[0], { + 'date': datetime(2019, 8, 1), + 'amount': '1000.00', + 'name': 'Invoice 1', + 'note': '1234567890: Payment for Invoice(s) 1', + 'partner_name': 'Acme, Inc.', + 'unique_import_id': '1234567890-1564617600', + }) + + def test_transaction_parse_2(self): + lines = self.paypal_parse_transaction("""{ + "transaction_info": { + "paypal_account_id": "1234567890", + "transaction_id": "1234567890", + "transaction_event_code": "T1234", + "transaction_initiation_date": "2019-08-01T00:00:00+0000", + "transaction_updated_date": "2019-08-01T00:00:00+0000", + "transaction_amount": { + "currency_code": "USD", + "value": "1000.00" + }, + "fee_amount": { + "currency_code": "USD", + "value": "0.00" + }, + "transaction_status": "S", + "transaction_subject": "Payment for Invoice(s) 1", + "ending_balance": { + "currency_code": "USD", + "value": "1000.00" + }, + "available_balance": { + "currency_code": "USD", + "value": "1000.00" + }, + "invoice_id": "1" + }, + "payer_info": { + "account_id": "1234567890", + "email_address": "partner@example.com", + "address_status": "Y", + "payer_status": "N", + "payer_name": { + "alternate_full_name": "Acme, Inc." + }, + "country_code": "US" + }, + "shipping_info": {}, + "cart_info": {}, + "store_info": {}, + "auction_info": {}, + "incentive_info": {} +}""") + self.assertEqual(len(lines), 1) + self.assertEqual(lines[0], { + 'date': datetime(2019, 8, 1), + 'amount': '1000.00', + 'name': 'Invoice 1', + 'note': '1234567890: Payment for Invoice(s) 1', + 'partner_name': 'Acme, Inc.', + 'unique_import_id': '1234567890-1564617600', + }) + + def test_transaction_parse_3(self): + lines = self.paypal_parse_transaction("""{ + "transaction_info": { + "paypal_account_id": "1234567890", + "transaction_id": "1234567890", + "transaction_event_code": "T1234", + "transaction_initiation_date": "2019-08-01T00:00:00+0000", + "transaction_updated_date": "2019-08-01T00:00:00+0000", + "transaction_amount": { + "currency_code": "USD", + "value": "1000.00" + }, + "fee_amount": { + "currency_code": "USD", + "value": "-100.00" + }, + "transaction_status": "S", + "transaction_subject": "Payment for Invoice(s) 1", + "ending_balance": { + "currency_code": "USD", + "value": "900.00" + }, + "available_balance": { + "currency_code": "USD", + "value": "900.00" + }, + "invoice_id": "1" + }, + "payer_info": { + "account_id": "1234567890", + "email_address": "partner@example.com", + "address_status": "Y", + "payer_status": "N", + "payer_name": { + "alternate_full_name": "Acme, Inc." + }, + "country_code": "US" + }, + "shipping_info": {}, + "cart_info": {}, + "store_info": {}, + "auction_info": {}, + "incentive_info": {} +}""") + self.assertEqual(len(lines), 2) + self.assertEqual(lines[0], { + 'date': datetime(2019, 8, 1), + 'amount': '1000.00', + 'name': 'Invoice 1', + 'note': '1234567890: Payment for Invoice(s) 1', + 'partner_name': 'Acme, Inc.', + 'unique_import_id': '1234567890-1564617600', + }) + self.assertEqual(lines[1], { + 'date': datetime(2019, 8, 1), + 'amount': '-100.00', + 'name': 'Fee for Invoice 1', + 'note': 'Transaction fee for 1234567890: Payment for Invoice(s) 1', + 'partner_name': 'PayPal', + 'unique_import_id': '1234567890-1564617600-FEE', + }) + + def test_transaction_parse_4(self): + lines = self.paypal_parse_transaction("""{ + "transaction_info": { + "paypal_account_id": "1234567890", + "transaction_id": "1234567890", + "transaction_event_code": "T1234", + "transaction_initiation_date": "2019-08-01T00:00:00+0000", + "transaction_updated_date": "2019-08-01T00:00:00+0000", + "transaction_amount": { + "currency_code": "USD", + "value": "1000.00" + }, + "transaction_status": "S", + "transaction_subject": "Payment for Invoice(s) 1", + "ending_balance": { + "currency_code": "USD", + "value": "1000.00" + }, + "available_balance": { + "currency_code": "USD", + "value": "1000.00" + }, + "invoice_id": "1" + }, + "payer_info": { + "account_id": "1234567890", + "email_address": "partner@example.com", + "address_status": "Y", + "payer_status": "N", + "payer_name": { + "alternate_full_name": "Acme, Inc." + }, + "country_code": "US" + }, + "shipping_info": {}, + "cart_info": {}, + "store_info": {}, + "auction_info": {}, + "incentive_info": {} +}""") + self.assertEqual(len(lines), 1) + self.assertEqual(lines[0], { + 'date': datetime(2019, 8, 1), + 'amount': '1000.00', + 'name': 'Invoice 1', + 'note': '1234567890: Payment for Invoice(s) 1', + 'partner_name': 'Acme, Inc.', + 'unique_import_id': '1234567890-1564617600', + }) diff --git a/account_bank_statement_import_online_paypal/views/online_bank_statement_provider.xml b/account_bank_statement_import_online_paypal/views/online_bank_statement_provider.xml new file mode 100644 index 0000000..0ec34e0 --- /dev/null +++ b/account_bank_statement_import_online_paypal/views/online_bank_statement_provider.xml @@ -0,0 +1,40 @@ + + + + + + online.bank.statement.provider.form + online.bank.statement.provider + + + + + + + + + + + + + + +