Add subscription cancellation email with manual trigger command (#47)
## Summary - Add a subscription cancellation email that can be manually triggered via artisan command - Include CONTINUE50 coupon code (50% off current and future payments for monthly/yearly subscriptions) - Add option to disable all drip emails via `DRIP_EMAILS_ENABLED` env var ## Usage ```bash # Send to single user php artisan email:subscription-cancelled user@example.com # Send to multiple users php artisan email:subscription-cancelled user1@example.com,user2@example.com # Disable all drip emails DRIP_EMAILS_ENABLED=false ```
This commit is contained in:
parent
989a446f3c
commit
beecff404a
|
|
@ -57,6 +57,9 @@ MAIL_FROM_NAME="Whisper Money"
|
|||
# Resend Email Service (set MAIL_MAILER=resend to use in production)
|
||||
RESEND_API_KEY=
|
||||
|
||||
# Drip Emails (welcome, onboarding, promo codes, etc.)
|
||||
DRIP_EMAILS_ENABLED=true
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
|
|
|
|||
15
README.md
15
README.md
|
|
@ -132,13 +132,14 @@ The template includes:
|
|||
|
||||
### Optional Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
| ----------------------- | ------- | ------------------------------------------- |
|
||||
| `HIDE_AUTH_BUTTONS` | `false` | Hide login/register buttons on landing page |
|
||||
| `SUBSCRIPTIONS_ENABLED` | `false` | Enable Stripe subscriptions |
|
||||
| `STRIPE_KEY` | - | Stripe publishable key |
|
||||
| `STRIPE_SECRET` | - | Stripe secret key |
|
||||
| `STRIPE_WEBHOOK_SECRET` | - | Stripe webhook signing secret |
|
||||
| Variable | Default | Description |
|
||||
| ----------------------- | ------- | -------------------------------------------------- |
|
||||
| `DRIP_EMAILS_ENABLED` | `true` | Enable drip emails (welcome, onboarding, feedback) |
|
||||
| `HIDE_AUTH_BUTTONS` | `false` | Hide login/register buttons on landing page |
|
||||
| `SUBSCRIPTIONS_ENABLED` | `false` | Enable Stripe subscriptions |
|
||||
| `STRIPE_KEY` | - | Stripe publishable key |
|
||||
| `STRIPE_SECRET` | - | Stripe secret key |
|
||||
| `STRIPE_WEBHOOK_SECRET` | - | Stripe webhook signing secret |
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\Drip\SendSubscriptionCancelledEmailJob;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SendSubscriptionCancelledEmailCommand extends Command
|
||||
{
|
||||
protected $signature = 'email:subscription-cancelled {emails : Comma-separated list of user emails}';
|
||||
|
||||
protected $description = 'Send subscription cancellation email to one or more users';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$emailsInput = $this->argument('emails');
|
||||
$emails = array_map('trim', explode(',', $emailsInput));
|
||||
|
||||
$results = [];
|
||||
|
||||
foreach ($emails as $email) {
|
||||
if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$results[] = [$email, 'Invalid email format'];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$user = User::where('email', $email)->first();
|
||||
|
||||
if (! $user) {
|
||||
$results[] = [$email, 'User not found'];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
SendSubscriptionCancelledEmailJob::dispatch($user);
|
||||
$results[] = [$email, 'Queued'];
|
||||
}
|
||||
|
||||
$this->table(['Email', 'Status'], $results);
|
||||
|
||||
$queuedCount = collect($results)->where(fn ($r) => $r[1] === 'Queued')->count();
|
||||
$this->newLine();
|
||||
$this->info("Queued {$queuedCount} email(s) for sending.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -9,4 +9,5 @@ enum DripEmailType: string
|
|||
case PromoCode = 'promo_code';
|
||||
case ImportHelp = 'import_help';
|
||||
case Feedback = 'feedback';
|
||||
case SubscriptionCancelled = 'subscription_cancelled';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs\Drip;
|
||||
|
||||
use App\Enums\DripEmailType;
|
||||
use App\Mail\Drip\SubscriptionCancelledEmail;
|
||||
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 SendSubscriptionCancelledEmailJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(public User $user)
|
||||
{
|
||||
$this->onQueue('emails');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
if ($this->user->hasReceivedEmail(DripEmailType::SubscriptionCancelled)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Mail::to($this->user)->send(new SubscriptionCancelledEmail($this->user));
|
||||
|
||||
UserMailLog::create([
|
||||
'user_id' => $this->user->id,
|
||||
'email_type' => DripEmailType::SubscriptionCancelled,
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,10 @@ class ScheduleDripEmailsListener
|
|||
{
|
||||
public function handle(Registered $event): void
|
||||
{
|
||||
if (! config('mail.drip_emails_enabled')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = $event->user;
|
||||
|
||||
SendWelcomeEmailJob::dispatch($user)->delay(now()->addMinutes(30));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
<?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\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\Middleware\RateLimited;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class SubscriptionCancelledEmail 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(
|
||||
subject: "We're sorry to see you go",
|
||||
)->from(config('mail.from.address', 'hello@example.com'), 'Victor');
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
markdown: 'mail.drip.subscription-cancelled',
|
||||
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)];
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,19 @@
|
|||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Drip Emails
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls whether drip emails are sent to new users upon
|
||||
| registration. When disabled, no welcome, onboarding, promo code,
|
||||
| import help, or feedback emails will be scheduled.
|
||||
|
|
||||
*/
|
||||
|
||||
'drip_emails_enabled' => env('DRIP_EMAILS_ENABLED', true),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Mailer
|
||||
|
|
|
|||
|
|
@ -59,4 +59,11 @@ class UserMailLogFactory extends Factory
|
|||
'email_type' => DripEmailType::Feedback,
|
||||
]);
|
||||
}
|
||||
|
||||
public function subscriptionCancelled(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'email_type' => DripEmailType::SubscriptionCancelled,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
<x-mail::message>
|
||||
# We're sorry to see you go, {{ $userName }}
|
||||
|
||||
Hi! It's Victor, the founder of Whisper Money. I noticed you've cancelled your subscription, and I wanted to reach out personally.
|
||||
|
||||
First, thank you for giving Whisper Money a try. I hope it helped you get a better handle on your finances while keeping your data private.
|
||||
|
||||
## Before you go...
|
||||
|
||||
If there's anything that didn't work well for you, or if you have suggestions for improvement, I'd genuinely love to hear about it. As a solo founder, your feedback is invaluable in making Whisper Money better.
|
||||
|
||||
If you'd like to come back, here's a special offer just for you:
|
||||
|
||||
<x-mail::panel>
|
||||
Use code **CONTINUE50** to get **50% off** all current and future payments - works for both monthly and yearly subscriptions.
|
||||
</x-mail::panel>
|
||||
|
||||
<x-mail::button :url="config('app.url') . '/subscribe'">
|
||||
Reactivate Your Subscription
|
||||
</x-mail::button>
|
||||
|
||||
Your data and settings will be preserved, so you can pick up right where you left off.
|
||||
|
||||
If you have any questions or just want to chat, simply reply to this email. I read and respond to every message personally.
|
||||
|
||||
Thanks again for being part of this journey!
|
||||
|
||||
Best,<br>
|
||||
Victor F,<br>
|
||||
Founder of Whisper Money
|
||||
</x-mail::message>
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
|
||||
use App\Jobs\Drip\SendSubscriptionCancelledEmailJob;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
use function Pest\Laravel\artisan;
|
||||
|
||||
beforeEach(function () {
|
||||
Queue::fake();
|
||||
});
|
||||
|
||||
test('command dispatches job for valid user email', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
artisan('email:subscription-cancelled', ['emails' => $user->email])
|
||||
->assertSuccessful();
|
||||
|
||||
Queue::assertPushed(SendSubscriptionCancelledEmailJob::class, function ($job) use ($user) {
|
||||
return $job->user->id === $user->id;
|
||||
});
|
||||
});
|
||||
|
||||
test('command dispatches jobs for multiple user emails', function () {
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
|
||||
artisan('email:subscription-cancelled', ['emails' => "{$user1->email},{$user2->email}"])
|
||||
->assertSuccessful();
|
||||
|
||||
Queue::assertPushed(SendSubscriptionCancelledEmailJob::class, function ($job) use ($user1) {
|
||||
return $job->user->id === $user1->id;
|
||||
});
|
||||
|
||||
Queue::assertPushed(SendSubscriptionCancelledEmailJob::class, function ($job) use ($user2) {
|
||||
return $job->user->id === $user2->id;
|
||||
});
|
||||
});
|
||||
|
||||
test('command handles non-existent user gracefully', function () {
|
||||
artisan('email:subscription-cancelled', ['emails' => 'nonexistent@example.com'])
|
||||
->assertSuccessful();
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
});
|
||||
|
||||
test('command handles invalid email format gracefully', function () {
|
||||
artisan('email:subscription-cancelled', ['emails' => 'not-an-email'])
|
||||
->assertSuccessful();
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
});
|
||||
|
||||
test('command handles mixed valid and invalid emails', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
artisan('email:subscription-cancelled', ['emails' => "invalid,{$user->email},nonexistent@example.com"])
|
||||
->assertSuccessful();
|
||||
|
||||
Queue::assertPushed(SendSubscriptionCancelledEmailJob::class, fn ($job) => $job->user->id === $user->id);
|
||||
Queue::assertCount(1);
|
||||
});
|
||||
|
||||
test('job is dispatched to emails queue', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
artisan('email:subscription-cancelled', ['emails' => $user->email])
|
||||
->assertSuccessful();
|
||||
|
||||
Queue::assertPushedOn('emails', SendSubscriptionCancelledEmailJob::class);
|
||||
});
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\DripEmailType;
|
||||
use App\Jobs\Drip\SendSubscriptionCancelledEmailJob;
|
||||
use App\Mail\Drip\SubscriptionCancelledEmail;
|
||||
use App\Models\User;
|
||||
use App\Models\UserMailLog;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
beforeEach(function () {
|
||||
Mail::fake();
|
||||
});
|
||||
|
||||
test('subscription cancelled email is sent and logged', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
SendSubscriptionCancelledEmailJob::dispatchSync($user);
|
||||
|
||||
Mail::assertQueued(SubscriptionCancelledEmail::class, function ($mail) use ($user) {
|
||||
return $mail->hasTo($user->email);
|
||||
});
|
||||
|
||||
$this->assertDatabaseHas('user_mail_logs', [
|
||||
'user_id' => $user->id,
|
||||
'email_type' => DripEmailType::SubscriptionCancelled->value,
|
||||
]);
|
||||
});
|
||||
|
||||
test('subscription cancelled email is not sent if already received', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
UserMailLog::factory()->for($user)->subscriptionCancelled()->create();
|
||||
|
||||
SendSubscriptionCancelledEmailJob::dispatchSync($user);
|
||||
|
||||
Mail::assertNotQueued(SubscriptionCancelledEmail::class);
|
||||
});
|
||||
|
|
@ -80,3 +80,13 @@ test('all jobs are dispatched to the emails queue', function () {
|
|||
Queue::assertPushedOn('emails', SendImportHelpEmailJob::class);
|
||||
Queue::assertPushedOn('emails', SendFeedbackEmailJob::class);
|
||||
});
|
||||
|
||||
test('no drip emails are dispatched when disabled', function () {
|
||||
config(['mail.drip_emails_enabled' => false]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
event(new Registered($user));
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue