diff --git a/account_statement_import_online_gocardless/README.rst b/account_statement_import_online_gocardless/README.rst new file mode 100644 index 0000000000..5f46cb2275 --- /dev/null +++ b/account_statement_import_online_gocardless/README.rst @@ -0,0 +1,145 @@ +================================== +Online Bank Statements: GoCardless +================================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:5523e4b06eaa38f0f74b6181f2043b93037f673d8a5038474c13dc323f1492ac + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/15.0/account_statement_import_online_gocardless + :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-15-0/bank-statement-import-15-0-account_statement_import_online_gocardless + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/bank-statement-import&target_branch=15.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module provides online bank statements from GoCardless Bank Account Data, +which provides a free API for connecting and getting transactions for bank +accounts. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +On the GoCardless website +~~~~~~~~~~~~~~~~~~~~~~~~~ + +#. Go to https://bankaccountdata.gocardless.com, and create or login into your + "GoCardLess Bank Account Data" account. +#. Go to Developers > User secrets option on the left. +#. Click on the "+ Create new" button on the bottom part. +#. Put a name to the user secret (eg. Odoo), and optionally limit it to certain + IPs using CIDR subnet notation. +#. Copy or download the secret ID and key for later use. The second one won't be + available anymore, so make sure you don't forget this step. + +On Odoo +~~~~~~~ + +To configure online bank statements provider: + +#. Add your user to the "Full Accounting Settings" group. +#. Go to *Invoicing > Configuration > Accounting > Journals*. +#. Select the journal representing your bank account (or create it). +#. The bank account number should be properly introduced. +#. Set *Bank Feeds* to *Online (OCA)*. +#. Select *GoCardless* as online bank statements provider in + *Online Bank Statements (OCA)* section. +#. Save the journal +#. Click on the created provider. +#. Put your secret ID and secret key on the existing fields. +#. Click on the button "Select Bank Account Identifier". + + .. image:: https://raw.githubusercontent.com/OCA/bank-statement-import/15.0/account_statement_import_online_gocardless/static/img/gocardless_configuration.gif + +#. A new window will appear for selecting the bank entity. + + .. image:: https://raw.githubusercontent.com/OCA/bank-statement-import/15.0/account_statement_import_online_gocardless/static/img/gocardless_bank_selection.gif + +#. Select it, and you will be redirected to the selected entity for introducing + your bank credentials to allow the connection. +#. If the process is completed, and the bank account linked to the journal is + accessible, you'll be again redirected to the online provider form, and + everything will be linked and ready to start the transaction pulling. A + message is logged about it on the chatter. +#. If not, an error message will be logged either in the chatter. + +Usage +===== + +To pull historical bank statements: + +#. Go to *Invoicing > Configuration > Accounting > Journals*. +#. Select the journal representing your bank account. +#. Launch *Actions > Online Bank Statements Pull Wizard* +#. Configure date interval and click on *Pull*. + +If historical data is not needed, then just simply wait for the scheduled +activity "Pull Online Bank Statements" to be executed for getting new +transactions. + +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 to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* ForgeFlow +* Tecnativa + +Contributors +~~~~~~~~~~~~ + +* `ForgeFlow `__: + + * Christopher Ormaza + * Jordi Ballester +* `Tecnativa `__: + + * Pedro M. Baeza + +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_statement_import_online_gocardless/__init__.py b/account_statement_import_online_gocardless/__init__.py new file mode 100644 index 0000000000..f4a659efb5 --- /dev/null +++ b/account_statement_import_online_gocardless/__init__.py @@ -0,0 +1,4 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import models +from . import controllers diff --git a/account_statement_import_online_gocardless/__manifest__.py b/account_statement_import_online_gocardless/__manifest__.py new file mode 100644 index 0000000000..b2885f0e68 --- /dev/null +++ b/account_statement_import_online_gocardless/__manifest__.py @@ -0,0 +1,30 @@ +# Copyright 2022 ForgeFlow S.L. +# Copyright 2023 Tecnativa - Pedro M. Baeza +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Online Bank Statements: GoCardless", + "version": "15.0.1.0.0", + "category": "Account", + "website": "https://github.com/OCA/bank-statement-import", + "author": "ForgeFlow, Tecnativa, Odoo Community Association (OCA)", + "license": "AGPL-3", + "installable": True, + "depends": [ + "account_statement_import_online", + ], + "data": ["view/online_bank_statement_provider.xml"], + "assets": { + "web.assets_backend": [ + "account_statement_import_online_gocardless/static/src/" + "lib/gocardless-ui/selector.css", + "account_statement_import_online_gocardless/static/src/" + "lib/gocardless-ui/selector.js", + "account_statement_import_online_gocardless/static/src/" + "js/select_bank_widget.js", + ], + "web.assets_qweb": [ + "account_statement_import_online_gocardless/static/src/xml" + "/select_bank_widget.xml" + ], + }, +} diff --git a/account_statement_import_online_gocardless/controllers/__init__.py b/account_statement_import_online_gocardless/controllers/__init__.py new file mode 100644 index 0000000000..12a7e529b6 --- /dev/null +++ b/account_statement_import_online_gocardless/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/account_statement_import_online_gocardless/controllers/main.py b/account_statement_import_online_gocardless/controllers/main.py new file mode 100644 index 0000000000..8333579610 --- /dev/null +++ b/account_statement_import_online_gocardless/controllers/main.py @@ -0,0 +1,35 @@ +# Copyright 2022 ForgeFlow S.L. +# Copyright 2023 Tecnativa - Pedro M. Baeza +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import werkzeug +from werkzeug.urls import url_encode + +from odoo import http +from odoo.http import request + + +class GocardlessController(http.Controller): + @http.route("/gocardless/response", type="http", auth="public", csrf=False) + def gocardless_response(self, **post): + Provider = request.env["online.bank.statement.provider"].sudo() + current_provider = Provider.search( + [("gocardless_requisition_ref", "=", post["ref"])] + ) + params = { + "action": request.env.ref( + "account_statement_import_online.online_bank_statement_provider_action" + ).id, + "model": "online.bank.statement.provider", + } + if current_provider: + current_provider._gocardless_finish_requisition() + params.update( + { + "view_type": "form", + "id": current_provider.id, + } + ) + else: + params["view_type"] = "list" + return werkzeug.utils.redirect("/web#" + url_encode(params), 303) diff --git a/account_statement_import_online_gocardless/models/__init__.py b/account_statement_import_online_gocardless/models/__init__.py new file mode 100644 index 0000000000..03e38c9993 --- /dev/null +++ b/account_statement_import_online_gocardless/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 diff --git a/account_statement_import_online_gocardless/models/online_bank_statement_provider.py b/account_statement_import_online_gocardless/models/online_bank_statement_provider.py new file mode 100644 index 0000000000..eb3aef2208 --- /dev/null +++ b/account_statement_import_online_gocardless/models/online_bank_statement_provider.py @@ -0,0 +1,348 @@ +# Copyright 2022 ForgeFlow S.L. +# Copyright 2023 Tecnativa - Pedro M. Baeza +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import json +from datetime import datetime +from uuid import uuid4 + +import requests +from dateutil.relativedelta import relativedelta +from werkzeug.urls import url_join + +from odoo import _, api, fields, models +from odoo.exceptions import UserError +from odoo.tools import DEFAULT_SERVER_DATE_FORMAT as DF + +GOCARDLESS_ENDPOINT = "https://bankaccountdata.gocardless.com/api/v2" + + +class OnlineBankStatementProvider(models.Model): + _inherit = "online.bank.statement.provider" + + gocardless_token = fields.Char(readonly=True) + gocardless_token_expiration = fields.Datetime(readonly=True) + gocardless_refresh_token = fields.Char(readonly=True) + gocardless_refresh_expiration = fields.Datetime(readonly=True) + gocardless_requisition_ref = fields.Char(readonly=True) + gocardless_requisition_id = fields.Char(readonly=True) + gocardless_requisition_expiration = fields.Datetime(readonly=True) + gocardless_institution_id = fields.Char() + gocardless_account_id = fields.Char() + + def gocardless_reset_requisition(self): + self.write( + { + "gocardless_requisition_id": False, + "gocardless_requisition_ref": False, + "gocardless_requisition_expiration": False, + } + ) + + @api.model + def _get_available_services(self): + """Include the new service GoCardless in the online providers.""" + return super()._get_available_services() + [ + ("gocardless", "GoCardless"), + ] + + def _gocardless_get_token(self): + """Resolve and return the corresponding GoCardless token for doing the requests. + If there's still no token, it's requested. If it exists, but it's expired and + the refresh token isn't, a refresh is requested. + """ + self.ensure_one() + now = fields.Datetime.now() + if not self.gocardless_token or now > self.gocardless_token_expiration: + # Refresh token + if ( + self.gocardless_refresh_token + and now > self.gocardless_refresh_expiration + ): + url = f"{GOCARDLESS_ENDPOINT}/token/refresh/" + else: + url = f"{GOCARDLESS_ENDPOINT}/token/new/" + response = requests.post( + url, + data=json.dumps( + {"secret_id": self.username, "secret_key": self.password} + ), + headers=self._gocardless_get_headers(basic=True), + ) + data = {} + if response.status_code == 200: + data = json.loads(response.text) + expiration_date = now + relativedelta(seconds=data.get("access_expires", 0)) + vals = { + "gocardless_token": data.get("access", False), + "gocardless_token_expiration": expiration_date, + } + if data.get("refresh"): + vals["gocardless_refresh_token"] = data["refresh"] + vals["gocardless_refresh_expiration"] = now + relativedelta( + seconds=data["refresh_expires"] + ) + self.sudo().write(vals) + return self.gocardless_token + + def _gocardless_get_headers(self, basic=False): + """Generic method for providing the needed request headers.""" + self.ensure_one() + headers = { + "accept": "application/json", + "Content-Type": "application/json", + } + if not basic: + headers["Authorization"] = f"Bearer {self._gocardless_get_token()}" + return headers + + def action_select_gocardless_bank(self): + if not self.journal_id.bank_account_id: + raise UserError( + _("To continue configure bank account on journal %s") + % (self.journal_id.display_name) + ) + country = self.journal_id.bank_account_id.company_id.country_id + response = requests.get( + f"{GOCARDLESS_ENDPOINT}/institutions/", + params={"country": country.code}, + headers=self._gocardless_get_headers(), + ) + if response.status_code == 400: + raise UserError(_("Incorrect country code or country not supported.")) + institutions = json.loads(response.text) + # Prepare data for being showed in the JS widget + ctx = self.env.context.copy() + ctx.update( + { + "dialog_size": "medium", + "country": country.code, + "country_name": country.name, + "provider_id": self.id, + "institutions": institutions, + "country_names": [{"code": country.code, "name": country.name}], + } + ) + return { + "type": "ir.actions.client", + "tag": "online_sync_institution_selector_gocardless", + "name": _("Select Bank of your Account"), + "params": {}, + "target": "new", + "context": ctx, + } + + def action_check_gocardless_agreement(self): + self.ensure_one() + self.gocardless_requisition_ref = str(uuid4()) + base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url") + redirect_url = url_join(base_url, "gocardless/response") + response = requests.post( + f"{GOCARDLESS_ENDPOINT}/requisitions/", + data=json.dumps( + { + "redirect": redirect_url, + "institution_id": self.gocardless_institution_id, + "reference": self.gocardless_requisition_ref, + } + ), + headers=self._gocardless_get_headers(), + ) + if response.status_code == 201: + requisition_data = json.loads(response.text) + self.gocardless_requisition_id = requisition_data["id"] + # JS code expects here to return a plain link or nothing + return requisition_data["link"] + + def _gocardless_finish_requisition(self): + """Once the requisiton to the bank institution has been made, and this is called + from the controller assigned to the redirect URL, we check that the IBAN account + of the linked journal is included in the accessible bank accounts, and if so, + we set the rest of the needed data. + + A message in the chatter is logged both for sucessful or failed operation. + """ + self.ensure_one() + requisition_response = requests.get( + f"{GOCARDLESS_ENDPOINT}/requisitions/{self.gocardless_requisition_id}/", + headers=self._gocardless_get_headers(), + ) + requisition_data = json.loads(requisition_response.text) + accounts = requisition_data.get("accounts", []) + found_account = False + accounts_iban = [] + for account_id in accounts: + account_response = requests.get( + f"{GOCARDLESS_ENDPOINT}/accounts/{account_id}/", + headers=self._gocardless_get_headers(), + ) + if account_response.status_code == 200: + account_data = json.loads(account_response.text) + accounts_iban.append(account_data["iban"]) + if ( + self.journal_id.bank_account_id.sanitized_acc_number + == account_data["iban"] + ): + found_account = True + self.gocardless_account_id = account_data["id"] + break + if found_account: + agreement_response = requests.get( + f"{GOCARDLESS_ENDPOINT}/agreements/enduser/" + f"{requisition_data['agreement']}/", + headers=self._gocardless_get_headers(), + ) + agreement_data = json.loads(agreement_response.text) + self.gocardless_requisition_expiration = datetime.strptime( + agreement_data["accepted"], "%Y-%m-%dT%H:%M:%S.%fZ" + ) + relativedelta(days=agreement_data["access_valid_for_days"]) + self.sudo().message_post( + body=_("Your account number %(iban_number)s is successfully attached.") + % {"iban_number": self.journal_id.bank_account_id.display_name} + ) + else: + self.sudo().write( + { + "gocardless_requisition_expiration": False, + "gocardless_requisition_id": False, + "gocardless_requisition_ref": False, + } + ) + self.sudo().message_post( + body=_( + "Your account number %(iban_number)s it's not in the IBAN " + "account numbers found %(accounts_iban)s, please check" + ) + % { + "iban_number": self.journal_id.bank_account_id.display_name, + "accounts_iban": " / ".join(accounts_iban), + } + ) + + def _obtain_statement_data(self, date_since, date_until): + """Generic online cron overrided for acting when the sync is for GoCardless.""" + self.ensure_one() + if self.service == "gocardless": + return self._gocardless_obtain_statement_data(date_since, date_until) + return super()._obtain_statement_data(date_since, date_until) + + def _gocardless_request_transactions(self, date_since, date_until): + """Method for requesting GoCardless transactions. + Isolated for being mocked in tests. + """ + transaction_response = requests.get( + f"{GOCARDLESS_ENDPOINT}/accounts/" + f"{self.gocardless_account_id}/transactions/", + params={ + "date_from": date_since.strftime(DF), + "date_to": date_until.strftime(DF), + }, + headers=self._gocardless_get_headers(), + ) + if transaction_response.status_code == 200: + return json.loads(transaction_response.text) + return {} + + def _gocardless_obtain_statement_data(self, date_since, date_until): + """Called from the cron or the manual pull wizard to obtain transactions for + the given period. + """ + self.ensure_one() + if not self.gocardless_account_id: + return + currency_model = self.env["res.currency"] + if self.gocardless_requisition_expiration <= fields.Datetime.now(): + self.sudo().message_post( + body=_( + "You should renew the authorization process with your bank " + "institution for GoCardless." + ) + ) + return [], {} + transactions = self._gocardless_request_transactions(date_since, date_until) + res = [] + sequence = 0 + currencies_cache = {} + for tr in transactions.get("transactions", {}).get("booked", []): + # Reference: https://developer.gocardless.com/bank-account-data/transactions + string_date = tr.get("bookingDate") or tr.get("valueDate") + # CHECK ME: if there's not date string, is transaction still valid? + if not string_date: + continue + current_date = fields.Date.from_string(string_date) + sequence += 1 + amount = float(tr.get("transactionAmount", {}).get("amount", 0.0)) + currency_code = tr.get("transactionAmount", {}).get( + "currency", self.journal_id.currency_id.name + ) + currency = currencies_cache.get(currency_code) + if not currency: + currency = currency_model.search([("name", "=", currency_code)]) + currencies_cache[currency_code] = currency + amount_currency = amount + if ( + currency + and self.journal_id.currency_id + and currency != self.journal_id.currency_id + ): + amount_currency = currency._convert( + amount, + self.journal_id.currency_id, + self.journal_id.company_id, + current_date, + ) + if amount_currency >= 0: + partner_name = tr.get("debtorName", False) + else: + partner_name = tr.get("creditorName", False) + account_number = "" + if tr.get("debtorAccount", {}): + account_number = tr.get("debtorAccount", {}).get("iban") + if tr.get("creditorAccount", {}): + account_number = tr.get("creditorAccount", {}).get("iban") + res.append( + { + "sequence": sequence, + "date": current_date, + "ref": partner_name or "/", + "payment_ref": tr.get( + "remittanceInformationUnstructured", partner_name + ), + "unique_import_id": tr.get("entryReference", False) + or tr.get("transactionId", False), + "amount": amount_currency, + "account_number": account_number, + "partner_name": partner_name, + "transaction_type": tr.get("bankTransactionCode", ""), + "narration": self._gocardless_get_note(tr), + } + ) + return res, {} + + def _gocardless_get_note(self, tr): + """Override to get different notes.""" + note_elements = [ + "additionalInformation", + "balanceAfterTransaction", + "bankTransactionCode", + "bookingDate", + "checkId", + "creditorAccount", + "creditorAgent", + "creditorId", + "creditorName", + "currencyExchange", + "debtorAccount", + "debtorAgent", + "debtorName", + "entryReference", + "mandateId", + "proprietaryBank", + "remittanceInformation Unstructured", + "transactionAmount", + "transactionId", + "ultimateCreditor", + "ultimateDebtor", + "valueDate", + ] + notes = [str(tr[element]) for element in note_elements if tr.get(element)] + return "\n".join(notes) diff --git a/account_statement_import_online_gocardless/readme/CONFIGURE.rst b/account_statement_import_online_gocardless/readme/CONFIGURE.rst new file mode 100644 index 0000000000..174499fcbb --- /dev/null +++ b/account_statement_import_online_gocardless/readme/CONFIGURE.rst @@ -0,0 +1,42 @@ +On the GoCardless website +~~~~~~~~~~~~~~~~~~~~~~~~~ + +#. Go to https://bankaccountdata.gocardless.com, and create or login into your + "GoCardLess Bank Account Data" account. +#. Go to Developers > User secrets option on the left. +#. Click on the "+ Create new" button on the bottom part. +#. Put a name to the user secret (eg. Odoo), and optionally limit it to certain + IPs using CIDR subnet notation. +#. Copy or download the secret ID and key for later use. The second one won't be + available anymore, so make sure you don't forget this step. + +On Odoo +~~~~~~~ + +To configure online bank statements provider: + +#. Add your user to the "Full Accounting Settings" group. +#. Go to *Invoicing > Configuration > Accounting > Journals*. +#. Select the journal representing your bank account (or create it). +#. The bank account number should be properly introduced. +#. Set *Bank Feeds* to *Online (OCA)*. +#. Select *GoCardless* as online bank statements provider in + *Online Bank Statements (OCA)* section. +#. Save the journal +#. Click on the created provider. +#. Put your secret ID and secret key on the existing fields. +#. Click on the button "Select Bank Account Identifier". + + .. image:: ../static/img/gocardless_configuration.gif + +#. A new window will appear for selecting the bank entity. + + .. image:: ../static/img/gocardless_bank_selection.gif + +#. Select it, and you will be redirected to the selected entity for introducing + your bank credentials to allow the connection. +#. If the process is completed, and the bank account linked to the journal is + accessible, you'll be again redirected to the online provider form, and + everything will be linked and ready to start the transaction pulling. A + message is logged about it on the chatter. +#. If not, an error message will be logged either in the chatter. diff --git a/account_statement_import_online_gocardless/readme/CONTRIBUTORS.rst b/account_statement_import_online_gocardless/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..4165b62791 --- /dev/null +++ b/account_statement_import_online_gocardless/readme/CONTRIBUTORS.rst @@ -0,0 +1,7 @@ +* `ForgeFlow `__: + + * Christopher Ormaza + * Jordi Ballester +* `Tecnativa `__: + + * Pedro M. Baeza diff --git a/account_statement_import_online_gocardless/readme/DESCRIPTION.rst b/account_statement_import_online_gocardless/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..2572d99a28 --- /dev/null +++ b/account_statement_import_online_gocardless/readme/DESCRIPTION.rst @@ -0,0 +1,3 @@ +This module provides online bank statements from GoCardless Bank Account Data, +which provides a free API for connecting and getting transactions for bank +accounts. diff --git a/account_statement_import_online_gocardless/readme/USAGE.rst b/account_statement_import_online_gocardless/readme/USAGE.rst new file mode 100644 index 0000000000..485621b019 --- /dev/null +++ b/account_statement_import_online_gocardless/readme/USAGE.rst @@ -0,0 +1,10 @@ +To pull historical bank statements: + +#. Go to *Invoicing > Configuration > Accounting > Journals*. +#. Select the journal representing your bank account. +#. Launch *Actions > Online Bank Statements Pull Wizard* +#. Configure date interval and click on *Pull*. + +If historical data is not needed, then just simply wait for the scheduled +activity "Pull Online Bank Statements" to be executed for getting new +transactions. diff --git a/account_statement_import_online_gocardless/static/description/icon.png b/account_statement_import_online_gocardless/static/description/icon.png new file mode 100644 index 0000000000..2efb75c0d4 Binary files /dev/null and b/account_statement_import_online_gocardless/static/description/icon.png differ diff --git a/account_statement_import_online_gocardless/static/description/index.html b/account_statement_import_online_gocardless/static/description/index.html new file mode 100644 index 0000000000..c48a3dac30 --- /dev/null +++ b/account_statement_import_online_gocardless/static/description/index.html @@ -0,0 +1,508 @@ + + + + + + +Online Bank Statements: GoCardless + + + +
+

