AI Budget Assistant and eKsięgowy AI (the Accounting An AI agent does more than answer: it plans the steps and uses tools on its own — querying systems, reading documents, then carrying the task out. Full definition → at eksiegowyai.pl) are two separate products. The first runs personal, family and business finances: you dictate an expense, photograph a receipt or let the app capture it from a bank notification. The second is an accounting agent for Polish companies, working on data from wFirma and KSeF is Poland's mandatory national system for issuing and receiving e-invoices directly with the tax authority. Full definition →. Users who have both asked for something that sounds simple: let the company expenses logged in AI Budget reach the accounting — and let the private ones stay private.

This article describes how we built it: our own OAuth server inside AI Budget, one connection covering several accounts, an accounting side that reconciles expenses against cost documents, and — in the most detail — what code reviews and security audits found before anything reached production. Every fix described here is already deployed.

Why a public API rather than a shortcut integration

The shortest path would have been two of our own servers, a shared secret and a private endpoint. We took the longer one. AI Budget got an open, free, documented API that any developer can use: you register an app yourself, with no manual review. eKsięgowy AI is simply its first consumer — treated as a first-party app (a verified badge, higher limits), but going through the same door as everyone else.

That decision set several others. The user connects accounts with a "Connect AI Budget" button in a standard OAuth flow. Phase 1 covers expenses only — incomes, budgets and goals come later. Instead of webhooks, the client polls a changes feed. Only an account's owners and editors can connect it. The documentation is in English, Polish and Russian.

The most important requirement, though, was a product one, not a technical one: accounting must see company spending strictly apart from private spending. The rest of the architecture follows from it.

Our own OAuth server inside AI Budget

The authorization server runs inside the AI Budget An API is the interface two systems use to exchange data automatically, with no Excel exports and no copying by hand. Full definition → (NestJS), with no third-party identity provider. We use the authorization code flow, and PKCE with the S256 method is mandatory for every client — public ones and confidential ones that hold a secret alike. The consent screen lives in the app at app.ai-budget.pl/authorize, with both a phone and a wide desktop layout.

Tokens are opaque and stored only as SHA-256 hashes — a database leak yields no working A token is a fragment of text, usually a piece of a word, and it is the unit AI models measure length and charge by — bills are counted in tokens, not in questions. Full definition →. They carry recognizable prefixes: aba_at_ for the access token, aba_rt_ for refresh, aba_cs_ for the client secret and aba_ci_ for the client id. A prefix lets secret scanners and people tell at a glance what has leaked. The access token lives one hour, the refresh token 90 days and is rotated on every use. If someone presents a refresh token that has already been spent, we treat it as a theft signal and revoke the whole connection.

Authorization codes sit in Redis and are consumed with GETDEL, fail-closed: a code can be exchanged exactly once, even when two requests race for it in the same millisecond.

Scopes are deny-by-default: expenses:read, expenses:write, receipts:read and categories:read. A route that declares no required scope is refused — a forgotten decorator means a denial, not an open door. On top of that, the grantor's role is re-checked on every request: if an owner demotes them to viewer, the next call gets 403 grant_inactive.

All of it sits behind one flag, PUBLIC_API_ENABLED, off by default — then every route answers 404. We switched it on on 2 November 2026, after an nginx change: a separate rate-limit zone for /public/ and /oauth/, and frame-ancestors 'none' plus X-Frame-Options DENY on the app host, so the consent screen cannot be framed by someone else's page to trick a click (clickjacking). Limits are counted per connection and per app: by default 120 requests a minute per connection and 1,200 per app, and 600 and 10,000 for verified apps.

A changes feed instead of webhooks

Sync runs through /public/v1/expenses/changes: keyset pagination over (updated_at, id), tombstones for deleted expenses and a 10-second safety lag measured on the database clock. Without it, a slowly committing transaction could be skipped: it gets its timestamp when it writes but becomes visible later — after the client's cursor has already moved past. The lag makes the feed return only rows old enough that everything before them has committed. Measuring it on the database clock rather than the application server's takes clock skew out of the equation.

A second detail: editing a child row — a tag, a project, a category split, a receipt line item — bumps the parent expense's updatedAt so the feed delivers it again. Otherwise the client would never learn that an expense changed its category split.

Creating an expense accepts an Idempotency-Key header, scoped to the client (and, from phase 2, to the account too), so a request retried after a network error does not create a duplicate.

