Skip to content

Repository files navigation

Obol

A desktop application for tracking subscriptions and recurring expenses. It answers one question quickly: what am I paying for, what does it cost, and what comes out next.

Local-first. No account, no server, no network calls. Everything lives in a SQLite file in your own user folder.

What follows is for developers. If you just want to use Obol, the site and the guides above are the place to start.


Running it

npm install         # also fetches the Electron binary and prepares native deps
npm run dev         # development, with hot reload in the renderer
npm run dist        # builds release/1.2.1/Obol-Setup-1.2.1.exe

The installer is a standard NSIS package: per-user by default, choosable install directory, Start Menu and desktop shortcuts, and a proper uninstaller. Nothing else has to be installed on the target machine — Node, SQLite and the native modules all ship inside it.

Other useful scripts:

Script What it does
npm run typecheck Three TypeScript projects: main/preload, renderer, tests
npm run lint ESLint, zero warnings allowed
npm run test Vitest — recurrence, money, cryptography, database/service and sync integration
npm run icons Regenerates build/icon.ico and the tray icon from code
npm run pack:dir Unpacked build in release/, for testing without installing
npm run seed:dev Loads the sample data straight into the installed app's database

Architecture

src/
  shared/       Domain types, recurrence maths, money, validation, i18n engine
  main/         Electron main process
    db/           Drizzle schema + forward-only migrations
    services/     All business logic; the only code that touches the database
      sync/         The local-network listener the phone companion talks to
    ipc/          One validated handler per API method
    platform/     OS integration: reminders, login item, theme
  preload/      The context bridge — the entire renderer-facing surface
  renderer/     React application
    components/ui/    Design-system primitives
    components/app/   Application-specific composites
    pages/            One file per screen
    store/            Zustand: settings, data cache, transient UI
mobile/         Android companion (Expo); shares src/shared with the desktop

Process split. The renderer has no Node access at all. Context isolation is on, node integration is off, and the preload exposes a fixed set of typed functions — there is no generic "run this query" channel to reach for. Every handler in the main process validates its input with Zod before a service sees it, and rejects calls from any frame the app did not load itself. A Content-Security-Policy is applied at the session level, and all permission requests are denied by default.

Dates. Renewal dates are civil dates, not instants: "the 31st of every month" means the same thing in Athens and Vancouver. All arithmetic runs on YYYY-MM-DD strings through an integer day count, so no timezone or DST can shift a billing date. Recurrence advances from a stable anchor rather than one period at a time, which is what keeps 31 Jan → 28 Feb → 31 Mar instead of degrading to the 28th forever.

Dates in the interface. A native <input type="date"> renders in Chromium's own locale and offers no way to change the field order, so it can never honour an app-level date-format setting. Dates are therefore edited in a text field that follows the chosen order, with a calendar popover for picking. Display goes through the same pattern rather than Intl.DateTimeFormat, which ignores the order its options are listed in and returns the locale's convention either way - the reason DD/MM and MM/DD used to look identical in lists.

Money. Amounts are major units rounded to the currency's minor unit on every write. Normalised cost is derived from an annual base so the cycles people actually use divide exactly: €30 every 3 months is €120 a year is €10 a month, with no floating-point residue. Exchange rates are user-maintained; a figure that could not be converted is never silently passed off as converted.

Billing reconciliation. An active subscription whose renewal date has passed really was charged — the card does not wait for the app to be opened. On launch, missed occurrences are written as payments and the next date is moved forward. Everything recorded that way is editable. Trials are not charged while running, and convert to active when they lapse.

Reminders are rows in the database, not timers. One row per (subscription, kind, date, lead time), with a unique index, so a sweep is safely repeatable and a reminder missed while the machine was asleep is still delivered on the next launch.


Where your data lives

%APPDATA%\Obol\
  obol.db          your subscriptions, payments, settings
  logs\obol.log    diagnostics — operation names and error codes, not values
  safety-backups\      automatic copy taken before any restore

Uninstalling leaves this folder in place by default and asks whether to remove it, so a reinstall finds everything intact.

The app was called SubTrack until version 1.2.0. Electron derives this folder from the product name, so the rename moved it — on first launch the new build copies the database and theme backgrounds across from %APPDATA%\SubTrack, leaving the original untouched. It copies rather than moves so a failure halfway, or a decision to go back, still has the data where it was. tests/adopt.test.ts covers it, including data still sitting in the write-ahead log, which a plain three-file copy loses.

