feat(drip): email users stuck on the paywall a day after onboarding (#562)

## What

Sends an automated follow-up email the day after a user finishes
onboarding but stays stuck on the paywall, asking why they didn't
continue and inviting a direct reply.

## Why

Users who connect a bank account during onboarding but never pick a plan
are permanently redirected to the paywall by `EnsureUserIsSubscribed` —
they finish onboarding and then can't access the app. This reaches out
to understand the friction and recover them.

## Who gets the email (the genuinely stuck cohort)

- `onboarded_at` is calendar-yesterday (`whereDate('onboarded_at',
today()->subDay())`)
- Has an active banking connection (`bankingConnections()->exists()`)
- No Pro plan (`hasProPlan()` is false)
- Hasn't already received this email (idempotent via `UserMailLog`)

Users **without** a bank connection are excluded: they fall through to
free access after seeing the paywall once, so they aren't stuck.

## How

- New `email:paywall-follow-up` command scans the cohort daily and
queues the job — scheduled `dailyAt('10:00')` Europe/Madrid in
`routes/console.php`.
- New `DripEmailType::PaywallFollowUp` (`paywall_follow_up`) +
idempotent send job following the existing drip pattern.
- Short, human Markdown email with an explicit `replyTo`
(`hi@whisper.money`) so replies reach a real inbox.
- Spanish copy added to `lang/es.json`.

## Tests

10 new tests (correct cohort matched; wrong day / no bank / Pro plan
excluded; no double-send; no-op when drip disabled). `pint` clean.

## Note

There was no existing user-scanning drip command (current drips dispatch
with `delay()` on `Registered`). Since "stuck on paywall" state isn't
known at registration time, a daily scanning command is the right fit.
This commit is contained in:
Víctor Falcón 2026-06-19 16:11:12 +02:00 committed by GitHub
parent ae59c90f2c
commit ce6bfc9c56
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 367 additions and 0 deletions

View File

@ -0,0 +1,48 @@
<?php
namespace App\Console\Commands;
use App\Enums\DripEmailType;
use App\Jobs\Drip\SendPaywallFollowUpEmailJob;
use App\Models\User;
use Illuminate\Console\Command;
class SendPaywallFollowUpEmailsCommand extends Command
{
protected $signature = 'email:paywall-follow-up';
protected $description = 'Queue the paywall follow-up email for users who completed onboarding yesterday but are stuck on the paywall';
public function handle(): int
{
if (! config('mail.drip_emails_enabled')) {
$this->info('Drip emails are disabled. Nothing to do.');
return self::SUCCESS;
}
$query = User::query()
->whereDate('onboarded_at', today()->subDay())
->whereHas('bankingConnections')
->whereDoesntHave('mailLogs', function ($query): void {
$query->where('email_type', DripEmailType::PaywallFollowUp);
});
$queued = 0;
$query->chunkById(100, function ($users) use (&$queued): void {
foreach ($users as $user) {
if ($user->hasProPlan()) {
continue;
}
SendPaywallFollowUpEmailJob::dispatch($user);
$queued++;
}
});
$this->info("Queued {$queued} paywall follow-up email(s).");
return self::SUCCESS;
}
}

View File

@ -11,5 +11,6 @@ enum DripEmailType: string
case ImportHelp = 'import_help';
case Feedback = 'feedback';
case SubscriptionCancelled = 'subscription_cancelled';
case PaywallFollowUp = 'paywall_follow_up';
case Update = 'update';
}

View File

@ -0,0 +1,52 @@
<?php
namespace App\Jobs\Drip;
use App\Enums\DripEmailType;
use App\Mail\Drip\PaywallFollowUpEmail;
use App\Models\User;
use App\Models\UserMailLog;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Mail;
class SendPaywallFollowUpEmailJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public User $user)
{
$this->onQueue('emails');
}
public function handle(): void
{
if (! $this->user->canReceiveEmails()) {
return;
}
if ($this->user->hasReceivedEmail(DripEmailType::PaywallFollowUp)) {
return;
}
if ($this->user->hasProPlan()) {
return;
}
if (! $this->user->bankingConnections()->exists()) {
return;
}
Mail::to($this->user)->send(new PaywallFollowUpEmail($this->user));
UserMailLog::create([
'user_id' => $this->user->id,
'email_type' => DripEmailType::PaywallFollowUp,
'email_identifier' => DripEmailType::PaywallFollowUp->value,
'sent_at' => now(),
]);
}
}

View File

