---
url: https://docs.nemo12.com/architecture/sdd-041-member-credit.md
description: >-
  Bản nháp tài khoản ảo của member (SDD-041): sổ ghi có, hoa hồng giới thiệu
  10%, số dư là tổng và chỗ chừa cho rút tiền.
---

# SDD-041 — Tài khoản ảo của member

Chỉ đạo chủ dự án 2026-09-22: *"Người đi giới thiệu được 10%, người nhận giới thiệu được giảm 10% so với giá tiêu chuẩn. 10% hoa hồng đó được cộng thêm vào tài khoản ảo của mỗi member. Việc đổi từ con số đó ra tiền mặt là một tính năng riêng, tôi sẽ làm sau."*

Yêu cầu đầy đủ: [Rủ bạn cùng học và tài khoản ảo](../product/referral-and-credit.md). Tài liệu này chỉ thiết kế phần **tài khoản ảo** (R-40..R-47, R-51); phần ghi công và mốc đã chạy và không đổi.

## 1. Việc mà tài liệu này giải

`referral_rewards` (migration 0064) đã có từ SRC-450, nhưng nó là một **sổ ý định**, không phải sổ tiền: mỗi lượt ghi công sinh một dòng `pending` với `amount_minor = NULL` — "sẽ có gì đó, chưa biết bao nhiêu". Nó đứng yên ở đó suốt vì thiếu hai thứ: một cái giá để nhân 10% vào, và một chỗ cho kết quả đi tới.

Chỉ đạo hôm nay cấp cái thứ hai. **Tài khoản ảo** là chỗ cho kết quả đi tới, và nó tháo được nút thắt mà lời hứa tiền mặt không tháo được: Nemo12 tự phát hành đơn vị này, nên nó ghi có được ngay hôm nay mà không cần cổng thanh toán, không cần tài khoản ngân hàng, không cần ai ký gì.

**Ngoài phạm vi:** quy đổi ra tiền mặt (R-46), cổng thanh toán, bảng giá. §6 nói rõ chỗ chừa cho chúng.

## 2. Nguyên tắc

1. **Số dư là kết quả của một phép cộng, không phải một ô nhớ.** §3.
2. **Một đồng vào sổ phải chỉ được nguồn của nó.** Mọi dòng ghi có mang một `source_kind` + `source_id` tra ngược được tới việc đã làm nó sinh ra.
3. **Sổ chỉ chèn, không sửa.** Ghi sai thì ghi một dòng ngược lại, không xoá dòng cũ. Một sổ sửa được là một sổ không đối soát được.
4. **Đơn vị ảo nói rõ là ảo.** Ở mọi bề mặt, nó không được trông giống số dư ngân hàng.

## 3. Vì sao số dư là tổng chứ không phải một cột

Cách rẻ nhất là thêm `users.credit_balance_minor` rồi `UPDATE … SET balance = balance + ?`. Bác bỏ, vì ba lý do xếp theo mức tốn kém:

1. **Không trả lời được "vì sao".** Người dùng thấy 240.000 và hỏi tiền ở đâu ra; một cột số không có câu trả lời nào.
2. **Sai một lần là sai mãi mãi.** Một lượt cộng chạy hai lần vì mạng đứt giữa chừng, và không có cách nào biết — con số không mang dấu vết của đường nó đã đi. Với sổ chèn thì lượt thứ hai va vào ràng buộc duy nhất và không vào được.
3. **Nó không tái lập được.** Đối soát nghĩa là tính lại từ sự kiện gốc rồi so; một cột thì không có gì để so với.

Cái giá phải trả là mỗi lần đọc số dư là một phép `SUM`. Với quy mô Nemo12 — vài nghìn member, mỗi người vài chục dòng — đây là phép cộng trên một chỉ mục, không phải một vấn đề. Ngày nó thành vấn đề thì thêm **bảng chụp** (snapshot) bên cạnh sổ, không thay sổ bằng cột: chụp sai thì dựng lại được từ sổ, cột sai thì không.

