Merge branch 'feat/stripe-lifecycle'
Stripe subscription lifecycle implementation: - Consolidated HandleStripeWebhook listener (5 event types) - users.grace_period_until column for past-due state - Branded Day-3 / Day-5 payment-failure reminder mailables - SendPaymentFailedReminderJob with grace-guard self-cancel - Past-due dashboard banner - Deletion of old DowngradeUserOnSubscriptionDeleted listener Spec: docs/superpowers/specs/2026-04-23-stripe-subscription-lifecycle-design.md Plan: docs/superpowers/plans/2026-04-23-stripe-subscription-lifecycle.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
44
app/Jobs/SendPaymentFailedReminderJob.php
Normal file
44
app/Jobs/SendPaymentFailedReminderJob.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Mail\PaymentFailedDay3Reminder;
|
||||
use App\Mail\PaymentFailedDay5Reminder;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class SendPaymentFailedReminderJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
public readonly int $userId,
|
||||
public readonly int $day,
|
||||
) {
|
||||
$this->onQueue('notifications');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$user = User::find($this->userId);
|
||||
|
||||
if ($user === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($user->grace_period_until === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$mailable = match ($this->day) {
|
||||
3 => new PaymentFailedDay3Reminder($user),
|
||||
5 => new PaymentFailedDay5Reminder($user),
|
||||
default => throw new InvalidArgumentException("Unsupported reminder day: {$this->day}"),
|
||||
};
|
||||
|
||||
Mail::to($user->email)->send($mailable);
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\UserNotificationPreference;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Laravel\Cashier\Events\WebhookReceived;
|
||||
|
||||
class DowngradeUserOnSubscriptionDeleted
|
||||
{
|
||||
public function handle(WebhookReceived $event): void
|
||||
{
|
||||
if (($event->payload['type'] ?? null) !== 'customer.subscription.deleted') {
|
||||
return;
|
||||
}
|
||||
|
||||
$stripeCustomerId = $event->payload['data']['object']['customer'] ?? null;
|
||||
|
||||
if (! $stripeCustomerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = User::where('stripe_id', $stripeCustomerId)->first();
|
||||
|
||||
if (! $user) {
|
||||
return;
|
||||
}
|
||||
|
||||
UserNotificationPreference::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereIn('channel', ['whatsapp', 'sms'])
|
||||
->update(['enabled' => false]);
|
||||
|
||||
Cache::tags(['plans'])->forget("plan_for_user_{$user->id}");
|
||||
}
|
||||
}
|
||||
78
app/Listeners/HandleStripeWebhook.php
Normal file
78
app/Listeners/HandleStripeWebhook.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Jobs\SendPaymentFailedReminderJob;
|
||||
use App\Models\User;
|
||||
use App\Models\UserNotificationPreference;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Laravel\Cashier\Events\WebhookReceived;
|
||||
|
||||
final class HandleStripeWebhook
|
||||
{
|
||||
public function handle(WebhookReceived $event): void
|
||||
{
|
||||
$type = $event->payload['type'] ?? null;
|
||||
$stripeCustomerId = $event->payload['data']['object']['customer'] ?? null;
|
||||
|
||||
if ($stripeCustomerId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = User::where('stripe_id', $stripeCustomerId)->first();
|
||||
|
||||
if ($user === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
match ($type) {
|
||||
'customer.subscription.created',
|
||||
'customer.subscription.updated' => $this->bustPlanCache($user),
|
||||
'customer.subscription.deleted' => $this->handleSubscriptionDeleted($user),
|
||||
'invoice.payment_succeeded' => $this->handlePaymentSucceeded($user),
|
||||
'invoice.payment_failed' => $this->handlePaymentFailed($user),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function handleSubscriptionDeleted(User $user): void
|
||||
{
|
||||
UserNotificationPreference::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereIn('channel', ['whatsapp', 'sms'])
|
||||
->update(['enabled' => false]);
|
||||
|
||||
$user->forceFill(['grace_period_until' => null])->save();
|
||||
|
||||
$this->bustPlanCache($user);
|
||||
}
|
||||
|
||||
private function handlePaymentSucceeded(User $user): void
|
||||
{
|
||||
$user->forceFill(['grace_period_until' => null])->save();
|
||||
$this->bustPlanCache($user);
|
||||
}
|
||||
|
||||
private function handlePaymentFailed(User $user): void
|
||||
{
|
||||
// Idempotency: only the first failed-payment event in a grace window
|
||||
// transitions state + dispatches reminders. Stripe may fire this event
|
||||
// multiple times per billing cycle (once per failed retry attempt).
|
||||
if ($user->grace_period_until !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user->forceFill(['grace_period_until' => Carbon::now()->addDays(5)])->save();
|
||||
|
||||
SendPaymentFailedReminderJob::dispatch($user->id, 3)->delay(Carbon::now()->addDays(3));
|
||||
SendPaymentFailedReminderJob::dispatch($user->id, 5)->delay(Carbon::now()->addDays(5));
|
||||
|
||||
$this->bustPlanCache($user);
|
||||
}
|
||||
|
||||
private function bustPlanCache(User $user): void
|
||||
{
|
||||
Cache::tags(['plans'])->forget("plan_for_user_{$user->id}");
|
||||
}
|
||||
}
|
||||
33
app/Mail/PaymentFailedDay3Reminder.php
Normal file
33
app/Mail/PaymentFailedDay3Reminder.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\PlanFeatures;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
|
||||
final class PaymentFailedDay3Reminder extends Mailable
|
||||
{
|
||||
public function __construct(public readonly User $user) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Heads up — your FuelAlert payment is retrying',
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
markdown: 'emails.payment-failed-day-3',
|
||||
with: [
|
||||
'name' => $this->user->name,
|
||||
'tier' => PlanFeatures::for($this->user)->tier(),
|
||||
'portalUrl' => route('billing.portal'),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
35
app/Mail/PaymentFailedDay5Reminder.php
Normal file
35
app/Mail/PaymentFailedDay5Reminder.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\PlanFeatures;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
|
||||
final class PaymentFailedDay5Reminder extends Mailable
|
||||
{
|
||||
public function __construct(public readonly User $user) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
$tier = PlanFeatures::for($this->user)->tier();
|
||||
|
||||
return new Envelope(
|
||||
subject: 'Last chance — your '.ucfirst($tier).' features end tomorrow',
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
markdown: 'emails.payment-failed-day-5',
|
||||
with: [
|
||||
'name' => $this->user->name,
|
||||
'tier' => PlanFeatures::for($this->user)->tier(),
|
||||
'portalUrl' => route('billing.portal'),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ use Laravel\Cashier\Billable;
|
||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
#[Fillable(['name', 'email', 'email_verified_at', 'password', 'is_admin', 'postcode', 'preferred_fuel_type'])]
|
||||
#[Fillable(['name', 'email', 'email_verified_at', 'password', 'is_admin', 'postcode', 'preferred_fuel_type', 'grace_period_until'])]
|
||||
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
|
||||
class User extends Authenticatable implements FilamentUser
|
||||
{
|
||||
@@ -35,6 +35,7 @@ class User extends Authenticatable implements FilamentUser
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'is_admin' => 'boolean',
|
||||
'grace_period_until' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Listeners\DowngradeUserOnSubscriptionDeleted;
|
||||
use App\Listeners\HandleStripeWebhook;
|
||||
use App\Services\ApiLogger;
|
||||
use App\Services\LlmPrediction\AnthropicPredictionProvider;
|
||||
use App\Services\LlmPrediction\GeminiPredictionProvider;
|
||||
@@ -41,7 +41,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
$this->configureDefaults();
|
||||
|
||||
Event::listen(WebhookReceived::class, DowngradeUserOnSubscriptionDeleted::class);
|
||||
Event::listen(WebhookReceived::class, HandleStripeWebhook::class);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->timestamp('grace_period_until')->nullable()->after('password')
|
||||
->comment('Set when invoice.payment_failed webhook fires; cleared on payment success or subscription deletion. Drives dashboard past-due banner.');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('grace_period_until');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
<x-layouts::app :title="__('Dashboard')">
|
||||
<div class="flex h-full w-full flex-1 flex-col gap-4 rounded-xl">
|
||||
@include('partials.past-due-banner')
|
||||
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
|
||||
<div class="relative aspect-video overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-700">
|
||||
<x-placeholder-pattern class="absolute inset-0 size-full stroke-gray-900/20 dark:stroke-neutral-100/20" />
|
||||
|
||||
22
resources/views/emails/payment-failed-day-3.blade.php
Normal file
22
resources/views/emails/payment-failed-day-3.blade.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<x-mail::message>
|
||||
# Payment retry in progress
|
||||
|
||||
Hi {{ $name }},
|
||||
|
||||
We tried to renew your FuelAlert **{{ ucfirst($tier) }}** subscription but the
|
||||
payment didn't go through. Stripe will retry automatically over the next couple
|
||||
of days.
|
||||
|
||||
If the card on file is out of date, please update it now so you don't lose your
|
||||
{{ ucfirst($tier) }} features:
|
||||
|
||||
<x-mail::button :url="$portalUrl">
|
||||
Update payment method
|
||||
</x-mail::button>
|
||||
|
||||
If Stripe's retries succeed, you won't need to do anything — you'll just get a
|
||||
normal payment receipt.
|
||||
|
||||
Thanks for using FuelAlert,
|
||||
The FuelAlert Team
|
||||
</x-mail::message>
|
||||
27
resources/views/emails/payment-failed-day-5.blade.php
Normal file
27
resources/views/emails/payment-failed-day-5.blade.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<x-mail::message>
|
||||
# Your {{ ucfirst($tier) }} features end tomorrow
|
||||
|
||||
Hi {{ $name }},
|
||||
|
||||
Stripe has been trying to renew your subscription for the past few days without
|
||||
success. If the next retry doesn't go through, your **{{ ucfirst($tier) }}**
|
||||
subscription will be cancelled tomorrow and your account will move back to the
|
||||
Free tier.
|
||||
|
||||
Here's what you'll lose on Free:
|
||||
|
||||
- Smart fill-up / wait recommendations
|
||||
- Multi-channel alerts (WhatsApp, SMS, push)
|
||||
- Advanced fuel-price predictions
|
||||
|
||||
You can keep everything running by updating your card now:
|
||||
|
||||
<x-mail::button :url="$portalUrl">
|
||||
Update payment method
|
||||
</x-mail::button>
|
||||
|
||||
If you meant to cancel, no action needed — you'll just drop to Free automatically.
|
||||
|
||||
Thanks for using FuelAlert,
|
||||
The FuelAlert Team
|
||||
</x-mail::message>
|
||||
18
resources/views/partials/past-due-banner.blade.php
Normal file
18
resources/views/partials/past-due-banner.blade.php
Normal file
@@ -0,0 +1,18 @@
|
||||
@auth
|
||||
@if (auth()->user()->grace_period_until !== null)
|
||||
<div class="mb-4 flex items-center justify-between gap-4 rounded-xl border border-amber-500/40 bg-amber-500/10 p-4 text-amber-900 dark:text-amber-100">
|
||||
<div class="flex-1 text-sm">
|
||||
<strong class="font-semibold">We couldn't charge your card.</strong>
|
||||
Update your payment method by
|
||||
{{ auth()->user()->grace_period_until->format('l, j M') }}
|
||||
or your paid features will end.
|
||||
</div>
|
||||
<a
|
||||
href="{{ route('billing.portal') }}"
|
||||
class="shrink-0 rounded-lg bg-amber-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-amber-700"
|
||||
>
|
||||
Update card
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
@endauth
|
||||
24
resources/views/vendor/mail/html/button.blade.php
vendored
Normal file
24
resources/views/vendor/mail/html/button.blade.php
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
@props([
|
||||
'url',
|
||||
'color' => 'primary',
|
||||
'align' => 'center',
|
||||
])
|
||||
<table class="action" align="{{ $align }}" width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td align="{{ $align }}">
|
||||
<table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td align="{{ $align }}">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ $url }}" class="button button-{{ $color }}" target="_blank" rel="noopener">{!! $slot !!}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
11
resources/views/vendor/mail/html/footer.blade.php
vendored
Normal file
11
resources/views/vendor/mail/html/footer.blade.php
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
<tr>
|
||||
<td>
|
||||
<table class="footer" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td class="content-cell" align="center">
|
||||
{{ Illuminate\Mail\Markdown::parse($slot) }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
12
resources/views/vendor/mail/html/header.blade.php
vendored
Normal file
12
resources/views/vendor/mail/html/header.blade.php
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
@props(['url'])
|
||||
<tr>
|
||||
<td class="header">
|
||||
<a href="{{ $url }}" style="display: inline-block;">
|
||||
@if (trim($slot) === 'Laravel')
|
||||
<img src="https://laravel.com/img/notification-logo-v2.1.png" class="logo" alt="Laravel Logo">
|
||||
@else
|
||||
{!! $slot !!}
|
||||
@endif
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
58
resources/views/vendor/mail/html/layout.blade.php
vendored
Normal file
58
resources/views/vendor/mail/html/layout.blade.php
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<title>{{ config('app.name') }}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta name="color-scheme" content="light">
|
||||
<meta name="supported-color-schemes" content="light">
|
||||
<style>
|
||||
@media only screen and (max-width: 600px) {
|
||||
.inner-body {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.footer {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 500px) {
|
||||
.button {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{!! $head ?? '' !!}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<table class="wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table class="content" width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
{!! $header ?? '' !!}
|
||||
|
||||
<!-- Email Body -->
|
||||
<tr>
|
||||
<td class="body" width="100%" cellpadding="0" cellspacing="0" style="border: hidden !important;">
|
||||
<table class="inner-body" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<!-- Body content -->
|
||||
<tr>
|
||||
<td class="content-cell">
|
||||
{!! Illuminate\Mail\Markdown::parse($slot) !!}
|
||||
|
||||
{!! $subcopy ?? '' !!}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{!! $footer ?? '' !!}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
27
resources/views/vendor/mail/html/message.blade.php
vendored
Normal file
27
resources/views/vendor/mail/html/message.blade.php
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
<x-mail::layout>
|
||||
{{-- Header --}}
|
||||
<x-slot:header>
|
||||
<x-mail::header :url="config('app.url')">
|
||||
{{ config('app.name') }}
|
||||
</x-mail::header>
|
||||
</x-slot:header>
|
||||
|
||||
{{-- Body --}}
|
||||
{!! $slot !!}
|
||||
|
||||
{{-- Subcopy --}}
|
||||
@isset($subcopy)
|
||||
<x-slot:subcopy>
|
||||
<x-mail::subcopy>
|
||||
{!! $subcopy !!}
|
||||
</x-mail::subcopy>
|
||||
</x-slot:subcopy>
|
||||
@endisset
|
||||
|
||||
{{-- Footer --}}
|
||||
<x-slot:footer>
|
||||
<x-mail::footer>
|
||||
© {{ date('Y') }} {{ config('app.name') }}. {{ __('All rights reserved.') }}
|
||||
</x-mail::footer>
|
||||
</x-slot:footer>
|
||||
</x-mail::layout>
|
||||
14
resources/views/vendor/mail/html/panel.blade.php
vendored
Normal file
14
resources/views/vendor/mail/html/panel.blade.php
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
<table class="panel" width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td class="panel-content">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td class="panel-item">
|
||||
{{ Illuminate\Mail\Markdown::parse($slot) }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
7
resources/views/vendor/mail/html/subcopy.blade.php
vendored
Normal file
7
resources/views/vendor/mail/html/subcopy.blade.php
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
<table class="subcopy" width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td>
|
||||
{{ Illuminate\Mail\Markdown::parse($slot) }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
3
resources/views/vendor/mail/html/table.blade.php
vendored
Normal file
3
resources/views/vendor/mail/html/table.blade.php
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
<div class="table">
|
||||
{{ Illuminate\Mail\Markdown::parse($slot) }}
|
||||
</div>
|
||||
297
resources/views/vendor/mail/html/themes/default.css
vendored
Normal file
297
resources/views/vendor/mail/html/themes/default.css
vendored
Normal file
@@ -0,0 +1,297 @@
|
||||
/* Base */
|
||||
|
||||
body,
|
||||
body *:not(html):not(style):not(br):not(tr):not(code) {
|
||||
box-sizing: border-box;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif,
|
||||
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
|
||||
position: relative;
|
||||
}
|
||||
|
||||
body {
|
||||
-webkit-text-size-adjust: none;
|
||||
background-color: #ffffff;
|
||||
color: #52525b;
|
||||
height: 100%;
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
p,
|
||||
ul,
|
||||
ol,
|
||||
blockquote {
|
||||
line-height: 1.4;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #18181b;
|
||||
}
|
||||
|
||||
a img {
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
|
||||
h1 {
|
||||
color: #18181b;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin-top: 0;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin-top: 0;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
margin-top: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 16px;
|
||||
line-height: 1.5em;
|
||||
margin-top: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
p.sub {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
|
||||
.wrapper {
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
-premailer-width: 100%;
|
||||
background-color: #fafafa;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.content {
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
-premailer-width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
|
||||
.header {
|
||||
padding: 25px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header a {
|
||||
color: #18181b;
|
||||
font-size: 19px;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Logo */
|
||||
|
||||
.logo {
|
||||
height: 75px;
|
||||
margin-top: 15px;
|
||||
margin-bottom: 10px;
|
||||
max-height: 75px;
|
||||
width: 75px;
|
||||
}
|
||||
|
||||
/* Body */
|
||||
|
||||
.body {
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
-premailer-width: 100%;
|
||||
background-color: #fafafa;
|
||||
border-bottom: 1px solid #fafafa;
|
||||
border-top: 1px solid #fafafa;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.inner-body {
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
-premailer-width: 570px;
|
||||
background-color: #ffffff;
|
||||
border-color: #e4e4e7;
|
||||
border-radius: 4px;
|
||||
border-width: 1px;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
width: 570px;
|
||||
}
|
||||
|
||||
.inner-body a {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Subcopy */
|
||||
|
||||
.subcopy {
|
||||
border-top: 1px solid #e4e4e7;
|
||||
margin-top: 25px;
|
||||
padding-top: 25px;
|
||||
}
|
||||
|
||||
.subcopy p {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
|
||||
.footer {
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
-premailer-width: 570px;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
width: 570px;
|
||||
}
|
||||
|
||||
.footer p {
|
||||
color: #a1a1aa;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: #a1a1aa;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
|
||||
.table table {
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
-premailer-width: 100%;
|
||||
margin: 30px auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table th {
|
||||
border-bottom: 1px solid #e4e4e7;
|
||||
margin: 0;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.table td {
|
||||
color: #52525b;
|
||||
font-size: 15px;
|
||||
line-height: 18px;
|
||||
margin: 0;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.content-cell {
|
||||
max-width: 100vw;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
|
||||
.action {
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
-premailer-width: 100%;
|
||||
margin: 30px auto;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
float: unset;
|
||||
}
|
||||
|
||||
.button {
|
||||
-webkit-text-size-adjust: none;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.button-blue,
|
||||
.button-primary {
|
||||
background-color: #18181b;
|
||||
border-bottom: 8px solid #18181b;
|
||||
border-left: 18px solid #18181b;
|
||||
border-right: 18px solid #18181b;
|
||||
border-top: 8px solid #18181b;
|
||||
}
|
||||
|
||||
.button-green,
|
||||
.button-success {
|
||||
background-color: #16a34a;
|
||||
border-bottom: 8px solid #16a34a;
|
||||
border-left: 18px solid #16a34a;
|
||||
border-right: 18px solid #16a34a;
|
||||
border-top: 8px solid #16a34a;
|
||||
}
|
||||
|
||||
.button-red,
|
||||
.button-error {
|
||||
background-color: #dc2626;
|
||||
border-bottom: 8px solid #dc2626;
|
||||
border-left: 18px solid #dc2626;
|
||||
border-right: 18px solid #dc2626;
|
||||
border-top: 8px solid #dc2626;
|
||||
}
|
||||
|
||||
/* Panels */
|
||||
|
||||
.panel {
|
||||
border-left: #18181b solid 4px;
|
||||
margin: 21px 0;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
background-color: #fafafa;
|
||||
color: #52525b;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.panel-content p {
|
||||
color: #52525b;
|
||||
}
|
||||
|
||||
.panel-item {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.panel-item p:last-of-type {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
/* Utilities */
|
||||
|
||||
.break-all {
|
||||
word-break: break-all;
|
||||
}
|
||||
1
resources/views/vendor/mail/text/button.blade.php
vendored
Normal file
1
resources/views/vendor/mail/text/button.blade.php
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{{ $slot }}: {{ $url }}
|
||||
1
resources/views/vendor/mail/text/footer.blade.php
vendored
Normal file
1
resources/views/vendor/mail/text/footer.blade.php
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{{ $slot }}
|
||||
1
resources/views/vendor/mail/text/header.blade.php
vendored
Normal file
1
resources/views/vendor/mail/text/header.blade.php
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{{ $slot }}: {{ $url }}
|
||||
9
resources/views/vendor/mail/text/layout.blade.php
vendored
Normal file
9
resources/views/vendor/mail/text/layout.blade.php
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
{!! strip_tags($header ?? '') !!}
|
||||
|
||||
{!! strip_tags($slot) !!}
|
||||
@isset($subcopy)
|
||||
|
||||
{!! strip_tags($subcopy) !!}
|
||||
@endisset
|
||||
|
||||
{!! strip_tags($footer ?? '') !!}
|
||||
27
resources/views/vendor/mail/text/message.blade.php
vendored
Normal file
27
resources/views/vendor/mail/text/message.blade.php
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
<x-mail::layout>
|
||||
{{-- Header --}}
|
||||
<x-slot:header>
|
||||
<x-mail::header :url="config('app.url')">
|
||||
{{ config('app.name') }}
|
||||
</x-mail::header>
|
||||
</x-slot:header>
|
||||
|
||||
{{-- Body --}}
|
||||
{{ $slot }}
|
||||
|
||||
{{-- Subcopy --}}
|
||||
@isset($subcopy)
|
||||
<x-slot:subcopy>
|
||||
<x-mail::subcopy>
|
||||
{{ $subcopy }}
|
||||
</x-mail::subcopy>
|
||||
</x-slot:subcopy>
|
||||
@endisset
|
||||
|
||||
{{-- Footer --}}
|
||||
<x-slot:footer>
|
||||
<x-mail::footer>
|
||||
© {{ date('Y') }} {{ config('app.name') }}. @lang('All rights reserved.')
|
||||
</x-mail::footer>
|
||||
</x-slot:footer>
|
||||
</x-mail::layout>
|
||||
1
resources/views/vendor/mail/text/panel.blade.php
vendored
Normal file
1
resources/views/vendor/mail/text/panel.blade.php
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{{ $slot }}
|
||||
1
resources/views/vendor/mail/text/subcopy.blade.php
vendored
Normal file
1
resources/views/vendor/mail/text/subcopy.blade.php
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{{ $slot }}
|
||||
1
resources/views/vendor/mail/text/table.blade.php
vendored
Normal file
1
resources/views/vendor/mail/text/table.blade.php
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{{ $slot }}
|
||||
@@ -1,42 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Listeners\DowngradeUserOnSubscriptionDeleted;
|
||||
use App\Models\User;
|
||||
use App\Models\UserNotificationPreference;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Cashier\Events\WebhookReceived;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('disables whatsapp and sms preferences when a stripe subscription is deleted', function (): void {
|
||||
$user = User::factory()->create(['stripe_id' => 'cus_test_123']);
|
||||
|
||||
UserNotificationPreference::query()->insert([
|
||||
['user_id' => $user->id, 'channel' => 'whatsapp', 'fuel_type' => 'E10', 'enabled' => true, 'created_at' => now(), 'updated_at' => now()],
|
||||
['user_id' => $user->id, 'channel' => 'sms', 'fuel_type' => 'E10', 'enabled' => true, 'created_at' => now(), 'updated_at' => now()],
|
||||
['user_id' => $user->id, 'channel' => 'email', 'fuel_type' => 'E10', 'enabled' => true, 'created_at' => now(), 'updated_at' => now()],
|
||||
]);
|
||||
|
||||
(new DowngradeUserOnSubscriptionDeleted)->handle(new WebhookReceived([
|
||||
'type' => 'customer.subscription.deleted',
|
||||
'data' => ['object' => ['customer' => 'cus_test_123']],
|
||||
]));
|
||||
|
||||
expect(UserNotificationPreference::where('user_id', $user->id)->where('channel', 'whatsapp')->value('enabled'))->toBeFalse();
|
||||
expect(UserNotificationPreference::where('user_id', $user->id)->where('channel', 'sms')->value('enabled'))->toBeFalse();
|
||||
expect(UserNotificationPreference::where('user_id', $user->id)->where('channel', 'email')->value('enabled'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('ignores non-deletion webhook events', function (): void {
|
||||
$user = User::factory()->create(['stripe_id' => 'cus_test_456']);
|
||||
UserNotificationPreference::query()->insert([
|
||||
['user_id' => $user->id, 'channel' => 'whatsapp', 'fuel_type' => 'E10', 'enabled' => true, 'created_at' => now(), 'updated_at' => now()],
|
||||
]);
|
||||
|
||||
(new DowngradeUserOnSubscriptionDeleted)->handle(new WebhookReceived([
|
||||
'type' => 'customer.subscription.updated',
|
||||
'data' => ['object' => ['customer' => 'cus_test_456']],
|
||||
]));
|
||||
|
||||
expect(UserNotificationPreference::where('user_id', $user->id)->where('channel', 'whatsapp')->value('enabled'))->toBeTrue();
|
||||
});
|
||||
139
tests/Feature/Payments/HandleStripeWebhookTest.php
Normal file
139
tests/Feature/Payments/HandleStripeWebhookTest.php
Normal file
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\SendPaymentFailedReminderJob;
|
||||
use App\Listeners\HandleStripeWebhook;
|
||||
use App\Models\User;
|
||||
use App\Models\UserNotificationPreference;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Laravel\Cashier\Events\WebhookReceived;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('busts the plan cache on customer.subscription.created', function (): void {
|
||||
$user = User::factory()->create(['stripe_id' => 'cus_created_1']);
|
||||
Cache::tags(['plans'])->put("plan_for_user_{$user->id}", 'stale', 3600);
|
||||
|
||||
(new HandleStripeWebhook)->handle(new WebhookReceived([
|
||||
'type' => 'customer.subscription.created',
|
||||
'data' => ['object' => ['customer' => 'cus_created_1']],
|
||||
]));
|
||||
|
||||
expect(Cache::tags(['plans'])->get("plan_for_user_{$user->id}"))->toBeNull();
|
||||
});
|
||||
|
||||
it('ignores subscription.created when the user is not found', function (): void {
|
||||
(new HandleStripeWebhook)->handle(new WebhookReceived([
|
||||
'type' => 'customer.subscription.created',
|
||||
'data' => ['object' => ['customer' => 'cus_unknown']],
|
||||
]));
|
||||
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
|
||||
it('busts the plan cache on customer.subscription.updated', function (): void {
|
||||
$user = User::factory()->create(['stripe_id' => 'cus_updated_1']);
|
||||
Cache::tags(['plans'])->put("plan_for_user_{$user->id}", 'stale', 3600);
|
||||
|
||||
(new HandleStripeWebhook)->handle(new WebhookReceived([
|
||||
'type' => 'customer.subscription.updated',
|
||||
'data' => ['object' => ['customer' => 'cus_updated_1']],
|
||||
]));
|
||||
|
||||
expect(Cache::tags(['plans'])->get("plan_for_user_{$user->id}"))->toBeNull();
|
||||
});
|
||||
|
||||
it('on customer.subscription.deleted disables whatsapp+sms prefs, clears grace, busts cache', function (): void {
|
||||
$user = User::factory()->create([
|
||||
'stripe_id' => 'cus_deleted_1',
|
||||
'grace_period_until' => now()->addDays(5),
|
||||
]);
|
||||
|
||||
UserNotificationPreference::query()->insert([
|
||||
['user_id' => $user->id, 'channel' => 'whatsapp', 'fuel_type' => 'E10', 'enabled' => true, 'created_at' => now(), 'updated_at' => now()],
|
||||
['user_id' => $user->id, 'channel' => 'sms', 'fuel_type' => 'E10', 'enabled' => true, 'created_at' => now(), 'updated_at' => now()],
|
||||
['user_id' => $user->id, 'channel' => 'email', 'fuel_type' => 'E10', 'enabled' => true, 'created_at' => now(), 'updated_at' => now()],
|
||||
]);
|
||||
|
||||
Cache::tags(['plans'])->put("plan_for_user_{$user->id}", 'stale', 3600);
|
||||
|
||||
(new HandleStripeWebhook)->handle(new WebhookReceived([
|
||||
'type' => 'customer.subscription.deleted',
|
||||
'data' => ['object' => ['customer' => 'cus_deleted_1']],
|
||||
]));
|
||||
|
||||
expect(UserNotificationPreference::where('user_id', $user->id)->where('channel', 'whatsapp')->value('enabled'))->toBeFalse();
|
||||
expect(UserNotificationPreference::where('user_id', $user->id)->where('channel', 'sms')->value('enabled'))->toBeFalse();
|
||||
expect(UserNotificationPreference::where('user_id', $user->id)->where('channel', 'email')->value('enabled'))->toBeTrue();
|
||||
expect($user->fresh()->grace_period_until)->toBeNull();
|
||||
expect(Cache::tags(['plans'])->get("plan_for_user_{$user->id}"))->toBeNull();
|
||||
});
|
||||
|
||||
it('on invoice.payment_succeeded clears grace_period_until and busts cache', function (): void {
|
||||
$user = User::factory()->create([
|
||||
'stripe_id' => 'cus_paid_1',
|
||||
'grace_period_until' => now()->addDays(4),
|
||||
]);
|
||||
Cache::tags(['plans'])->put("plan_for_user_{$user->id}", 'stale', 3600);
|
||||
|
||||
(new HandleStripeWebhook)->handle(new WebhookReceived([
|
||||
'type' => 'invoice.payment_succeeded',
|
||||
'data' => ['object' => ['customer' => 'cus_paid_1']],
|
||||
]));
|
||||
|
||||
expect($user->fresh()->grace_period_until)->toBeNull();
|
||||
expect(Cache::tags(['plans'])->get("plan_for_user_{$user->id}"))->toBeNull();
|
||||
});
|
||||
|
||||
it('invoice.payment_succeeded is a no-op when grace was never set', function (): void {
|
||||
$user = User::factory()->create(['stripe_id' => 'cus_paid_2', 'grace_period_until' => null]);
|
||||
|
||||
(new HandleStripeWebhook)->handle(new WebhookReceived([
|
||||
'type' => 'invoice.payment_succeeded',
|
||||
'data' => ['object' => ['customer' => 'cus_paid_2']],
|
||||
]));
|
||||
|
||||
expect($user->fresh()->grace_period_until)->toBeNull();
|
||||
});
|
||||
|
||||
it('on invoice.payment_failed sets grace_period_until 5 days out and queues both reminders', function (): void {
|
||||
Queue::fake();
|
||||
$user = User::factory()->create(['stripe_id' => 'cus_failed_1', 'grace_period_until' => null]);
|
||||
|
||||
$before = now();
|
||||
(new HandleStripeWebhook)->handle(new WebhookReceived([
|
||||
'type' => 'invoice.payment_failed',
|
||||
'data' => ['object' => ['customer' => 'cus_failed_1']],
|
||||
]));
|
||||
|
||||
$grace = $user->fresh()->grace_period_until;
|
||||
expect($grace)->not->toBeNull();
|
||||
expect($grace->greaterThanOrEqualTo($before->copy()->addDays(5)->subSecond()))->toBeTrue();
|
||||
expect($grace->lessThanOrEqualTo($before->copy()->addDays(5)->addSeconds(5)))->toBeTrue();
|
||||
|
||||
Queue::assertPushed(SendPaymentFailedReminderJob::class, function ($job) use ($user) {
|
||||
return $job->userId === $user->id && $job->day === 3;
|
||||
});
|
||||
Queue::assertPushed(SendPaymentFailedReminderJob::class, function ($job) use ($user) {
|
||||
return $job->userId === $user->id && $job->day === 5;
|
||||
});
|
||||
Queue::assertPushed(SendPaymentFailedReminderJob::class, 2);
|
||||
});
|
||||
|
||||
it('repeat invoice.payment_failed within grace does not re-dispatch reminders', function (): void {
|
||||
Queue::fake();
|
||||
$existingGrace = now()->addDays(3)->startOfSecond();
|
||||
$user = User::factory()->create(['stripe_id' => 'cus_failed_2', 'grace_period_until' => $existingGrace]);
|
||||
|
||||
(new HandleStripeWebhook)->handle(new WebhookReceived([
|
||||
'type' => 'invoice.payment_failed',
|
||||
'data' => ['object' => ['customer' => 'cus_failed_2']],
|
||||
]));
|
||||
|
||||
// grace_period_until unchanged (same value)
|
||||
expect($user->fresh()->grace_period_until->equalTo($existingGrace))->toBeTrue();
|
||||
|
||||
// No new jobs queued
|
||||
Queue::assertNothingPushed();
|
||||
});
|
||||
54
tests/Feature/Payments/SendPaymentFailedReminderJobTest.php
Normal file
54
tests/Feature/Payments/SendPaymentFailedReminderJobTest.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\SendPaymentFailedReminderJob;
|
||||
use App\Mail\PaymentFailedDay3Reminder;
|
||||
use App\Mail\PaymentFailedDay5Reminder;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('sends the day-3 mailable when grace_period_until is still set', function (): void {
|
||||
Mail::fake();
|
||||
$user = User::factory()->create(['grace_period_until' => now()->addDays(2)]);
|
||||
|
||||
(new SendPaymentFailedReminderJob($user->id, 3))->handle();
|
||||
|
||||
Mail::assertSent(PaymentFailedDay3Reminder::class, fn ($mail) => $mail->hasTo($user->email));
|
||||
});
|
||||
|
||||
it('sends the day-5 mailable when grace_period_until is still set', function (): void {
|
||||
Mail::fake();
|
||||
$user = User::factory()->create(['grace_period_until' => now()->addDay()]);
|
||||
|
||||
(new SendPaymentFailedReminderJob($user->id, 5))->handle();
|
||||
|
||||
Mail::assertSent(PaymentFailedDay5Reminder::class, fn ($mail) => $mail->hasTo($user->email));
|
||||
});
|
||||
|
||||
it('silently skips the day-3 mailable when grace has been cleared', function (): void {
|
||||
Mail::fake();
|
||||
$user = User::factory()->create(['grace_period_until' => null]);
|
||||
|
||||
(new SendPaymentFailedReminderJob($user->id, 3))->handle();
|
||||
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
|
||||
it('silently skips the day-5 mailable when grace has been cleared', function (): void {
|
||||
Mail::fake();
|
||||
$user = User::factory()->create(['grace_period_until' => null]);
|
||||
|
||||
(new SendPaymentFailedReminderJob($user->id, 5))->handle();
|
||||
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
|
||||
it('silently skips when the user no longer exists', function (): void {
|
||||
Mail::fake();
|
||||
|
||||
(new SendPaymentFailedReminderJob(999999, 3))->handle();
|
||||
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
Reference in New Issue
Block a user