Chuyển tới nội dung chính

Nebula Pricing

Flow tổng quan

   ┌──────────────────────── sim-core (BILLING) ────────────────────────┐
│ │
│ Pricing: 6 gói (Basic/Pro/Max × tháng/năm) + FREE_TRIAL_7D(MAX) │
│ │ │
│ ┌────┴─────┬──────────────┬───────────────┐ │
│ ▼ ▼ ▼ ▼ │
│ Trial Mua gói Upgrade Cron hết hạn │
│ (free- (activate- (pro-rata, (markExpired │
│ trial) NebulaPlan) finalAmount) NebulaPlans) │
│ │ │ │ │ │
│ └──────────┴──────┬───────┴───────────────┘ │
│ ▼ │
│ MembershipPlanUser (type=BASIC/PREMIUM/PRO/MAX, isFreeTrial) │
│ + MembershipPlanTransaction (nguồn sự thật BILLING) │
└────────────────────┬────────────────────────────────────────────────┘
│ getMembershipInfo(userId)

┌──────────────────── sim-personalize (AI / NEBULA) ─────────────────┐
│ │
│ NebulaBrainRuleService.getTierInfo(userId) │
│ → UserValidateNebulaBrainDtoConverter: type → tier │
│ (premium→pro; PRO/MAX/BASIC/FREE → lowercase) │
│ │ │
│ getSubscriptionTier(userId) → "free|basic|pro|max" │
│ │ │
│ ┌────┴──────────────┬──────────────────────┐ │
│ ▼ create() ▼ downgradeTier() ▼ validateProfileReq. │
│ set tier khi tạo so tier cũ/mới → chặn horizon nếu │
│ profile reinit ≠max │
│ │ │
│ ▼ NebulaProfile.subscriptionTier │
│ ▼ SQS initialize/reinitialize → Nebula Brain │
└─────────────────────────────────────────────────────────────────────┘

Mapping tier

  • PlanType (sim-core) hiện có: FREE(0), BASIC(1), PREMIUM(2), PRO(3), MAX(4).
  • SubscriptionTierEnum (sim-personalize) hiện có: free, basic, pro, max.
Tên user-facingTrả phí?sim-core PlanTypesim-personalize tierTháng (k)Năm sale (k)
Free / StarterKhông(không có plan active)free
BasicBASIC(1)basic3493.141
Pro (cũ)— (không bán mới)PREMIUM(2)pro5995.032
Pro (mới)PRO(3)pro7496.376
MaxMAX(4)max2.59918.713
Free trial 7DKhông (trial)MAX(4) + isFreeTrial=truemax00

Feature matrix per tier (NebulaProfile fields)

Featurefreebasic (Basic 349k)pro (749k / Pro cũ)max (+trial)NebulaProfile field
Nebula OrbitYesYesYesYesstrategyModel = orbit
Nebula HorizonNoNoNoYesstrategyModel = horizon
Pool BluechipYesYesYesYesstockUniverse = pool_50
Pool Full (bluechip+midcap)NoNoYesYesstockUniverse = pool_100
Pool 200NoNoNoYesstockUniverse = pool_200
StockYesYesYesYes(mặc định)
Tiền gửi + Trái phiếu (Fixed Income)NoYesYesYesactivateFixedIncome
Vàng (Gold)NoNoYesYesactivateGold
CryptoNoNoNoYesactivateCrypto
MarginNoNoYesYesmarginEnabled
Hedging futures (VN30F)NoNoNoYeshedgingEnabled

1. Trial Flow — trial 7D MAX

  • Trigger bên core khi đăng kí/đăng nhập
                         User truy cập Simplize

