From 527f12f53a79040317179a20a011ca56332cb1c5 Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Tue, 19 May 2026 10:13:13 -0400 Subject: [PATCH] [19.0][ADD] account_statement_import_online_mercury Mercury Bank statement import provider for the OCA bank-statement-import framework. Authored by Ledo Enterprises for Odoo 19.0. Originally prototyped on a private 18.0 branch (ledoent/18.0-add-mercury) and ported to 19.0 conventions (self.env._ lazy translation, current account.account / online_bank_statement_provider APIs) before this first upstream submission. --- .../README.rst | 160 ++++++ .../__init__.py | 3 + .../__manifest__.py | 15 + .../models/__init__.py | 3 + .../online_bank_statement_provider_mercury.py | 220 ++++++++ .../pyproject.toml | 3 + .../readme/CONFIGURE.md | 23 + .../readme/CONTRIBUTORS.md | 1 + .../readme/DESCRIPTION.md | 16 + .../readme/ROADMAP.md | 4 + .../readme/USAGE.md | 11 + .../static/description/index.html | 516 ++++++++++++++++++ .../tests/__init__.py | 3 + .../tests/test_mercury_provider.py | 375 +++++++++++++ .../online_bank_statement_provider_views.xml | 32 ++ 15 files changed, 1385 insertions(+) create mode 100644 account_statement_import_online_mercury/README.rst create mode 100644 account_statement_import_online_mercury/__init__.py create mode 100644 account_statement_import_online_mercury/__manifest__.py create mode 100644 account_statement_import_online_mercury/models/__init__.py create mode 100644 account_statement_import_online_mercury/models/online_bank_statement_provider_mercury.py create mode 100644 account_statement_import_online_mercury/pyproject.toml create mode 100644 account_statement_import_online_mercury/readme/CONFIGURE.md create mode 100644 account_statement_import_online_mercury/readme/CONTRIBUTORS.md create mode 100644 account_statement_import_online_mercury/readme/DESCRIPTION.md create mode 100644 account_statement_import_online_mercury/readme/ROADMAP.md create mode 100644 account_statement_import_online_mercury/readme/USAGE.md create mode 100644 account_statement_import_online_mercury/static/description/index.html create mode 100644 account_statement_import_online_mercury/tests/__init__.py create mode 100644 account_statement_import_online_mercury/tests/test_mercury_provider.py create mode 100644 account_statement_import_online_mercury/views/online_bank_statement_provider_views.xml diff --git a/account_statement_import_online_mercury/README.rst b/account_statement_import_online_mercury/README.rst new file mode 100644 index 0000000000..aca1557c7d --- /dev/null +++ b/account_statement_import_online_mercury/README.rst @@ -0,0 +1,160 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +=============================== +Online Bank Statements: Mercury +=============================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:03353adcf5fa8d9d385f92374f21471110044861612d1182fa6eee29a7dde09c + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/license-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/19.0/account_statement_import_online_mercury + :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-19-0/bank-statement-import-19-0-account_statement_import_online_mercury + :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=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Connects Odoo to `Mercury `__ using the `Mercury +REST API `__ to +automatically pull bank statement lines into your accounting journals. + +**Mercury** is a US business banking platform (checking, savings, +treasury) built for startups and small businesses. It offers a public +REST API at ``https://api.mercury.com/api/v1`` with Bearer token +authentication. This module uses the following endpoints: + +- ``GET /accounts`` — list accounts (used for auto-detection) +- ``GET /account/{id}/transactions`` — fetch transactions with date + filtering and cursor-based pagination + +A **read-only API key** is sufficient for statement import. Read-only +keys have no IP allowlist requirement. Write-access keys (needed for the +future Mercury Plus invoicing feature) require IP allowlisting in +*Mercury → Settings → API Keys*. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Generate a Mercury API Key +-------------------------- + +1. Log in to `Mercury `__ and go to **Settings + → API Keys**. +2. Click **Create API Key**, choose **Read-only** scope, and copy the + key. The key is shown once; store it securely. + +.. + + Read-only keys have no IP allowlist requirement. Write-access keys + require IP allowlisting and are only needed for future + invoicing/payment features. + +Configure Odoo +-------------- + +1. Go to **Accounting → Configuration → Journals** and open or create a + bank journal. +2. In the **Online Synchronization** section, select **Mercury** as the + service. +3. Paste your **API Key**. You may paste the bare token or the full + ``secret-token:…`` form — both are accepted. +4. Optionally paste your **Mercury Account ID** — the UUID in the + Mercury dashboard URL (``https://app.mercury.com/accounts/``). + Leave blank to auto-detect the first account on the key. If you have + multiple accounts under one API key (e.g. checking and savings), set + this field to pull from a specific account. +5. Enable **Include Pending Transactions** if you want to see unposted + transactions. Note: pending transactions may be reversed or change + amount before settlement. +6. Set the **Synchronization Frequency** and save. +7. Click **Pull Now** (or let the scheduled activity run) to import + transactions. + +Usage +===== + +Once configured, statement lines are created automatically in the linked +journal. Each line carries: + +- **Date** — ``postedAt`` timestamp (falls back to ``createdAt`` for + pending transactions) +- **Amount** — negative for debits, positive for credits +- **Reference** — bank description, external memo, and note concatenated + with ``|`` +- **Partner name** — counterparty name from Mercury +- **Account number** — counterparty account number (used for ACH partner + matching) + +Transactions are deduplicated by Mercury's transaction UUID, so pulling +the same date range twice will not create duplicate statement lines. + +Known issues / Roadmap +====================== + +- **Mercury Plus invoicing** — create Mercury invoices from Odoo sale + orders and sync payment status back via the Mercury Invoicing API. +- **OAuth2 multi-tenant** — support OAuth2 flow for users who prefer not + to manage API keys manually (e.g. Odoo.sh / SaaS deployments). + +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 +------- + +* Ledo Enterprises + +Contributors +------------ + +- Dan Kendall dkendall@ledoweb.com + +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_mercury/__init__.py b/account_statement_import_online_mercury/__init__.py new file mode 100644 index 0000000000..03df53fb17 --- /dev/null +++ b/account_statement_import_online_mercury/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2024 Ledo Enterprises +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from . import models # noqa: F401 diff --git a/account_statement_import_online_mercury/__manifest__.py b/account_statement_import_online_mercury/__manifest__.py new file mode 100644 index 0000000000..1c3a43af52 --- /dev/null +++ b/account_statement_import_online_mercury/__manifest__.py @@ -0,0 +1,15 @@ +# Copyright 2024 Ledo Enterprises +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Online Bank Statements: Mercury", + "version": "19.0.1.0.0", + "category": "Accounting/Accounting", + "website": "https://github.com/OCA/bank-statement-import", + "author": "Ledo Enterprises, Odoo Community Association (OCA)", + "license": "AGPL-3", + "installable": True, + "depends": ["account_statement_import_online"], + "data": [ + "views/online_bank_statement_provider_views.xml", + ], +} diff --git a/account_statement_import_online_mercury/models/__init__.py b/account_statement_import_online_mercury/models/__init__.py new file mode 100644 index 0000000000..ea21c9bd6f --- /dev/null +++ b/account_statement_import_online_mercury/models/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2024 Ledo Enterprises +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from . import online_bank_statement_provider_mercury # noqa: F401 diff --git a/account_statement_import_online_mercury/models/online_bank_statement_provider_mercury.py b/account_statement_import_online_mercury/models/online_bank_statement_provider_mercury.py new file mode 100644 index 0000000000..6a2ed63706 --- /dev/null +++ b/account_statement_import_online_mercury/models/online_bank_statement_provider_mercury.py @@ -0,0 +1,220 @@ +# Copyright 2024 Ledo Enterprises +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import json +import logging +from datetime import datetime, timezone + +import requests + +from odoo import api, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + +MERCURY_API_BASE = "https://api.mercury.com/api/v1" +# Mercury allows up to 500 results per page +_PAGE_LIMIT = 500 + + +class OnlineBankStatementProvider(models.Model): + _inherit = "online.bank.statement.provider" + + mercury_account_id = fields.Char( + string="Mercury Account ID", + help="The Mercury account UUID shown in the Mercury dashboard URL " + "(e.g. https://app.mercury.com/accounts/). " + "Leave blank to import from all accounts (first account used).", + ) + mercury_include_pending = fields.Boolean( + string="Include Pending Transactions", + default=False, + help="If enabled, transactions with status 'pending' are included. " + "They may be reversed or change amount before posting.", + ) + + @api.model + def _get_available_services(self): + return super()._get_available_services() + [ + ("mercury", "Mercury"), + ] + + # ------------------------------------------------------------------ + # Core hook + # ------------------------------------------------------------------ + + def _obtain_statement_data(self, date_since, date_until): + """Fetch Mercury transactions and return (lines, {}) for the base module.""" + self.ensure_one() + if self.service != "mercury": + return super()._obtain_statement_data(date_since, date_until) + + _logger.info( + "Mercury: fetching transactions for journal %s from %s to %s", + self.journal_id.name, + date_since, + date_until, + ) + transactions = self._mercury_fetch_transactions(date_since, date_until) + lines = [self._mercury_transaction_to_line(t) for t in transactions] + _logger.info("Mercury: got %d transactions", len(lines)) + return lines, {} + + # ------------------------------------------------------------------ + # API helpers + # ------------------------------------------------------------------ + + def _mercury_session(self): + """Return a requests.Session pre-configured with Mercury auth headers.""" + api_key = self.password + if not api_key: + raise UserError( + self.env._( + "Mercury API key is not configured. " + "Set it in the 'API Key / Password' field on the provider." + ) + ) + # Mercury tokens must be sent as "secret-token:" — normalize + # whether the user pastes the full form or just the bare token. + if not api_key.startswith("secret-token:"): + api_key = f"secret-token:{api_key}" + session = requests.Session() + session.headers.update( + { + "Authorization": f"Bearer {api_key}", + "Accept": "application/json", + } + ) + return session + + def _mercury_get(self, session, path, params=None): + """GET from Mercury API; raise UserError on non-200.""" + url = f"{MERCURY_API_BASE}{path}" + try: + resp = session.get(url, params=params, timeout=30) + except requests.RequestException as exc: + raise UserError( + self.env._("Mercury API request failed: %(error)s", error=str(exc)) + ) from exc + if resp.status_code == 401: + raise UserError( + self.env._("Mercury API key is invalid or expired (HTTP 401).") + ) + if not resp.ok: + raise UserError( + self.env._( + "Mercury API returned HTTP %(status)s: %(body)s", + status=resp.status_code, + body=resp.text[:400], + ) + ) + return resp.json() + + def _mercury_resolve_account_id(self, session): + """Return the Mercury account UUID to use for this provider.""" + if self.mercury_account_id: + return self.mercury_account_id + # Auto-detect: pick first account + data = self._mercury_get(session, "/accounts") + accounts = data.get("accounts", []) + if not accounts: + raise UserError(self.env._("No Mercury accounts found for this API key.")) + account_id = accounts[0]["id"] + _logger.info( + "Mercury: auto-selected account %s (%s)", + accounts[0].get("name", ""), + account_id, + ) + return account_id + + def _mercury_fetch_transactions(self, date_since, date_until): + """Return all transactions in [date_since, date_until] via cursor pagination.""" + session = self._mercury_session() + account_id = self._mercury_resolve_account_id(session) + + def _fmt(dt): + return dt.strftime("%Y-%m-%d") if hasattr(dt, "strftime") else str(dt)[:10] + + # Mercury date params are YYYY-MM-DD strings + params = { + "limit": _PAGE_LIMIT, + "start": _fmt(date_since), + "end": _fmt(date_until), + } + if not self.mercury_include_pending: + params["status"] = "sent" + + path = f"/account/{account_id}/transactions" + all_transactions = [] + + while True: + data = self._mercury_get(session, path, params=params) + transactions = data.get("transactions", []) + all_transactions.extend(transactions) + + # Cursor-based pagination — retain original filter params (date, status) + next_cursor = data.get("page", {}).get("nextPage") + if not next_cursor or len(transactions) < _PAGE_LIMIT: + break + params["offset"] = next_cursor + + return all_transactions + + # ------------------------------------------------------------------ + # Data mapping + # ------------------------------------------------------------------ + + def _mercury_transaction_to_line(self, transaction): + """Map a Mercury transaction dict to an Odoo statement line dict.""" + amount = transaction.get("amount", 0.0) + # Mercury uses positive for credits, negative for debits — matches Odoo sign + # convention. Verify by checking bankDescription direction if needed. + + # Pick the best available date: postedAt > createdAt + date_str = transaction.get("postedAt") or transaction.get("createdAt") + date = self._mercury_parse_datetime(date_str) + + # Build reference from available description fields + ref_parts = [ + transaction.get("bankDescription") or "", + transaction.get("externalMemo") or "", + transaction.get("note") or "", + ] + ref = " | ".join(p for p in ref_parts if p).strip() or "/" + + vals = { + "date": date, + "amount": amount, + "ref": ref, + "payment_ref": ref, + "unique_import_id": transaction["id"], + "raw_data": json.dumps(transaction), + } + + counterparty_name = transaction.get("counterpartyName") or transaction.get( + "counterpartyNickname" + ) + if counterparty_name: + vals["partner_name"] = counterparty_name + + account_number = transaction.get("counterpartyAccountNumber") + if account_number: + vals["account_number"] = account_number + + return vals + + @staticmethod + def _mercury_parse_datetime(dt_str): + """Parse Mercury ISO-8601 UTC timestamp to a naive local datetime.""" + if not dt_str: + return datetime.now() + # Handle both 'Z' suffix and '+00:00' + dt_str = dt_str.replace("Z", "+00:00") + try: + dt = datetime.fromisoformat(dt_str) + except ValueError: + _logger.debug("Mercury: could not parse date %r, using now()", dt_str) + return datetime.now() + # Convert to UTC-naive for Odoo + if dt.tzinfo is not None: + dt = dt.astimezone(timezone.utc).replace(tzinfo=None) + return dt diff --git a/account_statement_import_online_mercury/pyproject.toml b/account_statement_import_online_mercury/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/account_statement_import_online_mercury/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/account_statement_import_online_mercury/readme/CONFIGURE.md b/account_statement_import_online_mercury/readme/CONFIGURE.md new file mode 100644 index 0000000000..5e55e235c7 --- /dev/null +++ b/account_statement_import_online_mercury/readme/CONFIGURE.md @@ -0,0 +1,23 @@ +## Generate a Mercury API Key + +1. Log in to [Mercury](https://app.mercury.com) and go to **Settings → API Keys**. +2. Click **Create API Key**, choose **Read-only** scope, and copy the key. + The key is shown once; store it securely. + +> Read-only keys have no IP allowlist requirement. Write-access keys require +> IP allowlisting and are only needed for future invoicing/payment features. + +## Configure Odoo + +1. Go to **Accounting → Configuration → Journals** and open or create a bank journal. +2. In the **Online Synchronization** section, select **Mercury** as the service. +3. Paste your **API Key**. You may paste the bare token or the full + `secret-token:…` form — both are accepted. +4. Optionally paste your **Mercury Account ID** — the UUID in the Mercury dashboard + URL (`https://app.mercury.com/accounts/`). Leave blank to auto-detect the + first account on the key. If you have multiple accounts under one API key (e.g. + checking and savings), set this field to pull from a specific account. +5. Enable **Include Pending Transactions** if you want to see unposted transactions. + Note: pending transactions may be reversed or change amount before settlement. +6. Set the **Synchronization Frequency** and save. +7. Click **Pull Now** (or let the scheduled activity run) to import transactions. diff --git a/account_statement_import_online_mercury/readme/CONTRIBUTORS.md b/account_statement_import_online_mercury/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..11a61dd91c --- /dev/null +++ b/account_statement_import_online_mercury/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Dan Kendall diff --git a/account_statement_import_online_mercury/readme/DESCRIPTION.md b/account_statement_import_online_mercury/readme/DESCRIPTION.md new file mode 100644 index 0000000000..adde3894b5 --- /dev/null +++ b/account_statement_import_online_mercury/readme/DESCRIPTION.md @@ -0,0 +1,16 @@ +Connects Odoo to [Mercury](https://mercury.com) using the +[Mercury REST API](https://docs.mercury.com/reference/introduction) to automatically +pull bank statement lines into your accounting journals. + +**Mercury** is a US business banking platform (checking, savings, treasury) built for +startups and small businesses. It offers a public REST API at +`https://api.mercury.com/api/v1` with Bearer token authentication. This module uses +the following endpoints: + +- `GET /accounts` — list accounts (used for auto-detection) +- `GET /account/{id}/transactions` — fetch transactions with date filtering and + cursor-based pagination + +A **read-only API key** is sufficient for statement import. Read-only keys have no +IP allowlist requirement. Write-access keys (needed for the future Mercury Plus +invoicing feature) require IP allowlisting in *Mercury → Settings → API Keys*. diff --git a/account_statement_import_online_mercury/readme/ROADMAP.md b/account_statement_import_online_mercury/readme/ROADMAP.md new file mode 100644 index 0000000000..200a75a112 --- /dev/null +++ b/account_statement_import_online_mercury/readme/ROADMAP.md @@ -0,0 +1,4 @@ +- **Mercury Plus invoicing** — create Mercury invoices from Odoo sale orders and sync + payment status back via the Mercury Invoicing API. +- **OAuth2 multi-tenant** — support OAuth2 flow for users who prefer not to manage API + keys manually (e.g. Odoo.sh / SaaS deployments). diff --git a/account_statement_import_online_mercury/readme/USAGE.md b/account_statement_import_online_mercury/readme/USAGE.md new file mode 100644 index 0000000000..c3176e7763 --- /dev/null +++ b/account_statement_import_online_mercury/readme/USAGE.md @@ -0,0 +1,11 @@ +Once configured, statement lines are created automatically in the linked journal. +Each line carries: + +- **Date** — `postedAt` timestamp (falls back to `createdAt` for pending transactions) +- **Amount** — negative for debits, positive for credits +- **Reference** — bank description, external memo, and note concatenated with ` | ` +- **Partner name** — counterparty name from Mercury +- **Account number** — counterparty account number (used for ACH partner matching) + +Transactions are deduplicated by Mercury's transaction UUID, so pulling the same +date range twice will not create duplicate statement lines. diff --git a/account_statement_import_online_mercury/static/description/index.html b/account_statement_import_online_mercury/static/description/index.html new file mode 100644 index 0000000000..b71f8c615e --- /dev/null +++ b/account_statement_import_online_mercury/static/description/index.html @@ -0,0 +1,516 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Online Bank Statements: Mercury