Encrypted accounts, docs and screens

AI Budget has end-to-end text encryption tiers. Accounts at tier 2 or above cannot be connected at all. Tier 1 accounts are read-only, and every field encrypted at that tier comes back as null. The list of those fields is pinned by a test to the app's encryption field registry — if someone adds a new encrypted field, the test fails before the API starts returning it as ciphertext.

The OpenAPI (Swagger) docs are at /public/docs/en, /public/docs/pl and /public/docs/ru, with a guide in three languages beside them; tests check that every version has the same keys. App settings have Developers and Connected apps screens, the user gets an "app connected" e-mail (at most once a day per app and account pair), and admins have an API apps page: verify, limits and block.

One connection, several accounts

Phase 1 assumed one connection per account. Accounting needs more: the company account and the private ones at the same time, chosen once, on the consent screen, with checkboxes.

In the data this is a join table, oauth_grant_accounts (grant ↔ account), plus one active grant per (client, user) pair, enforced by a partial unique index. The migration copied the existing rows and merged duplicate grants. Here a security review caught a subtle bug in the first version: taking the union of the merged grants' scopes would have silently widened a read-only grant to write. The surviving grant keeps its own scopes instead.

The account is named with the X-Account-Id header, and the rule counts granted accounts, not currently usable ones:

Why count granted accounts rather than active ones? If one of two accounts became temporarily unavailable, an "active" rule would decide only one remained and start silently routing header-less requests to it. A request must never land in an account the integrator did not name.

GET /public/v1/accounts returns for each account: id, name, type, base currency, access (read or read_write), an active flag and the reason it is inactive. The type is what lets the accounting side tell company from private automatically. Name, type and currency are hidden once the user is no longer a member of that account. The token response always carries account_ids, and account_id only for a single-account connection. Re-consent pre-ticks the accounts already connected and replaces the whole set.

Co-owners are a case of their own. A co-owner of a shared account sees the connection only with their own accounts — never with the grantor's private accounts. Their "Disconnect" removes only their accounts; only the grantor can revoke the whole connection. The two-account flow is covered by an end-to-end test against real Postgres and Redis.

The connect flow: eKsięgowy AI issues an authorize URL with state and PKCE, the user ticks accounts in AI Budget, and the code is exchanged only after checking that the state belongs to the signed-in user.

The accounting side: three labels

On the eKsięgowy AI side every account gets one of three labels: company, other business or private. The label is set automatically from the account type in AI Budget — a single business account becomes "company", with several the user picks, the rest are private — and can be overridden by hand. Exactly one "company" per connection is enforced by a partial unique index in the database, not by application code. "Other business" is visible in chat but never in the company dashboard or in reconciliation.

Privacy: copy only what will end up in the books anyway

The owner asked the question that shaped this part: "isn't a copy a privacy loss?" The answer: a copy, yes, but only where it is justified. Only the company account is mirrored in the accounting database — it will end up in the books anyway. Private and other-business data are read live from AI Budget and kept in Redis, encrypted with AES-256-GCM, for at most 300 seconds. They are never written to the database, never logged and never stored in the agent's long-term memory.

The company mirror syncs through the changes feed every 15 minutes, on demand and when the dashboard opens: a cursor per account, tombstones and a weekly full resync, because AI Budget does not report an expense moved out to another account. Relabelling an account away from "company" deletes the mirror atomically. Tokens are encrypted at rest, refreshed only under a per-user Redis lock, and the new pair is saved before anything else happens.

Reconciliation: "expenses without a document"

The most useful feature for accounting compares company spending from the start of the previous month to today against wFirma cost documents fetched live. A match means the same currency, a gross amount equal to within 1 grosz (0.01) and a document date within ±7 days. Matching is one-to-one — each document matches at most one expense. Expenses with fewer candidates are matched first, so an "easy" expense does not take the only document a "hard" one could use. Ties are broken by name similarity after normalisation, with legal suffixes such as "sp. z o.o." or "S.A." dropped.

The wording is deliberate: "no matching document found", never "has no invoice" — the algorithm does not know whether an invoice exists, only that it did not match one. An item can be dismissed as "no document needed". Debts and split receivables are not spending and are excluded everywhere, and category totals follow category splits.

The dashboard has a company block on the main page (this month against last, categories, a reconciliation card) and a separate "Private — outside the company's books" page. Every unknown state is named explicitly: not connected, needs reconnect, syncing, sync failed, wFirma missing. Never zeros, and never "all matched".

