# Garage and catalog

How a Member adds a Vehicle from the NHTSA catalog or by hand, edits it, and reads the Organization's garage, with the Free plan limit.

## What it does [#what-it-does]

The garage is every Vehicle of the Active Organization, in one of four states: `garage` (current), `sold`, `wishlist` or `archived`. Any Member adds and edits cars; there is no Org role check.

- **Catalog search** (`catalog.search`): words match make, model and trim in any order. The shared `vehicle_catalog` table caches NHTSA vPIC (CAT-01). The first word is taken as a make and fetched from vPIC (`GetModelsForMake`) at most once per 30 days, only when the cache has no better answer, with a 4-second timeout. vPIC lists models only per make, so when the cache matches nothing the common makes (`COMMON_MAKES`) are fetched as well, six at a time within the same timeout: a model word alone ("Corolla") works on a cold cache. A slow or failing vPIC is not an error: the search answers from the cache. vPIC has no trims or year ranges, so those stay empty unless the source has them; trim is then free text. A curated entry whose trim is sold in one market carries that market (`market`, ISO country; vPIC rows have none). The client sends the account's market (its locale's country): an empty query lists that market's curated entries and nothing else, and a typed one never shows another market's trims and lists the market's first.
- **Add a car** (`vehicle.create`): from a Catalog Entry (make, model and trim are copied, never joined) or typed in and flagged `manual`. Year, nickname and photo are optional. The odometer is sent in the unit the user sees and stored in km as the first Odometer Reading. The photo is an image from the [direct upload flow](/docs/features/files) (`uploads.prepare` → `PUT` → `uploads.complete`) and becomes the cover. The currency defaults to the User's Account Preference, else EUR. A client-generated `id` makes a replay return the same car (RNF04).
- **Edit a car** (`vehicle.update`): make, model, trim, year, nickname, color, plate and VIN (null clears), link a manual car to a Catalog Entry (it stays flagged `manual`), or replace the cover. Plate and VIN are encrypted with `FIELD_ENCRYPTION_KEY` before the write (RN09).
- **Specs** (`vehicle.specs`, M-Galeria): the car's make, model, trim, year, color and its Catalog Entry's fuel, and only whether a plate and VIN are set, with `canReveal` for the Organization's owner. `vehicle.identifiers` (owner only, audited as `vehicle.identifiers_revealed`) decrypts them; the app keeps them in memory, never in the query cache.
- **Wishlist** (US27, RF27): `vehicle.create` with `wishlist: { targetPriceMinor?, note? }` saves a wished-for car in the `wishlist` state, with no Ownership Period and no odometer (one is refused), outside the Free limit. `vehicle.update` edits `targetPriceMinor` and `note` (null clears). `vehicle.moveToGarage` ("I bought it") moves it to `garage` and opens an Ownership Period from today, under the same lock and RN11 check as adding a car; a replay on the car already in the garage changes nothing, a sold or archived car is `CONFLICT`. `vehicle.removeWish` deletes a wished-for car with its photos (audited as `vehicle.deleted`); any other state is `CONFLICT`, since an owned car's history is never deleted (RN01).
- **Archive a car** (`vehicle.archive`): moves it to `archived`, out of the garage and out of the Free count. Nothing is deleted and its Ownership Period stays as it is (RN01).
- **A car's profile** (`vehicle.profile`, M-Veiculo): the hero (make, model, year, color, latest odometer, cover) and the overview, computed on the server. The latest Valuation comes with its source, date and confidence (RN03), its change against the previous one in basis points and up to 24 values for the sparkline. "Costs, last 12 months" is the total spent in the window, the same number as M-Gastos' "Total spent": `totalSpent` from `@autofolio/domain` over the ledger as it stands, each entry replaced by the latest correction of its chain, keeping the original's kind. It counts every expense category, upgrades included; never a Loan's interest and fees, its principal (RN05) or the acquisition. It is not the TCO ([Insights](/docs/features/insights) adds depreciation, interest and fees). Cost per distance is that total over the km driven since the reading on or before the window's start (`perDistance`), in the unit the client asks for; null without two readings. The next service is the most urgent open reminder from the [reminder feature](/docs/features/service)'s forecast (`listReminders`). The Loan balance is the newest Loan's latest snapshot. An entry in another currency counts only through its stored rate into the car's.
- **Add a value** (`vehicle.addValuation`): a manual Valuation in the car's currency, dated today unless `asOf` is given (never later), `medium` confidence by default. A car in another currency than the owner's main one may carry `rate`, what one unit is worth in that currency; it is stored with the Valuation (`fx_currency`, `fx_rate`) and net worth converts at it. ★ Appended, never overwritten; a client-generated `id` makes a replay write nothing new.
- **Net worth** (`garage.netWorth`, M-Patrimonio, US20): each current (`garage`) car with its latest Valuation and its newest Loan's latest balance, ranked by value, and the totals in the User's main currency through `netWorth` from `@autofolio/domain` (`netWorthOf` in `vehicle.rules.ts`): assets, debt and assets minus debt (RF17). Sold, wishlist and archived cars never count. An amount in another currency counts at the latest rate stored with any Valuation of that currency into the main one, returned in `rates`; without one it is returned in `unconverted` and never summed.
- **Reference values** (RF38, ADR-06): see [below](#reference-values-rf38). `vehicle.profile` and `garage.netWorth` return each car's `referenceSource`, the provider of its market, or null.
- **The garage** (`garage.list`): every car, newest first, with its latest odometer and Valuation (source, date and confidence), a 15-minute signed cover URL, years owned, target price and note, and `needsAcquisition` for a current car whose purchase is not recorded. It returns the count per state, the User's plan and `collection` (M-Colecao, RF28): `yearsOwned`, the days of every Ownership Period of the current and sold cars (open ones to today) in whole years, never rounded up, and `drivenKm`, each such car's latest reading minus its first (`collectionOf` in `vehicle.rules.ts`). The app filters by state on the device.

## In the app [#in-the-app]

The Garage tab (`features/garage/garage.screen.tsx`, M-Garagem) filters the list by state on the device and formats money and distance with the User's Account Preference. The list is saved per server, User and Organization in the app's cache folder (`garage.cache.ts`), so the tab opens offline; sign-out deletes it. Covers use the photo id as the image cache key, so they survive the 15-minute URL. Add vehicle (`garage.add-vehicle.screen.tsx`, M-Cadastro) debounces `catalog.search` by 300 ms and shows three results (a hint asks to type more when there are others), asks for the year and the odometer before a car joins the garage (a wish needs neither; a catalog year fills the field), uploads the picked photo before `vehicle.create` with the native file upload (`expo-file-system`, exactly the signed `content-type` and the file's own length), keeps Save pinned above the keyboard with a Done key for the number pads, returns to the Garage tab after a save, and turns `PLAN_LIMIT` into a toast with a way to Plus; the + button checks `canAddActiveVehicle` first. Add vehicle and its edit mode (`garage.edit-vehicle.screen.tsx`, from the profile's more menu) sit above the tabs, as their board has no tab bar.

A garage card or Sold row opens the car's profile (`features/vehicle/vehicle.screen.tsx`, M-Veiculo): the full-bleed hero, the segmented Overview | Costs | Service | Documents (one component per segment; Costs lists `expense.list`, Service lists `service.list`, Documents lists the newest four with their status and opens [M-Documentos](/docs/features/vehicle-documents)), the white "Add expense" pill (`add-gasto`) and Gallery, Prepare sale, Documents and History. Prepare sale opens [M-Vender](/docs/features/passport). The dev link `autofolio://dev/M-Veiculo` opens the seeded M3: the dev sign-in answers the seeded cars' ids.

My collection (`garage.collection.screen.tsx`, M-Colecao) is drawn from the same cached list, so it opens offline: the stats card (vehicles, current, sold, years owned; two by two at large text sizes), the distance driven under the title, a photo card per current and sold car with its period, opening its History, and the footer: "No public cars", or how many Showcase links are public. Its share button follows the flags: with `social.moments` and `social.showcase` a sheet offers "Share as image" (the collection's card) and "Public link" (the Showcase links, with the public count); with one flag it opens that one; with neither it is dimmed and only explains that nothing is public. The Sold section's "My collection" link and the "Sold." sheet after a sale open it; `autofolio://dev/M-Colecao` too. A Wishlist row opens the wish sheet (`garage.wish-sheet.tsx`): photo, target price and note, "I bought it" (then the purchase, M-Compra), Edit (the Add vehicle form with the target price and note instead of odometer and nickname) and Remove after a confirmation. Under the Wishlist filter, + and the empty state add a wish (`/add-vehicle?wishlist=1`).

Net worth (`features/vehicle/vehicle.net-worth.screen.tsx`, M-Patrimonio) sits in the Finances tab: its row on M-Gastos and the estimated value card of M-Veiculo open it. A car row opens the value sheet (`vehicle.value-sheet.tsx`, shared with M-Veiculo's "Add a value"): amount, date, confidence and, for a foreign car, the rate. More lists the loans behind the debt and opens M-Financiamento. The Insights pill opens a placeholder until M-Insights lands. `autofolio://dev/M-Patrimonio` opens the seeded garage. Its footnote says "Reference value unavailable in your country" while no provider covers a car (the value sheet and M-Veiculo's empty value card say it too); the manual value always stays possible.

### Reference values (RF38) [#reference-values-rf38]

A licensed provider per market (FIPE, DAT/Schwacke, KBB, CAP HPI) implements `ValuationProvider` in `vehicle.valuation-provider.ts`: an `id` (the Valuation's `source`), a country, the market's currency and `quote(car)`, which returns an amount, date, confidence and optional provider code, or `"unavailable"`. It sees catalog facts and the latest odometer only, never plate, VIN or owner. `VALUATION_PROVIDERS` is empty: no licence is contracted (VAL-03), FIPE has no official API (VAL-01), and nothing is scraped. A car's market is the currency it is priced in (euro countries share one until a car gets a country).

`pendingRevaluations` (`vehicle.jobs.ts`) runs on the cron tick during 03:00 UTC: for up to 50 current cars a tick whose market has a provider and that it has not valued today, it appends a ★ `valuation_snapshot` with `source` = the provider's id and no author. The row id is derived from car, provider and day, so a repeated tick writes once. A failing provider, an `"unavailable"` answer or a quote in another currency skips the car; it never fails the tick.

## Plan limit (RN11) [#plan-limit-rn11]

Free holds one current (`garage`) Vehicle; sold and wishlist cars do not count, and a wish is saved even when the garage is full. Plus is unlimited. The plan comes from the User's Paid Access (`billing.policy.ts`): an active entitlement is Plus, none is Free. A deployment with no billing provider sells nothing, so it enforces no Free limit and every User is Plus; that is the local default. A save or "I bought it" beyond the limit fails with `PLAN_LIMIT` (HTTP 402). The count runs under a per-garage advisory lock, so two saves cannot both pass it.

## Procedures [#procedures]

| Procedure              | Who                                | Failures                                                                                            |
| ---------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------- |
| `catalog.search`       | Any signed-in User                 | —                                                                                                   |
| `vehicle.create`       | Members of the Active Organization | `NOT_FOUND` (catalog entry), `BAD_REQUEST` (photo), `PLAN_LIMIT`, `CONFLICT` (id taken)             |
| `vehicle.update`       | Members of the Active Organization | `NOT_FOUND` (car or catalog entry), `BAD_REQUEST` (photo)                                           |
| `vehicle.specs`        | Members of the Active Organization | `NOT_FOUND`                                                                                         |
| `vehicle.identifiers`  | The Organization's owner           | `FORBIDDEN` (not the owner), `NOT_FOUND`                                                            |
| `vehicle.moveToGarage` | Members of the Active Organization | `NOT_FOUND`, `PLAN_LIMIT`, `CONFLICT` (sold or archived)                                            |
| `vehicle.removeWish`   | Members of the Active Organization | `NOT_FOUND`, `CONFLICT` (not on the Wishlist)                                                       |
| `vehicle.archive`      | Members of the Active Organization | `NOT_FOUND`                                                                                         |
| `vehicle.profile`      | Members of the Active Organization | `NOT_FOUND`                                                                                         |
| `vehicle.addValuation` | Members of the Active Organization | `NOT_FOUND` (car), `BAD_REQUEST` (future date, rate into the car's currency), `CONFLICT` (id taken) |
| `garage.list`          | Members of the Active Organization | —                                                                                                   |
| `garage.netWorth`      | Members of the Active Organization | —                                                                                                   |

## Data and isolation [#data-and-isolation]

`vehicle`, `odometer_reading`, `vehicle_photo`, `ownership_period`, `valuation_snapshot` and `acquisition` have row-level security; the procedures use `context.db`. `vehicle_catalog` is readable by every trusted session and written only through `asSystem(db, "catalog.refresh", …)`. `garage.list` and `garage.netWorth` read at most 200 cars (a garage, not an archive). Writes are audited as `vehicle.created`, `vehicle.updated` (an archive and "I bought it" too) and `vehicle.deleted` (a wish removed). Plate and VIN never reach a log: the audit row holds the car's id only. The photos and the cover are the [gallery](/docs/features/vehicle-photos).

## Key files [#key-files]

- [`apps/mobile/src/features/garage/garage.hooks.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/mobile/src/features/garage/garage.hooks.ts)
- [`packages/api/src/features/catalog/catalog.service.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/catalog/catalog.service.ts)
- [`packages/api/src/features/catalog/catalog.vpic.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/catalog/catalog.vpic.ts)
- [`packages/api/src/features/vehicle/vehicle.service.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/vehicle/vehicle.service.ts)
- [`packages/api/src/features/vehicle/vehicle.contract.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/vehicle/vehicle.contract.ts)
- [`packages/api/src/features/billing/billing.policy.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/billing/billing.policy.ts)
- [`packages/db/src/schema/garage.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/db/src/schema/garage.ts)

