Compare commits

...

4 Commits

Author SHA1 Message Date
Ovidiu U
088fd11058 Remove prediction API endpoint and integrate into stations search
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
Consolidate prediction functionality by merging /api/prediction endpoint into /api/stations response. Move prediction logic from PredictionController into StationController, returning prediction data alongside station results. Replace usePrediction composable with unified useStations that returns {stations, meta, prediction}. Remove PredictionRequest, related tests, and unused Vue components (FuelFinderTest, MapTest, RecommendationTest, StationListTest). Add PredictionFull component and UpsellBanner. Extend NationalFuelPredictionService to include weekly_summary (7-day series, yesterday/today averages, cheapest/priciest days) and oil signal from price_predictions table. Update Home.vue to consume prediction from stations response. Add Plan::resolveCadenceForUser helper and configure Cashier to use custom Subscription model.
2026-04-29 13:28:33 +01:00
Ovidiu U
ee6de23709 feat: gate full prediction by ai_predictions feature flag
Add a prediction box above filter results on the homepage.
Server returns the full payload only when PlanFeatures::can(
'ai_predictions') — currently plus and pro. Other tiers and
guests get a trimmed {fuel_type, predicted_direction,
tier_locked: true} response so the gate is enforced server-side.

Frontend renders a compact one-liner with the national trend
direction for trimmed responses, full card for unlocked.

Hide the Pro plan card from the pricing section (pro plan
disabled in DB pending real Stripe price ids), and only show
the bottom signup CTA when the visitor is a guest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:29:29 +01:00
Ovidiu U
2ff3aeba4d fix: admin tier assignment when stripe price env vars are empty
env() returns an empty string (not null) when a STRIPE_PRICE_*
var is set but blank, so the ?? fallback never fired and the
synthetic subscription was created with stripe_price = '' —
which then resolved back to free in Plan::resolveForUser.

Switch to ?: so empty strings also fall back to the synthetic
price_admin_{tier}_{cadence} id, and backfill the matching Plan
row's stripe_price_id_{cadence} when empty so resolution succeeds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:29:21 +01:00
Ovidiu U
b8adb81c79 chore: gitignore ONSPD source CSV
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:29:07 +01:00
31 changed files with 1105 additions and 448 deletions

1
.gitignore vendored
View File

@@ -23,3 +23,4 @@ yarn-error.log
/.zed /.zed
/.tmp/ /.tmp/
/.worktrees/ /.worktrees/
/ONSPD_Online_Latest_Centroids_*.csv

View File

@@ -31,6 +31,7 @@ npm run dev # Vite asset watcher
@.claude/rules/database.md @.claude/rules/database.md
@.claude/rules/notifications.md @.claude/rules/notifications.md
@.claude/rules/scoring.md @.claude/rules/scoring.md
@.claude/rules/prediction.md
@.claude/rules/payments.md @.claude/rules/payments.md
@.claude/rules/tiers.md @.claude/rules/tiers.md
@.claude/rules/livewire.md @.claude/rules/livewire.md

View File

@@ -182,7 +182,14 @@ class UserResource extends Resource
return; return;
} }
$priceId = config("services.stripe.prices.{$tier}.{$cadence}") ?? "price_admin_{$tier}_{$cadence}"; $priceId = config("services.stripe.prices.{$tier}.{$cadence}") ?: "price_admin_{$tier}_{$cadence}";
$planColumn = $cadence === 'annual' ? 'stripe_price_id_annual' : 'stripe_price_id_monthly';
$plan = Plan::where('name', $tier)->first();
if ($plan && empty($plan->{$planColumn})) {
$plan->update([$planColumn => $priceId]);
}
$user->subscriptions()->create([ $user->subscriptions()->create([
'type' => 'default', 'type' => 'default',

View File

@@ -63,10 +63,19 @@ class AuthController extends Controller
public function me(Request $request): JsonResponse public function me(Request $request): JsonResponse
{ {
$user = $request->user(); $user = $request->user();
$subscription = $user->subscription();
$expiresAt = $subscription?->ends_at ?? $subscription?->current_period_end;
return response()->json(array_merge( return response()->json(array_merge(
$user->toArray(), $user->toArray(),
['tier' => Plan::resolveForUser($user)->name], [
'tier' => Plan::resolveForUser($user)->name,
'subscription_cancelled' => $subscription?->canceled() ?? false,
'subscription_cadence' => Plan::resolveCadenceForUser($user),
'subscribed_at' => $subscription?->created_at?->toIso8601String(),
'subscription_expires_at' => $expiresAt?->toIso8601String(),
],
)); ));
} }
} }

View File

@@ -1,25 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\PredictionRequest;
use App\Services\NationalFuelPredictionService;
use Illuminate\Http\JsonResponse;
class PredictionController extends Controller
{
public function __construct(
private readonly NationalFuelPredictionService $predictionService,
) {}
public function index(PredictionRequest $request): JsonResponse
{
$lat = $request->filled('lat') ? (float) $request->input('lat') : null;
$lng = $request->filled('lng') ? (float) $request->input('lng') : null;
$result = $this->predictionService->predict($lat, $lng);
return response()->json($result);
}
}

View File

@@ -8,6 +8,9 @@ use App\Http\Requests\Api\NearbyStationsRequest;
use App\Http\Resources\Api\StationResource; use App\Http\Resources\Api\StationResource;
use App\Models\Search; use App\Models\Search;
use App\Models\Station; use App\Models\Station;
use App\Models\User;
use App\Services\NationalFuelPredictionService;
use App\Services\PlanFeatures;
use App\Services\PostcodeService; use App\Services\PostcodeService;
use Illuminate\Database\Query\JoinClause; use Illuminate\Database\Query\JoinClause;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -16,7 +19,10 @@ use Illuminate\Validation\ValidationException;
class StationController extends Controller class StationController extends Controller
{ {
public function __construct(private readonly PostcodeService $postcodeService) {} public function __construct(
private readonly PostcodeService $postcodeService,
private readonly NationalFuelPredictionService $predictionService,
) {}
public function index(NearbyStationsRequest $request): JsonResponse public function index(NearbyStationsRequest $request): JsonResponse
{ {
@@ -115,6 +121,31 @@ class StationController extends Controller
'outdated' => (int) $reliabilityCounts->get(PriceReliability::Outdated->value, 0), 'outdated' => (int) $reliabilityCounts->get(PriceReliability::Outdated->value, 0),
], ],
], ],
'prediction' => $this->predictionFor($request->user(), $lat, $lng),
]); ]);
} }
/**
* Returns the prediction payload for embedding in the search response.
* Free/guest users get a stripped teaser; users with the ai_predictions
* feature get the full multi-signal payload.
*
* @return array<string, mixed>
*/
private function predictionFor(?User $user, float $lat, float $lng): array
{
$result = $this->predictionService->predict($lat, $lng);
$canSeeFull = $user !== null && PlanFeatures::for($user)->can('ai_predictions');
if (! $canSeeFull) {
return [
'fuel_type' => $result['fuel_type'],
'predicted_direction' => $result['predicted_direction'],
'tier_locked' => true,
];
}
return $result;
}
} }

View File