## 4. Dữ liệu — dự định, chưa dựng

Toàn bộ schema dưới đây là thứ §9 sẽ dựng; hôm nay chưa có bảng nào trong số này.

```sql
-- Sổ ghi có của member. CHỈ CHÈN.
CREATE TABLE credit_entries (
  id            TEXT PRIMARY KEY,
  user_id       TEXT NOT NULL REFERENCES users(id),
  -- Dương = vào, âm = ra. Một cột chứ không phải hai cột debit/credit: một dòng chỉ
  -- ảnh hưởng MỘT tài khoản, nên không có vế đối ứng để ghi.
  amount_minor  INTEGER NOT NULL CHECK (amount_minor <> 0),
  currency      TEXT NOT NULL DEFAULT 'VND',
  -- Vì sao dòng này tồn tại.
  --   referral_commission · 10% hoa hồng khi người được giới thiệu trả tiền
  --   referral_clawback   · thu hồi khi khoản thu ấy bị hoàn (R-47)
  --   spend               · đem tiêu vào học phí
  --   adjustment          · admin sửa tay, BẮT BUỘC có note
  reason        TEXT NOT NULL CHECK (reason IN
                  ('referral_commission','referral_clawback','spend','adjustment')),
  -- Tra ngược tới việc đã sinh ra dòng này. 'referral_reward' + id dòng trong
  -- referral_rewards, 'invoice' + id hoá đơn, 'manual' + NULL.
  source_kind   TEXT NOT NULL CHECK (source_kind IN ('referral_reward','invoice','manual')),
  source_id     TEXT,
  note          TEXT,
  created_by    TEXT REFERENCES users(id),   -- NULL = hệ tự sinh
  created_at    TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
  CHECK (reason <> 'adjustment' OR note IS NOT NULL)
);

-- Số dư của một người: phép cộng này phải là một lần quét chỉ mục, không phải quét bảng.
CREATE INDEX idx_credit_entries_user ON credit_entries(user_id, created_at);

-- MỘT nguồn sinh ĐÚNG MỘT dòng. Đây là chỗ tính bình thản nằm: lượt quét chạy lại
-- bao nhiêu lần cũng không cộng hai lần, và nó nằm ở tầng dữ liệu chứ không ở tầng
-- code nhớ kiểm tra. Partial index vì 'manual' không có nguồn để trùng.
CREATE UNIQUE INDEX idx_credit_entries_source
  ON credit_entries(source_kind, source_id, reason)
  WHERE source_id IS NOT NULL;
```

Một chỗ dễ làm sai: `referral_clawback` phải là **một `reason` riêng**, không phải một dòng `referral_commission` âm. Cùng một `(source_kind, source_id)` nhưng khác `reason` nên chỉ mục duy nhất ở trên cho cả hai cùng tồn tại — mà vẫn chỉ cho **một** lượt cộng và **một** lượt thu hồi cho mỗi khoản. Nếu dùng chung `reason` thì hoặc chỉ mục chặn mất lượt thu hồi, hoặc phải nới chỉ mục và mất luôn lá chắn chống cộng hai lần.

## 5. Đường đi của một đồng hoa hồng

```
người được giới thiệu trả tiền
        │
        ▼
referral_rewards(kind='referrer_credit')  pending ──► earned, amount_minor = giá × percent%
        │                                              percent lấy từ dòng, KHÔNG từ bảng giá
        ▼                                              hiện hành (R-41)
credit_entries(reason='referral_commission', source_kind='referral_reward',
               source_id=<reward.id>, amount_minor=+X)
        │
        ▼
referral_rewards.status = 'settled', settled_at = now
        │
        ▼
thư 'referral-credited' (R-51) — số tiền ĐỌC TỪ SỔ, không tính lại lúc soạn thư
```

Bốn điểm neo:

