9 Commits
Author SHA1 Message Date
ndeet 457455edf0 Backward compatibilty fix. 2026-08-12 22:15:19 +02:00
ndeet c3d32a04ce Merge pull request #19 from SatoDri/19.0-fix-security-and-ipn
Fix amount tampering, broken IPN URL and invoice status handling (19.0)
2026-08-12 22:11:31 +02:00
Sato DRI 2a470afc5d Merge pull request #2 from ndeet/satodri-19-fixes
Make sure changes from 18.0 are also here:
2026-08-12 09:52:27 +02:00
ndeet cab44f0847 Make sure changes from 18.0 are also here:
* Strict invoice and order identity binding.
* IPN lookup by stored invoice ID instead of untrusted order ID.
* Invoice ID stored as provider_reference and checked for reuse.
* Transaction row locking prevents concurrent duplicate checkouts.
* Existing invoices are reused; expired ones are replaced safely.
* Late notifications for replaced invoices are ignored.
* Reduced sensitive webhook logging and restricted IPN to POST.
2026-08-07 20:22:15 +02:00
SatoDri 303bfce067 Build the BTCPay invoice server-side and validate the settled amount
Security: the checkout controller built the BTCPay invoice from the POSTed
redirect-form data (price, currency, orderId), and _extract_amount_data
returned None to opt out of amount validation, so the settled amount was
never checked against the transaction. A buyer could therefore submit an
arbitrary price and confirm an order after paying far less. The invoice is
now built server-side from the transaction, the redirect form only carries
the reference, and _extract_amount_data returns the amount and currency
settled on BTCPay (read back from the invoice in the IPN) so the framework
validates them against the transaction before it is set done.

Other fixes in the notification path:
- Fix the IPN notification URL: it was concatenated without a leading
  slash (base_url + "payment/btcpay/ipn"), producing an unreachable host
  so notifications never arrived. It is now built with urls.url_join.
- Handle the "expired" invoice status (cancel the transaction) instead of
  leaving it pending forever, and set an error on unknown statuses.
