Compare commits
30 Commits
b4bd78ab4c
...
3224b186b2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3224b186b2 | ||
|
|
36444cde05 | ||
|
|
b7175169f0 | ||
|
|
5b17f4cae4 | ||
|
|
c127cc379e | ||
|
|
de2499636f | ||
|
|
2078c4b83e | ||
|
|
b9d457578c | ||
|
|
25b79f095b | ||
|
|
a39d4b1b94 | ||
|
|
f1c1a1c572 | ||
|
|
bf013926c0 | ||
|
|
19fc61a0a3 | ||
|
|
13fc227619 | ||
|
|
d8f87f964d | ||
|
|
975a1522cf | ||
|
|
29ba2f3d86 | ||
|
|
3ec7cda790 | ||
|
|
d01a634f0b | ||
|
|
9ad62538b9 | ||
|
|
4a60298606 | ||
|
|
5426722c71 | ||
|
|
d460de1850 | ||
|
|
45bf1c0d24 | ||
|
|
1e3b246172 | ||
|
|
9fa9ea7835 | ||
|
|
55c81fab7b | ||
|
|
64a7cc3de5 | ||
|
|
7c114c72e4 | ||
|
|
2fe9c3ef77 |
@@ -4,51 +4,121 @@
|
|||||||
|
|
||||||
Laravel Cashier (Stripe). Never implement custom billing logic — use Cashier methods.
|
Laravel Cashier (Stripe). Never implement custom billing logic — use Cashier methods.
|
||||||
|
|
||||||
## Stripe products
|
## Source-of-truth spec
|
||||||
|
|
||||||
|
`docs/superpowers/specs/2026-04-23-stripe-subscription-lifecycle-design.md`
|
||||||
|
defines the full subscription lifecycle. This file is a quick-reference; the
|
||||||
|
spec document is authoritative on any contradiction.
|
||||||
|
|
||||||
|
## Stripe products & prices
|
||||||
|
|
||||||
|
Three recurring subscription products, each with monthly and annual prices:
|
||||||
|
|
||||||
Three recurring subscription products (monthly):
|
|
||||||
- `basic` — £0.99/mo
|
- `basic` — £0.99/mo
|
||||||
- `plus` — £2.49/mo
|
- `plus` — £2.49/mo
|
||||||
- `pro` — £3.99/mo
|
- `pro` — £3.99/mo
|
||||||
|
|
||||||
Price IDs stored in `config/services.php` under `stripe.prices.*`, loaded from .env:
|
Price IDs stored in `config/services.php` under `stripe.prices.*`, loaded from `.env`:
|
||||||
|
|
||||||
```
|
```
|
||||||
STRIPE_PRICES_BASIC=price_xxx
|
STRIPE_PRICE_BASIC_MONTHLY=price_xxx
|
||||||
STRIPE_PRICES_PLUS=price_xxx
|
STRIPE_PRICE_BASIC_ANNUAL=price_xxx
|
||||||
STRIPE_PRICES_PRO=price_xxx
|
STRIPE_PRICE_PLUS_MONTHLY=price_xxx
|
||||||
|
STRIPE_PRICE_PLUS_ANNUAL=price_xxx
|
||||||
|
STRIPE_PRICE_PRO_MONTHLY=price_xxx
|
||||||
|
STRIPE_PRICE_PRO_ANNUAL=price_xxx
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tier helpers (SubscriptionService)
|
Resolution from a Cashier subscription's Stripe price ID to a plan row is done
|
||||||
|
in `Plan::resolveForUser()` — never hand-code tier lookups elsewhere.
|
||||||
|
|
||||||
```php
|
## Tier resolution
|
||||||
public function tier(User $user): string
|
|
||||||
// Returns 'free' | 'basic' | 'plus' | 'pro'
|
|
||||||
|
|
||||||
public function canReceiveSms(User $user): bool
|
Use `PlanFeatures::for($user)->tier()` — returns `'free' | 'basic' | 'plus' | 'pro'`.
|
||||||
// true if tier is plus or pro
|
Never inspect `$user->subscribed(...)` directly in components, notifications, or
|
||||||
|
jobs. `PlanFeatures` is the single source of entitlement truth.
|
||||||
public function smsRemainingThisMonth(User $user): int
|
|
||||||
// checks alerts table count for current month
|
|
||||||
```
|
|
||||||
|
|
||||||
Never check tier inline in components or notification classes — always use SubscriptionService.
|
|
||||||
|
|
||||||
## Cashier conventions
|
## Cashier conventions
|
||||||
|
|
||||||
- Billable model: `User` (add `use Billable` trait)
|
- Billable model: `User` (uses `Billable` trait)
|
||||||
- Webhook route: `POST /stripe/webhook` — handled by Cashier automatically
|
- Webhook route: `POST /stripe/webhook` — auto-registered by Cashier
|
||||||
- Webhook secret in `.env` as `STRIPE_WEBHOOK_SECRET`
|
- Webhook secret in `.env` as `STRIPE_WEBHOOK_SECRET`
|
||||||
- Always handle `customer.subscription.deleted` to downgrade user to free tier
|
- `STRIPE_KEY` and `STRIPE_SECRET` also required
|
||||||
- Trial: none for v1
|
- `CASHIER_CURRENCY=gbp`
|
||||||
|
- Trial period: none
|
||||||
|
|
||||||
## Upgrade / downgrade flow
|
## User-facing flows — all via Stripe Customer Portal
|
||||||
|
|
||||||
- User upgrades in account settings Livewire component
|
**The Stripe-hosted Customer Billing Portal handles every subscription
|
||||||
- Swap plan with `$user->subscription()->swap($newPriceId)`
|
management action.** Do not build custom Livewire upgrade/downgrade UIs.
|
||||||
- Cashier handles proration automatically
|
|
||||||
- On downgrade to free: cancel subscription, remove WhatsApp/SMS notification preference
|
| Flow | Path |
|
||||||
|
|---|---|
|
||||||
|
| Sign up for paid tier | Pricing page → `GET /billing/checkout/{tier}/{cadence}` → Stripe Checkout |
|
||||||
|
| Upgrade | Pricing page → `GET /billing/portal` → Stripe Portal → pick higher plan → Stripe prorates, charges difference immediately |
|
||||||
|
| Downgrade | Stripe Portal → pick lower plan → Stripe schedules change at period end |
|
||||||
|
| Cancel | Stripe Portal → cancel → `cancel_at_period_end=true`; features stay until period end |
|
||||||
|
| Update card | Stripe Portal, or hosted link in Stripe's transactional dunning email |
|
||||||
|
| Reactivate after cancel / post-grace | Pricing page → Checkout (new subscription) |
|
||||||
|
|
||||||
|
Annual downgrades take effect at the end of the year — no mid-term refunds.
|
||||||
|
|
||||||
|
## Webhook handling
|
||||||
|
|
||||||
|
Single consolidated listener `HandleStripeWebhook` bound to Cashier's
|
||||||
|
`WebhookReceived` event in `AppServiceProvider`. Routes on `$event->payload['type']`:
|
||||||
|
|
||||||
|
| Event | Behaviour |
|
||||||
|
|---|---|
|
||||||
|
| `customer.subscription.created` | Bust `plan_for_user_{id}` cache |
|
||||||
|
| `customer.subscription.updated` | Bust cache |
|
||||||
|
| `customer.subscription.deleted` | Downgrade to free, disable WhatsApp + SMS prefs, clear `grace_period_until`, bust cache |
|
||||||
|
| `invoice.payment_succeeded` | Clear `grace_period_until`, bust cache |
|
||||||
|
| `invoice.payment_failed` | Set `grace_period_until = now()->addDays(5)`, queue day-3 + day-5 branded reminder mailables |
|
||||||
|
|
||||||
|
All branches must be idempotent — Stripe retries failed webhook deliveries.
|
||||||
|
|
||||||
|
`invoice.upcoming` is intentionally not handled.
|
||||||
|
|
||||||
|
## Payment failure & grace period
|
||||||
|
|
||||||
|
5-day grace window. Stripe is configured (dashboard) to retry on days 1, 3, 5
|
||||||
|
and **cancel the subscription** after the final failure.
|
||||||
|
|
||||||
|
- Features stay ON during grace — `past_due` is treated as subscribed by
|
||||||
|
Cashier, so `PlanFeatures::tier()` keeps returning the paid tier.
|
||||||
|
- After day 5 Stripe cancels → `customer.subscription.deleted` → downgrade.
|
||||||
|
- User can pay at any time via Stripe's dunning email link or the Customer
|
||||||
|
Portal — on success, grace is cleared automatically by the webhook.
|
||||||
|
|
||||||
|
## Dunning emails
|
||||||
|
|
||||||
|
- **Stripe sends:** payment-failed "update your card", successful-payment
|
||||||
|
receipts, upcoming-renewal reminders. Configure in Stripe dashboard.
|
||||||
|
- **We send:** branded reminder mailables on day 3 and day 5 after a
|
||||||
|
payment failure. Both mailables self-cancel by checking
|
||||||
|
`$this->user->grace_period_until === null` before sending — simpler than
|
||||||
|
cancelling queued jobs when payment recovers.
|
||||||
|
|
||||||
|
## Data model additions
|
||||||
|
|
||||||
|
- `users.grace_period_until` — nullable timestamp. Set on
|
||||||
|
`invoice.payment_failed`, cleared on `invoice.payment_succeeded` or
|
||||||
|
`customer.subscription.deleted`. Drives the dashboard past-due banner.
|
||||||
|
|
||||||
|
No other schema additions. Cashier + Stripe are the source of truth for
|
||||||
|
subscription state.
|
||||||
|
|
||||||
|
## VAT / Stripe Tax
|
||||||
|
|
||||||
|
Not enabled for v1. Revisit before £90k/year turnover (~£1.88k/month at
|
||||||
|
£3.99 avg, or ~470 paying pro users).
|
||||||
|
|
||||||
## Stripe test mode
|
## Stripe test mode
|
||||||
|
|
||||||
Use Stripe test keys in local `.env`. Never commit real Stripe keys.
|
Use Stripe test keys in local `.env`. Never commit real Stripe keys.
|
||||||
Test cards: 4242424242424242 (success), 4000000000000002 (decline).
|
|
||||||
|
Test cards:
|
||||||
|
- `4242 4242 4242 4242` — success
|
||||||
|
- `4000 0000 0000 0002` — generic decline
|
||||||
|
- `4000 0000 0000 0341` — renewal charge fails (use to test dunning flow)
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -22,3 +22,4 @@ yarn-error.log
|
|||||||
/.vscode
|
/.vscode
|
||||||
/.zed
|
/.zed
|
||||||
/.tmp/
|
/.tmp/
|
||||||
|
/.worktrees/
|
||||||
|
|||||||
137
app/Console/Commands/ImportPostcodes.php
Normal file
137
app/Console/Commands/ImportPostcodes.php
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Attributes\Description;
|
||||||
|
use Illuminate\Console\Attributes\Signature;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
#[Signature('postcodes:import {--file= : Path to ONSPD CSV file}')]
|
||||||
|
#[Description('Import UK postcodes (ONSPD) into the local postcodes and outcodes tables')]
|
||||||
|
final class ImportPostcodes extends Command
|
||||||
|
{
|
||||||
|
private const int CHUNK_SIZE = 1000;
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$file = $this->option('file');
|
||||||
|
|
||||||
|
if ($file === null || ! is_readable($file)) {
|
||||||
|
$this->error('--file is required and must be a readable path to an ONSPD CSV.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$handle = fopen($file, 'r');
|
||||||
|
|
||||||
|
if ($handle === false) {
|
||||||
|
$this->error("Unable to open {$file}.");
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$header = fgetcsv($handle);
|
||||||
|
|
||||||
|
if ($header === false) {
|
||||||
|
$this->error('CSV is empty.');
|
||||||
|
fclose($handle);
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$headerCounts = array_count_values(array_map('strtolower', $header));
|
||||||
|
$columns = array_change_key_case(array_flip($header), CASE_LOWER);
|
||||||
|
|
||||||
|
$pcdColumn = null;
|
||||||
|
|
||||||
|
foreach (['pcd', 'pcds', 'pcd7', 'pcd8'] as $candidate) {
|
||||||
|
if (isset($columns[$candidate])) {
|
||||||
|
$pcdColumn = $candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pcdColumn === null) {
|
||||||
|
$this->error('Missing required postcode column (expected one of: pcd, pcds, pcd7, pcd8).');
|
||||||
|
fclose($handle);
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ([$pcdColumn, 'lat', 'long'] as $required) {
|
||||||
|
if (($headerCounts[$required] ?? 0) > 1) {
|
||||||
|
$this->error("Column '{$required}' appears more than once — refusing to import.");
|
||||||
|
fclose($handle);
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['lat', 'long'] as $required) {
|
||||||
|
if (! isset($columns[$required])) {
|
||||||
|
$this->error("Missing required column '{$required}'.");
|
||||||
|
fclose($handle);
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$hasDoterm = isset($columns['doterm']);
|
||||||
|
|
||||||
|
DB::table('postcodes')->truncate();
|
||||||
|
DB::table('outcodes')->truncate();
|
||||||
|
|
||||||
|
$buffer = [];
|
||||||
|
$imported = 0;
|
||||||
|
|
||||||
|
while (($row = fgetcsv($handle)) !== false) {
|
||||||
|
if ($hasDoterm && trim((string) ($row[$columns['doterm']] ?? '')) !== '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lat = trim((string) ($row[$columns['lat']] ?? ''));
|
||||||
|
$lng = trim((string) ($row[$columns['long']] ?? ''));
|
||||||
|
|
||||||
|
if ($lat === '' || $lng === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pcd = strtoupper(preg_replace('/\s+/', '', (string) $row[$columns[$pcdColumn]]));
|
||||||
|
|
||||||
|
if ($pcd === '' || strlen($pcd) < 5) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$buffer[] = [
|
||||||
|
'postcode' => $pcd,
|
||||||
|
'outcode' => substr($pcd, 0, strlen($pcd) - 3),
|
||||||
|
'lat' => (float) $lat,
|
||||||
|
'lng' => (float) $lng,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (count($buffer) >= self::CHUNK_SIZE) {
|
||||||
|
DB::table('postcodes')->insert($buffer);
|
||||||
|
$imported += count($buffer);
|
||||||
|
$buffer = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($buffer !== []) {
|
||||||
|
DB::table('postcodes')->insert($buffer);
|
||||||
|
$imported += count($buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($handle);
|
||||||
|
|
||||||
|
DB::statement(
|
||||||
|
'INSERT INTO outcodes (outcode, lat, lng)
|
||||||
|
SELECT outcode, AVG(lat), AVG(lng) FROM postcodes GROUP BY outcode'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->info("Imported {$imported} postcodes.");
|
||||||
|
$this->info('Derived '.DB::table('outcodes')->count().' outcode centroids.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
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'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/Models/Outcode.php
Normal file
26
app/Models/Outcode.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
#[Fillable(['outcode', 'lat', 'lng'])]
|
||||||
|
class Outcode extends Model
|
||||||
|
{
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected $primaryKey = 'outcode';
|
||||||
|
|
||||||
|
public $incrementing = false;
|
||||||
|
|
||||||
|
protected $keyType = 'string';
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'lat' => 'float',
|
||||||
|
'lng' => 'float',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/Models/Postcode.php
Normal file
26
app/Models/Postcode.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
#[Fillable(['postcode', 'outcode', 'lat', 'lng'])]
|
||||||
|
class Postcode extends Model
|
||||||
|
{
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected $primaryKey = 'postcode';
|
||||||
|
|
||||||
|
public $incrementing = false;
|
||||||
|
|
||||||
|
protected $keyType = 'string';
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'lat' => 'float',
|
||||||
|
'lng' => 'float',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ use Laravel\Cashier\Billable;
|
|||||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||||
use Laravel\Sanctum\HasApiTokens;
|
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'])]
|
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
|
||||||
class User extends Authenticatable implements FilamentUser
|
class User extends Authenticatable implements FilamentUser
|
||||||
{
|
{
|
||||||
@@ -35,6 +35,7 @@ class User extends Authenticatable implements FilamentUser
|
|||||||
'email_verified_at' => 'datetime',
|
'email_verified_at' => 'datetime',
|
||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
'is_admin' => 'boolean',
|
'is_admin' => 'boolean',
|
||||||
|
'grace_period_until' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use App\Listeners\DowngradeUserOnSubscriptionDeleted;
|
use App\Listeners\HandleStripeWebhook;
|
||||||
use App\Services\ApiLogger;
|
use App\Services\ApiLogger;
|
||||||
use App\Services\LlmPrediction\AnthropicPredictionProvider;
|
use App\Services\LlmPrediction\AnthropicPredictionProvider;
|
||||||
use App\Services\LlmPrediction\GeminiPredictionProvider;
|
use App\Services\LlmPrediction\GeminiPredictionProvider;
|
||||||
@@ -41,7 +41,7 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
{
|
{
|
||||||
$this->configureDefaults();
|
$this->configureDefaults();
|
||||||
|
|
||||||
Event::listen(WebhookReceived::class, DowngradeUserOnSubscriptionDeleted::class);
|
Event::listen(WebhookReceived::class, HandleStripeWebhook::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Outcode;
|
||||||
|
use App\Models\Postcode;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
@@ -24,7 +26,16 @@ class PostcodeService
|
|||||||
public function resolve(string $query): ?LocationResult
|
public function resolve(string $query): ?LocationResult
|
||||||
{
|
{
|
||||||
$query = trim($query);
|
$query = trim($query);
|
||||||
$cacheKey = 'postcode:'.strtolower(preg_replace('/\s+/', '', $query));
|
|
||||||
|
if ($this->isFullPostcode($query)) {
|
||||||
|
return $this->lookupLocalPostcode($query) ?? $this->lookupPostcode($query);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isOutcode($query)) {
|
||||||
|
return $this->lookupLocalOutcode($query) ?? $this->lookupOutcode($query);
|
||||||
|
}
|
||||||
|
|
||||||
|
$cacheKey = 'place:'.strtolower(preg_replace('/\s+/', '', $query));
|
||||||
|
|
||||||
$cached = Cache::get($cacheKey);
|
$cached = Cache::get($cacheKey);
|
||||||
|
|
||||||
@@ -32,11 +43,7 @@ class PostcodeService
|
|||||||
return $cached;
|
return $cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = match (true) {
|
$result = $this->lookupPlace($query);
|
||||||
$this->isFullPostcode($query) => $this->lookupPostcode($query),
|
|
||||||
$this->isOutcode($query) => $this->lookupOutcode($query),
|
|
||||||
default => $this->lookupPlace($query),
|
|
||||||
};
|
|
||||||
|
|
||||||
if ($result !== null) {
|
if ($result !== null) {
|
||||||
Cache::put($cacheKey, $result, self::CACHE_TTL);
|
Cache::put($cacheKey, $result, self::CACHE_TTL);
|
||||||
@@ -45,6 +52,11 @@ class PostcodeService
|
|||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function normalisePostcode(string $value): string
|
||||||
|
{
|
||||||
|
return strtoupper(preg_replace('/\s+/', '', $value));
|
||||||
|
}
|
||||||
|
|
||||||
private function isFullPostcode(string $query): bool
|
private function isFullPostcode(string $query): bool
|
||||||
{
|
{
|
||||||
return (bool) preg_match('/^[A-Z]{1,2}[0-9][0-9A-Z]?\s*[0-9][A-Z]{2}$/i', $query);
|
return (bool) preg_match('/^[A-Z]{1,2}[0-9][0-9A-Z]?\s*[0-9][A-Z]{2}$/i', $query);
|
||||||
@@ -55,9 +67,55 @@ class PostcodeService
|
|||||||
return (bool) preg_match('/^[A-Z]{1,2}[0-9][0-9A-Z]?$/i', $query);
|
return (bool) preg_match('/^[A-Z]{1,2}[0-9][0-9A-Z]?$/i', $query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function lookupLocalPostcode(string $postcode): ?LocationResult
|
||||||
|
{
|
||||||
|
$normalised = $this->normalisePostcode($postcode);
|
||||||
|
|
||||||
|
$row = Postcode::find($normalised);
|
||||||
|
|
||||||
|
if ($row === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new LocationResult(
|
||||||
|
query: $postcode,
|
||||||
|
displayName: $this->formatPostcode($normalised),
|
||||||
|
lat: $row->lat,
|
||||||
|
lng: $row->lng,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function lookupLocalOutcode(string $outcode): ?LocationResult
|
||||||
|
{
|
||||||
|
$normalised = strtoupper(trim($outcode));
|
||||||
|
|
||||||
|
$row = Outcode::find($normalised);
|
||||||
|
|
||||||
|
if ($row === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new LocationResult(
|
||||||
|
query: $outcode,
|
||||||
|
displayName: $normalised,
|
||||||
|
lat: $row->lat,
|
||||||
|
lng: $row->lng,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatPostcode(string $normalised): string
|
||||||
|
{
|
||||||
|
// Insert the single space before the last 3 chars ("SW1A1AA" -> "SW1A 1AA").
|
||||||
|
if (strlen($normalised) < 5) {
|
||||||
|
return $normalised;
|
||||||
|
}
|
||||||
|
|
||||||
|
return substr($normalised, 0, -3).' '.substr($normalised, -3);
|
||||||
|
}
|
||||||
|
|
||||||
private function lookupPostcode(string $postcode): ?LocationResult
|
private function lookupPostcode(string $postcode): ?LocationResult
|
||||||
{
|
{
|
||||||
$normalised = strtoupper(preg_replace('/\s+/', '', $postcode));
|
$normalised = $this->normalisePostcode($postcode);
|
||||||
$url = self::BASE_URL.'/postcodes/'.$normalised;
|
$url = self::BASE_URL.'/postcodes/'.$normalised;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -69,12 +127,34 @@ class PostcodeService
|
|||||||
|
|
||||||
$data = $response->json('result');
|
$data = $response->json('result');
|
||||||
|
|
||||||
return new LocationResult(
|
if (! is_array($data) || ! isset($data['postcode'], $data['latitude'], $data['longitude'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = new LocationResult(
|
||||||
query: $postcode,
|
query: $postcode,
|
||||||
displayName: $data['postcode'],
|
displayName: $data['postcode'],
|
||||||
lat: $data['latitude'],
|
lat: $data['latitude'],
|
||||||
lng: $data['longitude'],
|
lng: $data['longitude'],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
Postcode::updateOrCreate(
|
||||||
|
['postcode' => $normalised],
|
||||||
|
[
|
||||||
|
'outcode' => substr($normalised, 0, strlen($normalised) - 3),
|
||||||
|
'lat' => $data['latitude'],
|
||||||
|
'lng' => $data['longitude'],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::warning('PostcodeService: failed to persist postcode after HTTP fallback', [
|
||||||
|
'postcode' => $normalised,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
Log::error('PostcodeService: postcode lookup failed', [
|
Log::error('PostcodeService: postcode lookup failed', [
|
||||||
'postcode' => $postcode,
|
'postcode' => $postcode,
|
||||||
@@ -99,12 +179,33 @@ class PostcodeService
|
|||||||
|
|
||||||
$data = $response->json('result');
|
$data = $response->json('result');
|
||||||
|
|
||||||
return new LocationResult(
|
if (! is_array($data) || ! isset($data['outcode'], $data['latitude'], $data['longitude'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = new LocationResult(
|
||||||
query: $outcode,
|
query: $outcode,
|
||||||
displayName: $data['outcode'],
|
displayName: $data['outcode'],
|
||||||
lat: $data['latitude'],
|
lat: $data['latitude'],
|
||||||
lng: $data['longitude'],
|
lng: $data['longitude'],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
Outcode::updateOrCreate(
|
||||||
|
['outcode' => $normalised],
|
||||||
|
[
|
||||||
|
'lat' => $data['latitude'],
|
||||||
|
'lng' => $data['longitude'],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::warning('PostcodeService: failed to persist outcode after HTTP fallback', [
|
||||||
|
'outcode' => $normalised,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
Log::error('PostcodeService: outcode lookup failed', [
|
Log::error('PostcodeService: outcode lookup failed', [
|
||||||
'outcode' => $outcode,
|
'outcode' => $outcode,
|
||||||
|
|||||||
@@ -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::create('postcodes', function (Blueprint $table): void {
|
||||||
|
$table->string('postcode', 7)->primary()->comment('Normalised: uppercase, no spaces');
|
||||||
|
$table->string('outcode', 4)->index();
|
||||||
|
$table->decimal('lat', 10, 7);
|
||||||
|
$table->decimal('lng', 10, 7);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('postcodes');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?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::create('outcodes', function (Blueprint $table): void {
|
||||||
|
$table->string('outcode', 4)->primary();
|
||||||
|
$table->decimal('lat', 10, 7);
|
||||||
|
$table->decimal('lng', 10, 7);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('outcodes');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
1071
docs/superpowers/plans/2026-04-22-self-hosted-postcodes.md
Normal file
1071
docs/superpowers/plans/2026-04-22-self-hosted-postcodes.md
Normal file
File diff suppressed because it is too large
Load Diff
1095
docs/superpowers/plans/2026-04-23-stripe-subscription-lifecycle.md
Normal file
1095
docs/superpowers/plans/2026-04-23-stripe-subscription-lifecycle.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,216 @@
|
|||||||
|
# Stripe Subscription Lifecycle — Design Spec
|
||||||
|
|
||||||
|
**Date:** 2026-04-23
|
||||||
|
**Status:** Approved — ready for implementation plan
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Formalise the end-to-end Stripe subscription flow: signup, upgrade, downgrade,
|
||||||
|
cancellation, renewal, payment failure recovery, and final downgrade. Covers
|
||||||
|
webhook handling, email communication, user-facing flows, and minimal data-model
|
||||||
|
additions on top of the existing Cashier + `Plan` + `PlanFeatures` foundation.
|
||||||
|
|
||||||
|
Existing working pieces (see `docs/superpowers/specs/2026-04-15-tier-features-design.md`)
|
||||||
|
are kept as-is:
|
||||||
|
|
||||||
|
- Laravel Cashier, `Plan` model with `resolveForUser()` + cache
|
||||||
|
- `PlanFeatures` service (all entitlement decisions)
|
||||||
|
- `RequiresFeature` middleware
|
||||||
|
- `DispatchUserNotificationJob` using `PlanFeatures`
|
||||||
|
- `BillingController` (checkout + portal + success/cancel routes)
|
||||||
|
- Existing `DowngradeUserOnSubscriptionDeleted` listener (absorbed into the new
|
||||||
|
consolidated handler below)
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| Topic | Decision |
|
||||||
|
|---|---|
|
||||||
|
| Grace period length | 5 days from first failed renewal charge |
|
||||||
|
| Retry strategy | Stripe-managed: 3 attempts on days 1, 3, 5; then cancel subscription |
|
||||||
|
| Features during grace | Stay ON until `customer.subscription.deleted` fires |
|
||||||
|
| Dunning emails | Hybrid — Stripe sends "update card" transactional; we send branded day-3 and day-5 reminders |
|
||||||
|
| Annual downgrade policy | Wait until renewal; no mid-term refunds |
|
||||||
|
| Subscription management UI | Stripe-hosted Customer Portal for everything (upgrade, downgrade, cancel, card update, invoices) |
|
||||||
|
| VAT / Stripe Tax | Skip for v1; revisit before £90k turnover |
|
||||||
|
| Post-grace reactivation | User returns via pricing page → Stripe Checkout (new subscription) |
|
||||||
|
|
||||||
|
## Webhook Event Catalogue
|
||||||
|
|
||||||
|
All Stripe events arrive via Cashier's auto-registered `/stripe/webhook` route
|
||||||
|
and fire the `Laravel\Cashier\Events\WebhookReceived` event. A single consolidated
|
||||||
|
listener `HandleStripeWebhook` routes on `$event->payload['type']`. The existing
|
||||||
|
`DowngradeUserOnSubscriptionDeleted` listener is folded into it.
|
||||||
|
|
||||||
|
| Event | Action |
|
||||||
|
|---|---|
|
||||||
|
| `customer.subscription.created` | Bust `plan_for_user_{id}` cache |
|
||||||
|
| `customer.subscription.updated` | Bust cache (catches portal plan swaps + `past_due` ↔ `active` transitions) |
|
||||||
|
| `customer.subscription.deleted` | Downgrade user to free, disable WhatsApp + SMS prefs, clear `grace_period_until`, bust cache |
|
||||||
|
| `invoice.payment_succeeded` | Clear `grace_period_until`, bust cache |
|
||||||
|
| `invoice.payment_failed` | Set `grace_period_until = now + 5 days`, queue day-3 and day-5 reminder mailables with `->delay()` |
|
||||||
|
|
||||||
|
Events not listed are ignored. `invoice.upcoming` is intentionally not handled —
|
||||||
|
Stripe's default renewal-preview email covers it.
|
||||||
|
|
||||||
|
The listener is **idempotent**: every branch is safe to run twice on the same
|
||||||
|
event (Cashier does not natively deduplicate webhooks, and Stripe retries
|
||||||
|
failing deliveries). Cache busts are idempotent by nature; state writes use
|
||||||
|
`updateOrCreate` / direct column updates that converge on the same result.
|
||||||
|
|
||||||
|
## Feature Access During Grace
|
||||||
|
|
||||||
|
- When `invoice.payment_failed` fires, Stripe transitions the subscription to
|
||||||
|
`past_due`. Cashier's `$user->subscribed('plus')` returns `true` for
|
||||||
|
`past_due` subscriptions by default, so `PlanFeatures::tier()` already reports
|
||||||
|
the paid tier. **No code change needed.**
|
||||||
|
- Features only turn off when `customer.subscription.deleted` fires — which
|
||||||
|
happens after Stripe's 3rd failed retry (day 5). At that point the listener
|
||||||
|
clears the Cashier subscription and the next `PlanFeatures::for($user)` call
|
||||||
|
resolves to `free`.
|
||||||
|
- The dashboard renders a banner while `$user->subscription()->pastDue()` is
|
||||||
|
true, linking to the Stripe Portal to update the card.
|
||||||
|
|
||||||
|
## Data Model Additions
|
||||||
|
|
||||||
|
### `users` table
|
||||||
|
|
||||||
|
Add one nullable column:
|
||||||
|
|
||||||
|
| Column | Type | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `grace_period_until` | `timestamp` nullable | Drives the past-due banner + is used by reminder mailables as the cancellation check |
|
||||||
|
|
||||||
|
Set when `invoice.payment_failed` fires (`now()->addDays(5)`). Cleared on
|
||||||
|
`invoice.payment_succeeded` or `customer.subscription.deleted`.
|
||||||
|
|
||||||
|
No other schema changes. Stripe + Cashier tables remain the source of truth for
|
||||||
|
subscription state.
|
||||||
|
|
||||||
|
## User-Facing Flows
|
||||||
|
|
||||||
|
| Flow | Path |
|
||||||
|
|---|---|
|
||||||
|
| Sign up (paid) | Pricing page → `/billing/checkout/{tier}/{cadence}` → Stripe Checkout → dashboard |
|
||||||
|
| Upgrade | Pricing page → "Manage subscription" → Stripe Portal → select higher plan → Stripe prorates immediately → webhook updates cache |
|
||||||
|
| Downgrade | Stripe Portal → select lower plan → Stripe schedules change at period end → webhook on change day fires `customer.subscription.updated` → features swap on period rollover |
|
||||||
|
| Cancel | Stripe Portal → cancel → `cancel_at_period_end=true` → features remain until period end → `customer.subscription.deleted` on that date |
|
||||||
|
| Update card | Stripe Portal, or via the "update payment method" link in Stripe's transactional dunning email |
|
||||||
|
| Reactivate after cancel / post-grace | Pricing page → Stripe Checkout (new subscription) |
|
||||||
|
|
||||||
|
## Email Communication
|
||||||
|
|
||||||
|
### Stripe-sent (configure in Stripe dashboard)
|
||||||
|
|
||||||
|
- Successful payment receipts
|
||||||
|
- Failed payment "update your card" — includes hosted update link
|
||||||
|
- Upcoming renewal reminder (default 7 days pre-renewal)
|
||||||
|
|
||||||
|
### FuelAlert-sent (our Laravel Mailables)
|
||||||
|
|
||||||
|
| Name | Triggered by | Delay | Subject |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `PaymentFailedDay3Reminder` | `invoice.payment_failed` webhook | `now + 3 days` | "Heads up — your FuelAlert payment is retrying" |
|
||||||
|
| `PaymentFailedDay5Reminder` | Same | `now + 5 days` | "Last chance — {Tier} features end tomorrow" |
|
||||||
|
|
||||||
|
Both mailables check `$this->user->grace_period_until === null` in `build()` /
|
||||||
|
`content()` and abort silently if the grace period has already been cleared
|
||||||
|
(payment recovered or subscription cancelled). This is simpler than cancelling
|
||||||
|
queued jobs.
|
||||||
|
|
||||||
|
Copy references the user's current tier by name, spells out which features
|
||||||
|
they'll lose on downgrade, and links to the Stripe Portal to update the card.
|
||||||
|
|
||||||
|
## Stripe Dashboard Configuration (one-time, manual)
|
||||||
|
|
||||||
|
1. **Billing → Automations → Subscription retry rules:**
|
||||||
|
- Switch from "Smart Retries" to **Custom**
|
||||||
|
- Retry schedule: day 1, day 3, day 5 after first failure
|
||||||
|
- After final retry: **Cancel subscription**
|
||||||
|
2. **Emails → Customer emails:**
|
||||||
|
- Enable: "Successful payments", "Failed payments", "Upcoming renewals"
|
||||||
|
3. **Settings → Branding:**
|
||||||
|
- Upload FuelAlert logo
|
||||||
|
- Set primary colour to match app accent
|
||||||
|
4. **Customer Portal settings:**
|
||||||
|
- Allow plan changes (all 3 paid tiers × monthly + annual)
|
||||||
|
- Allow cancellation (at period end only)
|
||||||
|
- Allow payment method updates, invoice history
|
||||||
|
- Hide everything else (no custom domains, no promo code input — keep
|
||||||
|
promotion codes to checkout only)
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Stripe
|
||||||
|
│
|
||||||
|
│ webhook POST /stripe/webhook
|
||||||
|
▼
|
||||||
|
CashierController (built-in)
|
||||||
|
│
|
||||||
|
│ dispatches WebhookReceived event
|
||||||
|
▼
|
||||||
|
HandleStripeWebhook (new consolidated listener)
|
||||||
|
│
|
||||||
|
├── subscription.created/updated → cache bust
|
||||||
|
├── subscription.deleted → downgrade + disable prefs + cache bust
|
||||||
|
├── invoice.payment_succeeded → clear grace_period_until + cache bust
|
||||||
|
└── invoice.payment_failed → set grace_period_until + queue reminder mails
|
||||||
|
│
|
||||||
|
├─► PaymentFailedDay3Reminder (delay 3d)
|
||||||
|
└─► PaymentFailedDay5Reminder (delay 5d)
|
||||||
|
│
|
||||||
|
└─ guard: abort if grace_period_until is null
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
Pest feature tests, one file per webhook branch, using Cashier's webhook-test
|
||||||
|
helpers (simulate `WebhookReceived` with a representative payload).
|
||||||
|
|
||||||
|
Required test cases:
|
||||||
|
|
||||||
|
- `customer.subscription.created` busts the plan cache
|
||||||
|
- `customer.subscription.updated` busts the plan cache after a portal plan swap
|
||||||
|
- `customer.subscription.deleted` downgrades to free, disables WhatsApp + SMS
|
||||||
|
prefs, clears `grace_period_until` (this folds in the existing
|
||||||
|
`DowngradeUserOnSubscriptionDeletedTest`)
|
||||||
|
- `invoice.payment_failed` sets `grace_period_until` 5 days out and queues both
|
||||||
|
reminder mailables with correct delays (use `Queue::fake()`)
|
||||||
|
- `invoice.payment_succeeded` clears `grace_period_until`
|
||||||
|
- `PaymentFailedDay3Reminder` aborts when `grace_period_until` is null
|
||||||
|
- `PaymentFailedDay5Reminder` aborts when `grace_period_until` is null
|
||||||
|
- Listener is idempotent — replaying the same event twice produces the same
|
||||||
|
final state
|
||||||
|
- Existing `BillingControllerTest` + `PlanFeaturesTest` continue to pass
|
||||||
|
|
||||||
|
Manual QA checklist (production Stripe test mode):
|
||||||
|
|
||||||
|
- Sign up on all three paid tiers × both cadences
|
||||||
|
- Upgrade basic-monthly → pro-monthly via Portal; confirm instant swap
|
||||||
|
- Downgrade pro-monthly → basic-monthly via Portal; confirm change takes effect
|
||||||
|
at next renewal
|
||||||
|
- Cancel mid-period; confirm features persist until period end
|
||||||
|
- Trigger payment failure with test card `4000 0000 0000 0341`; confirm banner
|
||||||
|
appears, day-3 + day-5 emails send, subscription cancels on day 5, user
|
||||||
|
downgrades to free
|
||||||
|
|
||||||
|
## Out of Scope (v1)
|
||||||
|
|
||||||
|
- Stripe Tax / VAT — revisit before £90k turnover
|
||||||
|
- Mid-term annual refunds — commitment model, no refunds
|
||||||
|
- Custom in-app upgrade/downgrade UI — Stripe Portal is the UI
|
||||||
|
- Trial periods — none offered
|
||||||
|
- `invoice.upcoming` handling — Stripe's default email is sufficient
|
||||||
|
- Subscription pause / skip-a-month — not in tier spec
|
||||||
|
- Multi-currency — GBP only for v1
|
||||||
|
|
||||||
|
## Open Documentation Updates
|
||||||
|
|
||||||
|
The following project docs need editing to match this spec (done as part of
|
||||||
|
implementation, not a separate task):
|
||||||
|
|
||||||
|
- `.claude/rules/payments.md` — current version doesn't mention grace period,
|
||||||
|
webhook catalogue, or decision to use Stripe Portal exclusively. Describes a
|
||||||
|
custom Livewire upgrade UI that is no longer planned.
|
||||||
|
- `.claude/rules/tiers.md` — largely accurate; check the "Notification Dispatch
|
||||||
|
Flow" section doesn't contradict any webhook behaviour here.
|
||||||
@@ -380,6 +380,7 @@
|
|||||||
<div class="max-w-7xl mx-auto pt-8 border-t border-zinc-300 flex flex-col md:flex-row justify-between items-center gap-4 text-[10px] font-bold uppercase tracking-widest text-zinc-500">
|
<div class="max-w-7xl mx-auto pt-8 border-t border-zinc-300 flex flex-col md:flex-row justify-between items-center gap-4 text-[10px] font-bold uppercase tracking-widest text-zinc-500">
|
||||||
<p>© 2024 FuelAlert UK Limited. All Rights Reserved.</p>
|
<p>© 2024 FuelAlert UK Limited. All Rights Reserved.</p>
|
||||||
<p>Data provided by official UK retail price transparency schemes.</p>
|
<p>Data provided by official UK retail price transparency schemes.</p>
|
||||||
|
<p>Postcode data from <a class="underline hover:text-accent" href="https://geoportal.statistics.gov.uk/datasets/ons::onspd-online-latest-centroids-1/about" rel="noopener" target="_blank">ONS Postcode Directory</a>: contains OS data © Crown copyright & database right, Royal Mail data © Royal Mail copyright & database right, and National Statistics data © Crown copyright & database right.</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<x-layouts::app :title="__('Dashboard')">
|
<x-layouts::app :title="__('Dashboard')">
|
||||||
<div class="flex h-full w-full flex-1 flex-col gap-4 rounded-xl">
|
<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="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">
|
<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" />
|
<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 }}
|
||||||
100
tests/Feature/Console/ImportPostcodesTest.php
Normal file
100
tests/Feature/Console/ImportPostcodesTest.php
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Outcode;
|
||||||
|
use App\Models\Postcode;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
if (! function_exists('writeOnspdFixture')) {
|
||||||
|
function writeOnspdFixture(string $contents): string
|
||||||
|
{
|
||||||
|
$path = tempnam(sys_get_temp_dir(), 'onspd_').'.csv';
|
||||||
|
file_put_contents($path, $contents);
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('imports active postcodes from an ONSPD CSV', function (): void {
|
||||||
|
$csv = <<<'CSV'
|
||||||
|
pcd,pcds,doterm,lat,long
|
||||||
|
"SW1A1AA","SW1A 1AA","",51.501009,-0.141588
|
||||||
|
"M11AD","M1 1AD",,53.480957,-2.237428
|
||||||
|
CSV;
|
||||||
|
|
||||||
|
$path = writeOnspdFixture($csv);
|
||||||
|
|
||||||
|
$this->artisan('postcodes:import', ['--file' => $path])
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
|
expect(Postcode::count())->toBe(2)
|
||||||
|
->and(Postcode::find('SW1A1AA')->outcode)->toBe('SW1A')
|
||||||
|
->and(Postcode::find('M11AD')->outcode)->toBe('M1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips terminated postcodes', function (): void {
|
||||||
|
$csv = <<<'CSV'
|
||||||
|
pcd,pcds,doterm,lat,long
|
||||||
|
"SW1A1AA","SW1A 1AA","",51.501009,-0.141588
|
||||||
|
"OLD1AA","OLD 1AA","202301",50.000000,-1.000000
|
||||||
|
CSV;
|
||||||
|
|
||||||
|
$path = writeOnspdFixture($csv);
|
||||||
|
|
||||||
|
$this->artisan('postcodes:import', ['--file' => $path])->assertSuccessful();
|
||||||
|
|
||||||
|
expect(Postcode::count())->toBe(1)
|
||||||
|
->and(Postcode::find('OLD1AA'))->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips rows with blank coordinates', function (): void {
|
||||||
|
$csv = <<<'CSV'
|
||||||
|
pcd,pcds,doterm,lat,long
|
||||||
|
"SW1A1AA","SW1A 1AA","",51.501009,-0.141588
|
||||||
|
"BT11AA","BT1 1AA","",,
|
||||||
|
CSV;
|
||||||
|
|
||||||
|
$path = writeOnspdFixture($csv);
|
||||||
|
|
||||||
|
$this->artisan('postcodes:import', ['--file' => $path])->assertSuccessful();
|
||||||
|
|
||||||
|
expect(Postcode::count())->toBe(1)
|
||||||
|
->and(Postcode::find('BT11AA'))->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts ArcGIS ONSPD exports that use PCD7 instead of PCD', function (): void {
|
||||||
|
$csv = <<<'CSV'
|
||||||
|
OBJECTID,PCD7,PCD8,PCDS,DOTERM,LAT,LONG,x,y
|
||||||
|
1,"SW1A 1AA","SW1A 1AA","SW1A 1AA","",51.501009,-0.141588,529090,179645
|
||||||
|
2,"M1 1AD","M1 1AD","M1 1AD","",53.480957,-2.237428,384730,398295
|
||||||
|
CSV;
|
||||||
|
|
||||||
|
$path = writeOnspdFixture($csv);
|
||||||
|
|
||||||
|
$this->artisan('postcodes:import', ['--file' => $path])->assertSuccessful();
|
||||||
|
|
||||||
|
expect(Postcode::count())->toBe(2)
|
||||||
|
->and(Postcode::find('SW1A1AA')->outcode)->toBe('SW1A')
|
||||||
|
->and(Postcode::find('M11AD')->outcode)->toBe('M1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives outcode centroids as the average of member postcodes', function (): void {
|
||||||
|
$csv = <<<'CSV'
|
||||||
|
pcd,pcds,doterm,lat,long
|
||||||
|
"PE71AA","PE7 1AA","",52.500000,-0.200000
|
||||||
|
"PE71AB","PE7 1AB","",52.600000,-0.220000
|
||||||
|
"M11AD","M1 1AD","",53.480957,-2.237428
|
||||||
|
CSV;
|
||||||
|
|
||||||
|
$path = writeOnspdFixture($csv);
|
||||||
|
|
||||||
|
$this->artisan('postcodes:import', ['--file' => $path])->assertSuccessful();
|
||||||
|
|
||||||
|
$pe7 = Outcode::find('PE7');
|
||||||
|
|
||||||
|
expect($pe7)->not->toBeNull()
|
||||||
|
->and(round((float) $pe7->lat, 6))->toBe(52.550000)
|
||||||
|
->and(round((float) $pe7->lng, 6))->toBe(-0.210000)
|
||||||
|
->and(Outcode::find('M1'))->not->toBeNull();
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Outcode;
|
||||||
|
use App\Models\Postcode;
|
||||||
use App\Services\ApiLogger;
|
use App\Services\ApiLogger;
|
||||||
use App\Services\LocationResult;
|
use App\Services\LocationResult;
|
||||||
use App\Services\PostcodeService;
|
use App\Services\PostcodeService;
|
||||||
@@ -132,23 +134,41 @@ it('returns null when place name yields no results', function (): void {
|
|||||||
|
|
||||||
// --- Caching ---
|
// --- Caching ---
|
||||||
|
|
||||||
it('caches a successful resolution for 30 days', function (): void {
|
it('caches a successful place resolution for 30 days', function (): void {
|
||||||
Http::fake([
|
Http::fake([
|
||||||
'*/outcodes/PE7' => Http::response([
|
'*/places*' => Http::response([
|
||||||
|
'status' => 200,
|
||||||
|
'result' => [[
|
||||||
|
'name_1' => 'Manchester',
|
||||||
|
'latitude' => 53.480957,
|
||||||
|
'longitude' => -2.237428,
|
||||||
|
]],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->service->resolve('Manchester');
|
||||||
|
$this->service->resolve('Manchester');
|
||||||
|
|
||||||
|
Http::assertSentCount(1);
|
||||||
|
expect(Cache::get('place:manchester'))->toBeInstanceOf(LocationResult::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not cache postcode resolutions in the Cache store (DB is the cache)', function (): void {
|
||||||
|
Http::fake([
|
||||||
|
'*/postcodes/SW1A1AA' => Http::response([
|
||||||
'status' => 200,
|
'status' => 200,
|
||||||
'result' => [
|
'result' => [
|
||||||
'outcode' => 'PE7',
|
'postcode' => 'SW1A 1AA',
|
||||||
'latitude' => 52.536397,
|
'latitude' => 51.501009,
|
||||||
'longitude' => -0.210181,
|
'longitude' => -0.141588,
|
||||||
],
|
],
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->service->resolve('PE7');
|
$this->service->resolve('SW1A 1AA');
|
||||||
$this->service->resolve('PE7');
|
|
||||||
|
|
||||||
Http::assertSentCount(1);
|
expect(Cache::get('postcode:sw1a1aa'))->toBeNull()
|
||||||
expect(Cache::get('postcode:pe7'))->toBeInstanceOf(LocationResult::class);
|
->and(Postcode::find('SW1A1AA'))->not->toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not cache failed lookups', function (): void {
|
it('does not cache failed lookups', function (): void {
|
||||||
@@ -171,3 +191,91 @@ it('returns null and does not throw on API failure', function (): void {
|
|||||||
|
|
||||||
expect($result)->toBeNull();
|
expect($result)->toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Local DB (full postcode) ---
|
||||||
|
|
||||||
|
it('resolves a full postcode from local DB without calling HTTP', function (): void {
|
||||||
|
Postcode::create([
|
||||||
|
'postcode' => 'SW1A1AA',
|
||||||
|
'outcode' => 'SW1A',
|
||||||
|
'lat' => 51.501009,
|
||||||
|
'lng' => -0.141588,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Http::fake(); // any HTTP call will be recorded
|
||||||
|
|
||||||
|
$result = $this->service->resolve('SW1A 1AA');
|
||||||
|
|
||||||
|
expect($result)->toBeInstanceOf(LocationResult::class)
|
||||||
|
->and($result->displayName)->toBe('SW1A 1AA')
|
||||||
|
->and($result->lat)->toBe(51.501009)
|
||||||
|
->and($result->lng)->toBe(-0.141588);
|
||||||
|
|
||||||
|
Http::assertNothingSent();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Local DB (outcode) ---
|
||||||
|
|
||||||
|
it('resolves an outcode from local DB without calling HTTP', function (): void {
|
||||||
|
Outcode::create([
|
||||||
|
'outcode' => 'PE7',
|
||||||
|
'lat' => 52.536397,
|
||||||
|
'lng' => -0.210181,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Http::fake();
|
||||||
|
|
||||||
|
$result = $this->service->resolve('PE7');
|
||||||
|
|
||||||
|
expect($result)->toBeInstanceOf(LocationResult::class)
|
||||||
|
->and($result->displayName)->toBe('PE7')
|
||||||
|
->and($result->lat)->toBe(52.536397)
|
||||||
|
->and($result->lng)->toBe(-0.210181);
|
||||||
|
|
||||||
|
Http::assertNothingSent();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- HTTP fallback persistence ---
|
||||||
|
|
||||||
|
it('persists a full postcode resolved via HTTP fallback', function (): void {
|
||||||
|
Http::fake([
|
||||||
|
'*/postcodes/SW1A1AA' => Http::response([
|
||||||
|
'status' => 200,
|
||||||
|
'result' => [
|
||||||
|
'postcode' => 'SW1A 1AA',
|
||||||
|
'latitude' => 51.501009,
|
||||||
|
'longitude' => -0.141588,
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->service->resolve('SW1A 1AA');
|
||||||
|
|
||||||
|
$row = Postcode::find('SW1A1AA');
|
||||||
|
|
||||||
|
expect($row)->not->toBeNull()
|
||||||
|
->and($row->outcode)->toBe('SW1A')
|
||||||
|
->and((float) $row->lat)->toBe(51.501009)
|
||||||
|
->and((float) $row->lng)->toBe(-0.141588);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists an outcode resolved via HTTP fallback', function (): void {
|
||||||
|
Http::fake([
|
||||||
|
'*/outcodes/PE7' => Http::response([
|
||||||
|
'status' => 200,
|
||||||
|
'result' => [
|
||||||
|
'outcode' => 'PE7',
|
||||||
|
'latitude' => 52.536397,
|
||||||
|
'longitude' => -0.210181,
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->service->resolve('PE7');
|
||||||
|
|
||||||
|
$row = Outcode::find('PE7');
|
||||||
|
|
||||||
|
expect($row)->not->toBeNull()
|
||||||
|
->and((float) $row->lat)->toBe(52.536397)
|
||||||
|
->and((float) $row->lng)->toBe(-0.210181);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user