1. **`percent` lấy từ chính dòng `referral_rewards`**, đã chụp lúc ghi công. Đọc bảng giá hiện hành ở bước này là hồi tố chính sách lên người đã giới thiệu từ tháng trước — đúng thứ R-41 cấm.
2. **Thứ tự bắt buộc**: ghi sổ trước, đổi `status` sau. D1 không có giao dịch bắc qua nhiều lượt gọi, nên phải chọn một chiều để hỏng. Hỏng ở giữa theo chiều này = dòng đã vào sổ, `status` còn `earned` → lượt chạy sau ghi lại, va vào chỉ mục duy nhất, bỏ qua, rồi đặt `status` cho đúng. Chiều ngược lại = `settled` mà không có tiền, và **không có gì phát hiện ra được**.
3. **Thu hồi (R-47)** ghi `referral_clawback` với `amount_minor` âm bằng đúng số đã cộng, cùng `source_id`. Số dư âm được phép tồn tại: một số dư bị chặn ở 0 là một số dư nói dối về khoản còn nợ.
4. **`spend`** trừ khi số dư được áp vào hoá đơn. Chưa có cổng thanh toán nên hiện là thao tác admin với `source_kind='invoice'`.

## 6. Chỗ chừa cho việc rút tiền

Rút tiền ngoài phạm vi (R-46), nhưng sổ phải dựng sao cho thêm nó sau này không phải viết lại. Ba chỗ đã chừa:

| Chỗ chừa | Thêm gì khi tới lúc |
| --- | --- |
| `reason` là `CHECK (… IN …)` | Thêm `'payout'` (âm) và `'payout_reversed'` |
| `source_kind` tương tự | Thêm `'payout_request'` |
| Số dư luôn là tổng của sổ | Bảng `credit_payouts` mới chỉ cần sinh dòng âm; không đụng cách tính số dư |

Thứ **không** chừa, và có chủ ý: chuyển số dư giữa hai người. Cho chuyển thì nó thành tiền tệ, và kéo theo cả một lớp nghĩa vụ pháp lý mà một sản phẩm học tập không nên gánh nhầm.

## 6b. Tầng hai: ghi vào sổ thế nào (SRC-1026)

Hoa hồng gián tiếp 2% đi **cùng một đường** với tầng một: một dòng `referral_rewards`, rồi một dòng `credit_entries` với `source_kind = 'referral_reward'`. Khác đúng hai chỗ:

| | Tầng một | Tầng hai |
| --- | --- | --- |
| Người nhận | người giới thiệu trực tiếp | người giới thiệu của người giới thiệu |
| Tỷ lệ | `pricing_items.referral-bonus` | `pricing_items.referral-bonus-l2` |

Tìm người tầng hai: `referral_attributions` đã đủ. Người mới `X` có `referrer = Y`; tra tiếp `Y` trong chính bảng ấy ra `Z` — `Z` là người nhận 2%. `referred_user_id` là PRIMARY KEY nên phép tra này trả về đúng một người hoặc không ai, và không có vòng lặp nào để lo.

**Dừng ở tầng hai, và dừng bằng CÂU TRUY VẤN chứ không bằng một biến đếm.** Một hàm đệ quy có `depth` là một hàm mà ngày nào đó có người sửa `depth` lên 3; hai lượt `JOIN` viết thẳng thì tầng ba không tồn tại để mà sửa.

**Thu hồi (R-47) phải kéo theo cả hai tầng.** Người mới đòi lại tiền thì cả 10% lẫn 2% đều bị rút. Bỏ sót tầng hai ở đây là một lỗ rò tiền không ai thấy, vì nó chỉ hiện ra ở những lượt hoàn tiền — thứ hiếm, và vì hiếm nên không ai đi kiểm.

## 7. API

| Route | Trả về |
| --- | --- |
| `GET /v1/credit/me` | `{ balance_minor, currency, entries: [{ amount_minor, reason, note, created_at }] }` — 50 dòng gần nhất |
| `GET /v1/referral/me` | Thêm `credit_balance_minor` để trang giới thiệu hiện số dư mà không phải gọi hai lượt |

Sổ của một người **chỉ người ấy đọc được**. Không có route nào cho người giới thiệu đọc sổ của người được giới thiệu: người giới thiệu thấy *mốc* của người kia (R-30), không thấy tiền của người kia.

