Remove prediction API endpoint and integrate into stations search
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.
This commit is contained in:
@@ -31,6 +31,7 @@ npm run dev # Vite asset watcher
|
||||
@.claude/rules/database.md
|
||||
@.claude/rules/notifications.md
|
||||
@.claude/rules/scoring.md
|
||||
@.claude/rules/prediction.md
|
||||
@.claude/rules/payments.md
|
||||
@.claude/rules/tiers.md
|
||||
@.claude/rules/livewire.md
|
||||
|
||||
@@ -63,10 +63,19 @@ class AuthController extends Controller
|
||||
public function me(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$subscription = $user->subscription();
|
||||
|
||||
$expiresAt = $subscription?->ends_at ?? $subscription?->current_period_end;
|
||||
|
||||
return response()->json(array_merge(
|
||||
$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(),
|
||||
],
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\PredictionRequest;
|
||||
use App\Services\NationalFuelPredictionService;
|
||||
use App\Services\PlanFeatures;
|
||||
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);
|
||||
|
||||
$user = $request->user();
|
||||
$canSeeFull = $user !== null && PlanFeatures::for($user)->can('ai_predictions');
|
||||
|
||||
if (! $canSeeFull) {
|
||||
return response()->json([
|
||||
'fuel_type' => $result['fuel_type'],
|
||||
'predicted_direction' => $result['predicted_direction'],
|
||||
'tier_locked' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ use App\Http\Requests\Api\NearbyStationsRequest;
|
||||
use App\Http\Resources\Api\StationResource;
|
||||
use App\Models\Search;
|
||||
use App\Models\Station;
|
||||
use App\Models\User;
|
||||
use App\Services\NationalFuelPredictionService;
|
||||
use App\Services\PlanFeatures;
|
||||
use App\Services\PostcodeService;
|
||||
use Illuminate\Database\Query\JoinClause;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -16,7 +19,10 @@ use Illuminate\Validation\ValidationException;
|
||||
|
||||
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
|
||||
{
|
||||
@@ -115,6 +121,31 @@ class StationController extends Controller
|
||||
'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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
|
||||
use App\Enums\PlanTier;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Laravel\Cashier\Checkout;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
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.
|
||||
*/
|
||||
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($cadence, ['monthly', 'annual'], true), 404);
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Jobs\SendPaymentFailedReminderJob;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\User;
|
||||
use App\Models\UserNotificationPreference;
|
||||
use Illuminate\Support\Carbon;
|
||||
@@ -28,15 +29,27 @@ final class HandleStripeWebhook
|
||||
|
||||
match ($type) {
|
||||
'customer.subscription.created',
|
||||
'customer.subscription.updated' => $this->bustPlanCache($user),
|
||||
'customer.subscription.deleted' => $this->handleSubscriptionDeleted($user),
|
||||
'customer.subscription.updated' => $this->handleSubscriptionUpserted($user, $event->payload['data']['object'] ?? []),
|
||||
'customer.subscription.deleted' => $this->handleSubscriptionDeleted($user, $event->payload['data']['object'] ?? []),
|
||||
'invoice.payment_succeeded' => $this->handlePaymentSucceeded($user),
|
||||
'invoice.payment_failed' => $this->handlePaymentFailed($user),
|
||||
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()
|
||||
->where('user_id', $user->id)
|
||||
@@ -45,9 +58,52 @@ final class HandleStripeWebhook
|
||||
|
||||
$user->forceFill(['grace_period_until' => null])->save();
|
||||
|
||||
$this->syncPeriodFromStripePayload($stripeSubscription);
|
||||
$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
|
||||
{
|
||||
$user->forceFill(['grace_period_until' => null])->save();
|
||||
|
||||
@@ -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
|
||||
{
|
||||
static::saved(function (): void {
|
||||
|
||||
@@ -9,7 +9,6 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
#[Fillable(['predicted_for', 'source', 'direction', 'confidence', 'reasoning', 'generated_at'])]
|
||||
class PricePrediction extends Model
|
||||
@@ -39,11 +38,17 @@ class PricePrediction extends Model
|
||||
*/
|
||||
public function scopeBestFirst(Builder $query): Builder
|
||||
{
|
||||
$priority = implode(', ', array_map(
|
||||
fn (string $v) => "'$v'",
|
||||
[PredictionSource::LlmWithContext->value, PredictionSource::Llm->value, PredictionSource::Ewma->value],
|
||||
));
|
||||
$priority = [
|
||||
PredictionSource::LlmWithContext->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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Listeners\HandleStripeWebhook;
|
||||
use App\Models\Subscription;
|
||||
use App\Services\ApiLogger;
|
||||
use App\Services\LlmPrediction\AnthropicPredictionProvider;
|
||||
use App\Services\LlmPrediction\GeminiPredictionProvider;
|
||||
@@ -14,6 +15,7 @@ use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Laravel\Cashier\Cashier;
|
||||
use Laravel\Cashier\Events\WebhookReceived;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
@@ -41,6 +43,8 @@ class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
$this->configureDefaults();
|
||||
|
||||
Cashier::useSubscriptionModel(Subscription::class);
|
||||
|
||||
Event::listen(WebhookReceived::class, HandleStripeWebhook::class);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Services;
|
||||
|
||||
use App\Enums\FuelType;
|
||||
use App\Models\StationPriceCurrent;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class NationalFuelPredictionService
|
||||
@@ -12,6 +13,12 @@ class NationalFuelPredictionService
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
@@ -40,13 +47,14 @@ class NationalFuelPredictionService
|
||||
$dayOfWeek = $this->computeDayOfWeekSignal($fuelType);
|
||||
$brandBehaviour = $this->computeBrandBehaviourSignal($fuelType);
|
||||
$stickiness = $this->computeStickinessSignal($fuelType);
|
||||
$oil = $this->computeOilSignal();
|
||||
|
||||
$nationalMomentum = $this->disabledSignal('National momentum disabled for national predictions');
|
||||
$regionalMomentum = $hasCoordinates
|
||||
? $this->computeRegionalMomentumSignal($fuelType, $lat, $lng)
|
||||
: $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);
|
||||
|
||||
@@ -65,6 +73,8 @@ class NationalFuelPredictionService
|
||||
default => 'no_signal',
|
||||
};
|
||||
|
||||
$weeklySummary = $this->computeWeeklySummary($fuelType, $lat, $lng, $currentAvg, $slope);
|
||||
|
||||
return [
|
||||
'fuel_type' => $fuelType->value,
|
||||
'current_avg' => $currentAvg,
|
||||
@@ -73,10 +83,11 @@ class NationalFuelPredictionService
|
||||
'confidence_score' => $confidenceScore,
|
||||
'confidence_label' => $confidenceLabel,
|
||||
'action' => $action,
|
||||
'reasoning' => $this->buildReasoning($direction, $slope, $trend, $brandBehaviour),
|
||||
'reasoning' => $this->buildReasoning($direction, $slope, $trend, $brandBehaviour, $dayOfWeek),
|
||||
'prediction_horizon_days' => self::PREDICTION_HORIZON_DAYS,
|
||||
'region_key' => $hasCoordinates ? 'regional' : 'national',
|
||||
'methodology' => 'multi_signal_live_fallback',
|
||||
'weekly_summary' => $weeklySummary,
|
||||
'signals' => [
|
||||
'trend' => $trend,
|
||||
'day_of_week' => $dayOfWeek,
|
||||
@@ -84,6 +95,7 @@ class NationalFuelPredictionService
|
||||
'national_momentum' => $nationalMomentum,
|
||||
'regional_momentum' => $regionalMomentum,
|
||||
'price_stickiness' => $stickiness,
|
||||
'oil' => $oil,
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -138,7 +150,7 @@ class NationalFuelPredictionService
|
||||
default => 'stable',
|
||||
};
|
||||
$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);
|
||||
$detail = $direction === 'stable'
|
||||
? "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();
|
||||
|
||||
if ($uniqueDays < 56) {
|
||||
return $this->disabledSignal("Insufficient history for day-of-week pattern ({$uniqueDays} days, need 56)");
|
||||
if ($uniqueDays < self::DAY_OF_WEEK_MIN_DAYS) {
|
||||
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'));
|
||||
@@ -214,9 +226,11 @@ class NationalFuelPredictionService
|
||||
$todayAvg = $dowAverages->get($todayDow, $weekAvg);
|
||||
$cheapestDow = $dowAverages->keys()->sortBy(fn ($k) => $dowAverages[$k])->first();
|
||||
$dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
$cheapestDayName = $dayNames[($cheapestDow - 1) % 7] ?? 'Unknown';
|
||||
$weekRange = round(($dowAverages->max() - $dowAverages->min()) / 100, 1);
|
||||
$tomorrowDelta = round(($dowAverages->get(($todayDow % 7) + 1, $weekAvg) - $todayAvg) / 100, 1);
|
||||
$todayName = $dayNames[($todayDow - 1) % 7] ?? 'Today';
|
||||
$tomorrowName = $dayNames[$todayDow % 7] ?? 'Tomorrow';
|
||||
|
||||
$todayDeltaPence = round(($todayAvg - $weekAvg) / 100, 1);
|
||||
$tomorrowDeltaPence = round(($dowAverages->get(($todayDow % 7) + 1, $weekAvg) - $todayAvg) / 100, 1);
|
||||
|
||||
$direction = match (true) {
|
||||
($todayAvg - $weekAvg) / 100 >= 1.5 => 'up',
|
||||
@@ -226,11 +240,34 @@ class NationalFuelPredictionService
|
||||
|
||||
$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 [
|
||||
'score' => $score,
|
||||
'confidence' => min(1.0, $uniqueDays / 90),
|
||||
'direction' => $direction,
|
||||
'detail' => "Cheapest day: {$cheapestDayName}. Weekly range: {$weekRange}p. Tomorrow typically {$tomorrowDelta}p less than today.",
|
||||
'detail' => implode(' ', $parts),
|
||||
'data_points' => $uniqueDays,
|
||||
'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} */
|
||||
private function disabledSignal(string $detail): array
|
||||
{
|
||||
@@ -400,46 +494,64 @@ class NationalFuelPredictionService
|
||||
}
|
||||
|
||||
/**
|
||||
* Weighted aggregate of enabled signals.
|
||||
* Returns [direction string, confidence score 0-100].
|
||||
* Aggregate enabled signals into a final direction + confidence score.
|
||||
*
|
||||
* @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}
|
||||
*/
|
||||
private function aggregateSignals(array $signals, bool $hasCoordinates = false): array
|
||||
{
|
||||
$weights = $hasCoordinates
|
||||
? [
|
||||
'regionalMomentum' => 0.50,
|
||||
'trend' => 0.20,
|
||||
'regionalMomentum' => 0.35,
|
||||
'oil' => 0.20,
|
||||
'trend' => 0.15,
|
||||
'dayOfWeek' => 0.15,
|
||||
'brandBehaviour' => 0.10,
|
||||
'stickiness' => 0.05,
|
||||
]
|
||||
: [
|
||||
'trend' => 0.45,
|
||||
'trend' => 0.30,
|
||||
'oil' => 0.25,
|
||||
'dayOfWeek' => 0.20,
|
||||
'brandBehaviour' => 0.25,
|
||||
'brandBehaviour' => 0.15,
|
||||
'stickiness' => 0.10,
|
||||
];
|
||||
|
||||
$weightedSum = 0.0;
|
||||
$totalWeight = 0.0;
|
||||
$directionalScoreSum = 0.0;
|
||||
$directionalWeightSum = 0.0;
|
||||
$confidenceWeightedSum = 0.0;
|
||||
$totalEnabledWeight = 0.0;
|
||||
|
||||
foreach ($weights as $key => $weight) {
|
||||
$signal = $signals[$key] ?? null;
|
||||
if ($signal && $signal['enabled']) {
|
||||
$weightedSum += $signal['score'] * $signal['confidence'] * $weight;
|
||||
$totalWeight += $weight;
|
||||
if (! $signal || ! $signal['enabled']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$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];
|
||||
}
|
||||
|
||||
$normalised = $weightedSum / $totalWeight;
|
||||
$confidenceScore = round(min(100.0, abs($normalised) * 100), 1);
|
||||
$normalised = $directionalWeightSum > 0.01
|
||||
? $directionalScoreSum / $directionalWeightSum
|
||||
: 0.0;
|
||||
|
||||
$direction = match (true) {
|
||||
$normalised >= 0.1 => 'up',
|
||||
@@ -447,9 +559,175 @@ class NationalFuelPredictionService
|
||||
default => 'stable',
|
||||
};
|
||||
|
||||
$avgConfidence = $confidenceWeightedSum / $totalEnabledWeight;
|
||||
$agreement = $this->computeAgreement($signals, $weights, $direction);
|
||||
|
||||
$confidenceScore = round(min(100.0, $avgConfidence * $agreement * 100), 1);
|
||||
|
||||
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.
|
||||
* x is the array index (day number), y is the price value.
|
||||
@@ -491,7 +769,12 @@ class NationalFuelPredictionService
|
||||
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 = [];
|
||||
|
||||
@@ -503,8 +786,16 @@ class NationalFuelPredictionService
|
||||
$parts[] = $brandBehaviour['detail'];
|
||||
}
|
||||
|
||||
if ($dayOfWeek['enabled']) {
|
||||
$parts[] = $dayOfWeek['detail'];
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -29,40 +29,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Paid: full prediction -->
|
||||
<div
|
||||
v-else-if="prediction"
|
||||
class="p-6 bg-white rounded-2xl border border-zinc-300 space-y-4"
|
||||
>
|
||||
<p class="text-xs font-bold uppercase tracking-widest text-zinc-500">Price Prediction</p>
|
||||
|
||||
<h3
|
||||
:class="['text-2xl font-black', actionTextColor]"
|
||||
>
|
||||
{{ actionLabel }}
|
||||
</h3>
|
||||
|
||||
<div class="w-full h-2 bg-zinc-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
:class="['h-full rounded-full transition-all', actionBarColor]"
|
||||
:style="{ width: prediction.confidence_score + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-zinc-500 leading-relaxed">{{ prediction.reasoning }}</p>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 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>
|
||||
</div>
|
||||
<PredictionFull v-else :prediction="prediction" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import PredictionFull from './PredictionFull.vue'
|
||||
|
||||
const props = defineProps({
|
||||
prediction: { type: Object, default: null },
|
||||
@@ -70,31 +43,6 @@ const props = defineProps({
|
||||
isPaidTier: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const actionLabel = computed(() => {
|
||||
if (!props.prediction) return ''
|
||||
return {
|
||||
fill_now: 'Fill up now',
|
||||
wait: 'Wait — prices falling',
|
||||
no_signal: 'No clear signal',
|
||||
}[props.prediction.action] ?? 'Check local prices'
|
||||
})
|
||||
|
||||
const actionTextColor = computed(() => {
|
||||
if (!props.prediction) return 'text-zinc-800'
|
||||
return {
|
||||
fill_now: 'text-mauve',
|
||||
wait: 'text-teal',
|
||||
}[props.prediction.action] ?? 'text-tan'
|
||||
})
|
||||
|
||||
const actionBarColor = computed(() => {
|
||||
if (!props.prediction) return 'bg-zinc-400'
|
||||
return {
|
||||
fill_now: 'bg-mauve',
|
||||
wait: 'bg-teal',
|
||||
}[props.prediction.action] ?? 'bg-tan'
|
||||
})
|
||||
|
||||
const direction = computed(() => props.prediction?.predicted_direction ?? 'stable')
|
||||
|
||||
const genericSentence = computed(() => ({
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<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 }}
|
||||
</p>
|
||||
|
||||
|
||||
@@ -19,6 +19,22 @@ export function useAuth() {
|
||||
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() {
|
||||
if (fetched.value) {
|
||||
return
|
||||
@@ -68,6 +84,10 @@ export function useAuth() {
|
||||
isAuthenticated,
|
||||
userTier,
|
||||
isPaidTier,
|
||||
subscriptionCancelled,
|
||||
subscriptionCadence,
|
||||
subscribedAt,
|
||||
subscriptionExpiresAt,
|
||||
fetchUser,
|
||||
clearUser,
|
||||
logout,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import api from '../axios.js'
|
||||
export function useStations() {
|
||||
const stations = ref([])
|
||||
const meta = ref(null)
|
||||
const prediction = ref(null)
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
@@ -12,6 +13,7 @@ export function useStations() {
|
||||
error.value = null
|
||||
stations.value = []
|
||||
meta.value = null
|
||||
prediction.value = null
|
||||
|
||||
const params = { fuel_type: fuelType, radius, sort }
|
||||
|
||||
@@ -26,6 +28,7 @@ export function useStations() {
|
||||
const response = await api.get('/stations', { params })
|
||||
stations.value = response.data.data
|
||||
meta.value = response.data.meta
|
||||
prediction.value = response.data.prediction ?? null
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.errors
|
||||
?? { general: ['Unable to load stations. Please try again.'] }
|
||||
@@ -37,9 +40,10 @@ export function useStations() {
|
||||
function reset() {
|
||||
stations.value = []
|
||||
meta.value = null
|
||||
prediction.value = null
|
||||
error.value = null
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
return { stations, meta, loading, error, search, reset }
|
||||
return { stations, meta, prediction, loading, error, search, reset }
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
<!-- Prediction box (sits above filter results) -->
|
||||
<PredictionCard
|
||||
:is-paid-tier="showFullPrediction"
|
||||
:loading="predictionLoading"
|
||||
:loading="loading"
|
||||
:prediction="prediction"
|
||||
/>
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
:radius-miles="radiusMiles"
|
||||
:stations="filteredStations"
|
||||
/>
|
||||
<UpsellBanner :station-count="liveStats.stationCount" />
|
||||
<StationList
|
||||
:current-sort="sort"
|
||||
:origin="searchOrigin"
|
||||
@@ -236,7 +237,7 @@
|
||||
</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: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> 3 Daily Price Alerts</li>
|
||||
</ul>
|
||||
@@ -385,7 +386,7 @@ import api from '../axios.js'
|
||||
import PostSearchFilters from '../components/PostSearchFilters.vue'
|
||||
import PredictionCard from '../components/PredictionCard.vue'
|
||||
import StationList from '../components/StationList.vue'
|
||||
import { usePrediction } from '../composables/usePrediction.js'
|
||||
import UpsellBanner from '../components/UpsellBanner.vue'
|
||||
|
||||
const LeafletMap = defineAsyncComponent(() => import('../components/LeafletMap.vue'))
|
||||
import LandingNav from '../components/landing/LandingNav.vue'
|
||||
@@ -395,8 +396,6 @@ import HeroSearch from '../components/landing/HeroSearch.vue'
|
||||
import StatsRow from '../components/landing/StatsRow.vue'
|
||||
|
||||
const { isAuthenticated, userTier } = useAuth()
|
||||
const { prediction, loading: predictionLoading, fetch: fetchPrediction } = usePrediction()
|
||||
const showFullPrediction = computed(() => Boolean(prediction.value) && !prediction.value.tier_locked)
|
||||
|
||||
const liveStats = ref({ stationCount: null, latestPriceAt: null })
|
||||
|
||||
@@ -446,7 +445,8 @@ const PRICES = {
|
||||
annual: { basic: '£9.90', plus: '£24.90', pro: '£39.90' },
|
||||
}
|
||||
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) => {
|
||||
if (!isLoading) return
|
||||
@@ -557,9 +557,6 @@ async function runSearch(params) {
|
||||
radiusMiles.value = params.radius ?? radiusMiles.value
|
||||
searchAttempted.value = true
|
||||
await search(params)
|
||||
const lat = meta.value?.lat ?? params.lat ?? null
|
||||
const lng = meta.value?.lng ?? params.lng ?? null
|
||||
fetchPrediction(lat && lng ? { lat, lng } : {})
|
||||
}
|
||||
|
||||
async function onSearch(params) {
|
||||
|
||||
@@ -19,11 +19,39 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<a v-if="userTier === 'free'" class="inline-block text-sm font-bold text-accent hover:underline" href="/pricing">
|
||||
Upgrade for alerts + predictions →
|
||||
</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>
|
||||
</template>
|
||||
@@ -32,7 +60,25 @@
|
||||
import { RouterLink } from 'vue-router'
|
||||
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 = [
|
||||
{ to: '/dashboard/saved-stations', label: 'Saved Stations', icon: 'lucide:bookmark', description: 'Stations you\'ve bookmarked for quick access.' },
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div v-html="setupData.svg" class="[&_svg]:w-40 [&_svg]:h-40"></div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
use App\Enums\FuelType;
|
||||
use App\Http\Controllers\Api\AuthController;
|
||||
use App\Http\Controllers\Api\PredictionController;
|
||||
use App\Http\Controllers\Api\StationController;
|
||||
use App\Http\Controllers\Api\StatsController;
|
||||
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::get('/stations', [StationController::class, 'index']);
|
||||
Route::get('/stats/searches', [StatsController::class, 'searches']);
|
||||
Route::get('/prediction', [PredictionController::class, 'index']);
|
||||
});
|
||||
|
||||
// Sanctum-authenticated endpoints
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Plan;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
@@ -68,6 +69,146 @@ it('returns the authenticated user on /me', function () {
|
||||
->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 () {
|
||||
$user = User::factory()->create();
|
||||
$token = $user->createToken('api')->plainTextToken;
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\FuelType;
|
||||
use App\Filament\Resources\UserResource;
|
||||
use App\Models\Station;
|
||||
use App\Models\StationPriceCurrent;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->withHeaders(['X-Api-Key' => config('app.api_secret_key')]);
|
||||
$this->artisan('db:seed', ['--class' => 'PlanSeeder']);
|
||||
});
|
||||
|
||||
function actAsTier(string $tier): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
if ($tier !== 'free') {
|
||||
UserResource::applyTier($user, $tier, 'monthly');
|
||||
}
|
||||
|
||||
test()->actingAs($user->fresh());
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
it('returns the full payload for plus users', function () {
|
||||
actAsTier('plus');
|
||||
|
||||
$this->getJson('/api/prediction')
|
||||
->assertOk()
|
||||
->assertJsonStructure(['fuel_type', 'reasoning', 'signals'])
|
||||
->assertJsonMissingPath('tier_locked');
|
||||
});
|
||||
|
||||
it('returns the full payload for pro users', function () {
|
||||
actAsTier('pro');
|
||||
|
||||
$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('returns only direction + tier_locked flag for guests', function () {
|
||||
$response = $this->getJson('/api/prediction')->assertOk();
|
||||
|
||||
expect($response->json())
|
||||
->toHaveKey('fuel_type')
|
||||
->toHaveKey('predicted_direction')
|
||||
->toHaveKey('tier_locked', true)
|
||||
->not->toHaveKey('current_avg')
|
||||
->not->toHaveKey('reasoning')
|
||||
->not->toHaveKey('signals');
|
||||
});
|
||||
|
||||
it('returns the trimmed payload for free users', function () {
|
||||
actAsTier('free');
|
||||
|
||||
$this->getJson('/api/prediction')
|
||||
->assertOk()
|
||||
->assertJsonPath('tier_locked', true)
|
||||
->assertJsonMissing(['signals' => []])
|
||||
->assertJsonMissingPath('reasoning');
|
||||
});
|
||||
|
||||
it('returns the trimmed payload for basic users', function () {
|
||||
actAsTier('basic');
|
||||
|
||||
$this->getJson('/api/prediction')
|
||||
->assertOk()
|
||||
->assertJsonPath('tier_locked', true)
|
||||
->assertJsonMissingPath('reasoning');
|
||||
});
|
||||
|
||||
it('includes current average from live prices for pro users', function () {
|
||||
actAsTier('pro');
|
||||
|
||||
$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 to pro users', function () {
|
||||
actAsTier('pro');
|
||||
|
||||
$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 for pro users', function () {
|
||||
actAsTier('pro');
|
||||
|
||||
$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']);
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\FuelType;
|
||||
use App\Filament\Resources\UserResource;
|
||||
use App\Models\Station;
|
||||
use App\Models\StationPriceCurrent;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
@@ -12,6 +14,15 @@ beforeEach(function () {
|
||||
$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 () {
|
||||
$station = Station::factory()->create(['lat' => 52.555064, 'lng' => -0.256119]);
|
||||
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.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');
|
||||
});
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use App\Jobs\SendPaymentFailedReminderJob;
|
||||
use App\Listeners\HandleStripeWebhook;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\User;
|
||||
use App\Models\UserNotificationPreference;
|
||||
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);
|
||||
});
|
||||
|
||||
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 {
|
||||
Queue::fake();
|
||||
$existingGrace = now()->addDays(3)->startOfSecond();
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Models\StationPrice;
|
||||
use App\Models\StationPriceCurrent;
|
||||
use App\Services\NationalFuelPredictionService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@@ -78,14 +79,96 @@ it('includes all required keys in response', function () {
|
||||
'fuel_type', 'current_avg', 'predicted_direction', 'predicted_change_pence',
|
||||
'confidence_score', 'confidence_label', 'action', 'reasoning',
|
||||
'prediction_horizon_days', 'region_key', 'methodology',
|
||||
'signals',
|
||||
'weekly_summary', 'signals',
|
||||
])
|
||||
->and($result['signals'])->toHaveKeys([
|
||||
'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 () {
|
||||
$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
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user