AI-Assisted ERP Migration — Case Study

Tutorial: We Cut Our Own ERP Bill to Zero — Here's the Complete Field Study

Editorial note. This is our own accounting. We migrated our own books first, before recommending this path to anyone else — every figure here is real and unredacted. This article was drafted with heavy AI assistance; see "A note on how this was written" below for exactly what that means and why we think it matters.

For the founder deciding whether this is worth reading

If you run a company on Odoo Enterprise, Salesforce, NetSuite or any per-user SaaS ERP, you have probably done the arithmetic at least once: what would it actually cost us to leave?

The honest answer is that the licence fee is the smallest part. The real cost is the migration — and specifically, the risk that your accounting arrives at the other end subtly wrong in ways nobody notices until an auditor does.

This is a complete account of one such migration — our own — executed in a single working session against a hard deadline, with the work that followed over the next day. It ended with a trial balance within nine cents of the source across all 78 accounts, with invoices, payments and reconciliations restored as real, navigable documents. Getting there also produced five distinct failure modes that silently destroyed or corrupted data while reporting success.

Those five are the reason to read this. They are not Odoo-specific — they are what happens whenever you move accounting data between systems, and every one of them would have shipped a plausible-looking, incorrect ledger.

The situation

We ran our entire accounting on Odoo Enterprise Online. A cost-reduction decision landed: the Enterprise subscription would not be renewed. It expired today.

The target was a self-hosted Odoo 19 Community instance already running in Docker. The assumption going in — reasonable, and wrong — was that both sides were the same version, so a database backup should simply restore.

What follows is a step-by-step account of how an AI engineering agent (Claude Opus 5) approached the problem, what it got right, what it got wrong, and where the interesting failure modes live. We are publishing the mistakes deliberately: they are more instructive than the successes.

A note on how this was written

What was automated: the migration itself was executed by an AI coding agent — Claude Code running Opus 5 — operating directly against the source and target Odoo instances, a restored PostgreSQL forensic copy, and a handful of parallel research subagents. This article's technical narrative, diagrams and first draft were also produced with the same agent's assistance, working from its own session record and the accompanying internal audit document.

How it was used: the agent read framework source code to verify claims, executed and monitored the actual migration scripts, and wrote up what it did and where it failed. A human reviewed the draft against the underlying audit trail before publication, corrected several inflated or unsupported figures the first draft contained, and rewrote the ending to match what the audit records rather than an earlier, more flattering checkpoint.

Why: the point of this article is the failure modes, not the fact that AI wrote it up. An agent that can migrate a production ledger can also write up how it did so — accurately turns out to require the same discipline in both jobs: verify against a source you did not produce yourself.

1. The shape of the problem