@ -0,0 +1,74 @@
<?php
namespace App\Mail\Drip;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\SerializesModels;
class PaywallFollowUpEmail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/**
* The number of times the job may be attempted.
*
* @var int
*/
public $tries = 5;
/**
* The number of seconds to wait before retrying the job.
*
* @var array<int, int>
*/
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'),
),
replyTo: [
new Address(
config('mail.drip_from.address', 'hi@whisper.money'),
config('mail.drip_from.name', 'Álvaro and Víctor'),
),
],
subject: __('What stopped you from getting started?'),
);
}
public function content(): Content
{
return new Content(
markdown: 'mail.drip.paywall-follow-up',
with: [
'userName' => $this->user->name,
],
);
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new RateLimited('emails'))->releaseAfter(1)];
}
}

View File

@ -76,4 +76,12 @@ class UserMailLogFactory extends Factory
'email_identifier' => DripEmailType::SubscriptionCancelled->value,
]);
}
public function paywallFollowUp(): static
{
return $this->state(fn (array $attributes) => [
'email_type' => DripEmailType::PaywallFollowUp,
'email_identifier' => DripEmailType::PaywallFollowUp->value,
]);
}
}

View File

@ -800,6 +800,7 @@
"Help Me Improve": "Ayúdame a Mejorar",
"Help Us Improve": "Ayúdanos a Mejorar",
"Hey :name, is everything okay?": "Hey :name, ¿está todo bien?",
"Hey :name, what held you back?": "Hey :name, ¿qué te frenó?",
"Hey there!": "¡Hola!",
"Hey! A quick heads-up.": "¡Hola! Un aviso rápido.",
"Hey! We have some great news.": "¡Hola! Tenemos buenas noticias.",
@ -816,6 +817,7 @@
"Hi! It's Victor, the founder of Whisper Money. I noticed you've cancelled your subscription, and I wanted to reach out personally.": "¡Hola! Soy Víctor, el fundador de Whisper Money. Noté que cancelaste tu suscripción y quería contactarte personalmente.",
"Hi! It's Victor, the founder of Whisper Money. I noticed you've completed your setup but haven't imported any transactions yet. Let me help you get started!": "¡Hola! Soy Víctor, el fundador de Whisper Money. Noté que completaste tu configuración pero aún no has importado transacciones. ¡Déjame ayudarte a empezar!",
"Hi! It's Victor, the founder of Whisper Money. I see you've already started importing your transactions - that's awesome! You're well on your way to taking control of your finances while keeping your data private.": "¡Hola! Soy Víctor, el fundador de Whisper Money. Veo que ya has empezado a importar tus transacciones, ¡genial! Estás en camino de tomar el control de tus finanzas mientras mantienes tus datos privados.",
"Hi! It's Víctor and Álvaro, the founders of Whisper Money. You finished setting up your account and connected your bank yesterday, but didn't end up continuing. We wanted to reach out personally to understand why.": "¡Hola! Somos Víctor y Álvaro, los fundadores de Whisper Money. Ayer terminaste de configurar tu cuenta y conectaste tu banco, pero al final no continuaste. Queríamos escribirte personalmente para entender por qué.",
"Hi! We're Victor and Álvaro, the founders of Whisper Money. We noticed you signed up but haven't completed your setup yet. We wanted to check in personally and see if something went wrong or if you need any help.": "¡Hola! Somos Víctor y Álvaro, los fundadores de Whisper Money. Notamos que te registraste pero aún no has completado tu configuración. Queríamos contactarte personalmente para ver si algo salió mal o si necesitas ayuda.",
"Hi! We're Victor and Álvaro, the founders of Whisper Money. We wanted to personally welcome you and thank you for joining us.": "¡Hola! Somos Víctor y Álvaro, los fundadores de Whisper Money. Queríamos darte la bienvenida personalmente y agradecerte por unirte.",
"High-Yield Savings": "Ahorro de Alta Rentabilidad",
@ -918,6 +920,7 @@
"Join the community": "Unirse a la comunidad",
"Join the waiting list. We'll let you know when you're in.": "Únete a la lista de espera. Te avisaremos cuando sea tu turno.",
"Join thousands of users who have taken control of their finances without compromising their privacy.": "Únete a miles de usuarios que han tomado el control de sus finanzas sin comprometer su privacidad.",
"Just hit reply and tell us in a line or two. There's no form and no survey - your message comes straight to us, and we read and answer every single one.": "Solo responde a este correo y cuéntanoslo en una o dos líneas. No hay formulario ni encuesta: tu mensaje nos llega directamente, y leemos y respondemos cada uno de ellos.",
"Just reply to this email and let me know what's happening. I personally read every response and will help you get started. This is my project, so I care about making sure it works for you.": "Simplemente responde a este correo y cuéntame qué está pasando. Leo personalmente cada respuesta y te ayudaré a empezar. Este es mi proyecto, así que me importa que funcione para ti.",
"Just reply to this email and let us know what's happening. We personally read every response and will help you get started.": "Simplemente responde a este correo y cuéntanos qué está pasando. Leemos personalmente cada respuesta y te ayudaremos a empezar.",
"Just reply to this email with your feedback. No surveys, no forms, just a direct conversation with me.": "Simplemente responde a este correo con tus comentarios. Sin encuestas, sin formularios, solo una conversación directa conmigo.",
@ -1736,6 +1739,7 @@
"Want to connect your bank directly?": "¿Quieres conectar tu banco directamente?",
"Want to continue for free? Disconnect all bank connections in Settings.": "¿Quieres continuar gratis? Desconecta todas las conexiones bancarias en Configuración.",
"Warning": "Advertencia",
"Was it the price? Were you missing a feature? Did something feel confusing or not quite work the way you expected? Honestly, any reason helps us.": "¿Fue el precio? ¿Te faltaba alguna función? ¿Algo te resultó confuso o no funcionó como esperabas? De verdad, cualquier motivo nos ayuda.",
"We Can't Read It": "No Podemos Leerlo",
"We also support Open Banking connections so your transactions sync automatically. Head to Settings > Connections to link your bank directly.": "También admitimos conexiones de Open Banking para que tus transacciones se sincronicen automáticamente. Ve a Configuración > Conexiones para vincular tu banco directamente.",
"We are processing your payment...": "Estamos procesando tu pago...",
@ -1808,8 +1812,10 @@
"What is Whisper Money?": "¿Qué es Whisper Money?",
"What people are saying": "Lo que la gente dice",
"What should happen to its child categories?": "¿Qué debería pasar con sus categorías hijas?",
"What stopped you from getting started?": "¿Qué te impidió empezar?",
"What you get": "Lo que obtienes",
"What's more, even if I wanted to use it, I couldn't, since you're the only one who has the key to read the data.": "Es más, aunque quisiera usarlos, no podría, ya que tú eres el único que tiene la clave para leer los datos.",
"Whatever the reason, knowing it makes Whisper Money better for everyone. Thank you for giving it a try.": "Sea cual sea el motivo, conocerlo hace que Whisper Money sea mejor para todos. Gracias por darle una oportunidad.",
"When I started building Whisper Money, I was frustrated with finance apps that wanted to mine my data or use AI to analyze my spending. I just wanted a simple, private way to track my finances - and I figured others might too.": "Cuando empecé a construir Whisper Money, estaba frustrado con las apps de finanzas que querían minar mis datos o usar IA para analizar mis gastos. Solo quería una forma simple y privada de rastrear mis finanzas - y pensé que otros también.",
"When you create an account with Whisper Money, you agree to:": "Cuando creas una cuenta con Whisper Money, aceptas:",
"When you create an account with Whisper Money,\\n you agree to:": "Al crear una cuenta con Whisper Money, aceptas:",

