Skip to content

🔄 LUỒNG NGHIỆP VỤ GROUPBUY (spec)

Nguồn truth cho luồng nghiệp vụ — mọi thay đổi code (BE ../gghut-core, FE storefront, FE admin) phải khớp tài liệu này. Chi tiết kỹ thuật API nằm ở docs/backend-api-integration.md. Cập nhật: 2026-08-26.


1 · Vòng đời campaign

DRAFT ──open──▶ OPEN ──hệ thống tự chốt──▶ CLOSED   (thành công — chốt giá)
                  │  │
                  │  └──hệ thống auto khi hết giờ & thiếu MOQ──▶ FAILED (thất bại — hoàn cọc)
                  └──admin cancel──────────────────────────────▶ CANCELLED (hoàn cọc)

Quy tắc kết thúc OPEN (hệ thống tự động, cron ~60s trong BE):

Điều kiệnKết quả
taken == capacity (đủ slot)Chốt giá ngay lập tức — không chờ hết hạn
Quá closes_at và tổng qty cọc ≥ MOQChốt giá → CLOSED
Quá closes_at và tổng qty cọc < MOQCampaign → FAILED, toàn bộ đơn DEPOSIT_HELDREFUND_PENDING
Admin bấm Close/Cancel thủ côngVẫn hoạt động như cũ. Cancel: đơn DEPOSIT_HELDREFUND_PENDING
  • Admin vẫn có thể settle thủ công (POST /admin/campaigns/{id}/settle) như Phase 0 — auto-settle chỉ thêm đường tự động, không thay đổi semantics chốt giá.
  • MOQ (số lượng tối thiểu để chốt giá) = qty_threshold của tier thấp nhất. Được set ngay khi tạo campaign và hiển thị luôn trên storefront từ ngày đầu ("Cần tối thiểu N suất để chốt giá"). Không cần field riêng.

2 · Giá hiển thị trên storefront

Ba loại giá:

FieldÝ nghĩa
default_priceGiá gốc (niêm yết)
promo_priceGiá khuyến mãi
tiers[].unit_priceGiá theo mốc số lượng

Giá hiệu lực hiện tại (current_unit_price, BE tính sẵn cả ở list lẫn detail):

  1. Tier cao nhất mà taken đã vượt qua (qty_threshold ≤ taken) → dùng unit_price của tier đó.
  2. Chưa đạt mốc nào → promo_price nếu có, không thì default_price.
  3. Sau khi campaign đã chốt: final_unit_price trên từng đơn là truth; campaign hiển thị giá đã chốt.

Hiển thị FE:

  • Luôn show "giá hiện tại" to; default_price gạch ngang khi tồn tại giá thấp hơn (promo hoặc tier).
  • Realtime theo mốc: trang detail poll GET /campaigns/{slug} mỗi 15–30s (dừng khi tab ẩn) — vừa đủ cập nhật giá + progress đến khi có SSE (Phase 2).
  • Progress bar kèm thông điệp "Còn X suất nữa để giảm còn Y đ" và chỉ báo MOQ đạt/chưa đạt.

3 · Vòng đời order

RESERVED ──webhook cọc──▶ DEPOSIT_HELD ──settle thành công──▶ SETTLEMENT_DUE
   │ │                        │  │                                │
   │ └─15p không cọc──▶ EXPIRED│  ├─admin refund / fail / cancel─▶ REFUND_PENDING ─admin xác nhận đã trả─▶ REFUNDED
   │                          │  │                                 
   └─campaign bị cancel──▶ CANCELLED                              └─webhook đủ amount_due─▶ CONFIRMED

                                                              fulfill/"đã về hàng" hoặc ship ▼
                                                                            SHIPPED ─admin xác nhận─▶ DELIVERED

Ai kích hoạt gì:

