Fresclean POS · Chapter 3 of 8

Boring money

Jovanus Hartono2026

On this page

Every order at the counter ends in money. The cashier adds up the treatments, takes off a promo or a voucher, collects the payment, and sometimes gives some of it back. Every amount is whole rupiah, since nothing at our counter costs a fraction of one. I kept this part boring on purpose. In the database each amount is a numeric(12,0) column, up to twelve digits with none after the decimal point, and in the Drizzle schema that's one line per column:

price: decimal("price", { precision: 12, scale: 0 }).default("0").notNull(),

An order is either paid or unpaid, and a deposit is handled as two separate orders. Nothing leaves the shop until the order is paid in full, even one pair out of five.

Boring doesn't mean simple, though. Discounts, vouchers and a treatment with no price at all saw to that.

Refunds that add up

Say a customer drops off three pairs at Rp 100.000 each, with Rp 10.000 off the whole order, and asks for two of them to be refunded. How much do they get back?

Each pair gets back its price minus its share of the discount. Split three ways, that share is Rp 3.333,33, and there's no such thing as a third of a rupiah. If each refund simply rounds down, the two of them come to a rupiah less than they should:

LineExactRounded downAfter the leftover
Pair 1Rp 96.666,67Rp 96.666Rp 96.667
Pair 2Rp 96.666,67Rp 96.666Rp 96.666
TotalRp 193.333,33Rp 193.332Rp 193.333

So each refund is rounded down, and the rupiah left over are handed out one at a time to the lines that lost the most in rounding. Here the exact total rounds to Rp 193.333, the rounded-down lines only reach Rp 193.332, and the one spare rupiah goes to the line with the biggest remainder. The two remainders tie, so it goes to the lower line id. However the numbers fall, the refund adds up to the rounded total. It's the largest remainder method, the same one some countries use to hand out parliament seats.

A refund can also go wrong by happening twice. If two people pressed refund on the same order at nearly the same moment, both could go through. So the refund first locks the order in the database with SELECT … FOR UPDATE, and a second refund on the same order waits until the first has finished.

Caps and codes

The POS calls a promo a campaign: a rule the cashier picks from a list, like "20% off, minimum Rp 250.000". I wanted two more things from it. One was a cap, like "only the first 100 orders". The other was a voucher, a code a specific person can keep and use on a future visit, so one campaign can hand out fifty codes to fifty VIPs.

A voucher didn't get its own table. It needs everything a campaign already has, from the minimum order to the rules for combining promos, so it became a second kind of campaign, one that owns a batch of single-use codes.

The codes work like cash: whoever holds one can use it. In technical terms they're A code that works for whoever holds it, like cash, with no name attached.. I thought about tying each code to a customer, but the POS only works out who the customer is at checkout, so there'd be nothing to tie it to while the cashier is still building the order. I wrote the cost down plainly: "a leaked code is spendable by anyone."

A code is only useful if people can read it correctly. Each one is 8 characters from a Crockford-style alphabet that leaves out characters that are easy to confuse, like 0 and O, or 1 and I. That leaves 30 characters. They also have to be impossible to predict, so the server picks them with a cryptographic random generator, crypto.getRandomValues, using Throwing away random values that would make some results more likely than others, and drawing again.:

const CROCKFORD_CHARSET = "23456789ABCDEFGHJKMNPQRSTVWXYZ"; // 30 chars
const CODE_LENGTH = 8;
const CROCKFORD_SAMPLE_FLOOR = 240; // floor(256 / 30) * 30

export function generateCrockfordCode(): string {
  const result: string[] = [];
  while (result.length < CODE_LENGTH) {
    const bytes = crypto.getRandomValues(new Uint8Array(CODE_LENGTH * 2));
    for (const b of bytes) {
      if (b >= CROCKFORD_SAMPLE_FLOOR) {
        continue; // reject to eliminate modulo bias
      }
      result.push(CROCKFORD_CHARSET[b % 30]);
      if (result.length === CODE_LENGTH) {
        break;
      }
    }
  }
  return result.join("");
}

