diff --git a/account_statement_import_online_ramp/README.rst b/account_statement_import_online_ramp/README.rst new file mode 100644 index 000000000..b37cde71e --- /dev/null +++ b/account_statement_import_online_ramp/README.rst @@ -0,0 +1,179 @@ +============================ +Online Bank Statements: Ramp +============================ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:014c26271557700ee275f7181e7064b7b8b9a5faceda8d6abe649ead32e3d3c4 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/18.0/account_statement_import_online_ramp + :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-18-0/bank-statement-import-18-0-account_statement_import_online_ramp + :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=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Connects Odoo to `Ramp `__ using the `Ramp Developer +API `__ to automatically pull +corporate-card transactions into your accounting journals as bank +statement lines. + +**Ramp** is a US corporate-card and spend-management platform. A Ramp +organization has one credit line shared across all cards, billed as a +single monthly statement and paid via one ACH transfer. This module +follows that accounting reality: one Ramp credit line maps to **one** +Odoo bank-type journal, regardless of how many cards are issued under +it. + +The module uses these endpoints: + +- ``POST /developer/v1/token`` — OAuth2 client_credentials, mints a + short-lived bearer that is cached on the provider record and refreshed + on expiry or 401. +- ``GET /developer/v1/transactions`` — paginated card-transaction feed + filtered by date range; cursor pagination via the ``page.next`` field. + +Cardholder, card, merchant category, and Ramp accounting-category +metadata are preserved in each statement line's ``raw_data`` field so +downstream automation (reconcile rules, server actions, custom reports) +can use them without a second API call. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Generate Ramp API credentials +----------------------------- + +1. Log in to `Ramp `__ as an Owner or Admin and go + to **Settings → Developer API**. +2. Click **Create new app**, give it a name (e.g. *Odoo statement + import*), and select the OAuth2 grant type **Client credentials**. +3. Grant the scopes ``transactions:read`` and ``users:read``. + (``users:read`` lets the cardholder name and email travel with each + transaction in ``raw_data``; it is read-only.) +4. Copy the **Client ID** and **Client Secret**. The secret is shown + once — store it securely. + +Configure Odoo +-------------- + +1. Go to **Accounting → Configuration → Journals** and open or create a + bank-type journal whose default account is your Ramp credit-line + liability GL account. +2. In the **Online Synchronization** section, select **Ramp** as the + service. +3. Paste your **Client ID** and **Client Secret**. +4. Set the **Ramp Environment** to *Sandbox* for ``demo-api.ramp.com`` + (Ramp's developer sandbox) or *Production* for ``api.ramp.com``. +5. Set the **Synchronization Frequency** and save. +6. Click **Pull Now** (or let the scheduled activity run) to import + transactions. + +The bearer token is minted automatically on the first pull and cached on +the provider record. It refreshes a minute before its declared expiry, +and on any HTTP 401 it is dropped and re-minted transparently. + +Usage +===== + +Once configured, statement lines are created automatically in the linked +journal. Each line carries: + +- **Date** — ``user_transaction_time`` (falls back to + ``settlement_date``), normalized to UTC. +- **Amount** — Ramp returns positive amounts on spend; the module flips + the sign so spend lands as a negative line on the credit-line + liability journal (matching the standard Odoo convention for bank-type + journals). +- **Reference / Payment Reference** — ``merchant_name`` (falls back to + ``merchant_descriptor`` then ``memo``). +- **Partner name** — ``merchant_name`` when present (no ``res.partner`` + is auto- created; reconcile rules can match against the name). +- **Raw data** — the full Ramp transaction payload as JSON, including + ``card_id``, ``user_id``, ``sk_category_name``, + ``accounting_categories``, and any other fields Ramp returns. + +Transactions in state ``DECLINED``, ``PENDING_INITIATION``, or ``ERROR`` +are skipped — they do not post to the credit line and would otherwise +produce statement lines that need manual deletion. + +Transactions are deduplicated by Ramp's transaction UUID, so pulling the +same date range twice will not create duplicate statement lines. + +Known issues / Roadmap +====================== + +- **Per-card journal routing** — for organizations with multiple credit + lines or that want per-card analytic attribution at the journal level, + allow a provider field to filter the transaction pull to a list of + Ramp card UUIDs (or by card last-4). +- **Cardholder as a first-class field** — add ``ramp_card_last_four`` + and ``ramp_cardholder_user_id`` (M2O ``res.users``) to + ``account.bank.statement.line`` in a companion module so cardholder is + queryable/groupable in standard reports without parsing ``raw_data``. +- **Reimbursements / bills** — extend coverage to + ``/developer/v1/reimbursements`` and ``/developer/v1/bills`` for + organizations that use Ramp's full spend-management feature set. +- **Webhook ingestion** — Ramp supports webhook delivery; a webhook + receiver would deliver near-real-time statement lines instead of + polling on cron. + +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_ramp/__init__.py b/account_statement_import_online_ramp/__init__.py new file mode 100644 index 000000000..9d7ac86b8 --- /dev/null +++ b/account_statement_import_online_ramp/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2026 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_ramp/__manifest__.py b/account_statement_import_online_ramp/__manifest__.py new file mode 100644 index 000000000..625bb9de8 --- /dev/null +++ b/account_statement_import_online_ramp/__manifest__.py @@ -0,0 +1,15 @@ +# Copyright 2026 Ledo Enterprises +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Online Bank Statements: Ramp", + "version": "18.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_ramp/models/__init__.py b/account_statement_import_online_ramp/models/__init__.py new file mode 100644 index 000000000..48de9fc87 --- /dev/null +++ b/account_statement_import_online_ramp/models/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2026 Ledo Enterprises +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from . import online_bank_statement_provider_ramp # noqa: F401 diff --git a/account_statement_import_online_ramp/models/online_bank_statement_provider_ramp.py b/account_statement_import_online_ramp/models/online_bank_statement_provider_ramp.py new file mode 100644 index 000000000..107fcfd70 --- /dev/null +++ b/account_statement_import_online_ramp/models/online_bank_statement_provider_ramp.py @@ -0,0 +1,398 @@ +# Copyright 2026 Ledo Enterprises +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import json +import logging +from base64 import b64encode +from datetime import datetime, timedelta, timezone +from urllib.parse import urlparse + +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + +RAMP_HOSTS = { + "production": "https://api.ramp.com", + "sandbox": "https://demo-api.ramp.com", +} +# Scopes the module needs. transactions:read is the only hard requirement; +# users:read enriches raw_data with cardholder name/email so downstream +# integrations can attribute spend without a second API round-trip. +RAMP_SCOPES = "transactions:read users:read" +# Ramp's max page size is 100 (per developer docs). Smaller values just mean +# more round-trips for the same data; 100 is the right default. +_PAGE_SIZE = 100 +# Refresh the bearer token a minute before its declared expiry to avoid +# a thundering-herd of 401s right at the boundary. +_TOKEN_SAFETY_WINDOW = timedelta(seconds=60) +# Transactions in these states are not posted to the credit line. Skip them +# entirely so we never produce a statement line we'd later have to delete. +_SKIPPED_STATES = {"DECLINED", "PENDING_INITIATION", "ERROR"} +# Defence in depth against a runaway cursor loop. 1000 pages × 100/page is +# 100k transactions per pull — comfortably above any realistic single-org +# month. Hitting this almost certainly indicates an API bug or a malformed +# next-URL response. +_MAX_PAGES = 1000 +# Per-request HTTP timeout (seconds). Applied to both token mints and data +# fetches so cron pulls cannot wedge indefinitely. +_HTTP_TIMEOUT = 30 +# Retry strategy for transient Ramp failures. Idempotent calls only. +_RETRY_TOTAL = 3 +_RETRY_BACKOFF = 0.5 + + +def _utcnow_naive(): + """Return a timezone-naive UTC datetime. + + ``datetime.utcnow`` is deprecated in Python 3.12+. The combo below is + forward-compatible and produces the same value Odoo's ORM stores in + Datetime fields (naive UTC). + """ + return datetime.now(timezone.utc).replace(tzinfo=None) + + +def _build_retry_adapter(): + """HTTP adapter with backoff on transient 5xx + connection errors. + + Applied to both the token-mint POST and the data-fetch GETs. ``Retry`` + only retries the listed status codes and methods, so a 401 (auth + failure) still bubbles up immediately for our explicit handling. + """ + retry = Retry( + total=_RETRY_TOTAL, + backoff_factor=_RETRY_BACKOFF, + status_forcelist=(502, 503, 504), + allowed_methods=frozenset(("GET", "POST")), + raise_on_status=False, + ) + return HTTPAdapter(max_retries=retry) + + +class OnlineBankStatementProvider(models.Model): + _inherit = "online.bank.statement.provider" + + ramp_host = fields.Selection( + [("sandbox", "Sandbox"), ("production", "Production")], + string="Ramp Environment", + default="sandbox", + help="Sandbox uses demo-api.ramp.com; Production uses api.ramp.com.", + ) + ramp_access_token = fields.Char( + help="Cached OAuth2 bearer token. Refreshed automatically on expiry " + "or on HTTP 401. Should not normally be edited by hand.", + ) + ramp_token_expiry = fields.Datetime( + help="UTC expiry of the cached bearer token.", + ) + + @api.model + def _get_available_services(self): + return super()._get_available_services() + [("ramp", "Ramp")] + + # ------------------------------------------------------------------ + # Core hook + # ------------------------------------------------------------------ + + def _obtain_statement_data(self, date_since, date_until): + """Fetch Ramp transactions and return (lines, {}) for the base module.""" + self.ensure_one() + if self.service != "ramp": + return super()._obtain_statement_data(date_since, date_until) + + _logger.info( + "Ramp: fetching transactions for journal %s from %s to %s", + self.journal_id.name, + date_since, + date_until, + ) + transactions = self._ramp_fetch_transactions(date_since, date_until) + lines = [ + self._ramp_transaction_to_line(t) + for t in transactions + if t.get("state") not in _SKIPPED_STATES + ] + _logger.info("Ramp: produced %d statement lines", len(lines)) + return lines, {} + + # ------------------------------------------------------------------ + # OAuth2 token management + # ------------------------------------------------------------------ + + def _ramp_base_url(self): + host = RAMP_HOSTS.get(self.ramp_host or "sandbox") + if not host: + raise UserError( + _("Unknown Ramp environment: %(host)s") % {"host": self.ramp_host} + ) + return host + + def _ramp_token_is_valid(self): + """True iff cached token exists and is not within the safety window.""" + if not self.ramp_access_token or not self.ramp_token_expiry: + return False + return self.ramp_token_expiry > _utcnow_naive() + _TOKEN_SAFETY_WINDOW + + def _ramp_fetch_access_token(self): + """Mint a fresh bearer via OAuth2 client_credentials. + + Wraps the mint in a ``SELECT ... FOR UPDATE`` on the provider row so + two concurrent crons on the same provider serialise: the second one + re-reads the (possibly already-refreshed) token after the first + finishes and skips a redundant mint if it is now valid. The token + + expiry are written back on the record so subsequent calls across + cron runs can reuse them. + """ + self.ensure_one() + # Serialise concurrent refreshes on the same provider row. + self.env.cr.execute( + "SELECT id FROM online_bank_statement_provider " "WHERE id = %s FOR UPDATE", + (self.id,), + ) + # Drop any cached field values so we see whatever the lock-holder + # may have just written. + self.invalidate_recordset(["ramp_access_token", "ramp_token_expiry"]) + if self._ramp_token_is_valid(): + return self.ramp_access_token + + client_id = self.username + client_secret = self.password + if not client_id or not client_secret: + raise UserError( + _( + "Ramp client_id and client_secret must be set on the " + "provider (Username and Password fields)." + ) + ) + creds = b64encode(f"{client_id}:{client_secret}".encode()).decode() + url = f"{self._ramp_base_url()}/developer/v1/token" + try: + with requests.Session() as session: + session.mount("https://", _build_retry_adapter()) + resp = session.post( + url, + headers={ + "Authorization": f"Basic {creds}", + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + }, + data={"grant_type": "client_credentials", "scope": RAMP_SCOPES}, + timeout=_HTTP_TIMEOUT, + ) + except requests.RequestException as exc: + raise UserError( + _("Ramp token request failed: %(error)s") % {"error": str(exc)} + ) from exc + if resp.status_code in (401, 403): + raise UserError( + _( + "Ramp rejected the client credentials (HTTP %(status)s). " + "Verify client_id, client_secret, and that the OAuth2 app " + "is enabled for environment %(env)s." + ) + % {"status": resp.status_code, "env": self.ramp_host} + ) + if not resp.ok: + raise UserError( + _("Ramp token request returned HTTP %(status)s: %(body)s") + % {"status": resp.status_code, "body": resp.text[:400]} + ) + payload = resp.json() + token = payload.get("access_token") + # ``or 7200`` (not the dict default) so an explicit ``null`` or 0 + # in the response also falls back to a sane default instead of + # crashing on ``int(None)``. + expires_in = int(payload.get("expires_in") or 7200) + if not token: + raise UserError(_("Ramp token response missing access_token field.")) + self.write( + { + "ramp_access_token": token, + "ramp_token_expiry": _utcnow_naive() + timedelta(seconds=expires_in), + } + ) + return token + + def _ramp_get_access_token(self): + """Return a usable bearer, minting a fresh one if the cache is stale.""" + if self._ramp_token_is_valid(): + return self.ramp_access_token + return self._ramp_fetch_access_token() + + # ------------------------------------------------------------------ + # HTTP helpers + # ------------------------------------------------------------------ + + def _ramp_session(self): + token = self._ramp_get_access_token() + session = requests.Session() + session.mount("https://", _build_retry_adapter()) + session.headers.update( + { + "Authorization": f"Bearer {token}", + "Accept": "application/json", + } + ) + return session + + def _ramp_assert_safe_url(self, url): + """Reject any URL that is not HTTPS on the configured Ramp host. + + We get pagination URLs from Ramp's response payloads. Blindly + following them would send our bearer token to whatever host the + response says — a SSRF-style token-leak vector if the response is + ever tampered with. Verifying the host closes that loop. + + Comparison is on ``.hostname`` (lowercased, port-stripped, and + with any ``user@`` authority resolved) so legitimate variations + like ``api.ramp.com:443`` or ``API.Ramp.com`` are accepted while + ``api.ramp.com@evil.com`` is still rejected. + """ + expected = urlparse(self._ramp_base_url()).hostname + parsed = urlparse(url) + if parsed.scheme != "https" or parsed.hostname != expected: + raise UserError( + _( + "Ramp returned a URL pointing to an unexpected host " + "(%(actual)s, expected %(expected)s). Refusing to " + "follow it." + ) + % {"actual": parsed.hostname or "", "expected": expected} + ) + + def _ramp_get(self, session, url, params=None): + """GET from Ramp. + + ``url`` may be a relative path (we'll prefix the environment base + URL) or a fully-qualified cursor URL returned in a previous + response's ``page.next``. Cursor URLs are host-validated before + being followed. + + On 401, transparently refresh the bearer once and retry — this + covers the case where a token was revoked mid-cycle or our expiry + estimate was off. + """ + if not url.startswith("http"): + url = f"{self._ramp_base_url()}{url}" + else: + self._ramp_assert_safe_url(url) + try: + resp = session.get(url, params=params, timeout=_HTTP_TIMEOUT) + except requests.RequestException as exc: + raise UserError( + _("Ramp API request failed: %(error)s") % {"error": str(exc)} + ) from exc + if resp.status_code == 401: + # Token expired or revoked — drop cache, mint new, retry once. + self.write({"ramp_access_token": False, "ramp_token_expiry": False}) + new_token = self._ramp_get_access_token() + session.headers["Authorization"] = f"Bearer {new_token}" + try: + resp = session.get(url, params=params, timeout=_HTTP_TIMEOUT) + except requests.RequestException as exc: + raise UserError( + _("Ramp API request failed after token refresh: %(error)s") + % {"error": str(exc)} + ) from exc + if not resp.ok: + raise UserError( + _("Ramp API returned HTTP %(status)s: %(body)s") + % {"status": resp.status_code, "body": resp.text[:400]} + ) + return resp.json() + + # ------------------------------------------------------------------ + # Transactions + # ------------------------------------------------------------------ + + def _ramp_fetch_transactions(self, date_since, date_until): + """Return all Ramp card transactions in [date_since, date_until].""" + session = self._ramp_session() + + def _fmt(dt): + # Ramp accepts ISO 8601 with timezone. Normalize to UTC. + if hasattr(dt, "isoformat"): + if getattr(dt, "tzinfo", None) is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).isoformat() + return str(dt) + + url = "/developer/v1/transactions" + params = { + "from_date": _fmt(date_since), + "to_date": _fmt(date_until), + "page_size": _PAGE_SIZE, + } + all_transactions = [] + for _page in range(_MAX_PAGES): + data = self._ramp_get(session, url, params=params) + page = data.get("data") or [] + all_transactions.extend(page) + next_url = (data.get("page") or {}).get("next") + if not next_url: + return all_transactions + # Subsequent pages: Ramp returns a fully-formed URL with the + # cursor + original filters baked in. Don't re-send params or + # we'd double-encode them. Host is validated inside _ramp_get. + url = next_url + params = None + raise UserError( + _( + "Ramp pagination did not terminate after %(max)d pages. " + "Aborting to avoid an unbounded loop." + ) + % {"max": _MAX_PAGES} + ) + + # ------------------------------------------------------------------ + # Mapping + # ------------------------------------------------------------------ + + def _ramp_transaction_to_line(self, txn): + """Map a Ramp transaction dict to an Odoo statement line dict.""" + # Ramp returns positive amounts on spend; an Odoo CC-liability journal + # expects spend to land as a negative line (reduces the asset side of + # the entry), so flip the sign. + amount = -float(txn.get("amount", 0.0)) + date = self._ramp_parse_datetime( + txn.get("user_transaction_time") or txn.get("settlement_date") + ) + merchant = ( + txn.get("merchant_name") + or txn.get("merchant_descriptor") + or txn.get("memo") + or "/" + ) + vals = { + "date": date, + "amount": amount, + "ref": merchant, + "payment_ref": merchant, + "unique_import_id": txn["id"], + # raw_data preserves card_id, user_id, sk_category_name, + # accounting_categories, etc. so downstream automation (reconcile + # rules, server actions, custom reports) can use them without a + # second API call. + "raw_data": json.dumps(txn), + } + if txn.get("merchant_name"): + vals["partner_name"] = txn["merchant_name"] + return vals + + @staticmethod + def _ramp_parse_datetime(dt_str): + """Parse a Ramp ISO 8601 timestamp to a naive UTC datetime.""" + if not dt_str: + return _utcnow_naive() + dt_str = dt_str.replace("Z", "+00:00") + try: + dt = datetime.fromisoformat(dt_str) + except ValueError: + _logger.debug("Ramp: could not parse date %r, using utcnow()", dt_str) + return _utcnow_naive() + if dt.tzinfo is not None: + dt = dt.astimezone(timezone.utc).replace(tzinfo=None) + return dt diff --git a/account_statement_import_online_ramp/pyproject.toml b/account_statement_import_online_ramp/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/account_statement_import_online_ramp/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/account_statement_import_online_ramp/readme/CONFIGURE.md b/account_statement_import_online_ramp/readme/CONFIGURE.md new file mode 100644 index 000000000..dfd5faf15 --- /dev/null +++ b/account_statement_import_online_ramp/readme/CONFIGURE.md @@ -0,0 +1,28 @@ +## Generate Ramp API credentials + +1. Log in to [Ramp](https://app.ramp.com) as an Owner or Admin and go to + **Settings → Developer API**. +2. Click **Create new app**, give it a name (e.g. *Odoo statement import*), + and select the OAuth2 grant type **Client credentials**. +3. Grant the scopes `transactions:read` and `users:read`. (`users:read` lets + the cardholder name and email travel with each transaction in `raw_data`; + it is read-only.) +4. Copy the **Client ID** and **Client Secret**. The secret is shown once — + store it securely. + +## Configure Odoo + +1. Go to **Accounting → Configuration → Journals** and open or create a + bank-type journal whose default account is your Ramp credit-line liability + GL account. +2. In the **Online Synchronization** section, select **Ramp** as the service. +3. Paste your **Client ID** and **Client Secret**. +4. Set the **Ramp Environment** to *Sandbox* for `demo-api.ramp.com` + (Ramp's developer sandbox) or *Production* for `api.ramp.com`. +5. Set the **Synchronization Frequency** and save. +6. Click **Pull Now** (or let the scheduled activity run) to import + transactions. + +The bearer token is minted automatically on the first pull and cached on the +provider record. It refreshes a minute before its declared expiry, and on any +HTTP 401 it is dropped and re-minted transparently. diff --git a/account_statement_import_online_ramp/readme/CONTRIBUTORS.md b/account_statement_import_online_ramp/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..11a61dd91 --- /dev/null +++ b/account_statement_import_online_ramp/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Dan Kendall diff --git a/account_statement_import_online_ramp/readme/DESCRIPTION.md b/account_statement_import_online_ramp/readme/DESCRIPTION.md new file mode 100644 index 000000000..a774a3ede --- /dev/null +++ b/account_statement_import_online_ramp/readme/DESCRIPTION.md @@ -0,0 +1,22 @@ +Connects Odoo to [Ramp](https://ramp.com) using the +[Ramp Developer API](https://docs.ramp.com/developer-api) to automatically +pull corporate-card transactions into your accounting journals as bank +statement lines. + +**Ramp** is a US corporate-card and spend-management platform. A Ramp +organization has one credit line shared across all cards, billed as a single +monthly statement and paid via one ACH transfer. This module follows that +accounting reality: one Ramp credit line maps to **one** Odoo bank-type +journal, regardless of how many cards are issued under it. + +The module uses these endpoints: + +- `POST /developer/v1/token` — OAuth2 client_credentials, mints a short-lived + bearer that is cached on the provider record and refreshed on expiry or 401. +- `GET /developer/v1/transactions` — paginated card-transaction feed filtered + by date range; cursor pagination via the `page.next` field. + +Cardholder, card, merchant category, and Ramp accounting-category metadata are +preserved in each statement line's `raw_data` field so downstream automation +(reconcile rules, server actions, custom reports) can use them without a +second API call. diff --git a/account_statement_import_online_ramp/readme/ROADMAP.md b/account_statement_import_online_ramp/readme/ROADMAP.md new file mode 100644 index 000000000..a92c78d29 --- /dev/null +++ b/account_statement_import_online_ramp/readme/ROADMAP.md @@ -0,0 +1,13 @@ +- **Per-card journal routing** — for organizations with multiple credit lines + or that want per-card analytic attribution at the journal level, allow a + provider field to filter the transaction pull to a list of Ramp card UUIDs + (or by card last-4). +- **Cardholder as a first-class field** — add `ramp_card_last_four` and + `ramp_cardholder_user_id` (M2O `res.users`) to `account.bank.statement.line` + in a companion module so cardholder is queryable/groupable in standard + reports without parsing `raw_data`. +- **Reimbursements / bills** — extend coverage to `/developer/v1/reimbursements` + and `/developer/v1/bills` for organizations that use Ramp's full + spend-management feature set. +- **Webhook ingestion** — Ramp supports webhook delivery; a webhook receiver + would deliver near-real-time statement lines instead of polling on cron. diff --git a/account_statement_import_online_ramp/readme/USAGE.md b/account_statement_import_online_ramp/readme/USAGE.md new file mode 100644 index 000000000..3c4064d90 --- /dev/null +++ b/account_statement_import_online_ramp/readme/USAGE.md @@ -0,0 +1,22 @@ +Once configured, statement lines are created automatically in the linked +journal. Each line carries: + +- **Date** — `user_transaction_time` (falls back to `settlement_date`), + normalized to UTC. +- **Amount** — Ramp returns positive amounts on spend; the module flips the + sign so spend lands as a negative line on the credit-line liability journal + (matching the standard Odoo convention for bank-type journals). +- **Reference / Payment Reference** — `merchant_name` (falls back to + `merchant_descriptor` then `memo`). +- **Partner name** — `merchant_name` when present (no `res.partner` is auto- + created; reconcile rules can match against the name). +- **Raw data** — the full Ramp transaction payload as JSON, including + `card_id`, `user_id`, `sk_category_name`, `accounting_categories`, and + any other fields Ramp returns. + +Transactions in state `DECLINED`, `PENDING_INITIATION`, or `ERROR` are +skipped — they do not post to the credit line and would otherwise produce +statement lines that need manual deletion. + +Transactions are deduplicated by Ramp's transaction UUID, so pulling the +same date range twice will not create duplicate statement lines. diff --git a/account_statement_import_online_ramp/static/description/index.html b/account_statement_import_online_ramp/static/description/index.html new file mode 100644 index 000000000..ebec4e320 --- /dev/null +++ b/account_statement_import_online_ramp/static/description/index.html @@ -0,0 +1,531 @@ + + + + + +Online Bank Statements: Ramp + + + +
+

