Gia hạn và tự động gia hạn gói
Feature URL
Module
subscription-package
Status
shipped
Priority
P1
Platforms
fe · be
AC progress
15 / 15
Last reviewed
2026-06-08
Mục tiêu
Cho phép user giữ gói liên tục mà không phải mua lại thủ công mỗi kỳ: khi bật auto-renew, hệ thống tự động trừ ví V-Credits và mở kỳ mới cùng tier/chu kỳ ngay khi gói hết hạn. Đồng thời cho user chủ động bật/tắt auto-renew, và đảm bảo khi không đủ tiền hoặc đã tắt auto-renew thì gói chuyển sang hết hạn kèm thông báo rõ ràng.
Phạm vi
Trong phạm vi (In scope):
- API bật/tắt auto-renew cho gói active hiện tại:
POST /subscriptions/me/renewal/deactivate— thực chất là toggle (đang bật thì tắt, đang tắt thì bật). - Đặt cờ
isRenewalngay khi mua gói (quapackage-purchase) làm lựa chọn auto-renew ban đầu. - Job nền định kỳ (
SubscriptionMaintenanceJob) quét gói active đã quá hạn và xử lý:- Nếu auto-renew bật và đủ credits → tự gia hạn: trừ ví, tạo kỳ mới cùng tier/chu kỳ, notify “đã gia hạn”.
- Nếu không (tắt auto-renew, hoặc bật nhưng thiếu credits) → set
EXPIRED+ gửi notification phù hợp.
- Trả về trạng thái auto-renew sau khi toggle (
SubscriptionRenewalResponse).
Ngoài phạm vi (Out of scope):
- Mua gói lần đầu — xem package-purchase.
- Nâng cấp / hạ gói — xem package-change.
- Gia hạn thủ công sang gói khác (đó là đổi gói).
- Nhắc nạp tiền trước khi gia hạn (chưa có job nhắc trước hạn).
- Cấu hình lịch chạy job (thuộc cấu hình hệ thống, không phải requirement feature này).
User Stories
- Là user, tôi muốn bật auto-renew để gói tự gia hạn khi hết hạn mà không phải thao tác lại.
- Là user, tôi muốn tắt auto-renew khi không còn muốn tiếp tục, và kỳ hiện tại vẫn chạy đến khi hết hạn.
- Là user có auto-renew nhưng thiếu tiền, tôi muốn nhận thông báo gia hạn thất bại để kịp nạp ví.
- Là hệ thống, tôi muốn tự động trừ ví và mở kỳ mới đúng tier/chu kỳ cũ khi gói hết hạn và đủ điều kiện.
Luồng chức năng
sequenceDiagram
actor User
participant App as FE/Mobile
participant BE
participant Job as SubscriptionMaintenanceJob
participant Wallet as Ví V-Credits
Note over User,BE: Bật/tắt auto-renew (toggle)
User->>App: Bấm "Tự động gia hạn"
App->>BE: POST /subscriptions/me/renewal/deactivate
BE->>BE: Tìm gói active (404 nếu không có)
BE->>BE: isRenewal = !isRenewal
BE-->>App: SubscriptionRenewalResponse {isRenewal, paidExpiredAt, expiredAt, bonusMonths}
Note over Job,Wallet: Cron 0 5 0 * * * (00:05 hằng ngày, Asia/Ho_Chi_Minh)
Job->>BE: expirePackages(now)
loop Mỗi gói active đã quá hạn
alt renewal=true AND package ACTIVE AND priceCredits>0 AND ví đủ credit
BE->>BE: Mở kỳ mới cùng tier/chu kỳ
BE->>Wallet: Trừ priceCredits
BE->>BE: Notify "đã gia hạn"
else Đủ điều kiện auto-renew nhưng thiếu credit
BE->>BE: Set EXPIRED + Notify PACKAGE_RENEW_FAILED
else Còn lại (tắt renewal, package INACTIVE, giá ≤ 0...)
BE->>BE: Set EXPIRED + Notify PACKAGE_EXPIRED
end
endAcceptance Criteria
- AC-1:
POST /subscriptions/me/renewal/deactivateyêu cầu authoritysubscription:manage:own. - AC-2: Endpoint là toggle: lật cờ
isRenewalcủa gói active (đang bật → tắt, đang tắt → bật). (Tên endpoint là “deactivate” nhưng hành vi là toggle.) - AC-3: User không có gói active → 404 (
ResourceNotFoundExceptionchoUserPackageACTIVE). - AC-4: Trả về
SubscriptionRenewalResponse:userPackageId,userId,packageCode,isRenewal,paidExpiredAt,expiredAt,bonusMonths. - AC-5: Cờ
isRenewalcũng được set khi mua gói (fieldisRenewaltrongPurchasePackageRequest). - AC-6: Job nền chạy theo cron
0 5 0 * * *(00:05 hằng ngày, zoneAsia/Ho_Chi_Minh), có thể bật/tắt qua configapp.maintenance.jobs.enabled(mặc địnhtrue). - AC-7: Job tự gia hạn một gói khi THỎA CẢ 4 điều kiện:
user_package.renewal = trueANDpackage.status = ACTIVEAND giá gia hạn (priceCredits) > 0 AND ví đủ credit. - AC-8: Kỳ gia hạn bắt đầu từ thời điểm gói cũ hết hạn (
expiredAt, đã gồm bonus months) và kéo dàicycleMonths; cùng tier, cùng chu kỳ với kỳ trước. - AC-9: Gia hạn tự động trừ ví đúng
purchasePriceCreditsđã snapshot và gửi notification “đã gia hạn”. - AC-10: Auto-renew giữ nguyên tier nên không reset bộ đếm daily unlock (job reset daily riêng vẫn chạy).
- AC-11: Khi gói đủ điều kiện auto-renew (4 điều kiện AC-7) NHƯNG ví thiếu credit → set
EXPIREDvà gửiPACKAGE_RENEW_FAILED. - AC-12: Mọi trường hợp còn lại (tắt
renewal, gói đãINACTIVE, hoặc giá ≤ 0) → setEXPIREDvà gửiPACKAGE_EXPIRED(kèm tên gói) — KHÔNG phảiPACKAGE_RENEW_FAILED. - AC-13: Gói có giá gia hạn ≤ 0 (vd gói miễn phí) không được tự gia hạn.
- AC-14 (R2): Gói đã
INACTIVEkhông auto-renew dùisRenewal = true; khi hết hạnuser_packagechuyểnEXPIREDvà gửiPACKAGE_EXPIRED. - AC-15: Cùng job nền (
SubscriptionMaintenanceJob) cũng expire payment order trạng tháiCREATED → EXPIRED.
Quy tắc nghiệp vụ
- Auto-renew là một lựa chọn (cờ
isRenewal) trênUserPackage, đặt khi mua và đổi qua endpoint toggle. - Điều kiện auto-renew (tường minh): gói chỉ tự gia hạn khi THỎA CẢ 4:
user_package.renewal = trueANDpackage.status = ACTIVEAND giá gia hạn (priceCredits) > 0 AND ví đủ credit. Thiếu bất kỳ điều kiện nào → góiEXPIRED. - R2 —
INACTIVEchặn gia hạn: khi gói đãINACTIVE, lúc hết hạn hệ thống KHÔNG auto-renew dùisRenewal = true;user_packagechuyển thẳngEXPIRED. - Tắt auto-renew không huỷ gói ngay: kỳ hiện tại vẫn chạy đến hết hạn rồi mới
EXPIRED. - Gia hạn tự động chỉ áp dụng cùng tier, cùng chu kỳ — không phải dịp để đổi/nâng gói (muốn đổi thì dùng package-change).
- Giá gia hạn lấy từ snapshot
purchasePriceCreditscủa gói đang dùng (không lấy lại giá catalog hiện tại) — bảo vệ user khỏi biến động giá. - Kỳ gia hạn nối tiếp sau toàn bộ quyền lợi (gồm bonus months), không chồng lấn.
- Điều kiện đủ credits được kiểm tra trên ví
MAINđangACTIVEvới lock để tránh race. - Job là forward-only: chỉ gia hạn hoặc expire; không hồi tố, không nhắc nạp tiền trước hạn (hiện tại).
- Job chạy theo cron
0 5 0 * * *(00:05 hằng ngày, zoneAsia/Ho_Chi_Minh), bật/tắt quaapp.maintenance.jobs.enabled(mặc địnhtrue). - Ma trận notification khi job xử lý gói hết hạn:
- Gia hạn thành công (thỏa cả 4 điều kiện) → notify “đã gia hạn”.
- Đủ điều kiện auto-renew NHƯNG thiếu credit →
PACKAGE_RENEW_FAILED. - Còn lại — gồm trường hợp gói
INACTIVEdùisRenewal = true, tắtrenewal, giá ≤ 0 →PACKAGE_EXPIRED(KHÔNG phảiPACKAGE_RENEW_FAILED).
- Cùng job nền (
SubscriptionMaintenanceJob) cũng expire payment order trạng tháiCREATED → EXPIRED.
Dữ liệu & Trạng thái
Entity nghiệp vụ:
UserPackage:renewal(cờ auto-renew),purchasePriceCredits(giá gia hạn),cycleMonths,bonusMonths,paidExpiredAt,expiredAt,status.SubscriptionRenewalResponse: trạng thái auto-renew trả về sau toggle.SubscriptionMaintenanceJob: job nền (cron0 5 0 * * *,Asia/Ho_Chi_Minh) quét và xử lý gói hết hạn; cùng job cũng expire payment orderCREATED → EXPIRED.UserWallet: ví V-Credits bị trừ khi tự gia hạn.NotificationEvent/ loạiPACKAGE_RENEW_FAILED,PACKAGE_EXPIRED, notification “đã gia hạn”.
Trạng thái user-facing:
auto-renew-on— auto-renew đang bật.auto-renew-off— auto-renew đang tắt, gói chạy đến hết hạn.renewed— đã tự gia hạn thành công sang kỳ mới.renew-failed— định gia hạn nhưng thiếu credits, gói đãEXPIRED.expired— gói hết hạn (không bật auto-renew).
Liên quan
- Phụ thuộc: package-purchase
- Ảnh hưởng: Chưa có.
- Nền tảng: package-view
- Liên quan đổi gói: package-change