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:
| Line | Exact | Rounded down | After the leftover |
|---|---|---|---|
| Pair 1 | Rp 96.666,67 | Rp 96.666 | Rp 96.667 |
| Pair 2 | Rp 96.666,67 | Rp 96.666 | Rp 96.666 |
| Total | Rp 193.333,33 | Rp 193.332 | Rp 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.
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.

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.
Waiting until every line has a price rules out a failure that costs real money. Without the wait, this could happen:
- Two pairs on one order: a deep clean at Rp 150.000 and a repair the cashier estimated at Rp 200.000.
- The Rp 350.000 total clears a "20% off, minimum Rp 250.000" campaign, and its voucher code is claimed.
- The workshop prices the repair at Rp 80.000.
- The order is now Rp 230.000, and the campaign should never have applied, but the code is already spent.
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.
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.