diff --git a/api/internal/app/app.go b/api/internal/app/app.go index 0a9b704..f7a73fa 100644 --- a/api/internal/app/app.go +++ b/api/internal/app/app.go @@ -191,6 +191,17 @@ 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, 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/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/controllers/tco.go b/api/internal/controllers/tco.go new file mode 100644 index 0000000..0b0d170 --- /dev/null +++ b/api/internal/controllers/tco.go @@ -0,0 +1,206 @@ +// api/internal/controllers/tco.go +package controllers + +import ( + "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 — and, for limited members, inside their allowed groups. +func (t *TCOController) vehicleInTenant(c *fiber.Ctx, tenantID string, tokenID int64) bool { + allowed, _ := GetAllowedGroups(c) + _, err := t.vehicleSvc.GetVehicle(c.Context(), tenantID, tokenID, allowed) + 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, 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, 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()) + } + allowed, _ := GetAllowedGroups(c) + summary, err := t.tcoSvc.FleetSummary(c.Context(), tenant, allowed) + 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, tenant.ID, tokenID) { + return fiber.NewError(fiber.StatusForbidden, "vehicle is not part of this tenant") + } + allowed, _ := GetAllowedGroups(c) + summary, err := t.tcoSvc.VehicleSummary(c.Context(), tenant, tokenID, allowed) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "tco vehicle summary: "+err.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, 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) + 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, tenant.ID, tokenID) { + return fiber.NewError(fiber.StatusForbidden, "vehicle is not part of this tenant") + } + allowed, _ := GetAllowedGroups(c) + summary, err := t.tcoSvc.VehicleSummary(c.Context(), tenant, tokenID, allowed) + 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 { + allowed, _ := GetAllowedGroups(c) + fleet, err := t.tcoSvc.FleetSummary(c.Context(), tenant, allowed) + 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) +} 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) +} 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/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 new file mode 100644 index 0000000..1645e55 --- /dev/null +++ b/api/internal/service/tco_service.go @@ -0,0 +1,504 @@ +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" + "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 +// 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 { + // 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"` + 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 +} + +// 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 + attestSvc AttestService +} + +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, + } +} + +// 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(), + ) +} + +// 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"` + // 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/ + // 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. +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] +} + +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. +// allowedGroupIDs scopes the lookup to a limited member's accessible groups +// (nil for owners/full-access members); see VehicleService.GetVehicle. +func (s *TCOService) VehicleSummary(ctx context.Context, tenant models.Tenant, tokenID int64, allowedGroupIDs []string) (*VehicleTCOSummary, error) { + vehicle, err := s.vehicleSvc.GetVehicle(ctx, tenant.ID, tokenID, allowedGroupIDs) + 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) + permissionsRequired := false + if err != nil { + // 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) + 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 + } + if !isCostEligible(e.Type) { + continue + } + li := LineItem{ + ID: e.ID, + VehicleTokenID: tokenID, + VehicleLabel: label, + VIN: vehicle.VIN, + Date: e.Time, + Category: e.Type, + Description: descriptionFor(e), + } + 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) + 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, + MissingAmounts: missingAmounts, + PermissionsRequired: permissionsRequired, + } + 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 +} + +// 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 +// if that stops being true. A single vehicle's failure is logged and skipped +// rather than failing the whole report. +// allowedGroupIDs scopes the vehicle list to a limited member's accessible +// groups (nil for owners/full-access members); see VehicleService.ListVehicles. +func (s *TCOService) FleetSummary(ctx context.Context, tenant models.Tenant, allowedGroupIDs []string) (*FleetTCOSummary, error) { + vehicles, err := s.vehicleSvc.ListVehicles(ctx, tenant.ID, allowedGroupIDs) + 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, allowedGroupIDs) + 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 new file mode 100644 index 0000000..c5fa194 --- /dev/null +++ b/api/internal/service/tco_service_test.go @@ -0,0 +1,176 @@ +// api/internal/service/tco_service_test.go +package service + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/DIMO-Network/fleet-lite-app/internal/gateway" +) + +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)) + } +} + +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) + } +} + +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/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. + 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. 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/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}
diff --git a/web/src/generated/locales/es.ts b/web/src/generated/locales/es.ts index 5b4e836..c4ef6bd 100644 --- a/web/src/generated/locales/es.ts +++ b/web/src/generated/locales/es.ts @@ -344,6 +344,7 @@ 'sfe30cca1ef185151': `Mostrar vehículos ocultos`, 'sfedc31958f58f903': `Aún no tiene grupos asignados: consulte a un propietario.`, 'sff8f897f159ae5a4': `Nombre el grupo y elija un color. Luego podrá asignar vehículos.`, +'s6c6cb319e19e97fd': `TCO`, 's4e606ce6a461df05': `Close search`, 's31196398a641533e': `Search vehicles`, 's5017b86392faf520': `Comfortable view`, @@ -373,6 +374,7 @@ 's3578d562c2b5bada': `Trip detection method`, 's36c7ff52d04afe46': `Remove from Favorites`, 'se15c9243be269005': `Make Favorite`, +'s73cddee970a10604': `Amount (optional)`, 's80b97d0375031960': `Could not load DIMO settings. Please enter credentials manually.`, 's6a47adf8f680d9fc': `Opening DIMO…`, 'sc9b959bf636dd8ca': `Get credentials from DIMO`, @@ -388,5 +390,35 @@ 's7468e87263dfff7e': `Identifier`, 's9b38dee53cc7751c': `Last Location`, 'se68398e3c2c760b2': `Token`, +'s48bab881332d164c': `Failed to load vehicle detail`, +'sf345ddcc2e10c7ba': `Failed to save`, +'s1d6b1bc152eb6e81': `Enter an amount greater than 0`, +'s4d5a15b20f0dd5b6': `DIMO permissions required to read this vehicle’s documents`, +'sb83d6c0b89001488': `No access`, +'s48ce58ef9178285d': `All vehicles on this account are hidden.`, +'se78b336d8a6f6c3d': `Operating cost`, +'sda845aa996d5d504': `Acquisition`, +'s4718bce90803b027': `Depreciation to date`, +'s2723579f71ec1e23': `Total TCO`, +'s5c3e8af8729be9e5': `Fleet total`, +'se6f09b190519daa4': `all`, +'s40664bffa760c01c': `Showing`, +'s08b06007b5567108': `of`, +'s830486d6803c1dab': `Back to fleet`, +'sd5e67ee988f58e19': `Exporting…`, +'s8b409dc1f261b173': `Export CSV`, +'sbbd1f5edf6ff8df8': `Grant DIMO permissions to this vehicle to include its documents in operating cost. Acquisition and depreciation below are unaffected.`, +'s0f485ae78a8ee23b': `Acquisition & depreciation`, +'sb2bd6a3049d96b37': `Purchase price`, +'s59775f70a95321b2': `Purchase date`, +'sc281195c481f85de': `Useful life (years)`, +'seb30df772c5df3ee': `Missing amounts`, +'sfd8205687425cedd': `These documents don’t have a cost amount on file. Add one to include them in this vehicle’s totals.`, +'sac8252732f2edb19': `Date`, +'s63d894b1ddb06289': `Description`, +'sba98a7152d80b949': `Amount`, +'sbf782021bf42916f': `Line items`, +'s0d2ba7a853a4934b': `No cost documents on file for this vehicle yet.`, +'s22d5fa291f2d7672': `Total Cost of Ownership`, }; \ No newline at end of file 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 new file mode 100644 index 0000000..41e70c2 --- /dev/null +++ b/web/src/services/tco-service.ts @@ -0,0 +1,71 @@ +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}`); + } + + /** 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(); + 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..6786ed6 --- /dev/null +++ b/web/src/types/tco.ts @@ -0,0 +1,52 @@ +export interface TCOSettings { + tokenId: number; + purchasePrice?: number; + purchaseDate?: string; // YYYY-MM-DD + usefulLifeYears?: number; + currency: string; +} + +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; + 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[]; + /** 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). */ + permissionsRequired?: boolean; +} + +export interface FleetTotals { + operatingCost: number; + acquisitionCost: number; + depreciationToDate: number; + totalTco: number; +} + +export interface FleetTCOSummary { + vehicles: VehicleTCOSummary[]; + fleet: FleetTotals; +} 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'; } 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/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..12132fc --- /dev/null +++ b/web/src/views/tco-view.ts @@ -0,0 +1,925 @@ +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 { TCOCache } from '../services/tco-cache.ts'; +import { TCOService } from '../services/tco-service.ts'; +import { LineItem, 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 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; + 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; + @state() private detailError = ''; + @state() private exportingVehicle = false; + @state() private savingSettings = false; + @state() private settingsError = ''; + @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, + css` + :host { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + overflow-y: auto; + background: var(--background); + } + + /* ── 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; + height: var(--top-bar-height, 80px); + padding: 0 var(--gutter); + background: var(--background); + border-bottom: 1px solid var(--outline-variant); + } + 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); + border: none; + font: var(--type-label-caps); + letter-spacing: 0.05em; + text-transform: uppercase; + font-weight: 700; + cursor: pointer; + transition: opacity 0.15s ease; + } + .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.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; 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: 64px var(--gutter); + text-align: center; + color: var(--on-surface-variant); + font: var(--type-body-md); + } + .error { color: var(--error); } + .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: 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); + } + + /* ── 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; } + `, + ]; + + async connectedCallback() { + super.connectedCallback(); + this.connected = true; + this.hiddenVehicles = hiddenVehiclesService.getHidden(this.tenantId); + 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 || []; + } catch (e) { + console.error('Failed to load vehicles', e); + this.error = e instanceof Error ? e.message : msg('Failed to load vehicles'); + } 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(); + } + + 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 + * 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 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); + 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. + } + } + }; + 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 = ''; + 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 + // 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.syncCache(); + } + 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 = () => { + this.detailTokenId = null; + this.detail = null; + }; + + 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 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); + 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'); + } finally { + this.savingSettings = false; + } + } + + 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; + try { + await TCOService.getInstance().exportCsv(this.detailTokenId); + } catch (e) { + console.error('Failed to export vehicle TCO CSV', e); + } finally { + this.exportingVehicle = false; + } + } + + 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; + this.page = 0; + 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 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)); + // 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); + if (!t) continue; + fleetOperating += t.operatingCost; + fleetAcquisition += t.acquisitionCost; + 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` +
+ +
+ ` : nothing} +
+ + + + + + + + + + + + ${pageVehicles.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')} (${msg('all')} ${visible.length})${allLoaded ? formatMoney(fleetOperating) : html`···`}${allLoaded ? formatMoney(fleetAcquisition) : html`···`}${allLoaded ? formatMoney(fleetDepreciation) : html`···`}${allLoaded ? formatMoney(fleetTotal) : html`···`}
+
+ ${totalPages > 1 ? html` + + ` : nothing} + `; + } + + 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}

+ +
+ + ${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)}
+
${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.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.')}
` + : 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() { + return html` +
+
+

${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() + } +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'tco-view': TCOView; + } +} diff --git a/web/xliff/es.xlf b/web/xliff/es.xlf index e6dfaba..21beb29 100644 --- a/web/xliff/es.xlf +++ b/web/xliff/es.xlf @@ -1456,6 +1456,102 @@ Invited as · · expires Invitado como · · vence + + TCO + + + Amount (optional) + + + Failed to load vehicle detail + + + Failed to save + + + Enter an amount greater than 0 + + + DIMO permissions required to read this vehicle’s documents + + + No access + + + All vehicles on this account are hidden. + + + Operating cost + + + Acquisition + + + Depreciation to date + + + Total TCO + + + Fleet total + + + all + + + Showing + + + of + + + Back to fleet + + + Exporting… + + + Export CSV + + + Grant DIMO permissions to this vehicle to include its documents in operating cost. Acquisition and depreciation below are unaffected. + + + Acquisition & depreciation + + + Purchase price + + + Purchase date + + + Useful life (years) + + + Missing amounts + + + These documents don’t have a cost amount on file. Add one to include them in this vehicle’s totals. + + + Date + + + Description + + + Amount + + + Line items + + + No cost documents on file for this vehicle yet. + + + Total Cost of Ownership +