Skip to content
All Projects

PocketFlow

Personal Finance SaaS

React 19Node.jsMongoDBTypeScriptFirebaseViteTailwind CSSPlaywright (E2E testing)Vitest

Overview

PocketFlow is a personal finance tracker — track income and expenses, set monthly budgets and savings goals, import transaction history in bulk, and see exactly where your money goes, without handing a third party read access to your bank account. It started as a two-week project over a holiday break and grew into a production SaaS used by real people tracking real money, which meant the usual gap between "it works on my machine" and "it works in production" had to close fast.

The stack is intentionally plain: React 19 and Vite on the frontend, Node.js and MongoDB on the backend, Firebase for authentication. No Next.js, no state management library beyond what React already provides. Most of the engineering decisions worth talking about live in the data model, not the framework choices. Budget progress, account balances, and goal tracking are never stored as standalone values that have to be kept in sync with the transactions that produced them. They're derived live, every time they're requested. If a transaction write succeeds and a budget update doesn't, there's nothing left out of sync to debug, because there was never a second value to begin with.

That one decision — derive, don't duplicate — runs through most of what follows: how CSV imports get normalized, how budget alerts avoid firing on every transaction instead of once, and how data exports stay off the request path entirely.

The Problem

The honest version: there wasn't a customer-discovery process behind PocketFlow. It started as a way to stay productive over a holiday break, with Clerk handling auth because Clerk gets you to a working login screen in an afternoon. That was the right call for a holiday project. It stopped being the right call the moment the project needed to survive contact with a production deployment.

The first real problem showed up immediately after the first deploy. The backend on Render was talking to MongoDB Atlas without issue. The frontend on Vercel hit a wall — every sign-up and sign-in attempt came back with a 404, even though everything worked perfectly on localhost. Hours went into Clerk's dashboard, chasing domain configuration and environment variables, and the deeper the troubleshooting went, the more the "managed" nature of the service started to feel like a locked one. A pre-built auth provider is supposed to remove a category of problems, not introduce a new one that only appears in production.

That forced a decision that had nothing to do with the 404 directly: stay on a managed auth provider and keep fighting its configuration surface, or own the auth layer outright and accept the extra integration work that comes with it. Firebase won, mainly for three reasons — a deployment story that didn't involve opaque domain handshakes, full UI control instead of pre-built components that were already starting to clash with the design system, and direct server-side token verification through the Admin SDK instead of trusting client-side state.

The deeper problem only became visible after the migration was already underway: PocketFlow had been built with the assumption that "it works" was the finish line. Localhost has zero latency, a single user, and data that's always in the shape you expect. Production has none of those guarantees. Real users click submit twice because the UI didn't visibly respond the first time. Real bank exports are not clean CSVs. Real concurrent requests race each other. Fixing the auth migration surfaced how much of the rest of the app had been built on the same optimistic assumption, and that became the actual scope of the next several weeks of work — not "add features," but "stop assuming the happy path is the only path."

Technical Approach

The frontend is a React 19 SPA built with Vite rather than Next.js, which meant solving for SEO and performance without a meta-framework's built-in tooling. React 19 natively supports rendering <title> and <meta> tags from anywhere in the component tree, which is what replaced react-helmet and the hydration mismatches that library tends to introduce. A small Node script runs before every Vite build to regenerate sitemap.xml with the current date, and SoftwareApplication JSON-LD structured data gets injected through a plain React component, the same way the meta tags do. Going without Next.js just meant reading the web platform's own capabilities carefully and building the small remaining gap by hand.

Performance work followed the same logic. Cumulative Layout Shift was the main offender — feature screenshots had no reserved space until the browser finished downloading them, so the page kept jumping as images loaded in. The fix was an explicit aspect-ratio on every image container, paired with a <picture> element serving WebP with a JPG fallback for older browsers. Reserve the space before the image exists, and there's nothing left to shift once it does.

On the backend, the architecture is a standard Express and MongoDB REST API, with one rule shaping most of the design decisions inside it: nothing that touches real user data gets to fail silently or block other users. Heavy operations — CSV exports, specifically — never run inside the request-response cycle. A request to export data returns an immediate 202 Accepted, and the actual work happens in a background job processed through a MongoDB-backed queue. Blocking Node's single-threaded event loop to generate one user's CSV while everyone else waits is the kind of mistake that's invisible with five users and very visible with five hundred.

The same rule shows up in how feedback reaches the user. Early versions of the app handled errors by doing nothing visible — a request would fail and the page would just sit there, giving no indication anything had gone wrong. A global ToastProvider context, paired with a useToast hook, made every success and failure state visible to the user without coupling that feedback logic to whatever component happened to trigger it.