Online Bank Statements: Ramp

+ + +

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

+

Connects Odoo to Ramp using the Ramp Developer +API to automatically pull +corporate-card transactions into your accounting journals as bank +statement lines.

+

Ramp is a US corporate-card and spend-management platform. A Ramp +organization has one credit line shared across all cards, billed as a +single monthly statement and paid via one ACH transfer. This module +follows that accounting reality: one Ramp credit line maps to one +Odoo bank-type journal, regardless of how many cards are issued under +it.

+

The module uses these endpoints:

+
    +
  • POST /developer/v1/token — OAuth2 client_credentials, mints a +short-lived bearer that is cached on the provider record and refreshed +on expiry or 401.
  • +
  • GET /developer/v1/transactions — paginated card-transaction feed +filtered by date range; cursor pagination via the page.next field.
  • +
+

Cardholder, card, merchant category, and Ramp accounting-category +metadata are preserved in each statement line’s raw_data field so +downstream automation (reconcile rules, server actions, custom reports) +can use them without a second API call.

+

Table of contents

+ +
+

Configuration

+
+

Generate Ramp API credentials

+
    +
  1. Log in to Ramp as an Owner or Admin and go +to Settings → Developer API.
  2. +
  3. Click Create new app, give it a name (e.g. Odoo statement +import), and select the OAuth2 grant type Client credentials.
  4. +
  5. Grant the scopes transactions:read and users:read. +(users:read lets the cardholder name and email travel with each +transaction in raw_data; it is read-only.)
  6. +
  7. Copy the Client ID and Client Secret. The secret is shown +once — store it securely.
  8. +
