Cửa hàng thời trang fullstack với thanh toán PayOS
Website bán hàng thời trang Next.js 14: tồn kho theo variant màu và size, giỏ hàng Redux, đăng nhập Auth.js, thanh toán PayOS xác nhận bằng webhook.
- Next.js 14
- PostgreSQL
- Prisma
- Auth.js
- PayOS
Một website bán hàng thời trang viết bằng Next.js 14 App Router: danh mục sản phẩm, giỏ hàng, đăng nhập, và thanh toán qua cổng PayOS, với PostgreSQL truy cập qua Prisma.
Tồn kho nằm ở variant, không nằm ở sản phẩm#
Quyết định đầu tiên khi dựng schema là thứ quyết định tất cả phần còn lại: một chiếc áo
không có "số lượng tồn". Cái thực sự có tồn kho là áo đó, màu đen, size M. Nếu nhét
stock vào bảng Product thì mọi thứ phía trên sẽ phải nói dối.
model Product {
id String @id @default(uuid()) @db.Uuid
name String @db.VarChar(255)
price Decimal @db.Decimal(15, 2)
category_id Int
variants ProductVariant[]
}
model ProductVariant {
id Int @id @default(autoincrement())
product_id String @db.Uuid
color String @db.VarChar(50)
sizes ProductVariantSize[]
images ProductVariantImage[]
}
model ProductVariantSize {
id Int @id @default(autoincrement())
variant_id Int
size String @db.VarChar(20)
/* Tồn kho sống ở đây: một dòng cho mỗi cặp màu–size. */
stock Int @default(0)
}Ảnh cũng gắn vào variant chứ không gắn vào sản phẩm, nên khi người dùng bấm sang màu
khác thì bộ ảnh đổi theo mà không phải gọi thêm request nào — route /api/products đã
include sẵn cả ba tầng variant, size và ảnh trong một lần query. Giá để kiểu
Decimal(15, 2) thay vì Float: tiền mà làm tròn nhị phân thì sớm muộn cũng lệch.
Giỏ hàng ở client, đơn hàng ở server#
Giỏ hàng là một slice Redux Toolkit được persist xuống trình duyệt, nên người dùng đóng
tab rồi quay lại vẫn còn hàng. Một dòng giỏ được nhận dạng bằng bộ ba productId,
variantId và size — thêm cùng một chiếc áo ở hai size khác nhau phải ra hai dòng,
không phải cộng dồn số lượng.
Đơn hàng thì ngược lại, phải do server ghi. POST /api/orders validate toàn bộ payload
bằng Zod, kiểm tra từng product_id có thật trong database, kiểm tra phương thức
thanh toán còn is_active, rồi tạo đơn cùng các dòng OrderItem trong một lần
prisma.order.create lồng nhau. ID đơn do client sinh bằng crypto.randomUUID() và
server từ chối với mã 409 nếu ID đã tồn tại — nhờ vậy người dùng bấm nút hai lần cũng
không ra hai đơn.
Xác nhận thanh toán thuộc về webhook#
Đây là phần đáng giá nhất của một site bán hàng, và cũng là chỗ dễ làm sai nhất. Khi
chọn chuyển khoản nhanh, client gọi /api/payments/payos/create-link để dựng link
thanh toán — orderCode phải là số nguyên và description bị PayOS giới hạn 25 ký tự,
nên tôi có một hàm riêng cắt UUID đơn hàng cho vừa — rồi chuyển hướng người dùng sang
cổng.
Từ giây phút đó, trình duyệt của người dùng không còn là nguồn tin cậy nữa. Họ có thể đóng tab, mất mạng, hoặc tự gõ tay URL trang thành công. Nguồn chân lý là request mà PayOS gửi thẳng đến server:
export async function POST(req) {
const payload = await req.json()
/* Verify chữ ký trước khi đọc bất kỳ trường nào — không có bước này thì ai
cũng POST được một đơn "đã thanh toán". */
const verified = payos.webhooks.verify(payload)
if (!verified) {
return NextResponse.json({ ok: false, error: 'INVALID_SIGNATURE' }, { status: 400 })
}
const data = payload?.data || {}
const orderCode = String(data?.orderCode || '')
const statusFromGateway = String(data?.status || '').toUpperCase()
const order = await prisma.order.findFirst({
where: { payos_order_code: orderCode },
select: { id: true, status: true },
})
/* Trả 200 kèm cờ lỗi: sai mapping là lỗi của tôi, đừng để PayOS retry mãi. */
if (!order) {
return NextResponse.json({ ok: false, error: 'ORDER_NOT_FOUND' }, { status: 200 })
}
let newStatus = null
if (statusFromGateway === 'PAID') newStatus = 'CONFIRMED'
else if (statusFromGateway === 'CANCELLED') newStatus = 'CANCELLED'
else return NextResponse.json({ ok: true, skipped: true })
/* Idempotent: cổng có thể gọi lại, chỉ ghi khi trạng thái thật sự đổi. */
if (order.status !== newStatus) {
await prisma.order.update({ where: { id: order.id }, data: { status: newStatus } })
}
return NextResponse.json({ ok: true, orderId: order.id, status: newStatus })
}Trang /checkout/success có gọi một route handle-return để đối chiếu lastOrderId
lưu trong localStorage với query string mà cổng trả về, nhưng vai trò của nó chỉ nên
là hiển thị cho người dùng yên tâm, không phải là thứ quyết định đơn đã trả tiền hay
chưa.
Chặn cửa ở middleware#
middleware.js bọc trực tiếp bằng auth() của Auth.js, nên mọi kiểm tra quyền chạy ở
edge trước khi chạm tới page. Role được đọc từ JWT trước rồi mới tới session, vì lúc
điều hướng nhanh session có thể chưa hydrate xong. Mọi thứ dưới /admin và
/api/admin đều cần role admin, còn /checkout/* cho khách chưa đăng nhập ở lại 30
giây bằng một cookie httpOnly đánh dấu mốc thời gian, hết hạn thì đá về trang chủ.
Đăng nhập có hai đường: Google OAuth và cặp email–mật khẩu băm bằng bcrypt.
Kết quả#
- Danh mục có variant màu–size với tồn kho và ảnh riêng cho từng variant, đọc trong một query
- Flow PayOS đầy đủ: tạo link, verify chữ ký webhook, map trạng thái cổng sang
OrderStatus, cập nhật idempotent - Chạy được bằng một lệnh
docker compose up, image tự chạyprisma generatelúc build - Hạn chế thật: thiếu cột
payos_order_codenên webhook chưa khớp được đơn;POST /api/ordersvẫn tin vào headerx-user-idthay vì session phía server; giá lấy từ payload client chứ chưa đọc lại từ database; và đơn tạo xong chưa trừstockcủa variant