Landing page + BeePay
Ví dụ thực tế: ai.huyhoang.online — landing page bán AI Tool Access 2.000₫ dùng BeePay để nhận thanh toán chuyển khoản tự động.
Kiến trúc tổng quan
Khách bấm "Mua ngay"
→ POST /api/order (tạo order_id, sinh QR VietQR)
→ Khách quét QR chuyển khoản
→ BeePay parse giao dịch ngân hàng
→ POST /api/beepay-webhook (verify signature → cập nhật DB)
→ Frontend poll GET /api/order/:id mỗi 3 giây
→ Hiển thị "Thanh toán thành công"Cấu trúc project
project/
├── server.js # Express API
├── db.js # SQLite connection
├── setup.js # Tạo bảng orders
├── .env # Biến môi trường
└── public/
└── index.html # Landing page tĩnhCài đặt
bash
npm install express better-sqlite3 dotenv express-rate-limit
node setup.js # tạo bảng ordersBiến môi trường
ini
PORT=3000
BEEPAY_WEBHOOK_SECRET=your_webhook_secret
BANK_CODE=MB
BANK_ACCOUNT=0123456789
BANK_HOLDER=NGUYEN VAN A
ORDER_PREFIX=AIVNserver.js — full code
js
require('dotenv').config()
const express = require('express')
const crypto = require('crypto')
const rateLimit = require('express-rate-limit')
const db = require('./db')
const app = express()
const PORT = process.env.PORT || 3000
const PRODUCTS = {
ai_access: { name: 'AI Tool Access — Trọn đời', amount: 2000 }
}
const { BEEPAY_WEBHOOK_SECRET, BANK_CODE, BANK_ACCOUNT, BANK_HOLDER, ORDER_PREFIX = 'AIVN' } = process.env
// Raw body cho webhook — phải đặt TRƯỚC express.json()
app.use('/api/beepay-webhook', express.raw({ type: 'application/json' }))
app.use(express.json())
app.use(express.static('public'))
// ── Tạo đơn hàng ──────────────────────────────────
app.post('/api/order', (req, res) => {
const { product_code } = req.body
const product = PRODUCTS[product_code]
if (!product) return res.status(400).json({ error: 'Sản phẩm không tồn tại' })
const order_id = ORDER_PREFIX + Date.now().toString(36).toUpperCase()
const bankHolder = encodeURIComponent(BANK_HOLDER || '')
const qr_url = `https://img.vietqr.io/image/${BANK_CODE}-${BANK_ACCOUNT}-qr_only.png`
+ `?amount=${product.amount}&addInfo=${order_id}&accountName=${bankHolder}`
db.prepare(`
INSERT INTO orders (order_id, product_code, amount, status)
VALUES (?, ?, ?, 'pending')
`).run(order_id, product_code, product.amount)
res.json({ order_id, amount: product.amount, product_name: product.name,
bank_code: BANK_CODE, bank_account: BANK_ACCOUNT, bank_holder: BANK_HOLDER, qr_url })
})
// ── BeePay Webhook ─────────────────────────────────
app.post('/api/beepay-webhook', (req, res) => {
const signature = req.headers['x-webhook-signature']
if (!signature) return res.status(401).json({ error: 'Missing signature' })
const expected = 'sha256=' + crypto
.createHmac('sha256', BEEPAY_WEBHOOK_SECRET)
.update(req.body)
.digest('hex')
// timingSafeEqual chống timing attack
if (signature.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
return res.status(401).json({ error: 'Invalid signature' })
}
const { order_id, amount, transaction_id } = JSON.parse(req.body.toString())
const order = db.prepare('SELECT * FROM orders WHERE order_id = ?').get(order_id)
if (!order) return res.status(404).json({ error: 'Order not found' })
// Idempotent: bỏ qua nếu đã paid
if (order.status === 'paid') return res.json({ ok: true, already_paid: true })
if (parseFloat(amount) >= parseFloat(order.amount)) {
db.prepare(`
UPDATE orders SET status='paid', transaction_id=?,
paid_at=CURRENT_TIMESTAMP, updated_at=CURRENT_TIMESTAMP
WHERE order_id=?
`).run(transaction_id, order_id)
}
res.json({ ok: true })
})
// ── Polling status ─────────────────────────────────
const pollLimiter = rateLimit({ windowMs: 2000, max: 1, standardHeaders: false, legacyHeaders: false })
app.get('/api/order/:id', pollLimiter, (req, res) => {
const order = db.prepare('SELECT status, paid_at, amount FROM orders WHERE order_id = ?')
.get(req.params.id)
if (!order) return res.status(404).json({ error: 'Not found' })
res.json(order)
})
app.listen(PORT, () => console.log(`Server: http://localhost:${PORT}`))Điểm quan trọng
1. Raw body cho webhook
js
// ĐÚNG: raw trước json
app.use('/api/beepay-webhook', express.raw({ type: 'application/json' }))
app.use(express.json())Nếu để express.json() xử lý trước, body bị parse thành object → HMAC sai → tất cả webhook đều bị reject 401.
2. timingSafeEqual chống timing attack
js
if (signature.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {So sánh độ dài trước (hai buffer phải cùng size), sau đó dùng timingSafeEqual thay vì ===.
3. Idempotency
js
if (order.status === 'paid') return res.json({ ok: true, already_paid: true })BeePay retry webhook khi endpoint không trả 200. Luôn check trước khi cập nhật để tránh double-credit.
4. Rate limit polling
js
const pollLimiter = rateLimit({ windowMs: 2000, max: 1 })
app.get('/api/order/:id', pollLimiter, ...)Frontend poll mỗi 3 giây — rate limit 1 req/2s ngăn abuse.
Frontend — Polling pattern
js
async function startPolling(orderId) {
const timer = setInterval(async () => {
const res = await fetch(`/api/order/${orderId}`)
const data = await res.json()
if (data.status === 'paid') {
clearInterval(timer)
showSuccessScreen()
}
}, 3000) // poll mỗi 3 giây
}db.js + setup.js
js
// db.js
const Database = require('better-sqlite3')
const db = new Database('orders.db')
db.pragma('journal_mode = WAL')
module.exports = db
// setup.js
const db = require('./db')
db.exec(`
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id TEXT NOT NULL UNIQUE,
product_code TEXT NOT NULL,
amount REAL NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
transaction_id TEXT,
paid_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)Deploy (pm2 + nginx)
bash
# pm2
pm2 start server.js --name ai-landing
# nginx upstream
location /api/ {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location / {
root /var/www/ai.huyhoang.online/public;
try_files $uri $uri/ /index.html;
}