Integrating VNPay Payment Gateway & VietQR Dynamic QR into Odoo Invoices
How to build a seamless automated payment pipeline in Odoo 18 that generates dynamic VietQR codes, processes VNPay IPN callbacks, and reconciles bank statements automatically.
The Need for Automated Payment in Modern ERP
Manual bank transfer confirmation creates bottlenecks at service checkout counters. By embedding **VietQR dynamic QR codes** on printed repair orders and providing an online **VNPay payment link**, customers pay instantly and invoices are reconciled automatically.
1. Generating Dynamic VietQR Payloads (EMVCo Standard)
VietQR follows the EMVCo QR code format. In Odoo, we compute the CRC16-checksummed payload based on the invoice reference, bank code, and exact invoice balance:
def generate_vietqr_url(self):
self.ensure_one()
bank_bin = "970422" # MBBank / Vietcombank BIN
account_no = "0913476315"
amount = int(self.amount_residual)
description = urllib.parse.quote(f"THANHTOAN {self.name}")
return f"https://img.vietqr.io/image/{bank_bin}-{account_no}-compact2.png?amount={amount}&addInfo={description}"
2. VNPay Hashing & Instant Payment Notification (IPN)
When a customer pays via VNPay, VNPay sends a cryptographic HMAC-SHA512 webhook to our Odoo endpoint:
@http.route('/payment/vnpay/ipn', type='http', auth='none', methods=['GET'], csrf=False)
def vnpay_ipn_handler(self, **kwargs):
vnp_params = dict(kwargs)
vnp_secure_hash = vnp_params.pop('vnp_SecureHash', None)
# Sort and verify HMAC hash
secret_key = request.env['ir.config_parameter'].sudo().get_param('vnpay.hash_secret')
calculated_hash = hmac_sha512_sort(vnp_params, secret_key)
if calculated_hash != vnp_secure_hash:
return json.dumps({"RspCode": "97", "Message": "Invalid Checksum"})
order_code = vnp_params.get('vnp_TxnRef')
response_code = vnp_params.get('vnp_ResponseCode')
if response_code == '00':
# Payment Successful -> Register Odoo Payment & Post Journal Entry
invoice = request.env['account.move'].sudo().search([('name', '=', order_code)], limit=1)
if invoice and invoice.state == 'posted' and invoice.payment_state != 'paid':
invoice._register_vnpay_payment(vnp_params)
return json.dumps({"RspCode": "00", "Message": "Confirm Success"})
return json.dumps({"RspCode": "01", "Message": "Order Not Found"})
Summary
Automated payment webhooks reduce cashier reconciliation time by over 90% and completely eliminate human accounting errors.

Võ Nghĩa Kỳ
Senior Full-Stack & Odoo 18/19 Developer