The Ledger as a Component

We moved our books off Fortnox and onto a self-hosted open-source accounting system. The migration was the easy half.

We moved our books off Fortnox and onto a self-hosted install of an open-source accounting system. The migration was the easy half. The interesting half was what it did to the way we build everything around it.

Why we left, and why it was not about features

Fortnox did what it says it does. Our bookkeeping worked. The invoices went out, the VAT returns were filed, nobody was firefighting. If you are looking for a story about a system that failed us, this is not it.

The problem was structural. HDL Group bills for consulting time, and that time is tracked, approved and priced inside our own platform. Every other part of that chain is something we own: the time entries, the approval flow, the client record, the rate card. Finance was the one link we could only reach through somebody else’s API, on their schedule, with the surface they chose to expose.

That is a perfectly reasonable arrangement for most companies. It stops being reasonable when the integration you need does not exist, and the answer is a support ticket and a wait.

We did not want a better accounting product. We wanted the ledger to be a component of our stack rather than a destination we exported to.

So we moved to Accounted, an open-source Swedish bookkeeping system under AGPL, running on our own server. Same double-entry engine, same statutory triggers, same BAS chart. The difference is that when it lacks something we need, adding it is an afternoon rather than a roadmap request.

What actually moved

Swedish accounting has a lucky property: there is a standard interchange format, SIE, and every serious system exports it. The ledger itself, the part that must reconcile, moved as a file. Everything that SIE does not carry moved over the Fortnox API.

DataRouteNote
General ledgerSIE4 importVerifications, opening balances, the chart of accounts. The part that has to tie out.
Supplier invoicesFortnox APIThe full register, including settled ones. Needed so vouchers have something to link to.
Fixed assetsFortnox APIAcquisition values, useful lives, accumulated depreciation. Continues forward monthly.
CustomersFortnox API51 rows. Names, org numbers, addresses, payment terms.
DocumentsManual uploadSeven years of receipts. Boring, unautomatable, done once.

Two defaults we had to delete

Both were in our own importer, not in Fortnox, and both were the same mistake wearing different clothes.

The SIE importer had a rolling three-year window, inherited from an earlier provider integration with no reason recorded anywhere. It quietly decided that a company migrating its books could not bring anything older than two years back, against a law that expects seven to be archived and available. It also left imported supplier invoices with no vouchers to link to.

The supplier invoice importer fetched only unpaid invoices. That had been added to stop settled invoices from arriving as apparent open liabilities, which was a real symptom with a different cause. The effect was that most of the register never came across.

The rule we settled on: a missing row is recoverable by re-running the import. An invented debt reads as real to anyone looking at the ledger. When those two risks are in tension, import everything and fix the interpretation.

The asset register taught us to reconcile first

The asset import ran clean and produced wrong numbers, which is the worst kind of clean. Two bugs, both of the sort you only find by checking totals against the ledger rather than by reading code.

The first: assets landed on the wrong BAS accounts because the importer matched the asset type by numeric id, while the source system reports that type as a text label in its list endpoint. Every lookup missed and fell through to a default.

The second: useful lives came out wrong because the arithmetic worked in day fractions where the source counts whole months.

Neither showed up as an error. Both showed up as a register that did not tie to the ledger accounts it was supposed to explain. Reconcile before you trust an import, and reconcile against the accounts, not against the row count.

When the API is missing something, you add the endpoint

This is the part that changed how we work, and it is hard to appreciate until it happens the first time.

Our platform needed the seller identity to render an invoice: legal name as printed on documents, organisation number, VAT registration number, address, bankgiro, the F-skatt flag. The accounting system held every one of those fields. Its public API exposed almost none of them for reading. There was a write endpoint for payment details and no read endpoint at all.

Under the old arrangement that is where the thread ends. You file a request, you hand-type the values into your own system, and you accept that a transcription error in an organisation number will produce a non-compliant invoice that nobody notices for months.

Instead we wrote the endpoint. A read route, scoped to a read-only permission, returning the seller block. Written, tested, reviewed, deployed the same afternoon. It is a small piece of work and that is exactly the point: the size of the fix finally matched the size of the problem.

Three in one week: a route to attach an externally rendered PDF to an invoice, a read route for company settings, and a security fix to the invoice preview endpoint, which validated nothing and would render an unstamped, correctly numbered invoice for arbitrary content. The last one had nothing to do with our integration. We found it because we were reading the code we depend on.

That last one is worth sitting with. Running open source you host yourself means the security surface is yours to read and yours to fix, and it means you can. The same finding against a closed vendor is a report you send and then wait on.

Who owns the document

The integration we actually wanted is unusual, and it forced a question we would never have had to answer as an ordinary API consumer.

Our platform creates the invoice, numbers it, renders the PDF and sends it to the client. The accounting system holds the ledger and the archive. That split is fine right up until you notice that both systems can now produce a PDF of the same invoice.

