← Work with Chip brontiqbooks.com

Brontiq Books

Local-first invoice ledger

Brontiq Books is an invoicing and bookkeeping system for freelancers, contractors and small businesses. It is local-first in the literal sense: the database is a single file on a volume you own, running the same container on your laptop as it does in production. What comes out of it is a compliant Australian tax invoice, and the whole ledger is scriptable, so an invoice can be raised by a sentence rather than a form.

Data
Yours
PocketBase on your own volume
GST
Per line
Summed, not applied at the foot
Ledger
Scriptable
Bearer-token invoice API
Overdue
Derived
Read time, never a stored flag

What local-first actually means here

The phrase is worth being precise about, because it is used loosely. Brontiq Books is not an offline browser app that queues writes and merges them on reconnect. It has no service worker and no client-side replica, and claiming otherwise makes a nicer story than the true one.

What it is instead is a system where the database is not someone else's. Brontiq runs on PocketBase, which is a single Go binary with SQLite behind it. The whole ledger, every invoice, payment, credit and BAS period, is one file on a mounted volume. In production that volume is attached to a machine in Sydney. On a laptop it is a directory called pb_data next to the source, served by the same container image, with the same migrations applied in the same order.

That is the property that matters to the person whose money is in it. There is no export step required to get your data out, because there is no format it is trapped in. You can stop paying for the hosting and keep working. For a ledger, which is a record you are legally required to retain and may need to produce years later, that is a different guarantee from a SaaS form over a database you cannot see.

Brontiq Books · where the ledger lives Architecture Same image, same schema, both sides
Scroll the diagram sideways
Where the Brontiq ledger lives, on a laptop and in production Two columns showing the same stack twice. On the left, a developer laptop runs the SvelteKit app against a PocketBase container from Docker Compose, whose SQLite database sits in a local pb_data directory. On the right, production runs the same SvelteKit app on one Fly machine in Sydney and the same PocketBase image on a second machine, whose SQLite database sits on a mounted pb_data volume. Between the two columns, an arrow marked migrations shows that the same migration files are applied to both. Underneath, a note records that the ledger is one SQLite file either way. ON YOUR MACHINE SvelteKit app npm run dev · Svelte 5, Tailwind 4 HTTP PocketBase docker compose up · one Go binary port 8290, healthchecked ./pb_data A directory next to the source. One SQLite file. copy it, back it up, read it with any client SHARED pb_migrations same schema IN PRODUCTION SvelteKit app Fly machine, syd · adapter-node HTTPS PocketBase a second Fly machine, syd deployed separately from the app /pb/pb_data A mounted volume. The same one SQLite file. survives the machine it is attached to The ledger is one SQLite file on a volume, either way. There is no proprietary format to be exported from, which is the whole of the local-first claim and the end of it.
The same stack twice. The app and the database deploy as two separate machines, so the ledger is not coupled to the lifetime of the web process, and the volume outlives both. There is no offline mode and no sync layer; the guarantee on offer is ownership, not availability without a network.

What it does buy you

A backup is a file copy. A migration to another host is moving one volume. An auditor asking for seven years of records gets a database, not a CSV approximation of one.

What it does not

Close the laptop lid on a plane and the app stops working like any other web app. Offline capture is not built, and the page does not pretend it is.

Why that trade

Invoicing is deliberate desk work, not field capture. The valuable property is that the record cannot be held hostage, and that is the one it delivers.

The invoice is a tax invoice

An invoice is not a receipt with a logo on it. In Australia it is a document with legal requirements, and the thing Brontiq issues is built to be that document: the words Tax invoice, the supplier and their ABN, the client, an invoice number, the date and the due date, each line with the GST that applies to it, and the GST total stated separately from the amount payable.

The detail that gives the model away is where GST is computed. It is not ten per cent applied to the subtotal at the foot of the page. Every line item carries its own gst_percent, defaulting to 10, and the invoice total sums the per-line GST and rounds the sum. That distinction is invisible while everything on the invoice is taxable, and it is the entire difference the moment one line is not.

