INTERNAL DEV ONLY SRS TXNG-F46: Kích hoạt & Vòng đời Gói Dịch vụ DN (WBS 2.33.1, 2.34.1 & 2.35.1)

TÀI LIỆU ĐẶC TẢ YÊU CẦU PHẦN MỀM (SRS) ​

HỆ THỐNG: NỀN TẢNG TRUY XUẤT NGUỒN GỐC GOTRACE ​


TXNG-F46 -- Kích hoạt & Vòng đời Dịch vụ Doanh nghiệp ​

Thông tin Chi tiết
Phiên bản 1.0
Ngày tạo 01/07/2026
Ngày cập nhật 01/07/2026
Tác giả GoTrace Team
Người review
Người phê duyệt

Lịch sử thay đổi ​

Ngày Người cập nhật Mô tả thay đổi
01/07/2026 GoTrace Team Khởi tạo SRS v1.0 — Kích hoạt DN + vòng đời dịch vụ theo SERVICE_STATUS (DRAFT → TRIAL → ACTIVE → EXPIRED / TERMINATED); state machine đầy đủ; login gate lazy-expire (chặn đăng nhập EXPIRED/TERMINATED + invalidate cache user_perms); nhật ký subscription_histories append-only immutable; mirror companies.status; upload hồ sơ hợp đồng; tab "Lịch sử sử dụng dịch vụ" cho admin & DN; KHÔNG có CTE/KDE (module vận hành thương mại)

1. Tổng quan tài liệu ​

1.1. Mục đích tài liệu ​

Tài liệu mô tả chi tiết chức năng Kích hoạt & Vòng đời Dịch vụ Doanh nghiệp (TXNG-F46) trong Module Vận hành Nền tảng (Platform Operations) của hệ thống GoTrace.

Chức năng cho phép Quản trị viên Nền tảng GoTrace (PLATFORM_ADMIN) thực hiện toàn bộ nghiệp vụ thương mại vận hành trên một Doanh nghiệp (DN) khách hàng:

  1. Tạo yêu cầu kích hoạt dịch vụ cho một DN: chọn DN chưa-active, chọn gói dịch vụ (service_plans — TXNG-F45), nhập mã hợp đồng, thời hạn (term_months), số ngày dùng thử (trial_days), bonus QR/sản phẩm, upload hồ sơ (hợp đồng/MOU/phụ lục).
  2. Kích hoạt dịch vụ: chuyển DRAFT → TRIAL (hoặc → ACTIVE khi trial_days = 0), chốt plan_snapshot (phí/quota/features tại thời điểm kích hoạt).
  3. Quản lý vòng đời: chuyển trial sang chính thức (TRIAL → ACTIVE), gia hạn (EXPIRED → ACTIVE, extend paid_until_date), chấm dứt (→ TERMINATED, bắt buộc lý do), mở lại (REACTIVATE).
  4. Login gate (login gate): chặn đăng nhập của mọi user thuộc DN khi dịch vụ ở trạng thái EXPIRED/TERMINATED; đánh giá hết hạn theo kiểu lazy khi đăng nhập (không cần cron ở v1).

Điểm cốt lõi khác biệt so với các module nghiệp vụ khác: đây là state machine 5 trạng thái (SERVICE_STATUS) với login gate tác động trực tiếp đến AuthService.login() + JwtStrategy.validate(), và nhật ký append-only immutable (subscription_histories) được ghi trong cùng transaction với mọi thay đổi trạng thái, kèm invalidate cache user_perms:{userId} và mirror companies.status.

Đây là module vận hành thương mại nội bộ GoTrace, KHÔNG phát sinh CTE/KDE truy xuất (nêu rõ trong Section 17 — Ràng buộc). Nguồn sự thật (source of truth) của trạng thái dịch vụ là company_subscriptions; companies.status chỉ là bản mirror phục vụ chặn đăng nhập nhanh.

Tài liệu là cơ sở cho đội ngũ phát triển (Developer), kiểm thử (QA), DBA và Business Analyst để triển khai chức năng phù hợp với BRD tương ứng (docs/brd/BRD_TXNG-F46_Kich_Hoat_Va_Vong_Doi_Dich_Vu_Doanh_Nghiep.md).

1.2. Thông tin chức năng ​

Thuộc tính Giá trị
Mã chức năng TXNG-F46
Tên chức năng Kích hoạt & Vòng đời Dịch vụ Doanh nghiệp
Menu TXNG > Vận hành Nền tảng > Kích hoạt Doanh nghiệp
Ưu tiên Bắt buộc
Giai đoạn v1.0
Pháp lý Chính sách thương mại nội bộ GoTrace (docs/sale/Sale_Pricing.md, hiệu lực 01/06/2026 — báo giá 19/05/2026); Luật Giao dịch điện tử 2023 (hợp đồng điện tử, chữ ký số hồ sơ); Nghị định 13/2023/NĐ-CP (bảo vệ dữ liệu cá nhân — thông tin liên hệ DN + hồ sơ upload); Bộ luật Dân sự 2015 (hợp đồng dịch vụ). KHÔNG áp dụng TCVN 12850:2019 / CTE / KDE (module vận hành thương mại, không thuộc chuỗi truy xuất)
Liên thông ← TXNG-F45 (Cấu hình Gói Dịch vụ — chọn service_plans.id ACTIVE khi kích hoạt; chốt plan_snapshot)
← TXNG-F01 (Quản lý Doanh nghiệp — chọn DN, mirror companies.status)
← TXNG-F29 (Hồ sơ Doanh nghiệp — chèn tab "Lịch sử sử dụng dịch vụ" vào màn chi tiết DN)
→ Module Auth (login gate: sửa AuthService.login() + JwtStrategy.validate() — chặn EXPIRED/TERMINATED + invalidate cache)
→ TXNG-F16 (Cấp mã QR — ràng buộc effective_qr_quota theo năm)
→ TXNG-F03 (Sản phẩm — giới hạn effective_max_products)
↔ TXNG-F42 (Loại hình tổ chức — phân loại DN)

1.3. Tác nhân tham gia (Actors) ​

Actor Vai trò Quyền hạn
PLATFORM_ADMIN Quản trị viên Nền tảng GoTrace (impl ROLE.ADMIN = numeric 1; chặn qua AdminGuard; API prefix /api/v1/admin/...) Actor duy nhất thao tác: tạo/sửa yêu cầu kích hoạt; kích hoạt (ACTIVATE); chuyển sang ACTIVE; gia hạn (RENEW); chấm dứt (TERMINATE); mở lại (REACTIVATE); upload hồ sơ; xem toàn bộ danh sách/chi tiết/lịch sử của mọi DN (cross-tenant). Sale thao tác qua tài khoản PLATFORM_ADMIN.
SYSTEM Hệ thống tự động — login gate (lazy-evaluate khi đăng nhập) Khi user đăng nhập: đọc companies.status (mirror) để chặn nhanh; nếu stored TRIAL/ACTIVE nhưng đã quá hạn theo ngày → lazy-expire: flip EXPIRED + ghi subscription_histories {action: EXPIRE} + mirror companies.status + invalidate cache user_perms:{userId} (trong cùng transaction), rồi chặn đăng nhập. KHÔNG có UI, KHÔNG có cron ở v1.
ENTERPRISE_ADMIN Quản trị viên Doanh nghiệp (khách hàng) CHỈ XEM tab "Lịch sử sử dụng dịch vụ" của chính DN mình (multi-tenant: lọc company_id = current_company_id). KHÔNG được tạo/sửa/kích hoạt/gia hạn/chấm dứt.
ENTERPRISE_USER Nhân viên Doanh nghiệp (khách hàng) CHỈ XEM tab "Lịch sử sử dụng dịch vụ" của chính DN mình (giống ENTERPRISE_ADMIN, read-only).

Ghi chú phân quyền: SALE_STAFF (impl ROLE.AGENT = numeric 2) không là actor trực tiếp của F46; nghiệp vụ onboard/chăm sóc KH nằm ngoài hệ thống, sale thao tác qua tài khoản PLATFORM_ADMIN. Mọi endpoint ghi/chuyển trạng thái đều chặn qua AdminGuard.

1.4. Tham chiếu UI/UX ​

