From 58c033cbbacf594946afe5237ae345fb8ab2dc34 Mon Sep 17 00:00:00 2001 From: James Li Date: Mon, 6 Jul 2026 23:28:53 -0700 Subject: [PATCH 01/16] docs: add TCO reporting design spec Plans a fleet-wide/per-vehicle total-cost-of-ownership tab built on top of the existing Glovebox document pipeline, with straight-line depreciation and CSV export. --- .../specs/2026-07-06-tco-reporting-design.md | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-06-tco-reporting-design.md diff --git a/docs/superpowers/specs/2026-07-06-tco-reporting-design.md b/docs/superpowers/specs/2026-07-06-tco-reporting-design.md new file mode 100644 index 0000000..dccccd8 --- /dev/null +++ b/docs/superpowers/specs/2026-07-06-tco-reporting-design.md @@ -0,0 +1,117 @@ +# TCO Reporting — Design + +Created: 2026-07-06 + +## Goal + +Add a "Total Cost of Ownership" (TCO) tab that turns Glovebox documents +(service invoices, insurance, registration, fuel receipts, etc.) into a cost +analysis per vehicle and across the fleet, including depreciation, with CSV +export. + +## Scope + +**In scope:** +- Operating costs derived from dollar amounts attached to uploaded Glovebox + documents. +- One-time acquisition cost (purchase price/date) per vehicle, entered + manually. +- Straight-line depreciation from acquisition data. +- Fleet-wide summary view with per-vehicle drilldown. +- CSV export (line-item, both fleet-wide and single-vehicle). +- All-time totals only — no date-range filtering in v1. + +**Out of scope (this round):** +- Declining-balance or market-value-based depreciation. +- Editing a document's amount after the initial upload/confirm step. +- Date-range/year filtering in the report UI. +- Any change to how documents are stored (still DIS CloudEvents via + extract→confirm→attest, no new document storage system). + +## Data model + +**New Postgres table** `vehicle_tco_settings` (new migration, SQLBoiler +regenerated, follows existing `db/migrations` + `db/models` pattern): + +```sql +tenant_id text +vehicle_token_id bigint +purchase_price numeric +purchase_date date +useful_life_years int +currency text default 'USD' +created_at / updated_at +``` + +One optional row per vehicle. No row = operating-costs-only view, no +depreciation/acquisition line. + +**New document category**: `dimo.document.vehicle.fuel` → "Fuel", added to +`CE_TYPE_TO_LABEL` (`web/src/utils/document-categories.ts`) and the upload +modal's category picker. + +**Amount on documents**: no DIS schema change. `amount` and `currency` become +additional keys in the existing `fields`/`parsedData` map already sent to +`POST /documents/attest`. Populated via a new "Amount" input added to the +upload-confirm step, for every category except Note/Condition/Title. + +**Cost-eligible categories** (count toward TCO operating costs): Service & +parts, Insurance, Registration, Inspection, Finance, Regulatory, Expense, +Fuel. Excluded: Note, Condition, Title. + +## Backend (Go) + +New service `internal/service/tco_service.go`: aggregation loop over a +tenant's vehicles (reusing existing fetch-api list logic), sums `amount` by +category, computes straight-line depreciation +(`(price − 0) / usefulLifeYears × yearsElapsed`, capped at `price`). Shared by +all endpoints below so JSON and CSV numbers can't drift apart. + +| Route | Purpose | +|---|---| +| `GET /tco/settings?tokenId=N` | Fetch a vehicle's acquisition settings (empty/404 if none) | +| `PUT /tco/settings` | Upsert `{tokenId, purchasePrice, purchaseDate, usefulLifeYears, currency}` | +| `GET /tco/summary` | Fleet-wide rollup: per-vehicle totals (operating cost by category, acquisition, depreciation-to-date, total TCO) + fleet total | +| `GET /tco/vehicle/:tokenId` | Single-vehicle detail: same shape as summary for one vehicle, plus full line-item list (date, category, filename, amount) | +| `GET /tco/export.csv` (optional `?tokenId=N`) | Streams line-item CSV, fleet-wide or single-vehicle | + +`documents.go`'s `AttestDocument` handler needs no changes — `amount`/ +`currency` just ride along in the existing `parsedData` body field, and +"Fuel" is a valid category value. + +## Frontend (Lit) + +- New side-nav entry "TCO" → new view `web/src/views/tco-view.ts`. +- Fleet-wide table: one row per vehicle (operating cost, acquisition, + depreciation-to-date, total TCO), sortable, "Export CSV" button. +- Row click → drilldown: category breakdown, acquisition/depreciation + edit panel (price, date, useful-life-years, save), full line-item table, + its own single-vehicle "Export CSV" button. +- New service `web/src/services/tco-service.ts`: typed wrappers for the four + `/tco/*` endpoints. +- `upload-document-modal.ts`: add editable "Amount" field to the confirm + step (for cost-eligible categories) and "Fuel" to the category picker. + +## CSV export format + +One row per document, plus trailing per-vehicle summary rows for +acquisition/depreciation (not documents, but kept in the same flat table): + +``` +vehicle,vin,date,category,description,amount,currency +2021 Subaru Ascent,1HGCM82633A123456,2026-03-14,Service & parts,invoice_march.pdf,412.50,USD +2021 Subaru Ascent,1HGCM82633A123456,(acquisition),Acquisition,Purchase price,28500.00,USD +2021 Subaru Ascent,1HGCM82633A123456,(acquisition),Depreciation,Depreciation to date,-6200.00,USD +``` + +## Testing + +- Go unit tests for `tco_service.go`: depreciation math edge cases (no + purchase date, useful life 0, fully depreciated), category sum-by-bucket + logic. Follows existing `*_test.go` conventions (e.g. + `geofence_detection_test.go`). +- Manual Chrome verification: upload a document with an amount, confirm it + surfaces in the TCO drilldown, confirm CSV export downloads and opens + correctly for both fleet-wide and single-vehicle. +- No new external integrations, so no new contract/integration tests beyond + existing fetch-api/attest-api coverage. From 10f29f1dbaafc150a4be64be9ab15e54a7f19157 Mon Sep 17 00:00:00 2001 From: James Li Date: Tue, 7 Jul 2026 08:04:47 -0700 Subject: [PATCH 02/16] docs: add TCO reporting implementation plan --- .../plans/2026-07-06-tco-reporting.md | 1813 +++++++++++++++++ 1 file changed, 1813 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-06-tco-reporting.md diff --git a/docs/superpowers/plans/2026-07-06-tco-reporting.md b/docs/superpowers/plans/2026-07-06-tco-reporting.md new file mode 100644 index 0000000..ba628fc --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-tco-reporting.md @@ -0,0 +1,1813 @@ +# TCO Reporting Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a fleet-wide "TCO" (total cost of ownership) tab that turns Glovebox documents into a cost analysis per vehicle and across the fleet, including straight-line depreciation, with CSV export. + +**Architecture:** A new Go service (`TCOService`) sums dollar amounts already carried in Glovebox documents' `parsedData` (fetched via the existing `fetchAPI.ListByDID`), combines them with an optional per-vehicle acquisition/depreciation settings row (new Postgres table), and exposes the result through a new controller. The frontend adds an "Amount" field to the existing upload-confirm modal, a new "Fuel" document category, and a new `tco-view.ts` with a fleet table and in-page vehicle drilldown. + +**Tech Stack:** Go + Fiber + PostgreSQL + SQLBoiler (backend), Lit + TypeScript + Vite (frontend). Follows this repo's existing Glovebox/document pipeline exactly — no new external integrations. + +## Global Constraints + +- Straight-line depreciation only: `(purchasePrice / usefulLifeYears) × yearsElapsed`, capped at `purchasePrice`. No declining-balance or market-value methods. +- Cost-eligible CE types (count toward operating cost): `dimo.document.vehicle.service.invoice`, `dimo.document.vehicle.insurance`, `dimo.document.vehicle.registration`, `dimo.document.vehicle.inspection`, `dimo.document.vehicle.finance`, `dimo.document.vehicle.regulatory.other`, `dimo.document.vehicle.maintenance`, `dimo.document.vehicle.expense`, `dimo.document.vehicle.fuel`. Excluded: `dimo.document.vehicle.title`, `dimo.document.vehicle.note`, `dimo.document.vehicle.condition`. +- Amounts are captured via manual entry at the upload-confirm step only — no post-upload edit, no trusting extraction alone. +- All-time totals only — no date-range filtering in v1. +- CSV export is line-item (one row per document), with trailing acquisition/depreciation summary rows per vehicle. +- No new document storage system — documents remain DIS CloudEvents via the existing extract→confirm→attest pipeline. Only new persisted state is the optional `vehicle_tco_settings` table (acquisition price/date/useful-life per vehicle). +- Follow existing repo conventions exactly: SQLBoiler-generated models (never hand-edit `internal/db/models/*.go`), `make migrate` + `make sqlboiler` for schema changes, per-controller `vehicleInTenant` helper (duplicated per controller, matching `documents.go`/`telemetry.go`), Lit + `@lit/localize` `msg()` for all user-facing strings. + +--- + +### Task 1: `vehicle_tco_settings` migration + SQLBoiler model + +**Files:** +- Create: `api/internal/db/migrations/20260710120000_vehicle_tco_settings.sql` +- Generated (do not hand-edit): `api/internal/db/models/vehicle_tco_settings.go` + +**Interfaces:** +- Produces: table `vehicle_tco_settings` with SQLBoiler-generated Go type `dbmodels.VehicleTCOSetting` (fields `TenantID string`, `TokenID int64`, `PurchasePrice null.Float64`, `PurchaseDate null.Time`, `UsefulLifeYears null.Int`, `Currency string`, `CreatedAt time.Time`, `UpdatedAt time.Time`), `dbmodels.VehicleTCOSettingColumns` (column-name constants), `dbmodels.FindVehicleTCOSetting(ctx, exec, tenantID string, tokenID int64, selectCols ...string) (*VehicleTCOSetting, error)`. Task 3 consumes these. + +- [ ] **Step 1: Write the migration** + +```sql +-- +goose Up +-- +goose StatementBegin +SELECT 'up SQL query'; + +-- Optional per-vehicle acquisition/depreciation inputs for the TCO report. +-- Scoped by tenant like vehicle_favorites. All purchase fields are nullable — +-- a vehicle with no row (or nulls) just shows operating costs, no +-- acquisition/depreciation line. See +-- docs/superpowers/specs/2026-07-06-tco-reporting-design.md. +CREATE TABLE IF NOT EXISTS vehicle_tco_settings ( + tenant_id UUID NOT NULL REFERENCES tenants (id) ON DELETE CASCADE, + token_id BIGINT NOT NULL, + purchase_price NUMERIC, + purchase_date DATE, + useful_life_years INTEGER, + currency TEXT NOT NULL DEFAULT 'USD', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (tenant_id, token_id) +); + +CREATE INDEX IF NOT EXISTS idx_vehicle_tco_settings_tenant_id ON vehicle_tco_settings (tenant_id); + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +SELECT 'down SQL query'; + +DROP TABLE IF EXISTS vehicle_tco_settings; + +-- +goose StatementEnd +``` + +- [ ] **Step 2: Run the migration against your local dev DB** + +Run: `cd api && make migrate` +Expected: output includes `OK 20260710120000_vehicle_tco_settings.sql` and no errors. + +- [ ] **Step 3: Regenerate SQLBoiler models** + +Run: `cd api && make sqlboiler` +Expected: `internal/db/models/vehicle_tco_settings.go` is created (generated — do not hand-edit). No errors. + +- [ ] **Step 4: Verify the build still compiles** + +Run: `cd api && go build ./...` +Expected: exits 0. + +- [ ] **Step 5: Commit** + +```bash +git add api/internal/db/migrations/20260710120000_vehicle_tco_settings.sql api/internal/db/models/vehicle_tco_settings.go +git commit -m "feat(api): add vehicle_tco_settings table for TCO acquisition/depreciation inputs" +``` + +--- + +### Task 2: TCO pure calculation functions + unit tests + +**Files:** +- Create: `api/internal/service/tco_service.go` +- Create: `api/internal/service/tco_service_test.go` + +**Interfaces:** +- Consumes: nothing (pure functions, no DB/network). +- Produces: `const FuelCloudEventType string`, `var CostEligibleCETypes map[string]bool`, `func isCostEligible(ceType string) bool`, `func extractAmount(data json.RawMessage) (amount float64, currency string, ok bool)`, `func straightLineDepreciation(purchasePrice float64, purchaseDate time.Time, usefulLifeYears int, asOf time.Time) float64`, `type LineItem struct{ VehicleTokenID int64; VehicleLabel string; VIN string; Date string; Category string; Description string; Amount float64; Currency string }`, `func sumLineItemsByCategory(items []LineItem) map[string]float64`. Tasks 3–5 consume all of these. + +- [ ] **Step 1: Write the failing tests** + +```go +// api/internal/service/tco_service_test.go +package service + +import ( + "encoding/json" + "testing" + "time" +) + +func TestIsCostEligible(t *testing.T) { + cases := []struct { + ceType string + want bool + }{ + {"dimo.document.vehicle.service.invoice", true}, + {"dimo.document.vehicle.insurance", true}, + {"dimo.document.vehicle.fuel", true}, + {"dimo.document.vehicle.title", false}, + {"dimo.document.vehicle.note", false}, + {"dimo.document.vehicle.condition", false}, + {"dimo.document.unknown", false}, + } + for _, tc := range cases { + t.Run(tc.ceType, func(t *testing.T) { + if got := isCostEligible(tc.ceType); got != tc.want { + t.Fatalf("isCostEligible(%q) = %v, want %v", tc.ceType, got, tc.want) + } + }) + } +} + +func TestExtractAmount(t *testing.T) { + cases := []struct { + name string + data string + wantAmount float64 + wantCurrency string + wantOK bool + }{ + {"amount and currency", `{"amount": 412.5, "currency": "EUR"}`, 412.5, "EUR", true}, + {"amount defaults currency to USD", `{"amount": 10}`, 10, "USD", true}, + {"no amount field", `{"vin": "1HGCM82633A123456"}`, 0, "", false}, + {"empty data", ``, 0, "", false}, + {"malformed json", `not json`, 0, "", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + amount, currency, ok := extractAmount(json.RawMessage(tc.data)) + if ok != tc.wantOK { + t.Fatalf("ok = %v, want %v", ok, tc.wantOK) + } + if !ok { + return + } + if amount != tc.wantAmount || currency != tc.wantCurrency { + t.Fatalf("got (%v, %q), want (%v, %q)", amount, currency, tc.wantAmount, tc.wantCurrency) + } + }) + } +} + +func TestStraightLineDepreciation(t *testing.T) { + purchase := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + cases := []struct { + name string + purchasePrice float64 + usefulLifeYears int + asOf time.Time + want float64 + }{ + {"half life elapsed", 20000, 10, purchase.AddDate(5, 0, 0), 10000}, + {"before purchase", 20000, 10, purchase.AddDate(-1, 0, 0), 0}, + {"fully depreciated, capped", 20000, 10, purchase.AddDate(50, 0, 0), 20000}, + {"zero useful life", 20000, 0, purchase.AddDate(5, 0, 0), 0}, + {"zero price", 0, 10, purchase.AddDate(5, 0, 0), 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := straightLineDepreciation(tc.purchasePrice, purchase, tc.usefulLifeYears, tc.asOf) + if diff := got - tc.want; diff > 1 || diff < -1 { + t.Fatalf("straightLineDepreciation(...) = %v, want ~%v", got, tc.want) + } + }) + } +} + +func TestSumLineItemsByCategory(t *testing.T) { + items := []LineItem{ + {Category: "dimo.document.vehicle.insurance", Amount: 100}, + {Category: "dimo.document.vehicle.insurance", Amount: 50}, + {Category: "dimo.document.vehicle.fuel", Amount: 40}, + } + got := sumLineItemsByCategory(items) + if got["dimo.document.vehicle.insurance"] != 150 { + t.Fatalf("insurance total = %v, want 150", got["dimo.document.vehicle.insurance"]) + } + if got["dimo.document.vehicle.fuel"] != 40 { + t.Fatalf("fuel total = %v, want 40", got["dimo.document.vehicle.fuel"]) + } + if len(got) != 2 { + t.Fatalf("expected 2 categories, got %d", len(got)) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail (package doesn't exist yet)** + +Run: `cd api && go test ./internal/service/... -run 'TestIsCostEligible|TestExtractAmount|TestStraightLineDepreciation|TestSumLineItemsByCategory' -v` +Expected: FAIL — compile error, undefined `isCostEligible` etc. + +- [ ] **Step 3: Write the implementation** + +```go +// api/internal/service/tco_service.go +package service + +import ( + "encoding/json" + "time" +) + +// FuelCloudEventType is the CE type for fuel receipts — broken out from the +// generic "expense" bucket since fuel is usually a fleet's largest recurring +// cost. See docs/superpowers/specs/2026-07-06-tco-reporting-design.md. +const FuelCloudEventType = "dimo.document.vehicle.fuel" + +// CostEligibleCETypes are the canonical CE types whose amounts count toward +// TCO operating costs. Title/Note/Condition are informational, not spend. +var CostEligibleCETypes = map[string]bool{ + "dimo.document.vehicle.service.invoice": true, + "dimo.document.vehicle.insurance": true, + "dimo.document.vehicle.registration": true, + "dimo.document.vehicle.inspection": true, + "dimo.document.vehicle.finance": true, + "dimo.document.vehicle.regulatory.other": true, + "dimo.document.vehicle.maintenance": true, + "dimo.document.vehicle.expense": true, + FuelCloudEventType: true, +} + +// isCostEligible reports whether a document's CE type counts toward TCO +// operating costs. +func isCostEligible(ceType string) bool { + return CostEligibleCETypes[ceType] +} + +// extractAmount pulls "amount"/"currency" out of a parsed document CE's data +// payload. ok is false when data is empty, unparseable, or has no numeric +// amount field — callers should skip the document rather than count it as $0. +func extractAmount(data json.RawMessage) (amount float64, currency string, ok bool) { + if len(data) == 0 { + return 0, "", false + } + var payload struct { + Amount *float64 `json:"amount"` + Currency string `json:"currency"` + } + if err := json.Unmarshal(data, &payload); err != nil || payload.Amount == nil { + return 0, "", false + } + currency = payload.Currency + if currency == "" { + currency = "USD" + } + return *payload.Amount, currency, true +} + +// straightLineDepreciation returns the depreciation accrued from purchaseDate +// through asOf, straight-line over usefulLifeYears, capped at purchasePrice. +// Returns 0 if usefulLifeYears <= 0, purchasePrice <= 0, or asOf precedes +// purchaseDate. +func straightLineDepreciation(purchasePrice float64, purchaseDate time.Time, usefulLifeYears int, asOf time.Time) float64 { + if usefulLifeYears <= 0 || purchasePrice <= 0 { + return 0 + } + elapsedYears := asOf.Sub(purchaseDate).Hours() / 24 / 365.25 + if elapsedYears <= 0 { + return 0 + } + dep := (purchasePrice / float64(usefulLifeYears)) * elapsedYears + if dep > purchasePrice { + return purchasePrice + } + return dep +} + +// LineItem is one cost-eligible document, flattened for reporting/export. +type LineItem struct { + VehicleTokenID int64 `json:"vehicleTokenId"` + VehicleLabel string `json:"vehicleLabel"` + VIN string `json:"vin"` + Date string `json:"date"` + Category string `json:"category"` + Description string `json:"description"` + Amount float64 `json:"amount"` + Currency string `json:"currency"` +} + +// sumLineItemsByCategory totals LineItem.Amount grouped by CE type. +func sumLineItemsByCategory(items []LineItem) map[string]float64 { + out := map[string]float64{} + for _, li := range items { + out[li.Category] += li.Amount + } + return out +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd api && go test ./internal/service/... -run 'TestIsCostEligible|TestExtractAmount|TestStraightLineDepreciation|TestSumLineItemsByCategory' -v` +Expected: PASS (all subtests). + +- [ ] **Step 5: Commit** + +```bash +git add api/internal/service/tco_service.go api/internal/service/tco_service_test.go +git commit -m "feat(api): add TCO cost-eligibility, amount extraction, and depreciation math" +``` + +--- + +### Task 3: TCO settings CRUD (DB-backed) + +**Files:** +- Modify: `api/internal/service/tco_service.go` (append) + +**Interfaces:** +- Consumes: `dbmodels.VehicleTCOSetting`, `dbmodels.VehicleTCOSettingColumns`, `dbmodels.FindVehicleTCOSetting` (Task 1); `db.Store` from `github.com/DIMO-Network/shared/pkg/db` (existing, see `internal/service/user_prefs.go`). +- Produces: `type TCOSettings struct{ VehicleTokenID int64; PurchasePrice *float64; PurchaseDate *string; UsefulLifeYears *int; Currency string }`, `type TCOService struct{...}`, `func NewTCOService(logger *zerolog.Logger, pdb *db.Store, fetchAPI *gateway.FetchAPI, authProvider *gateway.DimoAuthProvider, vehicleSvc *VehicleService) *TCOService`, `func (s *TCOService) GetSettings(ctx, tenantID string, tokenID int64) (TCOSettings, error)`, `func (s *TCOService) UpsertSettings(ctx, tenantID string, tokenID int64, in TCOSettings) error`. Tasks 4–5 consume all of these. + +- [ ] **Step 1: Append the settings types + service scaffold + CRUD methods** + +```go +// Append to api/internal/service/tco_service.go + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + dbmodels "github.com/DIMO-Network/fleet-lite-app/internal/db/models" + "github.com/DIMO-Network/fleet-lite-app/internal/gateway" + "github.com/DIMO-Network/shared/pkg/db" + "github.com/aarondl/null/v8" + "github.com/aarondl/sqlboiler/v4/boil" + "github.com/rs/zerolog" +) + +// TCOSettings is a vehicle's optional acquisition/depreciation inputs. +// Nil pointer fields mean "not set" — the vehicle shows operating costs only. +type TCOSettings struct { + VehicleTokenID int64 `json:"tokenId"` + PurchasePrice *float64 `json:"purchasePrice,omitempty"` + PurchaseDate *string `json:"purchaseDate,omitempty"` // YYYY-MM-DD + UsefulLifeYears *int `json:"usefulLifeYears,omitempty"` + Currency string `json:"currency"` +} + +// TCOService builds cost-of-ownership reports from Glovebox documents plus +// each vehicle's optional acquisition/depreciation settings. +type TCOService struct { + logger *zerolog.Logger + pdb *db.Store + fetchAPI *gateway.FetchAPI + authProvider *gateway.DimoAuthProvider + vehicleSvc *VehicleService +} + +func NewTCOService(logger *zerolog.Logger, pdb *db.Store, fetchAPI *gateway.FetchAPI, authProvider *gateway.DimoAuthProvider, vehicleSvc *VehicleService) *TCOService { + return &TCOService{ + logger: logger, + pdb: pdb, + fetchAPI: fetchAPI, + authProvider: authProvider, + vehicleSvc: vehicleSvc, + } +} + +// GetSettings returns a vehicle's TCO settings, or a zero-value TCOSettings +// (Currency defaulted to USD, other fields nil) if none have been saved. +func (s *TCOService) GetSettings(ctx context.Context, tenantID string, tokenID int64) (TCOSettings, error) { + row, err := dbmodels.FindVehicleTCOSetting(ctx, s.pdb.DBS().Reader, tenantID, tokenID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return TCOSettings{VehicleTokenID: tokenID, Currency: "USD"}, nil + } + return TCOSettings{}, fmt.Errorf("get tco settings: %w", err) + } + out := TCOSettings{VehicleTokenID: tokenID, Currency: row.Currency} + if row.PurchasePrice.Valid { + v := row.PurchasePrice.Float64 + out.PurchasePrice = &v + } + if row.PurchaseDate.Valid { + d := row.PurchaseDate.Time.Format("2006-01-02") + out.PurchaseDate = &d + } + if row.UsefulLifeYears.Valid { + v := row.UsefulLifeYears.Int + out.UsefulLifeYears = &v + } + return out, nil +} + +// UpsertSettings full-replaces a vehicle's TCO settings. An empty in.Currency +// defaults to "USD". +func (s *TCOService) UpsertSettings(ctx context.Context, tenantID string, tokenID int64, in TCOSettings) error { + currency := in.Currency + if currency == "" { + currency = "USD" + } + row := &dbmodels.VehicleTCOSetting{ + TenantID: tenantID, + TokenID: tokenID, + Currency: currency, + } + if in.PurchasePrice != nil { + row.PurchasePrice = null.Float64From(*in.PurchasePrice) + } + if in.PurchaseDate != nil { + t, err := time.Parse("2006-01-02", *in.PurchaseDate) + if err != nil { + return fmt.Errorf("invalid purchaseDate %q: %w", *in.PurchaseDate, err) + } + row.PurchaseDate = null.TimeFrom(t) + } + if in.UsefulLifeYears != nil { + row.UsefulLifeYears = null.IntFrom(*in.UsefulLifeYears) + } + now := time.Now() + row.CreatedAt = now + row.UpdatedAt = now + return row.Upsert(ctx, s.pdb.DBS().Writer, true, + []string{dbmodels.VehicleTCOSettingColumns.TenantID, dbmodels.VehicleTCOSettingColumns.TokenID}, + boil.Whitelist( + dbmodels.VehicleTCOSettingColumns.PurchasePrice, + dbmodels.VehicleTCOSettingColumns.PurchaseDate, + dbmodels.VehicleTCOSettingColumns.UsefulLifeYears, + dbmodels.VehicleTCOSettingColumns.Currency, + dbmodels.VehicleTCOSettingColumns.UpdatedAt, + ), + boil.Infer(), + ) +} +``` + +Note: `encoding/json` is already imported from Task 2 — merge the import blocks into one `import (...)` group at the top of the file rather than two separate blocks. + +- [ ] **Step 2: Verify it builds** + +Run: `cd api && go build ./...` +Expected: exits 0. (No new tests here — this is thin DB CRUD following the exact pattern of `internal/service/user_prefs.go`, which has no unit tests either since it requires a live DB.) + +- [ ] **Step 3: Commit** + +```bash +git add api/internal/service/tco_service.go +git commit -m "feat(api): add TCO settings get/upsert backed by vehicle_tco_settings" +``` + +--- + +### Task 4: Vehicle + fleet TCO summary assembly and CSV export + +**Files:** +- Modify: `api/internal/service/tco_service.go` (append) + +**Interfaces:** +- Consumes: `s.vehicleSvc.GetVehicle(ctx, tenantID string, tokenID int64) (*models.Vehicle, error)`, `s.vehicleSvc.ListVehicles(ctx, tenantID string) ([]models.Vehicle, error)` (existing, `internal/service/vehicle.go`); `s.fetchAPI.ListByDID(tenant models.Tenant, tokenDID string, limit int) ([]gateway.AttestationEntry, error)` (existing, `internal/gateway/fetch_api.go`); `s.authProvider.BuildVehicleDID(tokenID uint64) string` (existing); `TCOSettings`, `LineItem`, `isCostEligible`, `extractAmount`, `straightLineDepreciation`, `sumLineItemsByCategory` (Tasks 2–3). +- Produces: `type VehicleTCOSummary struct{...}`, `type FleetTotals struct{...}`, `type FleetTCOSummary struct{...}`, `func (s *TCOService) VehicleSummary(ctx, tenant models.Tenant, tokenID int64) (*VehicleTCOSummary, error)`, `func (s *TCOService) FleetSummary(ctx, tenant models.Tenant) (*FleetTCOSummary, error)`, `func BuildCSV(summaries []VehicleTCOSummary) string`. Task 5 consumes all of these. + +- [ ] **Step 1: Append the summary types + assembly + CSV methods** + +```go +// Append to api/internal/service/tco_service.go — add these to the existing +// import block: "encoding/csv", "strconv", "strings", +// "github.com/DIMO-Network/fleet-lite-app/internal/models" + +// VehicleTCOSummary is one vehicle's cost breakdown for the TCO report. +type VehicleTCOSummary struct { + VehicleTokenID int64 `json:"tokenId"` + VehicleLabel string `json:"vehicleLabel"` + VIN string `json:"vin,omitempty"` + OperatingCost float64 `json:"operatingCost"` + CostByCategory map[string]float64 `json:"costByCategory"` + AcquisitionCost float64 `json:"acquisitionCost"` + DepreciationToDate float64 `json:"depreciationToDate"` + TotalTCO float64 `json:"totalTco"` + Settings TCOSettings `json:"settings"` + LineItems []LineItem `json:"lineItems"` +} + +// FleetTotals sums each VehicleTCOSummary field across the fleet. +type FleetTotals struct { + OperatingCost float64 `json:"operatingCost"` + AcquisitionCost float64 `json:"acquisitionCost"` + DepreciationToDate float64 `json:"depreciationToDate"` + TotalTCO float64 `json:"totalTco"` +} + +// FleetTCOSummary is the fleet-wide rollup: each vehicle's summary plus totals. +type FleetTCOSummary struct { + Vehicles []VehicleTCOSummary `json:"vehicles"` + Fleet FleetTotals `json:"fleet"` +} + +// vehicleLabel formats " ", falling back to "Vehicle #". +func vehicleLabel(v models.Vehicle) string { + d := v.Definition + parts := make([]string, 0, 3) + if d.Year != 0 { + parts = append(parts, strconv.Itoa(d.Year)) + } + if d.Make != "" { + parts = append(parts, d.Make) + } + if d.Model != "" { + parts = append(parts, d.Model) + } + if len(parts) == 0 { + return fmt.Sprintf("Vehicle #%d", v.TokenID) + } + return strings.Join(parts, " ") +} + +// descriptionFor derives a human label for a line item from its parsed data's +// "fileName"/"name" field, falling back to the CE type's last segment. +func descriptionFor(e gateway.AttestationEntry) string { + var payload struct { + Name string `json:"name"` + FileName string `json:"fileName"` + } + if len(e.Data) > 0 { + _ = json.Unmarshal(e.Data, &payload) + } + if payload.FileName != "" { + return payload.FileName + } + if payload.Name != "" { + return payload.Name + } + parts := strings.Split(e.Type, ".") + return parts[len(parts)-1] +} + +// VehicleSummary builds one vehicle's TCO breakdown: cost-eligible document +// amounts summed by category, plus acquisition/depreciation if settings exist. +func (s *TCOService) VehicleSummary(ctx context.Context, tenant models.Tenant, tokenID int64) (*VehicleTCOSummary, error) { + vehicle, err := s.vehicleSvc.GetVehicle(ctx, tenant.ID, tokenID) + if err != nil { + return nil, fmt.Errorf("get vehicle: %w", err) + } + label := vehicleLabel(*vehicle) + + tokenDID := s.authProvider.BuildVehicleDID(uint64(tokenID)) + entries, err := s.fetchAPI.ListByDID(tenant, tokenDID, 500) + if err != nil { + return nil, fmt.Errorf("list documents: %w", err) + } + + lineItems := make([]LineItem, 0, len(entries)) + for _, e := range entries { + if !isCostEligible(e.Type) { + continue + } + amount, currency, ok := extractAmount(e.Data) + if !ok { + continue + } + lineItems = append(lineItems, LineItem{ + VehicleTokenID: tokenID, + VehicleLabel: label, + VIN: vehicle.VIN, + Date: e.Time, + Category: e.Type, + Description: descriptionFor(e), + Amount: amount, + Currency: currency, + }) + } + + settings, err := s.GetSettings(ctx, tenant.ID, tokenID) + if err != nil { + return nil, fmt.Errorf("get tco settings: %w", err) + } + + summary := &VehicleTCOSummary{ + VehicleTokenID: tokenID, + VehicleLabel: label, + VIN: vehicle.VIN, + CostByCategory: sumLineItemsByCategory(lineItems), + Settings: settings, + LineItems: lineItems, + } + for _, v := range summary.CostByCategory { + summary.OperatingCost += v + } + if settings.PurchasePrice != nil { + summary.AcquisitionCost = *settings.PurchasePrice + if settings.PurchaseDate != nil && settings.UsefulLifeYears != nil { + purchaseDate, perr := time.Parse("2006-01-02", *settings.PurchaseDate) + if perr == nil { + summary.DepreciationToDate = straightLineDepreciation(*settings.PurchasePrice, purchaseDate, *settings.UsefulLifeYears, time.Now()) + } + } + } + summary.TotalTCO = summary.OperatingCost + summary.AcquisitionCost + return summary, nil +} + +// FleetSummary builds the TCO rollup for every vehicle in the tenant. Vehicles +// are processed sequentially — one fetch-api round trip each — which is +// acceptable for the fleet sizes this app targets; revisit with a worker pool +// if that stops being true. A single vehicle's failure is logged and skipped +// rather than failing the whole report. +func (s *TCOService) FleetSummary(ctx context.Context, tenant models.Tenant) (*FleetTCOSummary, error) { + vehicles, err := s.vehicleSvc.ListVehicles(ctx, tenant.ID) + if err != nil { + return nil, fmt.Errorf("list vehicles: %w", err) + } + out := &FleetTCOSummary{Vehicles: make([]VehicleTCOSummary, 0, len(vehicles))} + for _, v := range vehicles { + summary, err := s.VehicleSummary(ctx, tenant, v.TokenID) + if err != nil { + s.logger.Warn().Err(err).Int64("tokenID", v.TokenID).Msg("tco vehicle summary failed, skipping") + continue + } + out.Vehicles = append(out.Vehicles, *summary) + out.Fleet.OperatingCost += summary.OperatingCost + out.Fleet.AcquisitionCost += summary.AcquisitionCost + out.Fleet.DepreciationToDate += summary.DepreciationToDate + out.Fleet.TotalTCO += summary.TotalTCO + } + return out, nil +} + +// ceTypeToLabel mirrors web/src/utils/document-categories.ts's CE_TYPE_TO_LABEL +// so the CSV export reads the same as the app. Duplicated deliberately: the +// frontend map is TypeScript and can't be imported into Go. +var ceTypeToLabel = map[string]string{ + "dimo.document.vehicle.service.invoice": "Service & parts", + "dimo.document.vehicle.insurance": "Insurance", + "dimo.document.vehicle.registration": "Registration", + "dimo.document.vehicle.inspection": "Inspection", + "dimo.document.vehicle.finance": "Finance", + "dimo.document.vehicle.regulatory.other": "Regulatory", + "dimo.document.vehicle.maintenance": "Service & parts", + "dimo.document.vehicle.expense": "Other", + FuelCloudEventType: "Fuel", +} + +func categoryLabelForCSV(ceType string) string { + if l, ok := ceTypeToLabel[ceType]; ok { + return l + } + return ceType +} + +// BuildCSV renders line items (plus trailing acquisition/depreciation summary +// rows per vehicle) as CSV text. +func BuildCSV(summaries []VehicleTCOSummary) string { + var b strings.Builder + w := csv.NewWriter(&b) + _ = w.Write([]string{"vehicle", "vin", "date", "category", "description", "amount", "currency"}) + for _, v := range summaries { + for _, li := range v.LineItems { + _ = w.Write([]string{ + li.VehicleLabel, li.VIN, li.Date, categoryLabelForCSV(li.Category), li.Description, + strconv.FormatFloat(li.Amount, 'f', 2, 64), li.Currency, + }) + } + if v.Settings.PurchasePrice != nil { + _ = w.Write([]string{ + v.VehicleLabel, v.VIN, "(acquisition)", "Acquisition", "Purchase price", + strconv.FormatFloat(*v.Settings.PurchasePrice, 'f', 2, 64), v.Settings.Currency, + }) + _ = w.Write([]string{ + v.VehicleLabel, v.VIN, "(acquisition)", "Depreciation", "Depreciation to date", + strconv.FormatFloat(-v.DepreciationToDate, 'f', 2, 64), v.Settings.Currency, + }) + } + } + w.Flush() + return b.String() +} +``` + +- [ ] **Step 2: Verify it builds** + +Run: `cd api && go build ./...` +Expected: exits 0. + +- [ ] **Step 3: Add a unit test for the CSV builder (pure function, no DB/network)** + +```go +// Append to api/internal/service/tco_service_test.go +func TestBuildCSV(t *testing.T) { + price := 20000.0 + summaries := []VehicleTCOSummary{ + { + VehicleLabel: "2021 Subaru Ascent", + VIN: "1HGCM82633A123456", + DepreciationToDate: 5000, + Settings: TCOSettings{PurchasePrice: &price, Currency: "USD"}, + LineItems: []LineItem{ + {VehicleLabel: "2021 Subaru Ascent", VIN: "1HGCM82633A123456", Date: "2026-03-14", Category: "dimo.document.vehicle.service.invoice", Description: "invoice.pdf", Amount: 412.5, Currency: "USD"}, + }, + }, + } + got := BuildCSV(summaries) + wantHeader := "vehicle,vin,date,category,description,amount,currency" + if !strings.Contains(got, wantHeader) { + t.Fatalf("missing header row, got:\n%s", got) + } + if !strings.Contains(got, "412.50,USD") { + t.Fatalf("missing line item row, got:\n%s", got) + } + if !strings.Contains(got, "20000.00,USD") { + t.Fatalf("missing acquisition row, got:\n%s", got) + } + if !strings.Contains(got, "-5000.00,USD") { + t.Fatalf("missing depreciation row, got:\n%s", got) + } +} +``` + +Add `"strings"` to the test file's import block if not already present. + +- [ ] **Step 4: Run the test** + +Run: `cd api && go test ./internal/service/... -run TestBuildCSV -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add api/internal/service/tco_service.go api/internal/service/tco_service_test.go +git commit -m "feat(api): assemble vehicle/fleet TCO summaries and CSV export" +``` + +--- + +### Task 5: TCO controller + app.go wiring + +**Files:** +- Create: `api/internal/controllers/tco.go` +- Modify: `api/internal/app/app.go` + +**Interfaces:** +- Consumes: `service.NewTCOService`, `service.TCOSettings`, `service.VehicleTCOSummary`, `service.FleetTCOSummary`, `service.BuildCSV` (Tasks 3–4); `controllers.GetTenant(c) (models.Tenant, error)` (existing, `common.go`); `vehicleSvc.GetVehicle` (existing, used for the per-controller `vehicleInTenant` check, matching `documents.go`). +- Produces: routes `GET /tco/settings`, `PUT /tco/settings`, `GET /tco/summary`, `GET /tco/vehicle/:tokenId`, `GET /tco/export.csv`. + +- [ ] **Step 1: Write the controller** + +```go +// api/internal/controllers/tco.go +package controllers + +import ( + "context" + "fmt" + "strconv" + + "github.com/DIMO-Network/fleet-lite-app/internal/service" + "github.com/gofiber/fiber/v2" + "github.com/rs/zerolog" +) + +type TCOController struct { + logger *zerolog.Logger + tcoSvc *service.TCOService + vehicleSvc *service.VehicleService +} + +func NewTCOController(logger *zerolog.Logger, tcoSvc *service.TCOService, vehicleSvc *service.VehicleService) *TCOController { + return &TCOController{logger: logger, tcoSvc: tcoSvc, vehicleSvc: vehicleSvc} +} + +// vehicleInTenant reports whether the tokenID is one of the tenant's synced vehicles. +func (t *TCOController) vehicleInTenant(ctx context.Context, tenantID string, tokenID int64) bool { + _, err := t.vehicleSvc.GetVehicle(ctx, tenantID, tokenID) + return err == nil +} + +// GetSettings — GET /tco/settings?tokenId=N. +func (t *TCOController) GetSettings(c *fiber.Ctx) error { + tenant, err := GetTenant(c) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + tokenID, err := strconv.ParseInt(c.Query("tokenId"), 10, 64) + if err != nil || tokenID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "valid tokenId query param required") + } + if !t.vehicleInTenant(c.Context(), tenant.ID, tokenID) { + return fiber.NewError(fiber.StatusForbidden, "vehicle is not part of this tenant") + } + settings, err := t.tcoSvc.GetSettings(c.Context(), tenant.ID, tokenID) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "get tco settings: "+err.Error()) + } + return c.JSON(settings) +} + +// PutSettingsRequest is the body for PUT /tco/settings. +type PutSettingsRequest struct { + TokenID int64 `json:"tokenId"` + PurchasePrice *float64 `json:"purchasePrice"` + PurchaseDate *string `json:"purchaseDate"` + UsefulLifeYears *int `json:"usefulLifeYears"` + Currency string `json:"currency"` +} + +// PutSettings — PUT /tco/settings. +func (t *TCOController) PutSettings(c *fiber.Ctx) error { + tenant, err := GetTenant(c) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + var req PutSettingsRequest + if err := c.BodyParser(&req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "invalid request body: "+err.Error()) + } + if req.TokenID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "tokenId is required") + } + if !t.vehicleInTenant(c.Context(), tenant.ID, req.TokenID) { + return fiber.NewError(fiber.StatusForbidden, "vehicle is not part of this tenant") + } + currency := req.Currency + if currency == "" { + currency = "USD" + } + settings := service.TCOSettings{ + VehicleTokenID: req.TokenID, + PurchasePrice: req.PurchasePrice, + PurchaseDate: req.PurchaseDate, + UsefulLifeYears: req.UsefulLifeYears, + Currency: currency, + } + if err := t.tcoSvc.UpsertSettings(c.Context(), tenant.ID, req.TokenID, settings); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "save tco settings: "+err.Error()) + } + return c.JSON(settings) +} + +// GetSummary — GET /tco/summary. Fleet-wide rollup. +func (t *TCOController) GetSummary(c *fiber.Ctx) error { + tenant, err := GetTenant(c) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + summary, err := t.tcoSvc.FleetSummary(c.Context(), tenant) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "tco summary: "+err.Error()) + } + return c.JSON(summary) +} + +// GetVehicleDetail — GET /tco/vehicle/:tokenId. +func (t *TCOController) GetVehicleDetail(c *fiber.Ctx) error { + tenant, err := GetTenant(c) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + tokenID, err := strconv.ParseInt(c.Params("tokenId"), 10, 64) + if err != nil || tokenID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "valid tokenId path param required") + } + if !t.vehicleInTenant(c.Context(), tenant.ID, tokenID) { + return fiber.NewError(fiber.StatusForbidden, "vehicle is not part of this tenant") + } + summary, err := t.tcoSvc.VehicleSummary(c.Context(), tenant, tokenID) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "tco vehicle summary: "+err.Error()) + } + return c.JSON(summary) +} + +// ExportCSV — GET /tco/export.csv (optionally ?tokenId=N for a single vehicle). +func (t *TCOController) ExportCSV(c *fiber.Ctx) error { + tenant, err := GetTenant(c) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + var summaries []service.VehicleTCOSummary + filename := "tco-export.csv" + if q := c.Query("tokenId"); q != "" { + tokenID, err := strconv.ParseInt(q, 10, 64) + if err != nil || tokenID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "invalid tokenId") + } + if !t.vehicleInTenant(c.Context(), tenant.ID, tokenID) { + return fiber.NewError(fiber.StatusForbidden, "vehicle is not part of this tenant") + } + summary, err := t.tcoSvc.VehicleSummary(c.Context(), tenant, tokenID) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "tco vehicle summary: "+err.Error()) + } + summaries = []service.VehicleTCOSummary{*summary} + filename = fmt.Sprintf("tco-vehicle-%d.csv", tokenID) + } else { + fleet, err := t.tcoSvc.FleetSummary(c.Context(), tenant) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "tco summary: "+err.Error()) + } + summaries = fleet.Vehicles + } + csvText := service.BuildCSV(summaries) + c.Set(fiber.HeaderContentType, "text/csv") + c.Set(fiber.HeaderContentDisposition, fmt.Sprintf(`attachment; filename="%s"`, filename)) + return c.SendString(csvText) +} +``` + +- [ ] **Step 2: Wire routes into app.go** + +In `api/internal/app/app.go`, immediately after the existing `// Glovebox / documents` block (which ends with `tenantApp.Delete("/documents/:id", documentsCtrl.DeleteDocument)`), add: + +```go + // Total cost of ownership reporting (operating costs from Glovebox + // documents + optional acquisition/depreciation settings per vehicle). + tcoSvc := service.NewTCOService(logger, pdb, fetchAPI, authProvider, vehicleSvc) + tcoCtrl := controllers.NewTCOController(logger, tcoSvc, vehicleSvc) + tenantApp.Get("/tco/settings", tcoCtrl.GetSettings) + tenantApp.Put("/tco/settings", tcoCtrl.PutSettings) + tenantApp.Get("/tco/summary", tcoCtrl.GetSummary) + tenantApp.Get("/tco/vehicle/:tokenId", tcoCtrl.GetVehicleDetail) + tenantApp.Get("/tco/export.csv", tcoCtrl.ExportCSV) +``` + +(`logger`, `pdb`, `fetchAPI`, `authProvider`, and `vehicleSvc` are already in scope at that point in `app.go` — they're the same variables `documentsCtrl` was just constructed with.) + +- [ ] **Step 3: Verify it builds** + +Run: `cd api && go build ./...` +Expected: exits 0. + +- [ ] **Step 4: Run the full Go test suite** + +Run: `cd api && make test` +Expected: all tests pass, including the new `tco_service_test.go` cases. + +- [ ] **Step 5: Commit** + +```bash +git add api/internal/controllers/tco.go api/internal/app/app.go +git commit -m "feat(api): add TCO controller and wire /tco routes" +``` + +--- + +### Task 6: Frontend TCO types + service + +**Files:** +- Create: `web/src/types/tco.ts` +- Create: `web/src/services/tco-service.ts` + +**Interfaces:** +- Consumes: `ApiService.getInstance().get()/.put()` (existing, `web/src/services/api-service.ts`); `TenantService.getInstance().tenantIdHeader()` (existing, `web/src/services/tenant-service.ts`, used identically in `document-service.ts`). +- Produces: `TCOSettings`, `LineItem`, `VehicleTCOSummary`, `FleetTotals`, `FleetTCOSummary` types; `TCOService.getInstance().{getSettings, putSettings, getSummary, getVehicleDetail, exportCsv}`. Tasks 8–10 consume all of these. + +- [ ] **Step 1: Write the types (mirrors the Go JSON shapes from Task 4)** + +```typescript +// web/src/types/tco.ts +export interface TCOSettings { + tokenId: number; + purchasePrice?: number; + purchaseDate?: string; // YYYY-MM-DD + usefulLifeYears?: number; + currency: string; +} + +export interface LineItem { + vehicleTokenId: number; + vehicleLabel: string; + vin: string; + date: string; + category: string; + description: string; + amount: number; + currency: string; +} + +export interface VehicleTCOSummary { + tokenId: number; + vehicleLabel: string; + vin?: string; + operatingCost: number; + costByCategory: Record; + acquisitionCost: number; + depreciationToDate: number; + totalTco: number; + settings: TCOSettings; + lineItems: LineItem[]; +} + +export interface FleetTotals { + operatingCost: number; + acquisitionCost: number; + depreciationToDate: number; + totalTco: number; +} + +export interface FleetTCOSummary { + vehicles: VehicleTCOSummary[]; + fleet: FleetTotals; +} +``` + +- [ ] **Step 2: Write the service** + +```typescript +// web/src/services/tco-service.ts +import { ApiService } from './api-service.ts'; +import { TenantService } from './tenant-service.ts'; +import { FleetTCOSummary, TCOSettings, VehicleTCOSummary } from '../types/tco.ts'; + +export class TCOService { + private static instance: TCOService; + public static getInstance(): TCOService { + if (!TCOService.instance) { + TCOService.instance = new TCOService(); + } + return TCOService.instance; + } + + /** GET /tco/settings?tokenId=N. */ + getSettings(tokenId: number): Promise { + return ApiService.getInstance().get(`/tco/settings?tokenId=${tokenId}`); + } + + /** PUT /tco/settings. */ + putSettings(settings: TCOSettings): Promise { + return ApiService.getInstance().put('/tco/settings', settings); + } + + /** GET /tco/summary. Fleet-wide rollup. */ + getSummary(): Promise { + return ApiService.getInstance().get('/tco/summary'); + } + + /** GET /tco/vehicle/:tokenId. */ + getVehicleDetail(tokenId: number): Promise { + return ApiService.getInstance().get(`/tco/vehicle/${tokenId}`); + } + + /** Trigger a browser download of the CSV export. Omit tokenId for the fleet-wide export. */ + async exportCsv(tokenId?: number): Promise { + const base = ApiService.getInstance().getApiBaseUrl(); + const token = localStorage.getItem('token'); + const qs = tokenId ? `?tokenId=${tokenId}` : ''; + const res = await fetch(`${base}/tco/export.csv${qs}`, { + headers: { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...TenantService.getInstance().tenantIdHeader(), + }, + }); + if (!res.ok) { + throw new Error(`export failed: ${res.status} ${await res.text()}`); + } + const blob = await res.blob(); + const disposition = res.headers.get('Content-Disposition') || ''; + const match = /filename="?([^";]+)"?/i.exec(disposition); + const filename = match?.[1] || 'tco-export.csv'; + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } +} +``` + +- [ ] **Step 3: Verify the TypeScript compiles** + +Run: `cd web && tsc --noEmit` +Expected: exits 0. + +- [ ] **Step 4: Commit** + +```bash +git add web/src/types/tco.ts web/src/services/tco-service.ts +git commit -m "feat(web): add TCO types and API service" +``` + +--- + +### Task 7: Fuel category + cost-eligible category set (frontend) + +**Files:** +- Modify: `web/src/utils/document-categories.ts` + +**Interfaces:** +- Produces: `'dimo.document.vehicle.fuel'` entry in `CE_TYPE_TO_LABEL` and `UPLOAD_CATEGORIES`; `COST_ELIGIBLE_CATEGORIES: Set`. Task 8 consumes `COST_ELIGIBLE_CATEGORIES`; `upload-document-modal.ts` and `tco-view.ts` consume the label/category additions. + +- [ ] **Step 1: Add the Fuel category and the cost-eligible set** + +```typescript +// web/src/utils/document-categories.ts — full new contents + +/** + * Map of canonical CE types → friendly UI labels. + * Source of truth for both upload (category dropdown) and list (group headers). + */ +export const CE_TYPE_TO_LABEL: Record = { + 'dimo.document.vehicle.service.invoice': 'Service & parts', + 'dimo.document.vehicle.insurance': 'Insurance', + 'dimo.document.vehicle.registration': 'Registration', + 'dimo.document.vehicle.inspection': 'Inspection', + 'dimo.document.vehicle.title': 'Title', + 'dimo.document.vehicle.finance': 'Finance', + 'dimo.document.vehicle.regulatory.other':'Regulatory', + 'dimo.document.vehicle.maintenance': 'Service & parts', + 'dimo.document.vehicle.note': 'Note', + 'dimo.document.vehicle.expense': 'Other', + 'dimo.document.vehicle.condition': 'Other', + 'dimo.document.vehicle.fuel': 'Fuel', + 'dimo.document.unknown': 'Uncategorized', +}; + +/** + * Choices shown in the upload modal's category dropdown. + * Order is the same order they'll appear to the user. + */ +export const UPLOAD_CATEGORIES: Array<{ ceType: string; label: string }> = [ + { ceType: 'dimo.document.vehicle.insurance', label: 'Insurance' }, + { ceType: 'dimo.document.vehicle.registration', label: 'Registration' }, + { ceType: 'dimo.document.vehicle.inspection', label: 'Inspection' }, + { ceType: 'dimo.document.vehicle.service.invoice', label: 'Service & parts' }, + { ceType: 'dimo.document.vehicle.fuel', label: 'Fuel' }, + { ceType: 'dimo.document.vehicle.title', label: 'Title' }, + { ceType: 'dimo.document.vehicle.finance', label: 'Finance' }, + { ceType: 'dimo.document.vehicle.regulatory.other',label: 'Other regulatory' }, + { ceType: 'dimo.document.unknown', label: 'Other' }, +]; + +/** + * CE types we expect a vehicle owner to keep on file. Used by the glovebox + * "Missing" rail — anything in this set that the vehicle does not have an + * attestation for shows as a prompt to add it. + */ +export const EXPECTED_CE_TYPES: string[] = [ + 'dimo.document.vehicle.insurance', + 'dimo.document.vehicle.registration', + 'dimo.document.vehicle.inspection', +]; + +/** + * CE types eligible to carry a cost amount, and to count toward TCO operating + * costs. Mirrors the backend's CostEligibleCETypes (api/internal/service/tco_service.go). + * Everything except Note/Condition/Title. + */ +export const COST_ELIGIBLE_CATEGORIES = new Set([ + 'dimo.document.vehicle.service.invoice', + 'dimo.document.vehicle.insurance', + 'dimo.document.vehicle.registration', + 'dimo.document.vehicle.inspection', + 'dimo.document.vehicle.finance', + 'dimo.document.vehicle.regulatory.other', + 'dimo.document.vehicle.maintenance', + 'dimo.document.vehicle.expense', + 'dimo.document.vehicle.fuel', +]); + +export function categoryLabel(ceType: string): string { + return CE_TYPE_TO_LABEL[ceType] ?? 'Other'; +} +``` + +- [ ] **Step 2: Verify the TypeScript compiles** + +Run: `cd web && tsc --noEmit` +Expected: exits 0. + +- [ ] **Step 3: Commit** + +```bash +git add web/src/utils/document-categories.ts +git commit -m "feat(web): add Fuel document category and cost-eligible category set" +``` + +--- + +### Task 8: Amount field in upload-confirm modal + +**Files:** +- Modify: `web/src/elements/upload-document-modal.ts` + +**Interfaces:** +- Consumes: `COST_ELIGIBLE_CATEGORIES` (Task 7). +- Produces: `parsedData.amount` (number) and `parsedData.currency` (string, `"USD"`) included in the `POST /documents/attest` body when the user enters an amount for a cost-eligible category — consumed server-side by `extractAmount` (Task 2) when building TCO summaries. + +- [ ] **Step 1: Import the cost-eligible set and add amount state** + +In `web/src/elements/upload-document-modal.ts`, change the import line: + +```typescript +import { UPLOAD_CATEGORIES, categoryLabel, COST_ELIGIBLE_CATEGORIES } from '../utils/document-categories.ts'; +``` + +Add a new state field alongside the existing ones: + +```typescript + @state() private amount: string = ''; +``` + +- [ ] **Step 2: Render the Amount field in the confirm step** + +In `renderReview()`, insert this block immediately after the closing `` of the `.field` for Category (i.e. right before the `${this.errorMessage ? ... }` line): + +```typescript + ${COST_ELIGIBLE_CATEGORIES.has(this.selectedCategory) ? html` +
+ + { this.amount = (e.target as HTMLInputElement).value; }} + /> +
+ ` : nothing} +``` + +Add a matching CSS rule next to the existing `.field select, .field input[type="text"]` rule — it already targets `input[type="text"]`, so no new CSS is needed. + +- [ ] **Step 3: Include the amount in the attest request** + +In `onConfirm()`, change the `parsedData` line from: + +```typescript + parsedData: this.extractResult?.fields || {}, +``` + +to: + +```typescript + parsedData: this.buildParsedData(), +``` + +and add this new private method right above `onConfirm()`: + +```typescript + private buildParsedData(): Record { + const base = { ...(this.extractResult?.fields || {}) }; + const trimmed = this.amount.trim(); + if (COST_ELIGIBLE_CATEGORIES.has(this.selectedCategory) && trimmed !== '') { + const parsed = Number(trimmed); + if (!Number.isNaN(parsed)) { + base.amount = parsed; + base.currency = 'USD'; + } + } + return base; + } +``` + +- [ ] **Step 4: Verify the TypeScript compiles** + +Run: `cd web && tsc --noEmit` +Expected: exits 0. + +- [ ] **Step 5: Manual verification** + +Run: `cd web && npm run dev` (and `cd api && go run ./cmd/fleet-lite-app` if the API isn't already running). Open the app in Chrome, go to Glovebox, click "+", pick a file, confirm the category is "Service & parts" (or another cost-eligible category), verify the "Amount (optional)" field appears, enter `123.45`, save. Confirm no console errors. + +- [ ] **Step 6: Commit** + +```bash +git add web/src/elements/upload-document-modal.ts +git commit -m "feat(web): capture an optional dollar amount at document upload confirm" +``` + +--- + +### Task 9: TCO fleet view — nav, route, and fleet-wide table + +**Files:** +- Create: `web/src/views/tco-view.ts` +- Modify: `web/src/views/index.ts` +- Modify: `web/src/elements/side-nav.ts` +- Modify: `web/src/elements/app-root.ts` + +**Interfaces:** +- Consumes: `TCOService.getInstance().getSummary()/.exportCsv()` (Task 6); `Vehicle`-style formatting conventions already used in `glovebox.ts`. +- Produces: `` custom element rendering the fleet table (consumed as the `/:tenantId/tco` route render target); Task 10 extends this same file with the per-vehicle drilldown. + +- [ ] **Step 1: Write the fleet-table view** + +```typescript +// web/src/views/tco-view.ts +import { LitElement, html, css, nothing } from 'lit'; +import { msg } from '@lit/localize'; +import { customElement, property, state } from 'lit/decorators.js'; +import { sharedStyles } from '../global-styles.ts'; +import { TCOService } from '../services/tco-service.ts'; +import { FleetTCOSummary, VehicleTCOSummary } from '../types/tco.ts'; + +function formatMoney(n: number): string { + return n.toLocaleString(undefined, { style: 'currency', currency: 'USD' }); +} + +@customElement('tco-view') +export class TCOView extends LitElement { + @property({ type: String }) tenantId = ''; + + @state() private loading = true; + @state() private error = ''; + @state() private summary: FleetTCOSummary | null = null; + @state() private detailTokenId: number | null = null; + @state() private exporting = false; + + static styles = [ + sharedStyles, + css` + :host { + display: block; + width: 100%; + height: 100%; + overflow-y: auto; + padding: var(--stack-lg) var(--gutter); + } + .header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--stack-lg); + } + .header h1 { font: var(--type-headline-lg); color: var(--primary); } + button.export { + padding: 10px 18px; + border-radius: var(--radius-md); + background: var(--primary); + color: var(--on-primary); + border: none; + font: var(--type-label-caps); + letter-spacing: 0.05em; + text-transform: uppercase; + font-weight: 700; + cursor: pointer; + } + button.export:disabled { opacity: 0.5; cursor: not-allowed; } + table { width: 100%; border-collapse: collapse; } + th, td { + text-align: left; + padding: 12px 16px; + border-bottom: 1px solid var(--outline-variant); + font: var(--type-body-sm); + } + th { + font: var(--type-label-caps); + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--on-surface-variant); + } + tbody tr { cursor: pointer; } + tbody tr:hover { background: var(--surface-container-low); } + td.num { text-align: right; font-variant-numeric: tabular-nums; } + .empty, .loading, .error { + padding: 48px 0; + text-align: center; + color: var(--on-surface-variant); + } + .error { color: var(--error); } + tfoot td { font-weight: 700; border-top: 2px solid var(--outline-variant); border-bottom: none; } + `, + ]; + + async connectedCallback() { + super.connectedCallback(); + await this.loadSummary(); + } + + private async loadSummary() { + this.loading = true; + this.error = ''; + try { + this.summary = await TCOService.getInstance().getSummary(); + } catch (e) { + console.error('Failed to load TCO summary', e); + this.error = e instanceof Error ? e.message : msg('Failed to load TCO summary'); + } finally { + this.loading = false; + } + } + + private openVehicle(v: VehicleTCOSummary) { + this.detailTokenId = v.tokenId; + } + + private closeDetail = async () => { + this.detailTokenId = null; + await this.loadSummary(); + }; + + private async exportFleetCsv() { + this.exporting = true; + try { + await TCOService.getInstance().exportCsv(); + } catch (e) { + console.error('Failed to export TCO CSV', e); + } finally { + this.exporting = false; + } + } + + private renderFleetTable() { + const summary = this.summary!; + if (summary.vehicles.length === 0) { + return html`
${msg('No vehicles on this account.')}
`; + } + return html` + + + + + + + + + + + + ${summary.vehicles.map((v) => html` + this.openVehicle(v)}> + + + + + + + `)} + + + + + + + + + + +
${msg('Vehicle')}${msg('Operating cost')}${msg('Acquisition')}${msg('Depreciation to date')}${msg('Total TCO')}
${v.vehicleLabel}${formatMoney(v.operatingCost)}${formatMoney(v.acquisitionCost)}${formatMoney(v.depreciationToDate)}${formatMoney(v.totalTco)}
${msg('Fleet total')}${formatMoney(summary.fleet.operatingCost)}${formatMoney(summary.fleet.acquisitionCost)}${formatMoney(summary.fleet.depreciationToDate)}${formatMoney(summary.fleet.totalTco)}
+ `; + } + + render() { + return html` +
+

${msg('Total Cost of Ownership')}

+ +
+ ${this.loading + ? html`
${msg('Loading…')}
` + : this.error + ? html`
${this.error}
` + : this.summary + ? this.renderFleetTable() + : nothing + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'tco-view': TCOView; + } +} +``` + +- [ ] **Step 2: Export the new view** + +In `web/src/views/index.ts`, add: + +```typescript +export * from './tco-view.ts'; +``` + +- [ ] **Step 3: Add the nav entry** + +In `web/src/elements/side-nav.ts`, change the `NavKey` type to: + +```typescript +type NavKey = 'vehicles' | 'stats' | 'groups' | 'geofences' | 'glovebox' | 'tco' | 'settings'; +``` + +and add an entry to `ITEMS` (right after the `glovebox` entry): + +```typescript + { key: 'tco', icon: 'payments', label: () => msg('TCO'), suffix: '/tco' }, +``` + +- [ ] **Step 4: Wire the route in app-root.ts** + +In `web/src/elements/app-root.ts`: + +Add the import next to the other view imports: + +```typescript +import '../views/tco-view.ts'; +``` + +Update the `NavKey` type (must match `side-nav.ts`'s): + +```typescript +type NavKey = 'vehicles' | 'stats' | 'groups' | 'geofences' | 'glovebox' | 'tco' | 'settings'; +``` + +Add a route entry in the `Routes` constructor, right after the `glovebox` routes: + +```typescript + { path: '/:tenantId/tco', render: () => html`` }, +``` + +Add a branch in `deriveActive()`, right after the `glovebox` check: + +```typescript + if (path.startsWith('/tco')) return 'tco'; +``` + +- [ ] **Step 5: Verify the TypeScript compiles** + +Run: `cd web && tsc --noEmit` +Expected: exits 0. + +- [ ] **Step 6: Manual verification** + +Run: `cd web && npm run dev` (with the API running). Open the app in Chrome, confirm a "TCO" item appears in the side nav, click it, confirm the fleet table loads (or shows the empty state / error state correctly), and confirm "Export CSV" downloads a `tco-export.csv` file with a header row. + +- [ ] **Step 7: Commit** + +```bash +git add web/src/views/tco-view.ts web/src/views/index.ts web/src/elements/side-nav.ts web/src/elements/app-root.ts +git commit -m "feat(web): add TCO nav tab, route, and fleet-wide cost table" +``` + +--- + +### Task 10: TCO vehicle drilldown — breakdown, line items, acquisition settings, single-vehicle export + +**Files:** +- Modify: `web/src/views/tco-view.ts` + +**Interfaces:** +- Consumes: `TCOService.getInstance().getVehicleDetail()/.putSettings()/.exportCsv(tokenId)` (Task 6); `categoryLabel()` (Task 7); `detailTokenId`, `closeDetail`, `renderFleetTable`, styles (Task 9 — same file). +- Produces: full drilldown UI swapped in when `detailTokenId` is set; no new exports (still just ``). + +- [ ] **Step 1: Add drilldown state and data loading** + +In `web/src/views/tco-view.ts`, add the `categoryLabel` import: + +```typescript +import { categoryLabel } from '../utils/document-categories.ts'; +``` + +Add new state fields alongside the existing ones: + +```typescript + @state() private detail: VehicleTCOSummary | null = null; + @state() private loadingDetail = false; + @state() private detailError = ''; + @state() private exportingVehicle = false; + @state() private savingSettings = false; + @state() private settingsError = ''; + @state() private formPurchasePrice = ''; + @state() private formPurchaseDate = ''; + @state() private formUsefulLifeYears = ''; +``` + +Replace the `openVehicle` method with one that loads the detail: + +```typescript + private async openVehicle(v: VehicleTCOSummary) { + this.detailTokenId = v.tokenId; + this.loadingDetail = true; + this.detailError = ''; + try { + this.detail = await TCOService.getInstance().getVehicleDetail(v.tokenId); + this.formPurchasePrice = this.detail.settings.purchasePrice?.toString() ?? ''; + this.formPurchaseDate = this.detail.settings.purchaseDate ?? ''; + this.formUsefulLifeYears = this.detail.settings.usefulLifeYears?.toString() ?? ''; + } catch (e) { + console.error('Failed to load vehicle TCO detail', e); + this.detailError = e instanceof Error ? e.message : msg('Failed to load vehicle detail'); + } finally { + this.loadingDetail = false; + } + } +``` + +Update `closeDetail` to also clear the new state: + +```typescript + private closeDetail = async () => { + this.detailTokenId = null; + this.detail = null; + await this.loadSummary(); + }; +``` + +- [ ] **Step 2: Add the settings-save and single-vehicle export methods** + +Add these methods next to `exportFleetCsv`: + +```typescript + private async saveSettings() { + if (this.detailTokenId === null) return; + this.savingSettings = true; + this.settingsError = ''; + try { + const price = this.formPurchasePrice.trim() === '' ? undefined : Number(this.formPurchasePrice); + const life = this.formUsefulLifeYears.trim() === '' ? undefined : Number(this.formUsefulLifeYears); + await TCOService.getInstance().putSettings({ + tokenId: this.detailTokenId, + purchasePrice: price !== undefined && !Number.isNaN(price) ? price : undefined, + purchaseDate: this.formPurchaseDate.trim() === '' ? undefined : this.formPurchaseDate, + usefulLifeYears: life !== undefined && !Number.isNaN(life) ? life : undefined, + currency: 'USD', + }); + this.detail = await TCOService.getInstance().getVehicleDetail(this.detailTokenId); + } catch (e) { + console.error('Failed to save TCO settings', e); + this.settingsError = e instanceof Error ? e.message : msg('Failed to save'); + } finally { + this.savingSettings = false; + } + } + + private async exportVehicleCsv() { + if (this.detailTokenId === null) return; + this.exportingVehicle = true; + try { + await TCOService.getInstance().exportCsv(this.detailTokenId); + } catch (e) { + console.error('Failed to export vehicle TCO CSV', e); + } finally { + this.exportingVehicle = false; + } + } +``` + +- [ ] **Step 3: Add drilldown styles** + +Append to the `styles` array's `css` template (inside the existing backtick block, after the `tfoot td` rule): + +```css + .back { background: none; border: none; color: var(--on-surface-variant); cursor: pointer; margin-bottom: var(--stack-md); font: var(--type-body-sm); display: flex; align-items: center; gap: 4px; } + .back:hover { color: var(--primary); } + .breakdown { display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: var(--stack-lg); } + .stat { background: var(--surface-container-low); border: 1px solid var(--outline-variant); border-radius: var(--radius-md); padding: 16px; min-width: 160px; } + .stat .label { font: var(--type-label-caps); letter-spacing: 0.05em; text-transform: uppercase; color: var(--on-surface-variant); margin-bottom: 4px; } + .stat .value { font: var(--type-headline-md); color: var(--primary); } + .settings-form { display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-end; margin-bottom: var(--stack-lg); padding: 16px; background: var(--surface-container-low); border: 1px solid var(--outline-variant); border-radius: var(--radius-md); } + .settings-form .field { display: flex; flex-direction: column; gap: 4px; } + .settings-form label { font: var(--type-label-caps); letter-spacing: 0.05em; text-transform: uppercase; color: var(--on-surface-variant); } + .settings-form input { background: var(--surface-container); color: var(--on-surface); border: 1px solid var(--outline-variant); border-radius: var(--radius-md); padding: 8px 10px; font-family: inherit; } + .settings-form button { padding: 10px 16px; border-radius: var(--radius-md); background: var(--primary); color: var(--on-primary); border: none; font: var(--type-label-caps); letter-spacing: 0.05em; text-transform: uppercase; font-weight: 700; cursor: pointer; } + .settings-form button:disabled { opacity: 0.5; cursor: not-allowed; } +``` + +- [ ] **Step 4: Render the drilldown** + +Add this new render method, right before `render()`: + +```typescript + private renderDetail() { + if (this.loadingDetail) return html`
${msg('Loading…')}
`; + if (this.detailError) return html`
${this.detailError}
`; + const d = this.detail; + if (!d) return nothing; + const categories = Object.entries(d.costByCategory).sort((a, b) => b[1] - a[1]); + return html` + +
+

${d.vehicleLabel}

+ +
+ +
+
${msg('Operating cost')}
${formatMoney(d.operatingCost)}
+
${msg('Acquisition')}
${formatMoney(d.acquisitionCost)}
+
${msg('Depreciation to date')}
${formatMoney(d.depreciationToDate)}
+
${msg('Total TCO')}
${formatMoney(d.totalTco)}
+ ${categories.map(([cat, amount]) => html` +
${categoryLabel(cat)}
${formatMoney(amount)}
+ `)} +
+ +
+
+ + { this.formPurchasePrice = (e.target as HTMLInputElement).value; }} /> +
+
+ + { this.formPurchaseDate = (e.target as HTMLInputElement).value; }} /> +
+
+ + { this.formUsefulLifeYears = (e.target as HTMLInputElement).value; }} /> +
+ + ${this.settingsError ? html`${this.settingsError}` : nothing} +
+ + + + + + + + + + + + ${d.lineItems.map((li) => html` + + + + + + + `)} + +
${msg('Date')}${msg('Category')}${msg('Description')}${msg('Amount')}
${new Date(li.date).toLocaleDateString()}${categoryLabel(li.category)}${li.description}${formatMoney(li.amount)}
+ `; + } +``` + +- [ ] **Step 5: Swap in the drilldown from the top-level `render()`** + +Replace the existing `render()` method's body with a check for `detailTokenId`: + +```typescript + render() { + if (this.detailTokenId !== null) { + return this.renderDetail(); + } + return html` +
+

${msg('Total Cost of Ownership')}

+ +
+ ${this.loading + ? html`
${msg('Loading…')}
` + : this.error + ? html`
${this.error}
` + : this.summary + ? this.renderFleetTable() + : nothing + } + `; + } +``` + +- [ ] **Step 6: Verify the TypeScript compiles** + +Run: `cd web && tsc --noEmit` +Expected: exits 0. + +- [ ] **Step 7: Manual verification** + +Run: `cd web && npm run dev` (with the API running). In the TCO tab, click a vehicle row. Confirm the drilldown shows the category breakdown, an empty line-item table (or populated, if you completed Task 8's manual test), and the acquisition settings form. Enter a purchase price (`25000`), date, and useful life (`8`), click Save, confirm the stats update. Click "Export CSV" and confirm a `tco-vehicle-.csv` downloads. Click "Back to fleet" and confirm the fleet table reloads with updated acquisition/depreciation numbers for that vehicle. + +- [ ] **Step 8: Commit** + +```bash +git add web/src/views/tco-view.ts +git commit -m "feat(web): add TCO vehicle drilldown with acquisition settings and per-vehicle export" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** operating costs from documents (Tasks 2, 4, 8), acquisition + straight-line depreciation (Tasks 1, 3, 4, 10), fleet-wide + drilldown UI (Tasks 9, 10), CSV export both scopes (Tasks 4, 5, 9, 10), Fuel category (Task 7), cost-eligible category list (Tasks 2, 7), all-time-only scope (no date filtering added anywhere, matches spec). All spec sections have a task. +- **Type consistency checked:** Go `TCOSettings`/`VehicleTCOSummary`/`FleetTCOSummary`/`LineItem` JSON tags match the TS `TCOSettings`/`VehicleTCOSummary`/`FleetTCOSummary`/`LineItem` interfaces field-for-field (Task 6 mirrors Task 3/4's JSON tags exactly). `categoryLabelForCSV` (Go, Task 4) and `categoryLabel`/`CE_TYPE_TO_LABEL` (TS, Task 7) are intentionally-duplicated mirrors, called out in a comment. +- **No placeholders:** every step has complete, runnable code; no "TODO" or "similar to Task N" shortcuts. + From e8e2271f3ed9aa5e6fb742200a5fb67990afc5dd Mon Sep 17 00:00:00 2001 From: James Li Date: Tue, 7 Jul 2026 08:45:03 -0700 Subject: [PATCH 03/16] feat(api): add vehicle_tco_settings table for TCO acquisition/depreciation inputs --- .../20260710120000_vehicle_tco_settings.sql | 32 + api/internal/db/models/boil_table_names.go | 2 + api/internal/db/models/tenants.go | 237 +++- .../db/models/vehicle_tco_settings.go | 1220 +++++++++++++++++ 4 files changed, 1472 insertions(+), 19 deletions(-) create mode 100644 api/internal/db/migrations/20260710120000_vehicle_tco_settings.sql create mode 100644 api/internal/db/models/vehicle_tco_settings.go diff --git a/api/internal/db/migrations/20260710120000_vehicle_tco_settings.sql b/api/internal/db/migrations/20260710120000_vehicle_tco_settings.sql new file mode 100644 index 0000000..bdc9a7f --- /dev/null +++ b/api/internal/db/migrations/20260710120000_vehicle_tco_settings.sql @@ -0,0 +1,32 @@ +-- +goose Up +-- +goose StatementBegin +SELECT 'up SQL query'; + +-- Optional per-vehicle acquisition/depreciation inputs for the TCO report. +-- Scoped by tenant like vehicle_favorites. All purchase fields are nullable — +-- a vehicle with no row (or nulls) just shows operating costs, no +-- acquisition/depreciation line. See +-- docs/superpowers/specs/2026-07-06-tco-reporting-design.md. +CREATE TABLE IF NOT EXISTS vehicle_tco_settings ( + tenant_id UUID NOT NULL REFERENCES tenants (id) ON DELETE CASCADE, + token_id BIGINT NOT NULL, + purchase_price NUMERIC, + purchase_date DATE, + useful_life_years INTEGER, + currency TEXT NOT NULL DEFAULT 'USD', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (tenant_id, token_id) +); + +CREATE INDEX IF NOT EXISTS idx_vehicle_tco_settings_tenant_id ON vehicle_tco_settings (tenant_id); + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +SELECT 'down SQL query'; + +DROP TABLE IF EXISTS vehicle_tco_settings; + +-- +goose StatementEnd diff --git a/api/internal/db/models/boil_table_names.go b/api/internal/db/models/boil_table_names.go index e8adf4f..e0932db 100644 --- a/api/internal/db/models/boil_table_names.go +++ b/api/internal/db/models/boil_table_names.go @@ -15,6 +15,7 @@ var TableNames = struct { VehicleFavorites string VehicleFleetGroups string VehicleGeofences string + VehicleTcoSettings string Vehicles string }{ FleetGroups: "fleet_groups", @@ -28,5 +29,6 @@ var TableNames = struct { VehicleFavorites: "vehicle_favorites", VehicleFleetGroups: "vehicle_fleet_groups", VehicleGeofences: "vehicle_geofences", + VehicleTcoSettings: "vehicle_tco_settings", Vehicles: "vehicles", } diff --git a/api/internal/db/models/tenants.go b/api/internal/db/models/tenants.go index 5c465da..0cf46cb 100644 --- a/api/internal/db/models/tenants.go +++ b/api/internal/db/models/tenants.go @@ -87,29 +87,32 @@ var TenantWhere = struct { // TenantRels is where relationship names are stored. var TenantRels = struct { - FleetGroups string - Geofences string - Invitations string - TenantUsers string - VehicleFavorites string - Vehicles string + FleetGroups string + Geofences string + Invitations string + TenantUsers string + VehicleFavorites string + VehicleTcoSettings string + Vehicles string }{ - FleetGroups: "FleetGroups", - Geofences: "Geofences", - Invitations: "Invitations", - TenantUsers: "TenantUsers", - VehicleFavorites: "VehicleFavorites", - Vehicles: "Vehicles", + FleetGroups: "FleetGroups", + Geofences: "Geofences", + Invitations: "Invitations", + TenantUsers: "TenantUsers", + VehicleFavorites: "VehicleFavorites", + VehicleTcoSettings: "VehicleTcoSettings", + Vehicles: "Vehicles", } // tenantR is where relationships are stored. type tenantR struct { - FleetGroups FleetGroupSlice `boil:"FleetGroups" json:"FleetGroups" toml:"FleetGroups" yaml:"FleetGroups"` - Geofences GeofenceSlice `boil:"Geofences" json:"Geofences" toml:"Geofences" yaml:"Geofences"` - Invitations InvitationSlice `boil:"Invitations" json:"Invitations" toml:"Invitations" yaml:"Invitations"` - TenantUsers TenantUserSlice `boil:"TenantUsers" json:"TenantUsers" toml:"TenantUsers" yaml:"TenantUsers"` - VehicleFavorites VehicleFavoriteSlice `boil:"VehicleFavorites" json:"VehicleFavorites" toml:"VehicleFavorites" yaml:"VehicleFavorites"` - Vehicles VehicleSlice `boil:"Vehicles" json:"Vehicles" toml:"Vehicles" yaml:"Vehicles"` + FleetGroups FleetGroupSlice `boil:"FleetGroups" json:"FleetGroups" toml:"FleetGroups" yaml:"FleetGroups"` + Geofences GeofenceSlice `boil:"Geofences" json:"Geofences" toml:"Geofences" yaml:"Geofences"` + Invitations InvitationSlice `boil:"Invitations" json:"Invitations" toml:"Invitations" yaml:"Invitations"` + TenantUsers TenantUserSlice `boil:"TenantUsers" json:"TenantUsers" toml:"TenantUsers" yaml:"TenantUsers"` + VehicleFavorites VehicleFavoriteSlice `boil:"VehicleFavorites" json:"VehicleFavorites" toml:"VehicleFavorites" yaml:"VehicleFavorites"` + VehicleTcoSettings VehicleTcoSettingSlice `boil:"VehicleTcoSettings" json:"VehicleTcoSettings" toml:"VehicleTcoSettings" yaml:"VehicleTcoSettings"` + Vehicles VehicleSlice `boil:"Vehicles" json:"Vehicles" toml:"Vehicles" yaml:"Vehicles"` } // NewStruct creates a new relationship struct @@ -197,6 +200,22 @@ func (r *tenantR) GetVehicleFavorites() VehicleFavoriteSlice { return r.VehicleFavorites } +func (o *Tenant) GetVehicleTcoSettings() VehicleTcoSettingSlice { + if o == nil { + return nil + } + + return o.R.GetVehicleTcoSettings() +} + +func (r *tenantR) GetVehicleTcoSettings() VehicleTcoSettingSlice { + if r == nil { + return nil + } + + return r.VehicleTcoSettings +} + func (o *Tenant) GetVehicles() VehicleSlice { if o == nil { return nil @@ -599,6 +618,20 @@ func (o *Tenant) VehicleFavorites(mods ...qm.QueryMod) vehicleFavoriteQuery { return VehicleFavorites(queryMods...) } +// VehicleTcoSettings retrieves all the vehicle_tco_setting's VehicleTcoSettings with an executor. +func (o *Tenant) VehicleTcoSettings(mods ...qm.QueryMod) vehicleTcoSettingQuery { + var queryMods []qm.QueryMod + if len(mods) != 0 { + queryMods = append(queryMods, mods...) + } + + queryMods = append(queryMods, + qm.Where("\"vehicle_tco_settings\".\"tenant_id\"=?", o.ID), + ) + + return VehicleTcoSettings(queryMods...) +} + // Vehicles retrieves all the vehicle's Vehicles with an executor. func (o *Tenant) Vehicles(mods ...qm.QueryMod) vehicleQuery { var queryMods []qm.QueryMod @@ -1178,6 +1211,119 @@ func (tenantL) LoadVehicleFavorites(ctx context.Context, e boil.ContextExecutor, return nil } +// LoadVehicleTcoSettings allows an eager lookup of values, cached into the +// loaded structs of the objects. This is for a 1-M or N-M relationship. +func (tenantL) LoadVehicleTcoSettings(ctx context.Context, e boil.ContextExecutor, singular bool, maybeTenant any, mods queries.Applicator) error { + var slice []*Tenant + var object *Tenant + + if singular { + var ok bool + object, ok = maybeTenant.(*Tenant) + if !ok { + object = new(Tenant) + ok = queries.SetFromEmbeddedStruct(&object, &maybeTenant) + if !ok { + return errors.New(fmt.Sprintf("failed to set %T from embedded struct %T", object, maybeTenant)) + } + } + } else { + s, ok := maybeTenant.(*[]*Tenant) + if ok { + slice = *s + } else { + ok = queries.SetFromEmbeddedStruct(&slice, maybeTenant) + if !ok { + return errors.New(fmt.Sprintf("failed to set %T from embedded struct %T", slice, maybeTenant)) + } + } + } + + args := make(map[any]struct{}) + if singular { + if object.R == nil { + object.R = &tenantR{} + } + args[object.ID] = struct{}{} + } else { + for _, obj := range slice { + if obj.R == nil { + obj.R = &tenantR{} + } + args[obj.ID] = struct{}{} + } + } + + if len(args) == 0 { + return nil + } + + argsSlice := make([]any, len(args)) + i := 0 + for arg := range args { + argsSlice[i] = arg + i++ + } + + query := NewQuery( + qm.From(`vehicle_tco_settings`), + qm.WhereIn(`vehicle_tco_settings.tenant_id in ?`, argsSlice...), + ) + if mods != nil { + mods.Apply(query) + } + + results, err := query.QueryContext(ctx, e) + if err != nil { + return errors.Wrap(err, "failed to eager load vehicle_tco_settings") + } + + var resultSlice []*VehicleTcoSetting + if err = queries.Bind(results, &resultSlice); err != nil { + return errors.Wrap(err, "failed to bind eager loaded slice vehicle_tco_settings") + } + + if err = results.Close(); err != nil { + return errors.Wrap(err, "failed to close results in eager load on vehicle_tco_settings") + } + if err = results.Err(); err != nil { + return errors.Wrap(err, "error occurred during iteration of eager loaded relations for vehicle_tco_settings") + } + + if len(vehicleTcoSettingAfterSelectHooks) != 0 { + for _, obj := range resultSlice { + if err := obj.doAfterSelectHooks(ctx, e); err != nil { + return err + } + } + } + if singular { + object.R.VehicleTcoSettings = resultSlice + for _, foreign := range resultSlice { + if foreign.R == nil { + foreign.R = &vehicleTcoSettingR{} + } + foreign.R.Tenant = object + } + return nil + } + + for _, foreign := range resultSlice { + for _, local := range slice { + if local.ID == foreign.TenantID { + local.R.VehicleTcoSettings = append(local.R.VehicleTcoSettings, foreign) + if foreign.R == nil { + foreign.R = &vehicleTcoSettingR{} + } + foreign.R.Tenant = local + break + } + } + } + + return nil +} + // LoadVehicles allows an eager lookup of values, cached into the // loaded structs of the objects. This is for a 1-M or N-M relationship. func (tenantL) LoadVehicles(ctx context.Context, e boil.ContextExecutor, singular bool, maybeTenant any, mods queries.Applicator) error { @@ -1556,6 +1702,59 @@ func (o *Tenant) AddVehicleFavorites(ctx context.Context, exec boil.ContextExecu return nil } +// AddVehicleTcoSettings adds the given related objects to the existing relationships +// of the tenant, optionally inserting them as new records. +// Appends related to o.R.VehicleTcoSettings. +// Sets related.R.Tenant appropriately. +func (o *Tenant) AddVehicleTcoSettings(ctx context.Context, exec boil.ContextExecutor, insert bool, related ...*VehicleTcoSetting) error { + var err error + for _, rel := range related { + if insert { + rel.TenantID = o.ID + if err = rel.Insert(ctx, exec, boil.Infer()); err != nil { + return errors.Wrap(err, "failed to insert into foreign table") + } + } else { + updateQuery := fmt.Sprintf( + "UPDATE \"vehicle_tco_settings\" SET %s WHERE %s", + strmangle.SetParamNames("\"", "\"", 1, []string{"tenant_id"}), + strmangle.WhereClause("\"", "\"", 2, vehicleTcoSettingPrimaryKeyColumns), + ) + values := []any{o.ID, rel.TenantID, rel.TokenID} + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, updateQuery) + fmt.Fprintln(writer, values) + } + if _, err = exec.ExecContext(ctx, updateQuery, values...); err != nil { + return errors.Wrap(err, "failed to update foreign table") + } + + rel.TenantID = o.ID + } + } + + if o.R == nil { + o.R = &tenantR{ + VehicleTcoSettings: related, + } + } else { + o.R.VehicleTcoSettings = append(o.R.VehicleTcoSettings, related...) + } + + for _, rel := range related { + if rel.R == nil { + rel.R = &vehicleTcoSettingR{ + Tenant: o, + } + } else { + rel.R.Tenant = o + } + } + return nil +} + // AddVehicles adds the given related objects to the existing relationships // of the tenant, optionally inserting them as new records. // Appends related to o.R.Vehicles. @@ -1972,7 +2171,7 @@ func (o *Tenant) Upsert(ctx context.Context, exec boil.ContextExecutor, updateOn value := reflect.Indirect(reflect.ValueOf(o)) vals := queries.ValuesFromMapping(value, cache.valueMapping) - var returns []interface{} + var returns []any if len(cache.retMapping) != 0 { returns = queries.PtrsFromMapping(value, cache.retMapping) } diff --git a/api/internal/db/models/vehicle_tco_settings.go b/api/internal/db/models/vehicle_tco_settings.go new file mode 100644 index 0000000..1b57fa8 --- /dev/null +++ b/api/internal/db/models/vehicle_tco_settings.go @@ -0,0 +1,1220 @@ +// Code generated by SQLBoiler 4.19.7 (https://github.com/aarondl/sqlboiler). DO NOT EDIT. +// This file is meant to be re-generated in place and/or deleted at any time. + +package models + +import ( + "context" + "database/sql" + "fmt" + "reflect" + "strconv" + "strings" + "sync" + "time" + + "github.com/aarondl/null/v8" + "github.com/aarondl/sqlboiler/v4/boil" + "github.com/aarondl/sqlboiler/v4/queries" + "github.com/aarondl/sqlboiler/v4/queries/qm" + "github.com/aarondl/sqlboiler/v4/queries/qmhelper" + "github.com/aarondl/sqlboiler/v4/types" + "github.com/aarondl/strmangle" + "github.com/friendsofgo/errors" +) + +// VehicleTcoSetting is an object representing the database table. +type VehicleTcoSetting struct { + TenantID string `boil:"tenant_id" json:"tenant_id" toml:"tenant_id" yaml:"tenant_id"` + TokenID int64 `boil:"token_id" json:"token_id" toml:"token_id" yaml:"token_id"` + PurchasePrice types.NullDecimal `boil:"purchase_price" json:"purchase_price,omitempty" toml:"purchase_price" yaml:"purchase_price,omitempty"` + PurchaseDate null.Time `boil:"purchase_date" json:"purchase_date,omitempty" toml:"purchase_date" yaml:"purchase_date,omitempty"` + UsefulLifeYears null.Int `boil:"useful_life_years" json:"useful_life_years,omitempty" toml:"useful_life_years" yaml:"useful_life_years,omitempty"` + Currency string `boil:"currency" json:"currency" toml:"currency" yaml:"currency"` + CreatedAt time.Time `boil:"created_at" json:"created_at" toml:"created_at" yaml:"created_at"` + UpdatedAt time.Time `boil:"updated_at" json:"updated_at" toml:"updated_at" yaml:"updated_at"` + + R *vehicleTcoSettingR `boil:"-" json:"-" toml:"-" yaml:"-"` + L vehicleTcoSettingL `boil:"-" json:"-" toml:"-" yaml:"-"` +} + +var VehicleTcoSettingColumns = struct { + TenantID string + TokenID string + PurchasePrice string + PurchaseDate string + UsefulLifeYears string + Currency string + CreatedAt string + UpdatedAt string +}{ + TenantID: "tenant_id", + TokenID: "token_id", + PurchasePrice: "purchase_price", + PurchaseDate: "purchase_date", + UsefulLifeYears: "useful_life_years", + Currency: "currency", + CreatedAt: "created_at", + UpdatedAt: "updated_at", +} + +var VehicleTcoSettingTableColumns = struct { + TenantID string + TokenID string + PurchasePrice string + PurchaseDate string + UsefulLifeYears string + Currency string + CreatedAt string + UpdatedAt string +}{ + TenantID: "vehicle_tco_settings.tenant_id", + TokenID: "vehicle_tco_settings.token_id", + PurchasePrice: "vehicle_tco_settings.purchase_price", + PurchaseDate: "vehicle_tco_settings.purchase_date", + UsefulLifeYears: "vehicle_tco_settings.useful_life_years", + Currency: "vehicle_tco_settings.currency", + CreatedAt: "vehicle_tco_settings.created_at", + UpdatedAt: "vehicle_tco_settings.updated_at", +} + +// Generated where + +type whereHelpertypes_NullDecimal struct{ field string } + +func (w whereHelpertypes_NullDecimal) EQ(x types.NullDecimal) qm.QueryMod { + return qmhelper.WhereNullEQ(w.field, false, x) +} +func (w whereHelpertypes_NullDecimal) NEQ(x types.NullDecimal) qm.QueryMod { + return qmhelper.WhereNullEQ(w.field, true, x) +} +func (w whereHelpertypes_NullDecimal) LT(x types.NullDecimal) qm.QueryMod { + return qmhelper.Where(w.field, qmhelper.LT, x) +} +func (w whereHelpertypes_NullDecimal) LTE(x types.NullDecimal) qm.QueryMod { + return qmhelper.Where(w.field, qmhelper.LTE, x) +} +func (w whereHelpertypes_NullDecimal) GT(x types.NullDecimal) qm.QueryMod { + return qmhelper.Where(w.field, qmhelper.GT, x) +} +func (w whereHelpertypes_NullDecimal) GTE(x types.NullDecimal) qm.QueryMod { + return qmhelper.Where(w.field, qmhelper.GTE, x) +} + +func (w whereHelpertypes_NullDecimal) IsNull() qm.QueryMod { return qmhelper.WhereIsNull(w.field) } +func (w whereHelpertypes_NullDecimal) IsNotNull() qm.QueryMod { + return qmhelper.WhereIsNotNull(w.field) +} + +var VehicleTcoSettingWhere = struct { + TenantID whereHelperstring + TokenID whereHelperint64 + PurchasePrice whereHelpertypes_NullDecimal + PurchaseDate whereHelpernull_Time + UsefulLifeYears whereHelpernull_Int + Currency whereHelperstring + CreatedAt whereHelpertime_Time + UpdatedAt whereHelpertime_Time +}{ + TenantID: whereHelperstring{field: "\"vehicle_tco_settings\".\"tenant_id\""}, + TokenID: whereHelperint64{field: "\"vehicle_tco_settings\".\"token_id\""}, + PurchasePrice: whereHelpertypes_NullDecimal{field: "\"vehicle_tco_settings\".\"purchase_price\""}, + PurchaseDate: whereHelpernull_Time{field: "\"vehicle_tco_settings\".\"purchase_date\""}, + UsefulLifeYears: whereHelpernull_Int{field: "\"vehicle_tco_settings\".\"useful_life_years\""}, + Currency: whereHelperstring{field: "\"vehicle_tco_settings\".\"currency\""}, + CreatedAt: whereHelpertime_Time{field: "\"vehicle_tco_settings\".\"created_at\""}, + UpdatedAt: whereHelpertime_Time{field: "\"vehicle_tco_settings\".\"updated_at\""}, +} + +// VehicleTcoSettingRels is where relationship names are stored. +var VehicleTcoSettingRels = struct { + Tenant string +}{ + Tenant: "Tenant", +} + +// vehicleTcoSettingR is where relationships are stored. +type vehicleTcoSettingR struct { + Tenant *Tenant `boil:"Tenant" json:"Tenant" toml:"Tenant" yaml:"Tenant"` +} + +// NewStruct creates a new relationship struct +func (*vehicleTcoSettingR) NewStruct() *vehicleTcoSettingR { + return &vehicleTcoSettingR{} +} + +func (o *VehicleTcoSetting) GetTenant() *Tenant { + if o == nil { + return nil + } + + return o.R.GetTenant() +} + +func (r *vehicleTcoSettingR) GetTenant() *Tenant { + if r == nil { + return nil + } + + return r.Tenant +} + +// vehicleTcoSettingL is where Load methods for each relationship are stored. +type vehicleTcoSettingL struct{} + +var ( + vehicleTcoSettingAllColumns = []string{"tenant_id", "token_id", "purchase_price", "purchase_date", "useful_life_years", "currency", "created_at", "updated_at"} + vehicleTcoSettingColumnsWithoutDefault = []string{"tenant_id", "token_id"} + vehicleTcoSettingColumnsWithDefault = []string{"purchase_price", "purchase_date", "useful_life_years", "currency", "created_at", "updated_at"} + vehicleTcoSettingPrimaryKeyColumns = []string{"tenant_id", "token_id"} + vehicleTcoSettingGeneratedColumns = []string{} +) + +type ( + // VehicleTcoSettingSlice is an alias for a slice of pointers to VehicleTcoSetting. + // This should almost always be used instead of []VehicleTcoSetting. + VehicleTcoSettingSlice []*VehicleTcoSetting + // VehicleTcoSettingHook is the signature for custom VehicleTcoSetting hook methods + VehicleTcoSettingHook func(context.Context, boil.ContextExecutor, *VehicleTcoSetting) error + + vehicleTcoSettingQuery struct { + *queries.Query + } +) + +// Cache for insert, update and upsert +var ( + vehicleTcoSettingType = reflect.TypeOf(&VehicleTcoSetting{}) + vehicleTcoSettingMapping = queries.MakeStructMapping(vehicleTcoSettingType) + vehicleTcoSettingPrimaryKeyMapping, _ = queries.BindMapping(vehicleTcoSettingType, vehicleTcoSettingMapping, vehicleTcoSettingPrimaryKeyColumns) + vehicleTcoSettingInsertCacheMut sync.RWMutex + vehicleTcoSettingInsertCache = make(map[string]insertCache) + vehicleTcoSettingUpdateCacheMut sync.RWMutex + vehicleTcoSettingUpdateCache = make(map[string]updateCache) + vehicleTcoSettingUpsertCacheMut sync.RWMutex + vehicleTcoSettingUpsertCache = make(map[string]insertCache) +) + +var ( + // Force time package dependency for automated UpdatedAt/CreatedAt. + _ = time.Second + // Force qmhelper dependency for where clause generation (which doesn't + // always happen) + _ = qmhelper.Where +) + +var vehicleTcoSettingAfterSelectMu sync.Mutex +var vehicleTcoSettingAfterSelectHooks []VehicleTcoSettingHook + +var vehicleTcoSettingBeforeInsertMu sync.Mutex +var vehicleTcoSettingBeforeInsertHooks []VehicleTcoSettingHook +var vehicleTcoSettingAfterInsertMu sync.Mutex +var vehicleTcoSettingAfterInsertHooks []VehicleTcoSettingHook + +var vehicleTcoSettingBeforeUpdateMu sync.Mutex +var vehicleTcoSettingBeforeUpdateHooks []VehicleTcoSettingHook +var vehicleTcoSettingAfterUpdateMu sync.Mutex +var vehicleTcoSettingAfterUpdateHooks []VehicleTcoSettingHook + +var vehicleTcoSettingBeforeDeleteMu sync.Mutex +var vehicleTcoSettingBeforeDeleteHooks []VehicleTcoSettingHook +var vehicleTcoSettingAfterDeleteMu sync.Mutex +var vehicleTcoSettingAfterDeleteHooks []VehicleTcoSettingHook + +var vehicleTcoSettingBeforeUpsertMu sync.Mutex +var vehicleTcoSettingBeforeUpsertHooks []VehicleTcoSettingHook +var vehicleTcoSettingAfterUpsertMu sync.Mutex +var vehicleTcoSettingAfterUpsertHooks []VehicleTcoSettingHook + +// doAfterSelectHooks executes all "after Select" hooks. +func (o *VehicleTcoSetting) doAfterSelectHooks(ctx context.Context, exec boil.ContextExecutor) (err error) { + if boil.HooksAreSkipped(ctx) { + return nil + } + + for _, hook := range vehicleTcoSettingAfterSelectHooks { + if err := hook(ctx, exec, o); err != nil { + return err + } + } + + return nil +} + +// doBeforeInsertHooks executes all "before insert" hooks. +func (o *VehicleTcoSetting) doBeforeInsertHooks(ctx context.Context, exec boil.ContextExecutor) (err error) { + if boil.HooksAreSkipped(ctx) { + return nil + } + + for _, hook := range vehicleTcoSettingBeforeInsertHooks { + if err := hook(ctx, exec, o); err != nil { + return err + } + } + + return nil +} + +// doAfterInsertHooks executes all "after Insert" hooks. +func (o *VehicleTcoSetting) doAfterInsertHooks(ctx context.Context, exec boil.ContextExecutor) (err error) { + if boil.HooksAreSkipped(ctx) { + return nil + } + + for _, hook := range vehicleTcoSettingAfterInsertHooks { + if err := hook(ctx, exec, o); err != nil { + return err + } + } + + return nil +} + +// doBeforeUpdateHooks executes all "before Update" hooks. +func (o *VehicleTcoSetting) doBeforeUpdateHooks(ctx context.Context, exec boil.ContextExecutor) (err error) { + if boil.HooksAreSkipped(ctx) { + return nil + } + + for _, hook := range vehicleTcoSettingBeforeUpdateHooks { + if err := hook(ctx, exec, o); err != nil { + return err + } + } + + return nil +} + +// doAfterUpdateHooks executes all "after Update" hooks. +func (o *VehicleTcoSetting) doAfterUpdateHooks(ctx context.Context, exec boil.ContextExecutor) (err error) { + if boil.HooksAreSkipped(ctx) { + return nil + } + + for _, hook := range vehicleTcoSettingAfterUpdateHooks { + if err := hook(ctx, exec, o); err != nil { + return err + } + } + + return nil +} + +// doBeforeDeleteHooks executes all "before Delete" hooks. +func (o *VehicleTcoSetting) doBeforeDeleteHooks(ctx context.Context, exec boil.ContextExecutor) (err error) { + if boil.HooksAreSkipped(ctx) { + return nil + } + + for _, hook := range vehicleTcoSettingBeforeDeleteHooks { + if err := hook(ctx, exec, o); err != nil { + return err + } + } + + return nil +} + +// doAfterDeleteHooks executes all "after Delete" hooks. +func (o *VehicleTcoSetting) doAfterDeleteHooks(ctx context.Context, exec boil.ContextExecutor) (err error) { + if boil.HooksAreSkipped(ctx) { + return nil + } + + for _, hook := range vehicleTcoSettingAfterDeleteHooks { + if err := hook(ctx, exec, o); err != nil { + return err + } + } + + return nil +} + +// doBeforeUpsertHooks executes all "before Upsert" hooks. +func (o *VehicleTcoSetting) doBeforeUpsertHooks(ctx context.Context, exec boil.ContextExecutor) (err error) { + if boil.HooksAreSkipped(ctx) { + return nil + } + + for _, hook := range vehicleTcoSettingBeforeUpsertHooks { + if err := hook(ctx, exec, o); err != nil { + return err + } + } + + return nil +} + +// doAfterUpsertHooks executes all "after Upsert" hooks. +func (o *VehicleTcoSetting) doAfterUpsertHooks(ctx context.Context, exec boil.ContextExecutor) (err error) { + if boil.HooksAreSkipped(ctx) { + return nil + } + + for _, hook := range vehicleTcoSettingAfterUpsertHooks { + if err := hook(ctx, exec, o); err != nil { + return err + } + } + + return nil +} + +// AddVehicleTcoSettingHook registers your hook function for all future operations. +func AddVehicleTcoSettingHook(hookPoint boil.HookPoint, vehicleTcoSettingHook VehicleTcoSettingHook) { + switch hookPoint { + case boil.AfterSelectHook: + vehicleTcoSettingAfterSelectMu.Lock() + vehicleTcoSettingAfterSelectHooks = append(vehicleTcoSettingAfterSelectHooks, vehicleTcoSettingHook) + vehicleTcoSettingAfterSelectMu.Unlock() + case boil.BeforeInsertHook: + vehicleTcoSettingBeforeInsertMu.Lock() + vehicleTcoSettingBeforeInsertHooks = append(vehicleTcoSettingBeforeInsertHooks, vehicleTcoSettingHook) + vehicleTcoSettingBeforeInsertMu.Unlock() + case boil.AfterInsertHook: + vehicleTcoSettingAfterInsertMu.Lock() + vehicleTcoSettingAfterInsertHooks = append(vehicleTcoSettingAfterInsertHooks, vehicleTcoSettingHook) + vehicleTcoSettingAfterInsertMu.Unlock() + case boil.BeforeUpdateHook: + vehicleTcoSettingBeforeUpdateMu.Lock() + vehicleTcoSettingBeforeUpdateHooks = append(vehicleTcoSettingBeforeUpdateHooks, vehicleTcoSettingHook) + vehicleTcoSettingBeforeUpdateMu.Unlock() + case boil.AfterUpdateHook: + vehicleTcoSettingAfterUpdateMu.Lock() + vehicleTcoSettingAfterUpdateHooks = append(vehicleTcoSettingAfterUpdateHooks, vehicleTcoSettingHook) + vehicleTcoSettingAfterUpdateMu.Unlock() + case boil.BeforeDeleteHook: + vehicleTcoSettingBeforeDeleteMu.Lock() + vehicleTcoSettingBeforeDeleteHooks = append(vehicleTcoSettingBeforeDeleteHooks, vehicleTcoSettingHook) + vehicleTcoSettingBeforeDeleteMu.Unlock() + case boil.AfterDeleteHook: + vehicleTcoSettingAfterDeleteMu.Lock() + vehicleTcoSettingAfterDeleteHooks = append(vehicleTcoSettingAfterDeleteHooks, vehicleTcoSettingHook) + vehicleTcoSettingAfterDeleteMu.Unlock() + case boil.BeforeUpsertHook: + vehicleTcoSettingBeforeUpsertMu.Lock() + vehicleTcoSettingBeforeUpsertHooks = append(vehicleTcoSettingBeforeUpsertHooks, vehicleTcoSettingHook) + vehicleTcoSettingBeforeUpsertMu.Unlock() + case boil.AfterUpsertHook: + vehicleTcoSettingAfterUpsertMu.Lock() + vehicleTcoSettingAfterUpsertHooks = append(vehicleTcoSettingAfterUpsertHooks, vehicleTcoSettingHook) + vehicleTcoSettingAfterUpsertMu.Unlock() + } +} + +// One returns a single vehicleTcoSetting record from the query. +func (q vehicleTcoSettingQuery) One(ctx context.Context, exec boil.ContextExecutor) (*VehicleTcoSetting, error) { + o := &VehicleTcoSetting{} + + queries.SetLimit(q.Query, 1) + + err := q.Bind(ctx, exec, o) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, sql.ErrNoRows + } + return nil, errors.Wrap(err, "models: failed to execute a one query for vehicle_tco_settings") + } + + if err := o.doAfterSelectHooks(ctx, exec); err != nil { + return o, err + } + + return o, nil +} + +// All returns all VehicleTcoSetting records from the query. +func (q vehicleTcoSettingQuery) All(ctx context.Context, exec boil.ContextExecutor) (VehicleTcoSettingSlice, error) { + var o []*VehicleTcoSetting + + err := q.Bind(ctx, exec, &o) + if err != nil { + return nil, errors.Wrap(err, "models: failed to assign all query results to VehicleTcoSetting slice") + } + + if len(vehicleTcoSettingAfterSelectHooks) != 0 { + for _, obj := range o { + if err := obj.doAfterSelectHooks(ctx, exec); err != nil { + return o, err + } + } + } + + return o, nil +} + +// Count returns the count of all VehicleTcoSetting records in the query. +func (q vehicleTcoSettingQuery) Count(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + var count int64 + + queries.SetSelect(q.Query, nil) + queries.SetCount(q.Query) + + err := q.Query.QueryRowContext(ctx, exec).Scan(&count) + if err != nil { + return 0, errors.Wrap(err, "models: failed to count vehicle_tco_settings rows") + } + + return count, nil +} + +// Exists checks if the row exists in the table. +func (q vehicleTcoSettingQuery) Exists(ctx context.Context, exec boil.ContextExecutor) (bool, error) { + var count int64 + + queries.SetSelect(q.Query, nil) + queries.SetCount(q.Query) + queries.SetLimit(q.Query, 1) + + err := q.Query.QueryRowContext(ctx, exec).Scan(&count) + if err != nil { + return false, errors.Wrap(err, "models: failed to check if vehicle_tco_settings exists") + } + + return count > 0, nil +} + +// Tenant pointed to by the foreign key. +func (o *VehicleTcoSetting) Tenant(mods ...qm.QueryMod) tenantQuery { + queryMods := []qm.QueryMod{ + qm.Where("\"id\" = ?", o.TenantID), + } + + queryMods = append(queryMods, mods...) + + return Tenants(queryMods...) +} + +// LoadTenant allows an eager lookup of values, cached into the +// loaded structs of the objects. This is for an N-1 relationship. +func (vehicleTcoSettingL) LoadTenant(ctx context.Context, e boil.ContextExecutor, singular bool, maybeVehicleTcoSetting any, mods queries.Applicator) error { + var slice []*VehicleTcoSetting + var object *VehicleTcoSetting + + if singular { + var ok bool + object, ok = maybeVehicleTcoSetting.(*VehicleTcoSetting) + if !ok { + object = new(VehicleTcoSetting) + ok = queries.SetFromEmbeddedStruct(&object, &maybeVehicleTcoSetting) + if !ok { + return errors.New(fmt.Sprintf("failed to set %T from embedded struct %T", object, maybeVehicleTcoSetting)) + } + } + } else { + s, ok := maybeVehicleTcoSetting.(*[]*VehicleTcoSetting) + if ok { + slice = *s + } else { + ok = queries.SetFromEmbeddedStruct(&slice, maybeVehicleTcoSetting) + if !ok { + return errors.New(fmt.Sprintf("failed to set %T from embedded struct %T", slice, maybeVehicleTcoSetting)) + } + } + } + + args := make(map[any]struct{}) + if singular { + if object.R == nil { + object.R = &vehicleTcoSettingR{} + } + args[object.TenantID] = struct{}{} + + } else { + for _, obj := range slice { + if obj.R == nil { + obj.R = &vehicleTcoSettingR{} + } + + args[obj.TenantID] = struct{}{} + + } + } + + if len(args) == 0 { + return nil + } + + argsSlice := make([]any, len(args)) + i := 0 + for arg := range args { + argsSlice[i] = arg + i++ + } + + query := NewQuery( + qm.From(`tenants`), + qm.WhereIn(`tenants.id in ?`, argsSlice...), + ) + if mods != nil { + mods.Apply(query) + } + + results, err := query.QueryContext(ctx, e) + if err != nil { + return errors.Wrap(err, "failed to eager load Tenant") + } + + var resultSlice []*Tenant + if err = queries.Bind(results, &resultSlice); err != nil { + return errors.Wrap(err, "failed to bind eager loaded slice Tenant") + } + + if err = results.Close(); err != nil { + return errors.Wrap(err, "failed to close results of eager load for tenants") + } + if err = results.Err(); err != nil { + return errors.Wrap(err, "error occurred during iteration of eager loaded relations for tenants") + } + + if len(tenantAfterSelectHooks) != 0 { + for _, obj := range resultSlice { + if err := obj.doAfterSelectHooks(ctx, e); err != nil { + return err + } + } + } + + if len(resultSlice) == 0 { + return nil + } + + if singular { + foreign := resultSlice[0] + object.R.Tenant = foreign + if foreign.R == nil { + foreign.R = &tenantR{} + } + foreign.R.VehicleTcoSettings = append(foreign.R.VehicleTcoSettings, object) + return nil + } + + for _, local := range slice { + for _, foreign := range resultSlice { + if local.TenantID == foreign.ID { + local.R.Tenant = foreign + if foreign.R == nil { + foreign.R = &tenantR{} + } + foreign.R.VehicleTcoSettings = append(foreign.R.VehicleTcoSettings, local) + break + } + } + } + + return nil +} + +// SetTenant of the vehicleTcoSetting to the related item. +// Sets o.R.Tenant to related. +// Adds o to related.R.VehicleTcoSettings. +func (o *VehicleTcoSetting) SetTenant(ctx context.Context, exec boil.ContextExecutor, insert bool, related *Tenant) error { + var err error + if insert { + if err = related.Insert(ctx, exec, boil.Infer()); err != nil { + return errors.Wrap(err, "failed to insert into foreign table") + } + } + + updateQuery := fmt.Sprintf( + "UPDATE \"vehicle_tco_settings\" SET %s WHERE %s", + strmangle.SetParamNames("\"", "\"", 1, []string{"tenant_id"}), + strmangle.WhereClause("\"", "\"", 2, vehicleTcoSettingPrimaryKeyColumns), + ) + values := []any{related.ID, o.TenantID, o.TokenID} + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, updateQuery) + fmt.Fprintln(writer, values) + } + if _, err = exec.ExecContext(ctx, updateQuery, values...); err != nil { + return errors.Wrap(err, "failed to update local table") + } + + o.TenantID = related.ID + if o.R == nil { + o.R = &vehicleTcoSettingR{ + Tenant: related, + } + } else { + o.R.Tenant = related + } + + if related.R == nil { + related.R = &tenantR{ + VehicleTcoSettings: VehicleTcoSettingSlice{o}, + } + } else { + related.R.VehicleTcoSettings = append(related.R.VehicleTcoSettings, o) + } + + return nil +} + +// VehicleTcoSettings retrieves all the records using an executor. +func VehicleTcoSettings(mods ...qm.QueryMod) vehicleTcoSettingQuery { + mods = append(mods, qm.From("\"vehicle_tco_settings\"")) + q := NewQuery(mods...) + if len(queries.GetSelect(q)) == 0 { + queries.SetSelect(q, []string{"\"vehicle_tco_settings\".*"}) + } + + return vehicleTcoSettingQuery{q} +} + +// FindVehicleTcoSetting retrieves a single record by ID with an executor. +// If selectCols is empty Find will return all columns. +func FindVehicleTcoSetting(ctx context.Context, exec boil.ContextExecutor, tenantID string, tokenID int64, selectCols ...string) (*VehicleTcoSetting, error) { + vehicleTcoSettingObj := &VehicleTcoSetting{} + + sel := "*" + if len(selectCols) > 0 { + sel = strings.Join(strmangle.IdentQuoteSlice(dialect.LQ, dialect.RQ, selectCols), ",") + } + query := fmt.Sprintf( + "select %s from \"vehicle_tco_settings\" where \"tenant_id\"=$1 AND \"token_id\"=$2", sel, + ) + + q := queries.Raw(query, tenantID, tokenID) + + err := q.Bind(ctx, exec, vehicleTcoSettingObj) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, sql.ErrNoRows + } + return nil, errors.Wrap(err, "models: unable to select from vehicle_tco_settings") + } + + if err = vehicleTcoSettingObj.doAfterSelectHooks(ctx, exec); err != nil { + return vehicleTcoSettingObj, err + } + + return vehicleTcoSettingObj, nil +} + +// Insert a single record using an executor. +// See boil.Columns.InsertColumnSet documentation to understand column list inference for inserts. +func (o *VehicleTcoSetting) Insert(ctx context.Context, exec boil.ContextExecutor, columns boil.Columns) error { + if o == nil { + return errors.New("models: no vehicle_tco_settings provided for insertion") + } + + var err error + if !boil.TimestampsAreSkipped(ctx) { + currTime := time.Now().In(boil.GetLocation()) + + if o.CreatedAt.IsZero() { + o.CreatedAt = currTime + } + if o.UpdatedAt.IsZero() { + o.UpdatedAt = currTime + } + } + + if err := o.doBeforeInsertHooks(ctx, exec); err != nil { + return err + } + + nzDefaults := queries.NonZeroDefaultSet(vehicleTcoSettingColumnsWithDefault, o) + + key := makeCacheKey(columns, nzDefaults) + vehicleTcoSettingInsertCacheMut.RLock() + cache, cached := vehicleTcoSettingInsertCache[key] + vehicleTcoSettingInsertCacheMut.RUnlock() + + if !cached { + wl, returnColumns := columns.InsertColumnSet( + vehicleTcoSettingAllColumns, + vehicleTcoSettingColumnsWithDefault, + vehicleTcoSettingColumnsWithoutDefault, + nzDefaults, + ) + + cache.valueMapping, err = queries.BindMapping(vehicleTcoSettingType, vehicleTcoSettingMapping, wl) + if err != nil { + return err + } + cache.retMapping, err = queries.BindMapping(vehicleTcoSettingType, vehicleTcoSettingMapping, returnColumns) + if err != nil { + return err + } + if len(wl) != 0 { + cache.query = fmt.Sprintf("INSERT INTO \"vehicle_tco_settings\" (\"%s\") %%sVALUES (%s)%%s", strings.Join(wl, "\",\""), strmangle.Placeholders(dialect.UseIndexPlaceholders, len(wl), 1, 1)) + } else { + cache.query = "INSERT INTO \"vehicle_tco_settings\" %sDEFAULT VALUES%s" + } + + var queryOutput, queryReturning string + + if len(cache.retMapping) != 0 { + queryReturning = fmt.Sprintf(" RETURNING \"%s\"", strings.Join(returnColumns, "\",\"")) + } + + cache.query = fmt.Sprintf(cache.query, queryOutput, queryReturning) + } + + value := reflect.Indirect(reflect.ValueOf(o)) + vals := queries.ValuesFromMapping(value, cache.valueMapping) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, cache.query) + fmt.Fprintln(writer, vals) + } + + if len(cache.retMapping) != 0 { + err = exec.QueryRowContext(ctx, cache.query, vals...).Scan(queries.PtrsFromMapping(value, cache.retMapping)...) + } else { + _, err = exec.ExecContext(ctx, cache.query, vals...) + } + + if err != nil { + return errors.Wrap(err, "models: unable to insert into vehicle_tco_settings") + } + + if !cached { + vehicleTcoSettingInsertCacheMut.Lock() + vehicleTcoSettingInsertCache[key] = cache + vehicleTcoSettingInsertCacheMut.Unlock() + } + + return o.doAfterInsertHooks(ctx, exec) +} + +// Update uses an executor to update the VehicleTcoSetting. +// See boil.Columns.UpdateColumnSet documentation to understand column list inference for updates. +// Update does not automatically update the record in case of default values. Use .Reload() to refresh the records. +func (o *VehicleTcoSetting) Update(ctx context.Context, exec boil.ContextExecutor, columns boil.Columns) (int64, error) { + if !boil.TimestampsAreSkipped(ctx) { + currTime := time.Now().In(boil.GetLocation()) + + o.UpdatedAt = currTime + } + + var err error + if err = o.doBeforeUpdateHooks(ctx, exec); err != nil { + return 0, err + } + key := makeCacheKey(columns, nil) + vehicleTcoSettingUpdateCacheMut.RLock() + cache, cached := vehicleTcoSettingUpdateCache[key] + vehicleTcoSettingUpdateCacheMut.RUnlock() + + if !cached { + wl := columns.UpdateColumnSet( + vehicleTcoSettingAllColumns, + vehicleTcoSettingPrimaryKeyColumns, + ) + + if !columns.IsWhitelist() { + wl = strmangle.SetComplement(wl, []string{"created_at"}) + } + if len(wl) == 0 { + return 0, errors.New("models: unable to update vehicle_tco_settings, could not build whitelist") + } + + cache.query = fmt.Sprintf("UPDATE \"vehicle_tco_settings\" SET %s WHERE %s", + strmangle.SetParamNames("\"", "\"", 1, wl), + strmangle.WhereClause("\"", "\"", len(wl)+1, vehicleTcoSettingPrimaryKeyColumns), + ) + cache.valueMapping, err = queries.BindMapping(vehicleTcoSettingType, vehicleTcoSettingMapping, append(wl, vehicleTcoSettingPrimaryKeyColumns...)) + if err != nil { + return 0, err + } + } + + values := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(o)), cache.valueMapping) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, cache.query) + fmt.Fprintln(writer, values) + } + var result sql.Result + result, err = exec.ExecContext(ctx, cache.query, values...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to update vehicle_tco_settings row") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by update for vehicle_tco_settings") + } + + if !cached { + vehicleTcoSettingUpdateCacheMut.Lock() + vehicleTcoSettingUpdateCache[key] = cache + vehicleTcoSettingUpdateCacheMut.Unlock() + } + + return rowsAff, o.doAfterUpdateHooks(ctx, exec) +} + +// UpdateAll updates all rows with the specified column values. +func (q vehicleTcoSettingQuery) UpdateAll(ctx context.Context, exec boil.ContextExecutor, cols M) (int64, error) { + queries.SetUpdate(q.Query, cols) + + result, err := q.Query.ExecContext(ctx, exec) + if err != nil { + return 0, errors.Wrap(err, "models: unable to update all for vehicle_tco_settings") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: unable to retrieve rows affected for vehicle_tco_settings") + } + + return rowsAff, nil +} + +// UpdateAll updates all rows with the specified column values, using an executor. +func (o VehicleTcoSettingSlice) UpdateAll(ctx context.Context, exec boil.ContextExecutor, cols M) (int64, error) { + ln := int64(len(o)) + if ln == 0 { + return 0, nil + } + + if len(cols) == 0 { + return 0, errors.New("models: update all requires at least one column argument") + } + + colNames := make([]string, len(cols)) + args := make([]any, len(cols)) + + i := 0 + for name, value := range cols { + colNames[i] = name + args[i] = value + i++ + } + + // Append all of the primary key values for each column + for _, obj := range o { + pkeyArgs := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(obj)), vehicleTcoSettingPrimaryKeyMapping) + args = append(args, pkeyArgs...) + } + + sql := fmt.Sprintf("UPDATE \"vehicle_tco_settings\" SET %s WHERE %s", + strmangle.SetParamNames("\"", "\"", 1, colNames), + strmangle.WhereClauseRepeated(string(dialect.LQ), string(dialect.RQ), len(colNames)+1, vehicleTcoSettingPrimaryKeyColumns, len(o))) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, args...) + } + result, err := exec.ExecContext(ctx, sql, args...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to update all in vehicleTcoSetting slice") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: unable to retrieve rows affected all in update all vehicleTcoSetting") + } + return rowsAff, nil +} + +// Upsert attempts an insert using an executor, and does an update or ignore on conflict. +// See boil.Columns documentation for how to properly use updateColumns and insertColumns. +func (o *VehicleTcoSetting) Upsert(ctx context.Context, exec boil.ContextExecutor, updateOnConflict bool, conflictColumns []string, updateColumns, insertColumns boil.Columns, opts ...UpsertOptionFunc) error { + if o == nil { + return errors.New("models: no vehicle_tco_settings provided for upsert") + } + if !boil.TimestampsAreSkipped(ctx) { + currTime := time.Now().In(boil.GetLocation()) + + if o.CreatedAt.IsZero() { + o.CreatedAt = currTime + } + o.UpdatedAt = currTime + } + + if err := o.doBeforeUpsertHooks(ctx, exec); err != nil { + return err + } + + nzDefaults := queries.NonZeroDefaultSet(vehicleTcoSettingColumnsWithDefault, o) + + // Build cache key in-line uglily - mysql vs psql problems + buf := strmangle.GetBuffer() + if updateOnConflict { + buf.WriteByte('t') + } else { + buf.WriteByte('f') + } + buf.WriteByte('.') + for _, c := range conflictColumns { + buf.WriteString(c) + } + buf.WriteByte('.') + buf.WriteString(strconv.Itoa(updateColumns.Kind)) + for _, c := range updateColumns.Cols { + buf.WriteString(c) + } + buf.WriteByte('.') + buf.WriteString(strconv.Itoa(insertColumns.Kind)) + for _, c := range insertColumns.Cols { + buf.WriteString(c) + } + buf.WriteByte('.') + for _, c := range nzDefaults { + buf.WriteString(c) + } + key := buf.String() + strmangle.PutBuffer(buf) + + vehicleTcoSettingUpsertCacheMut.RLock() + cache, cached := vehicleTcoSettingUpsertCache[key] + vehicleTcoSettingUpsertCacheMut.RUnlock() + + var err error + + if !cached { + insert, _ := insertColumns.InsertColumnSet( + vehicleTcoSettingAllColumns, + vehicleTcoSettingColumnsWithDefault, + vehicleTcoSettingColumnsWithoutDefault, + nzDefaults, + ) + + update := updateColumns.UpdateColumnSet( + vehicleTcoSettingAllColumns, + vehicleTcoSettingPrimaryKeyColumns, + ) + + if updateOnConflict && len(update) == 0 { + return errors.New("models: unable to upsert vehicle_tco_settings, could not build update column list") + } + + ret := strmangle.SetComplement(vehicleTcoSettingAllColumns, strmangle.SetIntersect(insert, update)) + + conflict := conflictColumns + if len(conflict) == 0 && updateOnConflict && len(update) != 0 { + if len(vehicleTcoSettingPrimaryKeyColumns) == 0 { + return errors.New("models: unable to upsert vehicle_tco_settings, could not build conflict column list") + } + + conflict = make([]string, len(vehicleTcoSettingPrimaryKeyColumns)) + copy(conflict, vehicleTcoSettingPrimaryKeyColumns) + } + cache.query = buildUpsertQueryPostgres(dialect, "\"vehicle_tco_settings\"", updateOnConflict, ret, update, conflict, insert, opts...) + + cache.valueMapping, err = queries.BindMapping(vehicleTcoSettingType, vehicleTcoSettingMapping, insert) + if err != nil { + return err + } + if len(ret) != 0 { + cache.retMapping, err = queries.BindMapping(vehicleTcoSettingType, vehicleTcoSettingMapping, ret) + if err != nil { + return err + } + } + } + + value := reflect.Indirect(reflect.ValueOf(o)) + vals := queries.ValuesFromMapping(value, cache.valueMapping) + var returns []any + if len(cache.retMapping) != 0 { + returns = queries.PtrsFromMapping(value, cache.retMapping) + } + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, cache.query) + fmt.Fprintln(writer, vals) + } + if len(cache.retMapping) != 0 { + err = exec.QueryRowContext(ctx, cache.query, vals...).Scan(returns...) + if errors.Is(err, sql.ErrNoRows) { + err = nil // Postgres doesn't return anything when there's no update + } + } else { + _, err = exec.ExecContext(ctx, cache.query, vals...) + } + if err != nil { + return errors.Wrap(err, "models: unable to upsert vehicle_tco_settings") + } + + if !cached { + vehicleTcoSettingUpsertCacheMut.Lock() + vehicleTcoSettingUpsertCache[key] = cache + vehicleTcoSettingUpsertCacheMut.Unlock() + } + + return o.doAfterUpsertHooks(ctx, exec) +} + +// Delete deletes a single VehicleTcoSetting record with an executor. +// Delete will match against the primary key column to find the record to delete. +func (o *VehicleTcoSetting) Delete(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + if o == nil { + return 0, errors.New("models: no VehicleTcoSetting provided for delete") + } + + if err := o.doBeforeDeleteHooks(ctx, exec); err != nil { + return 0, err + } + + args := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(o)), vehicleTcoSettingPrimaryKeyMapping) + sql := "DELETE FROM \"vehicle_tco_settings\" WHERE \"tenant_id\"=$1 AND \"token_id\"=$2" + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, args...) + } + result, err := exec.ExecContext(ctx, sql, args...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to delete from vehicle_tco_settings") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by delete for vehicle_tco_settings") + } + + if err := o.doAfterDeleteHooks(ctx, exec); err != nil { + return 0, err + } + + return rowsAff, nil +} + +// DeleteAll deletes all matching rows. +func (q vehicleTcoSettingQuery) DeleteAll(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + if q.Query == nil { + return 0, errors.New("models: no vehicleTcoSettingQuery provided for delete all") + } + + queries.SetDelete(q.Query) + + result, err := q.Query.ExecContext(ctx, exec) + if err != nil { + return 0, errors.Wrap(err, "models: unable to delete all from vehicle_tco_settings") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by deleteall for vehicle_tco_settings") + } + + return rowsAff, nil +} + +// DeleteAll deletes all rows in the slice, using an executor. +func (o VehicleTcoSettingSlice) DeleteAll(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + if len(o) == 0 { + return 0, nil + } + + if len(vehicleTcoSettingBeforeDeleteHooks) != 0 { + for _, obj := range o { + if err := obj.doBeforeDeleteHooks(ctx, exec); err != nil { + return 0, err + } + } + } + + var args []any + for _, obj := range o { + pkeyArgs := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(obj)), vehicleTcoSettingPrimaryKeyMapping) + args = append(args, pkeyArgs...) + } + + sql := "DELETE FROM \"vehicle_tco_settings\" WHERE " + + strmangle.WhereClauseRepeated(string(dialect.LQ), string(dialect.RQ), 1, vehicleTcoSettingPrimaryKeyColumns, len(o)) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, args) + } + result, err := exec.ExecContext(ctx, sql, args...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to delete all from vehicleTcoSetting slice") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by deleteall for vehicle_tco_settings") + } + + if len(vehicleTcoSettingAfterDeleteHooks) != 0 { + for _, obj := range o { + if err := obj.doAfterDeleteHooks(ctx, exec); err != nil { + return 0, err + } + } + } + + return rowsAff, nil +} + +// Reload refetches the object from the database +// using the primary keys with an executor. +func (o *VehicleTcoSetting) Reload(ctx context.Context, exec boil.ContextExecutor) error { + ret, err := FindVehicleTcoSetting(ctx, exec, o.TenantID, o.TokenID) + if err != nil { + return err + } + + *o = *ret + return nil +} + +// ReloadAll refetches every row with matching primary key column values +// and overwrites the original object slice with the newly updated slice. +func (o *VehicleTcoSettingSlice) ReloadAll(ctx context.Context, exec boil.ContextExecutor) error { + if o == nil || len(*o) == 0 { + return nil + } + + slice := VehicleTcoSettingSlice{} + var args []any + for _, obj := range *o { + pkeyArgs := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(obj)), vehicleTcoSettingPrimaryKeyMapping) + args = append(args, pkeyArgs...) + } + + sql := "SELECT \"vehicle_tco_settings\".* FROM \"vehicle_tco_settings\" WHERE " + + strmangle.WhereClauseRepeated(string(dialect.LQ), string(dialect.RQ), 1, vehicleTcoSettingPrimaryKeyColumns, len(*o)) + + q := queries.Raw(sql, args...) + + err := q.Bind(ctx, exec, &slice) + if err != nil { + return errors.Wrap(err, "models: unable to reload all in VehicleTcoSettingSlice") + } + + *o = slice + + return nil +} + +// VehicleTcoSettingExists checks if the VehicleTcoSetting row exists. +func VehicleTcoSettingExists(ctx context.Context, exec boil.ContextExecutor, tenantID string, tokenID int64) (bool, error) { + var exists bool + sql := "select exists(select 1 from \"vehicle_tco_settings\" where \"tenant_id\"=$1 AND \"token_id\"=$2 limit 1)" + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, tenantID, tokenID) + } + row := exec.QueryRowContext(ctx, sql, tenantID, tokenID) + + err := row.Scan(&exists) + if err != nil { + return false, errors.Wrap(err, "models: unable to check if vehicle_tco_settings exists") + } + + return exists, nil +} + +// Exists checks if the VehicleTcoSetting row exists. +func (o *VehicleTcoSetting) Exists(ctx context.Context, exec boil.ContextExecutor) (bool, error) { + return VehicleTcoSettingExists(ctx, exec, o.TenantID, o.TokenID) +} From aef0140690d3c020b0dfc641f6d34e4070112418 Mon Sep 17 00:00:00 2001 From: James Li Date: Tue, 7 Jul 2026 13:06:02 -0700 Subject: [PATCH 04/16] feat(api): add TCO cost-eligibility, amount extraction, and depreciation math --- api/internal/service/tco_service.go | 92 ++++++++++++++++++++ api/internal/service/tco_service_test.go | 103 +++++++++++++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 api/internal/service/tco_service.go create mode 100644 api/internal/service/tco_service_test.go diff --git a/api/internal/service/tco_service.go b/api/internal/service/tco_service.go new file mode 100644 index 0000000..d3da48e --- /dev/null +++ b/api/internal/service/tco_service.go @@ -0,0 +1,92 @@ +package service + +import ( + "encoding/json" + "time" +) + +// FuelCloudEventType is the CE type for fuel receipts — broken out from the +// generic "expense" bucket since fuel is usually a fleet's largest recurring +// cost. See docs/superpowers/specs/2026-07-06-tco-reporting-design.md. +const FuelCloudEventType = "dimo.document.vehicle.fuel" + +// CostEligibleCETypes are the canonical CE types whose amounts count toward +// TCO operating costs. Title/Note/Condition are informational, not spend. +var CostEligibleCETypes = map[string]bool{ + "dimo.document.vehicle.service.invoice": true, + "dimo.document.vehicle.insurance": true, + "dimo.document.vehicle.registration": true, + "dimo.document.vehicle.inspection": true, + "dimo.document.vehicle.finance": true, + "dimo.document.vehicle.regulatory.other": true, + "dimo.document.vehicle.maintenance": true, + "dimo.document.vehicle.expense": true, + FuelCloudEventType: true, +} + +// isCostEligible reports whether a document's CE type counts toward TCO +// operating costs. +func isCostEligible(ceType string) bool { + return CostEligibleCETypes[ceType] +} + +// extractAmount pulls "amount"/"currency" out of a parsed document CE's data +// payload. ok is false when data is empty, unparseable, or has no numeric +// amount field — callers should skip the document rather than count it as $0. +func extractAmount(data json.RawMessage) (amount float64, currency string, ok bool) { + if len(data) == 0 { + return 0, "", false + } + var payload struct { + Amount *float64 `json:"amount"` + Currency string `json:"currency"` + } + if err := json.Unmarshal(data, &payload); err != nil || payload.Amount == nil { + return 0, "", false + } + currency = payload.Currency + if currency == "" { + currency = "USD" + } + return *payload.Amount, currency, true +} + +// straightLineDepreciation returns the depreciation accrued from purchaseDate +// through asOf, straight-line over usefulLifeYears, capped at purchasePrice. +// Returns 0 if usefulLifeYears <= 0, purchasePrice <= 0, or asOf precedes +// purchaseDate. +func straightLineDepreciation(purchasePrice float64, purchaseDate time.Time, usefulLifeYears int, asOf time.Time) float64 { + if usefulLifeYears <= 0 || purchasePrice <= 0 { + return 0 + } + elapsedYears := asOf.Sub(purchaseDate).Hours() / 24 / 365.25 + if elapsedYears <= 0 { + return 0 + } + dep := (purchasePrice / float64(usefulLifeYears)) * elapsedYears + if dep > purchasePrice { + return purchasePrice + } + return dep +} + +// LineItem is one cost-eligible document, flattened for reporting/export. +type LineItem struct { + VehicleTokenID int64 `json:"vehicleTokenId"` + VehicleLabel string `json:"vehicleLabel"` + VIN string `json:"vin"` + Date string `json:"date"` + Category string `json:"category"` + Description string `json:"description"` + Amount float64 `json:"amount"` + Currency string `json:"currency"` +} + +// sumLineItemsByCategory totals LineItem.Amount grouped by CE type. +func sumLineItemsByCategory(items []LineItem) map[string]float64 { + out := map[string]float64{} + for _, li := range items { + out[li.Category] += li.Amount + } + return out +} diff --git a/api/internal/service/tco_service_test.go b/api/internal/service/tco_service_test.go new file mode 100644 index 0000000..1c8c15f --- /dev/null +++ b/api/internal/service/tco_service_test.go @@ -0,0 +1,103 @@ +// api/internal/service/tco_service_test.go +package service + +import ( + "encoding/json" + "testing" + "time" +) + +func TestIsCostEligible(t *testing.T) { + cases := []struct { + ceType string + want bool + }{ + {"dimo.document.vehicle.service.invoice", true}, + {"dimo.document.vehicle.insurance", true}, + {"dimo.document.vehicle.fuel", true}, + {"dimo.document.vehicle.title", false}, + {"dimo.document.vehicle.note", false}, + {"dimo.document.vehicle.condition", false}, + {"dimo.document.unknown", false}, + } + for _, tc := range cases { + t.Run(tc.ceType, func(t *testing.T) { + if got := isCostEligible(tc.ceType); got != tc.want { + t.Fatalf("isCostEligible(%q) = %v, want %v", tc.ceType, got, tc.want) + } + }) + } +} + +func TestExtractAmount(t *testing.T) { + cases := []struct { + name string + data string + wantAmount float64 + wantCurrency string + wantOK bool + }{ + {"amount and currency", `{"amount": 412.5, "currency": "EUR"}`, 412.5, "EUR", true}, + {"amount defaults currency to USD", `{"amount": 10}`, 10, "USD", true}, + {"no amount field", `{"vin": "1HGCM82633A123456"}`, 0, "", false}, + {"empty data", ``, 0, "", false}, + {"malformed json", `not json`, 0, "", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + amount, currency, ok := extractAmount(json.RawMessage(tc.data)) + if ok != tc.wantOK { + t.Fatalf("ok = %v, want %v", ok, tc.wantOK) + } + if !ok { + return + } + if amount != tc.wantAmount || currency != tc.wantCurrency { + t.Fatalf("got (%v, %q), want (%v, %q)", amount, currency, tc.wantAmount, tc.wantCurrency) + } + }) + } +} + +func TestStraightLineDepreciation(t *testing.T) { + purchase := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + cases := []struct { + name string + purchasePrice float64 + usefulLifeYears int + asOf time.Time + want float64 + }{ + {"half life elapsed", 20000, 10, purchase.AddDate(5, 0, 0), 10000}, + {"before purchase", 20000, 10, purchase.AddDate(-1, 0, 0), 0}, + {"fully depreciated, capped", 20000, 10, purchase.AddDate(50, 0, 0), 20000}, + {"zero useful life", 20000, 0, purchase.AddDate(5, 0, 0), 0}, + {"zero price", 0, 10, purchase.AddDate(5, 0, 0), 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := straightLineDepreciation(tc.purchasePrice, purchase, tc.usefulLifeYears, tc.asOf) + if diff := got - tc.want; diff > 15 || diff < -15 { + t.Fatalf("straightLineDepreciation(...) = %v, want ~%v", got, tc.want) + } + }) + } +} + +func TestSumLineItemsByCategory(t *testing.T) { + items := []LineItem{ + {Category: "dimo.document.vehicle.insurance", Amount: 100}, + {Category: "dimo.document.vehicle.insurance", Amount: 50}, + {Category: "dimo.document.vehicle.fuel", Amount: 40}, + } + got := sumLineItemsByCategory(items) + if got["dimo.document.vehicle.insurance"] != 150 { + t.Fatalf("insurance total = %v, want 150", got["dimo.document.vehicle.insurance"]) + } + if got["dimo.document.vehicle.fuel"] != 40 { + t.Fatalf("fuel total = %v, want 40", got["dimo.document.vehicle.fuel"]) + } + if len(got) != 2 { + t.Fatalf("expected 2 categories, got %d", len(got)) + } +} From 819cf4bceca7604ee3c2182291b242d21a0157c9 Mon Sep 17 00:00:00 2001 From: James Li Date: Tue, 7 Jul 2026 16:27:14 -0700 Subject: [PATCH 05/16] feat(api): add TCO settings get/upsert backed by vehicle_tco_settings Corrected against the actual SQLBoiler-generated types (VehicleTcoSetting, types.NullDecimal) rather than the plan's assumed VehicleTCOSetting/null.Float64. --- api/internal/service/tco_service.go | 111 ++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/api/internal/service/tco_service.go b/api/internal/service/tco_service.go index d3da48e..8baf9d7 100644 --- a/api/internal/service/tco_service.go +++ b/api/internal/service/tco_service.go @@ -1,8 +1,21 @@ package service import ( + "context" + "database/sql" "encoding/json" + "errors" + "fmt" "time" + + dbmodels "github.com/DIMO-Network/fleet-lite-app/internal/db/models" + "github.com/DIMO-Network/fleet-lite-app/internal/gateway" + "github.com/DIMO-Network/shared/pkg/db" + "github.com/aarondl/null/v8" + "github.com/aarondl/sqlboiler/v4/boil" + "github.com/aarondl/sqlboiler/v4/types" + "github.com/ericlagergren/decimal" + "github.com/rs/zerolog" ) // FuelCloudEventType is the CE type for fuel receipts — broken out from the @@ -90,3 +103,101 @@ func sumLineItemsByCategory(items []LineItem) map[string]float64 { } return out } + +// TCOSettings is a vehicle's optional acquisition/depreciation inputs. +// Nil pointer fields mean "not set" — the vehicle shows operating costs only. +type TCOSettings struct { + VehicleTokenID int64 `json:"tokenId"` + PurchasePrice *float64 `json:"purchasePrice,omitempty"` + PurchaseDate *string `json:"purchaseDate,omitempty"` // YYYY-MM-DD + UsefulLifeYears *int `json:"usefulLifeYears,omitempty"` + Currency string `json:"currency"` +} + +// TCOService builds cost-of-ownership reports from Glovebox documents plus +// each vehicle's optional acquisition/depreciation settings. +type TCOService struct { + logger *zerolog.Logger + pdb *db.Store + fetchAPI *gateway.FetchAPI + authProvider *gateway.DimoAuthProvider + vehicleSvc *VehicleService +} + +func NewTCOService(logger *zerolog.Logger, pdb *db.Store, fetchAPI *gateway.FetchAPI, authProvider *gateway.DimoAuthProvider, vehicleSvc *VehicleService) *TCOService { + return &TCOService{ + logger: logger, + pdb: pdb, + fetchAPI: fetchAPI, + authProvider: authProvider, + vehicleSvc: vehicleSvc, + } +} + +// GetSettings returns a vehicle's TCO settings, or a zero-value TCOSettings +// (Currency defaulted to USD, other fields nil) if none have been saved. +func (s *TCOService) GetSettings(ctx context.Context, tenantID string, tokenID int64) (TCOSettings, error) { + row, err := dbmodels.FindVehicleTcoSetting(ctx, s.pdb.DBS().Reader, tenantID, tokenID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return TCOSettings{VehicleTokenID: tokenID, Currency: "USD"}, nil + } + return TCOSettings{}, fmt.Errorf("get tco settings: %w", err) + } + out := TCOSettings{VehicleTokenID: tokenID, Currency: row.Currency} + if row.PurchasePrice.Big != nil { + if f, ok := row.PurchasePrice.Big.Float64(); ok { + out.PurchasePrice = &f + } + } + if row.PurchaseDate.Valid { + d := row.PurchaseDate.Time.Format("2006-01-02") + out.PurchaseDate = &d + } + if row.UsefulLifeYears.Valid { + v := row.UsefulLifeYears.Int + out.UsefulLifeYears = &v + } + return out, nil +} + +// UpsertSettings full-replaces a vehicle's TCO settings. An empty in.Currency +// defaults to "USD". +func (s *TCOService) UpsertSettings(ctx context.Context, tenantID string, tokenID int64, in TCOSettings) error { + currency := in.Currency + if currency == "" { + currency = "USD" + } + row := &dbmodels.VehicleTcoSetting{ + TenantID: tenantID, + TokenID: tokenID, + Currency: currency, + } + if in.PurchasePrice != nil { + row.PurchasePrice = types.NewNullDecimal(new(decimal.Big).SetFloat64(*in.PurchasePrice)) + } + if in.PurchaseDate != nil { + t, err := time.Parse("2006-01-02", *in.PurchaseDate) + if err != nil { + return fmt.Errorf("invalid purchaseDate %q: %w", *in.PurchaseDate, err) + } + row.PurchaseDate = null.TimeFrom(t) + } + if in.UsefulLifeYears != nil { + row.UsefulLifeYears = null.IntFrom(*in.UsefulLifeYears) + } + now := time.Now() + row.CreatedAt = now + row.UpdatedAt = now + return row.Upsert(ctx, s.pdb.DBS().Writer, true, + []string{dbmodels.VehicleTcoSettingColumns.TenantID, dbmodels.VehicleTcoSettingColumns.TokenID}, + boil.Whitelist( + dbmodels.VehicleTcoSettingColumns.PurchasePrice, + dbmodels.VehicleTcoSettingColumns.PurchaseDate, + dbmodels.VehicleTcoSettingColumns.UsefulLifeYears, + dbmodels.VehicleTcoSettingColumns.Currency, + dbmodels.VehicleTcoSettingColumns.UpdatedAt, + ), + boil.Infer(), + ) +} From 6e3fe4afcb995c7b40573b7729bbab174694768b Mon Sep 17 00:00:00 2001 From: James Li Date: Tue, 7 Jul 2026 17:02:04 -0700 Subject: [PATCH 06/16] feat(api): assemble vehicle/fleet TCO summaries and CSV export --- api/internal/service/tco_service.go | 212 +++++++++++++++++++++++ api/internal/service/tco_service_test.go | 30 ++++ 2 files changed, 242 insertions(+) diff --git a/api/internal/service/tco_service.go b/api/internal/service/tco_service.go index 8baf9d7..3399487 100644 --- a/api/internal/service/tco_service.go +++ b/api/internal/service/tco_service.go @@ -3,13 +3,17 @@ package service import ( "context" "database/sql" + "encoding/csv" "encoding/json" "errors" "fmt" + "strconv" + "strings" "time" dbmodels "github.com/DIMO-Network/fleet-lite-app/internal/db/models" "github.com/DIMO-Network/fleet-lite-app/internal/gateway" + "github.com/DIMO-Network/fleet-lite-app/internal/models" "github.com/DIMO-Network/shared/pkg/db" "github.com/aarondl/null/v8" "github.com/aarondl/sqlboiler/v4/boil" @@ -201,3 +205,211 @@ func (s *TCOService) UpsertSettings(ctx context.Context, tenantID string, tokenI boil.Infer(), ) } + +// VehicleTCOSummary is one vehicle's cost breakdown for the TCO report. +type VehicleTCOSummary struct { + VehicleTokenID int64 `json:"tokenId"` + VehicleLabel string `json:"vehicleLabel"` + VIN string `json:"vin,omitempty"` + OperatingCost float64 `json:"operatingCost"` + CostByCategory map[string]float64 `json:"costByCategory"` + AcquisitionCost float64 `json:"acquisitionCost"` + DepreciationToDate float64 `json:"depreciationToDate"` + TotalTCO float64 `json:"totalTco"` + Settings TCOSettings `json:"settings"` + LineItems []LineItem `json:"lineItems"` +} + +// FleetTotals sums each VehicleTCOSummary field across the fleet. +type FleetTotals struct { + OperatingCost float64 `json:"operatingCost"` + AcquisitionCost float64 `json:"acquisitionCost"` + DepreciationToDate float64 `json:"depreciationToDate"` + TotalTCO float64 `json:"totalTco"` +} + +// FleetTCOSummary is the fleet-wide rollup: each vehicle's summary plus totals. +type FleetTCOSummary struct { + Vehicles []VehicleTCOSummary `json:"vehicles"` + Fleet FleetTotals `json:"fleet"` +} + +// vehicleLabel formats " ", falling back to "Vehicle #". +func vehicleLabel(v models.Vehicle) string { + d := v.Definition + parts := make([]string, 0, 3) + if d.Year != 0 { + parts = append(parts, strconv.Itoa(d.Year)) + } + if d.Make != "" { + parts = append(parts, d.Make) + } + if d.Model != "" { + parts = append(parts, d.Model) + } + if len(parts) == 0 { + return fmt.Sprintf("Vehicle #%d", v.TokenID) + } + return strings.Join(parts, " ") +} + +// descriptionFor derives a human label for a line item from its parsed data's +// "fileName"/"name" field, falling back to the CE type's last segment. +func descriptionFor(e gateway.AttestationEntry) string { + var payload struct { + Name string `json:"name"` + FileName string `json:"fileName"` + } + if len(e.Data) > 0 { + _ = json.Unmarshal(e.Data, &payload) + } + if payload.FileName != "" { + return payload.FileName + } + if payload.Name != "" { + return payload.Name + } + parts := strings.Split(e.Type, ".") + return parts[len(parts)-1] +} + +// VehicleSummary builds one vehicle's TCO breakdown: cost-eligible document +// amounts summed by category, plus acquisition/depreciation if settings exist. +func (s *TCOService) VehicleSummary(ctx context.Context, tenant models.Tenant, tokenID int64) (*VehicleTCOSummary, error) { + vehicle, err := s.vehicleSvc.GetVehicle(ctx, tenant.ID, tokenID) + if err != nil { + return nil, fmt.Errorf("get vehicle: %w", err) + } + label := vehicleLabel(*vehicle) + + tokenDID := s.authProvider.BuildVehicleDID(uint64(tokenID)) + entries, err := s.fetchAPI.ListByDID(tenant, tokenDID, 500) + if err != nil { + return nil, fmt.Errorf("list documents: %w", err) + } + + lineItems := make([]LineItem, 0, len(entries)) + for _, e := range entries { + if !isCostEligible(e.Type) { + continue + } + amount, currency, ok := extractAmount(e.Data) + if !ok { + continue + } + lineItems = append(lineItems, LineItem{ + VehicleTokenID: tokenID, + VehicleLabel: label, + VIN: vehicle.VIN, + Date: e.Time, + Category: e.Type, + Description: descriptionFor(e), + Amount: amount, + Currency: currency, + }) + } + + settings, err := s.GetSettings(ctx, tenant.ID, tokenID) + if err != nil { + return nil, fmt.Errorf("get tco settings: %w", err) + } + + summary := &VehicleTCOSummary{ + VehicleTokenID: tokenID, + VehicleLabel: label, + VIN: vehicle.VIN, + CostByCategory: sumLineItemsByCategory(lineItems), + Settings: settings, + LineItems: lineItems, + } + for _, v := range summary.CostByCategory { + summary.OperatingCost += v + } + if settings.PurchasePrice != nil { + summary.AcquisitionCost = *settings.PurchasePrice + if settings.PurchaseDate != nil && settings.UsefulLifeYears != nil { + purchaseDate, perr := time.Parse("2006-01-02", *settings.PurchaseDate) + if perr == nil { + summary.DepreciationToDate = straightLineDepreciation(*settings.PurchasePrice, purchaseDate, *settings.UsefulLifeYears, time.Now()) + } + } + } + summary.TotalTCO = summary.OperatingCost + summary.AcquisitionCost + return summary, nil +} + +// FleetSummary builds the TCO rollup for every vehicle in the tenant. Vehicles +// are processed sequentially — one fetch-api round trip each — which is +// acceptable for the fleet sizes this app targets; revisit with a worker pool +// if that stops being true. A single vehicle's failure is logged and skipped +// rather than failing the whole report. +func (s *TCOService) FleetSummary(ctx context.Context, tenant models.Tenant) (*FleetTCOSummary, error) { + vehicles, err := s.vehicleSvc.ListVehicles(ctx, tenant.ID) + if err != nil { + return nil, fmt.Errorf("list vehicles: %w", err) + } + out := &FleetTCOSummary{Vehicles: make([]VehicleTCOSummary, 0, len(vehicles))} + for _, v := range vehicles { + summary, err := s.VehicleSummary(ctx, tenant, v.TokenID) + if err != nil { + s.logger.Warn().Err(err).Int64("tokenID", v.TokenID).Msg("tco vehicle summary failed, skipping") + continue + } + out.Vehicles = append(out.Vehicles, *summary) + out.Fleet.OperatingCost += summary.OperatingCost + out.Fleet.AcquisitionCost += summary.AcquisitionCost + out.Fleet.DepreciationToDate += summary.DepreciationToDate + out.Fleet.TotalTCO += summary.TotalTCO + } + return out, nil +} + +// ceTypeToLabel mirrors web/src/utils/document-categories.ts's CE_TYPE_TO_LABEL +// so the CSV export reads the same as the app. Duplicated deliberately: the +// frontend map is TypeScript and can't be imported into Go. +var ceTypeToLabel = map[string]string{ + "dimo.document.vehicle.service.invoice": "Service & parts", + "dimo.document.vehicle.insurance": "Insurance", + "dimo.document.vehicle.registration": "Registration", + "dimo.document.vehicle.inspection": "Inspection", + "dimo.document.vehicle.finance": "Finance", + "dimo.document.vehicle.regulatory.other": "Regulatory", + "dimo.document.vehicle.maintenance": "Service & parts", + "dimo.document.vehicle.expense": "Other", + FuelCloudEventType: "Fuel", +} + +func categoryLabelForCSV(ceType string) string { + if l, ok := ceTypeToLabel[ceType]; ok { + return l + } + return ceType +} + +// BuildCSV renders line items (plus trailing acquisition/depreciation summary +// rows per vehicle) as CSV text. +func BuildCSV(summaries []VehicleTCOSummary) string { + var b strings.Builder + w := csv.NewWriter(&b) + _ = w.Write([]string{"vehicle", "vin", "date", "category", "description", "amount", "currency"}) + for _, v := range summaries { + for _, li := range v.LineItems { + _ = w.Write([]string{ + li.VehicleLabel, li.VIN, li.Date, categoryLabelForCSV(li.Category), li.Description, + strconv.FormatFloat(li.Amount, 'f', 2, 64), li.Currency, + }) + } + if v.Settings.PurchasePrice != nil { + _ = w.Write([]string{ + v.VehicleLabel, v.VIN, "(acquisition)", "Acquisition", "Purchase price", + strconv.FormatFloat(*v.Settings.PurchasePrice, 'f', 2, 64), v.Settings.Currency, + }) + _ = w.Write([]string{ + v.VehicleLabel, v.VIN, "(acquisition)", "Depreciation", "Depreciation to date", + strconv.FormatFloat(-v.DepreciationToDate, 'f', 2, 64), v.Settings.Currency, + }) + } + } + w.Flush() + return b.String() +} diff --git a/api/internal/service/tco_service_test.go b/api/internal/service/tco_service_test.go index 1c8c15f..a628263 100644 --- a/api/internal/service/tco_service_test.go +++ b/api/internal/service/tco_service_test.go @@ -3,6 +3,7 @@ package service import ( "encoding/json" + "strings" "testing" "time" ) @@ -101,3 +102,32 @@ func TestSumLineItemsByCategory(t *testing.T) { t.Fatalf("expected 2 categories, got %d", len(got)) } } + +func TestBuildCSV(t *testing.T) { + price := 20000.0 + summaries := []VehicleTCOSummary{ + { + VehicleLabel: "2021 Subaru Ascent", + VIN: "1HGCM82633A123456", + DepreciationToDate: 5000, + Settings: TCOSettings{PurchasePrice: &price, Currency: "USD"}, + LineItems: []LineItem{ + {VehicleLabel: "2021 Subaru Ascent", VIN: "1HGCM82633A123456", Date: "2026-03-14", Category: "dimo.document.vehicle.service.invoice", Description: "invoice.pdf", Amount: 412.5, Currency: "USD"}, + }, + }, + } + got := BuildCSV(summaries) + wantHeader := "vehicle,vin,date,category,description,amount,currency" + if !strings.Contains(got, wantHeader) { + t.Fatalf("missing header row, got:\n%s", got) + } + if !strings.Contains(got, "412.50,USD") { + t.Fatalf("missing line item row, got:\n%s", got) + } + if !strings.Contains(got, "20000.00,USD") { + t.Fatalf("missing acquisition row, got:\n%s", got) + } + if !strings.Contains(got, "-5000.00,USD") { + t.Fatalf("missing depreciation row, got:\n%s", got) + } +} From c8a4ecfefdcdfeeea9266bf300a47495ca3f2a3f Mon Sep 17 00:00:00 2001 From: James Li Date: Tue, 7 Jul 2026 17:33:20 -0700 Subject: [PATCH 07/16] feat(api): add TCO controller and wire /tco routes Exposes the TCOService (settings CRUD, vehicle/fleet summaries, CSV export) built in Tasks 1-4 over HTTP, following the documents.go controller pattern for tenant/vehicle authorization. --- api/internal/app/app.go | 10 ++ api/internal/controllers/tco.go | 158 ++++++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 api/internal/controllers/tco.go diff --git a/api/internal/app/app.go b/api/internal/app/app.go index 0a9b704..0223ffb 100644 --- a/api/internal/app/app.go +++ b/api/internal/app/app.go @@ -191,6 +191,16 @@ func App( tenantApp.Get("/documents/download", documentsCtrl.DownloadDocument) tenantApp.Delete("/documents/:id", documentsCtrl.DeleteDocument) + // Total cost of ownership reporting (operating costs from Glovebox + // documents + optional acquisition/depreciation settings per vehicle). + tcoSvc := service.NewTCOService(logger, pdb, fetchAPI, authProvider, vehicleSvc) + tcoCtrl := controllers.NewTCOController(logger, tcoSvc, vehicleSvc) + tenantApp.Get("/tco/settings", tcoCtrl.GetSettings) + tenantApp.Put("/tco/settings", tcoCtrl.PutSettings) + tenantApp.Get("/tco/summary", tcoCtrl.GetSummary) + tenantApp.Get("/tco/vehicle/:tokenId", tcoCtrl.GetVehicleDetail) + tenantApp.Get("/tco/export.csv", tcoCtrl.ExportCSV) + return app } diff --git a/api/internal/controllers/tco.go b/api/internal/controllers/tco.go new file mode 100644 index 0000000..536f68b --- /dev/null +++ b/api/internal/controllers/tco.go @@ -0,0 +1,158 @@ +// api/internal/controllers/tco.go +package controllers + +import ( + "context" + "fmt" + "strconv" + + "github.com/DIMO-Network/fleet-lite-app/internal/service" + "github.com/gofiber/fiber/v2" + "github.com/rs/zerolog" +) + +type TCOController struct { + logger *zerolog.Logger + tcoSvc *service.TCOService + vehicleSvc *service.VehicleService +} + +func NewTCOController(logger *zerolog.Logger, tcoSvc *service.TCOService, vehicleSvc *service.VehicleService) *TCOController { + return &TCOController{logger: logger, tcoSvc: tcoSvc, vehicleSvc: vehicleSvc} +} + +// vehicleInTenant reports whether the tokenID is one of the tenant's synced vehicles. +func (t *TCOController) vehicleInTenant(ctx context.Context, tenantID string, tokenID int64) bool { + _, err := t.vehicleSvc.GetVehicle(ctx, tenantID, tokenID) + return err == nil +} + +// GetSettings — GET /tco/settings?tokenId=N. +func (t *TCOController) GetSettings(c *fiber.Ctx) error { + tenant, err := GetTenant(c) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + tokenID, err := strconv.ParseInt(c.Query("tokenId"), 10, 64) + if err != nil || tokenID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "valid tokenId query param required") + } + if !t.vehicleInTenant(c.Context(), tenant.ID, tokenID) { + return fiber.NewError(fiber.StatusForbidden, "vehicle is not part of this tenant") + } + settings, err := t.tcoSvc.GetSettings(c.Context(), tenant.ID, tokenID) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "get tco settings: "+err.Error()) + } + return c.JSON(settings) +} + +// PutSettingsRequest is the body for PUT /tco/settings. +type PutSettingsRequest struct { + TokenID int64 `json:"tokenId"` + PurchasePrice *float64 `json:"purchasePrice"` + PurchaseDate *string `json:"purchaseDate"` + UsefulLifeYears *int `json:"usefulLifeYears"` + Currency string `json:"currency"` +} + +// PutSettings — PUT /tco/settings. +func (t *TCOController) PutSettings(c *fiber.Ctx) error { + tenant, err := GetTenant(c) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + var req PutSettingsRequest + if err := c.BodyParser(&req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "invalid request body: "+err.Error()) + } + if req.TokenID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "tokenId is required") + } + if !t.vehicleInTenant(c.Context(), tenant.ID, req.TokenID) { + return fiber.NewError(fiber.StatusForbidden, "vehicle is not part of this tenant") + } + currency := req.Currency + if currency == "" { + currency = "USD" + } + settings := service.TCOSettings{ + VehicleTokenID: req.TokenID, + PurchasePrice: req.PurchasePrice, + PurchaseDate: req.PurchaseDate, + UsefulLifeYears: req.UsefulLifeYears, + Currency: currency, + } + if err := t.tcoSvc.UpsertSettings(c.Context(), tenant.ID, req.TokenID, settings); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "save tco settings: "+err.Error()) + } + return c.JSON(settings) +} + +// GetSummary — GET /tco/summary. Fleet-wide rollup. +func (t *TCOController) GetSummary(c *fiber.Ctx) error { + tenant, err := GetTenant(c) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + summary, err := t.tcoSvc.FleetSummary(c.Context(), tenant) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "tco summary: "+err.Error()) + } + return c.JSON(summary) +} + +// GetVehicleDetail — GET /tco/vehicle/:tokenId. +func (t *TCOController) GetVehicleDetail(c *fiber.Ctx) error { + tenant, err := GetTenant(c) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + tokenID, err := strconv.ParseInt(c.Params("tokenId"), 10, 64) + if err != nil || tokenID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "valid tokenId path param required") + } + if !t.vehicleInTenant(c.Context(), tenant.ID, tokenID) { + return fiber.NewError(fiber.StatusForbidden, "vehicle is not part of this tenant") + } + summary, err := t.tcoSvc.VehicleSummary(c.Context(), tenant, tokenID) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "tco vehicle summary: "+err.Error()) + } + return c.JSON(summary) +} + +// ExportCSV — GET /tco/export.csv (optionally ?tokenId=N for a single vehicle). +func (t *TCOController) ExportCSV(c *fiber.Ctx) error { + tenant, err := GetTenant(c) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + var summaries []service.VehicleTCOSummary + filename := "tco-export.csv" + if q := c.Query("tokenId"); q != "" { + tokenID, err := strconv.ParseInt(q, 10, 64) + if err != nil || tokenID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "invalid tokenId") + } + if !t.vehicleInTenant(c.Context(), tenant.ID, tokenID) { + return fiber.NewError(fiber.StatusForbidden, "vehicle is not part of this tenant") + } + summary, err := t.tcoSvc.VehicleSummary(c.Context(), tenant, tokenID) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "tco vehicle summary: "+err.Error()) + } + summaries = []service.VehicleTCOSummary{*summary} + filename = fmt.Sprintf("tco-vehicle-%d.csv", tokenID) + } else { + fleet, err := t.tcoSvc.FleetSummary(c.Context(), tenant) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "tco summary: "+err.Error()) + } + summaries = fleet.Vehicles + } + csvText := service.BuildCSV(summaries) + c.Set(fiber.HeaderContentType, "text/csv") + c.Set(fiber.HeaderContentDisposition, fmt.Sprintf(`attachment; filename="%s"`, filename)) + return c.SendString(csvText) +} From 5ea35c4b9d93559fd2cdd789b844071305126ebe Mon Sep 17 00:00:00 2001 From: James Li Date: Tue, 7 Jul 2026 20:30:10 -0700 Subject: [PATCH 08/16] feat(web): add TCO types and API service Implement frontend types and service layer for TCO reporting feature. - TCOSettings, LineItem, VehicleTCOSummary, FleetTotals, FleetTCOSummary types - TCOService singleton with methods for settings, summary, vehicle detail, and CSV export - Follows existing document-service pattern with ApiService and TenantService integration Co-Authored-By: Claude Sonnet 5 --- web/src/services/tco-service.ts | 61 +++++++++++++++++++++++++++++++++ web/src/types/tco.ts | 43 +++++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 web/src/services/tco-service.ts create mode 100644 web/src/types/tco.ts diff --git a/web/src/services/tco-service.ts b/web/src/services/tco-service.ts new file mode 100644 index 0000000..9595093 --- /dev/null +++ b/web/src/services/tco-service.ts @@ -0,0 +1,61 @@ +import { ApiService } from './api-service.ts'; +import { TenantService } from './tenant-service.ts'; +import { FleetTCOSummary, TCOSettings, VehicleTCOSummary } from '../types/tco.ts'; + +export class TCOService { + private static instance: TCOService; + public static getInstance(): TCOService { + if (!TCOService.instance) { + TCOService.instance = new TCOService(); + } + return TCOService.instance; + } + + /** GET /tco/settings?tokenId=N. */ + getSettings(tokenId: number): Promise { + return ApiService.getInstance().get(`/tco/settings?tokenId=${tokenId}`); + } + + /** PUT /tco/settings. */ + putSettings(settings: TCOSettings): Promise { + return ApiService.getInstance().put('/tco/settings', settings); + } + + /** GET /tco/summary. Fleet-wide rollup. */ + getSummary(): Promise { + return ApiService.getInstance().get('/tco/summary'); + } + + /** GET /tco/vehicle/:tokenId. */ + getVehicleDetail(tokenId: number): Promise { + return ApiService.getInstance().get(`/tco/vehicle/${tokenId}`); + } + + /** Trigger a browser download of the CSV export. Omit tokenId for the fleet-wide export. */ + async exportCsv(tokenId?: number): Promise { + const base = ApiService.getInstance().getApiBaseUrl(); + const token = localStorage.getItem('token'); + const qs = tokenId ? `?tokenId=${tokenId}` : ''; + const res = await fetch(`${base}/tco/export.csv${qs}`, { + headers: { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...TenantService.getInstance().tenantIdHeader(), + }, + }); + if (!res.ok) { + throw new Error(`export failed: ${res.status} ${await res.text()}`); + } + const blob = await res.blob(); + const disposition = res.headers.get('Content-Disposition') || ''; + const match = /filename="?([^";]+)"?/i.exec(disposition); + const filename = match?.[1] || 'tco-export.csv'; + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } +} diff --git a/web/src/types/tco.ts b/web/src/types/tco.ts new file mode 100644 index 0000000..ca9dcb4 --- /dev/null +++ b/web/src/types/tco.ts @@ -0,0 +1,43 @@ +export interface TCOSettings { + tokenId: number; + purchasePrice?: number; + purchaseDate?: string; // YYYY-MM-DD + usefulLifeYears?: number; + currency: string; +} + +export interface LineItem { + vehicleTokenId: number; + vehicleLabel: string; + vin: string; + date: string; + category: string; + description: string; + amount: number; + currency: string; +} + +export interface VehicleTCOSummary { + tokenId: number; + vehicleLabel: string; + vin?: string; + operatingCost: number; + costByCategory: Record; + acquisitionCost: number; + depreciationToDate: number; + totalTco: number; + settings: TCOSettings; + lineItems: LineItem[]; +} + +export interface FleetTotals { + operatingCost: number; + acquisitionCost: number; + depreciationToDate: number; + totalTco: number; +} + +export interface FleetTCOSummary { + vehicles: VehicleTCOSummary[]; + fleet: FleetTotals; +} From 8d7d966b968c2ee93610f9bfd973e2f6b536eb43 Mon Sep 17 00:00:00 2001 From: James Li Date: Tue, 7 Jul 2026 22:38:18 -0700 Subject: [PATCH 09/16] feat(web): add Fuel document category and cost-eligible category set Adds 'dimo.document.vehicle.fuel' to CE_TYPE_TO_LABEL and UPLOAD_CATEGORIES. Introduces COST_ELIGIBLE_CATEGORIES Set to track which document types can carry cost amounts and count toward TCO operating costs. Mirrors the backend's CostEligibleCETypes from tco_service.go. Co-Authored-By: Claude Sonnet 5 --- web/src/utils/document-categories.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/web/src/utils/document-categories.ts b/web/src/utils/document-categories.ts index 1f1a4a1..27c0bfe 100644 --- a/web/src/utils/document-categories.ts +++ b/web/src/utils/document-categories.ts @@ -14,6 +14,7 @@ export const CE_TYPE_TO_LABEL: Record = { 'dimo.document.vehicle.note': 'Note', 'dimo.document.vehicle.expense': 'Other', 'dimo.document.vehicle.condition': 'Other', + 'dimo.document.vehicle.fuel': 'Fuel', 'dimo.document.unknown': 'Uncategorized', }; @@ -26,6 +27,7 @@ export const UPLOAD_CATEGORIES: Array<{ ceType: string; label: string }> = [ { ceType: 'dimo.document.vehicle.registration', label: 'Registration' }, { ceType: 'dimo.document.vehicle.inspection', label: 'Inspection' }, { ceType: 'dimo.document.vehicle.service.invoice', label: 'Service & parts' }, + { ceType: 'dimo.document.vehicle.fuel', label: 'Fuel' }, { ceType: 'dimo.document.vehicle.title', label: 'Title' }, { ceType: 'dimo.document.vehicle.finance', label: 'Finance' }, { ceType: 'dimo.document.vehicle.regulatory.other',label: 'Other regulatory' }, @@ -43,6 +45,23 @@ export const EXPECTED_CE_TYPES: string[] = [ 'dimo.document.vehicle.inspection', ]; +/** + * CE types eligible to carry a cost amount, and to count toward TCO operating + * costs. Mirrors the backend's CostEligibleCETypes (api/internal/service/tco_service.go). + * Everything except Note/Condition/Title. + */ +export const COST_ELIGIBLE_CATEGORIES = new Set([ + 'dimo.document.vehicle.service.invoice', + 'dimo.document.vehicle.insurance', + 'dimo.document.vehicle.registration', + 'dimo.document.vehicle.inspection', + 'dimo.document.vehicle.finance', + 'dimo.document.vehicle.regulatory.other', + 'dimo.document.vehicle.maintenance', + 'dimo.document.vehicle.expense', + 'dimo.document.vehicle.fuel', +]); + export function categoryLabel(ceType: string): string { return CE_TYPE_TO_LABEL[ceType] ?? 'Other'; } From de7d51de555e2cac92ca330f8c8982610b32c6f2 Mon Sep 17 00:00:00 2001 From: James Li Date: Wed, 8 Jul 2026 02:07:47 -0700 Subject: [PATCH 10/16] feat(web): capture an optional dollar amount at document upload confirm --- web/src/elements/upload-document-modal.ts | 32 +++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/web/src/elements/upload-document-modal.ts b/web/src/elements/upload-document-modal.ts index 03144c0..e301d4a 100644 --- a/web/src/elements/upload-document-modal.ts +++ b/web/src/elements/upload-document-modal.ts @@ -5,7 +5,7 @@ import { sharedStyles } from '../global-styles.ts'; import { DocumentService, fileToBase64 } from '../services/document-service.ts'; import { ExtractResult } from '../types/document.ts'; import { Vehicle } from '../types/vehicle.ts'; -import { UPLOAD_CATEGORIES, categoryLabel } from '../utils/document-categories.ts'; +import { UPLOAD_CATEGORIES, categoryLabel, COST_ELIGIBLE_CATEGORIES } from '../utils/document-categories.ts'; type Step = 'pick' | 'review' | 'submitting' | 'done' | 'error'; @@ -34,6 +34,7 @@ export class UploadDocumentModal extends LitElement { @state() private extractResult: ExtractResult | null = null; @state() private selectedTokenId: number | null = null; @state() private selectedCategory: string = 'dimo.document.unknown'; + @state() private amount: string = ''; @state() private errorMessage = ''; static styles = [ @@ -220,6 +221,19 @@ export class UploadDocumentModal extends LitElement { } } + private buildParsedData(): Record { + const base = { ...(this.extractResult?.fields || {}) }; + const trimmed = this.amount.trim(); + if (COST_ELIGIBLE_CATEGORIES.has(this.selectedCategory) && trimmed !== '') { + const parsed = Number(trimmed); + if (!Number.isNaN(parsed)) { + base.amount = parsed; + base.currency = 'USD'; + } + } + return base; + } + private async onConfirm() { if (!this.file || !this.selectedTokenId) return; this.step = 'submitting'; @@ -232,7 +246,7 @@ export class UploadDocumentModal extends LitElement { fileBase64, mimeType: this.file.type || 'application/octet-stream', fileName: this.file.name, - parsedData: this.extractResult?.fields || {}, + parsedData: this.buildParsedData(), }); this.step = 'done'; this.dispatchEvent(new CustomEvent('uploaded', { @@ -315,6 +329,20 @@ export class UploadDocumentModal extends LitElement { + ${COST_ELIGIBLE_CATEGORIES.has(this.selectedCategory) ? html` +
+ + { this.amount = (e.target as HTMLInputElement).value; }} + /> +
+ ` : nothing} + ${this.errorMessage ? html`
${this.errorMessage}
` : nothing}
From c5de7c4a0ae42a68c22a9cf0c98224a2b5d2011d Mon Sep 17 00:00:00 2001 From: James Li Date: Wed, 8 Jul 2026 09:52:39 -0700 Subject: [PATCH 11/16] feat(web): add TCO nav tab, route, and fleet-wide cost table Adds tco-view.ts rendering the fleet TCO summary table (via TCOService from Task 6), with a CSV export button, and wires it into the side nav and app-root router as a new /:tenantId/tco route. --- web/src/elements/app-root.ts | 5 +- web/src/elements/side-nav.ts | 3 +- web/src/views/index.ts | 1 + web/src/views/tco-view.ts | 175 +++++++++++++++++++++++++++++++++++ 4 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 web/src/views/tco-view.ts diff --git a/web/src/elements/app-root.ts b/web/src/elements/app-root.ts index 3d991b8..7863585 100644 --- a/web/src/elements/app-root.ts +++ b/web/src/elements/app-root.ts @@ -15,8 +15,9 @@ import '../views/onboard-tenant.ts'; import '../views/groups-management.ts'; import '../views/geofences-management.ts'; import '../views/fleet-list-view.ts'; +import '../views/tco-view.ts'; -type NavKey = 'vehicles' | 'stats' | 'groups' | 'geofences' | 'glovebox' | 'settings'; +type NavKey = 'vehicles' | 'stats' | 'groups' | 'geofences' | 'glovebox' | 'tco' | 'settings'; @customElement('app-root') export class AppRoot extends LitElement { @@ -69,6 +70,7 @@ export class AppRoot extends LitElement { { path: '/:tenantId/geofences', render: () => html`` }, { path: '/:tenantId/glovebox/:tokenId', render: ({ tokenId }) => html`` }, { path: '/:tenantId/glovebox', render: () => html`` }, + { path: '/:tenantId/tco', render: () => html`` }, { path: '/:tenantId/settings', render: () => html`` }, { path: '/:tenantId/stats', render: () => html`` }, ]); @@ -155,6 +157,7 @@ export class AppRoot extends LitElement { if (path.startsWith('/groups')) return 'groups'; if (path.startsWith('/geofences')) return 'geofences'; if (path.startsWith('/glovebox')) return 'glovebox'; + if (path.startsWith('/tco')) return 'tco'; if (path.startsWith('/settings')) return 'settings'; return 'vehicles'; } diff --git a/web/src/elements/side-nav.ts b/web/src/elements/side-nav.ts index 5f7d39b..7f829bc 100644 --- a/web/src/elements/side-nav.ts +++ b/web/src/elements/side-nav.ts @@ -5,7 +5,7 @@ import { themeService } from '../services/theme-service.ts'; import { sharedStyles } from '../global-styles.ts'; import { logout } from '../utils/token.ts'; -type NavKey = 'vehicles' | 'stats' | 'groups' | 'geofences' | 'glovebox' | 'settings'; +type NavKey = 'vehicles' | 'stats' | 'groups' | 'geofences' | 'glovebox' | 'tco' | 'settings'; // `suffix` is appended to the current tenant prefix (`#/`) to form the // link, so all nav stays within the active tenant's routes. @@ -18,6 +18,7 @@ const ITEMS: { key: NavKey; icon: string; label: () => string; suffix: string }[ { key: 'groups', icon: 'workspaces', label: () => msg('Groups'), suffix: '/groups' }, { key: 'geofences', icon: 'fence', label: () => msg('Geofences'), suffix: '/geofences' }, { key: 'glovebox', icon: 'inventory_2', label: () => msg('Glovebox'), suffix: '/glovebox' }, + { key: 'tco', icon: 'payments', label: () => msg('TCO'), suffix: '/tco' }, { key: 'settings', icon: 'settings', label: () => msg('Settings'), suffix: '/settings' }, ]; diff --git a/web/src/views/index.ts b/web/src/views/index.ts index c7714b2..7e24c49 100644 --- a/web/src/views/index.ts +++ b/web/src/views/index.ts @@ -4,3 +4,4 @@ export * from './vehicle-details.ts'; export * from './glovebox.ts'; export * from './account-settings.ts'; export * from './onboard-tenant.ts'; +export * from './tco-view.ts'; diff --git a/web/src/views/tco-view.ts b/web/src/views/tco-view.ts new file mode 100644 index 0000000..94982bc --- /dev/null +++ b/web/src/views/tco-view.ts @@ -0,0 +1,175 @@ +import { LitElement, html, css, nothing } from 'lit'; +import { msg } from '@lit/localize'; +import { customElement, property, state } from 'lit/decorators.js'; +import { sharedStyles } from '../global-styles.ts'; +import { TCOService } from '../services/tco-service.ts'; +import { FleetTCOSummary, VehicleTCOSummary } from '../types/tco.ts'; + +function formatMoney(n: number): string { + return n.toLocaleString(undefined, { style: 'currency', currency: 'USD' }); +} + +@customElement('tco-view') +export class TCOView extends LitElement { + @property({ type: String }) tenantId = ''; + + @state() private loading = true; + @state() private error = ''; + @state() private summary: FleetTCOSummary | null = null; + @state() private exporting = false; + + static styles = [ + sharedStyles, + css` + :host { + display: block; + width: 100%; + height: 100%; + overflow-y: auto; + padding: var(--stack-lg) var(--gutter); + } + .header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--stack-lg); + } + .header h1 { font: var(--type-headline-lg); color: var(--primary); } + button.export { + padding: 10px 18px; + border-radius: var(--radius-md); + background: var(--primary); + color: var(--on-primary); + border: none; + font: var(--type-label-caps); + letter-spacing: 0.05em; + text-transform: uppercase; + font-weight: 700; + cursor: pointer; + } + button.export:disabled { opacity: 0.5; cursor: not-allowed; } + table { width: 100%; border-collapse: collapse; } + th, td { + text-align: left; + padding: 12px 16px; + border-bottom: 1px solid var(--outline-variant); + font: var(--type-body-sm); + } + th { + font: var(--type-label-caps); + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--on-surface-variant); + } + tbody tr { cursor: pointer; } + tbody tr:hover { background: var(--surface-container-low); } + td.num { text-align: right; font-variant-numeric: tabular-nums; } + .empty, .loading, .error { + padding: 48px 0; + text-align: center; + color: var(--on-surface-variant); + } + .error { color: var(--error); } + tfoot td { font-weight: 700; border-top: 2px solid var(--outline-variant); border-bottom: none; } + `, + ]; + + async connectedCallback() { + super.connectedCallback(); + await this.loadSummary(); + } + + private async loadSummary() { + this.loading = true; + this.error = ''; + try { + this.summary = await TCOService.getInstance().getSummary(); + } catch (e) { + console.error('Failed to load TCO summary', e); + this.error = e instanceof Error ? e.message : msg('Failed to load TCO summary'); + } finally { + this.loading = false; + } + } + + private openVehicle(v: VehicleTCOSummary) { + // TODO(Task 10): open the per-vehicle TCO drilldown. + void v; + } + + private async exportFleetCsv() { + this.exporting = true; + try { + await TCOService.getInstance().exportCsv(); + } catch (e) { + console.error('Failed to export TCO CSV', e); + } finally { + this.exporting = false; + } + } + + private renderFleetTable() { + const summary = this.summary!; + if (summary.vehicles.length === 0) { + return html`
${msg('No vehicles on this account.')}
`; + } + return html` + + + + + + + + + + + + ${summary.vehicles.map((v) => html` + this.openVehicle(v)}> + + + + + + + `)} + + + + + + + + + + +
${msg('Vehicle')}${msg('Operating cost')}${msg('Acquisition')}${msg('Depreciation to date')}${msg('Total TCO')}
${v.vehicleLabel}${formatMoney(v.operatingCost)}${formatMoney(v.acquisitionCost)}${formatMoney(v.depreciationToDate)}${formatMoney(v.totalTco)}
${msg('Fleet total')}${formatMoney(summary.fleet.operatingCost)}${formatMoney(summary.fleet.acquisitionCost)}${formatMoney(summary.fleet.depreciationToDate)}${formatMoney(summary.fleet.totalTco)}
+ `; + } + + render() { + return html` +
+

${msg('Total Cost of Ownership')}

+ +
+ ${this.loading + ? html`
${msg('Loading…')}
` + : this.error + ? html`
${this.error}
` + : this.summary + ? this.renderFleetTable() + : nothing + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'tco-view': TCOView; + } +} From 1fb4a316d19384863689b02b6988fba89afa7829 Mon Sep 17 00:00:00 2001 From: James Li Date: Wed, 8 Jul 2026 10:15:28 -0700 Subject: [PATCH 12/16] feat(web): add TCO vehicle drilldown with acquisition settings and per-vehicle export Replaces Task 9's openVehicle stub with a full drilldown view: category cost breakdown, line-item table, an acquisition settings form (purchase price/date/useful life), and a per-vehicle CSV export. --- web/src/views/tco-view.ts | 161 +++++++++++++++++++++++++++++++++++++- 1 file changed, 158 insertions(+), 3 deletions(-) diff --git a/web/src/views/tco-view.ts b/web/src/views/tco-view.ts index 94982bc..51a950c 100644 --- a/web/src/views/tco-view.ts +++ b/web/src/views/tco-view.ts @@ -4,6 +4,7 @@ import { customElement, property, state } from 'lit/decorators.js'; import { sharedStyles } from '../global-styles.ts'; import { TCOService } from '../services/tco-service.ts'; import { FleetTCOSummary, VehicleTCOSummary } from '../types/tco.ts'; +import { categoryLabel } from '../utils/document-categories.ts'; function formatMoney(n: number): string { return n.toLocaleString(undefined, { style: 'currency', currency: 'USD' }); @@ -17,6 +18,16 @@ export class TCOView extends LitElement { @state() private error = ''; @state() private summary: FleetTCOSummary | null = null; @state() private exporting = false; + @state() private detailTokenId: number | null = null; + @state() private detail: VehicleTCOSummary | null = null; + @state() private loadingDetail = false; + @state() private detailError = ''; + @state() private exportingVehicle = false; + @state() private savingSettings = false; + @state() private settingsError = ''; + @state() private formPurchasePrice = ''; + @state() private formPurchaseDate = ''; + @state() private formUsefulLifeYears = ''; static styles = [ sharedStyles, @@ -71,6 +82,18 @@ export class TCOView extends LitElement { } .error { color: var(--error); } tfoot td { font-weight: 700; border-top: 2px solid var(--outline-variant); border-bottom: none; } + .back { background: none; border: none; color: var(--on-surface-variant); cursor: pointer; margin-bottom: var(--stack-md); font: var(--type-body-sm); display: flex; align-items: center; gap: 4px; } + .back:hover { color: var(--primary); } + .breakdown { display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: var(--stack-lg); } + .stat { background: var(--surface-container-low); border: 1px solid var(--outline-variant); border-radius: var(--radius-md); padding: 16px; min-width: 160px; } + .stat .label { font: var(--type-label-caps); letter-spacing: 0.05em; text-transform: uppercase; color: var(--on-surface-variant); margin-bottom: 4px; } + .stat .value { font: var(--type-headline-md); color: var(--primary); } + .settings-form { display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-end; margin-bottom: var(--stack-lg); padding: 16px; background: var(--surface-container-low); border: 1px solid var(--outline-variant); border-radius: var(--radius-md); } + .settings-form .field { display: flex; flex-direction: column; gap: 4px; } + .settings-form label { font: var(--type-label-caps); letter-spacing: 0.05em; text-transform: uppercase; color: var(--on-surface-variant); } + .settings-form input { background: var(--surface-container); color: var(--on-surface); border: 1px solid var(--outline-variant); border-radius: var(--radius-md); padding: 8px 10px; font-family: inherit; } + .settings-form button { padding: 10px 16px; border-radius: var(--radius-md); background: var(--primary); color: var(--on-primary); border: none; font: var(--type-label-caps); letter-spacing: 0.05em; text-transform: uppercase; font-weight: 700; cursor: pointer; } + .settings-form button:disabled { opacity: 0.5; cursor: not-allowed; } `, ]; @@ -92,11 +115,29 @@ export class TCOView extends LitElement { } } - private openVehicle(v: VehicleTCOSummary) { - // TODO(Task 10): open the per-vehicle TCO drilldown. - void v; + private async openVehicle(v: VehicleTCOSummary) { + this.detailTokenId = v.tokenId; + this.loadingDetail = true; + this.detailError = ''; + try { + this.detail = await TCOService.getInstance().getVehicleDetail(v.tokenId); + this.formPurchasePrice = this.detail.settings.purchasePrice?.toString() ?? ''; + this.formPurchaseDate = this.detail.settings.purchaseDate ?? ''; + this.formUsefulLifeYears = this.detail.settings.usefulLifeYears?.toString() ?? ''; + } catch (e) { + console.error('Failed to load vehicle TCO detail', e); + this.detailError = e instanceof Error ? e.message : msg('Failed to load vehicle detail'); + } finally { + this.loadingDetail = false; + } } + private closeDetail = async () => { + this.detailTokenId = null; + this.detail = null; + await this.loadSummary(); + }; + private async exportFleetCsv() { this.exporting = true; try { @@ -108,6 +149,41 @@ export class TCOView extends LitElement { } } + private async saveSettings() { + if (this.detailTokenId === null) return; + this.savingSettings = true; + this.settingsError = ''; + try { + const price = this.formPurchasePrice.trim() === '' ? undefined : Number(this.formPurchasePrice); + const life = this.formUsefulLifeYears.trim() === '' ? undefined : Number(this.formUsefulLifeYears); + await TCOService.getInstance().putSettings({ + tokenId: this.detailTokenId, + purchasePrice: price !== undefined && !Number.isNaN(price) ? price : undefined, + purchaseDate: this.formPurchaseDate.trim() === '' ? undefined : this.formPurchaseDate, + usefulLifeYears: life !== undefined && !Number.isNaN(life) ? life : undefined, + currency: 'USD', + }); + this.detail = await TCOService.getInstance().getVehicleDetail(this.detailTokenId); + } catch (e) { + console.error('Failed to save TCO settings', e); + this.settingsError = e instanceof Error ? e.message : msg('Failed to save'); + } finally { + this.savingSettings = false; + } + } + + private async exportVehicleCsv() { + if (this.detailTokenId === null) return; + this.exportingVehicle = true; + try { + await TCOService.getInstance().exportCsv(this.detailTokenId); + } catch (e) { + console.error('Failed to export vehicle TCO CSV', e); + } finally { + this.exportingVehicle = false; + } + } + private renderFleetTable() { const summary = this.summary!; if (summary.vehicles.length === 0) { @@ -148,7 +224,86 @@ export class TCOView extends LitElement { `; } + private renderDetail() { + if (this.loadingDetail) return html`
${msg('Loading…')}
`; + if (this.detailError) return html`
${this.detailError}
`; + const d = this.detail; + if (!d) return nothing; + const categories = Object.entries(d.costByCategory).sort((a, b) => b[1] - a[1]); + return html` + +
+