┌─────────────────┴─────────────────┐
[Chưa có tk] [Đã có tk]
│ │
▼ ▼
┌───────────────────┐ ┌───────────────────┐
│ Đăng ký │ │ Đăng nhập │
│ POST /auth/register│ │ POST /auth/login │
└─────────┬─────────┘ │ (hoặc social / │
│ │ refresh-token) │
▼ └─────────┬─────────┘
┌───────────────────┐ │
│ Verify OTP │ │ authService.authenticate() OK
│ (SĐT / email) │ │
│ UserServiceImpl │ ▼
│ :634 / :999 │ ┌───────────────────────┐
└─────────┬─────────┘ │ [B5 — HOOK MỚI] │
│ ĐÃ CÓ sẵn gọi │ try { activeMembership│
▼ │ PlanFreeTrial(userId);}│
┌───────────────────┐ │ catch { log } │
│ activeMembership │ └─────────┬─────────────┘
│ PlanFreeTrial │ │
│ (user.getId()) │ │
└─────────┬─────────┘ │
└──────────────┬───────────────────-─┘

┌──────────────────────────────────────┐
│ activeMembershipPlanFreeTrial(userId) │ (logic chung — Section "Sửa" bên dưới)
│ 1. config free.trial.on? → off: skip │
│ 2. guard: đang có gói trả phí? → chặn│
│ 3. đã dùng gói trial MỚI? (count) │
│ ├─ có → skip (đã dùng rồi) │
│ └─ chưa→ tạo MembershipPlanUser │
│ {FREE_TRIAL_MAX_7D, 7d} │
└──────────────────────────────────────┘


User vào Nebula → getSubscriptionTier() trả max (trial) → dùng full 7 ngày
  • Trigger bên personalize khi tạo nebula
                      User vào Nebula lần đầu → "Tạo danh mục"


┌─────────────────────────────────────────────┐
│ sim-personalize │
│ NebulaBrainProfileServiceImpl.create() │
│ 1. chưa có NebulaProfile? (chặn trùng) │
│ 2. [HOOK MỚI] RPC → sim-core: │
│ try { userServiceConsumer │
│ .activeMembershipPlanFreeTrial( │
│ userId); } │
│ catch { log — không chặn create } │
│ 3. tier = getSubscriptionTier(userId) │ ← pull lại membership SAU khi cấp trial
└───────────────────┬─────────────────────────┘
│ RPC

┌─────────────────────────────────────────────┐
│ sim-core │
│ activeMembershipPlanFreeTrial(userId) │
│ 1. config free.trial.on? → off: skip │
│ 2. guard: đang có gói trả phí? → skip │
│ 3. đã dùng gói trial MỚI? (count theo │
│ membershipId + isFreeTrial) │
│ ├─ có → skip (đã dùng rồi) │
│ └─ chưa→ tạo MembershipPlanUser │
│ {FREE_TRIAL_MAX_7D, 7d} │
└───────────────────┬─────────────────────────┘

tier = max (trial) → INSERT NebulaProfile{tier} → SQS initialize

User dùng full Nebula MAX 7 ngày

Các đối tượng: Chỉ áp dụng cho các user dùng Nebula

Hiện trạng logic chặn hiện tại (dòng 406-409) dùng countAllByUserId — đếm TẤT CẢ membership của user (mọi loại: trial/Premium/Pro/đã hết hạn):

Long count = membershipPlanUserRepository.countAllByUserId(SecurityUtils.getCurrentUserId());
if ((count != null && count > 0) || BooleanUtils.isNotTrue(user.getVerifiedPhoneNumber())) {
throw new BusinessException("Bạn không đủ điều kiện để nâng cấp gói dùng thử PRO");
}

→ Bất kỳ user nào đã từng có 1 record membership → count > 0 → bị chặn vĩnh viễn. → Toàn bộ user cũ (đã mua Pro, từng dùng trial...) không thể dùng thử Nebula Max 7 ngày.

Hướng xử lý:

  • Tái dùng: record FREE_TRIAL_7D đã có trong DB → UPDATE type=MAX(4), period=DAY, periodNumber=7.
  • Config gói free mới: MEMBERSHIP_FREE_TRIAL_ID
  • Đếm record của ĐÚNG gói trial mới + cờ free trial
