Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
1 change: 1 addition & 0 deletions docs/REPOSITORY_PROTECTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 0 additions & 1 deletion docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion makepay_odoo/__manifest__.py
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
6 changes: 3 additions & 3 deletions makepay_odoo/controllers/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
74 changes: 56 additions & 18 deletions makepay_odoo/models/payment_transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
)

Expand All @@ -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()
Expand Down
69 changes: 69 additions & 0 deletions makepay_odoo/tests/test_payment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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")
Loading