All posts

CoinQuest2 min read

Interest without a cron job

CoinQuest teaches kids investing with pretend markets that grow every day. None of it runs on a schedule: each balance is worked out from two numbers whenever someone looks.

The CoinQuest landing page: a headline about a daily money habit app for kids beside a phone mockup showing a spend-or-save choice screen.

CoinQuest is a money-skills app for kids aged six to twelve. They earn coins from lessons, games and a daily quiz, and one of the things they can do with coins is invest them. An admin creates markets, each with an emoji, a name and a daily return, and a child moves coins into a market and watches them grow.

The obvious way to build that is a nightly job that adds interest to every investment. I didn't want one. The backend had to be safe to run on serverless hosting, where there's no process sitting around to run a job, and a job that silently fails one night leaves every balance wrong the next morning.

Two numbers per investment

Each investment stores a base amount and the time it was last settled. Nothing else. Its value right now is compound growth over the time since then:

function calculateInvestmentValue(baseCoins, dailyReturn, lastSettledAt) {
  const days = (Date.now() - lastSettledAt) / 86_400_000;
  const value = baseCoins * (1 + dailyReturn / 100) ** days;
  return Math.max(0, Math.round(value * 100) / 100);
}

days is fractional, so growth accrues continuously rather than jumping at midnight. The result is floored at zero, since a market with a negative return shouldn't be able to take a child's investment below nothing. Every controller that needs a value calls this one function.

Settling

The trick that keeps it simple is settling. When a child adds more coins to a market they're already in, or takes some out, the controller first computes the current value, then folds the change into it and resets the clock:

const current = calculateInvestmentValue(inv.baseCoins, market.dailyReturn, inv.lastSettledAt);
inv.baseCoins = current + deposit;   // or current - withdrawal
inv.lastSettledAt = new Date();

All the growth so far becomes part of the new base, and growth from here on starts from the new time. That's why one entry per market per child is enough, with no history inside the wallet.

The "grew this week" figure on the wallet screen uses the same function, evaluated at now and at seven days ago. For an investment younger than a week, it's simply the growth since it was made.

What it costs

The market's rate isn't snapshotted. If an admin changes a market's daily return, the new rate applies back to each investment's last settle time. For a teaching app with admin-set rates that was an acceptable trade, and it's written down in the docs so nobody is surprised by it. A real brokerage would need rate history.

History lives in a separate, append-only transaction log: every deposit, withdrawal and refund, indexed by user and date. Each entry copies the market's name at the time it was written. When an admin deletes a market, every child's investment in it is valued, refunded to their main balance and logged as a refund, and their history still reads correctly because it never depended on the market existing.

The same lesson showed up in the allowance feature later. A withdrawal stores the coin-to-dollar ratio that applied when it was made, so a parent changing the ratio tomorrow doesn't change what yesterday's withdrawal was worth. If a number can change later, store the value you used, not a pointer to it.