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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+