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-facing | Trả phí? | sim-core PlanType | sim-personalize tier | Tháng (k) | Năm sale (k) |
|---|---|---|---|---|---|
| Free / Starter | Không | (không có plan active) | free | — | — |
| Basic | Có | BASIC(1) | basic | 349 | 3.141 |
| Pro (cũ) | — (không bán mới) | PREMIUM(2) | pro | 599 | 5.032 |
| Pro (mới) | Có | PRO(3) | pro | 749 | 6.376 |
| Max | Có | MAX(4) | max | 2.599 | 18.713 |
| Free trial 7D | Không (trial) | MAX(4) + isFreeTrial=true | max | 0 | 0 |
Feature matrix per tier (NebulaProfile fields)
| Feature | free | basic (Basic 349k) | pro (749k / Pro cũ) | max (+trial) | NebulaProfile field |
|---|---|---|---|---|---|
| Nebula Orbit | Yes | Yes | Yes | Yes | strategyModel = orbit |
| Nebula Horizon | No | No | No | Yes | strategyModel = horizon |
| Pool Bluechip | Yes | Yes | Yes | Yes | stockUniverse = pool_50 |
| Pool Full (bluechip+midcap) | No | No | Yes | Yes | stockUniverse = pool_100 |
| Pool 200 | No | No | No | Yes | stockUniverse = pool_200 |
| Stock | Yes | Yes | Yes | Yes | (mặc định) |
| Tiền gửi + Trái phiếu (Fixed Income) | No | Yes | Yes | Yes | activateFixedIncome |
| Vàng (Gold) | No | No | Yes | Yes | activateGold |
| Crypto | No | No | No | Yes | activateCrypto |
| Margin | No | No | Yes | Yes | marginEnabled |
| Hedging futures (VN30F) | No | No | No | Yes | hedgingEnabled |
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
| # | Method | Endpoint | Mô tả | Status |
|---|---|---|---|---|
| 1 | GET | /api/general/membership/nebula/plans/v3 | Lấy danh sách gói Nebula (PRO/MAX × tháng/năm). Đọc từ config membership.nebula.pricing.plan.ids | CHƯA CÓ |
| 1 | GET | api/general/membership/ant/plan-detail/16 | Lấ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ói | CẦN SỬA |
| 2 | POST | /api/general/payment/create-payment | Kí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-personalize | Cần bổ sung update tier personalize |
sim-personalize — sửa endpoints có sẵn
| # | Method | Endpoint | Mô tả | Status |
|---|---|---|---|---|
| 8 | POST | /api/personalize/control/nebula-profile/create | CÓ SẴN — cần bổ sung logic: validate tier change → create() + history + reinitialize | CẦN SỬA |
| 8 | POST | /api/personalize/control/nebula-profile/update | CÓ SẴN — cần bổ sung logic: validate tier change → update() + history + reinitialize | CẦN SỬA |
5. Job
| # | Service | Job Name | Cron | Mô tả |
|---|---|---|---|---|
| 1 | sim-personalize | checkExpiredNebulaPlan | Daily | Query 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 |
| 2 | sim-personalize | updateActivationStatus | Daily | User 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ệc | Loại |
|---|---|---|
| A | DB: INSERT gói trial mới NEBULA_FREE_TRIAL_7D + INSERT 6 gói Basic/Pro/Max + ẩn gói cũ + config | Migration |
| B1 | isHaveProPlan / isHaveProPlanAdjust → thêm BASIC + MAX (gói trả phí → true, Free → false) => !FREE | Sửa |
| B2 | activeMembershipPlanInternal (:380) — bỏ set cứng PREMIUM, set theo plan.getType() | Sửa |
| B3 | activeMembershipPlanFreeTrial — đổi check countAll → countByUserIdAndMembershipIdAndIsFreeTrial (1 lần / gói trial mới) | Sửa |
| B4 | Hook login flow AuthService.authenticate() → gọi activeMembershipPlanFreeTrial(userId) (try-catch) để cấp trial cho tk cũ đăng nhập | Sửa |
| C4 | (tùy GĐ1) Convert credit → gói Pro | Tạo |
7.2 sim-personalize
| # | Việc | Loại |
|---|---|---|
| D1 | validateProfileRequest — bổ sung rule feature per tier (gold / crypto / fi / margin / pool theo tier) | Sửa |
| D2 | updateTier — sim-core gọi khi thanh toán | Sửa nhỏ |
7.3 DB Changes (sim-core)
INSERT membership_plan (gói trả phí mới):
| code | nameDisplay | type | amount | period | periodNumber | isFreeTrial | publicStatus |
|---|---|---|---|---|---|---|---|
BASIC_1M | Basic | BASIC(1) | 349.000 | MONTH | 1 | false | PUBLIC |
BASIC_1Y | Basic | BASIC(1) | 3.141.000 | YEAR | 1 | false | PUBLIC |
PRO_1M | Pro | PRO(3) | 749.000 | MONTH | 1 | false | PUBLIC |
PRO_1Y | Pro | PRO(3) | 6.376.000 | YEAR | 1 | false | PUBLIC |
MAX_1M | Max | MAX(4) | 2.599.000 | MONTH | 1 | false | PUBLIC |
MAX_1Y | Max | MAX(4) | 18.713.000 | YEAR | 1 | false | PUBLIC |
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:
| key | value |
|---|---|
nebula.brain.pricing.plan.ids | ids 6 gói mới (cho pricing page) |
membership.free.trail.on | true |
membership.free.trail.id | id 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 recordFREE_TRIAL_7Dcũ tự động không bị tính.
7.4 Thứ tự triển khai (đề xuất)
- Query DB kiểm tra record
type=BASIC(1)(câu hỏi #1) — quyết định migration. - 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ênFREE_TRIAL_7D, không reset trial.) - sim-core sửa: B1
isHaveProPlan/Adjust (+BASIC+MAX), B2activeMembershipPlanInternal, B3 free-trial, B4 repo. - sim-personalize sửa nhỏ: D1 verify
setTier(), D2 fallbackbasic→free, D3 rule feature per tier. → Đến đây pricing cơ bản chạy. - sim-core tạo:
NebulaMembershipService+ Controller (plans / activate / free-trial / status). - sim-core:
activateNebulaPlan()+ pro-rata upgrade. - Cron downgrade khi hết hạn (câu hỏi #9) — tái dùng
downgradeTier()/markExpiredNebulaPlans(). - (Nếu GĐ1) Convert credit → gói:
CreditMembershipExchangeService. - (Nếu cần FE) trial fields params + GET
/subscription(D5). - Frontend: Pricing Page, Trial banner, route guard.