2026-07-20 00:06:39 +02:00
SatoDri 023e4060c5 Remove dead payment.provider create() override
The override guarded on self.code, which is empty on the model-level
recordset passed to create(), so the branch never ran. The private key is
generated during pairing (in the onchange) instead, so the override is
dead code; removing it also keeps copying a provider from regenerating
(and thus invalidating) an already paired key.
2026-07-20 00:06:39 +02:00
SatoDri 9f3f702c9f Declare ecdsa as the module Python dependency
The bundled BTCPay client (models/libs) imports ecdsa, while the manifest
and requirements only listed btcpay-python, which is not used (the client
is vendored). Installations that did not already have ecdsa available
failed the external-dependency check. Declare ecdsa instead and bump the
module version.
2026-07-20 00:06:39 +02:00
ndeet 94eab634ca Update Readme. 2026-03-30 16:45:20 +02:00
ndeet 225d4c33c5 Odoo 19 compatibilty (#16)
* Odoo 19 compatibilty.

* Update docs.
2026-03-30 16:36:20 +02:00
6 changed files with 114 additions and 113 deletions

No files matched your search

+3 -3
View File
@@ -1,12 +1,12 @@
# BTCPay Server payment gateway for Odoo 16, 17 and 18
# BTCPay Server payment gateway for Odoo 16, 17, 18 and 19
## Accept bitcoin payments on Odoo 16, 17 and 18
## Accept bitcoin payments on Odoo 16, 17, 18 and 19
This odoo addon allows you to accept bitcoin and Lightning Network (and other cryptocurrency) payments in your Odoo e-commerce store.
![BTCPay Server Banner](https://raw.githubusercontent.com/btcpayserver/odoo/17.0/payment_btcpayserver/static/description/BTCPay-Odoo-17-featured.png)
## How to install
Please find the installation instructions here:
- [Odoo 18 documentation](https://github.com/btcpayserver/odoo/blob/18.0/payment_btcpayserver/README.md)
- [Odoo 18 + 19 documentation](https://github.com/btcpayserver/odoo/blob/19.0/payment_btcpayserver/README.md)
- [Odoo 17 documentation](https://github.com/btcpayserver/odoo/blob/17.0/payment_btcpayserver/README.md)
- [Odoo 16 documentation](https://github.com/btcpayserver/odoo/blob/16.0/payment_btcpayserver/README.md)
+11 -6
View File
@@ -1,6 +1,6 @@
# BTCPay Server payment gateway for Odoo 18
# BTCPay Server payment gateway for Odoo 18 / 19
## This is the module to connect Odoo 18 and BTCPay Server
## This is the module to connect Odoo 18 / 19 and BTCPay Server
This module allows you to accept bitcoin (and other cryptocurrency) payments in your Odoo e-commerce store.
![BTCPay Server Banner](https://raw.githubusercontent.com/btcpayserver/odoo/18.0/payment_btcpayserver/static/description/BTCPay-Odoo-17-featured.png)
@@ -8,9 +8,13 @@ This module allows you to accept bitcoin (and other cryptocurrency) payments in
If you use Odoo 16 you can find the documentation [here](https://github.com/btcpayserver/odoo/blob/16.0/payment_btcpayserver/README.md) and for Odoo 17 [here](https://github.com/btcpayserver/odoo/blob/17.0/payment_btcpayserver/README.md).
:::
:::tip
The following instructions work for Odoo 18 and 19. You just need to make sure to download the matching branch release or use the matching branch (18.0 or 19.0) mentioned in the steps below.
:::
## Install the module
* Clone our [repository](https://github.com/btcpayserver/odoo) or download the .zip from the [releases page](https://github.com/btcpayserver/odoo/releases)
* Make sure you are on branch `18.0` or downloaded a release tagged with version v18.x
* Make sure you are on branch `19.0` (or `18.0`) or downloaded a [release](https://github.com/btcpayserver/odoo/releases) tagged with version v19.x (or v18.x)
* Place the `payment_btcpayserver` directory in your Odoo addons directory
* Install dependencies by running `pip install -r requirements.txt` (from inside the `payment_btcpayserver` directory)
* Restart Odoo
@@ -25,15 +29,16 @@ If you use Odoo 16 you can find the documentation [here](https://github.com/btcp
In the BTCPay settings form, tab "Credentials":
* Set field "State" to enabled
* Set field "BTCPay Server URL" as test or live URL including https://. Example URL: https://testnet.demo.btcpayserver.org
* Get a pairing code from your BTCPay Server store: Settings -> Access Tokens
* Get a pairing code from your BTCPay Server store:
* Go to: Settings -> Access Tokens
* Click on "Create Token" button
* Label: enter e.g. "My odoo store"
* Public Key: leave empty
* Click on "Request Pairing" button, on next page click "Approve" button
* At the top copy the code next to "Server initiated pairing code", e.g. "hg7z8wN"
* Back in Odoo, paste the code into "Pairing Code" field
* Back in Odoo: paste the code into "Pairing Code" field
* Hit Tab key on your keyboard (or click on another field) and the pairing process will start automatically
* When the pairing is successful the "Token" and "Private Key" field will be filled automatically
* When the pairing is successful the "Token" and "Private Key" field will be filled automatically (see screenshot below)
* Field Facade, keep default 'merchant'.
On the tab "Configuration":
+2 -2
View File
@@ -21,11 +21,11 @@
{
'name': 'Payment Provider: BTCPay Server',
'summary': 'This module integrates BTCPAY - pay with Bitcoin - with Odoo v18.0',
'summary': 'This module integrates BTCPAY - pay with Bitcoin - with Odoo v19.0',
'author': 'BTCPay Server team and contributors',
'website': 'https://github.com/btcpayserver/odoo',
'category': 'Accounting/Payment Providers',
'version': '18.0.1.1',
'version': '19.0.1.1',
'license': 'GPL-3',
'currency': 'USD',
'application': False,
+22 -16
View File
@@ -20,7 +20,6 @@
# ******************************************************************************
import json
import logging
from werkzeug import urls
@@ -28,9 +27,11 @@ from odoo import _, http
from odoo.exceptions import ValidationError
from odoo.http import request
from odoo.addons.payment.logging import get_payment_logger
from ..models.libs.client import BTCPayClient
_logger = logging.getLogger(__name__)
_logger = get_payment_logger(__name__)
class BTCPayController(http.Controller):
@@ -46,6 +47,13 @@ class BTCPayController(http.Controller):
tokens={provider.btcpay_facade: provider.btcpay_token},
)
@staticmethod
def _find_transaction(reference):
return request.env['payment.transaction'].sudo().search([
('reference', '=', reference),
('provider_code', '=', 'btcpayserver'),
], limit=1)
@staticmethod
def _lock_transaction(tx_sudo):
"""Lock a transaction until the current database transaction ends.
@@ -176,8 +184,11 @@ class BTCPayController(http.Controller):
data, so a buyer cannot have an invoice created for a tampered amount.
"""
_logger.info("BTCPay: checkout request for transaction %s", data.get('reference'))
tx_sudo = request.env['payment.transaction'].sudo()._get_tx_from_notification_data(
'btcpayserver', data)
reference = data.get('reference')
tx_sudo = self._find_transaction(reference)
if not tx_sudo:
raise ValidationError(
_("BTCPay: No transaction found matching reference %s.", reference))
self._lock_transaction(tx_sudo)
if tx_sudo.state != 'draft':
# Already processed (e.g. double submission): fall back to the
@@ -239,7 +250,7 @@ class BTCPayController(http.Controller):
invoice['id'], tx_sudo.reference)
return request.redirect(invoice['url'], local=False)
@http.route(_notify_url, type='json', auth='public', methods=['POST'], csrf=False)
@http.route(_notify_url, type='jsonrpc', auth='public', methods=['POST'], csrf=False)
def btcpay_ipn(self, **post):
""" BTCPay IPN. """
_logger.info('BTCPay: IPN received')
@@ -274,30 +285,25 @@ class BTCPayController(http.Controller):
fetched_invoice.get('id'), fetched_invoice.get('status'), tx_sudo.reference,
)
notification_data = {
payment_data = {
"reference": tx_sudo.reference,
"status": fetched_invoice.get('status'),
"invoiceID": incoming_invoice_id,
"txid": fetched_invoice.get('url'),
# Settled amount and currency, verified against the transaction
# before it is set done.
# Settled amount and currency, validated against the transaction
# by the payment framework before it is set done.
"amount": fetched_invoice.get('price'),
"currency": fetched_invoice.get('currency'),
}
# Handle the notification data
tx_sudo._handle_notification_data('btcpayserver', notification_data)
except ValidationError as error: # Acknowledge rejected notifications without retries.
tx_sudo._process('btcpayserver', payment_data)
except ValidationError as error:
_logger.warning("BTCPay: rejected notification: %s", error)
return ''
@http.route(_return_url, type='http', auth="public", methods=['GET'],
csrf=False, save_session=False)
def btcpay_return_from_redirect(self, **data):
""" BTCPay return
We could process and check the invoice status here but there is no need to as the status get's updated via
IPN anyway, so just show the user the order confirmation / payment status page.
"""
""" BTCPay return """
_logger.info("BTCPay: user returned to shop after payment")
return request.redirect('/payment/status')
@@ -1,10 +1,11 @@
import logging
from odoo import api, fields, models
from .libs.client import BTCPayClient
from .libs import crypto
_logger = logging.getLogger(__name__)
from odoo.addons.payment.logging import get_payment_logger
_logger = get_payment_logger(__name__)
class PaymentProvider(models.Model):
@@ -23,7 +24,6 @@ class PaymentProvider(models.Model):
@api.onchange('btcpay_pairingCode')
def _onchange_pairingCode(self):
if not self.btcpay_token and self.code == 'btcpayserver' and self.btcpay_pairingCode:
#_logger.info("ONCHANGE PAIRING CODE***SELF: %s %s %s", self.btcpay_location, self.btcpay_privateKey, self.btcpay_pairingCode)
self.btcpay_privateKey = crypto.generate_privkey()
client = BTCPayClient(host=self.btcpay_location, pem=self.btcpay_privateKey)
token = client.pair_client(self.btcpay_pairingCode)
@@ -33,13 +33,10 @@ class PaymentProvider(models.Model):
def _onchange_token(self):
if self.code == 'btcpayserver':
self.btcpay_pairingCode = ''
#_logger.info("ONCHANGE TOKEN")
@api.onchange('btcpay_location')
def _onchange_location(self):
if self.code == 'btcpayserver':
self.btcpay_token = ''
#_logger.info("ONCHANGE LOCATION ***SELF: %s %s %s", self.btcpay_location, self.btcpay_privateKey, self.btcpay_pairingCode)
self.btcpay_privateKey = ''
self.btcpay_pairingCode = ''
@@ -1,10 +1,12 @@
import logging
import pprint
from odoo import _, fields, models
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
_logger = logging.getLogger(__name__)
from odoo.addons.payment.logging import get_payment_logger
_logger = get_payment_logger(__name__)
class PaymentTransaction(models.Model):
@@ -35,66 +37,82 @@ class PaymentTransaction(models.Model):
return {
'api_url': '/btcpay/checkout',
'reference': self.reference,
# Keep the pre-19.0.1.1 key during rolling deployments. QWeb
# templates live in the database, so an instance restarted before
# the module is upgraded can still render the previous template.
'item_number': self.reference,
}
def _get_tx_from_notification_data(self, provider_code, notification_data):
""" Override of payment to find the transaction based on BTCPay data.
@api.model
def _extract_reference(self, provider_code, payment_data):
""" Override of payment to extract the transaction reference from BTCPay data.
:param str provider_code: The code of the provider that handled the transaction
:param dict notification_data: The notification data sent by the provider
:return: The transaction if found
:rtype: recordset of `payment.transaction`
:raise: ValidationError if the data match no transaction
:param str provider_code: The code of the provider handling the transaction.
:param dict payment_data: The payment data sent by the provider.
:return: The transaction reference.
:rtype: str
"""
tx = super()._get_tx_from_notification_data(provider_code, notification_data)
if provider_code != 'btcpayserver' or len(tx) == 1:
return tx
if provider_code != 'btcpayserver':
return super()._extract_reference(provider_code, payment_data)
reference = notification_data.get('reference')
tx = self.search([('reference', '=', reference), ('provider_code', '=', 'btcpayserver')])
if not tx:
raise ValidationError(
"BTCPay: " + _("No transaction found matching reference %s.", reference)
)
return tx
return payment_data.get('reference')
def _process_notification_data(self, notification_data):
def _extract_amount_data(self, payment_data):
""" Override of payment to return the settled amount and currency.
The amount and currency are read back from BTCPay on notification and
returned here so that the generic amount validation compares them
against the transaction. This prevents a tampered or mismatched invoice
from confirming the transaction (and thus the order).
:param dict payment_data: The payment data sent by the provider.
:return: The settled amount data, or ``None`` for other providers.
:rtype: dict|None
"""
if self.provider_code != 'btcpayserver':
return super()._extract_amount_data(payment_data)
try:
amount = float(payment_data.get('amount'))
except (TypeError, ValueError):
amount = None
return {
'amount': amount,
'currency_code': payment_data.get('currency'),
}
def _apply_updates(self, payment_data):
""" Override of payment to process the transaction based on BTCPay data.
Only the transaction state is updated here; confirming the related
sale order, creating the payment and sending the confirmation email are
handled by the generic post-processing of the `payment` framework.
Note: self.ensure_one() from `_process`
Note: self.ensure_one()
:param dict notification_data: The notification data sent by the provider
:param dict payment_data: The payment data sent by the provider.
:return: None
:raise: ValidationError if inconsistent data were received
"""
super()._process_notification_data(notification_data)
if self.provider_code != 'btcpayserver':
return
return super()._apply_updates(payment_data)
_logger.info(
"processing notification data for BTCPay transaction with reference %s:\n%s",
self.reference, pprint.pformat(notification_data))
if not notification_data.get('reference'):
_logger.info("BTCPay _apply_updates: %s", pprint.pformat(payment_data))
if not payment_data.get('reference'):
raise ValidationError(
"BTCPay: " + _("Received notification data with missing reference."))
"BTCPay: " + _("Received payment data with missing reference."))
if notification_data.get('invoiceID'):
self.btcpay_invoiceId = notification_data['invoiceID']
self.provider_reference = notification_data['invoiceID']
if notification_data.get('txid'):
self.btcpay_txid = notification_data['txid']
status = notification_data.get('status')
# Keep both external-reference fields bound to the authenticated
# BTCPay invoice. The controller validates this identity before
# entering `_process`.
if invoice_id := payment_data.get('invoiceID'):
self.btcpay_invoiceId = invoice_id
self.provider_reference = invoice_id
if payment_data.get('txid'):
self.btcpay_txid = payment_data['txid']
status = payment_data.get('status')
self.btcpay_status = status
if status in ('paid', 'processing'):
self._set_pending(state_message=notification_data.get('pending_reason'))
self._set_pending(state_message=payment_data.get('pending_reason'))
elif status in ('confirmed', 'complete'):
if self._btcpay_verify_amount(notification_data):
self._set_done()
self._set_done()
elif status == 'new':
pass # Invoice created on BTCPay, waiting for the buyer to pay.
elif status in ('expired', 'cancel', 'cancelled'):
@@ -102,42 +120,17 @@ class PaymentTransaction(models.Model):
state_message="BTCPay: " + _("Invoice status: %s.", status))
elif status == 'invalid':
_logger.warning(
"received invalid payment status for transaction with reference %s", self.reference)
"Received data with invalid payment status (%s) for transaction with reference %s",
status, self.reference
)
self._set_error(
"BTCPay: " + _("Received data with invalid payment status: %s.", status))
"BTCPay: " + _("Received data with invalid payment status: %s.", status)
)
else:
_logger.warning(
"received unrecognized payment status (%s) for transaction with reference %s",
status, self.reference)
"Received data with unknown payment status (%s) for transaction with reference %s",
status, self.reference
)
self._set_error(
"BTCPay: " + _("Received data with unknown payment status: %s.", status))
def _btcpay_verify_amount(self, notification_data):
""" Check that the settled BTCPay invoice matches the transaction.
The BTCPay invoice is created from the transaction, but its settled
amount and currency are read back from BTCPay on notification and
compared here so that a tampered or mismatched invoice can never
confirm the transaction (and thus the order). On mismatch the
transaction is set in error and ``False`` is returned.
:param dict notification_data: The notification data sent by the provider
:return: Whether the settled amount and currency match the transaction
:rtype: bool
"""
self.ensure_one()
currency = notification_data.get('currency')
try:
amount = float(notification_data.get('amount'))
except (TypeError, ValueError):
amount = None
if (amount is None
or currency != self.currency_id.name
or self.currency_id.compare_amounts(amount, self.amount) != 0):
self._set_error("BTCPay: " + _(
"The settled amount (%(amount)s %(currency)s) does not match the "
"expected amount (%(expected)s %(expected_currency)s).",
amount=notification_data.get('amount'), currency=currency,
expected=self.amount, expected_currency=self.currency_id.name))
return False
return True
"BTCPay: " + _("Received data with unknown payment status: %s.", status)
)
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.