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.
- Download — Obol on the web · Windows installer and Android APK
- Using it — Guide · Οδηγός στα ελληνικά
- On your phone — Guide · Οδηγός στα ελληνικά
What follows is for developers. If you just want to use Obol, the site and the guides above are the place to start.
npm install # also fetches the Electron binary and prepares native deps
npm run dev # development, with hot reload in the renderernpm run dist # builds release/1.2.1/Obol-Setup-1.2.1.exeThe 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 |
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.
%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.
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.
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.
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.
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.