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_date của người giới thiệu về phía trước
  • Đồng bộ current_period_end củ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ệu
  • paid_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_endbilling_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:

  1. Tra cứu giá trị ?ref trong users.referral_code → lấy referrer_id.
  2. 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.
  3. Đặt users.referrer_id trên người dùng mới.
  4. INSERT vào bảng referrals (ràng buộc UNIQUE ngăn trùng lặp).
  5. Tăng referral_stats.total_referred.
  6. Kiểm tra Bộ đếm A: Nếu total_referred % 3 == 0free_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.”

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:

  1. Xác nhận người dùng có referrer_id IS NOT NULLreferrals.first_paid_at IS NULL.
  2. Cập nhật referrals.first_paid_at = nowfirst_paid_invoice_id = invoice.id.
  3. 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.
  4. 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)
  5. Tăng referral_stats.total_paid_conversions.
  6. Kiểm tra mốc 5 người: Nếu total_paid_conversions == 5milestone_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:

  1. Tìm referral_ticketsstatus='available' AND expires_at IS NOT NULL AND expires_at < now. Cần bảo vệ bằng IS NOT NULL — ticket người giới thiệu có expires_at = NULL và không được khớp điều kiện này. Trên thực tế, chỉ các hàng referee_reward mới khớp.
  2. Cập nhật chúng thành status='expired'.
  3. 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_bonus chuyển sang consumed hoặc voided, đặt free_signup_bonus_count = 0 (để 3 lượt đăng ký miễn phí tiếp theo bắt đầu đếm lại).
  4. 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.

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:

  1. Kiểm tra validUserPlan của người dùng.
  2. Nếu validUserPlan là NULL hoặc plan IN (FREE, FREE_TRIAL):
    • Cập nhật tất cả ticket status='available' của người dùng thành status='voided'.
    • Với mỗi ticket bị void có source='free_signup_bonus', đặt referral_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.
  3. 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ới expires_at = NULL luôn đủ điều kiện.)
    • Monthly: Gọi Subscription::update để đẩy current_period_end lên 1 tháng VÀ cập nhật user_plans.billing_use_end_date tươ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_anchor mở 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', đặt referral_stats.free_signup_bonus_count = 0 (đặt lại Bộ đếm A khi consume).

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 calcAmountOff hiệ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).
  • 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_endbilling_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, userDowngradePlan chạy và void tất cả ticket.

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 ticket
  • PaymentWebhookController::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ách
  • pages/referrals/[userId].tsx — chi tiết
  • pages/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=xxxxxx từ URL khi mount
  • Hiển thị trường mã mời chỉ đọc (theo phán đoán của Furuha)
  • Bao gồm ref trong payload của POST /api/pre-registerPOST /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ị ExpiringTicketBanner trên dashboard / trang Giới thiệu
  • Sử dụng cờ referral_tickets.notify_14d_sentnotify_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_tickets dù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.id tại đăng ký
  • Nếu phát hiện, referrer_id = NULL và 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_id UNIQUE)
  • 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

  • StripePaymentSubscription trait: createSubscription, cancelSubscription
  • Subscription util: newOrUpdateUserSubscription
  • Trait userDowngradePlan (mở rộng với logic void ticket)
  • Mẫu cron từ DowngradePlanTrialToFree cho 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ủy
  • PaymentWebhookController::handleCustomerSubscriptionUpdated: thêm phát hiện lần trả phí đầu tiên
  • userDowngradePlan: thêm hook void ticket
  • Model User: thêm referral_code, referrer_id và 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 expired sau 90 ngày
  • Ticket chuyển trạng thái voided khi 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 = NULL và không bao giờ chuyển sang expired
  • Ticket của người được giới thiệu (referee_reward) hết hạn đúng 90 ngày sau granted_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_count chỉ đặt lại về 0 khi consumed hoặc voided (không phải khi expired)
  • Cột user_plans.paid_period_end tồn tại; bất biến paid_period_end ≤ billing_use_end_date được duy trì
  • Backfill điền paid_period_end = billing_use_end_date cho 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_end khô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ên billing_use_end_date
  • Nâng cấp tính theo max(0, paid_period_end − now); trả về amountOff = 0 khi giá trị này bằng 0
  • Khi nâng cấp amountOff = 0 (chỉ bonus), paid_period_end mới = max(now, old_paid_period_end)billing_use_end_date bảo toàn bonus_remaining tháng
  • Khi nâng cấp amountOff > 0 (giữa kỳ trả phí), paid_period_endbilling_use_end_date mớ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_endbilling_use_end_date khô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ới paid_period_endbilling_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)