Merge branch 'feat/stripe-lifecycle'
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled

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:
Ovidiu U
2026-04-23 12:00:19 +01:00
32 changed files with 973 additions and 82 deletions

View 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);
}
}

View File

@@ -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}");
}
}

View 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}");
}
}

View 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'),
],
);
}
}

View 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'),
],
);
}
}

View File

@@ -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',
];
}

View File

@@ -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);
}
/**

View File

@@ -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');
});
}
};

View File

@@ -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" />

View 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>

View 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>

View 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

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,3 @@
<div class="table">
{{ Illuminate\Mail\Markdown::parse($slot) }}
</div>

View 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;
}

View File

@@ -0,0 +1 @@
{{ $slot }}: {{ $url }}

View File

@@ -0,0 +1 @@
{{ $slot }}

View File

@@ -0,0 +1 @@
{{ $slot }}: {{ $url }}

View File

@@ -0,0 +1,9 @@
{!! strip_tags($header ?? '') !!}
{!! strip_tags($slot) !!}
@isset($subcopy)
{!! strip_tags($subcopy) !!}
@endisset
{!! strip_tags($footer ?? '') !!}

View 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>

View File

@@ -0,0 +1 @@
{{ $slot }}

View File

@@ -0,0 +1 @@
{{ $slot }}

View File

@@ -0,0 +1 @@
{{ $slot }}

View File

@@ -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();
});

View 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();
});

View 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();
});