Add comprehensive project documentation and architecture guidelines
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled

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.
This commit is contained in:
Ovidiu U
2026-04-03 16:49:19 +01:00
parent c94c4f7beb
commit 02d4c9d888
11 changed files with 644 additions and 0 deletions

33
CLAUDE.local.md Normal file
View File

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

38
CLAUDE.md Normal file
View File

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

68
api-data.md Normal file
View File

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

53
architecture.md Normal file
View File

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

47
code-style.md Normal file
View File

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

98
database.md Normal file
View File

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

61
livewire.md Normal file
View File

@@ -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
<div
x-data="stationMap(@entangle('stations'))"
id="map"
style="height: 400px"
></div>
```
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"
---

68
notifications.md Normal file
View File

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

54
payments.md Normal file
View File

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

76
scoring.md Normal file
View File

@@ -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 70100: strong signal → fire recommendation + notification
- Score 4069: weak signal → show in dashboard only, no push/SMS/WhatsApp
- Score 039: 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."

48
testing.md Normal file
View File

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