From 02d4c9d888c7fb812a26ad8d76bb8c14cd4250ff Mon Sep 17 00:00:00 2001 From: Ovidiu U Date: Fri, 3 Apr 2026 16:49:19 +0100 Subject: [PATCH] Add comprehensive project documentation and architecture guidelines Establishes core rules and conventions for the FuelAlert Laravel application: architecture patterns (fat services, thin controllers), database schema with partitioned station_prices table, multi-tier notification system with Vonage and OneSignal, 4-signal scoring engine for fuel price recommendations, Stripe subscription tiers, Livewire classic component structure, and Pest testing standards. --- CLAUDE.local.md | 33 ++++++++++++++++ CLAUDE.md | 38 +++++++++++++++++++ api-data.md | 68 +++++++++++++++++++++++++++++++++ architecture.md | 53 ++++++++++++++++++++++++++ code-style.md | 47 +++++++++++++++++++++++ database.md | 98 ++++++++++++++++++++++++++++++++++++++++++++++++ livewire.md | 61 ++++++++++++++++++++++++++++++ notifications.md | 68 +++++++++++++++++++++++++++++++++ payments.md | 54 ++++++++++++++++++++++++++ scoring.md | 76 +++++++++++++++++++++++++++++++++++++ testing.md | 48 ++++++++++++++++++++++++ 11 files changed, 644 insertions(+) create mode 100644 CLAUDE.local.md create mode 100644 CLAUDE.md create mode 100644 api-data.md create mode 100644 architecture.md create mode 100644 code-style.md create mode 100644 database.md create mode 100644 livewire.md create mode 100644 notifications.md create mode 100644 payments.md create mode 100644 scoring.md create mode 100644 testing.md diff --git a/CLAUDE.local.md b/CLAUDE.local.md new file mode 100644 index 0000000..72ee0ae --- /dev/null +++ b/CLAUDE.local.md @@ -0,0 +1,33 @@ +# Local overrides (gitignored — personal only) + +## Local environment + +- PhpStorm with SFTP sync to Proxmox LXC (CT 150, 192.168.1.150) +- MySQL on IONOS VPS: 192.168.x.x (update with actual) +- Local dev URL: http://fuel-alert.test (Valet) or http://localhost:8000 + +## Test credentials (local only) + +- Stripe test publishable key: pk_test_... +- Fuel Finder sandbox credentials (if available): see 1Password +- Vonage test account: see 1Password +- OneSignal test app: see 1Password + +## Deployment + +- Production: IONOS VPS behind Traefik (same setup as uovidiu.com portfolio) +- Deploy: git push → SSH → composer install --no-dev → php artisan migrate --force → php artisan queue:restart +- Redis: Docker container on IONOS VPS + +## Useful local commands + +```bash +# Manually trigger fuel price poll +php artisan app:poll-fuel-prices + +# Run scoring for a specific user +php artisan app:score-user {user_id} + +# Clear scored results cache +php artisan cache:forget scoring_results +``` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c703173 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,38 @@ +# FuelAlert — Claude Code Instructions + +UK fuel price intelligence app. Subscribers receive fill-up timing recommendations +based on local price trends. Built solo by a PHP/Laravel developer. + +## Project overview + +- **Product**: "Fill up now or wait?" — local fuel price trend scoring for UK drivers +- **Monetisation**: £0/mo free, £0.99/mo Basic, £2.49/mo Plus, £3.99/mo Pro +- **Stack**: Laravel 11 + Livewire 3 (Volt disabled — use classic components) + Alpine.js + Tailwind CSS +- **Database**: MySQL — Eloquent ORM, migrations only (no raw DDL) +- **Payments**: Stripe via Laravel Cashier +- **Notifications**: Laravel Notification channels — email, WhatsApp (Vonage), SMS (Vonage), push (OneSignal) +- **Queue**: Laravel queues with Redis driver (notifications and polling jobs) +- **Scheduler**: Laravel scheduler for Fuel Finder API polling and scoring + +## Key commands + +```bash +php artisan serve # Local dev server +php artisan queue:work # Process notification jobs +php artisan schedule:run # Run scheduled commands (cron every minute) +php artisan migrate # Run migrations +php artisan test # Run Pest test suite +npm run dev # Vite asset watcher +``` + +## Imports + +@.claude/rules/architecture.md +@.claude/rules/database.md +@.claude/rules/notifications.md +@.claude/rules/scoring.md +@.claude/rules/payments.md +@.claude/rules/livewire.md +@.claude/rules/api-data.md +@.claude/rules/testing.md +@.claude/rules/code-style.md diff --git a/api-data.md b/api-data.md new file mode 100644 index 0000000..099cdbb --- /dev/null +++ b/api-data.md @@ -0,0 +1,68 @@ +# External API & Data Sources + +## UK Fuel Finder API (gov.uk) — PRIMARY SOURCE + +- Base URL: `https://api.fuel-finder.service.gov.uk/` +- Auth: OAuth 2.0 client credentials (client_id + client_secret → Bearer token) +- Token stored in cache with TTL matching expiry minus 60 seconds +- Returns: all UK station prices + station metadata +- Update frequency: stations report within 30 minutes of price change +- Our polling interval: every 15 minutes via scheduler + +### FuelPriceService responsibilities +1. Fetch OAuth token (cache it) +2. GET all station prices +3. Upsert `stations` table with metadata +4. Insert new rows into `station_prices` only when price has changed for that station+fuel combo +5. Call StationTaggingService to set `is_supermarket` and `brand` +6. Dispatch `PricesUpdatedEvent` for downstream processing + +### Deduplication +Only insert a new `station_prices` row if price differs from the most recent stored price +for that `(station_id, fuel_type)` combination. Avoids row explosion on unchanged prices. + +### Credentials in .env +``` +FUEL_FINDER_CLIENT_ID= +FUEL_FINDER_CLIENT_SECRET= +FUEL_FINDER_BASE_URL=https://api.fuel-finder.service.gov.uk +``` + +## Postcodes.io — postcode → lat/lng + +- URL: `https://api.postcodes.io/postcodes/{postcode}` +- Free, no API key required +- Called once on user registration / when postcode changes +- Store resolved `lat` + `lng` on `users` table +- Cache postcode lookups for 30 days (postcodes rarely change coordinates) + +## FRED API (St. Louis Fed) — Brent crude direction + +- Series: `DCOILBRENTEU` (daily Brent spot price) +- URL: `https://api.stlouisfed.org/fred/series/observations?series_id=DCOILBRENTEU&api_key={key}&sort_order=desc&limit=10&file_type=json` +- Free API key required — stored as `FRED_API_KEY` in .env +- Fetched once daily via scheduler at 7am +- Stored in `brent_prices` table: `(date DATE, price_usd DECIMAL(8,2))` +- Only the 5-day trend direction is used by the scoring engine + +## OneSignal — push notifications + +- REST API: `https://oapi.onesignal.com/notifications` +- App ID + REST API key stored in .env as `ONESIGNAL_APP_ID`, `ONESIGNAL_API_KEY` +- Target by `player_id` (stored in `users.push_token`) +- No official Laravel package needed — use Laravel HTTP client (`Http::post(...)`) +- Free plan: 10,000 subscribers — sufficient for v1 + +## Vonage — WhatsApp + SMS + +- Package: `vonage/client-core` via Composer +- Credentials: `VONAGE_KEY`, `VONAGE_SECRET`, `VONAGE_WHATSAPP_FROM` in .env +- WhatsApp: Messages API, utility template category (pre-approved) +- SMS: SMS API, alphanumeric sender ID "FuelAlert" +- All Vonage calls go through NotificationDispatchService — never call Vonage directly from components + +## HTTP client + +Use Laravel's built-in `Http` facade for all external API calls. +Always set a timeout: `Http::timeout(10)->get(...)`. +Wrap in try/catch — log failures, never let a failed API call crash the scheduler. diff --git a/architecture.md b/architecture.md new file mode 100644 index 0000000..351d8b2 --- /dev/null +++ b/architecture.md @@ -0,0 +1,53 @@ +# Architecture + +## Core principle: fat Services, thin everything else + +All business logic lives in Service classes. Controllers, Livewire components, +and console commands are thin orchestrators — they call Services and return results. +This keeps the app API-extractable later without a rewrite. + +## Directory structure + +``` +app/ +├── Console/Commands/ # Scheduler commands (PollFuelPrices, RunScoringEngine) +├── Http/Controllers/ # Minimal — auth + Stripe webhook only +├── Livewire/ # Classic two-file Livewire components +├── Models/ # Eloquent models +├── Notifications/ # Laravel Notification classes (multi-channel) +├── Services/ # ALL business logic lives here +│ ├── FuelPriceService.php # Fuel Finder API polling + storage +│ ├── AlertScoringService.php # Fill-up timing recommendation engine +│ ├── StationTaggingService.php # Supermarket brand detection +│ ├── NotificationDispatchService.php # Tier-aware notification routing +│ └── SubscriptionService.php # Cashier/tier helpers +└── Jobs/ # Queued jobs (dispatch notifications per user) + +resources/views/ +├── livewire/ # Livewire Blade templates +└── emails/ # Mailable templates + +routes/ +├── web.php # All web routes (Livewire pages) +└── api.php # Empty for now — API added later if needed +``` + +## Service class conventions + +- Constructor injection only — no facade usage inside Services +- Services are bound in AppServiceProvider if they need interfaces +- Each Service has one responsibility — do not merge concerns +- Return typed DTOs or Eloquent collections — never raw arrays from Services +- Services never dispatch jobs directly — that's the controller/command's job + +## No API yet + +`routes/api.php` stays empty for v1. Do not create API controllers or Sanctum +token auth. The app is Livewire-only until subscriber count justifies a native app. +When the API is added later, it will reuse the same Service classes. + +## Livewire components (classic only) + +Use two-file classic Livewire components. Do NOT use Volt single-file syntax. +Volt files from the starter kit (auth screens) are left as-is — do not convert them. +New components go in `app/Livewire/` with corresponding Blade in `resources/views/livewire/`. diff --git a/code-style.md b/code-style.md new file mode 100644 index 0000000..1600945 --- /dev/null +++ b/code-style.md @@ -0,0 +1,47 @@ +# Code Style + +## PHP + +- PHP 8.2+ features: constructor property promotion, readonly properties, enums, match expressions +- Type declarations on all method parameters and return types — no untyped signatures +- Named arguments for clarity on calls with 3+ parameters +- Enums over string constants: `FuelType::E10`, `Recommendation::Wait`, `NotificationChannel::WhatsApp` +- `final` classes for Services and Jobs — they are not designed for extension + +## Laravel conventions + +- Route model binding over manual `findOrFail()` in controllers +- Form Requests for any validated input +- `config()` helper over `env()` outside of config files — never call `env()` in app code +- Events + Listeners for side effects (e.g. `PricesUpdatedEvent` triggers scoring) +- Do not use `DB::transaction()` manually — wrap in Service methods where needed + +## Naming + +- Services: `{Noun}Service` — e.g. `AlertScoringService` +- Jobs: `{Verb}{Noun}Job` — e.g. `SendFuelAlertJob`, `PollFuelPricesJob` +- Events: `{Noun}{PastTense}Event` — e.g. `PricesUpdatedEvent` +- Livewire components: `{Context}/{Feature}` — e.g. `Dashboard/FuelRecommendation` +- Migrations: descriptive, e.g. `create_station_prices_table`, `add_whatsapp_fields_to_users_table` + +## Formatting + +- 4-space indentation (PSR-12) +- Max line length: 120 characters +- Run `./vendor/bin/pint` before committing (Laravel Pint, default ruleset) +- No commented-out code committed — use git to recover old code + +## Security + +- Never store raw passwords — Bcrypt via Laravel's Hash facade only +- Validate and sanitise all user input — postcodes, phone numbers, fuel type selections +- Phone numbers: strip non-numeric characters, enforce UK format (07xxx or +447xxx) +- OTP codes: 6 digits, expire in 10 minutes, single use only +- All Stripe webhook payloads verified via Cashier's built-in signature check + +## Environment + +- Local: Laravel Valet or `php artisan serve` +- Production: IONOS VPS, Nginx + PHP-FPM, MySQL, Redis +- Never commit `.env` — `.env.example` must stay up to date with all required keys +- Queue driver: `redis` in production, `sync` in testing only diff --git a/database.md b/database.md new file mode 100644 index 0000000..1fcf512 --- /dev/null +++ b/database.md @@ -0,0 +1,98 @@ +# Database + +## Engine and conventions + +- MySQL with InnoDB. All schema changes via Laravel migrations only — no raw DDL. +- All models use Eloquent. No raw DB:: queries except for complex aggregations. +- Table names: plural snake_case. Column names: snake_case. +- Always define `$fillable` or `$guarded` on models — never leave both empty. +- Use `->comment()` on migration columns for non-obvious fields. + +## Core tables + +### users +Standard Laravel users table + additions: +- `postcode` VARCHAR(8) — user's home postcode, used for nearby station lookup +- `lat` / `lng` DECIMAL(10,7) — resolved from postcode via postcodes.io on registration +- `whatsapp_number` VARCHAR(20) NULLABLE — verified mobile, set after OTP flow +- `whatsapp_verified_at` TIMESTAMP NULLABLE +- `push_token` VARCHAR(255) NULLABLE — OneSignal player ID + +### subscriptions +Managed by Laravel Cashier — do not hand-edit this table. +Tier is read via `$user->subscribed('basic')`, `->subscribed('plus')`, `->subscribed('pro')`. + +### station_prices (HIGH VOLUME — partitioned) +``` +id BIGINT UNSIGNED AUTO_INCREMENT +station_id VARCHAR(64) — Fuel Finder API station identifier +fuel_type ENUM('e10','e5','diesel','super_diesel','b10','hvo') +price_pence SMALLINT UNSIGNED — e.g. 14523 = 145.23p (store as integer × 100) +is_supermarket TINYINT(1) DEFAULT 0 +brand VARCHAR(64) NULLABLE +recorded_at DATETIME + +INDEX (station_id, fuel_type, recorded_at) +INDEX (recorded_at) +PARTITION BY RANGE (YEAR(recorded_at) * 100 + MONTH(recorded_at)) +``` +Partition monthly. Use `recorded_at` not `created_at` — it reflects actual price time. +Never store floats for prices — always pence as SMALLINT (price × 100). + +### stations (reference/cache) +``` +station_id VARCHAR(64) PRIMARY KEY — from Fuel Finder API +name VARCHAR(128) +brand VARCHAR(64) NULLABLE +lat DECIMAL(10,7) +lng DECIMAL(10,7) +postcode VARCHAR(8) +is_supermarket TINYINT(1) DEFAULT 0 +amenities JSON NULLABLE +last_seen_at DATETIME +``` +Refreshed on each Fuel Finder poll. `is_supermarket` set by StationTaggingService. + +### alerts (sent notification log) +``` +id BIGINT UNSIGNED AUTO_INCREMENT +user_id BIGINT UNSIGNED FK users.id +alert_type ENUM('fill_up_now','wait','watchdog','price_drop') +channel ENUM('email','whatsapp','sms','push') +signal_strength TINYINT — 1=weak, 2=medium, 3=strong +payload JSON — recommendation data snapshot +sent_at DATETIME +INDEX (user_id, sent_at) +``` + +### scoring_results (daily per-user cache) +``` +user_id BIGINT UNSIGNED FK users.id +scored_at DATE +recommendation ENUM('fill_up','wait','no_signal') +confidence TINYINT — 0–100 +signals JSON — breakdown of each signal used +local_avg_pence SMALLINT UNSIGNED +trend_direction ENUM('falling','rising','flat') +expires_at DATETIME +PRIMARY KEY (user_id, scored_at) +``` + +### otp_verifications +``` +id BIGINT UNSIGNED AUTO_INCREMENT +user_id BIGINT UNSIGNED FK users.id +channel ENUM('whatsapp','sms') +phone_number VARCHAR(20) +code CHAR(6) +expires_at DATETIME +used_at DATETIME NULLABLE +INDEX (user_id, expires_at) +``` +OTP codes expire after 10 minutes. Mark `used_at` on success — never delete rows. + +## Supermarket brands (StationTaggingService) + +Match station `name` (case-insensitive) against: +`['tesco', 'asda', 'morrisons', 'sainsbury', 'aldi', 'lidl', 'costco']` +Set `is_supermarket = 1` and normalise `brand` on match. diff --git a/livewire.md b/livewire.md new file mode 100644 index 0000000..671942b --- /dev/null +++ b/livewire.md @@ -0,0 +1,61 @@ +# Livewire Components + +## Classic two-file components only + +Do NOT use Volt single-file syntax for new components. +Volt files created by the Livewire starter kit (auth screens) are left untouched. + +## Component locations + +``` +app/Livewire/ +├── Dashboard/ +│ ├── FuelRecommendation.php # Main fill-up/wait card +│ ├── NearbyStations.php # Map + station list +│ └── PriceHistory.php # 14-day trend chart +├── Account/ +│ ├── NotificationSettings.php # Channel prefs + WhatsApp OTP +│ ├── SubscriptionManager.php # Upgrade/downgrade UI +│ └── FuelPreferences.php # Fuel type, postcode +└── Public/ + └── PriceWatchdog.php # Public-facing local price watchdog +``` + +## Component conventions + +- Component properties that are shown in the view must be `public` +- Use `#[Computed]` attribute for derived values — not re-computed on every render +- Validate with `#[Validate]` attribute on properties, not in separate rules array +- Never put Service instantiation in the component — inject via method parameter or mount() +- Dispatch browser events with `$this->dispatch()` not `$this->emit()` (Livewire 3 syntax) +- Use `wire:loading` on all buttons that trigger server actions + +## Alpine.js usage + +Alpine.js handles local UI state only: dropdowns, modals, tab switching, copy-to-clipboard. +Do not replicate server state in Alpine — use Livewire for anything that needs PHP. +Alpine components stay inline in Blade (`x-data="{}"`), not in separate JS files unless reused 3+ times. + +## Map (Leaflet.js) + +Leaflet is a plain JS drop-in, not a Livewire component. +Station data is fetched from a dedicated Livewire endpoint and passed to Leaflet via Alpine: +```blade +
+``` +Map initialisation lives in `resources/js/maps/station-map.js`. + +## Page routing + +Livewire full-page components are mounted in `routes/web.php` using `Route::get()->component()`. +No separate view files for pages — the Livewire component IS the page. + +--- +paths: + - "app/Livewire/**/*.php" + - "resources/views/livewire/**/*.blade.php" +--- diff --git a/notifications.md b/notifications.md new file mode 100644 index 0000000..2ed93c9 --- /dev/null +++ b/notifications.md @@ -0,0 +1,68 @@ +# Notifications + +## Tier-to-channel mapping + +| Tier | Price | Email | Push (PWA) | WhatsApp | SMS | +|------------|--------|-------|------------|----------|-----------| +| Free | £0 | ✓ weekly digest | ✗ | ✗ | ✗ | +| Basic | £0.99 | ✓ daily | ✓ daily | ✓ daily | ✗ | +| Plus | £2.49 | ✓ | ✓ | ✓ | ✓ max 1/day triggered | +| Pro | £3.99 | ✓ | ✓ | ✓ | ✓ max 3/day triggered | + +## NotificationDispatchService + +Reads user tier via Cashier, returns allowed channels: +```php +public function channelsFor(User $user): array +// Returns e.g. ['mail', 'vonage-whatsapp'] for Basic tier +// Returns ['mail', 'vonage-whatsapp', 'vonage-sms'] for Plus+ +``` + +Never fire SMS unless confidence ≥ 70 AND user is Plus/Pro. +Never fire more than 1 notification per user per day unless they are Pro tier. +Log every sent notification to `alerts` table. + +## Laravel Notification class + +One `FuelPriceAlert` notification class with `via()` returning channels based on tier. +Subject line / message copy adapts based on `recommendation` and `confidence`. + +## WhatsApp (Vonage) + +- Provider: Vonage Messages API (WhatsApp channel) +- PHP package: `vonage/client` +- Template: utility category (one-way alert) — pre-approved template required by Meta +- Opt-in: phone number + OTP flow (see otp_verifications table) +- User must have `whatsapp_verified_at` set — never send to unverified numbers +- Cost: ~0.3–0.5p per message (utility rate) +- Message format: keep under 160 chars. Include station name, price, and recommendation reason. + +## SMS (Vonage — Plus/Pro only) + +- Same Vonage client, SMS channel +- Triggered only (not daily) — fires when signal strength ≥ 2 AND price event warrants it +- Plus: max 8 SMS/month (enforced via alerts table count) +- Pro: max 30 SMS/month +- Cost: ~3.5p per message UK + +## Email + +- Driver: Mailgun (configured in .env) +- Free tier: weekly digest every Monday 8am +- Basic+: daily summary if recommendation is not `no_signal` +- Use Mailable classes, not raw Mail::send +- Templates in `resources/views/emails/` + +## Push notifications (OneSignal) + +- Free plan: up to 10,000 subscribers +- Player ID stored in `users.push_token` +- Use OneSignal REST API — no official Laravel package needed, simple HTTP call +- PWA service worker setup required (separate task) +- Basic+: daily push alongside email + +## Queue + +All notifications are dispatched as queued jobs — never send synchronously. +Use `FuelAlertNotificationJob` dispatched per user. +Queue: `notifications` (separate from default queue for priority control). diff --git a/payments.md b/payments.md new file mode 100644 index 0000000..041ea90 --- /dev/null +++ b/payments.md @@ -0,0 +1,54 @@ +# Payments & Subscriptions + +## Stack + +Laravel Cashier (Stripe). Never implement custom billing logic — use Cashier methods. + +## Stripe products + +Three recurring subscription products (monthly): +- `basic` — £0.99/mo +- `plus` — £2.49/mo +- `pro` — £3.99/mo + +Price IDs stored in `config/services.php` under `stripe.prices.*`, loaded from .env: +``` +STRIPE_PRICES_BASIC=price_xxx +STRIPE_PRICES_PLUS=price_xxx +STRIPE_PRICES_PRO=price_xxx +``` + +## Tier helpers (SubscriptionService) + +```php +public function tier(User $user): string +// Returns 'free' | 'basic' | 'plus' | 'pro' + +public function canReceiveSms(User $user): bool +// true if tier is plus or pro + +public function smsRemainingThisMonth(User $user): int +// checks alerts table count for current month +``` + +Never check tier inline in components or notification classes — always use SubscriptionService. + +## Cashier conventions + +- Billable model: `User` (add `use Billable` trait) +- Webhook route: `POST /stripe/webhook` — handled by Cashier automatically +- Webhook secret in `.env` as `STRIPE_WEBHOOK_SECRET` +- Always handle `customer.subscription.deleted` to downgrade user to free tier +- Trial: none for v1 + +## Upgrade / downgrade flow + +- User upgrades in account settings Livewire component +- Swap plan with `$user->subscription()->swap($newPriceId)` +- Cashier handles proration automatically +- On downgrade to free: cancel subscription, remove WhatsApp/SMS notification preference + +## Stripe test mode + +Use Stripe test keys in local `.env`. Never commit real Stripe keys. +Test cards: 4242424242424242 (success), 4000000000000002 (decline). diff --git a/scoring.md b/scoring.md new file mode 100644 index 0000000..c80b5ff --- /dev/null +++ b/scoring.md @@ -0,0 +1,76 @@ +# Scoring Engine (AlertScoringService) + +## Purpose + +Produces a "fill up now or wait?" recommendation per user based on their local +station history. Output is one of: `fill_up`, `wait`, `no_signal`. +Never guess — stay silent (no_signal) when signals conflict or data is insufficient. + +## The 4 signals (in priority order) + +### Signal 1 — Local price trend (HIGHEST WEIGHT) +- Query `station_prices` for user's nearest 5 stations (within 5km of user lat/lng) +- Use last 14 days of history for `e10` (or user's preferred fuel type) +- Calculate 3-day rolling average vs 7-day rolling average +- **Falling**: 3-day avg < 7-day avg by ≥ 0.5p → positive wait signal +- **Rising**: 3-day avg > 7-day avg by ≥ 0.5p → fill_up signal +- **Flat**: difference < 0.5p → neutral, no signal +- Weight: 40 points max + +### Signal 2 — Supermarket anchor effect (HIGH WEIGHT) +- Find nearest supermarket station (is_supermarket = 1) within 10km +- Check if supermarket cut price in last 48 hours (> 1p drop) +- Check if nearest non-supermarket stations have NOT yet followed +- If supermarket cut AND independents haven't moved → strong wait signal +- Weight: 35 points max + +### Signal 3 — Day-of-week pattern (MEDIUM WEIGHT — needs 8+ weeks data) +- Per station: average price by day-of-week over last 90 days +- Only activate if station has 56+ days of history +- If today is statistically 1.5p+ cheaper than weekly average → mild fill_up +- If today is statistically 1.5p+ more expensive → mild wait +- Weight: 15 points max + +### Signal 4 — Brent crude direction (LOW WEIGHT) +- Fetched daily from FRED API, stored in a simple `brent_prices` table +- 5-day trend: rising ≥ 3% → mild fill_up pressure; falling ≥ 3% → mild wait +- Weight: 10 points max + +## Confidence thresholds + +- Score 70–100: strong signal → fire recommendation + notification +- Score 40–69: weak signal → show in dashboard only, no push/SMS/WhatsApp +- Score 0–39: no_signal → stay silent entirely + +**Only send notifications when confidence ≥ 70.** Never spam. + +## Output (stored in scoring_results) + +```php +[ + 'recommendation' => 'wait', // fill_up | wait | no_signal + 'confidence' => 78, // 0-100 + 'signals' => [ + 'trend' => ['direction' => 'falling', 'points' => 32], + 'supermarket' => ['triggered' => true, 'points' => 35], + 'day_pattern' => ['triggered' => false, 'points' => 0], + 'brent' => ['direction' => 'flat', 'points' => 0], + ], + 'local_avg_pence' => 14380, // 143.80p + 'trend_delta' => -2.3, // pence change over 7 days +] +``` + +## Human-readable reason strings + +Always generate a plain-English reason for the recommendation: +- "Prices near you have been falling for 6 days. Tesco {station} cut 3p yesterday — independents usually follow within 48 hours." +- "Prices are rising in your area — filling up today avoids paying more later." +- "No clear pattern this week — fill up at the cheapest station near you now." + +Reason strings are stored in `scoring_results.signals` JSON and shown in the UI and notifications. + +## Accuracy self-tracking + +After 3 days, check if `wait` recommendation was correct (prices did fall further). +Store outcome in `scoring_results` for future display: "This signal has been right X% of the time in your area." diff --git a/testing.md b/testing.md new file mode 100644 index 0000000..731cd46 --- /dev/null +++ b/testing.md @@ -0,0 +1,48 @@ +# Testing + +## Framework + +Pest PHP. Never use PHPUnit test classes directly — always Pest syntax. + +## Test structure + +``` +tests/ +├── Feature/ +│ ├── Scoring/AlertScoringServiceTest.php +│ ├── Notifications/NotificationDispatchTest.php +│ ├── Payments/SubscriptionTest.php +│ └── Livewire/DashboardTest.php +└── Unit/ + ├── Services/FuelPriceServiceTest.php + └── Services/StationTaggingServiceTest.php +``` + +## Conventions + +- Use `RefreshDatabase` trait on Feature tests +- Factory-first: all test data via Eloquent factories — never raw DB inserts +- Mock external APIs (Fuel Finder, Vonage, OneSignal, FRED) — never hit live APIs in tests +- Use `Http::fake()` for all outbound HTTP in tests +- Stripe: use Cashier's built-in test helpers, never hit Stripe API in tests + +## What to test + +- AlertScoringService: all signal combinations, confidence thresholds, no_signal cases +- NotificationDispatchService: correct channels returned per tier +- SubscriptionService: tier detection, SMS limit counting +- Livewire components: use `Livewire::test()` for interaction testing +- Stripe webhooks: test `customer.subscription.deleted` downgrades user correctly + +## Running tests + +```bash +php artisan test # Full suite +php artisan test --filter=Scoring # Single test class +php artisan test --parallel # Parallel (faster) +``` + +--- +paths: + - "tests/**/*.php" +---