Rejection sampling keeps every character equally likely. A random byte is one of 256 values, and 256 doesn't divide evenly by 30: it's 8 × 30 with 16 left over. Turning every byte into a character with % 30 would give the first 16 characters one extra chance each. Throwing away bytes from 240 up leaves exactly 240 values, 8 for every character.

Every byte, % 302 to H: 9 values eachJ to Z: 8 each23456789ABCDEFGHJKMNPQRSTVWXYZBytes 240 to 255 thrown awayevery character: 8 values each23456789ABCDEFGHJKMNPQRSTVWXYZ
How many of the 256 possible byte values land on each character. With a plain % 30, the 16 values left over after 8 × 30 each give one character an extra chance. Throwing them away leaves 8 for every character.

Caps and codes share one weak spot. Two cashiers could each try to use the 100th slot, or the same voucher code, at the same moment, and without a guard both would get it. So claiming one is a single database update that only succeeds while the slot or the code is still free, and reports back which rows it changed:

const rows = await tx
  .update(campaignCodesTable)
  .set({ redeemed_at: new Date(), redeemed_order_id: orderId })
  .where(
    and(
      eq(campaignCodesTable.code, code),
      isNull(campaignCodesTable.redeemed_at)
    )
  )
  .returning({ id: campaignCodesTable.id });

If another cashier redeemed the code a moment earlier, redeemed_at is no longer empty, the update matches nothing, and rows comes back empty.

A used code isn't given back when the order is refunded, only when the whole order is cancelled or its promo is voided. Stock works the same way: a cancelled line puts a product back on the shelf, and a refunded one doesn't.

The price nobody knows yet

Repairs didn't fit any of this. A repair has no list price, because what it costs depends on how much of the shoe has to be replaced, and nobody knows that until someone has looked inside. The shoe still has to be checked in at the counter, tagged and photographed, with no price to put on the order.

My first design asked the cashier to type a guess at drop-off, an "estimate", and I changed my mind about it twice. The first time was because it made them invent a number for a job whose whole point is that nobody knows the number yet.

So a price is now either known or blank. A blank price isn't zero. Zero already means free on purpose, which is what a rework after a complaint costs. Blank means we don't know yet, and in the database it's NULL. And an order with a blank line can't be paid: "no price, no payment". The server's payment step and the POS's payment buttons run the same check:

export function isUnpricedLine(line: UnpricedLineState): boolean {
  return line.status !== "cancelled" && line.price === null;
}

A cancelled line doesn't count, because nobody owes its price anymore. Once the workshop has looked at the shoe and agreed a price with the customer, anyone on staff can fill it in, and every price change is logged with who made it.

An unpaid order with one pair queued for a leather replacement repair. The repair line is marked Unpriced and shows a dash where its price would be.
A repair still waiting for its price, with made-up data. It shows a dash instead of Rp 0, because zero would read as free, and the order can't be paid until it's filled in.

When the discount settles

The second change came from a driver. Customers sometimes send a driver to drop off their shoes. The driver has no money, the customer will pay at pickup, and the customer expects the promo they were told about.

In the estimate version, discounts were only worked out at payment, so the driver would have taken home a receipt showing the full price. A receipt with the wrong total starts an argument at pickup, and I wrote down who wins it: "the paper wins every argument." So a discount now settles at drop-off, meaning it's worked out and fixed on the order, whenever every line already has a price, which is most orders. The receipt shows the discounted total, and payment collects exactly that. An order with a blank line gets its promo at the payment desk instead, in the same step as the money.

every line priced at drop-offDrop-offpromo claimedReceiptdiscounted totalPaymentthe printed totalDrops below a promo's minimum before payment: every promo is voided.a repair with no price yetDrop-offno promo yetInspectionprice agreedPaymentpromo claimed
A discount settles at drop-off when every line already has a price, which is most orders, so the receipt shows what the customer will pay. A blank line moves the promo to the payment desk.

