Build Tallymeter
KINDAreplaces $10.38/mosaves $124.56/yrback to the verdict
A solo time tracker and invoicer whose hard problems are durations that stay correct across a clock change, a rounding rule chosen once and recorded on every invoice it produced, and a number sequence that survives concurrency, cancellations and the year somebody audits you.
Before step 1
Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.
- installfree
Why Runs the tracker and the renderer.
Get it nodejs.org or your version manager. open ↗
Verify
node --version prints v22 or higher - installfree
Why Invoices are documents other people print, and a renderer that reflows differently between versions is a reprint that does not match.
Get it Install a headless browser package and pin its version.
Verify
The same invoice renders to the same checksum twice - have readyfree
Why The number sequence, the required fields and the retention period all come from these, and getting them from a blog post rather than the tax authority is how an invoice ends up invalid.
Get it Read the tax authority's own guidance on invoice contents and record keeping, and note the date. open ↗
Verify
The required fields list and the retention period are recorded in the README with their source - have readyfree
Why These are snapshotted onto every invoice, so having them wrong on day one means every invoice issued before you notice is wrong in a way you cannot edit.
Get it Collect the exact wording your bank and tax authority expect.
Verify
A test invoice shows them exactly as they should appear - decidefree
Why Per-line and per-total rounding give different totals, and choosing after you have issued invoices means two eras of arithmetic in the same ledger.
Get it Read the rule that applies to you, choose one, and record the choice and the source.
Verify
The configured rule matches the README and appears on every issued invoice
Data model
Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.
- clients(id, name, address_lines, tax_number, country, created_at) - projects(id, client_id, name, rate_minor_per_hour, currency, active) · the current rate, which is not what old invoices read from - entries(id, project_id, description, started_at_utc, ended_at_utc, seconds, source, actor_token_id, invoice_id, frozen_at) · seconds as an integer, instants in UTC, and the actor recorded - invoice_series(code, prefix, next_number, last_issued_at) · the number is claimed inside the issuing transaction and nowhere else - invoices(id, series_code, number, issued_at, client_snapshot_json, currency, rounding_rule, tax_rate_ppm, subtotal_minor, tax_minor, total_minor, state, void_reason, voided_at, pdf_path, pdf_checksum) - invoice_lines(invoice_id, ordinal, description, seconds, rate_minor_per_hour, amount_minor) · snapshotted values, never joined back to projects - api_tokens(id, label, scope, created_at, last_used_at, revoked_at) · so an automated agent logging its own hours is attributable on the invoice - An issued invoice references nothing that can change. Every figure printed on it is stored on the invoice or its lines.
Environment variables
These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
DATABASE_PATH | required | ./data/tally.db | Clients, projects, entries and invoices. The only record of your billing history. |
INVOICE_DIR | required | ./invoices | Rendered PDFs, named by series and number. Backed up with the database. |
INVOICE_SERIES | required | INV-2026 | The default series prefix. Additional series are rows, not configuration. |
ROUNDING_RULE | required | per_total | Recorded on every invoice it produces. Changing it does not alter issued invoices. |
TAX_RATE_PPM | required | 200000 | Parts per million, snapshotted onto each invoice at issue. |
RECORD_RETENTION_YEARS | required | 6 | Deletion inside this window is refused by the code, not just discouraged. |
BACKUP_DIR | required | ./backups | Nightly copies, restored into a scratch database on a schedule. |
API_TOKEN_SALTsecret | required | redacted | Hashes scoped tokens so the database never holds a usable one. |
The build, in order
One timer, and durations that are not opinions
Time is recorded as UTC instants and whole seconds, exactly one timer runs, and a clock change does not alter a duration.
A duration derived from wall-clock times is wrong twice a year.
Files
migrations/002_entries.sqltests/dst-duration.test.tsTwo running timers is a billing dispute waiting to happen.
Files
src/timer/start.tstests/single-timer.test.tsOnce billed, the entry is evidence rather than a note.
Files
src/entries/edit.ts
done when · tick each as it passeswatch out- Storing a duration as decimal hours loses information immediately, because three hours and twenty five minutes is not representable as a terminating decimal, and a week of those rounded figures produces an invoice total that disagrees with the entries it came from. Store whole seconds, compute decimal hours only when rendering a line, and never store the rendered figure.
Choosing the rounding rule once
The way money is rounded is a decision recorded on every invoice, not an accident of the order things were summed.
Decimal money in the application layer is how pennies vanish.
Files
src/money/minor.tstests/money.test.tsNext year you will not remember which one you chose.
Files
migrations/003_invoices.sqlIf your fixture cannot tell them apart it is not testing anything.
Files
tests/rounding.test.ts
done when · tick each as it passeswatch out- Rounding each line to the penny and summing gives a different total from rounding the sum, and since both look right in isolation the disagreement only appears when a client's accounts payable system computes it the other way and short-pays you by two pence. Pick one rule, store it on the invoice, and write a test with a set of lines where the two approaches genuinely differ.
A sequence with no holes and no collisions
Numbers are allocated at issue, inside one transaction, and a cancelled invoice stays as a voided record.
Select-then-update is the collision, drafts are the gap.
Files
src/invoices/issue.tstests/concurrent-issue.test.tsThe record of a mistake is part of the record.
Files
src/invoices/void.tsAn entry billed on one invoice must not appear on another.
Files
src/invoices/issue.tstests/no-double-bill.test.ts
done when · tick each as it passeswatch out- Allocating the number when a draft is created leaves a hole in the sequence every time you abandon one, and the hole is exactly the thing an inspector asks about, while allocating it with a select followed by an update lets two invoices issued in the same second take the same number. Claim inside the issuing transaction, and never allocate to anything that has not been issued.
A PDF that still says the same thing next year
Reprinting an old invoice reproduces the original exactly, byte for byte where possible.
A join to a live table is a time bomb with a client's name on it.
Files
src/invoices/render.tstests/reprint-stability.test.tsSo a changed reprint is loud rather than silent.
Files
src/invoices/render.tsThe accountant's first two questions, answered on the page.
Files
src/invoices/template.html
done when · tick each as it passeswatch out- Rendering the PDF by joining back to clients and projects means a client who moved office or a project whose rate went up silently changes what last year's invoice says, and you will not find out until somebody compares your copy with theirs. Snapshot everything the document prints onto the invoice at issue, and render only from that.
Tokens, retention and a restore you have actually doneproduct builder
Automated time logging is attributable, records survive the statutory period, and the backup has been restored at least once on purpose.
Attribution on the line is what makes the hour defensible.
Files
src/auth/tokens.tstests/attribution.test.tsThe code should know the rule, not just the README.
Files
src/invoices/delete.tstests/retention-refusal.test.tsThis file is the only record of what you are owed.
Files
scripts/restore-check.shdocs/operations.md
done when · tick each as it passeswatch out- An automated agent logging hours through the same unauthenticated local endpoint you use yourself means an invoice line nobody can account for, and the conversation with the client about it is unwinnable because your own system cannot say where the hours came from. Give automation its own scoped token, record it on the entry, and print the attribution on the line.
That is the whole plan for Tallymeter. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- Payment collection and reconciliation.
- Multi-user workspaces and per-member totals.
- Two-way worklog synchronisation with an issue tracker.
- Automatic tax determination across jurisdictions.
- two-way Jira Cloud worklog sync that reconciles ticket by ticket
- a hosted MCP server and scoped API tokens agents can call from anywhere, with per-agent attribution
- invoice numbering, VAT and bank details that survive an accountant handoff
- shared team workspaces and per-member unbilled totals
- managed hosting, nightly backups and a phone-sized UI for starting a timer away from your desk
- Credit notes as a first-class document type in their own series, which is the correct way to fix an issued invoice
- A per-client statement showing issued, paid and outstanding, computed from invoices rather than from a status column
- A sequence audit report that lists every number in each series and explains every void, which is the document you want before an inspection rather than during one
Need the files? The project pack on the verdict page hands your agent the whole brief · more finance & accounting.