Every invoicing system has a status column: draft, unpaid, partially paid, paid, past due. It looks harmless. It is also the column most likely to be wrong.
A client pays half. Someone has to remember to flip the status to "partially paid". The due date passes at midnight. Nobody is awake to flip it to "past due". A payment is refunded, and the status says "paid" next to a balance of zero, or "unpaid" next to a receipt. Each fix is a special case, and the special cases never stop.
When I built the money side of a studio management platform, I decided the status would not be stored at all. It is a function of the payments and the due date, computed whenever it is read. This post shows the whole function, the small helpers around it, and the trade-offs.
The platform itself is in the Visual Studios Lens case study. Here is only the invoice logic.
The idea: store facts, derive conclusions
An invoice has facts: line items, a discount, a tax rate and a due date. Payments have facts too: an amount and a state. "Paid in full" is not a fact. It is a conclusion you can reach from those facts at any moment.
Facts are stored once. Conclusions are recomputed, so they cannot disagree with the facts. That is the entire design.
The one exception is a pair of states a person chooses on purpose: Draft and Canceled. Nothing about payments makes an invoice a draft, so those are stored, and the function respects them.
Money is integers, not decimals
Before any status logic, fix the number type. Floating point and money do not mix: 0.1 + 0.2 is 0.30000000000000004, and a "paid" check that compares a total to a sum of payments will fail on a cent that is not there.
I round every computed amount to cents with one helper, and I use it at every step:
export const cents = (n: number): number => Math.round(Number(n) * 100) / 100;
Then the total is built from small pure functions:
export const subtotalOf = (inv: InvoiceMoneyFields): number =>
cents(
(inv.lineItems ?? []).reduce(
(sum, l) => sum + Number(l.quantity) * Number(l.unitPrice),
0,
),
);
export const totalOf = (inv: InvoiceMoneyFields): number => {
const afterDiscount = Math.max(0, cents(subtotalOf(inv) - Number(inv.discount ?? 0)));
return cents(afterDiscount + afterDiscount * (Number(inv.taxRate ?? 0) / 100));
};
Notice the Math.max(0, ...): a discount larger than the subtotal never produces a negative invoice. And the Number(...) calls cope with values that arrive from a JSON column or a database driver as strings.
What counts as paid
Now the payments side. This is where a business rule hides, and it is worth writing down:
// Refunded payments still count toward "paid": an invoice that was fully paid and later
// refunded keeps its Paid status rather than reverting to Unpaid or Past Due against a due
// date that is now meaningless.
export const paidOf = (invoiceId: string, payments: StudioPayment[]): number =>
cents(
payments
.filter(
(p) =>
p.invoiceId === invoiceId &&
(p.status === StudioPaymentStatus.PAID ||
p.status === StudioPaymentStatus.REFUNDED),
)
.reduce((sum, p) => sum + Number(p.amount), 0),
);
This is a deliberate choice, not an accident. If a client pays in full and you refund them a month later, should the invoice go back to "past due"? Almost never: the work was billed and paid, and the refund is a separate event. So refunded payments still count as received for status purposes. The refund itself is tracked as its own payment state, and money owed back is a separate figure:
export const balanceOf = (inv, payments): number =>
Math.max(0, cents(totalOf(inv) - paidOf(inv.id, payments)));
/** Anything received beyond the total: money that is owed back to the client. */
export const overpaidOf = (inv, payments): number =>
Math.max(0, cents(paidOf(inv.id, payments) - totalOf(inv)));
Your own rule might be different. The point is that it lives in one function with a comment, instead of being scattered across update handlers.
The status function
With the helpers in place, the status is a short, boring function. Boring is the goal.
export const deriveInvoiceStatus = (
inv: StudioInvoice,
payments: StudioPayment[],
): StudioInvoiceStatus => {
// Draft and Canceled are set by hand; everything else follows the money and the due date.
if (inv.status === StudioInvoiceStatus.DRAFT || inv.status === StudioInvoiceStatus.CANCELED) {
return inv.status;
}
const total = totalOf(inv);
const paid = paidOf(inv.id, payments);
if (total > 0 && paid >= total) return StudioInvoiceStatus.PAID;
if (paid > 0) {
return inv.dueDate && isPast(inv.dueDate)
? StudioInvoiceStatus.PAST_DUE
: StudioInvoiceStatus.PARTIALLY_PAID;
}
return inv.dueDate && isPast(inv.dueDate)
? StudioInvoiceStatus.PAST_DUE
: StudioInvoiceStatus.UNPAID;
};
Read it top to bottom and you can state the business rules out loud:
| Situation | Status |
|---|---|
| Marked draft or canceled by a person | Draft or Canceled |
| Total is more than zero and payments cover it | Paid |
| Something paid, due date passed | Past due |
| Something paid, not yet due | Partially paid |
| Nothing paid, due date passed | Past due |
| Nothing paid, not yet due | Unpaid |
The total > 0 guard matters. Without it, a zero-dollar invoice counts as "paid" the moment it exists.
There is no cron job. "Past due" appears on its own once the due date has passed, because the status is computed from isPast(dueDate) at read time. The same invoice is "unpaid" on its due date and "past due" the next morning, with no write to the database.
isPast itself is worth a look, because dates are where invoices go wrong:
export const isPast = (iso?: string): boolean => {
if (!iso) return false;
return iso.slice(0, 10) < todayISO();
};
It compares calendar days as YYYY-MM-DD strings, and todayISO() uses the server's local date. That avoids two classic bugs: using toISOString(), which reports the UTC date and is already "tomorrow" in the evening in New York, and turning an invoice "past due" at lunchtime on its own due date.
Where to run it
I compute the status when invoices are listed or fetched, then use the result for the API response, the dashboard totals and the client's own invoice page. The dashboard uses the same helpers to sum balances and flag what is overdue:
const status = deriveInvoiceStatus(inv, payments);
if (status === StudioInvoiceStatus.PAST_DUE) pastDue += balanceOf(inv, payments);
Because everything goes through the same functions, the invoice list, the dashboard and the client page cannot disagree.
The trade-offs, honestly
Deriving is not free:
- You cannot query by status in SQL. "Show me all past-due invoices" needs the payments and the due date. For a small studio, loading the invoices and their payments and filtering in memory is fine. At large scale you would add a view or a cached column, and then you are back to keeping something in sync, so do it only when measurements say you must.
- Two copies of the logic. My frontend also computes status for instant UI updates. The two copies must match exactly. I keep the helpers small and pure, and mark each one with a comment pointing at its twin. A shared package would be better if both sides were in one repository.
- The rule must be tested. Because it is pure functions with no database, it is easy to test. A table of cases (zero total, exact payment, overpayment, refund, due date edge, discount larger than subtotal) catches regressions in a few milliseconds.
What to take from this
- Store facts. Derive conclusions. A status you compute cannot drift.
- Keep a manual state (draft, canceled) only for things no data can tell you.
- Round money in one helper and use it everywhere. Never compare raw floats.
- Write the awkward business rules, such as refunds, once, in one commented function.
- Pure functions on plain data are the cheapest code in the world to test. Use that.
A column that can be wrong will eventually be wrong. A function can only be wrong in one place, and that place has tests.
Working on something like this?
I take on backend, payments and deployment projects. I reply within one working day.