${d.vehicleLabel}

+ +
+ +
+
${msg('Operating cost')}
${formatMoney(d.operatingCost)}
+
${msg('Acquisition')}
${formatMoney(d.acquisitionCost)}
+
${msg('Depreciation to date')}
${formatMoney(d.depreciationToDate)}
+
${msg('Total TCO')}
${formatMoney(d.totalTco)}
+ ${categories.map(([cat, amount]) => html` +
${categoryLabel(cat)}
${formatMoney(amount)}
+ `)} +
+ +
+
+ + { this.formPurchasePrice = (e.target as HTMLInputElement).value; }} /> +
+
+ + { this.formPurchaseDate = (e.target as HTMLInputElement).value; }} /> +
+
+ + { this.formUsefulLifeYears = (e.target as HTMLInputElement).value; }} /> +
+ + ${this.settingsError ? html`${this.settingsError}` : nothing} +
+ + + + + + + + + + + + ${d.lineItems.map((li) => html` + + + + + + + `)} + +
${msg('Date')}${msg('Category')}${msg('Description')}${msg('Amount')}
${new Date(li.date).toLocaleDateString()}${categoryLabel(li.category)}${li.description}${formatMoney(li.amount)}
+ `; + } + render() { + if (this.detailTokenId !== null) { + return this.renderDetail(); + } return html`

