DEV Community

Cover image for Budgeting apps kept asking for a subscription, so I built a free one
Neilasm
Neilasm

Posted on AI-assisted

Budgeting apps kept asking for a subscription, so I built a free one

Most budgeting apps want a subscription. YNAB is around $100 a year, and the free ones often keep the useful parts, like importing your bank statement, behind a paywall. Paying every month for an app whose whole job is helping me spend less never sat right with me.

So I built my own. Budget Ctrl is a desktop budgeting app for Windows and Linux. It's free and MIT licensed: no subscription, no premium tier, no ads. Your budget never leaves your computer.

It also follows payday rather than the calendar month. You tell it when you're paid, and each budget period runs from one payday to the next.

This post is about the parts that turned out harder than they looked.

No account, no server, no bank login
The whole budget is one JSON file in your app data folder. The only thing the app connects to is GitHub, to check for updates, and you can turn that off.

Part of that is privacy. Plaid, which links bank accounts to a lot of budgeting apps, agreed to pay $58 million in 2021 to settle a class action over how it collected people's bank data (without admitting wrongdoing). The other part is that apps disappear. Mint shut down in 2024, and when Level Money closed it deleted the transaction history of every account people had linked from other banks. A file on your own disk can't be switched off by someone else.

The trade-off is that you import bank statements instead of syncing. Bank sync is the thing people complain about most in other budgeting apps (connections that need logging into again every week or two, transactions missing or doubled after a reconnect), so I'm happy with that trade.

The stack:

Tauri v2 for the desktop shell, React 19 and Vite for the UI
Tauri's built-in tray icon, plus plugins for notifications, start-at-login, single instance and updates
A Windows installer of about 3.7 MB
377 Vitest tests. The logic lives in plain functions under src/lib, away from React, which makes it easy to test

Payday periods

A budget period is a month shifted by a cutoff day. Paid on the 10th, the "September" period runs from 10 September to 9 October. Most things fall out of that easily. Recurring bills don't.

A bill due on the 5th belongs to the period it actually lands in, which here is 5 October. A bill on the 31st has to land on the 30th in a 30-day month and on the 28th or 29th in February. This is the function that decides:

// Resolves which actual date inside the period a given dayOfMonth lands on.
// With cutoff 10 and period 2026-09: day 15 -> 15 Sep; day 5 -> 5 Oct.
export function recurringDateInPeriod(dayOfMonth, periodKey, cutoffDay) {
  const { start, end } = periodRange(periodKey, cutoffDay);
  const day = Number(dayOfMonth || 1);
  const tryIn = (y, m1) => new Date(y, m1 - 1, Math.min(day, daysInCalMonth(y, m1)));
  let c = tryIn(start.getFullYear(), start.getMonth() + 1);
  if (c >= start && c <= end) return c;
  c = tryIn(end.getFullYear(), end.getMonth() + 1);
  if (c >= start && c <= end) return c;
  return start;
}
Enter fullscreen mode Exit fullscreen mode

A bill only counts against what you have left once its day has passed. So the "Remaining" figure is money you still have, not money you'll have spent by the end of the period.

Bank statements

Bank CSV exports have no common format, and Polish banks have their own habits. Many write Windows-1250 instead of UTF-8, separate columns with semicolons, use decimal commas, and put a block of account details above the actual table. So the importer assumes nothing. It works out the encoding, separator, header row, date order and what each column means, and lets you correct its guess.

Parsing turned out to be the easy part. The hard part is counting things twice. A statement contains the rent standing order (already a recurring bill in the budget), the salary (already the period's income) and the shop you typed in by hand on Tuesday. Imported blindly, all of those count twice. So the importer matches every row against what the budget already holds and leaves anything it thinks is already counted unticked. Import rules handle the rest: tell it once that BIEDRONKA means Food, and it remembers.

Amounts bit me too, in a place I didn't expect. The importer had a proper amount parser, but two forms in the app still used parseFloat(value.replace(",", ".")). Typing "1 234,56" saved 1, and "1.234,56" saved 1.234. That's not great in an app with a Polish translation. Both use the same parser now.

Not losing anyone's data

I spent more time here than anywhere else. A budgeting app that loses a year of entries is worse than no app at all.

The desktop app keeps the budget in a real file rather than in the webview's localStorage. Webview storage can be cleared along with browser data, while a file is something you can copy and back up yourself. The file is still mirrored to localStorage as a fallback. There's a backup each day, taken before that day's first change, plus one before anything risky: imports, restores, starting over, installing an update. The last 14 are kept.

A code review before the last release found two problems that could still lose data.

A damaged file meant an empty budget. If the file was cut short, by a crash in the middle of a save for example, the app couldn't read it and quietly started an empty budget. Your next edit would have saved over the real one. Now the damaged file is set aside, the last good copy is used if there is one, and otherwise a recovery screen lists your backups. Nothing is saved until you pick one.

Saves could finish out of order. Some inputs save on every keystroke. Two writes of the whole file in flight at once can finish in either order, and if the older one finishes last, it wins. The fix is small: queue the writes.

The queue has a test that starts a slow write and two quick ones, and it fails if you take the queue out.

Updates without a server

Pushing a version tag starts a GitHub Actions workflow. It runs the tests, builds the Windows and Linux installers, signs them with the project's update key, and puts them in a draft release together with a latest.json manifest. The app checks that manifest once a day, shows a banner when there's something new, and installs it when you click. Tauri's updater refuses anything not signed with the project's key. Because the release starts as a draft, nothing reaches anyone until I've checked the signatures and downloaded the files myself.

One small gotcha: 0.5.1 went out with empty release notes in the app's "What's new". The job that writes latest.json hadn't been given the notes. Now the job that opens the draft passes them along.

One thing isn't solved yet. The Windows installer isn't code-signed, so SmartScreen warns the first time you install it ("More info", then "Run anyway"). Updates from inside the app don't trigger it. Certificates cost money every year, so I plan to apply to SignPath Foundation, which signs open-source projects for free, once the project has more users behind it.

Five Languages

The app speaks English, Polish, German, Spanish and Chinese. Two things caught me out.

Plurals. English has two forms. Polish has three: 1 rachunek, 2 rachunki, 5 rachunków, and 22 takes the same form as 2 while 12 takes the same form as 5. Intl.PluralRules knows the rules, so each translation stores its forms in the order its language needs:

export const PLURAL_FORMS = { de: ["one", "other"], es: ["one", "other"], pl: ["one", "few", "many"], zh: ["other"] };

const i = PLURAL_FORMS[lang].indexOf(new Intl.PluralRules(lang).select(n));
Enter fullscreen mode Exit fullscreen mode

Category names. The built-in categories used to be saved in whatever language you set up in, so switching from Polish back to English left "Jedzenie" everywhere. Now they're stored under English names and translated when shown.

What's next

Weekly and fortnightly pay periods, for everyone who isn't paid monthly
MT940 and OFX statement import, alongside CSV
Budgets for households: who paid and who owes whom first, then one budget shared between two computers through a folder you already sync
For 2.0, a phone app, still with no account and no server

Try it

The Windows installer, an AppImage and a .deb are on the releases page: https://github.com/CptMeowgi/budgetctrl/releases

If you try it, tell me what breaks or what's missing. Issues are open. And if you've built sync between a desktop app and a phone app without a server in the middle, I'd like to hear how you did it.
_
This post was written with help from an AI assistant. I checked the details myself._

Top comments (0)