Online Bank Statements: GoCardless

+ + +

Beta License: AGPL-3 OCA/bank-statement-import Translate me on Weblate Try me on Runboat

+

This module provides online bank statements from GoCardless Bank Account Data, +which provides a free API for connecting and getting transactions for bank +accounts.

+

Table of contents

+ +
+

Configuration

+
+

On the GoCardless website

+
    +
  1. Go to https://bankaccountdata.gocardless.com, and create or login into your +“GoCardLess Bank Account Data” account.
  2. +
  3. Go to Developers > User secrets option on the left.
  4. +
  5. Click on the “+ Create new” button on the bottom part.
  6. +
  7. Put a name to the user secret (eg. Odoo), and optionally limit it to certain +IPs using CIDR subnet notation.
  8. +
  9. Copy or download the secret ID and key for later use. The second one won’t be +available anymore, so make sure you don’t forget this step.
  10. +
+
+
+

On Odoo

+

To configure online bank statements provider:

+
    +
  1. Add your user to the “Full Accounting Settings” group.

    +
  2. +
  3. Go to Invoicing > Configuration > Accounting > Journals.

    +
  4. +
  5. Select the journal representing your bank account (or create it).

    +
  6. +
  7. The bank account number should be properly introduced.

    +
  8. +
  9. Set Bank Feeds to Online (OCA).

    +
  10. +
  11. Select GoCardless as online bank statements provider in +Online Bank Statements (OCA) section.

    +
  12. +
  13. Save the journal

    +
  14. +
  15. Click on the created provider.

    +
  16. +
  17. Put your secret ID and secret key on the existing fields.

    +
  18. +
  19. Click on the button “Select Bank Account Identifier”.

    +https://raw.githubusercontent.com/OCA/bank-statement-import/15.0/account_statement_import_online_gocardless/static/img/gocardless_configuration.gif +
  20. +
  21. A new window will appear for selecting the bank entity.

    +https://raw.githubusercontent.com/OCA/bank-statement-import/15.0/account_statement_import_online_gocardless/static/img/gocardless_bank_selection.gif +
  22. +
  23. Select it, and you will be redirected to the selected entity for introducing +your bank credentials to allow the connection.

    +
  24. +
  25. If the process is completed, and the bank account linked to the journal is +accessible, you’ll be again redirected to the online provider form, and +everything will be linked and ready to start the transaction pulling. A +message is logged about it on the chatter.

    +
  26. +
  27. If not, an error message will be logged either in the chatter.

    +
  28. +
