ST
← All writing · 15 min read

One number every morning: the maths behind safe-to-spend

How Gravity, the iPhone expense tracker I built, turns a month of transactions into one daily figure: the formula, the TypeScript behind it, and the edge cases it handles.

  • Engineering
  • TypeScript
  • Personal finance
  • React Native

Gravity is an iPhone expense tracker I built, and its home screen leads with one figure: what's safe to spend today. Not a pie chart, not a list of categories. Just a rupee amount and a bar for today's spending. The formula fits on one line; getting every transaction into the right part of that line is most of the code, and that's what this post is about.

One question, one number

Most expense trackers answer "what did I spend?". That's useful for a monthly review. It's less useful in the moment, when the real question looks forward: given what's already gone and what's already promised, how much room is left for today?

Gravity answers it with this:

safe per day = (income this month − spent so far − bills still due this month)
               ÷ days left in the month, today included

The bracket is the pool: money that isn't spent and isn't spoken for. Dividing it by the days left turns a monthly figure into a daily one you can act on.

A worked example

These numbers are invented for illustration. Say it's 12 October and the month so far looks like this:

RowAmountWhere it goes
Salary, credited 1 Oct₹72,000Income
Rent, groceries, fuel and food so far₹36,500Spent
Dinner for two, ₹3,000, a friend owes half₹1,500Spent (your half only)
Moved to a savings account₹10,000Ignored: a transfer
Car EMI, due 15 Oct₹6,500Bills still due
Electricity, due 21 Oct₹1,500Bills still due
Broadband, due 26 Oct₹1,000Bills still due
income this month
₹72,000
income this month
spent so far
₹38,000
spent so far
bills still due
₹9,000
bills still due
safe per day, 20 days left
₹1,250
safe per day, 20 days left

Spent is ₹36,500 plus your half of dinner, so ₹38,000. The three bills add up to ₹9,000. The pool is ₹72,000 − ₹38,000 − ₹9,000 = ₹25,000. From 12 to 31 October is 20 days counting today, so Home shows ₹25,000 ÷ 20 = ₹1,250.

Notice what didn't count. The ₹10,000 to savings left the bank account but didn't leave you. Your friend's ₹1,500 is money you're owed, not money you spent.

And notice what the bills do. Leave them out and the number would read ₹1,700 today, then fall each time one cleared. With them in, the EMI going out on the 15th changes nothing, because that ₹6,500 was never in the pool.

"Can I afford this?" is the same maths

Home has a "Can I afford this?" button. It computes nothing new: the price comes off the pool and the rest of the month is divided again. If the new daily figure keeps at least 60% of the current one, the verdict is comfortable; below that it's tight; if the pool goes negative, it's over. In the example, ₹6,000 headphones are comfortable, a ₹12,000 phone is tight, and anything above ₹25,000 is over.

The code

Here's the function, lightly trimmed from safeSpend.ts:

safeSpend.ts
export function safeToSpend(
  transactions: Transaction[],
  recurrings: Recurring[],
  now = new Date(),
): SafeToSpend {
  const monthRows = inMonth(transactions, currentMonthKey(now));
  let income = totalIncome(monthRows);
  let incomeEstimated = false;
 
  // Salary not in yet? Average the previous three months that had income.
  if (income <= 0) {
    const keys = lastMonthKeys(4, now).slice(0, 3);
    const totals = keys.map((k) => totalIncome(inMonth(transactions, k))).filter((v) => v > 0);
    if (totals.length) {
      income = totals.reduce((s, v) => s + v, 0) / totals.length;
      incomeEstimated = true;
    }
  }
 
  const spent = totalSpend(monthRows);
 
  const monthEnd = format(endOfMonth(now), 'yyyy-MM-dd');
  const billsDue = upcomingBills(recurrings, daysLeftInMonth(now) + 1, now)
    .filter((b) => b.date <= monthEnd && !b.overdue)
    .reduce((s, b) => s + b.recurring.amount, 0);
 
  const pool = income - spent - billsDue;
  const daysLeft = Math.max(1, daysLeftInMonth(now) + 1); // include today
 
  return { perDay: pool > 0 ? pool / daysLeft : 0, pool, billsDue, income, incomeEstimated };
}

