diff --git a/account_bank_statement_import_online_ponto/README.rst b/account_bank_statement_import_online_ponto/README.rst
new file mode 100644
index 0000000..8439d25
--- /dev/null
+++ b/account_bank_statement_import_online_ponto/README.rst
@@ -0,0 +1,102 @@
+===============================
+Online Bank Statements: MyPonto
+===============================
+
+.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
+ :target: https://odoo-community.org/page/development-status
+ :alt: Beta
+.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
+ :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
+ :alt: License: AGPL-3
+.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fbank--statement--import-lightgray.png?logo=github
+ :target: https://github.com/OCA/bank-statement-import/tree/12.0/account_bank_statement_import_online_ponto
+ :alt: OCA/bank-statement-import
+.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
+ :target: https://translation.odoo-community.org/projects/bank-statement-import-12-0/bank-statement-import-12-0-account_bank_statement_import_online_ponto
+ :alt: Translate me on Weblate
+.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png
+ :target: https://runbot.odoo-community.org/runbot/174/12.0
+ :alt: Try me on Runbot
+
+|badge1| |badge2| |badge3| |badge4| |badge5|
+
+This module provides online bank statements from MyPonto.com.
+MyPonto
+
+**Table of contents**
+
+.. contents::
+ :local:
+
+Configuration
+=============
+
+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 *MyPonto.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 *Qonto.eu* 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 *Login* and *Key*:
+
+#. Open `MyPonto.com `_.
+
+Usage
+=====
+
+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*
+
+Bug Tracker
+===========
+
+Bugs are tracked on `GitHub Issues `_.
+In case of trouble, please check there if your issue has already been reported.
+If you spotted it first, help us smashing it by providing a detailed and welcomed
+`feedback `_.
+
+Do not contact contributors directly about support or help with technical issues.
+
+Credits
+=======
+
+Authors
+~~~~~~~
+
+* Florent de Labarre
+
+Maintainers
+~~~~~~~~~~~
+
+This module is maintained by the OCA.
+
+.. image:: https://odoo-community.org/logo.png
+ :alt: Odoo Community Association
+ :target: https://odoo-community.org
+
+OCA, or the Odoo Community Association, is a nonprofit organization whose
+mission is to support the collaborative development of Odoo features and
+promote its widespread use.
+
+This module is part of the `OCA/bank-statement-import `_ project on GitHub.
+
+You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
diff --git a/account_bank_statement_import_online_ponto/__init__.py b/account_bank_statement_import_online_ponto/__init__.py
new file mode 100644
index 0000000..de73a3e
--- /dev/null
+++ b/account_bank_statement_import_online_ponto/__init__.py
@@ -0,0 +1,4 @@
+# Copyright 2020 Florent de Labarre
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+from . import models
diff --git a/account_bank_statement_import_online_ponto/__manifest__.py b/account_bank_statement_import_online_ponto/__manifest__.py
new file mode 100644
index 0000000..a709649
--- /dev/null
+++ b/account_bank_statement_import_online_ponto/__manifest__.py
@@ -0,0 +1,15 @@
+# Copyright 2020 Florent de Labarre
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+{
+ "name": "Online Bank Statements: MyPonto.com",
+ "version": "12.0.1.0.0",
+ "category": "Account",
+ "website": "https://github.com/OCA/bank-statement-import",
+ "author": "Florent de Labarre, Odoo Community Association (OCA)",
+ "license": "AGPL-3",
+ "installable": True,
+ "depends": ["account_bank_statement_import_online"],
+ "data": [
+ "view/online_bank_statement_provider.xml"
+ ],
+}
diff --git a/account_bank_statement_import_online_ponto/models/__init__.py b/account_bank_statement_import_online_ponto/models/__init__.py
new file mode 100644
index 0000000..cc57537
--- /dev/null
+++ b/account_bank_statement_import_online_ponto/models/__init__.py
@@ -0,0 +1,4 @@
+# Copyright 2020 Florent de Labarre
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+from . import online_bank_statement_provider_ponto
diff --git a/account_bank_statement_import_online_ponto/models/online_bank_statement_provider_ponto.py b/account_bank_statement_import_online_ponto/models/online_bank_statement_provider_ponto.py
new file mode 100644
index 0000000..2d05fcd
--- /dev/null
+++ b/account_bank_statement_import_online_ponto/models/online_bank_statement_provider_ponto.py
@@ -0,0 +1,229 @@
+# Copyright 2020 Florent de Labarre
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+import requests
+import json
+import base64
+import time
+import pytz
+import re
+from datetime import datetime
+
+from odoo import api, fields, models, _
+from odoo.exceptions import UserError
+from dateutil.relativedelta import relativedelta
+from odoo.addons.base.models.res_bank import sanitize_account_number
+
+PONTO_ENDPOINT = 'https://api.myponto.com'
+
+
+class OnlineBankStatementProviderPonto(models.Model):
+ _inherit = 'online.bank.statement.provider'
+
+ ponto_token = fields.Char(readonly=True)
+ ponto_token_expiration = fields.Datetime(readonly=True)
+ ponto_last_identifier = fields.Char(readonly=True)
+
+ def ponto_reset_last_identifier(self):
+ self.write({'ponto_last_identifier': False})
+
+ @api.model
+ def _get_available_services(self):
+ return super()._get_available_services() + [
+ ('ponto', 'MyPonto.com'),
+ ]
+
+ def _obtain_statement_data(self, date_since, date_until):
+ self.ensure_one()
+ if self.service != 'ponto':
+ return super()._obtain_statement_data(
+ date_since,
+ date_until,
+ )
+ return self._ponto_obtain_statement_data(date_since, date_until)
+
+ def _get_statement_date(self, date_since, date_until):
+ self.ensure_one()
+ if self.service != 'ponto':
+ return super()._get_statement_date(
+ date_since,
+ date_until,
+ )
+ return date_since.astimezone(pytz.timezone('Europe/Paris')).date()
+
+ #########
+ # ponto #
+ #########
+
+ def _ponto_header_token(self):
+ self.ensure_one()
+ if self.username and self.password:
+ login = '%s:%s' % (self.username, self.password)
+ login = base64.b64encode(login.encode('UTF-8')).decode('UTF-8')
+ return {'Content-Type': 'application/x-www-form-urlencoded',
+ 'Accept': 'application/json',
+ 'Authorization': 'Basic %s' % login, }
+ raise UserError(_('Please fill login and key.'))
+
+ def _ponto_header(self):
+ self.ensure_one()
+ if not self.ponto_token \
+ or not self.ponto_token_expiration \
+ or self.ponto_token_expiration <= fields.Datetime.now():
+
+ url = PONTO_ENDPOINT + '/oauth2/token'
+ response = requests.post(url, verify=False,
+ params={'grant_type': 'client_credentials'},
+ headers=self._ponto_header_token())
+ if response.status_code == 200:
+ data = json.loads(response.text)
+ access_token = data.get('access_token', False)
+ if not access_token:
+ raise UserError(_('Ponto : no token'))
+ else:
+ self.sudo().ponto_token = access_token
+ expiration_date = fields.Datetime.now() + relativedelta(
+ seconds=data.get('expires_in', False))
+ self.sudo().ponto_token_expiration = expiration_date
+ else:
+ raise UserError(_('%s \n\n %s') % (response.status_code, response.text))
+ return {'Accept': 'application/json',
+ 'Authorization': 'Bearer %s' % self.ponto_token, }
+
+ def _ponto_get_account_ids(self):
+ url = PONTO_ENDPOINT + '/accounts'
+ response = requests.get(url, verify=False, params={'limit': 100},
+ headers=self._ponto_header())
+ if response.status_code == 200:
+ data = json.loads(response.text)
+ res = {}
+ for account in data.get('data', []):
+ iban = sanitize_account_number(
+ account.get('attributes', {}).get('reference', ''))
+ res[iban] = account.get('id')
+ return res
+ raise UserError(_('%s \n\n %s') % (response.status_code, response.text))
+
+ def _ponto_synchronisation(self, account_id):
+ url = PONTO_ENDPOINT + '/synchronizations'
+ data = {'data': {
+ 'type': 'synchronization',
+ 'attributes': {
+ 'resourceType': 'account',
+ 'resourceId': account_id,
+ 'subtype': 'accountTransactions'
+ }
+ }}
+ response = requests.post(url, verify=False,
+ headers=self._ponto_header(),
+ json=data)
+ if response.status_code in (200, 201, 400):
+ data = json.loads(response.text)
+ sync_id = data.get('attributes', {}).get('resourceId', False)
+ else:
+ raise UserError(_('Error during Create Synchronisation %s \n\n %s') % (
+ response.status_code, response.text))
+
+ # Check synchronisation
+ if not sync_id:
+ return
+ url = PONTO_ENDPOINT + '/synchronizations/' + sync_id
+ number = 0
+ while number == 100:
+ number += 1
+ response = requests.get(url, verify=False, headers=self._ponto_header())
+ if response.status_code == 200:
+ data = json.loads(response.text)
+ status = data.get('status', {})
+ if status in ('success', 'error'):
+ return
+ time.sleep(4)
+
+ def _ponto_get_transaction(self, account_id, date_since, date_until):
+ page_url = PONTO_ENDPOINT + '/accounts/' + account_id + '/transactions'
+ params = {'limit': 100}
+ page_next = True
+ last_identifier = self.ponto_last_identifier
+ if last_identifier:
+ params['before'] = last_identifier
+ page_next = False
+ transaction_lines = []
+ latest_identifier = False
+ while page_url:
+ response = requests.get(page_url, verify=False, params=params,
+ headers=self._ponto_header())
+ if response.status_code == 200:
+ if params.get('before'):
+ params.pop('before')
+ data = json.loads(response.text)
+ links = data.get('links', {})
+ if page_next:
+ page_url = links.get('next', False)
+ else:
+ page_url = links.get('prev', False)
+ transactions = data.get('data', [])
+ if transactions:
+ current_transactions = []
+ for transaction in transactions:
+ date = self._ponto_date_from_string(
+ transaction.get('attributes', {}).get('executionDate'))
+ if date_since <= date < date_until:
+ current_transactions.append(transaction)
+
+ if current_transactions:
+ if not page_next or (page_next and not latest_identifier):
+ latest_identifier = current_transactions[0].get('id')
+ transaction_lines.extend(current_transactions)
+ else:
+ raise UserError(
+ _('Error during get transaction.\n\n%s \n\n %s') % (
+ response.status_code, response.text))
+ if latest_identifier:
+ self.ponto_last_identifier = latest_identifier
+ return transaction_lines
+
+ def _ponto_date_from_string(self, date_str):
+ return datetime.strptime(date_str, '%Y-%m-%dT%H:%M:%S.%fZ')
+
+ def _ponto_obtain_statement_data(self, date_since, date_until):
+ self.ensure_one()
+
+ account_ids = self._ponto_get_account_ids()
+ journal = self.journal_id
+
+ iban = self.account_number
+ account_id = account_ids.get(iban)
+ if not account_id:
+ raise UserError(
+ _('Ponto : wrong configuration, unknow account %s')
+ % journal.bank_account_id.acc_number)
+
+ self._ponto_synchronisation(account_id)
+
+ transaction_lines = self._ponto_get_transaction(account_id,
+ date_since,
+ date_until)
+
+ new_transactions = []
+ sequence = 0
+ for transaction in transaction_lines:
+ sequence += 1
+ attributes = transaction.get('attributes', {})
+ ref = '%s %s' % (
+ attributes.get('description'),
+ attributes.get('counterpartName'))
+ date = self._ponto_date_from_string(attributes.get('executionDate'))
+
+ vals_line = {
+ 'sequence': sequence,
+ 'date': date,
+ 'name': re.sub(' +', ' ', ref) or '/',
+ 'ref': attributes.get('remittanceInformation', ''),
+ 'unique_import_id': transaction['id'],
+ 'amount': attributes['amount'],
+ }
+ new_transactions.append(vals_line)
+
+ if new_transactions:
+ return new_transactions, {}
+ return
diff --git a/account_bank_statement_import_online_ponto/static/description/icon.png b/account_bank_statement_import_online_ponto/static/description/icon.png
new file mode 100644
index 0000000..3a0328b
Binary files /dev/null and b/account_bank_statement_import_online_ponto/static/description/icon.png differ
diff --git a/account_bank_statement_import_online_ponto/tests/__init__.py b/account_bank_statement_import_online_ponto/tests/__init__.py
new file mode 100644
index 0000000..8e1252e
--- /dev/null
+++ b/account_bank_statement_import_online_ponto/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_ponto
diff --git a/account_bank_statement_import_online_ponto/tests/test_account_bank_statement_import_online_ponto.py b/account_bank_statement_import_online_ponto/tests/test_account_bank_statement_import_online_ponto.py
new file mode 100644
index 0000000..8b4353b
--- /dev/null
+++ b/account_bank_statement_import_online_ponto/tests/test_account_bank_statement_import_online_ponto.py
@@ -0,0 +1,128 @@
+# Copyright 2020 Florent de Labarre
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+from datetime import datetime
+from unittest import mock
+
+from odoo import fields
+from odoo.tests import common
+
+_module_ns = 'odoo.addons.account_bank_statement_import_online_ponto'
+_provider_class = (
+ _module_ns
+ + '.models.online_bank_statement_provider_ponto'
+ + '.OnlineBankStatementProviderPonto'
+)
+
+
+class TestAccountBankAccountStatementImportOnlineQonto(
+ 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.ResPartnerBank = self.env['res.partner.bank']
+ self.OnlineBankStatementProvider = self.env[
+ 'online.bank.statement.provider'
+ ]
+ self.AccountBankStatement = self.env['account.bank.statement']
+ self.AccountBankStatementLine = self.env['account.bank.statement.line']
+
+ self.bank_account = self.ResPartnerBank.create(
+ {'acc_number': 'FR0214508000302245362775K46',
+ 'partner_id': self.env.user.company_id.partner_id.id})
+ 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': 'ponto',
+ 'bank_account_id': self.bank_account.id,
+ })
+ self.provider = self.journal.online_bank_statement_provider_id
+
+ self.mock_header = lambda: mock.patch(
+ _provider_class + '._ponto_header',
+ return_value={'Accept': 'application/json',
+ 'Authorization': 'Bearer --TOKEN--'},
+ )
+
+ self.mock_account_ids = lambda: mock.patch(
+ _provider_class + '._ponto_get_account_ids',
+ return_value={'FR0214508000302245362775K46': 'id'},
+ )
+ self.mock_synchronisation = lambda: mock.patch(
+ _provider_class + '._ponto_synchronisation',
+ return_value=None,
+ )
+
+ self.mock_transaction = lambda: mock.patch(
+ _provider_class + '._ponto_get_transaction',
+ return_value=[{
+ 'type': 'transaction',
+ 'relationships': {'account': {
+ 'links': {
+ 'related': 'https://api.myponto.com/accounts/'},
+ 'data': {'type': 'account',
+ 'id': 'fd3d5b1d-fca9-4310-a5c8-76f2a9dc7c75'}}},
+ 'id': '701ab965-21c4-46ca-b157-306c0646e0e2',
+ 'attributes': {'valueDate': '2019-11-18T00:00:00.000Z',
+ 'remittanceInformationType': 'unstructured',
+ 'remittanceInformation': 'Minima vitae totam!',
+ 'executionDate': '2019-11-20T00:00:00.000Z',
+ 'description': 'Wire transfer',
+ 'currency': 'EUR',
+ 'counterpartReference': 'BE26089479973169',
+ 'counterpartName': 'Osinski Group',
+ 'amount': 6.08}},
+ {'type': 'transaction',
+ 'relationships': {
+ 'account': {'links': {
+ 'related': 'https://api.myponto.com/accounts/'},
+ 'data': {
+ 'type': 'account',
+ 'id': 'fd3d5b1d-fca9-4310-a5c8-76f2a9dc7c75'}}},
+ 'id': '9ac50483-16dc-4a82-aa60-df56077405cd',
+ 'attributes': {
+ 'valueDate': '2019-11-04T00:00:00.000Z',
+ 'remittanceInformationType': 'unstructured',
+ 'remittanceInformation': 'Quia voluptatem blanditiis.',
+ 'executionDate': '2019-11-06T00:00:00.000Z',
+ 'description': 'Wire transfer',
+ 'currency': 'EUR',
+ 'counterpartReference': 'BE97201830401438',
+ 'counterpartName': 'Stokes-Miller',
+ 'amount': 5.48}},
+ {'type': 'transaction', 'relationships': {'account': {'links': {
+ 'related': 'https://api.myponto.com/accounts/'},
+ 'data': {
+ 'type': 'account',
+ 'id': 'fd3d5b1d-fca9-4310-a5c8-76f2a9dc7c75'}}},
+ 'id': 'b21a6c65-1c52-4ba6-8cbc-127d2b2d85ff',
+ 'attributes': {
+ 'valueDate': '2019-11-04T00:00:00.000Z',
+ 'remittanceInformationType': 'unstructured',
+ 'remittanceInformation': 'Laboriosam repelo?',
+ 'executionDate': '2019-11-04T00:00:00.000Z',
+ 'description': 'Wire transfer', 'currency': 'EUR',
+ 'counterpartReference': 'BE10325927501996',
+ 'counterpartName': 'Strosin-Veum', 'amount': 5.83}}],
+ )
+
+ def test_ponto(self):
+ with self.mock_transaction(), \
+ self.mock_header(),\
+ self.mock_synchronisation(), \
+ self.mock_account_ids():
+ lines, statement_values = self.provider._obtain_statement_data(
+ datetime(2019, 11, 3),
+ datetime(2019, 11, 17),
+ )
+
+ self.assertEqual(len(lines), 3)
diff --git a/account_bank_statement_import_online_ponto/view/online_bank_statement_provider.xml b/account_bank_statement_import_online_ponto/view/online_bank_statement_provider.xml
new file mode 100644
index 0000000..e856cf5
--- /dev/null
+++ b/account_bank_statement_import_online_ponto/view/online_bank_statement_provider.xml
@@ -0,0 +1,19 @@
+
+
+
+ online.bank.statement.provider.form
+ online.bank.statement.provider
+
+
+
+
+
+
+
+
+
+
+
+
+