Skip to content

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ĩnh

Cài đặt

bash
npm install express better-sqlite3 dotenv express-rate-limit
node setup.js   # tạo bảng orders

Biế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=AIVN

server.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;
}

Xem thêm