+
+
+

Configure Odoo

+
    +
  1. Go to Accounting → Configuration → Journals and open or create a +bank-type journal whose default account is your Ramp credit-line +liability GL account.
  2. +
  3. In the Online Synchronization section, select Ramp as the +service.
  4. +
  5. Paste your Client ID and Client Secret.
  6. +
  7. Set the Ramp Environment to Sandbox for demo-api.ramp.com +(Ramp’s developer sandbox) or Production for api.ramp.com.
  8. +
  9. Set the Synchronization Frequency and save.
  10. +
  11. Click Pull Now (or let the scheduled activity run) to import +transactions.
  12. +
+

The bearer token is minted automatically on the first pull and cached on +the provider record. It refreshes a minute before its declared expiry, +and on any HTTP 401 it is dropped and re-minted transparently.

+
+
+
+

Usage

+

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

+
    +
  • Dateuser_transaction_time (falls back to +settlement_date), normalized to UTC.
  • +
  • Amount — Ramp returns positive amounts on spend; the module flips +the sign so spend lands as a negative line on the credit-line +liability journal (matching the standard Odoo convention for bank-type +journals).
  • +
  • Reference / Payment Referencemerchant_name (falls back to +merchant_descriptor then memo).
  • +
  • Partner namemerchant_name when present (no res.partner +is auto- created; reconcile rules can match against the name).
  • +
  • Raw data — the full Ramp transaction payload as JSON, including +card_id, user_id, sk_category_name, +accounting_categories, and any other fields Ramp returns.
  • +
