- C# 58.2%
- Vue 35.6%
- TypeScript 4.6%
- Shell 1.1%
- Dockerfile 0.4%
- Other 0.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .cursor/rules | ||
| .forgejo/workflows | ||
| .impeccable | ||
| build | ||
| src | ||
| .dockerignore | ||
| .gitignore | ||
| CLAUDE.md | ||
| DESIGN.md | ||
| Dockerfile | ||
| Dockerfile.web | ||
| FinanceMan.slnx | ||
| PRODUCT.md | ||
| README.md | ||
Samla
Household finance app. Samla is the product name — the solution, namespaces, Docker
images, repository and database schema stay financeman / FinanceMan, so the rename is
skin deep and nothing in the build had to move.
.NET Aspire orchestrates three pieces:
| Resource | What it is |
|---|---|
postgres / financemandb |
Postgres 17 container, data kept in the financeman-pgdata volume |
api |
.NET 10 web API — Wolverine (messaging + HTTP endpoints) over Marten (document store) |
web |
Nuxt 4 app — Nuxt UI 4 and Tailwind 4 |
Running it
Needs the .NET 10 SDK, Node, and a running Docker daemon.
dotnet run --project src/FinanceMan.AppHost
The Aspire dashboard prints a login URL on startup; every resource, its logs and its
endpoints are reachable from there. Aspire starts Postgres, waits for it, starts the API,
then runs npm install and npm run dev for the Nuxt app.
To work on the frontend alone, npm run dev in src/financeman.web — it falls back to
http://localhost:5000 for the API, override with NUXT_PUBLIC_API_BASE.
User accounts
Users are Marten documents (User), passwords are hashed with ASP.NET's
PasswordHasher<T> (PBKDF2), and the session is an HttpOnly cookie issued by the API.
| Endpoint | Purpose |
|---|---|
POST /auth/register |
Create an account and sign in |
POST /auth/login |
Sign in |
POST /auth/logout |
Clear the session cookie |
GET /auth/me |
Current user, or 401 |
In development the app seeds one account at startup so it is usable immediately:
| Password | |
|---|---|
morten@talla.no |
morten123 |
The seed runs only when ASPNETCORE_ENVIRONMENT is Development — the password is in
source, so seeding it anywhere else would put a publicly known credential in a real
database — and it leaves an existing account of that address untouched, password included.
Delete the row and restart to get it back. Any other account is made at /register.
Sessions are validated against the database on every request, so deleting a user ends their session immediately rather than leaving the cookie usable until it expires, and a renamed user's claims refresh on their next request.
Households
A household is a group of people who share finances. A user can belong to several. Within one, a member is either an owner (can invite, revoke invitations, remove members and promote others) or a member.
| Endpoint | Purpose |
|---|---|
POST /households |
Create one; the creator becomes an owner |
GET /households |
Households you belong to |
GET /households/{id} |
Detail with members (members only) |
POST /households/{id}/leave |
Leave |
DELETE /households/{id}/members/{memberId} |
Remove someone (owners) |
POST /households/{id}/members/{memberId}/make-owner |
Promote (owners) |
Two rules keep a household coherent: the last owner cannot leave while others remain (hand
over first), and when the last member leaves, the household and its invitations are deleted
rather than orphaned. Non-members get 404, not 403, so household ids cannot be probed.
Invitations
Nothing is emailed. An owner invites an email address and gets back a link to pass on
however they like. If that address already has an account, the invitation also appears in
that user's in-app inbox on /households.
| Endpoint | Purpose |
|---|---|
POST /households/{id}/invitations |
Invite an email address (owners) |
GET /households/{id}/invitations |
Pending invitations (owners) |
POST /households/{id}/invitations/{invitationId}/revoke |
Revoke (owners) |
GET /invitations/mine |
Your inbox |
GET /invitations/{token} |
Look up a link before acting on it |
POST /invitations/{token}/accept |
Accept |
POST /invitations/{token}/decline |
Decline |
An invitation is addressed to an address, not to whoever holds the link: accepting requires
being signed in as that email, so a leaked link is not enough to join. Invitations expire
after 14 days. A signed-out visitor who opens an invite link is sent through
/login?redirect=… (or /register) and lands back on the invitation afterwards.
Because the API and the web app are separate origins, the browser sends the cookie
cross-origin: the API allows credentialed CORS from exactly one origin, which the AppHost
passes in as Frontend__Origin. Anything the frontend calls on the API must go through
the api helper returned by useAuth(), which sets credentials: 'include'.
Bank accounts and the ledger
An account is one of two kinds.
A personal account is one member's own. It is private to the member who added it, and what
they spend on the household's behalf is settled up between members. Nobody else in the household can see it, its ledger or its imports,
however they ask: a request for someone else's account is answered 404, not 403, so an
account you do not own is indistinguishable from one that never existed.
What the household shares of a personal account is the common account, assembled from the transactions members have deliberately marked common. Sharing a purchase therefore never exposes the account it was paid from — the household sees the transaction, not the ledger it came from.
A common account is the household's own — a joint account, say. Every member can see it and its ledger, and everything imported into it is shared the moment it lands, with nothing to review. Changing it still belongs to the member who added it: importing, deleting entries and removing imports are theirs. That is a real refusal rather than a pretence, unlike a personal account someone else owns, which is reported as not existing — they can see a common account, so denying it exists would be a lie.
Spending on a common account is not settled up. It shows in the common account, because it is shared, but it is left out of who-owes-whom entirely. Attributing it to whoever added the account would show them owed for spending joint money, and counting it towards the split with nobody having paid would leave every member in debt and the balances no longer summing to zero. So the settlement looks only at personal accounts: joint spending is recorded, not balanced — and settling up leaves it out of its list as well as its arithmetic. A household that puts a joint card through the app has hundreds of common-account rows a month that change nobody's position; listing them beside the handful that do would bury them. They are read in the common account, which is where they belong.
Entries in those lists link to the account they came from when the viewer can open it — a common account, which belongs to the household, or their own personal one. Another member's personal account is named by its owner and not linked, since following it would only lead to a 404.
A ledger entry holds date, description and a signed amount, and nothing else. Statements carry more (merchant category, area, transaction type) and none of it is imported: a category should come from your own rules, not from whatever the bank labelled a purchase.
| Endpoint | Purpose |
|---|---|
POST /households/{id}/accounts |
Add an account |
GET /households/{id}/accounts |
Your own accounts in the household, with balances |
GET /households/{id}/accounts/summary?kind=&period= |
What those accounts spent and had paid in, added up, for a month or a year |
GET /accounts/{id} |
One account |
GET /accounts/{id}/transactions?page=&pageSize=&period= |
The ledger, newest first, paged; one month (yyyy-MM) or year (yyyy) at a time, with what those rows come to |
DELETE /accounts/{id} |
Remove it with its ledger and imports |
Importing statements
Choosing a file previews it rather than importing it. The preview names the layout it was recognised as, the period it covers, and every row marked new or already held, and nothing is written until you accept — so picking the wrong file costs nothing. A file whose rows are all already in the ledger says so and cannot be imported.
Preview rows are shown newest first, matching the ledger they are about to join. Files do not agree on order — the spreadsheets run oldest first and the printed statement newest first — so sorting here is what makes a preview read the same way whichever format it came from. The events are still appended oldest first, so an account's stream reads forwards in time.
Individual rows can be left out before accepting. Rows already in the ledger are not selectable, since they are not being added either way. An excluded row is still recorded against the import, with a status of its own, so the import continues to account for every row the file contained rather than quietly forgetting some.
Excluding is a decision about one import, not a standing rule: because duplicate detection counts entries rather than remembering rows, importing the same file again offers the excluded rows once more. That is deliberate — a row skipped by accident is recoverable by re-importing, and nothing has to store a permanent list of things never to import.
No preview is kept on the server. Accepting sends the same file to the import endpoint, which analyses it again against the ledger as it stands then; a preview is what a file would do, not a promise reserved for later, and an abandoned one leaves nothing behind to clean up.
Uploading a statement records the upload whole and adds only what the ledger does not already hold. Every row is kept against the import — the already-held ones too — so an import can be reviewed later without the original file.
An import can be removed, which takes every entry it added back out of the ledger. The entries go the same way a single deletion does, by appending an event each, read from the transaction as it stands now rather than as the import left it — a row may have been marked common since, and the household's shared total has to come back down by what is actually there. Rows the import only recognised as already held belong to whatever put them there and stay; rows it left out were never added. An entry already deleted by hand is skipped.
Each import also records the period it covers: the earliest and latest transaction date in the file. That is what tells you which dates have been fed into an account and where the gaps are. It is taken as the minimum and maximum rather than the first and last row, because the exports disagree on ordering — the spreadsheets run oldest first and the printed statement newest first — and both should report the same span for the same statement.
| Endpoint | Purpose |
|---|---|
POST /accounts/{id}/imports/preview |
Read a statement and report what importing it would do |
POST /accounts/{id}/imports |
Upload a statement (multipart, field file) |
DELETE /imports/{id} |
Remove an import and the entries it added |
GET /accounts/{id}/imports |
Imports for the account |
GET /imports/{id} |
One import with every row and its status |
Both import endpoints return periodStart and periodEnd (null only for a file that
yielded no rows).
Reviewing transactions
A statement cannot know whether a purchase is one person's own or something the household shares, so nothing is guessed. Everything imported starts unreviewed and the account's owner decides.
In an account's ledger each row carries a Personal / Common toggle and a delete button, and the ledger can be filtered to To review, which is the working queue. Clicking the choice a row already has clears it back to unreviewed, so a slip is undone by the same click that made it.
Money coming in
What leaves an account is spending. What arrives is one of two things, and the difference decides whether a month reads as anything at all.
A deposit is money put into the account: a salary, a transfer to cover the joint card. It is not spending, so it is left out of what was spent. Cashback is money handed back on something bought — a refund, a discount paid out later — so it counts, and reduces the spending it came off. A positive row on any account carries the choice; on money going out there is nothing to decide, and asking is refused rather than quietly ignored.
Everything arrives a deposit, including everything imported before this existed. That is the safe way round: money coming in is not spending until somebody says it was money coming back. There is no third, unreviewed state — one of the two answers is always right, and the default is the one that is right more often.
The distinction matters because a joint card is topped up as fast as it is spent. One real month: the household's shared rows come to −2 432,47, which reads like a quiet month and is nothing of the sort — two 7 500 transfers in are cancelling out the shopping. The same month spent −17 721,12. The account behind it shows the gap at its starkest: a balance of −1 668,00 against −57 240,80 spent over three months.
A shared cost is never money paid in, and the app refuses the combination rather than
carrying it. Sharing a cost says somebody covered something for the household and is owed a
share back; money arriving covered nothing, so such a row would sit in the household's list
changing nobody's position. Marking a paid-in row common is answered 409, and so is calling
a shared cost paid in — the same rule from the other side, with the way out named in the
message: clear the sharing, then say it was paid in. The ledger takes both choices away rather
than letting them fail, and says why on hover, where the choice would have been.
The exception is a common account, where every row is the household's by construction — its deposits are exactly what this is for, so only a member's own account can be caught by that second refusal.
Who filled the joint account
A household's own account is spent from jointly but filled individually: one member puts in their share, the other puts in theirs, and by the time it has been spent there is nothing left in the ledger saying who put what in. So a deposit into a common account can name the member it came from, and the account's overview divides what came in between them — Morten 7 500, Anette 7 500, and whatever nobody has claimed yet on a line of its own, so the parts add up to the total and an unanswered pile cannot hide inside somebody's share.
That is the shape of a common account read back: what it spent, whole rather than split by whose it was, because all of it is the household's; and what each member put in.
Naming somebody is refused where the question means nothing — on cashback, which came from a shop, and on a member's own account, where money arriving is theirs by definition. It moves no total either: the money arrived whoever sent it, and this only divides what came in.
It does not settle anything. Contributing more to the joint pot than the other member does not, today, make them owe you — settling up divides shared costs, and what a common account spends is nobody's to be repaid for. If a household wants unequal contributions evened out, that is a decision to take deliberately rather than something these figures should start doing quietly.
So the two figures are kept apart and both are reported. Balance is every amount that ever
moved, which is what a balance is. Spent is what the account was spent, and it is what the
dashboard's Spent together tile, an account's own total and a member's Paid in settling
up are all built from. A deposit marked common still changes nobody's position — nobody
covered anything for the household by moving their own money about — while cashback on a
shared purchase reduces what that member is owed.
Spent is maintained on the account projection beside the other totals, adjusted by one
helper (Spending) that every event path shares, so the definition cannot drift between
recording, deciding and deleting. Accounts stored before the distinction existed are folded
again from their streams on startup, which is how the figure appears for data that predates
it — every incoming amount replays as a deposit, which is what they all were.
Notes on a transaction
A statement describes a purchase the way the terminal spelled it — REMA 1000 NITTEDAL Notanr 74463666224542251383042 — which says where the card was, not what it was for. Any row can
therefore carry a note in the member's own words: what it actually was, so it still means
something months later, and so the household can read a shared cost rather than decode it.
The note sits beside the bank's description, never in place of it. The description is what the ledger was told and what an import compares against, so a note can be written, rewritten and cleared as often as you like without any risk of the same statement importing twice — the fingerprint is built from the description alone.
Once a row has a note, the note is what is shown — the description is not lost, it is what
the tooltip holds. A note is written precisely because REMA 1000 NITTEDAL Notanr 74463666224542251383042 does not read as anything, so showing both would put the reason for
writing it back on the page. TransactionText decides this in one place, and every list that
shows a transaction uses it.
Notes are written from the row's menu, which is where everything that can be done to a single row lives: add or edit the note, delete the note, delete the transaction. Deleting the transaction sits in a group of its own — separated, and the only entry that cannot be undone. Editing happens inline: Enter or clicking away saves, Escape abandons. Like classifying, it updates the row where it sits rather than reloading the list, and writing an unchanged note appends no event.
One trap worth knowing about, since the menu is a natural place to reach for other row actions
later: a menu hands focus back to its trigger as it closes, which took focus straight off the
note field that "Add a note" had just opened — and losing focus is what saves the field, so the
editor closed the instant it appeared. The menu is told not to move focus on close
(onCloseAutoFocus prevented), which leaves it where the field put it.
Writing follows the same permission as reviewing — it is the account owner's, since it is their card. A note on a transaction they mark common is read by the household along with the description, wherever that cost is listed: the settlement's shared costs, the dashboard's month and the common account.
Deciding on a row does not move it. The row is updated where it sits and only the counters change; the list re-filters the next time it is actually loaded. Reloading after every click would pull the row you just judged out from under the cursor — under the "To review" filter it no longer matches — and everything below would jump up while you were reading it.
That also depends on the ledger having a stable order. An import stamps every row in a
batch with one timestamp, so date and creation time alone leave large groups of ties — 116
rows over 23 distinct sort keys in one real statement, the largest tie 17 rows — and Postgres
may return a tied group in any order, in practice its physical order, which shifts as soon as
a row is rewritten. Classifying one transaction reshuffled its neighbours, and paging could
repeat or skip rows between pages. Every ledger query therefore ends ThenByDescending(t => t.Id); ids are time-ordered, so this settles ties without disturbing the intended order.
The household's common account (/households/{id}/common) gathers everything anyone has
marked common, across all of their accounts, and names who shared each one — the classification
event records who made the decision, so attribution comes off the same events as the totals. It sits apart from
the accounts, which mirrors who owns what: the accounts are private to one member each,
while the common account belongs to the household. Its totals are household-wide — deliberately
not filtered to the caller — while every other account query is scoped to the owner. It is a view over the
same projected rows the personal ledgers use, so a transaction is never in two places or out
of step with itself — marking one common moves nothing, it just says the household shares it,
which keeps each account's balance honest.
| Endpoint | Purpose |
|---|---|
POST /transactions/{id}/classification |
Mark personal, common, or back to unreviewed |
POST /transactions/{id}/note |
Write the member's own note on a row, or clear it with a blank one |
POST /transactions/{id}/inflow |
Say whether an incoming amount was paid in or is money back |
POST /transactions/{id}/paid-in-by |
Name the member a deposit into a common account came from |
DELETE /transactions/{id} |
Remove a transaction from the ledger |
GET /accounts/{id}/transactions?classification=&period= |
Ledger, optionally narrowed to one state and one month or year |
GET /households/{id}/common/transactions?month=yyyy-MM |
The household's shared ledger and its total; one month of it when asked, which is what the dashboard reads |
Reviewing and deleting follow the same rule as everything else on an account: only the member who added it can reach it at all. The household sees the results of a review only through the common account.
Settling up
Settling up has a page of its own. For any month it shows what each member paid towards shared costs, where that leaves them, and the shared costs themselves — attributed to whoever paid, so the figures above them can be checked line by line. Whoever's account a cost came from paid it. The month's total is split equally between the household's members; a member who covered more than their share is owed the difference.
The list is built as the figures are counted rather than queried separately, so the two can
never disagree about what is in the month: what it shows adds up to the total being split,
exactly. It can be filtered by who paid, which is how you see what one member covered
without reading past everyone else's rows; the filter also totals what is on show, which for
one member is the Paid figure in the table above.
A row names the member who paid and nothing else about where the money came from. Which of their accounts they used is theirs to know — this is the one place the household sees anything about another member's spending at all — so only your own rows carry a link, to the account you paid from.
Nothing resets at a month boundary. A month left unsettled stays owed, so each month starts from where the previous one ended — which is why a month is computed by replaying the household from its first shared transaction rather than read in isolation. Looking up an earlier month shows what it carried in, and what it left owed, which is exactly what carried into the month after it. Carried debt is netted, not stacked: if you owe 300 from June and cover 400 more than your share in July, July ends with the other member owing you 100.
Paying somebody back is recorded with POST /households/{id}/settlements and appended to the
household's own event stream — settling is between people, and the money may never touch an
account the app knows about. You can only settle what you owe: the payment is always from the
caller. Settling reduces the balance from that month onwards and leaves earlier months exactly
as they were.
A settlement can be taken back — recorded in error, or a payment that never happened. Only the member who made it can withdraw it: it is their record of what they paid, and letting the person owed the money delete it would let them quietly reinstate a debt. Balances move back from that month onwards; earlier months are untouched.
A settlement carries the date it was paid, which decides the month it counts towards. The form defaults to the last day of the month being settled, on the assumption that is when the balance was struck, but money often moves on some other day and the date is yours to set.
| Endpoint | Purpose |
|---|---|
GET /households/{id}/settlement?month=yyyy-MM |
A month's spending, balances and what is owed |
POST /households/{id}/settlements |
Record paying another member back |
DELETE /settlements/{id} |
Take a settlement back (only the member who paid) |
Odd cents are handed out rather than rounded away. A third of 100 is 33.33 three times over, which is a cent short; left as a fraction that cent would sit in somebody's balance and be carried forward forever, so the shares are 33.34 / 33.33 / 33.33 and add back to the spending exactly. Balances always sum to zero.
Two simplifications worth knowing: the split is equal rather than weighted, and it is across the household's current members, so a member who joined later still counts in earlier months.
Event sourcing
The ledger is event-sourced. Three events say everything that can happen to a transaction:
| Event | Meaning |
|---|---|
TransactionRecorded |
An import brought a transaction in |
TransactionClassified |
Someone decided personal or common |
TransactionDeleted |
It was removed from the ledger |
One stream per bank account, and no stream of its own for a transaction. Everything that
happens to a transaction is appended to the stream of the account it belongs to, keyed by the
account id, so an account's stream is its ledger's whole history in order. Every write goes
through AccountStream.Append, which is the single place that decides where an event lands,
and it opens the stream declared as BankAccount — the aggregate it folds up to — so the
database records what the stream is for instead of leaving an anonymous stream that merely
happens to share an id with an account.
Deleting a transaction appends an event rather than erasing one, so what the account was told is never rewritten.
TransactionProjection builds the ledger rows from those events. It writes one read-model
document per transaction — a projection detail, not a stream one; the events themselves stay
on the account's single stream — because a ledger runs to thousands of entries that have to be
paged, filtered and summed in the database, which a single fat document could not do. Both readers come off those same rows: an account's own ledger by
BankAccountId, the household's common list by HouseholdId and Classification. That is
why the two views cannot disagree.
The projection is registered inline, so rows are written in the same transaction as the events. An import's rows are queryable the moment it returns and a review shows on the next read, with no async daemon to run or wait for. Because rows are derived, they can be rebuilt from the events at any time; nothing writes to them directly.
The household's common account is a projection across every account in it.
CommonAccountProjection is a MultiStreamProjection grouped by household id, so events from
each member's own account stream fold into one CommonAccount — which is what a common account
is. Only classification and deletion can change it; recording a transaction never does, because
everything arrives unreviewed and is shared only once somebody says so. It runs inline like the
rest, so the shared total is exact the moment a member marks something, with no daemon and no
lag. The paged rows still come from the transaction read model, filtered by household and
classification.
The account is one entity, not two. Adding an account is an AccountAdded event on that
same stream, so BankAccountProjection folds the whole stream into a single BankAccount:
its identity — name, reference, owner, household — and its running totals, being the balance,
transaction count, a count per classification, and what it contributes to the household's
common total. A SingleStreamProjection fits exactly, because a stream is exactly one account.
There was briefly a separate AccountBalance document holding the totals while the account
itself was stored directly. That split was not a design so much as a seam: two documents
sharing an id and describing one thing, one owned by Marten and one not, which also meant the
account's own history was missing from its stream. Event-sourcing the account closed it. The
constraint that forced the split — a projection owns its document wholesale, so a rebuild
discards anything not derivable from the stream — is now satisfied for every field.
This is what makes the summaries cheap. Reading an account used to mean a sum and two counts across every transaction it had ever held, run once per account in a listing; the common total meant summing every shared transaction in the household. Both are now a load by id — constant work however long the history gets:
before: Bitmap Heap Scan on mt_doc_transaction -> Aggregate (reads every row of the ledger)
after: Index Scan using pkey_mt_doc_accountbalance_id (reads one row)
Paging totals come off the same document, so listing a ledger no longer counts rows either.
The household's common total sums the CommonTotal of its accounts, which is a handful of
loads by id rather than a scan.
For this to work from the event alone, TransactionClassified carries the amount and the
decision it replaces, and TransactionDeleted carries the amount and classification it had.
Otherwise adjusting a total would mean re-reading the transaction — the very cost being
avoided. Both handlers already have the row loaded when they append, so nothing extra is read.
LedgerBackfill runs at startup and does three things: it puts any ledger written before the
ledger was event-sourced onto its account's stream; it records AccountAdded for accounts
that predate event-sourced identity, appended after the transactions already there because
a stream cannot be prepended to — which is why the projection accepts identity arriving last
as readily as first; it rebuilds any account it touched by replaying the whole stream through
the same projection the live path uses, so a rebuilt account cannot drift from an
incrementally maintained one; it gives a kind to accounts stored before accounts had one — reading such an
account already yields the default, personal, but a SQL comparison against a field that is
absent is null rather than false, which would have dropped those accounts out of the
settlement entirely; it builds a household's common account where sharing predates that
projection, by replaying the household's account streams through the projection's own methods
so a seeded document is identical to one the live path would have produced; and it names any
stream not declared as the current aggregate, taking the alias from Marten rather than a
literal so it cannot disagree with what new streams are given. Every step skips work already
done, so it is a no-op after the first run.
Layouts
Files are recognised by shape rather than by extension, and the first parser that
recognises one reads it. Adding a bank means adding a parser and registering it in
Program.cs.
| Layout | Recognised by | Direction comes from |
|---|---|---|
xlsx/signed-amount |
TransactionDate, Text, Amount headers |
the sign of Amount |
xlsx/inn-ut |
Dato, Beløpet gjelder, Inn, Ut headers |
which of Inn/Ut is filled |
pdf/inn-ut-columns |
the Bokføring … Inn på konto … Ut av konto header line |
the amount's position on the page |
csv/inn-ut |
Transaksjonsdato, Beskrivelse, Ut av konto, Inn på konto headers |
which of the two amount columns is filled |
csv/date-text-amount |
four unnamed columns: a date, a text, a signed amount, a currency | the sign of the amount |
xlsx/date-text-amount |
the same four columns, transactions from the first row down | the sign of the amount |
pdf/date-text-amount |
the Dato … Tekst … Beløp heading |
the sign of the amount |
Both CSV exports are semicolon separated and written with a byte order mark. Fields are read
with quotes honoured, so a description containing a semicolon stays one field, and a row's
line number in the file is what the preview shows. csv/inn-ut writes ISO dates and splits
amounts across Ut av konto and Inn på konto the way the printed statement does; the
column an amount sits in is what gives it its sign.
The three date-text-amount layouts are one bank's three ways of exporting the same table:
a date, a text, a single signed amount and the currency. Nothing in the CSV or the
spreadsheet names those columns — there is no header row at all — so recognition is that the
first row already parses as a transaction, which a headed file's never does. That is what
keeps the two CSV layouts apart without either having to know about the other.
The spreadsheet of this family reads the sheet's XML itself rather than through ClosedXML,
which cannot open it: its dates are typed date cells holding ISO 8601 instants
(2026-08-31T00:00:00.000Z), and ClosedXML 0.105.1 — the current release — throws on the
trailing Z while loading the workbook, before any cell can be asked for. Reading the XML
directly is a few dozen lines and confined to that one parser; the other spreadsheets still
go through ClosedXML.
All three are checked against each other: for the same account the CSV, the spreadsheet and the PDF produce the same rows with identical fingerprints, so importing two of them recognises every row as already held rather than doubling the ledger.
pdf/inn-ut-columns is the fiddly one. Nothing in that statement's text says whether a
figure is incoming or outgoing — only which column it sits in — so the parser reads the
Ut av konto heading's position off the page and classifies each amount by whether its
right edge falls before or after it. Thousands are separated by spaces, which the text
extractor reports as word breaks, so 63 431,41 arrives in two pieces; they are rejoined
only when the gap is tight (a real thousands gap is a fraction of the glyph height, while
the gap between the description and the amount column is thirty times wider). Both PDF
parsers are checked against the spreadsheet export of the same statement: same row count,
same net, identical amounts.
Both also share PdfText, which groups a page's words into visual lines and lifts the
amount off the end of one, rejoining as above. pdf/date-text-amount prints its own signs
and so needs nothing from the page's geometry beyond that; a row there is any line that
opens with a date and closes with an amount, which is what tells transactions apart from the
headings repeated on every page and from the printing date above Side 1 av 4. It keeps a
description exactly as printed, dates and all, because dropping the Betalingsdato: 31.08.2026 inside one would leave the PDF describing a transaction differently from the CSV
and spreadsheet of the same account.
What counts as a duplicate
A row's fingerprint is its date, amount and description reduced to letters and digits.
Punctuation is dropped because a bank does not spell a purchase identically across its own
exports — one file writes PIZZA & SHOW AS - FOOD where another writes PIZZA SHOW AS - FOOD — and punctuation is never what distinguishes two transactions.
Matching counts rather than tests for existence: if the ledger holds two entries with a fingerprint and a statement lists three, exactly one is added. Buying the same coffee twice in a day is two real transactions, not a duplicate.
The printed statement uses the date the bank booked a transaction, not the date it
happened, and the two differ by up to a few days. The spreadsheet parser therefore reads
BookDate rather than TransactionDate, so the same statement produces the same entries
whichever file it arrives in.
One known gap. The PDF truncates long merchant names to fit its column — NORMAL STROEMMEN STORSENT prints as NORMAL STROEMMEN — and no fingerprint can recover what the
page does not contain. Importing both the PDF and the spreadsheet of the same statement
therefore adds one spurious entry per truncated name. Import one format per account.
Language
The UI ships in Norwegian and English through @nuxtjs/i18n. Norwegian is the default;
the browser's Accept-Language picks the first locale on a fresh visit and the choice is
remembered in the samla_locale cookie. strategy: 'no_prefix' keeps one URL per page —
/households/x is the same route in both languages — because the app is behind a login
and has nothing to gain from indexable per-language URLs.
| File | Holds |
|---|---|
i18n/locales/nb.json |
Norwegian (default) |
i18n/locales/en.json |
English |
Both files carry the same key set; a check that they stay in step:
cd src/financeman.web
python3 -c "
import json
f = lambda d, p='': {p+k: v for k, x in d.items() for k, v in (f(x, p+k+'.') if isinstance(x, dict) else {k: x}).items()}
en, nb = (json.load(open(f'i18n/locales/{l}.json')) for l in ('en', 'nb'))
print(set(f(en)) ^ set(f(nb)) or 'in step')"
Money, dates and month names are formatted by useMoney, which reads the active locale
and hands it to Intl — so English shows 31 Aug 2025 and NOK 1,234.50 where Norwegian
shows 31. aug. 2025 and kr 1 234,50. Amounts are always in NOK; only the presentation
changes.
Brand
The mark is one coin split down the middle, the halves set slightly apart: a single pot divided into shares, which is what the app does. Two solid shapes and one gap, so it still reads at 16px in a tab strip.
| File | Used for |
|---|---|
public/logo.svg |
Standalone mark, fixed green |
public/favicon.svg |
Tab icon on anything modern |
public/favicon.ico |
16/32/48px fallback |
public/apple-touch-icon.png |
180px home-screen icon |
app/components/SamlaLogo.vue |
In-app mark; the tile takes --ui-primary, so it follows the theme |
Regenerate the raster icons from the SVG after editing it:
cd src/financeman.web
for s in 16 32 48; do rsvg-convert -w $s -h $s public/favicon.svg -o /tmp/icon-$s.png; done
magick /tmp/icon-16.png /tmp/icon-32.png /tmp/icon-48.png public/favicon.ico
rsvg-convert -w 180 -h 180 public/favicon.svg -o public/apple-touch-icon.png
Layout
src/
├── FinanceMan.AppHost/ Aspire orchestration
├── FinanceMan.ServiceDefaults/ OpenTelemetry, health checks, service discovery
├── FinanceMan.Api/
│ ├── Program.cs Marten, Wolverine, cookie auth, CORS
│ ├── Auth/ User document + auth endpoints
│ ├── Households/ Household + Invitation documents and endpoints
│ └── Accounts/ BankAccount, events, projection, endpoints
│ └── Importing/ Statement parsers and the importer
└── financeman.web/
└── app/
├── composables/ useApi (fetcher), useAuth (session), useHousehold
├── middleware/ auth (guard), guest (redirect when signed in)
├── plugins/auth.client.ts Resolves the session before routing
├── components/SamlaLogo.vue Brand mark, themed
└── pages/
├── index.vue Your dashboard across households
├── login, register
├── invitations/[token].vue
└── households/
├── index.vue List, create, invitation inbox
├── [id].vue Shell: name, aside nav, <NuxtPage>
└── [id]/
├── index.vue Dashboard: the month's shared spending and balances
├── settlement.vue Who paid what, and what that leaves owed
├── settings.vue Members, invitations, leave
├── accounts/ personal.vue and common.vue list one kind each;
│ [accountId].vue is one account's shell, over
│ [accountId]/index.vue (the ledger and its totals)
│ and [accountId]/imports.vue (files in, rows out)
└── common.vue The household's shared ledger
Opening a household gives a shell ([id].vue) that loads it once and renders an aside
linking to Dashboard, Personal accounts, Common accounts, Settling up and
Settings, with the child page beside it. The children call useHousehold(), which shares a single
useAsyncData entry keyed by household id — so promoting someone in settings updates the
dashboard's counts without a second fetch. useAccount() does the same one level down, which
is what lets an account's two pages share its header: classifying a row on the overview
updates the totals above it, and switching to the imports costs no request.
The dashboard answers "how are we doing this month": what the household spent together, what its accounts hold, where settling up leaves you, and the month's shared costs in full. It opens on the month in progress and any earlier month is a choice away. Settling up moved off it and onto a page of its own — it is a monthly reckoning rather than a daily glance, and sharing the page made both harder to read.
The four tiles cost three requests, two of which are shared: the account balances come from
the same useAsyncData key the account pages use, so arriving from either of them is free,
and the settlement figures reuse the settlement page's key whenever the months agree.
An account is two pages behind one header, which keeps its name, what it holds and the two tabs in place while you move between them. Overview answers what the account did: what it spent and what was paid into it over a month, a year, or its whole life, with the transactions behind both figures underneath. Spending is split by whose it was — the member's own, the household's, and whatever is still unreviewed, which is shown only while there is any and is what makes the three add back up to the total. What it says the household's came to is the same figure settling up divides — reviewing, noting and categorising all happen there, on the rows the figures are made of. Imports is where files go in: upload one, and click any file in the history to open the rows it brought with it, below the list rather than over the top of it.
The figures always describe exactly the rows on show. Narrowing to a month, or to what still needs reviewing, moves both together, which is why the ledger is totalled where it is read rather than taken from the account's own running totals — those describe the whole account and would quietly disagree with a filtered list. The period choices are only periods the account has something in; a month it was quiet through is not worth offering.
Each list answers the same two questions as the accounts on it, added up: what they spent and
what was paid into them, over a month, a year, or all of it. AccountTotals draws that pair in
all three places, and it divides differently by kind — a member's own accounts split their
spending by whose it was, since that is what settling up turns on, while the household's
own accounts split what came in, one member's contribution at a time. There are no
transactions on the lists, and no balances either: the rows are read on the account they
belong to, and so is what it holds. What a list row carries is its name, how much is in it to
read, and a chevron — it is a way through to the account, and looks like one.
The dashboard keeps the two figures that say where the household stands — what it spent together, and what settling up leaves you owing — and nothing else. Account balances live on the lists that itemise them, and the month's rows were a long scroll of a joint card's shopping on a page nobody reads for that.
Two figures both called spending sit near each other and answer different questions, so it is worth being plain about which is which. The dashboard's is what the household shares: everything marked common, wherever it was paid from, including a member's own card. The common accounts list is what those accounts spent, whoever it was for. One August: −14 117,97 shared against −13 549,97 out of the joint accounts, the difference being what members covered from their own cards.
The two kinds of account get a page each rather than one mixed list, because the difference
between them decides who sees what and is worth being unmissable. Each page lists only its
own kind and carries the add form for it, so adding an account is a choice of page instead
of a dropdown to misread; AddAccount.vue is that form, with the kind fixed by the page it
sits on. Both pages read the one list the API returns and share its useAsyncData key, so
switching between them costs no request and adding on either refreshes the other. The older
/accounts link redirects to the personal page.
The Nuxt app runs as a SPA (ssr: false). Every page is behind the login and the session
cookie belongs to the API's origin, so the Nuxt server can never render a signed-in page
correctly; rendering it anyway produced markup that disagreed with the client and left
event handlers unbound after hydration.