Testing and deployment got the same level of seriousness. Vitest covers business logic — budget calculations, validation, normalization helpers — and Playwright runs full end-to-end flows: sign up, add a transaction, delete it, confirm the UI reflects the change. GitHub Actions runs both suites on every push and blocks the deploy to Render if either one fails, which means every change that reaches production has already passed the equivalent of a manual regression check, without anyone having to run one by hand.

Key Decisions

Technical Approach covered the architecture in broad strokes. This section is about specific forks in the road — places where the obvious choice would have been easier to ship and wrong to live with.

Normalize the input, don't just validate it

The CSV importer is the cleanest example of a decision that didn't start as a decision — it started as two days of fighting a parsing library that kept insisting valid data was malformed.

The first version validated CSV rows as they came in: check the date format, check the amount is a number, reject anything that didn't match. It failed constantly, with an error that made no sense — "expected 1 field but parsed 6" — even though the input genuinely was a CSV file. The actual bug, once found, had nothing to do with validation. The test file was an Excel export that had been "Saved As" .csv without actually being converted — it still carried binary Excel headers under a .csv extension. PapaParse was choking on binary data it had every right to choke on.

That bug forced the real decision: stop treating malformed-looking input as an immediate failure, and normalize first. Real bank exports use inconsistent header casing, currency symbols mixed into amount fields, and date formats that are genuinely ambiguous — is 02/01/2024 February 1st or January 2nd? Treating any of that as a validation failure means rejecting data that's perfectly usable once it's been cleaned up.

The importer now retries with an explicit comma delimiter if the first parse attempt looks suspicious — a single field where there should be several, or a TooManyFields error — before giving up:

const isSuspicious =
  (results.meta.fields && results.meta.fields.length <= 1) ||
  results.errors.some((e) => e.code === 'TooManyFields');
 
if (!retryWithComma && isSuspicious) {
  parseFile(file, true); // retry with delimiter: ','
  return;
}

Past that point, every row gets its keys normalized — lowercased, trimmed, spaces collapsed to underscores — before any validation runs, so a column header of " Transaction Date " and transaction_date are treated identically:

const normalizeRow = (row: Record<string, any>) => {
  const normalized: Record<string, any> = {};
  Object.entries(row).forEach(([key, value]) => {
    const cleanKey = key.toLowerCase().trim().replace(/\s+/g, '_');
    normalized[cleanKey] = value;
  });
  return normalized;
};

Amount parsing strips currency symbols and thousands separators before attempting to convert the value, and deliberately returns NaN on failure rather than defaulting to 0:

const parseAmount = (raw?: string) => {
  if (!raw) return NaN;
  const cleaned = raw.toString().replace(/[₦,$]/g, '').replace(/,/g, '').trim();
  return Number(cleaned);
};

That NaN matters more than it looks. A 0 would silently become a valid, importable transaction of zero naira — wrong, but not loud about it. NaN fails the validation step that runs immediately after, and the row gets flagged and shown to the user instead of quietly entering their financial records as nonsense.

Payment method gets the same alias treatment — a CSV might label that column payment_method, method, source, or half a dozen other things depending on which bank exported it — resolved down to one normalized value before storage. And every row, valid or not, shows up in a preview table before anything gets imported, with the specific failure reason visible on hover for anything that didn't pass. The user decides whether to import 47 valid rows and ignore 3 broken ones, instead of the importer deciding for them.

Derive everything, store nothing

Key Decision

The decision with the widest blast radius in this codebase: budget progress, account balances, and goal completion are never written to the database as standalone fields. A budget document, to highlight, stores a category, a limit, and a period — nothing else. How much has actually been spent against it is computed from the transaction records every time it's requested.

The alternative — storing a spent value on each budget and incrementing it whenever a matching transaction happens — is the obvious approach, and it's also the one that quietly corrupts data the first time a transaction write succeeds while the budget update doesn't. Now there are two values that are supposed to agree and don't, and nothing in the system knows which one is correct.

Deriving the value instead of storing it removes that failure mode by removing the second value entirely. A single MongoDB aggregation computes total spend per category for the period in one query, and the result gets merged into the budget list with a plain Map lookup:

const expenseAggregation = await FinancialRecordModel.aggregate([
  {
    $match: {
      userId,
      date: { $gte: startDate, $lte: endDate },
      type: 'expense',
    },
  },
  { $group: { _id: '$category', totalSpent: { $sum: '$amount' } } },
]);
 
const expenseMap = new Map<string, number>();
expenseAggregation.forEach((item) => {
  if (item._id) expenseMap.set(item._id, item.totalSpent);
});
 
const results = budgets.map((budget) => {
  const spent = expenseMap.get(budget.category) || 0;
  return { ...budget.toObject(), spent, remaining: budget.amount - spent };
});

One query for every budget in the period, not one query per budget — a small detail, but it's the difference between a dashboard load that scales with the number of budget categories and one that doesn't. The cost of deriving instead of storing is recomputing this on every read instead of reading a cached field. That's a real cost, and one that's solvable later with better indexing if it ever becomes a bottleneck. A correctness bug in someone's actual financial data is not something that gets to wait.

