Blueprint · Finance

Overdue invoice follow-up

Every Monday, find overdue invoices and email capped reminders to clients.

Published by Croft · version 6 · invoicesaccounts-receivablefollow-upemail

What this needs

Requires an Email connection. Runs Mondays at 9am America/New_York.

Connections

  • Email — send (write)

Record streams

  • An unspecified record stream (read)

Not statically known

  • Line 54: the iterated value does not trace back to a recorded step result, so the branch count is unknown until run time

About this blueprint

Runs each Monday at 9:00 a.m. America/New_York. It checks invoice due dates, finds up to 100 overdue invoices, and emails clients with an address on file. Requires an email connection and does not modify invoice records.

How it works

overdue-invoice-follow-up
TRIGGER overdue-invoice-follow-up
Find overdue invoices
SPAN Find overdue invoices ×1
Send reminder emails
SPAN Send reminder emails ×3
done
Source — 1 file, show

index.ts

import { schedule, workflow } from "@croft/sdk";

export default workflow("overdue-invoice-follow-up", {
  trigger: schedule("0 9 * * MON", { timezone: "America/New_York" }),
  connections: ["email"],
  description: "Every Monday morning, finds overdue invoices and emails a follow-up reminder to each client with an email address. It does not change invoice records.",
}, async (ctx) => {
  const asOf = ctx.run.date;
  const asOfDate = new Date(asOf + "T00:00:00.000Z");
  const minimumDaysOverdue = 1;
  const maxResults = 100;
  const queryLimit = maxResults + 1;

  const result = await ctx.step("Find overdue invoices", async (ctx) => {
    return await ctx.records.query(
      `SELECT i.id, i.number, i.due_on, i.status,
              c.name AS client_name, c.email AS client_email
       FROM invoices AS i
       LEFT JOIN clients AS c ON c.id = i.client_id
       WHERE i.due_on IS NOT NULL
         AND CAST(i.due_on AS DATE) <= CAST('${asOf}' AS DATE) - ${minimumDaysOverdue}
         AND LOWER(COALESCE(i.status, '')) NOT IN ('paid', 'cancelled', 'canceled', 'void', 'draft')
         AND COALESCE(i._op, 'live') = 'live'
       ORDER BY i.due_on ASC
       LIMIT ${queryLimit}`,
    );
  });

  const hasMore = result.rows.length > maxResults;
  const columnIndex = Object.fromEntries(result.columns.map((column, index) => [column, index]));
  const reminders = result.rows.slice(0, maxResults).map((row) => {
    const invoiceId = String(row[columnIndex.id]);
    const invoiceNumber = String(row[columnIndex.number] ?? invoiceId);
    const clientName = String(row[columnIndex.client_name] ?? "there");
    const clientEmail = row[columnIndex.client_email] == null ? null : String(row[columnIndex.client_email]).trim();
    const dueOn = String(row[columnIndex.due_on]);
    const dueDate = new Date(dueOn + "T00:00:00.000Z");
    const daysOverdue = Math.max(0, Math.floor((asOfDate.getTime() - dueDate.getTime()) / 86400000));
    return {
      invoiceId,
      invoiceNumber,
      clientName,
      clientEmail,
      dueOn,
      daysOverdue,
      subject: `Follow-up: Invoice ${invoiceNumber} is overdue`,
      bodyMarkdown: `Hello ${clientName},\n\nOur records show that invoice ${invoiceNumber}, due ${dueOn}, is ${daysOverdue} day(s) overdue. If you have already sent payment, please disregard this note. Otherwise, please let us know if you need another copy of the invoice.\n\nThank you.`,
    };
  });

  const delivery = await ctx.step("Send reminder emails", async (ctx) => {
    const sent: string[] = [];
    const skipped: string[] = [];
    for (const reminder of reminders) {
      if (!reminder.clientEmail) {
        skipped.push(reminder.invoiceNumber);
        continue;
      }
      await ctx.email.send("email", {
        to: reminder.clientEmail,
        subject: reminder.subject,
        bodyMarkdown: reminder.bodyMarkdown,
      });
      sent.push(reminder.invoiceNumber);
    }
    return { sent, skipped };
  });

  return {
    asOf,
    minimumDaysOverdue,
    returnedCount: reminders.length,
    hasMore,
    emailsSent: delivery.sent.length,
    sentInvoices: delivery.sent,
    skippedMissingEmail: delivery.skipped,
    note: "Scheduled follow-up emails were sent to clients with an email address. No invoice records were changed.",
  };
});

Want this running in your own workspace?

Installing recompiles this exact version in your workspace, lands it paused, and walks you through granting whatever it needs above. No account yet? Installing creates one.