Skip to content
Money flow

Every cent right, from checkout to the club's invoice

Players paid for courts with cards, wallets, credits and, in Kuwait, KNET. Every month each club needed an invoice that added up. I worked on the payment screens in the app and built the tool that turns a club's month of payments into one PDF invoice.

Market
Padel clubs, Sweden and Kuwait
My role
App developer; built the club billing tool
Money path
Player → app → Stripe or KNET → club invoice
Output
One PDF invoice per club, every month

ProblemDecimal prices drifted on screen, and each club's monthly invoice was worked out by hand.

What I didI gave court prices one shared rounding helper, put promo codes in one calculation, and built an invoice tool that calculates every fee per club from the platform's own data.

ResultClub billing became a repeatable process that anyone can check, and I learned to keep money in whole units from the start.

The problem

Small rounding errors become wrong invoices

Open any browser console and type 0.1 + 0.2. The answer is not 0.3. It is 0.30000000000000004.

This is not a bug in JavaScript. Computers store numbers in binary. Some decimal numbers, like 0.1, have no exact binary form. So the computer stores a value that is very close, but not exact. Most of the time nobody sees it, because the screen rounds it away.

Money is where it starts to matter. A booking app adds a court price, a member discount, a promo code and a fee. It divides the total between players. Then, at the end of the month, it adds thousands of these amounts together for each club. Every step can carry a tiny error. If two parts of the system round at different points, they can end one öre apart, and both look right.

The app I worked on was a padel booking and community app for players in Sweden, with clubs in Kuwait as well. Players booked courts, joined games, tournaments and training, bought memberships and paid in the app. The company collected the money for the clubs and then had to settle with each club. That was the second problem. Every month someone had to work out what each club owed the platform and what the platform owed each club. It was work for people with spreadsheets. It was slow, and it was hard for anyone else to check.

What made it hard

Many ways in, many fees out

Money came into the app through many doors. Cards, Apple Pay and Google Pay went through Stripe, with 3D Secure because European banks require it. Clubs in Kuwait took KNET through a separate local provider. Players could pay with credits they had bought in the app. Promo codes and member discounts lowered the price. Split payment let a group share one court, so one booking could become several payments.

Money also went out in many ways. A club's monthly invoice was not one number. It had the platform fee with VAT, card fees with minimum amounts, fees for credit pay, fees for split payments and extra charges that were agreed by hand. Bookings were not the only source either. Games, tournaments, training and series games (league play over a season) all produced payments that belonged to a club.

Time was a third problem. A payment made a few minutes after midnight belongs to a different day, and sometimes to a different month. If the tool showed times in the wrong time zone, a payment could appear in the wrong month on the invoice.

And the rules changed. During the time I worked on billing, the fee rules were changed. Old months still had to be explained with the old rules, and new months had to use the new ones.

Follow one booking's money

From the price on the screen to a line on the invoice

It helps to follow one booking from start to end. The amounts in this walk-through are only an example.

First, the price on the screen. A player picks a court for an hour at 400.00 kr. If they add an extra slot, or a member discount applies, the app works out a new total. This is the first place where decimals can drift, so the court price and the extra slots run through one shared rounding helper before the screen shows them.

Second, the discount. A promo code with a percentage almost always creates part of an öre. Early on, the coupon price calculation had a bug. I fixed it and moved the coupon logic into one reusable component, then added promo codes to game and tournament payments as well.

Third, the split. If four players share the court, the total becomes four shares, and each share can carry a split fee. Now the question is not only how to round, but who pays the leftover öre when the total does not divide evenly.

Fourth, the payment provider. The app sends the total, rounded to two decimals, and the backend asks Stripe for a payment intent. Stripe does not take 400.00. It takes 40000, the amount in the smallest unit of the currency. So the amount is turned into a whole number at that point, just before it leaves the platform. If the player confirms with 3D Secure, the payment goes through and the booking is saved.

Fifth, the month closes. The invoice tool collects that club's transactions and activities for the month. It works out every fee on its own line and makes one PDF invoice for the club. Only on the screen and on the PDF does the money become "400.00 kr" again.

Where the last cent goes

Round per share, or round once: two right-looking answers

EventStateAmount
Court priceTotal100.00 kr
Player 1 shareShare33.34 kr
Player 2 shareShare33.33 kr
Player 3 shareShare33.33 kr
Fee, rounded on each share and added3 roundings2.49 kr
Fee, rounded once on the total1 rounding2.50 kr
Example only: a 100.00 kr court split three ways, with a made-up 2.5 percent fee. Each share's fee rounds to 0.83 kr, so three shares give 2.49 kr. The same fee on the total is 2.50 kr.
Rounding

