I'm a software engineer, and I built a budgeting app because I was tired of guessing where my money went. One piece of it has its own free page: a Subscription Finder where you drop in a CSV from your bank and it lists every recurring charge. The rule that shaped the whole thing was simple. The file must never leave the browser. This post is about how that works and the parts that turned out harder than I expected.
No upload endpoint, so nothing to leak
The page is a client component. The file is read with file.text() and parsed in the browser. There is no API route that accepts it, so there is nothing on my server that could store or leak a statement. That is a stronger promise than a privacy policy, because it holds even if I make a mistake later.
One honest detail: when the tool runs it logs a single analytics event with counts only, how many recurring charges it found, how many possible ones, and how many months the statement covers. No file, no merchant names, no amounts.
Reuse the real detector, not a toy one
The free tool calls the same recurring detector the app uses, so the two never disagree:
const charges = txns.filter((t) => !NOT_A_CHARGE.test(t.name));
const recurring = extractSubscriptions(detectRecurring(charges, null));
Here is what detectRecurring does. It groups charges by normalized merchant name, direction, and account. A group needs at least three charges spread over at least two weeks. It takes the median gap between the dates and snaps it to a frequency such as weekly, biweekly, or monthly. Then it averages the amount after dropping anything more than 20 percent from the median, so a late fee on a phone bill does not drag the number around.
The null argument matters. The app normally looks at a rolling window of the last few months, but an uploaded statement can end months ago, so the finder asks it to use every transaction.
One statement has no other accounts
Inside the app, transfers between your own accounts are removed by matching them across accounts. A single CSV has nothing to match against, so the finder filters by name instead:
const NOT_A_CHARGE =
/TRANSFER|\bXFER\b|PAYMENT THANK YOU|AUTOPAY PAYMENT|ONLINE PAYMENT|\bZELLE\b|\bATM\b|DEPOSIT|INTEREST (CHARGE|PAID)/i;
It is a heuristic and it will sometimes be wrong. I would rather show a slightly noisy list than hide real subscriptions.
Short statements need honest handling
Three charges are the minimum, so a one month statement cannot show recurrence at all. I handle that two ways. A list of well known subscription merchants flags a charge seen only once as a possible subscription. And if the tool finds nothing and the statement covers less than three months, it says that is probably why, instead of implying you have no subscriptions.
Real bank files are messy too. Some banks put a few account summary lines above the header, so the parser looks through the first 15 rows for the real header with date, description, and amount columns. Billing phone numbers get stripped from merchant names, so "HULU 877-8244858" and "HULU" are not listed twice.
What I would tell another developer
- If a privacy promise can be structural, make it structural. No upload endpoint beats a paragraph in a policy.
- Reuse the product's real logic for a free tool, so the free version and the product agree.
- When the input is too small to answer the question, say so. Tell the person what you cannot see.
If you want to try it, it is free and needs no account: https://finsight.it.com/free-subscription-finder. It is part of FinSight, a budgeting app with an AI assistant that explains your money.
Top comments (0)