Zudget — Know What You Can Spend Before You Spend It

A personal finance planner that models money the way it really moves: accounts, planned vs. paid entries, credit-card invoices with installments, budgets, goals and recurring entries. A SvelteKit monolith on MySQL, with money in integer cents, tests against a real database, and an SSH-locked deploy.

  • SvelteKit
  • Svelte 5
  • TypeScript
  • MySQL
  • Drizzle ORM
  • Better Auth
  • Valibot
  • Vitest
  • Tailwind CSS
  • GitHub Actions
  • PM2
  • Problem: answering “how much can I still spend this month?” without connecting a bank account.
  • Stack: SvelteKit monolith (server rendering + form actions), MySQL with Drizzle, Better Auth, Vitest, GitHub Actions to a VPS.
  • Key decision: credit cards modelled as billing cycles, so no money is ever counted twice.
  • Outcome: live, with all seven planned phases shipped, plus invoice import and recurring entries.

The problem

Most budgeting apps answer “where did my money go?”. I wanted one that answers “how much can I still spend?”. That’s a forecast. It needs to know what’s paid, what’s planned (rent next week, salary on the 5th), what’s sitting on a credit-card invoice that only hits the bank on the due date, and what’s set aside for a goal.

Spreadsheets break at the first instalment purchase or transfer between accounts. Bank-connected apps need your bank credentials. Zudget needs neither.

Zudget landing page: “Saiba quanto você pode gastar antes de gastar”, with an animated demo of adding an expense “Know how much you can spend before you spend it.”

Constraints

  • Totals must be exact. Being one cent off, or counting something twice, is worse than having no app.
  • No bank integration. Data comes in by hand, from recurring entries, from imported invoices or from forwarded notifications.
  • One maintainer, shipped in phases, each one usable on its own.

Key decisions

1. A money model that adds up

Problem. Floating-point maths drifts. Accounts used only for tracking (an investment broker, say) inflate what looks spendable. Transfers can be counted twice.

Decision.

  • Every amount is an integer number of cents. Converting to and from currency happens only at the edges.
  • Every entry is paid or planned, and each screen shows Balance (paid only) next to Forecast (paid + planned).
  • Accounts can be excluded from totals. Transfers between counted accounts cancel out.

Why it holds. The month view answers the real question, and every rule about what counts is covered by tests.

Zudget monthly summary: balance, income, expenses and transfer cards above an accounts table with balance and forecast columns Paid and planned side by side, with a tracking-only account kept out of the totals (earlier visual identity).

2. Credit cards as billing cycles

Problem. A purchase on the 28th might land on next month’s invoice or the one after, depending on the closing day. Instalments spread over many invoices. If both the purchase and the invoice payment count, the money is counted twice.

Decision.

  • A card is an account with a limit, a closing day and a due day. Each invoice is named after its due month.
  • Instalments (up to 48) become one real entry per month, each on its own invoice.
  • Paying an invoice is a transfer, and payments settle the oldest invoice first.
  • By default only the invoice payment counts as spending. One setting switches to counting each purchase instead.

Why it holds. Nothing is counted twice, the available limit includes future instalments, and spending shows up when the invoice is actually paid, which is when people feel it.

3. Recurring entries are real rows

Problem. Rent, salary and subscriptions need to show up in forecasts months ahead, and editing one occurrence mustn’t break the series.

Decision. A repeat rule creates real planned entries up to 12 months ahead, topped up as time passes. Each date is calculated from the start date, so a series starting on the 31st doesn’t slowly drift to the 28th. Edits apply to “only this one” or “this and the following”.

Why it holds. Reports, budgets and invoices all keep working with plain rows. The repeat logic lives in one module, not in every query.

4. Getting data in without a bank connection

Problem. Typing everything by hand is why most people give up on budgeting.

Decision. Two input channels, both with a review step before anything is saved:

  • Invoice import from a Nubank CSV or a Banco do Brasil PDF. Categories are suggested from history, refunds and payments are handled, and purchases already recorded come unchecked.
  • Notification inbox: the phone forwards bank notifications to a personal token-protected endpoint (only the token’s hash is stored). They’re deduplicated and parsed into draft entries.

Why it holds. The app automates the safe parts (parsing, suggesting, deduplicating) and leaves the decision about what gets saved to a person.

Zudget transactions: expenses with filters by account, category, tag and status, and a search box Transactions: planned and paid entries, with the tags that budgets are built on.

5. Tests against the real database

Problem. The project started on SQLite, but production runs MySQL. Differences in grouping rules and date handling would only show up in production.

Decision. Development moved to MySQL 8 early, using its strict default settings. Tests run against a real MySQL instance, with one database per test worker so they run in parallel. CI tests against MySQL 8.4.

Why it holds. The tests prove the queries work on the database that real users hit.

Hard problems solved

  • Neutral transfers. Transfers must cancel out between counted accounts, count as spending when they go into tracking accounts, and still count towards a tagged budget (an investment contribution, say).
  • Invoice edge cases. Closing on the 31st in February, purchases on the closing day itself, overpayments becoming credit, and editing a single instalment.
  • A safe deploy key. The CI key can only run the deploy script, nothing else, and the build itself needs no secrets.

Trade-offs

  • No bank sync: no stored credentials, but data entry depends on imports, recurring entries and the inbox.
  • Recurrences stored as rows: simpler queries, more rows to keep topped up.
  • A server-rendered monolith: one deploy and one mental model, but not the shape for an offline mobile app.

Result

Zudget is live at zudget.zwinglio.com. All seven planned phases are done (accounts, transactions, summary, budgets, goals, credit cards), plus recurring entries, invoice import, the notification inbox, reports and a set of financial calculators.