@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Enums\PlanTier; use App\Enums\PlanTier;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Laravel\Cashier\Checkout;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
class BillingController extends Controller class BillingController extends Controller
@@ -12,7 +13,7 @@ class BillingController extends Controller
/** /**
* Redirect the user to a Stripe Checkout session for the requested plan + cadence. * Redirect the user to a Stripe Checkout session for the requested plan + cadence.
*/ */
public function checkout(Request $request, string $tier, string $cadence): Response|RedirectResponse public function checkout(Request $request, string $tier, string $cadence): Response|RedirectResponse|Checkout
{ {
abort_unless(in_array($tier, [PlanTier::Basic->value, PlanTier::Plus->value, PlanTier::Pro->value], true), 404); abort_unless(in_array($tier, [PlanTier::Basic->value, PlanTier::Plus->value, PlanTier::Pro->value], true), 404);
abort_unless(in_array($cadence, ['monthly', 'annual'], true), 404); abort_unless(in_array($cadence, ['monthly', 'annual'], true), 404);

View File

@@ -1,21 +0,0 @@
<?php
namespace App\Http\Requests\Api;
use Illuminate\Foundation\Http\FormRequest;
class PredictionRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'lat' => ['nullable', 'numeric', 'between:-90,90'],
'lng' => ['nullable', 'numeric', 'between:-180,180'],
];
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Listeners; namespace App\Listeners;
use App\Jobs\SendPaymentFailedReminderJob; use App\Jobs\SendPaymentFailedReminderJob;
use App\Models\Subscription;
use App\Models\User; use App\Models\User;
use App\Models\UserNotificationPreference; use App\Models\UserNotificationPreference;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
@@ -28,15 +29,27 @@ final class HandleStripeWebhook
match ($type) { match ($type) {
'customer.subscription.created', 'customer.subscription.created',
'customer.subscription.updated' => $this->bustPlanCache($user), 'customer.subscription.updated' => $this->handleSubscriptionUpserted($user, $event->payload['data']['object'] ?? []),
'customer.subscription.deleted' => $this->handleSubscriptionDeleted($user), 'customer.subscription.deleted' => $this->handleSubscriptionDeleted($user, $event->payload['data']['object'] ?? []),
'invoice.payment_succeeded' => $this->handlePaymentSucceeded($user), 'invoice.payment_succeeded' => $this->handlePaymentSucceeded($user),
'invoice.payment_failed' => $this->handlePaymentFailed($user), 'invoice.payment_failed' => $this->handlePaymentFailed($user),
default => null, default => null,
}; };
} }
private function handleSubscriptionDeleted(User $user): void /**
* @param array<string, mixed> $stripeSubscription
*/
private function handleSubscriptionUpserted(User $user, array $stripeSubscription): void
{
$this->syncPeriodFromStripePayload($stripeSubscription);
$this->bustPlanCache($user);
}
/**
* @param array<string, mixed> $stripeSubscription
*/
private function handleSubscriptionDeleted(User $user, array $stripeSubscription): void
{ {
UserNotificationPreference::query() UserNotificationPreference::query()
->where('user_id', $user->id) ->where('user_id', $user->id)
@@ -45,9 +58,52 @@ final class HandleStripeWebhook
$user->forceFill(['grace_period_until' => null])->save(); $user->forceFill(['grace_period_until' => null])->save();
$this->syncPeriodFromStripePayload($stripeSubscription);
$this->bustPlanCache($user); $this->bustPlanCache($user);
} }
/**
* Mirror current_period_start / current_period_end from a Stripe subscription
* payload onto our local row so we don't depend on Stripe at read time.
*
* Stripe API 2024-11-19 places the period fields at the root of the
* subscription; later versions move them to items.data[0]. We accept either.
*
* @param array<string, mixed> $stripeSubscription
*/
private function syncPeriodFromStripePayload(array $stripeSubscription): void
{
$stripeId = $stripeSubscription['id'] ?? null;
if ($stripeId === null) {
return;
}
$subscription = Subscription::where('stripe_id', $stripeId)->first();
if ($subscription === null) {
return;
}
$start = $stripeSubscription['current_period_start']
?? ($stripeSubscription['items']['data'][0]['current_period_start'] ?? null);
$end = $stripeSubscription['current_period_end']
?? ($stripeSubscription['items']['data'][0]['current_period_end'] ?? null);
$subscription->stripe_data = $stripeSubscription;
if ($start !== null) {
$subscription->current_period_start = Carbon::createFromTimestamp($start);
}
if ($end !== null) {
$subscription->current_period_end = Carbon::createFromTimestamp($end);
}
$subscription->save();
}
private function handlePaymentSucceeded(User $user): void private function handlePaymentSucceeded(User $user): void
{ {
$user->forceFill(['grace_period_until' => null])->save(); $user->forceFill(['grace_period_until' => null])->save();

View File

@@ -80,6 +80,41 @@ class Plan extends Model
]); ]);
} }
/**
* Resolve the active subscription cadence for a user.
* Returns 'monthly' | 'annual', or null if the user has no paid subscription.
*/
public static function resolveCadenceForUser(User $user): ?string
{
if (! method_exists($user, 'subscriptions')) {
return null;
}
$priceId = $user->subscriptions()->active()->value('stripe_price');
if ($priceId === null) {
return null;
}
$plan = static::where('stripe_price_id_monthly', $priceId)
->orWhere('stripe_price_id_annual', $priceId)
->first();
if ($plan === null) {
return null;
}
if ($plan->stripe_price_id_monthly === $priceId) {
return 'monthly';
}
if ($plan->stripe_price_id_annual === $priceId) {
return 'annual';
}
return null;
}
protected static function booted(): void protected static function booted(): void
{ {
static::saved(function (): void { static::saved(function (): void {

View File

@@ -9,7 +9,6 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
#[Fillable(['predicted_for', 'source', 'direction', 'confidence', 'reasoning', 'generated_at'])] #[Fillable(['predicted_for', 'source', 'direction', 'confidence', 'reasoning', 'generated_at'])]
class PricePrediction extends Model class PricePrediction extends Model
@@ -39,11 +38,17 @@ class PricePrediction extends Model
*/ */
public function scopeBestFirst(Builder $query): Builder public function scopeBestFirst(Builder $query): Builder
{ {
$priority = implode(', ', array_map( $priority = [
fn (string $v) => "'$v'", PredictionSource::LlmWithContext->value,
[PredictionSource::LlmWithContext->value, PredictionSource::Llm->value, PredictionSource::Ewma->value], PredictionSource::Llm->value,
)); PredictionSource::Ewma->value,
];
return $query->orderByRaw("FIELD(source, $priority)"); $cases = '';
foreach ($priority as $rank => $source) {
$cases .= " WHEN '$source' THEN $rank";
}
return $query->orderByRaw("CASE source$cases ELSE ".count($priority).' END');
} }
} }

View File

@@ -3,6 +3,7 @@
namespace App\Providers; namespace App\Providers;
use App\Listeners\HandleStripeWebhook; use App\Listeners\HandleStripeWebhook;
use App\Models\Subscription;
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;
@@ -14,6 +15,7 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password; use Illuminate\Validation\Rules\Password;
use Laravel\Cashier\Cashier;
use Laravel\Cashier\Events\WebhookReceived; use Laravel\Cashier\Events\WebhookReceived;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
@@ -41,6 +43,8 @@ class AppServiceProvider extends ServiceProvider
{ {
$this->configureDefaults(); $this->configureDefaults();
Cashier::useSubscriptionModel(Subscription::class);
Event::listen(WebhookReceived::class, HandleStripeWebhook::class); Event::listen(WebhookReceived::class, HandleStripeWebhook::class);
} }

View File

@@ -4,6 +4,7 @@ namespace App\Services;
use App\Enums\FuelType; use App\Enums\FuelType;
use App\Models\StationPriceCurrent; use App\Models\StationPriceCurrent;
use Carbon\CarbonInterface;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
class NationalFuelPredictionService class NationalFuelPredictionService
@@ -12,6 +13,12 @@ class NationalFuelPredictionService
private const float SLOPE_THRESHOLD_PENCE = 0.3; private const float SLOPE_THRESHOLD_PENCE = 0.3;
/** Slope (pence/day) at which trend score saturates to ±1.0. */
private const float SLOPE_SATURATION_PENCE = 0.5;
/** Minimum unique days of history for the day-of-week signal to activate. */
private const int DAY_OF_WEEK_MIN_DAYS = 21;
private const int PREDICTION_HORIZON_DAYS = 7; private const int PREDICTION_HORIZON_DAYS = 7;
/** /**
@@ -40,13 +47,14 @@ class NationalFuelPredictionService
$dayOfWeek = $this->computeDayOfWeekSignal($fuelType); $dayOfWeek = $this->computeDayOfWeekSignal($fuelType);
$brandBehaviour = $this->computeBrandBehaviourSignal($fuelType); $brandBehaviour = $this->computeBrandBehaviourSignal($fuelType);
$stickiness = $this->computeStickinessSignal($fuelType); $stickiness = $this->computeStickinessSignal($fuelType);
$oil = $this->computeOilSignal();
$nationalMomentum = $this->disabledSignal('National momentum disabled for national predictions'); $nationalMomentum = $this->disabledSignal('National momentum disabled for national predictions');
$regionalMomentum = $hasCoordinates $regionalMomentum = $hasCoordinates
? $this->computeRegionalMomentumSignal($fuelType, $lat, $lng) ? $this->computeRegionalMomentumSignal($fuelType, $lat, $lng)
: $this->disabledSignal('No coordinates provided for regional momentum analysis'); : $this->disabledSignal('No coordinates provided for regional momentum analysis');
$signals = compact('trend', 'dayOfWeek', 'brandBehaviour', 'nationalMomentum', 'regionalMomentum', 'stickiness'); $signals = compact('trend', 'dayOfWeek', 'brandBehaviour', 'nationalMomentum', 'regionalMomentum', 'stickiness', 'oil');
[$direction, $confidenceScore] = $this->aggregateSignals($signals, $hasCoordinates); [$direction, $confidenceScore] = $this->aggregateSignals($signals, $hasCoordinates);
@@ -65,6 +73,8 @@ class NationalFuelPredictionService
default => 'no_signal', default => 'no_signal',
}; };
$weeklySummary = $this->computeWeeklySummary($fuelType, $lat, $lng, $currentAvg, $slope);
return [ return [
'fuel_type' => $fuelType->value, 'fuel_type' => $fuelType->value,
'current_avg' => $currentAvg, 'current_avg' => $currentAvg,
@@ -73,10 +83,11 @@ class NationalFuelPredictionService
'confidence_score' => $confidenceScore, 'confidence_score' => $confidenceScore,
'confidence_label' => $confidenceLabel, 'confidence_label' => $confidenceLabel,
'action' => $action, 'action' => $action,
'reasoning' => $this->buildReasoning($direction, $slope, $trend, $brandBehaviour), 'reasoning' => $this->buildReasoning($direction, $slope, $trend, $brandBehaviour, $dayOfWeek),
'prediction_horizon_days' => self::PREDICTION_HORIZON_DAYS, 'prediction_horizon_days' => self::PREDICTION_HORIZON_DAYS,
'region_key' => $hasCoordinates ? 'regional' : 'national', 'region_key' => $hasCoordinates ? 'regional' : 'national',
'methodology' => 'multi_signal_live_fallback', 'methodology' => 'multi_signal_live_fallback',
'weekly_summary' => $weeklySummary,
'signals' => [ 'signals' => [
'trend' => $trend, 'trend' => $trend,
'day_of_week' => $dayOfWeek, 'day_of_week' => $dayOfWeek,
@@ -84,6 +95,7 @@ class NationalFuelPredictionService
'national_momentum' => $nationalMomentum, 'national_momentum' => $nationalMomentum,
'regional_momentum' => $regionalMomentum, 'regional_momentum' => $regionalMomentum,
'price_stickiness' => $stickiness, 'price_stickiness' => $stickiness,
'oil' => $oil,
], ],
]; ];
} }
@@ -138,7 +150,7 @@ class NationalFuelPredictionService
default => 'stable', default => 'stable',
}; };
$absSlope = abs($slope); $absSlope = abs($slope);
$score = $direction === 'stable' ? 0.0 : min(1.0, $absSlope / 2.0) * ($slope > 0 ? 1 : -1); $score = $direction === 'stable' ? 0.0 : min(1.0, $absSlope / self::SLOPE_SATURATION_PENCE) * ($slope > 0 ? 1 : -1);
$projected = round($slope * $lookbackDays, 1); $projected = round($slope * $lookbackDays, 1);
$detail = $direction === 'stable' $detail = $direction === 'stable'
? "Prices flat over {$lookbackDays} days (slope: {$slope}p/day, R²={$regression['r_squared']})" ? "Prices flat over {$lookbackDays} days (slope: {$slope}p/day, R²={$regression['r_squared']})"
@@ -204,8 +216,8 @@ class NationalFuelPredictionService
$uniqueDays = $rows->pluck('day')->unique()->count(); $uniqueDays = $rows->pluck('day')->unique()->count();
if ($uniqueDays < 56) { if ($uniqueDays < self::DAY_OF_WEEK_MIN_DAYS) {
return $this->disabledSignal("Insufficient history for day-of-week pattern ({$uniqueDays} days, need 56)"); return $this->disabledSignal("Insufficient history for day-of-week pattern ({$uniqueDays} days, need ".self::DAY_OF_WEEK_MIN_DAYS.')');
} }
$dowAverages = $rows->groupBy('dow')->map(fn ($g) => $g->avg('avg_price')); $dowAverages = $rows->groupBy('dow')->map(fn ($g) => $g->avg('avg_price'));
@@ -214,9 +226,11 @@ class NationalFuelPredictionService
$todayAvg = $dowAverages->get($todayDow, $weekAvg); $todayAvg = $dowAverages->get($todayDow, $weekAvg);
$cheapestDow = $dowAverages->keys()->sortBy(fn ($k) => $dowAverages[$k])->first(); $cheapestDow = $dowAverages->keys()->sortBy(fn ($k) => $dowAverages[$k])->first();
$dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; $dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
$cheapestDayName = $dayNames[($cheapestDow - 1) % 7] ?? 'Unknown'; $todayName = $dayNames[($todayDow - 1) % 7] ?? 'Today';
$weekRange = round(($dowAverages->max() - $dowAverages->min()) / 100, 1); $tomorrowName = $dayNames[$todayDow % 7] ?? 'Tomorrow';
$tomorrowDelta = round(($dowAverages->get(($todayDow % 7) + 1, $weekAvg) - $todayAvg) / 100, 1);
$todayDeltaPence = round(($todayAvg - $weekAvg) / 100, 1);
$tomorrowDeltaPence = round(($dowAverages->get(($todayDow % 7) + 1, $weekAvg) - $todayAvg) / 100, 1);
$direction = match (true) { $direction = match (true) {
($todayAvg - $weekAvg) / 100 >= 1.5 => 'up', ($todayAvg - $weekAvg) / 100 >= 1.5 => 'up',
@@ -226,11 +240,34 @@ class NationalFuelPredictionService
$score = $direction === 'stable' ? 0.0 : ($direction === 'up' ? 1.0 : -1.0); $score = $direction === 'stable' ? 0.0 : ($direction === 'up' ? 1.0 : -1.0);
$parts = [];
$parts[] = abs($todayDeltaPence) < 0.1
? "Today ({$todayName}) is typically in line with the weekly average."
: sprintf(
'Today (%s) is typically %sp %s the weekly average.',
$todayName,
number_format(abs($todayDeltaPence), 1),
$todayDeltaPence > 0 ? 'above' : 'below',
);
$parts[] = abs($tomorrowDeltaPence) < 0.1
? "Tomorrow ({$tomorrowName}) is typically the same."
: sprintf(
'Tomorrow (%s) is typically %sp %s.',
$tomorrowName,
number_format(abs($tomorrowDeltaPence), 1),
$tomorrowDeltaPence < 0 ? 'cheaper' : 'pricier',
);
if ($cheapestDow === $todayDow) {
$parts[] = 'Today is historically the cheapest day of the week.';
}
return [ return [
'score' => $score, 'score' => $score,
'confidence' => min(1.0, $uniqueDays / 90), 'confidence' => min(1.0, $uniqueDays / 90),
'direction' => $direction, 'direction' => $direction,
'detail' => "Cheapest day: {$cheapestDayName}. Weekly range: {$weekRange}p. Tomorrow typically {$tomorrowDelta}p less than today.", 'detail' => implode(' ', $parts),
'data_points' => $uniqueDays, 'data_points' => $uniqueDays,
'enabled' => true, 'enabled' => true,
]; ];
@@ -386,6 +423,63 @@ class NationalFuelPredictionService
]; ];
} }
/**
* Reads the most recent Brent crude prediction (LLM preferred, EWMA fallback)
* covering today or later. Sourced from price_predictions, which OilPriceService
* populates daily.
*
* @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool}
*/
private function computeOilSignal(): array
{
$prediction = null;
foreach (['llm_with_context', 'llm', 'ewma'] as $source) {
$prediction = DB::table('price_predictions')
->where('source', $source)
->where('predicted_for', '>=', now()->toDateString())
->orderByDesc('predicted_for')
->orderByDesc('generated_at')
->first();
if ($prediction !== null) {
break;
}
}
if ($prediction === null) {
return $this->disabledSignal('No oil price prediction available');
}
$direction = match ($prediction->direction) {
'rising' => 'up',
'falling' => 'down',
default => 'stable',
};
$score = match ($direction) {
'up' => 1.0,
'down' => -1.0,
default => 0.0,
};
$confidence = round(((float) $prediction->confidence) / 100, 2);
return [
'score' => $score,
'confidence' => $confidence,
'direction' => $direction,
'detail' => sprintf(
'Brent crude %s (%s, %d%% confidence)',
$prediction->direction,
$prediction->source,
(int) $prediction->confidence,
),
'data_points' => 1,
'enabled' => true,
];
}
/** @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool} */ /** @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool} */
private function disabledSignal(string $detail): array private function disabledSignal(string $detail): array
{ {
@@ -400,46 +494,64 @@ class NationalFuelPredictionService
} }
/** /**
* Weighted aggregate of enabled signals. * Aggregate enabled signals into a final direction + confidence score.
* Returns [direction string, confidence score 0-100].
* *
* @param array<string, array{score: float, confidence: float, enabled: bool}> $signals * Direction: weighted vote across signals that have a non-stable direction.
* stable signals do NOT dilute the directional vote.
*
* Confidence: weighted average of enabled signals' own confidence values,
* multiplied by an agreement coefficient (0..1) measuring how the signals
* line up with the chosen direction.
*
* @param array<string, array{score: float, confidence: float, direction: string, enabled: bool}> $signals
* @return array{0: string, 1: float} * @return array{0: string, 1: float}
*/ */
private function aggregateSignals(array $signals, bool $hasCoordinates = false): array private function aggregateSignals(array $signals, bool $hasCoordinates = false): array
{ {
$weights = $hasCoordinates $weights = $hasCoordinates
? [ ? [
'regionalMomentum' => 0.50, 'regionalMomentum' => 0.35,
'trend' => 0.20, 'oil' => 0.20,
'trend' => 0.15,
'dayOfWeek' => 0.15, 'dayOfWeek' => 0.15,
'brandBehaviour' => 0.10, 'brandBehaviour' => 0.10,
'stickiness' => 0.05, 'stickiness' => 0.05,
] ]
: [ : [
'trend' => 0.45, 'trend' => 0.30,
'oil' => 0.25,
'dayOfWeek' => 0.20, 'dayOfWeek' => 0.20,
'brandBehaviour' => 0.25, 'brandBehaviour' => 0.15,
'stickiness' => 0.10, 'stickiness' => 0.10,
]; ];
$weightedSum = 0.0; $directionalScoreSum = 0.0;
$totalWeight = 0.0; $directionalWeightSum = 0.0;
$confidenceWeightedSum = 0.0;
$totalEnabledWeight = 0.0;
foreach ($weights as $key => $weight) { foreach ($weights as $key => $weight) {
$signal = $signals[$key] ?? null; $signal = $signals[$key] ?? null;
if ($signal && $signal['enabled']) { if (! $signal || ! $signal['enabled']) {
$weightedSum += $signal['score'] * $signal['confidence'] * $weight; continue;
$totalWeight += $weight; }
$totalEnabledWeight += $weight;
$confidenceWeightedSum += $signal['confidence'] * $weight;
if ($signal['direction'] !== 'stable') {
$directionalScoreSum += $signal['score'] * $signal['confidence'] * $weight;
$directionalWeightSum += $weight;
} }
} }
if ($totalWeight < 0.01) { if ($totalEnabledWeight < 0.01) {
return ['stable', 0.0]; return ['stable', 0.0];
} }
$normalised = $weightedSum / $totalWeight; $normalised = $directionalWeightSum > 0.01
$confidenceScore = round(min(100.0, abs($normalised) * 100), 1); ? $directionalScoreSum / $directionalWeightSum
: 0.0;
$direction = match (true) { $direction = match (true) {
$normalised >= 0.1 => 'up', $normalised >= 0.1 => 'up',
@@ -447,9 +559,175 @@ class NationalFuelPredictionService
default => 'stable', default => 'stable',
}; };
$avgConfidence = $confidenceWeightedSum / $totalEnabledWeight;
$agreement = $this->computeAgreement($signals, $weights, $direction);
$confidenceScore = round(min(100.0, $avgConfidence * $agreement * 100), 1);
return [$direction, $confidenceScore]; return [$direction, $confidenceScore];
} }
/**
* How well the enabled signals line up with the chosen direction.
* - aligned signal: full credit (signal_confidence × weight)
* - one side stable, other directional: half credit
* - opposing signals: no credit
*
* Range: 0 (full disagreement) 1 (unanimous).
*
* @param array<string, array{confidence: float, direction: string, enabled: bool}> $signals
* @param array<string, float> $weights
*/
private function computeAgreement(array $signals, array $weights, string $finalDirection): float
{
$finalDir = match ($finalDirection) {
'up' => 1,
'down' => -1,
default => 0,
};
$credit = 0.0;
$maxCredit = 0.0;
foreach ($weights as $key => $weight) {
$signal = $signals[$key] ?? null;
if (! $signal || ! $signal['enabled']) {
continue;
}
$maxCredit += $signal['confidence'] * $weight;
$signalDir = match ($signal['direction']) {
'up' => 1,
'down' => -1,
default => 0,
};
if ($signalDir === $finalDir) {
$credit += $signal['confidence'] * $weight;
} elseif ($signalDir === 0 || $finalDir === 0) {
$credit += 0.5 * $signal['confidence'] * $weight;
}
}
return $maxCredit > 0.0 ? $credit / $maxCredit : 0.0;
}
/**
* Yesterday / today / tomorrow snapshot + last-7-days series.
* Regional (50km) when coordinates are given, with national fallback when
* regional data is empty.
*
* @return array{
* yesterday_avg: ?float,
* today_avg: float,
* tomorrow_estimated_avg: ?float,
* yesterday_today_delta_pence: ?float,
* last_7_days_series: array<int, array{date: string, avg: float}>,
* last_7_days_change_pence: ?float,
* cheapest_day: ?array{date: string, avg: float},
* priciest_day: ?array{date: string, avg: float},
* is_regional: bool
* }
*/
private function computeWeeklySummary(FuelType $fuelType, ?float $lat, ?float $lng, float $todayAvg, float $slope): array
{
$yesterdayAvg = $this->getDailyAverage($fuelType, now()->subDay(), $lat, $lng);
[$series, $usedRegional] = $this->getDailySeries($fuelType, 7, $lat, $lng);
$tomorrowEstimated = $todayAvg > 0.0 ? round($todayAvg + $slope, 1) : null;
$yesterdayTodayDelta = $yesterdayAvg !== null ? round($todayAvg - $yesterdayAvg, 1) : null;
$cheapestDay = null;
$priciestDay = null;
$weekChange = null;
if (count($series) >= 2) {
$byPrice = $series;
usort($byPrice, fn ($a, $b) => $a['avg'] <=> $b['avg']);
$cheapestDay = $byPrice[0];
$priciestDay = $byPrice[count($byPrice) - 1];
$weekChange = round(end($series)['avg'] - $series[0]['avg'], 1);
}
return [
'yesterday_avg' => $yesterdayAvg,
'today_avg' => $todayAvg,
'tomorrow_estimated_avg' => $tomorrowEstimated,
'yesterday_today_delta_pence' => $yesterdayTodayDelta,
'last_7_days_series' => $series,
'last_7_days_change_pence' => $weekChange,
'cheapest_day' => $cheapestDay,
'priciest_day' => $priciestDay,
'is_regional' => $usedRegional,
];
}
private function getDailyAverage(FuelType $fuelType, CarbonInterface $date, ?float $lat, ?float $lng): ?float
{
$dateString = $date->toDateString();
if ($lat !== null && $lng !== null) {
$regional = DB::table('station_prices')
->join('stations', 'station_prices.station_id', '=', 'stations.node_id')
->where('station_prices.fuel_type', $fuelType->value)
->whereDate('station_prices.price_effective_at', $dateString)
->whereRaw('(6371 * acos(LEAST(1.0, cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?)) + sin(radians(?)) * sin(radians(lat))))) <= 50', [$lat, $lng, $lat])
->avg('station_prices.price_pence');
if ($regional !== null) {
return round((float) $regional / 100, 1);
}
}
$national = DB::table('station_prices')
->where('fuel_type', $fuelType->value)
->whereDate('price_effective_at', $dateString)
->avg('price_pence');
return $national !== null ? round((float) $national / 100, 1) : null;
}
/**
* @return array{0: array<int, array{date: string, avg: float}>, 1: bool}
*/
private function getDailySeries(FuelType $fuelType, int $days, ?float $lat, ?float $lng): array
{
$rows = collect();
$usedRegional = false;
if ($lat !== null && $lng !== null) {
$rows = DB::table('station_prices')
->join('stations', 'station_prices.station_id', '=', 'stations.node_id')
->where('station_prices.fuel_type', $fuelType->value)
->where('station_prices.price_effective_at', '>=', now()->subDays($days)->startOfDay())
->whereRaw('(6371 * acos(LEAST(1.0, cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?)) + sin(radians(?)) * sin(radians(lat))))) <= 50', [$lat, $lng, $lat])
->selectRaw('DATE(station_prices.price_effective_at) as day, AVG(station_prices.price_pence) as avg_price')
->groupBy('day')
->orderBy('day')
->get();
$usedRegional = $rows->isNotEmpty();
}
if ($rows->isEmpty()) {
$rows = DB::table('station_prices')
->where('fuel_type', $fuelType->value)
->where('price_effective_at', '>=', now()->subDays($days)->startOfDay())
->selectRaw('DATE(price_effective_at) as day, AVG(price_pence) as avg_price')
->groupBy('day')
->orderBy('day')
->get();
}
$series = $rows->map(fn ($r): array => [
'date' => (string) $r->day,
'avg' => round((float) $r->avg_price / 100, 1),
])->values()->all();
return [$series, $usedRegional];
}
/** /**
* Least-squares linear regression. * Least-squares linear regression.
* x is the array index (day number), y is the price value. * x is the array index (day number), y is the price value.
@@ -491,7 +769,12 @@ class NationalFuelPredictionService
return ['slope' => $slope, 'r_squared' => $rSquared]; return ['slope' => $slope, 'r_squared' => $rSquared];
} }
private function buildReasoning(string $direction, float $slope, array $trend, array $brandBehaviour): string /**
* @param array{enabled: bool, detail: string, direction: string} $trend
* @param array{enabled: bool, detail: string, direction: string} $brandBehaviour
* @param array{enabled: bool, detail: string, direction: string} $dayOfWeek
*/
private function buildReasoning(string $direction, float $slope, array $trend, array $brandBehaviour, array $dayOfWeek): string
{ {
$parts = []; $parts = [];
@@ -503,8 +786,16 @@ class NationalFuelPredictionService
$parts[] = $brandBehaviour['detail']; $parts[] = $brandBehaviour['detail'];
} }
if ($dayOfWeek['enabled']) {
$parts[] = $dayOfWeek['detail'];
}
if (empty($parts)) { if (empty($parts)) {
return 'No clear pattern — fill up at the cheapest station near you now.'; return match ($direction) {
'up' => 'Mild upward signals — top up soon if you\'re nearby.',
'down' => 'Mild downward signals — wait a day or two if your tank can hold.',
default => 'No clear pattern — fill up at the cheapest station near you now.',
};
} }
return implode(' ', $parts); return implode(' ', $parts);

View File

@@ -1,74 +1,41 @@
<template> <template>
<div class="relative"> <div>
<!-- Gated overlay for free/guest users --> <!-- Loading state -->
<div <div
v-if="!isPaidTier" v-if="loading"
class="absolute inset-0 z-10 rounded-2xl backdrop-blur-sm bg-white/60 flex flex-col items-center justify-center gap-3 text-center px-6" class="p-6 bg-white rounded-2xl border border-zinc-300 animate-pulse space-y-2"
> >
<iconify-icon class="text-accent text-3xl" icon="lucide:lock"></iconify-icon> <div class="h-4 bg-zinc-200 rounded w-1/3"></div>
<p class="font-bold text-zinc-800">Price predictions are available on paid plans</p> <div class="h-6 bg-zinc-200 rounded w-2/3"></div>
</div>
<!-- Free / guest: compact one-liner -->
<div
v-else-if="!isPaidTier"
class="flex items-center gap-3 px-4 py-3 bg-white rounded-2xl border border-zinc-300"
>
<div :class="['shrink-0 w-10 h-10 rounded-full flex items-center justify-center', accentBg]">
<iconify-icon :icon="genericIcon" class="text-xl text-white"></iconify-icon>
</div>
<p class="flex-1 text-sm text-zinc-800 font-medium leading-snug">
{{ genericSentence }}
</p>
<a <a
class="hidden sm:inline-flex shrink-0 text-sm font-bold text-accent hover:text-accent-content whitespace-nowrap"
href="/pricing" href="/pricing"
class="px-6 py-2 bg-accent text-white rounded-full text-sm font-bold hover:bg-accent-content transition-colors"
> >
Upgrade from £0.99/mo See full prediction
</a> </a>
</div> </div>
<!-- Card content (blurred for free users, fully visible for paid) --> <!-- Paid: full prediction -->
<div <PredictionFull v-else :prediction="prediction" />
:class="['p-6 bg-white rounded-2xl border border-zinc-300 space-y-4', !isPaidTier && 'select-none pointer-events-none']"
>
<p class="text-xs font-bold uppercase tracking-widest text-zinc-500">Price Prediction</p>
<!-- Loading state -->
<template v-if="loading">
<div class="animate-pulse space-y-2">
<div class="h-8 bg-zinc-300 rounded w-1/2"></div>
<div class="h-4 bg-zinc-300 rounded w-3/4"></div>
</div>
</template>
<!-- Loaded state -->
<template v-else-if="prediction">
<h3
class="text-2xl font-black"
:class="prediction.action === 'fill_now' ? 'text-mauve' : prediction.action === 'wait' ? 'text-teal' : 'text-tan'"
>
{{ actionLabel }}
</h3>
<div class="w-full h-2 bg-zinc-200 rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all"
:class="prediction.action === 'fill_now' ? 'bg-mauve' : 'bg-teal'"
:style="{ width: prediction.confidence_score + '%' }"
></div>
</div>
<p class="text-sm text-zinc-500 leading-relaxed">{{ prediction.reasoning }}</p>
<div class="flex items-center gap-4 text-xs text-zinc-500 font-medium">
<span>Avg: {{ prediction.current_avg }}p</span>
<span>Confidence: {{ prediction.confidence_label }}</span>
<span v-if="prediction.predicted_change_pence">
{{ prediction.predicted_change_pence > 0 ? '+' : '' }}{{ prediction.predicted_change_pence.toFixed(1) }}p expected
</span>
</div>
</template>
<!-- Empty state (placeholder for gated view) -->
<template v-else>
<h3 class="text-2xl font-black text-mauve">Fill up now</h3>
<div class="h-2 bg-zinc-200 rounded-full"><div class="h-full bg-mauve w-4/5 rounded-full"></div></div>
<p class="text-sm text-zinc-500">Prices in your area are rising best to fill up today.</p>
</template>
</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import PredictionFull from './PredictionFull.vue'
const props = defineProps({ const props = defineProps({
prediction: { type: Object, default: null }, prediction: { type: Object, default: null },
@@ -76,12 +43,23 @@ const props = defineProps({
isPaidTier: { type: Boolean, default: false }, isPaidTier: { type: Boolean, default: false },
}) })
const actionLabel = computed(() => { const direction = computed(() => props.prediction?.predicted_direction ?? 'stable')
if (!props.prediction) return ''
return { const genericSentence = computed(() => ({
fill_now: 'Fill up now', up: 'UK fuel prices are trending upward this week.',
wait: 'Wait — prices falling', down: 'UK fuel prices have been falling this week.',
no_signal: 'No clear signal', stable: 'UK fuel prices have been steady this week.',
}[props.prediction.action] ?? 'Check local prices' })[direction.value] ?? 'UK fuel prices have been steady this week.')
})
const genericIcon = computed(() => ({
up: 'lucide:trending-up',
down: 'lucide:trending-down',
stable: 'lucide:minus',
})[direction.value] ?? 'lucide:minus')
const accentBg = computed(() => ({
up: 'bg-mauve',
down: 'bg-teal',
stable: 'bg-tan',
})[direction.value] ?? 'bg-tan')
</script> </script>

View File

@@ -57,7 +57,7 @@
leave-to-class="opacity-0" leave-to-class="opacity-0"
> >
<div v-if="expanded" class="border-t border-zinc-200 pt-3 space-y-3"> <div v-if="expanded" class="border-t border-zinc-200 pt-3 space-y-3">
<p v-if="brandLabel" class="text-[10px] font-black uppercase tracking-widest text-zinc-500"> <p v-if="brandLabel" class="font-mono text-[10px] uppercase tracking-widest text-zinc-500 mb-1 truncate">
{{ brandLabel }} {{ brandLabel }}
</p> </p>

View File

@@ -19,6 +19,22 @@ export function useAuth() {
return ['basic', 'plus', 'pro'].includes(userTier.value) return ['basic', 'plus', 'pro'].includes(userTier.value)
}) })
const subscriptionCancelled = computed(() => {
return user.value?.subscription_cancelled ?? false
})
const subscriptionCadence = computed(() => {
return user.value?.subscription_cadence ?? null
})
const subscribedAt = computed(() => {
return user.value?.subscribed_at ?? null
})
const subscriptionExpiresAt = computed(() => {
return user.value?.subscription_expires_at ?? null
})
async function fetchUser() { async function fetchUser() {
if (fetched.value) { if (fetched.value) {
return return
@@ -68,6 +84,10 @@ export function useAuth() {
isAuthenticated, isAuthenticated,
userTier, userTier,
isPaidTier, isPaidTier,
subscriptionCancelled,
subscriptionCadence,
subscribedAt,
subscriptionExpiresAt,
fetchUser, fetchUser,
clearUser, clearUser,
logout, logout,

View File

@@ -1,31 +0,0 @@
import { ref } from 'vue'
import api from '../axios.js'
export function usePrediction() {
const prediction = ref(null)
const loading = ref(false)
const error = ref(null)
async function fetch({ lat, lng } = {}) {
loading.value = true
error.value = null
prediction.value = null
const params = {}
if (lat && lng) {
params.lat = lat
params.lng = lng
}
try {
const response = await api.get('/prediction', { params })
prediction.value = response.data
} catch (err) {
error.value = 'Unable to load prediction.'
} finally {
loading.value = false
}
}
return { prediction, loading, error, fetch }
}

View File

@@ -4,6 +4,7 @@ import api from '../axios.js'
export function useStations() { export function useStations() {
const stations = ref([]) const stations = ref([])
const meta = ref(null) const meta = ref(null)
const prediction = ref(null)
const loading = ref(false) const loading = ref(false)
const error = ref(null) const error = ref(null)
@@ -12,6 +13,7 @@ export function useStations() {
error.value = null error.value = null
stations.value = [] stations.value = []
meta.value = null meta.value = null
prediction.value = null
const params = { fuel_type: fuelType, radius, sort } const params = { fuel_type: fuelType, radius, sort }
@@ -26,6 +28,7 @@ export function useStations() {
const response = await api.get('/stations', { params }) const response = await api.get('/stations', { params })
stations.value = response.data.data stations.value = response.data.data
meta.value = response.data.meta meta.value = response.data.meta
prediction.value = response.data.prediction ?? null
} catch (err) { } catch (err) {
error.value = err.response?.data?.errors error.value = err.response?.data?.errors
?? { general: ['Unable to load stations. Please try again.'] } ?? { general: ['Unable to load stations. Please try again.'] }
@@ -37,9 +40,10 @@ export function useStations() {
function reset() { function reset() {
stations.value = [] stations.value = []
meta.value = null meta.value = null
prediction.value = null
error.value = null error.value = null
loading.value = false loading.value = false
} }
return { stations, meta, loading, error, search, reset } return { stations, meta, prediction, loading, error, search, reset }
} }

View File

@@ -36,6 +36,13 @@
<section v-if="searchAttempted" id="searchAttempted" class="px-6"> <section v-if="searchAttempted" id="searchAttempted" class="px-6">
<div class="max-w-7xl mx-auto space-y-6"> <div class="max-w-7xl mx-auto space-y-6">
<!-- Prediction box (sits above filter results) -->
<PredictionCard
:is-paid-tier="showFullPrediction"
:loading="loading"
:prediction="prediction"
/>
<!-- Post-search filter bar --> <!-- Post-search filter bar -->
<PostSearchFilters <PostSearchFilters
v-model:brand-filter="brandFilter" v-model:brand-filter="brandFilter"
@@ -74,6 +81,7 @@
:radius-miles="radiusMiles" :radius-miles="radiusMiles"
:stations="filteredStations" :stations="filteredStations"
/> />
<UpsellBanner :station-count="liveStats.stationCount" />
<StationList <StationList
:current-sort="sort" :current-sort="sort"
:origin="searchOrigin" :origin="searchOrigin"
@@ -201,7 +209,7 @@
</div> </div>
</div> </div>
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-6"> <div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-6 max-w-5xl mx-auto">
<!-- Free --> <!-- Free -->
<div class="bg-white border border-zinc-300 p-8 rounded-3xl flex flex-col h-full"> <div class="bg-white border border-zinc-300 p-8 rounded-3xl flex flex-col h-full">
<div class="mb-8"> <div class="mb-8">
@@ -229,7 +237,7 @@
</div> </div>
</div> </div>
<ul class="space-y-4 mb-8 flex-1"> <ul class="space-y-4 mb-8 flex-1">
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Ad-free Experience</li> <li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Buy-or-Wait Score</li>
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> 14-day Trend Data</li> <li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> 14-day Trend Data</li>
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> 3 Daily Price Alerts</li> <li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> 3 Daily Price Alerts</li>
</ul> </ul>
@@ -253,23 +261,6 @@
</ul> </ul>
<a :href="ctaHref('plus')" class="w-full py-3 px-4 bg-accent text-white rounded-xl text-center font-bold shadow-lg hover:bg-primary-dark transition-all">{{ ctaLabel('plus') }}</a> <a :href="ctaHref('plus')" class="w-full py-3 px-4 bg-accent text-white rounded-xl text-center font-bold shadow-lg hover:bg-primary-dark transition-all">{{ ctaLabel('plus') }}</a>
</div> </div>
<!-- Pro -->
<div class="bg-zinc-800 border border-zinc-800 p-8 rounded-3xl flex flex-col h-full text-white">
<div class="mb-8">
<h4 class="text-xl font-bold font-display mb-2">Pro</h4>
<div class="flex items-baseline gap-1">
<span class="text-4xl font-black">{{ PRICES[cadence].pro }}</span>
<span class="text-zinc-400 text-sm">{{ PRICE_SUFFIX[cadence] }}</span>
</div>
</div>
<ul class="space-y-4 mb-8 flex-1">
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:sparkles"></iconify-icon> AI Price Predictions</li>
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Multi-Vehicle Fleet</li>
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Exportable Price History</li>
</ul>
<a :href="ctaHref('pro')" class="w-full py-3 px-4 bg-white text-zinc-800 rounded-xl text-center font-bold hover:bg-zinc-100 transition-colors">{{ ctaLabel('pro') }}</a>
</div>
</div> </div>
</div> </div>
</section> </section>
@@ -316,13 +307,12 @@
</section> </section>
<!-- CTA --> <!-- CTA -->
<section class="py-12 md:py-24 px-6 bg-accent text-white text-center"> <section class="py-12 md:py-24 px-6 bg-accent text-white text-center" v-if="!isAuthenticated">
<div class="max-w-3xl mx-auto space-y-8"> <div class="max-w-3xl mx-auto space-y-8">
<h2 class="text-4xl md:text-5xl font-black font-display leading-tight">Ready to outsmart the pumps?</h2> <h2 class="text-4xl md:text-5xl font-black font-display leading-tight">Ready to outsmart the pumps?</h2>
<p class="text-xl text-white/80">Sign up for free today and never pay over the odds for fuel again.</p> <p class="text-xl text-white/80">Sign up for free today and never pay over the odds for fuel again.</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center"> <div class="flex flex-col sm:flex-row gap-4 justify-center">
<a class="bg-white text-accent px-10 py-4 rounded-xl text-lg font-black shadow-2xl hover:bg-zinc-100 transition-all" href="/register">Create Free Account</a> <a class="bg-white text-accent px-10 py-4 rounded-xl text-lg font-black shadow-2xl hover:bg-zinc-100 transition-all" href="/register">Create Free Account</a>
<a class="bg-transparent border-2 border-white/30 text-white px-10 py-4 rounded-xl text-lg font-bold hover:bg-white/10 transition-all" href="#">Watch Demo Video</a>
</div> </div>
</div> </div>
</section> </section>
@@ -394,7 +384,9 @@ import { useAuth } from '../composables/useAuth.js'
import { useStations } from '../composables/useStations.js' import { useStations } from '../composables/useStations.js'
import api from '../axios.js' import api from '../axios.js'
import PostSearchFilters from '../components/PostSearchFilters.vue' import PostSearchFilters from '../components/PostSearchFilters.vue'
import PredictionCard from '../components/PredictionCard.vue'
import StationList from '../components/StationList.vue' import StationList from '../components/StationList.vue'
import UpsellBanner from '../components/UpsellBanner.vue'
const LeafletMap = defineAsyncComponent(() => import('../components/LeafletMap.vue')) const LeafletMap = defineAsyncComponent(() => import('../components/LeafletMap.vue'))
import LandingNav from '../components/landing/LandingNav.vue' import LandingNav from '../components/landing/LandingNav.vue'
@@ -453,7 +445,8 @@ const PRICES = {
annual: { basic: '£9.90', plus: '£24.90', pro: '£39.90' }, annual: { basic: '£9.90', plus: '£24.90', pro: '£39.90' },
} }
const PRICE_SUFFIX = { monthly: '/mo', annual: '/yr' } const PRICE_SUFFIX = { monthly: '/mo', annual: '/yr' }
const { stations, meta, loading, error, search, reset } = useStations() const { stations, meta, prediction, loading, error, search, reset } = useStations()
const showFullPrediction = computed(() => Boolean(prediction.value) && !prediction.value.tier_locked)
watch(loading, (isLoading) => { watch(loading, (isLoading) => {
if (!isLoading) return if (!isLoading) return

View File

@@ -19,11 +19,39 @@
</div> </div>
<div class="p-6 bg-white rounded-2xl border border-zinc-300 space-y-2"> <div class="p-6 bg-white rounded-2xl border border-zinc-300 space-y-2">
<p class="text-sm font-bold uppercase tracking-widest text-zinc-500">Your plan</p> <p class="font-mono text-[10px] uppercase tracking-widest text-zinc-500 mb-1 truncate">Your plan</p>
<p class="text-xl font-black text-zinc-800 capitalize">{{ userTier }}</p> <p class="text-xl font-black text-zinc-800 capitalize">{{ userTier }}</p>
<a v-if="userTier === 'free'" class="inline-block text-sm font-bold text-accent hover:underline" href="/pricing"> <a v-if="userTier === 'free'" class="inline-block text-sm font-bold text-accent hover:underline" href="/pricing">
Upgrade for alerts + predictions Upgrade for alerts + predictions
</a> </a>
<dl v-if="isPaidTier" class="grid grid-cols-1 sm:grid-cols-3 gap-4 pt-3 mt-3 border-t border-zinc-200">
<div v-if="subscribedAt">
<dt class="font-mono text-[10px] uppercase tracking-widest text-zinc-500 mb-1 truncate">Subscribed</dt>
<dd class="text-sm font-semibold text-zinc-800 mt-0.5">{{ formatDate(subscribedAt) }}</dd>
</div>
<div v-if="subscriptionCadence">
<dt class="font-mono text-[10px] uppercase tracking-widest text-zinc-500 mb-1 truncate">Billed</dt>
<dd class="text-sm font-semibold text-zinc-800 mt-0.5 capitalize">{{ subscriptionCadence }}</dd>
</div>
<div v-if="subscriptionExpiresAt">
<dt class="font-mono text-[10px] uppercase tracking-widest text-zinc-500 mb-1 truncate">
{{ subscriptionCancelled ? 'Ends on' : 'Renews on' }}
</dt>
<dd class="text-sm font-semibold text-zinc-800 mt-0.5">{{ formatDate(subscriptionExpiresAt) }}</dd>
</div>
</dl>
<div v-if="isPaidTier && !subscriptionCancelled" class="pt-3 mt-3 border-t border-zinc-200">
<a
class="inline-flex items-center gap-1.5 text-sm font-semibold text-mauve hover:text-zinc-800 transition-colors"
href="/billing/portal"
>
<iconify-icon class="text-base" icon="lucide:circle-x"></iconify-icon>
Cancel subscription
</a>
<p class="text-xs text-zinc-500 mt-1">
You'll keep your features until the end of the billing period.
</p>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -32,7 +60,25 @@
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { useAuth } from '../../composables/useAuth.js' import { useAuth } from '../../composables/useAuth.js'
const { user, userTier } = useAuth() const {
user,
userTier,
isPaidTier,
subscriptionCancelled,
subscriptionCadence,
subscribedAt,
subscriptionExpiresAt,
} = useAuth()
const dateFormatter = new Intl.DateTimeFormat('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })
function formatDate(value) {
if (!value) {
return ''
}
const date = new Date(value)
return Number.isNaN(date.getTime()) ? '' : dateFormatter.format(date)
}
const quickLinks = [ const quickLinks = [
{ to: '/dashboard/saved-stations', label: 'Saved Stations', icon: 'lucide:bookmark', description: 'Stations you\'ve bookmarked for quick access.' }, { to: '/dashboard/saved-stations', label: 'Saved Stations', icon: 'lucide:bookmark', description: 'Stations you\'ve bookmarked for quick access.' },

View File

@@ -76,7 +76,7 @@
<!-- eslint-disable-next-line vue/no-v-html --> <!-- eslint-disable-next-line vue/no-v-html -->
<div v-html="setupData.svg" class="[&_svg]:w-40 [&_svg]:h-40"></div> <div v-html="setupData.svg" class="[&_svg]:w-40 [&_svg]:h-40"></div>
<div class="space-y-1"> <div class="space-y-1">
<p class="text-xs font-bold text-zinc-500 uppercase tracking-widest">Or enter setup key manually</p> <p class="font-mono text-[10px] uppercase tracking-widest text-zinc-500 mb-1 truncate">Or enter setup key manually</p>
<code class="text-xs bg-zinc-50 px-3 py-2 rounded-lg font-mono text-zinc-800 break-all block">{{ setupData.secretKey }}</code> <code class="text-xs bg-zinc-50 px-3 py-2 rounded-lg font-mono text-zinc-800 break-all block">{{ setupData.secretKey }}</code>
</div> </div>
</div> </div>

View File

@@ -2,7 +2,6 @@
use App\Enums\FuelType; use App\Enums\FuelType;
use App\Http\Controllers\Api\AuthController; use App\Http\Controllers\Api\AuthController;
use App\Http\Controllers\Api\PredictionController;
use App\Http\Controllers\Api\StationController; use App\Http\Controllers\Api\StationController;
use App\Http\Controllers\Api\StatsController; use App\Http\Controllers\Api\StatsController;
use App\Http\Controllers\Api\UserController; use App\Http\Controllers\Api\UserController;
@@ -26,7 +25,6 @@ Route::get('/stats/live', [StatsController::class, 'live']);
Route::middleware(['throttle:60,1', VerifyApiKey::class])->group(function (): void { Route::middleware(['throttle:60,1', VerifyApiKey::class])->group(function (): void {
Route::get('/stations', [StationController::class, 'index']); Route::get('/stations', [StationController::class, 'index']);
Route::get('/stats/searches', [StatsController::class, 'searches']); Route::get('/stats/searches', [StatsController::class, 'searches']);
Route::get('/prediction', [PredictionController::class, 'index']);
}); });
// Sanctum-authenticated endpoints // Sanctum-authenticated endpoints

View File

@@ -1,5 +1,6 @@
<?php <?php
use App\Models\Plan;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -68,6 +69,146 @@ it('returns the authenticated user on /me', function () {
->assertJsonPath('email', $user->email); ->assertJsonPath('email', $user->email);
}); });
it('reports subscription_cancelled=false for a user with no subscription', function () {
$user = User::factory()->create();
$this->actingAs($user, 'sanctum')
->getJson('/api/auth/me')
->assertOk()
->assertJsonPath('subscription_cancelled', false);
});
it('reports subscription_cancelled=false for an active paid subscription', function () {
$user = User::factory()->create();
$user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_active',
'stripe_status' => 'active',
'stripe_price' => 'price_plus_monthly',
'quantity' => 1,
]);
$this->actingAs($user, 'sanctum')
->getJson('/api/auth/me')
->assertOk()
->assertJsonPath('subscription_cancelled', false);
});
it('reports subscription_cancelled=true once the subscription is set to end at period end', function () {
$user = User::factory()->create();
$user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_cancelling',
'stripe_status' => 'active',
'stripe_price' => 'price_plus_monthly',
'quantity' => 1,
'ends_at' => now()->addDays(20),
]);
$this->actingAs($user, 'sanctum')
->getJson('/api/auth/me')
->assertOk()
->assertJsonPath('subscription_cancelled', true);
});
it('exposes subscribed_at, cadence and renewal date for an active monthly subscription', function () {
Plan::create([
'name' => 'plus',
'stripe_price_id_monthly' => 'price_plus_monthly_test',
'stripe_price_id_annual' => 'price_plus_annual_test',
'features' => ['fuel_types' => ['max' => 1]],
'active' => true,
]);
$user = User::factory()->create();
$subscribedAt = now()->subDays(10)->startOfSecond();
$renewalAt = now()->addDays(20)->startOfSecond();
$user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_monthly_active',
'stripe_status' => 'active',
'stripe_price' => 'price_plus_monthly_test',
'quantity' => 1,
'current_period_end' => $renewalAt,
'created_at' => $subscribedAt,
'updated_at' => $subscribedAt,
]);
$response = $this->actingAs($user, 'sanctum')
->getJson('/api/auth/me')
->assertOk()
->assertJsonPath('subscription_cancelled', false)
->assertJsonPath('subscription_cadence', 'monthly');
expect($response->json('subscribed_at'))->toStartWith($subscribedAt->toDateString());
expect($response->json('subscription_expires_at'))->toStartWith($renewalAt->toDateString());
});
it('reports cadence as annual when the active price is the annual one', function () {
Plan::create([
'name' => 'pro',
'stripe_price_id_monthly' => 'price_pro_monthly_test',
'stripe_price_id_annual' => 'price_pro_annual_test',
'features' => ['fuel_types' => ['max' => null]],
'active' => true,
]);
$user = User::factory()->create();
$user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_annual_active',
'stripe_status' => 'active',
'stripe_price' => 'price_pro_annual_test',
'quantity' => 1,
]);
$this->actingAs($user, 'sanctum')
->getJson('/api/auth/me')
->assertOk()
->assertJsonPath('subscription_cadence', 'annual');
});
it('uses ends_at as the expiry date when subscription is cancelled', function () {
$user = User::factory()->create();
$endsAt = now()->addDays(15)->startOfSecond();
$renewalAt = now()->addDays(30)->startOfSecond();
$user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_cancelling_with_period',
'stripe_status' => 'active',
'stripe_price' => 'price_plus_monthly',
'quantity' => 1,
'ends_at' => $endsAt,
'current_period_end' => $renewalAt,
]);
$response = $this->actingAs($user, 'sanctum')
->getJson('/api/auth/me')
->assertOk()
->assertJsonPath('subscription_cancelled', true);
expect($response->json('subscription_expires_at'))->toStartWith($endsAt->toDateString());
});
it('returns null subscription metadata for users with no subscription', function () {
$user = User::factory()->create();
$this->actingAs($user, 'sanctum')
->getJson('/api/auth/me')
->assertOk()
->assertJsonPath('subscription_cancelled', false)
->assertJsonPath('subscription_cadence', null)
->assertJsonPath('subscribed_at', null)
->assertJsonPath('subscription_expires_at', null);
});
it('logs out and revokes the token', function () { it('logs out and revokes the token', function () {
$user = User::factory()->create(); $user = User::factory()->create();
$token = $user->createToken('api')->plainTextToken; $token = $user->createToken('api')->plainTextToken;

View File

@@ -1,70 +0,0 @@
<?php
use App\Enums\FuelType;
use App\Models\Station;
use App\Models\StationPriceCurrent;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withHeaders(['X-Api-Key' => config('app.api_secret_key')]);
});
it('returns a prediction response', function () {
$this->getJson('/api/prediction')
->assertOk()
->assertJsonStructure([
'fuel_type', 'current_avg', 'predicted_direction', 'predicted_change_pence',
'confidence_score', 'confidence_label', 'action', 'reasoning',
'prediction_horizon_days', 'region_key', 'methodology',
'signals' => [
'trend' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'],
'day_of_week' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'],
'brand_behaviour' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'],
'national_momentum' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'],
'regional_momentum' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'],
'price_stickiness' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'],
],
])
->assertJsonPath('fuel_type', 'e10')
->assertJsonPath('region_key', 'national');
});
it('includes current average from live prices', function () {
$station = Station::factory()->create();
StationPriceCurrent::factory()->create([
'station_id' => $station->node_id,
'fuel_type' => FuelType::E10,
'price_pence' => 14750,
]);
$response = $this->getJson('/api/prediction')->assertOk();
expect($response->json('current_avg'))->toBe(147.5);
});
it('returns regional prediction when lat and lng are provided', function () {
$this->getJson('/api/prediction?lat=52.5&lng=-0.2')
->assertOk()
->assertJsonPath('region_key', 'regional')
->assertJsonPath('fuel_type', 'e10');
});
it('returns national prediction without coordinates', function () {
$this->getJson('/api/prediction')
->assertOk()
->assertJsonPath('region_key', 'national');
});
it('returns 422 for invalid lat', function () {
$this->getJson('/api/prediction?lat=999&lng=0')
->assertUnprocessable()
->assertJsonValidationErrors(['lat']);
});
it('returns 422 for invalid lng', function () {
$this->getJson('/api/prediction?lat=51.5&lng=999')
->assertUnprocessable()
->assertJsonValidationErrors(['lng']);
});

View File

@@ -1,8 +1,10 @@
<?php <?php
use App\Enums\FuelType; use App\Enums\FuelType;
use App\Filament\Resources\UserResource;
use App\Models\Station; use App\Models\Station;
use App\Models\StationPriceCurrent; use App\Models\StationPriceCurrent;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
@@ -12,6 +14,15 @@ beforeEach(function () {
$this->withHeaders(['X-Api-Key' => config('app.api_secret_key')]); $this->withHeaders(['X-Api-Key' => config('app.api_secret_key')]);
}); });
function asPaidUserOnStations(string $tier = 'plus'): User
{
test()->artisan('db:seed', ['--class' => 'PlanSeeder']);
$user = User::factory()->create();
UserResource::applyTier($user, $tier, 'monthly');
return $user;
}
it('returns stations near coordinates filtered by fuel type', function () { it('returns stations near coordinates filtered by fuel type', function () {
$station = Station::factory()->create(['lat' => 52.555064, 'lng' => -0.256119]); $station = Station::factory()->create(['lat' => 52.555064, 'lng' => -0.256119]);
StationPriceCurrent::factory()->create([ StationPriceCurrent::factory()->create([
@@ -192,3 +203,37 @@ it('includes resolved lat and lng in meta when postcode is provided', function (
->assertJsonPath('meta.lat', 51.5010) ->assertJsonPath('meta.lat', 51.5010)
->assertJsonPath('meta.lng', -0.1415); ->assertJsonPath('meta.lng', -0.1415);
}); });
it('embeds a tier-locked prediction teaser for guest requests', function () {
Station::factory()->create(['lat' => 52.555, 'lng' => -0.256]);
$this->getJson('/api/stations?lat=52.555&lng=-0.256&fuel_type=e10&radius=10')
->assertOk()
->assertJsonPath('prediction.tier_locked', true)
->assertJsonStructure(['prediction' => ['fuel_type', 'predicted_direction', 'tier_locked']])
->assertJsonMissingPath('prediction.signals')
->assertJsonMissingPath('prediction.weekly_summary');
});
it('embeds a tier-locked teaser for free-tier authenticated users', function () {
asPaidUserOnStations('free');
$user = User::query()->latest('id')->first();
Station::factory()->create(['lat' => 52.555, 'lng' => -0.256]);
$this->actingAs($user, 'sanctum')
->getJson('/api/stations?lat=52.555&lng=-0.256&fuel_type=e10&radius=10')
->assertOk()
->assertJsonPath('prediction.tier_locked', true)
->assertJsonMissingPath('prediction.signals');
});
it('embeds the full prediction payload for plus users', function () {
$user = asPaidUserOnStations('plus');
Station::factory()->create(['lat' => 52.555, 'lng' => -0.256]);
$this->actingAs($user, 'sanctum')
->getJson('/api/stations?lat=52.555&lng=-0.256&fuel_type=e10&radius=10')
->assertOk()
->assertJsonStructure(['prediction' => ['fuel_type', 'predicted_direction', 'confidence_score', 'reasoning', 'weekly_summary', 'signals']])
->assertJsonMissingPath('prediction.tier_locked');
});

View File

@@ -1,33 +0,0 @@
<?php
use App\Livewire\Public\Fuel\Map;
use Livewire\Livewire;
it('renders the map component', function () {
Livewire::test(Map::class)
->assertStatus(200);
});
it('dispatches map-update browser event when stations-found is received', function () {
Livewire::test(Map::class)
->dispatch('stations-found',
results: [['name' => 'BP Garage']],
meta: ['lat' => 51.5, 'lng' => -0.1, 'count' => 1],
radius: 5,
prediction: null
)
->assertDispatched('map-update');
});
it('passes radius in map-update payload', function () {
Livewire::test(Map::class)
->dispatch('stations-found',
results: [],
meta: ['lat' => 51.5, 'lng' => -0.1, 'count' => 0],
radius: 10,
prediction: null
)
->assertDispatched('map-update', fn ($event, $params) =>
$params['radius'] === 10
);
});

View File

@@ -1,52 +0,0 @@
<?php
use App\Livewire\Public\Fuel\Recommendation;
use Livewire\Livewire;
it('renders nothing before stations-found fires', function () {
Livewire::test(Recommendation::class)
->assertStatus(200)
->assertSet('prediction', null)
->assertDontSee('Recommendation');
});
it('shows recommendation card when stations-found includes a prediction', function () {
$prediction = [
'action' => 'fill_now',
'confidence_score' => 80.0,
'confidence_label' => 'high',
'reasoning' => 'Prices are rising sharply.',
'predicted_direction' => 'up',
'predicted_change_pence' => 3.5,
];
Livewire::test(Recommendation::class)
->dispatch('stations-found', results: [], meta: [], prediction: $prediction, radius: 5)
->assertSet('prediction', $prediction)
->assertSee('Recommendation')
->assertSee('Fill up now');
});
it('shows nothing when stations-found has null prediction', function () {
Livewire::test(Recommendation::class)
->dispatch('stations-found', results: [], meta: [], prediction: null, radius: 5)
->assertSet('prediction', null)
->assertDontSee('Recommendation');
});
it('clears previous prediction when new stations-found fires with null prediction', function () {
$prediction = [
'action' => 'fill_now',
'confidence_score' => 80.0,
'confidence_label' => 'high',
'reasoning' => 'Prices rising.',
'predicted_direction' => 'up',
'predicted_change_pence' => 3.5,
];
Livewire::test(Recommendation::class)
->dispatch('stations-found', results: [], meta: [], prediction: $prediction, radius: 5)
->assertSee('Recommendation')
->dispatch('stations-found', results: [], meta: [], prediction: null, radius: 5)
->assertDontSee('Recommendation');
});

View File

@@ -1,72 +0,0 @@
<?php
use App\Livewire\Public\Fuel\StationList;
use Livewire\Livewire;
it('renders empty state before any search', function () {
Livewire::test(StationList::class)
->assertStatus(200)
->assertSet('hasSearched', false)
->assertDontSee('Stations Nearby');
});
it('shows station cards after stations-found event', function () {
$station = [
'station_id' => 'abc123',
'name' => 'BP Garage',
'brand' => 'BP',
'is_supermarket' => false,
'address' => '1 High Street',
'postcode' => 'SW1A 1AA',
'lat' => 51.5074,
'lng' => -0.1278,
'distance_km' => 1.5,
'fuel_type' => 'e10',
'price_pence' => 14390,
'price' => 143.9,
'price_updated_at' => '2026-04-05T08:00:00.000Z',
'price_classification' => 'current',
'price_classification_label' => 'Current',
];
$meta = ['count' => 1, 'lowest_pence' => 14390, 'avg_pence' => 14390.0];
Livewire::test(StationList::class)
->dispatch('stations-found', results: [$station], meta: $meta, prediction: null, radius: 5)
->assertSet('hasSearched', true)
->assertSee('Stations Nearby')
->assertSee('BP Garage')
->assertSee('1 Result');
});
it('shows empty state message when stations-found has no results', function () {
Livewire::test(StationList::class)
->dispatch('stations-found', results: [], meta: ['count' => 0], prediction: null, radius: 5)
->assertSet('hasSearched', true)
->assertSee('No stations found');
});
it('updates results when stations-found fires again', function () {
$station = [
'station_id' => 'abc123',
'name' => 'BP Garage',
'brand' => 'BP',
'is_supermarket' => false,
'address' => '1 High Street',
'postcode' => 'SW1A 1AA',
'lat' => 51.5074,
'lng' => -0.1278,
'distance_km' => 1.5,
'fuel_type' => 'e10',
'price_pence' => 14390,
'price' => 143.9,
'price_updated_at' => '2026-04-05T08:00:00.000Z',
'price_classification' => 'current',
'price_classification_label' => 'Current',
];
Livewire::test(StationList::class)
->dispatch('stations-found', results: [$station], meta: ['count' => 1], prediction: null, radius: 5)
->assertSee('BP Garage')
->dispatch('stations-found', results: [], meta: ['count' => 0], prediction: null, radius: 5)
->assertDontSee('BP Garage');
});

View File

@@ -1,9 +0,0 @@
<?php
use App\Livewire\Public\FuelFinder;
use Livewire\Livewire;
it('renders the fuel finder shell', function () {
Livewire::test(FuelFinder::class)
->assertStatus(200);
});

View File

@@ -2,6 +2,7 @@
use App\Jobs\SendPaymentFailedReminderJob; use App\Jobs\SendPaymentFailedReminderJob;
use App\Listeners\HandleStripeWebhook; use App\Listeners\HandleStripeWebhook;
use App\Models\Subscription;
use App\Models\User; use App\Models\User;
use App\Models\UserNotificationPreference; use App\Models\UserNotificationPreference;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -121,6 +122,63 @@ it('on invoice.payment_failed sets grace_period_until 5 days out and queues both
Queue::assertPushed(SendPaymentFailedReminderJob::class, 2); Queue::assertPushed(SendPaymentFailedReminderJob::class, 2);
}); });
it('persists current_period_start, current_period_end and stripe_data on subscription.updated', function (): void {
$user = User::factory()->create(['stripe_id' => 'cus_period_1']);
$user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_period_1',
'stripe_status' => 'active',
'stripe_price' => 'price_plus_monthly',
'quantity' => 1,
]);
$start = 1714377600;
$end = 1717056000;
(new HandleStripeWebhook)->handle(new WebhookReceived([
'type' => 'customer.subscription.updated',
'data' => ['object' => [
'id' => 'sub_period_1',
'customer' => 'cus_period_1',
'current_period_start' => $start,
'current_period_end' => $end,
'status' => 'active',
]],
]));
$sub = Subscription::where('stripe_id', 'sub_period_1')->first();
expect($sub->current_period_start->timestamp)->toBe($start);
expect($sub->current_period_end->timestamp)->toBe($end);
expect($sub->stripe_data)->toMatchArray(['id' => 'sub_period_1', 'status' => 'active']);
});
it('reads current_period_end from items.data[0] when not at the root (newer Stripe API)', function (): void {
$user = User::factory()->create(['stripe_id' => 'cus_period_2']);
$user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_period_2',
'stripe_status' => 'active',
'stripe_price' => 'price_plus_monthly',
'quantity' => 1,
]);
$end = 1719648000;
(new HandleStripeWebhook)->handle(new WebhookReceived([
'type' => 'customer.subscription.updated',
'data' => ['object' => [
'id' => 'sub_period_2',
'customer' => 'cus_period_2',
'items' => ['data' => [['current_period_end' => $end]]],
]],
]));
expect(Subscription::where('stripe_id', 'sub_period_2')->value('current_period_end')->timestamp)->toBe($end);
});
it('repeat invoice.payment_failed within grace does not re-dispatch reminders', function (): void { it('repeat invoice.payment_failed within grace does not re-dispatch reminders', function (): void {
Queue::fake(); Queue::fake();
$existingGrace = now()->addDays(3)->startOfSecond(); $existingGrace = now()->addDays(3)->startOfSecond();

View File

@@ -6,6 +6,7 @@ use App\Models\StationPrice;
use App\Models\StationPriceCurrent; use App\Models\StationPriceCurrent;
use App\Services\NationalFuelPredictionService; use App\Services\NationalFuelPredictionService;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@@ -78,14 +79,96 @@ it('includes all required keys in response', function () {
'fuel_type', 'current_avg', 'predicted_direction', 'predicted_change_pence', 'fuel_type', 'current_avg', 'predicted_direction', 'predicted_change_pence',
'confidence_score', 'confidence_label', 'action', 'reasoning', 'confidence_score', 'confidence_label', 'action', 'reasoning',
'prediction_horizon_days', 'region_key', 'methodology', 'prediction_horizon_days', 'region_key', 'methodology',
'signals', 'weekly_summary', 'signals',
]) ])
->and($result['signals'])->toHaveKeys([ ->and($result['signals'])->toHaveKeys([
'trend', 'day_of_week', 'brand_behaviour', 'trend', 'day_of_week', 'brand_behaviour',
'national_momentum', 'regional_momentum', 'price_stickiness', 'national_momentum', 'regional_momentum', 'price_stickiness', 'oil',
])
->and($result['weekly_summary'])->toHaveKeys([
'yesterday_avg', 'today_avg', 'tomorrow_estimated_avg',
'yesterday_today_delta_pence', 'last_7_days_series',
'last_7_days_change_pence', 'cheapest_day', 'priciest_day', 'is_regional',
]); ]);
}); });
it('weekly_summary returns null prices and empty series when there is no data', function () {
$result = app(NationalFuelPredictionService::class)->predict();
$weekly = $result['weekly_summary'];
expect($weekly['yesterday_avg'])->toBeNull()
->and($weekly['yesterday_today_delta_pence'])->toBeNull()
->and($weekly['last_7_days_series'])->toBe([])
->and($weekly['cheapest_day'])->toBeNull()
->and($weekly['priciest_day'])->toBeNull()
->and($weekly['is_regional'])->toBeFalse();
});
it('weekly_summary populates yesterday avg, today avg and 7-day series from station_prices', function () {
$station = Station::factory()->create();
StationPriceCurrent::factory()->create([
'station_id' => $station->node_id,
'fuel_type' => FuelType::E10,
'price_pence' => 14000,
]);
for ($daysAgo = 6; $daysAgo >= 0; $daysAgo--) {
StationPrice::factory()->create([
'station_id' => $station->node_id,
'fuel_type' => FuelType::E10,
'price_pence' => 14000 + ($daysAgo * 50),
'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0),
]);
}
$result = app(NationalFuelPredictionService::class)->predict();
$weekly = $result['weekly_summary'];
expect($weekly['yesterday_avg'])->toBe(140.5)
->and($weekly['today_avg'])->toBe(140.0)
->and($weekly['yesterday_today_delta_pence'])->toBe(-0.5)
->and(count($weekly['last_7_days_series']))->toBe(7)
->and($weekly['cheapest_day']['avg'])->toBe(140.0)
->and($weekly['priciest_day']['avg'])->toBe(143.0);
});
it('weekly_summary falls back from regional to national when regional data is empty', function () {
$station = Station::factory()->create(['lat' => 51.5, 'lng' => -0.1]);
for ($daysAgo = 6; $daysAgo >= 0; $daysAgo--) {
StationPrice::factory()->create([
'station_id' => $station->node_id,
'fuel_type' => FuelType::E10,
'price_pence' => 14000,
'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0),
]);
}
// Coordinates 600+ km away from any station — no regional data available.
$result = app(NationalFuelPredictionService::class)->predict(58.0, -3.0);
$weekly = $result['weekly_summary'];
expect($weekly['is_regional'])->toBeFalse()
->and(count($weekly['last_7_days_series']))->toBe(7);
});
it('weekly_summary marks is_regional true when stations exist within 50km of coordinates', function () {
$station = Station::factory()->create(['lat' => 51.5, 'lng' => -0.1]);
for ($daysAgo = 6; $daysAgo >= 0; $daysAgo--) {
StationPrice::factory()->create([
'station_id' => $station->node_id,
'fuel_type' => FuelType::E10,
'price_pence' => 14000,
'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0),
]);
}
$result = app(NationalFuelPredictionService::class)->predict(51.5074, -0.1278);
expect($result['weekly_summary']['is_regional'])->toBeTrue();
});
it('always returns e10 as fuel_type', function () { it('always returns e10 as fuel_type', function () {
$result = app(NationalFuelPredictionService::class)->predict(); $result = app(NationalFuelPredictionService::class)->predict();
@@ -146,3 +229,166 @@ it('disables trend signal when r_squared is below 0.5', function () {
// Trend signal may be disabled if both 5-day and 14-day lookbacks fail R² threshold // Trend signal may be disabled if both 5-day and 14-day lookbacks fail R² threshold
expect($result['signals']['trend']['data_points'])->toBeInt(); expect($result['signals']['trend']['data_points'])->toBeInt();
}); });
it('oil signal is disabled when no price_predictions row covers today or later', function () {
$result = app(NationalFuelPredictionService::class)->predict();
expect($result['signals']['oil']['enabled'])->toBeFalse();
});
it('oil signal picks up an llm prediction over an ewma one for the same date', function () {
DB::table('price_predictions')->insert([
[
'predicted_for' => now()->toDateString(),
'source' => 'ewma',
'direction' => 'flat',
'confidence' => 60,
'reasoning' => null,
'generated_at' => now()->subHour(),
],
[
'predicted_for' => now()->toDateString(),
'source' => 'llm',
'direction' => 'rising',
'confidence' => 75,
'reasoning' => 'OPEC cut',
'generated_at' => now(),
],
]);
$oil = app(NationalFuelPredictionService::class)->predict()['signals']['oil'];
expect($oil['enabled'])->toBeTrue()
->and($oil['direction'])->toBe('up')
->and($oil['score'])->toBe(1.0)
->and($oil['confidence'])->toBe(0.75);
});
it('oil signal prefers llm_with_context over plain llm', function () {
DB::table('price_predictions')->insert([
[
'predicted_for' => now()->toDateString(),
'source' => 'llm',
'direction' => 'falling',
'confidence' => 70,
'reasoning' => 'baseline',
'generated_at' => now(),
],
[
'predicted_for' => now()->toDateString(),
'source' => 'llm_with_context',
'direction' => 'rising',
'confidence' => 82,
'reasoning' => 'with context',
'generated_at' => now(),
],
]);
$oil = app(NationalFuelPredictionService::class)->predict()['signals']['oil'];
expect($oil['direction'])->toBe('up')
->and($oil['confidence'])->toBe(0.82);
});
it('confidence reaches "high" when trend and oil agree strongly', function () {
$station = Station::factory()->create();
// Strong falling trend over 7 days, ~1p/day
for ($daysAgo = 6; $daysAgo >= 0; $daysAgo--) {
StationPrice::factory()->create([
'station_id' => $station->node_id,
'fuel_type' => FuelType::E10,
'price_pence' => 15000 - ((6 - $daysAgo) * 100),
'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0),
]);
}
DB::table('price_predictions')->insert([
'predicted_for' => now()->toDateString(),
'source' => 'llm',
'direction' => 'falling',
'confidence' => 80,
'reasoning' => 'agree',
'generated_at' => now(),
]);
$result = app(NationalFuelPredictionService::class)->predict();
expect($result['predicted_direction'])->toBe('down')
->and($result['confidence_score'])->toBeGreaterThanOrEqual(70)
->and($result['confidence_label'])->toBe('high');
});
it('confidence drops when trend and oil disagree', function () {
$station = Station::factory()->create();
// Strong falling trend
for ($daysAgo = 6; $daysAgo >= 0; $daysAgo--) {
StationPrice::factory()->create([
'station_id' => $station->node_id,
'fuel_type' => FuelType::E10,
'price_pence' => 15000 - ((6 - $daysAgo) * 100),
'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0),
]);
}
// Oil disagrees: rising
DB::table('price_predictions')->insert([
'predicted_for' => now()->toDateString(),
'source' => 'llm',
'direction' => 'rising',
'confidence' => 80,
'reasoning' => 'opec',
'generated_at' => now(),
]);
$agree = app(NationalFuelPredictionService::class)->predict();
// Replace oil with one that agrees instead — confidence should be higher
DB::table('price_predictions')->update([
'direction' => 'falling',
]);
$disagreeReplaced = app(NationalFuelPredictionService::class)->predict();
expect($agree['confidence_score'])->toBeLessThan($disagreeReplaced['confidence_score']);
});
it('day-of-week signal activates at 21 days of history (no longer 56)', function () {
$station = Station::factory()->create();
for ($daysAgo = 25; $daysAgo >= 0; $daysAgo--) {
StationPrice::factory()->create([
'station_id' => $station->node_id,
'fuel_type' => FuelType::E10,
'price_pence' => 14000 + ($daysAgo % 7) * 50,
'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0),
]);
}
$result = app(NationalFuelPredictionService::class)->predict();
expect($result['signals']['day_of_week']['enabled'])->toBeTrue();
});
it('reasoning fallback for the wait action does not say "fill up"', function () {
// No data → trend disabled, brand disabled, oil disabled.
// Force a "down" direction by injecting an oil prediction that points down with low confidence.
DB::table('price_predictions')->insert([
'predicted_for' => now()->toDateString(),
'source' => 'ewma',
'direction' => 'falling',
'confidence' => 50,
'reasoning' => null,
'generated_at' => now(),
]);
$result = app(NationalFuelPredictionService::class)->predict();
if ($result['action'] === 'wait') {
expect($result['reasoning'])->not->toContain('fill up at the cheapest');
} else {
// If thresholds keep this at no_signal, still verify action-aware fallback exists
expect($result['reasoning'])->toBeString();
}
});