+ +

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

+

Connects Odoo to Mercury using the Mercury +REST API to +automatically pull bank statement lines into your accounting journals.

+

Mercury is a US business banking platform (checking, savings, +treasury) built for startups and small businesses. It offers a public +REST API at https://api.mercury.com/api/v1 with Bearer token +authentication. This module uses the following endpoints:

+
    +
  • GET /accounts — list accounts (used for auto-detection)
  • +
  • GET /account/{id}/transactions — fetch transactions with date +filtering and cursor-based pagination
  • +
+

A read-only API key is sufficient for statement import. Read-only +keys have no IP allowlist requirement. Write-access keys (needed for the +future Mercury Plus invoicing feature) require IP allowlisting in +Mercury → Settings → API Keys.

+

Table of contents

+ +
+

Configuration

+
+

Generate a Mercury API Key

+
    +
  1. Log in to Mercury and go to Settings +→ API Keys.
  2. +
  3. Click Create API Key, choose Read-only scope, and copy the +key. The key is shown once; store it securely.
  4. +
+ +
+Read-only keys have no IP allowlist requirement. Write-access keys +require IP allowlisting and are only needed for future +invoicing/payment features.
+
+
+

Configure Odoo

+
    +
  1. Go to Accounting → Configuration → Journals and open or create a +bank journal.
  2. +
  3. In the Online Synchronization section, select Mercury as the +service.
  4. +
  5. Paste your API Key. You may paste the bare token or the full +secret-token:… form — both are accepted.
  6. +
  7. Optionally paste your Mercury Account ID — the UUID in the +Mercury dashboard URL (https://app.mercury.com/accounts/<uuid>). +Leave blank to auto-detect the first account on the key. If you have +multiple accounts under one API key (e.g. checking and savings), set +this field to pull from a specific account.
  8. +
  9. Enable Include Pending Transactions if you want to see unposted +transactions. Note: pending transactions may be reversed or change +amount before settlement.
  10. +
  11. Set the Synchronization Frequency and save.
  12. +
  13. Click Pull Now (or let the scheduled activity run) to import +transactions.
  14. +
+
+
+
+

Usage

+

Once configured, statement lines are created automatically in the linked +journal. Each line carries:

+
    +
  • DatepostedAt timestamp (falls back to createdAt for +pending transactions)
  • +
  • Amount — negative for debits, positive for credits
  • +
  • Reference — bank description, external memo, and note concatenated +with |
  • +
  • Partner name — counterparty name from Mercury
  • +
  • Account number — counterparty account number (used for ACH partner +matching)
  • +
+

Transactions are deduplicated by Mercury’s transaction UUID, so pulling +the same date range twice will not create duplicate statement lines.

+
+
+

Known issues / Roadmap

+
    +
  • Mercury Plus invoicing — create Mercury invoices from Odoo sale +orders and sync payment status back via the Mercury Invoicing API.
  • +
  • OAuth2 multi-tenant — support OAuth2 flow for users who prefer not +to manage API keys manually (e.g. Odoo.sh / SaaS deployments).
  • +
+
+
+

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

+
    +
  • Ledo Enterprises
  • +
+
+
+

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_mercury/tests/__init__.py b/account_statement_import_online_mercury/tests/__init__.py new file mode 100644 index 0000000000..1643270c97 --- /dev/null +++ b/account_statement_import_online_mercury/tests/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2024 Ledo Enterprises +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from . import test_mercury_provider # noqa: F401 diff --git a/account_statement_import_online_mercury/tests/test_mercury_provider.py b/account_statement_import_online_mercury/tests/test_mercury_provider.py new file mode 100644 index 0000000000..92e854fd65 --- /dev/null +++ b/account_statement_import_online_mercury/tests/test_mercury_provider.py @@ -0,0 +1,375 @@ +# Copyright 2024 Ledo Enterprises +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from datetime import datetime +from unittest.mock import MagicMock, Mock, patch + +from odoo.exceptions import UserError +from odoo.tests.common import TransactionCase + +SAMPLE_ACCOUNTS = { + "accounts": [ + {"id": "acct-uuid-001", "name": "Mercury Checking"}, + ] +} + +SAMPLE_TRANSACTIONS = { + "transactions": [ + { + "id": "txn-uuid-001", + "amount": -150.00, + "status": "sent", + "bankDescription": "ACH PAYMENT VENDOR CO", + "externalMemo": "Invoice 1234", + "note": "", + "counterpartyName": "Vendor Co", + "counterpartyAccountNumber": "123456789", + "createdAt": "2024-03-01T12:00:00.000Z", + "postedAt": "2024-03-02T08:00:00.000Z", + }, + { + "id": "txn-uuid-002", + "amount": 5000.00, + "status": "sent", + "bankDescription": "WIRE TRANSFER IN", + "externalMemo": "", + "note": "Client payment", + "counterpartyName": "Client LLC", + "counterpartyAccountNumber": None, + "createdAt": "2024-03-05T10:00:00.000Z", + "postedAt": "2024-03-05T10:00:00.000Z", + }, + ], + "page": {"nextPage": None, "previousPage": None}, +} + +# Two-page dataset for pagination tests +PAGE_1 = { + "transactions": [ + { + "id": f"txn-page1-{i:03d}", + "amount": float(i), + "status": "sent", + "bankDescription": f"TXN {i}", + "externalMemo": "", + "note": "", + "counterpartyName": None, + "counterpartyAccountNumber": None, + "createdAt": "2024-03-01T00:00:00.000Z", + "postedAt": "2024-03-01T00:00:00.000Z", + } + for i in range(500) + ], + "page": {"nextPage": "cursor-abc", "previousPage": None}, +} + +PAGE_2 = { + "transactions": [ + { + "id": "txn-page2-001", + "amount": 99.0, + "status": "sent", + "bankDescription": "LAST TXN", + "externalMemo": "", + "note": "", + "counterpartyName": None, + "counterpartyAccountNumber": None, + "createdAt": "2024-03-15T00:00:00.000Z", + "postedAt": "2024-03-15T00:00:00.000Z", + } + ], + "page": {"nextPage": None, "previousPage": "cursor-abc"}, +} + +_journal_seq = [0] + + +class TestMercuryProvider(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + # Always create a dedicated journal — the unique constraint on + # online_bank_statement_provider(journal_id) forbids sharing one. + cls.journal = cls.env["account.journal"].create( + {"name": "Mercury Test Bank", "type": "bank", "code": "MRCTEST"} + ) + cls.provider = cls.env["online.bank.statement.provider"].create( + { + "journal_id": cls.journal.id, + "service": "mercury", + "password": "test-api-key", + "mercury_account_id": "acct-uuid-001", + "mercury_include_pending": False, + } + ) + + def _new_provider(self, **vals): + """Create a provider with a fresh journal. + + Avoids the journal_id unique constraint. + """ + _journal_seq[0] += 1 + journal = self.env["account.journal"].create( + { + "name": f"Mercury Test {_journal_seq[0]}", + "type": "bank", + "code": f"MRC{_journal_seq[0]:02d}", + } + ) + base = { + "journal_id": journal.id, + "service": "mercury", + "password": "test-api-key", + "mercury_account_id": "acct-uuid-001", + "mercury_include_pending": False, + } + base.update(vals) + return self.env["online.bank.statement.provider"].create(base) + + @staticmethod + def _mock_get(session, path, params=None): + """Side-effect for _mercury_get: dispatches by path. + + Note: patch.object on the class replaces the method with a Mock that is + NOT bound, so side_effects receive (session, path, params) — not self. + """ + if path == "/accounts": + return SAMPLE_ACCOUNTS + if path.endswith("/transactions"): + return SAMPLE_TRANSACTIONS + return {} + + # ------------------------------------------------------------------ + # Service registration + # ------------------------------------------------------------------ + + def test_service_registered(self): + services = dict(self.provider._get_available_services()) + self.assertIn("mercury", services) + + # ------------------------------------------------------------------ + # Core data fetch + # ------------------------------------------------------------------ + + def test_obtain_statement_data(self): + date_since = datetime(2024, 3, 1) + date_until = datetime(2024, 3, 31) + with patch.object( + type(self.provider), "_mercury_get", side_effect=self._mock_get + ): + lines, meta = self.provider._obtain_statement_data(date_since, date_until) + self.assertEqual(len(lines), 2) + self.assertEqual(meta, {}) + + def test_obtain_statement_data_delegates_non_mercury(self): + """Provider with a different service falls through to super().""" + date_since = datetime(2024, 3, 1) + date_until = datetime(2024, 3, 31) + dummy = self._new_provider(service="dummy") + result = dummy._obtain_statement_data(date_since, date_until) + # Base returns empty list (no lines), not a tuple — just confirm no crash + self.assertIsNotNone(result) + + # ------------------------------------------------------------------ + # Pagination + # ------------------------------------------------------------------ + + def test_pagination_fetches_all_pages(self): + """Two-page response yields transactions from both pages.""" + call_count = [0] + + def _paged_get(session, path, params=None): + if not path.endswith("/transactions"): + return SAMPLE_ACCOUNTS + call_count[0] += 1 + if call_count[0] == 1: + return PAGE_1 + return PAGE_2 + + with patch.object(type(self.provider), "_mercury_get", side_effect=_paged_get): + txns = self.provider._mercury_fetch_transactions( + datetime(2024, 3, 1), datetime(2024, 3, 31) + ) + + self.assertEqual(len(txns), 501) # 500 + 1 + self.assertEqual(call_count[0], 2) + + def test_pagination_preserves_filter_params(self): + """Date and status params are kept on subsequent pages (not dropped).""" + captured_params = [] + + def _capture_get(session, path, params=None): + if path.endswith("/transactions"): + captured_params.append(dict(params or {})) + # Return full page 1 first, then empty page 2 + if len(captured_params) == 1: + return PAGE_1 + return PAGE_2 + + with patch.object( + type(self.provider), "_mercury_get", side_effect=_capture_get + ): + self.provider._mercury_fetch_transactions( + datetime(2024, 3, 1), datetime(2024, 3, 31) + ) + + self.assertGreaterEqual(len(captured_params), 2) + # Both pages must carry start/end date filters + for p in captured_params: + self.assertIn("start", p) + self.assertIn("end", p) + # Second page must include the cursor offset + self.assertEqual(captured_params[1].get("offset"), "cursor-abc") + + # ------------------------------------------------------------------ + # Transaction mapping + # ------------------------------------------------------------------ + + def test_transaction_mapping(self): + txn = SAMPLE_TRANSACTIONS["transactions"][0] + line = self.provider._mercury_transaction_to_line(txn) + + self.assertEqual(line["unique_import_id"], "txn-uuid-001") + self.assertAlmostEqual(line["amount"], -150.00) + self.assertEqual(line["partner_name"], "Vendor Co") + self.assertEqual(line["account_number"], "123456789") + self.assertIn("ACH PAYMENT", line["ref"]) + self.assertIsInstance(line["date"], datetime) + + def test_transaction_mapping_no_counterparty(self): + txn = SAMPLE_TRANSACTIONS["transactions"][1] + line = self.provider._mercury_transaction_to_line(txn) + + self.assertEqual(line["unique_import_id"], "txn-uuid-002") + self.assertAlmostEqual(line["amount"], 5000.00) + self.assertNotIn("account_number", line) + self.assertIn("Client payment", line["ref"]) + + def test_transaction_ref_fallback_to_slash(self): + """Transaction with no description fields gets ref='/'.""" + txn = { + "id": "txn-empty", + "amount": 1.0, + "bankDescription": None, + "externalMemo": None, + "note": None, + "counterpartyName": None, + "counterpartyAccountNumber": None, + "createdAt": "2024-03-01T00:00:00.000Z", + "postedAt": None, + } + line = self.provider._mercury_transaction_to_line(txn) + self.assertEqual(line["ref"], "/") + + # ------------------------------------------------------------------ + # Datetime parsing + # ------------------------------------------------------------------ + + def test_parse_datetime_z_suffix(self): + dt = self.provider._mercury_parse_datetime("2024-03-15T14:30:00.000Z") + self.assertIsInstance(dt, datetime) + self.assertIsNone(dt.tzinfo) + + def test_parse_datetime_offset(self): + dt = self.provider._mercury_parse_datetime("2024-03-15T14:30:00+00:00") + self.assertIsInstance(dt, datetime) + self.assertIsNone(dt.tzinfo) + + def test_parse_datetime_none(self): + dt = self.provider._mercury_parse_datetime(None) + self.assertIsInstance(dt, datetime) + + def test_parse_datetime_malformed_falls_back_to_now(self): + dt = self.provider._mercury_parse_datetime("not-a-date") + self.assertIsInstance(dt, datetime) + + # ------------------------------------------------------------------ + # API session / authentication + # ------------------------------------------------------------------ + + def test_missing_api_key_raises(self): + provider_no_key = self._new_provider(password=False) + with self.assertRaises(UserError): + provider_no_key._mercury_session() + + def test_secret_token_prefix_normalised(self): + """Bare token is prefixed; already-prefixed token is not double-prefixed.""" + mock_session = MagicMock() + _SESSION = ( + "odoo.addons.account_statement_import_online_mercury" + ".models.online_bank_statement_provider_mercury.requests.Session" + ) + with patch(_SESSION, return_value=mock_session): + self._new_provider(password="mytoken")._mercury_session() + bare_auth = mock_session.headers.update.call_args[0][0]["Authorization"] + + mock_session.reset_mock() + self._new_provider(password="secret-token:mytoken")._mercury_session() + prefixed_auth = mock_session.headers.update.call_args[0][0]["Authorization"] + + self.assertEqual(bare_auth, "Bearer secret-token:mytoken") + self.assertEqual(bare_auth, prefixed_auth) + + # ------------------------------------------------------------------ + # API error handling + # ------------------------------------------------------------------ + + def test_http_401_raises_user_error(self): + """Invalid API key (HTTP 401) raises a clear UserError.""" + resp = Mock(ok=False, status_code=401, text="Unauthorized") + session = Mock() + session.get.return_value = resp + + with self.assertRaises(UserError) as ctx: + self.provider._mercury_get(session, "/accounts") + self.assertIn("401", str(ctx.exception)) + + def test_http_error_raises_user_error(self): + """Any non-2xx response raises UserError with status and body.""" + resp = Mock(ok=False, status_code=429, text="Rate limit exceeded") + session = Mock() + session.get.return_value = resp + + with self.assertRaises(UserError) as ctx: + self.provider._mercury_get(session, "/accounts") + self.assertIn("429", str(ctx.exception)) + + def test_request_exception_raises_user_error(self): + """Network-level failure (timeout, DNS) raises UserError.""" + import requests as req_lib + + session = Mock() + session.get.side_effect = req_lib.RequestException("connection timeout") + + with self.assertRaises(UserError) as ctx: + self.provider._mercury_get(session, "/accounts") + self.assertIn("connection timeout", str(ctx.exception)) + + # ------------------------------------------------------------------ + # Account resolution + # ------------------------------------------------------------------ + + def test_auto_detect_account(self): + """When mercury_account_id is blank, first account from /accounts is used.""" + provider = self._new_provider(mercury_account_id=False, password="test-key") + with patch.object(type(provider), "_mercury_get", side_effect=self._mock_get): + account_id = provider._mercury_resolve_account_id(MagicMock()) + self.assertEqual(account_id, "acct-uuid-001") + + def test_no_accounts_raises_user_error(self): + """Empty accounts list raises UserError.""" + + def _no_accounts(session, path, params=None): + return {"accounts": []} + + provider = self._new_provider(mercury_account_id=False, password="test-key") + with patch.object(type(provider), "_mercury_get", side_effect=_no_accounts): + with self.assertRaises(UserError): + provider._mercury_resolve_account_id(MagicMock()) + + def test_explicit_account_id_skips_api_call(self): + """When mercury_account_id is set, /accounts is never called.""" + mock_get = MagicMock() + with patch.object(type(self.provider), "_mercury_get", mock_get): + account_id = self.provider._mercury_resolve_account_id(MagicMock()) + mock_get.assert_not_called() + self.assertEqual(account_id, "acct-uuid-001") diff --git a/account_statement_import_online_mercury/views/online_bank_statement_provider_views.xml b/account_statement_import_online_mercury/views/online_bank_statement_provider_views.xml new file mode 100644 index 0000000000..cf439ec357 --- /dev/null +++ b/account_statement_import_online_mercury/views/online_bank_statement_provider_views.xml @@ -0,0 +1,32 @@ + + + + online.bank.statement.provider.mercury.form + online.bank.statement.provider + + + + + + + + + + + +