And the selectors it leans on:

selectors.ts
export const isSpend = (t: Transaction) => t.type === 'debit' && t.category !== 'transfer';
export const isIncome = (t: Transaction) => t.type === 'credit' && t.category === 'income';
 
/** What this row costs *you*: a part someone else owes is a receivable, not spend. */
export function effectiveSpend(t: Transaction): number {
  if (!t.splits?.length) return t.amount;
  return t.splits.reduce((s, p) => (p.person ? s : s + p.amount), 0);
}
 
export const totalSpend = (rows: Transaction[]) =>
  rows.filter(isSpend).reduce((s, t) => s + effectiveSpend(t), 0);
 
export const totalIncome = (rows: Transaction[]) =>
  rows.filter(isIncome).reduce((s, t) => s + t.amount, 0);

Most of the judgement lives in those two one-line predicates. A debit is spending unless it's a transfer. A credit is income only if it's categorised as income.

The function takes now as a parameter instead of calling new Date() inside, which makes it testable. The unit tests pin now to 2 August 2026, 30 days from month end counting today, and check that a ₹1,00,000 salary, ₹20,000 of spending and a ₹22,000 rent bill due on the 5th give a pool of ₹58,000 and a daily figure of ₹58,000 ÷ 30.

From raw rows to the morning number

  1. 01Keypad, widget or CSV
  2. 02Stored in rupees
  3. 03Rules set the category
  4. 04This month's rows
  5. 05Income − spending
  6. 06− bills still due
  7. 07÷ days left
  8. 08Home, widget, Siri
Every row is classified before the formula sees it; the division is the last and easiest step.

Transactions arrive from the keypad, the home-screen widget or a bank statement import. Every amount is converted to rupees on the way in and stored as a positive number, with a type field saying which way it went. Category rules (a rule like "swiggy" → Food) run over imported rows and suggest categories on the keypad, so by the time the formula sees a row it knows whether it's spending, income or a transfer.

The result goes to three places: Home, the widget (as "₹1,250/day safe"), and a Siri shortcut that answers "What's safe to spend in Gravity" from a snapshot shared with the widget, without opening the app.

The bank statement with a buried header

Bank exports are the messiest input. A typical one opens with the bank's name, the account number and the statement period before the real header (this one is made up, in the shape the tests use):

statement.csv
Example Bank Ltd.
Account Statement,,,
Account No,XXXXXX4321,,
From,01/07/2026,To,31/07/2026
,,,
Date,Narration,Chq./Ref.No.,Value Dt,Withdrawal Amt.,Deposit Amt.,Closing Balance
05/07/2026,UPI-SWIGGY BANGALORE,UPI-1234,05/07/2026,450.00,,84550.00
01/07/2026,NEFT SALARY JULY,NEFT-99,01/07/2026,,145000.00,229550.00

So the importer doesn't assume row one is the header. It scans the first 25 rows and scores each on whether it has something that looks like a date column, a description column and a money column:

csv.ts
function findHeaderRow(rows: string[][]): number {
  let best = -1;
  let bestScore = 0;
  for (let i = 0; i < Math.min(rows.length, 25); i++) {
    const has = (key: string) => (findColumn(rows[i], key) >= 0 ? 1 : 0);
    const score = has('date') + has('merchant') + Math.max(has('amount'), has('credit'));
    if (score >= 2 && score > bestScore) {
      best = i;
      bestScore = score;
      if (score === 3) break;
    }
  }
  return best;
}

Column matching is fuzzy and scored, because every bank names things differently. "Transaction Date" beats a plain "Date", which beats "Value Date". "Withdrawal Amt." and "DR" map to the debit side, "Deposit Amt." and "CR" to the credit side. Amounts lose their ₹ signs and commas, "(500)" reads as an accounting negative, and trailing "Dr" or "Cr" markers are understood. Numeric dates are read day first, always, so 05/07/2026 is 5 July.

If no row scores at least two, the importer infers columns from the data: the column where most cells parse as dates, the one with the most numbers, the one with the most text. If that fails, it returns an error rather than guessing. Imported rows are de-duplicated on date, amount and merchant, so importing a statement twice doesn't double the month.

