Nakodo works out what a brand should offer each creator, and then an email goes out with that number in it. The arithmetic produces things like 56.32 and 1,873.44. Nobody charges 1,873.44 for a sponsored video. An amount like that announces that a formula wrote the message, and it also invites a haggle over the 44 pence.

So every amount in the app passes through one function on its way out.

// Amounts people actually charge: 50, 75, 120, 150, 175, 200, 250, 300 and so // on, the nearest one on a log scale. Works the same in yen or forints. const NICE_STEPS = [1, 1.2, 1.5, 1.75, 2, 2.5, 3, 3.5, 4, 4.5, 5, 6, 7, 7.5, 8, 9, 10]; export function niceAmount(n: number): number { if (!Number.isFinite(n) || n <= 0) return 0; const magnitude = 10 ** Math.floor(Math.log10(n)); const x = n / magnitude; let best = NICE_STEPS[0]; for (const s of NICE_STEPS) if (Math.abs(Math.log(x / s)) < Math.abs(Math.log(x / best))) best = s; return Math.max(1, Math.round(best * magnitude)); } 

Seventeen mantissas, repeated at every power of ten. 56 becomes 60, 82 becomes 80, 117 becomes 120, 340 becomes 350, 12,345 becomes 12,000.

Nearest in logs, not nearest in units

The comparison is Math.abs(Math.log(x / s)), not Math.abs(x - s). Distance between prices is multiplicative: 1,750 and 2,000 are 14% apart whether the currency is pounds or rupees, and the step list is denser at the bottom of each decade than the top, exactly like real price lists. 1,870 rounds to 1,750 rather than 2,000, which linear distance gets the other way round.

The step list itself is the editorial judgement in the file. Seventeen entries sounds arbitrary until you try to remove one: dropping 1.75 makes everything between 1.6 and 1.9 jump to 2, and a fee of 200 reads as a very different offer from 175.

One function, 23 currencies, no per-currency table

magnitude comes from Math.log10, so the function has no idea what a currency is and does not need a table of sensible amounts per currency. Yen are three orders of magnitude bigger than pounds and the behaviour is identical: 12,345 yen becomes 12,000 yen, 98,700 becomes 100,000.

Nakodo prices in 23 currencies, from a fixed table of rates rather than a live feed, and fees inherit it:

const scale = clamp(r.value, VALUE_MIN, VALUE_MAX) * LEVEL_FACTOR[r.level] * q * CURRENCIES[r.currency].rate; return Math.max(minFee(r.currency), niceAmount((base + (perThousand * views) / 1000) * scale)); 

One multiplication by the rate inside the scale, one rounding at the end. Note what is absent: the 4% margin our own subscription prices carry on a converted sale. A creator fee is money the brand pays someone else, so adding a safety margin to it would be charging the brand for our Stripe fees. Our own plan prices get the opposite treatment, converted and then pushed up to the next amount ending in 9, which is why the pricing page shows prices ending in 9 in whatever currency it decides you are reading in, rather than an exact conversion of the pound price.

Round exactly once, at the very end

The rule that cost us a bug: every intermediate value stays unrounded.

const minFee = (currency: Currency) => niceAmount(MIN_FEE_GBP * CURRENCIES[currency].rate); 

MIN_FEE_GBP is 50, and the floor is rounded per currency once rather than converting a rounded pound figure and rounding again. Chained rounding drifts: round to a nice amount, apply an 85% opening share, round again, apply a currency rate, round again, and the number that reaches the creator is no longer any particular share of anything. In the fee path there is exactly one niceAmount call per amount that a human will see, and the other calls are on independently computed figures (the floor, a converted fixed fee, the value of a customer).

Converting a whole set of terms re-rounds rather than scaling the rounded amount:

tiers: terms.tiers.map((t) => (t.fee ? { ...t, fee: { ...t.fee, amount: niceAmount(t.fee.amount * factor) } } : t)), 

A 250 pound tier becomes 300 euros, not 294.

The property worth testing

niceAmount is a fixed point on its own output: once a value is nice, rounding it again changes nothing. I checked that over a few hundred thousand values up to two million and it holds everywhere, which matters because amounts get re-read from the database, re-displayed, and re-sent through the editor, and a function that drifts on each pass would walk a fee upwards every time somebody opened the page.

Display is the only place decimals get removed, and Intl does it:

export function formatFee(amount: number, currency: Currency = BASE_CURRENCY): string { const { code, locale } = CURRENCIES[currency]; return new Intl.NumberFormat(locale, { style: "currency", currency: code, maximumFractionDigits: 0, minimumFractionDigits: 0 }).format(amount); } 

Zero on both fraction digit options, because the default for a currency is two and a sponsorship fee with pence in it looks like an invoice.

If you want to see the unrounded arithmetic that sits under all of this, the sponsorship cost calculator does it in the open: views times a CPM gives a fee, a quote divided by views gives an effective CPM, and no number on that page is rounded for you. The vocabulary, flat fee and rate card and CPM, is defined in the glossary. The rounding only matters where a number leaves the app and lands in front of a person.