Swedish bookkeeping law requires the accounting record to be preserved for seven years in legible form and producible as a faithful reproduction of the document. Faithful is not a property two different documents can share. If our platform renders one invoice and the accounting system can render another from the same row, we do not have an archive, we have two claims.

What we built:

  • An origin marker on the invoice. Set only by an explicit field on the create request, never inferred from the fact that an API key made the call. Fixed at creation and immutable afterwards, including for the integration itself.
  • A link to the uploaded PDF that is the invoice. Written once, never replaced, and the file cannot be deleted while it is linked.
  • A read-only lock. Database triggers that freeze such an invoice against the accounting system’s own interface, so the two writers cannot fight over it.

The boundary is in the database, not the application

The lock has to distinguish our platform from a person clicking in the accounting UI. That distinction already existed and cost nothing to use: a browser session arrives with an authenticated user, while an API-key request runs as a service role. One is a human at a keyboard, the other is the system that owns the document.

Two things stay deliberately open. Payment state, because registering a customer payment is the accounting system’s work on every surface and a bank reconciliation that cannot settle an invoice is a worse failure than the one we were preventing. And the journal entry link, because whoever wrote the document, the books are still ours.

They own the document. We own the ledger. Everything else followed from writing that sentence down.

The bugs moved somewhere new

Owning both ends does not remove integration failures. It relocates them, and the new ones are quieter than the old ones.

Pointing at the wrong instance

Our platform spent an afternoon getting a 405 from an endpoint we had just deployed and verified. The endpoint was live. The platform was calling the vendor’s hosted service rather than our own server, because the connector shipped the hosted URL as its default and nobody overrode it. The hosted service answers, returns plausible errors, and reports itself healthy. Nothing about the failure said “wrong host”.

With a closed SaaS there is one address and this class of bug does not exist. Self-hosting creates it. Put the base URL somewhere it has to be set rather than defaulted.

Two columns holding the same fact

A company’s name lives in two places in the schema: on the company record and in company settings. Nothing keeps them equal, and the invoice prints the second one. An integration seeding itself from the first would put a subtly wrong legal name on a statutory document, and every check would pass.

Defaults read as decisions

This is the one worth taking away, because it generalises past accounting entirely.

Two booleans govern how an invoice is presented: whether the company is VAT registered, and whether it is approved for F-tax. In the database one defaults to false and the other to true. Inside a bookkeeping app that is unobjectionable, because a person walks through onboarding and sees the toggles. The value means “not chosen yet” and the interface makes that obvious.

The moment another system reads those columns, “not chosen yet” becomes “decided”. A VAT-registered company that never opened the setting reads as not registered, and an invoice renders without its VAT number. A company that never touched the other flag reads as approved for F-tax, and we print a statutory claim about its tax status on the strength of a default nobody chose.

What we do now: neither flag is copied across. Both are asked once, unanswered, on the platform side. Every boolean crossing between two systems gets the same question first: could this value be here because nobody chose it?

What this costs

Self-hosting your accounting system is not free, and the costs are not the ones people expect. The software is fine. The server is cheap. What you take on is everything a vendor was quietly doing.

You own the deploy. Ours streams a built image straight to the host over SSH, and stops before the build if there are unapplied migrations, because it will not run schema changes against a production ledger on its own. You own the migrations, which means someone decides when the books’ schema changes and takes a dump first. You own the backups, and you find out whether you own them by restoring one. You own upgrades from upstream, and every local change you keep is a rebase you will do again.

You also own the security surface, which cuts both ways. We found a real vulnerability in the invoice preview path because we were reading the code. Nobody would have found it for us.

The honest test is whether finance is part of your product surface or beside it. If invoices are something the office does at month end, keep the SaaS and enjoy your weekends. If invoicing is a step in a workflow your own software runs, the ledger is already a component of your stack, and treating it as one stops being an indulgence.

Where this actually stands

Blog posts tend to end at the launch. This one ends mid-build, because that is where we are.

The books are live in Accounted and have been for weeks. Ledger, supplier invoices, fixed assets with depreciation running forward monthly, VAT returns. That part is finished and unremarkable, which is the highest praise you can give a bookkeeping migration.

The integration is half built. The accounting side is deployed: the origin marker, the document link, the read-only lock, the settings read, the routes our platform calls. The platform side has the storage, the mapping and the tests, and does not yet have the renderer. Until it does, Fortnox is still the only thing here that puts an invoice in front of a client.

We are not in a hurry about that. The ledger moved, which was the part with a deadline. The rest is ordinary software, on our side of the line, where we can reach it.


Accounted is open source under AGPL-3.0. The integration described here is specific to how we bill and stays in our own fork; the fixes that were not, including the preview vulnerability, belong upstream.