DEV Community

Cover image for Money Math for a Freelance Fee Calculator in JavaScript: Integer Cents, Rounding on Purpose and Currency Symbols That Don't Pretend to Convert
Abid Ali
Abid Ali

Posted on

Money Math for a Freelance Fee Calculator in JavaScript: Integer Cents, Rounding on Purpose and Currency Symbols That Don't Pretend to Convert

A fee calculator looks like the easiest thing you can put on a website. Take an amount, subtract a percentage, subtract a withdrawal fee, print the result. I've built a few of these for Digital Dialogue, my freelancing blog, and every one of them was "three subtractions" for about an hour, until the numbers started coming out one cent wrong.

One cent sounds harmless. It isn't, for two reasons. People compare your number with the one in their payment history, and when it doesn't match they stop trusting everything else on the page. And a rate calculator that rounds the wrong way can tell someone to charge a rate that leaves them slightly short of the goal they typed in.

This post is the money layer I'd write today: integer cents, percentages as basis points, rounding you choose on purpose, a gross-up that never undershoots, and formatting that changes the symbol without pretending to convert anything. It's plain JavaScript with tests for Node's built-in runner. It's a generic version of the pattern, not a copy of the production source.

Why floats bite

JavaScript numbers are binary floating point. Most decimal fractions don't have an exact binary form, so they're stored as the nearest value that does:

0.1 + 0.2      // 0.30000000000000004
1.15 * 100     // 114.99999999999999
19.99 * 100    // 1998.9999999999998
Enter fullscreen mode Exit fullscreen mode

The last two are the dangerous ones, because converting to cents is exactly what people do first. Math.floor(1.15 * 100) is 114. Somebody types $1.15 and your calculator quietly works with $1.14.

toFixed(2) at the end doesn't save you either. It rounds the final display, but by then the error may have been multiplied, subtracted and compared against a threshold along the way.

The fix is old and boring: keep money as an integer number of the smallest unit (cents, paisa) and never let a float near the arithmetic. Integers are exact in JavaScript up to Number.MAX_SAFE_INTEGER, which is far beyond any freelance invoice.

Parse strings, not numbers

The trick is to go from the input string to cents without ever building the float 1.15. Split on the decimal point and treat each side as an integer:

// money.mjs
export function parseAmount(input) {
  const s = String(input).trim().replace(/,/g, "");
  const m = /^(\d+)(?:\.(\d{0,2}))?$/.exec(s);
  if (!m) return null;                       // not a plain positive amount
  const whole = Number(m[1]);
  const frac = Number((m[2] ?? "").padEnd(2, "0"));
  const cents = whole * 100 + frac;
  return Number.isSafeInteger(cents) ? cents : null;
}
Enter fullscreen mode Exit fullscreen mode

"1.15" becomes 1 * 100 + 15 = 115. "1,250.5" becomes 125050. Anything with three decimal places, a minus sign or letters returns null, and the form shows an error instead of guessing. Stripping commas is a deliberate choice for an English-language form. If your audience types 1.250,50, you need a locale-aware parser, and that's a different post.

Read the value from input.value, which is always a string, rather than input.valueAsNumber, which has already turned it into a float.

Percentages as basis points

A percentage has the same problem. 12.5% as 0.125 is fine, but 10.4% as 0.104 isn't exact. So store percentages as basis points: hundredths of a percent, as integers. 10% is 1000, 12.5% is 1250, 100% is 10000.

export function parsePercent(input) {
  const s = String(input).trim();
  const m = /^(\d{1,3})(?:\.(\d{0,2}))?$/.exec(s);
  if (!m) return null;
  const bp = Number(m[1]) * 100 + Number((m[2] ?? "").padEnd(2, "0"));
  return bp <= 10000 ? bp : null;            // 0% to 100%
}
Enter fullscreen mode Exit fullscreen mode

Now "a percentage of an amount" is integer times integer divided by 10,000. The multiplication is exact. The division is where you decide how to round.

Round on purpose

There's no neutral rounding. You pick one, and you should pick it per calculation:

  • For a fee shown as an estimate, round half up to the nearest cent. It's what most people expect when they check by hand.
  • For a minimum rate or price floor, round up, always. A floor that is a cent too low isn't a floor.
  • For what someone keeps, never show a negative number. If the costs are bigger than the amount, say so in words.

Half-up rounding with integers, for non-negative amounts:

// cents * bp / 10000, rounded half up
export function percentOf(cents, bp) {
  return Math.floor((cents * bp + 5000) / 10000);
}
Enter fullscreen mode Exit fullscreen mode

Adding half the divisor before flooring is the integer version of "add 0.5 and round down". percentOf(1005, 1000) is 10% of $10.05, which is $1.005, and it rounds to 101 cents.

Platforms do their own rounding, and they don't publish every detail of it. That's fine. Your job isn't to reproduce their ledger to the cent. It's to be consistent, to say "estimate" on the page, and to tell people where the exact number lives. On Digital Dialogue's calculators that's the contract details and transaction history on the platform itself.

The order of deductions matters

A take-home calculator usually has three kinds of cost:

  1. The platform's service fee, a percentage of what you bill.
  2. A flat withdrawal or transfer fee from your payment method.
  3. A percentage cost from your bank or provider, such as a conversion margin.

The question is what the third percentage applies to. On the gross? On what's left after the service fee? After the flat fee? Each is defensible, and each gives a different answer. Pick one, write it on the page next to the field, and implement exactly that. The order I use, and the one the free Upwork fee calculator on Digital Dialogue explains under its "Additional costs" field, applies the percentage to what's left after the service fee:

export function takeHome({ grossCents, feeBp, flatCents = 0, extraBp = 0 }) {
  const fee = percentOf(grossCents, feeBp);
  const afterFee = grossCents - fee;
  const extraPct = percentOf(afterFee, extraBp);
  const net = afterFee - flatCents - extraPct;
  return {
    fee, afterFee, extraPct,
    net: Math.max(net, 0),
    shortfall: net < 0 ? -net : 0,
    effectiveBp: grossCents === 0
      ? null
      : Math.round(((grossCents - Math.max(net, 0)) * 10000) / grossCents),
  };
}
Enter fullscreen mode Exit fullscreen mode

A worked example with illustrative numbers: a $500 milestone, a 10% service fee and a $2 flat withdrawal fee. The fee is 5000 cents, 45000 is left, minus 200 is 44800. The effective deduction is 1040 basis points, so 10.4%. That last number is the one I find most useful on the page, because it shows the real cut in a single figure, and it's always a bit higher than the headline fee.

shortfall exists so the UI can say "These costs are more than the amount by $1.50" instead of showing -$1.50. Negative take-home pay isn't a result. It's a sign that an input is wrong.

effectiveBp returns null for a zero amount instead of dividing by zero and getting NaN. The UI decides what to show for null. Don't let NaN% reach a screen.

Gross-up: the calculation people get wrong by hand

The reverse question is more useful for pricing: what do I need to bill so that I keep X? The common mistake is to add the fee on top. If you want to keep $900 and the fee is 10%, adding 10% gives $990. The platform takes 10% of $990, which is $99, and you keep $891.

The correct move is to divide by (1 − fee). $900 / 0.9 is $1,000, the fee is $100, and you keep $900. In integers, rounding up:

export function grossUp(targetCents, feeBp) {
  if (feeBp >= 10000) return null;
  let g = Math.ceil((targetCents * 10000) / (10000 - feeBp));
  while (g > 0 && g - 1 - percentOf(g - 1, feeBp) >= targetCents) g--;
  while (g - percentOf(g, feeBp) < targetCents) g++;
  return g;
}
Enter fullscreen mode Exit fullscreen mode

The two while loops look paranoid, and they are. The formula is exact in real numbers, but the fee is then rounded half up to a cent, so the formula's answer can be one cent off either way. The loops check the actual result with the same percentOf the take-home function uses and nudge until the answer is the smallest amount that still leaves the target. In practice they run zero or one time. The test below checks thousands of combinations.

grossUp(70000, 1500) returns 82353: to keep $700 with a 15% fee you bill $823.53. Divide by hand and you get $823.529..., and rounding that to the nearest cent would give $823.53 here. With other numbers it rounds down, and you fall a cent short. Rounding up removes the question.

Formatting: symbol, not conversion

Most freelance calculators let you pick a currency. Be clear about what that does. If you don't have live exchange rates, and for an estimate you usually shouldn't pretend to, the selector should change the symbol and format only. Then say so under the field, the way the calculators on Digital Dialogue do: "Changes the symbol and format only. No exchange rates are applied."

Intl.NumberFormat does the formatting:

export function formatMoney(cents, currency, locale = "en-US") {
  return new Intl.NumberFormat(locale, {
    style: "currency", currency, currencyDisplay: "narrowSymbol",
  }).format(cents / 100);
}
Enter fullscreen mode Exit fullscreen mode

Dividing by 100 at the very end is fine, because it's only for display. A few things I learned while testing this on Node 20:

  • With the default currencyDisplay, en-US shows Canadian dollars as CA$1,234.50 and Pakistani rupees as PKR 1,234.50. narrowSymbol gives $1,234.50 and Rs 1,234.50. Narrow is friendlier, but two currencies can look identical, so put the currency code somewhere near the result.
  • The space in Rs 1,234.50 is a non-breaking space (U+00A0), not a normal one. My first test failed on exactly that. Don't string-compare formatted output in tests or code unless you account for it, and never parse a formatted string back into a number.
  • Locale changes grouping, not just symbols. en-IN formats 123456.5 rupees as ₹1,23,456.50. If you hard-code "en-US", everyone gets Western grouping. That may be what you want. Just make it a decision.
  • Minor units differ by currency. All the currencies above use two decimal places, so cents work. If you add one that uses zero or three, store "minor units" per currency and read the count from resolvedOptions().maximumFractionDigits.

Tests

The tests are where this approach pays off, because every expected value is an exact integer:

// money.test.mjs (run with: node --test)
import { test } from "node:test";
import assert from "node:assert/strict";
import { parseAmount, percentOf, takeHome, grossUp, formatMoney } from "./money.mjs";

test("parses amounts without float drift", () => {
  assert.equal(parseAmount("19.99"), 1999);
  assert.equal(parseAmount("1.15"), 115);
  assert.equal(parseAmount("1,250.5"), 125050);
  assert.equal(parseAmount("1.999"), null);
});

test("fee, flat cost, then percentage on the remainder", () => {
  const r = takeHome({ grossCents: 50000, feeBp: 1000, flatCents: 200 });
  assert.equal(r.net, 44800);
  assert.equal(r.effectiveBp, 1040);
});

test("never shows a negative take-home", () => {
  const r = takeHome({ grossCents: 500, feeBp: 1000, flatCents: 600 });
  assert.equal(r.net, 0);
  assert.equal(r.shortfall, 150);
});

test("gross-up never lands below the target", () => {
  for (let t = 1; t < 20000; t += 7) {
    for (const bp of [0, 500, 1000, 1250, 1500]) {
      const g = grossUp(t, bp);
      assert.ok(g - percentOf(g, bp) >= t);         // enough
      assert.ok(g - 1 - percentOf(g - 1, bp) < t);  // and the smallest such amount
    }
  }
});

test("currency changes the symbol only", () => {
  assert.equal(formatMoney(123450, "PKR"), "Rs\u00a01,234.50");
});
Enter fullscreen mode Exit fullscreen mode

All of these pass on Node 20. The gross-up loop is the one I'd keep if I could keep only one, because it tests a property ("enough, and the smallest amount that is enough") instead of a handful of examples I picked myself.

What to put on the page

The arithmetic is half the job. The other half is honesty around it:

  • Label the result as an estimate and say which inputs it depends on.
  • Put the default fee in the field, but tell people to replace it with the one on their own offer. Upwork, for example, shows the fee per contract and says it can range from 0% to 15%.
  • Say what isn't included. Tax is the big one.
  • Show the breakdown, not just the final number. People trust a result they can follow line by line.

If you want to see these choices as a finished tool, the Digital Dialogue Upwork fee calculator handles hourly and fixed-price contracts, an editable service fee, flat and percentage withdrawal costs, and a currency picker that only changes the symbol. It shows a worked example as well.

The short version: parse strings to integer cents, keep percentages as basis points, choose your rounding per calculation, round floors up, never show negative money, and format only at the edge.


I'm Abid Ali, a freelance full-stack developer in Karachi working with TypeScript, Next.js, React and Node.js. I write practical freelancing guides and build small free calculators on Digital Dialogue.

Top comments (0)