## 8. Bề mặt người dùng

Trên `learn.nemo12.com/referral` và `marlins.nemo12.com/referral`, một khối thêm dưới phần hiện có:

* Số dư, kèm **một câu nói rõ nó là gì**: dùng để trừ vào học phí Nemo12, chưa quy đổi ra tiền mặt. Câu này không phải chú thích nhỏ — nó là thứ ngăn một người tưởng mình có 240.000 đồng trong tay.
* Lịch sử: ngày · vì sao · số tiền. Mỗi dòng đọc được thành một câu tiếng Việt, không phải một mã `reason`.
* Chưa có dòng nào thì nói thẳng chưa có, và vì sao (chưa ai được giới thiệu trả tiền) — không hiện `0 ₫` trần trụi.

Chữ tiếng Việt (luật ngôn ngữ), kể cả với learner IELTS: đây là chữ Nemo **nói về** tiền của learner, không phải nội dung luyện thi.

## 9. Việc phải làm

| # | Việc | Ghi chú |
| --- | --- | --- |
| 1 | Migration: dựng bảng `credit_entries` (chưa dựng) + hai chỉ mục | Cấp số bằng `npm run migration:new` |
| 2 | Migration: `pricing_items` mã `referral-bonus` từ `percent = 15` → `10` | Không hồi tố: dòng `referral_rewards` đã sinh giữ `percent` cũ (R-41) |
| 3 | Module `workers/api/src/modules/credit/` — service + route §7 | Test hành vi theo skill `api-test`, phủ cả nhánh chạy hai lần |
| 4 | Nối `referral_rewards` → sổ theo §5 | Chưa có sự kiện "trả tiền" nên chỗ gọi còn để trống, có chủ ý |
| 5 | Khối giao diện §8 ở cả hai app | |
| 6 | Thư `referral-credited` (R-51) | Theo trần thư ở [emails.md](../reference/emails/index.md) |

Việc 2 đã xong: migration 0277 hạ hoa hồng xuống 10%, migration 0278 dựng `membership_pricing` với ba mức (20 triệu mức thường, 10 triệu tới hết 30.09.2026, 15 triệu tới hết 31.12.2026) và hạ cờ toàn bộ `band_pricing`. Ba mức ấy **không còn bán** từ 25.09.2026 (SRC-1036, migration 0293): học phí nay là 1.800.000đ/tháng, đóng 3 tháng một lần — xem [SDD-029 §11.8.3](sdd-029-public-site-ia/index.md) và [§7.0 của referral-and-credit](../product/referral-and-credit.md).

Việc 4 là chỗ tài liệu này chạm trần: **giá đã có, nhưng sự kiện "người được giới thiệu trả tiền" thì chưa** — Nemo12 chưa có cổng thanh toán (Q-015). Nên `amount_minor` còn chưa tính được, và sổ còn trống dù mọi thứ quanh nó đã đứng. Đây là cố ý, cùng lý lẽ với việc bảng ghi công phải có trước bảng giá ở SRC-450: attribution không làm bù được, còn số tiền thì làm bù được.

Khi có cổng thanh toán, phép tính là: `amount_minor = <số tiền thực trả> × percent / 100`, với `percent` lấy từ chính dòng `referral_rewards` (§5 điểm 1) và `<số tiền thực trả>` là số SAU khi đã trừ cả ưu đãi đợt lẫn 10% giới thiệu.

## Trace

| REQ | Section |
| --- | --- |
| REQ-GRW-03 (R-40, R-41) | §5 |
| REQ-GRW-03 (R-42, R-43) | §3, §4 |
| REQ-GRW-03 (R-44) | §5 điểm 4 |
| REQ-GRW-03 (R-45) | §5 điểm 4, §9 |
| REQ-GRW-03 (R-46) | §6 |
| REQ-GRW-03 (R-47) | §4, §5 điểm 3 |
| REQ-GRW-03 (R-51) | §5, §9 |
