Sultan Kautsar

Replacing a Paid CRM with ERPNext

The studio I work for ran its quotations and invoices on Perfex CRM. Perfex is paid software and has no accounting, so the finance team kept the journal by hand, outside the CRM.

I replaced it with a self-hosted ERPNext that holds the sales documents and the double-entry books in one system. This note covers why I chose ERPNext, how I moved eight years of clients, quotations, invoices, and payments and checked that the totals still matched, how the cutover went, what broke afterward, and what is still open.

10 min read

What Finance Needed

Finance confirmed early what they actually needed: invoicing, quotations, and accounting. For them, accounting means a chart of accounts, journal entries, the general ledger, a balance sheet and profit and loss, and an aging report for receivables.

The list of things I left out of the first version was longer:

  • Projects, tasks, tickets, contracts, a leads pipeline, and HR or payroll.
  • A payment gateway. Payments are recorded by hand, as they were before.
  • Multi-currency. Everything is in IDR.
  • Files uploaded to Perfex, and Perfex staff accounts. Users were created fresh with new passwords.
  • Tax invoice (e-Faktur) export, until the company’s VAT status is confirmed.

Why ERPNext

The requirements were free and open source, self-hosted, and nothing important behind a paid edition. I compared four options:

  • Odoo Community has invoicing, but the financial reports are missing from the free edition.
  • Invoice Ninja has quotes and invoices but no accounting.
  • Dolibarr is lighter, and its accounting is more basic.
  • ERPNext, built on the Frappe framework under GPL-3.0, has quotations, invoices, and full double-entry accounting with nothing held back.

The cost of ERPNext is its size. It ships manufacturing, stock, buying, assets, and more, and the desk shows all of it by default. A fair part of the work was hiding what the studio does not use.

How It’s Built

The stack is Docker Compose, based on the official frappe_docker setup: ERPNext v16 pinned to an exact version, MariaDB, two Redis instances, NGINX, and the background workers and scheduler. Locally it runs on 127.0.0.1:8080 and idles at about 0.6 GB of memory.

One rule shaped everything else: configuration before code. Custom fields, print formats, module profiles, and settings come first. When something needs code, it goes into one small custom Frappe app that is built into the image on top of the official one. I never edit the frappe or erpnext apps, so an upgrade means changing the pinned version, testing it locally, and running the migration.

The custom app does two things:

  • Quotation to invoice without a sales order. ERPNext expects a quotation to become a sales order and then an invoice. The studio skips the middle step, so submitting an invoice made from a quotation marks that quotation Ordered, and cancelling its last invoice sets it back to Open.
  • Branding. The app name, logo, favicon, button color, and email footer are reapplied on every migration, so updates do not reset them.

The rest is configuration. The quotation and invoice PDFs use my own Jinja print formats with the company’s letter head, because ERPNext’s standard quotation formats rendered broken in wkhtmltopdf. Finance has a module profile that hides Buying, Stock, Manufacturing, Projects, and the other modules they do not need. That profile also fixed real errors: without it, workspace cards for modules finance can’t read threw permission errors on the home screen.

I built the system with a coding agent, as in my AI workflow notes. A status file in the repository holds the current state, the open items, and one log line per working session, so every session starts from the same page. Every write on the production server needed the exact commands shown first and my explicit OK.

Moving Eight Years of Data

Perfex held invoices from October 2018 onward. The importer is a small Python tool that reads a local copy of the Perfex database and writes to ERPNext through its REST API, in four commands: select, setup, run, and reconcile. Three rules held for the whole migration.

The dump is untrusted
I took the database dump read-only, restored it into a local container, and the importer never connected to a production database. Before importing, I checked that no staff account had been created since 2023, that every recent record came from one known staff user during business hours with sequential numbers, and that no text field held PHP or script payloads. Finance confirmed the recent records were real work.

Every record keeps its old ID
Each customer, contact, address, quotation, invoice, and payment gets a custom_perfex_id field, and a re-run skips anything that already has one. That made the import safe to stop. On the production server I stopped it partway through the invoices and resumed it later, and nothing was created twice.

Totals must match
reconcile compares each client in both systems: number of invoices, total invoiced, total paid, and number and total of quotations. It exits with an error on any mismatch. The final run matched 211 of 211 clients and 974 of 974 old proposals.

The mapping needed more decisions than I expected:

  • Every Perfex line becomes a generic non-stock service item with the original description.
  • Taxes become fixed rows that add up to the exact Perfex tax total. Some clients withhold a 2% income tax (PPh 23), which Perfex stored as a negative tax. It posts to a prepaid tax account and reduces the receivable.
  • Perfex’s manual “adjustment” amount goes to the round-off account.
  • Migrated documents get their own series without a year, such as PFX-SINV-#####, because Frappe fills the year in a name from today’s date, not the document’s date. An invoice from 2019 would otherwise be named 2026. The PDF prints the old Perfex number.
  • Lines with a quantity of zero become one unit at zero, with the original values in the description. I also turned off ERPNext’s automatic item prices, which otherwise saved the first rate it saw and filled it into later zero-rate lines.
  • Fiscal years 2018 and 2019 exist only so old quotations can be saved at all.
  • Inactive clients are imported disabled. The importer enables each one while its documents load and disables it again afterward.
  • One client existed twice in Perfex, so an alias maps the duplicate’s documents to the original.
  • Old proposals sent to leads came across too, as 769 leads with 974 quotations, because finance needs the history. They are kept as history only, with no sales pipeline built around them.

