Phân tích kiến trúc · Thanh toán & nâng gói
Năm sơ đồ dựng từ source thật của vays-panel: hệ thống hiện đang làm gì, luồng nghiệp vụ
mong muốn khác ở đâu, và mỗi khoảng trống nằm ở phía ai. Mọi khẳng định đã đối chiếu code — chỗ nào
không tìm thấy thì ghi thẳng là chưa có.
Cách đọc — quy ước dùng lại ở cả năm sơ đồ
Sơ đồ quan trọng nhất trong bộ này, vì nó quyết định việc nào Panel tự làm được và việc nào bắt buộc phải có Mắt Bão đổi hợp đồng. Hai mũi tên gạch đỏ là hai thứ không thể sửa đơn phương từ phía mình.
%%{init: {"theme":"base","themeVariables":{"background":"#EFF4F8","primaryColor":"#DCE9F4","primaryBorderColor":"#3D6D96","primaryTextColor":"#122030","lineColor":"#5C7893","textColor":"#233246","fontSize":"13px","clusterBkg":"#E7EEF5","clusterBorder":"#A9BFD2"}}}%%
flowchart LR
subgraph OURS["VIBE HOST — Panel, mình kiểm soát"]
direction TB
UI["Panel UI
upgrade-plan-dialog.tsx"]
API["POST /me/plan/upgrade
prepareUpgrade()"]
AUTOM["PUT /automation/customers/plan
API key + IP allowlist"]
DB[("Postgres
Plan · PlanOrder · User")]
end
subgraph THEIRS["MẮT BÃO — không kiểm soát"]
direction TB
SSO["SSO OIDC"]
PAY["Pay
replace-service · ví · PTTT"]
ERP["ERP
sổ đơn hàng, chốt kế toán"]
end
SSO -->|"id_token"| UI
UI -->|"chọn gói"| API
API -->|"tạo đơn — serviceId + productid"| PAY
PAY -->|"orderId + name
KHÔNG có số tiền"| API
API -->|"PlanOrder = PENDING"| DB
PAY -->|"giao dịch đã thu"| ERP
ERP -->|"subscriptionRef + planCode
KHÔNG có orderId"| AUTOM
AUTOM -->|"set User.planId"| DB
ERP -.->|"THIẾU — callback thanh toán
có orderId, số tiền, chữ ký"| API
linkStyle 8 stroke:#AE3535,stroke-width:2.4px;
linkStyle 3 stroke:#B4893A,stroke-width:2px;
linkStyle 6 stroke:#B4893A,stroke-width:2px;
Nửa trên chạy thật và đã vá hai lỗ hổng bảo mật. Nửa dưới là chỗ khách trả tiền rồi mà Panel không được ai báo — gói lên được là nhờ một endpoint khác, gọi bằng dữ liệu khác.
%%{init: {"theme":"base","themeVariables":{"background":"#EFF4F8","primaryColor":"#DCE9F4","primaryBorderColor":"#3D6D96","primaryTextColor":"#122030","lineColor":"#5C7893","textColor":"#233246","fontSize":"13px","actorBkg":"#DCE9F4","actorBorder":"#3D6D96","actorTextColor":"#122030","signalColor":"#41627E","signalTextColor":"#1B2A3A","noteBkgColor":"#FBF0D6","noteBorderColor":"#B4893A","noteTextColor":"#4A3714","labelBoxBkgColor":"#E7EEF5","labelBoxBorderColor":"#A9BFD2","labelTextColor":"#233246","sequenceNumberColor":"#FFFFFF"}}}%%
sequenceDiagram
autonumber
actor U as Khách
participant P as Panel
participant S as SSO Mắt Bão
participant Y as Pay Mắt Bão
participant E as ERP Mắt Bão
actor A as Admin
U->>P: Mở dashboard
P->>S: OIDC authorize
S-->>P: id_token
Note over P,S: Đã có — auth/index.ts:46-65
U->>P: Chọn gói cao hơn, bấm Nâng cấp
P->>P: Kiểm serviceId, allowUpgrade, rank, productId, quota
rect rgb(250, 226, 226)
Note over P: THIẾU — Panel không tính và không hiện số tiền
end
P->>Y: POST replace-service — serviceId + productid
Y-->>P: errorCode 200, data id + name
rect rgb(250, 226, 226)
Note over Y,P: THIẾU — response không có số tiền, không có kỳ hạn
end
P->>P: Ghi PlanOrder PENDING theo matbaoOrderId
P-->>U: Redirect payUrl — AES-128-ECB
U->>Y: Thanh toán bằng ví, chuyển khoản hoặc QR
Note over U,Y: Panel không thấy gì trong suốt chặng này
Y->>E: Ghi nhận giao dịch đã thu
rect rgb(250, 226, 226)
Note over Y,P: ĐIỂM ĐỨT — không có callback thanh toán về Panel
end
alt Đường chính thức
E->>P: PUT automation customers plan
Note over E,P: Chỉ subscriptionRef + planCode.
Không orderId, không số tiền, không trạng thái
P->>P: CAS đổi User.planId
P->>P: Đóng đơn PENDING CŨ NHẤT — phải đoán
else Đường dự phòng
A->>P: Admin đổi gói tay
end
P-->>U: Gói sẽ được nâng trong ít phút
Note over P,U: Không có trang trạng thái đơn cho khách tự xem
PENDING cũ nhất — khách bấm Nâng cấp hai lần rồi trả đơn thứ hai là hệ đóng sai
đơn, và chỉ ghi được một dòng cảnh báo vào audit.
Giữ nguyên nguyên tắc nghiệp vụ đã chốt: mọi giao dịch đều phải tạo đơn trên Pay, kể cả khi ví đủ tiền. Phần tô xanh là những gì phải thêm — và phần lớn nằm ở chỗ Pay/ERP trả về nhiều thông tin hơn, không phải Panel làm nhiều hơn.
%%{init: {"theme":"base","themeVariables":{"background":"#EFF4F8","primaryColor":"#DCE9F4","primaryBorderColor":"#3D6D96","primaryTextColor":"#122030","lineColor":"#5C7893","textColor":"#233246","fontSize":"13px","actorBkg":"#DCE9F4","actorBorder":"#3D6D96","actorTextColor":"#122030","signalColor":"#41627E","signalTextColor":"#1B2A3A","noteBkgColor":"#FBF0D6","noteBorderColor":"#B4893A","noteTextColor":"#4A3714","labelBoxBkgColor":"#E7EEF5","labelBoxBorderColor":"#A9BFD2","labelTextColor":"#233246","sequenceNumberColor":"#FFFFFF"}}}%%
sequenceDiagram
autonumber
actor U as Khách
participant P as Panel
participant Y as Pay Mắt Bão
participant E as ERP Mắt Bão
U->>P: Chọn gói cao hơn
P->>P: Tính quote tạm tính, có bù trừ nếu Pay hỗ trợ
P->>Y: Tạo đơn — serviceId, productid, idempotencyKey
rect rgb(222, 242, 231)
Y-->>P: orderId, amount, currency, expiredAt
Note over Y,P: THÊM — số tiền quyết định do Pay trả về
end
P->>P: PlanOrder PENDING kèm amount và expiredAt
P-->>U: Hiện đúng số tiền rồi mới chuyển sang trang thanh toán
U->>Y: Mở trang thanh toán
Y->>Y: Kiểm số dư ví
alt Số dư đủ
Y-->>U: Hiện đơn và số dư khả dụng
U->>Y: Xác nhận trả bằng số dư
Y->>Y: Trừ số dư, đơn chuyển PAID
else Số dư không đủ
Y-->>U: Hiện số tiền còn thiếu
U->>Y: Nạp thêm hoặc chọn phương thức khác
Y->>Y: Thu đủ, đơn chuyển PAID
end
Y->>E: Ghi nhận giao dịch
E->>E: Lưu hoặc cập nhật đơn, chốt sổ
rect rgb(222, 242, 231)
E->>P: POST /api/v1/webhooks/matbao/payment
Note over E,P: THÊM — orderId, status, amount, paidAt,
paymentMethod, servicePeriod, signature
P->>P: Verify HMAC trên rawBody, chống replay 300s
P->>P: claimDelivery theo orderId — idempotent
end
rect rgb(222, 242, 231)
P->>P: Một transaction — đóng đơn PAID, tạo hoặc cộng nối kỳ
dịch vụ, cập nhật gói và hạn mức, ghi audit
end
P-->>U: Trang trạng thái đơn — đã kích hoạt, hạn dùng tới ngày
middleware.ts:34 cho /api/v1/webhooks/* đi qua không cần session,
webhookService.ts:11-36 có verifySignature HMAC và claimDelivery
chống replay đã tham số hoá theo provider, và PlanOrder.matbaoOrderId đã
@unique để làm khoá idempotency. Việc còn lại chủ yếu là hợp đồng với Mắt Bão.
Đơn hiện chỉ có bốn trạng thái và không ai quét những đơn treo. Quan trọng hơn: sau PAID
không có gì diễn tả việc gói còn hiệu lực tới khi nào — nên nghiệp vụ gia hạn không có chỗ để bám.
Hiện tại — schema.prisma:224-272
%%{init: {"theme":"base","themeVariables":{"background":"#EFF4F8","primaryColor":"#DCE9F4","primaryBorderColor":"#3D6D96","primaryTextColor":"#122030","lineColor":"#5C7893","textColor":"#233246","fontSize":"13px","noteBkgColor":"#FAE2E2","noteBorderColor":"#C36B6B","noteTextColor":"#5A1B1B"}}}%%
stateDiagram-v2
[*] --> PENDING: prepareUpgrade tạo đơn
PENDING --> PAID: callback tới, đoán đơn cũ nhất
PENDING --> CANCELLED: đổi tay
PENDING --> EXPIRED: đổi tay
PAID --> [*]
note right of PENDING
Không có FAILED
Không job nào quét đơn treo
Đơn mồ côi chỉ nằm ở console.error
end note
note right of PAID
Hết ở đây.
Gói không có hạn dùng
nên không gia hạn được
end note
Đề xuất — thêm kỳ dịch vụ
%%{init: {"theme":"base","themeVariables":{"background":"#EFF4F8","primaryColor":"#DEF2E7","primaryBorderColor":"#5C9E7A","primaryTextColor":"#12301F","lineColor":"#5C7893","textColor":"#233246","fontSize":"13px","noteBkgColor":"#FBF0D6","noteBorderColor":"#B4893A","noteTextColor":"#4A3714"}}}%%
stateDiagram-v2
[*] --> PENDING: tạo đơn, có amount và expiredAt
PENDING --> PAID: webhook đã verify, khớp orderId
PENDING --> FAILED: Pay báo thất bại
PENDING --> EXPIRED: quá expiredAt, job quét
PENDING --> CANCELLED: khách hoặc sales huỷ
PAID --> ACTIVATED: tạo hoặc cộng nối kỳ dịch vụ
ACTIVATED --> [*]
FAILED --> [*]
EXPIRED --> [*]
CANCELLED --> [*]
note right of ACTIVATED
Chuyển trạng thái idempotent
theo orderId, nên mua nhiều kỳ
cộng dồn đúng
end note
ACTIVATED gắn với kỳ
dịch vụ là điều kiện bắt buộc để có nghiệp vụ gia hạn — không phải một tính năng phụ.
Ba lớp bên trái tồn tại thật. Phần đánh dấu là những gì phải thêm để đỡ được luồng đích — và một lớp cố ý không xây, vì nó thuộc Pay.
%%{init: {"theme":"base","themeVariables":{"background":"#EFF4F8","primaryColor":"#DCE9F4","primaryBorderColor":"#3D6D96","primaryTextColor":"#122030","lineColor":"#5C7893","textColor":"#233246","fontSize":"13px","classText":"#122030"}}}%%
classDiagram
direction LR
class User {
+String id
+String planId
+String subscriptionRef
+String serviceId
}
class Plan {
+String id
+Int priceVND
+Int productId
+Int rank
+Boolean allowUpgrade
}
class PlanOrder {
+String id
+Int matbaoOrderId
+String planId
+PlanOrderStatus status
+DateTime paidAt
}
class OrderFields {
<>
+Int amount
+String currency
+String paymentMethod
+String erpOrderId
+DateTime expiresAt
+String failureReason
}
class ServicePeriod {
<>
+DateTime startsAt
+DateTime endsAt
+String orderId
+String status
}
class Wallet {
<>
}
User "1" --> "1" Plan : planId
User "1" --> "*" PlanOrder : đơn đã tạo
PlanOrder ..> Plan : planId, không FK
PlanOrder "1" --> "1" OrderFields : gộp cùng bảng
PlanOrder "1" --> "1" ServicePeriod : kích hoạt sinh ra kỳ
User "1" --> "*" ServicePeriod : kỳ dịch vụ
Wallet ..> PlanOrder : Pay trừ số dư
note for User "Không có cột hạn dùng gói"
note for Plan "Không có duration hay kỳ hạn"
note for PlanOrder "Sổ đối chiếu, chưa phải đơn hàng đủ nghĩa"
note for Wallet "Không xây ở Panel — ví thuộc Pay"
PlanOrder hiện là sổ đối chiếu chứ chưa phải đơn hàng —
thiếu số tiền, phương thức, mã đơn ERP và snapshot giá lúc mua. ServicePeriod là mảnh
thiếu tốn công nhất, vì nó đụng cả quota lẫn cutover dữ liệu khách V1 và cần chốt chính sách trước:
gói hết hạn thì hạ về free, khoá, hay giữ nguyên?
Xếp theo thứ tự phụ thuộc, không theo độ khó. Ba việc P0 đầu tiên là điều kiện để mọi việc sau có nghĩa.
| Ưu tiên | Việc | Ai làm | Sơ đồ liên quan |
|---|---|---|---|
| P0 | Chốt hợp đồng với Mắt Bão: replace-service có bù trừ không, response thêm được
amount / currency / expiredAt không, callback PAID do ai
gọi và mang trường nào. Không tốn dòng code nào và quyết định toàn bộ phần sau. |
BA → Mắt Bão | 1, 3 |
| P0 | Webhook thanh toán có HMAC — POST /api/v1/webhooks/matbao/payment, idempotent theo
orderId. Hạ tầng đã sẵn, thiết kế đã viết ở 04-THANH-TOAN-MATBAO.md §3.5. |
Panel | 2 → 3 |
| P0 | Mở rộng PlanOrder cho đủ nghĩa một đơn hàng: số tiền, phương thức, mã đơn ERP,
snapshot giá và tên gói lúc mua. |
Panel | 5 |
| P1 | Model kỳ dịch vụ — điều kiện bắt buộc để có gia hạn. Cần chốt chính sách hết hạn trước khi code. | BA + Panel | 4, 5 |
| P1 | Job quét đơn PENDING quá hạn, cảnh báo đơn mồ côi, và trang lịch sử nâng cấp cho
khách tự xem trạng thái đơn. |
Panel | 2, 4 |
| P1 | Đếm số khách thiếu serviceId trước go-live — nhóm này hiện không nâng gói
được, vấp SERVICE_ID_MISSING. Chạy ngay, gần như không tốn gì. |
BA | 2 |
| P2 | Bù trừ khi nâng gói giữa kỳ — chỉ làm sau khi có câu trả lời P0. Nếu Mắt Bão đã bù trừ ở phía họ thì Panel chỉ cần hiện đúng số tiền, không xây gì thêm. | Chờ P0 | 3 |