eZacto powered by CONFLICT

Migrating from Harvest

From an operator checkout, build the standalone tool with npm ci and npm run build -w @conflict-hq/ezacto-migrate. Run node packages/migrate/dist/cli.js in place of the installed ezacto-migrate command below when the published package is unavailable. Keep the checkout, credentials and snapshot files on the operator's machine.

The happy path, for somebody who is not us.

cutover-runbook.md is the full walkthrough of one real production run, kept for what it observed. This is the shape of the thing, the order that matters, and the places it bites. It was written the day that account was closed, from what actually happened rather than what was planned.

What you get, and what you do not

ezacto-migrate sweeps a Harvest account into a snapshot directory, proves the snapshot is internally consistent, and loads it into an ezacto database. Time entries, expenses, clients, projects, users, invoices with their line items, payments, messages and PDFs all come across.

Four things do not, because Harvest has no API for them:

The first two are filled in by hand from worksheets. Plan for that — it needs the Harvest UI open, which means it has to happen before you close the account.

Running it

The standalone package name is @conflict-hq/ezacto-migrate. Before using the registry commands below, check that a release is available:

npm view @conflict-hq/ezacto-migrate version

If the registry reports “not found,” use the operator-checkout build at the top of this guide. The package requires Node 22 or newer. Once published, it can be installed without cloning or building this repository.

npx @conflict-hq/ezacto-migrate            # prints the command list

The package installs a single bin, so npx runs it without naming it. The rest of this guide writes ezacto-migrate <command>; put npx @conflict-hq/ in front of it, or install it once and use the short form:

npm install --global @conflict-hq/ezacto-migrate
ezacto-migrate auth --snapshot-dir ./snapshot

It reads a Harvest personal access token from HARVEST_PAT (Harvest ID > Developers), or from a .dev.vars file in the working directory, and optionally HARVEST_ACCOUNT_ID. The token needs administrator scope: a member-scoped token sweeps a fraction of the account and auth says so. The token is never written into the snapshot, but the snapshot is your account's data, so keep the directory private and out of version control.

better-sqlite3 is a native module, so install with lifecycle scripts enabled (the npm default). If npx fails on a machine with no C++ toolchain, that is the first place to look.

To prove an install works before pointing it at a real account, run the smoke test from a checkout: npm run test:package -w @conflict-hq/ezacto-migrate packs the package, installs it into an empty prefix, and drives the installed binary through npx and the global bin. Add --live with HARVEST_PAT and HARVEST_ACCOUNT_ID set (node packages/migrate/scripts/test-package.mjs --live) and it also sweeps, verifies, loads and reconciles a real account into temporary files, printing only exit codes and counts.

The order, and why it is the order

auth → extract → verify → load → worksheets → convert → import

Freeze first. Agree a time after which nobody writes to Harvest, and mean it. Everything downstream treats the snapshot as truth.

Sweep last. extract and sync are point-in-time. Anything that happens in Harvest afterwards is not in your snapshot. This is not theoretical: on our cutover day an invoice was re-sent eight hours after the sweep, and a client opened it — two rows that only exist in the archive because we swept again.

Worksheets after the load, and no sync afterwards. Worksheet completions are bound to the snapshot digest. A later sync moves that digest and voids every one of them, with no in-app repair path. There is no incremental load to recover with: you get a virgin database and the hand-entered rows typed again.

That trap is cheap to fall into. Running extract against a snapshot that already exists — without noticing it exists — advances the watermarks and moves the digest. That is all it takes.

verify is a gate, not a formality. It is the only check of the snapshot's own internal consistency. Do not continue until it exits 0.

reconcile is the other gate. It compares Harvest's own report checksums against your snapshot and your loaded database. The number to look at is unexplained. Zero means every difference is accounted for; anything else means one is not. Gaps are differences matched against the known-limitations list in migration-spec.md §7 — they are accepted, not ignored.

Running in parallel

A parallel run keeps Harvest as the book while you try ezacto on a copy of its data. It works, with two limits that decide how to do it.

What parallel means here. extract and sync keep the snapshot current: run them as often as you like while people keep working in Harvest. Loading is the expensive, one-way step. Treat every load before the real one as a rehearsal into a throwaway database file, and delete it afterwards:

ezacto-migrate sync    --snapshot-dir ./snapshot
ezacto-migrate verify  --snapshot-dir ./snapshot
ezacto-migrate load    --snapshot-dir ./snapshot --database ./rehearsal.sqlite
ezacto-migrate reconcile --snapshot-dir ./snapshot --database ./rehearsal.sqlite

Limit 1: load is insert-if-absent, not upsert (issue 407). A row is written only if no row with its harvest_id exists. After a load, a row edited in Harvest keeps its old values in the database even though sync refreshed it in the snapshot, and a row deleted in Harvest stays in the database. Neither shows up as a row-count difference. reconcile reports it: a nonzero load currency row means the database is behind the snapshot, and upstream_deletion gaps name the deleted rows still held. There is no refresh in place. The remedy is a new load into an empty database file.

So do not enter real work into a database you may have to rebuild. Time typed into a rehearsal copy is lost when it is replaced. The one database that people work in is the one loaded after the freeze.

Limit 2: worksheets are bound to the snapshot digest. Retainer and recurring-invoice completions are tied to the exact snapshot a load was admitted under. Any later sync or extract that changes the snapshot moves the digest, and every completion is void with no in-app repair; load also refuses a database admitted under a different snapshot. A rebuild therefore means a virgin database, a full reload, and the worksheet rows entered again. Keep the filled worksheet JSON files: only their header digests need regenerating.

The order that follows from both: parallel-run on rehearsal databases, freeze, final sync, verify, one real load, worksheets, reconcile, ship. No sync after the real load. cutover-runbook.md section 1 walks through the sequencing and what a late sync costs.

If the instance has already moved on (people are working in it) and you only need invoices it is missing, carry-invoices below is the supported path; it does not touch anything else.

For an instance that has already moved on

load wants a virgin target and refuses one admitted under a different snapshot. That is right for a cutover and useless afterwards: once people are working in ezacto, reloading would take their work with it.

carry-invoices moves the invoices a live database is missing across from a freshly loaded one, using the same importer load uses:

ezacto-migrate carry-invoices --source ./fresh-load.db --database ./live.db --dry-run
ezacto-migrate carry-invoices --source ./fresh-load.db --database ./live.db --only 1314

It resolves every reference by harvest_id against the target rather than trusting row ids to line up, and it carries missing invoices — not updates to invoices the target already has.

Do not hand-write these rows. invoices carries forty-five triggers, and an imported line is admitted only while a matching invoice_import_operations row is open. Copying finished rows verbatim is refused, correctly.

Before you close the account

That last one is worth doing properly. Counts matching is not the same as every invoice having its own PDF.

A free reconciliation on the way out

Harvest's account-closure page shows lifetime hours, projects and total invoiced. On the run this was written from, that total matched the migrated database to the cent — a whole-account end-to-end check, free, from the vendor being left. If yours disagrees with your migration, find out why before you click through.

The cancellation path itself is three retention screens deep, and Cancel plan and Close account permanently are different decisions. Cancelling stops the billing; closing starts a ten-day deletion clock.