Invoicing software has one job that no amount of interface polish can rescue: the numbers have to be right, every time.
The bugs that hurt are never the crashes. They are the ones where the system is confidently wrong. A total off by one sen. An invoice issued twice. A payment that was received but never recorded.
Building SnapInvois, a WhatsApp-native invoicing platform, I stopped treating those as edge cases to test and started treating them as invariants to enforce. Four of them carry most of the weight.
1. Money is an integer, never a float
JavaScript numbers are binary floating point. 0.1 + 0.2 is not 0.3. That is
fine for graphics and deadly for invoices, because the error stays invisible
until it lands on a customer's document.
So money is stored and computed as an integer number of sen, and parsing is the only place a decimal string becomes a number.
// src/lib/money.ts
// Money is stored and computed as integer sen, never as a float.
export type Sen = number;
export function parseAmount(input: string): Sen {
// "12.30" -> 1230, with no binary floating point anywhere in between.
const match = /^(-?)(\d+)(?:\.(\d{1,2}))?$/.exec(input.trim());
if (!match) throw new Error(`Invalid amount: ${input}`);
const [, sign, whole, cents = '0'] = match;
const sen = Number(whole) * 100 + Number(cents.padEnd(2, '0'));
return sign === '-' ? -sen : sen;
}
The database column is a Decimal, the API speaks integer sen, and formatting
into RM 12.30 happens only at the edge, for display.
2. The balance invariant, enforced at the boundary
Representing money safely is not the same as using it correctly.
The stronger rule is that a document has to agree with itself: subtotal minus discount plus tax must equal what the buyer is asked to pay. That is not a test I run sometimes. It is a function called before anything is persisted or sent.
export function assertInvoiceBalances(invoice: {
subtotal: Sen;
discount: Sen;
taxTotal: Sen;
payable: Sen;
}): void {
const expected = invoice.subtotal - invoice.discount + invoice.taxTotal;
if (expected !== invoice.payable) {
// Refuse to persist or submit a document whose own arithmetic disagrees.
throw new Error(
`Invoice does not balance: expected ${expected} sen, payable ${invoice.payable} sen`,
);
}
}
A thrown error on a bad document is a feature. The alternative is a plausible-looking invoice that is quietly wrong, discovered weeks later by the person who has to explain it to a customer.
3. Webhooks are delivered more than once
Messaging and payment providers retry. Meta's WhatsApp Cloud API will re-deliver a webhook if your endpoint is slow or returns a non-200, and it makes no promise that delivery happens once. If your handler creates a record per delivery, a retry becomes a duplicate invoice.
The fix is to make the message id the unit of work. The first thing the handler does is claim the inbound message id. A unique constraint makes the second delivery fail on purpose, and that failure is treated as success.
// app/api/v1/whatsapp/webhook/route.ts
// Claim the inbound message id first. A replay hits the unique
// constraint, and a duplicate delivery becomes a no-op.
try {
await db.waMessage.create({ data: { waMessageId } });
} catch (error) {
if (isUniqueViolation(error)) {
return new Response('ok', { status: 200 }); // already processed
}
throw error;
}
// Only now do the work: parse -> draft -> PDF -> deliver.
The same idea applies one layer down, in the queue. Background jobs are retried too, so anything a job does has to be safe to run twice. That is far easier to guarantee when every step is keyed by a unique id rather than by "the thing that arrived last."
4. Verify the signature before you trust the body
An endpoint that accepts unsigned input is an open door. Anyone who finds the URL can invent messages.
// src/lib/whatsapp/signature.ts
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifySignature(rawBody: string, header: string, secret: string): boolean {
const expected = createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex');
const provided = header.replace(/^sha256=/, '');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(provided, 'hex');
// Compare in constant time, and never let a length mismatch throw.
return a.length === b.length && timingSafeEqual(a, b);
}
Two details matter. Verify against the raw request body, not a re-serialised
object, because JSON key order will betray you. And compare with
timingSafeEqual, because ordinary string comparison leaks how much of a guess
was correct.
What ties it together
Individually these are small. Together they change the failure mode: instead of silently producing wrong numbers, the system either produces the right ones or refuses to act.
| The assumption | The invariant | |
|---|---|---|
| Arithmetic | Floats are close enough | Integer sen, balanced before persist |
| Delivery | The webhook arrives once | Message id claimed once, replays are no-ops |
| Trust | The request came from the provider | Signature verified in constant time, on raw bytes |
| Retries | Jobs run exactly once | Every step is safe to run again |