CSV format
The lossless source-of-truth format fiscode reads and writes — and how to author it by hand.
CSV is the canonical format for fiscode data. Every entity is a row in a single CSV, discriminated
by the record_type column. The flat row shape lives at packages/csv/src/schema.ts; adding new
fields means adding new optional columns at the end so old exports still parse.
Two workflows
fiscode supports two ways into the same data model:
- App-first — enter rows in the web app, click Export when you want a CSV (for backup, handoff to an accountant, or to load on a different device). This is the common path.
- CSV-first — author the CSV yourself (in Excel, Numbers, a text editor, a script), then Import it into the app. Everything you can do in the UI you can do directly in CSV; the schema below is the contract.
Both paths produce identical state. Round-tripping Export → Import is byte-stable across data
rows; the only thing that changes is the provenance timestamp.
The manual-workflow example
A complete, ready-to-import example CSV — one row of every record_type — is available as
a download:
To regenerate it from the latest schema:
bun run example:csvThe example exercises every record type so a hand-author has a concrete pattern for each row shape. Use it as a starting skeleton: keep the ones you need, delete the rest, edit values.
Authoring tips
- Column order doesn't matter on import — the parser keys on the header row. But the export
always emits columns in the canonical order from
CSV_COLUMNSinpackages/csv/src/schema.ts, so if you want a stable diff, follow that order. - Leave irrelevant columns empty. A flat row carries every column for every record type; for
an
incomerow, fields likeoffice_sqftandmakeare empty strings. - IDs must be unique within a
record_type. They can be any string but ULIDs are what the app emits. A simple convention works fine for hand-authored files:in_001,in_002, … - The profile row is a singleton. Its
idis always the literal stringprofile. - Dates are ISO YYYY-MM-DD. Timestamps in
created_at/updated_atare ISO 8601 with aZsuffix — but if you're hand-authoring, you can leave these empty and the importer will fill them. - Money is integer cents. $1,234.56 becomes
123456. No decimals; the importer rejects non-integer numeric cells loudly rather than silently zeroing them. - Booleans are
1/0/ empty. Anything else fails validation.
Provenance header
Each exported CSV begins with #-prefixed comment lines containing app version, export timestamp,
scope (full vs yearly), and the source URL. Comments are stripped on import via
Papa.parse({ comments: '#' }). You can omit these lines in a hand-authored file — they're purely
informational.
# fiscode export
# source: https://fiscode.app
# schema: v1
# exported: 2026-06-04T12:00:00.000Z
# scope: full
record_type,id,...Common columns
Every row carries these five columns regardless of record_type. Empty values are serialized as
empty strings.
Prop
Type
Record types
Below: one table per record_type showing the columns that apply to that record. The common
columns above always apply too; they're omitted here to keep each table focused.
profile
Single row. Drives the tax engine's filing status, state, and feature flags. Its id must be
the literal string profile.
Prop
Type
entity
Dated entity periods. Currently one active per year, validated at import. Use end_date to
mark a switch (e.g. sole-prop Jan–Jun, single-member LLC Jul onward = two rows).
Prop
Type
spouse
Dated spouse income/withholding spans. Optional; only relevant when filing_status is mfj
or mfs and your spouse has W-2 income. The app uses the latest-overlapping span for the
estimate year.
Prop
Type
client
Payers. Income rows reference clients by client_id.
Prop
Type
income
1099 or W-2 entries, per payment. Dated so the quarterly engine can bucket them. Tax math
only sums rows where source_type is 1099; w2 rows are informational unless you also
record them on a spouse span.
Prop
Type
time_entry
Time-tracking sessions. Independent of income; not consumed by the tax engine.
Prop
Type
vehicle
Vehicles available for business mileage. Each mileage row references one by vehicle_id.
Prop
Type
mileage
Per-trip business miles. The tax engine multiplies the year's total by the IRS standard mileage rate (configurable per year).
Prop
Type
home_office
Dated home-office configuration. Only the simplified method ($5/sqft, capped at 300 sqft /
$1,500/yr) is calculated in v1; actual-method rows are stored but contribute $0 to the
deduction until the actual-method calc lands.
Prop
Type
expense
Categorized business expenses. flag_for_section_179 marks candidates for §179 expensing —
purely informational in v1 since every expense is already fully deducted in the year incurred.
Prop
Type
retirement_contribution
Gated by profile.uses_retirement. SEP / Solo 401(k) / Roth contributions. Informational
in v1 — they're stored and surfaced in the dashboard but don't yet flow into the deduction.
Prop
Type
Import modes
When you upload a CSV to fiscode you pick one of three modes:
- overwrite — snapshot the current DB state into history, truncate all data tables, load the CSV. Use this when the CSV is the source of truth (typical CSV-first workflow).
- append — keep existing rows; merge new ones by ID. Collisions are reported and existing rows win. Use this when adding a batch (e.g. a month of receipts exported from a bank).
- restore — alias for overwrite, with confirmation copy emphasizing the snapshot. Use this when reverting to a known-good backup.
Every import — regardless of mode — appends a __import_snapshot history entry containing the
full pre-import bundle, so you can revert.
Validation
The importer surfaces errors per-row rather than crashing on the first bad cell. Common rejections:
- Unknown
record_type— typo or new record type the running app doesn't recognize. - Non-integer numeric cells — e.g.,
amount_cents: 12.50. Use integer cents. - Missing
se_start_dateonprofile— required; the tax engine uses it to gate which rows count toward the year. - Bad boolean values —
1,0, or empty only.true/yes/TRUEall reject.
Round-trip guarantee
Export full CSV → wipe → Import → Export full CSV is byte-stable across data rows. The
provenance timestamp on line 4 is the only thing that changes. This invariant is enforced by
a vitest test in packages/csv/src/round-trip.test.ts.