Chat: seven tools

The agent got 7 tools: list accounts, get expenses, spending summary, create, update and delete an expense, and expenses without a document. Every figure in an answer is labelled with its account. A write needs an explicitly named account and a confirmation, encrypted (read-only) accounts refuse writes with an explanation, and every create carries its own Idempotency-Key. In a conversation shared with the organization the tools see only the company account; Telegram group chats likewise.

What the reviews found and how we closed it

Every task was checked by a fresh reviewer who had not seen it being written. Then the whole branch went through one more review, and each repository through a separate security audit. Below are all the serious findings, each laid out as: problem, why it mattered, fix.

FindingWhat it riskedFix
IDOR in AI Budget /sync (pre-existing)A member of one account could change links and see a line item in another accountEvery write resolves through the parent account; foreign rows are "not found"
Consent phishing: state bound to whoever startedA victim's tokens stored under someone else's accountCode exchanged only on an authenticated page; the state must belong to the signed-in user
Markdown images in chat, no CSPThe browser itself sending private totals to an outside serverNo remote images; every AI Budget string sanitised; tool output is data
Private data in LangSmith tracesPrivate figures in a third-party storeTrace privacy decided before the root run; inputs and outputs blanked
Spent refresh token after a failed saveAI Budget revoking the whole connectionRetry under the lock, needs_reconnect, 60-second marker, compare-and-swap
Disconnect during a running syncStale expenses permanently in company figuresRevoke locally first; SELECT … FOR UPDATE on every mirror write
Silent company switchThe real company's mirror wipedAn inactive but visible account keeps its label
"All matched" instead of "unknown"A false "every expense has a document"Discriminated states; a mirror is complete only after a full run

1. An IDOR in AI Budget's existing /sync

Problem. While the API was being added, a review found a bug that predated it: in the app's sync, relation rows (expense ↔ tag, project, category split) and receipt line items were looked up by id without checking the caller's account.

Why it mattered. A member of any account who knew ids from another account could delete or attach links there, and a version conflict returned a line item from that other account. The public API widened access to the data, so this had to be closed before launch.

Fix. A separate task (ABA-708): every write resolves the row through its parent's account, and foreign rows answer "not found" — the same as rows that do not exist.

2. Consent phishing and authorization-code injection

Problem. The OAuth state was bound to the user who started the connect, not to the browser finishing it. Someone could start a connect in their own eKsięgowy AI account and send the authorize link to a victim.

Why it mattered. Had the victim approved, their AI Budget tokens would have been stored under the other person's account — access to someone else's finances, with nothing on the victim's side but one "Allow" click.

Fix. The API callback no longer exchanges the code. It forwards code and state to a page in the web app, which POSTs them to an authenticated endpoint. The server takes the state with GETDEL and requires it to belong to the signed-in user — otherwise state_mismatch, and nothing is stored. The page drops code and state from the URL immediately (history.replaceState), uses a no-referrer policy and is excluded from analytics, so the code leaks neither into history, nor a Referer header, nor a statistics tool.

3. Exfiltration through injected markdown

Problem. Merchant names, descriptions and account and category names come from AI Budget, and they can be written by a co-member of the account, a receipt or a bank notification. The chat rendered markdown images, and the site had no CSP.

Why it mattered. Text that persuaded the model to put an image into its answer, with an address carrying private totals, would make the browser itself send those figures to an outside server — without a single click.

Fix. Three layers. The chat never loads remote images. Every AI Budget string is sanitised before it reaches the model: markdown links and images, URLs and HTML are stripped, and the length is capped. A A prompt is the instruction you give an AI model, and how precisely it is written decides whether the answer comes back useful or vague. Full definition → rule says tool output is third-party data, never instructions. A later re-review found one more unsanitised spot — category names in a write tool's error message — and it was fixed too.

4. Private data in LangSmith traces

Problem. Tracing (a third-party store) received tool outputs, and in later iterations also the model's answers restating private figures, the text-to-speech input and the replayed history.

Why it mattered. Redacting by tool name was not enough: the "this run used private data" flag is known only after a tool has run, and by then the root run has already been posted.

Fix. The decision is made before the root run is posted: a trace is private when the conversation was already flagged or the AI Budget tools are bound with all-accounts scope. In a private trace every run's inputs and outputs are blanked. The tests were made hermetic — a developer's local .env with tracing on had made one test call LangSmith (it answered 403) — and tracing detection covers all four environment variable names.

