Bỏ qua nội dung

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ờ isRenewal ngay khi mua gói (qua package-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 đủ 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

  • 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.
  • 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.
  • 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í.
  • 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
    end

Acceptance Criteria

  • AC-1: POST /subscriptions/me/renewal/deactivate yêu cầu authority subscription:manage:own.
  • AC-2: Endpoint là toggle: lật cờ isRenewal củ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 (ResourceNotFoundException cho UserPackage ACTIVE).
  • AC-4: Trả về SubscriptionRenewalResponse: userPackageId, userId, packageCode, isRenewal, paidExpiredAt, expiredAt, bonusMonths.
  • AC-5: Cờ isRenewal cũng được set khi mua gói (field isRenewal trong PurchasePackageRequest).
  • AC-6: Job nền chạy theo cron 0 5 0 * * * (00:05 hằng ngày, zone Asia/Ho_Chi_Minh), có thể bật/tắt qua config app.maintenance.jobs.enabled (mặc định true).
  • AC-7: Job tự gia hạn một gói khi THỎA CẢ 4 điều kiện: user_package.renewal = true AND package.status = ACTIVE AND 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ài cycleMonths; 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 EXPIRED và gửi PACKAGE_RENEW_FAILED.
  • AC-12: Mọi trường hợp còn lại (tắt renewal, gói đã INACTIVE, hoặc giá ≤ 0) → set EXPIRED và gửi PACKAGE_EXPIRED (kèm tên gói) — KHÔNG phải PACKAGE_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 đã INACTIVE không auto-renew dù isRenewal = true; khi hết hạn user_package chuyển EXPIRED và gửi PACKAGE_EXPIRED.
  • AC-15: Cùng job nền (SubscriptionMaintenanceJob) cũng expire payment order trạng thái CREATED → EXPIRED.

Quy tắc nghiệp vụ

  • Auto-renew là một lựa chọn (cờ isRenewal) trên UserPackage, đặ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 = true AND package.status = ACTIVE AND giá gia hạn (priceCredits) > 0 AND ví đủ credit. Thiếu bất kỳ điều kiện nào → gói EXPIRED.
  • R2 — INACTIVE chặ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_package chuyển thẳng EXPIRED.
  • 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 purchasePriceCredits củ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 đang ACTIVE vớ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, zone Asia/Ho_Chi_Minh), bật/tắt qua app.maintenance.jobs.enabled (mặc định true).
  • 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 INACTIVEisRenewal = true, tắt renewal, giá ≤ 0 → PACKAGE_EXPIRED (KHÔNG phải PACKAGE_RENEW_FAILED).
  • Cùng job nền (SubscriptionMaintenanceJob) cũng expire payment order trạng thái CREATED → 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 (cron 0 5 0 * * *, Asia/Ho_Chi_Minh) quét và xử lý gói hết hạn; cùng job cũng expire payment order CREATED → EXPIRED.
  • UserWallet: ví V-Credits bị trừ khi tự gia hạn.
  • NotificationEvent / loại PACKAGE_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