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.
This commit is contained in:
SatoDri committed 2026-07-20 00:06:39 +02:00
1 parent 023e4060c5
commit 303bfce067
3 files changed
+110 -98

No files matched your search

+69 -48
View File
@@ -22,7 +22,6 @@
import json import json
import pprint import pprint
import werkzeug
from werkzeug import urls from werkzeug import urls
from odoo import _, http from odoo import _, http
@@ -41,85 +40,107 @@ class BTCPayController(http.Controller):
_notify_url = '/payment/btcpay/ipn' _notify_url = '/payment/btcpay/ipn'
_return_url = '/payment/btcpay/return' _return_url = '/payment/btcpay/return'
@http.route(_checkout_url, type='http', auth='public', csrf=False, website=True) @staticmethod
def checkout(self, **data): def _btcpay_client(provider):
return BTCPayClient(
host=provider.btcpay_location,
pem=provider.btcpay_privateKey,
tokens={provider.btcpay_facade: provider.btcpay_token},
)
_logger.info("CHECKOUT: received data:\n%s", pprint.pformat(data)) @staticmethod
def _find_transaction(reference):
# Look up the transaction by reference return request.env['payment.transaction'].sudo().search([
reference = data.get('reference')
tx_sudo = request.env['payment.transaction'].sudo().search([
('reference', '=', reference), ('reference', '=', reference),
('provider_code', '=', 'btcpayserver'), ('provider_code', '=', 'btcpayserver'),
], limit=1) ], limit=1)
@http.route(_checkout_url, type='http', auth='public', methods=['POST'],
csrf=False, website=True)
def checkout(self, **data):
""" Create the BTCPay invoice and redirect the buyer to it.
The redirect form only carries the transaction reference. The invoice
price, currency and buyer details are read from the transaction
server-side and are never taken from the (client-controlled) request
data, so a buyer cannot have an invoice created for a tampered amount.
"""
_logger.info("BTCPay: checkout request with data:\n%s", pprint.pformat(data))
reference = data.get('reference')
tx_sudo = self._find_transaction(reference)
if not tx_sudo: if not tx_sudo:
raise ValidationError( raise ValidationError(
_("BTCPay: No transaction found matching reference %s.", reference) _("BTCPay: No transaction found matching reference %s.", reference))
) if tx_sudo.state != 'draft':
# Already processed (e.g. double submission): fall back to the
# generic payment status page.
return request.redirect('/payment/status')
provider = tx_sudo.provider_id provider = tx_sudo.provider_id
notification_url = str(data.get('notify_url')).replace("http://", "https://") base_url = provider.get_base_url()
base_url = request.env['ir.config_parameter'].sudo().get_param('web.base.url') client = self._btcpay_client(provider)
redirect_url = urls.url_join(base_url, self._return_url) invoice = client.create_invoice({
client = BTCPayClient(host=provider.btcpay_location, pem=provider.btcpay_privateKey, tokens={provider.btcpay_facade: provider.btcpay_token}) "price": tx_sudo.amount,
invoice = client.create_invoice( "currency": tx_sudo.currency_id.name,
{"price": data.get('amount'), "orderId": tx_sudo.reference,
"currency": data.get('currency_id'), "token": provider.btcpay_token,
"orderId": data.get('reference'), "redirectURL": urls.url_join(base_url, self._return_url),
"token": provider.btcpay_token, "notificationURL": urls.url_join(base_url, self._notify_url),
"redirectURL": redirect_url, "extendedNotifications": True,
"notificationURL": notification_url, "buyer": {
"extendedNotifications": True, "email": tx_sudo.partner_email or 'noemailavailable@example.com',
"buyer": {"email": data.get('email') or 'noemailavailable@example.com', "name": tx_sudo.partner_name,
"name": data.get('name'), "address1": tx_sudo.partner_address,
"address1": data.get('street'), "locality": tx_sudo.partner_city,
"locality": data.get('city'), "postalCode": tx_sudo.partner_zip,
"postalCode": data.get('zip'), "country": tx_sudo.partner_country_id.code,
"country": data.get('country'), "notify": False,
"notify": False}}) },
_logger.info('Invoice %s \n NOTIFY URL: %s', invoice, notification_url) })
return werkzeug.utils.redirect(invoice['url']) tx_sudo.btcpay_invoiceId = invoice.get('id')
_logger.info("BTCPay: created invoice %s for transaction %s",
invoice.get('id'), tx_sudo.reference)
return request.redirect(invoice['url'], local=False)
@http.route(_notify_url, type='jsonrpc', auth='public', csrf=False) @http.route(_notify_url, type='jsonrpc', auth='public', csrf=False)
def btcpay_ipn(self, **post): def btcpay_ipn(self, **post):
""" BTCPay IPN. """ """ BTCPay IPN. """
_logger.info('BTCPAY IPN RECEIVED...') _logger.info('BTCPay: IPN received')
data = json.loads(request.httprequest.data) data = json.loads(request.httprequest.data)
_logger.info("%s", pprint.pformat(data)) _logger.info("%s", pprint.pformat(data))
try: try:
reference = data['data']['orderId'] reference = data['data']['orderId']
invoice_id = data['data']['id'] invoice_id = data['data']['id']
# Look up the transaction by reference tx_sudo = self._find_transaction(reference)
tx_sudo = request.env['payment.transaction'].sudo().search([
('reference', '=', reference),
('provider_code', '=', 'btcpayserver'),
], limit=1)
if not tx_sudo: if not tx_sudo:
_logger.warning("No transaction found matching reference %s.", reference) _logger.warning("No transaction found matching reference %s.", reference)
return '' return ''
provider = tx_sudo.provider_id # The invoice is fetched back from BTCPay (signed request); the
client = BTCPayClient(host=provider.btcpay_location, pem=provider.btcpay_privateKey, # posted payload is only used to locate the transaction.
tokens={provider.btcpay_facade: provider.btcpay_token}) client = self._btcpay_client(tx_sudo.provider_id)
fetched_invoice = client.get_invoice(invoice_id) fetched_invoice = client.get_invoice(invoice_id)
_logger.info('fetched_invoice = %s', pprint.pformat(fetched_invoice)) _logger.info('BTCPay: fetched invoice = %s', pprint.pformat(fetched_invoice))
payment_data = { payment_data = {
"reference": fetched_invoice['orderId'], "reference": fetched_invoice.get('orderId'),
"status": fetched_invoice['status'], "status": fetched_invoice.get('status'),
"invoiceID": fetched_invoice['id'], "invoiceID": fetched_invoice.get('id'),
"txid": fetched_invoice['url'], "txid": fetched_invoice.get('url'),
# 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'),
} }
# Use the new Odoo 19 _process() pipeline
tx_sudo._process('btcpayserver', payment_data) tx_sudo._process('btcpayserver', payment_data)
except ValidationError: except ValidationError:
_logger.exception("Unable to handle the notification data; skipping to acknowledge") _logger.exception("Unable to handle the notification data; skipping to acknowledge")
return '' return ''
@http.route(_return_url, type='http', auth="public", methods=['GET'], csrf=False, save_session=False) @http.route(_return_url, type='http', auth="public", methods=['GET'],
csrf=False, save_session=False)
def btcpay_return_from_redirect(self, **data): def btcpay_return_from_redirect(self, **data):
""" BTCPay return """ """ BTCPay return """
_logger.info("BTCPay: user returned to shop after payment") _logger.info("BTCPay: user returned to shop after payment")
@@ -2,7 +2,6 @@ import pprint
from odoo import _, api, fields, models from odoo import _, api, fields, models
from odoo.addons.payment import utils as payment_utils
from odoo.addons.payment.logging import get_payment_logger from odoo.addons.payment.logging import get_payment_logger
@@ -15,13 +14,15 @@ class PaymentTransaction(models.Model):
btcpay_invoiceId = fields.Char("Invoice Id") btcpay_invoiceId = fields.Char("Invoice Id")
btcpay_txid = fields.Char("Transaction Id") btcpay_txid = fields.Char("Transaction Id")
btcpay_status = fields.Char("Transaction Status") btcpay_status = fields.Char("Transaction Status")
api_url = '/btcpay/checkout'
checkout_url = '/btcpay/checkout'
notify_url = 'payment/btcpay/ipn'
def _get_specific_rendering_values(self, processing_values): def _get_specific_rendering_values(self, processing_values):
""" Override of payment to return BTCPay-specific rendering values. """ Override of payment to return BTCPay-specific rendering values.
The redirect form only needs to carry the transaction reference: the
BTCPay invoice is built server-side (see the checkout controller) from
the transaction, so no amount, currency or buyer data is sent through
the browser.
Note: self.ensure_one() from `_get_processing_values` Note: self.ensure_one() from `_get_processing_values`
:param dict processing_values: The generic and specific processing values of the transaction :param dict processing_values: The generic and specific processing values of the transaction
@@ -29,29 +30,12 @@ class PaymentTransaction(models.Model):
:rtype: dict :rtype: dict
""" """
res = super()._get_specific_rendering_values(processing_values) res = super()._get_specific_rendering_values(processing_values)
if self.provider_code != 'btcpayserver': if self.provider_code != 'btcpayserver':
return res return res
base_url = self.provider_id.get_base_url()
partner_first_name, partner_last_name = payment_utils.split_partner_name(self.partner_name)
return { return {
'address1': self.partner_address, 'api_url': '/btcpay/checkout',
'amount': self.amount, 'reference': self.reference,
'city': self.partner_city,
'country': self.partner_country_id.code,
'currency_code': self.currency_id.name,
'email': self.partner_email,
'first_name': partner_first_name,
'item_name': f"{self.company_id.name}: {self.reference}",
'item_number': self.reference,
'last_name': partner_last_name,
'lc': self.partner_lang,
'state': self.partner_state_id.name,
'zip_code': self.partner_zip,
'api_url': self.checkout_url,
'notify_url': base_url + self.notify_url,
} }
@api.model @api.model
@@ -69,19 +53,28 @@ class PaymentTransaction(models.Model):
return payment_data.get('reference') return payment_data.get('reference')
def _extract_amount_data(self, payment_data): def _extract_amount_data(self, payment_data):
""" Override of payment to skip amount validation for BTCPay. """ Override of payment to return the settled amount and currency.
BTCPay invoices handle amount validation on the BTCPay server side, The amount and currency are read back from BTCPay on notification and
so we skip the Odoo-side validation. 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. :param dict payment_data: The payment data sent by the provider.
:return: None to skip validation. :return: The settled amount data, or ``None`` for other providers.
:rtype: None :rtype: dict|None
""" """
if self.provider_code != 'btcpayserver': if self.provider_code != 'btcpayserver':
return super()._extract_amount_data(payment_data) return super()._extract_amount_data(payment_data)
return None 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): def _apply_updates(self, payment_data):
""" Override of payment to process the transaction based on BTCPay data. """ Override of payment to process the transaction based on BTCPay data.
@@ -106,13 +99,22 @@ class PaymentTransaction(models.Model):
self._set_done() self._set_done()
elif self.btcpay_status in ['new']: elif self.btcpay_status in ['new']:
self.btcpay_invoiceId = payment_data.get('invoiceID') self.btcpay_invoiceId = payment_data.get('invoiceID')
elif self.btcpay_status in ['cancel', 'cancelled']: elif self.btcpay_status in ['expired', 'cancel', 'cancelled']:
self._set_canceled() self._set_canceled(
state_message="BTCPay: " + _("Invoice status: %s.", self.btcpay_status))
elif self.btcpay_status in ['invalid']: elif self.btcpay_status in ['invalid']:
_logger.info( _logger.warning(
"Received data with invalid payment status (%s) for transaction with reference %s", "Received data with invalid payment status (%s) for transaction with reference %s",
self.btcpay_status, self.reference self.btcpay_status, self.reference
) )
self._set_error( self._set_error(
"BTCPay: " + _("Received data with invalid payment status: %s", self.btcpay_status) "BTCPay: " + _("Received data with invalid payment status: %s.", self.btcpay_status)
)
else:
_logger.warning(
"Received data with unknown payment status (%s) for transaction with reference %s",
self.btcpay_status, self.reference
)
self._set_error(
"BTCPay: " + _("Received data with unknown payment status: %s.", self.btcpay_status)
) )
@@ -1,25 +1,14 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<odoo> <odoo>
<!-- The redirect form is auto-submitted by the payment framework. It only
carries the transaction reference; the BTCPay invoice is built
server-side from the transaction (amount and currency cannot be
tampered with from the browser). -->
<template id="redirect_form"> <template id="redirect_form">
<form t-att-action="api_url" method="post"> <form t-att-action="api_url" method="post">
<input type="hidden" name="reference" t-att-value="item_number"/> <input type="hidden" name="reference" t-att-value="reference"/>
<input type="hidden" name="name" t-att-value="first_name"/>
<input type="hidden" name="email" t-att-value="email"/>
<input type="hidden" name="street" t-att-value="address1"/>
<input type="hidden" name="zip" t-att-value="zip_code"/>
<input type="hidden" name="city" t-att-value="city"/>
<input type="hidden" name="currency_id" t-att-value="currency_code"/>
<input type="hidden" name="country" t-att-value="country"/>
<input type="hidden" name="custom" t-att-value="item_name"/>
<input type="hidden" name="amount" t-att-value="amount"/>
<input type="hidden" name="notify_url" t-att-value="notify_url"/>
<button type="submit" width="100px" t-att-class="submit_class">
<img t-if="not submit_txt" src="/static/description/icon.png"/>
<span t-if="submit_txt"> <t t-esc="submit_txt"/> <span class="fa fa-long-arrow-right"/>
</span>
</button>
</form> </form>
</template> </template>
</odoo> </odoo>
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.