From 19c3536ef0b473afe64df38d9df011b866f2a3e2 Mon Sep 17 00:00:00 2001 From: orangebtc Date: Tue, 13 Oct 2026 14:21:30 +0400 Subject: [PATCH] Support Odoo 18 and 19 in one payment provider addon --- .github/workflows/validate.yml | 18 ++++-- CHANGELOG.md | 9 +++ CONTRIBUTING.md | 2 +- README.md | 14 ++-- docs/REPOSITORY_PROTECTION.md | 1 + docs/ROADMAP.md | 1 - makepay_odoo/__manifest__.py | 2 +- makepay_odoo/controllers/main.py | 6 +- makepay_odoo/models/payment_transaction.py | 74 ++++++++++++++++------ makepay_odoo/tests/test_payment.py | 69 ++++++++++++++++++++ 10 files changed, 163 insertions(+), 33 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 5588e72..7e2e41a 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -23,8 +23,17 @@ jobs: - name: Validate module run: python scripts/validate.py - odoo18: + odoo: + name: odoo (${{ matrix.version }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - version: '18.0' + without_demo: all + - version: '19.0' + without_demo: 'True' services: postgres: image: postgres:16-alpine @@ -43,11 +52,12 @@ jobs: - name: Checkout uses: actions/checkout@v5 - - name: Install and test in Odoo 18 + - name: Install and test in Odoo ${{ matrix.version }} run: | docker run --rm --network host --entrypoint odoo \ -v "$GITHUB_WORKSPACE:/mnt/extra-addons:ro" \ - odoo:18.0 \ + odoo:${{ matrix.version }} \ --db_host=127.0.0.1 --db_user=odoo --db_password=odoo-test \ -d makepay_test -i makepay_odoo --test-enable --test-tags /makepay_odoo \ - --stop-after-init --without-demo=all --http-interface=127.0.0.1 --log-level=test + --stop-after-init --without-demo=${{ matrix.without_demo }} \ + --http-interface=127.0.0.1 --log-level=test diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c491fc..0182e50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 1.1.0 — 2026-10-13 + +- Support Odoo 18 and 19 from the same addon and `main` branch. +- Use Odoo 19's transaction lookup and payment update pipeline while retaining Odoo 18's notification hooks. +- Reconcile signed lifecycle events by hosted link UID without comparing crypto settlement amounts to the order currency. +- Use a series-independent manifest version, normalized by Odoo to `18.0.1.1.0` or `19.0.1.1.0`. +- Fix checkout error translation when MakePay returns a link UID without a redirect URL. +- Run installation, checkout rendering, and webhook regression tests in both Odoo versions. + ## 18.0.1.0.1 — 2026-10-13 - Fix unauthenticated payment-state changes through the browser return route. Return parameters no longer reconcile transactions; payment confirmation requires a signed webhook. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0c97df9..f6f3e6e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,6 +10,6 @@ Run the module's HTTP regression suite with: odoo -d makepay_test -i makepay_odoo --test-enable --test-tags /makepay_odoo --stop-after-init --without-demo=all ``` -Add this repository to Odoo's addons path and use a disposable development database. GitHub Actions runs this suite in Odoo 18 with PostgreSQL 16. +Add this repository to Odoo's addons path and use a disposable development database. Run the suite in both Odoo 18 and Odoo 19; GitHub Actions tests both with PostgreSQL 16. Keep the manifest version independent of the Odoo series so the same addon installs in both versions. Do not commit MakePay keys, Odoo database dumps, production transaction payloads, webhook signing secrets, or customer data. diff --git a/README.md b/README.md index f4b066d..ed9751e 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,10 @@ The module adds a MakePay provider, creates hosted payment links for Odoo transa ## Target Versions -- Odoo 18 payment provider API -- Odoo 19 is not supported by this release; its payment notification hooks differ. +- Odoo 18 and Odoo 19, using the same addon and `main` branch. +- Version `1.1.0` uses Odoo's version normalization: `18.0.1.1.0` on Odoo 18 and `19.0.1.1.0` on Odoo 19. No manifest edits or separate version branches are needed. + +Install on Odoo.sh or a self-hosted Odoo instance that allows custom addons. Add this repository to the addons path, update the Apps list, and install **MakePay Payment Provider**. ## Configuration @@ -30,10 +32,12 @@ https://your-odoo.example/payment/makepay/webhook python scripts/validate.py ``` -The validator compiles Python files, checks XML data files, verifies required module metadata, and scans for forbidden identifiers and committed secrets. +The validator checks Python syntax and XML data files and scans for forbidden identifiers and committed secrets. GitHub Actions also installs the addon and runs its checkout and HTTP webhook regression suite against both Odoo 18 and 19 with PostgreSQL 16. ## Production Notes -Upgrade installed modules to **18.0.1.0.1**. Browser return URLs only redirect to Odoo's payment status page; signed MakePay webhooks are required to confirm payments. Configure the webhook URL and secret on both sides before accepting payments. A customer may see a pending status until the webhook arrives. +Upgrade installed modules to **1.1.0** (shown as **18.0.1.1.0** or **19.0.1.1.0** by Odoo). Existing Odoo 18 installations can upgrade the addon in place. Moving an entire Odoo database from 18 to 19 still requires the usual Odoo database migration. + +Browser return URLs only redirect to Odoo's payment status page; signed MakePay webhooks are required to confirm payments. Configure the webhook URL and secret on both sides before accepting payments. A customer may see a pending status until the webhook arrives. -Run this module in an Odoo development database before production use. The provider hooks follow Odoo 18's payment provider extension points. +The addon uses Odoo 18's notification hooks or Odoo 19's payment processing hooks automatically. MakePay lifecycle events can contain crypto settlement amounts rather than the original order currency, so Odoo 19's generic amount comparison is skipped for MakePay. Reconciliation uses the signed payment status and the hosted link UID stored when the transaction's payment link was created. diff --git a/docs/REPOSITORY_PROTECTION.md b/docs/REPOSITORY_PROTECTION.md index 2a399a1..9cf2cd8 100644 --- a/docs/REPOSITORY_PROTECTION.md +++ b/docs/REPOSITORY_PROTECTION.md @@ -5,6 +5,7 @@ Recommended GitHub settings for `makepay-odoo`: - Require pull requests before merging to `main`. - Require one approval. - Require status check `validate`. +- Require status checks `odoo (18.0)` and `odoo (19.0)`. - Require branches to be up to date before merging. - Require conversation resolution. - Enforce branch protection for administrators. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f7daaf8..c268317 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -2,7 +2,6 @@ ## Next -- Run install and checkout flows in Odoo 18 and 19 development databases. - Add screenshots and configuration guidance for the Odoo Apps listing. - Add invoice-specific and sales-order-specific metadata mapping. diff --git a/makepay_odoo/__manifest__.py b/makepay_odoo/__manifest__.py index db4b9dc..6e0fcc3 100644 --- a/makepay_odoo/__manifest__.py +++ b/makepay_odoo/__manifest__.py @@ -1,6 +1,6 @@ { "name": "MakePay Payment Provider", - "version": "18.0.1.0.1", + "version": "1.1.0", "category": "Accounting/Payment Providers", "summary": "Accept MakePay hosted payment links in Odoo.", "author": "MakePay", diff --git a/makepay_odoo/controllers/main.py b/makepay_odoo/controllers/main.py index 62849ca..6e6f297 100644 --- a/makepay_odoo/controllers/main.py +++ b/makepay_odoo/controllers/main.py @@ -37,8 +37,8 @@ def makepay_webhook(self, **kwargs): return request.make_json_response({"error": "invalid payload"}, status=400) try: - tx_sudo = request.env["payment.transaction"].sudo()._get_tx_from_notification_data( - "makepay", payload, + tx_sudo = request.env["payment.transaction"].sudo()._makepay_get_tx_from_payment_data( + payload, ) except ValidationError: return request.make_json_response({"error": "missing payment link UID"}, status=400) @@ -52,7 +52,7 @@ def makepay_webhook(self, **kwargs): _logger.warning("MakePay webhook rejected because signature verification failed.") return request.make_json_response({"error": "invalid signature"}, status=401) - tx_sudo._process_notification_data(payload) + tx_sudo._makepay_process_payment_data(payload) return request.make_json_response({"ok": True, "reconciled": True}) @staticmethod diff --git a/makepay_odoo/models/payment_transaction.py b/makepay_odoo/models/payment_transaction.py index c4d855f..6f11352 100644 --- a/makepay_odoo/models/payment_transaction.py +++ b/makepay_odoo/models/payment_transaction.py @@ -35,17 +35,17 @@ def _get_specific_rendering_values(self, processing_values): }, } payment_link = self.provider_id._makepay_create_payment_link(payload) - uid = payment_link.get("uid") or payment_link.get("id") + link_uid = payment_link.get("uid") or payment_link.get("id") redirect_url = payment_link.get("checkoutUrl") or payment_link.get("publicUrl") - if not uid or not redirect_url: + if not link_uid or not redirect_url: raise ValidationError(_("MakePay did not return a payment link URL.")) self.write( { - "makepay_payment_link_uid": uid, + "makepay_payment_link_uid": link_uid, "makepay_payment_url": redirect_url, - "provider_reference": uid, + "provider_reference": link_uid, } ) @@ -58,33 +58,71 @@ def _get_specific_rendering_values(self, processing_values): return rendering_values def _get_tx_from_notification_data(self, provider_code, notification_data): - tx = super()._get_tx_from_notification_data(provider_code, notification_data) - if provider_code != "makepay" or tx: - return tx - - uid = self._makepay_payment_link_uid(notification_data) - if not uid: + """Odoo 18 notification lookup hook.""" + if provider_code != "makepay": + return super()._get_tx_from_notification_data(provider_code, notification_data) + return self._makepay_find_transaction(notification_data) + + def _search_by_reference(self, provider_code, payment_data): + """Odoo 19 lookup hook: MakePay identifies payments by the hosted link UID.""" + if provider_code != "makepay": + return super()._search_by_reference(provider_code, payment_data) + return self._makepay_find_transaction(payment_data) + + def _makepay_get_tx_from_payment_data(self, payment_data): + if hasattr(self, "_process"): + return self._search_by_reference("makepay", payment_data) + return self._get_tx_from_notification_data("makepay", payment_data) + + def _makepay_process_payment_data(self, payment_data): + """Use the installed Odoo version's payment processing entry point.""" + self.ensure_one() + if hasattr(self, "_process"): + return self._process("makepay", payment_data) + return self._handle_notification_data("makepay", payment_data) + + def _makepay_find_transaction(self, payment_data): + link_uid = self._makepay_payment_link_uid(payment_data) + if not link_uid: raise ValidationError(_("MakePay notification did not include a payment link UID.")) return self.search( [ ("provider_code", "=", "makepay"), "|", - ("makepay_payment_link_uid", "=", uid), - ("provider_reference", "=", uid), + ("makepay_payment_link_uid", "=", link_uid), + ("provider_reference", "=", link_uid), ], limit=1, ) def _process_notification_data(self, notification_data): - super()._process_notification_data(notification_data) + """Odoo 18 payment update hook.""" + if self.provider_code != "makepay": + return super()._process_notification_data(notification_data) + self._makepay_apply_payment_data(notification_data) + + def _extract_amount_data(self, payment_data): + """Odoo 19 amount validation hook.""" if self.provider_code != "makepay": - return + return super()._extract_amount_data(payment_data) + # Lifecycle events do not guarantee an amount in the Odoo transaction's currency. + # Reconcile the signed status of the link created for this transaction instead. + return None + + def _apply_updates(self, payment_data): + """Odoo 19 payment update hook.""" + if self.provider_code != "makepay": + return super()._apply_updates(payment_data) + self._makepay_apply_payment_data(payment_data) + + def _makepay_apply_payment_data(self, notification_data): + self.ensure_one() - uid = self._makepay_payment_link_uid(notification_data) - if uid and not self.makepay_payment_link_uid: - self.makepay_payment_link_uid = uid - self.provider_reference = uid + link_uid = self._makepay_payment_link_uid(notification_data) + if link_uid and not self.makepay_payment_link_uid: + self.makepay_payment_link_uid = link_uid + self.provider_reference = link_uid if self._makepay_is_paid(notification_data): self._set_done() diff --git a/makepay_odoo/tests/test_payment.py b/makepay_odoo/tests/test_payment.py index c5828a8..5a2279b 100644 --- a/makepay_odoo/tests/test_payment.py +++ b/makepay_odoo/tests/test_payment.py @@ -4,6 +4,8 @@ from hashlib import sha256 from unittest.mock import patch +from odoo import release +from odoo.exceptions import ValidationError from odoo.tests import tagged from odoo.addons.payment.tests.http_common import PaymentHttpCommon @@ -160,3 +162,70 @@ def test_checkout_creates_and_renders_hosted_link(self): self.assertEqual(self.tx.makepay_payment_link_uid, "checkout-link") self.assertEqual(self.tx.provider_reference, "checkout-link") self.assertEqual(self.tx.state, "pending") + + def test_manifest_version_matches_installed_odoo_series(self): + module = self.env["ir.module.module"].search([("name", "=", "makepay_odoo")]) + self.assertEqual(module.latest_version, f"{release.major_version}.1.1.0") + + def test_signed_webhooks_match_supported_link_identifiers(self): + for identity in ( + {"paymentLink": {"id": "test-link-uid"}}, + {"data": {"paymentLinkUid": "test-link-uid"}}, + {"data": {"uid": "test-link-uid"}}, + {"reference": "test-link-uid"}, + ): + with self.subTest(identity=identity): + self.tx.state = "pending" + payload = {"session": {"status": "complete"}, **identity} + response = self._post_webhook(payload) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"ok": True, "reconciled": True}) + self.tx.invalidate_recordset() + self.assertEqual(self.tx.state, "done") + + def test_signed_webhook_matches_existing_provider_reference(self): + self.tx.makepay_payment_link_uid = False + response = self._post_webhook(self._payload()) + self.assertEqual(response.status_code, 200) + self.tx.invalidate_recordset() + self.assertEqual(self.tx.state, "done") + self.assertEqual(self.tx.makepay_payment_link_uid, "test-link-uid") + + def test_native_payment_pipeline_reconciles_by_link_uid(self): + transactions = self.env["payment.transaction"] + payload = self._payload() + if hasattr(transactions, "_process"): + tx = transactions._process("makepay", payload) + else: + tx = transactions._handle_notification_data("makepay", payload) + self.assertEqual(tx, self.tx) + self.assertEqual(tx.state, "done") + + def test_other_provider_lookup_uses_native_behavior(self): + transactions = self.env["payment.transaction"] + payload = {"reference": self.tx.reference} + if hasattr(transactions, "_process"): + # The same reference with another provider must not match MakePay. + self.assertFalse(transactions._search_by_reference("none", payload)) + else: + self.assertFalse(transactions._get_tx_from_notification_data("none", payload)) + + def test_crypto_settlement_amount_does_not_replace_order_amount(self): + payload = self._payload() + payload["paymentLink"].update({"amount": "0.0001", "currency": "BTC"}) + payload["session"].update({"invoiceAmount": "0.0001", "invoiceAsset": "BTC"}) + response = self._post_webhook(payload) + self.assertEqual(response.status_code, 200) + self.tx.invalidate_recordset() + self.assertEqual(self.tx.state, "done") + self.assertEqual(self.tx.amount, self.amount) + self.assertEqual(self.tx.currency_id, self.currency) + + def test_checkout_rejects_incomplete_payment_link_response(self): + for response in ({"uid": "checkout-link"}, {"checkoutUrl": "https://www.makepay.io"}): + with self.subTest(response=response): + with patch.object(type(self.provider), "_makepay_create_payment_link", return_value=response): + with self.assertRaises(ValidationError): + self.tx._get_processing_values() + self.assertEqual(self.tx.makepay_payment_link_uid, "test-link-uid") + self.assertEqual(self.tx.state, "pending")