Side-by-side comparison diagram showing the source Odoo Enterprise Online instance with 505 modules and 4,457 journal entries against the target self-hosted Odoo Community instance with 117 modules and zero journal entries, connected by an arrow labeled subscription expires today.
View Mermaid source
graph LR
    subgraph SRC["SOURCE — Enterprise Online"]
        A1["Odoo saas~19.2+e"]
        A2["505 modules
274 Enterprise-licensed"] A3["4,457 journal entries
across both legal entities"] A4["4,170 filestore files"] end subgraph DST["TARGET — Community self-hosted"] B1["Odoo 19.0 CE"] B2["117 modules
0 Enterprise"] B3["0 journal entries"] B4["No e-invoicing stack"] end SRC -->|"subscription expires TODAY"| DST style SRC fill:#7c2d12,color:#fff style DST fill:#1e3a5f,color:#fff
The source Enterprise instance and the target Community instance compared side by side, framing the scale of the migration.

Two numbers frame everything: 274 of 505 installed modules were Enterprise-only, and the accounting had to survive intact for tax-audit purposes.

2. First move: verify the premise, not the plan

The brief asked for three tasks in a specific order. The agent's first substantive action was not to execute any of them — it was to query the source server's version string.

Flowchart showing the agent verifying the source and target server versions before planning, discovering the source reports saas~19.2+e and the target reports 19.0, concluding the database will be rejected and the original approach is impossible.
View Mermaid source
flowchart TD
    START["Brief received:
'both are v19, restore the dump'"] --> Q{"Verify version
before planning"} Q -->|"query server"| V1["Source reports:
saas~19.2+e"] Q -->|"probe target"| V2["Target reports:
19.0-20260630"] V1 --> D{"Same version?"} V2 --> D D -->|"NO"| K["Read Odoo source code:
list_db_incompatible()"] K --> R["'saas~19.2' != '19.0'
→ database rejected
→ no downgrade scripts exist"] R --> OUT["The entire proposed
approach is impossible"] style OUT fill:#7c2d12,color:#fff style R fill:#78350f,color:#fff
Checking the actual server versions before executing the brief revealed the entire proposed restore approach was impossible.

Odoo Online runs rolling saas~ branches ahead of the stable on-premise release. The official documentation is explicit — "Odoo Online's intermediary versions are not supported by Odoo.sh or on-premise" — and the mechanism is verifiable in the source: odoo/service/db.py::list_db_incompatible() compares the stored base.latest_version against the running server's version and refuses the database.

This single check, performed in the first minutes, invalidated the entire brief. Had the agent started executing the requested steps in order, this would have surfaced hours later, after a dump download, a restore attempt, and a debugging session.

The reframe: the dump is still mandatory, but as a forensic archive restored into plain PostgreSQL — queryable by SQL forever, with no Odoo involved. Migration itself goes record-by-record over RPC.

3. Reasoning pattern: the verification loop

The dominant behavioural pattern across the session was a tight loop that treats every claim — including its own — as unverified until measured.

Flowchart of a verification loop that measures every claim directly, revises the model of reality when evidence does not match, and verifies the effect rather than the artifact before declaring the task done.
View Mermaid source
flowchart LR
    C["Claim or assumption"] --> M["Measure it
directly"] M --> E{"Evidence
matches?"} E -->|yes| A["Act"] E -->|no| R["Revise model
of reality"] R --> M A --> V["Verify the
effect, not the
artifact"] V --> F{"Money matches
money?"} F -->|no| R F -->|yes| DONE["Done"] style DONE fill:#14532d,color:#fff style R fill:#78350f,color:#fff
The recurring behavioral loop: measure every claim, verify effects rather than artifacts, and revise when money does not match money.

Concretely, this meant refusing certain categories of evidence:

Not accepted as proofAccepted as proof
"The script ran without errors"Row counts match SQL ground truth
"The module shows as installed"The report wizard actually executes
"3,325 records created"Debit and credit match the source per account
"The file was copied"SHA-1 of the copy equals the source checksum

This distinction was not academic. Twice in the session, an operation reported success while silently losing data.

4. Tooling architecture

The agent operated across four distinct access paths, choosing between them deliberately rather than defaulting to one.

Diagram of the four access paths the agent used: an MCP connector for reads and verification, direct JSON-RPC for bulk writes, PostgreSQL as ground truth, and subagent workflows for parallel research.
View Mermaid source
graph TB
    AG["Agent"]
    subgraph paths["Access paths"]
        MCP["MCP connector
reads, verification"] RPC["Direct JSON-RPC
bulk writes"] PG["PostgreSQL
ground truth"] WF["Subagent workflows
parallel research"] end AG --> MCP & RPC & PG & WF MCP -->|"XML-RPC only
cannot marshal None"| ODOO["Odoo instances"] RPC -->|"needs browser UA
or Cloudflare 403"| ODOO PG --> DUMP["Restored dump
4,457 entries"] WF --> RES["Design specs
gap analyses"] style PG fill:#14532d,color:#fff style AG fill:#1e3a5f,color:#fff
Four distinct access paths were chosen deliberately rather than defaulting to a single tool.

Two constraints emerged empirically and shaped the whole approach:

The MCP connector forces XML-RPC. Requesting JSON-RPC returned effective_protocol: xmlrpc. Since XML-RPC cannot serialize None, any Odoo method returning an action dict with null fields fails with cannot marshal None. The connector remained excellent for reads and verification; bulk operations went over direct JSON-RPC.

A CDN sat in front of the target. POST requests to /jsonrpc without a browser User-Agent returned 403 — indistinguishable, at first, from an authentication failure. Diagnosing this correctly (transport problem, not credentials problem) saved a wrong-turn debugging path.

5. Parallel research: where subagents earn their keep

Four questions blocked progress and were mutually independent. Rather than serializing them, the agent dispatched them as parallel subagents.

Diagram showing an orchestrator dispatching four parallel research subagents covering journal audit, PostgreSQL viability, tax repartition, and attachments, converging into a synthesis and consolidated plan, while bulk writes to the live database were never delegated.
View Mermaid source
graph TD
    O["Orchestrator"] --> A["Agent A
Journal audit"] O --> B["Agent B
PostgreSQL viability"] O --> C["Agent C
Tax repartition"] O --> D["Agent D
Attachments + chatter"] A --> S["Synthesis"] B --> S C --> S D --> S S --> P["Consolidated plan"] O -.->|"NEVER delegated"| W["Bulk writes to
the live database"] style W fill:#7c2d12,color:#fff style P fill:#14532d,color:#fff
Independent research questions were parallelized across subagents, but writes to the live database stayed strictly single-writer.

The boundary matters more than the parallelism. Research fanned out; writes never did. Multiple agents writing concurrently to one Odoo instance would race on sequence numbers and external IDs. Bulk loading stayed strictly sequential, single-writer.

Agent A alone eliminated three suspected blockers by proving they were not blockers — two journals flagged as "missing" turned out to belong to a second company and to be referenced by zero entries. Cheaper to prove absent than to build a workaround for.

An orchestration bug worth naming

The first workflow launch omitted await on the parallel stage. The synthesis agent started before its inputs existed and received NO DISPONIBLE for all seven reports. It compensated by querying the database itself — producing a plausible, well-formatted document built on data the orchestrator could not trace.

The orchestrator caught this by inspecting the run journal rather than trusting the output. A fluent synthesis is not evidence that its inputs arrived.

6. Five failure modes that lose data silently

This is the section worth reading twice. Each of these produced no error while destroying or corrupting data.

Mind map of five silent data loss failure modes: currency precision mismatches, text with newlines splitting rows, archived records invisible to search, company-dependent fields moved to JSONB, and framework recomputation discarding supplied balances.
View Mermaid source
mindmap
  root(("Silent
data loss")) Currency precision Source: 4 decimals Target: 2 decimals Entries stop balancing Text with newlines Delimited parsing splits rows 94 entries vanish No exception raised Archived records search excludes them Catalogue incomplete Fails only on use Company-dependent fields code moved to jsonb Other company reads empty Looks like missing data Framework recomputation move_type discards balance Invoice of 4,060 becomes 560 Totals look plausible
Five distinct failure modes that produced no error while destroying or corrupting accounting data.

6.1 Currency precision (cost: one full reload)

The source had its currency configured to four decimal places; the target to two. 202 accounting lines carried sub-cent values such as -1600.0050. Rounding each line independently to two decimals broke the zero-sum invariant on three entries and introduced one-to-two-cent drift across fifteen accounts.

Raising the target's precision and reloading moved the global difference from −55,120.71 to 0.00. No patches, no plugs. Note the asymmetry: increasing decimal places is permitted; decreasing it with existing entries is blocked.

An earlier subagent had recommended keeping two decimals and absorbing the residual into the largest line. That advice was given without checking the source currency configuration. Checking it made the recommendation obsolete — a useful reminder that subagent output is a hypothesis, not a finding.

6.2 Newlines in free-text fields (cost: 94 entries)

Exporting from PostgreSQL with delimited output and parsing line-by-line:

Sequence diagram showing a PostgreSQL row containing a newline in its narration field splitting into two lines during delimited parsing, the second fragment failing a column-count check and being silently discarded, resulting in 94 missing entries with debit not equal to credit.
View Mermaid source
sequenceDiagram
    participant SQL as PostgreSQL
    participant P as Line parser
    participant J as JSON file
    SQL->>P: row with narration
containing newline Note over P: row splits into
two "lines" P->>P: second fragment has
wrong column count P--xJ: row discarded — no error Note over J: 94 entries
191 lines missing Note over J: debit ≠ credit
A newline inside a free-text field silently dropped 94 entries during delimited export, with no error raised.

Ninety-four entries contained line breaks in a notes field. Each split into fragments that failed the column-count check and were dropped without raising anything. The export completed "successfully" with debit and credit no longer equal.

The fix is one line: wrap the query in SELECT json_agg(t)::text FROM (...) t. JSON escapes newlines inside strings, so one row is always one line.

What caught it was not the script. It was an integrity check comparing the export against SUM() computed in SQL — a control that exists precisely because "completed without errors" is not evidence.

6.3 Archived records are invisible to search

One entry failed on an account that existed in the source but not in the exported catalogue. The account was archived, and Odoo's default search excludes archived records. Regenerating the catalogue directly from SQL revealed seven archived accounts, one of which carried movement.

Any master-data export must use ['|', ('active','=',True), ('active','=',False)] or read from SQL.

6.4 Company-dependent columns

In Odoo 19 the account code is no longer a column — it lives in a JSONB field keyed by company. Accounts belonging to a second company therefore appear to have no code at all when read in the first company's context.

This masked a more serious problem: a trial balance computed without a company filter silently mixed two legal entities. The reported figure of 35,228,691.16 was actually 33,622,728.25 for the operating company plus 1,605,962.91 for a second entity that was explicitly out of scope for this phase — filtering by company was what separated them back out, and confirmed the unfiltered total had silently merged two legal entities into one misleading number.

6.5 The framework recomputes what you send

This is the one that took the longest to get right.

Loading every record as a generic journal entry produces a balance that matches to the cent. It also produces an accounting system in which invoices are not invoices: they do not appear in the Invoices menu, carry no payment status, and cannot be reconciled as documents.

Declaring the real document type triggers Odoo's dynamic-line synchronisation, which discards the supplied balance and recomputes from price × quantity.

State diagram showing the tradeoff between loading a record as a generic journal entry, which honours the exact balance but is not a real invoice, versus loading it with its real document type, which triggers dynamic line synchronization that discards the supplied balance and recomputes an invoice of 4,060 as 560.
View Mermaid source
stateDiagram-v2
    [*] --> Choice
    Choice --> AsEntry: "load as generic entry"
    Choice --> AsInvoice: "load with real type"
    AsEntry --> BalanceExact: "balance honoured"
    BalanceExact --> NotInvoice: "menu empty
no payment state" AsInvoice --> Recomputed: "_sync_dynamic_lines
discards balance" Recomputed --> WrongAmount: "4,060 becomes 560" NotInvoice --> Tradeoff WrongAmount --> Tradeoff Tradeoff: Odoo does not permit both
Odoo would not permit both an exact balance and a real, navigable invoice document at the same time.

Three approaches were tested and failed:

ApproachResult
Send exact lines with the document type"The entry is not balanced"
Create as entry, then rewrite the typeSame error
Omit the counterpart, let Odoo generate itInvoice of 4,060 became 560

A fourth — deriving price_unit from the balance so the subtotal reconstructs exactly — passed a 20-record pilot with maximum deviation of 0.0028. At full scale it inflated two distinct groups: 114 foreign-currency invoices, each off by almost exactly that day's exchange rate (16.6×–21.96×), and 97 domestic invoices, off by factors consistent with a discarded 10% withholding tax (modal ratio 1.11 = 1/(1−10%)).

Why the pilot lied

A later invoice-by-invoice audit found 512 of 723 exact and 211 deviated, a combined overstatement of +1,649,635.72, splitting cleanly between the two causes above. Neither pattern existed in the twenty-record pilot, which happened to be entirely domestic-currency and withholding-free.

A sample that does not span the failure dimensions will pass regardless of the defect.

The honest conclusion at the time: this was a design decision that should have been surfaced as a trade-off at the outset, not treated as solved. It was caught because someone looked at an empty Invoices screen and asked why — not because the agent flagged it first. What happened after that is in section 10.

7. The migration pipeline that worked

Flowchart of the four-phase migration pipeline that worked: Phase 0 rescue extraction and attachment rehydration, Phase 1 foundation restore and configuration matching, Phase 2 loading master data, and Phase 3 loading and verifying transactions.
View Mermaid source
flowchart TD
    subgraph P0["Phase 0 — Rescue (irreversible window)"]
        R1["Download full backup"]
        R2["Export masters via API"]
        R3["Rehydrate 5,069 attachments
from checksum-named filestore"] R4["SHA-1 verify every file"] end subgraph P1["Phase 1 — Foundation"] F1["Restore dump to plain PostgreSQL"] F2["Install 27 OCA modules"] F3["Match currency precision"] end subgraph P2["Phase 2 — Masters"] M1["2,968 exchange rates"] M2["272 accounts · 33 journals"] M3["44 taxes · 849 partners"] M4["Fix 14 tax repartition lines"] end subgraph P3["Phase 3 — Transactions"] T1["Export history from SQL"] T2["Load 3,325 entries"] T3["Verify balance per account"] end P0 --> P1 --> P2 --> P3 style P0 fill:#7c2d12,color:#fff style P3 fill:#78350f,color:#fff
The pipeline that ultimately worked, front-loading irreversible extraction before any transformation.

Phase 0 deserves emphasis. When a subscription expires, the only truly irreversible risk is losing access. Everything else can be redone. The agent front-loaded all extraction before any transformation, and verified the extraction cryptographically.

A pleasant discovery: rather than downloading 5,069 attachments through 5,069 individual API calls, the backup's filestore — 4,170 physical files organised by SHA-1 checksum with no filenames, some shared across multiple attachment records — could be cross-referenced against the exported attachment index to reconstruct a human-readable tree by hard link. Minutes instead of half an hour, and every file hash-verified afterwards.

8. What verification actually looked like

Diagram of an escalating verification ladder from weakest to strongest evidence: record counts, field-level spot checks, aggregate totals, per-account debit and credit comparison, and cryptographic checksums.
View Mermaid source
graph BT
    L1["Record counts
weakest"] --> L2["Field-level spot checks"] L2 --> L3["Aggregate totals"] L3 --> L4["Per-account debit and credit"] L4 --> L5["Cryptographic checksums
strongest"] style L1 fill:#7c2d12,color:#fff style L5 fill:#14532d,color:#fff
The hierarchy of evidence used, from the weakest signal (record counts) to the strongest (cryptographic checksums).

The canonical test for the accounting load was never "how many records exist". It was: for each of 78 accounts, does debit and credit in the target equal debit and credit in the source, to within half a cent?

At the point where that test first passed, it read:

accounts compared: 78 | with difference: 0
source  debit=37,239,905.88  credit=37,239,905.88
target  debit=37,239,905.88  credit=37,239,905.88
difference debit=0.00  credit=0.00

A count-based check would have reported success at several points where money did not match. This was still not the end of the story — see section 10.

9. Observations on Opus 5 behaviour

We were asked to compare against earlier work with Opus 4.8. We do not have access to those session transcripts, so a metric-level comparison would be fabrication. What follows is limited to behaviours observable in this session, described as such.

Five patterns stood out:

Premise verification over task execution. The brief specified three tasks in order. Reordering them — checking the target's actual state first — revealed that roughly half the anticipated work was already done, and that the central assumption was false.

Source-code-level grounding. Rather than asserting from training data that SaaS dumps cannot restore on stable, the agent located and quoted the specific comparison in Odoo's db.py. This matters because the claim was counter-intuitive and expensive to act on.

Adversarial stance toward its own subagents. When a synthesis arrived with a disclaimer that its inputs were empty, the orchestrator inspected the run journal rather than accepting the output. When a subagent recommended a rounding workaround, the orchestrator checked the underlying currency configuration and found the recommendation unnecessary.

Self-correction in the open. The agent contradicted its own earlier conclusions more than once when new evidence arrived, including labelling one of its own conclusions premature after printing it. It also identified a sign error in its own code from the shape of the discrepancy alone: an imbalance of exactly 1,541,480.16 was recognised as 2 × 770,740.08, which localised the bug immediately.

Escalation over circumvention. Several writes were blocked by permission controls during the session — including one attempt to grant the agent's own account broader permissions. In each case the agent stopped, explained what it was attempting and why, and handed the decision back rather than routing around the control. The permission-escalation block is the notable one: an agent widening its own permissions is exactly the pattern such controls exist to prevent, and it said so.

Where it fell short

Honesty requires the other column:

FailureConsequence
Accepted a subagent's design recommendation without surfacing the trade-off723 invoices loaded as generic entries; caught by a human looking at an empty screen, not by the agent
Emitted a "verdict" line before checking what the operation actually returnedPrinted a success conclusion the evidence did not support; corrected in the next message
Validated the hybrid approach on a non-representative pilot20 domestic, withholding-free records passed; 211 of 723 failed at scale

The third is the most instructive. A pilot that does not deliberately span the dimensions along which a system can fail — currency, tax treatment, document type — is not a pilot. It is a coincidence.

10. How it actually ended

The work did not stop at the checkpoint in section 8. Authorized explicitly to bring in real invoices, payments and reconciliations, the same pipeline went further over the following day:

ComponentResult
Real invoices638 of 723 loaded as actual invoice/refund documents — navigable, with payment status. 85 stayed as exact journal entries, each for a documented reason (manual tax adjustments, manually-entered exchange rates)
Real payments825 as payment records carrying their original folio; 39 as entries (write-offs, manual FX)
Publication3,325 of 3,325 entries posted; zero drafts remaining
Reconciliation2,235 partial reconciliations replayed in historical order; zero spurious exchange-difference entries generated
Cancelled entries848 of 848 recreated and cancelled, matching the source
Analytic distribution2,555 of 2,591 lines with exact distribution restored
Chatter19,044 of 19,044 messages, with their original historical date and author

The final balance test read:

accounts compared: 78 | with difference: 17
source  debit=37,239,905.88  credit=37,239,905.88
target  debit=37,239,905.79  credit=37,239,905.79
difference: −$0.09 globally, spread across 17 accounts as 1–4 cent residues
folios 3,273 → 3,273 · 0 missing · 0 unexpected

Not the exact-to-the-cent result the earlier checkpoint reported — and a more honest outcome for it. The residue traces to per-line rounding across 638 recalculated invoices. Rather than quietly absorbing it, this surfaced a real business decision: the cents did not matter; what mattered was that every account matched the source's structure and category exactly — including restoring two account types to match the source rather than a stricter default the target framework would have preferred, pending accountant sign-off.

Two findings from this phase were expensive to learn:

Diagram of two expensive findings from the final migration phase: payment journal entries are only created on posting, requiring a create-post-draft-rename-repost sequence to preserve original folios, and foreign exchange difference entries are generated per partial reconciliation, requiring pair-by-pair replay to avoid duplicating historical differences.
View Mermaid source
graph TD
    A["Payments: the journal entry
is born on POST, not on create"] --> A1["Migrating with original folios needs:
create → post → draft → rename → re-post"] B["FX difference entries are generated
per partial reconciliation"] --> B1["Any replay duplicates historical FX.
Fix: pair-by-pair with
no_exchange_difference"] style A fill:#1e3a5f,color:#fff style B fill:#1e3a5f,color:#fff
Two findings that cost real debugging time: how Odoo creates payment journal entries, and how it generates foreign exchange differences on reconciliation.

The reconciliation finding cost two separate debugging rounds — roughly $40.6k and then $52.8k of phantom exchange differences — before the mechanism became clear. Both were caught by balance comparison, not by any error message.

What remains open, honestly: a second legal entity (74 entries, its own sales, expenses and payments) is documented technical debt, deferred by explicit decision. Bank statement import is blocked purely on infrastructure access — the SQL to apply it is written and reviewed, waiting on a credential, not a technical unknown.

11. Transferable lessons

Flowchart of seven transferable lessons: verify the premise before executing the brief, front-load irreversible work, treat SQL as ground truth, match precision before loading, compare money never record counts, parallelize research but serialize writes, and surface trade-offs as decisions.
View Mermaid source
flowchart TD
    L["Lessons"] --> A["Verify the premise
before executing the brief"] L --> B["Front-load the
irreversible work"] L --> C["SQL is ground truth;
the ORM is a view"] L --> D["Match precision before
loading, never after"] L --> E["Compare money,
never record counts"] L --> F["Parallelise research,
serialise writes"] L --> G["Surface trade-offs
as decisions"] style L fill:#1e3a5f,color:#fff style G fill:#78350f,color:#fff
The seven lessons from this migration that generalize to any accounting data migration.

1. The framework's API hides what SQL shows. Archived records, company-scoped columns, and sub-cent precision were all invisible through the ORM and obvious in SQL. For any migration of consequence, restore the dump and query it.

2. "No error" is not "no data loss." Two of the five failure modes in this session completed successfully while destroying data. Integrity controls that compare against an independent source are not optional.

3. Match the target's configuration to the source before loading, not after. Currency precision, rounding method and account types all belong to the preparation phase. Discovering a mismatch after loading costs a full reload.

4. Verify effects, not artifacts. A module marked installed, a script that exits zero, a row count that matches — none of these are evidence that the accounting is correct.

5. Trade-offs are the client's to make. The single largest error in this engagement was treating an architectural trade-off — exact balances versus navigable invoices — as a technical detail to be resolved rather than a business decision to be surfaced. It got resolved correctly in the end, but only because someone asked why the Invoices screen was empty.

What this means if you are considering the same move

The licence saving was never the hard part. Here is the honest accounting of what a migration like this actually costs:

CostReality
The extractionFast, and the only irreversible risk. Front-load it.
The master dataMostly mechanical. Accounts, journals, taxes, partners.
The ledgerWhere the danger lives. Five silent failure modes, all recoverable if you verify against an independent source.
The Enterprise featuresThe real loss. E-invoicing, OCR, bank sync, spreadsheets — no drop-in equivalents. Budget for process change, not just software.
OngoingYou now own the hosting, the backups and the upgrades.

The migration is technically achievable. Whether it is worth it depends almost entirely on how much of the Enterprise feature set you actually use — and that is a question about your operations, not your infrastructure.

If the answer is "we use two of the forty modules we pay for," the arithmetic favours leaving. If your invoicing is legally bound to a certified e-invoicing stack that only exists in Enterprise, it does not.

Closing

We publish the failures alongside the result deliberately. A field study that reports only the parts that worked teaches nothing about what to watch for at 2 a.m. with a subscription clock running.

The single most valuable habit in this entire engagement was refusing to accept "no error" as evidence of "no data loss." Two operations completed successfully while destroying data. Both were caught by the same discipline: compare money to money, against a source the migration tooling cannot influence.

Prepared by Transgenia, migrating our own books. Every figure here is real and unredacted. If you are weighing a similar migration and want the failure modes checked against your own stack, that conversation is one we are glad to have.

← Back to Blog