Zum Inhalt springen
Familienkonto

Overview

Familienkonto is an expense-and-settlement ledger for a family, with Austrian family-allowance reporting on top. It is written in Jennifer and stores its data in MariaDB.

This manual as a PDF - every technical page in one file. The German handbook for the people who use the application is a separate download: Handbuch.

Shape of the code

src/
  core/    decisions and values: money, dates, currency, roles, the ledger,
           coverage, the notification catalogue, the message catalogue. No
           database; testable on a bare machine
  store/   everything that talks to MariaDB: the schema and its migrations,
           people, expenses, income, settlements, attachments, the CSV import
  report/  reads the store and writes documents: reports and the tax bundle
  app/     the layer the entry points stand on: authentication, demo data
  testing/ fixtures, used only by tests
web/     the HTTP interface: routes, handlers, HTML rendering
bin/fk   the command-line tool

The dependencies inside src/ run one way - core knows nothing of store, store knows nothing of report or app - which is what keeps the pure arithmetic testable without a database and stops a reporting question from reaching into the schema.

docker/  the image
docs/    this documentation

Everything of consequence lives in src/. web/ and bin/fk are two front ends over the same modules, which is why the rules cannot differ between them: a child is refused the review queue in the browser and on the command line by the same predicate.

Layers

LayerModulesDepends on a database?
Pure domainmoney dates currency ledger coverage roles notify bankcsvno
Configurationconfigno
Persistencedbyes
Repositoriesusers expenses incomes receipts settlements notifications mailqueue audit bankimportyes
Reportingreports taxexportyes
Accessauthyes
Front endsweb/ bin/fkyes

The pure layer is where the arithmetic lives, and it is deliberately ignorant of storage. ledger.j decides who owes whom, coverage.j decides whether the family-allowance threshold is met, and neither can be told otherwise by a query. Their tests run without MariaDB.

Modules worth reading first

  • ledger.j - the settlement model in about 250 lines of arithmetic. If you read one file, read this one; everything about balances follows from it.
  • coverage.j - the family-allowance rule, in exact integers.
  • expenses.j - how a payment becomes shares, and what approval means.
  • auth.j - who is asking, and why a header is not evidence.

Design decisions that shaped everything else

JavaScript is avoided, not forbidden. The rule is: do it on the server if the server can do it, and reach for a script only where leaving it out costs the user something real - then keep it to the smallest thing that buys that back. Every page is a server-rendered form, the dark mode is a CSS media query and a cookie, the split calculator is arithmetic done on POST. The application therefore works in any browser, has no build step for its front end, and cannot leak a session to a script somebody injected.

Nothing may depend on a script. Each one is a convenience laid over a page that already works without it, the server re-derives whatever the script computed, and every check happens where it always did.

What has met that bar so far:

  • the payment total under /settlements (SETTLE_SCRIPT, ~30 lines, inline). The amount is the sum of the ticked open items; ticking lines without seeing the total is a hole where the important number should be. With scripting off the box stays empty and means "exactly what is ticked", which is what the server books either way.
  • the photograph on the last quick-entry screen (QUICK_SCRIPT). It scales the picture to 1600 pixels on the long edge before sending it and puts a spinner in the button. A phone camera produces eight to twelve megapixels over a shop's mobile connection, and the server scales the picture down on arrival anyway - so those bytes buy nothing and cost the minute in which somebody gives up. Every failure mode falls back to uploading the original: no script, no DataTransfer, no canvas, an image the browser cannot decode.

What has not, and why the bar is worth having:

  • no progress indicator on the ordinary upload form, only on the quick entry. An identical file is refused with a reason, and the form states the size limit.
  • no client-side resizing outside the quick entry, so the scaling happens on the server after the whole file has arrived - see Deployment.
  • the Finanzamt checkbox cannot follow the category as it changes, so the form offers three states and resolves the default on submit.

A test walks the ordinary pages and asserts none of them carries a <script> at all, so "avoided" stays measurable rather than aspirational.

The quick entry is a separate answer to the same problem. /quick asks one question per screen, with no navigation and no header, and is installable on a phone's home screen through a web app manifest. It writes through the same expenses.create as the long form - same shares, same open bearer, same review queue - because a second way in that books something slightly different is worse than no second way in. Its own script is described above and, like the other one, nothing depends on it.

Money is integer cents, never a float. Expense totals, shares and balances have to reconcile exactly; a float drifts. money.splitByWeights uses the largest-remainder method so a split always sums back to the original.

The database runs in a strict SQL mode, applied through the DSN so it reaches every pooled connection. A value a column cannot hold is an error, not a silent truncation. See Data model.

Roles are held per family, not per person. The same person can be the master of their own household and merely a parent in another. Capabilities are explicit sets rather than a rank ladder - see Domain model.

Nothing is created from an identity-provider header. Authelia says who someone is; it never grants them a place in a family's books.

The audit trail is a family's decision, not the installation's. store/audit.j records who changed what, and records nothing at all unless households.audit_enabled is set. Two consequences shaped the module: audit.record never throws - bookkeeping must not fail because the record of it could not be written, so a failure goes to the log and the action stands - and switching the trail off keeps every existing row, because a trail that could be erased by flicking a switch twice is not a trail. Page views are not events; only creations, corrections and deletions are. In web/app.j every call site goes through one helper, noted, so an entry cannot disagree with the write it describes about who was acting for whom.

Errors are written for the person who will read them, in German, and are shown as they stand. The application has no "an error occurred" page.