New
Apps 1,895Makers 1,890This week 1,690
Promote your app−30%
Reckon screenshot 1
0views in
the last 30 days

Reckon

Self-hosted invoicing and time tracking for a small trade business

Built with Claude Code

by tylervigario · Unclaimed · from USUnited States · added Oct 29, 2026
no ratings0 comments

Jesus is Lord.

Invoicing and time tracking, self-hosted, for a small trade business. Given freely, and built so that anybody could run it — nothing about one operator is a constant in the code.

To reckon up an account is to settle it, and settling is the point: a ledger, an invoice and a timesheet that disagree are three problems rather than one system.

Every client here is invented

The seed, the guard suite, the schema's comments and the docs describe Kestrel Field Services, a business that does not exist, and its clients, who do not either. No real client's records are in this repository, in any commit: they are rows in the operator's own database. Addresses are public places, chosen because CDTFA answers for them, so every rate and district in the seed is a real answer for a real address.

The reasoning is kept beside the rule. The schema says why each rule exists, table by table, and tests/db/constraints.sql proves it both ways, against an invented case.

Where things are

reckon is a SvelteKit project with Drizzle, laid out as both lay one out.

src/lib/server/db/schema/ the schema, in TypeScript, a file per area, with every table's reasons beside it
drizzle/ the schema as applied SQL: generated from the above, and hand-written migrations for whatever Drizzle can't say
src/lib/server/db/migrate.ts brings a database up to date, which the server does as it starts
src/lib/server/valuation/ what an hour bills, what it pays, what a retainer covers, and the tax split — exact decimals, tested
tests/db/constraints.sql proves the guards, both ways
tests/ the behaviour checks, which drive the built app in a browser against an invented business
scripts/ the commands: adding people, refreshing tax rates, migrating, proving the guards, printing, making a release
docs/schema/*.drawio the data model: an overview and eight clusters
docs/schema/regenerate.py lays the diagrams out from one definition of the tables; overwrites hand edits
docs/schema/make-printable.py derives the printed reference from the diagrams
docs/schema-reference.typ generated — do not hand-edit
third_party/press/ press, the Typst library, fonts and tools the documents are set in, installed from its release

The database

npm run db:test                              a scratch database, brought up to date, every guard proven, dropped
npm run db:migrate -- <database>             bring a database up to date
npm run db:seed -- <database>                the invented business the checks run against
npm run db:generate -- --name what-changed   the migration for a change to the schema

The server brings its database up to date as it starts, before it answers anything: every migration in drizzle/ the database has not seen, by Drizzle's migrator, each in a transaction. One that fails stops it starting, so nothing is ever served from a database the code does not expect. npm run db:migrate does the same for a database named on its command line.

Where the database can hold a rule about what may be stored rather than trusting the application to remember it, it does — and every guard is tested both ways, because one that also blocks ordinary use is a bug rather than a guard. What a stored row is worth is the application's: it is arithmetic, and it is done once, in #lib/server/valuation.

The schema is changed in src/lib/server/db/schema/, and the migration written from it with npm run db:generate. What Drizzle cannot express — the triggers that freeze a sent invoice and keep the history, one foreign key, the roles every operator starts with — is in the hand-written drizzle/0001_integrity.sql, with its invoice guards made to hold in 0002_sent_invoices_hold.sql and a file described in the history rather than copied into it in 0003_history_describes_binary.sql. A new one starts as npm run db:generate -- --custom --name what-changed.

The commands connect the way every other Postgres client does — PGHOST, PGPORT, PGUSER, PGPASSWORD — and add nothing of their own. Whoever owns a database owns everything a migration makes, since the owner is who the application connects as; npm run db:migrate, run by another role, acts as the owner.

Afterwards nothing in public or drizzle — where the record of applied migrations is kept — may be owned by anyone else, or it stops. An object made by another role is invisible to the application, and that surfaces as a permission error from whichever query reaches it first — a long way from the cause.

The application

/                what is owed, what is ready to send, and what needs a decision
/timesheet       the running timers and today's entries
/timesheet/all   every entry in a month
/clients         every client; /clients/:id is one, with its sites and agreements
/invoices        drafts, sent and paid
/settings        everything about the operator
/invoice/:token  one sent invoice, for the client: opens without signing in

A draft is sent from its own Send screen, which dates it, gives it its due date and its link, and hands the link to the phone's share sheet or the clipboard. The link is the only thing that opens a page without signing in, and it opens that invoice alone: what it asks for, what is still owed on it, and who it is from. Nothing of how the work was done is on it. Paid is not a status: an invoice is paid when what is owed on it, less payments and credit notes, comes to nothing.

Timers run in localStorage, several at once — one person can be on a job while another is on something else, and one phone tracks both. They survive a refresh, because a timer a reload silently ends is worse than no timer: you find out hours later. Nothing reaches time_entry until one stops. Stopping writes its entry to the same queue as everything else, dated the day it started and carrying the timer's own id as the entry's client_uuid — and the timer is let go only once that write has committed.

The queue is in IndexedDB, and nothing in it is thrown away. An entry leaves when the server has it or when someone discards it by hand. One the server refuses for good — it names a service deleted after it was recorded offline — is kept with the server's reason, shown on Time as not saved, and fixed or discarded there.

DELETE /api/time/:id removes an entry. One an invoice was built from cannot be removed: invoice_line.time_entry_id is ON DELETE RESTRICT. What is removed is written to record_history in full, because a time entry is what an invoice is built from.

A calendar date is a string, 2026-03-14, all the way through. A JS Date is a moment, and a date read into one at UTC midnight renders as the day before west of Greenwich. So the schema's date columns say mode: 'string', and the client turns off the driver's own parsing of DATE, which a date computed in SQL would otherwise go through.

Mobile first, because a timer is used standing in a server room: one column, a thumb-reach tab bar, and a sidebar only once there is room for one.

One accent colour, and it is the operator's. operator.accent_colour replaces reckon's muted default when it is set, and the logo is served from the same row; green, amber and red are kept for state and never for decoration. With no operator saved, the shell is titled reckon.

Signing in

Better Auth, over the application's own database. Sessions are rows in session, not stateless tokens. One server, one database, and every page reads it anyway — so a stateless token would save no round trip and would cost the thing that matters: a session that can be revoked now.

The cookie is the session's token signed with BETTER_AUTH_SECRET, so a copy of the table lets nobody in without the secret as well. Passwords are argon2id at the OWASP minimum rather than Better Auth's default scrypt, and the hash records its own parameters, so raising them later re-hashes on the next sign-in rather than locking anyone out. Five wrong passwords from one address in fifteen minutes and that address waits; the limit is per address and never per account, so it says nothing about which addresses have one.

There is no sign-up. Everyone who signs in is added from the command line:

node scripts/user.mjs add you@example.com "Your Name" --role Partner
node scripts/user.mjs password you@example.com
node scripts/user.mjs list

It prompts for the password rather than taking an argument — a password in history or in ps is a password given away — and ends every existing session for that person, because a password change that leaves old sessions alive has changed nothing for whoever holds one.

Twelve characters at least, checked on the first entry rather than after the second. On a development database, --insecure lifts that and says so:

node scripts/user.mjs password avery@kestrel.example --insecure   # the seeded user

BETTER_AUTH_SECRET must be set when the server runs, 32 characters at least, or it will not start. Left to itself Better Auth would sign with a default that is printed in its own source.

The session cookie is Secure unless SECURE_COOKIES=false, so a browser only ever sends it over https. Turning that off is for a proxy that serves plain http, and what it costs is the session token crossing that network in the clear — the server says so in its log every time it starts.

Serving it

There is no address to configure. The server reads its own from each request — the Host header, over https — so the address a form is checked against is always the one the browser used. A configured address can disagree with that, and when it does every form post is refused as cross-site, sign-in included.

What that asks of whatever is in front of it:

set
https, through a reverse proxy that passes Host through the default nothing
https, through one that puts the public name in another header HOST_HEADER, e.g. x-forwarded-host
plain http, through a proxy PROTOCOL_HEADER=x-forwarded-proto and SECURE_COOKIES=false
plain http straight to the process, no proxy not supported —

The last is SvelteKit's limit rather than reckon's. With nothing in the request to say the scheme, adapter-node takes it to be https, and a form posted from an http page reads as cross-site. The only way round that is building the address into the artifact, which makes it one build per deployment.

Behind any proxy, say where the visitor is. The process has one client — the proxy — so without ADDRESS_HEADER every visitor has the proxy's address: the per-address sign-in limit becomes one limit for everyone, five wrong passwords from anyone lock everyone out, and every session records the proxy. Name a header the proxy sets from the connection. One it overwrites, like an X-Real-IP set from the connection's address, needs nothing else. X-Forwarded-For is one a client can start, so it also needs XFF_DEPTH: the number of proxies in front, 1 for one.

A release

A release is one tarball, built by npm run release and published with its provenance attested: the part of this repository that runs, at the same paths it has here, with its production dependencies installed, so a host needs Node and nothing else.

build/ the application; node build runs it
drizzle/ the migrations, which the application applies as it starts
scripts/user.mjs adds the people who sign in
node_modules/ the production dependencies, from npm ci --omit dev
package.json and package-lock.json, as they are in the repository
RELEASE the version, the commit, and the Node it was built with
MANIFEST.sha256 every file's digest, so what is installed can be checked

Run it from its own directory — a service unit's WorkingDirectory — since the migrations are read from drizzle/ there. A deploy is a backup of the database, the release unpacked, and the server restarted: it brings the database up to date before it answers anything.

Environment

Everything the process needs, and nothing that belongs in the database. src/env.ts declares each variable with its default and its check, and every one is read when the server starts rather than built in, so the same artifact runs on any host with any values. A value that fails its check stops the server at start with the reason.

.env.example is the same list as a file, and is what to copy to .env for local development, beside vite.config.ts, where Vite reads it.

BETTER_AUTH_SECRET required signs every session cookie; 32 characters at least, random — openssl rand -hex 32. Without it the server does not start. Changing it signs everybody out
SECURE_COOKIES true false only behind a proxy that serves plain http — see Serving it
PORT 3000
HOST 0.0.0.0 set it to 127.0.0.1 where a reverse proxy is the only route in, so the bind enforces that rather than convention
PROTOCOL_HEADER, HOST_HEADER unset read by adapter-node: the headers a proxy uses for the scheme and the public name, where it does not pass them as https and Host — see Serving it
ADDRESS_HEADER, XFF_DEPTH unset read by adapter-node: the header holding a visitor's address. Needed behind any proxy — unset, every visitor is the proxy — see Serving it
PGHOST /var/run/postgresql a unix socket, which peer-authenticates rather than asking for a password
PGDATABASE reckon_dev
DATABASE_URL — wins outright over PGHOST/PGDATABASE, for TCP, another machine, or a managed service
PUBLIC_GOOGLE_MAPS_API_KEY unset the browser key. Address lookup; unset, no address can be chosen, so none is saved
GOOGLE_MAPS_API_KEY unset the server key. Confirms a chosen place, and measures a site's drive and a trip's miles; unset, no address is saved and miles are typed

Two keys, because a Google API key carries exactly one application restriction. It can be restricted to HTTP referrers or to IP addresses, never both — so the key a browser uses and the key this server uses cannot be the same key without one of them being unrestricted.

PUBLIC_ is deliberate: that key reaches the browser. public: true in src/env.ts is what sends it there; the prefix is what tells whoever fills in the environment. Address lookup is a type-ahead, and the browser calls Google directly. Proxying it through here would put the browser-to-server leg in front of every keystroke — over whatever uplink the host has, from a phone, in the places this is used. Direct is one hop to an edge that is near everybody.

Restrict it to HTTP referrers for this deployment's origin, and to two APIs: Maps JavaScript API and Places API (New).

The JavaScript one is not optional, and it is why the lookup goes through the SDK rather than fetch. Google applies referrer restrictions to the JavaScript API because browsers send the header; a direct web-service call wants an IP restriction instead, which cannot mean anything when the caller is somebody else's browser. A key calling places.googleapis.com from a page is unrestrictable — the worst of the options, and not obviously so.

GOOGLE_MAPS_API_KEY is private in src/env.ts and never reaches a page. Restrict it to IP addresses for this host, and to three APIs: Address Validation API; Places API (New), which confirms that a place id a browser sent is a place; and Routes API, which gives each drive of a trip being recorded its miles, and a site its round trip and drive time whenever its place is chosen — every site's at once when the business's is. Each is used once per address or once per change to a trip's stops, rather than once per keystroke, so the round trip that ruled out proxying the autocomplete does not apply. A trip's route is one request at the Essentials tier, ten places between its first and last at most, and no traffic; a site's is one request, from the business by the site and back.

A host with IPv6 reaches Google over it whenever it can, from addresses that change within its prefix. Restrict the key to that range — its /64 — as well as its IPv4 address: a key that admits only one refuses the other, and every address is refused with it.

An address is chosen, never typed. Wherever one is looked up — a site's, the business's own — it is one field: what is typed asks Google for suggestions, choosing one saves it, and leaving without choosing changes nothing. The server a

Description taken from the repository README.

Ratings

–
0 ratings
5 0
4 0
3 0
2 0
1 0

Rate this app

Sign in to rate

Comments 0

Sign in to comment

No comments yet.

Related apps

#invoicing · #time-tracking · #self-hosted