Bills that haven't hit yet

Each recurring bill has an amount, a cadence (weekly, fortnightly, monthly, quarterly or yearly) and a next due date. upcomingBills walks every active one forward from that date, one period at a time, to the end of the window. A weekly charge can appear four or five times in what's left of a month, and each occurrence counts. Monthly bills step by calendar months, not 30-day blocks, so a bill on the 5th stays on the 5th. EMIs carry an optional end date and the walk stops there, so a loan with two instalments left never projects a third.

Paying a bill is where double counting would creep in, so it's one operation. payRecurring logs the spend and moves the next due date forward exactly one period from the occurrence it settled. On the next render that amount has left "bills still due" and joined "spent". The code comment explains the one-period rule: someone three months behind pays three times, and each payment should clear one occurrence, not all three.

Overdue occurrences are left out of the sum. If the bill was paid some other way, say through a statement import, the money is already in "spent" and counting it again would charge the month twice. If it really is unpaid, the number is optimistic by that amount, which is why overdue bills show in red in Home's upcoming list. Fixed bills like rent can also be set to auto-post: if the reminder goes unanswered, the charge is recorded as paid the day after it was due, with an undo, unless you answered "Not yet".

Finding the bills in your history

Nobody wants to type in every subscription, so Gravity proposes them. detectRecurring groups debits by merchant, skips transfers, and needs at least three charges. The cadence comes from the gaps between them:

engine.ts
const CADENCE_DAYS = { weekly: 7, fortnightly: 14, monthly: 30, quarterly: 91, yearly: 365 };
 
function inferCadence(dates: string[]): Cadence | null {
  if (dates.length < 2) return null;
  const sorted = [...dates].sort();
  const gaps = sorted
    .slice(1)
    .map((d, i) => (Date.parse(d) - Date.parse(sorted[i])) / DAY)
    .sort((a, b) => a - b);
  const median = gaps[Math.floor(gaps.length / 2)];
 
  let best: Cadence | null = null;
  let bestError = Infinity;
  for (const [cadence, expected] of Object.entries(CADENCE_DAYS) as [Cadence, number][]) {
    const error = Math.abs(median - expected) / expected;
    // A charge landing on the 3rd vs the 1st is still monthly: allow 30% drift.
    if (error < 0.3 && error < bestError) {
      best = cadence;
      bestError = error;
    }
  }
  return best;
}

The median rather than the mean, so one odd gap in a long history doesn't decide the answer. With a 30% tolerance the five windows don't overlap, and they don't cover everything: a median gap of 45 days matches nothing, and that merchant is left alone.

Three more checks follow. Every amount must sit within 20% of the average: subscriptions get taxed, repriced or prorated, but a shop you visit monthly with wildly different bills isn't a subscription. The latest charge must be recent; older than two and a half periods (75 days for a monthly bill) and the service is treated as cancelled. And the next due date is the latest charge rolled forward past today.

Along the way it records a price-history entry whenever the amount moves by more than ₹1. Comparing the last two entries gives price-hike alerts: a streaming plan that went from ₹649 to ₹749 rose 15%, over the 5% threshold, so it's flagged. Detected bills merge by merchant name. A bill you added by hand is never overwritten, and a detected one only has its amount, due date and price history refreshed, so your reminder and auto-post settings stay put.

The edges of the month

Before salary lands

Salary usually arrives a few days into the month, and until then the income line is zero. A literal formula would put the month in the red the moment rent went out on the 1st. So when this month has no income yet, the function averages the previous three full months, skipping any that had none, and sets incomeEstimated. Home then adds a line under the number: "Based on your usual income — this month's hasn't landed yet."

With no income history at all, Home doesn't show a red zero. It shows what you've spent this month and asks you to add your salary.

The last day

Days left is the calendar difference between today and month end, plus one for today. On the 31st that's 0 + 1, so the whole pool is today's. The Math.max(1, …) makes sure nothing divides by zero; the affordability check has the same guard, and its unit test is named "treats the last day as one day, not zero". Month length comes from date-fns' endOfMonth, so February needs no special case.

When the month is already gone

If spending plus bills already exceed income, the pool is negative. The daily figure is floored at zero rather than shown as a negative allowance, and Home swaps its label to "Over this month's income" with the overshoot in red. The widget says "Over budget".

