Back to all engineering articles
Aug 18, 20269 min readPayment & Fintech

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.

#VNPay#VietQR#Odoo Payments#Fintech#FastAPI

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ỳ

Võ Nghĩa Kỳ

Senior Full-Stack & Odoo 18/19 Developer

Discuss Project Architecture