Yoyacoo Referral Program — Specification
Last updated: 2026-07-07
Status: Confirmed (overrides all prior Slack discussions) — includes (a) expiration rule fix (referrer tickets never expire; referee tickets expire 90 days from issuance), (b) upgrade-with-tickets fix (pro-rate over paid portion only; free upgrade in bonus-only period; bonus months carried over), and © deferred downgrade (downgrade is scheduled for billing_use_end_date; user keeps current plan and any active bonus until then)
1. Overview
1.1 Purpose
Accelerate Yoyacoo’s new user acquisition and conversion of those users to paying customers.
1.2 Scope
Grant rewards to both existing users (referrers) and new users (referees) when the latter sign up via the former’s referral link.
1.3 Out of Scope (not implemented in this version)
- Invitation code input field (link only)
- Cash rewards / agency commissions
- Ranking features
- Complex fraud detection
- Automated referral emails
- Auto-generated social share text
- Agency-only admin screen
- Yearly → Monthly plan switch (not supported in the current system)
2. Core Design Principles
2.1 All rewards are unified as “payment + billing cycle extension”
Every reward is applied in the same way, regardless of whether the user is a referrer/referee or on a monthly/yearly plan:
- Push the referrer’s
user_plans.billing_use_end_dateforward - Sync the Stripe Subscription’s
current_period_end - No actual payment transaction occurs (Stripe Coupon is NOT used)
Approaches like “first month free” or “% discount” via Stripe are explicitly rejected.
2.2 Two fully independent counters
A. Free registration counter: +1 month for every 3 referrals. Stock cap of 1 (no new ticket is granted while one is still held). Resets to 0 after the held ticket transitions to consumed or voided. There is no expiration-driven reset — referrer tickets never expire (see §4).
B. Paid conversion counter: +1 month per paid conversion. No cap, no expiration on the counter. At exactly 5 paid conversions, an additional +7 months are granted (5 normal + 7 bonus = 12 total). The 5-person milestone bonus is one-time only; from the 6th person onward, only the +1-month standard rule continues.
Important: Mixed judgment is prohibited. The “5 people” milestone uses ONLY Counter B. A combination like “3 free signups + 2 paid conversions = 5” does NOT count.
2.3 Referee reward
A referee who signs up via a referral link and subscribes to a paid plan within 90 days of registration receives a +1-month ticket.
- The 90-day window is computed dynamically from the registration date (no hard-coded dates).
3. Application Timing
| Referrer’s state | Application method |
|---|---|
| Free / Trial | Internally stocked. Auto-applied on first paid upgrade. |
| Monthly | Push the next billing date back. |
| Yearly | Push the renewal date back (cancel + recreate the subscription at renewal time to extend the billing cycle anchor). |
4. Expiration Rules
Expiration is split by ticket holder — referrer tickets never expire; referee tickets do.
| Ticket source | Holder | expires_at |
Pre-expiry notify (14d / 3d) |
|---|---|---|---|
free_signup_bonus |
Referrer (Counter A) | NULL — never expires |
No |
paid_conversion |
Referrer (Counter B) | NULL — never expires |
No |
milestone_bonus |
Referrer (5-paid milestone) | NULL — never expires |
No |
referee_reward |
Referee (+1 month) | granted_at + 90 days |
Yes (referee only) |
| Already-applied extensions | n/a | n/a — no expiration | n/a |
Referee eligibility window (§2.3) is unchanged: the referee must subscribe to a paid plan within 90 days of registration to qualify for the ticket. Once issued, the referee_reward ticket has its own 90-day lifetime from granted_at.
Pre-expiration notifications: Sent 14 days and 3 days before expiration via email + in-app banner. These notifications fire only for referee_reward tickets. Referrer tickets are structurally excluded (their expires_at is NULL).
Storage convention: referral_tickets.expires_at is nullable. NULL semantically means “never expires”. All batch and notification queries must guard with expires_at IS NOT NULL before any range comparison.
5. Plan Change Behavior (Final)
| Change pattern | Behavior |
|---|---|
| Monthly → Yearly | Push the yearly renewal date back by the remaining bonus months. |
| Yearly → Yearly (renewal) | Apply the entire held stock at year-end in one batch. |
| Yearly → Monthly | Out of scope (current system does not support this). After yearly + bonus is consumed, the user must re-subscribe or fall to Free. |
| Lite → Standard (upgrade) | Pro-rate the upgrade over the remaining paid portion only (max(0, paid_period_end − now)). If paid_remaining = 0 (user is in a bonus-only period), the upgrade is free. Bonus months are preserved on the new tier — see §6.6 and the formula below. |
| Standard → Lite (downgrade) | Scheduled for billing_use_end_date. The user keeps the current plan (and any active bonus) until the period ends; the new (lower) tier takes effect on the next billing cycle. No refund — the user gets to use what they already paid for. Held tickets continue to apply on the new tier at the renewal. |
| Lite ⇔ Business (future) | Same as above. |
| → Free (downgrade) | Unconsumed stock vanishes at contract end. |
| Standard → Free (cancellation) | Stock vanishes at period end, even during a bonus period. |
Upgrade pricing formula (paid-portion-only):
paid_remaining = max(0, paid_period_end − now) // months bonus_remaining = max(0, billing_use_end_date − max(now, paid_period_end)) // months amountOff = (new_price_per_month − old_price_per_month) × paid_remaining if amountOff ≤ 0: free upgrade — switch tiers, charge nothingAfter the upgrade:
- If
paid_remaining > 0:new_paid_period_end = old_paid_period_end(unchanged).new_billing_use_end_date = old_billing_use_end_date(unchanged). The period structure (paid + bonus) is preserved.- If
paid_remaining = 0(free upgrade):new_paid_period_end = max(now, old_paid_period_end).new_billing_use_end_date = new_paid_period_end + bonus_remaining. The bonus months transfer to the new tier.Tickets are NOT consumed at upgrade time — they remain held for the next billing cycle. The upgrade affects the current period only; held tickets continue to apply at the renewal webhook as before.
6. Data Model
6.1 Additions to the users table
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: Auto-generated on user creation (e.g.,base62_encode(random_bytes(8))≈ 14 characters).referrer_id: Set when signing up via a referral link. Self-referral prevention: if referrer_id == user_id, set to NULL.
6.2 referrals (new table)
Immutable log. One-to-one relationship (one referee has exactly one referrer).
| Column | Type | Description |
|---|---|---|
| id | BIGINT PK | |
| referrer_user_id | BIGINT FK users.id | Referrer |
| referee_user_id | BIGINT FK users.id UNIQUE | Referee (1:1) |
| registered_at | TIMESTAMP | Referee’s registration time |
| first_paid_at | TIMESTAMP NULL | First paid charge completion time |
| first_paid_invoice_id | VARCHAR(255) NULL | Stripe invoice id |
| source | ENUM(‘link’) | Referral channel (MVP: link only) |
| created_at, updated_at | TIMESTAMP |
UNIQUE constraint: (referee_user_id) UNIQUE — one referee has only one referrer.
6.3 referral_tickets (new table)
Unified ticket entity. All reward types share this table, distinguished by source.
| Column | Type | Description |
|---|---|---|
| id | BIGINT PK | |
| user_id | BIGINT FK users.id | Ticket holder |
| source | ENUM | See below |
| status | ENUM | ‘available’, ‘consumed’, ‘voided’, ‘expired’ |
| granted_at | TIMESTAMP | When granted |
| expires_at | TIMESTAMP NULL | Expiration time. NULL for referrer tickets (free_signup_bonus, paid_conversion, milestone_bonus) — never expires. Non-null only for referee_reward, set to granted_at + 90 days. |
| consumed_at | TIMESTAMP NULL | When consumed |
| consumed_on_user_plan_id | BIGINT NULL | user_plans.id the ticket was applied to |
| notify_14d_sent | BOOLEAN DEFAULT FALSE | Pre-expiry notification flag |
| notify_3d_sent | BOOLEAN DEFAULT FALSE | Pre-expiry notification flag |
| metadata | JSON NULL | Source-specific extra info |
source values:
referee_reward— Referee’s +1 month (paid upgrade within 90 days of registration)free_signup_bonus— 3-referral bonuspaid_conversion— Referrer’s +1 month (referee’s first paid charge)milestone_bonus— 7 extra months at 5 paid conversions
Indexes: (user_id, status, expires_at), (status, expires_at) (for expiry batch), (user_id, source) (for aggregations).
6.4 referral_stats (new table)
Denormalized counters for fast dashboard queries.
| Column | Type | Description |
|---|---|---|
| user_id | BIGINT PK FK users.id | Referrer |
| total_referred | INT DEFAULT 0 | Cumulative referrals (for Counter A) |
| total_paid_conversions | INT DEFAULT 0 | Cumulative paid conversions (for Counter B) |
| free_signup_bonus_count | INT DEFAULT 0 | Bonuses already granted (A: 0 or 1) |
| milestone_5_issued | BOOLEAN DEFAULT FALSE | 5-person milestone bonus flag |
| updated_at | TIMESTAMP |
6.5 referral_settings (new table)
Stores the share text used by the copy button, designed to be replaceable in the future.
| Column | Type | Description |
|---|---|---|
| id | INT PK | Always 1 (singleton) |
| share_text | TEXT | Pre-canned text, contains a {referral_link} placeholder |
| created_at, updated_at | TIMESTAMP |
6.6 Addition to the user_plans table
A new column to track the boundary between the paid portion and any ticket-based bonus portion. This is what makes the upgrade-with-tickets behavior (pro-rate paid only, preserve bonus) possible.
ALTER TABLE user_plans ADD COLUMN paid_period_end TIMESTAMP NOT NULL
COMMENT 'End of the paid portion only. bonus = billing_use_end_date − paid_period_end';
| Column | Type | Description |
|---|---|---|
paid_period_end |
TIMESTAMP NOT NULL | The date the user’s access ends if no tickets had been applied. billing_use_end_date is the actual end (paid + bonus). Invariant: paid_period_end ≤ billing_use_end_date. |
Backfill (existing user_plans rows pre-referral-program): paid_period_end = billing_use_end_date (no bonus has been applied historically; the two columns are equal).
Update rules (see §5 for full upgrade flow):
| Event | paid_period_end |
billing_use_end_date |
|---|---|---|
| New subscription (no tickets) | now + period_length |
same |
| Monthly ticket consumed | unchanged | + 1 month per ticket |
| Yearly batch at renewal | + 12 months (new paid year) |
paid_period_end + ticket_months |
| Monthly → Yearly switch | now + 12 months |
paid_period_end + held_ticket_months |
| Yearly → Yearly renewal (no tickets) | + 12 months |
same |
| Upgrade (any tier) | unchanged (or set to now if bonus-only — see §5) |
unchanged (or shift forward by bonus_remaining if bonus-only) |
| Downgrade (any tier) | Schedules the new tier for billing_use_end_date. Until then, the user remains on the current plan with no changes to paid_period_end or billing_use_end_date. At billing_use_end_date, the new tier becomes active and the renewal proceeds at the new price. |
(same — deferred) |
| Cancellation → Free (period end) | irrelevant (user is Free) | irrelevant (user is Free) |
Invariant: paid_period_end ≤ billing_use_end_date always. A non-zero diff means a bonus is active.
7. Triggers in Detail
7.1 Signup via referral link (?ref=xxxxxx)
Trigger: Referee calls POST /api/pre-register or POST /api/register with ?ref=.
Process:
- Look up
?refvalue inusers.referral_code→ obtainreferrer_id. - Verify that the referrer is not the same as the new user (self-referral prevention). If so, set
referrer_id = NULL. - Set
users.referrer_idon the new user. - INSERT into
referralstable (UNIQUE constraint prevents duplicates). - Increment
referral_stats.total_referred. - Counter A check: If
total_referred % 3 == 0ANDfree_signup_bonus_count == 0:- INSERT into
referral_tickets(source=‘free_signup_bonus’, expires_at=NULL). – never expires (referrer ticket) - Set
referral_stats.free_signup_bonus_count = 1. - Send notification: “3 free signups achieved! +1-month ticket earned.”
- INSERT into
Notifications: The referee sees the offer details on the signup screen and confirmation email. The referrer is notified only when Counter A triggers a bonus — not on every signup.
7.2 Referee’s first paid charge
Trigger: Stripe webhook invoice.payment_succeeded or customer.subscription.updated that transitions user_payments.status from UNSETTLED to SETTLED for the first time.
Process:
- Confirm the user has
referrer_id IS NOT NULLANDreferrals.first_paid_at IS NULL. - Update
referrals.first_paid_at = nowandfirst_paid_invoice_id = invoice.id. - Issue referee ticket: INSERT into
referral_tickets(source=‘referee_reward’, expires_at=now+90d). Verify the 90-day eligibility window:referrals.registered_at + 90 days > now. Once issued, the ticket has its own 90-day lifetime fromgranted_at. - Issue referrer ticket (Counter B): INSERT into
referral_tickets(source=‘paid_conversion’, expires_at=NULL). – never expires (referrer ticket) - Increment
referral_stats.total_paid_conversions. - 5-person milestone check: If
total_paid_conversions == 5ANDmilestone_5_issued == FALSE:- INSERT 7 additional tickets (source=‘milestone_bonus’, expires_at=NULL). – never expires (referrer ticket)
- Set
referral_stats.milestone_5_issued = TRUE. - Send notification: “5 paid conversions achieved! 12 months of free tickets earned.”
Notifications: Referrer receives “+1-month ticket earned” (special message on the 5th). Referee also receives “Referral reward +1 month granted”.
7.3 Ticket expiration (batch)
Trigger: Daily cron (similar to existing DowngradePlanTrialToFree).
Process:
- Find
referral_ticketswherestatus='available' AND expires_at IS NOT NULL AND expires_at < now. TheIS NOT NULLguard is required — referrer tickets carryexpires_at = NULLand must not match this predicate. In practice, onlyreferee_rewardrows can match. - Update them to
status='expired'. - Counter A reset check: removed. Referrer tickets never expire, so the expiration-driven reset path no longer exists. Counter A resets are handled in §7.4 — when a
free_signup_bonusticket isconsumedorvoided, setfree_signup_bonus_count = 0(so the next 3 free signups start counting again). - Pre-expiration notifications (14-day and 3-day) are handled in a separate job:
- 14-day:
source='referee_reward' AND status='available' AND expires_at BETWEEN now+13days AND now+15days AND notify_14d_sent = FALSE. - 3-day:
source='referee_reward' AND status='available' AND expires_at BETWEEN now+2days AND now+4days AND notify_3d_sent = FALSE. - The
source='referee_reward'filter scopes these jobs to referee tickets only — referrer tickets have no expiration to warn about.
- 14-day:
7.4 Ticket application
Trigger (monthly): Stripe webhook invoice.created (for the next billing cycle).
Trigger (yearly): Stripe webhook invoice.created or customer.subscription.updated (at yearly renewal).
Process:
- Check the user’s
validUserPlan. - If
validUserPlanis NULL orplan IN (FREE, FREE_TRIAL):- Update all the user’s
status='available'tickets tostatus='voided. - For any voided ticket where
source='free_signup_bonus', setreferral_stats.free_signup_bonus_count = 0(Counter A reset on void). - Reason: reaching the Free plan = stock expiration.
- Update all the user’s
- Otherwise:
- Fetch the oldest ticket where
status='available' AND (expires_at IS NULL OR expires_at > now). If none, do nothing. (Referrer tickets withexpires_at = NULLare always eligible.) - Monthly: Call
Subscription::updateto pushcurrent_period_endby 1 month AND updateuser_plans.billing_use_end_dateaccordingly. (No Stripe Coupon.) - Yearly (batch): Aggregate all available tickets → cancel + recreate the subscription with an extended
billing_cycle_anchor(by N months where N is the ticket count). - Mark the tickets as
status='consumed'. - For any consumed ticket where
source='free_signup_bonus', setreferral_stats.free_signup_bonus_count = 0(Counter A reset on consume).
- Fetch the oldest ticket where
7.5 Plan-change ticket handling
Trigger: PaymentInteractor::handle (upgrade) / CancelInteractor::handle (cancellation).
Process:
- Upgrade (Lite → Standard):
- Calculate the prorated difference using the existing
calcAmountOff. - If
amountOff > held-ticket-equivalent value (ticket months × monthly price): charge the difference normally (amountOff - ticket-equivalent value). - Otherwise: set
amountOff = 0(free upgrade). - Tickets are NOT consumed at this point (carry over to the next cycle).
- Calculate the prorated difference using the existing
- Downgrade (Standard → Lite) — DEFERRED to
billing_use_end_date:- The tier switch is scheduled, not immediate. The user keeps the current plan (with all its features and any active bonus) until the period ends.
- At
billing_use_end_date, the subscription renews at the new (lower) tier.paid_period_endandbilling_use_end_dateare reset by the renewal webhook based on the new tier’s price. - No refund; no immediate feature loss.
- Held tickets are NOT consumed during the deferred period — they remain
status='available'. They will be applied at the renewal, now on the new (lower) tier. - Already-consumed bonus is applied at the CURRENT (higher) tier — the user enjoys the higher tier for the entire paid + bonus period, since they already paid for it. The bonus is “used up” before the switch.
- The user may cancel the scheduled downgrade before
billing_use_end_date(revert to staying on the current plan).
- Cancellation (→ Free):
- Set the old subscription’s
cancel_at_period_end = true. - When
user_plans.billing_use_end_dateis reached,userDowngradePlanruns and voids all tickets.
- Set the old subscription’s
8. API Endpoints
8.1 User-facing (yoyacoo_be/user)
| Method | Path | Purpose |
|---|---|---|
| GET | /api/referrals/me |
Get my referral info (link + stats + tickets) |
| GET | /api/referrals/me/tickets |
List my held tickets |
| GET | /api/referrals/me/referees |
List users I have referred |
8.2 Admin-facing (yoyacoo_be/admin)
| Method | Path | Purpose |
|---|---|---|
| GET | /api/admin/referrals |
List all referrals (filterable by referrer/referee/status/date) |
| GET | /api/admin/referrals/{userId} |
Drill-down on a specific referrer |
| GET | /api/admin/referrals/stats |
Aggregate stats (total referrals, paid conversions, top referrers) |
| GET | /api/admin/referral-settings |
Get share text settings |
| PUT | /api/admin/referral-settings |
Update share text settings |
8.3 Webhook additions
PaymentWebhookController::handleInvoiceCreated(new): ticket consumption triggerPaymentWebhookController::handleCustomerSubscriptionUpdated(existing, modified): detect first paid charge
9. UI Surfaces
9.1 User-facing (yoyacoo_fe/supplier)
| Component | Location | Purpose |
|---|---|---|
ReferralBanner |
components/organisms/referral/ReferralBanner.tsx |
Dashboard banner “Refer Yoyacoo?” |
ReferralPage |
pages/accounts/referral/index.tsx |
Full referral page (link, copy, progress, ticket list) |
TicketBadge |
components/atoms/referral/TicketBadge.tsx |
Held-ticket count badge |
ProgressBarPaid |
components/molecules/referral/ProgressBarPaid.tsx |
“X / 5 paid” progress (gold) |
ProgressBarFree |
components/molecules/referral/ProgressBarFree.tsx |
“X / 3 free” progress (green) |
ExpiringTicketBanner |
components/atoms/referral/ExpiringTicketBanner.tsx |
Pre-expiration warning banner |
9.2 Admin-facing (yoyacoo_fe/admin)
pages/referrals/index.tsx— list viewpages/referrals/[userId].tsx— detail viewpages/referrals/settings.tsx— share text settings
9.3 Registration screen (yoyacoo_fe/customer + yoyacoo_fe/supplier)
- Read
?ref=xxxxxxfrom URL on mount - Display a read-only invitation code field (per Furuha’s judgment)
- Include
refin thePOST /api/pre-registerandPOST /api/registerpayloads
9.4 Copy button
- One click copies pre-canned text + link to the clipboard
- Text is fetched from
referral_settings.share_text - The
{referral_link}placeholder is replaced with the actual link
10. Notifications
10.1 Email
| Trigger | Content | Template | Recipient |
|---|---|---|---|
| 3 free signups achieved | “+1-month ticket earned” | emails.html.referral_free_signup_bonus |
Referrer |
| Paid conversion bonus | “+1-month ticket earned” | emails.html.referral_paid_conversion |
Referrer |
| 5 paid conversions achieved | “12 months of free tickets earned” | emails.html.referral_milestone_5 |
Referrer |
| 14-day pre-expiration | “Your ticket expires in 14 days” | emails.html.referral_ticket_expiring_14d |
Referee only (referee_reward tickets) |
| 3-day pre-expiration | “Your ticket expires in 3 days” | emails.html.referral_ticket_expiring_3d |
Referee only (referee_reward tickets) |
| Ticket consumed | “+1 month applied” | emails.html.referral_ticket_consumed |
Holder |
Pre-expiration notifications are scoped to referee_reward tickets only. Referrer tickets (free_signup_bonus, paid_conversion, milestone_bonus) have expires_at = NULL and never trigger pre-expiration emails.
10.2 In-app banner
- Show
ExpiringTicketBanneron the dashboard / Referral page - Use
referral_tickets.notify_14d_sentandnotify_3d_sentflags to prevent duplicate sends
11. Edge Cases and Idempotency
11.1 Idempotency
- Webhook retries: all main operations use UNIQUE constraints + select-then-act pattern
referrals(referee_user_id)UNIQUE prevents duplicate signup processingreferral_ticketsduplicate prevention uses job ID or UNIQUE(source, referee_user_id, type) constraint
11.2 Self-referral prevention
users.referrer_id != users.idis checked at signup- If detected,
referrer_id = NULLand no referral is recorded
11.3 Duplicate-referee prevention
- One referee has only one referrer (
referrals.referee_user_idUNIQUE) - A second
?ref=from a different referrer is silently ignored
11.4 Sub-accounts
- Referral link issuance is restricted to the master account (child accounts cannot generate links)
- Parent and child accounts are treated as separate users (they can refer each other)
11.5 Cancellation → re-subscription
- After cancellation (downgrade to Free), all held tickets are voided. Re-subscribing is treated as a fresh start.
- Past referral history (Counters A/B) is preserved (the cumulative count for the 5-person milestone does not reset).
11.6 Yearly ticket batch-application failure
- If any ticket in the batch fails, roll back the entire batch — leave the affected tickets as
status='available' - Schedule a retry job (e.g., 1 hour later)
12. Alignment with the Existing System
12.1 What we reuse
StripePaymentSubscriptiontrait’screateSubscription,cancelSubscriptionSubscriptionutil’snewOrUpdateUserSubscriptionuserDowngradePlantrait (extended with ticket-voiding logic)- Cron pattern from
DowngradePlanTrialToFreefor the daily batch - Existing SendMail infrastructure (only template additions)
- Existing reservation-level Coupon patterns as a reference for table/service design
12.2 Existing components that need modification
PaymentInteractor::handle: add ticket-equivalent cap logic to the upgrade pricingCancelInteractor::handle: add ticket-voiding hook on cancellationPaymentWebhookController::handleCustomerSubscriptionUpdated: add first-paid-charge detectionuserDowngradePlan: add ticket-voiding hookUsermodel: addreferral_code,referrer_id, and related relationsUserPlanmodel: add relations as needed
12.3 Where new code lives (3-app split)
- Common models (
yoyacoo_be/common):Referral,ReferralTicket,ReferralStat,ReferralSetting - User app (
yoyacoo_be/user): link generation, signup hook, user-facing API - Admin app (
yoyacoo_be/admin): webhook handlers, admin API - Common webhook (
yoyacoo_be/common): expiry batches, notification batches
13. Acceptance Criteria
- A new user can sign up with
?ref=xxxxxx - The referrer’s counters (A and B) update correctly
- 3 free signups trigger a +1-month ticket
- A referee’s first paid charge triggers a +1-month ticket for the referrer
- 5 paid conversions trigger the +7-month bonus
- Monthly users see their next billing date extended correctly
- Yearly users see all held tickets applied at the renewal boundary
- Email + banner notifications are sent 14 days and 3 days before expiration
- Tickets transition to
expiredafter 90 days - Tickets transition to
voidedwhen the user reaches the Free plan - Upgrade pricing is capped by the held-ticket-equivalent value
- The copy button puts the share text + link on the clipboard
- Admins can view all referral data via the admin screen
- Webhook retries do not cause duplicate processing
- Self-referral is prevented
- Referrer tickets (
free_signup_bonus,paid_conversion,milestone_bonus) haveexpires_at = NULLand never transition toexpired - Referee tickets (
referee_reward) expire exactly 90 days aftergranted_at - Pre-expiration notifications (14d / 3d) fire only for
referee_rewardtickets - Counter A
free_signup_bonus_countresets to 0 only onconsumedorvoided(not onexpired) -
user_plans.paid_period_endcolumn exists; invariantpaid_period_end ≤ billing_use_end_dateholds - Backfill populates
paid_period_end = billing_use_end_datefor existing rows - Monthly ticket consumption extends only
billing_use_end_date;paid_period_endunchanged - Yearly batch consumption extends
paid_period_endby exactly 12 months and adds consumed ticket months tobilling_use_end_dateon top - Upgrade pro-rates over
max(0, paid_period_end − now); returnsamountOff = 0when this is 0 - When
amountOff = 0upgrade (bonus-only period), the new plan’spaid_period_end = max(now, old_paid_period_end)andbilling_use_end_datepreservesbonus_remainingmonths - When
amountOff > 0upgrade (mid-paid-period), the new plan’spaid_period_endandbilling_use_end_dateare unchanged from the pre-upgrade values - Held (unconsumed) tickets are not affected by an upgrade; they continue to apply at the next billing cycle
- Downgrade is scheduled (not immediate) — the user keeps the current plan until
billing_use_end_date, then switches to the new tier on the renewal - During the deferred-downgrade period,
paid_period_endandbilling_use_end_dateare unchanged - At
billing_use_end_date, the scheduled downgrade fires: the subscription renews at the new (lower) tier, withpaid_period_endandbilling_use_end_datereset based on the new tier’s price - Already-consumed bonus time during the deferred-downgrade period is enjoyed at the CURRENT (higher) tier, not the new one
- Held tickets remain
status='available'during the deferred-downgrade period and are consumed on the new tier at the renewal - A user can cancel a scheduled downgrade before
billing_use_end_date(revert to staying on the current plan)