+

Transactions in state DECLINED, PENDING_INITIATION, or ERROR +are skipped — they do not post to the credit line and would otherwise +produce statement lines that need manual deletion.

+

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

+
+
+

Known issues / Roadmap

+
    +
  • Per-card journal routing — for organizations with multiple credit +lines or that want per-card analytic attribution at the journal level, +allow a provider field to filter the transaction pull to a list of +Ramp card UUIDs (or by card last-4).
  • +
  • Cardholder as a first-class field — add ramp_card_last_four +and ramp_cardholder_user_id (M2O res.users) to +account.bank.statement.line in a companion module so cardholder is +queryable/groupable in standard reports without parsing raw_data.
  • +
  • Reimbursements / bills — extend coverage to +/developer/v1/reimbursements and /developer/v1/bills for +organizations that use Ramp’s full spend-management feature set.
  • +
  • Webhook ingestion — Ramp supports webhook delivery; a webhook +receiver would deliver near-real-time statement lines instead of +polling on cron.
  • +
+
+
+

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_ramp/tests/__init__.py b/account_statement_import_online_ramp/tests/__init__.py new file mode 100644 index 000000000..346f74de6 --- /dev/null +++ b/account_statement_import_online_ramp/tests/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2026 Ledo Enterprises +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from . import test_ramp_provider # noqa: F401 diff --git a/account_statement_import_online_ramp/tests/test_ramp_provider.py b/account_statement_import_online_ramp/tests/test_ramp_provider.py new file mode 100644 index 000000000..3a9cb0f6c --- /dev/null +++ b/account_statement_import_online_ramp/tests/test_ramp_provider.py @@ -0,0 +1,613 @@ +# Copyright 2026 Ledo Enterprises +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from datetime import datetime, timedelta, timezone +from unittest.mock import MagicMock, Mock, patch + +from odoo.exceptions import UserError +from odoo.tests.common import TransactionCase + + +def _utcnow(): + """Match the module's naive-UTC convention without using deprecated utcnow.""" + return datetime.now(timezone.utc).replace(tzinfo=None) + + +SAMPLE_TOKEN = { + "access_token": "ramp-test-bearer-001", + "token_type": "Bearer", + "expires_in": 7200, + "scope": "transactions:read users:read", +} + +SAMPLE_TXNS = { + "data": [ + { + "id": "txn-uuid-001", + "amount": 42.50, + "state": "CLEARED", + "merchant_name": "Acme Coffee", + "merchant_descriptor": "ACME COFFEE #4421", + "user_transaction_time": "2026-03-01T14:30:00.000Z", + "settlement_date": "2026-03-02", + "card_id": "card-uuid-001", + "user_id": "user-uuid-alice", + "sk_category_name": "Restaurants", + }, + { + "id": "txn-uuid-002", + "amount": 199.00, + "state": "CLEARED", + "merchant_name": "Cloudy Hosting Inc.", + "merchant_descriptor": "CLOUDY*HOST", + "user_transaction_time": "2026-03-05T09:15:00.000Z", + "card_id": "card-uuid-002", + "user_id": "user-uuid-bob", + "sk_category_name": "Computer Services", + }, + { + "id": "txn-uuid-declined", + "amount": 1000.00, + "state": "DECLINED", + "merchant_name": "Suspicious Co", + "user_transaction_time": "2026-03-10T00:00:00.000Z", + }, + ], + "page": {"next": None}, +} + +# Two-page dataset for pagination tests +PAGE_1 = { + "data": [ + { + "id": f"txn-page1-{i:03d}", + "amount": float(i + 1), + "state": "CLEARED", + "merchant_name": f"Merchant {i}", + "user_transaction_time": "2026-03-01T00:00:00.000Z", + } + for i in range(100) + ], + "page": { + "next": "https://demo-api.ramp.com/developer/v1/transactions?start=cursor-abc" + }, +} + +PAGE_2 = { + "data": [ + { + "id": "txn-page2-001", + "amount": 5.0, + "state": "CLEARED", + "merchant_name": "Last Merchant", + "user_transaction_time": "2026-03-15T00:00:00.000Z", + } + ], + "page": {"next": None}, +} + +_journal_seq = [0] + + +class TestRampProvider(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.journal = cls.env["account.journal"].create( + {"name": "Ramp Test Card", "type": "bank", "code": "RMPTEST"} + ) + cls.provider = cls.env["online.bank.statement.provider"].create( + { + "journal_id": cls.journal.id, + "service": "ramp", + "username": "test-client-id", + "password": "test-client-secret", + "ramp_host": "sandbox", + "ramp_access_token": "preset-bearer", + "ramp_token_expiry": _utcnow() + timedelta(hours=1), + } + ) + + def _new_provider(self, **vals): + """Create a provider with a fresh journal — journal_id is unique.""" + _journal_seq[0] += 1 + journal = self.env["account.journal"].create( + { + "name": f"Ramp Test {_journal_seq[0]}", + "type": "bank", + "code": f"RMP{_journal_seq[0]:02d}", + } + ) + base = { + "journal_id": journal.id, + "service": "ramp", + "username": "test-client-id", + "password": "test-client-secret", + "ramp_host": "sandbox", + "ramp_access_token": "preset-bearer", + "ramp_token_expiry": _utcnow() + timedelta(hours=1), + } + base.update(vals) + return self.env["online.bank.statement.provider"].create(base) + + @staticmethod + def _mock_get(session, url, params=None): + """Side-effect for _ramp_get: dispatch by URL.""" + if "/developer/v1/transactions" in url: + return SAMPLE_TXNS + return {} + + # ------------------------------------------------------------------ + # Service registration + # ------------------------------------------------------------------ + + def test_service_registered(self): + services = dict(self.provider._get_available_services()) + self.assertIn("ramp", services) + + # ------------------------------------------------------------------ + # Core data fetch + state filtering + # ------------------------------------------------------------------ + + def test_obtain_statement_data_drops_declined(self): + date_since = datetime(2026, 3, 1) + date_until = datetime(2026, 3, 31) + with patch.object(type(self.provider), "_ramp_get", side_effect=self._mock_get): + lines, meta = self.provider._obtain_statement_data(date_since, date_until) + # 3 fixtures, 1 declined → 2 lines + self.assertEqual(len(lines), 2) + self.assertEqual(meta, {}) + ids = {line["unique_import_id"] for line in lines} + self.assertEqual(ids, {"txn-uuid-001", "txn-uuid-002"}) + + def test_obtain_statement_data_delegates_non_ramp(self): + date_since = datetime(2026, 3, 1) + date_until = datetime(2026, 3, 31) + dummy = self._new_provider(service="dummy") + result = dummy._obtain_statement_data(date_since, date_until) + self.assertIsNotNone(result) + + # ------------------------------------------------------------------ + # Pagination + # ------------------------------------------------------------------ + + def test_pagination_fetches_all_pages(self): + call_count = [0] + + def _paged_get(session, url, params=None): + call_count[0] += 1 + if call_count[0] == 1: + return PAGE_1 + return PAGE_2 + + with patch.object(type(self.provider), "_ramp_get", side_effect=_paged_get): + txns = self.provider._ramp_fetch_transactions( + datetime(2026, 3, 1), datetime(2026, 3, 31) + ) + self.assertEqual(len(txns), 101) + self.assertEqual(call_count[0], 2) + + def test_pagination_uses_next_url_without_params(self): + """Page 2 must use the cursor URL as-is and pass params=None. + + Ramp's `next` URL already contains the encoded filters; re-sending + `params` would double-encode them. + """ + captured = [] + + def _capture_get(session, url, params=None): + captured.append((url, params)) + if len(captured) == 1: + return PAGE_1 + return PAGE_2 + + with patch.object(type(self.provider), "_ramp_get", side_effect=_capture_get): + self.provider._ramp_fetch_transactions( + datetime(2026, 3, 1), datetime(2026, 3, 31) + ) + + self.assertEqual(len(captured), 2) + # First call: relative path + params dict with from_date/to_date/page_size + first_url, first_params = captured[0] + self.assertEqual(first_url, "/developer/v1/transactions") + self.assertIn("from_date", first_params) + self.assertIn("to_date", first_params) + self.assertEqual(first_params["page_size"], 100) + # Second call: full cursor URL + params=None + second_url, second_params = captured[1] + self.assertTrue(second_url.startswith("https://demo-api.ramp.com")) + self.assertIn("start=cursor-abc", second_url) + self.assertIsNone(second_params) + + # ------------------------------------------------------------------ + # Mapping + # ------------------------------------------------------------------ + + def test_transaction_mapping_flips_sign(self): + txn = SAMPLE_TXNS["data"][0] + line = self.provider._ramp_transaction_to_line(txn) + self.assertEqual(line["unique_import_id"], "txn-uuid-001") + # Ramp 42.50 spend → -42.50 on a CC liability journal + self.assertAlmostEqual(line["amount"], -42.50) + self.assertEqual(line["partner_name"], "Acme Coffee") + self.assertEqual(line["ref"], "Acme Coffee") + self.assertIsInstance(line["date"], datetime) + # raw_data preserves cardholder/card so downstream automation can use it + import json as _json + + raw = _json.loads(line["raw_data"]) + self.assertEqual(raw["card_id"], "card-uuid-001") + self.assertEqual(raw["user_id"], "user-uuid-alice") + + def test_transaction_mapping_no_merchant(self): + txn = { + "id": "txn-empty", + "amount": 1.0, + "user_transaction_time": "2026-03-01T00:00:00.000Z", + } + line = self.provider._ramp_transaction_to_line(txn) + self.assertEqual(line["ref"], "/") + self.assertNotIn("partner_name", line) + + # ------------------------------------------------------------------ + # Datetime parsing + # ------------------------------------------------------------------ + + def test_parse_datetime_z_suffix(self): + dt = self.provider._ramp_parse_datetime("2026-03-15T14:30:00.000Z") + self.assertIsInstance(dt, datetime) + self.assertIsNone(dt.tzinfo) + + def test_parse_datetime_offset(self): + dt = self.provider._ramp_parse_datetime("2026-03-15T14:30:00+00:00") + self.assertIsInstance(dt, datetime) + self.assertIsNone(dt.tzinfo) + + def test_parse_datetime_none(self): + dt = self.provider._ramp_parse_datetime(None) + self.assertIsInstance(dt, datetime) + + def test_parse_datetime_malformed_falls_back_to_now(self): + dt = self.provider._ramp_parse_datetime("not-a-date") + self.assertIsInstance(dt, datetime) + + # ------------------------------------------------------------------ + # OAuth2 token cache + # ------------------------------------------------------------------ + + def test_token_cache_hit(self): + """A valid cached token short-circuits the network call.""" + provider = self._new_provider( + ramp_access_token="cached-bearer", + ramp_token_expiry=_utcnow() + timedelta(hours=1), + ) + with patch.object(type(provider), "_ramp_fetch_access_token") as mock_fetch: + token = provider._ramp_get_access_token() + mock_fetch.assert_not_called() + self.assertEqual(token, "cached-bearer") + + def test_token_cache_miss_when_expired(self): + """A token within the safety window is treated as stale.""" + provider = self._new_provider( + ramp_access_token="stale-bearer", + # 30s left — inside the 60s safety window + ramp_token_expiry=_utcnow() + timedelta(seconds=30), + ) + with patch.object( + type(provider), + "_ramp_fetch_access_token", + return_value="fresh-bearer", + ) as mock_fetch: + token = provider._ramp_get_access_token() + mock_fetch.assert_called_once() + self.assertEqual(token, "fresh-bearer") + + def test_token_cache_miss_when_blank(self): + provider = self._new_provider(ramp_access_token=False, ramp_token_expiry=False) + with patch.object( + type(provider), + "_ramp_fetch_access_token", + return_value="fresh-bearer", + ) as mock_fetch: + token = provider._ramp_get_access_token() + mock_fetch.assert_called_once() + self.assertEqual(token, "fresh-bearer") + + def _mock_session_with_response(self, mock_resp): + """Build a MagicMock that mimics ``with requests.Session() as session``. + + ``requests.Session`` is used as a context manager in the token mint + path, so the patched factory must return an object whose ``__enter__`` + yields itself (and whose ``.post`` returns the supplied response). + """ + mock_session = MagicMock() + mock_session.__enter__.return_value = mock_session + mock_session.__exit__.return_value = False + mock_session.post.return_value = mock_resp + return mock_session + + def test_fetch_token_persists_to_record(self): + """Successful token mint writes access_token + expiry back to the row.""" + provider = self._new_provider(ramp_access_token=False, ramp_token_expiry=False) + mock_resp = Mock(ok=True, status_code=200) + mock_resp.json.return_value = SAMPLE_TOKEN + mock_session = self._mock_session_with_response(mock_resp) + _SESSION = ( + "odoo.addons.account_statement_import_online_ramp" + ".models.online_bank_statement_provider_ramp.requests.Session" + ) + with patch(_SESSION, return_value=mock_session): + token = provider._ramp_fetch_access_token() + self.assertEqual(token, "ramp-test-bearer-001") + self.assertEqual(provider.ramp_access_token, "ramp-test-bearer-001") + self.assertGreater(provider.ramp_token_expiry, _utcnow()) + + def test_fetch_token_missing_credentials_raises(self): + # Empty cache so the credential check is reached — _ramp_fetch_access_token + # short-circuits on a valid cached token (post-lock double-check). + provider = self._new_provider( + username=False, + password=False, + ramp_access_token=False, + ramp_token_expiry=False, + ) + with self.assertRaises(UserError): + provider._ramp_fetch_access_token() + + def test_fetch_token_rejected_credentials_raises(self): + provider = self._new_provider(ramp_access_token=False, ramp_token_expiry=False) + mock_resp = Mock(ok=False, status_code=401, text="Unauthorized") + mock_session = self._mock_session_with_response(mock_resp) + _SESSION = ( + "odoo.addons.account_statement_import_online_ramp" + ".models.online_bank_statement_provider_ramp.requests.Session" + ) + with patch(_SESSION, return_value=mock_session): + with self.assertRaises(UserError) as ctx: + provider._ramp_fetch_access_token() + self.assertIn("401", str(ctx.exception)) + + # ------------------------------------------------------------------ + # _ramp_get — error handling and 401 retry + # ------------------------------------------------------------------ + + def test_get_401_refreshes_token_and_retries(self): + """A 401 on a data call drops the cache, mints fresh, retries once.""" + provider = self._new_provider() + first_resp = Mock(ok=False, status_code=401, text="Token expired") + second_resp = Mock(ok=True, status_code=200) + second_resp.json.return_value = {"data": [], "page": {"next": None}} + session = Mock() + session.get.side_effect = [first_resp, second_resp] + session.headers = {} + + with patch.object( + type(provider), + "_ramp_get_access_token", + return_value="fresh-bearer", + ) as mock_token: + data = provider._ramp_get(session, "/developer/v1/transactions") + + self.assertEqual(data, {"data": [], "page": {"next": None}}) + # Cache cleared then refilled (one refresh call after the 401) + mock_token.assert_called_once() + self.assertEqual(session.headers["Authorization"], "Bearer fresh-bearer") + self.assertEqual(session.get.call_count, 2) + + def test_get_non_2xx_raises(self): + provider = self._new_provider() + resp = Mock(ok=False, status_code=500, text="Internal Server Error") + session = Mock() + session.get.return_value = resp + with self.assertRaises(UserError) as ctx: + provider._ramp_get(session, "/developer/v1/transactions") + self.assertIn("500", str(ctx.exception)) + + def test_get_network_error_raises(self): + import requests as req_lib + + provider = self._new_provider() + session = Mock() + session.get.side_effect = req_lib.RequestException("connection timeout") + with self.assertRaises(UserError) as ctx: + provider._ramp_get(session, "/developer/v1/transactions") + self.assertIn("connection timeout", str(ctx.exception)) + + # ------------------------------------------------------------------ + # Base URL + # ------------------------------------------------------------------ + + def test_base_url_sandbox(self): + provider = self._new_provider(ramp_host="sandbox") + self.assertEqual(provider._ramp_base_url(), "https://demo-api.ramp.com") + + def test_base_url_production(self): + provider = self._new_provider(ramp_host="production") + self.assertEqual(provider._ramp_base_url(), "https://api.ramp.com") + + # ------------------------------------------------------------------ + # Session + # ------------------------------------------------------------------ + + def test_session_uses_bearer_header(self): + mock_session = MagicMock() + _SESSION_CLS = ( + "odoo.addons.account_statement_import_online_ramp" + ".models.online_bank_statement_provider_ramp.requests.Session" + ) + with patch(_SESSION_CLS, return_value=mock_session): + self.provider._ramp_session() + called_headers = mock_session.headers.update.call_args[0][0] + self.assertEqual(called_headers["Authorization"], "Bearer preset-bearer") + self.assertEqual(called_headers["Accept"], "application/json") + # Retry adapter mounted on https:// — confirms transient 5xx will retry. + mount_calls = mock_session.mount.call_args_list + self.assertTrue( + any(call.args[0] == "https://" for call in mount_calls), + "expected session.mount('https://', ...) to install retry adapter", + ) + + # ------------------------------------------------------------------ + # Safe-URL guard (SSRF / token-leak defense) + # ------------------------------------------------------------------ + + def test_assert_safe_url_accepts_matching_host(self): + """Sandbox provider accepts demo-api.ramp.com cursor URLs.""" + provider = self._new_provider(ramp_host="sandbox") + # No raise = pass. + provider._ramp_assert_safe_url( + "https://demo-api.ramp.com/developer/v1/transactions?start=abc" + ) + + def test_assert_safe_url_rejects_foreign_host(self): + """Cursor URL pointing at another host is rejected.""" + provider = self._new_provider(ramp_host="sandbox") + with self.assertRaises(UserError) as ctx: + provider._ramp_assert_safe_url("https://attacker.example/steal") + self.assertIn("unexpected host", str(ctx.exception)) + + def test_assert_safe_url_rejects_http(self): + """Plain HTTP is rejected — no bearer over cleartext, even to Ramp's host.""" + provider = self._new_provider(ramp_host="sandbox") + with self.assertRaises(UserError): + provider._ramp_assert_safe_url( + "http://demo-api.ramp.com/developer/v1/transactions" + ) + + def test_assert_safe_url_rejects_cross_env(self): + """Sandbox provider rejects a production-host cursor URL and vice versa.""" + sandbox = self._new_provider(ramp_host="sandbox") + with self.assertRaises(UserError): + sandbox._ramp_assert_safe_url( + "https://api.ramp.com/developer/v1/transactions" + ) + prod = self._new_provider(ramp_host="production") + with self.assertRaises(UserError): + prod._ramp_assert_safe_url( + "https://demo-api.ramp.com/developer/v1/transactions" + ) + + def test_assert_safe_url_accepts_explicit_port(self): + """``https://demo-api.ramp.com:443/...`` is the same host — accept it.""" + provider = self._new_provider(ramp_host="sandbox") + provider._ramp_assert_safe_url( + "https://demo-api.ramp.com:443/developer/v1/transactions?start=abc" + ) + + def test_assert_safe_url_accepts_mixed_case_host(self): + """Hostnames are case-insensitive per RFC 3986.""" + provider = self._new_provider(ramp_host="sandbox") + provider._ramp_assert_safe_url( + "https://Demo-Api.Ramp.Com/developer/v1/transactions?start=abc" + ) + + def test_assert_safe_url_rejects_user_authority_bypass(self): + """``https://demo-api.ramp.com@evil.com/...`` resolves to evil.com.""" + provider = self._new_provider(ramp_host="sandbox") + with self.assertRaises(UserError): + provider._ramp_assert_safe_url( + "https://demo-api.ramp.com@evil.com/developer/v1/transactions" + ) + + def test_get_rejects_hijacked_next_url(self): + """A cursor URL pointing at a non-Ramp host fails before the GET.""" + provider = self._new_provider(ramp_host="sandbox") + session = Mock() + with self.assertRaises(UserError): + provider._ramp_get( + session, "https://attacker.example/transactions?start=abc" + ) + # Crucially, session.get must NOT have been called — the bearer + # never leaves the process. + session.get.assert_not_called() + + # ------------------------------------------------------------------ + # Token-response edge cases + # ------------------------------------------------------------------ + + def test_fetch_token_uses_default_expiry_when_missing(self): + """A response without ``expires_in`` falls back to a 2h default.""" + provider = self._new_provider(ramp_access_token=False, ramp_token_expiry=False) + mock_resp = Mock(ok=True, status_code=200) + mock_resp.json.return_value = {"access_token": "minimal-bearer"} + mock_session = self._mock_session_with_response(mock_resp) + _SESSION = ( + "odoo.addons.account_statement_import_online_ramp" + ".models.online_bank_statement_provider_ramp.requests.Session" + ) + with patch(_SESSION, return_value=mock_session): + token = provider._ramp_fetch_access_token() + self.assertEqual(token, "minimal-bearer") + # 2h default = 7200s. Expiry should be between now+1h and now+3h. + now = _utcnow() + self.assertGreater(provider.ramp_token_expiry, now + timedelta(hours=1)) + self.assertLess(provider.ramp_token_expiry, now + timedelta(hours=3)) + + def test_fetch_token_handles_null_expires_in(self): + """A response with ``expires_in: null`` must not crash on int(None).""" + provider = self._new_provider(ramp_access_token=False, ramp_token_expiry=False) + mock_resp = Mock(ok=True, status_code=200) + mock_resp.json.return_value = { + "access_token": "null-expiry-bearer", + "expires_in": None, + } + mock_session = self._mock_session_with_response(mock_resp) + _SESSION = ( + "odoo.addons.account_statement_import_online_ramp" + ".models.online_bank_statement_provider_ramp.requests.Session" + ) + with patch(_SESSION, return_value=mock_session): + token = provider._ramp_fetch_access_token() + self.assertEqual(token, "null-expiry-bearer") + + def test_fetch_token_missing_access_token_raises(self): + """A response without ``access_token`` is a clear UserError.""" + provider = self._new_provider(ramp_access_token=False, ramp_token_expiry=False) + mock_resp = Mock(ok=True, status_code=200) + mock_resp.json.return_value = {"expires_in": 7200} # no access_token + mock_session = self._mock_session_with_response(mock_resp) + _SESSION = ( + "odoo.addons.account_statement_import_online_ramp" + ".models.online_bank_statement_provider_ramp.requests.Session" + ) + with patch(_SESSION, return_value=mock_session): + with self.assertRaises(UserError) as ctx: + provider._ramp_fetch_access_token() + self.assertIn("access_token", str(ctx.exception)) + + # ------------------------------------------------------------------ + # Pagination safeguards + # ------------------------------------------------------------------ + + def test_pagination_max_pages_raises(self): + """A cursor that never terminates is bounded by _MAX_PAGES.""" + from ..models import online_bank_statement_provider_ramp as _mod + + # Always return "there's more" so the loop would run forever + # without the safeguard. + infinite_page = { + "data": [ + { + "id": "txn-loop", + "amount": 1.0, + "state": "CLEARED", + "user_transaction_time": "2026-03-01T00:00:00.000Z", + } + ], + "page": { + "next": "https://demo-api.ramp.com/developer/v1/transactions?start=x" + }, + } + # Temporarily lower the cap so the test runs fast. + original = _mod._MAX_PAGES + _mod._MAX_PAGES = 5 + try: + with patch.object( + type(self.provider), "_ramp_get", return_value=infinite_page + ): + with self.assertRaises(UserError) as ctx: + self.provider._ramp_fetch_transactions( + datetime(2026, 3, 1), datetime(2026, 3, 31) + ) + finally: + _mod._MAX_PAGES = original + self.assertIn("did not terminate", str(ctx.exception)) diff --git a/account_statement_import_online_ramp/views/online_bank_statement_provider_views.xml b/account_statement_import_online_ramp/views/online_bank_statement_provider_views.xml new file mode 100644 index 000000000..0d04786ac --- /dev/null +++ b/account_statement_import_online_ramp/views/online_bank_statement_provider_views.xml @@ -0,0 +1,34 @@ + + + + online.bank.statement.provider.ramp.form + online.bank.statement.provider + + + + + + + + + + + + +