What it does
An Expense is a running cost of a car (fuel, maintenance, insurance, tax, upgrade, other). Saving one writes, in one transaction:
- a ★
ledger_entry(RN02): integer minor units in the car's currency, effective date, category, vendor, odometer, note, author, and its source:manual, orocr_draft_confirmed/voice_draft_confirmedfor a photo or voice draft the User confirmed (below). Its kind isupgradefor an upgrade (capitalized, RN06) andoperatingfor everything else (TCO); - the
expenserow: fuel in millilitres or charge in watt-hours, full or partial tank (RF09), and the receipt; - with an odometer value, a ★
odometer_readingwith sourceexpense(RN07, below); - with a receipt, a
vehicle_documentof kindreceiptfor the uploaded file, and the entry's trust becomeswith_proof.
Only a car with an open Ownership Period takes an expense: a sold or wished-for car answers BAD_REQUEST. The currency is always the car's.
Outcomes, not errors
expense.create answers one of three results:
status | Meaning | Written |
|---|---|---|
saved | expense holds the saved expense | all |
duplicate | RF10: duplicate is an expense of the car with the same amount, date and vendor (case and spaces ignored). Send again with allowDuplicate: true to save anyway | nothing |
odometer_reason_required | RN07: the reading is lower than previousKm, the latest reading on or before the expense date. Send again with odometerReason | nothing |
A reading lower than the previous one, with a reason, is saved and flagged.
Offline and idempotency (RNF04)
The client generates the expense id (a UUID) when the User taps Save and keeps it with the queued request. The id becomes the ledger entry's and the expense's id, so sending it again, even while the first send is still running, answers the first save and writes nothing more. The same id for another car, or an id another entry already uses, is CONFLICT. A correction's correctionId works the same way.
Corrections (ADR-01, RNF12)
expense.correct is PATCH /expenses/:id: a new correction ledger entry with every current value, overridden by the fields given (null clears vendor or note), corrects_id naming the entry it corrects and the reason. The original stays; the expense now points at the correction, and a later correction corrects that one. Changing the odometer appends a reading, flagged with the correction's reason when it is lower. Moving to a category other than fuel clears the fuel fields. Corrections of one expense run one at a time.
Procedures
| Procedure | Who | Failures |
|---|---|---|
expense.create { id, vehicleId, category, amountMinor, effectiveDate, vendor?, odometerKm?, odometerReason?, note?, receiptFileId?, fuel?, allowDuplicate, source? } | Members of the Organization | INPUT_VALIDATION, NOT_FOUND (car or receipt file), BAD_REQUEST (no open ownership period), CONFLICT (id taken) |
expense.correct { id, correctionId, reason, …fields } | Members of the Organization | INPUT_VALIDATION (blank reason), NOT_FOUND, CONFLICT |
expense.list { vehicleId, limit ≤ 100 } | Members of the Organization | INPUT_VALIDATION |
expense.get { id } | Members of the Organization | NOT_FOUND |
expense.summary { vehicleId?, period, today } | Members of the Organization | INPUT_VALIDATION, NOT_FOUND (car) |
expense.entries { vehicleId?, category, period, today, cursor?, limit ≤ 100 } | Members of the Organization | INPUT_VALIDATION |
expense.structure { kind, text, locale, today, platform } | Members of the Organization | INPUT_VALIDATION; { draft: null } when off |
odometer.latest { vehicleId } | Members of the Organization | NOT_FOUND (car) |
expense.list is a bounded window of the latest expenses, newest first, with their current values. Distances are always km and fuel millilitres; the client converts to the account's units.
Fuel economy (RF09)
fuelEconomy(fills) in @autofolio/domain/consumption computes full to full: the distance between the first and last full fill-ups with a reading, over everything bought after the first. formatFuelEconomy and formatEnergyEconomy in @autofolio/domain/format show it as km/l, l/100 km, mpg or kWh/100 km.
The app screen (M-Novo-Gasto)
/add-expense?vehicleId= in the mobile app (apps/mobile/src/features/expense/), pushed from the vehicle profile's add-gasto. The form starts from the car's latest expense of the category (amount and vendor), its latest reading and today, so a repeat fill-up is two taps (US09). The amount is in the car's currency, the odometer in the account's unit, fuel in litres or gallons with a switch to kWh for a charge. A reading below the last one shows a reason field before anything is sent (RN07). The client runs findLikelyDuplicate over expense.list and shows the warning banner; the second tap sends the same id with allowDuplicate. Photo and Voice build a draft instead (below).
Saving goes through the app's offline queue (apps/mobile/src/platform/offline/): the input is written to disk under its client id before it is sent, and sent again when the network or the app comes back. A queued expense the server answers with duplicate, odometer_reason_required or a refusal stays on the phone as "needs your review"; a toast and the car's form list it, and opening it puts it back in the form with the answer shown. Sign-out clears the queue with the rest of the device's private data.
Photo and voice drafts (US34, US35)
AI only creates drafts (RN10, ADR-05 of the handoff): nothing reaches the ledger until the User taps Confirm and save.
- Photo (RF35): the User takes or picks the receipt photo and taps Extract details. The text is read on the phone, by Apple Vision on iOS and ML Kit's on-device model on Android (
expo-text-extractor); no cloud OCR.readReceiptin@autofolio/domain/capturemakes the draft: the amount on a total line, else the one printed most often; the currency named; the first real date not after today (10/04follows the account's locale and is less sure); the first line that reads like a name as the vendor; litres, gallons or kWh; a mileage after "km", "Kilometerstand" or "odometer"; the category from those and from keywords. Each field carries a confidence from 0 to 1. - Voice (RF36): the User holds the microphone (or taps, then taps again) and says the expense: "Changed the oil on the BMW, 650, 42,000 km".
expo-speech-recognitiontranscribes it on the device where the phone supports it, else through the system recognizer.readSpeechreads the car (a model's name, or a make only one car has), km or miles as the odometer, litres as fuel, a number with a currency as the amount (else the first bare number, less sure), "yesterday" as the date and the category. - Review: the form opens on the draft under "Draft to confirm": "confidence high" or "medium" under each value read, a "check" pill otherwise. A field not read stays empty, except the date (today) and the odometer (the car's latest reading, to check). An amount read in another currency than the car's keeps its number with "check" and both currencies named: an expense is in the car's currency. The voice draft's car row opens the list of cars. Confirming runs
confirmDraftand saves through the same queue, duplicate check and RN07 check as a typed expense, with the photo as the receipt. - Reading failed: when nothing worth a draft is read (no amount, date or vendor), the form opens by hand with what was read and the photo attached, and the User saves it as a manual expense.
With the ai.capture flag on and an AI provider configured (the assistant's, see Assistant), the app also sends the recognised text, never the photo or the audio, to expense.structure. The model may only fill a fixed schema (category, amount, currency, date, vendor, odometer, litres); groundDraft keeps only the values written in that text, so it can never invent an amount, and mergeDrafts keeps the more confident value per field. The call shares the assistant's per-minute limit, gives up after 8 seconds on the phone and is never stored or logged; off, limited or failing, the phone's own draft stands.
Costs by category and period (M-Gastos, US13)
expense.summary is the Finances tab's aggregate. It reads every cost through what holds its current values, never by summing raw ledger rows: an Expense's ledger entry and a Service's ledger entry (the original or the latest correction). Acquisition, the sale and a Loan never count: its principal is not an expense (RN05), and its interest and fees are a component of the TCO on Insights and listed on M-Financiamento, never a row here (the board has none). The total is totalSpent from @autofolio/domain/rules: the six expense categories, upgrades included. Rows are grouped in Postgres by category, month, currency and stored rate; summarize in expense.rules.ts folds them.
- Period:
3mand12mare calendar months, today's and the 2 or 11 before it (from the 1st);allis every entry up totoday.todayis the device's date, so months follow the user's calendar. The 12 monthly bars are always the last 12 months, whatever the period. - Currency (RF23, US30): one car's summary is in its own currency; every car's is in the account's main currency. An entry in another currency counts at its stored rate (
fx_rateto that currency,fx_date) and the latest rate per currency comes back inratesfor "rate · date". An entry without a rate to it is listed inunconvertedand never summed. expense.entrieslists the costs behind one row, newest first, with anextCursor(date and id) for the next page. Each line says where it lives (expenseorservice) and is in its own currency.
M-Veiculo's "Costs, last 12 months" (#7) is a rolling year from the same day a year ago; M-Gastos' 12 months are calendar months, so the bars add up to the total. They agree when the oldest month has no entries before the same day.
The Finances tab (M-Gastos)
/finances (apps/mobile/src/features/finances/) opens on the newest current car, or the car of ?vehicleId= (the dev link). The subtitle opens a sheet with "All vehicles", the current and the sold cars. The segmented control switches the period (per-3m, per-12m, per-all); the card shows the total, the bars (tap one to select its month) and the selected month's amount as text. Screen readers skip the bars and step through the months on that line (an adjustable element). Each category row opens /cost-entries, whose expenses open /correct-expense?id=: the expense's values and a required reason, saved as expense.correct through the offline queue under a correction id made once. A correction the server refuses later is dropped with a toast. The "Upcoming recurring" card waits for recurring expenses (#20).
Data and isolation
All four tables have row-level security by Active Organization. ledger_entry and odometer_reading are append-only for every role; the expense row may only repoint its ledger entry and fuel fields. No audit_log event is written: the ledger row is the money's audit trail, and amounts never go to logs (RNF07).
Key files
packages/api/src/features/expense/expense.contract.tspackages/api/src/features/expense/expense.service.tspackages/api/src/features/odometer/odometer.service.tspackages/api/src/features/expense/expense.rules.tsapps/mobile/src/features/finances/finances.hooks.tsapps/mobile/src/features/expense/expense.hooks.tsapps/mobile/src/platform/offline/mutation-queue.tspackages/domain/src/consumption.tspackages/domain/src/capture.tspackages/api/src/features/expense/expense.structure.tsapps/mobile/src/features/expense/expense.capture.hooks.ts