Compare commits

..

30 Commits

Author SHA1 Message Date
Ovidiu U
3224b186b2 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>
2026-04-23 12:00:19 +01:00
Ovidiu U
36444cde05 feat: add past-due payment banner to dashboard
Show an amber banner to logged-in users whose grace_period_until is set,
linking to the Stripe Customer Portal to update their card.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:59:51 +01:00
Ovidiu U
b7175169f0 feat: handle invoice.payment_failed — set grace period and queue reminders
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:56:26 +01:00
Ovidiu U
5b17f4cae4 feat: add SendPaymentFailedReminderJob with grace guard 2026-04-23 10:51:13 +01:00
Ovidiu U
c127cc379e feat: add day-5 branded payment-failure reminder mailable 2026-04-23 10:48:22 +01:00
Ovidiu U
de2499636f feat: add day-3 branded payment-failure reminder mailable 2026-04-23 10:44:37 +01:00
Ovidiu U
2078c4b83e feat: clear grace period on invoice.payment_succeeded
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:37:50 +01:00
Ovidiu U
b9d457578c feat: fold subscription deletion handling into HandleStripeWebhook 2026-04-23 10:34:05 +01:00
Ovidiu U
25b79f095b feat: bust plan cache on customer.subscription.updated 2026-04-23 10:31:07 +01:00
Ovidiu U
a39d4b1b94 feat: consolidate stripe webhook handling into HandleStripeWebhook listener 2026-04-23 10:27:23 +01:00
Ovidiu U
f1c1a1c572 feat: add grace_period_until to users table 2026-04-23 10:23:27 +01:00
Ovidiu U
bf013926c0 docs: add stripe subscription lifecycle spec + implementation plan
Captures the agreed design for Stripe webhook handling, 5-day grace
period with branded day-3/day-5 reminders, and Stripe Customer Portal
as the single subscription-management surface. Updates payments rules
to match and ignores .worktrees/ for isolated implementation work.
2026-04-23 10:05:50 +01:00
Ovidiu U
19fc61a0a3 feat: accept ArcGIS ONSPD column aliases (PCD7/PCD8/PCDS) in postcodes:import 2026-04-22 13:31:27 +01:00
Ovidiu U
13fc227619 docs: link ONSPD attribution to source dataset page 2026-04-22 13:28:39 +01:00
Ovidiu U
d8f87f964d Merge branch 'feat/self-hosted-postcodes'
Self-hosted UK postcode lookup: ONS Postcode Directory loaded into
local postcodes/outcodes tables; postcodes.io retained as fallback
for place names and unknown postcodes, with successful fallback
results persisted back to the local tables.
2026-04-22 13:19:39 +01:00
Ovidiu U
975a1522cf docs: plan for self-hosted UK postcodes 2026-04-22 13:19:33 +01:00
Ovidiu U
29ba2f3d86 docs: add ONS/Royal Mail/OS attribution required by OGL v3 2026-04-22 12:39:11 +01:00
Ovidiu U
3ec7cda790 feat: derive outcode centroids from postcodes during import 2026-04-22 12:36:39 +01:00
Ovidiu U
d01a634f0b test: cover terminated + blank-coord skip paths for postcodes:import 2026-04-22 12:34:19 +01:00
Ovidiu U
9ad62538b9 fix: harden postcodes:import against duplicate headers and test collisions 2026-04-22 12:33:10 +01:00
Ovidiu U
4a60298606 feat: add postcodes:import command for loading ONSPD CSV 2026-04-22 12:28:08 +01:00
Ovidiu U
5426722c71 refactor: scope postcode cache to place names, DB is authoritative for postcodes 2026-04-22 12:23:50 +01:00
Ovidiu U
d460de1850 fix: guard malformed postcodes.io responses and isolate persist errors from HTTP success 2026-04-22 12:22:15 +01:00
Ovidiu U
45bf1c0d24 feat: persist postcodes.io fallback results into local DB 2026-04-22 12:18:20 +01:00
Ovidiu U
1e3b246172 feat: resolve outcodes from local DB before HTTP 2026-04-22 12:13:52 +01:00
Ovidiu U
9fa9ea7835 feat: resolve full postcodes from local DB before HTTP 2026-04-22 12:09:19 +01:00
Ovidiu U
55c81fab7b style: align Postcode/Outcode models with house Fillable+casts convention 2026-04-22 12:07:23 +01:00
Ovidiu U
64a7cc3de5 feat: add Postcode and Outcode Eloquent models 2026-04-22 12:04:39 +01:00
Ovidiu U
7c114c72e4 style: add void return type to postcodes migration closures 2026-04-22 12:03:51 +01:00
Ovidiu U
2fe9c3ef77 feat: add postcodes and outcodes tables for self-hosted lookup 2026-04-22 12:00:53 +01:00
46 changed files with 4016 additions and 128 deletions

View File

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

@@ -22,3 +22,4 @@ yarn-error.log
/.vscode /.vscode
/.zed /.zed
/.tmp/ /.tmp/
/.worktrees/

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

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

26
app/Models/Outcode.php Normal file
View 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
View 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',
];
}
}

View File

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

View File

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

View File

@@ -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,

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

View File

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

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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.

View File

@@ -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 &amp; database right, Royal Mail data © Royal Mail copyright &amp; database right, and National Statistics data © Crown copyright &amp; database right.</p>
</div> </div>
</footer> </footer>

View File

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

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

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

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

View File

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