A-101 · Case study · Mặt cắt 01
Banh Mi Daily
Nền tảng đặt món & vận hành cửa hàng
Đọc nhanh trong 30 giây
- Vai trò
- Full-stack — đảm nhận end-to-end FE, BE, DB & tự quản lý deployment
- Giao diện
- Website · Kiosk tự phục vụ · POS trên tablet · Trang quản trị
- Thời gian
- 06/2026 — Hiện tại
- Stack
- Nuxt 4 · Vue 3 · TypeScript · Feature-Sliced Design · TanStack Query · Zod · Ant Design Vue · PWA · Turborepo · NestJS · Prisma · PostgreSQL · JWT · RBAC · Mollie · Webhooks · Docker · Nginx · Cloudflare Pages/R2 · GitHub Actions
- Liên kết
- banhmidaily.nl ↗admin.banhmidaily.nl ↗
Màn hình

Website: menu, cùng giỏ hàng và nút chọn ngôn ngữ. 
Kiosk tự phục vụ: chọn món, giỏ hàng nằm ở dưới cùng. 
POS trên tablet: các đơn đang xử lý. Mỗi thẻ hiện trạng thái đơn (Being prepared) và trạng thái thanh toán (Paid · Cash) cạnh nhau. 
Trang quản trị: mỗi đơn có một cột Status và một cột Payment status — hai trục của state machine đơn hàng (D1).
Vấn đề
Một cửa hàng cần một nền tảng chung cho cả đặt món online lẫn vận hành tại quán — website, kiosk tự phục vụ, POS trên tablet và trang quản trị — do một developer duy nhất thiết kế, xây dựng và vận hành.
Ràng buộc
- Developer duy nhất cho cả frontend, backend, database và deployment
- Checkout đồng thời nhưng không có race condition
- Thanh toán thật bằng iDEAL và thẻ
- Website ba ngôn ngữ cho khách hàng
- In hóa đơn trên máy in ESC/POS từ trình duyệt
Mặt cắt 01 · vị trí các quyết định
- D1
State machine đơn hàng hai trục
Trạng thái đơn được mô hình hóa thành một state machine tường minh và do API kiểm soát.
Trade-off Mọi lần đổi trạng thái đều phải đi qua một transition khai báo sẵn, nên một trường hợp mới cần một transition mới chứ không phải sửa nhanh một field. Đổi lại, những tổ hợp không hợp lệ — như COMPLETED trước READY — không thể được ghi xuống.
- D2
Idempotency key + advisory lock
Loại bỏ race condition khi nhiều checkout đến cùng lúc.
Trade-off Mỗi checkout lấy một lock và ghi một row vào ledger trước khi đơn được tạo. Chỉ dùng unique constraint thì đơn giản hơn, nhưng nó chỉ chặn request trùng ở bước cuối, khi cả hai request đã xử lý xong.
Thử ngay — gửi cùng một đơn hai lần → - D3
Giá & VAT do server quyết định
Tổng tiền và VAT do API tính, không bao giờ tin giá trị gửi từ phía client.
Trade-off Mọi giao diện đều gọi API để lấy tổng tiền thay vì tự tính, nên quy tắc tính giá chỉ nằm ở một chỗ, nhưng mỗi lần tính là một round trip. Đổi lại, không kiosk, POS hay website nào có thể thay đổi số tiền khách phải trả.
- D4
Payment webhook được verify và idempotent
Mollie webhook được verify chữ ký, dùng để đối soát trạng thái thanh toán và xác nhận đơn.
Trade-off Một khoản thanh toán chỉ được chốt khi webhook có chữ ký của Mollie tới, nên đơn chờ ở PENDING thay vì tin vào redirect của trình duyệt. Đổi lại, mỗi khoản thanh toán được xác nhận đúng một lần, kể cả khi Mollie retry webhook.
- D5
JWT refresh token rotation + RBAC
Session dùng refresh token rotation; quyền truy cập phân theo role.
Trade-off Refresh token rotation nghĩa là phải lưu và thu hồi token ở server, và mọi endpoint phải khai báo các role được phép. Đổi lại, một refresh token bị đánh cắp sẽ mất hiệu lực sau một lần dùng, và nhân viên chỉ truy cập được những gì role của mình cho phép.
- D6
Validate tại ranh giới API
TanStack Query kết hợp Zod validation tại điểm dữ liệu đi vào frontend.
Trade-off Zod schema phải được duy trì song song với type của API. Đổi lại, dữ liệu không khớp sẽ báo lỗi rõ ràng ngay tại ranh giới, thay vì làm hỏng một màn hình về sau.
- D7
Pipeline xử lý ảnh
Ảnh upload được xử lý bằng Sharp và lưu trên Cloudflare R2.
Trade-off Ảnh upload phải qua một bước xử lý trước khi lưu, thay vì giữ nguyên file gốc. Đổi lại, mọi giao diện đều serve ảnh đã đúng kích thước và đã được nén.
Triển khai · mặt bằng tại ▽ −11.00
- GitHub Actions CI chạy lint, typecheck, test và E2E test trên PostgreSQL
- API chạy trong Docker, phía sau Nginx, trên một VPS
- Website và ảnh được serve từ Cloudflare Pages và R2