From 934d834ab3dd19d66525fbd883d30de1b3d77982 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Fri, 26 Jun 2026 19:56:06 +0200 Subject: [PATCH] feat(email): follow up after post-onboarding AI consent (#596) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Adds a lifecycle email sent **2 days after a user records AI consent**, but only when that consent was given **outside of onboarding** — i.e. more than 3 days after sign-up. This targets users who deliberately opted into AI later (e.g. via the billing toggle / transactions banner gated by the `AiConsentSettings` flag), not those who consented during initial setup. ## How - **`email:ai-consent-follow-up`** scheduled command (daily, 10:15 Europe/Madrid) selects active consents accepted exactly 2 days ago, skips onboarding-era ones (`accepted_at <= created_at + 3 days`), and queues a job per user. - **`SendAiConsentFollowUpEmailJob`** sends the mailable and records a `UserMailLog`. Dedup, locale and the queued-email plumbing all reuse the existing drip system — **no migration**. - Email is localised automatically via the user's `HasLocalePreference`, like every other drip email. ## Design notes - **Scheduled command, not a 2-day delayed job** — survives worker restarts and respects consent revoked in the meantime. - **Exact "2 days ago" window**, matching the existing paywall drip. If the scheduler misses a day that cohort isn't retried; kept consistent with the other drips on purpose. - Copy is a first draft — open to wording tweaks. ## Tests `tests/Feature/SendAiConsentFollowUpEmailsCommandTest.php` — 9 tests covering the happy path, onboarding exclusion (incl. the exact-3-days boundary), timing window, revoked consent, the disabled-drip gate, single-send + mail log, no-resend, and locale rendering. All green, Pint clean. --- .../SendAiConsentFollowUpEmailsCommand.php | 57 ++++++++ app/Enums/DripEmailType.php | 1 + .../Drip/SendAiConsentFollowUpEmailJob.php | 48 +++++++ app/Mail/Drip/AiConsentFollowUpEmail.php | 68 +++++++++ app/Models/AiConsent.php | 5 + lang/es.json | 11 +- .../mail/drip/ai-consent-follow-up.blade.php | 23 +++ routes/console.php | 1 + ...SendAiConsentFollowUpEmailsCommandTest.php | 135 ++++++++++++++++++ 9 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 app/Console/Commands/SendAiConsentFollowUpEmailsCommand.php create mode 100644 app/Jobs/Drip/SendAiConsentFollowUpEmailJob.php create mode 100644 app/Mail/Drip/AiConsentFollowUpEmail.php create mode 100644 resources/views/mail/drip/ai-consent-follow-up.blade.php create mode 100644 tests/Feature/SendAiConsentFollowUpEmailsCommandTest.php diff --git a/app/Console/Commands/SendAiConsentFollowUpEmailsCommand.php b/app/Console/Commands/SendAiConsentFollowUpEmailsCommand.php new file mode 100644 index 00000000..32e7e2f9 --- /dev/null +++ b/app/Console/Commands/SendAiConsentFollowUpEmailsCommand.php @@ -0,0 +1,57 @@ +info('Drip emails are disabled. Nothing to do.'); + + return self::SUCCESS; + } + + $queued = 0; + + AiConsent::query() + ->active() + ->whereDate('accepted_at', today()->subDays(2)) + ->with('user') + ->chunkById(100, function (Collection $consents) use (&$queued): void { + foreach ($consents as $consent) { + $user = $consent->user; + + if ($user === null) { + continue; + } + + if ($consent->accepted_at->lessThanOrEqualTo($user->created_at->copy()->addDays(self::ONBOARDING_GRACE_DAYS))) { + continue; + } + + SendAiConsentFollowUpEmailJob::dispatch($user); + $queued++; + } + }); + + $this->info("Queued {$queued} AI consent follow-up email(s)."); + + return self::SUCCESS; + } +} diff --git a/app/Enums/DripEmailType.php b/app/Enums/DripEmailType.php index 91e68b40..da998859 100644 --- a/app/Enums/DripEmailType.php +++ b/app/Enums/DripEmailType.php @@ -12,5 +12,6 @@ enum DripEmailType: string case Feedback = 'feedback'; case SubscriptionCancelled = 'subscription_cancelled'; case PaywallFollowUp = 'paywall_follow_up'; + case AiConsentFollowUp = 'ai_consent_follow_up'; case Update = 'update'; } diff --git a/app/Jobs/Drip/SendAiConsentFollowUpEmailJob.php b/app/Jobs/Drip/SendAiConsentFollowUpEmailJob.php new file mode 100644 index 00000000..482c0332 --- /dev/null +++ b/app/Jobs/Drip/SendAiConsentFollowUpEmailJob.php @@ -0,0 +1,48 @@ +onQueue('emails'); + } + + public function handle(): void + { + if (! $this->user->canReceiveEmails()) { + return; + } + + if ($this->user->hasReceivedEmail(DripEmailType::AiConsentFollowUp)) { + return; + } + + if (! $this->user->aiConsents()->active()->exists()) { + return; + } + + Mail::to($this->user)->send(new AiConsentFollowUpEmail($this->user)); + + UserMailLog::create([ + 'user_id' => $this->user->id, + 'email_type' => DripEmailType::AiConsentFollowUp, + 'email_identifier' => DripEmailType::AiConsentFollowUp->value, + 'sent_at' => now(), + ]); + } +} diff --git a/app/Mail/Drip/AiConsentFollowUpEmail.php b/app/Mail/Drip/AiConsentFollowUpEmail.php new file mode 100644 index 00000000..1a5470b9 --- /dev/null +++ b/app/Mail/Drip/AiConsentFollowUpEmail.php @@ -0,0 +1,68 @@ + + */ + public $backoff = [2, 5, 10, 30]; + + public function __construct(public User $user) + { + $this->onQueue('emails'); + } + + public function envelope(): Envelope + { + return new Envelope( + from: new Address( + config('mail.drip_from.address', 'hi@whisper.money'), + config('mail.drip_from.name', 'Álvaro and Víctor'), + ), + subject: __('Putting AI to work on your finances'), + ); + } + + public function content(): Content + { + return new Content( + markdown: 'mail.drip.ai-consent-follow-up', + with: [ + 'userName' => $this->user->name, + ], + ); + } + + /** + * Get the middleware the job should pass through. + * + * @return array + */ + public function middleware(): array + { + return [(new RateLimited('emails'))->releaseAfter(1)]; + } +} diff --git a/app/Models/AiConsent.php b/app/Models/AiConsent.php index 27939504..16b2eb21 100644 --- a/app/Models/AiConsent.php +++ b/app/Models/AiConsent.php @@ -2,6 +2,7 @@ namespace App\Models; +use Carbon\Carbon; use Database\Factories\AiConsentFactory; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Concerns\HasUuids; @@ -9,6 +10,10 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +/** + * @property Carbon $accepted_at + * @property ?Carbon $revoked_at + */ class AiConsent extends Model { /** @use HasFactory */ diff --git a/lang/es.json b/lang/es.json index 01352d15..b0112af9 100644 --- a/lang/es.json +++ b/lang/es.json @@ -2166,5 +2166,14 @@ "Connect your Interactive Brokers account using a Flex Web Service token and Query ID.": "Conecta tu cuenta de Interactive Brokers usando un token del Flex Web Service y un Query ID.", "Enter your Flex Web Service token and Query ID to connect your Interactive Brokers account.": "Introduce tu token del Flex Web Service y el Query ID para conectar tu cuenta de Interactive Brokers.", "In Client Portal, create an Activity Flex Query including the \"Net Asset Value (NAV)\" and \"Open Positions\" sections, then generate a Flex Web Service token under": "En el Client Portal, crea una Activity Flex Query que incluya las secciones \"Net Asset Value (NAV)\" y \"Open Positions\", y luego genera un token del Flex Web Service en", - "Performance & Reports → Flex Queries": "Rendimiento e Informes → Flex Queries" + "Performance & Reports → Flex Queries": "Rendimiento e Informes → Flex Queries", + "Putting AI to work on your finances": "Ponemos la IA a trabajar en tus finanzas", + "Your AI assistant is hard at work, :name": "Tu asistente de IA está trabajando duro, :name", + "A couple of days ago you turned on AI-powered insights in Whisper Money. We wanted to check in and show you what it has been doing for you behind the scenes.": "Hace un par de días activaste los análisis con IA en Whisper Money. Queríamos saber cómo te va y enseñarte lo que ha estado haciendo por ti entre bastidores.", + "Your transactions, categorised automatically": "Tus transacciones, categorizadas automáticamente", + "Instead of sorting every transaction by hand, our AI suggests a category for each one — so your spending breakdown stays accurate and up to date with almost no effort from you.": "En lugar de ordenar cada transacción a mano, nuestra IA sugiere una categoría para cada una, para que el desglose de tus gastos se mantenga preciso y actualizado casi sin esfuerzo por tu parte.", + "See your categorised transactions": "Ver tus transacciones categorizadas", + "Private by design, always": "Privado por diseño, siempre", + "Turning on AI never changes who owns your data — you do. We only use it to help you understand your own finances, and you can revoke this consent at any time from your settings.": "Activar la IA nunca cambia quién es el dueño de tus datos: lo eres tú. Solo la usamos para ayudarte a entender tus propias finanzas, y puedes revocar este consentimiento cuando quieras desde tus ajustes.", + "If you have any questions, **just reply to this email**. We personally read every message.": "Si tienes cualquier duda, **responde a este correo**. Leemos personalmente cada mensaje." } diff --git a/resources/views/mail/drip/ai-consent-follow-up.blade.php b/resources/views/mail/drip/ai-consent-follow-up.blade.php new file mode 100644 index 00000000..ccb18226 --- /dev/null +++ b/resources/views/mail/drip/ai-consent-follow-up.blade.php @@ -0,0 +1,23 @@ + +# {{ __('Your AI assistant is hard at work, :name', ['name' => $userName]) }} + +{{ __('A couple of days ago you turned on AI-powered insights in Whisper Money. We wanted to check in and show you what it has been doing for you behind the scenes.') }} + +## {{ __('Your transactions, categorised automatically') }} + +{{ __('Instead of sorting every transaction by hand, our AI suggests a category for each one — so your spending breakdown stays accurate and up to date with almost no effort from you.') }} + + +{{ __('See your categorised transactions') }} + + +## {{ __('Private by design, always') }} + +{{ __('Turning on AI never changes who owns your data — you do. We only use it to help you understand your own finances, and you can revoke this consent at any time from your settings.') }} + +{{ __('If you have any questions, **just reply to this email**. We personally read every message.') }} + +{{ __('Best,') }}
+{{ __('Víctor & Álvaro') }}
+{{ __('Founders of Whisper Money') }} +
diff --git a/routes/console.php b/routes/console.php index 461e4de0..997b06c3 100644 --- a/routes/console.php +++ b/routes/console.php @@ -12,6 +12,7 @@ Schedule::command('resend:sync-leads')->dailyAt('03:00'); Schedule::command('leads:send-invitations --force --limit=150')->dailyAt('09:00'); Schedule::command('leads:send-re-invitations --force')->dailyAt('09:00'); Schedule::command('email:paywall-follow-up')->dailyAt('10:00')->timezone('Europe/Madrid'); +Schedule::command('email:ai-consent-follow-up')->dailyAt('10:15')->timezone('Europe/Madrid'); Schedule::command('stats:daily-report')->dailyAt('09:00')->timezone('Europe/Madrid'); Schedule::command('stats:ai-cohort-report')->monthlyOn(1, '09:00')->timezone('Europe/Madrid'); Schedule::command('stats:stuck-cohort-report')->weekly()->mondays()->at('09:00')->timezone('Europe/Madrid'); diff --git a/tests/Feature/SendAiConsentFollowUpEmailsCommandTest.php b/tests/Feature/SendAiConsentFollowUpEmailsCommandTest.php new file mode 100644 index 00000000..a1539541 --- /dev/null +++ b/tests/Feature/SendAiConsentFollowUpEmailsCommandTest.php @@ -0,0 +1,135 @@ +create(['created_at' => now()->subDays($signedUpDaysAgo)]); + + $user->aiConsents()->create([ + 'scope' => AiConsent::SCOPE_FINANCE, + 'version' => (string) config('ai_suggestions.consent_version'), + 'accepted_at' => now()->subDays($acceptedDaysAgo), + ]); + + return $user; +} + +it('queues the follow-up for a post-onboarding consent accepted two days ago', function () { + Bus::fake(); + + $user = userWithConsent(signedUpDaysAgo: 10, acceptedDaysAgo: 2); + + $this->artisan('email:ai-consent-follow-up')->assertSuccessful(); + + Bus::assertDispatched( + SendAiConsentFollowUpEmailJob::class, + fn (SendAiConsentFollowUpEmailJob $job): bool => $job->user->is($user), + ); +}); + +it('skips consent given during onboarding', function () { + Bus::fake(); + + // Signed up and consented the same day → onboarding, not a deliberate opt-in. + userWithConsent(signedUpDaysAgo: 2, acceptedDaysAgo: 2); + + $this->artisan('email:ai-consent-follow-up')->assertSuccessful(); + + Bus::assertNotDispatched(SendAiConsentFollowUpEmailJob::class); +}); + +it('treats consent exactly three days after signup as onboarding', function () { + Bus::fake(); + + // accepted two days ago, signed up five days ago → exactly 3 days apart (not > 3). + userWithConsent(signedUpDaysAgo: 5, acceptedDaysAgo: 2); + + $this->artisan('email:ai-consent-follow-up')->assertSuccessful(); + + Bus::assertNotDispatched(SendAiConsentFollowUpEmailJob::class); +}); + +it('does not queue when the consent was not accepted exactly two days ago', function () { + Bus::fake(); + + userWithConsent(signedUpDaysAgo: 10, acceptedDaysAgo: 1); + userWithConsent(signedUpDaysAgo: 10, acceptedDaysAgo: 3); + + $this->artisan('email:ai-consent-follow-up')->assertSuccessful(); + + Bus::assertNotDispatched(SendAiConsentFollowUpEmailJob::class); +}); + +it('skips revoked consent', function () { + Bus::fake(); + + $user = userWithConsent(signedUpDaysAgo: 10, acceptedDaysAgo: 2); + $user->aiConsents()->update(['revoked_at' => now()]); + + $this->artisan('email:ai-consent-follow-up')->assertSuccessful(); + + Bus::assertNotDispatched(SendAiConsentFollowUpEmailJob::class); +}); + +it('does nothing when drip emails are disabled', function () { + config(['mail.drip_emails_enabled' => false]); + Bus::fake(); + + userWithConsent(signedUpDaysAgo: 10, acceptedDaysAgo: 2); + + $this->artisan('email:ai-consent-follow-up')->assertSuccessful(); + + Bus::assertNotDispatched(SendAiConsentFollowUpEmailJob::class); +}); + +it('sends the email once and records a mail log', function () { + Mail::fake(); + + $user = User::factory()->has(AiConsent::factory(), 'aiConsents')->create(); + + (new SendAiConsentFollowUpEmailJob($user))->handle(); + + Mail::assertQueued(AiConsentFollowUpEmail::class); + expect($user->hasReceivedEmail(DripEmailType::AiConsentFollowUp))->toBeTrue(); + expect(UserMailLog::where('user_id', $user->id)->count())->toBe(1); +}); + +it('renders the email in the user locale', function () { + app()->setLocale('es'); + + $user = User::factory()->make(['name' => 'Ada']); + $html = (new AiConsentFollowUpEmail($user))->render(); + + app()->setLocale('en'); + + expect($html)->toContain('Tu asistente de IA está trabajando duro'); + expect($html)->toContain('category_source=ai'); +}); + +it('does not resend if the user already received it', function () { + Mail::fake(); + + $user = User::factory()->has(AiConsent::factory(), 'aiConsents')->create(); + UserMailLog::create([ + 'user_id' => $user->id, + 'email_type' => DripEmailType::AiConsentFollowUp, + 'email_identifier' => DripEmailType::AiConsentFollowUp->value, + 'sent_at' => now(), + ]); + + (new SendAiConsentFollowUpEmailJob($user))->handle(); + + Mail::assertNotQueued(AiConsentFollowUpEmail::class); +});