Long membershipPlanId = configSystemService.getLong(MEMBERSHIP_FREE_TRIAL_ID, 0L);  // = id gói trial MỚI
if (membershipPlanId <= 0) throw new BusinessException("Chưa có dữ liệu");
Long count = membershipPlanUserRepository
.countByUserIdAndMembershipIdAndIsFreeTrial(userId, membershipPlanId, true);
if((count != null && count > 0) || BooleanUtils.isNotTrue(user.getVerifiedPhoneNumber())) {
throw new BusinessException("Bạn không đủ điều kiện để nâng cấp gói dùng thử PRO");
}

2. Profile Create/Update Flow

  • Validate theo subscription_tier + strategy_model
User vào Nebula lần đầu → Welcome Page

├── "Tạo danh mục" → Wizard (4 steps)
└── "Skip" → default profile


sim-personalize: findActiveMembershipPlan(userId) ← gọi sim-core

├── plan type=3, isFreeTrial=true → tier=max, status=TRIAL
├── plan type=3, isFreeTrial=false → tier=max, status=ACTIVE
├── plan type=2 → tier=pro, status=ACTIVE
├── plan type=1 → tier=basic, status=ACTIVE
└── null → tier=free, status=EXPIRED


INSERT NebulaProfile { tier }


SQS "initialize" → Polling → Dashboard

3. Upgrade/Downgrade

  • User bấm "Nâng cấp gói" trên Pricing Page => về nebula setting
                 User bấm "Nâng cấp gói" trên Pricing Page
┌───────────────────────────────────┐
│ GET /membership/nebula/plans/v3 │ danh sách các gói mua
└───────────────┬───────────────────┘


┌───────────────────────────────────┐
│ GET /membership/ant/plan-detail/16│ (tùy chọn: xem trước số tiền)
│ → trả {newPrice, unusedAmount, finalAmt} │
└───────────────┬───────────────────┘
│ User confirm + thanh toán finalAmount

┌───────────────────────────────────┐
│ POST /payment/create-payment │ (sau payment success)
└───────────────┬───────────────────┘

┌───────────────────────────────────┐
│ oldPlan = findMembershipPlanActive │
└───────────────┬───────────────────┘

oldPlan có phải gói TRẢ PHÍ active?
┌──────────┴───────────┐
[Có, không phải trial] [Trial / null]
│ │
▼ ▼
┌────────────────────┐ unused = 0
│ unused = │ │
│ (endDate-today) / │ │
│ (endDate-startDate)│ │
│ × oldPlan.totalAmt │ │
│ unused=max(0,round)│ │
└─────────┬──────────┘ │
└───────────┬──────────┘

┌───────────────────────────────────┐
│ finalAmount = max(0, │
│ newPlanPrice − unused) │
└───────────────┬───────────────────┘

┌───────────────────────────────────┐
│ Expire oldPlan: endDate=today │
│ activeMembershipPlan(request{ │
│ planId, finalAmount, │ ← core dùng finalAmount (:352)
│ originalAmount=newPlanPrice}) │ tạo MembershipPlanUser + txn
└───────────────┬───────────────────┘
  • User chọn "Mua gói" bên nebula (setting + thanh toán) — phân nhánh theo tương quan gói mới vs gói đang dùng
                 User bấm "Mua gói" bên Nebula (setting)


┌───────────────────────────────────┐
│ oldPlan = findMembershipPlanActive │ (gói trả phí đang còn hạn, không tính trial)
└───────────────┬───────────────────┘

So newPlan.tier vs oldPlan.tier
┌─────────────────────┼─────────────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌───────────────────┐ ┌──────────────────────┐
│ 1. GIA HẠN │ │ 2. MUA GÓI CAO HƠN│ │ 3. MUA GÓI THẤP HƠN │
│ (đúng gói │ │ (UPGRADE) │ │ (DOWNGRADE) │
│ đang dùng) │ │ new.tier>old.tier │ │ new.tier<old.tier │
└───────┬───────┘ └─────────┬─────────┘ └──────────┬───────────┘
│ │ │
▼ ▼ ▼
└──────────┬──────────┴─────────────────────────┘

┌───────────────────────────────────┐
│ User confirm + thanh toán │
│ POST /payment/create-payment │ (sau payment success)
└───────────────┬───────────────────┘

