Leverandørvakt watches Norwegian companies through open data from the Brønnøysund Register Centre (Brreg) and warns its users when a supplier or customer shows signs of trouble: a new CEO, an auditor leaving, late or weak annual accounts, bankruptcy. On paper that is "call an API and compare". In practice almost every feature hit a problem that only showed up once real data, real failures or a real deadline came into play. These notes collect the ones I learned the most from: what went wrong, why, and how I solved it.
The backend is Express 5 and TypeScript, with PostgreSQL, Sequelize and Umzug migrations. It has 298 automated tests, runs in Docker, and is checked by GitHub Actions on every push.
1. Watching companies without asking about every company
Problem. The naive approach is to fetch every followed company from Brreg every few minutes and compare. With many users and companies that becomes thousands of requests for nothing, since most companies never change.
Solution. Brreg publishes two update feeds: one for company data and one for roles (CEO, board, auditor). Every ten minutes the app reads both feeds from where it stopped last time, and only looks up the followed companies that actually appear in them. Each feed has its own stored position (a cursor).
The hard part was failure. If Brreg fails halfway through a run, the position must not move past the companies that were not checked, or their changes are lost forever. If it moves too little, changes are recorded twice. The rule I ended up with: stop at the first company that fails, and set each feed's position to just before the oldest event of that company. Everything after it is read again on the next run, and nothing before it is repeated.
A bug the tests found. My first version processed the company feed first and the role feed second. A test with two companies showed that an older role change could end up behind a newer company update. Companies are now processed in the order their oldest event happened, across both feeds.
2. A first look is not a change
Problem. When a user follows a company, or when the app starts tracking something new (roles, then annual accounts, then risk levels), there is no earlier state to compare with. Treating "nothing" to "something" as a change would send every user a burst of meaningless notifications.
Solution. The first state is always stored as the starting point, and only later differences are reported. This rule came back in every feature, and it also needed a second half: companies followed before a feature existed have no starting point at all. Each feature therefore has a small backfill that fills in the missing state quietly (roles, accounts, risk levels) instead of waiting weeks for Brreg to publish an event for that company.
3. An API that answers in XML, uses 500 for "not supported", and may disappear
The Register of Company Accounts API was full of surprises, and each one became a design rule:
- It answers in XML by default. A browser got XML; the app must send
Accept: application/json. A test checks it, so removing the header fails the build. - 404 means "no accounts yet", not "no company". So the app first checks that the company exists in the main register, then asks for its accounts.
- Banks get a 500 error. The message says the accounts format "is not supported". Treating every 500 as an outage would retry thousands of banks forever. The app reads the message and marks such companies as
unsupported: a permanent state, not an error. - Not every company reports in NOK. Equinor reports in USD. Figures are never converted, and the currency always travels with them.
- Field names contain typos (
regnkapsprinsipper,sumInnskuttEgenkaptial). They stay inside one mapper and never reach the rest of the app. - Brreg marks the API as temporary. So accounts live in their own table, are checked by their own daily job, and a failure there never stops following a company or the change detection.
Corrected filings. A company can file corrected accounts for the same year, and they get a new Brreg id. Keying stored accounts on that id would have shown one year twice. They are keyed on company, type and period instead, so a correction replaces the row and is recorded as "corrected".
4. Warn about changes for the worse, not about known states
Problem. A small company that has legally opted out of audit would trigger "not audited" every single year. A company that already had negative equity would trigger the same warning again and again. That turns warnings into noise.
Solution. Warnings fire on transitions: audited last year and not audited now, equity positive last year and negative now. When a company files two years at once, they are compared year by year.
5. A risk score that explains itself
Problem. A single number ("risk: 35") invites the question "why?", and a low score can hide the fact that half of the data was missing.
Solution. The assessment returns every signal with its points and a sentence explaining it, plus a list of limitations: no key figures because the company is a bank, no change history because the company is not followed, or history known only since last week. "Low risk" and "low risk, but we could not see the accounts" are no longer the same answer. All weights and thresholds sit in one object, and the rules are a pure function of the data and the date.
Time as an input. "Annual accounts overdue" depends on the calendar: accounts for a year are due by 31 July of the next year. Passing the date into the function makes every rule testable on a fixed day. It also exposed a hidden time bomb: the fake test companies were "up to date" with a hard-coded year, so in August 2027 many unrelated tests would have started failing on their own. The fake data now follows the date the tests run on.
One piece of news, one notification. When a company goes bankrupt, both "bankruptcy" and "risk rose to high" are recorded. Notifying both would tell the user the same thing twice. A rise in risk is only notified when the same check found no other news. That is exactly the case that matters most: nothing changed in the register, but 31 July passed without accounts.
6. Finding shared people without storing personal data
Problem. "Do the people behind this new supplier also run my current ones?" is a strong early-warning question. But names are not unique: two different people called Ole Hansen would show up as a false connection. Telling them apart needs a birth date, and storing birth dates is exactly what I did not want to do.
Solution. Connections are computed on request. The current roles of the user's companies are fetched live, at most five requests at a time, and people are matched in memory on name and birth date. The answer only contains names and roles. Birth dates are never stored or returned, and a test checks that they never appear in the response. People without a birth date in the register are not matched at all, and the answer says how many there were. Only the user's own companies are compared, so nobody can learn what other users follow.
7. An AI assistant that cannot be talked into anything
The assistant answers questions like "Which of my suppliers have the highest risk?" with tool calling over the app's own service functions. The interesting problems were about trust and cost:
- It only reads. It has no tool that changes data. Following, notes and notifications are changed by the user, not by the model, so there is no "the model did it without asking" problem.
- The user's identity never comes from the model. Tools receive the user from the login token. There is no parameter the model could use to read someone else's data, and the user's private notes are left out of every tool result.
- Bad tool input is returned to the model as an error, so it can correct itself (for example an organization number written with spaces) instead of failing the question. The loop stops after five model calls.
- Provider-independent. One interface with Ollama (local or Ollama Cloud) and Gemini behind it, chosen in
.env. Development runs free on a local model. Gemini 3 requires its "thought signatures" to be sent back unchanged with tool calls, so each provider keeps its own copy of the model's reply. - A daily question limit that cannot be reset. I first counted questions from the stored messages, then noticed that deleting a conversation would reset the limit. Usage now lives in its own table that holds no content and survives deletion.
- Testing without a model. CI uses a fake Ollama server that speaks Ollama's real format and replays scripted answers. No real model is ever called in a test.
Running a real model locally taught its own lessons. The 17 GB model only half fit into the GPU's 12 GB, which explained the slow answers. Its context window was 4,096 tokens, so tool results have to stay short. The model also answered an English question in Norwegian, because the data was Norwegian, and it guessed what "ASA" stands for, wrongly. The fixes were a clearer language rule and giving the model the name of the organization form instead of only the code. The lesson: when a model finds a gap, it fills it.
8. All tests passed, and the app did not start
Problem. After adding the assistant, all tests were green, but the real app crashed on startup with Cannot access 'AssistantConversation' before initialization.
Why. Two models imported each other (HasMany and BelongsTo). With decorator metadata enabled, the TypeScript compiler emits code that uses the class while the file is still loading, which fails in a circular import. Vitest runs the source with its own fast compiler, which does not emit that metadata, so the tests ran different code than production.
Solution. Removing the unused side of the relation fixed the crash. The more important fix closes the gap: CI now loads the code compiled by tsc, and a separate Docker job builds the production image, starts the whole stack with Docker Compose and checks that the API answers. Errors that only exist in the real build can no longer pass.
9. Trends that only show what the data can honestly say
Problem. "How many companies were founded in Drammen this year, and in which industries?" Brreg's search cannot count by industry, only pages through the first 10,000 results, has no date for bankruptcies, and hides struck-off companies.
Solution. Monthly counts use one request each, reading only the total and downloading no companies. Industry breakdowns download the companies month by month and refuse periods above 5,000 registrations with an explanation instead of running for minutes. Bankruptcies over time and company survival are left out rather than estimated. Past months hardly change, so answers are cached: 30 days for past months, 6 hours for the current one.
Real data found what the tests did not. All tests passed, and then the first real query for Drammen showed three problems:
- November had 51 registrations against 126 a year earlier. But this November was only 11 days old. Comparing part of a month with a whole month made a 13 % increase look like a 60 % drop. The current month is now compared with the same days a year earlier.
- "Unknown industry" appeared twice. Brreg uses the placeholder code
00.000 Uoppgittas well as leaving the field empty. Both are now counted apart from the real industries. - The parts added up to one more than the total, because the total and the breakdown came from two caches filled an hour apart. The breakdown's total is now counted from the same data as its parts.
Fine-grained industry codes also scattered the picture: the top ten covered only a third of new companies. Grouping by main industry, with official names from Statistics Norway's classification service, made the top ten cover half of them, and put IT in one row instead of two.
10. How I make sure the tests actually test something
- A real PostgreSQL database in tests, not mocks, so migrations, transactions and constraints are tested the way they run.
- A fake Brreg behind
fetchthat behaves like the real one: XML without the right header, 404 without accounts, 500 for banks, paging limits, and errors for dates that do not exist. - Breaking the rules on purpose. After writing the tests for a feature, I break each important rule in the code (match people on name only, notify every risk rise, skip the 31 July deadline) and check that a test fails. Several times a test passed when it should not have, and that pointed at a gap in the test or in the fake.
- Fictional test data. The repository is public, so no real person's data is used in fixtures.
What I take with me
- External APIs are part of the design, not a detail. Their status codes, defaults and limits decided how several features had to work.
- "A first look is not a change" and "warn on transitions, not states" decide whether an alert system is useful or noise.
- Being honest about missing data (limitations,
unsupported, numbers that are not shown) is a feature, not a weakness. - Green tests are a starting point. Real data and the real production build each found bugs the tests could not see, and each of those bugs now has a test of its own.