From 7b03d7cf230ef0cea3fe4ce6ea2215e3348910f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Tue, 26 May 2026 08:35:31 +0200 Subject: [PATCH] feat(leads): add user lead re-invite campaign (#432) ## Summary - add re-invite tracking columns for user leads - add queued re-invitation mailable and command - default re-invite eligibility to leads invited at least 3 days ago - localize re-invite email in Spanish for `es` leads - add stats command output for conversion rate ## Tests - vendor/bin/pint --dirty --format agent - vendor/bin/phpstan analyse --memory-limit=2G - php artisan test --compact tests/Feature/Commands/SendUserLeadReInvitationsTest.php tests/Feature/Mail/UserLeadReInvitationTest.php tests/Feature/Commands/SendUserLeadInvitationsTest.php tests/Feature/Mail/UserLeadInvitationTest.php --- .../Commands/SendUserLeadReInvitations.php | 174 ++++++++++++++++++ app/Mail/UserLeadReInvitation.php | 61 ++++++ app/Models/UserLead.php | 20 ++ ...invitation_columns_to_user_leads_table.php | 32 ++++ lang/es.json | 9 +- .../mail/user-lead-re-invitation.blade.php | 32 ++++ .../SendUserLeadReInvitationsTest.php | 113 ++++++++++++ .../Feature/Mail/UserLeadReInvitationTest.php | 54 ++++++ 8 files changed, 494 insertions(+), 1 deletion(-) create mode 100644 app/Console/Commands/SendUserLeadReInvitations.php create mode 100644 app/Mail/UserLeadReInvitation.php create mode 100644 database/migrations/2026_05_26_061232_add_reinvitation_columns_to_user_leads_table.php create mode 100644 resources/views/mail/user-lead-re-invitation.blade.php create mode 100644 tests/Feature/Commands/SendUserLeadReInvitationsTest.php create mode 100644 tests/Feature/Mail/UserLeadReInvitationTest.php diff --git a/app/Console/Commands/SendUserLeadReInvitations.php b/app/Console/Commands/SendUserLeadReInvitations.php new file mode 100644 index 00000000..47fe4594 --- /dev/null +++ b/app/Console/Commands/SendUserLeadReInvitations.php @@ -0,0 +1,174 @@ +option('stats')) { + $this->displayStats(); + + return self::SUCCESS; + } + + $limit = (int) $this->option('limit'); + if ($limit < 1) { + $this->error('Limit must be a positive integer.'); + + return self::FAILURE; + } + + $minDaysSinceInvite = max(0, (int) $this->option('min-days-since-invite')); + $emailFilter = $this->resolveEmailFilter(); + if ($emailFilter === false) { + return self::FAILURE; + } + + $leads = $this->pendingReInvitationQuery($minDaysSinceInvite, (bool) $this->option('again')) + ->when( + $emailFilter !== null, + fn (Builder $query) => $query->where('email', $emailFilter), + fn (Builder $query) => $query->orderBy('invitation_sent_at')->limit($limit), + ) + ->get(); + + if ($leads->isEmpty()) { + if ($emailFilter !== null) { + $this->error("No invited lead pending re-invitation found for {$emailFilter}."); + + return self::FAILURE; + } + + $this->info('No invited leads pending re-invitation found.'); + + return self::SUCCESS; + } + + $this->table( + ['#', 'Email', 'Invited at', 'Re-invited at', 'Re-invites'], + $leads->values()->map(fn (UserLead $lead, int $index): array => [ + $index + 1, + $lead->email, + $lead->invitation_sent_at?->toDateTimeString(), + $lead->re_invitation_sent_at?->toDateTimeString() ?? '-', + $lead->re_invitation_count ?? 0, + ])->all(), + ); + + if ((bool) $this->option('dry-run')) { + $this->info('[dry-run] No re-invitation emails sent.'); + + return self::SUCCESS; + } + + if (! $this->option('force')) { + if (! $this->confirm('Send these re-invitation emails?', true)) { + $this->info('Cancelled.'); + + return self::SUCCESS; + } + } + + $sent = 0; + $failed = 0; + $progressBar = $this->output->createProgressBar($leads->count()); + $progressBar->start(); + + foreach ($leads as $lead) { + try { + Mail::to($lead->email)->send(new UserLeadReInvitation($lead)); + + $lead->forceFill([ + 're_invitation_sent_at' => now(), + 're_invitation_count' => ((int) $lead->re_invitation_count) + 1, + ])->save(); + + $sent++; + } catch (Throwable $exception) { + $failed++; + $this->error("Failed for {$lead->email}: {$exception->getMessage()}"); + report($exception); + } + + $progressBar->advance(); + } + + $progressBar->finish(); + $this->newLine(); + $this->info("Queued {$sent} re-invitation email(s)".($failed > 0 ? " ({$failed} failed)" : '').'.'); + $this->displayStats(); + + return $failed === 0 ? self::SUCCESS : self::FAILURE; + } + + /** @return Builder */ + private function pendingReInvitationQuery(int $minDaysSinceInvite, bool $includeAlreadyReInvited): Builder + { + return UserLead::query() + ->whereNotNull('invitation_sent_at') + ->whereDoesntHave('signedUpUser') + ->when(! $includeAlreadyReInvited, fn (Builder $query) => $query->whereNull('re_invitation_sent_at')) + ->when( + $minDaysSinceInvite > 0, + fn (Builder $query) => $query->where('invitation_sent_at', '<=', now()->subDays($minDaysSinceInvite)), + ); + } + + private function displayStats(): void + { + $reInvited = UserLead::query() + ->whereNotNull('re_invitation_sent_at') + ->count(); + + $signedUpAfterReInvite = UserLead::query() + ->whereNotNull('re_invitation_sent_at') + ->whereHas('signedUpUser', fn (Builder $query) => $query->whereColumn('users.created_at', '>=', 'user_leads.re_invitation_sent_at')) + ->count(); + + $rate = $reInvited > 0 ? round(($signedUpAfterReInvite / $reInvited) * 100, 2) : 0.0; + + $this->table(['Metric', 'Value'], [ + ['Re-invited leads', $reInvited], + ['Re-invited leads signed up', $signedUpAfterReInvite], + ['Success rate', $rate.'%'], + ]); + } + + private function resolveEmailFilter(): string|false|null + { + $email = $this->option('email'); + + if ($email === null || $email === '') { + return null; + } + + $email = strtolower(trim((string) $email)); + + if (! filter_var($email, FILTER_VALIDATE_EMAIL)) { + $this->error("Invalid email `{$email}`."); + + return false; + } + + return $email; + } +} diff --git a/app/Mail/UserLeadReInvitation.php b/app/Mail/UserLeadReInvitation.php new file mode 100644 index 00000000..e3de3495 --- /dev/null +++ b/app/Mail/UserLeadReInvitation.php @@ -0,0 +1,61 @@ + */ + public $backoff = [2, 5, 10, 30]; + + public function __construct(public UserLead $lead) + { + $this->onQueue('emails'); + $this->locale($lead->preferredLocale()); + } + + public function envelope(): Envelope + { + return new Envelope( + subject: __('Still want to try Whisper Money?'), + ); + } + + public function content(): Content + { + $signupUrl = app(LandingAuthOverrideService::class) + ->generateInvitationUrl($this->lead->id, days: 30); + + return new Content( + markdown: 'mail.user-lead-re-invitation', + with: [ + 'lead' => $this->lead, + 'signupUrl' => $signupUrl, + 'promoCodeMonthly' => $this->lead->promo_code_monthly, + 'promoCodeYearly' => $this->lead->promo_code_yearly, + ], + ); + } + + /** + * @return array + */ + public function middleware(): array + { + return [(new RateLimited('emails'))->releaseAfter(1)]; + } +} diff --git a/app/Models/UserLead.php b/app/Models/UserLead.php index 4ccfd9f5..6aff0da5 100644 --- a/app/Models/UserLead.php +++ b/app/Models/UserLead.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Enums\LeadCohort; use App\Notifications\VerifyUserLeadEmailNotification; +use Carbon\CarbonInterface; use Database\Factories\UserLeadFactory; use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Contracts\Translation\HasLocalePreference; @@ -12,12 +13,17 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notification; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; /** + * @property string $email + * @property ?CarbonInterface $invitation_sent_at + * @property ?CarbonInterface $re_invitation_sent_at + * @property int $re_invitation_count * @property ?LeadCohort $cohort */ class UserLead extends Model implements HasLocalePreference, MustVerifyEmail @@ -36,6 +42,8 @@ class UserLead extends Model implements HasLocalePreference, MustVerifyEmail 'promo_code_monthly', 'promo_code_yearly', 'invitation_sent_at', + 're_invitation_sent_at', + 're_invitation_count', ]; /** @@ -48,6 +56,8 @@ class UserLead extends Model implements HasLocalePreference, MustVerifyEmail return [ 'email_verified_at' => 'datetime', 'invitation_sent_at' => 'datetime', + 're_invitation_sent_at' => 'datetime', + 're_invitation_count' => 'integer', 'cohort' => LeadCohort::class, ]; } @@ -92,6 +102,16 @@ class UserLead extends Model implements HasLocalePreference, MustVerifyEmail return $this->hasMany(UserLead::class, 'referred_by_id'); } + /** + * The user account created from this lead email, if any. + * + * @return HasOne + */ + public function signedUpUser(): HasOne + { + return $this->hasOne(User::class, 'email', 'email')->withTrashed(); + } + /** * The shareable referral URL for this lead. */ diff --git a/database/migrations/2026_05_26_061232_add_reinvitation_columns_to_user_leads_table.php b/database/migrations/2026_05_26_061232_add_reinvitation_columns_to_user_leads_table.php new file mode 100644 index 00000000..53ee453a --- /dev/null +++ b/database/migrations/2026_05_26_061232_add_reinvitation_columns_to_user_leads_table.php @@ -0,0 +1,32 @@ +timestamp('re_invitation_sent_at')->nullable()->after('invitation_sent_at'); + $table->unsignedInteger('re_invitation_count')->default(0)->after('re_invitation_sent_at'); + + $table->index('re_invitation_sent_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('user_leads', function (Blueprint $table): void { + $table->dropIndex(['re_invitation_sent_at']); + $table->dropColumn(['re_invitation_sent_at', 're_invitation_count']); + }); + } +}; diff --git a/lang/es.json b/lang/es.json index c786f028..efcc2b9a 100644 --- a/lang/es.json +++ b/lang/es.json @@ -1850,5 +1850,12 @@ "yellow": "amarillo", "— $9/month": "— $9/mes", "← Back to home": "← Volver al inicio", - "🎉 Get a founder discount •": "🎉 Obtén un descuento de fundador •" + "🎉 Get a founder discount •": "🎉 Obtén un descuento de fundador •", + "Still want to try Whisper Money?": "¿Todavía quieres probar Whisper Money?", + "Still interested in Whisper Money?": "¿Sigues interesado en Whisper Money?", + "We sent you an invitation to Whisper Money, but it looks like you have not created your account yet.": "Te enviamos una invitación a Whisper Money, pero parece que aún no has creado tu cuenta.", + "If privacy-first personal finance still sounds useful, your early access is still ready. Whisper Money helps you import transactions, organize spending, and understand your money without selling or sharing your financial data.": "Si una app de finanzas personales centrada en la privacidad sigue sonándote útil, tu acceso anticipado sigue listo. Whisper Money te ayuda a importar transacciones, organizar tus gastos y entender tu dinero sin vender ni compartir tus datos financieros.", + "Your launch codes are still available:": "Tus códigos de lanzamiento siguen disponibles:", + "No pressure—if now is not the right time, you can ignore this email.": "Sin presión: si ahora no es el momento, puedes ignorar este correo.", + "Questions or feedback? Reply to this email. We read every message personally.": "¿Tienes preguntas o comentarios? Responde a este correo. Leemos cada mensaje personalmente." } diff --git a/resources/views/mail/user-lead-re-invitation.blade.php b/resources/views/mail/user-lead-re-invitation.blade.php new file mode 100644 index 00000000..395e1026 --- /dev/null +++ b/resources/views/mail/user-lead-re-invitation.blade.php @@ -0,0 +1,32 @@ + +# {{ __('Still interested in Whisper Money?') }} + +{{ __('Hey there!') }} + +{{ __('We sent you an invitation to Whisper Money, but it looks like you have not created your account yet.') }} + +{{ __('If privacy-first personal finance still sounds useful, your early access is still ready. Whisper Money helps you import transactions, organize spending, and understand your money without selling or sharing your financial data.') }} + + +{{ __('Create your account') }} + + +@if ($promoCodeMonthly || $promoCodeYearly) +{{ __('Your launch codes are still available:') }} + +@if ($promoCodeMonthly) +- {{ __('Monthly') }}: `{{ $promoCodeMonthly }}` +@endif +@if ($promoCodeYearly) +- {{ __('Yearly') }}: `{{ $promoCodeYearly }}` +@endif +@endif + +{{ __('No pressure—if now is not the right time, you can ignore this email.') }} + +{{ __('Questions or feedback? Reply to this email. We read every message personally.') }} + +{{ __('Best,') }}
+{{ __('Álvaro & Víctor') }}
+{{ __('Founders of Whisper Money') }} +
diff --git a/tests/Feature/Commands/SendUserLeadReInvitationsTest.php b/tests/Feature/Commands/SendUserLeadReInvitationsTest.php new file mode 100644 index 00000000..430b2b16 --- /dev/null +++ b/tests/Feature/Commands/SendUserLeadReInvitationsTest.php @@ -0,0 +1,113 @@ +count(3)->state(new Sequence( + ['email' => 'first@example.com', 'invitation_sent_at' => now()->subDays(5)], + ['email' => 'second@example.com', 'invitation_sent_at' => now()->subDays(4)], + ['email' => 'third@example.com', 'invitation_sent_at' => now()->subDays(3)], + ))->create(); + + $this->artisan('leads:send-re-invitations', ['--limit' => 2, '--force' => true]) + ->assertSuccessful(); + + Mail::assertQueued(UserLeadReInvitation::class, 2); + + expect(UserLead::query()->whereNotNull('re_invitation_sent_at')->count())->toBe(2) + ->and(UserLead::query()->whereNotNull('re_invitation_sent_at')->sum('re_invitation_count'))->toBe('2'); + + $emails = UserLead::query()->whereNotNull('re_invitation_sent_at')->orderBy('invitation_sent_at')->pluck('email')->all(); + expect($emails)->toBe(['first@example.com', 'second@example.com']); +}); + +it('skips leads that already signed up or were already re-invited', function (): void { + UserLead::factory()->create(['email' => 'pending@example.com', 'invitation_sent_at' => now()->subDays(3)]); + UserLead::factory()->create(['email' => 'signed-up@example.com', 'invitation_sent_at' => now()->subDays(3)]); + UserLead::factory()->create([ + 'email' => 'already-reinvited@example.com', + 'invitation_sent_at' => now()->subDays(3), + 're_invitation_sent_at' => now()->subDay(), + 're_invitation_count' => 1, + ]); + User::factory()->create(['email' => 'signed-up@example.com']); + + $this->artisan('leads:send-re-invitations', ['--limit' => 10, '--force' => true]) + ->assertSuccessful(); + + Mail::assertQueued(UserLeadReInvitation::class, 1); + Mail::assertQueued(UserLeadReInvitation::class, fn (UserLeadReInvitation $mail): bool => $mail->hasTo('pending@example.com')); +}); + +it('can re-invite a specific email', function (): void { + $target = UserLead::factory()->create(['email' => 'target@example.com', 'invitation_sent_at' => now()->subDays(3)]); + UserLead::factory()->create(['email' => 'other@example.com', 'invitation_sent_at' => now()->subDays(3)]); + + $this->artisan('leads:send-re-invitations', ['--email' => 'target@example.com', '--force' => true]) + ->assertSuccessful(); + + Mail::assertQueued(UserLeadReInvitation::class, 1); + Mail::assertQueued(UserLeadReInvitation::class, fn (UserLeadReInvitation $mail): bool => $mail->hasTo('target@example.com')); + + expect($target->refresh()->re_invitation_sent_at)->not->toBeNull() + ->and(UserLead::query()->whereNotNull('re_invitation_sent_at')->pluck('email')->all())->toBe(['target@example.com']); +}); + +it('shows re-invitation signup stats', function (): void { + UserLead::factory()->create([ + 'email' => 'converted@example.com', + 'invitation_sent_at' => now()->subDays(4), + 're_invitation_sent_at' => now()->subDays(2), + 're_invitation_count' => 1, + ]); + UserLead::factory()->create([ + 'email' => 'not-converted@example.com', + 'invitation_sent_at' => now()->subDays(4), + 're_invitation_sent_at' => now()->subDays(2), + 're_invitation_count' => 1, + ]); + User::factory()->create(['email' => 'converted@example.com', 'created_at' => now()->subDay()]); + + $this->artisan('leads:send-re-invitations', ['--stats' => true]) + ->expectsTable(['Metric', 'Value'], [ + ['Re-invited leads', 2], + ['Re-invited leads signed up', 1], + ['Success rate', '50%'], + ]) + ->assertSuccessful(); + + Mail::assertNothingQueued(); +}); + +it('defaults to leads invited at least three days ago', function (): void { + UserLead::factory()->create(['email' => 'old@example.com', 'invitation_sent_at' => now()->subDays(3)]); + UserLead::factory()->create(['email' => 'fresh@example.com', 'invitation_sent_at' => now()->subDays(2)]); + + $this->artisan('leads:send-re-invitations', ['--limit' => 10, '--force' => true]) + ->assertSuccessful(); + + Mail::assertQueued(UserLeadReInvitation::class, 1); + Mail::assertQueued(UserLeadReInvitation::class, fn (UserLeadReInvitation $mail): bool => $mail->hasTo('old@example.com')); +}); + +it('respects the minimum days since original invite filter', function (): void { + UserLead::factory()->create(['email' => 'old@example.com', 'invitation_sent_at' => now()->subDays(10)]); + UserLead::factory()->create(['email' => 'fresh@example.com', 'invitation_sent_at' => now()->subDays(2)]); + + $this->artisan('leads:send-re-invitations', [ + '--limit' => 10, + '--min-days-since-invite' => 7, + '--force' => true, + ])->assertSuccessful(); + + Mail::assertQueued(UserLeadReInvitation::class, 1); + Mail::assertQueued(UserLeadReInvitation::class, fn (UserLeadReInvitation $mail): bool => $mail->hasTo('old@example.com')); +}); diff --git a/tests/Feature/Mail/UserLeadReInvitationTest.php b/tests/Feature/Mail/UserLeadReInvitationTest.php new file mode 100644 index 00000000..829bd278 --- /dev/null +++ b/tests/Feature/Mail/UserLeadReInvitationTest.php @@ -0,0 +1,54 @@ +ranked(1)->create([ + 'invitation_sent_at' => now()->subDay(), + 'promo_code_monthly' => 'WM-TEST-M', + 'promo_code_yearly' => 'WM-TEST-Y', + 'locale' => 'en', + ]); + + $rendered = (new UserLeadReInvitation($lead))->render(); + + expect($rendered)->toContain('lead='.$lead->id); + expect($rendered)->toContain('signup=1'); + expect($rendered)->toContain('signature='); + expect($rendered)->toContain('WM-TEST-M'); + expect($rendered)->toContain('WM-TEST-Y'); +}); + +it('renders without promo codes', function (): void { + $lead = UserLead::factory()->ranked(1)->create([ + 'invitation_sent_at' => now()->subDay(), + 'promo_code_monthly' => null, + 'promo_code_yearly' => null, + 'locale' => 'en', + ]); + + $rendered = (new UserLeadReInvitation($lead))->render(); + + expect($rendered)->toContain('Still interested in Whisper Money?'); + expect($rendered)->not->toContain('Your launch codes are still available'); +}); + +it('renders Spanish copy for Spanish leads', function (): void { + $lead = UserLead::factory()->ranked(1)->create([ + 'invitation_sent_at' => now()->subDay(), + 'promo_code_monthly' => 'WM-TEST-M', + 'promo_code_yearly' => 'WM-TEST-Y', + 'locale' => 'es', + ]); + + $mailable = new UserLeadReInvitation($lead); + $rendered = $mailable->render(); + + app()->setLocale('es'); + + expect($mailable->envelope()->subject)->toBe('¿Todavía quieres probar Whisper Money?'); + expect($rendered)->toContain('¿Sigues interesado en Whisper Money?'); + expect($rendered)->toContain('Tus códigos de lanzamiento siguen disponibles'); + expect($rendered)->toContain('Crear mi cuenta'); +});