Tài liệu Link
Mockup danh sách kích hoạt docs/html/kich_hoat/activation_list.html (tạo trong Phase implementation)
Mockup tạo/sửa yêu cầu kích hoạt docs/html/kich_hoat/activation_form.html
Mockup chi tiết + timeline docs/html/kich_hoat/activation_detail.html
Mockup tab lịch sử dịch vụ (nhúng chi tiết DN) docs/html/kich_hoat/subscription_history_tab.html
Design System apps/frontend/CLAUDE.md — Section Frontend UI/UX
Component tái dùng components/admin/shared/timeline.tsx (TimelineEvent[]); components/admin/company/list/* (DebouncedInput + CSelect + useFilter + TableWrapper); FormWrapper/FormRow + ImageUploadGroup

2. Luồng nghiệp vụ (Business Flow) ​

2.1. Luồng nghiệp vụ tổng quan ​

┌────────────────────────────────────────────────────────────────────────────────────────┐
│              LUỒNG KÍCH HOẠT & VÒNG ĐỜI DỊCH VỤ DOANH NGHIỆP (F46)                     │
├────────────────────────────────────────────────────────────────────────────────────────┤
│                                                                                        │
│  ╔══════════════════════════════════════════════════════════════════════════════════╗ │
│  ║          PHẦN A: PLATFORM_ADMIN TẠO & VẬN HÀNH VÒNG ĐỜI DỊCH VỤ                    ║ │
│  ╚══════════════════════════════════════════════════════════════════════════════════╝ │
│                                                                                        │
│   ┌────────────────┐   ┌──────────────────────────────┐                                │
│   │ DN đã đăng ký  │──>│ ADMIN TẠO YÊU CẦU KÍCH HOẠT   │  (UC-F46-01)                   │
│   │ (companies)    │   │ • Chọn DN (service_status ∈  │                                │
│   │ chưa có sub    │   │   {chưa có / DRAFT / EXPIRED │                                │
│   │ hoặc DRAFT     │   │   / TERMINATED})             │                                │
│   └────────────────┘   │ • Chọn plan (F45, ACTIVE)    │                                │
│                        │ • contract_code + term_months│                                │
│                        │ • trial_days (mặc định 7)    │                                │
│                        │ • bonus_qr / bonus_products  │                                │
│                        │ • upload hồ sơ (CONTRACT...) │                                │
│                        │ → status = DRAFT             │                                │
│                        │ → history {action: CREATE}   │                                │
│                        └──────────────┬───────────────┘                                │
│                                       │                                                 │
│                     ┌─────────────────┤ (UC-F46-02: SỬA — ghi history UPDATE)           │
│                     │                 ▼                                                 │
│                     │      ┌────────────────────────────┐                               │
│                     │      │ ADMIN KÍCH HOẠT (ACTIVATE)  │  (UC-F46-03)                  │
│                     │      │ • chốt plan_snapshot        │                               │
│                     │      │ • effective_qr_quota =      │                               │
│                     │      │   plan.qr + bonus_qr        │                               │
│                     │      │ • trial_end = start+trial   │                               │
│                     │      │ • trial_days>0 → TRIAL      │                               │
│                     │      │   trial_days=0 → ACTIVE     │                               │
│                     │      │ ▸ history {ACTIVATE}        │                               │
│                     │      │ ▸ mirror companies.status   │                               │
│                     │      │ ▸ invalidate user_perms     │                               │
│                     │      └───────┬──────────────┬──────┘                              │
│                     │              │              │                                     │
│                     │      trial_days>0    trial_days=0                                 │
│                     │              ▼              ▼                                     │
│                     │       ┌───────────┐   ┌───────────┐                               │
│                     │       │  TRIAL     │   │  ACTIVE    │<───────┐                     │
│                     │       │ (dùng thử) │   │ (chính     │        │ RENEW               │
│                     │       └─────┬──────┘   │  thức)     │        │ (UC-F46-05)         │
│                     │             │          └─────┬──────┘        │                     │
│                     │  chuyển ACTIVE (UC-F46-04)    │              │                     │
│                     │             └──────►──────────┘              │                     │
│                     │             │ hết hạn (lazy)  │ hết hạn(lazy) │                     │
│                     │             ▼                 ▼              │                     │
│                     │          ┌────────────────────────┐         │                     │
│                     │          │        EXPIRED          │─────────┘                     │
│                     │          │ (hết trial/hết kỳ)      │                               │
│                     │          └───────────┬────────────┘                               │
│                     │                       │                                            │
│                     │        TERMINATE (bắt buộc reason, UC-F46-06)                      │
│                     └───────────────────────┼──────────────►┌────────────────┐          │
│                                             ▼                │  TERMINATED     │          │
│                                    (từ TRIAL/ACTIVE/EXPIRED) │  (chấm dứt)     │          │
│                                                              └───────┬─────────┘          │
│                                              REACTIVATE (mở lại) ◄────┘                    │
│                                                                                        │
│  ════════════════════════════════════════════════════════════════════════════════════ │
│  ╔══════════════════════════════════════════════════════════════════════════════════╗ │
│  ║          PHẦN B: LOGIN GATE (SYSTEM — lazy-expire khi user DN đăng nhập)           ║ │
│  ╚══════════════════════════════════════════════════════════════════════════════════╝ │
│                                                                                        │
│   ┌──────────────┐   ┌──────────────────────────┐   ┌──────────────────────────────┐  │
│   │ User DN gọi  │──>│ AuthService.login()       │──>│ Đọc companies.status (mirror) │  │
│   │ POST /login  │   │ + JwtStrategy.validate()  │   │ + company_subscriptions       │  │
│   └──────────────┘   └──────────────────────────┘   └──────────────┬───────────────┘  │
│                                                                     │                    │
│         ┌───────────────────────────────────────────────────────────┤                   │
│         ▼                          ▼                                 ▼                   │
│  ┌─────────────┐          ┌──────────────────────┐        ┌──────────────────────────┐ │
│  │ EXPIRED     │          │ stored TRIAL/ACTIVE   │        │ TRIAL/ACTIVE còn hạn     │ │
│  │ hoặc        │          │ nhưng quá hạn theo    │        │ hoặc DRAFT               │ │
│  │ TERMINATED  │          │ ngày → LAZY-EXPIRE:   │        │ → CHO PHÉP đăng nhập     │ │
│  │ → 403 chặn  │          │ flip EXPIRED + history│        │ (cache user_perms 10')   │ │
│  │ (2 message) │          │ + mirror + invalidate │        └──────────────────────────┘ │
│  └─────────────┘          │ → rồi 403 chặn        │                                      │
│                           └──────────────────────┘                                      │
│                                                                                        │
└────────────────────────────────────────────────────────────────────────────────────────┘

2.2. Diễn giải luồng ​

Bước Mô tả hành động Hệ thống thực hiện
B1 DN đã tồn tại trong companies (đăng ký tài khoản, TXNG-F01). Chưa có subscription hiện hành hoặc đang DRAFT/EXPIRED/TERMINATED ADMIN mở SCR-F46-01 → filter DN đủ điều kiện
B2 ADMIN tạo yêu cầu kích hoạt (UC-F46-01): chọn DN + gói (F45 ACTIVE) + contract_code + term_months + trial_days + bonus + upload hồ sơ 1. Validate DN đủ điều kiện (BR-F46-02)
2. Validate contract_code unique (partial)
3. INSERT company_subscriptions {service_status: DRAFT}
4. INSERT subscription_documents (nếu upload)
5. INSERT subscription_histories {action: CREATE}
6. Mirror companies.status = 0 (DRAFT)
B3 (Tùy chọn) ADMIN sửa yêu cầu ở DRAFT (UC-F46-02) UPDATE + INSERT subscription_histories {action: UPDATE, changes_json diff} — cùng transaction
B4 ADMIN kích hoạt (UC-F46-03): DRAFT → TRIAL (hoặc → ACTIVE nếu trial_days=0) ATOMIC: chốt plan_snapshot; tính effective_qr_quota/effective_max_products; set trial_end_date/active_start_date/paid_until_date; UPDATE status; INSERT history {ACTIVATE}; mirror companies.status; invalidate cache user_perms:{userId} mọi user DN
B5 ADMIN chuyển trial sang chính thức (UC-F46-04): TRIAL → ACTIVE ATOMIC: set active_start_date, tính paid_until_date = active_start + term_months; UPDATE; history {ACTIVATE (chuyển ACTIVE)}; mirror; invalidate cache
B6 Hết hạn (lazy): user DN đăng nhập, stored TRIAL/ACTIVE nhưng trial_end_date/paid_until_date < today (UC-F46-07) LAZY-EXPIRE ATOMIC: flip EXPIRED; INSERT history {EXPIRE}; mirror companies.status = 3; invalidate cache; trả 403 chặn login
B7 ADMIN gia hạn (UC-F46-05): EXPIRED → ACTIVE (hoặc extend paid_until_date khi còn ACTIVE) ATOMIC: cộng paid_until_date += term_months; áp chiết khấu theo term_months; UPDATE status ACTIVE; history {RENEW}; mirror; invalidate cache
B8 ADMIN chấm dứt (UC-F46-06): → TERMINATED (bắt buộc terminate_reason) ATOMIC: UPDATE status TERMINATED, terminated_at, terminated_by, terminate_reason; history {TERMINATE}; mirror companies.status = 4; invalidate cache
B9 ADMIN mở lại (REACTIVATE): TERMINATED → TRIAL/ACTIVE ATOMIC: UPDATE status; history {REACTIVATE}; mirror; invalidate cache
B10 Login gate chặn khi EXPIRED/TERMINATED (UC-F46-07) AuthService.login() đọc mirror companies.status; nếu EXPIRED (3) → 403 msg "Cần liên hệ sale của gotrace để kích hoạt dịch vụ"; nếu TERMINATED (4) → 403 msg "Cần liên hệ với quản trị viên gotrace"
B11 DN xem lịch sử dịch vụ của chính mình (UC-F46-08) GET /api/v1/companies/:id/subscription-history (multi-tenant: company_id = current); render tab {CompanySubscriptionHistoryTab}

3. Phân loại / Trạng thái ​

3.1. Trạng thái dịch vụ (SERVICE_STATUS — Canonical §2.43) ​

Vòng đời sử dụng dịch vụ của một DN (company_subscriptions.service_status; mirror sang companies.status). Đánh giá "hết hạn" theo kiểu lazy khi đăng nhập (không cần cron ở v1).

Trạng thái Mã Mirror companies.status Mô tả Ảnh hưởng đăng nhập CSS class Background Color
Nháp DRAFT 0 DN mới đăng ký, chưa kích hoạt dịch vụ (mặc định) Cho phép (onboarding, hạn chế) .bdg.bdg-neutral #f0f2f5 #8c99ab
Dùng thử TRIAL 1 Đã kích hoạt, đang dùng thử miễn phí (trial_days) Cho phép (đầy đủ) .bdg.bdg-info #e3f2fd #0277bd
Chính thức ACTIVE 2 Hết trial, tính ngày dịch vụ theo gói Cho phép (đầy đủ) .bdg.bdg-success #e8f5e9 #2e7d32
Hết hạn EXPIRED 3 Hết trial chưa kích hoạt, hoặc hết kỳ chưa gia hạn Chặn → "Cần liên hệ sale của gotrace để kích hoạt dịch vụ" .bdg.bdg-warning #fff8e1 #fb8c00
Chấm dứt TERMINATED 4 Admin chấm dứt thủ công (bắt buộc lý do) Chặn → "Cần liên hệ với quản trị viên gotrace" .bdg.bdg-danger #feecec #e53935

Chỉ PLATFORM_ADMIN được chuyển trạng thái. Mọi transition ghi subscription_histories (§2.46) trong cùng $transaction + invalidate cache user_perms:{userId} của mọi user thuộc DN + mirror companies.status.

3.2. Bảng effective-status (lazy-evaluate theo ngày) ​

Trạng thái hiển thị/hiệu lực được tính tại thời điểm đọc (login hoặc admin mở màn hình) dựa trên stored service_status và ngày hệ thống. Nếu stored ≠ effective → thực hiện flip + ghi history (chỉ với login của user DN; màn admin chỉ hiển thị badge "quá hạn" nhắc admin, không tự flip trừ khi login trigger).

Stored service_status Điều kiện ngày Effective status Hành động khi user DN login
DRAFT — DRAFT Cho phép (hạn chế onboarding)
TRIAL today ≤ trial_end_date TRIAL Cho phép (đầy đủ)
TRIAL today > trial_end_date EXPIRED Lazy-expire → flip EXPIRED + history + mirror + invalidate → 403
ACTIVE today ≤ paid_until_date ACTIVE Cho phép (đầy đủ)
ACTIVE today > paid_until_date EXPIRED Lazy-expire → flip EXPIRED + history + mirror + invalidate → 403
EXPIRED — EXPIRED Chặn (E-F46-005)
TERMINATED — TERMINATED Chặn (E-F46-006)

3.3. Phân loại tài liệu (Subscription Document Type — Canonical §2.47) ​

Mã Tên Mô tả
CONTRACT Hợp đồng dịch vụ File hợp đồng ký kết giữa GoTrace và DN
MOU Biên bản ghi nhớ (MOU) Biên bản ghi nhớ hợp tác
APPENDIX Phụ lục / báo giá Phụ lục hợp đồng, báo giá đính kèm
OTHER Tài liệu khác Tài liệu bổ sung khác

3.4. Phân loại thao tác nhật ký (Subscription History Action — Canonical §2.46) ​

Mã Tên Mô tả
CREATE Tạo yêu cầu Tạo yêu cầu kích hoạt (trạng thái DRAFT)
UPDATE Sửa Sửa thông tin đăng ký (ghi diff changes_json bắt buộc)
ACTIVATE Kích hoạt DRAFT → TRIAL (hoặc → ACTIVE nếu trial_days=0); TRIAL → ACTIVE
RENEW Gia hạn Cập nhật paid_until_date; EXPIRED → ACTIVE
EXPIRE Hết hạn TRIAL/ACTIVE → EXPIRED (lazy khi login hoặc admin)
TERMINATE Chấm dứt → TERMINATED (bắt buộc reason)
REACTIVATE Mở lại TERMINATED → TRIAL/ACTIVE

4. Danh sách màn hình ​

Mã màn hình Tên màn hình Chức năng chính Vai trò sử dụng Ghi chú
SCR-F46-01 Danh sách yêu cầu kích hoạt Hiển thị, tìm kiếm, lọc danh sách subscription; summary cards đếm theo trạng thái PLATFORM_ADMIN Màn hình chính; filter khoảng thời gian Active (service_start_date/activated_at), trạng thái dịch vụ, search MST/tên DN/SĐT/email
SCR-F46-02 Tạo yêu cầu kích hoạt Form tạo mới subscription (DRAFT) PLATFORM_ADMIN Chọn DN đủ điều kiện + gói + hồ sơ
SCR-F46-03 Sửa yêu cầu kích hoạt Sửa subscription ở DRAFT (ghi history bắt buộc) PLATFORM_ADMIN UPDATE ghi changes_json
SCR-F46-04 Chi tiết yêu cầu kích hoạt Xem chi tiết + timeline lịch sử + nút hành động PLATFORM_ADMIN Nút: Kích hoạt / Chuyển Active / Gia hạn / Chấm dứt / Mở lại
SCR-F46-05 Tab "Lịch sử sử dụng dịch vụ" Tab nhúng vào màn chi tiết DN (TXNG-F01/F29) PLATFORM_ADMIN, ENTERPRISE_ADMIN, ENTERPRISE_USER Admin xem mọi DN; DN chỉ xem của chính mình (multi-tenant)

5. Mô tả chi tiết trường dữ liệu ​

5.1. SCR-F46-01: Danh sách yêu cầu kích hoạt ​

Summary cards (đếm theo trạng thái):

Card Nguồn Mô tả
Nháp COUNT service_status = 'DRAFT' Số subscription chờ kích hoạt
Dùng thử COUNT service_status = 'TRIAL' Số DN đang dùng thử
Chính thức COUNT service_status = 'ACTIVE' Số DN chính thức
Hết hạn COUNT service_status = 'EXPIRED' Số DN hết hạn (cần gia hạn)
Chấm dứt COUNT service_status = 'TERMINATED' Số DN đã chấm dứt

Thanh công cụ (Toolbar):

Phần tử Loại Mô tả
Tìm kiếm Text Input (Debounced 300ms) Tìm theo company_tax_code (MST), company_name, contact_phone, company_email
Lọc theo trạng thái Dropdown (CSelect) DRAFT / TRIAL / ACTIVE / EXPIRED / TERMINATED / Tất cả
Lọc theo gói Dropdown (Search) Danh sách service_plans (plan_code)
Lọc theo khoảng Active Date Range Picker service_start_date (hoặc activated_at) từ ngày – đến ngày
Nút "Tạo yêu cầu kích hoạt" Button (Primary) Mở SCR-F46-02
Nút "Xuất Excel" Button (Secondary) Export danh sách theo filter

Bảng dữ liệu (Data Table):

Cột Kiểu Sortable Mô tả
STT Number Không Số thứ tự
Tên DN Text (Link) Có companies.company_name — click mở SCR-F46-04
MST Text Có companies.company_tax_code
Gói Tag Có plan_code (snapshot, color-coded)
Mã hợp đồng Text Có contract_code
Trạng thái Badge Có SERVICE_STATUS (Section 3.1, color-coded)
Ngày bắt đầu Date Có service_start_date (dd/MM/yyyy)
Hết trial Date Có trial_end_date (dd/MM/yyyy) — warning nếu ≤ 3 ngày
Thanh toán đến Date Có paid_until_date (dd/MM/yyyy) — warning nếu sắp hết
Kích hoạt lúc DateTime Có activated_at
Thao tác Actions Không Xem / Sửa (nếu DRAFT) / Kích hoạt / Gia hạn / Chấm dứt

Phân trang: 10 / 20 / 50 / 100 bản ghi/trang; envelope { page, page_size, total_items, total_pages }.

5.2. SCR-F46-02: Tạo yêu cầu kích hoạt ​

Card 1: Chọn Doanh nghiệp ​

Tên trường Field name Kiểu dữ liệu Bắt buộc Validation Mô tả
Doanh nghiệp company_id Dropdown (Search) Có FK companies; chỉ DN đủ điều kiện (BR-F46-02) Chọn DN chưa-active (chưa có sub / DRAFT / EXPIRED / TERMINATED)
Mã số thuế company_tax_code Text (auto) — Read-only Auto-fill từ DN đã chọn
Người liên hệ contact_name Text (auto) — Read-only Auto-fill từ DN
SĐT / Email contact_phone / company_email Text (auto) — Read-only Auto-fill từ DN

Card 2: Gói dịch vụ & Điều khoản ​

Tên trường Field name Kiểu dữ liệu Bắt buộc Validation Mô tả
Gói dịch vụ plan_id Dropdown (Search) Có FK service_plans; chỉ gói status = ACTIVE (BR-F45-05) Chọn 1 trong các gói ACTIVE; hiển thị phí/quota kèm theo
Mã gói (snapshot) plan_code Text (auto) Có Inherit từ plan Read-only; snapshot lúc chọn
Mã hợp đồng contract_code Text Có Unique (partial); max 100 Mã hợp đồng dịch vụ (E-F46-002 nếu thiếu)
Thời hạn (tháng) term_months Number Có > 0; gợi ý 12/24/36 Kỳ hạn dịch vụ (áp chiết khấu 24m/36m)
Số ngày dùng thử trial_days Number Có ≥ 0; mặc định 7 0 = kích hoạt thẳng ACTIVE
Ngày bắt đầu dịch vụ service_start_date Date Có mặc định hôm nay trial_end_date = start + trial_days
Bonus QR (tặng thêm) bonus_qr_quota Number Không ≥ 0; mặc định 0 Cộng vào effective_qr_quota
Bonus sản phẩm bonus_max_products Number Không ≥ 0; mặc định 0 Cộng vào effective_max_products
Chiết khấu (%) discount_percent Decimal(5,2) Không 0–100; mặc định 0 Chiết khấu thủ công (ngoài chiết khấu term)
Ghi chú note Textarea Không max 1000 Ghi chú nội bộ

Card 3: Hồ sơ đính kèm (Documents) ​

Tên trường Field name Kiểu dữ liệu Bắt buộc Validation Mô tả
Loại tài liệu doc_type Dropdown *Có (nếu upload) Enum §2.47 (CONTRACT/MOU/APPENDIX/OTHER) Loại hồ sơ
File hồ sơ file File Upload Multi Không PDF/JPG/PNG; ≤ 20MB/file (E-F46-003) Lưu MinIO (MinioStorageService + FileFieldsInterceptor)

5.3. SCR-F46-03: Sửa yêu cầu kích hoạt ​

Chỉ khả dụng khi service_status = DRAFT (BR-F46 — sau kích hoạt các trường điều khoản đã bị chốt qua plan_snapshot). Form giống SCR-F46-02, load data hiện tại. Khi lưu:

5.4. SCR-F46-04: Chi tiết yêu cầu kích hoạt ​

Header Info Box (read-only):

Phần tử Mô tả
Tên DN + MST company_name + company_tax_code, badge trạng thái màu
Gói plan_code (snapshot) + tên gói
Mã hợp đồng contract_code
Ngày bắt đầu / hết trial / thanh toán đến service_start_date / trial_end_date / paid_until_date
Quota hiệu lực effective_qr_quota (QR/năm) + effective_max_products
Người kích hoạt / lúc activated_by + activated_at
Người chấm dứt / lý do terminated_by + terminated_at + terminate_reason (nếu có)

Tabs:

Action buttons (theo service_status):

Status Nút khả dụng (PLATFORM_ADMIN)
DRAFT Sửa / Kích hoạt / Xóa (soft)
TRIAL Chuyển Active / Chấm dứt
ACTIVE Gia hạn / Chấm dứt
EXPIRED Gia hạn (→ ACTIVE) / Chấm dứt
TERMINATED Mở lại (Reactivate)

Dialog xác nhận theo hành động:

Hành động Trường bắt buộc trong dialog
Kích hoạt Xác nhận chốt plan_snapshot + effective_qr_quota/effective_max_products
Chuyển Active Xác nhận active_start_date (default NOW) + tính paid_until_date
Gia hạn term_months (kỳ gia hạn) + chiết khấu preview
Chấm dứt terminate_reason (Textarea ≥ 20 ký tự, E-F46-004)
Mở lại Chọn trạng thái đích (TRIAL/ACTIVE) + lý do

5.5. SCR-F46-05: Tab "Lịch sử sử dụng dịch vụ" ​

Component {CompanySubscriptionHistoryTab} nhúng vào màn chi tiết DN (/quan-ly/doanh-nghiep/[id]/chi-tiet). Dùng components/admin/shared/timeline.tsx (TimelineEvent[]).

Phần tử Mô tả
Header Gói hiện hành + trạng thái + quota còn lại
Timeline Mỗi bản ghi subscription_histories: icon theo action, changed_at (dd/MM/yyyy HH:mm), actor, from_status → to_status, reason
Bảng hồ sơ Danh sách subscription_documents (chỉ đọc)

Multi-tenant: ENTERPRISE_ADMIN/ENTERPRISE_USER chỉ thấy tab này với company_id = current_company_id (BR-F46-14). PLATFORM_ADMIN thấy mọi DN.


6. Danh sách Use Case ​

ID Tên Use Case Actor chính Mô tả ngắn
UC-F46-01 Tạo yêu cầu kích hoạt PLATFORM_ADMIN Tạo subscription mới (DRAFT) cho DN đủ điều kiện
UC-F46-02 Sửa yêu cầu kích hoạt PLATFORM_ADMIN Sửa subscription ở DRAFT — ghi history UPDATE bắt buộc
UC-F46-03 Kích hoạt dịch vụ PLATFORM_ADMIN DRAFT → TRIAL (hoặc → ACTIVE nếu trial_days=0); chốt plan_snapshot
UC-F46-04 Chuyển sang ACTIVE PLATFORM_ADMIN TRIAL → ACTIVE; tính paid_until_date
UC-F46-05 Gia hạn dịch vụ PLATFORM_ADMIN EXPIRED → ACTIVE / extend paid_until_date
UC-F46-06 Chấm dứt dịch vụ PLATFORM_ADMIN → TERMINATED (bắt buộc terminate_reason)
UC-F46-07 Chặn đăng nhập + lazy-expire SYSTEM Login gate: chặn EXPIRED/TERMINATED; lazy-expire khi quá hạn
UC-F46-08 Xem lịch sử sử dụng dịch vụ PLATFORM_ADMIN, ENTERPRISE_ADMIN, ENTERPRISE_USER Xem timeline lịch sử (admin mọi DN; DN chỉ của mình)
UC-F46-09 Mở lại dịch vụ (Reactivate) PLATFORM_ADMIN TERMINATED → TRIAL/ACTIVE

7. Mô tả chi tiết Use Case ​

UC-F46-01: Tạo yêu cầu kích hoạt ​

Mô tả: PLATFORM_ADMIN tạo một yêu cầu kích hoạt dịch vụ cho DN đủ điều kiện.

Tiền điều kiện:

Hậu điều kiện:

Bước Tác nhân Hành động / Mô tả Phản hồi / Xử lý hệ thống Ghi chú
1 ADMIN Click "Tạo yêu cầu kích hoạt" trên SCR-F46-01 Mở SCR-F46-02
2 ADMIN Chọn DN (Card 1) Validate DN đủ điều kiện (BR-F46-02); nếu không → E-F46-001; auto-fill MST/liên hệ
3 ADMIN Chọn gói + nhập contract_code, term_months, trial_days, bonus (Card 2) Validate contract_code unique; hiển thị preview phí/quota
4 ADMIN (Tùy chọn) upload hồ sơ (Card 3) Validate PDF/JPG/PNG ≤ 20MB (E-F46-003); upload MinIO
5 ADMIN Click "Lưu nháp" 1. Validate toàn bộ form (server-side)
2. Validate DN đủ điều kiện (recheck BR-F46-02)
3. Validate contract_code unique (partial)
4. BEGIN transaction
5. INSERT company_subscriptions {service_status: DRAFT, plan_code (snapshot), trial_end_date = start + trial_days, created_by, version: 1}
6. INSERT subscription_documents (nếu có)
7. INSERT subscription_histories {action: CREATE, from_status: NULL, to_status: DRAFT, changed_by, changed_at, ip_address}
8. UPDATE companies.status = 0 (mirror DRAFT)
9. COMMIT
10. Return 201 Created + subscription_id
Status: 201

Alternative Flow:

Bước Điều kiện Xử lý
2a DN đang TRIAL/ACTIVE (đã có sub hiện hành) E-F46-001 "DN không đủ điều kiện kích hoạt (đang dùng dịch vụ)"
3a Thiếu contract_code E-F46-002 "Bắt buộc nhập mã hợp đồng"
3b contract_code đã tồn tại E-F46-002 (biến thể) "Mã hợp đồng đã tồn tại"
3c Gói đã chọn ở DEPRECATED E-F46-001 (biến thể) "Gói dịch vụ đã ngừng bán, chọn gói khác" (BR-F45-05)
4a File > 20MB hoặc sai định dạng E-F46-003 "File tối đa 20MB, chỉ chấp nhận PDF/JPG/PNG"
5a Concurrent: DN vừa được tạo sub bởi admin khác HTTP 409 + "DN đã có subscription hiện hành, vui lòng tải lại" (BR-F46-10)

UC-F46-02: Sửa yêu cầu kích hoạt ​

Tiền điều kiện: PLATFORM_ADMIN; subscription ở service_status = DRAFT.

Hậu điều kiện: subscription cập nhật; subscription_histories {action: UPDATE, changes_json} bắt buộc.

Bước Tác nhân Hành động / Mô tả Phản hồi / Xử lý hệ thống
1 ADMIN Click "Sửa" trên SCR-F46-04 Mở SCR-F46-03 với data hiện tại
2 ADMIN Chỉnh sửa gói/điều khoản/hồ sơ → "Lưu" 1. Validate: service_status = DRAFT (E-F46-001 nếu khác)
2. Validate optimistic lock version (E-F46-007 nếu lệch)
3. Tính diff before/after
4. BEGIN transaction
5. UPDATE company_subscriptions SET ..., version = version + 1, updated_by, updated_at
6. INSERT subscription_histories {action: UPDATE, changes_json = diff, changed_by, changed_at}
7. COMMIT
8. Return 200 OK

Alternative Flow:

Bước Điều kiện Xử lý
2a service_status ≠ DRAFT E-F46-001 "Chỉ sửa được yêu cầu ở trạng thái Nháp"
2b version lệch (đã bị sửa) E-F46-007 "Bản ghi đã được cập nhật bởi người khác, vui lòng tải lại"

UC-F46-03: Kích hoạt dịch vụ ​

Mô tả: PLATFORM_ADMIN kích hoạt subscription từ DRAFT; chốt snapshot; login gate của DN chuyển sang cho phép.

Tiền điều kiện: PLATFORM_ADMIN; subscription ở service_status = DRAFT; gói tham chiếu còn tồn tại.

Hậu điều kiện: service_status = TRIAL (hoặc ACTIVE nếu trial_days=0); plan_snapshot chốt; history {ACTIVATE}; companies.status mirror; cache user_perms invalidated.

Bước Tác nhân Hành động / Mô tả Phản hồi / Xử lý hệ thống Ghi chú
1 ADMIN Mở SCR-F46-04 → click "Kích hoạt" → dialog xác nhận Hiển thị preview plan_snapshot + quota hiệu lực
2 ADMIN Xác nhận ATOMIC TRANSACTION (BEGIN … COMMIT):
1. Validate: service_status = DRAFT, optimistic lock version
2. Load gói service_plans (recheck ACTIVE)
3. Chốt plan_snapshot = JSONB {phí, quota, features, chiết khấu}
4. effective_qr_quota = plan.qr_quota_year + bonus_qr_quota (BR-F46-04)
5. effective_max_products = plan.max_products + bonus_max_products
6. trial_end_date = service_start_date + trial_days
7. Nếu trial_days > 0: service_status = TRIAL; nếu = 0: service_status = ACTIVE, active_start_date = service_start_date, paid_until_date = active_start_date + term_months
8. UPDATE company_subscriptions SET service_status, plan_snapshot, effective_*, activated_at = NOW, activated_by, version = version + 1
9. INSERT subscription_histories {action: ACTIVATE, from_status: DRAFT, to_status: (TRIAL/ACTIVE), changed_by, changed_at, ip_address}
10. Mirror: UPDATE companies.status = (TRIAL=1 / ACTIVE=2) (BR-F46-09)
11. Invalidate cache: xóa key user_perms:{userId} của MỌI user thuộc company_id (BR-F46-08)
12. COMMIT
13. Notification N-F46-01 to DN admin
14. Return 200 OK
Nếu bất kỳ step lỗi → ROLLBACK toàn bộ

Alternative Flow:

Bước Điều kiện Xử lý
2a service_status ≠ DRAFT E-F46-001 "Yêu cầu không ở trạng thái có thể kích hoạt"
2b Gói tham chiếu DEPRECATED E-F46-001 (biến thể) — chặn, yêu cầu đổi gói (BR-F45-05)
2c Optimistic lock lệch E-F46-007 (HTTP 409)
2d Cache invalidation lỗi (Redis down) Log cảnh báo; transaction DB vẫn commit; cache tự hết hạn sau 10' (fail-safe, không block)

UC-F46-04: Chuyển sang ACTIVE ​

Tiền điều kiện: PLATFORM_ADMIN; subscription ở service_status = TRIAL.

Hậu điều kiện: service_status = ACTIVE; active_start_date + paid_until_date set; history {ACTIVATE}; mirror; invalidate cache.

Bước Tác nhân Hành động / Mô tả Phản hồi / Xử lý hệ thống
1 ADMIN SCR-F46-04 → "Chuyển Active" → dialog
2 ADMIN Xác nhận active_start_date (default NOW) ATOMIC:
1. Validate service_status = TRIAL, lock version
2. active_start_date = NOW (hoặc nhập), paid_until_date = active_start_date + term_months
3. UPDATE company_subscriptions SET service_status = ACTIVE, active_start_date, paid_until_date, version+1
4. INSERT history {ACTIVATE, from: TRIAL, to: ACTIVE}
5. Mirror companies.status = 2
6. Invalidate cache user_perms mọi user DN
7. COMMIT + Return 200 OK

Alternative Flow: service_status ≠ TRIAL → E-F46-001.

UC-F46-05: Gia hạn dịch vụ ​

Tiền điều kiện: PLATFORM_ADMIN; subscription ở service_status ∈ {ACTIVE, EXPIRED}.

Hậu điều kiện: paid_until_date extend; nếu từ EXPIRED → ACTIVE; history {RENEW}; mirror; invalidate cache.

Bước Tác nhân Hành động / Mô tả Phản hồi / Xử lý hệ thống
1 ADMIN SCR-F46-04 → "Gia hạn" → dialog nhập term_months (kỳ gia hạn) Hiển thị chiết khấu preview theo term_months (BR-F46-13)
2 ADMIN Xác nhận ATOMIC:
1. Validate service_status ∈ {ACTIVE, EXPIRED}, lock version
2. base = GREATEST(paid_until_date, today) (nếu đã EXPIRED tính từ hôm nay)
3. paid_until_date = base + term_months; term_months += renew_months
4. service_status = ACTIVE (nếu trước đó EXPIRED)
5. UPDATE company_subscriptions SET paid_until_date, term_months, service_status, version+1
6. INSERT history {RENEW, from_status, to_status: ACTIVE, reason: kỳ gia hạn}
7. Mirror companies.status = 2
8. Invalidate cache user_perms mọi user DN
9. COMMIT + Return 200 OK

Alternative Flow: service_status ∉ {ACTIVE, EXPIRED} → E-F46-001.

UC-F46-06: Chấm dứt dịch vụ ​

Tiền điều kiện: PLATFORM_ADMIN; subscription ở service_status ∈ {TRIAL, ACTIVE, EXPIRED}.

Hậu điều kiện: service_status = TERMINATED (bắt buộc terminate_reason); history {TERMINATE}; mirror companies.status = 4; invalidate cache → user DN bị chặn login.

Bước Tác nhân Hành động / Mô tả Phản hồi / Xử lý hệ thống
1 ADMIN SCR-F46-04 → "Chấm dứt" → dialog
2 ADMIN Nhập terminate_reason (≥ 20 ký tự) → xác nhận ATOMIC:
1. Validate service_status ∈ {TRIAL, ACTIVE, EXPIRED}, lock version
2. Validate terminate_reason length ≥ 20 (E-F46-004)
3. UPDATE company_subscriptions SET service_status = TERMINATED, terminated_at = NOW, terminated_by, terminate_reason, version+1
4. INSERT history {TERMINATE, from_status, to_status: TERMINATED, reason: terminate_reason}
5. Mirror companies.status = 4
6. Invalidate cache user_perms mọi user DN (BR-F46-08)
7. COMMIT
8. Notification N-F46-04 to DN admin
9. Return 200 OK

Alternative Flow:

Bước Điều kiện Xử lý
2a terminate_reason < 20 ký tự E-F46-004 "Bắt buộc nhập lý do chấm dứt (≥ 20 ký tự)"
2b service_status đã TERMINATED E-F46-001 "Dịch vụ đã chấm dứt"

UC-F46-07: Chặn đăng nhập + lazy-expire (Login Gate) ​

Mô tả: SYSTEM chặn đăng nhập của user thuộc DN có dịch vụ EXPIRED/TERMINATED; đồng thời lazy-expire khi stored TRIAL/ACTIVE đã quá hạn theo ngày.

Tiền điều kiện: User thuộc một DN gọi POST /api/v1/auth/login (hoặc mọi request qua JwtStrategy.validate()).

Hậu điều kiện: Login bị chặn (403) nếu EXPIRED/TERMINATED; nếu lazy-expire, trạng thái flip + history + mirror + cache invalidated trong cùng transaction.

Bước Tác nhân Hành động / Mô tả Phản hồi / Xử lý hệ thống Ghi chú
1 User DN POST /api/v1/auth/login với credential hợp lệ 1. AuthService.login() xác thực mật khẩu (như hiện tại)
2. Đọc companies.status (mirror) của DN — rẻ, không JOIN
3. Nếu status = 3 (EXPIRED) → dừng, return 403 (bước 5)
4. Nếu status = 4 (TERMINATED) → dừng, return 403 (bước 6)
Đọc mirror trước để chặn nhanh, tránh JOIN mọi login
2 SYSTEM Nếu status ∈ {1 TRIAL, 2 ACTIVE} → kiểm tra hết hạn lazy Load company_subscriptions hiện hành; tính effective (Section 3.2):
- TRIAL & today > trial_end_date → cần expire
- ACTIVE & today > paid_until_date → cần expire
3 SYSTEM Nếu cần expire → LAZY-EXPIRE ATOMIC TRANSACTION:
1. UPDATE company_subscriptions SET service_status = EXPIRED, expired_at = NOW, version+1
2. INSERT subscription_histories {action: EXPIRE, from_status: (TRIAL/ACTIVE), to_status: EXPIRED, changed_by: SYSTEM, changed_at, reason: "Lazy-expire khi đăng nhập"}
3. Mirror companies.status = 3 (EXPIRED)
4. Invalidate cache user_perms:{userId} mọi user DN
5. COMMIT
BR-F46-07; SYSTEM là actor
4 SYSTEM Sau lazy-expire → chặn login Return 403 với message EXPIRED (bước 5)
5 SYSTEM Chặn EXPIRED Response 403: { success: false, statusCode: 403, error: { code: "SERVICE_EXPIRED", message: "Cần liên hệ sale của gotrace để kích hoạt dịch vụ" } } (BR-F46-05, E-F46-005)
6 SYSTEM Chặn TERMINATED Response 403: { success: false, statusCode: 403, error: { code: "SERVICE_TERMINATED", message: "Cần liên hệ với quản trị viên gotrace" } } (BR-F46-06, E-F46-006)
7 SYSTEM Nếu còn hạn (TRIAL/ACTIVE) hoặc DRAFT Phát JWT bình thường; JwtStrategy.validate() cache user_perms:{userId} 10 phút DRAFT cho phép onboarding hạn chế

Alternative Flow:

Bước Điều kiện Xử lý
2a DN chưa có subscription nào Coi như DRAFT — cho phép login (onboarding)
3a Redis down khi invalidate Log cảnh báo; transaction DB commit; cache tự hết hạn ≤ 10' (fail-safe)
7a Token cache còn hiệu lực nhưng DN vừa bị TERMINATED trong 10' qua Vì đã invalidate cache lúc TERMINATE (BR-F46-08), lần validate kế tiếp re-load → chặn ngay

UC-F46-08: Xem lịch sử sử dụng dịch vụ ​

Tiền điều kiện: PLATFORM_ADMIN (mọi DN) HOẶC ENTERPRISE_ADMIN/ENTERPRISE_USER (chỉ DN của mình).

Bước Tác nhân Hành động / Mô tả Phản hồi / Xử lý hệ thống
1 User Mở tab "Lịch sử sử dụng dịch vụ" (SCR-F46-05) trong chi tiết DN Navigate; render {CompanySubscriptionHistoryTab}
2 SYSTEM Query lịch sử 1. Xác định company_id: nếu DN → ép company_id = current_company_id (BR-F46-14, multi-tenant); nếu ADMIN → theo tham số
2. SELECT * FROM subscription_histories WHERE company_id = X ORDER BY changed_at DESC
3. SELECT subscription hiện hành + subscription_documents
4. Render timeline

Alternative Flow: DN cố xem company_id ≠ của mình → 403 "Không có quyền xem lịch sử của DN khác".

UC-F46-09: Mở lại dịch vụ (Reactivate) ​

Tiền điều kiện: PLATFORM_ADMIN; subscription ở service_status = TERMINATED.

Hậu điều kiện: service_status = TRIAL/ACTIVE; history {REACTIVATE}; mirror; invalidate cache.

Bước Tác nhân Hành động / Mô tả Phản hồi / Xử lý hệ thống
1 ADMIN SCR-F46-04 → "Mở lại" → dialog chọn trạng thái đích + lý do
2 ADMIN Xác nhận ATOMIC:
1. Validate service_status = TERMINATED, lock version
2. UPDATE service_status = (TRIAL/ACTIVE), terminated_at = NULL, terminate_reason = NULL, (set trial_end/paid_until nếu cần), version+1
3. INSERT history {REACTIVATE, from: TERMINATED, to: (TRIAL/ACTIVE), reason}
4. Mirror companies.status
5. Invalidate cache user_perms mọi user DN
6. COMMIT + Return 200 OK

8. Thiết kế Database Schema ​

8.1. ER Diagram ​

┌─────────────────────────────────┐        ┌─────────────────────────────────┐
│         companies (F01)          │        │       service_plans (F45)        │
│  id (BIGINT, PK)                │        │  id (BIGSERIAL, PK)             │
│  company_tax_code               │        │  plan_code (UNIQUE)             │
│  company_name                   │        │  plan_name                      │
│  company_email, contact_phone   │        │  setup_fee, standardization_fee │
│  status (INT) ◄── mirror         │        │  maintenance_fee_year           │
│    service_status (0..4)        │        │  qr_quota_year, max_products    │
│  sale_user_id                   │        │  features (JSONB)               │
└──────────┬──────────────────────┘        │  status (DRAFT/ACTIVE/DEPRECATED)│
           │ 1:1 (hiện hành)                └──────────┬──────────────────────┘
           │                                            │ N:1 (plan_id + snapshot)
           ▼                                            ▼
┌──────────────────────────────────────────────────────────────────────────┐
│                       company_subscriptions (F46)                         │
│  id (BIGSERIAL, PK)                                                       │
│  company_id (BIGINT, FK → companies)  ── UNIQUE WHERE deleted_at IS NULL  │
│  plan_id (BIGINT, FK → service_plans)                                     │
│  plan_code (VARCHAR 30) ── snapshot                                       │
│  contract_code (VARCHAR 100) ── UNIQUE partial                            │
│  service_status (VARCHAR 20) ── DRAFT/TRIAL/ACTIVE/EXPIRED/TERMINATED     │
│  service_start_date, trial_days, trial_end_date                          │
│  active_start_date, term_months, paid_until_date                        │
│  bonus_qr_quota, bonus_max_products                                      │
│  effective_qr_quota, effective_max_products                             │
│  plan_snapshot (JSONB) ── chốt phí/quota/features lúc kích hoạt          │
│  discount_percent                                                       │
│  activated_at/by, terminated_at/by, terminate_reason, expired_at        │
│  version (INT) ── optimistic lock                                        │
│  created_by/at, updated_by/at, deleted_at                               │
└──────┬────────────────────────────┬──────────────────────────────────────┘
       │ 1:N                        │ 1:N
       ▼                            ▼
┌───────────────────────────┐  ┌──────────────────────────────────────────┐
│  subscription_documents   │  │  subscription_histories (APPEND-ONLY)    │
│  id (BIGSERIAL, PK)       │  │  id (BIGSERIAL, PK)                       │
│  subscription_id          │  │  subscription_id (FK)                    │
│   (FK ON DELETE CASCADE)  │  │  company_id (BIGINT)                      │
│  doc_type (CONTRACT/MOU/  │  │  action (CREATE/UPDATE/ACTIVATE/RENEW/    │
│   APPENDIX/OTHER)         │  │   EXPIRE/TERMINATE/REACTIVATE)           │
│  file_name, file_url      │  │  from_status, to_status                   │
│  file_size, mime_type     │  │  changed_by, changed_at                   │
│  uploaded_by/at           │  │  changes_json (JSONB — diff before/after) │
│  deleted_at               │  │  reason, ip_address                       │
└───────────────────────────┘  │  ── IMMUTABLE (trigger chống UPDATE/DELETE)│
                               └──────────────────────────────────────────┘

8.2. Mô tả bảng ​

Bảng company_subscriptions (1 bản ghi hiện hành / DN) ​

Cột Kiểu dữ liệu Nullable Default Mô tả Constraint
id BIGSERIAL NOT NULL Khóa chính PRIMARY KEY
company_id BIGINT NOT NULL DN sở hữu FK → companies(id)
plan_id BIGINT NOT NULL Gói dịch vụ FK → service_plans(id)
plan_code VARCHAR(30) NOT NULL Snapshot mã gói
contract_code VARCHAR(100) NULL Mã hợp đồng UNIQUE partial
service_status VARCHAR(20) NOT NULL 'DRAFT' Trạng thái dịch vụ CHECK IN (DRAFT,TRIAL,ACTIVE,EXPIRED,TERMINATED)
service_start_date DATE NULL CURRENT_DATE Ngày bắt đầu dịch vụ
trial_days INT NOT NULL 7 Số ngày dùng thử CHECK ≥ 0
trial_end_date DATE NULL service_start_date + trial_days
active_start_date DATE NULL Ngày bắt đầu chính thức
term_months INT NULL Kỳ hạn (tháng) CHECK > 0
paid_until_date DATE NULL Thanh toán đến ngày
bonus_qr_quota INT NOT NULL 0 QR tặng thêm CHECK ≥ 0
bonus_max_products INT NOT NULL 0 SP tặng thêm CHECK ≥ 0
effective_qr_quota INT NULL plan.qr_quota_year + bonus_qr_quota
effective_max_products INT NULL plan.max_products + bonus_max_products
plan_snapshot JSONB NULL Chốt phí/quota/features lúc kích hoạt
discount_percent NUMERIC(5,2) NOT NULL 0 Chiết khấu thủ công CHECK 0–100
note TEXT NULL Ghi chú nội bộ
activated_at TIMESTAMPTZ NULL Thời điểm kích hoạt
activated_by BIGINT NULL Người kích hoạt FK → users(id)
terminated_at TIMESTAMPTZ NULL Thời điểm chấm dứt
terminated_by BIGINT NULL Người chấm dứt FK → users(id)
terminate_reason TEXT NULL Lý do chấm dứt *NOT NULL khi TERMINATED
expired_at TIMESTAMPTZ NULL Thời điểm hết hạn (lazy)
version INT NOT NULL 1 Optimistic lock
created_by BIGINT NULL Người tạo FK → users(id)
updated_by BIGINT NULL Người sửa FK → users(id)
created_at TIMESTAMPTZ NOT NULL NOW() Ngày tạo
updated_at TIMESTAMPTZ NOT NULL NOW() Ngày cập nhật
deleted_at TIMESTAMPTZ NULL Soft delete

Bảng subscription_documents ​

Cột Kiểu dữ liệu Nullable Default Mô tả Constraint
id BIGSERIAL NOT NULL Khóa chính PRIMARY KEY
subscription_id BIGINT NOT NULL Subscription cha FK → company_subscriptions(id) ON DELETE CASCADE
doc_type VARCHAR(20) NOT NULL Loại tài liệu CHECK IN (CONTRACT,MOU,APPENDIX,OTHER)
file_name VARCHAR(255) NOT NULL Tên file gốc
file_url VARCHAR(500) NOT NULL MinIO key
file_size BIGINT NULL Kích thước byte CHECK ≤ 20971520 (20MB)
mime_type VARCHAR(100) NULL MIME CHECK IN (application/pdf, image/jpeg, image/png)
uploaded_by BIGINT NULL Người upload FK → users(id)
uploaded_at TIMESTAMPTZ NOT NULL NOW() Thời điểm upload
deleted_at TIMESTAMPTZ NULL Soft delete

Bảng subscription_histories (APPEND-ONLY, immutable) ​

Cột Kiểu dữ liệu Nullable Default Mô tả Constraint
id BIGSERIAL NOT NULL Khóa chính PRIMARY KEY
subscription_id BIGINT NOT NULL Subscription liên quan FK → company_subscriptions(id)
company_id BIGINT NOT NULL DN (denormalize để query tab DN)
action VARCHAR(20) NOT NULL Loại thao tác CHECK IN (CREATE,UPDATE,ACTIVATE,RENEW,EXPIRE,TERMINATE,REACTIVATE)
from_status VARCHAR(20) NULL Trạng thái trước
to_status VARCHAR(20) NULL Trạng thái sau
changed_by BIGINT NULL Người thực hiện (NULL nếu SYSTEM) FK → users(id)
changed_at TIMESTAMPTZ NOT NULL NOW() Thời điểm
changes_json JSONB NULL Diff before/after (BẮT BUỘC cho UPDATE)
reason TEXT NULL Lý do (bắt buộc cho TERMINATE)
ip_address VARCHAR(45) NULL IP người thực hiện

8.3. Indexes ​

company_subscriptions:

subscription_documents:

subscription_histories:

companies (mirror):

8.4. Migration SQL ​

sql
-- =========================================================================
-- Migration: TXNG-F46 — Kích hoạt & Vòng đời Dịch vụ Doanh nghiệp
-- Depends: TXNG-F45 (service_plans), companies, users
-- =========================================================================

-- 1) company_subscriptions -----------------------------------------------
CREATE TABLE company_subscriptions (
    id                     BIGSERIAL PRIMARY KEY,
    company_id             BIGINT NOT NULL REFERENCES companies(id),
    plan_id                BIGINT NOT NULL REFERENCES service_plans(id),
    plan_code              VARCHAR(30) NOT NULL,
    contract_code          VARCHAR(100),
    service_status         VARCHAR(20) NOT NULL DEFAULT 'DRAFT'
                             CHECK (service_status IN ('DRAFT','TRIAL','ACTIVE','EXPIRED','TERMINATED')),
    service_start_date     DATE DEFAULT CURRENT_DATE,
    trial_days             INT NOT NULL DEFAULT 7 CHECK (trial_days >= 0),
    trial_end_date         DATE,
    active_start_date      DATE,
    term_months            INT CHECK (term_months IS NULL OR term_months > 0),
    paid_until_date        DATE,
    bonus_qr_quota         INT NOT NULL DEFAULT 0 CHECK (bonus_qr_quota >= 0),
    bonus_max_products     INT NOT NULL DEFAULT 0 CHECK (bonus_max_products >= 0),
    effective_qr_quota     INT,
    effective_max_products INT,
    plan_snapshot          JSONB,
    discount_percent       NUMERIC(5,2) NOT NULL DEFAULT 0 CHECK (discount_percent >= 0 AND discount_percent <= 100),
    note                   TEXT,
    activated_at           TIMESTAMPTZ,
    activated_by           BIGINT REFERENCES users(id),
    terminated_at          TIMESTAMPTZ,
    terminated_by          BIGINT REFERENCES users(id),
    terminate_reason       TEXT,
    expired_at             TIMESTAMPTZ,
    version                INT NOT NULL DEFAULT 1,
    created_by             BIGINT REFERENCES users(id),
    updated_by             BIGINT REFERENCES users(id),
    created_at             TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at             TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    deleted_at             TIMESTAMPTZ,
    -- TERMINATED bắt buộc có lý do
    CONSTRAINT chk_terminate_reason
        CHECK (service_status <> 'TERMINATED' OR terminate_reason IS NOT NULL)
);

CREATE UNIQUE INDEX uq_company_subscriptions_company
    ON company_subscriptions (company_id) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX uq_company_subscriptions_contract
    ON company_subscriptions (contract_code)
    WHERE contract_code IS NOT NULL AND deleted_at IS NULL;
CREATE INDEX idx_company_subscriptions_status       ON company_subscriptions (service_status);
CREATE INDEX idx_company_subscriptions_start        ON company_subscriptions (service_start_date);
CREATE INDEX idx_company_subscriptions_activated    ON company_subscriptions (activated_at);
CREATE INDEX idx_company_subscriptions_paid_until   ON company_subscriptions (paid_until_date);

-- 2) subscription_documents ----------------------------------------------
CREATE TABLE subscription_documents (
    id               BIGSERIAL PRIMARY KEY,
    subscription_id  BIGINT NOT NULL REFERENCES company_subscriptions(id) ON DELETE CASCADE,
    doc_type         VARCHAR(20) NOT NULL
                       CHECK (doc_type IN ('CONTRACT','MOU','APPENDIX','OTHER')),
    file_name        VARCHAR(255) NOT NULL,
    file_url         VARCHAR(500) NOT NULL,
    file_size        BIGINT CHECK (file_size IS NULL OR file_size <= 20971520),
    mime_type        VARCHAR(100)
                       CHECK (mime_type IS NULL OR mime_type IN ('application/pdf','image/jpeg','image/png')),
    uploaded_by      BIGINT REFERENCES users(id),
    uploaded_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    deleted_at       TIMESTAMPTZ
);
CREATE INDEX idx_subscription_documents_sub ON subscription_documents (subscription_id);

-- 3) subscription_histories (APPEND-ONLY) --------------------------------
CREATE TABLE subscription_histories (
    id               BIGSERIAL PRIMARY KEY,
    subscription_id  BIGINT NOT NULL REFERENCES company_subscriptions(id),
    company_id       BIGINT NOT NULL,
    action           VARCHAR(20) NOT NULL
                       CHECK (action IN ('CREATE','UPDATE','ACTIVATE','RENEW','EXPIRE','TERMINATE','REACTIVATE')),
    from_status      VARCHAR(20),
    to_status        VARCHAR(20),
    changed_by       BIGINT REFERENCES users(id),
    changed_at       TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    changes_json     JSONB,
    reason           TEXT,
    ip_address       VARCHAR(45)
);
CREATE INDEX idx_subscription_histories_sub
    ON subscription_histories (subscription_id, changed_at DESC);
CREATE INDEX idx_subscription_histories_company
    ON subscription_histories (company_id, changed_at DESC);

-- 4) Trigger chống UPDATE/DELETE trên subscription_histories (immutable) --
CREATE OR REPLACE FUNCTION trg_block_subscription_histories_mutation()
RETURNS TRIGGER AS $$
BEGIN
    RAISE EXCEPTION 'subscription_histories is append-only (immutable audit log); % không được phép', TG_OP;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER block_subscription_histories_update
    BEFORE UPDATE ON subscription_histories
    FOR EACH ROW EXECUTE FUNCTION trg_block_subscription_histories_mutation();
CREATE TRIGGER block_subscription_histories_delete
    BEFORE DELETE ON subscription_histories
    FOR EACH ROW EXECUTE FUNCTION trg_block_subscription_histories_mutation();

-- 5) Mirror companies.status ---------------------------------------------
-- companies.status (INT, đã có sẵn) = mirror service_status:
--   DRAFT=0, TRIAL=1, ACTIVE=2, EXPIRED=3, TERMINATED=4
-- Nguồn sự thật = company_subscriptions.service_status; cập nhật cùng transaction.
COMMENT ON COLUMN companies.status IS
    'Mirror company_subscriptions.service_status (DRAFT=0,TRIAL=1,ACTIVE=2,EXPIRED=3,TERMINATED=4) — phục vụ login gate; nguồn sự thật = company_subscriptions';

-- =========================================================================
-- INSERT ví dụ: kích hoạt DN id=1001 gói PKG_STANDARD, trial 7 ngày
-- =========================================================================
-- Giả định service_plans có bản ghi PKG_STANDARD id=2 (qr_quota_year=20000, max_products=50)
INSERT INTO company_subscriptions (
    company_id, plan_id, plan_code, contract_code, service_status,
    service_start_date, trial_days, trial_end_date, term_months,
    bonus_qr_quota, bonus_max_products, effective_qr_quota, effective_max_products,
    discount_percent, created_by, created_at, updated_at
) VALUES (
    1001, 2, 'PKG_STANDARD', 'HD-2026-0001', 'DRAFT',
    '2026-07-01', 7, '2026-07-08', 12,
    0, 0, 20000, 50,
    0, 1, NOW(), NOW()
);

-- Ghi nhật ký CREATE (cùng transaction thực tế)
INSERT INTO subscription_histories (
    subscription_id, company_id, action, from_status, to_status,
    changed_by, changed_at, reason, ip_address
) VALUES (
    currval('company_subscriptions_id_seq'), 1001, 'CREATE', NULL, 'DRAFT',
    1, NOW(), 'Tạo yêu cầu kích hoạt gói Chuẩn hóa', '10.0.0.5'
);

-- Ví dụ ACTIVATE (DRAFT -> TRIAL): mirror companies.status = 1
-- UPDATE company_subscriptions SET service_status='TRIAL', plan_snapshot='{...}'::jsonb,
--     activated_at=NOW(), activated_by=1, version=version+1 WHERE id=...;
-- INSERT subscription_histories (..., action='ACTIVATE', from_status='DRAFT', to_status='TRIAL', ...);
-- UPDATE companies SET status=1 WHERE id=1001;

9. Thiết kế API ​

9.1. Danh sách endpoint ​

Tất cả endpoint quản trị dùng prefix /api/v1/admin, Bearer Token JWT, chặn qua AdminGuard (chỉ PLATFORM_ADMIN). Envelope: { success, statusCode, data }.

# Method Endpoint Mô tả Role
1 GET /api/v1/admin/company-subscriptions Danh sách + filter (status, plan_code, khoảng Active, search MST/tên/SĐT/email) + pagination PLATFORM_ADMIN
2 GET /api/v1/admin/company-subscriptions/analysis Summary cards đếm theo trạng thái PLATFORM_ADMIN
3 POST /api/v1/admin/company-subscriptions Tạo yêu cầu kích hoạt (DRAFT) PLATFORM_ADMIN
4 GET /api/v1/admin/company-subscriptions/:id Chi tiết subscription + hồ sơ PLATFORM_ADMIN
5 PUT /api/v1/admin/company-subscriptions/:id Sửa (chỉ DRAFT) — ghi history UPDATE PLATFORM_ADMIN
6 POST /api/v1/admin/company-subscriptions/:id/activate Kích hoạt (DRAFT→TRIAL/ACTIVE) hoặc chuyển TRIAL→ACTIVE PLATFORM_ADMIN
7 POST /api/v1/admin/company-subscriptions/:id/renew Gia hạn (EXPIRED→ACTIVE / extend) PLATFORM_ADMIN
8 POST /api/v1/admin/company-subscriptions/:id/terminate Chấm dứt (→TERMINATED, bắt buộc reason) PLATFORM_ADMIN
9 POST /api/v1/admin/company-subscriptions/:id/reactivate Mở lại (TERMINATED→TRIAL/ACTIVE) PLATFORM_ADMIN
10 POST /api/v1/admin/company-subscriptions/:id/documents Upload hồ sơ (multipart) PLATFORM_ADMIN
11 GET /api/v1/admin/company-subscriptions/:id/history Nhật ký của subscription PLATFORM_ADMIN
12 GET /api/v1/companies/:id/subscription-history Lịch sử dịch vụ của DN (admin mọi DN; DN chỉ của mình) PLATFORM_ADMIN, ENTERPRISE_ADMIN, ENTERPRISE_USER
13 POST /api/v1/auth/login Sửa: login gate — chặn EXPIRED/TERMINATED + lazy-expire ALL (public)

9.2. Chi tiết API ​

9.2.1. POST /api/v1/admin/company-subscriptions — Tạo yêu cầu ​

Request:

json
{
  "company_id": 1001,
  "plan_id": 2,
  "contract_code": "HD-2026-0001",
  "term_months": 12,
  "trial_days": 7,
  "service_start_date": "2026-07-01",
  "bonus_qr_quota": 0,
  "bonus_max_products": 0,
  "discount_percent": 0,
  "note": "Khách giới thiệu bởi sale A"
}

Response 201:

json
{
  "success": true,
  "statusCode": 201,
  "data": {
    "id": 55,
    "company_id": 1001,
    "plan_code": "PKG_STANDARD",
    "service_status": "DRAFT",
    "contract_code": "HD-2026-0001",
    "trial_end_date": "2026-07-08",
    "effective_qr_quota": 20000,
    "effective_max_products": 50,
    "version": 1
  }
}

Lỗi: 400 (E-F46-001/002/003), 409 (BR-F46-10 concurrent).

9.2.2. POST /api/v1/admin/company-subscriptions/:id/activate — Kích hoạt ​

Request: { "version": 1 } (optimistic lock; body có thể kèm active_start_date khi chuyển TRIAL→ACTIVE).

Response 200:

json
{
  "success": true,
  "statusCode": 200,
  "data": {
    "id": 55,
    "service_status": "TRIAL",
    "trial_end_date": "2026-07-08",
    "activated_at": "2026-07-01T03:00:00Z",
    "plan_snapshot": {
      "setup_fee": 3000000, "standardization_fee": 5000000,
      "maintenance_fee_year": 9500000, "qr_quota_year": 20000,
      "max_products": 50, "features": { "recall_management": false, "api_integration": "none" }
    },
    "companies_status_mirror": 1,
    "version": 2
  }
}

Lỗi: 400 (E-F46-001), 409 (E-F46-007 version lệch).

9.2.3. POST /api/v1/admin/company-subscriptions/:id/renew — Gia hạn ​

Request: { "term_months": 12, "version": 3 } Response 200: { "success": true, "statusCode": 200, "data": { "service_status": "ACTIVE", "paid_until_date": "2027-07-08", "version": 4 } }

9.2.4. POST /api/v1/admin/company-subscriptions/:id/terminate — Chấm dứt ​

Request: { "terminate_reason": "DN vi phạm điều khoản hợp đồng dịch vụ", "version": 4 } Response 200: { "success": true, "statusCode": 200, "data": { "service_status": "TERMINATED", "terminated_at": "2026-09-01T02:00:00Z", "companies_status_mirror": 4, "version": 5 } } Lỗi: 400 (E-F46-004 thiếu/ngắn reason).

9.2.5. POST /api/v1/admin/company-subscriptions/:id/documents — Upload hồ sơ ​

Request: multipart/form-data — doc_type=CONTRACT, file=@hopdong.pdf (FileFieldsInterceptor). Response 201: { "success": true, "statusCode": 201, "data": { "id": 88, "doc_type": "CONTRACT", "file_name": "hopdong.pdf", "file_url": "subscriptions/55/hopdong.pdf", "file_size": 1048576 } } Lỗi: 400 (E-F46-003 quá 20MB / sai định dạng).

9.2.6. GET /api/v1/companies/:id/subscription-history — Lịch sử dịch vụ ​

Response 200:

json
{
  "success": true,
  "statusCode": 200,
  "data": {
    "current": { "plan_code": "PKG_STANDARD", "service_status": "ACTIVE", "paid_until_date": "2027-07-08" },
    "histories": [
      { "action": "ACTIVATE", "from_status": "DRAFT", "to_status": "TRIAL", "changed_at": "2026-07-01T03:00:00Z", "changed_by": "Admin GoTrace" },
      { "action": "ACTIVATE", "from_status": "TRIAL", "to_status": "ACTIVE", "changed_at": "2026-07-08T01:00:00Z" }
    ]
  }
}

Multi-tenant: nếu caller là ENTERPRISE_ADMIN/USER, server ép company_id = current_company_id; truy vấn DN khác → 403 (BR-F46-14).

9.2.7. POST /api/v1/auth/login — Login gate (SỬA) ​

Request: { "email": "user@dn.com", "password": "..." }

Xử lý bổ sung (login gate):

  1. Xác thực credential (như cũ).
  2. Đọc companies.status (mirror) của DN user.
  3. Nếu status = 1 (TRIAL)/2 (ACTIVE): kiểm tra lazy-expire theo trial_end_date/paid_until_date; nếu quá hạn → flip EXPIRED (transaction: UPDATE + history {EXPIRE} + mirror status=3 + invalidate user_perms:{userId} mọi user DN).
  4. Nếu (sau lazy) EXPIRED → 403 SERVICE_EXPIRED; nếu TERMINATED → 403 SERVICE_TERMINATED.
  5. Ngược lại (DRAFT/TRIAL/ACTIVE còn hạn) → phát JWT; JwtStrategy.validate() cache user_perms:{userId} 10'.

Response 403 — EXPIRED (E-F46-005):

json
{
  "success": false,
  "statusCode": 403,
  "error": { "code": "SERVICE_EXPIRED", "message": "Cần liên hệ sale của gotrace để kích hoạt dịch vụ" }
}

Response 403 — TERMINATED (E-F46-006):

json
{
  "success": false,
  "statusCode": 403,
  "error": { "code": "SERVICE_TERMINATED", "message": "Cần liên hệ với quản trị viên gotrace" }
}

9.2.8. GET /api/v1/admin/company-subscriptions — Danh sách ​

Query params: page, page_size, service_status, plan_code, active_from, active_to, keyword (MST/tên/SĐT/email). Response 200:

json
{
  "success": true,
  "statusCode": 200,
  "data": {
    "items": [ { "id": 55, "company_name": "Công ty ABC", "company_tax_code": "0101234567", "plan_code": "PKG_STANDARD", "service_status": "ACTIVE", "paid_until_date": "2027-07-08" } ],
    "pagination": { "page": 1, "page_size": 20, "total_items": 137, "total_pages": 7 }
  }
}

10. Test Cases cho QA ​

TC ID Use Case Tiền điều kiện Bước thực hiện Kết quả mong đợi
TC-01-01 UC-F46-01 DN chưa có sub Tạo yêu cầu gói PKG_STANDARD, trial 7 201; service_status=DRAFT; history {CREATE}; companies.status=0
TC-01-02 UC-F46-01 DN đang ACTIVE Tạo yêu cầu mới cho DN đó 400 E-F46-001
TC-01-03 UC-F46-01 — Bỏ trống contract_code 400 E-F46-002
TC-01-04 UC-F46-01 contract_code "HD-1" đã dùng Tạo với "HD-1" 400 E-F46-002 (trùng)
TC-01-05 UC-F46-01 — Chọn gói DEPRECATED 400 (BR-F45-05)
TC-01-06 UC-F46-01 — Upload file 25MB 400 E-F46-003
TC-02-01 UC-F46-02 Sub DRAFT Sửa term_months 12→24 200; history {UPDATE, changes_json diff}; version+1
TC-02-02 UC-F46-02 Sub TRIAL Cố sửa 400 E-F46-001
TC-02-03 UC-F46-02 version cũ Sửa với version lệch 409 E-F46-007
TC-03-01 UC-F46-03 Sub DRAFT, trial=7 Kích hoạt 200; TRIAL; plan_snapshot chốt; effective_qr_quota=plan+bonus; companies.status=1; cache invalidated; N-F46-01
TC-03-02 UC-F46-03 Sub DRAFT, trial=0 Kích hoạt 200; nhảy thẳng ACTIVE; paid_until_date set; companies.status=2
TC-03-03 UC-F46-03 Sub TRIAL Gọi activate 400 E-F46-001 (dùng /activate cho TRIAL→ACTIVE riêng)
TC-04-01 UC-F46-04 Sub TRIAL Chuyển Active 200; ACTIVE; paid_until_date=active_start+term; mirror 2
TC-05-01 UC-F46-05 Sub EXPIRED Gia hạn 12 tháng 200; ACTIVE; paid_until_date +12m từ hôm nay; history {RENEW}; mirror 2
TC-05-02 UC-F46-05 Sub ACTIVE Gia hạn 12 tháng 200; paid_until_date cộng dồn từ ngày cũ
TC-06-01 UC-F46-06 Sub ACTIVE Chấm dứt, reason 30 ký tự 200; TERMINATED; companies.status=4; cache invalidated; N-F46-04
TC-06-02 UC-F46-06 Sub ACTIVE Chấm dứt, reason 5 ký tự 400 E-F46-004
TC-07-01 UC-F46-07 DN EXPIRED User DN login 403 SERVICE_EXPIRED, đúng message
TC-07-02 UC-F46-07 DN TERMINATED User DN login 403 SERVICE_TERMINATED, đúng message
TC-07-03 UC-F46-07 DN TRIAL, trial_end_date = hôm qua User DN login Lazy-expire: flip EXPIRED + history {EXPIRE} + mirror 3 + invalidate; rồi 403 SERVICE_EXPIRED
TC-07-04 UC-F46-07 DN ACTIVE, paid_until_date = hôm qua User DN login Lazy-expire tương tự → 403
TC-07-05 UC-F46-07 DN ACTIVE còn hạn User DN login 200; JWT phát; cache user_perms 10'
TC-07-06 UC-F46-07 DN DRAFT User DN login 200 (onboarding hạn chế)
TC-08-01 UC-F46-08 DN A đăng nhập Xem lịch sử DN A 200; timeline đúng
TC-08-02 UC-F46-08 DN A đăng nhập Gọi history của DN B 403 (multi-tenant BR-F46-14)
TC-09-01 UC-F46-09 Sub TERMINATED Mở lại → ACTIVE 200; ACTIVE; terminate_reason clear; history {REACTIVATE}; mirror 2
TC-10-01 Immutable History đã ghi UPDATE subscription_histories SET reason='x' RAISE EXCEPTION (trigger chặn)
TC-10-02 Immutable History đã ghi DELETE FROM subscription_histories RAISE EXCEPTION (trigger chặn)
TC-11-01 Performance 137 sub GET list page 1 ≤ 500ms; pagination đúng
TC-11-02 Performance — Kích hoạt (transaction) ≤ 1 giây (write)

11. Acceptance Criteria ​

AC ID Tiêu chí chấp nhận
AC-F46-01 Chỉ PLATFORM_ADMIN (qua AdminGuard) truy cập được các endpoint /api/v1/admin/company-subscriptions/*; role khác → 403
AC-F46-02 Mỗi DN chỉ có tối đa 1 subscription hiện hành (partial unique company_id); tạo trùng → 409
AC-F46-03 contract_code unique (partial); trùng → 400
AC-F46-04 Kích hoạt chốt plan_snapshot và effective_qr_quota = plan.qr_quota_year + bonus_qr_quota, effective_max_products = plan.max_products + bonus_max_products
AC-F46-05 Mọi chuyển trạng thái ghi 1 bản ghi subscription_histories trong cùng transaction (append-only); rollback nếu lỗi
AC-F46-06 subscription_histories không thể UPDATE/DELETE (trigger raise exception)
AC-F46-07 Mọi chuyển trạng thái mirror companies.status đúng bảng map (0..4) trong cùng transaction
AC-F46-08 Mọi chuyển trạng thái invalidate cache user_perms:{userId} của tất cả user thuộc DN
AC-F46-09 User DN có dịch vụ EXPIRED bị chặn login với message "Cần liên hệ sale của gotrace để kích hoạt dịch vụ"
AC-F46-10 User DN có dịch vụ TERMINATED bị chặn login với message "Cần liên hệ với quản trị viên gotrace"
AC-F46-11 Login của user DN có stored TRIAL/ACTIVE quá hạn tự động lazy-expire (flip EXPIRED + history + mirror + invalidate) rồi chặn
AC-F46-12 Chấm dứt bắt buộc terminate_reason ≥ 20 ký tự; thiếu → 400
AC-F46-13 Upload hồ sơ chỉ PDF/JPG/PNG ≤ 20MB; sai → 400
AC-F46-14 ENTERPRISE_ADMIN/USER chỉ xem lịch sử dịch vụ của chính DN mình; DN khác → 403
AC-F46-15 Sửa/chuyển trạng thái dùng optimistic lock version; concurrent → 409
AC-F46-16 Không phát sinh CTE/KDE (module vận hành thương mại)
AC-F46-17 Không dùng cron ở v1; hết hạn đánh giá lazy khi login

12. Chuyển đổi trạng thái (State Transitions) ​

Bảng đầy đủ transition của SERVICE_STATUS. Mọi transition (trừ không có) thực hiện trong cùng $transaction: ghi subscription_histories + mirror companies.status + invalidate cache user_perms.

# From To Actor Action (history) Precondition Hành động hệ thống
T1 (none) DRAFT PLATFORM_ADMIN CREATE DN đủ điều kiện (BR-F46-02); gói ACTIVE INSERT sub; history CREATE; mirror 0
T2 DRAFT DRAFT PLATFORM_ADMIN UPDATE version khớp UPDATE; history UPDATE (changes_json); mirror giữ 0
T3 DRAFT TRIAL PLATFORM_ADMIN ACTIVATE trial_days > 0 Chốt snapshot; effective quota; trial_end_date; history ACTIVATE; mirror 1; invalidate cache
T4 DRAFT ACTIVE PLATFORM_ADMIN ACTIVATE trial_days = 0 Chốt snapshot; active_start_date, paid_until_date; history ACTIVATE; mirror 2; invalidate
T5 TRIAL ACTIVE PLATFORM_ADMIN ACTIVATE status=TRIAL active_start_date, paid_until_date; history ACTIVATE; mirror 2; invalidate
T6 TRIAL EXPIRED SYSTEM EXPIRE today > trial_end_date (login) Lazy-expire; history EXPIRE; mirror 3; invalidate
T7 ACTIVE EXPIRED SYSTEM EXPIRE today > paid_until_date (login) Lazy-expire; history EXPIRE; mirror 3; invalidate
T8 EXPIRED ACTIVE PLATFORM_ADMIN RENEW status=EXPIRED paid_until_date += term; history RENEW; mirror 2; invalidate
T9 ACTIVE ACTIVE PLATFORM_ADMIN RENEW status=ACTIVE (extend) paid_until_date += term; history RENEW; mirror giữ 2; invalidate
T10 TRIAL TERMINATED PLATFORM_ADMIN TERMINATE reason ≥ 20 history TERMINATE; mirror 4; invalidate
T11 ACTIVE TERMINATED PLATFORM_ADMIN TERMINATE reason ≥ 20 history TERMINATE; mirror 4; invalidate
T12 EXPIRED TERMINATED PLATFORM_ADMIN TERMINATE reason ≥ 20 history TERMINATE; mirror 4; invalidate
T13 TERMINATED TRIAL PLATFORM_ADMIN REACTIVATE status=TERMINATED clear terminate_*; history REACTIVATE; mirror 1; invalidate
T14 TERMINATED ACTIVE PLATFORM_ADMIN REACTIVATE status=TERMINATED clear terminate_*; paid_until_date; history REACTIVATE; mirror 2; invalidate

Các transition không hợp lệ (vd EXPIRED → TRIAL trực tiếp, ACTIVE → DRAFT) đều bị chặn với E-F46-001.


13. Notification List ​

ID Sự kiện Kênh Người nhận Template message
N-F46-01 Kích hoạt thành công In-app + Email ENTERPRISE_ADMIN (DN) "Dịch vụ GoTrace của [company_name] đã được kích hoạt gói [plan_code]. Thời hạn dùng thử đến [trial_end_date]."
N-F46-02 Sắp hết trial (≤ 3 ngày) In-app + Email ENTERPRISE_ADMIN (DN) "Thời gian dùng thử của [company_name] còn [n] ngày (đến [trial_end_date]). Vui lòng liên hệ để chuyển gói chính thức."
N-F46-03 Hết hạn dịch vụ In-app + Email ENTERPRISE_ADMIN (DN) "Dịch vụ GoTrace của [company_name] đã hết hạn ngày [expired_at]. Vui lòng liên hệ sale để gia hạn."
N-F46-04 Dịch vụ bị chấm dứt In-app + Email ENTERPRISE_ADMIN (DN) "Dịch vụ GoTrace của [company_name] đã bị chấm dứt. Lý do: [terminate_reason]. Vui lòng liên hệ quản trị viên."

Ghi chú: N-F46-02/N-F46-03 ở v1 phát khi có sự kiện login/thao tác admin phát hiện (không cron). Warning W-F46-001/002 (Section 14) là biến thể hiển thị trên UI admin.


14. Error / Warning Message Handling ​

14.1. Errors (chặn hành động) ​

Mã Mô tả Điều kiện kích hoạt Xử lý hệ thống
E-F46-001 DN không đủ điều kiện kích hoạt (đang TRIAL/ACTIVE) hoặc trạng thái không cho phép thao tác Tạo/kích hoạt/sửa khi DN đang có sub hiện hành hoặc trạng thái sai; gói DEPRECATED HTTP 400; chặn; hiển thị "DN không đủ điều kiện kích hoạt"
E-F46-002 Thiếu / trùng mã hợp đồng contract_code rỗng hoặc đã tồn tại (partial unique) HTTP 400; chặn; "Bắt buộc nhập mã hợp đồng" / "Mã hợp đồng đã tồn tại"
E-F46-003 File vượt 20MB hoặc sai định dạng Upload file > 20MB hoặc ≠ PDF/JPG/PNG HTTP 400; chặn; "File tối đa 20MB, chỉ chấp nhận PDF/JPG/PNG"
E-F46-004 Chấm dứt thiếu lý do terminate_reason rỗng hoặc < 20 ký tự HTTP 400; chặn; "Bắt buộc nhập lý do chấm dứt (≥ 20 ký tự)"
E-F46-005 Login khi EXPIRED User DN có service_status/mirror = EXPIRED đăng nhập HTTP 403 SERVICE_EXPIRED; "Cần liên hệ sale của gotrace để kích hoạt dịch vụ"
E-F46-006 Login khi TERMINATED User DN có service_status/mirror = TERMINATED đăng nhập HTTP 403 SERVICE_TERMINATED; "Cần liên hệ với quản trị viên gotrace"
E-F46-007 Concurrent edit (version lệch) version gửi lên ≠ version DB HTTP 409; "Bản ghi đã được cập nhật bởi người khác, vui lòng tải lại"

14.2. Warnings (cho phép tiếp tục, cảnh báo) ​

Mã Mô tả Điều kiện kích hoạt Xử lý hệ thống
W-F46-001 Trial sắp hết hạn trial_end_date - today ≤ 3 ngày Badge cảnh báo trên list/detail; N-F46-02
W-F46-002 Dịch vụ sắp hết hạn paid_until_date - today ≤ 30 ngày Badge cảnh báo; gợi ý gia hạn

15. Ma trận truy xuất (Traceability Matrix) ​

Use Case Screen Business Rule Acceptance Test Case Error/Warning Notification
UC-F46-01 SCR-F46-02 BR-F46-01,02,10,11,12 AC-F46-02,03,05 TC-01-01..06 E-F46-001,002,003 —
UC-F46-02 SCR-F46-03 BR-F46-01,15,17 AC-F46-05,15 TC-02-01..03 E-F46-001,007 —
UC-F46-03 SCR-F46-04 BR-F46-01,03,04,08,09,16 AC-F46-04,05,07,08 TC-03-01..03 E-F46-001,007 N-F46-01
UC-F46-04 SCR-F46-04 BR-F46-01,08,09,16 AC-F46-05,07,08 TC-04-01 E-F46-001 —
UC-F46-05 SCR-F46-04 BR-F46-01,08,09,13,16 AC-F46-05,07,08 TC-05-01,02 E-F46-001 —
UC-F46-06 SCR-F46-04 BR-F46-01,06,08,09,16 AC-F46-05,07,08,12 TC-06-01,02 E-F46-004 N-F46-04
UC-F46-07 (login gate) BR-F46-05,06,07,08,09 AC-F46-09,10,11 TC-07-01..06 E-F46-005,006 N-F46-03
UC-F46-08 SCR-F46-05 BR-F46-14 AC-F46-14 TC-08-01,02 — —
UC-F46-09 SCR-F46-04 BR-F46-01,08,09,16 AC-F46-05,07,08 TC-09-01 E-F46-001 —
(Immutable log) — BR-F46-01 AC-F46-06 TC-10-01,02 — —

16. Quy tắc nghiệp vụ (Business Rules) ​

Mã Quy tắc Giải thích / Cơ sở
BR-F46-01 Mỗi thay đổi trạng thái/dữ liệu ghi 1 bản ghi subscription_histories trong CÙNG $transaction (append-only, immutable qua trigger) Audit trail bất biến; Luật GDĐT 2023; mẫu insertPoHistory
BR-F46-02 Chỉ DN có service_status ∈ {DRAFT, EXPIRED, TERMINATED} hoặc chưa có subscription mới được tạo/kích hoạt mới Tránh double-active; nghiệp vụ thương mại
BR-F46-03 trial_days mặc định 7 (≥ 0); service_start_date mặc định hôm nay; trial_end_date = service_start_date + trial_days Chính sách dùng thử
BR-F46-04 effective_qr_quota = plan.qr_quota_year + bonus_qr_quota; effective_max_products = plan.max_products + bonus_max_products Ràng buộc TXNG-F16/F03
BR-F46-05 EXPIRED chặn login với message "Cần liên hệ sale của gotrace để kích hoạt dịch vụ" Login gate; §2.43
BR-F46-06 TERMINATED chặn login với message "Cần liên hệ với quản trị viên gotrace"; bắt buộc terminate_reason Login gate; §2.43
BR-F46-07 Lazy-expire: login thấy stored TRIAL/ACTIVE quá hạn → flip EXPIRED + ghi history + mirror + invalidate cache Không cron ở v1
BR-F46-08 Invalidate cache user_perms:{userId} của MỌI user thuộc DN khi đổi status JwtStrategy.validate() cache 10'
BR-F46-09 Mirror companies.status ↔ service_status (0..4) trong cùng transaction Chặn login rẻ; nguồn sự thật = company_subscriptions
BR-F46-10 1 subscription hiện hành/DN (partial unique company_id WHERE deleted_at IS NULL) Data integrity
BR-F46-11 contract_code unique (partial) khi không NULL Data integrity
BR-F46-12 Upload chỉ PDF/JPG/PNG ≤ 20MB; doc_type theo enum §2.47 Bảo mật file; NĐ 13/2023
BR-F46-13 Chiết khấu duy trì theo term_months: 24 tháng −10%, 36 tháng −20% (trước VAT); áp khi kích hoạt/gia hạn Sale_Pricing.md; BR-F45-06
BR-F46-14 Multi-tenant: ENTERPRISE_ADMIN/USER chỉ xem lịch sử dịch vụ của chính DN (company_id = current) Multi-tenant isolation
BR-F46-15 Optimistic lock qua version; concurrent edit → 409 Concurrent access
BR-F46-16 Kích hoạt/gia hạn/chấm dứt/mở lại chỉ PLATFORM_ADMIN (qua AdminGuard) Phân quyền
BR-F46-17 changes_json ghi before/after (ai sửa field gì) cho action UPDATE Audit chi tiết; NĐ 13/2023

17. Ràng buộc & Giả định (Constraints / Assumptions) ​

Ràng buộc:

  1. Không cron ở v1 — hết hạn (EXPIRED) đánh giá theo kiểu lazy khi user DN đăng nhập (UC-F46-07). Cron quét paid_until_date/trial_end_date nền tảng là hạng mục v1.5 (để notification N-F46-02/03 chủ động hơn).
  2. Cache invalidation — mọi chuyển trạng thái phải xóa key user_perms:{userId} của mọi user thuộc DN; nếu Redis lỗi, transaction DB vẫn commit, cache tự hết hạn ≤ 10' (fail-safe, không block nghiệp vụ).
  3. Optimistic lock — dùng cột version; không dùng pessimistic lock để tránh giữ khóa lâu.
  4. Mirror companies.status — chỉ để chặn login rẻ; nguồn sự thật là company_subscriptions.service_status. Nếu lệch (do lỗi), ưu tiên company_subscriptions; cần job đối soát định kỳ (v1.5).
  5. KHÔNG CTE/KDE — TXNG-F46 là module vận hành thương mại nội bộ, KHÔNG phát sinh Critical Tracking Event / Key Data Element và KHÔNG tham gia chuỗi truy xuất TCVN 12850:2019.
  6. Giá VND chưa gồm VAT — mọi phí trong plan_snapshot là NUMERIC, VND, chưa bao gồm VAT (BR-F45-10).
  7. Immutable audit — subscription_histories chặn UPDATE/DELETE bằng trigger; retention ≥ 2 năm (khuyến nghị ≥ 5 năm theo hồ sơ hợp đồng).
  8. SLA — GET list ≤ 500ms; write (activate/renew/terminate transaction) ≤ 1 giây; login gate thêm ≤ 50ms (đọc mirror, không JOIN).

Giả định:

  1. TXNG-F45 (service_plans) đã seed 4 gói canonical (PKG_BASIC/STANDARD/LINKED/ENTERPRISE) ở status = ACTIVE trước khi kích hoạt DN.
  2. TXNG-F01 (companies) đã có DN với company_tax_code, thông tin liên hệ đầy đủ.
  3. AuthService.login() + JwtStrategy.validate() đã có cơ chế cache user_perms:{userId} 10' để login gate hook vào.
  4. MinioStorageService + FileFieldsInterceptor sẵn sàng cho upload hồ sơ.
  5. Sale thao tác qua tài khoản PLATFORM_ADMIN; SALE_STAFF không phải actor trực tiếp.

18. Phụ lục (Appendix) ​

18.1. Bảng seed 4 gói tham chiếu (từ docs/sale/Sale_Pricing.md, đối chiếu TXNG-F45) ​

field PKG_BASIC PKG_STANDARD PKG_LINKED PKG_ENTERPRISE
plan_name Gói Cơ bản Gói Chuẩn hóa Gói Liên kết Gói Quản trị
setup_fee 2.000.000 3.000.000 6.000.000 9.000.000
standardization_fee 3.000.000 5.000.000 8.000.000 15.000.000
maintenance_fee_year 4.500.000 9.500.000 25.500.000 85.000.000
qr_quota_year 5.000 20.000 100.000 500.000
max_products 10 50 150 500
max_users 2 5 20 50
storage_gb 1 3 10 50
extra_qr_fee 150 120 100 80
extra_product_fee 490.000 490.000 490.000 490.000
training_type / onsite ONLINE / 0 ONLINE / 0 ONSITE / 1 ONSITE / 3
go_live_support_days 7 15 30 60
sla_support Trong giờ hành chính 24/7 Ưu tiên 24/7 Ưu tiên 24/7
Tổng năm đầu 9.500.000 17.500.000 39.500.000 109.000.000
Từ năm 2 (duy trì) 4.500.000 9.500.000 25.500.000 85.000.000

Tổng năm đầu = setup_fee + standardization_fee + maintenance_fee_year. Từ năm 2 = maintenance_fee_year. Chiết khấu duy trì: 2 năm −10%, 3 năm −20% (trước VAT). Giá VND, chưa gồm VAT.

18.2. Ví dụ plan_snapshot JSONB (chốt lúc kích hoạt) ​

json
{
  "plan_code": "PKG_STANDARD",
  "setup_fee": 3000000,
  "standardization_fee": 5000000,
  "maintenance_fee_year": 9500000,
  "qr_quota_year": 20000,
  "max_products": 50,
  "max_users": 5,
  "storage_gb": 3,
  "extra_qr_fee": 120,
  "extra_product_fee": 490000,
  "discount_2y_percent": 10,
  "discount_3y_percent": 20,
  "features": {
    "supply_chain_link": false,
    "recall_management": false,
    "operation_log": false,
    "api_integration": "none",
    "blockchain": "optional",
    "public_trace_page": "standard",
    "qr_scan_stats": true,
    "dashboard": true
  },
  "snapshot_at": "2026-07-01T03:00:00Z"
}

18.3. Ví dụ changes_json cho action UPDATE ​

json
{
  "term_months": { "before": 12, "after": 24 },
  "discount_percent": { "before": 0, "after": 10 },
  "bonus_qr_quota": { "before": 0, "after": 5000 }
}

18.4. Map trạng thái ↔ mirror companies.status ​

service_status companies.status Login
DRAFT 0 Cho phép (hạn chế)
TRIAL 1 Cho phép (đầy đủ)
ACTIVE 2 Cho phép (đầy đủ)
EXPIRED 3 Chặn (E-F46-005)
TERMINATED 4 Chặn (E-F46-006)

18.5. Thuật ngữ (Glossary) ​

Thuật ngữ Giải thích
Login gate Cơ chế chặn đăng nhập tại AuthService.login()/JwtStrategy.validate() dựa trên trạng thái dịch vụ DN
Lazy-expire Đánh giá & chuyển EXPIRED tại thời điểm login (không cron), khi phát hiện stored TRIAL/ACTIVE đã quá hạn
Mirror Bản sao companies.status (INT) phản chiếu service_status để chặn login nhanh, không JOIN
plan_snapshot Bản chốt phí/quota/features của gói tại thời điểm kích hoạt; DN đang dùng giữ nguyên kể cả khi gói đổi/DEPRECATED
effective quota Hạn mức thực dùng = quota gói + bonus (QR/năm, sản phẩm)

Hết tài liệu SRS TXNG-F46 v1.0.