Whole numbers remove binary errors, not fractions

The example above is the part most people miss. Even if every amount is a whole number of öre, percentages still create fractions. You still have to round, and where you round changes the answer.

Round the fee on each share, and three shares of 0.8335 kr, 0.83325 kr and 0.83325 kr each become 0.83 kr. Together they make 2.49 kr. Round the fee on the total, and 2.5 percent of 100.00 kr is exactly 2.50 kr. Both methods are reasonable. Both look correct on their own screen. But a club's invoice can only show one of them, and if the app uses one method and the invoice uses the other, the month ends one öre apart for every split booking.

There is a second question in the same table. 100.00 kr does not divide into three equal whole öre. Someone must pay 33.34 kr. If the code leaves that to chance, the shares can add up to 99.99 kr or 100.01 kr, and the club is paid the wrong amount.

This is why the rounding rule has to be a decision, written down, and not something each screen does in its own way. When I built the invoice tool, I wrote the fee calculation down in two documents, one for the old rules and one for the new ones. That way anyone could take a month's numbers and check them against the rules that applied that month.

Currencies

One unit does not fit every currency

"Multiply by 100" works for kronor, euros and dollars, because they have two decimals. It does not work for every currency.

The Kuwaiti dinar has three decimals. One dinar is 1000 fils. The Japanese yen has no decimals at all. So a booking of 1.500 KWD is 1500 in the smallest unit, not 150, and a booking of 400 JPY is 400, not 40000.

Our clubs in Kuwait paid through KNET, using a different provider and flow from Stripe. I built the first version of that integration in the app: a payment hook that every payment screen could use, a check for whether the local provider was available for the club's currency, and a screen for choosing the payment method. Split payments for KNET came later, built on the same hook.

The rounding helper I wrote for the booking screens took the number of decimals as a setting, with two as the default. That default is fine for kronor. It is exactly the kind of assumption that breaks when a three-decimal currency arrives. The lesson I took from Kuwait is simple: the number of decimals belongs to the currency, kept in one table, and every conversion reads it from there.

Credits and promo codes

Every way to pay has to follow the same rule

Credits are money inside the app. A player buys a credit package, then spends credits on a booking, a game or a tournament. I built credit pay for bookings and activities, and later the screen for buying credit packages. Credits look like a separate world, but they meet real money on the invoice, because the club is paid for a booking whether the player used a card or credits. One of my fixes in this area was to the credit pay amount itself. Every way to pay has to follow the same rounding rule, or the totals drift apart.

Promo codes were the easiest place to make mistakes. A discount can be a fixed amount or a percentage. It can stack with a member discount. It can apply to a court, a game or a tournament. If the coupon maths lives in several screens, each screen can round in its own way. One reusable component means one calculation and one place to fix it.

Zero payments were a smaller trap. When a promo code or a membership brings the price to zero, there is nothing to charge. Sending a zero amount to a card provider fails. So the app had to recognise a zero total and confirm the booking without a card payment. I added that handling for bookings and for activities.

The invoice tool

From a CSV script to a tool the team could run

The billing work started small. The first version was a Node script. It read a file of payments, worked out the fees and printed a PDF for each club. I used it to produce the invoices for one month, and it worked, but it depended on me and on a file someone had to export.

So I turned it into a web tool. The tool lists the clubs, with a search. You open a club, pick a period and see its transactions. You can choose which user activities belong on the invoice, such as games, tournaments and training. Series games have their own tab. You can add extra charges by hand when the company has agreed one with a club. Then you download the invoice as a PDF, with a payment summary and every fee on its own line.

The data comes from the platform itself. I wrote backend endpoints for the tool: one to read the clubs' Stripe connected accounts and their transactions for a period, one to read user activities, one for series games across divisions, seasons and leagues, and one to save each club's fee settings and the last invoice number. That last one matters more than it looks. Invoice numbers have to follow each other without gaps or repeats, so the tool keeps the last invoice number in the backend instead of in a local file, as the second version of the script did.

Two smaller fixes made the numbers trustworthy. Amounts typed into the tool by hand were parsed as decimals, so I fixed a floating-point problem in that input. And the transaction list showed times in the right time zone, so a payment made just after midnight appeared on the right day and in the right month.

The tool also kept the amount and the currency apart on screen. Clubs did not all use the same currency. Showing "400" next to its currency, instead of baking a "kr" into the number, kept that clear.

The decisions

Four choices that shaped the money path

How should the booking screens round a price?

Option

Each screen rounds whenever a number looks long

Chosen

One shared helper for court prices and extra slots

Two screens that round in two ways will show two prices for the same booking.

Where should the promo code maths live?

Option

A copy of the calculation in every payment screen

Chosen

One reusable coupon component

The same code must give the same price everywhere, and a fix should land in one place.

Where should the invoice data come from?

Option

A payments file exported by hand each month

Chosen

Backend endpoints over the same transactions and activity records the app writes

Billing and payments then use one source of truth, and nobody has to remember to export a file.

How should club invoices be made?

Option

Spreadsheets, worked out by a person each month

Chosen

A tool that collects the month, calculates each fee and makes the PDF

The same input always gives the same invoice, so the result can be checked instead of rebuilt.

In code

The rule I follow now, as a short sketch

// Written for this page. Not client code. Positive amounts only.
const DECIMALS = { SEK: 2, EUR: 2, KWD: 3, JPY: 0 } as const;
type Currency = keyof typeof DECIMALS;

// "12.56" SEK -> 1256, without floating-point maths
function toMinor(amount: string, currency: Currency): number {
  const d = DECIMALS[currency];
  const [whole, frac = ""] = amount.split(".");
  return Number(whole) * 10 ** d + Number(frac.padEnd(d, "0").slice(0, d) || 0);
}

// A fee in basis points (250 = 2.5 percent), rounded once
const fee = (minor: number, bps: number) => Math.round((minor * bps) / 10_000);

// 10000 split 3 ways -> [3334, 3333, 3333]; the first payer takes the extra unit
function split(total: number, parts: number): number[] {
  const base = Math.floor(total / parts);
  const rest = total - base * parts;
  return Array.from({ length: parts }, (_, i) => base + (i < rest ? 1 : 0));
}
A fresh sketch of the rule: whole minor units as early as possible, decimals taken from the currency, one rounding step, and a split that always adds up to the total.
What changed

Billing became a process, not a monthly puzzle

One PDF per clubEach month's invoice is built from the platform's own transactions and activities, with every fee on its own line.
RepeatableThe same month's data gives the same invoice, so billing can be checked instead of worked out again by hand.
One rounding ruleCourt prices and extra slots go through one rounding helper, and promo codes through one coupon calculation.
What I learned

Decide the rounding before you write the code

Looking back, the app treated money as decimal kronor and rounded it at the edges: on the screen, and again when the amount went to the payment provider. My fixes made that rounding consistent. They did not change the model. Rounding a decimal to two places is a patch. It hides the drift, but every new screen has to remember to apply it.

The real fix is to keep money in whole minor units from the moment it enters the system, the same numbers the payment provider uses, and to turn it back into "400.00 kr" only for display. I now start every money feature that way. On a later court booking platform, I moved the Stripe fee calculation from floating-point numbers to whole-number maths.

The second lesson is about where to round. Most money bugs I have seen are not maths errors. They come from two parts of a system rounding in two different places. So I write the rounding rule down before the code exists: round per line or per total, and which payer takes the leftover unit in a split. Then the app, the payment provider and the invoice all follow the same rule.

The third lesson is that billing is a product, not a script. My first invoice script worked, but only I could run it, and it needed a file nobody else knew how to make. The version that lasted was the one with a screen, a search, real data from the backend and invoice numbers it could not get wrong. Nobody notices a correct cent. Everyone notices a wrong one.

Where this applies

The same rule works anywhere money is split or summed

None of this is special to padel. A hotel adds nightly rates, taxes and fees over a long stay. A restaurant splits a deposit between several guests. A travel booking adds seat, bag and card fees in more than one currency. A marketplace pays its sellers after taking a commission, and sends each one a statement at the end of the month.

In every case the rule is the same. Keep amounts in whole units of the currency. Know how many decimals each currency has. Round once, at a point you chose on purpose, and decide who takes the leftover unit. Then build the statement or invoice from the same records the payments came from, so the two can never tell different stories.

Do your totals and your payment provider disagree?

If your app, your payment provider and your invoices do not add up to the same number, let's talk. I can find where the rounding happens and help you move it to one place.

Let's talk