Waiting until every line has a price rules out a failure that costs real money. Without the wait, this could happen:

  1. Two pairs on one order: a deep clean at Rp 150.000 and a repair the cashier estimated at Rp 200.000.
  2. The Rp 350.000 total clears a "20% off, minimum Rp 250.000" campaign, and its voucher code is claimed.
  3. The workshop prices the repair at Rp 80.000.
  4. The order is now Rp 230.000, and the campaign should never have applied, but the code is already spent.
minimum Rp 250.000Drop-off, repair guessed at Rp 200.000deep cleanrepair, guessedRp 350.000 clears the minimum, code claimedRepair priced at Rp 80.000deep cleanrepairRp 230.000 is under the minimum, but the code is spent
The same order before and after the workshop prices the repair. The code was claimed against the guessed total, and the real total falls Rp 20.000 short of the campaign's minimum.

With blank prices, no promo can be attached until the repair is priced, so this order can't exist.

Prices on an unpaid order can still go down after drop-off. A pair gets cancelled, or a mistyped price is corrected, and the order can fall below a promo's minimum, leaving the customer with a discount they no longer qualify for. When discounts were only worked out at payment, this couldn't happen, because the prices were fixed in the same step. So a promo that settled at drop-off now has to keep checking its own minimum. If the order drops below any promo's minimum, every promo on the order is voided, and their codes and slots are given back. The cashier re-applies what still fits, which rechecks every rule on the way in. Keeping the other promos and recalculating around the one that dropped out, as I put it in a code comment, "is a money bug waiting to happen". An order that's already paid is never touched, because that money was taken against the printed total.

Drop-off: Rp 350.000min Rp 250.000Rp 150.000Rp 100.000Rp 100.000Rp 100.000 off, min Rp 250.000voucher, 10% offThe Rp 150.000 pair is cancelled: Rp 200.000Rp 100.000Rp 100.000Rp 100.000 off, min Rp 250.000voucher, 10% offBoth are voided, and the slot and the code go backThe cashier re-applies what fitsRp 100.000Rp 100.000Rp 100.000 off, min Rp 250.000voucher, 10% off
An unpaid order that falls below one promo's minimum loses every promo on it, even the voucher that would still fit. The cashier re-applies them, and only the ones that pass every rule come back.

Stacking

An order can qualify for more than one promo, and the cashier can also type in a discount by hand. Two things must never happen: the same rupiah discounted twice, and an order that comes to less than zero.

Say an order comes to Rp 300.000 and its campaigns already take Rp 60.000 off. A cashier who types a Rp 500.000 manual discount gets Rp 240.000, which is everything the campaigns left, and the order comes to zero.

The biggest promo is applied first, and each one after it applies to what's left. A manual discount only takes whatever the campaigns have left, and one typed too big makes the order free. Both of the manual discount's rules fit in one line:

export const applyManualDiscount = (
  grossTotal: number,
  campaignDiscount: number,
  manual: number
): number => Math.min(manual, Math.max(0, grossTotal - campaignDiscount));

The cashier sees this discount on screen before checkout, and the server works it out again when it charges. Both have to land on the same number, or the customer is quoted one total and charged another. The stacking and campaign rules were already one shared piece of code that both sides import. The manual discount cap and the amount due weren't. They were written out by hand in four places, and any one copy could drift from the others. Now they live in the server package with the rest, and the POS imports the same functions, so the Rp 240.000 the cashier sees in the example above is exactly what the server takes off.

Before: four hand-written copiesPOS, the cashier's screenServer, the chargemanual discount capamount duemanual discount capamount dueeach pair has to be kept the same by handAfter: one shared copyshared discount rulesapplyManualDiscountorderNetDueboth importPOS, the cashier's screenServer, the chargeRp 240.000 off on screen is Rp 240.000 off the charge
The manual discount cap and the amount due were each written twice, once for the cashier's screen and once for the server. Now both sides import the same two functions, so the screen and the charge can't disagree.