AI can't count. That's why Tetri uses math
Financial results should be reproducible, not merely plausible. Here is how Tetri calculates reports, forecasts and tax estimates without asking a model to invent the number.
Tetri does not use AI to calculate reports, analyse spending or forecast cash flow.
This can sound almost defensive now. AI is being added to every kind of software, and personal finance looks like an obvious place for it. There is a lot of data. People want answers. A chat box promising to explain where the money went is easy to imagine.
The problem is more basic: AI cannot be the calculator.
A language model generates a likely continuation. Sometimes that continuation contains the correct sum. Sometimes it contains a wrong number in a very confident sentence.
If an AI agent calls a calculator, executes code or queries a database, the result can be correct. But the deterministic tool did the calculation. The model selected the tool and described its output. In a finance app, I want that boundary to stay obvious.
Here are a few examples of what performs the actual analysis inside Tetri. The excerpts omit surrounding query and DTO code, but keep the calculations unchanged.
Money starts with decimal arithmetic
JavaScript numbers use binary floating point. That is fine for coordinates and animation. It is a poor foundation for money: 0.1 + 0.2 does not equal exactly 0.3.
Tetri stores monetary values as decimal strings and performs arithmetic with Decimal. Precision and rounding are configured once, and aggregation never converts values through native floating point.
Decimal.set({ precision: 36, rounding: Decimal.ROUND_HALF_UP });
export function toDecimal(
value: Decimal | string | number | null | undefined,
): Decimal {
if (value == null || value === '') return new Decimal(0);
return new Decimal(value);
}
export function sumDecimals<T>(
items: readonly T[],
selector: (item: T) => Decimal,
): Decimal {
let total = new Decimal(0);
for (const item of items) {
total = total.plus(selector(item));
}
return total;
}The rule is simple: calculate at full precision, aggregate first and round at the output boundary. A balance is not the model's interpretation of several transactions. It is their sum.
How Tetri detects unusual spending
The anomaly report does not ask a model whether a transaction “looks unusual”.
For the selected month, Tetri takes the previous six months as a baseline. Historical expenses are grouped by category and currency. Each currency bucket is converted into the workspace's home currency before aggregation, because adding euros directly to dollars would make the baseline meaningless.
For every category, the total historical amount is divided by the number of transactions. Each current transaction is then compared with that category average.
for (const [categoryId, { total, count }] of totalsByCategory) {
if (count === 0) continue;
avgMap.set(categoryId, total.dividedBy(count));
}
for (const row of currentRows) {
if (!row.categoryId) continue;
const catAvg = avgMap.get(row.categoryId);
if (!catAvg || catAvg.isZero()) continue;
const homeAmount = this.currencyConversion
.legToHome(row, defaultCurrencyId, rateMap)
.abs();
const multiplier = homeAmount.dividedBy(catAvg);
if (multiplier.greaterThan(THRESHOLD)) {
candidates.push({ row, homeAmount, catAvg, multiplier });
}
}The threshold is explicit. More than twice the category average is flagged; four times or more is counted as extreme. The same transaction history and exchange rates produce the same anomalies.
This algorithm is deliberately unsophisticated. That is useful. A user can understand why a transaction appeared in the report and can disagree with the threshold without wondering what a model inferred from their life.
How the cash flow forecast is built
Tetri's forecast is not a generated prediction of what the user might do.
It starts from explicit future sources: recurring rules, subscriptions, scheduled transactions, debt payments, goals and planned budget amounts. For each forecast month, the engine counts the actual occurrences of every active rule and multiplies the configured amount by that count.
const count = getRuleOccurrences(
ctx,
rule,
month,
monthStart,
monthEnd,
).length;
if (count === 0) continue;
const perOcc = rulePerOccFor(ctx, rule.id, month);
const total = perOcc.times(count);
if (rule.type === 'income') {
if (rule.walletId == null) {
incomeFromRules = incomeFromRules.plus(total);
} else {
incomeByWallet.set(
rule.walletId,
(incomeByWallet.get(rule.walletId) ?? new Decimal(0)).plus(total),
);
}
} else {
commitments = commitments.plus(total);
}Scheduled transactions are added on their business dates. Debt principal is tracked separately from spending because paying back principal moves value from cash into a smaller liability; treating the whole payment as an expense would understate net worth. Goal contributions and planned variable spending are added through their own sources.
The balance then walks forward one month at a time:
const net = finiteDecimal(a.income)
.minus(finiteDecimal(a.commitments))
.minus(finiteDecimal(a.variable))
.minus(finiteDecimal(a.goalContributions))
.plus(finiteDecimal(a.debtPaydown ?? '0'));
balance = balance.plus(net);The uncertainty band is mathematical too. Tetri measures the historical variation of monthly net flow using the median absolute deviation. That is less sensitive to one extreme month than a plain standard deviation. The band grows with the forecast horizon using a fixed variance formula:
const variance = safeShock
.pow(2)
.times(monthsAheadDecimal)
.plus(safeDrift.pow(2).times(monthsAheadDecimal.pow(2)));
const band = variance.sqrt();
const low = balance.minus(band);
const high = balance.plus(band);It is still a forecast, not a promise. Its assumptions can be wrong. But the line is reproducible, and every point can be traced back to the payments, budgets and rules that produced it.
How progressive tax is estimated
Tax estimation is another place where a plausible answer is not enough.
Tetri classifies transactions according to the user's tax profile, converts them using the configured rate source, applies deductions and then walks the relevant rate table. For a progressive table, only the part of income inside each bracket receives that bracket's rate.
const periodStartIncome = Decimal.max(
thresholdIncome.minus(gross),
new Decimal(0),
);
for (const bracket of sorted) {
const from = toDecimal(bracket.from);
const to = bracket.to !== null ? toDecimal(bracket.to) : null;
const taxableFrom = Decimal.max(from, periodStartIncome);
const taxableTo =
to === null ? thresholdIncome : Decimal.min(to, thresholdIncome);
const incomeInBracket = taxableTo.minus(taxableFrom);
if (incomeInBracket.lte(0)) continue;
const rate = new Decimal(bracket.rate);
const taxInBracket = incomeInBracket.times(rate);
tax = tax.plus(taxInBracket);
}The engine returns the applied brackets along with the result. That makes it possible to show which slice of income was taxed at which rate. The result is an estimate, not tax advice, but it is an estimate with an inspectable calculation behind it.
Rules are small programs
Tetri automations and tax classifications use condition groups rather than probabilistic classification. Conditions can be nested with AND and OR, while money comparisons run in the Decimal domain.
export function matchesGroup(
subject: RuleSubject,
group: ConditionGroup,
depth = 0,
): boolean {
if (depth > MAX_CONDITION_DEPTH) {
throw new Error(`ConditionGroup exceeds max depth ${MAX_CONDITION_DEPTH}`);
}
const results = group.conditions.map((item) =>
'conditions' in item
? matchesGroup(subject, item, depth + 1)
: matchesCondition(subject, item),
);
return group.operator === 'AND'
? results.every(Boolean)
: results.some(Boolean);
}These rules are less flexible than asking a model to decide what a transaction means. They are also visible, editable and repeatable. If a rule is wrong, there is something concrete to fix.
Where AI could fit
A language model could still be useful around these systems. It could turn a natural-language question into a request for an existing report. It could explain a result that was already calculated. An external agent could call a narrow Tetri operation through MCP after the user explicitly grants access.
It should not become the source of a balance, forecast or tax estimate.
There is also a data boundary to consider. An embedded AI feature would need financial history to produce a useful answer. Running a capable model entirely inside Tetri's infrastructure is not a realistic investment for a solo developer, while sending user data to an external model introduces another processor and another set of failure modes.
Right now the number of Tetri financial records sent to external AI services is zero.
If AI appears in Tetri later, the math should remain where it is. The model may help a user ask the question or understand the answer. It should not invent the number between them.