Yoyacoo Referral Program — Implementation Plan
Last updated: 2026-07-07
Companion spec: spec.md
Status: Awaiting pre-implementation review — 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)
Implementation order principle: Schema → Models → Common logic → API → Webhook → Batches → Frontend → Integration tests
Phase 0: Preparation
Task 0.1: Final code review
- Review
yoyacoo_be/user/app/Domains/Register/Usecase/VerifyInteractor.php - Review
yoyacoo_be/user/app/Domains/Register/Usecase/SocialVerifyInteractor.php - Identify modification points in
yoyacoo_be/user/app/Domains/Subscription/Usecase/PaymentInteractor.php - Identify modification points in
yoyacoo_be/admin/app/Domains/Subscription/Controllers/PaymentWebhookController.php - Review
yoyacoo_be/common/src/Models/User.phparoundgetUserPlanConfig - Identify modification points in
yoyacoo_be/common/src/Packages/Util/UserDowngradePlan.php - Use
yoyacoo_be/admin/app/Console/Commands/DowngradePlanTrialToFree.phpas a template
Phase 1: Database Schema (Migrations)
Task 1.1: Add columns to the users table
Location: yoyacoo_be/common/database/migrations/YYYYMMDDHHMMSS_add_referral_columns_to_users_table.php
- Add
referral_codeVARCHAR(20) UNIQUE NOT NULL - Add
referrer_idBIGINT UNSIGNED NULL (no FK constraint — avoid circular reference) - Add
idx_users_referrer (referrer_id)index - Add
idx_users_referral_code (referral_code)index (UNIQUE auto-creates it, but be explicit) - Backfill existing users with auto-generated
referral_code(useDB::statementinside the migration)
Verification:
-
php artisan migratecompletes without errors - Existing users have an auto-generated
referral_code -
SHOW INDEX FROM usersshows the expected indexes
Task 1.2: Create the referrals table
Location: yoyacoo_be/common/database/migrations/YYYYMMDDHHMMSS_create_referrals_table.php
-
idBIGINT UNSIGNED PK AUTO_INCREMENT -
referrer_user_idBIGINT UNSIGNED NOT NULL (FK → users.id, ON DELETE CASCADE) -
referee_user_idBIGINT UNSIGNED NOT NULL UNIQUE (FK → users.id, ON DELETE CASCADE) -
registered_atTIMESTAMP NOT NULL -
first_paid_atTIMESTAMP NULL -
first_paid_invoice_idVARCHAR(255) NULL -
sourceENUM(‘link’) NOT NULL DEFAULT ‘link’ -
created_at,updated_atTIMESTAMP - Indexes:
(referrer_user_id),(referee_user_id)UNIQUE
Task 1.3: Create the referral_tickets table
Location: yoyacoo_be/common/database/migrations/YYYYMMDDHHMMSS_create_referral_tickets_table.php
-
idBIGINT UNSIGNED PK AUTO_INCREMENT -
user_idBIGINT UNSIGNED NOT NULL (FK → users.id, ON DELETE CASCADE) -
sourceENUM(‘referee_reward’, ‘free_signup_bonus’, ‘paid_conversion’, ‘milestone_bonus’) NOT NULL -
statusENUM(‘available’, ‘consumed’, ‘voided’, ‘expired’) NOT NULL DEFAULT ‘available’ -
granted_atTIMESTAMP NOT NULL -
expires_atTIMESTAMP NULL —NULLmeans “never expires” (referrer tickets); onlyreferee_rewardcarries a value (granted_at + 90 days) -
consumed_atTIMESTAMP NULL -
consumed_on_user_plan_idBIGINT UNSIGNED NULL (FK → user_plans.id, ON DELETE SET NULL) -
notify_14d_sentBOOLEAN DEFAULT FALSE -
notify_3d_sentBOOLEAN DEFAULT FALSE -
metadataJSON NULL -
created_at,updated_atTIMESTAMP - Indexes:
(user_id, status, expires_at),(status, expires_at),(user_id, source)
Task 1.4: Create the referral_stats table
Location: yoyacoo_be/common/database/migrations/YYYYMMDDHHMMSS_create_referral_stats_table.php
-
user_idBIGINT UNSIGNED PK (FK → users.id, ON DELETE CASCADE) -
total_referredINT NOT NULL DEFAULT 0 -
total_paid_conversionsINT NOT NULL DEFAULT 0 -
free_signup_bonus_countINT NOT NULL DEFAULT 0 -
milestone_5_issuedBOOLEAN NOT NULL DEFAULT FALSE -
updated_atTIMESTAMP
Task 1.5: Create the referral_settings table
Location: yoyacoo_be/common/database/migrations/YYYYMMDDHHMMSS_create_referral_settings_table.php
-
idINT UNSIGNED PK (always 1) -
share_textTEXT NOT NULL -
created_at,updated_atTIMESTAMP - Seed default share text on
id=1
Task 1.6: Add paid_period_end column to user_plans
Location: yoyacoo_be/common/database/migrations/YYYYMMDDHHMMSS_add_paid_period_end_to_user_plans_table.php
-
paid_period_endTIMESTAMP NOT NULL (comment: “End of the paid portion only. bonus = billing_use_end_date − paid_period_end”) - Backfill existing rows:
UPDATE user_plans SET paid_period_end = billing_use_end_date(pre-referral-program rows have no bonus; both columns are equal) - Index:
idx_user_plans_paid_period_end (paid_period_end)(for queries like “users in bonus-only period”) - Invariant check (add to a follow-up test or artisan command): for every
user_plansrow,paid_period_end ≤ billing_use_end_date
Verification:
-
php artisan migratecompletes without errors - Existing rows have
paid_period_end = billing_use_end_date -
SHOW INDEX FROM user_plansshows the new index
Task 1.7: Add scheduled-downgrade columns to user_plans
Location: yoyacoo_be/common/database/migrations/YYYYMMDDHHMMSS_add_scheduled_downgrade_to_user_plans_table.php
Tracks a pending downgrade that fires at billing_use_end_date. Used by the deferred-downgrade flow (spec §5/§7.5).
-
scheduled_new_price_idVARCHAR(255) NULL — the Stripe price ID to switch to at the scheduled change time -
scheduled_change_atTIMESTAMP NULL — when the scheduled change should fire (always equal to the currentbilling_use_end_dateat the time of scheduling) - Composite index:
idx_user_plans_scheduled_change (scheduled_change_at)(for the renewal webhook to find users whose scheduled change is due) - No backfill needed (new columns are nullable; existing rows have no scheduled change)
Verification:
-
php artisan migratecompletes without errors - Existing rows have both new columns as NULL
Task 1.8: Run migrations on all three apps
-
yoyacoo_be/user→php artisan migrate -
yoyacoo_be/customer→php artisan migrate -
yoyacoo_be/admin→php artisan migrate - Confirm all DBs have identical schema
Phase 2: Common Models (Eloquent)
Task 2.1: Referral model
Location: yoyacoo_be/common/src/Models/Referral.php (extends BaseReferral)
- Confirm
BaseReferral(Reliese-generated) -
referrer()relation (belongsTo User) -
referee()relation (belongsTo User) -
scopeByReferrer(User $user)scope -
scopePaid()scope (first_paid_at IS NOT NULL) -
scopeUnpaid()scope (first_paid_at IS NULL)
Task 2.2: ReferralTicket model
Location: yoyacoo_be/common/src/Models/ReferralTicket.php (extends BaseReferralTicket)
-
user()relation (belongsTo User) -
userPlan()relation (belongsTo UserPlan via consumed_on_user_plan_id) -
scopeAvailable()scope (status='available' AND (expires_at IS NULL OR expires_at > now)) — referrer tickets (NULLexpires_at) are always considered available -
scopeExpiringIn14Days()scope (referee only — seescopeExpiringIn14DaysForReferee()) -
scopeExpiringIn3Days()scope (referee only — seescopeExpiringIn3DaysForReferee()) -
scopeExpiredPending()scope (status='available' AND expires_at IS NOT NULL AND expires_at < now) — onlyreferee_rewardrows can match -
markConsumed(int $userPlanId)method -
markVoided()method -
markExpired()method -
isAvailable(): boolhelper -
Sourceenum (PHP 8.1) -
Statusenum
Task 2.3: ReferralStat model
Location: yoyacoo_be/common/src/Models/ReferralStat.php
-
user()relation (belongsTo User) -
incrementReferred(): boolmethod (returns true if a bonus should fire whentotal_referred % 3 == 0) -
incrementPaidConversions(): boolmethod (returns true if the 5-person milestone should fire) -
resetFreeSignupBonusCounter()method — called fromconsumeandvoidpaths only (NOT from an expiration path; referrer tickets never expire) -
markMilestone5Issued()method
Task 2.4: ReferralSetting model
Location: yoyacoo_be/common/src/Models/ReferralSetting.php
-
getShareText(): stringstatic helper (fetch id=1) -
updateShareText(string $text): voidstatic helper
Task 2.5: Additions to the User model
Location: yoyacoo_be/common/src/Models/Base/User.php + yoyacoo_be/common/src/Models/User.php
- Add
referral_code,referrer_idto fillable + casts -
referrer()relation (belongsTo User) -
referrals()relation (hasMany Referral via referrer_user_id) -
referredBy()relation (hasOne Referral via referee_user_id) -
referralTickets()relation (hasMany ReferralTicket) -
referralStats()relation (hasOne ReferralStat) -
generateReferralCode()static helper (base62, with collision retry) - Auto-generate
referral_codein the model’screatingevent
Task 2.6: Additions to the UserPlan model
Location: yoyacoo_be/common/src/Models/UserPlan.php
-
referralTickets()relation (hasMany ReferralTicket via consumed_on_user_plan_id) -
extendByMonths(int $months): boolmethod (pushbilling_use_end_dateforward) -
getRemainingMonths(): inthelper (months from now)
Phase 3: Service Layer
Task 3.1: ReferralCodeService
Location: yoyacoo_be/common/src/Packages/Service/ReferralCodeService.php
-
generate(): stringstatic (14-char base62, retry up to 5 times on collision) -
find(string $code): ?Userstatic (lookup byreferral_code)
Task 3.2: ReferralIssuanceService
Location: yoyacoo_be/common/src/Packages/Service/ReferralIssuanceService.php
-
recordReferral(User $referrer, User $referee): Referral(create referral + update stats + Counter A check) -
recordFirstPaid(User $referee, Invoice $invoice): void(referee_reward + paid_conversion + 5-person check) -
grantFreeSignupBonus(User $referrer): ReferralTicket(internal —expires_at = NULL, referrer ticket) -
grantPaidConversionBonus(User $referrer): ReferralTicket(internal —expires_at = NULL, referrer ticket) -
grantMilestoneBonus(User $referrer): array(7 tickets in one go, each withexpires_at = NULL, referrer ticket) -
grantRefereeReward(User $referee): ReferralTicket(internal —expires_at = granted_at + 90 days, referee ticket) - All methods wrapped in a transaction + idempotency check
Task 3.3: TicketApplicationService
Location: yoyacoo_be/common/src/Packages/Service/TicketApplicationService.php
-
applyMonthlyTicket(User $user, UserPlan $plan): ?ReferralTicket(consume 1 ticket, extendbilling_use_end_dateby 1 month; do NOT touchpaid_period_end; after consumption, resetfree_signup_bonus_countif the consumed ticket was afree_signup_bonus) -
applyYearlyTickets(User $user, UserPlan $plan): array(consume all available tickets, batch extension: setnew_paid_period_end = old_paid_period_end + 12 monthsand setnew_billing_use_end_date = new_paid_period_end + ticket_months_consumed; resetfree_signup_bonus_countfor any consumedfree_signup_bonustickets) -
voidAllForUser(User $user, string $reason): int(called on Free-plan arrival; resetfree_signup_bonus_countif any voided ticket was afree_signup_bonus) -
consumeOldest(User $user): ?ReferralTicket(internal) -
extendUserPlan(UserPlan $plan, int $months): void(internal — DB update) -
updateStripeSubscription(UserPlan $plan, int $months): void(internal — Stripe API)
Task 3.4: TicketExpiryService
Location: yoyacoo_be/common/src/Packages/Service/TicketExpiryService.php
-
expireOverdue(): int(mark expired — usesexpires_at IS NOT NULL AND expires_at < nowto exclude referrer tickets) -
resetExpiredCounters(): int(Counter A expiration-driven reset — deprecated, kept as a no-op stub; the consume/void paths inTicketApplicationServicenow handle Counter A reset) -
findExpiringIn14Days(): Collection(referee only — query filterssource='referee_reward') -
findExpiringIn3Days(): Collection(referee only — query filterssource='referee_reward') -
markNotified(ReferralTicket $ticket, string $when): void(14d or 3d)
Task 3.5: UpgradePricingService
Location: yoyacoo_be/common/src/Packages/Service/UpgradePricingService.php
-
getPaidRemainingMonths(User $user, UserPlan $plan): int—max(0, paid_period_end − now)in months (rounded up to the nearest day) -
getBonusRemainingMonths(User $user, UserPlan $plan): int—max(0, billing_use_end_date − max(now, paid_period_end))in months -
calculateAmountOffWithTickets(User $user, UserPlan $plan, string $newPriceId, string $currentPriceId): int— returns(new_price_per_month − old_price_per_month) × paid_remaining; returns 0 whenpaid_remaining = 0(free upgrade in bonus-only period). Does NOT look at held tickets — tickets are unaffected by the upgrade. -
isFreeUpgrade(User $user, UserPlan $plan): bool— true whenpaid_remaining = 0 -
applyUpgradeToUserPlan(UserPlan $plan, bool $isFreeUpgrade): UserPlan— apply the new tier: if!isFreeUpgrade, leavepaid_period_endandbilling_use_end_dateunchanged; ifisFreeUpgrade, setnew_paid_period_end = max(now, old_paid_period_end)andnew_billing_use_end_date = new_paid_period_end + bonus_remaining. Returns the updated plan. - Remove (or repurpose) the old
getAvailableTicketValue()andcapAmountOffByTickets()methods — they are no longer used (held tickets do NOT cap the upgrade; onlypaid_remainingdoes)
Phase 4: User-Facing API (yoyacoo_be/user)
Task 4.1: Capture ?ref= at signup
Location: yoyacoo_be/user/app/Domains/Register/Usecase/VerifyInteractor.php (modify)
- Add
?string $referralCodetoVerifyInterface -
VerifyInteractor::handlereceives thereferralCode - Look up the referrer via
ReferralCodeService::find($referralCode)before creating theUser - Self-referral check (if referrer ID == new user ID, set NULL)
- Set
referrer_idwhen creating theUser - After
User::created, callReferralIssuanceService::recordReferral() - Apply the same change to
SocialVerifyInteractor
Location: yoyacoo_be/user/app/Domains/Register/Controller/VerifyAction.php (modify)
- Extract
?ref=from the request query string - Pass it to
VerifyInterfaceand callinteractor->handle()
Location: yoyacoo_be/user/app/Domains/Register/Controller/Request/VerifyRequest.php (modify)
- Add
reffield (optional, string, max:20)
Task 4.2: Get-my-referral API
Location: yoyacoo_be/user/app/Http/Controllers/Api/Referral/ShowReferralAction.php (new)
- GET
/api/referrals/meendpoint - Response:
{ referral_code, referral_url, stats, held_tickets: [...], share_text } - Auth middleware (
auth:sanctum) - Generate
referral_codeon the fly if missing - Referral URL:
config('services.domains.user') + '/register?ref=' + $user->referral_code
Task 4.3: List-tickets API
Location: yoyacoo_be/user/app/Http/Controllers/Api/Referral/ListTicketsAction.php (new)
- GET
/api/referrals/me/ticketsendpoint - Query params:
status(available|consumed|voided|expired),source - Pagination (20/page)
- Response in JSON Resource format
Task 4.4: List-referees API
Location: yoyacoo_be/user/app/Http/Controllers/Api/Referral/ListRefereesAction.php (new)
- GET
/api/referrals/me/refereesendpoint - Each entry:
referee_id, referee_name, registered_at, first_paid_at, status - Pagination
Task 4.5: Routes
Location: yoyacoo_be/user/routes/api.php (inside the auth:sanctum group)
Route::prefix('referrals')->group(function (): void {
Route::get('me', \App\Http\Controllers\Api\Referral\ShowReferralAction::class);
Route::get('me/tickets', \App\Http\Controllers\Api\Referral\ListTicketsAction::class);
Route::get('me/referees', \App\Http\Controllers\Api\Referral\ListRefereesAction::class);
});
Task 4.6: Resource classes
-
yoyacoo_be/user/app/Http/Resources/Referral/ReferralResource.php -
yoyacoo_be/user/app/Http/Resources/Referral/ReferralTicketResource.php -
yoyacoo_be/user/app/Http/Resources/Referral/RefereeResource.php -
yoyacoo_be/user/app/Http/Resources/Referral/ReferralStatsResource.php
Phase 5: Admin-Facing API (yoyacoo_be/admin)
Task 5.1: List-referrals API
Location: yoyacoo_be/admin/app/Http/Controllers/Api/Referral/IndexReferralAction.php (new)
- GET
/api/admin/referralsendpoint - Query params:
referrer_id,referee_id,paid_status,from_date,to_date - Pagination
- Admin auth middleware
Task 5.2: Referrer-detail API
Location: yoyacoo_be/admin/app/Http/Controllers/Api/Referral/ShowReferrerAction.php (new)
- GET
/api/admin/referrals/{userId}endpoint - Response: referrer info + referee list + ticket history
Task 5.3: Stats API
Location: yoyacoo_be/admin/app/Http/Controllers/Api/Referral/StatsReferralAction.php (new)
- GET
/api/admin/referrals/statsendpoint - Aggregate stats: total referrals, paid conversions, top 10 referrers
Task 5.4: Settings API
Location: yoyacoo_be/admin/app/Http/Controllers/Api/Referral/SettingsAction.php (new)
- GET
/api/admin/referral-settings— fetch current settings - PUT
/api/admin/referral-settings— update settings - Request validation:
share_textrequired, max:1000
Task 5.5: Routes
Location: yoyacoo_be/admin/routes/api.php (inside the admin-auth group)
Route::prefix('admin/referrals')->group(function (): void {
Route::get('', \App\Http\Controllers\Api\Referral\IndexReferralAction::class);
Route::get('stats', \App\Http\Controllers\Api\Referral\StatsReferralAction::class);
Route::get('{userId}', \App\Http\Controllers\Api\Referral\ShowReferrerAction::class)->whereNumber('userId');
Route::get('settings', \App\Http\Controllers\Api\Referral\SettingsAction::class . '@show');
Route::put('settings', \App\Http\Controllers\Api\Referral\SettingsAction::class . '@update');
});
Phase 6: Stripe Webhook Modifications (yoyacoo_be/admin)
Task 6.1: First-paid-charge detection
Location: yoyacoo_be/admin/app/Domains/Subscription/Controllers/PaymentWebhookController.php (modify)
- Inside
handleCustomerSubscriptionUpdated, detectuser_payments.statustransition from UNSETTLED to SETTLED - New helper:
detectFirstPaidCharge(User $user, array $data): void - Inside the helper, call
ReferralIssuanceService::recordFirstPaid() - Idempotency: guard on
referrals.first_paid_at IS NULL - Log:
[Referral] first paid detected for user {id}
Task 6.2: Add the invoice.created handler
Location: yoyacoo_be/admin/app/Domains/Subscription/Controllers/PaymentWebhookController.php (modify)
- New method:
handleInvoiceCreated(array $payload) - Logic: identify the user → fetch available tickets → apply
- Monthly:
TicketApplicationService::applyMonthlyTicket() - Yearly:
TicketApplicationService::applyYearlyTickets() - Post-application check: confirm
user_plans.billing_use_end_datewas updated as expected - Log:
[Referral] ticket applied to user {id} for sub {sub_id}
Task 6.3: Webhook idempotency tests
- Sending the same
invoice.payment_succeededtwice does not double-issue tickets - Sending the same
invoice.createdtwice does not double-extend - Use the Stripe CLI to test retry scenarios
Phase 7: Plan-Change Integration (yoyacoo_be/user)
Task 7.1: PaymentInteractor modifications
Location: yoyacoo_be/user/app/Domains/Subscription/Usecase/PaymentInteractor.php (modify)
- Use
UpgradePricingServiceinhandle - On upgrade, recalculate via
calculateAmountOffWithTickets() - Cap
amountOffby the held-ticket-equivalent value - Log:
[Referral] upgrade pricing calculated: amountOff={X}, ticketValue={Y}
Task 7.2: CancelInteractor modifications
Location: yoyacoo_be/user/app/Domains/Subscription/Usecase/CancelInteractor.php (modify)
- At cancellation time (cancel_at_period_end), do nothing extra
- Add a comment: tickets are voided together inside
userDowngradePlanat period end
Task 7.3: userDowngradePlan modifications
Location: yoyacoo_be/common/src/Packages/Util/UserDowngradePlan.php (modify)
- At the end of
userDowngradePlan(int $userId), void all tickets - Call
TicketApplicationService::voidAllForUser($user, 'downgraded_to_free') - Inside
voidAllForUser: if any voided ticket wasfree_signup_bonus, resetreferral_stats.free_signup_bonus_count = 0so the next 3 free signups can issue a new bonus - NEW: Also clear any scheduled downgrade columns (
scheduled_new_price_id,scheduled_change_at) — by the time the user is on Free, the scheduled change is moot - Log:
[Referral] {N} tickets voided for user {id} (downgrade to free)
Task 7.4: DowngradeInteractor (NEW — deferred downgrade flow)
Location: yoyacoo_be/user/app/Domains/Subscription/Usecase/DowngradeInteractor.php (new)
The user-facing downgrade flow. Replaces the previous “tickets carry over” behavior with a scheduled change at billing_use_end_date.
-
schedule(User $user, UserPlan $plan, string $newPriceId): void- Set
user_plans.scheduled_new_price_id = $newPriceId - Set
user_plans.scheduled_change_at = $plan->billing_use_end_date - DO NOT touch
paid_period_endorbilling_use_end_date— the user keeps the current plan until then - Log:
[Referral] downgrade scheduled for user {id}: {old_price_id} → {new_price_id} at {scheduled_at}
- Set
-
cancelSchedule(User $user, UserPlan $plan): void- Clear
user_plans.scheduled_new_price_idandscheduled_change_at(set to NULL) - Log:
[Referral] scheduled downgrade cancelled for user {id}
- Clear
- Triggered from the same UI entry point as the previous immediate downgrade (e.g., a “Downgrade” button on the plan page). The user does NOT see any feature change immediately — they see a confirmation: “Your plan will switch to {new} on {date}. You can cancel this any time before then.”
Task 7.5: Renewal webhook — fire scheduled downgrades
Location: yoyacoo_be/admin/app/Domains/Subscription/Controllers/PaymentWebhookController.php (modify handleInvoiceCreated)
When the renewal invoice is created at billing_use_end_date, check for a scheduled downgrade before issuing the renewal.
- In
handleInvoiceCreated, after identifying the user and theirUserPlan:- If
user_plans.scheduled_new_price_id IS NOT NULLANDscheduled_change_at <= now:- Use
scheduled_new_price_idas the new price for the renewal - Create the new Stripe subscription with the scheduled price
- Clear
user_plans.scheduled_new_price_idandscheduled_change_at(set to NULL) - Log:
[Referral] scheduled downgrade fired for user {id}: switched to {new_price_id}
- Use
- Otherwise: proceed with the normal renewal at the current price
- If
- Idempotency: if the same renewal event fires twice, the second one should find
scheduled_new_price_id = NULLand proceed normally (no double-swap)
Task 7.6: Plan-switch E2E tests
- Monthly → Yearly: remaining tickets extend the new yearly contract
- Lite → Standard upgrade: the difference is capped by ticket value
- Standard → Lite downgrade (DEFERRED): user clicks downgrade → no immediate feature change → at
billing_use_end_datethe subscription renews at the new (lower) tier - Downgrade with active bonus: user has 6 months of bonus; downgrades at month 6 of an 18-month period; stays on Standard for the remaining 12 months (6 paid + 6 bonus), then switches to Lite at month 18
- Downgrade with held tickets: held tickets remain
status='available'during the deferred period; consumed at renewal on the new (lower) tier - Cancel scheduled downgrade: user schedules downgrade, then cancels it; the scheduled columns are cleared; no tier change at renewal
- Scheduled downgrade + cancellation race: user schedules downgrade, then cancels subscription; at
billing_use_end_date,userDowngradePlanruns, voids tickets, clears scheduled columns, user goes to Free
Phase 8: Batch Jobs (yoyacoo_be/admin)
Task 8.1: Ticket expiration batch
Location: yoyacoo_be/admin/app/Console/Commands/ExpireReferralTickets.php (new)
- Signature:
signature = 'referral:expire-tickets' - Call
TicketExpiryService::expireOverdue()— in practice this only affectsreferee_rewardtickets (referrer tickets haveexpires_at = NULL) - Log how many tickets were expired
- Schedule in
app/Console/Kernel.php: run nightly at 02:00
Task 8.2: Counter reset batch
Location: yoyacoo_be/admin/app/Console/Commands/ResetReferralCounters.php (new)
- Signature:
signature = 'referral:reset-counters' - Call
TicketExpiryService::resetExpiredCounters()— kept as a safety no-op for backward compatibility; the real Counter A reset happens inside the consume/void paths ofTicketApplicationService - Log the result (expected: 0 rows affected in steady state)
- Schedule: run right after the expiration batch (optional — can be removed in a follow-up)
Task 8.3: Expiration-warning notification batch
Location: yoyacoo_be/admin/app/Console/Commands/NotifyExpiringTickets.php (new)
- Signature:
signature = 'referral:notify-expiring' - Process 14-day expiring tickets: query
source='referee_reward' AND expires_at BETWEEN now+13d AND now+15d→ send email + setnotify_14d_sent=TRUE - Process 3-day expiring tickets: query
source='referee_reward' AND expires_at BETWEEN now+2d AND now+4d→ send email + setnotify_3d_sent=TRUE - The
source='referee_reward'filter excludes referrer tickets (which never expire) - Create the email templates (see Section 10.1)
- Schedule: run daily at 09:00
Task 8.4: Scheduler registration
Location: yoyacoo_be/admin/app/Console/Kernel.php (verify / append)
-
$schedule->command('referral:expire-tickets')->dailyAt('02:00') -
$schedule->command('referral:reset-counters')->dailyAt('02:30')(optional — see Task 8.2) -
$schedule->command('referral:notify-expiring')->dailyAt('09:00')
Phase 9: Email Templates (yoyacoo_be/user)
Task 9.1: Create each template
-
yoyacoo_be/user/resources/views/emails/html/referral_free_signup_bonus.blade.php -
yoyacoo_be/user/resources/views/emails/html/referral_paid_conversion.blade.php -
yoyacoo_be/user/resources/views/emails/html/referral_milestone_5.blade.php -
yoyacoo_be/user/resources/views/emails/html/referral_ticket_expiring_14d.blade.php -
yoyacoo_be/user/resources/views/emails/html/referral_ticket_expiring_3d.blade.php -
yoyacoo_be/user/resources/views/emails/html/referral_ticket_consumed.blade.php - Each template is finalized after design (consult with Furuha)
Task 9.2: Plain-text variants
- Create a text version of each template under
emails/text/...
Phase 10: Frontend (yoyacoo_fe/supplier)
Task 10.1: Shared type definitions
Location: yoyacoo_fe/supplier/src/types/referral.ts
-
Referraltype -
ReferralTickettype -
ReferralStatstype -
Refereetype -
ReferralSettingstype
Task 10.2: API client
Location: yoyacoo_fe/supplier/src/apis/referral.ts
-
getMyReferral(): Promise<Referral> -
getMyTickets(params): Promise<ReferralTicket[]> -
getMyReferees(params): Promise<Referee[]> - Error handling
Task 10.3: Banner component
Location: yoyacoo_fe/supplier/src/components/organisms/referral/ReferralBanner.tsx
- “Refer Yoyacoo?” CTA
- On click → navigate to
/accounts/referral - Hover state shows the held-ticket count badge
Task 10.4: Progress bar components
Location: yoyacoo_fe/supplier/src/components/molecules/referral/ProgressBarPaid.tsx
Location: yoyacoo_fe/supplier/src/components/molecules/referral/ProgressBarFree.tsx
- Paid: 0–5 hearts (gold)
- Free: 0–3 hearts (green)
- Animation on milestone completion
Task 10.5: Ticket badge
Location: yoyacoo_fe/supplier/src/components/atoms/referral/TicketBadge.tsx
- Shows the held-ticket count
- Warning color when close to expiration
Task 10.6: Expiration warning banner
Location: yoyacoo_fe/supplier/src/components/atoms/referral/ExpiringTicketBanner.tsx
- Shown only when there is a ticket expiring within 14 days
- On click → navigate to the Referral page
Task 10.7: Referral page
Location: yoyacoo_fe/supplier/src/pages/accounts/referral/index.tsx
- Referral link display
- Copy button (share text + link)
- Two progress bars
- Ticket list
- Referee list
- Expiration banner
Task 10.8: Side menu entry
Location: yoyacoo_fe/supplier/src/components/organisms/sidemenu/index.tsx (modify)
- Add “Referral Program” link
- Icon + label
Task 10.9: Registration screen modifications
Location: yoyacoo_fe/customer/src/pages/register/... and yoyacoo_fe/supplier/src/pages/accounts/register/...
- Custom hook to read
?ref=from the URL - Add a read-only invitation code display
- Include
refin the registration API payload - Show the offer description
Phase 11: Admin Frontend (yoyacoo_fe/admin)
Task 11.1: Type definitions and API client
-
yoyacoo_fe/admin/src/types/referral.ts -
yoyacoo_fe/admin/src/apis/referral.ts
Task 11.2: List page
Location: yoyacoo_fe/admin/src/pages/referrals/index.tsx
- Table format
- Filters (referrer/referee/status/date)
- Pagination
Task 11.3: Detail page
Location: yoyacoo_fe/admin/src/pages/referrals/[userId].tsx
- Referrer info
- Referee list
- Ticket history
- Aggregate stats
Task 11.4: Settings page
Location: yoyacoo_fe/admin/src/pages/referrals/settings.tsx
- Text area
- Preview
- Save button
Phase 12: Testing
Task 12.1: Unit tests (PHPUnit)
-
tests/Unit/Common/ReferralCodeServiceTest.php— code generation -
tests/Unit/Common/ReferralIssuanceServiceTest.php— reward issuance -
tests/Unit/Common/TicketApplicationServiceTest.php— ticket consumption -
tests/Unit/Common/TicketExpiryServiceTest.php— expiration logic -
tests/Unit/Common/UpgradePricingServiceTest.php— pricing math
Task 12.2: Feature tests
-
tests/Feature/User/RegisterWithReferralTest.php—?ref=signup -
tests/Feature/User/ReferralApiTest.php— user-side API -
tests/Feature/Admin/ReferralApiTest.php— admin-side API -
tests/Feature/Admin/PaymentWebhookReferralTest.php— webhook integration -
tests/Feature/Admin/ReferralBatchTest.php— batch jobs
Task 12.3: E2E tests
- New user signs up via
?ref=xxx→ referrer counters increment - Referee pays → referrer gets a ticket + 5-person milestone bonus triggers correctly
- Monthly user: next invoice consumes a ticket
- Yearly user: annual renewal batch-consumes all tickets
- After 90 days,
referee_rewardtickets becomeexpired;free_signup_bonus/paid_conversion/milestone_bonustickets do NOT - 14-day and 3-day warnings arrive (referee only — verify referrer never receives these)
- Free-plan arrival voids all tickets and resets
free_signup_bonus_countto 0 - Upgrade pricing is capped by ticket value
- Copy button puts the correct text + link on the clipboard
- Free-plan user with held
free_signup_bonusticket: counter stays at 1 across further free signups; only resets after the user upgrades and the bonus is consumed
Upgrade-with-tickets scenarios (spec §5, new):
- Example A — Monthly, mid-paid-period, 1 held ticket: upgrade charges
amountOff = (Std − Lite) × paid_remaining(> 0); held ticket stays held - Example B — Monthly, mid-bonus-period (consumed 1 ticket, in bonus): upgrade is free (
amountOff = 0); newpaid_period_end = now, newbilling_use_end_date = now + bonus_remaining; user gets the bonus months at the new tier - Example C — Yearly, 6-ticket batch consumed, mid-bonus-period: upgrade is free; user gets 6 free months of the new tier
- Example D — Yearly, mid-paid-with-bonus (paid_period_end in the future, bonus extending further): upgrade charges
amountOff = (Std_yearly − Lite_yearly) × paid_remaining / 12;paid_period_endandbilling_use_end_dateunchanged - After free upgrade,
paid_period_end ≤ billing_use_end_dateinvariant still holds - After paid upgrade,
paid_period_end ≤ billing_use_end_dateinvariant still holds - Held (unconsumed) tickets are unaffected by any upgrade and continue to apply at the next billing cycle
Task 12.4: Frontend tests
-
yoyacoo_fe/supplier/src/components/organisms/referral/*.test.tsx -
yoyacoo_fe/supplier/src/pages/accounts/referral/index.test.tsx
Phase 13: Documentation
Task 13.1: Operations docs
-
docs/Referral-link/operations.md— operations runbook- How to change the share text
- How to inspect referrals in the admin screen
- Troubleshooting (webhook failures, incorrect ticket issuance)
-
docs/Referral-link/monitoring.md— monitoring checklist- Daily batch success/failure verification
- Referral trends
- 5-person milestone achievers
Task 13.2: Blog illustrations
- Paid-conversion bonus diagram
- 5-person milestone bonus diagram
- 3-free-signup bonus diagram
- Referee reward diagram
- (Produced by the design team)
Phase 14: Deployment Prep
Task 14.1: Environment variables
- Confirm no new env vars needed (existing Stripe keys are sufficient)
Task 14.2: Feature flag
- Add
REFERRAL_PROGRAM_ENABLEDenv var (config/referral.php) - When OFF: hide UI + skip webhook handlers
- Initial release: FALSE; flip to TRUE on production cutover
Task 14.3: Production cutover
- E2E suite green in staging
- Run migrations on all three apps
- Configure the three cron jobs
- Verify webhook URL (Stripe dashboard)
- Confirm
referral_settingsseed data - Flip the feature flag ON
- Monitor for one week post-release
Critical Implementation Reminders
- Idempotency is the top priority: webhooks retry. Every main operation must use a UNIQUE constraint + select-then-act pattern.
- Transaction boundaries: creating a referral, updating stats, and issuing a ticket must be in a single transaction.
- Stripe API error handling: define a clear retry strategy when
Subscription::update/cancelfail. - Yearly batch application: if any ticket fails, roll back the whole batch — leave tickets as
available, notvoided. - Self-referral prevention: enforce
referrer_id != user_idat signup. - 3-app split: place new code in the right app (common models → user/admin business logic).
- Follow existing patterns: mirror the Coupon feature’s domain structure, PaymentInteractor’s shape, and the Cashier webhook style.
- PHPStan compliance: new code must pass phpstan at the configured level.
- Migration duplication: copy each new migration into all three apps’
database/migrations/directories. - E2E tests via Stripe CLI: do not connect to real Stripe — use the CLI to fire webhooks locally.
Reference: Existing Patterns
- Coupon feature: see
docs/967-Coupon/(DESIGN.md / INTEGRATION.md) - Webhook pattern:
yoyacoo_be/admin/app/Domains/Subscription/Controllers/PaymentWebhookController.php - Subscription upgrade:
yoyacoo_be/user/app/Domains/Subscription/Usecase/PaymentInteractor.php - Batch pattern:
yoyacoo_be/admin/app/Console/Commands/DowngradePlanTrialToFree.php - Sendgrid email:
yoyacoo_be/user/app/Domains/Register/Listener/UserRegisterCompleteMail.php
Effort Estimate (Reference)
Indicative effort (single engineer baseline):
- Phases 1–3 (DB + models + services): 3–4 days
- Phases 4–5 (API): 2–3 days
- Phases 6–7 (Webhook + integration): 2–3 days
- Phases 8–9 (Batches + emails): 1–2 days
- Phases 10–11 (Frontend): 3–4 days
- Phase 12 (Tests): 2–3 days
- Phases 13–14 (Docs + deploy): 1–2 days
Total: ~14–21 days (1 engineer)
Note: email templates and UI components pending design finalization. Additional work may be required once designs are confirmed.