---
url: https://docs.nemo12.com/architecture/sdd-046-partner-program.md
description: >-
  SDD-046 chương trình đối tác partners.nemo12.com: kiến trúc, migration
  0304/0305, mở lại 10.10.2026 với cờ PARTNERS_ENABLED (SRC-1332).
---

# SDD-046 - Chương trình đối tác bán hàng

Yêu cầu: [PRD-006](../product/prd-006-partners.md). Đã hiện thực 27.09.2026 theo câu trả lời của chủ dự án (PRD-006 §0). Code: `workers/api/src/modules/partners`, `apps/partners`, `apps/web/src/partnerTouch.ts`, `apps/admin/src/pages/Partners.tsx`, migration 0304. Chỗ bản hiện thực khác bản nháp dưới đây: xem §7.

## 1. Quyết định chính

1. **Hệ riêng, không mở rộng referral learner.** Bảng `partner_*` riêng, module API
   `workers/api/src/modules/partners` riêng, app `apps/partners` riêng. Chỉ dùng chung: phiên đăng
   nhập (`/v1/auth/google`, cookie `*.nemo12.com`), bảng giá `pricing_items`, sự kiện thanh toán.
   Lý do: luật của referral learner (R-25 cùng vai, tài khoản ảo) đang bảo vệ trẻ con và sổ tiêu
   nội bộ; nới chúng cho người bán là mở lỗ ở hệ đang chạy.
2. **Một người mua, một nguồn** (P-22). Hai câu đọc chéo: ghi công đối tác hỏi
   `referral_attributions`, ghi công referral learner hỏi `partner_attributions`. Ai ghi trước thắng.
3. **Sổ hoa hồng chỉ chèn** như SDD-041 §2: số dư = `SUM`, hoàn tiền = dòng âm.
4. **Không lộ danh tính người mua** (P-52): API bảng điều khiển trả `order_ref` băm, ngày, sản
   phẩm, số tiền; không join sang `users`/`learners`.

## 2. Kiến trúc

```
nemo12.com/ielts?p=ABC123&c=fb-thang10   (trang công khai, đã có)
   │  lưu {p,c,ts} vào cookie .nemo12.com 30 ngày + localStorage (sống qua OAuth)
   ▼
POST /v1/partners/clicks            (không cần phiên; đếm lượt bấm, rate-limit theo IP)
   ▼  đăng nhập Google lần đầu
POST /v1/auth/google  ── hook attribution ──►  partner_attributions (PK buyer_user_id)
   ▼  thanh toán được xác nhận (membership/SAT/Speaking)
event payment.confirmed ──► partner_commissions (+) , hold_until = paid_at + N ngày
event payment.refunded  ──► partner_commissions (−)
   ▼
partners.nemo12.com  (apps/partners: Vite + React + shadcn + Tailwind + motion)
admin.nemo12.com/partners (duyệt, trả tiền)
```

## 3. Dữ liệu (migration 0304)

| Bảng | Việc | Ràng buộc giữ luật |
| --- | --- | --- |
| `partners` | Hồ sơ, mã, tầng trên, tài khoản ngân hàng | `UNIQUE(user_id)`, `UNIQUE(code)`, `CHECK(upline <> id)` |
| `partner_campaigns` | Nhãn chiến dịch theo dòng sản phẩm | `UNIQUE(partner_id, slug)` |
| `partner_clicks` | Đếm lượt bấm, không lưu IP | |
| `partner_attributions` | Người mua thuộc đối tác nào | `PRIMARY KEY(buyer_user_id)`: first touch trọn đời |
| `partner_sales` | Khoản người mua thực trả | `PRIMARY KEY(payment_ref)`: ghi hai lần không thành hai khoản |
| `partner_commissions` | Sổ hoa hồng chỉ chèn; hoàn = dòng âm | `UNIQUE(payment_ref, tier, kind)`, tỷ lệ chụp `rate_bp` |
| `partner_payouts` | Yêu cầu rút, ngày trả, mã chuyển khoản | chỉ mục duy nhất một phần: một `requested` mỗi đối tác |
| `partner_rates` | Tỷ lệ theo (dòng sản phẩm, tầng), basis point | seed 1000 / 200 |

Con số tiền (30 ngày, 5tr, 3tr, 1tr, ngày 5) ở `workers/api/src/modules/partners/policy.ts`.