Chuyển trạng tháiAiCơ chế
RESERVED → EXPIREDHệ thốngCron quét đơn quá 15 phút giữ slot (đã có)
RESERVED → DEPOSIT_HELDWebhook SePayCọc về đủ deposit_due
DEPOSIT_HELD → SETTLEMENT_DUEHệ thống/adminSettle (auto hoặc tay); BE tính sẵn amount_due = qty × final_unit_price + shipping_fee − deposit_paid
SETTLEMENT_DUE → CONFIRMEDWebhook SePayUser chuyển đủ phần còn lại (cộng dồn qua settlement_paid)
CONFIRMED → SHIPPEDHệ thống/adminCampaign-level "Đã về hàng" (fulfill) chuyển tất cả đơn đã trả đủ sang SHIPPED; hoặc admin ship từng đơn
SHIPPED → DELIVEREDAdminXác nhận giao từng đơn
bất kỳ giai đoạn trước settle → REFUND_PENDINGHệ thốngAuto khi FAILED/Cancel
REFUND_PENDING → REFUNDEDAdminBấm "Đã hoàn cọc" sau khi đã chuyển tiền ngoài hệ thống
DEPOSIT_HELD/SETTLEMENT_DUE → REFUNDEDAdminRefund chủ động 1 bước (không qua pending)

Tự động hoàn tất (mặc định): admin KHÔNG phải bấm từng đơn — chỉ cần 1 nút "Đã về hàng" ở campaign; mọi đơn đủ tiền tự vào hàng gửi. Nút refund/ship/deliver per-order chỉ là can thiệp chủ động khi cần (hoàn cọc riêng, ship sớm…).

4 · Thanh toán

  1. Cọc: đặt slot → giữ 15 phút → user chuyển khoản theo mã đơn (GB-XXXXXX trong nội dung CK) → webhook SePay xác nhận tự động → RESERVEDDEPOSIT_HELD.
  2. Phần còn lại: campaign chốt xong, BE trả amount_due trên đơn. User chuyển tiếp theo cùng order_code; webhook ghi nhận dạng thanh toán settlement, cộng dồn settlement_paid; đủ → CONFIRMED.
  3. FE hiển thị rõ ở lịch sử mua: "Thành công" / "Cần thanh toán còn lại X đ" (+ nút dẫn tới trang thanh toán).

5 · Quyền & lịch sử

  • Bắt buộc đăng nhập để đặt hàng: BE đã chặn (401); FE storefront phải gate nút đặt hàng — chưa login thì redirect /login?next=<trang hiện tại> rồi quay lại đúng chỗ.
  • Lịch sử mua tại /orders (storefront, cần đăng nhập): danh sách đơn kèm badge trạng thái (đang giữ/đã cọc/cần thanh toán/thành công/đang giao/đã giao/đã hoàn), số tiền còn phải trả, link thanh toán khi còn thiếu.
  • Admin thấy đầy đủ đơn theo campaign + hành động refund/ship/deliver + nút fulfill.

6 · Quyết định thiết kế đã chốt

#Quyết địnhLý do
D1MOQ = threshold tier thấp nhất, không thêm columnTái sử dụng model tiers hiện có; tránh 2 nguồn truth
D2Auto-settle chạy bằng cron in-process (như ExpiryService)Phase 0 không có queue riêng; Asynq để Phase sau
D3"Có hàng" = admin bấm fulfill ở campaign-level → bulk ship đơn đủ tiềnTránh phải tick từng đơn; vẫn ship/refund riêng khi muốn
D4Hoàn cọc là ghi nhận thủ công (REFUND_PENDING → REFUNDED bởi admin)Phase 0 chưa có cổng hoàn tiền tự động
D5Realtime = polling 15–30s trang detailSSE là Phase 2
D6Tiền settlement nhận qua cùng webhook SePay, phân nhánh theo trạng thái đơn (tách mã GB-XXXXXX từ nội dung CK)Không thêm endpoint/provider mới

7 · Bản đồ công việc

Thành phầnRepoViệc
BE../gghut-coreMigration enum mới (SHIPPED,DELIVERED,REFUND_PENDING,REFUNDED; campaign FAILED), computed fields (moq, moq_reached, current_unit_price), auto-settle/fail cron, webhook settlement, endpoints admin orders + fulfill, tests
Storefrontnextjs-storefront/Hiển thị giá hiệu lực + MOQ + polling, gate đăng nhập, lịch sử mua theo trạng thái mới, banner campaign FAILED
Adminnextjs-admin/Form default/promo price, bảng đơn + hành động refund/ship/deliver, nút fulfill, badge trạng thái mới