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.php around getUserPlanConfig
  • Identify modification points in yoyacoo_be/common/src/Packages/Util/UserDowngradePlan.php
  • Use yoyacoo_be/admin/app/Console/Commands/DowngradePlanTrialToFree.php as 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_code VARCHAR(20) UNIQUE NOT NULL
  • Add referrer_id BIGINT 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 (use DB::statement inside the migration)

Verification:

  • php artisan migrate completes without errors
  • Existing users have an auto-generated referral_code
  • SHOW INDEX FROM users shows the expected indexes

Task 1.2: Create the referrals table

Location: yoyacoo_be/common/database/migrations/YYYYMMDDHHMMSS_create_referrals_table.php

  • id BIGINT UNSIGNED PK AUTO_INCREMENT
  • referrer_user_id BIGINT UNSIGNED NOT NULL (FK → users.id, ON DELETE CASCADE)
  • referee_user_id BIGINT UNSIGNED NOT NULL UNIQUE (FK → users.id, ON DELETE CASCADE)
  • registered_at TIMESTAMP NOT NULL
  • first_paid_at TIMESTAMP NULL
  • first_paid_invoice_id VARCHAR(255) NULL
  • source ENUM(‘link’) NOT NULL DEFAULT ‘link’
  • created_at, updated_at TIMESTAMP
  • 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

  • id BIGINT UNSIGNED PK AUTO_INCREMENT
  • user_id BIGINT UNSIGNED NOT NULL (FK → users.id, ON DELETE CASCADE)
  • source ENUM(‘referee_reward’, ‘free_signup_bonus’, ‘paid_conversion’, ‘milestone_bonus’) NOT NULL
  • status ENUM(‘available’, ‘consumed’, ‘voided’, ‘expired’) NOT NULL DEFAULT ‘available’
  • granted_at TIMESTAMP NOT NULL
  • expires_at TIMESTAMP NULLNULL means “never expires” (referrer tickets); only referee_reward carries a value (granted_at + 90 days)
  • consumed_at TIMESTAMP NULL
  • consumed_on_user_plan_id BIGINT UNSIGNED NULL (FK → user_plans.id, ON DELETE SET NULL)
  • notify_14d_sent BOOLEAN DEFAULT FALSE
  • notify_3d_sent BOOLEAN DEFAULT FALSE
  • metadata JSON NULL
  • created_at, updated_at TIMESTAMP
  • 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_id BIGINT UNSIGNED PK (FK → users.id, ON DELETE CASCADE)
  • total_referred INT NOT NULL DEFAULT 0
  • total_paid_conversions INT NOT NULL DEFAULT 0
  • free_signup_bonus_count INT NOT NULL DEFAULT 0
  • milestone_5_issued BOOLEAN NOT NULL DEFAULT FALSE
  • updated_at TIMESTAMP

Task 1.5: Create the referral_settings table

Location: yoyacoo_be/common/database/migrations/YYYYMMDDHHMMSS_create_referral_settings_table.php

  • id INT UNSIGNED PK (always 1)
  • share_text TEXT NOT NULL
  • created_at, updated_at TIMESTAMP
  • 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_end TIMESTAMP 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_plans row, paid_period_end ≤ billing_use_end_date

Verification:

  • php artisan migrate completes without errors
  • Existing rows have paid_period_end = billing_use_end_date
  • SHOW INDEX FROM user_plans shows 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_id VARCHAR(255) NULL — the Stripe price ID to switch to at the scheduled change time
  • scheduled_change_at TIMESTAMP NULL — when the scheduled change should fire (always equal to the current billing_use_end_date at 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 migrate completes without errors
  • Existing rows have both new columns as NULL

Task 1.8: Run migrations on all three apps

  • yoyacoo_be/userphp artisan migrate
  • yoyacoo_be/customerphp artisan migrate
  • yoyacoo_be/adminphp 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 (NULL expires_at) are always considered available
  • scopeExpiringIn14Days() scope (referee only — see scopeExpiringIn14DaysForReferee())
  • scopeExpiringIn3Days() scope (referee only — see scopeExpiringIn3DaysForReferee())
  • scopeExpiredPending() scope (status='available' AND expires_at IS NOT NULL AND expires_at < now) — only referee_reward rows can match
  • markConsumed(int $userPlanId) method
  • markVoided() method
  • markExpired() method
  • isAvailable(): bool helper
  • Source enum (PHP 8.1)
  • Status enum

Task 2.3: ReferralStat model

Location: yoyacoo_be/common/src/Models/ReferralStat.php

  • user() relation (belongsTo User)
  • incrementReferred(): bool method (returns true if a bonus should fire when total_referred % 3 == 0)
  • incrementPaidConversions(): bool method (returns true if the 5-person milestone should fire)
  • resetFreeSignupBonusCounter() method — called from consume and void paths 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(): string static helper (fetch id=1)
  • updateShareText(string $text): void static 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_id to 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_code in the model’s creating event

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): bool method (push billing_use_end_date forward)
  • getRemainingMonths(): int helper (months from now)

Phase 3: Service Layer

Task 3.1: ReferralCodeService