Alert once, and only when it matters

A budget alert firing once per overage sounds like a small UX detail, but the actual mechanism ended up touching three different things: the alert itself, what happens when an import brings in old transactions, and what happens when spending drops back under the limit.

The idempotency comes from a notified boolean stored on the budget document for that period — not the transaction, the budget. The first time spending crosses the limit, the flag gets set to true before the email is attempted, which means even an email-sending failure can't leave the system in a state where it tries to notify the same overage twice:

if (budget.spent >= budget.amount) {
  if (!budget.notified) {
    await BudgetModel.findByIdAndUpdate(budget._id, { notified: true });
    // ...send email, wrapped in its own try/catch
  }
} else if (budget.notified) {
  await BudgetModel.findByIdAndUpdate(budget._id, { notified: false });
}

The reset in the else branch is what makes this safe to leave running indefinitely: if spending drops back under the limit — a refund, a correction, a deleted transaction — the flag clears, and the next genuine overage triggers a fresh alert instead of staying silently suppressed forever.

The other piece connects directly back to the CSV importer. Bulk-importing six months of bank history shouldn't generate six months of "you went over budget" emails for periods that already happened. The function accepts an explicit suppression option for callers that already know better, and independently checks whether the transaction's date falls in a past billing period — skipping the email either way, while still updating the flag and logging the event. Importing old data updates the numbers without spamming the inbox over events nobody can act on anymore.

Cancel what's stale

Filtering quickly — switching between expense categories or date ranges in rapid succession — could leave an older, slower request resolving after a newer one, overwriting fresh results with stale ones the user had already moved past. Every fetch cancels whatever request was still in flight before starting a new one:

const abortControllerRef = useRef<AbortController | null>(null);
 
const fetchRecords = useCallback(
  async (filters: FilterState = {}) => {
    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
    }
    const controller = new AbortController();
    abortControllerRef.current = controller;
    setLoading(true);
 
    try {
      const response = await fetch(url, { signal: controller.signal });
      // ...
    } catch (error) {
      if (error instanceof Error && error.name === 'AbortError') return;
      console.error('Error fetching records:', error);
    } finally {
      if (abortControllerRef.current === controller) {
        setLoading(false);
      }
    }
  },
  [user, API_BASE_URL],
);

The detail that's easy to miss is the check inside finally. An aborted request's catch block returns early, but its finally still runs — and without checking that the ref still points at this specific controller, a slow, cancelled request could clear the loading spinner after a newer request had already started loading fresh data. The guard makes sure only the request that's actually still active gets to control loading state.

The same ref pattern tracks the last filters used, so that after any mutation — adding a record, deleting one, finishing a bulk import — the list re-fetches with whatever filters were last applied, rather than the mutation silently leaving a stale, unfiltered view on screen.

Outcome

PocketFlow shipped with a CI/CD pipeline that won't let a broken build reach production — Vitest covering business logic, Playwright running real sign-up-to-transaction flows, GitHub Actions blocking any deploy where either suite fails. That pipeline caught real problems before they shipped, which is the entire point of having one, and it's the reason nothing else broke quietly in front of real users.

And there were real users — not many, but enough to surface things a solo test account never will. The first clear signal came from the analytics, not from a bug report: people would sign in, log one transaction, and leave, without ever touching the budgets or goals features that were supposed to be the actual value of the app. The fix wasn't more onboarding copy explaining where to find things. It was moving Budgets and Goals onto the dashboard itself, as quick actions, so the features came to the user instead of waiting to be discovered behind a sidebar link.

The CSV importer told its own story through the same analytics. Every import — successful or not — reports its outcome, including which validation errors fired and how often. That turned "the importer is broken" from a vague complaint into a specific, ranked list of what's actually going wrong in real bank exports, which is a different kind of debugging entirely from staring at one failing test file.

Not everything shipped right the first time. The Goals feature originally let a user set a savings goal against any category, including ones that were clearly expenses — nothing stopped someone from creating a "goal" for Groceries, which doesn't mean anything. The fix followed the same principle as the budget data model: stop tracking a category's raw spend and start deriving Net Balance — income minus expenses — for goal-eligible categories, so a withdrawal and a contribution both move the number in the direction they actually should.

A friend who works in product looked at all of this — the CI pipeline, the architecture decision records sitting in the repo, the real users, the test coverage — and called it a stable MVP. The instinct was to argue. The more accurate read is that he wasn't wrong. "Production-ready" meant something specific from inside the codebase: it builds, it's tested, it's deployed. From outside it, the bar is whether it's survived contact with the market at any real scale, and PocketFlow hasn't been tested at that scale yet. Both definitions are correct. They're just answering different questions, and it's worth knowing which one you're being asked before answering it with the wrong kind of confidence.