Migrating

Bring the history. Check it before it lands.

A studio moving a decade of billing cares about exactly one thing: that the numbers still add up afterwards. So the importer writes nothing on the way in, shows you what it found, and waits for you to agree.

  1. Step 1

    Land

    Your export is stored verbatim, byte for byte, with a content hash.

  2. Step 2

    Normalize

    Thousands separators, three date conventions, four column layouts.

  3. Step 3

    Validate

    Anything ambiguous becomes a discrepancy to resolve, never a guess.

  4. Step 4

    Dry run

    A full report of what would be written. Nothing has been written yet.

  5. Step 5

    You approve

    Check the totals against Harvest. This is the point of the exercise.

  6. Step 6

    Commit

    Written in one pass, then reconciled against the source figures.

Proven against the source, not asserted

Your original rows are kept verbatim as they arrived, with a content hash, so any figure in the app traces back to the bytes it came from. After a commit the totals are recomputed and compared against the file. If they do not match, the import is marked discrepant rather than reconciled, and it tells you which figures disagree.

Anything unresolvable is recorded, never guessed

An ambiguous person, an unknown currency, a date format a file cannot prove — each becomes a discrepancy and blocks the commit until you decide. The dry run is your chance to catch a wrong assumption before it becomes your general ledger.

What actually goes wrong in a Harvest export

These are not hypotheticals. Each was found in a real export and each is handled by name.

Thousands separators inside quoted fields

A real export carried 1,600.0 in 19 of 76 rows. Handed to a naive parser, $1,600.00 silently becomes $1.00 -- it stops at the comma and does not complain. Separators only appear at four figures and up, so an importer tested on small values looks flawless while destroying the amounts that matter.

Three date conventions in one account's exports

ISO in the full report, 04/01/2026 in trimmed ones, 7/1/2025 in another. The slash forms are locale-dependent, and 04/01/2026 is either April or January depending which. The format is decided once per file from every date in it, and a file that cannot prove its convention is refused rather than guessed at.

A column called Employee Id that holds an email

In detailed exports it is an address; in the people export the same column is empty and the address is elsewhere. So the obvious join key between two files is neither stable nor named for what it contains. People are matched on email, then employee id, then name -- and reported as ambiguous rather than merged on a hunch.

Currency stored as a display name

The cell reads United States Dollar - USD. Keep the whole string and every later currency comparison quietly fails.

Invoiced rows with no invoice number

60 of 76 rows in one sample said Invoiced? = Yes and carried nothing to say which invoice. Those import as billed and flagged as unresolved, and the report says so. Guessing would produce a ledger that reconciles against nothing.

Two ways in

CSV exports

Works on any Harvest plan. Column layouts vary between accounts, so columns are matched by header name and an unrecognised shape is refused rather than guessed at.

The Harvest API

Carries what CSV cannot — most usefully the link between a billed hour and the invoice it was billed on, which no export column contains.

Importing is not metered. Forty thousand entries cost the same as forty.