Location: yoyacoo_be/common/src/Packages/Service/ReferralCodeService.php

  • generate(): string static (14-char base62, retry up to 5 times on collision)
  • find(string $code): ?User static (lookup by referral_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 with expires_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, extend billing_use_end_date by 1 month; do NOT touch paid_period_end; after consumption, reset free_signup_bonus_count if the consumed ticket was a free_signup_bonus)
  • applyYearlyTickets(User $user, UserPlan $plan): array (consume all available tickets, batch extension: set new_paid_period_end = old_paid_period_end + 12 months and set new_billing_use_end_date = new_paid_period_end + ticket_months_consumed; reset free_signup_bonus_count for any consumed free_signup_bonus tickets)
  • voidAllForUser(User $user, string $reason): int (called on Free-plan arrival; reset free_signup_bonus_count if any voided ticket was a free_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 — uses expires_at IS NOT NULL AND expires_at < now to exclude referrer tickets)
  • resetExpiredCounters(): int (Counter A expiration-driven reset — deprecated, kept as a no-op stub; the consume/void paths in TicketApplicationService now handle Counter A reset)
  • findExpiringIn14Days(): Collection (referee only — query filters source='referee_reward')
  • findExpiringIn3Days(): Collection (referee only — query filters source='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): intmax(0, paid_period_end − now) in months (rounded up to the nearest day)
  • getBonusRemainingMonths(User $user, UserPlan $plan): intmax(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 when paid_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 when paid_remaining = 0
  • applyUpgradeToUserPlan(UserPlan $plan, bool $isFreeUpgrade): UserPlan — apply the new tier: if !isFreeUpgrade, leave paid_period_end and billing_use_end_date unchanged; if isFreeUpgrade, set new_paid_period_end = max(now, old_paid_period_end) and new_billing_use_end_date = new_paid_period_end + bonus_remaining. Returns the updated plan.
  • Remove (or repurpose) the old getAvailableTicketValue() and capAmountOffByTickets() methods — they are no longer used (held tickets do NOT cap the upgrade; only paid_remaining does)

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 $referralCode to VerifyInterface
  • VerifyInteractor::handle receives the referralCode
  • Look up the referrer via ReferralCodeService::find($referralCode) before creating the User
  • Self-referral check (if referrer ID == new user ID, set NULL)
  • Set referrer_id when creating the User
  • After User::created, call ReferralIssuanceService::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 VerifyInterface and call interactor->handle()

Location: yoyacoo_be/user/app/Domains/Register/Controller/Request/VerifyRequest.php (modify)

  • Add ref field (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/me endpoint
  • Response: { referral_code, referral_url, stats, held_tickets: [...], share_text }
  • Auth middleware (auth:sanctum)
  • Generate referral_code on 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/tickets endpoint
  • 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/referees endpoint
  • 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/referrals endpoint
  • 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/stats endpoint
  • 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_text required, 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, detect user_payments.status transition 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_date was 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_succeeded twice does not double-issue tickets
  • Sending the same invoice.created twice 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 UpgradePricingService in handle
  • On upgrade, recalculate via calculateAmountOffWithTickets()
  • Cap amountOff by 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 userDowngradePlan at 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 was free_signup_bonus, reset referral_stats.free_signup_bonus_count = 0 so 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_end or billing_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}
  • cancelSchedule(User $user, UserPlan $plan): void
    • Clear user_plans.scheduled_new_price_id and scheduled_change_at (set to NULL)
    • Log: [Referral] scheduled downgrade cancelled for user {id}
  • 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 their UserPlan:
    • If user_plans.scheduled_new_price_id IS NOT NULL AND scheduled_change_at <= now:
      • Use scheduled_new_price_id as the new price for the renewal
      • Create the new Stripe subscription with the scheduled price
      • Clear user_plans.scheduled_new_price_id and scheduled_change_at (set to NULL)
      • Log: [Referral] scheduled downgrade fired for user {id}: switched to {new_price_id}
    • Otherwise: proceed with the normal renewal at the current price
  • Idempotency: if the same renewal event fires twice, the second one should find scheduled_new_price_id = NULL and 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_date the 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, userDowngradePlan runs, 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 affects referee_reward tickets (referrer tickets have expires_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 of TicketApplicationService
  • 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 + set notify_14d_sent=TRUE
  • Process 3-day expiring tickets: query source='referee_reward' AND expires_at BETWEEN now+2d AND now+4d → send email + set notify_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

  • Referral type
  • ReferralTicket type
  • ReferralStats type
  • Referee type
  • ReferralSettings type

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 ref in 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_reward tickets become expired; free_signup_bonus / paid_conversion / milestone_bonus tickets 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_count to 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_bonus ticket: 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); new paid_period_end = now, new billing_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_end and billing_use_end_date unchanged
  • After free upgrade, paid_period_end ≤ billing_use_end_date invariant still holds
  • After paid upgrade, paid_period_end ≤ billing_use_end_date invariant 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_ENABLED env 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_settings seed data
  • Flip the feature flag ON
  • Monitor for one week post-release

Critical Implementation Reminders

  1. Idempotency is the top priority: webhooks retry. Every main operation must use a UNIQUE constraint + select-then-act pattern.
  2. Transaction boundaries: creating a referral, updating stats, and issuing a ticket must be in a single transaction.
  3. Stripe API error handling: define a clear retry strategy when Subscription::update / cancel fail.
  4. Yearly batch application: if any ticket fails, roll back the whole batch — leave tickets as available, not voided.
  5. Self-referral prevention: enforce referrer_id != user_id at signup.
  6. 3-app split: place new code in the right app (common models → user/admin business logic).
  7. Follow existing patterns: mirror the Coupon feature’s domain structure, PaymentInteractor’s shape, and the Cashier webhook style.
  8. PHPStan compliance: new code must pass phpstan at the configured level.
  9. Migration duplication: copy each new migration into all three apps’ database/migrations/ directories.
  10. 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.