The number is live

It isn't frozen at breakfast. Home recomputes it whenever the ledger changes, and today is one of the days in the divisor. So a ₹600 lunch on the 12th takes the pool to ₹24,400 and the number from ₹1,250 to ₹1,220: the lunch is spread across all 20 days, not charged to today alone. That's right about the month. To keep a sense of today, the bar under the number fills with today's spending measured against today's spending plus the number.

Money that moves but isn't spending

Transfers. The transfer sheet books two rows sharing a transferId: a debit from one account and a credit to the other, both in the transfer category. isSpend rejects the debit, isIncome rejects the credit, and both balances still move. Deleting either leg deletes both, because one leg alone would unbalance the books. A unit test books a transfer and asserts that totalSpend of the result is zero.

The case this matters most for is a credit card. The dinner counted when you paid with the card; paying the card bill from your bank account later is a transfer between your own accounts, not a second dinner. Recurring detection and round-up savings skip transfers too, so a monthly move into savings isn't flagged as a subscription.

Splits. A part of a transaction with a person attached is a receivable: they owe you. effectiveSpend counts only the parts without a person, so the ₹3,000 dinner costs you ₹1,500 in every total and category chart, and the other ₹1,500 appears in the People ledger as owed to you.

Refunds. A refund is a credit, and its category decides where it lands. Filed as income, which is where keypad and CSV credits start, it raises the income line by exactly what it would have taken off spending, so the pool is the same as if it had been netted. Moved to a spending category like Shopping, it's ignored: not income, and not negative spending. That errs low, which for a number called "safe" is the right direction.

Rupees, dollars and floating point

Every amount is stored in rupees, as a positive number. Switch the app to dollars and nothing stored changes. The currency is a display layer with one rate in it:

format.ts
/** 1 INR expressed in the active currency. Amounts are always stored in INR. */
let activeRate = 1;
 
/** Display amount (active currency) → stored base amount (INR). */
export function toBaseAmount(displayValue: number): number {
  return activeRate === 1 ? displayValue : displayValue / activeRate;
}
 
/** Stored base amount (INR) → display amount in the active currency. */
export function toDisplayAmount(baseValue: number): number {
  const v = baseValue * activeRate;
  return activeRate === 1 ? v : Math.round(v * 100) / 100;
}

toBaseAmount runs on every input (the keypad, the bill editor, the account sheet, the affordability calculator) and the formatter converts back on every output. Safe-to-spend, budgets and recurring detection only ever see rupees. Rates come from frankfurter.app, which serves European Central Bank reference rates without an API key; they're cached for a day, and the app ships fallback rates so it works offline. That's the only network request the app makes. The maths all runs on the phone.

Two consequences follow. An old row re-displays at today's rate, so a $10 coffee from months ago shows whatever today's rate makes of its rupee value. And imports must convert too, or a statement imported while the app shows dollars would be stored as if the figures were rupees. csvToTransactions takes a toBase function for exactly that.

Amounts are plain JavaScript numbers, so floating point, not integer paise. A $10 entry at a rate of 0.0115 is stored as ₹869.565…, unrounded. That holds up here because a month of personal transactions is a few hundred additions, which keeps rounding error far below a paisa, and every output is rounded at the edge: rupees display with no decimals, other currencies with two, round-ups are rounded to the paisa, and budget limits are saved as whole rupees. Rupee totals use Indian digit grouping, so a lakh reads ₹1,00,000.

What I'd do differently

The formula is one line, and safeSpend.ts is 70 lines including a round-up helper. Nearly all the other code decides which term a row belongs in. If I started again:

  • Snapshot the morning number. The live figure is right about the month but gentle about the day. I'd store the start-of-day figure too and measure today's spending against it, so a big lunch reads as a big lunch.
  • Use integer paise from day one. It costs nothing at the start and a migration later.

What I'd keep is the shape: one number, a pool you can explain, and the edge cases written down as rules in code, with tests pinned to fixed dates. A number you can reproduce on the back of an envelope is a number you can check, and this one fits on an envelope.

Next post →Data leakage: why your model is brilliant until it ships