## 4. API

| Route | Phiên | Việc |
| --- | --- | --- |
| `POST /v1/partners/clicks` | không | Đếm lượt bấm (rate-limit) |
| `POST /v1/partners/apply` | user | Đăng ký, tự duyệt, nhận `upline_code` |
| `GET /v1/partners/me` | user | Hồ sơ, chính sách, số dư, phễu, đơn ẩn danh, lịch sử rút |
| `POST /v1/partners/me/campaigns` | đối tác | Tạo chiến dịch |
| `PUT /v1/partners/me/bank` | đối tác | Khai ngân hàng |
| `POST /v1/partners/me/payouts`, `.../{id}/cancel` | đối tác | Yêu cầu / huỷ rút |
| `GET /v1/admin/partners` | admin | Đối tác + yêu cầu rút chờ trả |
| `POST /v1/admin/partners/{code}/status` | admin | Khoá / mở khoá |
| `POST /v1/admin/partners/sales`, `.../sales/{ref}/refund` | admin | Ghi khoản thu / hoàn |
| `POST /v1/admin/partners/payouts/{id}/paid` | admin | Đánh dấu đã trả |

Ghi công nằm trong `/v1/auth/google`, ngay sau khi tạo phiên; hỏng thì chỉ log, không chặn đăng nhập.

## 5. Giao diện `apps/partners`

Từ SRC-1335 (chủ dự án 11.10.2026) khu đối tác dùng **design system của www**: nạp `index.css` +
`soft.css`, `theme-soft n12-v1` trên `<body>`, nền sáng, thanh trên sẫm `n12-nav-deep`, Card / Badge /
Table / Label là bản sao canonical của `packages/design-system/shadcn`. Nền "biển sâu" cũ bỏ hẳn.

Sáu trang, mỗi trang một URL (`src/router.tsx`, SPA fallback ở `wrangler.jsonc`):

| URL | Trang | Nội dung |
| --- | --- | --- |
| `/` | Tổng quan | Số dư (rút được ngay nổi bật nhất), lượt bấm / người mua / doanh số, bảng theo sản phẩm, ba lối tắt |
| `/links` | Link | Link theo sản phẩm, link chiến dịch kèm số liệu, link mời đối tác |
| `/orders` | Đơn hàng | Bảng khoản hoa hồng ẩn danh, nhãn trạng thái |
| `/payouts` | Rút tiền | Số dư, gửi yêu cầu rút, tài khoản nhận tiền, lịch sử |
| `/kit` | Tài liệu | Bài mẫu theo sản phẩm, gắn sẵn link |
| `/help` | Hỏi đáp | Câu hỏi thường gặp, chính sách |

Link `?tab=overview|links|money|kit` của bản một trang (thư đã gửi) được đổi sang URL mới bằng
`history.replaceState`. Chưa đăng nhập → màn giới thiệu chương trình + nút Google. Đã đăng nhập mà
chưa là đối tác → form đăng ký trong một thẻ.

## 6. Kiểm chứng

* `api-test` trên D1 thật: ghi công, một nguồn, tự bán, hoàn tiền, chạy lại sự kiện hai lần,
  quyền (partner A không đọc được của B; learner không vào được `/me/stats`).
* e2e Playwright cho `apps/partners` (375px + desktop).

## 7. Chỗ bản hiện thực khác bản nháp

* **Không có bước duyệt**: `partners.status` chỉ còn `active | suspended`.
* **Không có bảng nguồn chung**: luật một nguồn giữ bằng hai câu đọc chéo (§1.2). Đủ cho hai
  nguồn; nguồn thứ ba xuất hiện thì mới đáng một bảng chung.
* **Ghi công bằng cookie `n12_partner` trên `.nemo12.com`**, đọc ngay trong `/v1/auth/google`.
  localStorage không dùng được vì người mua bấm link ở `nemo12.com` nhưng đăng nhập ở `learn`/`marlins`.
* **Tỷ lệ ở `partner_rates`**, không ở `pricing_items`: bảng sau được trang công khai đọc.
* **Khoản thu ở `partner_sales`**, do admin ghi (`POST /v1/admin/partners/sales`) vì chưa có cổng
  thanh toán; cổng về sau gọi cùng `recordSale()`.