┌───────────────────────────────────┐
│ Expire oldPlan (nếu upgrade): │
│ endDate = today │
│ activeMembershipPlan(request{ │
│ planId, finalAmount, │ ← tạo MembershipPlanUser + txn
│ originalAmount = newPlanPrice}) │
└───────────────┬───────────────────┘

┌───────────────────────────────────┐
│ Sync sim-personalize → updateTier │
│ (gia hạn/upgrade: nâng-bằng tier → │
│ updateTier chỉ set nhãn tier, │
│ needReinit=false trừ khi popularTier │
│ có clamp; downgrade đã gọi │
│ updateTier ở bước ① rồi → no-op │
│ vì newTier == oldTier) │
└───────────────────────────────────┘

Quy tắc business:

  • Tiền chưa dùng gói cũ = (end_date - today) / (end_date - start_date) × số tiền THỰC TRẢ gói cũ
  • Tiền cần thanh toán = Giá mua gói mới hiện tại − Tiền chưa dùng gói cũ
  • Thời hạn gói mới = tính từ ngày upgrade
  • "Số tiền thực trả" = số tiền KH đã thanh toán (có thể thấp hơn giá gốc nếu mua lúc KM) → lấy từ MembershipPlanUser.totalAmount của gói cũ, KHÔNG lấy MembershipPlan.amount.

Ví dụ số: Gói Pro 749k mua 30 ngày, đã dùng 10 ngày, còn 20 ngày.

  • unused = 20/30 × 749.000 = 499.333đ
  • Upgrade Max 2.599.000đ → finalAmount = 2.599.000 − 499.333 = 2.099.667đ
  • Gói Max mới: startDate = today, endDate = today + 30 ngày.

