How to build a FreshBooks alternative
How to build a FreshBooks alternative walks one freelancer from a client and a draft to a locked number, a public page, and a payment that clears the balance.
How to build a FreshBooks alternative is one freelancer, a handful of clients, and an invoice that receives a number only when you send it. FreshBooks is that product. The catalog line is “Invoices out, money in, spreadsheet retired.”
The card lists $23–$60 a month. That is $276–$720 a year. The verdict is “Send the invoice tonight”. The first estimate is 10 hours: clients, invoices, paid tracking, and a PDF download. The project name on the card is Billfold.
The FreshBooks teardown is the cent math and why the number waits. This guide is the parts, the screens, and the prompts for that row.
Hours you will paste onto a line live in the Harvest guide. Harvest is $13–$17.50 a person a month, about 4 hours. A receipt for a manager is Expensify, $5–$9 a person a month, in the Expensify post. A trip split with friends is the Splitwise guide. Billfold is money a client still owes you.
How to build a FreshBooks alternative on the first row
The FreshBooks page splits the work into three rows.
The first row is 10 hours. A client with one currency and default terms. A draft with line items, a tax rate, and notes. Send assigns the number, locks the amounts, and opens a public page. A PDF comes from that same page. You record payments until the balance is zero.
The second row is 4 days, 32 hours. An expense log, receipt uploads, an overdue list on a dashboard, and email at send time. The third row is 3 months and up, 480 hours. Bank feeds, payroll, double-entry books, and taxes.
If you tell an agent to “clone FreshBooks”, it aims at the third row. Paste the prompts below and keep Ada’s October invoice on the first one.
What the 10-hour build does
You can add Ada Rossi at Bootcamp.dev, USD, net 14. You can draft four blog posts at $450.00 each, tax 7.25 percent, notes “October writing hours”, due 2026-11-11. You can send it. The page shows 2026-014, a long link, and a total of 193050 cents, which is $1,930.50. Ada opens the link with no account. You record the payment when the transfer lands. The banner changes to paid.
You can void a payment you typed wrong. You can revoke the link and issue another without changing the number. You can refuse a second send, an edit after send, and a delete of Ada while 2026-014 still points at her.
What it leaves out
No email provider yet. You copy the link. No expense list and no receipt file. No dashboard of this month’s income. No bank login, no payroll, no chart of accounts, no tax filing, no second currency, and no card form. Ada pays you the way you already get paid. Billfold records that the money arrived.
Overdue is a comparison at read time. The server date is past dueOn, and the unpaid balance is still above zero. The dashboard that lists every late invoice waits for the 32-hour row. I would still put the banner on the public page in the 10-hour build, because that page is already in this row, and leave the month totals for later.
Where the invoice lives
Two browsers talk to one app. Yours is logged in. Ada’s has a link and no session.
Node.js and Astro render both. Use TanStack Start if React is your weekday language. The stack post is which tab you copy. On either tab the rows live in a SQL database. The card lists D1, so that is the database I would pick. R2 is on the card for receipt files, and those files are the 32-hour row, so the bucket stays empty this weekend.
The request does the work. Saving a draft checks the owner, writes the client id, the lines, the tax, and the due date. Sending runs in one transaction: read the draft, increment that owner’s counter for the year, store 2026-014, store a new random token, set status to sent. If the transaction fails, the counter and the invoice both stay where they were. There is no second job that “finishes” the send.
Reading the public page does not write. It loads by token, recomputes the total from the lines, subtracts payments that are not void, and compares the server’s date with dueOn. A wrong token and a missing invoice return the same 404. A revoked token does too.
Nothing in this v1 calls a bank, a card network, or an email API. The failure you plan for is your own database. When D1 is down, drafts do not half-save, and Ada sees an error page instead of a stale total from the browser. When it is up, the public page and the PDF are the same HTML. I would not render a second template for print. Two templates drift by a line the first week you add a note.
The session cookie is yours. The token is the only secret on Ada’s URL. Do not put the token in access logs. Log the invoice id, the number, and whether the lookup matched.
The rows behind one invoice
The card lists Client, Invoice, LineItem, Payment, and Expense. Expense waits. A stored total is not on the list, and I would not add one. The guardrail says amounts are integer minor units, and a sent invoice is never edited, renumbered, or deleted.
A client is Ada: name, email, currency, and terms in days. One currency on the client. Billfold does not convert.
An invoice points at one client. While it is a draft it has no number and no token. dueOn, tax, notes, and the client are fields you can change. Send copies nothing from a later edit of Ada. If you change her terms from 14 days to 30 the next morning, 2026-014 still says 2026-11-11.
A line item is a description, a whole-number quantity, and a unit price in cents. Four posts at 45000 cents is the line. The line total is quantity times unit cents, which stays an integer. Tax is one rate for the invoice, in basis points. 7.25 percent is 725. Apply it once to the subtotal and round half up. That is the only rounding in this version.
Math.round(1001 * 725 / 10000) // 73
A $10.01 subtotal with this tax becomes 73 cents of tax, 1074 cents due. Ada’s real invoice divides evenly: 4 times 45000 is 180000, times 725 over 10000 is 13050, total 193050. The printed lines have to add to the printed total. Compute that on the server from the stored lines. Ignore a total the browser posted.
A payment is a date, an amount in cents, a note, and a void flag. It attaches only to a sent invoice. The balance is the invoice total minus payments that are not void. Paid means the balance is zero or below. The paid date is the date on the payment that crossed zero. An overpayment stays on the row. 200000 cents against 193050 leaves 6950 cents extra, and the owner screen says so. The public page still says paid.
A counter is per owner and per year. The next number is taken inside the send transaction. An abandoned draft does not burn 2026-014. Two tabs sending two drafts get consecutive numbers. Two clicks on the same draft get one number. After send, the number and the lines are frozen. A typo is a new invoice whose notes name 2026-014. It does not change the original, and it does not start a general ledger.
Deleting Ada is refused while any invoice points at her. Deleting a draft is allowed, and it leaves no hole, because the draft never took a number. Deleting a sent invoice is refused. Void a payment instead of deleting it.
Paid means the sum has reached zero or gone past it. Partial coverage stays sent, with the remaining cents visible. Full coverage, or extra, shows paid. Void the payment that crossed zero and the invoice reads sent again, or overdue if the due date has passed. The voided row is still in the list.
The screens you ship
Clients, the invoice list, the editor, and the public page ship this weekend. The payment form sits on the sent invoice. The dashboard and the expense list are drawn here so you know their shape, and the prompts do not build them yet.
Clients
Ada’s row shows the name, the email, USD, and 14 days. The form is those four fields. A delete control is absent once an invoice exists. Changing terms updates the default for the next draft only.
Invoice list
Each row is a draft with no number, or a sent invoice with 2026-014. You see the client, the computed total, the balance, and a word for the result: draft, sent, paid, or overdue. Overdue is that comparison, shown as a label, not stored. A button starts a draft already pointed at Ada.
Editor
The draft screen is a client picker, lines, a tax rate, a due date, and notes. Under the lines, the server-rendered preview shows subtotal 180000, tax 13050, total 193050 for Ada’s invoice. Save stays on the draft. Send is a separate button.
After send, every field is plain text. The number, the copy-link control, revoke, and the payment form replace the inputs. A credit is a second button that starts a new draft and puts 2026-014 in the notes. It does not unlock this one.
Public page
Ada sees your business name, her name, the lines, the three totals, a status banner, and a way to print. The URL is the token. The number 2026-014 is on the page and not in the path. There is no login form and no pay button.
Print uses the browser. The PDF is that print. Speaker chrome, your nav, and the copy-link control stay out of the print stylesheet.
The payment form, back on your side, asks for a date, an amount in cents, and a note. It shows the remaining balance before you save. Each past payment has a void action. Void does not ask the browser to subtract. The next read does.
Screens that wait
The dashboard is outstanding total, overdue invoices, recent payments, and this month’s income beside expenses. It needs history, so it sits on the 32-hour row. The expense screen is a dated list, a category, an amount, a receipt, and a monthly total. Receipts are private. The public token never opens one.
From a draft to a zero balance
You save Ada. You save the draft with one line and tax 725. The preview reads 193050. You press send once. The editor becomes read-only, the number is 2026-014, and a private window with the token shows the same three figures.
You record 100000 cents. The invoice stays sent and the balance reads 93050. You record 93050. The banner reads paid, and the paid date is the second payment’s date. Both rows remain.
If send is clicked twice, the second request finds a sent invoice and writes nothing. If the due date is yesterday at send time, the public banner says overdue and the number of days. If the token is revoked, Ada’s bookmark gets the same 404 as a random string. A new token works. The number on the page is still 2026-014.
If you void the second payment, the balance returns to 93050 and the status word is sent again. The voided row stays, marked void, and it is left out of the sum.
A correction after send is a new draft. You write the note “replaces a typo on 2026-014” and you send that as 2026-015 when it is ready. 2026-014 still shows the original lines. You do not get a books screen that applies 2026-015 against 2026-014. That pairing is the 480-hour row.
Prompt 1. Clients and an empty list
Copy the Astro prompt from Build your own FreshBooks. Paste this above it.
Ship the 10-hour row only. Project name: Billfold.
Node.js and Astro. Rows in D1.
TanStack Start is the same screens and the same rules,
with server functions and a SQL database.
One owner. Clients have name, email, one currency,
and default terms in days.
Seed Ada Rossi, [email protected], USD, 14.
Invoice list can be empty.
A draft has no number and no public token.
Deleting a client who has any invoice is refused.
Deleting a sent invoice is refused.
No email, no expenses, no receipts, no bank,
no payroll, no card form, no second currency.
When the plan lists routes and tables, stop.
Do not store an invoice total column.
The TanStack Start tab keeps Ada, the list, and a SQL transaction for the later send. The checks below do not change.
What to check after the clients
Ada is on the list after a refresh. A second owner, or a logged-out window, does not see her. The tables are clients, invoices, line items, payments, and a counter. There is no expenses table and no totals table. Create a throwaway client with no invoices and delete them. Ada’s delete control does nothing useful once she has a draft, which you will prove in the next step.
Prompt 2. The draft and the cents
Add the draft editor on Billfold.
Fields: client, due date, notes, tax in basis points,
and lines with description, whole-number quantity,
and unit price in integer cents.
Line total is quantity times unit cents.
Tax is round half up of subtotal times tax
over 10000, once. No floats.
Ignore any total posted by the browser.
Seed the October invoice for Ada.
One line: Four blog posts, quantity 4, unit 45000.
Tax 725. Due 2026-11-11.
Notes: October writing hours.
Preview must show 180000, tax 13050, total 193050.
Also accept a one-line draft with subtotal 1001
and tax 725, and show tax 73, total 1074.
Save stays a draft. Quantity below 1 is refused.
Done when both previews match after a reload.
What to check after the draft
Reload the editor. The line is still 4 times 45000. The preview still ends at 193050. Change the quantity to 0 and save is refused. The 1001-cent case shows tax 73, not 72. Ada’s terms can change to 30 afterward, and this draft’s due date stays 2026-11-11 if you already set it. The database row for the invoice has a null number.
Prompt 3. Send, lock, and the public page
Add send and the public page.
Send runs in one transaction.
If status is not draft, write nothing.
Otherwise take the next number for this owner
and this year, store a token of at least 128 bits,
set status to sent, and freeze lines, tax, due date,
client, and notes.
Format like 2026-014. Abandoned drafts leave no gap.
Two sends of different drafts get consecutive numbers.
Two sends of the same draft get one number.
The public URL is the token, never the number.
Wrong, missing, and revoked tokens return the same 404.
Rate-limit lookups.
Revoke and reissue without changing the number.
Status on the page is computed.
Overdue means the server date is past dueOn
and the unpaid balance is above zero.
Show days late. Do not accept a status from the browser.
Print stylesheet uses this same HTML.
One slide of content per page is not the job.
The invoice prints as one document, nav hidden.
Do not store a PDF file.
Done when a private window shows 2026-014
and 193050 cents, and a second send click
does not mint 2026-015.
What to check after send
The editor inputs are gone. The list row shows 2026-014. The public page matches the preview, including the note. View source on the public URL and confirm the path is not 014. Request /invoices/014 and a random token. Both are 404s with the same body. Revoke, confirm the old link 404s, reissue, confirm the new link shows the same number.
Send a second draft in another tab after the first has sent, and it becomes 2026-015. Delete a third draft that was never sent. The next send is 2026-016, not 2026-015 again and not 2026-017. Set a draft’s due date to yesterday, send it, and the banner includes the day count. The counter row and the count of sent invoices for 2026 agree.
Prompt 4. Payments until the balance is zero
Add payment recording on a sent invoice.
A payment is date, amount in cents, and a note.
Drafts reject payments.
Recompute the balance from the locked total
minus payments that are not void.
A partial payment leaves the invoice sent
and shows the remaining cents.
Balance zero or below shows paid.
The paid date is the date of the payment
that crossed or passed zero.
An amount above the balance is stored in full
and the owner sees the extra cents.
Void keeps the row, sets a void flag,
and drops it from the sum.
Two payments saved together both count.
Do not call a payment provider.
Done when 100000 then 93050 on 2026-014
shows paid, and voiding the second payment
shows a balance of 93050 with both rows still listed.
What to check after the payments
Record 100000. Reload the public page. The banner shows 93050 still due, not paid. Record 93050. The banner says paid. The owner list agrees. Record a fresh sent invoice and pay 200000 against a 193050 total. The owner sees 6950 cents extra, and the public banner says paid.
Void that large payment. The extra disappears and the full balance is back. The voided row is still on the screen. A logged-out post to the payment URL is refused. A post that tries to edit the line quantity on 2026-014 is refused. Start the correction draft and see 2026-014 mentioned in the notes, with the original invoice unchanged.
Failures the agent ships anyway
Dollars in floats
The form stores 450.00 and the total prints $1,930.50 while the database holds a float. Add one more line that needs the 73-cent case and the printed tax is off by a cent. The check is the 1001-cent draft. If tax shows 72, rounding is wrong or a float got in.
A total column
The preview looks right on day one. You void a payment and the column still says paid. Balance has to be a query. If a total_cents column changes when you void, the page is trusting a stored answer. Delete that write.
A number on the draft
Saving the draft shows 2026-014. You abandon it and the next real send is 2026-015. The hole is the bug. Drafts display the word draft. The counter moves only inside send.
The number in the URL
The public path ends in 014 or the row id. Request the next integer and you see another client’s name. Move the token into the path and return one 404 body for every miss. Rate-limit before you look the token up, so a probe loop does not become a database loop.
A pay button
Send is wired to a card checkout because “invoice” sounded like a store. The card’s integration line is transactional email, and even that waits. There is no payment provider in this version. The button label is record payment. The amount is a note that the transfer already happened.
Email before the commit
On the 32-hour row the agent sends mail and then writes the row. The mail arrives, the transaction rolls back, and Ada has a link to nothing. The transaction commits first. Mail, when you add it, gets the token that is already stored. If the provider errors, the invoice stays sent and the copy-link control still works.
Two print templates
The on-screen total is 193050 and the PDF says 180000 because the print view forgot the tax. One HTML document, one print stylesheet. If you find a second renderer, delete it and print the public page.
Deploy, then send Ada’s invoice
Billfold wants Astro and D1. Put it on a hostname you already own, bills.flaviocopes.com. The hosting post is the adapter, the binding, and the domain. Take a backup of D1 before you delete a paid account. The rows are the invoices.
Log a send with the invoice id and the new number, and leave the token out of that line. Log a payment with the number and the cents. Count public 404s so you can see a probe without storing the strings people tried. If sent numbers for 2026 skip or repeat, the transaction is wrong. Run that check as a query against D1.
Then use it. Add the clients you bill this month. Draft one with the real lines. Send it. Open the link in a private window. Print. Record the payment when it lands. Confirm the editor, the public page, and the print all show the same number and the same total.
The same v1 also replaces Invoice Ninja and Bonsai if what you wanted was invoices you own. The card names them and does not give them prices.
What to skip on purpose
Leave the 32-hour row until one real invoice has gone from draft to paid. That row is the expense log, the private receipt, the overdue list with month totals, and the email. Leave the 480-hour row alone. Banks, payroll, double-entry, and tax filings are a different product.
A quote Ada accepts before you bill is Better Proposals, $13–$42 a person a month. The proposal post is the share link and the line items. Copy the accepted lines by hand. Household categories are the YNAB guide. YNAB is $9.08–$14.99 a month. That is spending you already did.
The scope post is the same three-row habit. The prompt post is the extra line that says do not connect a bank.
Start now
Go to Build your own FreshBooks. Copy the Astro prompt, or TanStack Start if that is what you write.
Tell the agent the first estimate. Clients, invoices, paid tracking, PDF download. Ten hours. Amounts in cents. The number waits until send.
Send one real invoice. Record the payment when Ada pays it. Reload, and read paid with 2026-014 still locked.