Bài này show code tích hợp BeePay vào Next.js 14 (App Router) + Postgres + Prisma — production-grade. Copy paste là chạy được sau khi điền .env.
1. Schema Prisma
model Order {
id String @id @default(cuid())
publicId String @unique // SHOP-ABC123 — match với BeePay
amount Int
status OrderStatus @default(PENDING)
customerEmail String?
bankRef String?
paidAt DateTime?
createdAt DateTime @default(now())
expiresAt DateTime
}
enum OrderStatus { PENDING PAID EXPIRED }
2. API tạo order
// app/api/orders/route.ts
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
export async function POST(req: Request) {
const { amount, customerEmail } = await req.json()
const publicId = 'SHOP' + Date.now().toString(36).toUpperCase()
const order = await prisma.order.create({
data: {
publicId, amount, customerEmail,
expiresAt: new Date(Date.now() + 15 * 60_000),
},
})
const qrUrl = `https://qr.beepay.vn/img/MB-${BANK_ACCOUNT}.png?amount=${amount}&addInfo=${publicId}`
return NextResponse.json({
orderId: order.publicId,
qrUrl,
bank: { code: 'MB', account: BANK_ACCOUNT, holder: HOLDER },
content: publicId,
expiresIn: 900,
})
}
3. Webhook receiver
// app/api/beepay-webhook/route.ts
import crypto from 'crypto'
import { prisma } from '@/lib/prisma'
export async function POST(req: Request) {
const raw = await req.text()
const sig = req.headers.get('x-webhook-signature') || ''
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.BEEPAY_WEBHOOK_SECRET!)
.update(raw)
.digest('hex')
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) {
return new Response('forbidden', { status: 401 })
}
const body = JSON.parse(raw)
if (body.event !== 'bank_transaction') {
return Response.json({ ok: true, ignored: true })
}
const order = await prisma.order.findUnique({
where: { publicId: body.order_id },
})
if (!order) return Response.json({ ok: true, skipped: 'not our order' })
if (order.status === 'PAID') return Response.json({ ok: true, already_paid: true })
const amountReceived = parseFloat(body.amount)
if (amountReceived < order.amount) {
return Response.json({ ok: true, skipped: 'amount mismatch' })
}
await prisma.order.update({
where: { id: order.id },
data: {
status: 'PAID',
bankRef: body.transaction_ref,
paidAt: new Date(),
},
})
// Trigger downstream: send email, kích hoạt license, ...
await sendConfirmationEmail(order)
return Response.json({ ok: true })
}
4. Polling status endpoint
// app/api/orders/[id]/route.ts
export async function GET(_req: Request, { params }: { params: { id: string } }) {
const order = await prisma.order.findUnique({ where: { publicId: params.id } })
if (!order) return Response.json({ ok: false }, { status: 404 })
return Response.json({
status: order.status,
paidAt: order.paidAt,
amount: order.amount,
})
}
5. Client component — modal checkout
'use client'
import { useEffect, useRef, useState } from 'react'
export default function PayModal({ orderId, qrUrl }: Props) {
const [status, setStatus] = useState<'waiting' | 'paid' | 'expired'>('waiting')
const ref = useRef<ReturnType<typeof setInterval> | null>(null)
useEffect(() => {
ref.current = setInterval(async () => {
const r = await fetch(`/api/orders/${orderId}`).then(r => r.json())
if (r.status === 'PAID') {
clearInterval(ref.current!)
setStatus('paid')
}
}, 3000)
return () => { if (ref.current) clearInterval(ref.current) }
}, [orderId])
return status === 'paid'
? <div>✓ Thành công</div>
: <img src={qrUrl} alt="QR" />
}
Setup script tự động
Boilerplate đầy đủ — gồm setup script tự gọi BeePay API tạo system + register webhook + lưu secret vào .env — có sẵn tại /tai-nguyen/nextjs-saas-boilerplate. git clone → npm install → điền .env → npm run setup → done.