Backups are a single .zip containing a consistent SQLite snapshot (taken through SQLite's own backup API, not a file copy) plus a manifest. Restoring validates the archive — the manifest, the schema version, an integrity check and the presence of every required table — before anything on disk is touched, and writes a safety copy of the current database first. CSV export is there so you can leave with your data at any time.

Privacy. Payment methods hold a label and at most the last four digits; the schema has no column that could store a full card number. Usage check-ins are answers you type — nothing is measured, observed or inferred. The app works with no network connection; the one thing that touches a network at all is the phone listener below, which is off unless you switch it on and never leaves your own Wi-Fi.


The phone companion

mobile/ is an Android companion built with Expo. It shows what is due and lets you record a payment away from the computer; the desktop stays the record of everything. Its own README covers building it.

Pairing. With Settings → Phone switched on, the desktop answers three paths on the local network. The pairing code is a bootstrap rather than a password: it is stretched with PBKDF2 once, used to seal a single handshake, and thrown away, and the desktop hands back a random 32-byte key the phone stores and uses from then on. One code pairs one device, it expires after five minutes, and eight wrong guesses close the window — a nine-character code is protected by how few attempts it gets.

Direction of authority. The desktop is the source of truth. The phone may add a subscription, mark one paid and answer a check-in, and nothing else. That asymmetry turns what would be a two-way merge with conflicts into an append-only outbox that replays here — something that can actually be reasoned about and tested. Every operation carries an id and the desktop records which it has applied, so a sync interrupted by a dropped connection is retried without recording the same payment twice.

What travels. A JSON snapshot of what the six companion screens read, not a copy of the database. Price history, exchange rates, themes and older payments stay here. Payment methods contribute a label and nothing else. Both directions are sealed with AES-GCM, so a shared office or café network cannot read someone's finances just because both devices are on it.

Why plain HTTP under the encryption. Two devices on a home network have no certificate authority between them, so TLS would mean a self-signed certificate the phone has to be told to trust — a worse security story than encrypting the payload under a key only the two paired devices hold.

Subscriptions carry a sync_ref for this: row ids are local to each database, and an id meaning Netflix here and Spotify there is exactly how a "mark as paid" lands on the wrong subscription.


Languages

English and Greek, switchable at runtime with no restart. Both catalogues live in src/renderer/src/locales/. The translator is shared between the main and renderer processes so tray menus, native reminders and the interface never disagree, and no string is translated twice.

Built-in categories store a stable key rather than a display name, so they translate with the interface — until you rename one, at which point your label wins in every language.


Design notes

The visual system is defined once in src/renderer/src/styles/tokens.css: spacing, radii, type scale and two hand-built palettes. Tailwind consumes those tokens through @theme inline, so there is a single place to change how the product looks.

Themes. Eight built-in themes, plus any number of the user's own. A theme is five colours - accent, background, panels, text, borders - and everything else in the palette is derived from them by mixing towards the text colour, which is a rule that produces a coherent result on a light or a dark base without knowing which it is on. Two values are chosen for legibility rather than appearance: the accent as a label is lifted until it clears WCAG AA against the panel colour, and button labels flip between near-white and near-black. The test suite asserts those thresholds for every built-in theme, so a palette that cannot be read is a failing build rather than a support ticket.

A theme may also carry a background image. It is copied into the app's own folder, downscaled and re-encoded on the way in, and served over a dedicated scheme that refuses any path outside that folder - so a theme cannot become a way to read arbitrary files, and the CSP stays narrow. Chrome steps aside for the image; panels and rows stay opaque so text is never set on top of it.

Three rules the interface sticks to:

  • Structure comes from borders, alignment and spacing, not from stacked cards. Elevation is reserved for things that genuinely float: menus, dialogs, the command palette.
  • Colour is scarce. Neutrals carry the interface; the accent marks what is active; the semantic colours appear only where money is at stake. Category colours are 3px markers, never fills.
  • Status is shown only when it is not the default. "Active" subscriptions and "Paid" payments render no badge — badging the ordinary case hides the handful worth noticing.

Everything custom lives inside a cascade layer. Unlayered CSS outranks every layered rule, so a plain button { background: none } would quietly beat a utility class on the same element with no way to win it back.


Development tooling

scripts/capture.mjs renders every screen to shots/ for design review, driving the app through webContents.capturePage() so only the application's own pixels are ever written to a file. It runs through a hook in the main process that is guarded by !app.isPackaged and an environment variable, and is unreachable in an installed build.

npm run seed:dev fills the database with sample subscriptions spanning three years, so the screens that only get interesting with history — analytics, price history, subscription creep — have something real to draw. The same data is available to users from Settings → Data & backup, and can be removed again from there.

About

Local-first subscription and recurring-expense tracker for Windows, with an Android companion that syncs over your own network. Electron + React + TypeScript + SQLite. English and Greek.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages