← Blog
·11 min read

Automating Invoice Chasing: Reminders, Late Fees, Cron

What it took to build the collections half of Receivix, an invoicing dashboard for marketing agencies: reminder timing across timezones, late-fee math, and scheduled jobs that are safe to run twice.


Key takeaways: Store each reminder as a database row with a unique key and the schedule doubles as the history. Work out every send time when the invoice is sent, and skip the ones already in the past. Recompute a late fee from the invoice total each day instead of adding to yesterday's. Assume every scheduled job will run twice, and hold emails until the transaction commits.

What We Were Building

Receivix is an invoicing and collections dashboard for small marketing agencies. It's client work and it's still in development; the case study has the product story and the screenshots. This post is about the half of it the client's brief calls Never Chase, the part that notices an invoice is late, sends the reminders, adds the late fee, and stops when the money arrives.

None of that is hard to describe. Send an email three days before the due date, another on the day, a few more after. Add 1.5% a month once it's overdue. But a collections system is mostly edge cases, and the edge cases are what shaped the code, so that's what this post walks through.

Two Kinds of Money on One Invoice

A detour first, because it affects everything downstream. An agency that runs paid media sends invoices with two kinds of money on them. There is the management fee, which is the agency's revenue, and there is the ad spend, which is the client's budget passing through to Google or Meta. Not every client has ad spend, but where it exists it is usually the bigger number by a wide margin. In the demo data we test with, one monthly invoice comes to $42,640, and only $5,240 of that is the agency's fee.

So every invoice line has a type: management fee, ad spend, or other. Subtotals are stored per type. Tax applies to fee and other lines, and to ad spend only if a client is flagged that way, which is off by default. The fee itself comes from one of three pricing models:

export function calcManagementFee(model: PricingModel, adSpendCents: number): number {
  if (adSpendCents < 0) throw new Error("Ad spend cannot be negative");
  switch (model.type) {
    case "PERCENT":
      return roundCents(adSpendCents * model.feePct);
    case "FLAT":
      return model.retainerCents;
    case "HYBRID":
      return model.baseFeeCents + roundCents(adSpendCents * model.feePct);
  }
}

Everything is integer cents, and rates are fractions (0.15, not 15). That rule is old and we didn't invent it. It's still worth saying out loud, because the alternative is a float that ends up a cent off on the one invoice a client decides to check by hand.

Each invoice also keeps a snapshot of the pricing model and late-fee policy it was created under. If the agency renegotiates a client from 15% to 12%, the old invoices keep saying 15%. The snapshots are validated JSON on the invoice row, and nothing on an invoice points back at the client record for its terms.

A Reminder Is a Row

The first real design decision in collections was how to represent "send this email in five days." A delayed job on a queue is the tempting answer. We used a table.

When an invoice is sent, the system writes one row per reminder step into a reminder log, each with the UTC instant it should go out and a status of pending. There's a unique key on invoice plus step. An hourly job picks up the pending rows whose time has come.

A few useful things fall out of that. The schedule and the history are the same table, so "what will this client receive and when" and "what have they already received" are one query with a different status filter. Cancelling is an update: when an invoice is paid or voided, its pending rows flip to cancelled with a reason attached. And because of the unique key, scheduling the same invoice twice can't produce two copies of a reminder, whatever else has gone wrong.

The default sequence has six steps. One goes out three days before the due date and one on the day, then there are four more at 3, 7, 14 and 30 days after. The tone moves from friendly to firm, and the thirty-day one is a final notice that waits for the agency owner's approval before it goes anywhere.

The Invoice That Was Sent Late

Here's the first edge case that would have embarrassed someone. An agency creates an invoice dated the 1st and due the 15th, forgets about it, and hits send on the 20th. A naive scheduler looks at the six steps, sees that three of them are already in the past, and sends all three within the hour. The client gets "heads up, due soon" and "due today" and a past-due nudge, back to back, for an invoice they first saw that morning.

So send times are computed at the moment of sending, and anything already in the past is marked skipped on the spot:

export function computeReminderSendTimes(
  steps: readonly { id: string; offsetDays: number }[],
  o: ScheduleOptions & { sentAt: Date },
): ScheduledReminder[] {
  return steps.map((s) => {
    const scheduledFor = reminderSendTime(o.dueDate, s.offsetDays, o);
    return {
      stepId: s.id,
      offsetDays: s.offsetDays,
      scheduledFor,
      status: scheduledFor.getTime() < o.sentAt.getTime() ? "SKIPPED" : "PENDING",
    };
  });
}

The same problem has a second form. If the job itself is down for a few days, several steps for one invoice can come due together. That case is handled in the job. When it picks up a reminder and finds a later one for the same invoice that is also due, it marks the earlier one as superseded, and only the latest goes out.

Nine O'Clock Where?

Reminders go out at a set hour, 9am by default. An agency in New York and one in Sydney both expect that to mean their own 9am, and a due date of the 15th has to mean the 15th where the agency is.

We split time into two kinds and don't let them mix. Calendar dates like the issue date and due date are stored as plain date strings with no time or zone attached. Instants, like when an email was actually sent, are timestamps. And "today" is never the server's today. It is always computed from the agency's timezone. A reminder's send time is built by taking the due date, adding the step's offset in days, and converting 9am on that date in that zone to UTC. Daylight saving is handled by the conversion, so nobody has to think about it per row.

The overdue job follows the same rule. An invoice becomes overdue when its due date is before today in the agency's zone, so nobody in Los Angeles gets marked late on the afternoon of the due date because a server somewhere else has rolled over to tomorrow.

Jobs That Can Run Twice

