Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
58c033c
docs: add TCO reporting design spec
jamesliupenn Jul 7, 2026
10f29f1
docs: add TCO reporting implementation plan
jamesliupenn Jul 7, 2026
e8e2271
feat(api): add vehicle_tco_settings table for TCO acquisition/depreci…
jamesliupenn Jul 7, 2026
aef0140
feat(api): add TCO cost-eligibility, amount extraction, and depreciat…
jamesliupenn Jul 7, 2026
819cf4b
feat(api): add TCO settings get/upsert backed by vehicle_tco_settings
jamesliupenn Jul 7, 2026
6e3fe4a
feat(api): assemble vehicle/fleet TCO summaries and CSV export
jamesliupenn Jul 8, 2026
c8a4ecf
feat(api): add TCO controller and wire /tco routes
jamesliupenn Jul 8, 2026
5ea35c4
feat(web): add TCO types and API service
jamesliupenn Jul 8, 2026
8d7d966
feat(web): add Fuel document category and cost-eligible category set
jamesliupenn Jul 8, 2026
de7d51d
feat(web): capture an optional dollar amount at document upload confirm
jamesliupenn Jul 8, 2026
c5de7c4
feat(web): add TCO nav tab, route, and fleet-wide cost table
jamesliupenn Jul 8, 2026
1fb4a31
feat(web): add TCO vehicle drilldown with acquisition settings and pe…
jamesliupenn Jul 8, 2026
ec80994
fix(tco): exclude tombstoned documents from TCO reports; reset stale …
jamesliupenn Jul 8, 2026
1aabc4e
fix(tco): match app styling conventions, load progressively, handle m…
jamesliupenn Jul 9, 2026
ccdf5d3
feat(tco): cache fleet table, backfill amounts on existing documents,…
jamesliupenn Jul 9, 2026
44a60cf
fix(tco): rebase onto main, thread member access scoping, sync locali…
jamesliupenn Jul 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions api/internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
32 changes: 1 addition & 31 deletions api/internal/controllers/documents.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"strconv"
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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":
Expand Down
206 changes: 206 additions & 0 deletions api/internal/controllers/tco.go
Original file line number Diff line number Diff line change
@@ -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)
}
32 changes: 32 additions & 0 deletions api/internal/db/migrations/20260710120000_vehicle_tco_settings.sql
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions api/internal/db/models/boil_table_names.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading