Why was my 52 euro dinner showing up as 52 kroner?
I added a 52 EUR dinner to a Danish-krone group and the balances came out as nonsense. The number was right. The currency was wrong. Here is what I learned about storing every expense in one base currency so the math actually works.
I was building multi-currency support into Even Steven, my little expense-splitting app, and I hit a bug that confused me for a while. I added a 52 EUR dinner to a group whose currency was Danish kroner (DKK), and the balances came out wrong. Not crash-wrong. Quietly wrong. Everyone owed a number that looked plausible until you checked it against what people had actually paid.
The short version is that the app was treating my 52 EUR dinner as if it were 52 DKK. Same number, wrong currency, and 52 euros is roughly 388 kroner, so the balances were off by a lot. The fix was to stop storing expenses in whatever currency the user happened to type and instead store every expense in one base currency per group, converting once at the moment it's saved. The interesting part is everything in between: why the number being right made the bug harder to see, and where the conversion actually has to live.
What was actually broken?
Even Steven stores each expense as a single amount with a currency code next to it. A 52 EUR dinner is the number 52 and the code EUR. When someone pays for a group, the app works out who owes what by adding up what each person paid and subtracting their share. That sum is what shows up as your balance.
Here's the thing the sum quietly assumed: that every number it was adding was in the same currency. It wasn't. When I added a 52 EUR dinner to a DKK group, the balance math grabbed the number 52 and threw it onto a pile of kroner. So 52 euros got added to the payer's balance as 52 kroner, and each person's share got subtracted as kroner too. The database never knew a conversion was supposed to happen. It just added numbers that happened to sit in the same column.
This is the part that made it sneaky. The arithmetic was correct. 52 minus a third of 52 is exactly the number it should be. If you were debugging by checking whether the addition worked, the addition always worked. The bug wasn't in the math. It was in the units. It's like adding a temperature in Celsius to one in Fahrenheit: 20 plus 68 gives you 88, and 88 is a real number, it's just not a temperature that means anything.
What does "store everything in a base currency" mean?
The fix is to pick one currency per group, call it the base currency, and make sure every expense is converted into that currency before any math touches it. If a group keeps its books in kroner, then a 52 EUR dinner gets converted to about 388 DKK the moment it's saved, and 388 is the number the balance math sees. The euro figure doesn't disappear. The app still remembers you typed 52 EUR, because that's what you'll recognize later. But the running total is always done in one currency, so it's always adding apples to apples.
Think of the base currency like a common measuring stick. People hand you receipts in euros, kroner, and Swedish kronor, but before you write anything in the ledger you convert it all to one unit. The ledger only ever speaks one language. That's the whole idea.
In the database, this meant adding one nullable column to two tables: base_currency_amount on the expenses table, and base_share_amount on the table that tracks each person's share. Then the balance function reads those base columns instead of the raw amounts. Here's the migration that adds the columns and rewrites the balance calculation:
ALTER TABLE expenses
ADD COLUMN IF NOT EXISTS base_currency_amount DECIMAL(12,2);
ALTER TABLE expense_participants
ADD COLUMN IF NOT EXISTS base_share_amount DECIMAL(12,2);
CREATE OR REPLACE FUNCTION public.recompute_group_member_balances(p_group_id uuid)
RETURNS void LANGUAGE plpgsql SECURITY DEFINER
SET search_path = public AS $$
BEGIN
UPDATE group_members gm
SET balance = (
COALESCE((
SELECT SUM(COALESCE(e.base_currency_amount, e.amount))
FROM expenses e
WHERE e.payer_id = gm.id AND e.group_id = p_group_id
), 0)
- COALESCE((
SELECT SUM(COALESCE(ep.base_share_amount, ep.share_amount))
FROM expense_participants ep
JOIN expenses e ON e.id = ep.expense_id
WHERE ep.member_id = gm.id AND e.group_id = p_group_id
), 0)
)
WHERE gm.group_id = p_group_id;
END;
$$;The part I want to point at is the COALESCE(e.base_currency_amount, e.amount). COALESCE means "use the first value that isn't null." So for new expenses, it uses the converted base amount. For old expenses from before I added the column, the base amount is null, so it falls back to the raw amount, exactly the way the app behaved before. That little fallback is what let me ship the column without rewriting every existing row first. Old single-currency groups keep working untouched, and new expenses get the corrected number.
Notice what the balance function does not do. It doesn't call a conversion function. It doesn't look up an exchange rate. It doesn't have an if-statement on currency code. It's the same dumb add-and-subtract it always was. All I did was make sure the numbers it adds were already in one currency before they got there. The smarts moved out of the math and onto the row.
Where does the conversion actually happen?
If the database stays currency-blind, something else has to do the converting. I decided the right place was the app itself, at the moment you hit save, because that's where the exchange rates already live. The app pulls a day's worth of rates from Frankfurter (a free service backed by European Central Bank reference rates) when it launches and keeps them in memory. So when you save an expense, the rate is already sitting right there.
Converting at save time has a nice property: the row is born correct. The amount you typed and the converted base amount get written together, using today's rate, which is the right rate for an expense that's happening today. Here's the block on the add-expense screen that does it:
// app/(tabs)/groups/[id]/add-expense.tsx
// Compute base-currency amount for balance tracking
const baseCurrency = (groupBaseCurrency ?? currency) as Currency;
let baseCurrencyAmount = amount;
if (rates && currency !== baseCurrency) {
try {
baseCurrencyAmount = convert(amount, currency, baseCurrency, rates);
} catch {
// fallback to raw amount
}
}
const splitsWithBase = computeBaseShares(splits, amount, baseCurrencyAmount, payerId!);It reads cleanly top to bottom. Start by assuming the base amount equals the amount you typed. If the group's base currency is different from the one you entered, and rates are loaded, convert it. convert(amount, currency, baseCurrency, rates) is just (amount / rates[from]) * rates[to] under the hood. Then computeBaseShares does the same conversion for each person's slice of the bill, so the shares add up in base currency too.
The two fallback paths are worth a second. If the group's base currency hasn't loaded yet, it falls back to the currency you typed, which is fine: if they match, the row is correct, and if they don't, the database fallback covers it. And if a rate is missing, the try/catch keeps the raw amount rather than crashing the save. Once this lands, every page that shows a balance is reading numbers that are already in one currency. Pages can still convert again for display, to show you your own preferred currency, but that conversion is now cosmetic. The number underneath is already right.
Things that surprised me
A few things I didn't see coming until I was in it:
- The bug was invisible in single-currency groups. If everyone in a group uses kroner, the "conversion" is multiplying by one, so the broken version and the fixed version give the same answer. The bug only shows up the moment two currencies meet, which is why it survived all my early testing.
- Storing the converted amount means storing a snapshot, not a live value. A 52 EUR dinner converted to 388 DKK in June stays 388 DKK forever, even when rates drift. That's correct, not a bug. You don't re-price a dinner you already split just because the euro moved. But it does mean you should never show a live conversion of an old expense next to its stored one, because they won't match, and that looks like a mistake.
- Every place that writes an expense has to remember to convert. The add screen does it. The edit screen has to do it too, and any future import path will have to as well. If one of them forgets, the row lands with no base amount and the balance silently falls back to the raw number. A quietly wrong balance is worse than a loud error, so this is the part I'm least comfortable with.
- Rounding has to happen in two currencies at once. The shares get rounded in the currency you typed, then again when scaled into the base currency. The trick that kept it honest was floor everyone else's share and give the leftover cent to the payer, in both currencies, so the parts always sum back to the whole.
So when is this worth doing?
Here's what I'd tell someone about to add currencies to a money app. If the headline number people open the app to see is a total that adds across currencies, like a group balance or a net amount owed, pick one base currency and convert everything into it before any math happens. Store both numbers: the original, because that's what the user remembers typing, and the base, because that's what the math runs on. Convert once, at save time, using the rate you have. Let the database stay dumb about currencies, because the moment your balance math has to know about exchange rates, it stops being simple and starts being the place bugs hide.
Where I'd do it differently: if the app is really about live values, like a portfolio that should re-price as rates move, then don't freeze a snapshot, convert on read instead. And if you need an auditor to reconstruct every line at the exact rate on a given day, store the rate itself on the row, not the converted amount, so you can re-derive anything. But for splitting dinners with friends across kroner and euros, freezing the conversion at save time is exactly right. The number you split was the number that was true that night, and that's the number that should stick. The whole bug came down to one mix-up: the math was never wrong, the units were. Fixing the units fixed everything.


