Chương trình Giới thiệu Yoyacoo — Đặc tả
Last updated: 2026-07-07
Status: Đã xác nhận (ghi đè mọi thảo luận trước đó trên Slack) — bao gồm (a) sửa quy tắc hết hạn (ticket của người giới thiệu không bao giờ hết hạn; ticket của người được giới thiệu hết hạn sau 90 ngày kể từ ngày cấp), (b) sửa quy tắc nâng cấp khi có ticket (tính theo phần trả phí; nâng cấp miễn phí khi đang ở giai đoạn bonus; các tháng bonus được giữ nguyên), và © hạ gói theo lịch (hạ gói được lên lịch cho billing_use_end_date; người dùng giữ gói hiện tại và mọi bonus đang hoạt động cho đến thời điểm đó)
1. Tổng quan
1.1 Mục đích
Tăng tốc độ thu hút người dùng mới của Yoyacoo và chuyển đổi những người dùng đó thành khách hàng trả phí.
1.2 Phạm vi
Trao thưởng cho cả người dùng hiện tại (người giới thiệu) và người dùng mới (người được giới thiệu) khi người được giới thiệu đăng ký thông qua liên kết giới thiệu của người giới thiệu.
1.3 Ngoài phạm vi (không triển khai trong phiên bản này)
- Trường nhập mã mời (chỉ dùng liên kết)
- Thưởng tiền mặt / hoa hồng đại lý
- Tính năng xếp hạng
- Phát hiện gian lận phức tạp
- Email giới thiệu tự động
- Văn bản chia sẻ mạng xã hội được tạo tự động
- Màn hình quản trị chỉ dành cho đại lý
- Chuyển đổi gói Yearly → Monthly (hệ thống hiện tại không hỗ trợ)
2. Nguyên tắc thiết kế cốt lõi
2.1 Mọi phần thưởng được thống nhất dưới dạng “thanh toán + gia hạn chu kỳ”
Mọi phần thưởng được áp dụng theo cùng một cách, bất kể người dùng là người giới thiệu/người được giới thiệu hay đang dùng gói monthly/yearly:
- Đẩy
user_plans.billing_use_end_datecủa người giới thiệu về phía trước - Đồng bộ
current_period_endcủa Stripe Subscription - Không có giao dịch thanh toán thực tế nào xảy ra (KHÔNG sử dụng Stripe Coupon)
Các phương pháp như “tháng đầu miễn phí” hoặc “giảm giá %” qua Stripe bị loại bỏ rõ ràng.
2.2 Hai bộ đếm hoàn toàn độc lập
A. Bộ đếm đăng ký miễn phí: +1 tháng cho mỗi 3 lượt giới thiệu. Giới hạn tồn kho là 1 (không cấp ticket mới khi vẫn còn ticket đang giữ). Đặt lại về 0 sau khi ticket đang giữ chuyển trạng thái thành consumed hoặc voided. Không có cơ chế đặt lại do hết hạn — ticket của người giới thiệu không bao giờ hết hạn (xem §4).
B. Bộ đếm chuyển đổi trả phí: +1 tháng cho mỗi lượt chuyển đổi trả phí. Không giới hạn, không hết hạn trên bộ đếm. Tại đúng 5 lượt chuyển đổi trả phí, thêm +7 tháng được cấp (5 thường + 7 bonus = 12 tổng). Phần thưởng mốc 5 người chỉ cấp một lần; từ người thứ 6 trở đi, chỉ áp dụng quy tắc +1 tháng tiêu chuẩn.
Quan trọng: Không được kết hợp phán đoán. Mốc “5 người” chỉ sử dụng Bộ đếm B. Kết hợp kiểu “3 đăng ký miễn phí + 2 chuyển đổi trả phí = 5” KHÔNG được tính.
2.3 Phần thưởng cho người được giới thiệu
Người được giới thiệu đăng ký qua liên kết giới thiệu và đăng ký gói trả phí trong vòng 90 ngày kể từ ngày đăng ký sẽ nhận được ticket +1 tháng.
- Cửa sổ 90 ngày được tính động từ ngày đăng ký (không hard-code ngày).
3. Thời điểm áp dụng
| Trạng thái người giới thiệu | Phương thức áp dụng |
|---|---|
| Free / Trial | Tích lũy nội bộ. Tự động áp dụng khi nâng cấp trả phí lần đầu. |
| Monthly | Đẩy ngày thanh toán tiếp theo về sau. |
| Yearly | Đẩy ngày gia hạn về sau (hủy + tạo lại subscription tại thời điểm gia hạn để kéo dài mốc chu kỳ). |
4. Quy tắc hết hạn
Thời hạn được phân chia theo người giữ ticket — ticket của người giới thiệu không bao giờ hết hạn; ticket của người được giới thiệu thì có.
| Nguồn ticket | Người giữ | expires_at |
Thông báo trước hết hạn (14 ngày / 3 ngày) |
|---|---|---|---|
free_signup_bonus |
Người giới thiệu (Bộ đếm A) | NULL — không bao giờ hết hạn |
Không |
paid_conversion |
Người giới thiệu (Bộ đếm B) | NULL — không bao giờ hết hạn |
Không |
milestone_bonus |
Người giới thiệu (mốc 5 trả phí) | NULL — không bao giờ hết hạn |
Không |
referee_reward |
Người được giới thiệu (+1 tháng) | granted_at + 90 ngày |
Có (chỉ người được giới thiệu) |
| Phần mở rộng đã áp dụng | n/a | n/a — không hết hạn | n/a |
Cửa sổ đủ điều kiện của người được giới thiệu (§2.3) không thay đổi: người được giới thiệu phải đăng ký gói trả phí trong vòng 90 ngày kể từ ngày đăng ký để nhận ticket. Sau khi được cấp, ticket referee_reward có vòng đời 90 ngày riêng tính từ granted_at.
Thông báo trước khi hết hạn: Gửi 14 ngày và 3 ngày trước khi hết hạn qua email + banner trong app. Các thông báo này chỉ kích hoạt cho ticket referee_reward. Ticket của người giới thiệu bị loại trừ về mặt cấu trúc (vì expires_at = NULL).
Quy ước lưu trữ: referral_tickets.expires_at có thể null. NULL về mặt ngữ nghĩa nghĩa là “không bao giờ hết hạn”. Mọi truy vấn batch và thông báo phải bảo vệ bằng expires_at IS NOT NULL trước bất kỳ so sánh khoảng nào.
5. Hành vi khi thay đổi gói (Cuối cùng)
| Mẫu thay đổi | Hành vi |
|---|---|
| Monthly → Yearly | Đẩy ngày gia hạn gói yearly về sau bằng số tháng bonus còn lại. |
| Yearly → Yearly (gia hạn) | Áp dụng toàn bộ tồn kho đang giữ vào cuối năm trong một lần. |
| Yearly → Monthly | Ngoài phạm vi (hệ thống hiện tại không hỗ trợ). Sau khi yearly + bonus được sử dụng hết, người dùng phải đăng ký lại hoặc rơi về Free. |
| Lite → Standard (nâng cấp) | Tính theo phần trả phí còn lại (max(0, paid_period_end − now)). Nếu paid_remaining = 0 (người dùng đang ở giai đoạn chỉ bonus), nâng cấp miễn phí. Các tháng bonus được giữ nguyên trên gói mới — xem §6.6 và công thức bên dưới. |
| Standard → Lite (hạ gói) | Lên lịch cho billing_use_end_date. Người dùng giữ gói hiện tại (và mọi bonus đang hoạt động) cho đến khi kết thúc kỳ; gói mới (thấp hơn) có hiệu lực vào chu kỳ thanh toán tiếp theo. Không hoàn tiền — người dùng được sử dụng những gì đã trả. Ticket đang giữ tiếp tục áp dụng trên gói mới tại thời điểm gia hạn. |
| Lite ⇔ Business (tương lai) | Giống trên. |
| → Free (hạ gói) | Tồn kho chưa sử dụng biến mất khi kết thúc hợp đồng. |
| Standard → Free (hủy) | Tồn kho biến mất khi kết thúc kỳ, kể cả khi đang trong giai đoạn bonus. |
Công thức giá nâng cấp (chỉ phần trả phí):
paid_remaining = max(0, paid_period_end − now) // tháng bonus_remaining = max(0, billing_use_end_date − max(now, paid_period_end)) // tháng amountOff = (new_price_per_month − old_price_per_month) × paid_remaining nếu amountOff ≤ 0: nâng cấp miễn phí — đổi gói, không thu phíSau khi nâng cấp:
- Nếu
paid_remaining > 0:new_paid_period_end = old_paid_period_end(không đổi).new_billing_use_end_date = old_billing_use_end_date(không đổi). Cấu trúc kỳ (trả phí + bonus) được bảo toàn.- Nếu
paid_remaining = 0(nâng cấp miễn phí):new_paid_period_end = max(now, old_paid_period_end).new_billing_use_end_date = new_paid_period_end + bonus_remaining. Các tháng bonus chuyển sang gói mới.Ticket KHÔNG bị consume khi nâng cấp — chúng vẫn được giữ cho chu kỳ tiếp theo. Nâng cấp chỉ ảnh hưởng đến kỳ hiện tại; ticket đang giữ tiếp tục áp dụng tại webhook gia hạn như trước.
6. Mô hình dữ liệu
6.1 Bổ sung vào bảng users
ALTER TABLE users ADD COLUMN referral_code VARCHAR(20) UNIQUE NOT NULL;
ALTER TABLE users ADD COLUMN referrer_id BIGINT UNSIGNED NULL;
ALTER TABLE users ADD INDEX idx_users_referrer (referrer_id);
ALTER TABLE users ADD INDEX idx_users_referral_code (referral_code);
referral_code: Tự động tạo khi user được tạo (ví dụ:base62_encode(random_bytes(8))≈ 14 ký tự).referrer_id: Được đặt khi đăng ký qua liên kết giới thiệu. Ngăn tự giới thiệu: nếu referrer_id == user_id, đặt thành NULL.
6.2 Bảng referrals (mới)
Log bất biến. Quan hệ một-một (một người được giới thiệu có đúng một người giới thiệu).
| Cột | Kiểu | Mô tả |
|---|---|---|
| id | BIGINT PK | |
| referrer_user_id | BIGINT FK users.id | Người giới thiệu |
| referee_user_id | BIGINT FK users.id UNIQUE | Người được giới thiệu (1:1) |
| registered_at | TIMESTAMP | Thời điểm đăng ký của người được giới thiệu |
| first_paid_at | TIMESTAMP NULL | Thời điểm hoàn tất thanh toán trả phí đầu tiên |
| first_paid_invoice_id | VARCHAR(255) NULL | ID hóa đơn Stripe |
| source | ENUM(‘link’) | Kênh giới thiệu (MVP: chỉ liên kết) |
| created_at, updated_at | TIMESTAMP |
Ràng buộc UNIQUE: (referee_user_id) UNIQUE — một người được giới thiệu chỉ có một người giới thiệu.
6.3 Bảng referral_tickets (mới)
Thực thể ticket thống nhất. Tất cả loại phần thưởng dùng chung bảng này, phân biệt bằng source.
| Cột | Kiểu | Mô tả |
|---|---|---|
| id | BIGINT PK | |
| user_id | BIGINT FK users.id | Người giữ ticket |
| source | ENUM | Xem bên dưới |
| status | ENUM | ‘available’, ‘consumed’, ‘voided’, ‘expired’ |
| granted_at | TIMESTAMP | Thời điểm cấp |
| expires_at | TIMESTAMP NULL | Thời điểm hết hạn. NULL cho ticket của người giới thiệu (free_signup_bonus, paid_conversion, milestone_bonus) — không bao giờ hết hạn. Chỉ referee_reward có giá trị, đặt bằng granted_at + 90 ngày. |
| consumed_at | TIMESTAMP NULL | Thời điểm sử dụng |
| consumed_on_user_plan_id | BIGINT NULL | user_plans.id mà ticket được áp dụng |
| notify_14d_sent | BOOLEAN DEFAULT FALSE | Cờ thông báo trước hết hạn |
| notify_3d_sent | BOOLEAN DEFAULT FALSE | Cờ thông báo trước hết hạn |
| metadata | JSON NULL | Thông tin thêm theo nguồn |
Các giá trị source:
referee_reward— +1 tháng cho người được giới thiệu (nâng cấp trả phí trong 90 ngày kể từ đăng ký)free_signup_bonus— Thưởng 3 lượt giới thiệupaid_conversion— +1 tháng cho người giới thiệu (lần thanh toán trả phí đầu tiên của người được giới thiệu)milestone_bonus— 7 tháng thưởng thêm tại mốc 5 lượt chuyển đổi trả phí
Indexes: (user_id, status, expires_at), (status, expires_at) (cho batch hết hạn), (user_id, source) (cho tổng hợp).
6.4 Bảng referral_stats (mới)
Các bộ đếm phi chuẩn hóa để truy vấn dashboard nhanh.
| Cột | Kiểu | Mô tả |
|---|---|---|
| user_id | BIGINT PK FK users.id | Người giới thiệu |
| total_referred | INT DEFAULT 0 | Tổng lượt giới thiệu (cho Bộ đếm A) |
| total_paid_conversions | INT DEFAULT 0 | Tổng lượt chuyển đổi trả phí (cho Bộ đếm B) |
| free_signup_bonus_count | INT DEFAULT 0 | Số thưởng đã cấp (A: 0 hoặc 1) |
| milestone_5_issued | BOOLEAN DEFAULT FALSE | Cờ thưởng mốc 5 người |
| updated_at | TIMESTAMP |
6.5 Bảng referral_settings (mới)
Lưu văn bản chia sẻ dùng cho nút copy, thiết kế để có thể thay thế trong tương lai.
| Cột | Kiểu | Mô tả |
|---|---|---|
| id | INT PK | Luôn là 1 (singleton) |
| share_text | TEXT | Văn bản định sẵn, chứa placeholder {referral_link} |
| created_at, updated_at | TIMESTAMP |
6.6 Bổ sung vào bảng user_plans
Một cột mới để theo dõi ranh giới giữa phần trả phí và phần bonus từ ticket. Đây là điều kiện cần để có hành vi nâng cấp-với-ticket (tính theo phần trả phí, giữ nguyên bonus).
ALTER TABLE user_plans ADD COLUMN paid_period_end TIMESTAMP NOT NULL
COMMENT 'Kết thúc phần trả phí. bonus = billing_use_end_date − paid_period_end';
| Cột | Kiểu | Mô tả |
|---|---|---|
paid_period_end |
TIMESTAMP NOT NULL | Ngày quyền truy cập kết thúc nếu không có ticket nào được áp dụng. billing_use_end_date là ngày kết thúc thực tế (trả phí + bonus). Bất biến: paid_period_end ≤ billing_use_end_date. |
Backfill (các hàng user_plans hiện có trước chương trình giới thiệu): paid_period_end = billing_use_end_date (chưa có bonus nào được áp dụng trong lịch sử; hai cột bằng nhau).
Quy tắc cập nhật (xem §5 để biết luồng nâng cấp đầy đủ):
| Sự kiện | paid_period_end |
billing_use_end_date |
|---|---|---|
| Đăng ký mới (không có ticket) | now + period_length |
giống |
| Ticket hàng tháng được sử dụng | không đổi | + 1 tháng mỗi ticket |
| Batch yearly tại gia hạn | + 12 tháng (năm trả phí mới) |
paid_period_end + ticket_months |
| Chuyển Monthly → Yearly | now + 12 tháng |
paid_period_end + held_ticket_months |
| Gia hạn Yearly → Yearly (không có ticket) | + 12 tháng |
giống |
| Nâng cấp (bất kỳ cấp nào) | không đổi (hoặc đặt bằng now nếu chỉ bonus — xem §5) |
không đổi (hoặc dời về sau bằng bonus_remaining nếu chỉ bonus) |
| Hạ gói (bất kỳ cấp nào) | Lên lịch gói mới cho billing_use_end_date. Cho đến thời điểm đó, người dùng vẫn ở gói hiện tại, paid_period_end và billing_use_end_date không thay đổi. Tại billing_use_end_date, gói mới có hiệu lực và việc gia hạn tiến hành ở giá mới. |
(giống — hoãn lại) |
| Hủy → Free (cuối kỳ) | không liên quan (user ở Free) | không liên quan (user ở Free) |
Bất biến: paid_period_end ≤ billing_use_end_date luôn đúng. Hiệu khác 0 nghĩa là đang có bonus.
7. Chi tiết các trigger
7.1 Đăng ký qua liên kết giới thiệu (?ref=xxxxxx)
Trigger: Người được giới thiệu gọi POST /api/pre-register hoặc POST /api/register với ?ref=.
Quy trình:
- Tra cứu giá trị
?reftrongusers.referral_code→ lấyreferrer_id. - Xác minh người giới thiệu không phải là cùng người dùng mới (ngăn tự giới thiệu). Nếu vậy, đặt
referrer_id = NULL. - Đặt
users.referrer_idtrên người dùng mới. - INSERT vào bảng
referrals(ràng buộc UNIQUE ngăn trùng lặp). - Tăng
referral_stats.total_referred. - Kiểm tra Bộ đếm A: Nếu
total_referred % 3 == 0VÀfree_signup_bonus_count == 0:- INSERT vào
referral_tickets(source=‘free_signup_bonus’, expires_at=NULL). – không bao giờ hết hạn (ticket người giới thiệu) - Đặt
referral_stats.free_signup_bonus_count = 1. - Gửi thông báo: “Đạt 3 lượt đăng ký miễn phí! Nhận ticket +1 tháng.”
- INSERT vào
Thông báo: Người được giới thiệu thấy chi tiết ưu đãi trên màn hình đăng ký và email xác nhận. Người giới thiệu chỉ được thông báo khi Bộ đếm A kích hoạt thưởng — không phải mỗi lượt đăng ký.
7.2 Lần thanh toán trả phí đầu tiên của người được giới thiệu
Trigger: Stripe webhook invoice.payment_succeeded hoặc customer.subscription.updated chuyển user_payments.status từ UNSETTLED sang SETTLED lần đầu tiên.
Quy trình:
- Xác nhận người dùng có
referrer_id IS NOT NULLVÀreferrals.first_paid_at IS NULL. - Cập nhật
referrals.first_paid_at = nowvàfirst_paid_invoice_id = invoice.id. - Cấp ticket cho người được giới thiệu: INSERT vào
referral_tickets(source=‘referee_reward’, expires_at=now+90d). Xác minh cửa sổ đủ điều kiện 90 ngày:referrals.registered_at + 90 ngày > now. Sau khi cấp, ticket có vòng đời 90 ngày riêng từgranted_at. - Cấp ticket cho người giới thiệu (Bộ đếm B): INSERT vào
referral_tickets(source=‘paid_conversion’, expires_at=NULL). – không bao giờ hết hạn (ticket người giới thiệu) - Tăng
referral_stats.total_paid_conversions. - Kiểm tra mốc 5 người: Nếu
total_paid_conversions == 5VÀmilestone_5_issued == FALSE:- INSERT thêm 7 ticket (source=‘milestone_bonus’, expires_at=NULL). – không bao giờ hết hạn (ticket người giới thiệu)
- Đặt
referral_stats.milestone_5_issued = TRUE. - Gửi thông báo: “Đạt 5 lượt chuyển đổi trả phí! Nhận 12 tháng ticket miễn phí.”
Thông báo: Người giới thiệu nhận “+1 tháng ticket” (thông báo đặc biệt ở lần thứ 5). Người được giới thiệu cũng nhận “Phần thưởng giới thiệu +1 tháng đã cấp”.
7.3 Hết hạn ticket (batch)
Trigger: Cron hàng ngày (tương tự DowngradePlanTrialToFree hiện có).
Quy trình:
- Tìm
referral_ticketscóstatus='available' AND expires_at IS NOT NULL AND expires_at < now. Cần bảo vệ bằngIS NOT NULL— ticket người giới thiệu cóexpires_at = NULLvà không được khớp điều kiện này. Trên thực tế, chỉ các hàngreferee_rewardmới khớp. - Cập nhật chúng thành
status='expired'. - Kiểm tra đặt lại Bộ đếm A: đã loại bỏ. Ticket người giới thiệu không bao giờ hết hạn, nên đường đặt lại do hết hạn không còn tồn tại. Đặt lại Bộ đếm A được xử lý trong §7.4 — khi ticket
free_signup_bonuschuyển sangconsumedhoặcvoided, đặtfree_signup_bonus_count = 0(để 3 lượt đăng ký miễn phí tiếp theo bắt đầu đếm lại). - Thông báo trước khi hết hạn (14 ngày và 3 ngày) được xử lý trong job riêng:
- 14 ngày:
source='referee_reward' AND status='available' AND expires_at BETWEEN now+13days AND now+15days AND notify_14d_sent = FALSE. - 3 ngày:
source='referee_reward' AND status='available' AND expires_at BETWEEN now+2days AND now+4days AND notify_3d_sent = FALSE. - Bộ lọc
source='referee_reward'giới hạn job này chỉ cho ticket người được giới thiệu — ticket người giới thiệu không có hạn để cảnh báo.
- 14 ngày:
7.4 Áp dụng ticket
Trigger (monthly): Stripe webhook invoice.created (cho chu kỳ thanh toán tiếp theo).
Trigger (yearly): Stripe webhook invoice.created hoặc customer.subscription.updated (tại gia hạn yearly).
Quy trình:
- Kiểm tra
validUserPlancủa người dùng. - Nếu
validUserPlanlà NULL hoặcplan IN (FREE, FREE_TRIAL):- Cập nhật tất cả ticket
status='available'của người dùng thànhstatus='voided'. - Với mỗi ticket bị void có
source='free_signup_bonus', đặtreferral_stats.free_signup_bonus_count = 0(đặt lại Bộ đếm A khi void). - Lý do: về tới gói Free = tồn kho hết hạn.
- Cập nhật tất cả ticket
- Ngược lại:
- Lấy ticket cũ nhất có
status='available' AND (expires_at IS NULL OR expires_at > now). Nếu không có, không làm gì. (Ticket người giới thiệu vớiexpires_at = NULLluôn đủ điều kiện.) - Monthly: Gọi
Subscription::updateđể đẩycurrent_period_endlên 1 tháng VÀ cập nhậtuser_plans.billing_use_end_datetương ứng. (Không dùng Stripe Coupon.) - Yearly (batch): Tổng hợp tất cả ticket có sẵn → hủy + tạo lại subscription với
billing_cycle_anchormở rộng (thêm N tháng, N là số ticket). - Đánh dấu các ticket thành
status='consumed'. - Với mỗi ticket bị consume có
source='free_signup_bonus', đặtreferral_stats.free_signup_bonus_count = 0(đặt lại Bộ đếm A khi consume).
- Lấy ticket cũ nhất có
7.5 Xử lý ticket khi đổi gói
Trigger: PaymentInteractor::handle (nâng cấp) / CancelInteractor::handle (hủy).
Quy trình:
- Nâng cấp (Lite → Standard):
- Tính chênh lệch theo tỷ lệ bằng
calcAmountOffhiện có. - Nếu
amountOff > held-ticket-equivalent value (ticket months × monthly price): thu chênh lệch bình thường (amountOff - ticket-equivalent value). - Ngược lại: đặt
amountOff = 0(nâng cấp miễn phí). - Ticket KHÔNG bị consume tại thời điểm này (chuyển sang chu kỳ tiếp theo).
- Tính chênh lệch theo tỷ lệ bằng
- Hạ gói (Standard → Lite) — HOÃN đến
billing_use_end_date:- Việc đổi gói được lên lịch, không áp dụng ngay. Người dùng giữ gói hiện tại (với tất cả tính năng và mọi bonus đang hoạt động) cho đến khi kết thúc kỳ.
- Tại
billing_use_end_date, subscription gia hạn ở gói mới (thấp hơn).paid_period_endvàbilling_use_end_dateđược đặt lại bởi webhook gia hạn dựa trên giá của gói mới. - Không hoàn tiền; không mất tính năng ngay lập tức.
- Ticket đang giữ KHÔNG bị consume trong thời gian hoãn — chúng vẫn ở
status='available'. Chúng sẽ được áp dụng tại thời điểm gia hạn, nay trên gói mới (thấp hơn). - Bonus đã consume được áp dụng ở gói HIỆN TẠI (cao hơn) — người dùng tận hưởng gói cao hơn cho toàn bộ kỳ trả phí + bonus, vì họ đã trả cho gói đó rồi. Bonus được “dùng hết” trước khi chuyển gói.
- Người dùng có thể hủy lịch hạ gói trước
billing_use_end_date(quay lại ở gói hiện tại).
- Hủy (→ Free):
- Đặt subscription cũ
cancel_at_period_end = true. - Khi
user_plans.billing_use_end_dateđến,userDowngradePlanchạy và void tất cả ticket.
- Đặt subscription cũ
8. API Endpoints
8.1 Phía người dùng (yoyacoo_be/user)
| Phương thức | Đường dẫn | Mục đích |
|---|---|---|
| GET | /api/referrals/me |
Lấy thông tin giới thiệu của tôi (liên kết + thống kê + ticket) |
| GET | /api/referrals/me/tickets |
Liệt kê các ticket đang giữ |
| GET | /api/referrals/me/referees |
Liệt kê người dùng tôi đã giới thiệu |
8.2 Phía quản trị (yoyacoo_be/admin)
| Phương thức | Đường dẫn | Mục đích |
|---|---|---|
| GET | /api/admin/referrals |
Liệt kê tất cả giới thiệu (lọc theo người giới thiệu/người được giới thiệu/trạng thái/ngày) |
| GET | /api/admin/referrals/{userId} |
Chi tiết một người giới thiệu cụ thể |
| GET | /api/admin/referrals/stats |
Thống kê tổng hợp (tổng giới thiệu, chuyển đổi trả phí, top người giới thiệu) |
| GET | /api/admin/referral-settings |
Lấy cài đặt văn bản chia sẻ |
| PUT | /api/admin/referral-settings |
Cập nhật cài đặt văn bản chia sẻ |
8.3 Bổ sung webhook
PaymentWebhookController::handleInvoiceCreated(mới): trigger sử dụng ticketPaymentWebhookController::handleCustomerSubscriptionUpdated(hiện có, sửa đổi): phát hiện lần thanh toán trả phí đầu tiên
9. Giao diện người dùng
9.1 Phía người dùng (yoyacoo_fe/supplier)
| Component | Vị trí | Mục đích |
|---|---|---|
ReferralBanner |
components/organisms/referral/ReferralBanner.tsx |
Banner dashboard “Giới thiệu Yoyacoo?” |
ReferralPage |
pages/accounts/referral/index.tsx |
Trang giới thiệu đầy đủ (liên kết, copy, tiến trình, danh sách ticket) |
TicketBadge |
components/atoms/referral/TicketBadge.tsx |
Badge đếm ticket đang giữ |
ProgressBarPaid |
components/molecules/referral/ProgressBarPaid.tsx |
Tiến trình “X / 5 trả phí” (vàng) |
ProgressBarFree |
components/molecules/referral/ProgressBarFree.tsx |
Tiến trình “X / 3 miễn phí” (xanh) |
ExpiringTicketBanner |
components/atoms/referral/ExpiringTicketBanner.tsx |
Banner cảnh báo trước hết hạn |
9.2 Phía quản trị (yoyacoo_fe/admin)
pages/referrals/index.tsx— danh sáchpages/referrals/[userId].tsx— chi tiếtpages/referrals/settings.tsx— cài đặt văn bản chia sẻ
9.3 Màn hình đăng ký (yoyacoo_fe/customer + yoyacoo_fe/supplier)
- Đọc
?ref=xxxxxxtừ URL khi mount - Hiển thị trường mã mời chỉ đọc (theo phán đoán của Furuha)
- Bao gồm
reftrong payload củaPOST /api/pre-registervàPOST /api/register
9.4 Nút copy
- Một cú nhấp chuột sao chép văn bản định sẵn + liên kết vào clipboard
- Văn bản được lấy từ
referral_settings.share_text - Placeholder
{referral_link}được thay bằng liên kết thực tế
10. Thông báo
10.1 Email
| Trigger | Nội dung | Template | Người nhận |
|---|---|---|---|
| Đạt 3 đăng ký miễn phí | “+1 tháng ticket” | emails.html.referral_free_signup_bonus |
Người giới thiệu |
| Thưởng chuyển đổi trả phí | “+1 tháng ticket” | emails.html.referral_paid_conversion |
Người giới thiệu |
| Đạt 5 chuyển đổi trả phí | “Nhận 12 tháng ticket miễn phí” | emails.html.referral_milestone_5 |
Người giới thiệu |
| Trước 14 ngày hết hạn | “Ticket của bạn hết hạn trong 14 ngày” | emails.html.referral_ticket_expiring_14d |
Chỉ người được giới thiệu (ticket referee_reward) |
| Trước 3 ngày hết hạn | “Ticket của bạn hết hạn trong 3 ngày” | emails.html.referral_ticket_expiring_3d |
Chỉ người được giới thiệu (ticket referee_reward) |
| Ticket đã sử dụng | “+1 tháng đã áp dụng” | emails.html.referral_ticket_consumed |
Người giữ ticket |
Thông báo trước hết hạn chỉ áp dụng cho ticket referee_reward. Ticket người giới thiệu (free_signup_bonus, paid_conversion, milestone_bonus) có expires_at = NULL và không bao giờ kích hoạt email trước hết hạn.
10.2 Banner trong app
- Hiển thị
ExpiringTicketBannertrên dashboard / trang Giới thiệu - Sử dụng cờ
referral_tickets.notify_14d_sentvànotify_3d_sentđể ngăn gửi trùng
11. Trường hợp biên và Idempotency
11.1 Idempotency
- Webhook retry: tất cả thao tác chính dùng ràng buộc UNIQUE + mẫu select-then-act
- UNIQUE trên
referrals(referee_user_id)ngăn xử lý đăng ký trùng lặp - Ngăn trùng lặp
referral_ticketsdùng job ID hoặc ràng buộc UNIQUE(source, referee_user_id, type)
11.2 Ngăn tự giới thiệu
- Kiểm tra
users.referrer_id != users.idtại đăng ký - Nếu phát hiện,
referrer_id = NULLvà không ghi nhận giới thiệu
11.3 Ngăn người được giới thiệu trùng lặp
- Một người được giới thiệu chỉ có một người giới thiệu (
referrals.referee_user_idUNIQUE) - Một
?ref=thứ hai từ người giới thiệu khác sẽ bị bỏ qua âm thầm
11.4 Tài khoản phụ
- Việc cấp liên kết giới thiệu bị giới hạn cho tài khoản chính (tài khoản con không thể tạo liên kết)
- Tài khoản chính và con được coi là người dùng riêng biệt (họ có thể giới thiệu lẫn nhau)
11.5 Hủy → đăng ký lại
- Sau khi hủy (hạ về Free), tất cả ticket đang giữ bị void. Đăng ký lại được coi là bắt đầu mới.
- Lịch sử giới thiệu trước đó (Bộ đếm A/B) được bảo toàn (tổng tích lũy cho mốc 5 người không đặt lại).
11.6 Lỗi khi áp dụng batch ticket yearly
- Nếu bất kỳ ticket nào trong batch thất bại, rollback toàn bộ batch — để các ticket bị ảnh hưởng ở
status='available' - Lên lịch job retry (ví dụ: 1 giờ sau)
12. Căn chỉnh với hệ thống hiện tại
12.1 Những gì được tái sử dụng
StripePaymentSubscriptiontrait:createSubscription,cancelSubscriptionSubscriptionutil:newOrUpdateUserSubscription- Trait
userDowngradePlan(mở rộng với logic void ticket) - Mẫu cron từ
DowngradePlanTrialToFreecho batch hàng ngày - Cơ sở hạ tầng SendMail hiện có (chỉ thêm template)
- Các mẫu Coupon ở cấp reservation hiện có làm tham chiếu cho thiết kế bảng/service
12.2 Các component hiện có cần sửa đổi
PaymentInteractor::handle: thêm logic giới hạn giá nâng cấp theo phần trả phíCancelInteractor::handle: thêm hook void ticket khi hủyPaymentWebhookController::handleCustomerSubscriptionUpdated: thêm phát hiện lần trả phí đầu tiênuserDowngradePlan: thêm hook void ticket- Model
User: thêmreferral_code,referrer_idvà các quan hệ liên quan - Model
UserPlan: thêm quan hệ khi cần
12.3 Nơi chứa code mới (chia 3 app)
- Common models (
yoyacoo_be/common):Referral,ReferralTicket,ReferralStat,ReferralSetting - App User (
yoyacoo_be/user): tạo liên kết, hook đăng ký, API người dùng - App Admin (
yoyacoo_be/admin): xử lý webhook, API quản trị - Common webhook (
yoyacoo_be/common): batch hết hạn, batch thông báo
13. Tiêu chí chấp nhận
- Người dùng mới có thể đăng ký với
?ref=xxxxxx - Bộ đếm của người giới thiệu (A và B) cập nhật đúng
- 3 đăng ký miễn phí kích hoạt ticket +1 tháng
- Lần thanh toán trả phí đầu tiên của người được giới thiệu kích hoạt ticket +1 tháng cho người giới thiệu
- 5 lượt chuyển đổi trả phí kích hoạt thưởng +7 tháng
- Người dùng monthly thấy ngày thanh toán tiếp theo được gia hạn đúng
- Người dùng yearly thấy tất cả ticket đang giữ được áp dụng tại mốc gia hạn
- Thông báo email + banner được gửi 14 ngày và 3 ngày trước khi hết hạn
- Ticket chuyển trạng thái
expiredsau 90 ngày - Ticket chuyển trạng thái
voidedkhi người dùng về gói Free - Giá nâng cấp được giới hạn bởi phần trả phí còn lại
- Nút copy đặt văn bản chia sẻ + liên kết vào clipboard
- Quản trị viên có thể xem tất cả dữ liệu giới thiệu qua màn hình quản trị
- Webhook retry không gây xử lý trùng lặp
- Ngăn tự giới thiệu
- Ticket của người giới thiệu (
free_signup_bonus,paid_conversion,milestone_bonus) cóexpires_at = NULLvà không bao giờ chuyển sangexpired - Ticket của người được giới thiệu (
referee_reward) hết hạn đúng 90 ngày saugranted_at - Thông báo trước hết hạn (14 ngày / 3 ngày) chỉ kích hoạt cho ticket
referee_reward - Bộ đếm A
free_signup_bonus_countchỉ đặt lại về 0 khiconsumedhoặcvoided(không phải khiexpired) - Cột
user_plans.paid_period_endtồn tại; bất biếnpaid_period_end ≤ billing_use_end_dateđược duy trì - Backfill điền
paid_period_end = billing_use_end_datecho các hàng hiện có - Sử dụng ticket hàng tháng chỉ mở rộng
billing_use_end_date;paid_period_endkhông đổi - Sử dụng batch yearly mở rộng
paid_period_endđúng 12 tháng và cộng các tháng ticket đã consume lênbilling_use_end_date - Nâng cấp tính theo
max(0, paid_period_end − now); trả vềamountOff = 0khi giá trị này bằng 0 - Khi nâng cấp
amountOff = 0(chỉ bonus),paid_period_endmới =max(now, old_paid_period_end)vàbilling_use_end_datebảo toànbonus_remainingtháng - Khi nâng cấp
amountOff > 0(giữa kỳ trả phí),paid_period_endvàbilling_use_end_datemới không đổi so với trước nâng cấp - Ticket đang giữ (chưa consume) không bị ảnh hưởng bởi nâng cấp; chúng tiếp tục áp dụng ở chu kỳ thanh toán tiếp theo
- Hạ gói được lên lịch (không áp dụng ngay) — người dùng giữ gói hiện tại cho đến
billing_use_end_date, rồi chuyển sang gói mới tại thời điểm gia hạn - Trong thời gian hoãn hạ gói,
paid_period_endvàbilling_use_end_datekhông thay đổi - Tại
billing_use_end_date, lịch hạ gói kích hoạt: subscription gia hạn ở gói mới (thấp hơn), vớipaid_period_endvàbilling_use_end_dateđược đặt lại dựa trên giá gói mới - Thời gian bonus đã consume trong thời gian hoãn được tận hưởng ở gói HIỆN TẠI (cao hơn), không phải gói mới
- Ticket đang giữ vẫn ở
status='available'trong thời gian hoãn và được consume trên gói mới tại thời điểm gia hạn - Người dùng có thể hủy lịch hạ gói trước
billing_use_end_date(quay lại ở gói hiện tại)