6 Commits
Author SHA1 Message Date
ndeet 9da65a6bad Merge pull request #18 from SatoDri/18.0-fix-security-and-ipn
Fix amount tampering, broken IPN URL and invoice status handling
2026-08-07 16:57:56 +02:00
Sato DRI e11cbab174 Merge pull request #1 from ndeet/satodri-18-fixes
Hardening the payment flow
2026-08-07 16:47:43 +02:00
ndeet fadfb0f6a8 * 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 16:25:15 +02:00
SatoDri 9dc936ef33 Build the BTCPay invoice server-side and fix notification handling
Security: the checkout controller built the BTCPay invoice from the POSTed
redirect-form data (price, currency, orderId), and the IPN never checked
the settled amount 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 the settled amount and currency returned by
BTCPay are verified before the transaction 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.
- Let the payment framework confirm the order and send the confirmation
  email via its post-processing, instead of calling
  _check_amount_and_confirm_order() and _send_order_confirmation_mail()
  manually, which sent the confirmation email twice.
- Fix a crsf=False typo (was disabling nothing) to csrf=False on the
  return route, and drop the redundant _handle_notification_data override
  that duplicated the core method.
- Guard against a missing reference instead of calling all() on it.
2026-07-19 23:56:03 +02:00
SatoDri 9705023233 Remove dead payment.provider create() override
The override guarded on self.code inside create(), which is empty on the
model-level recordset, so the branch never ran; it also called a
misspelled helper (generate_privakey). Removing it lets the private key be
generated during pairing as intended, and keeps copying a provider from
regenerating (and thus invalidating) an already paired key.
2026-07-19 23:56:03 +02:00
SatoDri dc6b7a5758 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-19 23:56:03 +02:00
7 changed files with 374 additions and 189 deletions

No files matched your search