Brontiq Books · Tax invoice BB-2026-0184 Issued A4 · 16 Feb 2026 · AUD incl GST
Scroll the sheet sideways
An issued Brontiq tax invoice, BB-2026-0184 An A4 tax invoice. The header carries the Brontiq mark, the words Tax invoice, the supplier name Harbourline Studio with an ABN, and a paid stamp. Below it the invoice number BB-2026-0184, the issue date 16 February 2026, the due date 2 March 2026, and the client Ravensworth Civil with a contact and an email. A table lists four lines: a two day discovery workshop at 1450 dollars, thirty eight hours of ledger integration at 165 dollars an hour, a branded statement template at 980 dollars, and a data migration at 1640 dollars. Each line shows GST at ten per cent. The subtotal is 11790 dollars, GST is 1179 dollars, and the total payable is 12969 dollars. Payment terms, bank details and a note that the document was generated by Brontiq Books close the sheet. B Brontiq TAX INVOICE PAID FROM Harbourline Studio ABN 54 118 402 663 INVOICE NUMBER BB-2026-0184 INVOICE DATE 16 Feb 2026 DUE DATE 02 Mar 2026 BILL TO Ravensworth Civil Pty Ltd Attn: Dale Whitmore [email protected] ABN 61 774 903 218 DESCRIPTION QTY UNIT PRICE GST LINE TOTAL Discovery workshop, on site Two days, scoping the ledger migration 2 1,450.00 10% 2,900.00 Ledger integration build Hourly, billed at the agreed rate 38 165.00 10% 6,270.00 Statement template, branded Fixed price, one revision included 1 980.00 10% 980.00 Data migration, 3 200 records Fixed price, reconciled against source 1 1,640.00 10% 1,640.00 SUBTOTAL $11,790.00 GST $1,179.00 Summed from the rate on each line, then rounded once TOTAL PAYABLE AUD $12,969.00 PAYMENT Payable within 14 days of the invoice date. Electronic transfer preferred. BSB 062 918 ACC 4471 0263 Reference BB-2026-0184 RECEIVED Paid in full on 2 March 2026. This invoice is closed and appears as settled on the February statement. AGAINST THIS ACCOUNT Invoice total $12,969.00 Payment received, 2 Mar 2026 −$12,969.00 Balance on this invoice $0.00 Generated by Brontiq Books · GST is stated separately as required for a tax invoice Supplier, client, ABNs, bank details and every figure on this sheet are invented Page 1 of 1
The issued document, drawn at A4. Four lines sum to $11,790.00, GST at 10 per cent is $1,179.00, and the total payable is their sum, $12,969.00. The second line is hourly, 38 hours at $165.00, which is the case the next section is about. PDFs are produced with jsPDF, so the document is generated rather than screenshotted.

Hourly against fixed, and a deliberate guard

A line on a Brontiq invoice is quantity × unit_price, which covers a fixed price as a quantity of one and an hourly line as a quantity of hours. Adding a line item recomputes the parent invoice: it sums every line total, sums the per-line GST, rounds both, and writes the result back, so the list view and the document can never disagree with each other.

The invoice record itself also carries is_hourly, hourly_rate and hourly_quantity for invoices billed as a single hourly engagement. This produces the one piece of API behaviour worth knowing before you use it: changing the rate or the hours alone will not move the money. The recalculation is guarded on the flag arriving in the request body, not on the flag stored against the record, so a PATCH must resend is_hourly: true alongside the new figures to trigger it.

Live screen
The working screen. Four billable lines and one carried at $0.00, which is how an unbillable entry stays on the timesheet without reaching the money. The lines sum to $8,300.00, the per-line GST sums to $830.00, and the total is $9,130.00. Each line carries its own hourly switch, which is the state the API guard below is about.
Does not recalculate · the total holds
PATCH /api/invoice/{id}

{
  "hourly_quantity": 42
}

// stored is_hourly is true, but the
// guard reads the request body, so
// amount, gst and total are untouched
Recalculates · amount, GST and total
PATCH /api/invoice/{id}

{
  "is_hourly": true,
  "hourly_quantity": 42
}

// amount = 42 x 165.00 = 6,930.00
// gst    = amount x 0.1 =   693.00
// total  = amount + gst = 7,623.00

That files easily under trivia. It reads better as a decision about who is allowed to move money. A PATCH that silently re-derived a total from whichever fields it happened to touch lets any partial update rewrite an issued invoice as a side effect. Requiring the caller to name the billing mode makes the recalculation an explicit instruction rather than an inference, which is the behaviour you want from the endpoint an automated agent is holding.

The same instinct shows up in the recompute helper, which refuses to act twice over. An invoice with no line items keeps its stored totals rather than being zeroed, and an invoice whose line items all sum to zero while the record carries a real amount is left alone as well, on the reasoning that those lines are notes rather than charges. Both guards exist so that a routine recalculation cannot quietly destroy a real figure.

The two paths are not identical, and the difference is instructive. The line-item recompute reads each line's own GST rate and sums them; the hourly shortcut multiplies by ten per cent directly, because a single hourly engagement is one rate by definition. The general path is the one that stays correct when an invoice is mixed, which is why it is the one the document is built from.

A ledger you can talk to

Small-business invoicing tools are usually a closed form. Brontiq exposes the ledger over an HTTP API behind a bearer token, compared in constant time, which is what allows an invoice to be raised from a sentence typed at an assistant rather than from a screen. Clients are matched by name rather than by identifier, so the caller does not have to hold a database key to bill someone. The match runs in tiers, exact first, then containment either way, then word overlap with a floor of half the words agreeing, and a name that matches nothing is refused rather than quietly creating a second client.

Method
Endpoint
What it does
GET
/api/invoice?action=find_client
Fuzzy-matches a client by name, so a rough spelling resolves to the record.
POST
/api/invoice
Raises an invoice against a matched client name, with a number and a due date.
PATCH
/api/invoice/{id}
Updates any permitted field. Setting status to paid stamps the payment date.
POST
/api/invoice/{id}/line-items
Adds a line and recomputes the parent invoice totals from every line.
DELETE
/api/invoice/{id}
Soft-deletes through a deletion log, so a removal stays auditable.
GET
/api/invoice/{id}/pdf
Returns the rendered tax invoice, ready to send.
Live screen
The product documents its own API, which is the tell that the ledger is meant to be driven from outside it. The two requests in the middle are the same PATCH with one field added: without is_hourly in the body the total holds at $9,130.00, and with it the invoice recalculates to 42 × $165.00, GST $693.00, total $7,623.00.