View File

@ -0,0 +1,15 @@
<x-mail::message>
# {{ __('Hey :name, what held you back?', ['name' => $userName]) }}
{{ __("Hi! It's Víctor and Álvaro, the founders of Whisper Money. You finished setting up your account and connected your bank yesterday, but didn't end up continuing. We wanted to reach out personally to understand why.") }}
{{ __('Was it the price? Were you missing a feature? Did something feel confusing or not quite work the way you expected? Honestly, any reason helps us.') }}
{{ __("Just hit reply and tell us in a line or two. There's no form and no survey - your message comes straight to us, and we read and answer every single one.") }}
{{ __("Whatever the reason, knowing it makes Whisper Money better for everyone. Thank you for giving it a try.") }}
{{ __('Best,') }}<br>
{{ __('Álvaro & Víctor') }}<br>
{{ __('Founders of Whisper Money') }}
</x-mail::message>

View File

@ -11,5 +11,6 @@ Schedule::command('loans:generate-balances')->monthlyOn(1, '00:00');
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('stats:daily-report')->dailyAt('09:00')->timezone('Europe/Madrid');
Schedule::command('stats:ai-cohort-report')->monthlyOn(1, '09:00')->timezone('Europe/Madrid');

View File

@ -0,0 +1,99 @@
<?php
use App\Enums\DripEmailType;
use App\Mail\Drip\PaywallFollowUpEmail;
use App\Models\BankingConnection;
use App\Models\User;
use App\Models\UserMailLog;
use Illuminate\Support\Facades\Mail;
beforeEach(function () {
Mail::fake();
config([
'subscriptions.enabled' => true,
'mail.drip_emails_enabled' => true,
]);
});
function stuckPaywallUser(): User
{
$user = User::factory()->create(['onboarded_at' => now()->subDay()]);
BankingConnection::factory()->for($user)->create();
return $user;
}
test('it sends the follow-up email to users onboarded yesterday with a bank connection and no pro plan', function () {
$user = stuckPaywallUser();
$this->artisan('email:paywall-follow-up')->assertSuccessful();
Mail::assertQueued(PaywallFollowUpEmail::class, fn ($mail) => $mail->hasTo($user->email));
$this->assertDatabaseHas('user_mail_logs', [
'user_id' => $user->id,
'email_type' => DripEmailType::PaywallFollowUp->value,
]);
});
test('it does not send to users who onboarded today or earlier than yesterday', function () {
User::factory()->create(['onboarded_at' => now()])
->bankingConnections()->save(BankingConnection::factory()->make());
User::factory()->create(['onboarded_at' => now()->subDays(2)])
->bankingConnections()->save(BankingConnection::factory()->make());
$this->artisan('email:paywall-follow-up')->assertSuccessful();
Mail::assertNotQueued(PaywallFollowUpEmail::class);
});
test('it does not send to users without a bank connection', function () {
User::factory()->create(['onboarded_at' => now()->subDay()]);
$this->artisan('email:paywall-follow-up')->assertSuccessful();
Mail::assertNotQueued(PaywallFollowUpEmail::class);
});
test('it does not send to users with a pro plan', function () {
$user = stuckPaywallUser();
$user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_test123',
'stripe_status' => 'active',
'stripe_price' => 'price_test123',
]);
$this->artisan('email:paywall-follow-up')->assertSuccessful();
Mail::assertNotQueued(PaywallFollowUpEmail::class);
});
test('it does not send the email twice to the same user', function () {
$user = stuckPaywallUser();
UserMailLog::factory()->for($user)->paywallFollowUp()->create();
$this->artisan('email:paywall-follow-up')->assertSuccessful();
Mail::assertNotQueued(PaywallFollowUpEmail::class);
expect(
UserMailLog::query()
->where('user_id', $user->id)
->where('email_type', DripEmailType::PaywallFollowUp)
->count()
)->toBe(1);
});
test('it does nothing when drip emails are disabled', function () {
config(['mail.drip_emails_enabled' => false]);
stuckPaywallUser();
$this->artisan('email:paywall-follow-up')->assertSuccessful();
Mail::assertNotQueued(PaywallFollowUpEmail::class);
});

