The envelope budgeting method genuinely works — you assign every rupee to a category before you spend it, and you stop wondering where your money went. But every app that does this well also stores your entire financial history on someone else's server. The market leader charges $109/year for the privilege.
I kept coming back to the same question: why does a tool that exists to help me save money need to see my data at all?
So I built one that doesn't. It's called Allot. Your financial data never leaves your browser.
Here's how it works, why I made the architectural decisions I did, and what I learned.
The core constraint: no server, ever
The entire product is built around one rule: financial data stays in the browser. No exceptions.
This sounds simple, but it shapes every technical decision:
- Storage → IndexedDB (not localStorage, not a database, not an API)
- Offline → Service Worker (cache-first, works with no internet)
- Auth → there is no auth. There is no account. Open the URL and start budgeting.
- Sync → end-to-end encrypted before any byte leaves the device
When Mint shut down in January 2024, millions of users lost access to years of financial history. Not because the data was stolen — because the company stopped running the server. If your data lives in a third party's database, you're renting access to it, even if you paid a one-time price.
With IndexedDB, the worst case is that you clear your browser. That's a problem I can mitigate with backups. The server shutting down is a problem I can't.
The tech stack: deliberately boring
-
Vanilla JavaScript. No React, no Vue, no Svelte. The entire app is four JS files:
app.js,db.js,import-parsers.js, andimport-wizard.js. A custombuild.jsminifies them viajavascript-obfuscatorand stitches everything together. -
IndexedDB via a thin wrapper class (
AllotDB). Seven stores: accounts, categories, category groups, budget months, transactions, settings, recurring templates. - Cloudflare Pages for hosting. No server costs, global CDN, preview deployments on the staging branch.
- Cloudflare Workers for the two lightweight server-side pieces: license validation (proxy to Gumroad API, keeps the secret off the client) and optional encrypted sync (stores only ciphertext — more on this below).
No framework means no dependency churn, no breaking upgrades, no supply-chain attack surface.
The interesting problems
1. Importing bank statements from every bank in the world
The trade-off of not connecting directly to your bank is that you have to import statements manually. Most banks let you export a CSV, OFX, or MT940 file. The problem is that no two banks format their CSV the same way.
Chase puts the date in column 1. HDFC puts it in column 3. DBS sends a .xls file that's actually a binary BIFF8 format in disguise. CommBank uses DD/MM/YYYY. Some US banks use MM/DD/YYYY. Some use 2-digit years.
I built a multi-step import wizard that:
- Auto-detects the file format (CSV, OFX, MT940, QIF, CAMT.053, BIFF8 XLS)
- For CSV: shows a column mapping UI — you drag Date, Amount, Payee to the right columns
- Parses dates using a disambiguation matrix (if day > 12, it's unambiguously DD/MM — if both are ≤ 12, fall back to the user's configured format preference)
- Runs duplicate detection against existing transactions before import
- Auto-categorises by payee using keyword rules you can customise
The hardest part was the BIFF8 parser. DBS Singapore sends a file with a .xls extension that is a genuine Excel 97-2003 binary file. I had to write a byte-level parser from scratch — the spec is publicly available but dense. Lesson learned: always read the actual file before writing any parser. I rewrote this six times because I was writing from assumptions, not from looking at real bytes.
2. Logging a transaction in 3 seconds via bank SMS
Most banks in India (and increasingly elsewhere) send an SMS when money leaves your account:
"INR 450.00 debited from A/C XX1234 on 30-Jun-26. Info: SWIGGY. Avbl Bal: INR 12,450."
The Web Share Target API lets a PWA register as a share destination. On Android, you can open the bank SMS, tap Share, choose Allot, and the transaction modal opens with the amount and payee already parsed from the message text.
The parsing is a regex that handles the most common Indian bank SMS formats: HDFC, ICICI, SBI, Axis, Kotak. It's not perfect — banks change their formats — but it handles ~90% of messages correctly. The user reviews and taps Save. Three seconds versus thirty.
No other envelope budgeting app on the market has this.
3. Encrypted cloud sync with no user accounts
The most common objection to a browser-only app: "What if I want to use it on two devices?"
I wanted sync without accounts. No email, no password, no "sign in with Google". Here's the model I landed on:
- On the first device, generate a sync code —
ALLOT-XXXX-XXXX-XXXX-XXXX— from 8 bytes ofcrypto.getRandomValues(). That's 64 bits of entropy. - Derive an AES-GCM-256 key from the sync code using PBKDF2 (600,000 SHA-256 iterations, deterministic salt derived from the code itself).
- Derive a KV slot key from
SHA-256(code + 'allot-sync-v1')— a 256-bit hash used to identify the storage slot on the server. - Encrypt the entire budget database on the client, then send the ciphertext to a Cloudflare Worker.
async function deriveSyncKeys(syncCode) {
const raw = normaliseSyncCode(syncCode);
const saltBuf = await crypto.subtle.digest('SHA-256', enc.encode(raw + 'allot-sync-salt-v1'));
const salt = new Uint8Array(saltBuf).slice(0, 16);
const km = await crypto.subtle.importKey('raw', enc.encode(raw), 'PBKDF2', false, ['deriveKey']);
const key = await crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations: 600_000, hash: 'SHA-256' },
km,
{ name: 'AES-GCM', length: 256 },
true, ['encrypt', 'decrypt']
);
const hashBuf = await crypto.subtle.digest('SHA-256', enc.encode(raw + 'allot-sync-v1'));
const keyHash = bytesToHex(new Uint8Array(hashBuf));
return { key, keyHash };
}
The Worker only ever stores ciphertext. The KV key is a SHA-256 hash — unguessable without knowing the sync code. If someone breached Cloudflare's KV storage, they'd get encrypted blobs with no way to decrypt them.
To add a second device: copy the sync code across (share it as text or scan it yourself). The second device derives the same key from the same code and can decrypt whatever the first device uploaded.
There's no account recovery. If you lose the sync code, the cloud slot becomes inaccessible — by design. Your local data is fine; you just can't sync it. The app also keeps 10 server-side versions of your data (30-day retention) so you can roll back if something gets corrupted.
4. Encrypted file exports
Manual backups needed the same treatment as sync. A plaintext JSON backup of your entire budget — stored in Google Drive or iCloud — is one breach away from exposing every transaction you've ever made.
Export produces a .allot file:
{
"version": 3,
"encrypted": true,
"salt": "a3f8...",
"iv": "b2c1...",
"ciphertext": "e4d7..."
}
AES-GCM-256 with a random salt and IV, key derived via PBKDF2 (600k iterations) from a user-chosen password. Import detects the format automatically — old .json backups still work without a password prompt.
One detail worth noting: sync credentials (syncCode, syncKeyJwk, syncKeyHash) are stripped from every export. A backup file should never grant access to someone's cloud sync slot.
What I learned
Vanilla JS at scale is fine, actually. The app is ~5,000 lines of app logic. Readable, debuggable, no abstraction layers to dig through. The main cost is verbose DOM manipulation — you write document.getElementById('txAmount').value a lot. Worth it.
IndexedDB is good but the API is terrible. Here's what a simple read looks like without any abstraction:
// Reading all transactions — raw IDB
const request = indexedDB.open('AllotDB', 3);
request.onsuccess = (event) => {
const db = event.target.result;
const tx = db.transaction(['transactions'], 'readonly');
const store = tx.objectStore('transactions');
const req = store.getAll();
req.onsuccess = () => {
const rows = req.result;
// finally have data here
};
req.onerror = () => console.error(req.error);
};
request.onerror = () => console.error(request.error);
Callback inside callback, two separate error paths, manual transaction management. Now multiply that across every read and write in the app.
The fix is a five-method Promise wrapper — no library needed:
async _getAll(storeName) {
const db = await this.init();
return new Promise((resolve, reject) => {
const tx = db.transaction([storeName], 'readonly');
const req = tx.objectStore(storeName).getAll();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async _put(storeName, record) {
const db = await this.init();
return new Promise((resolve, reject) => {
const tx = db.transaction([storeName], 'readwrite');
tx.objectStore(storeName).put(record);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
_get, _delete, and _getAllByIndex follow the same pattern. That's the whole wrapper. Every public method in the database class becomes a one-liner:
async getTransactions() { return this._getAll('transactions'); }
async saveTransaction(tx) { return this._put('transactions', tx); }
async deleteTransaction(id) { return this._delete('transactions', id); }
One gotcha worth knowing: IDB transactions auto-commit the moment there are no pending requests on them. If you await anything between opening a transaction and using it — a fetch, a Promise.all, anything — the transaction closes underneath you and your next operation throws. The wrapper avoids this by opening a fresh transaction per operation. For bulk writes (like a full database restore) you do want a single transaction — Allot's importAll() clears every store and writes thousands of records inside one transaction, which makes the whole restore atomic.
Don't reach for a library — the wrapper is 50 lines and you own it completely.
Service Workers are unforgiving about caching. I shipped stale JS to users three times because I forgot to bump the cache version. My build script now extracts the version string from sw.js and stamps it into index.html as a meta tag — so a mismatch is visible in DevTools.
PBKDF2 iteration count matters. I initially used 100,000 iterations for the sync key derivation. OWASP currently recommends 600,000 for PBKDF2-HMAC-SHA256. The sync code has 64 bits of entropy, so brute force is computationally infeasible regardless — but consistency is its own security property. I brought everything to 600k.
"No account" is a product decision, not a technical limitation. Every time I hit a design problem (sync, backup, recovery), I was tempted to add an email-based account system. Each time I resisted, I found a better answer. The sync code model is more private and simpler to implement than an identity server.
The business model
Allot costs $49 once. No subscription. All future updates included.
The premium tier unlocks: unlimited accounts, transfers between accounts, encrypted file backup, auto-backup to a local folder, cloud sync, and spending reports. The free tier has 1 account and all core budgeting features — enough to evaluate whether the method works for you.
License keys are purchased via Gumroad and validated on first use via a Cloudflare Worker (so the Gumroad API secret stays off the client). After validation, the result is cached locally so the app works offline indefinitely.
Try it
The app is at allot.aditco.in. No sign-up. Open it and start.
If you want to poke around the architecture without commitment, open DevTools → Network tab during normal use. You'll see exactly one outbound request on a fresh session: the license validation call. Everything else — budgeting, transactions, reports, import — runs locally with zero network activity.
I also publish a single-file offline build (allot-offline.html) — all CSS and JS inlined, all premium features unlocked, no internet required, no service worker. Download it and run it from your desktop. Useful if you want to audit what the app actually does before trusting it with real data.
Happy to answer questions about any of the above.
Top comments (0)