* **Rút tiền**: `available = hoa hồng đã qua 30 ngày - (đang chờ trả + đã trả)`; rút tối đa
  `available - 1.000.000` khi `available ≥ 5.000.000`, mỗi lần ≥ 3.000.000; chỉ mục duy nhất
  một phần giữ mỗi đối tác một yêu cầu `requested`. Hoàn tiền sau khi đã rút làm `available` âm và
  trừ vào hoa hồng về sau.

## 8. Giảm giá người mua và thuế TNCN (migration 0305)

* `partner_attributions.buyer_discount_bp` chụp 10% lúc ghi công. `referral.summary().my_discount`
  trả luôn ưu đãi này khi người dùng không có ưu đãi referral, nên trang học phí learn và hai trang
  giới thiệu hiện nó mà không đổi giao diện. Admin tra `GET /v1/admin/partners/buyer?email=` trước
  khi ghi khoản thu, và ghi số tiền SAU giảm.
* `partner_payouts` thêm `tax_rate_bp`, `tax_withheld_vnd`, `net_amount_vnd`, `tax_id`, chụp lúc
  yêu cầu rút. Số rút là số gộp và trừ vào số dư đủ cả thuế. `partners.tax_id` bắt buộc trước khi
  rút. `GET /v1/admin/partners/tax-report?year=` tổng theo người cho tờ khai.

## 9. Mở lại và cô lập (10.10.2026, SRC-1332)

Ngày 27.09.2026 cả tính năng bị revert khỏi main (commit `8fa8544c`) để nó không ảnh hưởng việc
deploy tính năng khác. Revert chỉ gỡ phía API: app `nemo12-partners` đã deploy vẫn sống, nên
partners.nemo12.com gọi `/v1/partners/me` và nhận 404 "Không tìm thấy tài nguyên". Bài học:
**tạm dừng một tính năng có giao diện riêng mà chỉ gỡ API là để lại một màn hỏng ngoài production.**

Bản mở lại khôi phục nguyên PR #203 và thêm bốn chỗ:

| Chỗ | Việc |
| --- | --- |
| Cờ `PARTNERS_ENABLED` | `"0"` thì mọi route `/v1/partners/*`, `/v1/admin/partners*` trả 503 "đang tạm dừng", và đăng nhập bỏ qua bước ghi công. Tạm dừng lần sau đổi một biến, không revert. Test: `partners/routes.test.ts` |
| Hai điểm chạm vào hệ đang chạy | Ghi công trong `/v1/auth/google` bọc `catch`, hỏng chỉ log; `partnerTouch` trên nemo12.com bọc `try`, gọi đếm lượt bấm không chờ. Cả hai không làm hỏng đăng nhập hay trang công khai |
| Link Speaking | Trỏ `/speak` (trang chương trình có từ sau 27.09). `productOf()` nhận cả `/speak` lẫn `*-speaking*` |
| Màn đối tác | Số liệu từng chiến dịch trên từng link, mục hỏi đáp (PRD-006 §7), lỗi tải có nút thử lại |

App `partners` deploy theo đường riêng trong `.github/app-paths.json`, nên một lỗi chỉ ở
`apps/partners` không chặn app khác. Module API vẫn nằm trong worker chung: test của nó đỏ thì
deploy API đỏ, như mọi module khác.

### 9a. Làm nốt cùng ngày

| Chỗ | Việc |
| --- | --- |
| Nhập mã tay | `POST /v1/referral/claim`: mã không phải của learner thì thử `attributeBuyer()` của đối tác, cùng luật (một nguồn, không tự bán, chỉ người mới), không có cửa sổ 30 ngày. Ô nhập ở thẻ "Your offers" trang `/ielts/membership` |
| Thư đối tác | `email/campaignsPartners.ts` chạy cùng cron 19:00 với thư giới thiệu, khoá riêng `partner-mail`. Bốn lá `partner-welcome`, `partner-first-push`, `partner-sale`, `partner-paid`; chống trùng bằng `dedupeKey`, cửa sổ 14 ngày, cờ `PARTNERS_ENABLED="0"` tắt cả lượt |
| Tài liệu | Trang "Tài liệu" (bài mẫu ở `apps/partners/src/kitPosts.ts`), từ SRC-1335 có URL riêng `/kit` |
| Admin | `adminPartners()` thêm `clicks`, `clicks_30d`; thẻ chỉ số kích hoạt trên trang Đối tác |