In the end ERPNext held 211 customers, 280 quotations, about 1,700 invoices, and 784 payments, and the imported invoices and payments posted their ledger entries automatically.

  1. Read-only dump Taken from the old CRM database with approval
  2. Local MariaDB Restored and checked; production is never queried
  3. Importer Skips every record that already has its old ID
  4. ERPNext Invoices and payments post to the ledger
  5. Reconcile per client
    • Everything matches Ready for cutover
    • A client differs Non-zero exit; fix and run again
The import path, run locally first and then against the production server.

Deploying Next to the Old System

There is no CI/CD and no registry. I build the image on my machine from a clean commit and send it over SSH with docker save and docker load. The deploy script prints every remote command and waits for yes before it runs anything, and it refuses to run from a dirty working tree, so the image on the server always matches a commit.

The server was the one already running Perfex, shared with about 17 other services: 3.8 GB of memory with no swap, two vCPUs, and a disk at 84%. Before installing, I added 2 GB of swap and pruned the Docker build cache, which brought the disk down to 74%.

Routing took one decision. Traefik on that server runs in Swarm mode and reads labels from Swarm services, but the ERPNext stack is plain Compose, because its one-shot setup jobs do not translate cleanly to Swarm. So the ERPNext frontend joins Traefik’s network under an alias, and one small file in Traefik’s dynamic configuration folder routes the hostname to it. Traefik picks the file up without a restart.

ERPNext first ran side by side with Perfex on a local port, reached through an SSH tunnel. I imported and reconciled there. Finance preferred to test on the real address, so their testing happened after the cutover, with the rollback ready.

The cutover order was: take a final dump, compare it with the one already imported (it was identical), reconcile, stop the Perfex stack, and add the route file. Perfex’s directory and database stay on the server as an archive. I wrote the rollback down before the switch: delete the route file, redeploy the old stack, and restore its cron line. The project started on November 3, and ERPNext took over the CRM’s address on the evening of November 4.

Backups run every night at 03:00 local time through a cron job that runs ERPNext’s backup with files and keeps the newest seven sets. The server’s existing weekly offsite job already archives that folder to S3, so the offsite copy is at most a week old.

What Broke After the Cutover

Payments in the wrong account
On production, all 784 payments posted to a customer deposit account, which is a liability. The setup wizard there had not created the bank account, so the importer picked the first account of type Bank it found. reconcile passed because it compares amounts, not accounts. I found it while comparing the local copy with production after the cutover and fixed it in one transaction: create the bank account, make it the default, and move the payments and their ledger entries. The install steps now create the bank account before any import.

Permission errors in places nobody opened
Frappe shows every number card to System Managers, even for document types they can’t read, so the home screen threw an error for an “Annual Purchase” card. A read-only permission on Purchase Order fixed it. Later, finance got “No permission for List View Settings” when changing the columns of a list, which needed a custom permission for all desk users.

Old quotations could not become invoices
Every migrated open quotation was past its valid-till date, and ERPNext refuses to invoice those. A Selling Settings option lifts the limit. Then finance asked how to make an invoice from a quotation at all: in v16 the quotation form has no button for it, and my manual pointed at the wrong one. The working path is a new Sales Invoice, then Get Items From and Quotation. I fixed the manual and decided not to add a custom button.

Working with Finance

I wrote the finance manual in Indonesian, the language the team works in. It covers customers, quotations, invoices, payments with withheld tax, journal entries, reports, and how to correct a submitted document by cancelling and amending it.

Questions for finance went out as one list, grouped by what they block. When something couldn’t wait, I made a provisional choice, such as the chart of accounts template or the receivable account, and recorded it as needing their confirmation. Accounting changes don’t happen without that confirmation.

Before finance touched the system, I ran their main flow as their user in a script: a quotation, an invoice with withheld tax, a partial and then a full payment, and the effect on the general ledger, trial balance, balance sheet, profit and loss, and receivables. Then I rolled it back.

Limitations

  • Old receivables are still open. Perfex has complete payment records only from 2024. Many invoices from 2019 to 2023 were paid outside it, so ERPNext shows them as unpaid until finance says which ones really are and the rest are closed with one entry at December 31, 2023.
  • The chart of accounts is a placeholder. It is ERPNext’s standard Indonesian template until finance sends theirs, and the bank, receivable, and withheld tax accounts are provisional.
  • Finance’s manual journal is not imported yet. It needs a sample file, and only the non-sales entries will be imported, because invoices and payments already post their own entries and would otherwise count twice.
  • Recurring invoices are not set up. ERPNext can repeat them automatically once finance lists the clients, amounts, and cycles.
  • The offsite backup is weekly. A bad day could lose up to a week of work if the server itself were lost.

Lessons

Reconcile the amounts, then check where they landed
A reconcile that compares totals proves nothing about which accounts the money went to. The payments matched to the rupiah and were still on the wrong side of the balance sheet.

Treat your own data as input from outside
The dump came from a system the studio owns, and it still went into an isolated container and was checked before the importer read it.

Make the import safe to run twice
Keeping the old ID on every record meant I could stop the import, resume it, and re-run it after every fix without cleaning anything up.

Write the rollback before the cutover
The same idea runs through my deployment pipeline: the way back was three written steps before the switch, and the old system stayed intact until finance signs off.

Write the manual in the team’s language and test it with them
My manual named a button that does not exist in v16, and finance hit it the first time they needed it. A manual is untested until the people who use it have followed it.

The studio now has one place where a quotation becomes an invoice, a payment, and a ledger entry, and the old data is in it, checked client by client. The accounting history before 2024 still waits on finance’s decisions.

Related Work

Next step

Have a project in mind? Let's talk.

Tell me what you have and what you need. I usually reply within one working day with how I'd approach it.

Write to Me

Tell me about your project or idea. I usually reply within one working day.