Two of those are worth pulling out. Marking an invoice paid sets the payment date automatically when one is not supplied, and moving the status back off paid clears it again, so the pair cannot drift out of agreement. And deletion is a soft delete written through a log rather than a row disappearing, which is the correct behaviour for a financial record and the reason the interface carries a deletion log of its own.

The statement, and how a balance is arrived at

A statement answers one question: what does this client owe today. Brontiq builds it per client, listing every invoice with its status and payment date, and reconciling to a balance. Status is the interesting part, because overdue is never stored. It is derived at read time by comparing the due date to now, and paid invoices are excluded from the test. An invoice becomes overdue because the calendar moved, not because a nightly job remembered to run.

Worth being straight about the rest of it. The other statuses are set by a person, not inferred from the payments table, so recording a part payment does not move an invoice to a partial state on its own. And the statement is delivered as a print view rather than a generated file: the client opens it and prints to PDF. The invoice is the document that is properly generated; the statement is a screen that knows how to print.

Live screen
The ledger the statement is drawn from, rendered on 10 April. Every row here is stored as either paid or outstanding, yet two read Overdue, because their due dates have passed and the status column is a comparison rather than a column. The third is not due until 24 April, so it still shows what the operator set. Loading the page later changes it without a write.
Live screen
The same client on 30 April, by which date all three unpaid invoices are past due. The ageing bands reconcile across as well as down: $12,045.00 current plus $4,620.00 in the 30 to 59 day band is the $16,665.00 balance. Print Statement opens the browser's own dialogue, because there is no server-side statement file.
How the balance is reached
Invoiced
Paid
Outstanding
Settled in the periodBB-2026-0179, 0181, 0184 and 0188, each with a payment date recorded
$29,942.00
$29,942.00
$0.00
Current ageing bandBB-2026-0197 at 21 days and BB-2026-0201 at 6 days past due
$12,045.00
$0.00
$12,045.00
30 to 59 daysBB-2026-0193, the oldest of the three still unpaid
$4,620.00
$0.00
$4,620.00
Statement total
$46,607.00
$29,942.00
$16,665.00

What it grew into

The schema history reads as the honest account of the product. It starts with clients, invoices and payments, which is the whole of an invoicing tool. Then line items arrive, because a single amount on an invoice stops being enough. Then a deletion log, because financial records should not vanish. Then credits, expenses, tax settings, GST compliance fields on expenses, and BAS periods, at which point the thing has stopped being an invoicer and become a bookkeeping system that knows what an Australian quarter is.

Alongside that a client portal appears, with its own logins, so a client can see their own statement without an account being created for them. Those logins are PIN-based, and the PIN is stored as a salted PBKDF2-SHA-256 hash at 200,000 iterations rather than a bare digest, with older hashes upgraded on the next successful login. A four to six digit PIN has a small enough keyspace to be exhausted trivially from a leaked bare digest, so the salt and the iteration count are doing real work there. Portal views and downloads are logged with their time and origin, so the operator can see what was accessed.

The most recent migration in the repository is the one that closed the door properly: read access on every financial collection now requires an authenticated request. The commit that introduced it says plainly what it was fixing, which is the correct way to record that kind of change rather than quietly amending it.

Invoices in

A supplier PDF can be uploaded and read rather than retyped. Extraction is a model call, so that step is the one place the local-first claim genuinely does not hold.

Money against them

Payments are their own records against an invoice, so a partial settlement and a split are simply several rows. Credits carry overpayment forward against a nominated invoice.

Quarterly

Expenses carry GST compliance fields and roll into BAS periods, which is the point at which the ledger stops being a list of invoices and starts being accounts.

The shape of the decision

Every choice on this page points the same direction. GST is computed per line so that a mixed invoice is correct rather than approximately correct. Deletion writes to a log so a removal leaves a trace. Recalculation is guarded so a partial update cannot silently rewrite an issued total, and guarded again so an empty recompute cannot zero a real one. Overdue is derived so it cannot be stale. And the database is a file on a volume you hold, so none of the above is contingent on the vendor continuing to exist.

That last one is what the name local-first is really buying. Not offline. Custody.

Brontiq Books is live at brontiqbooks.com The running product, behind a login. The screens on this page are drawn from it and the figures in them are invented.

The ledger is a file you own.

Same container on your laptop as in production, same schema, same one database.

Get in touch

Brontiq Books is a real product with real customers, so nothing on this page is taken from its data. The screens are drawn from the running product; every client, contact, ABN, bank detail, invoice number, date and figure shown is invented. The arithmetic is genuine: the invoice lines sum to their subtotal, GST is exactly 10 per cent of it, and the statement balance is the difference between what was invoiced and what was paid.