Compare commits
4 Commits
3224b186b2
...
088fd11058
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
088fd11058 | ||
|
|
ee6de23709 | ||
|
|
2ff3aeba4d | ||
|
|
b8adb81c79 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -23,3 +23,4 @@ yarn-error.log
|
|||||||
/.zed
|
/.zed
|
||||||
/.tmp/
|
/.tmp/
|
||||||
/.worktrees/
|
/.worktrees/
|
||||||
|
/ONSPD_Online_Latest_Centroids_*.csv
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
],
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
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();
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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() {
|
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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.' },
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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']);
|
|
||||||
});
|
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -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\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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user