Vibecode Tallymeter
track this build4 phases, 12 steps, beginner friendly0%The solo core is a weekend build and nobody should pretend otherwise: a timer, projects with hourly rates, and uninvoiced hours rolled into a numbered PDF. What survives the rebuild is the boring half · two-way Jira worklog sync that reconciles ticket by ticket without double-counting, a hosted MCP server and scoped API tokens your agents can reach from any machine, and invoice numbering plus VAT and bank details that hold up when an accountant reads them. Full disclosure: this entry was submitted by the person who builds Tallymeter. Same rubric as everything else on the list, and the honest answer is that a freelancer billing one client can absolutely one-shot the part they use.
You are building a lean indie version of Tallymeter. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Tallymeter · indie build 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. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with Express, server-rendered pages on the loopback address | one process, no accounts, and your billing history stays on your machine | | Storage | SQLite with amounts as integer minor units and durations as whole seconds | the two representation decisions that cannot be undone later without reconciling every invoice | | Numbering | A series table whose next number is claimed inside the issuing transaction | two invoices issued a second apart must never collide, and the sequence must not have holes | | Rendering | PDF generated from the invoice snapshot, never from a live join | reprinting last year's invoice must produce last year's invoice | | Access | Scoped tokens for anything automated, with the actor recorded on every entry | if something else logs time on your behalf, the invoice should say which something | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the tracker and the renderer. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **A headless browser or PDF renderer** · free - 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 - [ ] **Your jurisdiction's invoice content and record-keeping rules** · free - 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. - Verify: The required fields list and the retention period are recorded in the README with their source - [ ] **The bank details and tax registration you invoice under** · free - 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 - [ ] **The rounding rule, decided and written down** · free - 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 ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - 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 If one of those is essential to you, that is the reason to keep paying for Tallymeter, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Tallymeter The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a time tracker that turns tracked hours into numbered invoices an accountant can read a year later to replace Tallymeter. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero An invoice is a legal document, and a time tracker that issues invoices inherits that. Three things follow. The number is a sequence, not a label. Article 226 of the EU VAT Directive requires an invoice to carry 'a sequential number, based on one or more series, which uniquely identifies the invoice', and equivalent wording appears in national rules. So a number is allocated when an invoice is issued and never when a draft is created, it is never reused, and an issued invoice that turns out to be wrong is voided as a kept record rather than deleted. A gap in the sequence is a question somebody will eventually ask you. An issued invoice is immutable. The rate, the hours, the tax rate, the rounding rule and the bank details are snapshotted onto it at the moment of issue, so raising a project's rate in April does not quietly rewrite what you billed in March. Corrections happen by issuing a credit or a replacement, never by editing. And keep the records. HMRC's guidance says VAT records must be kept for at least 6 years, or 10 years under the VAT One Stop Shop scheme, and that this includes copies of all invoices you issue. A retention policy that deletes old invoices is not tidying up. Check the rule for your own jurisdiction, write the figure into configuration, and let the code refuse to delete anything inside it. ### Stack (fixed, do not substitute) - Runtime: Node 22 with Express, server-rendered pages on the loopback address · one process, no accounts, and your billing history stays on your machine - Storage: SQLite with amounts as integer minor units and durations as whole seconds · the two representation decisions that cannot be undone later without reconciling every invoice - Numbering: A series table whose next number is claimed inside the issuing transaction · two invoices issued a second apart must never collide, and the sequence must not have holes - Rendering: PDF generated from the invoice snapshot, never from a live join · reprinting last year's invoice must produce last year's invoice - Access: Scoped tokens for anything automated, with the actor recorded on every entry · if something else logs time on your behalf, the invoice should say which something ### Data model (create this before Phase 1) - 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. ### Phase 1 · One timer, and durations that are not opinions Build: Store started_at and ended_at as UTC instants and compute seconds as an integer difference. Starting a timer stops any running one in the same transaction. Allow editing a finished entry's description, and adding one after the fact with a duration in minutes for the call you forgot. Reject any edit to an entry that is already attached to an invoice. Trap: 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. Done when: A timer started and stopped across a daylight saving transition records the elapsed seconds correctly, tested with the transition in the fixture. Starting a second timer stops the first in the same transaction and two running timers are impossible, tested by firing both concurrently. An entry attached to an invoice rejects every edit with a named reason, tested. No column anywhere stores decimal hours, verified by review of the schema. Do not build yet: No rates, no rounding, no invoices yet. ### Phase 2 · Choosing the rounding rule once Build: Keep rates and amounts in integer minor units. Decide whether tax is computed per line and then summed, or computed once on the total, and store the choice on each invoice as rounding_rule. Note that HMRC's internal guidance describes rounding down the total VAT to a whole penny as a concession aimed at invoice traders where it is tax neutral, and that it is not appropriate for retailers, so read the rule that applies to you rather than copying one. Trap: 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. Done when: A fixture of line amounts where per-line and per-total rounding differ produces the documented result for the configured rule, tested for both rules. Every invoice stores the rounding rule that produced its figures and the PDF states it, checked by review. The tax rate is stored in parts per million on the invoice rather than referenced from configuration, tested by changing the configured rate and regenerating an old invoice. All arithmetic is integer, asserted by a test summing a large set of lines against an independent total. Do not build yet: No issuing, no numbering, no PDF yet. ### Phase 3 · A sequence with no holes and no collisions Build: Claim the next number from invoice_series inside the same transaction that writes the invoice and its lines and stamps invoice_id onto the entries. Do not allocate a number to a draft. Cancelling an issued invoice sets it to void with a reason and a timestamp, keeping the number and the lines. Show voided invoices in the list rather than hiding them. Trap: 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. Done when: Two invoices issued concurrently receive different consecutive numbers and neither transaction is lost, tested by issuing both inside one test. Abandoning a draft consumes no number and leaves no gap, tested. A voided invoice keeps its number, its lines and its entries association, and the entries do not become billable again unless explicitly released, tested. A second series can be added and numbers within each series remain independent, tested. Do not build yet: No PDF, no export, no tokens yet. ### Phase 4 · A PDF that still says the same thing next year Build: Render the PDF from the invoice snapshot and its lines only, touching no live client or project row. Store the file path and a checksum of the rendered bytes on the invoice. Reprinting compares the new checksum against the stored one and warns loudly if they differ. Include the sequence number, the tax breakdown, the rounding rule and the bank details from the snapshot. Trap: 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. Done when: Changing a client's address and a project's rate leaves an already-issued invoice's PDF unchanged, tested by regenerating and comparing checksums. Regeneration that produces a different checksum raises a warning naming the fields that differ rather than overwriting quietly, tested. The PDF shows the sequence number, tax breakdown, rounding rule and bank details, all read from the snapshot, checked by review. A voided invoice renders with its void reason and date visible. Do not build yet: No tokens, no backups, no export yet. ### Phase 5 · Tokens, retention and a restore you have actually done Build: Issue scoped tokens for anything that logs time without a person present, record the token on every entry it creates, and show the attribution on the invoice line. Refuse to delete any invoice inside the configured retention period. Back up nightly, restore into a scratch database on a schedule, and compare the invoice count and the sum of totals. Export every table as CSV alongside the PDFs. Trap: 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. Done when: An entry created through a token records that token and the invoice line shows the attribution, tested. Revoking a token stops new entries immediately and leaves existing ones intact with their attribution, tested. Deleting an invoice inside the retention window is refused with the configured period named, tested at the boundary. A scheduled restore into a scratch database reproduces the invoice count and the sum of totals exactly, and the check fails loudly when it does not. Do not build yet: Nothing after this. ### Out of scope (and why) - Payment collection and reconciliation, which brings a payment processor, refunds and disputes into a build whose point is producing a correct document. - Multi-user workspaces and per-member totals, which need a permissions model and change who is allowed to issue a number in the sequence. - Two-way worklog synchronisation with an issue tracker, which is a reconciliation problem in its own right and is the fastest way to double-bill an hour. - Automatic tax determination across jurisdictions, because the rate and the rules depend on facts about both parties that this build does not hold. ### README must contain - That invoice numbers are allocated at issue inside one transaction, never to drafts, and never reused - That an issued invoice is immutable and that corrections are issued as a credit or a replacement - Which rounding rule is configured, why it was chosen, and that it is recorded on every invoice it produced - The retention period the code enforces, where it came from, and the instruction to check it for your own jurisdiction - That the database file is the only record of your billing history, and exactly how to restore it ===== AGENTS.md ===== # Agent instructions · Tallymeter indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with Express, server-rendered pages on the loopback address, SQLite with amounts as integer minor units and durations as whole seconds, A series table whose next number is claimed inside the issuing transaction, PDF generated from the invoice snapshot, never from a live join, Scoped tokens for anything automated, with the actor recorded on every entry. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - 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. - 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. - 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. - 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. ===== BUILD_PLAN.md ===== # Build plan · Tallymeter 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · 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. ### Steps 1. Store instants and integer seconds A duration derived from wall-clock times is wrong twice a year. Files: `migrations/002_entries.sql`, `tests/dst-duration.test.ts` 2. Make starting a timer stop the running one atomically Two running timers is a billing dispute waiting to happen. Files: `src/timer/start.ts`, `tests/single-timer.test.ts` 3. Freeze invoiced entries Once billed, the entry is evidence rather than a note. Files: `src/entries/edit.ts` ### Done when - [ ] A duration across a DST transition is correct - [ ] Two timers can never run at once - [ ] Invoiced entries reject edits - [ ] No decimal hours are stored anywhere ### Watch 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. ## Phase 2 · 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. ### Steps 1. Keep rates and totals in minor units Decimal money in the application layer is how pennies vanish. Files: `src/money/minor.ts`, `tests/money.test.ts` 2. Store the rounding rule on the invoice Next year you will not remember which one you chose. Files: `migrations/003_invoices.sql` 3. Test a case where the two rules disagree If your fixture cannot tell them apart it is not testing anything. Files: `tests/rounding.test.ts` ### Done when - [ ] Both rounding rules produce their documented totals - [ ] Every invoice records the rule that produced it - [ ] The tax rate is snapshotted, not referenced - [ ] All money arithmetic is integer ### Watch 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. ## Phase 3 · 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. ### Steps 1. Claim the number inside the issuing transaction Select-then-update is the collision, drafts are the gap. Files: `src/invoices/issue.ts`, `tests/concurrent-issue.test.ts` 2. Void rather than delete The record of a mistake is part of the record. Files: `src/invoices/void.ts` 3. Stamp entries and freeze them in the same transaction An entry billed on one invoice must not appear on another. Files: `src/invoices/issue.ts`, `tests/no-double-bill.test.ts` ### Done when - [ ] Concurrent issues get distinct consecutive numbers - [ ] Abandoned drafts consume no number - [ ] Voided invoices keep number, lines and entries - [ ] Multiple series remain independent ### Watch 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. ## Phase 4 · A PDF that still says the same thing next year Reprinting an old invoice reproduces the original exactly, byte for byte where possible. ### Steps 1. Render only from the snapshot A join to a live table is a time bomb with a client's name on it. Files: `src/invoices/render.ts`, `tests/reprint-stability.test.ts` 2. Checksum the rendered bytes and store them So a changed reprint is loud rather than silent. Files: `src/invoices/render.ts` 3. Print the rounding rule and the tax breakdown The accountant's first two questions, answered on the page. Files: `src/invoices/template.html` ### Done when - [ ] Changed client or rate does not alter an issued PDF - [ ] A differing reprint warns with the fields named - [ ] The PDF carries number, tax, rounding and bank details - [ ] A voided invoice prints its reason and date ### Watch 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. ## Not in this build - Payment collection and reconciliation. - Multi-user workspaces and per-member totals. - Two-way worklog synchronisation with an issue tracker. - Automatic tax determination across jurisdictions. ## After v1, if you want it - 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 ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Clients, projects, entries and invoices. The only record of your billing history. DATABASE_PATH=./data/tally.db # Required. Rendered PDFs, named by series and number. Backed up with the database. INVOICE_DIR=./invoices # Required. The default series prefix. Additional series are rows, not configuration. INVOICE_SERIES=INV-2026 # Required. Recorded on every invoice it produces. Changing it does not alter issued invoices. ROUNDING_RULE=per_total # Required. Parts per million, snapshotted onto each invoice at issue. TAX_RATE_PPM=200000 # Required. Deletion inside this window is refused by the code, not just discouraged. RECORD_RETENTION_YEARS=6 # Required. Nightly copies, restored into a scratch database on a schedule. BACKUP_DIR=./backups # Required · secret. Hashes scoped tokens so the database never holds a usable one. API_TOKEN_SALT=redacted
You are building a lean indie version of Tallymeter. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Tallymeter · indie build 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. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with Express, server-rendered pages on the loopback address | one process, no accounts, and your billing history stays on your machine | | Storage | SQLite with amounts as integer minor units and durations as whole seconds | the two representation decisions that cannot be undone later without reconciling every invoice | | Numbering | A series table whose next number is claimed inside the issuing transaction | two invoices issued a second apart must never collide, and the sequence must not have holes | | Rendering | PDF generated from the invoice snapshot, never from a live join | reprinting last year's invoice must produce last year's invoice | | Access | Scoped tokens for anything automated, with the actor recorded on every entry | if something else logs time on your behalf, the invoice should say which something | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the tracker and the renderer. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **A headless browser or PDF renderer** · free - 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 - [ ] **Your jurisdiction's invoice content and record-keeping rules** · free - 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. - Verify: The required fields list and the retention period are recorded in the README with their source - [ ] **The bank details and tax registration you invoice under** · free - 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 - [ ] **The rounding rule, decided and written down** · free - 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 ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - 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 If one of those is essential to you, that is the reason to keep paying for Tallymeter, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Tallymeter The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a time tracker that turns tracked hours into numbered invoices an accountant can read a year later to replace Tallymeter. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero An invoice is a legal document, and a time tracker that issues invoices inherits that. Three things follow. The number is a sequence, not a label. Article 226 of the EU VAT Directive requires an invoice to carry 'a sequential number, based on one or more series, which uniquely identifies the invoice', and equivalent wording appears in national rules. So a number is allocated when an invoice is issued and never when a draft is created, it is never reused, and an issued invoice that turns out to be wrong is voided as a kept record rather than deleted. A gap in the sequence is a question somebody will eventually ask you. An issued invoice is immutable. The rate, the hours, the tax rate, the rounding rule and the bank details are snapshotted onto it at the moment of issue, so raising a project's rate in April does not quietly rewrite what you billed in March. Corrections happen by issuing a credit or a replacement, never by editing. And keep the records. HMRC's guidance says VAT records must be kept for at least 6 years, or 10 years under the VAT One Stop Shop scheme, and that this includes copies of all invoices you issue. A retention policy that deletes old invoices is not tidying up. Check the rule for your own jurisdiction, write the figure into configuration, and let the code refuse to delete anything inside it. ### Stack (fixed, do not substitute) - Runtime: Node 22 with Express, server-rendered pages on the loopback address · one process, no accounts, and your billing history stays on your machine - Storage: SQLite with amounts as integer minor units and durations as whole seconds · the two representation decisions that cannot be undone later without reconciling every invoice - Numbering: A series table whose next number is claimed inside the issuing transaction · two invoices issued a second apart must never collide, and the sequence must not have holes - Rendering: PDF generated from the invoice snapshot, never from a live join · reprinting last year's invoice must produce last year's invoice - Access: Scoped tokens for anything automated, with the actor recorded on every entry · if something else logs time on your behalf, the invoice should say which something ### Data model (create this before Phase 1) - 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. ### Phase 1 · One timer, and durations that are not opinions Build: Store started_at and ended_at as UTC instants and compute seconds as an integer difference. Starting a timer stops any running one in the same transaction. Allow editing a finished entry's description, and adding one after the fact with a duration in minutes for the call you forgot. Reject any edit to an entry that is already attached to an invoice. Trap: 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. Done when: A timer started and stopped across a daylight saving transition records the elapsed seconds correctly, tested with the transition in the fixture. Starting a second timer stops the first in the same transaction and two running timers are impossible, tested by firing both concurrently. An entry attached to an invoice rejects every edit with a named reason, tested. No column anywhere stores decimal hours, verified by review of the schema. Do not build yet: No rates, no rounding, no invoices yet. ### Phase 2 · Choosing the rounding rule once Build: Keep rates and amounts in integer minor units. Decide whether tax is computed per line and then summed, or computed once on the total, and store the choice on each invoice as rounding_rule. Note that HMRC's internal guidance describes rounding down the total VAT to a whole penny as a concession aimed at invoice traders where it is tax neutral, and that it is not appropriate for retailers, so read the rule that applies to you rather than copying one. Trap: 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. Done when: A fixture of line amounts where per-line and per-total rounding differ produces the documented result for the configured rule, tested for both rules. Every invoice stores the rounding rule that produced its figures and the PDF states it, checked by review. The tax rate is stored in parts per million on the invoice rather than referenced from configuration, tested by changing the configured rate and regenerating an old invoice. All arithmetic is integer, asserted by a test summing a large set of lines against an independent total. Do not build yet: No issuing, no numbering, no PDF yet. ### Phase 3 · A sequence with no holes and no collisions Build: Claim the next number from invoice_series inside the same transaction that writes the invoice and its lines and stamps invoice_id onto the entries. Do not allocate a number to a draft. Cancelling an issued invoice sets it to void with a reason and a timestamp, keeping the number and the lines. Show voided invoices in the list rather than hiding them. Trap: 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. Done when: Two invoices issued concurrently receive different consecutive numbers and neither transaction is lost, tested by issuing both inside one test. Abandoning a draft consumes no number and leaves no gap, tested. A voided invoice keeps its number, its lines and its entries association, and the entries do not become billable again unless explicitly released, tested. A second series can be added and numbers within each series remain independent, tested. Do not build yet: No PDF, no export, no tokens yet. ### Phase 4 · A PDF that still says the same thing next year Build: Render the PDF from the invoice snapshot and its lines only, touching no live client or project row. Store the file path and a checksum of the rendered bytes on the invoice. Reprinting compares the new checksum against the stored one and warns loudly if they differ. Include the sequence number, the tax breakdown, the rounding rule and the bank details from the snapshot. Trap: 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. Done when: Changing a client's address and a project's rate leaves an already-issued invoice's PDF unchanged, tested by regenerating and comparing checksums. Regeneration that produces a different checksum raises a warning naming the fields that differ rather than overwriting quietly, tested. The PDF shows the sequence number, tax breakdown, rounding rule and bank details, all read from the snapshot, checked by review. A voided invoice renders with its void reason and date visible. Do not build yet: No tokens, no backups, no export yet. ### Phase 5 · Tokens, retention and a restore you have actually done Build: Issue scoped tokens for anything that logs time without a person present, record the token on every entry it creates, and show the attribution on the invoice line. Refuse to delete any invoice inside the configured retention period. Back up nightly, restore into a scratch database on a schedule, and compare the invoice count and the sum of totals. Export every table as CSV alongside the PDFs. Trap: 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. Done when: An entry created through a token records that token and the invoice line shows the attribution, tested. Revoking a token stops new entries immediately and leaves existing ones intact with their attribution, tested. Deleting an invoice inside the retention window is refused with the configured period named, tested at the boundary. A scheduled restore into a scratch database reproduces the invoice count and the sum of totals exactly, and the check fails loudly when it does not. Do not build yet: Nothing after this. ### Out of scope (and why) - Payment collection and reconciliation, which brings a payment processor, refunds and disputes into a build whose point is producing a correct document. - Multi-user workspaces and per-member totals, which need a permissions model and change who is allowed to issue a number in the sequence. - Two-way worklog synchronisation with an issue tracker, which is a reconciliation problem in its own right and is the fastest way to double-bill an hour. - Automatic tax determination across jurisdictions, because the rate and the rules depend on facts about both parties that this build does not hold. ### README must contain - That invoice numbers are allocated at issue inside one transaction, never to drafts, and never reused - That an issued invoice is immutable and that corrections are issued as a credit or a replacement - Which rounding rule is configured, why it was chosen, and that it is recorded on every invoice it produced - The retention period the code enforces, where it came from, and the instruction to check it for your own jurisdiction - That the database file is the only record of your billing history, and exactly how to restore it ===== AGENTS.md ===== # Agent instructions · Tallymeter indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with Express, server-rendered pages on the loopback address, SQLite with amounts as integer minor units and durations as whole seconds, A series table whose next number is claimed inside the issuing transaction, PDF generated from the invoice snapshot, never from a live join, Scoped tokens for anything automated, with the actor recorded on every entry. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - 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. - 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. - 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. - 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. ===== BUILD_PLAN.md ===== # Build plan · Tallymeter 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · 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. ### Steps 1. Store instants and integer seconds A duration derived from wall-clock times is wrong twice a year. Files: `migrations/002_entries.sql`, `tests/dst-duration.test.ts` 2. Make starting a timer stop the running one atomically Two running timers is a billing dispute waiting to happen. Files: `src/timer/start.ts`, `tests/single-timer.test.ts` 3. Freeze invoiced entries Once billed, the entry is evidence rather than a note. Files: `src/entries/edit.ts` ### Done when - [ ] A duration across a DST transition is correct - [ ] Two timers can never run at once - [ ] Invoiced entries reject edits - [ ] No decimal hours are stored anywhere ### Watch 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. ## Phase 2 · 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. ### Steps 1. Keep rates and totals in minor units Decimal money in the application layer is how pennies vanish. Files: `src/money/minor.ts`, `tests/money.test.ts` 2. Store the rounding rule on the invoice Next year you will not remember which one you chose. Files: `migrations/003_invoices.sql` 3. Test a case where the two rules disagree If your fixture cannot tell them apart it is not testing anything. Files: `tests/rounding.test.ts` ### Done when - [ ] Both rounding rules produce their documented totals - [ ] Every invoice records the rule that produced it - [ ] The tax rate is snapshotted, not referenced - [ ] All money arithmetic is integer ### Watch 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. ## Phase 3 · 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. ### Steps 1. Claim the number inside the issuing transaction Select-then-update is the collision, drafts are the gap. Files: `src/invoices/issue.ts`, `tests/concurrent-issue.test.ts` 2. Void rather than delete The record of a mistake is part of the record. Files: `src/invoices/void.ts` 3. Stamp entries and freeze them in the same transaction An entry billed on one invoice must not appear on another. Files: `src/invoices/issue.ts`, `tests/no-double-bill.test.ts` ### Done when - [ ] Concurrent issues get distinct consecutive numbers - [ ] Abandoned drafts consume no number - [ ] Voided invoices keep number, lines and entries - [ ] Multiple series remain independent ### Watch 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. ## Phase 4 · A PDF that still says the same thing next year Reprinting an old invoice reproduces the original exactly, byte for byte where possible. ### Steps 1. Render only from the snapshot A join to a live table is a time bomb with a client's name on it. Files: `src/invoices/render.ts`, `tests/reprint-stability.test.ts` 2. Checksum the rendered bytes and store them So a changed reprint is loud rather than silent. Files: `src/invoices/render.ts` 3. Print the rounding rule and the tax breakdown The accountant's first two questions, answered on the page. Files: `src/invoices/template.html` ### Done when - [ ] Changed client or rate does not alter an issued PDF - [ ] A differing reprint warns with the fields named - [ ] The PDF carries number, tax, rounding and bank details - [ ] A voided invoice prints its reason and date ### Watch 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. ## Not in this build - Payment collection and reconciliation. - Multi-user workspaces and per-member totals. - Two-way worklog synchronisation with an issue tracker. - Automatic tax determination across jurisdictions. ## After v1, if you want it - 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 ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Clients, projects, entries and invoices. The only record of your billing history. DATABASE_PATH=./data/tally.db # Required. Rendered PDFs, named by series and number. Backed up with the database. INVOICE_DIR=./invoices # Required. The default series prefix. Additional series are rows, not configuration. INVOICE_SERIES=INV-2026 # Required. Recorded on every invoice it produces. Changing it does not alter issued invoices. ROUNDING_RULE=per_total # Required. Parts per million, snapshotted onto each invoice at issue. TAX_RATE_PPM=200000 # Required. Deletion inside this window is refused by the code, not just discouraged. RECORD_RETENTION_YEARS=6 # Required. Nightly copies, restored into a scratch database on a schedule. BACKUP_DIR=./backups # Required · secret. Hashes scoped tokens so the database never holds a usable one. API_TOKEN_SALT=redacted
You are building a production product version of Tallymeter. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # Tallymeter · product brief ## Problem The solo core is a weekend build and nobody should pretend otherwise: a timer, projects with hourly rates, and uninvoiced hours rolled into a numbered PDF. What survives the rebuild is the boring half · two-way Jira worklog sync that reconciles ticket by ticket without double-counting, a hosted MCP server and scoped API tokens your agents can reach from any machine, and invoice numbering plus VAT and bank details that hold up when an accountant reads them. Full disclosure: this entry was submitted by the person who builds Tallymeter. Same rubric as everything else on the list, and the honest answer is that a freelancer billing one client can absolutely one-shot the part they use. ## Product outcome A tracker that records seconds rather than opinions, applies one recorded rounding rule, allocates invoice numbers only at issue and never twice, reprints an invoice from three years ago exactly as it was, and refuses to delete anything inside the retention period you configured. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - an always-on box if you want the timer off your laptop - PDF generation - Jira API credentials if you want worklog sync - backups you actually test ## Explicit non-goals for v1 - 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 ## Success criteria - A duration across a DST transition is confirmed correct - Concurrent issues are confirmed to get distinct consecutive numbers - An abandoned draft is confirmed to consume no number - A changed client or rate is confirmed not to alter an issued PDF - Deletion inside the retention period is confirmed refused ===== BRIEF.md ===== # Build brief · Tallymeter The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a time tracker that turns tracked hours into numbered invoices an accountant can read a year later to replace Tallymeter. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero An invoice is a legal document, and a time tracker that issues invoices inherits that. Three things follow. The number is a sequence, not a label. Article 226 of the EU VAT Directive requires an invoice to carry 'a sequential number, based on one or more series, which uniquely identifies the invoice', and equivalent wording appears in national rules. So a number is allocated when an invoice is issued and never when a draft is created, it is never reused, and an issued invoice that turns out to be wrong is voided as a kept record rather than deleted. A gap in the sequence is a question somebody will eventually ask you. An issued invoice is immutable. The rate, the hours, the tax rate, the rounding rule and the bank details are snapshotted onto it at the moment of issue, so raising a project's rate in April does not quietly rewrite what you billed in March. Corrections happen by issuing a credit or a replacement, never by editing. And keep the records. HMRC's guidance says VAT records must be kept for at least 6 years, or 10 years under the VAT One Stop Shop scheme, and that this includes copies of all invoices you issue. A retention policy that deletes old invoices is not tidying up. Check the rule for your own jurisdiction, write the figure into configuration, and let the code refuse to delete anything inside it. ### Stack (fixed, do not substitute) - Runtime: Node 22 with Express, server-rendered pages on the loopback address · one process, no accounts, and your billing history stays on your machine - Storage: SQLite with amounts as integer minor units and durations as whole seconds · the two representation decisions that cannot be undone later without reconciling every invoice - Numbering: A series table whose next number is claimed inside the issuing transaction · two invoices issued a second apart must never collide, and the sequence must not have holes - Rendering: PDF generated from the invoice snapshot, never from a live join · reprinting last year's invoice must produce last year's invoice - Access: Scoped tokens for anything automated, with the actor recorded on every entry · if something else logs time on your behalf, the invoice should say which something ### Data model (create this before Phase 1) - 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. ### Phase 1 · One timer, and durations that are not opinions Build: Store started_at and ended_at as UTC instants and compute seconds as an integer difference. Starting a timer stops any running one in the same transaction. Allow editing a finished entry's description, and adding one after the fact with a duration in minutes for the call you forgot. Reject any edit to an entry that is already attached to an invoice. Trap: 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. Done when: A timer started and stopped across a daylight saving transition records the elapsed seconds correctly, tested with the transition in the fixture. Starting a second timer stops the first in the same transaction and two running timers are impossible, tested by firing both concurrently. An entry attached to an invoice rejects every edit with a named reason, tested. No column anywhere stores decimal hours, verified by review of the schema. Do not build yet: No rates, no rounding, no invoices yet. ### Phase 2 · Choosing the rounding rule once Build: Keep rates and amounts in integer minor units. Decide whether tax is computed per line and then summed, or computed once on the total, and store the choice on each invoice as rounding_rule. Note that HMRC's internal guidance describes rounding down the total VAT to a whole penny as a concession aimed at invoice traders where it is tax neutral, and that it is not appropriate for retailers, so read the rule that applies to you rather than copying one. Trap: 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. Done when: A fixture of line amounts where per-line and per-total rounding differ produces the documented result for the configured rule, tested for both rules. Every invoice stores the rounding rule that produced its figures and the PDF states it, checked by review. The tax rate is stored in parts per million on the invoice rather than referenced from configuration, tested by changing the configured rate and regenerating an old invoice. All arithmetic is integer, asserted by a test summing a large set of lines against an independent total. Do not build yet: No issuing, no numbering, no PDF yet. ### Phase 3 · A sequence with no holes and no collisions Build: Claim the next number from invoice_series inside the same transaction that writes the invoice and its lines and stamps invoice_id onto the entries. Do not allocate a number to a draft. Cancelling an issued invoice sets it to void with a reason and a timestamp, keeping the number and the lines. Show voided invoices in the list rather than hiding them. Trap: 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. Done when: Two invoices issued concurrently receive different consecutive numbers and neither transaction is lost, tested by issuing both inside one test. Abandoning a draft consumes no number and leaves no gap, tested. A voided invoice keeps its number, its lines and its entries association, and the entries do not become billable again unless explicitly released, tested. A second series can be added and numbers within each series remain independent, tested. Do not build yet: No PDF, no export, no tokens yet. ### Phase 4 · A PDF that still says the same thing next year Build: Render the PDF from the invoice snapshot and its lines only, touching no live client or project row. Store the file path and a checksum of the rendered bytes on the invoice. Reprinting compares the new checksum against the stored one and warns loudly if they differ. Include the sequence number, the tax breakdown, the rounding rule and the bank details from the snapshot. Trap: 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. Done when: Changing a client's address and a project's rate leaves an already-issued invoice's PDF unchanged, tested by regenerating and comparing checksums. Regeneration that produces a different checksum raises a warning naming the fields that differ rather than overwriting quietly, tested. The PDF shows the sequence number, tax breakdown, rounding rule and bank details, all read from the snapshot, checked by review. A voided invoice renders with its void reason and date visible. Do not build yet: No tokens, no backups, no export yet. ### Phase 5 · Tokens, retention and a restore you have actually done Build: Issue scoped tokens for anything that logs time without a person present, record the token on every entry it creates, and show the attribution on the invoice line. Refuse to delete any invoice inside the configured retention period. Back up nightly, restore into a scratch database on a schedule, and compare the invoice count and the sum of totals. Export every table as CSV alongside the PDFs. Trap: 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. Done when: An entry created through a token records that token and the invoice line shows the attribution, tested. Revoking a token stops new entries immediately and leaves existing ones intact with their attribution, tested. Deleting an invoice inside the retention window is refused with the configured period named, tested at the boundary. A scheduled restore into a scratch database reproduces the invoice count and the sum of totals exactly, and the check fails loudly when it does not. Do not build yet: Nothing after this. ### Out of scope (and why) - Payment collection and reconciliation, which brings a payment processor, refunds and disputes into a build whose point is producing a correct document. - Multi-user workspaces and per-member totals, which need a permissions model and change who is allowed to issue a number in the sequence. - Two-way worklog synchronisation with an issue tracker, which is a reconciliation problem in its own right and is the fastest way to double-bill an hour. - Automatic tax determination across jurisdictions, because the rate and the rules depend on facts about both parties that this build does not hold. ### README must contain - That invoice numbers are allocated at issue inside one transaction, never to drafts, and never reused - That an issued invoice is immutable and that corrections are issued as a credit or a replacement - Which rounding rule is configured, why it was chosen, and that it is recorded on every invoice it produced - The retention period the code enforces, where it came from, and the instruction to check it for your own jurisdiction - That the database file is the only record of your billing history, and exactly how to restore it ===== ARCHITECTURE.md ===== # Architecture · Tallymeter ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with Express, server-rendered pages on the loopback address | one process, no accounts, and your billing history stays on your machine | | Storage | SQLite with amounts as integer minor units and durations as whole seconds | the two representation decisions that cannot be undone later without reconciling every invoice | | Numbering | A series table whose next number is claimed inside the issuing transaction | two invoices issued a second apart must never collide, and the sequence must not have holes | | Rendering | PDF generated from the invoice snapshot, never from a live join | reprinting last year's invoice must produce last year's invoice | | Access | Scoped tokens for anything automated, with the actor recorded on every entry | if something else logs time on your behalf, the invoice should say which something | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Timer | Instants, seconds and the single running timer. | Any interface, provided durations come from instants rather than wall-clock times. | | Money | Minor units, rates and the rounding rule. | Not swappable for decimal arithmetic, which is what the rule exists to make deterministic. | | Issue | Sequence claiming, snapshotting and freezing. | Any store with a transaction, provided the number is claimed inside it. | | Render | PDF from the snapshot and its checksum. | Any renderer, provided it never joins to a live table. | | Access | Scoped tokens and entry attribution. | Any scheme, provided every automated entry names its token. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Clients, projects, entries and invoices. The only record of your billing history. - `INVOICE_DIR` · required · Rendered PDFs, named by series and number. Backed up with the database. - `INVOICE_SERIES` · required · The default series prefix. Additional series are rows, not configuration. - `ROUNDING_RULE` · required · Recorded on every invoice it produces. Changing it does not alter issued invoices. - `TAX_RATE_PPM` · required · Parts per million, snapshotted onto each invoice at issue. - `RECORD_RETENTION_YEARS` · required · Deletion inside this window is refused by the code, not just discouraged. - `BACKUP_DIR` · required · Nightly copies, restored into a scratch database on a schedule. - `API_TOKEN_SALT` · required, secret · Hashes scoped tokens so the database never holds a usable one. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · Tallymeter product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22 with Express, server-rendered pages on the loopback address, SQLite with amounts as integer minor units and durations as whole seconds, A series table whose next number is claimed inside the issuing transaction, PDF generated from the invoice snapshot, never from a live join, Scoped tokens for anything automated, with the actor recorded on every entry. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - 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. - 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. - 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. - 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. - 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. ===== MILESTONES.md ===== # Delivery milestones · Tallymeter Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · 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. ### Steps 1. Store instants and integer seconds A duration derived from wall-clock times is wrong twice a year. Files: `migrations/002_entries.sql`, `tests/dst-duration.test.ts` 2. Make starting a timer stop the running one atomically Two running timers is a billing dispute waiting to happen. Files: `src/timer/start.ts`, `tests/single-timer.test.ts` 3. Freeze invoiced entries Once billed, the entry is evidence rather than a note. Files: `src/entries/edit.ts` ### Done when - [ ] A duration across a DST transition is correct - [ ] Two timers can never run at once - [ ] Invoiced entries reject edits - [ ] No decimal hours are stored anywhere ### Watch 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. ## M2 · 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. ### Steps 1. Keep rates and totals in minor units Decimal money in the application layer is how pennies vanish. Files: `src/money/minor.ts`, `tests/money.test.ts` 2. Store the rounding rule on the invoice Next year you will not remember which one you chose. Files: `migrations/003_invoices.sql` 3. Test a case where the two rules disagree If your fixture cannot tell them apart it is not testing anything. Files: `tests/rounding.test.ts` ### Done when - [ ] Both rounding rules produce their documented totals - [ ] Every invoice records the rule that produced it - [ ] The tax rate is snapshotted, not referenced - [ ] All money arithmetic is integer ### Watch 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. ## M3 · 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. ### Steps 1. Claim the number inside the issuing transaction Select-then-update is the collision, drafts are the gap. Files: `src/invoices/issue.ts`, `tests/concurrent-issue.test.ts` 2. Void rather than delete The record of a mistake is part of the record. Files: `src/invoices/void.ts` 3. Stamp entries and freeze them in the same transaction An entry billed on one invoice must not appear on another. Files: `src/invoices/issue.ts`, `tests/no-double-bill.test.ts` ### Done when - [ ] Concurrent issues get distinct consecutive numbers - [ ] Abandoned drafts consume no number - [ ] Voided invoices keep number, lines and entries - [ ] Multiple series remain independent ### Watch 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. ## M4 · A PDF that still says the same thing next year Reprinting an old invoice reproduces the original exactly, byte for byte where possible. ### Steps 1. Render only from the snapshot A join to a live table is a time bomb with a client's name on it. Files: `src/invoices/render.ts`, `tests/reprint-stability.test.ts` 2. Checksum the rendered bytes and store them So a changed reprint is loud rather than silent. Files: `src/invoices/render.ts` 3. Print the rounding rule and the tax breakdown The accountant's first two questions, answered on the page. Files: `src/invoices/template.html` ### Done when - [ ] Changed client or rate does not alter an issued PDF - [ ] A differing reprint warns with the fields named - [ ] The PDF carries number, tax, rounding and bank details - [ ] A voided invoice prints its reason and date ### Watch 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. ## M5 · Tokens, retention and a restore you have actually done (production only) Automated time logging is attributable, records survive the statutory period, and the backup has been restored at least once on purpose. ### Steps 1. Give automation its own scoped token Attribution on the line is what makes the hour defensible. Files: `src/auth/tokens.ts`, `tests/attribution.test.ts` 2. Refuse deletion inside the retention period The code should know the rule, not just the README. Files: `src/invoices/delete.ts`, `tests/retention-refusal.test.ts` 3. Restore on a schedule and compare totals This file is the only record of what you are owed. Files: `scripts/restore-check.sh`, `docs/operations.md` ### Done when - [ ] Token-created entries carry attribution onto the line - [ ] Revocation stops new entries, keeps old ones - [ ] Deletion inside retention is refused with the period named - [ ] A scheduled restore reproduces counts and totals ### Watch 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. ===== OPERATIONS.md ===== # Operations · Tallymeter ## Backup The database and the rendered PDFs nightly, kept offsite, with a retention at least as long as the statutory record-keeping period. The PDFs are backed up as well as the rows because they are the copies of issued invoices the rules require you to keep. ## Restore Restore into a scratch database, compare the invoice count and the sum of totals against the last export, then regenerate one old PDF and compare its checksum. A checksum mismatch after restore means something in the snapshot did not survive, which is the only restore failure that matters here. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Gaps in each series, invoices whose PDF checksum no longer matches, entries older than a month that are still unbilled, and the result of the last restore check. A gap in a series is the alert that needs an answer the same day. ## Incident checklist The incidents are a duplicated number, a double-billed hour and an invoice that reprints differently. All three are answered from invoice_series, the frozen entries and the stored checksum, which is why none of those three are derived at render time. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A duration across a DST transition is confirmed correct - [ ] Concurrent issues are confirmed to get distinct consecutive numbers - [ ] An abandoned draft is confirmed to consume no number - [ ] A changed client or rate is confirmed not to alter an issued PDF - [ ] Deletion inside the retention period is confirmed refused ## Launch constraint Do not market omitted Tallymeter capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Clients, projects, entries and invoices. The only record of your billing history. DATABASE_PATH=./data/tally.db # Required. Rendered PDFs, named by series and number. Backed up with the database. INVOICE_DIR=./invoices # Required. The default series prefix. Additional series are rows, not configuration. INVOICE_SERIES=INV-2026 # Required. Recorded on every invoice it produces. Changing it does not alter issued invoices. ROUNDING_RULE=per_total # Required. Parts per million, snapshotted onto each invoice at issue. TAX_RATE_PPM=200000 # Required. Deletion inside this window is refused by the code, not just discouraged. RECORD_RETENTION_YEARS=6 # Required. Nightly copies, restored into a scratch database on a schedule. BACKUP_DIR=./backups # Required · secret. Hashes scoped tokens so the database never holds a usable one. API_TOKEN_SALT=redacted
# Tallymeter · indie build 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. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with Express, server-rendered pages on the loopback address | one process, no accounts, and your billing history stays on your machine | | Storage | SQLite with amounts as integer minor units and durations as whole seconds | the two representation decisions that cannot be undone later without reconciling every invoice | | Numbering | A series table whose next number is claimed inside the issuing transaction | two invoices issued a second apart must never collide, and the sequence must not have holes | | Rendering | PDF generated from the invoice snapshot, never from a live join | reprinting last year's invoice must produce last year's invoice | | Access | Scoped tokens for anything automated, with the actor recorded on every entry | if something else logs time on your behalf, the invoice should say which something | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the tracker and the renderer. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **A headless browser or PDF renderer** · free - 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 - [ ] **Your jurisdiction's invoice content and record-keeping rules** · free - 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. - Verify: The required fields list and the retention period are recorded in the README with their source - [ ] **The bank details and tax registration you invoice under** · free - 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 - [ ] **The rounding rule, decided and written down** · free - 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 ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - 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 If one of those is essential to you, that is the reason to keep paying for Tallymeter, and the README should say so rather than pretend.
# Build brief · Tallymeter The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a time tracker that turns tracked hours into numbered invoices an accountant can read a year later to replace Tallymeter. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero An invoice is a legal document, and a time tracker that issues invoices inherits that. Three things follow. The number is a sequence, not a label. Article 226 of the EU VAT Directive requires an invoice to carry 'a sequential number, based on one or more series, which uniquely identifies the invoice', and equivalent wording appears in national rules. So a number is allocated when an invoice is issued and never when a draft is created, it is never reused, and an issued invoice that turns out to be wrong is voided as a kept record rather than deleted. A gap in the sequence is a question somebody will eventually ask you. An issued invoice is immutable. The rate, the hours, the tax rate, the rounding rule and the bank details are snapshotted onto it at the moment of issue, so raising a project's rate in April does not quietly rewrite what you billed in March. Corrections happen by issuing a credit or a replacement, never by editing. And keep the records. HMRC's guidance says VAT records must be kept for at least 6 years, or 10 years under the VAT One Stop Shop scheme, and that this includes copies of all invoices you issue. A retention policy that deletes old invoices is not tidying up. Check the rule for your own jurisdiction, write the figure into configuration, and let the code refuse to delete anything inside it. ### Stack (fixed, do not substitute) - Runtime: Node 22 with Express, server-rendered pages on the loopback address · one process, no accounts, and your billing history stays on your machine - Storage: SQLite with amounts as integer minor units and durations as whole seconds · the two representation decisions that cannot be undone later without reconciling every invoice - Numbering: A series table whose next number is claimed inside the issuing transaction · two invoices issued a second apart must never collide, and the sequence must not have holes - Rendering: PDF generated from the invoice snapshot, never from a live join · reprinting last year's invoice must produce last year's invoice - Access: Scoped tokens for anything automated, with the actor recorded on every entry · if something else logs time on your behalf, the invoice should say which something ### Data model (create this before Phase 1) - 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. ### Phase 1 · One timer, and durations that are not opinions Build: Store started_at and ended_at as UTC instants and compute seconds as an integer difference. Starting a timer stops any running one in the same transaction. Allow editing a finished entry's description, and adding one after the fact with a duration in minutes for the call you forgot. Reject any edit to an entry that is already attached to an invoice. Trap: 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. Done when: A timer started and stopped across a daylight saving transition records the elapsed seconds correctly, tested with the transition in the fixture. Starting a second timer stops the first in the same transaction and two running timers are impossible, tested by firing both concurrently. An entry attached to an invoice rejects every edit with a named reason, tested. No column anywhere stores decimal hours, verified by review of the schema. Do not build yet: No rates, no rounding, no invoices yet. ### Phase 2 · Choosing the rounding rule once Build: Keep rates and amounts in integer minor units. Decide whether tax is computed per line and then summed, or computed once on the total, and store the choice on each invoice as rounding_rule. Note that HMRC's internal guidance describes rounding down the total VAT to a whole penny as a concession aimed at invoice traders where it is tax neutral, and that it is not appropriate for retailers, so read the rule that applies to you rather than copying one. Trap: 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. Done when: A fixture of line amounts where per-line and per-total rounding differ produces the documented result for the configured rule, tested for both rules. Every invoice stores the rounding rule that produced its figures and the PDF states it, checked by review. The tax rate is stored in parts per million on the invoice rather than referenced from configuration, tested by changing the configured rate and regenerating an old invoice. All arithmetic is integer, asserted by a test summing a large set of lines against an independent total. Do not build yet: No issuing, no numbering, no PDF yet. ### Phase 3 · A sequence with no holes and no collisions Build: Claim the next number from invoice_series inside the same transaction that writes the invoice and its lines and stamps invoice_id onto the entries. Do not allocate a number to a draft. Cancelling an issued invoice sets it to void with a reason and a timestamp, keeping the number and the lines. Show voided invoices in the list rather than hiding them. Trap: 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. Done when: Two invoices issued concurrently receive different consecutive numbers and neither transaction is lost, tested by issuing both inside one test. Abandoning a draft consumes no number and leaves no gap, tested. A voided invoice keeps its number, its lines and its entries association, and the entries do not become billable again unless explicitly released, tested. A second series can be added and numbers within each series remain independent, tested. Do not build yet: No PDF, no export, no tokens yet. ### Phase 4 · A PDF that still says the same thing next year Build: Render the PDF from the invoice snapshot and its lines only, touching no live client or project row. Store the file path and a checksum of the rendered bytes on the invoice. Reprinting compares the new checksum against the stored one and warns loudly if they differ. Include the sequence number, the tax breakdown, the rounding rule and the bank details from the snapshot. Trap: 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. Done when: Changing a client's address and a project's rate leaves an already-issued invoice's PDF unchanged, tested by regenerating and comparing checksums. Regeneration that produces a different checksum raises a warning naming the fields that differ rather than overwriting quietly, tested. The PDF shows the sequence number, tax breakdown, rounding rule and bank details, all read from the snapshot, checked by review. A voided invoice renders with its void reason and date visible. Do not build yet: No tokens, no backups, no export yet. ### Phase 5 · Tokens, retention and a restore you have actually done Build: Issue scoped tokens for anything that logs time without a person present, record the token on every entry it creates, and show the attribution on the invoice line. Refuse to delete any invoice inside the configured retention period. Back up nightly, restore into a scratch database on a schedule, and compare the invoice count and the sum of totals. Export every table as CSV alongside the PDFs. Trap: 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. Done when: An entry created through a token records that token and the invoice line shows the attribution, tested. Revoking a token stops new entries immediately and leaves existing ones intact with their attribution, tested. Deleting an invoice inside the retention window is refused with the configured period named, tested at the boundary. A scheduled restore into a scratch database reproduces the invoice count and the sum of totals exactly, and the check fails loudly when it does not. Do not build yet: Nothing after this. ### Out of scope (and why) - Payment collection and reconciliation, which brings a payment processor, refunds and disputes into a build whose point is producing a correct document. - Multi-user workspaces and per-member totals, which need a permissions model and change who is allowed to issue a number in the sequence. - Two-way worklog synchronisation with an issue tracker, which is a reconciliation problem in its own right and is the fastest way to double-bill an hour. - Automatic tax determination across jurisdictions, because the rate and the rules depend on facts about both parties that this build does not hold. ### README must contain - That invoice numbers are allocated at issue inside one transaction, never to drafts, and never reused - That an issued invoice is immutable and that corrections are issued as a credit or a replacement - Which rounding rule is configured, why it was chosen, and that it is recorded on every invoice it produced - The retention period the code enforces, where it came from, and the instruction to check it for your own jurisdiction - That the database file is the only record of your billing history, and exactly how to restore it
# Agent instructions · Tallymeter indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with Express, server-rendered pages on the loopback address, SQLite with amounts as integer minor units and durations as whole seconds, A series table whose next number is claimed inside the issuing transaction, PDF generated from the invoice snapshot, never from a live join, Scoped tokens for anything automated, with the actor recorded on every entry. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - 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. - 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. - 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. - 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.
# Build plan · Tallymeter 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · 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. ### Steps 1. Store instants and integer seconds A duration derived from wall-clock times is wrong twice a year. Files: `migrations/002_entries.sql`, `tests/dst-duration.test.ts` 2. Make starting a timer stop the running one atomically Two running timers is a billing dispute waiting to happen. Files: `src/timer/start.ts`, `tests/single-timer.test.ts` 3. Freeze invoiced entries Once billed, the entry is evidence rather than a note. Files: `src/entries/edit.ts` ### Done when - [ ] A duration across a DST transition is correct - [ ] Two timers can never run at once - [ ] Invoiced entries reject edits - [ ] No decimal hours are stored anywhere ### Watch 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. ## Phase 2 · 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. ### Steps 1. Keep rates and totals in minor units Decimal money in the application layer is how pennies vanish. Files: `src/money/minor.ts`, `tests/money.test.ts` 2. Store the rounding rule on the invoice Next year you will not remember which one you chose. Files: `migrations/003_invoices.sql` 3. Test a case where the two rules disagree If your fixture cannot tell them apart it is not testing anything. Files: `tests/rounding.test.ts` ### Done when - [ ] Both rounding rules produce their documented totals - [ ] Every invoice records the rule that produced it - [ ] The tax rate is snapshotted, not referenced - [ ] All money arithmetic is integer ### Watch 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. ## Phase 3 · 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. ### Steps 1. Claim the number inside the issuing transaction Select-then-update is the collision, drafts are the gap. Files: `src/invoices/issue.ts`, `tests/concurrent-issue.test.ts` 2. Void rather than delete The record of a mistake is part of the record. Files: `src/invoices/void.ts` 3. Stamp entries and freeze them in the same transaction An entry billed on one invoice must not appear on another. Files: `src/invoices/issue.ts`, `tests/no-double-bill.test.ts` ### Done when - [ ] Concurrent issues get distinct consecutive numbers - [ ] Abandoned drafts consume no number - [ ] Voided invoices keep number, lines and entries - [ ] Multiple series remain independent ### Watch 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. ## Phase 4 · A PDF that still says the same thing next year Reprinting an old invoice reproduces the original exactly, byte for byte where possible. ### Steps 1. Render only from the snapshot A join to a live table is a time bomb with a client's name on it. Files: `src/invoices/render.ts`, `tests/reprint-stability.test.ts` 2. Checksum the rendered bytes and store them So a changed reprint is loud rather than silent. Files: `src/invoices/render.ts` 3. Print the rounding rule and the tax breakdown The accountant's first two questions, answered on the page. Files: `src/invoices/template.html` ### Done when - [ ] Changed client or rate does not alter an issued PDF - [ ] A differing reprint warns with the fields named - [ ] The PDF carries number, tax, rounding and bank details - [ ] A voided invoice prints its reason and date ### Watch 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. ## Not in this build - Payment collection and reconciliation. - Multi-user workspaces and per-member totals. - Two-way worklog synchronisation with an issue tracker. - Automatic tax determination across jurisdictions. ## After v1, if you want it - 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
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Clients, projects, entries and invoices. The only record of your billing history. DATABASE_PATH=./data/tally.db # Required. Rendered PDFs, named by series and number. Backed up with the database. INVOICE_DIR=./invoices # Required. The default series prefix. Additional series are rows, not configuration. INVOICE_SERIES=INV-2026 # Required. Recorded on every invoice it produces. Changing it does not alter issued invoices. ROUNDING_RULE=per_total # Required. Parts per million, snapshotted onto each invoice at issue. TAX_RATE_PPM=200000 # Required. Deletion inside this window is refused by the code, not just discouraged. RECORD_RETENTION_YEARS=6 # Required. Nightly copies, restored into a scratch database on a schedule. BACKUP_DIR=./backups # Required · secret. Hashes scoped tokens so the database never holds a usable one. API_TOKEN_SALT=redacted
# Tallymeter · product brief ## Problem The solo core is a weekend build and nobody should pretend otherwise: a timer, projects with hourly rates, and uninvoiced hours rolled into a numbered PDF. What survives the rebuild is the boring half · two-way Jira worklog sync that reconciles ticket by ticket without double-counting, a hosted MCP server and scoped API tokens your agents can reach from any machine, and invoice numbering plus VAT and bank details that hold up when an accountant reads them. Full disclosure: this entry was submitted by the person who builds Tallymeter. Same rubric as everything else on the list, and the honest answer is that a freelancer billing one client can absolutely one-shot the part they use. ## Product outcome A tracker that records seconds rather than opinions, applies one recorded rounding rule, allocates invoice numbers only at issue and never twice, reprints an invoice from three years ago exactly as it was, and refuses to delete anything inside the retention period you configured. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - an always-on box if you want the timer off your laptop - PDF generation - Jira API credentials if you want worklog sync - backups you actually test ## Explicit non-goals for v1 - 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 ## Success criteria - A duration across a DST transition is confirmed correct - Concurrent issues are confirmed to get distinct consecutive numbers - An abandoned draft is confirmed to consume no number - A changed client or rate is confirmed not to alter an issued PDF - Deletion inside the retention period is confirmed refused
# Build brief · Tallymeter The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a time tracker that turns tracked hours into numbered invoices an accountant can read a year later to replace Tallymeter. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero An invoice is a legal document, and a time tracker that issues invoices inherits that. Three things follow. The number is a sequence, not a label. Article 226 of the EU VAT Directive requires an invoice to carry 'a sequential number, based on one or more series, which uniquely identifies the invoice', and equivalent wording appears in national rules. So a number is allocated when an invoice is issued and never when a draft is created, it is never reused, and an issued invoice that turns out to be wrong is voided as a kept record rather than deleted. A gap in the sequence is a question somebody will eventually ask you. An issued invoice is immutable. The rate, the hours, the tax rate, the rounding rule and the bank details are snapshotted onto it at the moment of issue, so raising a project's rate in April does not quietly rewrite what you billed in March. Corrections happen by issuing a credit or a replacement, never by editing. And keep the records. HMRC's guidance says VAT records must be kept for at least 6 years, or 10 years under the VAT One Stop Shop scheme, and that this includes copies of all invoices you issue. A retention policy that deletes old invoices is not tidying up. Check the rule for your own jurisdiction, write the figure into configuration, and let the code refuse to delete anything inside it. ### Stack (fixed, do not substitute) - Runtime: Node 22 with Express, server-rendered pages on the loopback address · one process, no accounts, and your billing history stays on your machine - Storage: SQLite with amounts as integer minor units and durations as whole seconds · the two representation decisions that cannot be undone later without reconciling every invoice - Numbering: A series table whose next number is claimed inside the issuing transaction · two invoices issued a second apart must never collide, and the sequence must not have holes - Rendering: PDF generated from the invoice snapshot, never from a live join · reprinting last year's invoice must produce last year's invoice - Access: Scoped tokens for anything automated, with the actor recorded on every entry · if something else logs time on your behalf, the invoice should say which something ### Data model (create this before Phase 1) - 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. ### Phase 1 · One timer, and durations that are not opinions Build: Store started_at and ended_at as UTC instants and compute seconds as an integer difference. Starting a timer stops any running one in the same transaction. Allow editing a finished entry's description, and adding one after the fact with a duration in minutes for the call you forgot. Reject any edit to an entry that is already attached to an invoice. Trap: 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. Done when: A timer started and stopped across a daylight saving transition records the elapsed seconds correctly, tested with the transition in the fixture. Starting a second timer stops the first in the same transaction and two running timers are impossible, tested by firing both concurrently. An entry attached to an invoice rejects every edit with a named reason, tested. No column anywhere stores decimal hours, verified by review of the schema. Do not build yet: No rates, no rounding, no invoices yet. ### Phase 2 · Choosing the rounding rule once Build: Keep rates and amounts in integer minor units. Decide whether tax is computed per line and then summed, or computed once on the total, and store the choice on each invoice as rounding_rule. Note that HMRC's internal guidance describes rounding down the total VAT to a whole penny as a concession aimed at invoice traders where it is tax neutral, and that it is not appropriate for retailers, so read the rule that applies to you rather than copying one. Trap: 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. Done when: A fixture of line amounts where per-line and per-total rounding differ produces the documented result for the configured rule, tested for both rules. Every invoice stores the rounding rule that produced its figures and the PDF states it, checked by review. The tax rate is stored in parts per million on the invoice rather than referenced from configuration, tested by changing the configured rate and regenerating an old invoice. All arithmetic is integer, asserted by a test summing a large set of lines against an independent total. Do not build yet: No issuing, no numbering, no PDF yet. ### Phase 3 · A sequence with no holes and no collisions Build: Claim the next number from invoice_series inside the same transaction that writes the invoice and its lines and stamps invoice_id onto the entries. Do not allocate a number to a draft. Cancelling an issued invoice sets it to void with a reason and a timestamp, keeping the number and the lines. Show voided invoices in the list rather than hiding them. Trap: 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. Done when: Two invoices issued concurrently receive different consecutive numbers and neither transaction is lost, tested by issuing both inside one test. Abandoning a draft consumes no number and leaves no gap, tested. A voided invoice keeps its number, its lines and its entries association, and the entries do not become billable again unless explicitly released, tested. A second series can be added and numbers within each series remain independent, tested. Do not build yet: No PDF, no export, no tokens yet. ### Phase 4 · A PDF that still says the same thing next year Build: Render the PDF from the invoice snapshot and its lines only, touching no live client or project row. Store the file path and a checksum of the rendered bytes on the invoice. Reprinting compares the new checksum against the stored one and warns loudly if they differ. Include the sequence number, the tax breakdown, the rounding rule and the bank details from the snapshot. Trap: 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. Done when: Changing a client's address and a project's rate leaves an already-issued invoice's PDF unchanged, tested by regenerating and comparing checksums. Regeneration that produces a different checksum raises a warning naming the fields that differ rather than overwriting quietly, tested. The PDF shows the sequence number, tax breakdown, rounding rule and bank details, all read from the snapshot, checked by review. A voided invoice renders with its void reason and date visible. Do not build yet: No tokens, no backups, no export yet. ### Phase 5 · Tokens, retention and a restore you have actually done Build: Issue scoped tokens for anything that logs time without a person present, record the token on every entry it creates, and show the attribution on the invoice line. Refuse to delete any invoice inside the configured retention period. Back up nightly, restore into a scratch database on a schedule, and compare the invoice count and the sum of totals. Export every table as CSV alongside the PDFs. Trap: 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. Done when: An entry created through a token records that token and the invoice line shows the attribution, tested. Revoking a token stops new entries immediately and leaves existing ones intact with their attribution, tested. Deleting an invoice inside the retention window is refused with the configured period named, tested at the boundary. A scheduled restore into a scratch database reproduces the invoice count and the sum of totals exactly, and the check fails loudly when it does not. Do not build yet: Nothing after this. ### Out of scope (and why) - Payment collection and reconciliation, which brings a payment processor, refunds and disputes into a build whose point is producing a correct document. - Multi-user workspaces and per-member totals, which need a permissions model and change who is allowed to issue a number in the sequence. - Two-way worklog synchronisation with an issue tracker, which is a reconciliation problem in its own right and is the fastest way to double-bill an hour. - Automatic tax determination across jurisdictions, because the rate and the rules depend on facts about both parties that this build does not hold. ### README must contain - That invoice numbers are allocated at issue inside one transaction, never to drafts, and never reused - That an issued invoice is immutable and that corrections are issued as a credit or a replacement - Which rounding rule is configured, why it was chosen, and that it is recorded on every invoice it produced - The retention period the code enforces, where it came from, and the instruction to check it for your own jurisdiction - That the database file is the only record of your billing history, and exactly how to restore it
# Architecture · Tallymeter ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with Express, server-rendered pages on the loopback address | one process, no accounts, and your billing history stays on your machine | | Storage | SQLite with amounts as integer minor units and durations as whole seconds | the two representation decisions that cannot be undone later without reconciling every invoice | | Numbering | A series table whose next number is claimed inside the issuing transaction | two invoices issued a second apart must never collide, and the sequence must not have holes | | Rendering | PDF generated from the invoice snapshot, never from a live join | reprinting last year's invoice must produce last year's invoice | | Access | Scoped tokens for anything automated, with the actor recorded on every entry | if something else logs time on your behalf, the invoice should say which something | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Timer | Instants, seconds and the single running timer. | Any interface, provided durations come from instants rather than wall-clock times. | | Money | Minor units, rates and the rounding rule. | Not swappable for decimal arithmetic, which is what the rule exists to make deterministic. | | Issue | Sequence claiming, snapshotting and freezing. | Any store with a transaction, provided the number is claimed inside it. | | Render | PDF from the snapshot and its checksum. | Any renderer, provided it never joins to a live table. | | Access | Scoped tokens and entry attribution. | Any scheme, provided every automated entry names its token. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Clients, projects, entries and invoices. The only record of your billing history. - `INVOICE_DIR` · required · Rendered PDFs, named by series and number. Backed up with the database. - `INVOICE_SERIES` · required · The default series prefix. Additional series are rows, not configuration. - `ROUNDING_RULE` · required · Recorded on every invoice it produces. Changing it does not alter issued invoices. - `TAX_RATE_PPM` · required · Parts per million, snapshotted onto each invoice at issue. - `RECORD_RETENTION_YEARS` · required · Deletion inside this window is refused by the code, not just discouraged. - `BACKUP_DIR` · required · Nightly copies, restored into a scratch database on a schedule. - `API_TOKEN_SALT` · required, secret · Hashes scoped tokens so the database never holds a usable one. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Tallymeter product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22 with Express, server-rendered pages on the loopback address, SQLite with amounts as integer minor units and durations as whole seconds, A series table whose next number is claimed inside the issuing transaction, PDF generated from the invoice snapshot, never from a live join, Scoped tokens for anything automated, with the actor recorded on every entry. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - 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. - 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. - 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. - 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. - 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.
# Delivery milestones · Tallymeter Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · 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. ### Steps 1. Store instants and integer seconds A duration derived from wall-clock times is wrong twice a year. Files: `migrations/002_entries.sql`, `tests/dst-duration.test.ts` 2. Make starting a timer stop the running one atomically Two running timers is a billing dispute waiting to happen. Files: `src/timer/start.ts`, `tests/single-timer.test.ts` 3. Freeze invoiced entries Once billed, the entry is evidence rather than a note. Files: `src/entries/edit.ts` ### Done when - [ ] A duration across a DST transition is correct - [ ] Two timers can never run at once - [ ] Invoiced entries reject edits - [ ] No decimal hours are stored anywhere ### Watch 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. ## M2 · 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. ### Steps 1. Keep rates and totals in minor units Decimal money in the application layer is how pennies vanish. Files: `src/money/minor.ts`, `tests/money.test.ts` 2. Store the rounding rule on the invoice Next year you will not remember which one you chose. Files: `migrations/003_invoices.sql` 3. Test a case where the two rules disagree If your fixture cannot tell them apart it is not testing anything. Files: `tests/rounding.test.ts` ### Done when - [ ] Both rounding rules produce their documented totals - [ ] Every invoice records the rule that produced it - [ ] The tax rate is snapshotted, not referenced - [ ] All money arithmetic is integer ### Watch 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. ## M3 · 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. ### Steps 1. Claim the number inside the issuing transaction Select-then-update is the collision, drafts are the gap. Files: `src/invoices/issue.ts`, `tests/concurrent-issue.test.ts` 2. Void rather than delete The record of a mistake is part of the record. Files: `src/invoices/void.ts` 3. Stamp entries and freeze them in the same transaction An entry billed on one invoice must not appear on another. Files: `src/invoices/issue.ts`, `tests/no-double-bill.test.ts` ### Done when - [ ] Concurrent issues get distinct consecutive numbers - [ ] Abandoned drafts consume no number - [ ] Voided invoices keep number, lines and entries - [ ] Multiple series remain independent ### Watch 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. ## M4 · A PDF that still says the same thing next year Reprinting an old invoice reproduces the original exactly, byte for byte where possible. ### Steps 1. Render only from the snapshot A join to a live table is a time bomb with a client's name on it. Files: `src/invoices/render.ts`, `tests/reprint-stability.test.ts` 2. Checksum the rendered bytes and store them So a changed reprint is loud rather than silent. Files: `src/invoices/render.ts` 3. Print the rounding rule and the tax breakdown The accountant's first two questions, answered on the page. Files: `src/invoices/template.html` ### Done when - [ ] Changed client or rate does not alter an issued PDF - [ ] A differing reprint warns with the fields named - [ ] The PDF carries number, tax, rounding and bank details - [ ] A voided invoice prints its reason and date ### Watch 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. ## M5 · Tokens, retention and a restore you have actually done (production only) Automated time logging is attributable, records survive the statutory period, and the backup has been restored at least once on purpose. ### Steps 1. Give automation its own scoped token Attribution on the line is what makes the hour defensible. Files: `src/auth/tokens.ts`, `tests/attribution.test.ts` 2. Refuse deletion inside the retention period The code should know the rule, not just the README. Files: `src/invoices/delete.ts`, `tests/retention-refusal.test.ts` 3. Restore on a schedule and compare totals This file is the only record of what you are owed. Files: `scripts/restore-check.sh`, `docs/operations.md` ### Done when - [ ] Token-created entries carry attribution onto the line - [ ] Revocation stops new entries, keeps old ones - [ ] Deletion inside retention is refused with the period named - [ ] A scheduled restore reproduces counts and totals ### Watch 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.
# Operations · Tallymeter ## Backup The database and the rendered PDFs nightly, kept offsite, with a retention at least as long as the statutory record-keeping period. The PDFs are backed up as well as the rows because they are the copies of issued invoices the rules require you to keep. ## Restore Restore into a scratch database, compare the invoice count and the sum of totals against the last export, then regenerate one old PDF and compare its checksum. A checksum mismatch after restore means something in the snapshot did not survive, which is the only restore failure that matters here. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Gaps in each series, invoices whose PDF checksum no longer matches, entries older than a month that are still unbilled, and the result of the last restore check. A gap in a series is the alert that needs an answer the same day. ## Incident checklist The incidents are a duplicated number, a double-billed hour and an invoice that reprints differently. All three are answered from invoice_series, the frozen entries and the stored checksum, which is why none of those three are derived at render time. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A duration across a DST transition is confirmed correct - [ ] Concurrent issues are confirmed to get distinct consecutive numbers - [ ] An abandoned draft is confirmed to consume no number - [ ] A changed client or rate is confirmed not to alter an issued PDF - [ ] Deletion inside the retention period is confirmed refused ## Launch constraint Do not market omitted Tallymeter capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Clients, projects, entries and invoices. The only record of your billing history. DATABASE_PATH=./data/tally.db # Required. Rendered PDFs, named by series and number. Backed up with the database. INVOICE_DIR=./invoices # Required. The default series prefix. Additional series are rows, not configuration. INVOICE_SERIES=INV-2026 # Required. Recorded on every invoice it produces. Changing it does not alter issued invoices. ROUNDING_RULE=per_total # Required. Parts per million, snapshotted onto each invoice at issue. TAX_RATE_PPM=200000 # Required. Deletion inside this window is refused by the code, not just discouraged. RECORD_RETENTION_YEARS=6 # Required. Nightly copies, restored into a scratch database on a schedule. BACKUP_DIR=./backups # Required · secret. Hashes scoped tokens so the database never holds a usable one. API_TOKEN_SALT=redacted
$ choose a build depth, inspect the files, then open the complete pack in your agent
The timer is a weekend. The failure modes are not. Uninvoiced time that silently double-counts against a Jira worklog, an invoice sequence with a gap in it, or a laptop that was asleep when the agent tried to log its hours are all problems you discover on invoice day, in front of a client. A business billing real money for hours generally decides that maintaining the thing that produces its invoices is not the hobby it wants.
xtwo-way Jira Cloud worklog sync that reconciles ticket by ticket
xa hosted MCP server and scoped API tokens agents can call from anywhere, with per-agent attribution
xinvoice numbering, VAT and bank details that survive an accountant handoff
xshared team workspaces and per-member unbilled totals
xmanaged hosting, nightly backups and a phone-sized UI for starting a timer away from your desk
Don't feel like building it? These folks already made it free.
no votes, no pay-to-list · just what's real
Tallymeter pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| pro | $10.38/workspace | $8.65/workspace | Unlimited projects and clients; reports, CSV exports, numbered invoices, Jira integration, REST API, and MCP server |
free tierno free tier; 30-day trial with no card
billingmonthly + annual (annual is 2 months free)
verified 2026-08-10 · source ↗
Vibecode Tallymeter
Kinda. The core of Tallymeter is buildable in a weekend with the prompt on this page, but there are real gaps: 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. Read the honest list above before committing.
How much does Tallymeter cost?
Tallymeter costs about $10.38/month (Pro, checked 2026-08-10), which is $124.56 per year.
What do I lose by replacing Tallymeter?
Honestly: 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. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Tallymeter?
Yes: Kimai (Time, expenses, numbered invoices and a JSON API; the MCP server is the missing party trick.) Invoice Ninja self-hosted (Time entries become numbered invoices and the API is usable; agents do not get a ready-made MCP server.) The prompt is for when you want it exactly your way.