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 sharedvehicle_catalogtable 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 flaggedmanual. 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 (uploads.prepare→PUT→uploads.complete) and becomes the cover. The currency defaults to the User's Account Preference, else EUR. A client-generatedidmakes 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 flaggedmanual), or replace the cover. Plate and VIN are encrypted withFIELD_ENCRYPTION_KEYbefore 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, withcanRevealfor the Organization's owner.vehicle.identifiers(owner only, audited asvehicle.identifiers_revealed) decrypts them; the app keeps them in memory, never in the query cache. - Wishlist (US27, RF27):
vehicle.createwithwishlist: { targetPriceMinor?, note? }saves a wished-for car in thewishliststate, with no Ownership Period and no odometer (one is refused), outside the Free limit.vehicle.updateeditstargetPriceMinorandnote(null clears).vehicle.moveToGarage("I bought it") moves it togarageand 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 isCONFLICT.vehicle.removeWishdeletes a wished-for car with its photos (audited asvehicle.deleted); any other state isCONFLICT, since an owned car's history is never deleted (RN01). - Archive a car (
vehicle.archive): moves it toarchived, 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":totalSpentfrom@autofolio/domainover 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 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'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 unlessasOfis given (never later),mediumconfidence by default. A car in another currency than the owner's main one may carryrate, 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-generatedidmakes 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 throughnetWorthfrom@autofolio/domain(netWorthOfinvehicle.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 inrates; without one it is returned inunconvertedand never summed. - Reference values (RF38, ADR-06): see below.
vehicle.profileandgarage.netWorthreturn each car'sreferenceSource, 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, andneedsAcquisitionfor a current car whose purchase is not recorded. It returns the count per state, the User's plan andcollection(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, anddrivenKm, each such car's latest reading minus its first (collectionOfinvehicle.rules.ts). The app filters by state on the device.
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), the white "Add expense" pill (add-gasto) and Gallery, Prepare sale, Documents and History. Prepare sale opens M-Vender. 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)
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)
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
| 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
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.
Key files
apps/mobile/src/features/garage/garage.hooks.tspackages/api/src/features/catalog/catalog.service.tspackages/api/src/features/catalog/catalog.vpic.tspackages/api/src/features/vehicle/vehicle.service.tspackages/api/src/features/vehicle/vehicle.contract.tspackages/api/src/features/billing/billing.policy.tspackages/db/src/schema/garage.ts