View File

@ -0,0 +1,63 @@
<?php
use App\Enums\DripEmailType;
use App\Jobs\Drip\SendPaywallFollowUpEmailJob;
use App\Mail\Drip\PaywallFollowUpEmail;
use App\Models\BankingConnection;
use App\Models\User;
use App\Models\UserMailLog;
use Illuminate\Support\Facades\Mail;
beforeEach(function () {
Mail::fake();
config(['subscriptions.enabled' => true]);
});
test('paywall follow-up email is sent to a stuck user', function () {
$user = User::factory()->onboarded()->create();
BankingConnection::factory()->for($user)->create();
SendPaywallFollowUpEmailJob::dispatchSync($user);
Mail::assertQueued(PaywallFollowUpEmail::class, fn ($mail) => $mail->hasTo($user->email));
$this->assertDatabaseHas('user_mail_logs', [
'user_id' => $user->id,
'email_type' => DripEmailType::PaywallFollowUp->value,
]);
});
test('paywall follow-up email is not sent to users with a pro plan', function () {
$user = User::factory()->onboarded()->create();
BankingConnection::factory()->for($user)->create();
$user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_test123',
'stripe_status' => 'active',
'stripe_price' => 'price_test123',
]);
SendPaywallFollowUpEmailJob::dispatchSync($user);
Mail::assertNotQueued(PaywallFollowUpEmail::class);
});
test('paywall follow-up email is not sent to users without a bank connection', function () {
$user = User::factory()->onboarded()->create();
SendPaywallFollowUpEmailJob::dispatchSync($user);
Mail::assertNotQueued(PaywallFollowUpEmail::class);
});
test('paywall follow-up email is not sent if already received', function () {
$user = User::factory()->onboarded()->create();
BankingConnection::factory()->for($user)->create();
UserMailLog::factory()->for($user)->paywallFollowUp()->create();
SendPaywallFollowUpEmailJob::dispatchSync($user);
Mail::assertNotQueued(PaywallFollowUpEmail::class);
});