When we tell other engineers that we build software for an accounting firm, they often look polite. Accounting sounds like a solved problem: some tables, some forms, and a report at the end of the year.
Then they see the actual work. The data is messy and comes from systems we do not control. The rules are exact, and they differ from one company to the next. The result must still be correct and explainable years from now.
Sanna is an accounting firm, and every customer has their own accountant. We build the software that those accountants and customers use every day. The easiest way to explain the engineering is to follow one payment through the four layers of the system.
Layer 1: making sense of the outside world
A payment starts its life in a bank. We fetch transactions every day through open banking providers, and each source has its own habits. Banks allow different date ranges. Fields can be present on one row and missing on the next. Even the ID of a transaction is not always stable, so the identity of a payment has to come from what happened: the amount, the date and the type.
Documents are harder. A receipt can be a clean PDF or a photo taken at an angle. We classify each file so that the right document can meet the right payment. The classifier also needs context that a person takes for granted. One invoice is income for the company that sent it and a cost for the company that received it, so the file must be read from the side of the company we work for.
Then there are the public systems. Norwegian companies deal with Altinn, the government’s portal, and with the tax administration, Skatteetaten. Each has its own authentication model and its own view of the same company.
The engineering question in this layer is simple to ask and hard to answer: how do you build something exact on top of data that is not?
Layer 2: proposing the bookkeeping
Next, somebody must decide what the payment was. Was it a bank fee, a dividend or a loan to a shareholder? The answer becomes a voucher, a balanced record that moves money between accounts.
AI agents propose these vouchers. They combine a language model, which is good at reading messy text, with ordinary code, which is good at being exact. The agents never write to the ledger.
This layer has a question that most software does not have: what does “correct” mean when two good accountants could book the same payment in slightly different ways?
Layer 3: working with the accountant
Our most demanding users are experts. An accountant goes through a long list of transactions and can see at once if something is wrong. They decide if a proposal is right, correct it if it is not, or ask the customer a question.
So this layer is a product problem as much as a technical one. Every screen has to help an expert make a good decision quickly, and show enough evidence to make that decision safe. When a rule is certain, code can do the work. When it is not, the accountant decides, and their decision always holds.
We also learn from each decision. When an accountant changes a proposal, that tells us something about the agents, the data or the product.
Layer 4: keeping the ledger true
At the end of the chain is the ledger, the record that a company’s reports and tax filings are built on. Here, a bug is not a broken page. It is a wrong number in a company’s accounts.
So we put the most important rules as low in the stack as we can. We keep them in Postgres, as constraints and triggers, and we test them directly with pgTAP. The database itself refuses to let an event link to more than one voucher, or to delete the lines that a source document created. It also records the history of every voucher line, so we can always answer the question “who changed this, and when?”
A ledger also has a long memory. Years close, reports get filed, and later changes must not quietly rewrite what was reported. Time is a real part of the data model here.
One domain layer for every caller
Our stack is SvelteKit, Supabase and Effect, but one principle matters more than the tools: the domain layer is the product.
Every piece of business logic must be callable without a browser. Rules do not live in a Svelte component. The web app, background jobs and our MCP server, which lets AI assistants use the same features, all go through the same domain code. Row-level security then limits what each of them can read. This keeps the system honest, because an agent gets the same rules as a person.
If you want to work on problems like these, see our open roles.