Skip to content

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. 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ừaThê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ộtTầng hai
Người nhậnngười giới thiệu trực tiếpngười giới thiệu của người giới thiệu
Tỷ lệpricing_items.referral-bonuspricing_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 ​

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

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 ​

REQSection
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