+
+
+
+

Usage

+

To pull historical bank statements:

+
    +
  1. Go to Invoicing > Configuration > Accounting > Journals.
  2. +
  3. Select the journal representing your bank account.
  4. +
  5. Launch Actions > Online Bank Statements Pull Wizard
  6. +
  7. Configure date interval and click on Pull.
  8. +
+

If historical data is not needed, then just simply wait for the scheduled +activity “Pull Online Bank Statements” to be executed for getting new +transactions.

+
+
+

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 to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • ForgeFlow
  • +
  • Tecnativa
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

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_statement_import_online_gocardless/static/img/gocardless_bank_selection.png b/account_statement_import_online_gocardless/static/img/gocardless_bank_selection.png new file mode 100644 index 0000000000..a25b4ca4c4 Binary files /dev/null and b/account_statement_import_online_gocardless/static/img/gocardless_bank_selection.png differ diff --git a/account_statement_import_online_gocardless/static/img/gocardless_configuration.png b/account_statement_import_online_gocardless/static/img/gocardless_configuration.png new file mode 100644 index 0000000000..5cea739133 Binary files /dev/null and b/account_statement_import_online_gocardless/static/img/gocardless_configuration.png differ diff --git a/account_statement_import_online_gocardless/static/src/js/select_bank_widget.js b/account_statement_import_online_gocardless/static/src/js/select_bank_widget.js new file mode 100644 index 0000000000..f257ce4f92 --- /dev/null +++ b/account_statement_import_online_gocardless/static/src/js/select_bank_widget.js @@ -0,0 +1,121 @@ +odoo.define( + "account_bank_statement_import_online_gocardless.acc_config_widget_gocardless", + function (require) { + "use strict"; + + require("web.dom_ready"); + var core = require("web.core"); + var AbstractAction = require("web.AbstractAction"); + var QWeb = core.qweb; + var framework = require("web.framework"); + + var OnlineSyncAccountInstitutionSelector = AbstractAction.extend({ + template: "OnlineSyncSearchBankGoCardless", + init: function (parent, action, options) { + this._super(parent, action, options); + this.context = action.context; + this.results = action.context.institutions; + this.country_names = action.context.country_names; + this.country_selected = action.context.country; + }, + + start: function () { + const self = this; + const $selectCountries = this.$el.find(".country_select"); + const $countryOptions = $( + QWeb.render("OnlineSyncSearchBankGoCardlessCountries", { + country_names: this.country_names, + }) + ); + $countryOptions.appendTo($selectCountries); + if ( + $selectCountries.find("option[value=" + this.country_selected + "]") + .length !== 0 + ) { + $selectCountries.val(this.country_selected); + $selectCountries.change(); + } + $selectCountries.change(function () { + self.country_selected = this.selectedOptions[0].value; + return self.renderSearchResult(); + }); + this.displayState(); + self.$el.find("#bank_search_input").on("keyup", function () { + const input = $(".institution-search-input"); + const filter = input[0].value.toUpperCase(); + const institutionList = $(".list-institution"); + + for (let i = 0; i < institutionList.length; i++) { + const txtValue = institutionList[i].textContent; + if (txtValue.toUpperCase().indexOf(filter) > -1) { + institutionList[i].style.display = ""; + } else { + institutionList[i].style.display = "none"; + } + } + }); + }, + + displayState: function () { + if (this.results.length > 0) { + this.renderSearchResult(); + } + }, + + renderElement: function () { + this._super.apply(this, arguments); + }, + + renderSearchResult: function () { + var self = this; + this.$(".institution-container").html(""); + const filteredInstitutions = this.results.filter(function ( + institution + ) { + return institution.countries.includes(self.country_selected); + }); + var $searchResults = $( + QWeb.render("OnlineSyncSearchBankGoCardlessList", { + institutions: filteredInstitutions, + }) + ); + $searchResults.find("a").click(function () { + framework.blockUI(); + const id = this.getAttribute("data-institution"); + if (id) { + return self + ._rpc({ + model: "online.bank.statement.provider", + method: "write", + args: [ + [self.context.provider_id], + {gocardless_institution_id: id}, + ], + }) + .then(function () { + return self + ._rpc({ + model: "online.bank.statement.provider", + method: "action_check_gocardless_agreement", + args: [[self.context.active_id]], + }) + .then(function (redirect_url) { + if (redirect_url !== undefined) { + window.location.replace(redirect_url); + } + }); + }); + } + }); + $searchResults.appendTo(self.$(".institution-container")); + }, + }); + core.action_registry.add( + "online_sync_institution_selector_gocardless", + OnlineSyncAccountInstitutionSelector + ); + return { + OnlineSyncAccountInstitutionSelector: OnlineSyncAccountInstitutionSelector, + }; + } +); diff --git a/account_statement_import_online_gocardless/static/src/lib/gocardless-ui/fonts/HKGrotesk-Bold.ttf b/account_statement_import_online_gocardless/static/src/lib/gocardless-ui/fonts/HKGrotesk-Bold.ttf new file mode 100644 index 0000000000..8645d73442 Binary files /dev/null and b/account_statement_import_online_gocardless/static/src/lib/gocardless-ui/fonts/HKGrotesk-Bold.ttf differ diff --git a/account_statement_import_online_gocardless/static/src/lib/gocardless-ui/fonts/HKGrotesk-SemiBold.ttf b/account_statement_import_online_gocardless/static/src/lib/gocardless-ui/fonts/HKGrotesk-SemiBold.ttf new file mode 100644 index 0000000000..d6a0bf4a4f Binary files /dev/null and b/account_statement_import_online_gocardless/static/src/lib/gocardless-ui/fonts/HKGrotesk-SemiBold.ttf differ diff --git a/account_statement_import_online_gocardless/static/src/lib/gocardless-ui/selector.css b/account_statement_import_online_gocardless/static/src/lib/gocardless-ui/selector.css new file mode 100644 index 0000000000..15e0ffa5dc --- /dev/null +++ b/account_statement_import_online_gocardless/static/src/lib/gocardless-ui/selector.css @@ -0,0 +1,209 @@ + +* { + box-sizing: border-box; +} + +@font-face { + font-family: "HK Grotesk"; + src: url('fonts/HKGrotesk-Bold.ttf') format("truetype"); +} + +.institution-content-wrapper { + margin: 0 auto; + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 2rem; +} + +.institution-search-bx-body { + max-height: 52vh; + overflow-y: auto; +} + + +.institution-search-bx-body::-webkit-scrollbar-thumb { + background: #AEB0B0; + border-radius: 7px; +} + +.institution-search-bx-body::-webkit-scrollbar { + width: 6px; + background-color: #F1F1F1; + border-radius: 7px; +} + +#institution-modal-content { + width: 100%; + max-width: 40rem; + background-color: #fff; + padding: 4.5rem; + display: flex; + flex-direction: column; + box-shadow: 0px 1px 3px #00000029; + border-radius: 14px; + color: #1B2021; +} + +.institution-modal-header { + text-align: right; +} + +.institution-modal-header h2 { + text-align: left; + font-size: 2.5rem; + margin: 0; +} + +.institution-modal-footer { + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + padding: 2rem; +} + +.institution-modal-footer > a { + line-height: 2.8rem; + text-decoration: none; + color: #808080; +} + +.institution-modal-footer > a:hover { + text-decoration: underline; +} + +.institution-modal-close { + color: #8D9090; + font-size: 2.6rem; + font-weight: bold; + cursor: pointer; + position: relative; + top: -20px; +} + +.institution-search-container { + position: relative; + bottom: 2.2rem; +} + +.institution-search-container input { + text-indent: 2.8rem; +} + +.institution-search-icon { + position: relative; + padding: 1rem; + height: 3.8rem; + top: 3.8rem; +} + +.institution-search-input { + width: 100%; + padding: 1rem; + border-radius: 9px; + color: #8C8E8F; + border: 1px solid #8c8e8f; + font-size: 1.6rem; + font-weight: 400; + outline-color: #70b1f7; +} + + +.institution-container { + display: flex; + flex-direction: column; +} + +.list-institution { + padding: 14px 0; + border-bottom: 1px solid #D7D8D8; +} + +.list-institution:last-child { + border: none; +} + +.list-institution a { + padding-right: 2.5rem; + display: flex; + flex-direction: row; + align-items: center; + text-decoration: none; +} + + +.list-institution span { + margin-left: 1.5rem; + font-size: 1.5rem; + font-weight: 600; + color: #1B2021; +} + +.list-institution:hover { + background-color: #F1F1F1; +} + +img.institution-logo { + width: 100%; + max-width: 3.5rem; + height: 3.5rem; + border-radius: 6px; +} + +.institution-company-logo { + max-width: 13rem; + height: 10rem; + margin: 2.5rem 0 7rem; +} + +@media screen and (max-width: 640px) { + + html { + font-size: 8px; + } + + #institution-modal-content { + height: 100%; + max-width: 100%; + border-radius: 0; + } + + #institution-modal-content, + .institution-company-logo { + margin: 0; + } + + .institution-company-logo { + position: absolute; + bottom: 0; + padding: 2.5rem; + } + + .institution-modal-close { + display: none; + } + + + .institution-modal-header { + margin-top: 3rem; + } + + .institution-modal-header h2 { + font-size: 3rem; + } + + .list-institution span { + font-size: 1.9rem; + } +} + +@media screen and (device-height: 568px) and (orientation: portrait) and (-webkit-min-device-pixel-ratio: 2) { + + html { + font-size: 7px; + } + +} diff --git a/account_statement_import_online_gocardless/static/src/xml/select_bank_widget.xml b/account_statement_import_online_gocardless/static/src/xml/select_bank_widget.xml new file mode 100644 index 0000000000..d2b4b978c5 --- /dev/null +++ b/account_statement_import_online_gocardless/static/src/xml/select_bank_widget.xml @@ -0,0 +1,58 @@ + + + +
+
+
+
+ +
+ + +
+
+
+
+
+
+
+ + + + + + + + + + + + + + + +
diff --git a/account_statement_import_online_gocardless/tests/__init__.py b/account_statement_import_online_gocardless/tests/__init__.py new file mode 100644 index 0000000000..bf7367ab4d --- /dev/null +++ b/account_statement_import_online_gocardless/tests/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import test_account_statement_import_online_gocardless diff --git a/account_statement_import_online_gocardless/tests/test_account_statement_import_online_gocardless.py b/account_statement_import_online_gocardless/tests/test_account_statement_import_online_gocardless.py new file mode 100644 index 0000000000..9c0712c218 --- /dev/null +++ b/account_statement_import_online_gocardless/tests/test_account_statement_import_online_gocardless.py @@ -0,0 +1,103 @@ +# Copyright 2023 Tecnativa - Pedro M.Baeza +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from unittest import mock + +from dateutil.relativedelta import relativedelta + +from odoo import fields +from odoo.tests import common + +_module_ns = "odoo.addons.account_statement_import_online_gocardless" +_provider_class = ( + _module_ns + ".models.online_bank_statement_provider.OnlineBankStatementProvider" +) + + +class TestAccountBankAccountStatementImportOnlineGocardless(common.TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.now = fields.Datetime.now() + cls.currency_eur = cls.env.ref("base.EUR") + cls.currency_eur.write({"active": True}) + cls.journal = cls.env["account.journal"].create( + { + "name": "GoCardless Bank Test", + "type": "bank", + "code": "GCB", + "currency_id": cls.currency_eur.id, + "bank_statements_source": "online", + "online_bank_statement_provider": "gocardless", + } + ) + cls.provider = cls.journal.online_bank_statement_provider_id + cls.provider.write( + { + "statement_creation_mode": "monthly", + "gocardless_account_id": "SANDBOXFINANCE_SFIN0000", + "gocardless_requisition_expiration": cls.now + relativedelta(days=30), + } + ) + cls.return_value = { # GoCardless sample return + "transactions": { + "booked": [ + { + "transactionId": "2020103000624289-1", + "debtorName": "MON MOTHMA", + "debtorAccount": {"iban": "GL53SAFI055151515"}, + "transactionAmount": {"currency": "EUR", "amount": "45.00"}, + "bookingDate": "2020-10-30", + "valueDate": "2020-10-30", + "remittanceInformationUnstructured": ( + "For the support of Restoration of the Republic foundation" + ), + }, + { + "transactionId": "2020111101899195-1", + "transactionAmount": {"currency": "EUR", "amount": "-15.00"}, + "bankTransactionCode": "PMNT", + "bookingDate": "2020-11-11", + "valueDate": "2020-11-11", + "remittanceInformationUnstructured": "PAYMENT Alderaan Coffe", + }, + ], + "pending": [ + { + "transactionAmount": {"currency": "EUR", "amount": "-10.00"}, + "valueDate": "2020-11-03", + "remittanceInformationUnstructured": ( + "Reserved PAYMENT Emperor's Burgers" + ), + } + ], + } + } + cls.mock_transaction = lambda cls: mock.patch( + _provider_class + "._gocardless_request_transactions", + return_value=cls.return_value, + ) + + def test_mocked_gocardless(self): + vals = { + "provider_ids": self.provider.ids, + "date_since": "2020-10-30", + "date_until": "2020-11-11", + } + wizard = ( + self.env["online.bank.statement.pull.wizard"] + .with_context( + active_model="account.journal", + active_id=self.journal.id, + ) + .create(vals) + ) + with self.mock_transaction(): + wizard.action_pull() + statements = self.env["account.bank.statement"].search( + [("journal_id", "=", self.journal.id)] + ) + self.assertEqual(len(statements), 2) + lines = statements.line_ids.sorted(lambda x: x.date) + self.assertEqual(len(lines), 2) + self.assertEqual(lines.mapped("amount"), [45.0, -15.0]) diff --git a/account_statement_import_online_gocardless/view/online_bank_statement_provider.xml b/account_statement_import_online_gocardless/view/online_bank_statement_provider.xml new file mode 100644 index 0000000000..2fe66c79d8 --- /dev/null +++ b/account_statement_import_online_gocardless/view/online_bank_statement_provider.xml @@ -0,0 +1,48 @@ + + + + online.bank.statement.provider.form + online.bank.statement.provider + + + + + + + + + + +