Created: 2026-02-14 | Updated: 2026-02-20 | Phase 1 WUI implemented: 2026-02-18 | Phase 2 implemented: 2026-02-20
Car Pulse Tracker v1 asks users to pay before connecting their vehicle — one car at a time, with a manual model selection step that duplicates what OAuth already knows. A fleet owner with 10 Teslas repeats the entire flow 10 times.
The vehicle data industry has moved past this. Services like Tessie, Recurrent, and Teslascope all follow a connect-first pattern: the user authorizes their account, sees their vehicles, then decides what to buy. Smartcar standardized this into a multi-brand OAuth flow used by 40+ manufacturers. SaaS onboarding research (Userpilot, Baymard Institute) confirms that users who see value before paying convert at significantly higher rates.
v2 adopts connect-first for one-time report purchases: connect your car, see what you have, pick a package, pay once, get reports for all selected vehicles.
v1: Landing → Brand/Model → Package → Payment → Connect → Report
v2: Landing → Brand → Connect → Vehicles → Package → Payment → Reports
Key changes from v1: model selection removed (OAuth discovers vehicles), OAuth moves before payment (value-first), multi-vehicle support added.
Step 1 — Landing: Hero, supported brands, how it works, pricing overview.
Step 2 — Select Brand: User picks Tesla (or future brands). Determines which OAuth provider. Model selection is gone — OAuth reveals actual vehicles.
Step 3 — Connect (OAuth): User authorizes Car Pulse Tracker. Backend exchanges code, fetches vehicle inventory (GET /api/1/vehicles — lightweight, no car wake), creates OrderSession.
Step 4 — Select Vehicles (new): Shows all vehicles on the account. Each card shows display name, model, masked VIN, state (online/offline/asleep). User checks which ones to report on. "Select All" for fleet users. If only 1 vehicle, auto-selected.
Note: no odometer or battery shown here — that requires vehicle_data which wakes the car. Only inventory data.
Step 5 — Select Package + Quote: Three packages (Basic, Pro, Expert — tier pricing based on vehicle count, see §7 CPQ). User picks one package for all vehicles. System shows quote with tier price, optional voucher input, VAT breakdown.
Step 6 — Payment: Stripe / PayPal / Google Pay / Apple Pay. Single transaction for the total.
Step 7 — Generate Reports: Backend calls Tesla API endpoints per the selected package, for each selected vehicle. Dashboard shows progress. PDF download per vehicle.
GET /api/1/vehicles) — lightweight, no wake.Browser/WUI CPT API Tesla Fleet API
----------- ------- ---------------
Click Connect -> POST /tesla/oauth/initiate
<- {authUrl}
Redirect to Tesla -> Tesla authorize page
Callback to API -> GET /tesla/oauth/callback?code&state
-> Exchange code for token
-> GET /api/1/vehicles (inventory only)
<- vehicles[]
-> Create OrderSession(status=connected, token, vehicles)
<- Redirect to WUI ?oauth=success&session_id=...
Load vehicle list -> GET /tesla/session/{session_id}/vehicles
<- vehicles[] for user selection
Select + package -> POST /tesla/session/{session_id}/select
<- {status: vehicles_selected}
Price quote -> POST /pricing/quote {session_id, voucher_code?}
<- {tier_price, voucher, vat, total, currency}
Pay -> POST /payment/create-intent {session_id, method}
Provider callback -> POST /payment/verify
<- payment succeeded
Generate reports -> POST /reports/generate {session_id}
-> For each selected vehicle (endpoints per package):
Basic: vehicle_data only
Pro: + charging, alerts, service, warranty, release_notes, options
Expert: + vehicle_specs ($0.10)
<- report artifacts per vehicle
Fetch reports -> GET /tesla/report/{session_id}
<- {status: complete, reports: {vehicle_id: report}}
v1 has no session. The OAuth state carries paymentId and the report is built inside the callback. v2 needs a server-side session that ties the entire flow together.
Current v1 storage (in TeslaFleetService):
- _states: dict — OAuth CSRF state, keyed by random string, 15 min TTL
- _reports: dict — pre-built reports, keyed by session_id, 15 min TTL, one-time retrieval
v2 replacement — OrderSession:
@dataclass
class OrderSession:
session_id: str # secrets.token_urlsafe(32)
created_at: datetime # UTC
expires_at: datetime # created_at + 30 min
status: str # connected | vehicles_selected | paid | generating | complete
# OAuth (set at callback)
access_token: str # Tesla user token, encrypted at rest
refresh_token: str # for token refresh if TTL is tight
token_expires_at: datetime
# Vehicle inventory (set at callback, from GET /api/1/vehicles)
vehicles: list[dict] # [{id, vin, display_name, state, car_type}, ...]
# User selections (set at vehicle selection)
selected_vehicle_ids: list[int] # vehicle IDs chosen by user
selected_package: str # basic | pro | expert
# Payment (set after payment verification)
payment_id: str
payment_method: str
amount_cents: int
# Reports (set after generation)
reports: dict[int, dict] # {vehicle_id: VehicleReport}
Session storage: In-memory dict (same pattern as _states/_reports). Phase 2: Redis or persistent file store.
PDF + order storage: Always generate PDFs regardless of whether the client downloads them. Store in GCS bucket alongside a provider-agnostic order manifest:
gs://bnc-cpt-{env}-reports/{session_id}/
├── order.json ← provider-agnostic order record
├── {vehicle_id_1}.pdf
└── {vehicle_id_2}.pdf
order.json captures the order at generation time — works identically for Stripe, PayPal, MobilePay, or any future provider:
{
"session_id": "abc123...",
"payment_id": "cs_test123",
"payment_method": "stripe",
"package": "pro",
"vehicle_count": 2,
"vehicles": [{"id": 123, "vin": "LRW3E7EK1RC98****"}],
"amount_cents": 2999,
"currency": "EUR",
"created_at": "2026-02-17T12:00:00Z"
}
Enables customer support re-sends and chargeback proof regardless of payment provider. GCS lifecycle rotation policy added later as a configurable retention parameter (GDPR).
TTL: 30 min from creation. Cleaned up by _cleanup_expired() on every API call.
Replaces: Both _states and _reports dicts. One unified session object tracks the entire order lifecycle.
[start] ──OAuth callback OK──> [connected]
│
POST /session/{id}/select
│
v
[vehicles_selected]
│
POST /payment/verify OK
│
v
[paid]
│
POST /reports/generate
│
v
[generating]
│
all reports built
│
v
[complete]
Transition guards:
| From → To | Guard |
|---|---|
| start → connected | Valid OAuth code, token exchanged, /api/1/vehicles returned 1+ vehicles |
| connected/vehicles_selected → vehicles_selected | selected_vehicle_ids non-empty, all IDs exist in session.vehicles, selected_package is basic, pro, or expert. Re-selection allowed (user can change vehicles/package). |
| vehicles_selected → paid | Payment verified, amount matches tier price for selected vehicles + package (+ voucher if applied) |
| paid → generating | Token not expired (refresh if needed), selected vehicles still in session |
| generating → complete | All vehicle reports built (partial success allowed — mark failed vehicles) |
Any request that violates the state machine returns 409 Conflict with the current status.
POST /tesla/oauth/initiatev1: { paymentId: str, plan: str } → { authUrl: str, state: str }
v2: { brand: str } → { authUrl: str }
paymentId and plan removed. OAuth happens before payment exists. Backend generates state internally.
GET /tesla/oauth/callbackv1: code exchange → fetch ALL vehicle data → store full report → redirect
v2: code exchange → GET /api/1/vehicles ONLY → create OrderSession → redirect
The Tesla API data-fetch calls move from callback to post-payment POST /reports/generate. Which endpoints are called depends on the selected package (see PACKAGE_MODULES in §7).
GET /tesla/session/{session_id}/vehiclesGuard: session.status == connected
Response: {
vehicles: [{
id: 12345678901234,
vin: "LRW3E7EK1RC98****", // masked last 4
display_name: "JT3",
model: "Model 3", // from _car_type_to_model()
year: 2024, // from _vin_to_year()
state: "online"
}]
}
No Tesla API calls — returns data cached in session from callback.
POST /tesla/session/{session_id}/selectGuard: session.status in (connected, vehicles_selected)
Body: { vehicle_ids: [12345678901234], package: "pro" }
Response: { status: "vehicles_selected", vehicle_count: 1, package: "pro" }
Validates all vehicle_ids exist in session. Sets selected_vehicle_ids and selected_package. Accepts both connected and vehicles_selected states — this allows the user to change their vehicle selection or package without starting over.
POST /pricing/quoteBody: { session_id: str, voucher_code?: str }
Response: {
package: "pro",
vehicle_count: 2,
tier_label: "2 vehicles",
tier_price_cents: 2999,
voucher: null,
total_cents: 2999,
vat_cents: 609,
currency: "EUR"
}
Pricing engine looks up tier table (package + vehicle count), applies voucher if provided. Server-side pricing — client cannot set amount. Recalculated on each call.
POST /payment/create-intentv1: { plan: str, amount: int, method: str }
v2: { session_id: str, method: str }
Server reads package and vehicle count from session. Computes amount internally. Client provides only session and payment method.
POST /reports/generateGuard: session.status == paid
Body: { session_id: str }
Response: { status: "generating", vehicle_count: 2 }
Uses stored access_token to call _build_vehicle_report() for each selected vehicle (same logic as current v1, just moved here from callback). Stores results in session.reports.
Idempotent: if session is already complete, returns existing reports.
GET /tesla/report/{session_id}v1: single report, one-time retrieval, deleted after return
v2: all reports for session, reusable (not deleted)
Guard: session.status == complete
Response: {
status: "complete",
reports: {
"12345678901234": { vehicle: {...}, vehicleData: {...}, chargingHistory: [...], ... },
"98765432109876": { vehicle: {...}, vehicleData: {...}, chargingHistory: [...], ... }
}
}
// v1
type AppStep = 'landing' | 'payment' | 'payment-gateway' | 'payment-success'
| 'oauth' | 'report' | 'verify' | 'support'
// v2 (implemented)
type AppStep = 'landing' | 'oauth' | 'package' | 'payment-gateway'
| 'generating' | 'report' | 'support'
// Removed
selectedModelId // model selection gone
// Added (persisted to localStorage)
sessionId // OrderSession ID, set after OAuth callback
sessionVehicles // SessionVehicle[], persisted to survive page refresh
vehicleReports // Record<string, VehicleReport>, persisted for refresh survival
activeReportVin // string | null, which tab is active in ReportDashboard
// Kept (persisted)
selectedBrandId // drives OAuth provider selection
selectedPlan // basic | pro | expert
paymentId // set after payment
paymentMethod // stripe | paypal | googlepay | applepay
v1 (5 visual steps): Vehicle → Package → Payment → Connect → Report
v2 (5 visual steps): Brand → Connect → Package → Payment → Report
Step 1 (Brand/landing) is clickable for back-navigation. Steps 2-5 are not navigable backwards.
| Component | Status |
|---|---|
BrandSelector.vue |
Kept. Drives OAuth provider. |
ModelSelector.vue |
Dead code (not imported). Delete pending. |
OAuthStep.vue |
Modified. Calls initiateTeslaOAuth(brand) — no paymentId/plan. |
PaymentStep.vue |
Modified. Now serves as Package step (step 3). Shows vehicle selection checkboxes + package selection + payment method + terms. Vehicle selection: 1 car = auto-selected, 2+ = user must choose. |
PaymentGateway.vue |
Modified. Auto-advances to 'generating' step after 2s. |
GeneratingStep.vue |
New (replaces VerificationStep.vue). Calls POST /reports/generate. |
VerificationStep.vue |
Deleted. |
ReportDashboard.vue |
Unchanged (Phase 1: single report view). |
VehicleSelector.vue |
Not implemented as separate component — vehicle selection integrated into PaymentStep.vue. |
PackageQuote.vue |
Not implemented — Phase 2 (CPQ/pricing engine needed first). |
| Scenario | Behavior |
|---|---|
| OAuth returns 0 vehicles | Error: "No vehicles found on your Tesla account." Return to brand selection. No OrderSession created. |
| OAuth returns 1 vehicle | Auto-select it. Show vehicle card (read-only, no checkbox). Skip directly to package selection. |
| OAuth returns 2+ vehicles | Show VehicleSelector with checkboxes + "Select All". |
| User connects but closes browser | Session expires server-side after 30 min. sessionId is persisted in localStorage — if user returns within TTL, they resume at vehicle selection. If expired, 410 Gone → reset to landing. |
| Token expires before report generation | Attempt refresh using refresh_token. If refresh fails: "Your Tesla connection expired. Please reconnect." → return to OAuth step. Payment is still valid — user doesn't re-pay. |
| Car is asleep at report generation | Tesla Fleet API handles wake internally for vehicle_data. May add latency (up to 30s). If timeout after 60s, mark vehicle report as partial with available data. |
| Payment succeeds but report generation fails | Reports are paid for. Allow retry via POST /reports/generate (idempotent). Show partial results. Contact support link for unresolved failures. |
| User goes back from vehicle selection | Clear selectedVehicleIds. Session and token stay valid. If they return to brand selection and pick a different brand, current session is abandoned. |
| Session TTL expires mid-flow | Any API call returns 410 Gone. WUI catches this, shows "Session expired. Please start again.", resets to landing. |
| Concurrent browser tabs | session_id is per-OAuth-flow. Each tab that completes OAuth gets its own session. No conflict. |
| Concern | Mitigation |
|---|---|
| Token stored server-side longer than v1 | Encrypted at rest. 30 min TTL (vs 15 min in v1). Deleted on session expiry or completion. Never sent to WUI. Never logged. |
| Session ID as bearer token | 32 bytes via secrets.token_urlsafe(32). Same as current v1. Rate-limited endpoints. |
| Price tampering | Pricing engine does tier lookup server-side from session.selected_vehicle_ids + session.selected_package. Client sends only session_id + payment method. |
| Report re-generation abuse | State machine: POST /reports/generate only works when status == paid. Idempotent — re-calling returns cached reports. |
| Abandoned sessions with tokens | _cleanup_expired() runs on every API call. Sessions older than 30 min are deleted including tokens. |
CPQ separates three concerns. Each is a distinct entity:
| Entity | What it is | CPT example |
|---|---|---|
| Product | What is being sold. Has a name and feature set. | Basic Report, Pro Report, Expert Report |
| Price | Tier table: package + vehicle count band → fixed total price. Easy to read, easy to change. | Pro, 2 vehicles → €29.99 |
| Quote | Captures product + tier price before any voucher/campaign adjustments. Becomes the order record once payment is confirmed. | "Pro (2 vehicles) = €29.99, voucher −100% → €0.00" |
Product and Price are separate entities — price is never a column on Product. This is the universal pattern across Salesforce CPQ, Oscar, Saleor, Medusa, Shopify, and every major commerce platform.
The pricing engine does a tier table lookup: given a package and vehicle count, return the fixed total price for that band. No per-vehicle multiplication, no percentage discounts — just a table.
Tier table (example prices — easy to change in one place):
| Vehicles | Basic | Pro | Expert |
|---|---|---|---|
| 1 | €9.99 | €19.99 | €29.99 |
| 2 | €14.99 | €29.99 | €44.99 |
| 3–5 | €29.99 | €49.99 | €79.99 |
| 6–10 | €49.99 | €79.99 | €129.99 |
| 11+ | Contact us | Contact us | Contact us |
The more vehicles, the better the per-vehicle value — built into the tier price, not calculated.
Vouchers / campaigns are applied after the tier lookup:
| Type | Example | Effect |
|---|---|---|
| Test pilot | PILOT2026 |
100% off (free report for beta testers) |
| Percentage | LAUNCH10 |
−10% off tier price |
| Percentage | FLEET20 |
−20% off tier price |
The engine is simple:
input: package + vehicle_count + optional voucher_code
step 1: look up tier table → tier_price
step 2: if voucher_code → apply voucher adjustment
step 3: compute VAT on final price
output: quote
Why a tier table? It's the pricing data — readable, changeable. Today it's a dict in cpq.py. When an admin panel exists, it reads from a config file or API. Changing a price = changing one number in the table, not touching any logic.
STEP USER API / SYSTEM STATE AFTER
──── ──────────────────────────── ────────────────────────────────── ──────────────────
1 Clicks "Connect Tesla" OAuth redirect to Tesla —
Tesla callback with code
→ Exchange code for token
→ GET /api/1/vehicles connected
→ Create OrderSession (3 vehicles found)
2 Selects vehicles: POST /session/{id}/select vehicles_selected
☑ Model 3 "JT3" → Validate IDs in session
☑ Model Y "Family" → Set package = pro
Picks: Pro
3 Sees quote POST /pricing/quote —
→ Tier lookup: pro + 2 = €29.99
→ VAT 25.5% = €6.09
← Quote: €29.99 incl. VAT
4a Enters voucher "PILOT2026" POST /pricing/validate-voucher —
← valid, 100% off, "Test pilot"
4b Clicks [Apply] POST /pricing/quote —
→ €29.99 − 100% = €0.00
← Updated quote: €0.00
(or: no voucher) ← Quote stays: €29.99 —
5 Clicks [Pay €29.99] POST /payment/create-intent —
→ Stripe PaymentIntent: €29.99
← client_secret for Stripe.js
6 Completes Stripe payment POST /payment/verify (webhook) paid
→ Verify amount = quote total
→ Quote → ORDER RECORD
7 Waits (progress shown) POST /reports/generate generating → complete
→ Per vehicle, per package:
Pro = vehicle_data + charging
+ alerts + service + warranty
+ release_notes + options
→ Store reports in session
8 Views report / downloads GET /tesla/report/{session_id} —
← { vehicle_1: report, vehicle_2: report }
→ PDF download per vehicle
┌────────────────────────────────────────────────┐
│ Your Quote │
│ │
│ Pro Report — 2 vehicles €29.99 │
│ Tesla Model 3 — "JT3" │
│ Tesla Model Y — "Family" │
│ │
│ Voucher: [PILOT2026___] [ Apply ] │
│ Test pilot discount (100%) − €29.99 │
│ ──────── │
│ Total (VAT incl.) €0.00 │
│ │
│ [ Get Free Report ] │
└────────────────────────────────────────────────┘
The quote recalculates whenever the user changes vehicle selection, package, or voucher. It becomes the order record once payment is confirmed.
The product (package) determines which Tesla API endpoints are called per vehicle. Verified against real Tesla Fleet API responses (2024 Model 3, VIN LRW3E7EK1RC988948, 2026-02-10).
| Data point | Tesla API source | Basic | Pro | Expert |
|---|---|---|---|---|
| Vehicle identity (make, model, year, VIN) | vehicle_data → root + vehicle_config.car_type |
x | x | x |
| Battery & charging state (SOC %, range, limit, charging status) | vehicle_data → charge_state |
x | x | x |
| Odometer | vehicle_data → vehicle_state.odometer |
x | x | x |
| Software version & update status | vehicle_data → vehicle_state.car_version, software_update |
x | x | x |
| Climate & temps (inside/outside, COP, preconditioning) | vehicle_data → climate_state |
x | x | x |
| Tire pressure (TPMS, 4 corners + warnings) | vehicle_data → vehicle_state.tpms_pressure_*, tpms_soft_warning_* |
x | x | x |
| Vehicle config (drivetrain, wheels, color, charge port) | vehicle_data → vehicle_config |
x | x | x |
| Security (sentry mode, locked, valet mode) | vehicle_data → vehicle_state.sentry_mode, locked, valet_mode |
x | x | x |
| Dashcam status | vehicle_data → vehicle_state.dashcam_state |
x | x | x |
| GUI settings (units: km, C, bar, kW) | vehicle_data → gui_settings |
x | x | x |
| Charging history (sessions, locations, costs) | charging/history → data[] |
x | x | |
| Recent alerts | recent_alerts → response.recent_alerts[] |
x | x | |
| Service status | service_data → response (empty if not in service) |
x | x | |
| Warranty details | warranty/details → response (requires VIN) |
x | x | |
| Firmware release notes | release_notes → response.release_notes[] |
x | x | |
| Factory options (trim, wheels, color, AP) | options → codes[] (option code + displayName) |
x | x | |
| Full vehicle specs | /vehicle_specs (requires vehicle_specs scope) |
x | ||
| Tesla API calls per vehicle | 1 | 7 | 8 | |
| Tesla API cost per vehicle | $0.002 | $0.014 | ~$0.11 |
Notes on real API responses (tested 2026-02-10):
- charging/history returns "data" array (not "response"), with totalResults: 107
- options returns 6 option codes: Autopilot, interior color, exterior color, wheels, trim+drivetrain, supercharger access
- service_data returns empty {} when vehicle is not in service — this is expected
- "Subscription eligibility" and "Upgrade eligibility" were removed — no such Tesla API endpoints exist
Must fix before Phase 1 launch (unverified endpoints):
- warranty/details — returned 500 due to empty VIN bug. Fix: pass VIN explicitly in _build_vehicle_report(). Response structure unknown until re-tested.
- vehicle_specs — returned 403 Unauthorized missing scopes. Fix: vehicle_specs scope added to config.py. Response structure unknown until re-tested.
- drive_state — not returned in vehicle_data response. TypeScript types define it (latitude, longitude, heading, power) but real API did not include it. Investigate: scope issue, vehicle state (parked/asleep), or needs explicit endpoints= parameter. PDF template currently renders latitude/longitude — will show empty.
Action: re-run OAuth flow after deploying VIN fix + new scopes → capture warranty, specs, and drive_state responses → update types + package table.
The vehicle_specs endpoint ($0.10/call) is 50x more expensive than the others — the natural package boundary for Expert.
At these API costs, margins are 95%+ at any package level.
The pricing engine lives in app/services/cpq.py. Two parts:
1. Tier table — the pricing data (one dict, easy to change):
TIER_TABLE = {
"basic": {1: 999, 2: 1499, 5: 2999, 10: 4999},
"pro": {1: 1999, 2: 2999, 5: 4999, 10: 7999},
"expert": {1: 2999, 2: 4499, 5: 7999, 10: 12999},
}
# Keys = max vehicles in band (1, 2, 5, 10). Values = total price in cents.
# Lookup: find the smallest key >= vehicle_count.
2. Quote builder — tier lookup + optional voucher:
quote = create_quote(session, voucher_code=None)
tier_price = lookup(package, vehicle_count)
if voucher_code: apply voucher (fixed, percentage, or free)
compute VAT on final price
return Quote (package, vehicles, tier_price, voucher_adjustment, vat, total)
Recalculated on each call. After payment, the quote becomes the order record.
Product modules — which Tesla API endpoints each package calls (separate from pricing):
PACKAGE_MODULES = {
"basic": ["vehicle_data"],
"pro": ["vehicle_data", "charging_history", "recent_alerts", "service_data", "warranty", "release_notes", "options"],
"expert": ["vehicle_data", "charging_history", "recent_alerts", "service_data", "warranty", "release_notes", "options", "vehicle_specs"],
}
Tesla / Session
POST /tesla/oauth/initiate → Build Tesla OAuth URL, return authUrl + state
GET /tesla/oauth/callback → Tesla redirects here with code+state → exchange token, fetch vehicles, create OrderSession → redirect WUI
GET /tesla/session/{id}/vehicles → Return vehicles[] from session (for vehicle selector)
POST /tesla/session/{id}/select → Set selected vehicles + package → state: vehicles_selected
GET /tesla/report/{id} → Return generated reports (per vehicle)
Pricing / CPQ
GET /pricing/catalog → Tier table: packages x vehicle bands with prices (for landing page)
POST /pricing/quote → Tier lookup + optional voucher → quote {tier_price, voucher, vat, total}
POST /pricing/validate-voucher → Check voucher code validity and effect (for UI preview)
Payment
POST /payment/create-intent → Reads quote total from session, creates Stripe/PayPal intent
POST /payment/verify → Webhook: verify payment, quote → order record
Reports
POST /reports/generate → Per selected vehicle: call Tesla API endpoints per package → store reports
POST /tesla/vehicle-report/pdf → Generate PDF from report data (existing)
PackageQuote.vue (new): Package selector + quote display. Fetches GET /pricing/catalog for tier table. Calls POST /pricing/quote for live quote. Voucher input. VAT breakdown.PaymentStep.vue: Reads quote total from session. No hardcoded prices.GeneratingStep.vue (new): Replaces PaymentSuccessStep. Triggers report generation, shows progress.ReportDashboard.vue: Product determines which sections render per vehicle. Multi-report tabs.Landing → Brand → Connect → Package (with vehicle selection) → Payment → Report
Done (API — commit fd35279):
- [x] OrderSession dataclass with state machine (connected → vehicles_selected → paid → generating → complete)
- [x] POST /tesla/oauth/initiate — takes { brand } only
- [x] OAuth callback creates OrderSession, fetches vehicle inventory
- [x] GET /tesla/session/{id}/vehicles — returns cached vehicles
- [x] POST /tesla/session/{id}/select — sets vehicles + package
- [x] POST /reports/generate — builds reports post-payment
- [x] GET /tesla/report/{id} — returns all reports
- [x] Payment bridge: create-intent reads price from session
- [x] valid_packages fix: "expert" → "bulk" (Phase 2 reverses to "expert")
- [x] 107 unit tests passing
Done (WUI — commit d8c1a5f):
- [x] AppStep type: 'landing' | 'oauth' | 'package' | 'payment-gateway' | 'generating' | 'report' | 'support'
- [x] api.ts rewritten for v2 endpoints
- [x] stores/app.ts: sessionId + sessionVehicles persisted in localStorage
- [x] App.vue: connect-first flow, OAuth/Stripe/PayPal callback handling
- [x] OAuthStep.vue: calls initiateTeslaOAuth(brand) — no paymentId/plan
- [x] PaymentStep.vue: vehicle selection checkboxes + package + payment method + terms
- [x] PaymentGateway.vue: auto-advances to report generation
- [x] GeneratingStep.vue: new, replaces VerificationStep
- [x] StepIndicator.vue: 5-step v2 flow with 'package' AppStep
- [x] i18n (en/fi/sv) updated
- [x] Build passes (vue-tsc + vite)
Not done (Phase 1 remaining):
- [x] E2E test on dev.carpulsetracker.com — DONE 2026-02-20: OAuth → vehicle selection → package → payment → report generation tested with 2 real Teslas
- [x] GCP secret bnc-cpt-dev-proxy-shared-secret — RESOLVED 2026-02-20
- [ ] PDF + order storage in GCS (moved to Phase 3)
- [x] Fix warranty/details VIN bug — DONE: VIN is passed correctly in generate_reports() → _build_vehicle_report(access_token, vehicle_tag, vin) → warranty/details endpoint
- [ ] Investigate missing drive_state (moved to Phase 3)
Moved to Phase 2 (done):
- [x] Deploy with vehicle_specs scope — part of Expert package PACKAGE_MODULES
- [x] Delete dead code: ModelSelector.vue, PaymentSuccessStep.vue, VehicleForm.vue
Vehicle selection and multi-vehicle support already in Phase 1. Phase 2 adds server-side pricing, vouchers, package-aware report generation, and multi-vehicle report dashboard.
Status: All 17 implementation steps complete. Deployed and tested on dev.carpulsetracker.com with 2 real Teslas (Model 3 + Model Y). Reports generated successfully with live Tesla data.
| Decision | Resolution |
|---|---|
| Package naming | basic / pro / expert — rename "bulk" → "expert" everywhere |
| Tier prices | Use roadmap prices exactly (§7 tier table) |
| 11+ vehicles | Use the 6–10 band price (no "contact us" cap) |
| UX flow | Single 'package' step with 4 sections: vehicles → packages → quote → payment method |
| Landing page pricing | No — prices visible only after OAuth |
| Voucher storage | JSON config file, configurable per environment (config/vouchers.json) |
| PACKAGE_MODULES | Yes — Basic=1 API call, Pro=7, Expert=8 (see §7 table) |
| Multi-report | Tabbed view per vehicle in ReportDashboard |
| Dead code | Delete ModelSelector.vue, PaymentSuccessStep.vue, VehicleForm.vue |
app/models/payment.py — PaymentPlan.BULK → PaymentPlan.EXPERTapp/config.py — PAYMENT_PLANS["bulk"] → PAYMENT_PLANS["expert"]app/services/tesla_fleet.py — valid_packages setapp/services/pdf.py — display textAll tests using "bulk"
New app/models/pricing.py:
QuoteRequest(session_id, voucher_code?)QuoteResponse(package, vehicle_count, tier_label, tier_price_cents, voucher, vat_cents, total_cents, currency)VoucherValidateRequest(code), VoucherValidateResponse(valid, code, type, label, discount_description)CatalogResponse(packages: [{name, features, tiers: [{max_vehicles, price_cents}]}])
New app/services/cpq.py:
TIER_TABLE — package → {max_vehicles: price_cents}PACKAGE_MODULES — package → list of Tesla API module namesPACKAGE_FEATURES — package → feature descriptions for catalogload_vouchers() — reads config/vouchers.jsonlookup_tier_price(package, vehicle_count) — find smallest band key ≥ countvalidate_voucher(code) → VoucherInfo or Nonecreate_quote(session, voucher_code?) → QuoteResponse (tier + voucher + VAT)get_catalog() → CatalogResponseget_package_modules(package) → list[str]
New app/api/v1/routers/pricing.py:
GET /pricing/catalog — tier table with packages/bands/featuresPOST /pricing/quote — session_id + voucher_code? → computed quote (also stores in session)POST /pricing/validate-voucher — code → validity + effect
OrderSession + payment flow:
quote_price_cents, quote_voucher_code, quote_vat_centscreate_payment_intent() reads session.quote_price_cents instead of PAYMENT_PLANSIf quote_price_cents = 0 (100% voucher): skip payment, mark session paid directly
PACKAGE_MODULES in report generation:
generate_reports() gets modules from get_package_modules(session.selected_package)_build_vehicle_report(access_token, vehicle_tag, vin, modules) conditionally calls endpointsNew endpoints: release_notes, options (Pro+Expert), vehicle_specs (Expert only, $0.10)
New config/vouchers.json:
json
{
"PILOT2026": {"type": "percentage", "value": 100, "label": "Test pilot (100% off)", "active": true},
"LAUNCH10": {"type": "percentage", "value": 10, "label": "Launch discount (10% off)", "active": true},
"FLEET20": {"type": "percentage", "value": 20, "label": "Fleet discount (20% off)", "active": true}
}
Config: VOUCHER_CONFIG_PATH = "config/vouchers.json"
Tests: test_cpq.py (tier lookup, vouchers, quote math, PACKAGE_MODULES), test_pricing.py (router endpoints), update test_payment.py
types/index.ts: TierBand, PackageCatalog, CatalogResponse, Quote, VoucherValidationapi.ts: getPricingCatalog(), getQuote(), validateVoucher()currentQuote, catalog refs (not persisted — fetched from API)PaymentStep.vue restructure — 4 sections:[Pay €XX.XX] buttonPOST /pricing/quote → live updatePaymentGateway.vue — show amount from quoteReportDashboard.vue — tabbed view per vehicle (1 vehicle = no tabs, 2+ = tabs with display_name)ModelSelector.vue, PaymentSuccessStep.vue, VehicleForm.vueThree issues found during live testing on dev.carpulsetracker.com with 2 real Teslas:
| Fix | Repo | Commit | Details |
|---|---|---|---|
| State machine re-selection | API | dc9aa7b |
select_vehicles_and_package guard only accepted connected state. After first selection, session was vehicles_selected, so changing package/vehicles returned 409. Fixed: accept both connected and vehicles_selected. |
| Charging history VIN filter | API | ed0b8e8 |
Tesla's GET /api/1/dx/charging/history returns account-level data (all vehicles). Both cars showed identical charging sessions. Fixed: filter chargingHistory by vin field in _build_vehicle_report(). |
| vehicleReports localStorage | WUI | 25efb37 |
vehicleReports and activeReportVin were not persisted to localStorage. On page refresh, reports disappeared even though session was still alive. Fixed: added STORAGE_KEYS + watch-based persistence (same pattern as other store fields). |
Note on charging/history: Tesla's GET /api/1/dx/charging/history is account-level, not per-vehicle. Each charging session has a vin field. The API filters by VIN server-side before returning data to the client.
Current gap:
src/vue/app/src/components/Header.vue/Users/petrisandholm/Downloads/cpt-brand2.htmlNeeded implementation:
src/vue/app/src/assets/images/branding/.src/vue/app/src/components/Header.vue.src/vue/app/src/style.css.Car Pulse Tracker or shifts to a stronger CPT lockup.config/vouchers.jsonwarranty/details VIN bug — FIXED: VIN passed correctly in current codevehicle_specs — what does the real response contain? (Scope added — re-test after deploy)drive_state — why missing from vehicle_data? Scope, vehicle state, or needs endpoints= param?| Source | What | URL |
|---|---|---|
| Smartcar Connect | Multi-brand vehicle OAuth standard (40+ brands) | https://smartcar.com/product/connect |
| Recurrent Auto | EV battery health reports, uses Smartcar, connect-first flow | https://www.recurrentauto.com/for-owners |
| Tessie | Tesla companion app, connect-first + free trial, 500k+ users | https://www.tessie.com |
| Teslascope | Tesla data service, freemium, per-vehicle pricing | https://teslascope.com/about |
| Carfax | VIN-based vehicle history reports, bundle pricing | https://www.carfax.com/vehicle-history-reports/ |
| Userpilot | SaaS signup flow UX research | https://userpilot.com/blog/saas-signup-flow/ |
| Baymard Institute | Checkout usability research (55% abandon on surprise costs) | https://baymard.com/research/checkout-usability |
| Smartcar Blog | Vehicle onboarding UX patterns | https://smartcar.com/blog/smartcar-connect-vehicle-onboarding |
| Salesforce CPQ | Industry-standard CPQ: separate Product, Price, Quote entities | https://www.salesforce.com/products/cpq/ |
| Medusa Commerce | Open-source commerce engine — Product/Price separation, tiered pricing | https://medusajs.com |
| Saleor Commerce | Open-source GraphQL commerce — Product ≠ Price architecture | https://saleor.io |
| Oscar Commerce | Django e-commerce — Product/StockRecord separation (same pattern) | https://django-oscar.readthedocs.io |
| Stripe Pricing Table | Embedded pricing table with tiered plans, Checkout integration | https://stripe.com/docs/payments/checkout/pricing-table |
| Stripe Coupons | Coupon/promotion code system — percentage, fixed, free trial | https://stripe.com/docs/billing/subscriptions/coupons |
| Tesla Fleet API | Official Tesla Fleet API documentation (OAuth, vehicle data, commands) | https://developer.tesla.com/docs/fleet-api |
| Tesla Fleet API Pricing | Tesla API endpoint pricing ($0.001–$0.10 per call by category) | https://developer.tesla.com/docs/fleet-api/getting-started/subscription-plans |