Schedulers retry. Deploys overlap. Someone triggers a job by hand while the scheduled run is still going. We wrote every job on the assumption that it will be run twice in a row, and the integration tests include second runs that have to send nothing and change nothing.

For reminders, each one is handled in its own transaction, and the first thing that transaction does is lock the row:

const [log] = await tx
  .select()
  .from(reminderLogs)
  .where(
    and(
      eq(reminderLogs.id, logId),
      eq(reminderLogs.agencyId, ctx.agencyId),
      eq(reminderLogs.status, "PENDING"),
    ),
  )
  .for("update", { skipLocked: true });
if (!log || log.scheduledFor.getTime() > now.getTime()) return { kind: "locked" };

With SKIP LOCKED, a second worker looking at the same row doesn't wait for the first one. It gets nothing back and moves on. The status condition covers the other case, where the first run has already finished. The row isn't pending any more, so there is nothing to select.

One row failing doesn't stop the batch either. The job collects failures, carries on with the rest, and throws once at the end so that the run is recorded as failed and somebody looks at it.

The other jobs get there by different routes. Marking overdue only moves invoices that aren't overdue yet. The recurring-invoice job advances the schedule's next run date in the same transaction that creates the invoice, so the two can't come apart.

Emails Wait for the Commit

This one is easy to miss. Recording a payment updates the balance, changes the status, cancels pending reminders, and sends a receipt. If the receipt goes out from inside the transaction and the transaction then fails, the client is holding a receipt for a payment the database has no record of.

So services never send email directly. They run inside a small helper that hands them a queue for side effects:

export async function runTx<T>(fn: (tx: Tx, fx: Effects) => Promise<T>): Promise<T> {
  const fx = new EffectQueue();
  const result = await db.transaction((tx) => fn(tx, fx));
  await fx.flush();
  return result;
}

Inside the transaction, code calls fx.defer with the function that sends the email, and nothing deferred runs unless the commit succeeds. It works in the other direction as well. If an email fails to send, that gets logged and the payment stays recorded.

Late Fees: Recompute, Don't Add

Late fees come in three policies: a fixed amount, a percentage per month, or a percentage per day. Each can have grace days and a cap.

const effectiveDays = Math.max(0, daysOverdue - Math.max(0, opts.graceDays ?? 0));
if (effectiveDays <= 0 || invoiceAmountCents <= 0) return 0;
let fee: number;
switch (policy.type) {
  case "FIXED":
    fee = policy.fixedFeeCents;
    break;
  case "MONTHLY_PCT":
    fee = roundCents(invoiceAmountCents * policy.monthlyRate * monthsOverdue(effectiveDays));
    break;
  case "DAILY_PCT":
    fee = roundCents(invoiceAmountCents * policy.dailyRate * effectiveDays);
    break;
}
if (opts.capCents != null) fee = Math.min(fee, opts.capCents);

Two decisions in there are easy to get wrong. The daily job does not add today's fee to yesterday's. It recalculates the whole fee from the original invoice total and the number of days overdue, and overwrites what was there. That makes the job safe to run any number of times, and it means a fee can never compound on itself by accident, because the basis is always the invoice total before late fees.

The other decision is what a month means. The rule we settled on is that every started 30-day period counts, so one day overdue is one month's fee and thirty-one days is two. These are the cases from the unit tests, all on a $5,000 invoice:

PolicyDays overdueGraceFee
$25 fixed35 days$0
$25 fixed65 days$25
1.5% per month1none$75
1.5% per month31none$150
0.05% per day10none$25
0.05% per day105 days$12.50
1% per day, capped at $5090none$50

By default a late fee doesn't simply appear on a client's invoice. The job works out the amount and raises an approval request, and the first fee on each invoice is applied only when the agency owner approves it. After that it keeps updating by the policy. An agency that wants it fully automatic can switch the approval off.

Status Is a Small State Machine

An invoice is a draft, scheduled, sent, partially paid, overdue, paid, or void. Which moves are legal is written down once, as a pure function that takes the current status and an event and either returns the next status or throws.

A few of the rules are worth calling out. A partial payment on an overdue invoice leaves it overdue, because it is still late. Paid and void are terminal. Sending an invoice that's already open counts as a resend and changes nothing. In the service layer there is one function allowed to write the status column, and it runs the state machine and writes an activity log entry in the same transaction. When a status looks wrong, there's a single place to look and a log line that says how it got there.

Forecasting When the Cash Lands

The dashboard shows expected cash over the next 30, 60 and 90 days. The obvious way to build that is to sum open invoices by due date, and that number is wrong for any agency with clients who pay late, which is most agencies.

The forecast moves each open invoice to the date that client is likely to pay instead. That's the due date plus the client's own average days late, taken from their payment history, and never earlier than today. Upcoming recurring invoices are projected forward the same way. It is a simple model, an average and a shift. We kept it that simple on purpose so that an agency owner can look at a number and work out why it is what it is.

What Isn't Built

Receivix doesn't take card payments yet. Payments are recorded by hand when the bank transfer shows up. Reminders are email only. The QuickBooks integration is an interface and a sync queue with a stub on the other end, and Xero and FreeAgent are named in the schema but nothing more. Tenant isolation is enforced in the application layer, with every query filtered by agency id and tests around it, and it isn't yet backed by row-level security in Postgres.

The client's brief parked most of these on purpose so that the first version could be about the core loop of invoice, remind, collect.

If You're Building Something That Bills People

Most of this carries over to anything with invoices, subscriptions, or dunning in it. The other post about this project covers the nine decisions that had to be made before any of this was written, and the case study shows the product itself. If you're scoping something similar, tell us about it.