5. Refresh-token reuse after a failed save

Problem. If the database save failed after a successful refresh, the next caller would present the spent refresh token.

Why it mattered. AI Budget rightly treats that as theft and revokes the whole connection. Our own theft protection would have cut off an honest client.

Fix. The save is retried under the lock, and if that fails too the connection is marked needs_reconnect. A refresh with an unknown outcome (timeout, network error, 5xx) sets a 60-second marker so nobody retries with the possibly-spent token. We refresh only on 401 invalid_token, and storing the new token is a compare-and-swap on the stored refresh token.

6. Disconnect during a running sync

Problem. A page fetched before a disconnect could be written after the mirror was cleared, carrying an old sync generation that a later full resync never sweeps.

Why it mattered. Stale expenses would stay permanently in the company figures — in the very place meant to reach the books.

Fix. The connection is first marked revoked locally. Every mirror write goes through a row-lock-based guard (SELECT … FOR UPDATE) that requires "company label AND active connection". Clearing the mirror updates the account row first, so it waits for a page that is in flight.

7. A silent company switch

Problem. When access to the company account was temporarily limited, auto-labelling promoted another business account to "company" and wiped the real company's mirror.

Why it mattered. A transient permissions problem would quietly replace the company dashboard's contents with another business's data.

Fix. An account that is merely inactive but still visible keeps its company label (and is excluded from figures). Only an account that is no longer visible at all loses it.

8. "All matched" instead of "unknown"

Problem. Reconciliation returned an empty list when wFirma was not connected or was failing, and a first sync showed a partial mirror as ready.

Why it mattered. A chat tool would read an empty list as "every expense has a document" — false reassurance on a matter of deductible costs.

Fix. Discriminated states everywhere, from the API through the interface to the agent's tools. A mirror counts as complete only after a full run.

How we worked

The process was the same in all three parts: a brainstorm with the owner, a written spec, an independent "builder check" that reads the spec through the implementer's eyes, a step-by-step plan, then subagent-driven execution — a fresh agent per task, a fresh reviewer after each task, fix rounds, and finally a whole-branch review on the strongest model plus a separate security audit.

Phase 1 of the API was 17 tasks, multi-account 10, the accounting integration 18. Review-driven fix rounds ran into the dozens; most tasks had at least one Important finding. Final test counts: the AI Budget API has 4,970 unit tests, the mobile app 2,370, the eKsięgowy AI API 781 and its web app 171, plus 13 integration tests on a real database, 12 Playwright tests and 10 end-to-end public API cases, including a two-account flow.

What we take away: a reviewer who did not write the code finds what its author will not see — here, among other things, consent phishing and the scope widening during the grant merge. The costliest findings were not syntax errors but wrong assumptions about who finishes the flow and what an empty list means.

What's next and what is still open

Incomes, budgets and goals are coming to the API; webhooks maybe later. A pilot is running on the owner's own accounts.

We chose to report three items rather than fix them right away: server-enforced confirmation for the agent's destructive tools (today it is prompt-level, and it is an app-wide design question, not specific to this integration), a site-wide CSP, and the fact that Telegram group messages still use the user's private conversation history.

For more on how both products are built, see How AI Budget Assistant uses AI and How the Accounting AI Agent works; the product itself is on the AI Budget Assistant page.

Frequently asked questions

Who can use the AI Budget public API?
Any developer. The API is free and documented, and you register an app yourself, with no manual review. Only an account's owner or editor can connect it. Phase 1 covers expenses; incomes, budgets and goals come later.
Does eKsięgowy AI store my private expenses?
No. The accounting database holds a copy only of the account labelled company. Private data is read live from AI Budget and kept in Redis, encrypted with AES-256-GCM, for at most 300 seconds — never written to the database, logged or kept in the agent's long-term memory.
Why a changes feed rather than webhooks?
A changes feed with keyset pagination over (updated_at, id) and tombstones lets the client catch up from its stored cursor on its own. A 10-second lag measured on the database clock keeps a slowly committing transaction from being skipped. Webhooks may come later.
How does the API pick the account when a connection covers several?
It doesn't. With one account in the grant the X-Account-Id header is optional; with several, a missing header gets 400 account_required and an id outside the grant gets 403 account_not_granted. A request never lands in an account the integrator did not name.