4. Downgrade khi gói hết hạn

  • Job chỉ lo việc hạ tier của NebulaProfile cho đúng với gói membership còn hạn (nếu có), rồi reinit. Việc tạm khóa (SUSPENDED) / dừng hẳn (STOPPED) là của job khác — xem mục ## 5.
  • TH: hết hạn gói max trial, user cũ ko còn membership nào còn hạn => tier = free (sau đó job ## 5 mới SUSPENDED).
  • TH: hết hạn gói max trial, user cũ đang còn membership (vd pro) => tier = pro (theo gói còn hạn).
  • TH: đang dùng gói cao, ko gia hạn gói đó nữa mà mua gói thấp hơn => tier hạ về gói thấp đang còn hạn.
  • Lấy các user có gói nebula quá hạn => resolve tier mới => hạ gói.
Cron daily (sim-personalize)
NebulaBrainJobServiceImpl.checkExpiredTier()

▼ List<MembershipUserDto> expiredPlans = userServiceConsumer.findAllExpiredPlans();
│ // RPC -> sim-core MembershipServiceImpl.findAllExpiredPlans()
│ // lấy MembershipPlanUser status=ACTIVE AND end_date < today AND membership_id IN (nebula plan ids config)
│ // group theo userId, mỗi user resolve ra tier mới:
│ │
│ ├── activePlan = findMembershipPlanActive(userId) // lọc status=ACTIVE AND end_date >= today (inclusive)
│ │
│ ├── activePlan != null (còn gói trả phí khác đang hạn)
│ │ → planType = activePlan.getType().getName() (vd "pro" / "max")
│ │
│ └── activePlan == null (KHÔNG còn gói nào còn hạn → đã thực sự hết)
│ → planType = "free"

▼ For each MembershipUserDto(userId, planType):
nebulaBrainProfileService.updateTier(userId, planType)
│ // NebulaBrainProfileServiceImpl.updateTier()

├── profile == null → bỏ qua

├── newTier = SubscriptionTierEnum.resolve(planType) ("free"/"basic"/"pro"/"max")
├── newTier == oldTier → return (ko đổi gì)

└── tier thay đổi:
→ lưu oldTier vào params, set subscriptionTier = newTier
→ needReinit = isDowngrade || popularTier(...) (clamp feature theo tier mới)
→ nếu needReinit: lastRequestType = REINITIALIZE, updateStatus = POLLING,
gửi message queue reinit + saveHistory("update_tier")

5. Suspend/Stop Nebula (khóa dần)

  • User khi ko đủ vốn (NAV) HOẶC ko gia hạn gói (membership) sẽ bị tạm dừng (SUSPENDED). Sau N ngày tạm dừng (config remainingDays, mặc định 7 ngày) mà điều kiện vẫn ko thoả thì dừng hẳn nebula (STOPPED).
Cron daily(sim-personalize) — updateBrainActivationStatus()

▼ Quét TẤT CẢ NebulaProfile: status IN getAllowedStatuses() AND tenantId = simplizevn
│ (KHÔNG lọc theo tier=free; submit từng profile vào executor xử lý song song)

▼ For each profile (async):
│ rule = validateConditionRule(userId, data) // → isValid / hasMembership / hasNavValid
│ activeMetadata = findAllByUserIdAndIsActive(userId, true)
│ status = popularStatus(rule, profile, activeMetadata)
│ nếu status != oldStatus → set activationStatus + updateActivationStatusInternal(profile)

▼ popularStatus() — xét theo thứ tự (return ngay khi khớp):

├── rule == null || profile == null
│ → INIT

├── profile.status = ACTIVE AND KHÔNG có active metadata
│ → ACTIVE

├── activationStatus = STOPPED AND profile.status = STOPPED
│ → STOPPED (giữ nguyên)

├── profile.status = REINIT_FREE
│ → STOPPED

├── profile.status = STOPPED HOẶC KHÔNG có active metadata
│ → STOPPED

├── rule.isValid() (đủ NAV + có membership)
│ → ACTIVE, clear suspendedDate = null


├── activationStatus = SUSPENDED AND suspendedDate != null
│ AND (now - suspendedDate) > remainingDays (config, mặc định 7 ngày)
│ → hủy toàn bộ active metadata (isActive=false, save)
│ → set stopDate = now, suspendedDate = null
│ → profile.status = STOPPED
│ → STOPPED (msg "Tạm dừng quá số ngày quy định ...")

├── !hasMembership HOẶC !hasNavValid
│ → nếu suspendedDate == null → set suspendedDate = now
│ → SUSPENDED (msg "Không thoả mãn điều kiện: membership=..., nav=...")

└── (mặc định)
→ ACTIVE

APIs

sim-core

#MethodEndpointMô tảStatus
1GET/api/general/membership/nebula/plans/v3Lấy danh sách gói Nebula (PRO/MAX × tháng/năm). Đọc từ config membership.nebula.pricing.plan.idsCHƯA CÓ
1GETapi/general/membership/ant/plan-detail/16Lấy detail gói Nebula (PRO/MAX × tháng/năm). Bổ sung giá tiền phải trả theo công thức nâng cấp góiCẦN SỬA
2POST/api/general/payment/create-paymentKích hoạt gói sau thanh toán: expire plan cũ → tạo MembershipPlanUser → tạo Transaction → check NebulaProfile tồn tại → gọi update sim-personalizeCần bổ sung update tier personalize

sim-personalize — sửa endpoints có sẵn

#MethodEndpointMô tảStatus
8POST/api/personalize/control/nebula-profile/createCÓ SẴN — cần bổ sung logic: validate tier change → create() + history + reinitializeCẦN SỬA
8POST/api/personalize/control/nebula-profile/updateCÓ SẴN — cần bổ sung logic: validate tier change → update() + history + reinitializeCẦN SỬA

5. Job

#ServiceJob NameCronMô tả
1sim-personalizecheckExpiredNebulaPlanDailyQuery membership_plan_user (Nebula) có endDate < now AND status=ACTIVE (bao gồm cả trial lẫn paid) → check user có plan active khác → có: gọi update với tier mới → không: gọi update downgrade basic. sim-personalize updateTicer() tự detect tier change → updateTier() + history
2sim-personalizeupdateActivationStatusDailyUser khi bị hạ về gói free thì nebula sẽ bị tạm dừng (SUSPENDED). Sau 7 ngày tạm dừng mà ko nâng gói thì sẽ dừng nebula (STOP)

7. Các công việc

7.1 sim-core

#ViệcLoại
ADB: INSERT gói trial mới NEBULA_FREE_TRIAL_7D + INSERT 6 gói Basic/Pro/Max + ẩn gói cũ + configMigration
B1isHaveProPlan / isHaveProPlanAdjust → thêm BASIC + MAX (gói trả phí → true, Free → false) => !FREESửa
B2activeMembershipPlanInternal (:380) — bỏ set cứng PREMIUM, set theo plan.getType()Sửa
B3activeMembershipPlanFreeTrial — đổi check countAllcountByUserIdAndMembershipIdAndIsFreeTrial (1 lần / gói trial mới)Sửa
B4Hook login flow AuthService.authenticate() → gọi activeMembershipPlanFreeTrial(userId) (try-catch) để cấp trial cho tk cũ đăng nhậpSửa
C4(tùy GĐ1) Convert credit → gói ProTạo

7.2 sim-personalize

#ViệcLoại
D1validateProfileRequest — bổ sung rule feature per tier (gold / crypto / fi / margin / pool theo tier)Sửa
D2updateTier — sim-core gọi khi thanh toánSửa nhỏ

7.3 DB Changes (sim-core)

INSERT membership_plan (gói trả phí mới):

codenameDisplaytypeamountperiodperiodNumberisFreeTrialpublicStatus
BASIC_1MBasicBASIC(1)349.000MONTH1falsePUBLIC
BASIC_1YBasicBASIC(1)3.141.000YEAR1falsePUBLIC
PRO_1MProPRO(3)749.000MONTH1falsePUBLIC
PRO_1YProPRO(3)6.376.000YEAR1falsePUBLIC
MAX_1MMaxMAX(4)2.599.000MONTH1falsePUBLIC
MAX_1YMaxMAX(4)18.713.000YEAR1falsePUBLIC

INSERT gói trial MỚI FREE_TRIAL_MAX_7D (type=MAX, amount=0, period=DAY, periodNumber=7, isFreeTrial=1, publicStatus=PRIVATE) → lấy id đưa vào config membership.free.trail.id. Gói cũ FREE_TRIAL_7D giữ nguyên, KHÔNG reset trial.

config_system:

keyvalue
nebula.brain.pricing.plan.idsids 6 gói mới (cho pricing page)
membership.free.trail.ontrue
membership.free.trail.idid gói MỚI NEBULA_FREE_TRIAL_7D

KHÔNG cần SQL reset trial cũ — code đếm theo ĐÚNG gói trial mới (membershipId) nên record FREE_TRIAL_7D cũ tự động không bị tính.

7.4 Thứ tự triển khai (đề xuất)

  1. Query DB kiểm tra record type=BASIC(1) (câu hỏi #1) — quyết định migration.
  2. DB migration: INSERT gói trial mới FREE_TRIAL_MAX_7D + 6 gói Basic/Pro/Max, ẩn gói cũ, config. (Giữ nguyên FREE_TRIAL_7D, không reset trial.)
  3. sim-core sửa: B1 isHaveProPlan/Adjust (+BASIC+MAX), B2 activeMembershipPlanInternal, B3 free-trial, B4 repo.
  4. sim-personalize sửa nhỏ: D1 verify setTier(), D2 fallback basicfree, D3 rule feature per tier. → Đến đây pricing cơ bản chạy.
  5. sim-core tạo: NebulaMembershipService + Controller (plans / activate / free-trial / status).
  6. sim-core: activateNebulaPlan() + pro-rata upgrade.
  7. Cron downgrade khi hết hạn (câu hỏi #9) — tái dùng downgradeTier() / markExpiredNebulaPlans().
  8. (Nếu GĐ1) Convert credit → gói: CreditMembershipExchangeService.
  9. (Nếu cần FE) trial fields params + GET /subscription (D5).
  10. Frontend: Pricing Page, Trial banner, route guard.