I run a small one-person software consultancy, and for the last year I've been paying a monthly SaaS fee to send maybe 8-10 invoices a month. Every renewal I'd think "this is a CRUD app with a PDF exporter, why am I paying for this" — so I finally built my own and called it Kite.
It's now open source: github.com/Skye-Technology/Kite-Invoice-App
What it actually does
Kite is a self-hosted invoice and expense tracker for freelancers and small businesses. Nothing exotic — invoices, quotations, expenses, contacts, PDF export — but built around a few things I actually needed and kept not finding together in one tool:
Multi-currency, with the invoice currency following the contact's default currency
Dutch-style VAT handling — 0% / 9% / 21% / exempt per line item, plus a reverse-charge flag for cross-border invoices
PDF generation that actually paginates correctly on long invoices (more on this below)
A timesheet importer — upload an .xlsx export, get invoice line items back with hours converted to quantity and descriptions built from configurable project-name rules
Expense categories with icon, balance-sheet, and VAT-deductibility flags, and gross-amount entry (type what the receipt says, the app back-calculates the net)
Stack: Next.js (App Router) + Prisma/PostgreSQL + S3-compatible storage (MinIO locally, R2/S3 in prod) + @react-pdf/renderer for the PDFs. Single-operator auth, no roles or permissions to manage — it's built for one person or a tiny team, not a multi-tenant SaaS.
The pagination problem nobody warns you about
The part I underestimated most was PDF pagination. @react-pdf/renderer has no equivalent of CSS's table-header-group, so there's no built-in way to say "repeat this header row on every page." You build pages by hand.
My first attempt just packed a fixed number of rows per page. That worked fine on every invoice I tested with short, one-line descriptions. Then I imported a real 41-line invoice built from timesheet data, where some descriptions wrapped to two or three lines, and got a genuinely blank page in the middle of the PDF with a duplicated page-number label.
The fix was switching from row-count budgets to estimated-height budgets: measure each row's likely rendered height and pack rows into a page until the next one would overflow an estimated available-height budget. Boring to write up, but the kind of thing that only breaks on real data, not synthetic test cases.
Importing history without lying about it
I had old invoices in a previous tool I was migrating away from, and wanted them in Kite for record-keeping without manually re-entering every line item — but they shouldn't look like real, editable invoices in the new system. No PDF export, no "Duplicate" action, and critically, importing them shouldn't consume the app's own next-invoice-number sequence.
That turned into an isHistorical flag: it still counts toward dashboard totals and shows up in the invoice list (with a badge), but the UI hides actions that don't make sense for a record you're not going to re-send.
Why open source it
Genuinely just: it's a solved problem for me now, and gatekeeping a CRUD app behind "buy my SaaS" felt silly. MIT licensed, Docker Compose for local dev, works against local Postgres or hosted Supabase without code changes.
Issues and PRs welcome — I'm using this myself day to day.
Top comments (0)