Phân tích kiến trúc · Thanh toán & nâng gói

Luồng thanh toán & nâng gói — sơ đồ tổng quan

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ó.

Nửa đầu luồng Đã chạy thật — SSO, tạo đơn, link thanh toán
Callback thanh toán Chưa có — không mang mã đơn
Thời hạn gói Không tồn tại — gói là vĩnh viễn
Ví & số dư Thuộc Pay — đúng ranh giới, giữ nguyên

Cách đọc — quy ước dùng lại ở cả năm sơ đồ

Đã có và đang chạy trong code
Khoảng trống — thiếu hoặc bị đứt
Phần đề xuất thêm
Ghi chú neo vào file:line
Sơ đồ 1 · Component diagram

Ranh giới hệ thống: cái gì mình sửa được, cái gì phải đàm phán

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;
Điều sơ đồ này cho thấy: Panel nằm giữa hai hệ nó không sở hữu. Hai mũi tên vàng là hai chỗ thông tin bị mất khi đi qua ranh giới — Pay không trả số tiền, ERP không trả mã đơn. Mũi tên đỏ gạch rời là đường callback thanh toán chưa tồn tại; thiếu nó thì Panel không bao giờ biết chắc đơn nào đã được trả.
Sơ đồ 2 · Sequence diagram — hiện tại

Luồng đang chạy, và chỗ nó đứt

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
Điều sơ đồ này cho thấy: giữa bước khách trả tiền và bước gói được nâng không có đường nối trực tiếp nào. Vì callback không mang mã đơn, bước cuối buộc phải đoán đơn nào đã được trả bằng cách lấy đơn 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.
Sơ đồ 3 · Sequence diagram — đề xuất

Luồng đích, có cả hai nhánh số dư

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
Điều sơ đồ này cho thấy: ba lớp hạ tầng Panel cần đều đã có sẵn trong repomiddleware.ts:34 cho /api/v1/webhooks/* đi qua không cần session, webhookService.ts:11-36verifySignature 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.
Sơ đồ 4 · State machine

Vòng đời đơn nâng gói: hiện tại thiếu trạng thái kết thúc thật

Đơ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
          
Điều sơ đồ này cho thấy: hiện tại mua lần thứ hai cùng một gói sẽ bị coi là callback trùng và không cộng thêm gì, trong khi khách đã trả tiền. Thêm bước 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ụ.
Sơ đồ 5 · Class diagram

Mô hình dữ liệu: cái đang có và cái còn thiếu

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"
        
Điều sơ đồ này cho thấy: 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?
Tổng hợp

Từ sơ đồ ra việc cần làm

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