+6 -11
View File
@@ -1,6 +1,6 @@
# BTCPay Server payment gateway for Odoo 18 / 19 # BTCPay Server payment gateway for Odoo 18
## This is the module to connect Odoo 18 / 19 and BTCPay Server ## This is the module to connect Odoo 18 and BTCPay Server
This module allows you to accept bitcoin (and other cryptocurrency) payments in your Odoo e-commerce store. 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) ![BTCPay Server Banner](https://raw.githubusercontent.com/btcpayserver/odoo/18.0/payment_btcpayserver/static/description/BTCPay-Odoo-17-featured.png)
@@ -8,13 +8,9 @@ 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). 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 ## 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) * 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 `19.0` (or `18.0`) or downloaded a [release](https://github.com/btcpayserver/odoo/releases) tagged with version v19.x (or v18.x) * Make sure you are on branch `18.0` or downloaded a release tagged with version v18.x
* Place the `payment_btcpayserver` directory in your Odoo addons directory * 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) * Install dependencies by running `pip install -r requirements.txt` (from inside the `payment_btcpayserver` directory)
* Restart Odoo * Restart Odoo
@@ -29,16 +25,15 @@ The following instructions work for Odoo 18 and 19. You just need to make sure t
In the BTCPay settings form, tab "Credentials": In the BTCPay settings form, tab "Credentials":
* Set field "State" to enabled * Set field "State" to enabled
* Set field "BTCPay Server URL" as test or live URL including https://. Example URL: https://testnet.demo.btcpayserver.org * 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: * Get a pairing code from your BTCPay Server store: Settings -> Access Tokens
* Go to: Settings -> Access Tokens
* Click on "Create Token" button * Click on "Create Token" button
* Label: enter e.g. "My odoo store" * Label: enter e.g. "My odoo store"
* Public Key: leave empty * Public Key: leave empty
* Click on "Request Pairing" button, on next page click "Approve" button * 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" * 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 * 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 (see screenshot below) * When the pairing is successful the "Token" and "Private Key" field will be filled automatically
* Field Facade, keep default 'merchant'. * Field Facade, keep default 'merchant'.
On the tab "Configuration": On the tab "Configuration":
+6 -4
View File
@@ -21,11 +21,11 @@
{ {
'name': 'Payment Provider: BTCPay Server', 'name': 'Payment Provider: BTCPay Server',
'summary': 'This module integrates BTCPAY - pay with Bitcoin - with Odoo v19.0', 'summary': 'This module integrates BTCPAY - pay with Bitcoin - with Odoo v18.0',
'author': 'BTCPay Server team and contributors', 'author': 'BTCPay Server team and contributors',
'website': 'https://github.com/btcpayserver/odoo', 'website': 'https://github.com/btcpayserver/odoo',
'category': 'Accounting/Payment Providers', 'category': 'Accounting/Payment Providers',
'version': '19.0.1.0', 'version': '18.0.1.1',
'license': 'GPL-3', 'license': 'GPL-3',
'currency': 'USD', 'currency': 'USD',
'application': False, 'application': False,
@@ -39,9 +39,11 @@
'data/payment_provider_data.xml', 'data/payment_provider_data.xml',
], ],
'images': ['static/description/BTCPay-Odoo-17-featured.png'], 'images': ['static/description/BTCPay-Odoo-17-featured.png'],
# The BTCPay client (legacy BitPay API) is vendored in models/libs and
# only requires 'ecdsa' (plus 'requests', shipped with Odoo).
'external_dependencies': { 'external_dependencies': {
'python': ['btcpay-python'] 'python': ['ecdsa']
}, },
'post_init_hook': 'post_init_hook', 'post_init_hook': 'post_init_hook',
'uninstall_hook': 'uninstall_hook', 'uninstall_hook': 'uninstall_hook',
} }
+244 -67
View File
@@ -20,20 +20,17 @@
# ****************************************************************************** # ******************************************************************************
import json import json
import pprint import logging
import werkzeug
from werkzeug import urls from werkzeug import urls
from odoo import _, http from odoo import _, http
from odoo.exceptions import ValidationError from odoo.exceptions import ValidationError
from odoo.http import request from odoo.http import request
from odoo.addons.payment.logging import get_payment_logger
from ..models.libs.client import BTCPayClient from ..models.libs.client import BTCPayClient
_logger = logging.getLogger(__name__)
_logger = get_payment_logger(__name__)
class BTCPayController(http.Controller): class BTCPayController(http.Controller):
@@ -41,86 +38,266 @@ 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 _lock_transaction(tx_sudo):
"""Lock a transaction until the current database transaction ends.
# Look up the transaction by reference Checkout and IPN requests can arrive concurrently. Serializing them on
reference = data.get('reference') the payment transaction prevents two checkout requests from creating
tx_sudo = request.env['payment.transaction'].sudo().search([ separate invoices and makes an IPN re-check the invoice ID after a
('reference', '=', reference), checkout replaces an expired invoice.
"""
tx_sudo.ensure_one()
tx_sudo.flush_recordset()
tx_sudo.env.cr.execute(
"SELECT id FROM payment_transaction WHERE id = %s FOR UPDATE",
[tx_sudo.id],
)
tx_sudo.invalidate_recordset([
'state', 'btcpay_invoiceId', 'provider_reference',
])
@staticmethod
def _ensure_invoice_id_is_unique(tx_sudo, invoice_id):
"""Reject an invoice ID already linked to another transaction."""
other_tx = tx_sudo.env['payment.transaction'].sudo().search([
('id', '!=', tx_sudo.id),
('provider_code', '=', 'btcpayserver'), ('provider_code', '=', 'btcpayserver'),
'|',
('btcpay_invoiceId', '=', invoice_id),
('provider_reference', '=', invoice_id),
], limit=1) ], limit=1)
if not tx_sudo: if other_tx:
raise ValidationError( raise ValidationError(_(
_("BTCPay: No transaction found matching reference %s.", reference) "BTCPay: The invoice is already linked to another transaction."
) ))
@classmethod
def _validate_invoice_binding(
cls, tx_sudo, incoming_invoice_id, incoming_order_id, fetched_invoice):
"""Bind the untrusted IPN payload to one authenticated invoice and transaction."""
if not isinstance(fetched_invoice, dict):
raise ValidationError(_("BTCPay: Received an invalid invoice response."))
fetched_invoice_id = fetched_invoice.get('id')
fetched_order_id = fetched_invoice.get('orderId')
if not (
incoming_invoice_id
and incoming_invoice_id == fetched_invoice_id
and incoming_invoice_id == tx_sudo.btcpay_invoiceId
and incoming_invoice_id == tx_sudo.provider_reference
):
raise ValidationError(_(
"BTCPay: The notification invoice does not match the transaction."
))
if not (
incoming_order_id
and incoming_order_id == fetched_order_id
and incoming_order_id == tx_sudo.reference
):
raise ValidationError(_(
"BTCPay: The notification order does not match the transaction."
))
cls._ensure_invoice_id_is_unique(tx_sudo, incoming_invoice_id)
@classmethod
def _find_transaction_by_invoice_id(cls, invoice_id):
"""Find exactly one BTCPay transaction from its stored invoice ID."""
if not isinstance(invoice_id, str) or not invoice_id:
raise ValidationError(_("BTCPay: Missing invoice ID in notification data."))
txs_sudo = request.env['payment.transaction'].sudo().search([
('provider_code', '=', 'btcpayserver'),
('btcpay_invoiceId', '=', invoice_id),
], limit=2)
if len(txs_sudo) != 1:
raise ValidationError(_(
"BTCPay: No unique transaction found matching the invoice ID."
))
return txs_sudo
@staticmethod
def _validate_invoice_amount(tx_sudo, invoice):
"""Reject reuse of an invoice with a different amount or currency."""
try:
amount = float(invoice.get('price'))
except (AttributeError, TypeError, ValueError):
amount = None
if (
amount is None
or invoice.get('currency') != tx_sudo.currency_id.name
or tx_sudo.currency_id.compare_amounts(amount, tx_sudo.amount) != 0
):
raise ValidationError(_(
"BTCPay: The existing invoice amount or currency does not match the transaction."
))
@classmethod
def _validate_existing_invoice(cls, tx_sudo, invoice_id, invoice):
"""Validate a stored invoice before redirecting the customer to it."""
cls._validate_invoice_binding(
tx_sudo, invoice_id, tx_sudo.reference, invoice,
)
if not isinstance(invoice.get('status'), str) or not invoice['status']:
raise ValidationError(_("BTCPay: The existing invoice response is incomplete."))
@classmethod
def _validate_created_invoice(cls, tx_sudo, invoice):
"""Validate a newly created invoice before storing its identity."""
if not isinstance(invoice, dict):
raise ValidationError(_("BTCPay: Received an invalid invoice response."))
invoice_id = invoice.get('id')
if (
not isinstance(invoice_id, str)
or not invoice_id
or not isinstance(invoice.get('url'), str)
or not invoice['url']
or invoice.get('orderId') not in (None, tx_sudo.reference)
):
raise ValidationError(_("BTCPay: The created invoice response is incomplete."))
cls._ensure_invoice_id_is_unique(tx_sudo, invoice_id)
@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 for transaction %s", data.get('reference'))
tx_sudo = request.env['payment.transaction'].sudo()._get_tx_from_notification_data(
'btcpayserver', data)
self._lock_transaction(tx_sudo)
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)
client = BTCPayClient(host=provider.btcpay_location, pem=provider.btcpay_privateKey, tokens={provider.btcpay_facade: provider.btcpay_token})
invoice = client.create_invoice(
{"price": data.get('amount'),
"currency": data.get('currency_id'),
"orderId": data.get('reference'),
"token": provider.btcpay_token,
"redirectURL": redirect_url,
"notificationURL": notification_url,
"extendedNotifications": True,
"buyer": {"email": data.get('email') or 'noemailavailable@example.com',
"name": data.get('name'),
"address1": data.get('street'),
"locality": data.get('city'),
"postalCode": data.get('zip'),
"country": data.get('country'),
"notify": False}})
_logger.info('Invoice %s \n NOTIFY URL: %s', invoice, notification_url)
return werkzeug.utils.redirect(invoice['url'])
@http.route(_notify_url, type='jsonrpc', auth='public', csrf=False) # A browser retry must not create a second payable invoice. Reuse the
# stored invoice while it remains payable. If it expired, the new ID is
# stored before the lock is released, so late notifications for the old
# invoice can no longer select this transaction.
if tx_sudo.btcpay_invoiceId:
existing_invoice = client.get_invoice(tx_sudo.btcpay_invoiceId)
self._validate_existing_invoice(
tx_sudo, tx_sudo.btcpay_invoiceId, existing_invoice,
)
if existing_invoice['status'].lower() != 'expired':
self._validate_invoice_amount(tx_sudo, existing_invoice)
if (
not isinstance(existing_invoice.get('url'), str)
or not existing_invoice['url']
):
raise ValidationError(_(
"BTCPay: The existing invoice response is incomplete."
))
_logger.info(
"BTCPay: reusing invoice %s for transaction %s",
tx_sudo.btcpay_invoiceId, tx_sudo.reference,
)
return request.redirect(existing_invoice['url'], local=False)
invoice = client.create_invoice({
"price": tx_sudo.amount,
"currency": tx_sudo.currency_id.name,
"orderId": tx_sudo.reference,
"token": provider.btcpay_token,
"redirectURL": urls.url_join(base_url, self._return_url),
"notificationURL": urls.url_join(base_url, self._notify_url),
"extendedNotifications": True,
"buyer": {
"email": tx_sudo.partner_email or 'noemailavailable@example.com',
"name": tx_sudo.partner_name,
"address1": tx_sudo.partner_address,
"locality": tx_sudo.partner_city,
"postalCode": tx_sudo.partner_zip,
"country": tx_sudo.partner_country_id.code,
"notify": False,
},
})
self._validate_created_invoice(tx_sudo, invoice)
tx_sudo.write({
'btcpay_invoiceId': invoice['id'],
'provider_reference': invoice['id'],
})
_logger.info("BTCPay: created invoice %s for transaction %s",
invoice['id'], tx_sudo.reference)
return request.redirect(invoice['url'], local=False)
@http.route(_notify_url, type='json', auth='public', methods=['POST'], 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)
_logger.info("%s", pprint.pformat(data))
try: try:
reference = data['data']['orderId'] data = json.loads(request.httprequest.data)
invoice_id = data['data']['id'] except (json.JSONDecodeError, TypeError, UnicodeDecodeError):
_logger.warning("BTCPay: received malformed JSON notification data")
return ''
# Look up the transaction by reference try:
tx_sudo = request.env['payment.transaction'].sudo().search([ ipn_data = data.get('data') if isinstance(data, dict) else None
('reference', '=', reference), if not isinstance(ipn_data, dict):
('provider_code', '=', 'btcpayserver'), raise ValidationError(_("BTCPay: Invalid notification data."))
], limit=1) incoming_invoice_id = ipn_data.get('id')
if not tx_sudo: incoming_order_id = ipn_data.get('orderId')
_logger.warning("No transaction found matching reference %s.", reference)
return ''
provider = tx_sudo.provider_id # Never use the untrusted order ID to select a transaction. The
client = BTCPayClient(host=provider.btcpay_location, pem=provider.btcpay_privateKey, # stored invoice ID is the lookup key.
tokens={provider.btcpay_facade: provider.btcpay_token}) tx_sudo = self._find_transaction_by_invoice_id(incoming_invoice_id)
client = self._btcpay_client(tx_sudo.provider_id)
fetched_invoice = client.get_invoice(invoice_id) # Fetching through the authenticated API establishes the invoice's
_logger.info('fetched_invoice = %s', pprint.pformat(fetched_invoice)) # current state. Lock afterwards and re-check the stored ID so a
# concurrent checkout cannot replace it between lookup and use.
fetched_invoice = client.get_invoice(incoming_invoice_id)
self._lock_transaction(tx_sudo)
self._validate_invoice_binding(
tx_sudo, incoming_invoice_id, incoming_order_id, fetched_invoice,
)
_logger.info(
"BTCPay: verified invoice %s with status %s for transaction %s",
fetched_invoice.get('id'), fetched_invoice.get('status'), tx_sudo.reference,
)
payment_data = { notification_data = {
"reference": fetched_invoice['orderId'], "reference": tx_sudo.reference,
"status": fetched_invoice['status'], "status": fetched_invoice.get('status'),
"invoiceID": fetched_invoice['id'], "invoiceID": incoming_invoice_id,
"txid": fetched_invoice['url'], "txid": fetched_invoice.get('url'),
# Settled amount and currency, verified against the transaction
# before it is set done.
"amount": fetched_invoice.get('price'),
"currency": fetched_invoice.get('currency'),
} }
# Use the new Odoo 19 _process() pipeline # Handle the notification data
tx_sudo._process('btcpayserver', payment_data) tx_sudo._handle_notification_data('btcpayserver', notification_data)
except ValidationError: except ValidationError as error: # Acknowledge rejected notifications without retries.
_logger.exception("Unable to handle the notification data; skipping to acknowledge") _logger.warning("BTCPay: rejected notification: %s", error)
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
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.
"""
_logger.info("BTCPay: user returned to shop after payment") _logger.info("BTCPay: user returned to shop after payment")
return request.redirect('/payment/status') return request.redirect('/payment/status')
+10 -13
View File
@@ -1,11 +1,10 @@
from odoo import _, api, fields, models import logging
from odoo import api, fields, models
from .libs.client import BTCPayClient from .libs.client import BTCPayClient
from .libs import crypto from .libs import crypto
from odoo.addons.payment.logging import get_payment_logger _logger = logging.getLogger(__name__)
_logger = get_payment_logger(__name__)
class PaymentProvider(models.Model): class PaymentProvider(models.Model):
@@ -21,15 +20,10 @@ class PaymentProvider(models.Model):
btcpay_privateKey = fields.Text(string='Private Key', help='Private Key for BTCPay Client. Leave empty, will be autogenerated during pairing.') btcpay_privateKey = fields.Text(string='Private Key', help='Private Key for BTCPay Client. Leave empty, will be autogenerated during pairing.')
btcpay_facade = fields.Char(string='Facade', help='Token facade type: merchant/pos/payroll. Keep merchant', default='merchant') btcpay_facade = fields.Char(string='Facade', help='Token facade type: merchant/pos/payroll. Keep merchant', default='merchant')
def create(self, values_list):
if self.code == 'btcpayserver':
values_list['btcpay_privateKey'] = crypto.generate_privkey()
return super(PaymentProvider, self).create(values_list)
@api.onchange('btcpay_pairingCode') @api.onchange('btcpay_pairingCode')
def _onchange_pairingCode(self): def _onchange_pairingCode(self):
if not self.btcpay_token and self.code == 'btcpayserver' and not self.btcpay_pairingCode == '': 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() self.btcpay_privateKey = crypto.generate_privkey()
client = BTCPayClient(host=self.btcpay_location, pem=self.btcpay_privateKey) client = BTCPayClient(host=self.btcpay_location, pem=self.btcpay_privateKey)
token = client.pair_client(self.btcpay_pairingCode) token = client.pair_client(self.btcpay_pairingCode)
@@ -39,10 +33,13 @@ class PaymentProvider(models.Model):
def _onchange_token(self): def _onchange_token(self):
if self.code == 'btcpayserver': if self.code == 'btcpayserver':
self.btcpay_pairingCode = '' self.btcpay_pairingCode = ''
#_logger.info("ONCHANGE TOKEN")
@api.onchange('btcpay_location') @api.onchange('btcpay_location')
def _onchange_location(self): def _onchange_location(self):
if self.code == 'btcpayserver': if self.code == 'btcpayserver':
self.btcpay_token = '' 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_privateKey = ''
self.btcpay_pairingCode = '' self.btcpay_pairingCode = ''
@@ -1,12 +1,10 @@
import logging
import pprint import pprint
from odoo import _, api, fields, models from odoo import _, fields, models
from odoo.exceptions import ValidationError
from odoo.addons.payment import utils as payment_utils _logger = logging.getLogger(__name__)
from odoo.addons.payment.logging import get_payment_logger
_logger = get_payment_logger(__name__)
class PaymentTransaction(models.Model): class PaymentTransaction(models.Model):
@@ -15,13 +13,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,90 +29,115 @@ 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 def _get_tx_from_notification_data(self, provider_code, notification_data):
def _extract_reference(self, provider_code, payment_data): """ Override of payment to find the transaction based on BTCPay data.
""" Override of payment to extract the transaction reference from BTCPay data.
:param str provider_code: The code of the provider handling the transaction. :param str provider_code: The code of the provider that handled the transaction
:param dict payment_data: The payment data sent by the provider. :param dict notification_data: The notification data sent by the provider
:return: The transaction reference. :return: The transaction if found
:rtype: str :rtype: recordset of `payment.transaction`
:raise: ValidationError if the data match no transaction
""" """
if provider_code != 'btcpayserver': tx = super()._get_tx_from_notification_data(provider_code, notification_data)
return super()._extract_reference(provider_code, payment_data) if provider_code != 'btcpayserver' or len(tx) == 1:
return tx
return payment_data.get('reference') 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
def _extract_amount_data(self, payment_data): def _process_notification_data(self, notification_data):
""" Override of payment to skip amount validation for BTCPay.
BTCPay invoices handle amount validation on the BTCPay server side,
so we skip the Odoo-side validation.
:param dict payment_data: The payment data sent by the provider.
:return: None to skip validation.
:rtype: None
"""
if self.provider_code != 'btcpayserver':
return super()._extract_amount_data(payment_data)
return None
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.
Note: self.ensure_one() from `_process` 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.
:param dict payment_data: The payment data sent by the provider. Note: self.ensure_one()
:param dict notification_data: The notification data sent by the provider
:return: None :return: None
:raise: ValidationError if inconsistent data were received
""" """
super()._process_notification_data(notification_data)
if self.provider_code != 'btcpayserver': if self.provider_code != 'btcpayserver':
return super()._apply_updates(payment_data) return
_logger.info("BTCPay _apply_updates: %s", pprint.pformat(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'):
raise ValidationError(
"BTCPay: " + _("Received notification data with missing reference."))
self.provider_reference = payment_data.get('reference') if notification_data.get('invoiceID'):
self.btcpay_txid = payment_data.get('txid') self.btcpay_invoiceId = notification_data['invoiceID']
self.btcpay_status = payment_data.get('status') self.provider_reference = notification_data['invoiceID']
if notification_data.get('txid'):
self.btcpay_txid = notification_data['txid']
status = notification_data.get('status')
self.btcpay_status = status
if self.btcpay_status in ['paid', 'processing']: if status in ('paid', 'processing'):
self._set_pending(state_message=payment_data.get('pending_reason')) self._set_pending(state_message=notification_data.get('pending_reason'))
elif self.btcpay_status in ['confirmed', 'complete']: elif status in ('confirmed', 'complete'):
self._set_done() if self._btcpay_verify_amount(notification_data):
elif self.btcpay_status in ['new']: self._set_done()
self.btcpay_invoiceId = payment_data.get('invoiceID') elif status == 'new':
elif self.btcpay_status in ['cancel', 'cancelled']: pass # Invoice created on BTCPay, waiting for the buyer to pay.
self._set_canceled() elif status in ('expired', 'cancel', 'cancelled'):
elif self.btcpay_status in ['invalid']: self._set_canceled(
_logger.info( state_message="BTCPay: " + _("Invoice status: %s.", status))
"Received data with invalid payment status (%s) for transaction with reference %s", elif status == 'invalid':
self.btcpay_status, self.reference _logger.warning(
) "received invalid payment status for transaction with reference %s", 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.", status))
) else:
_logger.warning(
"received unrecognized 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
+1 -1
View File
@@ -1 +1 @@
btcpay-python ecdsa
@@ -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.