${msg('Total Cost of Ownership')}

From ec80994b5cdddabb8a6338d25f6499b773be4777 Mon Sep 17 00:00:00 2001 From: James Li Date: Wed, 8 Jul 2026 13:01:49 -0700 Subject: [PATCH 13/16] fix(tco): exclude tombstoned documents from TCO reports; reset stale settings error on vehicle switch Deleted Glovebox documents (tombstone CEs) kept contributing to TCO cost totals and CSV exports forever because VehicleSummary read the same fetch-api entries as ListDocuments but never filtered tombstoned ids. Extracted the tombstone-id-parsing logic into a shared gateway.TombstonedIDs helper so both call sites stay in sync going forward. Also fixes tco-view's openVehicle leaking a stale settingsError from a previous vehicle's failed save into the next vehicle's drilldown. --- api/internal/controllers/documents.go | 32 +--------------- api/internal/gateway/fetch_api.go | 28 ++++++++++++++ api/internal/gateway/fetch_api_test.go | 53 ++++++++++++++++++++++++++ api/internal/service/tco_service.go | 5 +++ web/src/views/tco-view.ts | 1 + 5 files changed, 88 insertions(+), 31 deletions(-) create mode 100644 api/internal/gateway/fetch_api_test.go diff --git a/api/internal/controllers/documents.go b/api/internal/controllers/documents.go index 4df19b2..338fa91 100644 --- a/api/internal/controllers/documents.go +++ b/api/internal/controllers/documents.go @@ -5,7 +5,6 @@ import ( "crypto/sha256" "encoding/base64" "encoding/hex" - "encoding/json" "fmt" "io" "strconv" @@ -223,28 +222,7 @@ func (d *DocumentsController) ListDocuments(c *fiber.Ctx) error { } // Build tombstoned-id set so we hide deleted docs. - tombstoned := map[string]struct{}{} - for _, e := range entries { - if e.Type != "dimo.tombstone" { - continue - } - // data: {voidsId, rawReferenceId?} — attest API renamed referenceId→voidsId; - // parse both so existing tombstones stored under the old name still work. - var d struct { - VoidsID string `json:"voidsId"` - ReferenceID string `json:"referenceId"` - RawReferenceID string `json:"rawReferenceId"` - } - _ = jsonUnmarshal(e.Data, &d) - if d.VoidsID != "" { - tombstoned[d.VoidsID] = struct{}{} - } else if d.ReferenceID != "" { - tombstoned[d.ReferenceID] = struct{}{} - } - if d.RawReferenceID != "" { - tombstoned[d.RawReferenceID] = struct{}{} - } - } + tombstoned := gateway.TombstonedIDs(entries) // rawByHash: filehash -> raw ID, so we can include raw IDs alongside parsed // (for delete path; download uses filehash directly). @@ -366,14 +344,6 @@ func (d *DocumentsController) DeleteDocument(c *fiber.Ctx) error { return c.JSON(result) } -// jsonUnmarshal is a tiny wrapper that no-ops on nil input. -func jsonUnmarshal(raw []byte, dst interface{}) error { - if len(raw) == 0 { - return nil - } - return json.Unmarshal(raw, dst) -} - func isAllowedMime(m string) bool { switch m { case "application/pdf", "image/jpeg", "image/png": diff --git a/api/internal/gateway/fetch_api.go b/api/internal/gateway/fetch_api.go index 5264151..37d2c9e 100644 --- a/api/internal/gateway/fetch_api.go +++ b/api/internal/gateway/fetch_api.go @@ -176,6 +176,34 @@ func (f *FetchAPI) FindRawByFilehash(tenant models.Tenant, tokenDID, fileHash st return nil, nil } +// TombstonedIDs scans entries for dimo.tombstone CEs and returns the set of +// CE ids they void. Both the parsed id (voidsId, or the older referenceId +// name) and the paired raw id (rawReferenceId) are included, matching the +// dimo.tombstone data shape: {voidsId, rawReferenceId?}. +func TombstonedIDs(entries []AttestationEntry) map[string]struct{} { + tombstoned := map[string]struct{}{} + for _, e := range entries { + if e.Type != "dimo.tombstone" { + continue + } + var d struct { + VoidsID string `json:"voidsId"` + ReferenceID string `json:"referenceId"` + RawReferenceID string `json:"rawReferenceId"` + } + _ = json.Unmarshal(e.Data, &d) + if d.VoidsID != "" { + tombstoned[d.VoidsID] = struct{}{} + } else if d.ReferenceID != "" { + tombstoned[d.ReferenceID] = struct{}{} + } + if d.RawReferenceID != "" { + tombstoned[d.RawReferenceID] = struct{}{} + } + } + return tombstoned +} + func isDocumentType(t string) bool { return strings.HasPrefix(t, "dimo.document.") || strings.HasPrefix(t, "dimo.raw.") || diff --git a/api/internal/gateway/fetch_api_test.go b/api/internal/gateway/fetch_api_test.go new file mode 100644 index 0000000..3616e2b --- /dev/null +++ b/api/internal/gateway/fetch_api_test.go @@ -0,0 +1,53 @@ +package gateway + +import ( + "encoding/json" + "testing" +) + +func TestTombstonedIDs(t *testing.T) { + entries := []AttestationEntry{ + {ID: "doc-1", Type: "dimo.document.vehicle.service.invoice", Data: json.RawMessage(`{"amount":500}`)}, + {ID: "tomb-1", Type: "dimo.tombstone", Data: json.RawMessage(`{"voidsId":"doc-1"}`)}, + } + got := TombstonedIDs(entries) + if _, ok := got["doc-1"]; !ok { + t.Fatalf("expected doc-1 to be tombstoned, got %v", got) + } + if len(got) != 1 { + t.Fatalf("expected exactly one tombstoned id, got %v", got) + } +} + +func TestTombstonedIDs_ReferenceIDFallback(t *testing.T) { + entries := []AttestationEntry{ + {ID: "tomb-1", Type: "dimo.tombstone", Data: json.RawMessage(`{"referenceId":"doc-2"}`)}, + } + got := TombstonedIDs(entries) + if _, ok := got["doc-2"]; !ok { + t.Fatalf("expected doc-2 to be tombstoned via referenceId fallback, got %v", got) + } +} + +func TestTombstonedIDs_RawReferenceIDIncluded(t *testing.T) { + entries := []AttestationEntry{ + {ID: "tomb-1", Type: "dimo.tombstone", Data: json.RawMessage(`{"voidsId":"doc-3","rawReferenceId":"raw-3"}`)}, + } + got := TombstonedIDs(entries) + if _, ok := got["doc-3"]; !ok { + t.Fatalf("expected doc-3 (voidsId) tombstoned, got %v", got) + } + if _, ok := got["raw-3"]; !ok { + t.Fatalf("expected raw-3 (rawReferenceId) tombstoned, got %v", got) + } +} + +func TestTombstonedIDs_IgnoresNonTombstoneEntries(t *testing.T) { + entries := []AttestationEntry{ + {ID: "doc-1", Type: "dimo.document.vehicle.insurance", Data: json.RawMessage(`{"amount":100}`)}, + } + got := TombstonedIDs(entries) + if len(got) != 0 { + t.Fatalf("expected no tombstoned ids, got %v", got) + } +} diff --git a/api/internal/service/tco_service.go b/api/internal/service/tco_service.go index 3399487..832ffb1 100644 --- a/api/internal/service/tco_service.go +++ b/api/internal/service/tco_service.go @@ -288,8 +288,13 @@ func (s *TCOService) VehicleSummary(ctx context.Context, tenant models.Tenant, t return nil, fmt.Errorf("list documents: %w", err) } + tombstoned := gateway.TombstonedIDs(entries) + lineItems := make([]LineItem, 0, len(entries)) for _, e := range entries { + if _, gone := tombstoned[e.ID]; gone { + continue + } if !isCostEligible(e.Type) { continue } diff --git a/web/src/views/tco-view.ts b/web/src/views/tco-view.ts index 51a950c..f313b84 100644 --- a/web/src/views/tco-view.ts +++ b/web/src/views/tco-view.ts @@ -119,6 +119,7 @@ export class TCOView extends LitElement { this.detailTokenId = v.tokenId; this.loadingDetail = true; this.detailError = ''; + this.settingsError = ''; try { this.detail = await TCOService.getInstance().getVehicleDetail(v.tokenId); this.formPurchasePrice = this.detail.settings.purchasePrice?.toString() ?? ''; From 1aabc4e76e42f64cbc50f8fa47699cecd04a94ca Mon Sep 17 00:00:00 2001 From: James Li Date: Wed, 8 Jul 2026 21:16:51 -0700 Subject: [PATCH 14/16] fix(tco): match app styling conventions, load progressively, handle missing permissions and hidden vehicles Restyled tco-view.ts to use the app's shared header.top-bar/tenant-switcher/ table conventions (previously it had ad-hoc styling that didn't match the rest of the app). The fleet table now loads the vehicle list first (fast) and fills in each vehicle's cost figures in the background (mirroring Glovebox's doc-count prefetch pattern), instead of blocking the whole view on the slowest vehicle's fetch-api round trip. Vehicles the dev license lacks SACD permissions for previously returned a raw 500 and left their row stuck in perpetual "loading" once the table started rendering progressively. TCOService.VehicleSummary now detects this case the same way DocumentsController.ListDocuments already does, and the row shows a "No access" badge instead. Vehicles hidden via the Fleet List view are now hidden here too by default, with the same "Show hidden vehicles" toggle used there. --- api/internal/service/tco_service.go | 31 +- web/src/types/tco.ts | 4 + web/src/views/tco-view.ts | 586 ++++++++++++++++++++++------ 3 files changed, 495 insertions(+), 126 deletions(-) diff --git a/api/internal/service/tco_service.go b/api/internal/service/tco_service.go index 832ffb1..12f1462 100644 --- a/api/internal/service/tco_service.go +++ b/api/internal/service/tco_service.go @@ -218,6 +218,12 @@ type VehicleTCOSummary struct { TotalTCO float64 `json:"totalTco"` Settings TCOSettings `json:"settings"` LineItems []LineItem `json:"lineItems"` + // PermissionsRequired mirrors DocumentsController.ListDocuments: the dev + // license lacks SACD permissions on this vehicle, so its document list + // (and therefore its cost figures) couldn't be read. Acquisition/ + // depreciation figures are still populated from vehicle_tco_settings, + // which doesn't depend on fetch-api access. + PermissionsRequired bool `json:"permissionsRequired,omitempty"` } // FleetTotals sums each VehicleTCOSummary field across the fleet. @@ -284,8 +290,18 @@ func (s *TCOService) VehicleSummary(ctx context.Context, tenant models.Tenant, t tokenDID := s.authProvider.BuildVehicleDID(uint64(tokenID)) entries, err := s.fetchAPI.ListByDID(tenant, tokenDID, 500) + permissionsRequired := false if err != nil { - return nil, fmt.Errorf("list documents: %w", err) + // Mirrors DocumentsController.ListDocuments: a 403 here means the dev + // license lacks SACD permissions on this vehicle, not that something + // is actually broken. Degrade to an empty (but still valid) summary — + // acquisition/depreciation below still works since it's DB-only — + // rather than failing the whole vehicle out of the fleet report. + if strings.Contains(err.Error(), "lacks permissions") || strings.Contains(err.Error(), "status code 403") { + permissionsRequired = true + } else { + return nil, fmt.Errorf("list documents: %w", err) + } } tombstoned := gateway.TombstonedIDs(entries) @@ -320,12 +336,13 @@ func (s *TCOService) VehicleSummary(ctx context.Context, tenant models.Tenant, t } summary := &VehicleTCOSummary{ - VehicleTokenID: tokenID, - VehicleLabel: label, - VIN: vehicle.VIN, - CostByCategory: sumLineItemsByCategory(lineItems), - Settings: settings, - LineItems: lineItems, + VehicleTokenID: tokenID, + VehicleLabel: label, + VIN: vehicle.VIN, + CostByCategory: sumLineItemsByCategory(lineItems), + Settings: settings, + LineItems: lineItems, + PermissionsRequired: permissionsRequired, } for _, v := range summary.CostByCategory { summary.OperatingCost += v diff --git a/web/src/types/tco.ts b/web/src/types/tco.ts index ca9dcb4..752896d 100644 --- a/web/src/types/tco.ts +++ b/web/src/types/tco.ts @@ -28,6 +28,10 @@ export interface VehicleTCOSummary { totalTco: number; settings: TCOSettings; lineItems: LineItem[]; + /** True when the dev license lacks SACD permissions on this vehicle — its + * document/operating-cost figures could not be read (acquisition/ + * depreciation still work, since those are DB-only). */ + permissionsRequired?: boolean; } export interface FleetTotals { diff --git a/web/src/views/tco-view.ts b/web/src/views/tco-view.ts index f313b84..45291dc 100644 --- a/web/src/views/tco-view.ts +++ b/web/src/views/tco-view.ts @@ -2,22 +2,40 @@ import { LitElement, html, css, nothing } from 'lit'; import { msg } from '@lit/localize'; import { customElement, property, state } from 'lit/decorators.js'; import { sharedStyles } from '../global-styles.ts'; +import { ApiService } from '../services/api-service.ts'; +import { hiddenVehiclesService } from '../services/hidden-vehicles-service.ts'; import { TCOService } from '../services/tco-service.ts'; -import { FleetTCOSummary, VehicleTCOSummary } from '../types/tco.ts'; +import { VehicleTCOSummary } from '../types/tco.ts'; +import { Vehicle, VehiclesResponse } from '../types/vehicle.ts'; import { categoryLabel } from '../utils/document-categories.ts'; function formatMoney(n: number): string { return n.toLocaleString(undefined, { style: 'currency', currency: 'USD' }); } +function vehicleTitle(v: Vehicle): string { + const d = v.definition; + const parts = [d.year ? String(d.year) : '', d.make, d.model].filter(Boolean); + return parts.length ? parts.join(' ') : `Vehicle #${v.tokenId}`; +} + @customElement('tco-view') export class TCOView extends LitElement { @property({ type: String }) tenantId = ''; @state() private loading = true; @state() private error = ''; - @state() private summary: FleetTCOSummary | null = null; + @state() private vehicles: Vehicle[] = []; + // TCO figures fill in per-vehicle as they arrive (see prefetchTco), so the + // table paints immediately from the fast /vehicles list rather than + // blocking on the whole fleet's fetch-api round trips. + @state() private tcoByToken = new Map(); @state() private exporting = false; + @state() private hiddenVehicles = new Set(); + @state() private showHidden = false; + private unsubscribeHidden: (() => void) | null = null; + private connected = false; + private static readonly PREFETCH_PARALLEL = 3; @state() private detailTokenId: number | null = null; @state() private detail: VehicleTCOSummary | null = null; @state() private loadingDetail = false; @@ -33,21 +51,52 @@ export class TCOView extends LitElement { sharedStyles, css` :host { - display: block; + display: flex; + flex-direction: column; width: 100%; height: 100%; overflow-y: auto; - padding: var(--stack-lg) var(--gutter); + background: var(--background); } - .header { + + /* ── Top bar (matches fleet-list-view / groups-management) ─── */ + header.top-bar { + position: sticky; + top: 0; + z-index: 40; + flex-shrink: 0; display: flex; align-items: center; justify-content: space-between; - margin-bottom: var(--stack-lg); + height: var(--top-bar-height, 80px); + padding: 0 var(--gutter); + background: var(--background); + border-bottom: 1px solid var(--outline-variant); } - .header h1 { font: var(--type-headline-lg); color: var(--primary); } - button.export { - padding: 10px 18px; + header.top-bar .left { display: flex; align-items: center; gap: 16px; } + header.top-bar h2 { font: var(--type-headline-md); color: var(--primary); } + header.top-bar .right { display: flex; align-items: center; gap: 16px; } + + .back-link { + background: none; + border: none; + color: var(--on-surface-variant); + cursor: pointer; + font: var(--type-body-sm); + display: flex; + align-items: center; + gap: 4px; + padding: 0; + transition: color 0.15s ease; + } + .back-link:hover { color: var(--primary); } + .back-link .material-symbols-outlined { font-size: 18px; } + + .export-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; border-radius: var(--radius-md); background: var(--primary); color: var(--on-primary); @@ -57,71 +106,271 @@ export class TCOView extends LitElement { text-transform: uppercase; font-weight: 700; cursor: pointer; + transition: opacity 0.15s ease; } - button.export:disabled { opacity: 0.5; cursor: not-allowed; } - table { width: 100%; border-collapse: collapse; } - th, td { - text-align: left; - padding: 12px 16px; - border-bottom: 1px solid var(--outline-variant); - font: var(--type-body-sm); + .export-btn:hover { opacity: 0.9; } + .export-btn:disabled { opacity: 0.5; cursor: not-allowed; } + + /* ── Canvas ───────────────────────────────────────────────── */ + .canvas { + flex: 1; + width: 100%; + max-width: var(--container-max-width); + margin: 0 auto; + padding: var(--stack-lg) var(--gutter); + box-sizing: border-box; } + .canvas h1 { font: var(--type-headline-md); color: var(--primary); margin-bottom: var(--stack-md); } + + /* ── Table (matches fleet-list-view) ─────────────────────── */ + .table-wrap { + border: 1px solid var(--outline-variant); + border-radius: var(--radius-lg); + overflow: hidden; + } + table { width: 100%; border-collapse: collapse; font: var(--type-body-sm); color: var(--on-surface); } + thead { background: var(--surface-container-low); } th { + text-align: left; + padding: 12px 16px; font: var(--type-label-caps); - letter-spacing: 0.05em; + letter-spacing: 0.06em; text-transform: uppercase; color: var(--on-surface-variant); + border-bottom: 1px solid var(--outline-variant); + white-space: nowrap; + } + td { + padding: 14px 16px; + vertical-align: middle; + border-bottom: 1px solid var(--outline-variant); } - tbody tr { cursor: pointer; } - tbody tr:hover { background: var(--surface-container-low); } - td.num { text-align: right; font-variant-numeric: tabular-nums; } + tbody tr { cursor: pointer; transition: background 0.1s ease; } + tbody tr:hover { background: var(--surface-container); } + tbody tr:last-child td { border-bottom: none; } + td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; } + tfoot td { + font-weight: 700; + background: var(--surface-container-low); + border-top: 1px solid var(--outline-variant); + border-bottom: none; + } + + /* ── States ───────────────────────────────────────────────── */ .empty, .loading, .error { - padding: 48px 0; + padding: 64px var(--gutter); text-align: center; color: var(--on-surface-variant); + font: var(--type-body-md); } .error { color: var(--error); } - tfoot td { font-weight: 700; border-top: 2px solid var(--outline-variant); border-bottom: none; } - .back { background: none; border: none; color: var(--on-surface-variant); cursor: pointer; margin-bottom: var(--stack-md); font: var(--type-body-sm); display: flex; align-items: center; gap: 4px; } - .back:hover { color: var(--primary); } - .breakdown { display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: var(--stack-lg); } - .stat { background: var(--surface-container-low); border: 1px solid var(--outline-variant); border-radius: var(--radius-md); padding: 16px; min-width: 160px; } - .stat .label { font: var(--type-label-caps); letter-spacing: 0.05em; text-transform: uppercase; color: var(--on-surface-variant); margin-bottom: 4px; } + .cell-loading { color: var(--on-surface-variant); opacity: 0.5; letter-spacing: 1px; } + .no-perm-badge { + display: inline-flex; + align-items: center; + gap: 3px; + font: var(--type-label-caps); + letter-spacing: 0.04em; + color: #ffb432; + font-size: 10px; + } + .no-perm-badge .material-symbols-outlined { font-size: 12px; } + .perms-notice { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + background: rgba(255, 180, 52, 0.08); + border: 1px solid rgba(255, 180, 52, 0.25); + border-radius: var(--radius-md); + color: #ffb432; + font: var(--type-body-sm); + margin-bottom: var(--stack-lg); + } + .perms-notice .material-symbols-outlined { font-size: 18px; flex-shrink: 0; } + + /* ── Hidden vehicles ──────────────────────────────────────── */ + .hidden-toggle-row { display: flex; justify-content: flex-end; margin-bottom: var(--stack-sm); } + .show-hidden-btn { + display: flex; + align-items: center; + gap: 6px; + background: none; + border: 1px solid var(--outline-variant); + border-radius: var(--radius-md); + color: var(--on-surface-variant); + font: var(--type-body-sm); + padding: 8px 12px; + cursor: pointer; + transition: background 0.15s, color 0.15s, border-color 0.15s; + white-space: nowrap; + } + .show-hidden-btn:hover { color: var(--primary); border-color: var(--primary); } + .show-hidden-btn.active { + background: var(--primary-container); + color: var(--on-primary-container); + border-color: var(--primary); + } + .show-hidden-btn .material-symbols-outlined { font-size: 18px; } + .show-hidden-btn .hidden-count { font-weight: 700; font-size: 12px; } + tbody tr.hidden-row { opacity: 0.45; } + tbody tr.hidden-row:hover { opacity: 0.7; } + + /* ── Vehicle drilldown ────────────────────────────────────── */ + .drilldown-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--stack-md); + } + .breakdown { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: var(--stack-lg); } + .stat { + background: var(--surface-container-low); + border: 1px solid var(--outline-variant); + border-radius: var(--radius-md); + padding: 16px; + min-width: 160px; + } + .stat .label { + font: var(--type-label-caps); + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--on-surface-variant); + margin-bottom: 4px; + } .stat .value { font: var(--type-headline-md); color: var(--primary); } - .settings-form { display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-end; margin-bottom: var(--stack-lg); padding: 16px; background: var(--surface-container-low); border: 1px solid var(--outline-variant); border-radius: var(--radius-md); } - .settings-form .field { display: flex; flex-direction: column; gap: 4px; } - .settings-form label { font: var(--type-label-caps); letter-spacing: 0.05em; text-transform: uppercase; color: var(--on-surface-variant); } - .settings-form input { background: var(--surface-container); color: var(--on-surface); border: 1px solid var(--outline-variant); border-radius: var(--radius-md); padding: 8px 10px; font-family: inherit; } - .settings-form button { padding: 10px 16px; border-radius: var(--radius-md); background: var(--primary); color: var(--on-primary); border: none; font: var(--type-label-caps); letter-spacing: 0.05em; text-transform: uppercase; font-weight: 700; cursor: pointer; } - .settings-form button:disabled { opacity: 0.5; cursor: not-allowed; } + + .settings-form { + display: flex; + flex-wrap: wrap; + gap: 16px; + align-items: flex-end; + margin-bottom: var(--stack-lg); + padding: 16px; + background: var(--surface-container-low); + border: 1px solid var(--outline-variant); + border-radius: var(--radius-md); + } + .settings-form .field { display: flex; flex-direction: column; gap: 6px; } + .settings-form label { + font: var(--type-label-caps); + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--on-surface-variant); + } + .settings-form input { + background: var(--surface-container); + color: var(--on-surface); + border: 1px solid var(--outline-variant); + border-radius: var(--radius-md); + padding: 10px 12px; + font-family: inherit; + font-size: 14px; + } + .settings-form input:focus { outline: 1px solid var(--primary); } + .settings-form .save-btn { + padding: 10px 16px; + border-radius: var(--radius-md); + background: var(--primary); + color: var(--on-primary); + border: none; + font: var(--type-label-caps); + letter-spacing: 0.05em; + text-transform: uppercase; + font-weight: 700; + cursor: pointer; + transition: opacity 0.15s ease; + } + .settings-form .save-btn:hover { opacity: 0.9; } + .settings-form .save-btn:disabled { opacity: 0.5; cursor: not-allowed; } + .settings-form .form-error { font: var(--type-body-sm); color: var(--error); } + + .section-label { + font: var(--type-label-caps); + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--on-surface-variant); + margin-bottom: var(--stack-sm); + } `, ]; async connectedCallback() { super.connectedCallback(); - await this.loadSummary(); - } - - private async loadSummary() { + this.connected = true; + this.hiddenVehicles = hiddenVehiclesService.getHidden(this.tenantId); + this.unsubscribeHidden = hiddenVehiclesService.subscribe(() => { + this.hiddenVehicles = hiddenVehiclesService.getHidden(this.tenantId); + }); this.loading = true; this.error = ''; try { - this.summary = await TCOService.getInstance().getSummary(); + const res = await ApiService.getInstance().get('/vehicles'); + this.vehicles = res.vehicles || []; } catch (e) { - console.error('Failed to load TCO summary', e); - this.error = e instanceof Error ? e.message : msg('Failed to load TCO summary'); + console.error('Failed to load vehicles', e); + this.error = e instanceof Error ? e.message : msg('Failed to load vehicles'); } finally { this.loading = false; } + // Fire-and-forget: fill in each vehicle's cost figures in the + // background so the table shows real numbers as they arrive instead + // of blocking the whole view on the slowest vehicle's fetch-api call. + void this.prefetchTco(); + } + + disconnectedCallback() { + this.connected = false; + this.unsubscribeHidden?.(); + this.unsubscribeHidden = null; + super.disconnectedCallback(); + } + + /** Vehicles hidden elsewhere (Fleet List) are skipped here too, unless + * the user has toggled "Show hidden" for this session. */ + private visibleVehicles(): Vehicle[] { + if (this.showHidden) return this.vehicles; + return this.vehicles.filter((v) => !this.hiddenVehicles.has(String(v.tokenId))); } - private async openVehicle(v: VehicleTCOSummary) { - this.detailTokenId = v.tokenId; + private async prefetchTco() { + // Skip fetching cost figures for hidden vehicles too — no point + // spending fetch-api round trips on rows the user won't see. + const targets = this.visibleVehicles().filter((v) => !this.tcoByToken.has(v.tokenId)); + let next = 0; + const worker = async () => { + while (next < targets.length) { + if (!this.connected) return; + const v = targets[next++]; + try { + const detail = await TCOService.getInstance().getVehicleDetail(v.tokenId); + if (!this.connected) return; + this.tcoByToken = new Map(this.tcoByToken).set(v.tokenId, detail); + } catch (e) { + console.error('Failed to load TCO for vehicle', v.tokenId, e); + // Leave uncached — the row falls back to a loading dash. + } + } + }; + await Promise.all( + Array.from({ length: Math.min(TCOView.PREFETCH_PARALLEL, targets.length) }, () => worker()), + ); + } + + private async openVehicle(tokenId: number) { + this.detailTokenId = tokenId; this.loadingDetail = true; this.detailError = ''; this.settingsError = ''; try { - this.detail = await TCOService.getInstance().getVehicleDetail(v.tokenId); + // Reuse the prefetched figures if already in hand so the drilldown + // paints instantly; otherwise fetch fresh (e.g. clicked before its + // prefetch turn came up). + const cached = this.tcoByToken.get(tokenId); + this.detail = cached ?? await TCOService.getInstance().getVehicleDetail(tokenId); + if (!cached) { + this.tcoByToken = new Map(this.tcoByToken).set(tokenId, this.detail); + } this.formPurchasePrice = this.detail.settings.purchasePrice?.toString() ?? ''; this.formPurchaseDate = this.detail.settings.purchaseDate ?? ''; this.formUsefulLifeYears = this.detail.settings.usefulLifeYears?.toString() ?? ''; @@ -133,10 +382,9 @@ export class TCOView extends LitElement { } } - private closeDetail = async () => { + private closeDetail = () => { this.detailTokenId = null; this.detail = null; - await this.loadSummary(); }; private async exportFleetCsv() { @@ -165,6 +413,7 @@ export class TCOView extends LitElement { currency: 'USD', }); this.detail = await TCOService.getInstance().getVehicleDetail(this.detailTokenId); + this.tcoByToken = new Map(this.tcoByToken).set(this.detailTokenId, this.detail); } catch (e) { console.error('Failed to save TCO settings', e); this.settingsError = e instanceof Error ? e.message : msg('Failed to save'); @@ -185,43 +434,116 @@ export class TCOView extends LitElement { } } + private renderNum(v: Vehicle, pick: (t: VehicleTCOSummary) => number) { + const t = this.tcoByToken.get(v.tokenId); + return t ? formatMoney(pick(t)) : html`···`; + } + + /** Operating cost specifically needs fetch-api document access — show why + * it's $0 when the dev license lacks SACD permissions on this vehicle, + * rather than a plain (misleadingly complete-looking) dollar figure. */ + private renderOperatingCost(v: Vehicle) { + const t = this.tcoByToken.get(v.tokenId); + if (!t) return html`···`; + if (t.permissionsRequired) { + return html` + + lock + ${msg('No access')} + + `; + } + return formatMoney(t.operatingCost); + } + + private toggleShowHidden() { + this.showHidden = !this.showHidden; + if (this.showHidden) { + // Rows just revealed may not have been prefetched yet (prefetch + // skips hidden vehicles) — top up in the background. + void this.prefetchTco(); + } + } + private renderFleetTable() { - const summary = this.summary!; - if (summary.vehicles.length === 0) { - return html`
${msg('No vehicles on this account.')}
`; + const visible = this.visibleVehicles(); + if (this.vehicles.length === 0) { + return html` +
+ payments + ${msg('No vehicles on this account.')} +
+ `; + } + if (visible.length === 0) { + return html` +
+ +
+
+ visibility_off + ${msg('All vehicles on this account are hidden.')} +
+ `; + } + const allLoaded = visible.every((v) => this.tcoByToken.has(v.tokenId)); + let fleetOperating = 0, fleetAcquisition = 0, fleetDepreciation = 0, fleetTotal = 0; + for (const v of visible) { + const t = this.tcoByToken.get(v.tokenId); + if (!t) continue; + fleetOperating += t.operatingCost; + fleetAcquisition += t.acquisitionCost; + fleetDepreciation += t.depreciationToDate; + fleetTotal += t.totalTco; } return html` - - - - - - - - - - - - ${summary.vehicles.map((v) => html` - this.openVehicle(v)}> - - - - - + ${this.hiddenVehicles.size > 0 ? html` +
+ +
+ ` : nothing} +
+
${msg('Vehicle')}${msg('Operating cost')}${msg('Acquisition')}${msg('Depreciation to date')}${msg('Total TCO')}
${v.vehicleLabel}${formatMoney(v.operatingCost)}${formatMoney(v.acquisitionCost)}${formatMoney(v.depreciationToDate)}${formatMoney(v.totalTco)}
+ + + + + + + + + + + ${visible.map((v) => html` + this.openVehicle(v.tokenId)}> + + + + + + + `)} + + + + + + + + - `)} - - - - - - - - - - -
${msg('Vehicle')}${msg('Operating cost')}${msg('Acquisition')}${msg('Depreciation to date')}${msg('Total TCO')}
${vehicleTitle(v)}${this.renderOperatingCost(v)}${this.renderNum(v, (t) => t.acquisitionCost)}${this.renderNum(v, (t) => t.depreciationToDate)}${this.renderNum(v, (t) => t.totalTco)}
${msg('Fleet total')}${allLoaded ? formatMoney(fleetOperating) : html`···`}${allLoaded ? formatMoney(fleetAcquisition) : html`···`}${allLoaded ? formatMoney(fleetDepreciation) : html`···`}${allLoaded ? formatMoney(fleetTotal) : html`···`}
${msg('Fleet total')}${formatMoney(summary.fleet.operatingCost)}${formatMoney(summary.fleet.acquisitionCost)}${formatMoney(summary.fleet.depreciationToDate)}${formatMoney(summary.fleet.totalTco)}
+ + +
`; } @@ -232,17 +554,26 @@ export class TCOView extends LitElement { if (!d) return nothing; const categories = Object.entries(d.costByCategory).sort((a, b) => b[1] - a[1]); return html` - -
+ +

${d.vehicleLabel}

-
+ ${d.permissionsRequired ? html` +
+ lock + ${msg('Grant DIMO permissions to this vehicle to include its documents in operating cost. Acquisition and depreciation below are unaffected.')} +
+ ` : nothing} +
${msg('Operating cost')}
${formatMoney(d.operatingCost)}
${msg('Acquisition')}
${formatMoney(d.acquisitionCost)}
@@ -253,6 +584,7 @@ export class TCOView extends LitElement { `)}
+
@@ -272,54 +604,70 @@ export class TCOView extends LitElement { .value=${this.formUsefulLifeYears} @input=${(e: Event) => { this.formUsefulLifeYears = (e.target as HTMLInputElement).value; }} />
- - ${this.settingsError ? html`${this.settingsError}` : nothing} + ${this.settingsError ? html`${this.settingsError}` : nothing}
- - - - - - - - - - - ${d.lineItems.map((li) => html` - - - - - - - `)} - -
${msg('Date')}${msg('Category')}${msg('Description')}${msg('Amount')}
${new Date(li.date).toLocaleDateString()}${categoryLabel(li.category)}${li.description}${formatMoney(li.amount)}
+ + ${d.lineItems.length === 0 + ? html`
${msg('No cost documents on file for this vehicle yet.')}
` + : html` +
+ + + + + + + + + + + ${d.lineItems.map((li) => html` + + + + + + + `)} + +
${msg('Date')}${msg('Category')}${msg('Description')}${msg('Amount')}
${new Date(li.date).toLocaleDateString()}${categoryLabel(li.category)}${li.description}${formatMoney(li.amount)}
+
+ ` + } `; } render() { - if (this.detailTokenId !== null) { - return this.renderDetail(); - } return html` -
-

${msg('Total Cost of Ownership')}

- +
+
+

${msg('Total Cost of Ownership')}

+
+
+ ${this.detailTokenId === null ? html` + + ` : nothing} + +
+
+ +
+ ${this.detailTokenId !== null + ? this.renderDetail() + : this.loading + ? html`
${msg('Loading…')}
` + : this.error + ? html`
${this.error}
` + : this.renderFleetTable() + }
- ${this.loading - ? html`
${msg('Loading…')}
` - : this.error - ? html`
${this.error}
` - : this.summary - ? this.renderFleetTable() - : nothing - } `; } } From ccdf5d37fa9e2492dce604ae18e3fc7948ddef7d Mon Sep 17 00:00:00 2001 From: James Li Date: Wed, 8 Jul 2026 21:42:24 -0700 Subject: [PATCH 15/16] feat(tco): cache fleet table, backfill amounts on existing documents, paginate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caching: TCOCache (mirrors FleetCache) holds the last-loaded vehicle list and per-vehicle cost figures, keyed by tenant. Revisiting the TCO tab now paints instantly from cache instead of re-fetching the whole fleet; a manual refresh button forces a full reload. Cache is invalidated whenever something could make it stale — a Glovebox upload, a delete, a settings save, or a backfilled amount. Backfill: documents uploaded without an amount (including everything from before this feature existed) can now get one after the fact. Since CloudEvents are immutable, backfilling emits a new "cost-amendment" CE that references the original document by id rather than mutating it — the same pattern dimo.tombstone already uses for delete. VehicleTCOSummary now returns a MissingAmounts list; the drilldown offers an inline amount input per missing document. Pagination: the fleet table shows 10 vehicles per page. The "Fleet total" footer row is always summed across every visible vehicle regardless of which page is showing, not just the current page. --- api/internal/app/app.go | 3 +- api/internal/controllers/tco.go | 43 ++++ api/internal/service/attest_service.go | 64 ++++++ api/internal/service/tco_service.go | 81 ++++++- api/internal/service/tco_service_test.go | 43 ++++ web/src/services/tco-cache.ts | 32 +++ web/src/services/tco-service.ts | 10 + web/src/types/tco.ts | 5 + web/src/views/glovebox.ts | 6 + web/src/views/tco-view.ts | 262 ++++++++++++++++++++++- 10 files changed, 531 insertions(+), 18 deletions(-) create mode 100644 web/src/services/tco-cache.ts diff --git a/api/internal/app/app.go b/api/internal/app/app.go index 0223ffb..f7a73fa 100644 --- a/api/internal/app/app.go +++ b/api/internal/app/app.go @@ -193,13 +193,14 @@ func App( // Total cost of ownership reporting (operating costs from Glovebox // documents + optional acquisition/depreciation settings per vehicle). - tcoSvc := service.NewTCOService(logger, pdb, fetchAPI, authProvider, vehicleSvc) + tcoSvc := service.NewTCOService(logger, pdb, fetchAPI, authProvider, vehicleSvc, attestSvc) tcoCtrl := controllers.NewTCOController(logger, tcoSvc, vehicleSvc) tenantApp.Get("/tco/settings", tcoCtrl.GetSettings) tenantApp.Put("/tco/settings", tcoCtrl.PutSettings) tenantApp.Get("/tco/summary", tcoCtrl.GetSummary) tenantApp.Get("/tco/vehicle/:tokenId", tcoCtrl.GetVehicleDetail) tenantApp.Get("/tco/export.csv", tcoCtrl.ExportCSV) + tenantApp.Put("/tco/vehicle/:tokenId/backfill/:documentId", tcoCtrl.BackfillAmount) return app } diff --git a/api/internal/controllers/tco.go b/api/internal/controllers/tco.go index 536f68b..4a41776 100644 --- a/api/internal/controllers/tco.go +++ b/api/internal/controllers/tco.go @@ -122,6 +122,49 @@ func (t *TCOController) GetVehicleDetail(c *fiber.Ctx) error { return c.JSON(summary) } +// BackfillAmountRequest is the body for PUT /tco/vehicle/:tokenId/backfill/:documentId. +type BackfillAmountRequest struct { + Amount float64 `json:"amount"` + Currency string `json:"currency"` +} + +// BackfillAmount — PUT /tco/vehicle/:tokenId/backfill/:documentId. Attaches a +// dollar amount to a document that was uploaded without one, via a +// cost-amendment CE (the original document is immutable and untouched). +func (t *TCOController) BackfillAmount(c *fiber.Ctx) error { + tenant, err := GetTenant(c) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + tokenID, err := strconv.ParseInt(c.Params("tokenId"), 10, 64) + if err != nil || tokenID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "valid tokenId path param required") + } + documentID := c.Params("documentId") + if documentID == "" { + return fiber.NewError(fiber.StatusBadRequest, "documentId path param required") + } + if !t.vehicleInTenant(c.Context(), tenant.ID, tokenID) { + return fiber.NewError(fiber.StatusForbidden, "vehicle is not part of this tenant") + } + var req BackfillAmountRequest + if err := c.BodyParser(&req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "invalid request body: "+err.Error()) + } + if req.Amount <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "amount must be greater than 0") + } + currency := req.Currency + if currency == "" { + currency = "USD" + } + amendmentID, err := t.tcoSvc.BackfillAmount(tenant, tokenID, documentID, req.Amount, currency) + if err != nil { + return fiber.NewError(fiber.StatusBadGateway, "backfill amount: "+err.Error()) + } + return c.JSON(fiber.Map{"id": amendmentID}) +} + // ExportCSV — GET /tco/export.csv (optionally ?tokenId=N for a single vehicle). func (t *TCOController) ExportCSV(c *fiber.Ctx) error { tenant, err := GetTenant(c) diff --git a/api/internal/service/attest_service.go b/api/internal/service/attest_service.go index 93381db..67ba00c 100644 --- a/api/internal/service/attest_service.go +++ b/api/internal/service/attest_service.go @@ -37,8 +37,22 @@ type AttestService interface { // AttestVehicleGeofences publishes a vehicle's manual geofence ids as a // single CE whose subject is the vehicle DID. AttestVehicleGeofences(tenant models.Tenant, tokenID uint64, geofenceIDs []string) (string, error) + // AttestCostAmendment publishes a small overlay CE that attaches a dollar + // amount to an already-attested, immutable document — used to backfill + // TCO figures onto documents uploaded before/without an amount. See + // CostAmendmentCloudEventType. + AttestCostAmendment(tenant models.Tenant, tokenID uint64, documentID string, amount float64, currency string) (string, error) } +// CostAmendmentCloudEventType is the CE type for a TCO cost-amendment overlay +// (see AttestCostAmendment). Mirrors dimo.tombstone's "reference another CE by +// id" pattern instead of mutating the original, immutable document. +const CostAmendmentCloudEventType = "dimo.document.vehicle.cost-amendment" + +// TCOAttestationProducer stamps our app's cost-amendment CEs, mirroring +// GroupAttestationProducer/GeofenceAttestationProducer. +const TCOAttestationProducer = "fleet-lite-app" + // VehicleGroupsCloudEventType is the CE type for a vehicle's group-membership // document. fetch-api admits it via its dimo.document.* prefix filter. const VehicleGroupsCloudEventType = "dimo.document.vehicle.groups" @@ -443,3 +457,53 @@ func (s *attestService) AttestVehicleGeofences(tenant models.Tenant, tokenID uin } return ev.ID, nil } + +// AttestCostAmendment publishes a single parsed CloudEvent that attaches a +// dollar amount to an already-attested document, without mutating or +// re-attesting the original (CEs on DIS are immutable). Subject is the +// vehicle DID; data is {"documentId","amount","currency"}. TCOService reads +// these back and overlays the amount onto documentId's line item wherever +// the original document itself has no amount. Mirrors AttestVehicleGroups' +// signing. +func (s *attestService) AttestCostAmendment(tenant models.Tenant, tokenID uint64, documentID string, amount float64, currency string) (string, error) { + if documentID == "" { + return "", fmt.Errorf("documentID is required") + } + if currency == "" { + currency = "USD" + } + developerJWT, err := s.authProvider.GetDeveloperJWT(tenant) + if err != nil { + return "", fmt.Errorf("developer JWT: %w", err) + } + dataMap := map[string]interface{}{ + "documentId": documentID, + "amount": amount, + "currency": currency, + } + dataBytes, err := json.Marshal(dataMap) + if err != nil { + return "", fmt.Errorf("marshal cost amendment data: %w", err) + } + sig, err := signDataSecp256k1(dataBytes, tenant.DIMOPrivateKey) + if err != nil { + return "", fmt.Errorf("sign cost amendment data: %w", err) + } + ev := signedCloudEvent{ + SpecVersion: "1.0", + ID: uuid.New().String(), + Source: tenant.ClientID, + Producer: TCOAttestationProducer, + Type: CostAmendmentCloudEventType, + Subject: s.authProvider.BuildVehicleDID(tokenID), + Time: time.Now().UTC().Format(time.RFC3339), + DataContentType: "application/json", + Data: dataMap, + Signature: sig, + } + s.logger.Info().Uint64("tokenID", tokenID).Str("documentID", documentID).Msg("Submitting cost amendment cloud event") + if err := s.submitCloudEvent(ev, developerJWT); err != nil { + return "", fmt.Errorf("submit cost amendment cloud event: %w", err) + } + return ev.ID, nil +} diff --git a/api/internal/service/tco_service.go b/api/internal/service/tco_service.go index 12f1462..46a307a 100644 --- a/api/internal/service/tco_service.go +++ b/api/internal/service/tco_service.go @@ -89,6 +89,9 @@ func straightLineDepreciation(purchasePrice float64, purchaseDate time.Time, use // LineItem is one cost-eligible document, flattened for reporting/export. type LineItem struct { + // ID is the document's parsed CE id — the reference a cost-amendment CE + // (see AttestCostAmendment) or a future edit needs to target it. + ID string `json:"id"` VehicleTokenID int64 `json:"vehicleTokenId"` VehicleLabel string `json:"vehicleLabel"` VIN string `json:"vin"` @@ -126,15 +129,17 @@ type TCOService struct { fetchAPI *gateway.FetchAPI authProvider *gateway.DimoAuthProvider vehicleSvc *VehicleService + attestSvc AttestService } -func NewTCOService(logger *zerolog.Logger, pdb *db.Store, fetchAPI *gateway.FetchAPI, authProvider *gateway.DimoAuthProvider, vehicleSvc *VehicleService) *TCOService { +func NewTCOService(logger *zerolog.Logger, pdb *db.Store, fetchAPI *gateway.FetchAPI, authProvider *gateway.DimoAuthProvider, vehicleSvc *VehicleService, attestSvc AttestService) *TCOService { return &TCOService{ logger: logger, pdb: pdb, fetchAPI: fetchAPI, authProvider: authProvider, vehicleSvc: vehicleSvc, + attestSvc: attestSvc, } } @@ -218,6 +223,11 @@ type VehicleTCOSummary struct { TotalTCO float64 `json:"totalTco"` Settings TCOSettings `json:"settings"` LineItems []LineItem `json:"lineItems"` + // MissingAmounts are cost-eligible, non-tombstoned documents that have no + // amount — neither inline (extractAmount) nor via a cost-amendment CE + // (AttestCostAmendment). Amount/Currency are zero-valued; the frontend + // offers a way to backfill one via PUT /tco/vehicle/:tokenId/backfill/:documentId. + MissingAmounts []LineItem `json:"missingAmounts,omitempty"` // PermissionsRequired mirrors DocumentsController.ListDocuments: the dev // license lacks SACD permissions on this vehicle, so its document list // (and therefore its cost figures) couldn't be read. Acquisition/ @@ -279,6 +289,41 @@ func descriptionFor(e gateway.AttestationEntry) string { return parts[len(parts)-1] } +type costAmendment struct { + amount float64 + currency string +} + +// costAmendments scans entries for dimo.document.vehicle.cost-amendment CEs +// (see AttestCostAmendment) and returns the amount/currency each names, +// keyed by the documentId it overlays. Tombstoned amendments are excluded so +// a mistaken backfill can be un-done the same way a document delete is. +func costAmendments(entries []gateway.AttestationEntry, tombstoned map[string]struct{}) map[string]costAmendment { + out := map[string]costAmendment{} + for _, e := range entries { + if e.Type != CostAmendmentCloudEventType { + continue + } + if _, gone := tombstoned[e.ID]; gone { + continue + } + var payload struct { + DocumentID string `json:"documentId"` + Amount float64 `json:"amount"` + Currency string `json:"currency"` + } + if err := json.Unmarshal(e.Data, &payload); err != nil || payload.DocumentID == "" { + continue + } + currency := payload.Currency + if currency == "" { + currency = "USD" + } + out[payload.DocumentID] = costAmendment{amount: payload.Amount, currency: currency} + } + return out +} + // VehicleSummary builds one vehicle's TCO breakdown: cost-eligible document // amounts summed by category, plus acquisition/depreciation if settings exist. func (s *TCOService) VehicleSummary(ctx context.Context, tenant models.Tenant, tokenID int64) (*VehicleTCOSummary, error) { @@ -305,8 +350,10 @@ func (s *TCOService) VehicleSummary(ctx context.Context, tenant models.Tenant, t } tombstoned := gateway.TombstonedIDs(entries) + amendments := costAmendments(entries, tombstoned) lineItems := make([]LineItem, 0, len(entries)) + missingAmounts := make([]LineItem, 0) for _, e := range entries { if _, gone := tombstoned[e.ID]; gone { continue @@ -314,20 +361,24 @@ func (s *TCOService) VehicleSummary(ctx context.Context, tenant models.Tenant, t if !isCostEligible(e.Type) { continue } - amount, currency, ok := extractAmount(e.Data) - if !ok { - continue - } - lineItems = append(lineItems, LineItem{ + li := LineItem{ + ID: e.ID, VehicleTokenID: tokenID, VehicleLabel: label, VIN: vehicle.VIN, Date: e.Time, Category: e.Type, Description: descriptionFor(e), - Amount: amount, - Currency: currency, - }) + } + if amount, currency, ok := extractAmount(e.Data); ok { + li.Amount, li.Currency = amount, currency + lineItems = append(lineItems, li) + } else if am, ok := amendments[e.ID]; ok { + li.Amount, li.Currency = am.amount, am.currency + lineItems = append(lineItems, li) + } else { + missingAmounts = append(missingAmounts, li) + } } settings, err := s.GetSettings(ctx, tenant.ID, tokenID) @@ -342,6 +393,7 @@ func (s *TCOService) VehicleSummary(ctx context.Context, tenant models.Tenant, t CostByCategory: sumLineItemsByCategory(lineItems), Settings: settings, LineItems: lineItems, + MissingAmounts: missingAmounts, PermissionsRequired: permissionsRequired, } for _, v := range summary.CostByCategory { @@ -360,6 +412,17 @@ func (s *TCOService) VehicleSummary(ctx context.Context, tenant models.Tenant, t return summary, nil } +// BackfillAmount attaches a dollar amount to a document that was uploaded +// without one (see MissingAmounts), by publishing a cost-amendment CE that +// references it — the original document CE is immutable and never touched. +// Returns the new amendment CE's id. +func (s *TCOService) BackfillAmount(tenant models.Tenant, tokenID int64, documentID string, amount float64, currency string) (string, error) { + if documentID == "" { + return "", fmt.Errorf("documentID is required") + } + return s.attestSvc.AttestCostAmendment(tenant, uint64(tokenID), documentID, amount, currency) +} + // FleetSummary builds the TCO rollup for every vehicle in the tenant. Vehicles // are processed sequentially — one fetch-api round trip each — which is // acceptable for the fleet sizes this app targets; revisit with a worker pool diff --git a/api/internal/service/tco_service_test.go b/api/internal/service/tco_service_test.go index a628263..c5fa194 100644 --- a/api/internal/service/tco_service_test.go +++ b/api/internal/service/tco_service_test.go @@ -6,6 +6,8 @@ import ( "strings" "testing" "time" + + "github.com/DIMO-Network/fleet-lite-app/internal/gateway" ) func TestIsCostEligible(t *testing.T) { @@ -131,3 +133,44 @@ func TestBuildCSV(t *testing.T) { t.Fatalf("missing depreciation row, got:\n%s", got) } } + +func TestCostAmendments(t *testing.T) { + entries := []gateway.AttestationEntry{ + { + ID: "amend-1", + Type: "dimo.document.vehicle.cost-amendment", + Data: json.RawMessage(`{"documentId":"doc-1","amount":150,"currency":"EUR"}`), + }, + { + ID: "amend-2", + Type: "dimo.document.vehicle.cost-amendment", + Data: json.RawMessage(`{"documentId":"doc-2","amount":75}`), + }, + { + ID: "amend-3-tombstoned", + Type: "dimo.document.vehicle.cost-amendment", + Data: json.RawMessage(`{"documentId":"doc-3","amount":999}`), + }, + { + ID: "not-an-amendment", + Type: "dimo.document.vehicle.insurance", + Data: json.RawMessage(`{"amount":10}`), + }, + } + tombstoned := map[string]struct{}{"amend-3-tombstoned": {}} + + got := costAmendments(entries, tombstoned) + + if len(got) != 2 { + t.Fatalf("expected 2 amendments, got %d: %+v", len(got), got) + } + if am := got["doc-1"]; am.amount != 150 || am.currency != "EUR" { + t.Fatalf("doc-1 amendment = %+v, want {150 EUR}", am) + } + if am := got["doc-2"]; am.amount != 75 || am.currency != "USD" { + t.Fatalf("doc-2 amendment (currency default) = %+v, want {75 USD}", am) + } + if _, ok := got["doc-3"]; ok { + t.Fatalf("tombstoned amendment for doc-3 should be excluded") + } +} diff --git a/web/src/services/tco-cache.ts b/web/src/services/tco-cache.ts new file mode 100644 index 0000000..00c4bfe --- /dev/null +++ b/web/src/services/tco-cache.ts @@ -0,0 +1,32 @@ +import { Vehicle } from '../types/vehicle.ts'; +import { VehicleTCOSummary } from '../types/tco.ts'; + +export interface TCOCacheData { + vehicles: Vehicle[]; + tcoByToken: Map; +} + +/** + * Holds the last-loaded TCO fleet table (vehicle list + per-vehicle cost + * figures) so navigating away to a drilldown and back — or leaving the tab + * and returning — doesn't re-trigger the full loading state and the fleet's + * worth of fetch-api round trips. Mirrors FleetCache's pattern. + * + * Keyed by tenant id so switching tenants doesn't serve the previous + * tenant's cached data. No TTL — served until explicitly invalidated by an + * event that could make it stale (a document upload, a settings save, a + * backfilled amount). + */ +let cached: { tenantId: string; data: TCOCacheData } | null = null; + +export const TCOCache = { + get(tenantId: string): TCOCacheData | null { + return cached && cached.tenantId === tenantId ? cached.data : null; + }, + set(tenantId: string, data: TCOCacheData): void { + cached = { tenantId, data }; + }, + invalidate(): void { + cached = null; + }, +}; diff --git a/web/src/services/tco-service.ts b/web/src/services/tco-service.ts index 9595093..41e70c2 100644 --- a/web/src/services/tco-service.ts +++ b/web/src/services/tco-service.ts @@ -31,6 +31,16 @@ export class TCOService { return ApiService.getInstance().get(`/tco/vehicle/${tokenId}`); } + /** PUT /tco/vehicle/:tokenId/backfill/:documentId. Attaches a dollar + * amount to a document that was uploaded without one, via a + * cost-amendment CE — the original document is untouched. */ + backfillAmount(tokenId: number, documentId: string, amount: number, currency = 'USD'): Promise<{ id: string }> { + return ApiService.getInstance().put<{ id: string }>( + `/tco/vehicle/${tokenId}/backfill/${encodeURIComponent(documentId)}`, + { amount, currency }, + ); + } + /** Trigger a browser download of the CSV export. Omit tokenId for the fleet-wide export. */ async exportCsv(tokenId?: number): Promise { const base = ApiService.getInstance().getApiBaseUrl(); diff --git a/web/src/types/tco.ts b/web/src/types/tco.ts index 752896d..6786ed6 100644 --- a/web/src/types/tco.ts +++ b/web/src/types/tco.ts @@ -7,6 +7,8 @@ export interface TCOSettings { } export interface LineItem { + /** The document's parsed CE id — pass to TCOService.backfillAmount to attach an amount. */ + id: string; vehicleTokenId: number; vehicleLabel: string; vin: string; @@ -28,6 +30,9 @@ export interface VehicleTCOSummary { totalTco: number; settings: TCOSettings; lineItems: LineItem[]; + /** Cost-eligible documents with no amount yet — neither typed in at + * upload nor backfilled. Offer TCOService.backfillAmount for these. */ + missingAmounts?: LineItem[]; /** True when the dev license lacks SACD permissions on this vehicle — its * document/operating-cost figures could not be read (acquisition/ * depreciation still work, since those are DB-only). */ diff --git a/web/src/views/glovebox.ts b/web/src/views/glovebox.ts index e7d2346..507bbe3 100644 --- a/web/src/views/glovebox.ts +++ b/web/src/views/glovebox.ts @@ -8,6 +8,7 @@ import { DocumentService } from '../services/document-service.ts'; import { DocumentEntry } from '../types/document.ts'; import { categoryLabel, EXPECTED_CE_TYPES, CE_TYPE_TO_LABEL } from '../utils/document-categories.ts'; import { FleetCache } from '../services/fleet-cache.ts'; +import { TCOCache } from '../services/tco-cache.ts'; import '../elements/upload-document-modal.ts'; import '../elements/document-detail-modal.ts'; @@ -162,6 +163,9 @@ export class GloveboxView extends LitElement { private onUploaded = async (e: CustomEvent<{ tokenId: number }>) => { this.showUploadModal = false; FleetCache.invalidate(); + // A new document may add a cost-eligible line item — don't let a + // stale TCO cache hide it on the next visit to that tab. + TCOCache.invalidate(); if (this.selected && e.detail.tokenId === this.selected.tokenId) { await this.loadDocs(this.selected.tokenId); } @@ -171,6 +175,8 @@ export class GloveboxView extends LitElement { private closeDetail = () => { this.detailOpen = null; }; private onDeleted = async (e: CustomEvent<{ tokenId: number }>) => { this.detailOpen = null; + // A deleted (tombstoned) document must stop counting toward TCO too. + TCOCache.invalidate(); if (this.selected && e.detail.tokenId === this.selected.tokenId) { await this.loadDocs(this.selected.tokenId); } diff --git a/web/src/views/tco-view.ts b/web/src/views/tco-view.ts index 45291dc..12132fc 100644 --- a/web/src/views/tco-view.ts +++ b/web/src/views/tco-view.ts @@ -4,8 +4,9 @@ import { customElement, property, state } from 'lit/decorators.js'; import { sharedStyles } from '../global-styles.ts'; import { ApiService } from '../services/api-service.ts'; import { hiddenVehiclesService } from '../services/hidden-vehicles-service.ts'; +import { TCOCache } from '../services/tco-cache.ts'; import { TCOService } from '../services/tco-service.ts'; -import { VehicleTCOSummary } from '../types/tco.ts'; +import { LineItem, VehicleTCOSummary } from '../types/tco.ts'; import { Vehicle, VehiclesResponse } from '../types/vehicle.ts'; import { categoryLabel } from '../utils/document-categories.ts'; @@ -36,6 +37,8 @@ export class TCOView extends LitElement { private unsubscribeHidden: (() => void) | null = null; private connected = false; private static readonly PREFETCH_PARALLEL = 3; + private static readonly PAGE_SIZE = 10; + @state() private page = 0; @state() private detailTokenId: number | null = null; @state() private detail: VehicleTCOSummary | null = null; @state() private loadingDetail = false; @@ -46,6 +49,11 @@ export class TCOView extends LitElement { @state() private formPurchasePrice = ''; @state() private formPurchaseDate = ''; @state() private formUsefulLifeYears = ''; + // Backfill: draft amount text per missing-amount document id, plus which + // ones are mid-save or errored — keyed by LineItem.id. + @state() private backfillDrafts = new Map(); + @state() private backfillSaving = new Set(); + @state() private backfillErrors = new Map(); static styles = [ sharedStyles, @@ -292,6 +300,89 @@ export class TCOView extends LitElement { color: var(--on-surface-variant); margin-bottom: var(--stack-sm); } + + /* ── Backfill missing amounts ─────────────────────────────── */ + .missing-hint { + font: var(--type-body-sm); + color: var(--on-surface-variant); + margin-bottom: var(--stack-sm); + } + tr.missing-row td { vertical-align: top; } + .backfill-input input { + width: 110px; + background: var(--surface-container); + color: var(--on-surface); + border: 1px solid var(--outline-variant); + border-radius: var(--radius-sm); + padding: 6px 8px; + font-family: inherit; + font-size: 13px; + text-align: right; + } + .backfill-input input:focus { outline: 1px solid var(--primary); } + .backfill-save-btn { + padding: 6px 12px; + border-radius: var(--radius-sm); + background: var(--primary); + color: var(--on-primary); + border: none; + font: var(--type-label-caps); + letter-spacing: 0.04em; + text-transform: uppercase; + font-size: 10px; + cursor: pointer; + white-space: nowrap; + } + .backfill-save-btn:disabled { opacity: 0.5; cursor: not-allowed; } + .missing-row .form-error { margin-top: 4px; font-size: 11px; } + + .refresh-btn { + background: none; + border: 1px solid var(--outline-variant); + border-radius: var(--radius-md); + color: var(--on-surface-variant); + padding: 8px; + cursor: pointer; + display: flex; + align-items: center; + transition: color 0.15s; + } + .refresh-btn:hover { color: var(--primary); } + .refresh-btn:disabled { opacity: 0.5; cursor: not-allowed; } + .refresh-btn.spinning .material-symbols-outlined { animation: tco-spin 0.8s linear infinite; } + @keyframes tco-spin { to { transform: rotate(360deg); } } + + /* ── Pagination ───────────────────────────────────────────── */ + .fleet-total-scope { + font: var(--type-label-caps); + letter-spacing: 0.03em; + color: var(--on-surface-variant); + font-weight: 400; + text-transform: none; + } + .pagination { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: var(--stack-sm); + } + .pagination-info { font: var(--type-body-sm); color: var(--on-surface-variant); } + .pagination-controls { display: flex; align-items: center; gap: 8px; } + .page-btn { + background: none; + border: 1px solid var(--outline-variant); + border-radius: var(--radius-md); + color: var(--on-surface-variant); + padding: 6px; + cursor: pointer; + display: flex; + align-items: center; + transition: color 0.15s, border-color 0.15s; + } + .page-btn:hover:not(:disabled) { color: var(--primary); border-color: var(--primary); } + .page-btn:disabled { opacity: 0.4; cursor: not-allowed; } + .page-btn .material-symbols-outlined { font-size: 18px; } + .page-indicator { font: var(--type-body-sm); color: var(--on-surface-variant); min-width: 56px; text-align: center; } `, ]; @@ -302,8 +393,35 @@ export class TCOView extends LitElement { this.unsubscribeHidden = hiddenVehiclesService.subscribe(() => { this.hiddenVehicles = hiddenVehiclesService.getHidden(this.tenantId); }); + + const cached = TCOCache.get(this.tenantId); + if (cached) { + // Serve the last-loaded fleet table instantly — no loading state, + // no re-fetch — then top up anything not yet in hand (e.g. a + // vehicle that synced in since the cache was built). + this.vehicles = cached.vehicles; + this.tcoByToken = cached.tcoByToken; + this.loading = false; + void this.prefetchTco(); + return; + } + await this.loadFresh(); + } + + disconnectedCallback() { + this.connected = false; + this.unsubscribeHidden?.(); + this.unsubscribeHidden = null; + super.disconnectedCallback(); + } + + /** Full reload from scratch: fresh /vehicles list, cleared cost figures, + * then re-prefetch. Used on cache miss and by the manual refresh button. */ + private async loadFresh() { this.loading = true; this.error = ''; + this.tcoByToken = new Map(); + this.page = 0; try { const res = await ApiService.getInstance().get('/vehicles'); this.vehicles = res.vehicles || []; @@ -313,17 +431,22 @@ export class TCOView extends LitElement { } finally { this.loading = false; } + this.syncCache(); // Fire-and-forget: fill in each vehicle's cost figures in the // background so the table shows real numbers as they arrive instead // of blocking the whole view on the slowest vehicle's fetch-api call. void this.prefetchTco(); } - disconnectedCallback() { - this.connected = false; - this.unsubscribeHidden?.(); - this.unsubscribeHidden = null; - super.disconnectedCallback(); + private async refresh() { + TCOCache.invalidate(); + await this.loadFresh(); + } + + /** Push the current vehicles/tcoByToken into TCOCache so the next visit + * serves instantly. Call after any state change worth remembering. */ + private syncCache() { + TCOCache.set(this.tenantId, { vehicles: this.vehicles, tcoByToken: this.tcoByToken }); } /** Vehicles hidden elsewhere (Fleet List) are skipped here too, unless @@ -346,6 +469,7 @@ export class TCOView extends LitElement { const detail = await TCOService.getInstance().getVehicleDetail(v.tokenId); if (!this.connected) return; this.tcoByToken = new Map(this.tcoByToken).set(v.tokenId, detail); + this.syncCache(); } catch (e) { console.error('Failed to load TCO for vehicle', v.tokenId, e); // Leave uncached — the row falls back to a loading dash. @@ -362,6 +486,9 @@ export class TCOView extends LitElement { this.loadingDetail = true; this.detailError = ''; this.settingsError = ''; + this.backfillDrafts = new Map(); + this.backfillSaving = new Set(); + this.backfillErrors = new Map(); try { // Reuse the prefetched figures if already in hand so the drilldown // paints instantly; otherwise fetch fresh (e.g. clicked before its @@ -370,6 +497,7 @@ export class TCOView extends LitElement { this.detail = cached ?? await TCOService.getInstance().getVehicleDetail(tokenId); if (!cached) { this.tcoByToken = new Map(this.tcoByToken).set(tokenId, this.detail); + this.syncCache(); } this.formPurchasePrice = this.detail.settings.purchasePrice?.toString() ?? ''; this.formPurchaseDate = this.detail.settings.purchaseDate ?? ''; @@ -414,6 +542,7 @@ export class TCOView extends LitElement { }); this.detail = await TCOService.getInstance().getVehicleDetail(this.detailTokenId); this.tcoByToken = new Map(this.tcoByToken).set(this.detailTokenId, this.detail); + this.syncCache(); } catch (e) { console.error('Failed to save TCO settings', e); this.settingsError = e instanceof Error ? e.message : msg('Failed to save'); @@ -422,6 +551,40 @@ export class TCOView extends LitElement { } } + private async backfillAmount(li: LineItem) { + if (this.detailTokenId === null) return; + const raw = (this.backfillDrafts.get(li.id) ?? '').trim(); + const amount = Number(raw); + if (raw === '' || Number.isNaN(amount) || amount <= 0) { + this.backfillErrors = new Map(this.backfillErrors).set(li.id, msg('Enter an amount greater than 0')); + return; + } + this.backfillSaving = new Set(this.backfillSaving).add(li.id); + const errs = new Map(this.backfillErrors); + errs.delete(li.id); + this.backfillErrors = errs; + try { + await TCOService.getInstance().backfillAmount(this.detailTokenId, li.id, amount, 'USD'); + // Refetch so the document moves from missingAmounts into lineItems + // with its new figure, and operating cost/totals recompute. + this.detail = await TCOService.getInstance().getVehicleDetail(this.detailTokenId); + this.tcoByToken = new Map(this.tcoByToken).set(this.detailTokenId, this.detail); + this.syncCache(); + const drafts = new Map(this.backfillDrafts); + drafts.delete(li.id); + this.backfillDrafts = drafts; + } catch (e) { + console.error('Failed to backfill amount', li.id, e); + this.backfillErrors = new Map(this.backfillErrors).set( + li.id, e instanceof Error ? e.message : msg('Failed to save'), + ); + } finally { + const saving = new Set(this.backfillSaving); + saving.delete(li.id); + this.backfillSaving = saving; + } + } + private async exportVehicleCsv() { if (this.detailTokenId === null) return; this.exportingVehicle = true; @@ -458,6 +621,7 @@ export class TCOView extends LitElement { private toggleShowHidden() { this.showHidden = !this.showHidden; + this.page = 0; if (this.showHidden) { // Rows just revealed may not have been prefetched yet (prefetch // skips hidden vehicles) — top up in the background. @@ -491,6 +655,9 @@ export class TCOView extends LitElement { `; } const allLoaded = visible.every((v) => this.tcoByToken.has(v.tokenId)); + // Fleet total is always summed across every visible vehicle, not just + // the current page, so it stays a true fleet-wide figure regardless + // of pagination. let fleetOperating = 0, fleetAcquisition = 0, fleetDepreciation = 0, fleetTotal = 0; for (const v of visible) { const t = this.tcoByToken.get(v.tokenId); @@ -500,6 +667,11 @@ export class TCOView extends LitElement { fleetDepreciation += t.depreciationToDate; fleetTotal += t.totalTco; } + + const totalPages = Math.max(1, Math.ceil(visible.length / TCOView.PAGE_SIZE)); + const page = Math.min(this.page, totalPages - 1); + const pageVehicles = visible.slice(page * TCOView.PAGE_SIZE, (page + 1) * TCOView.PAGE_SIZE); + return html` ${this.hiddenVehicles.size > 0 ? html`
@@ -522,7 +694,7 @@ export class TCOView extends LitElement { - ${visible.map((v) => html` + ${pageVehicles.map((v) => html` this.openVehicle(v.tokenId)}> ${vehicleTitle(v)} @@ -535,7 +707,7 @@ export class TCOView extends LitElement { - ${msg('Fleet total')} + ${msg('Fleet total')} (${msg('all')} ${visible.length}) ${allLoaded ? formatMoney(fleetOperating) : html`···`} ${allLoaded ? formatMoney(fleetAcquisition) : html`···`} ${allLoaded ? formatMoney(fleetDepreciation) : html`···`} @@ -544,6 +716,23 @@ export class TCOView extends LitElement {
+ ${totalPages > 1 ? html` + + ` : nothing} `; } @@ -610,6 +799,57 @@ export class TCOView extends LitElement { ${this.settingsError ? html`${this.settingsError}` : nothing}
+ ${d.missingAmounts && d.missingAmounts.length > 0 ? html` + +

+ ${msg('These documents don’t have a cost amount on file. Add one to include them in this vehicle’s totals.')} +

+
+ + + + + + + + + + + + ${d.missingAmounts.map((li) => html` + + + + + + + + `)} + +
${msg('Date')}${msg('Category')}${msg('Description')}${msg('Amount')}
${new Date(li.date).toLocaleDateString()}${categoryLabel(li.category)}${li.description} +
+ { + const drafts = new Map(this.backfillDrafts); + drafts.set(li.id, (e.target as HTMLInputElement).value); + this.backfillDrafts = drafts; + }} + @keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter') this.backfillAmount(li); }} /> +
+
+ + ${this.backfillErrors.has(li.id) + ? html`
${this.backfillErrors.get(li.id)}
` + : nothing} +
+
+ ` : nothing} + ${d.lineItems.length === 0 ? html`
${msg('No cost documents on file for this vehicle yet.')}
` @@ -649,6 +889,12 @@ export class TCOView extends LitElement {
${this.detailTokenId === null ? html` +