Build a SaaS for Automated Invoice Follow-Ups

Build an invoice follow-up SaaS: detect overdue invoices, send polite escalation emails, and track payment recovery with Stripe + Resend.

Build the “money recovery” SaaS: connect Stripe or import invoices, auto-send escalating follow-ups, and report exactly which payments were recovered. Every helper referenced below is implemented below.

What we’re building

flowchart LR
    Stripe[Stripe invoice events] --> Sync[Daily sync relies on webhooks]
    Webhook[Stripe webhook] --> PG[(PostgreSQL)]
    PG --> Detector[Escalation job]
    Detector --> Mail[Resend emails]
    Mail --> Reply[Customer pays via Stripe hosted page]
    Reply --> Webhook

Invoice states live in Postgres; a scheduled job promotes each invoice through a defined escalation ladder; Stripe’s invoice.paid webhook marks recovery. The reporting query turns it into “recovered $X by sending N emails”.

What you’ll learn

  • Event-driven escalation logic that doesn’t annoy customers (a state machine, not a blast)
  • Stripe webhooks for the payment lifecycle
  • Honest reporting: recovered revenue vs emails sent

Prerequisites

  • Node.js 22+
  • PostgreSQL 17
  • Stripe account (test mode to start)
  • Resend account
  • A VPS with 1–2 GB RAM

1. Scaffold

npx create-next-app@latest invoicedun --ts --app
cd invoicedun
npm i stripe pg resend zod

resend sends the emails, stripe verifies webhooks, pg owns the states, zod guards the input. (A nodemailer dependency from earlier versions is gone — one provider SDK is enough.)

2. Schema

schema.sql:

CREATE TABLE invoices (
  id             UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  stripe_inv_id  TEXT UNIQUE,            -- Stripe invoice id, when sourced from Stripe
  customer_email TEXT NOT NULL,
  amount_cents   INTEGER NOT NULL,
  due_date       DATE NOT NULL,
  status         TEXT NOT NULL DEFAULT 'open',  -- open | reminder_1 | reminder_2 | final | manual | paid
  last_reminded  TIMESTAMPTZ,
  recovered_at   TIMESTAMPTZ
);

CREATE TABLE send_log (
  id         BIGSERIAL PRIMARY KEY,
  invoice_id UUID REFERENCES invoices(id),
  stage      TEXT NOT NULL,              -- reminder_1 | reminder_2 | final
  sent_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

Apply: psql "$DATABASE_URL" -f schema.sql.

Status is the whole product. Each escalation is a guarded state transition with a business rule, not a cron blast.

3. Environment variables

.env (project root; not committed):

DATABASE_URL=postgres://postgres:dev@localhost:5432/invoicedun
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
RESEND_API_KEY=re_...
APP_URL=http://localhost:3000
EMAIL_FROM="Billing <[email protected]>"
ESCALATION_START_DAYS=3
ESCALATION_STEP_DAYS=7
ESCALATION_FINAL_DAYS=14
Variable Required Description
DATABASE_URL yes PostgreSQL connection string.
STRIPE_SECRET_KEY yes Stripe dashboard → API keys (test mode first).
STRIPE_WEBHOOK_SECRET yes whsec_... from stripe listen.
RESEND_API_KEY yes resend.com → API keys.
APP_URL yes Base URL for payment links.
EMAIL_FROM yes Verified sender on your domain.
ESCALATION_*_DAYS no Cadence knobs: start after 3 days overdue, remind every 7, final at 14. Change these instead of editing rules.

4. The escalation rules

lib/escalation.ts — pure functions, parameterized by the env knobs:

export interface Invoice {
  id: string;
  status: string;
  due_date: string; // YYYY-MM-DD
  last_sent_at: string | null;
}

const startDays = Number(process.env.ESCALATION_START_DAYS ?? 3);
const stepDays = Number(process.env.ESCALATION_STEP_DAYS ?? 7);
const finalDays = Number(process.env.ESCALATION_FINAL_DAYS ?? 14);

function daysOverdue(due: string, now = new Date()): number {
  const dueDate = new Date(due + 'T00:00:00Z');
  return Math.floor((now.getTime() - dueDate.getTime()) / 86_400_000);
}

// Returns the stage to send right now, or null if nothing is due.
// The last_sent_at guard makes the logic idempotent: a re-run cannot re-send.
export function nextStage(inv: Invoice, now = new Date()): string | null {
  if (inv.status === 'paid') return null;
  const days = daysOverdue(inv.due_date, now);
  if (inv.status === 'open' && days >= startDays) return 'reminder_1';
  if (inv.status === 'reminder_1' && days >= startDays + stepDays) return 'reminder_2';
  if (inv.status === 'reminder_2' && days >= startDays + 2 * stepDays) return 'final';
  if (inv.status === 'final' && days >= startDays + 2 * stepDays + finalDays) return 'manual';
  return null;
}

export function requireSend(inv: Invoice, stage: string): boolean {
  const last = inv.last_sent_at ? new Date(inv.last_sent_at).getTime() : 0;
  // Do not re-send the same stage within 6 hours, even if the scheduler misfires.
  return Date.now() - last > 6 * 3600_000;
}

Every rule is a pure function of (status, due_date, time) — unit-testable, auditable, and the cadence is customer-configurable without touching code.

5. The email templates

lib/emails.ts — same payment link every time, so the customer’s quick-pay path is preserved:

import type { Invoice } from './escalation';

function paymentUrl(inv: Invoice): string {
  return `${process.env.APP_URL}/pay?invoice=${inv.id}`; // page that forwards to Stripe hosted invoice
}

export const templates: Record<string, (inv: Invoice) => { subject: string; html: string }> = {
  reminder_1: (inv) => ({
    subject: `Gentle reminder: invoice ${inv.id.slice(0, 8)} is due`,
    html: `<p>Hi, just checking in — your invoice is due. Pay here: <a href="${paymentUrl(inv)}">${paymentUrl(inv)}</a></p>`,
  }),
  reminder_2: (inv) => ({
    subject: `Second reminder: invoice ${inv.id.slice(0, 8)}`,
    html: `<p>This invoice is getting closer to a late fee. Please settle it now: <a href="${paymentUrl(inv)}">${paymentUrl(inv)}</a></p>`,
  }),
  final: (inv) => ({
    subject: `Final notice: invoice ${inv.id.slice(0, 8)}`,
    html: `<p>This invoice is now overdue for manual review. Please pay immediately or contact us: <a href="${paymentUrl(inv)}">${paymentUrl(inv)}</a></p>`,
  }),
};

6. The escalation job

app/api/cron/escalate/route.ts — pinged by cron (VPS) or Vercel Cron; safe to run every hour:

import { NextRequest, NextResponse } from 'next/server';
import { pool } from '@/lib/db';
import { nextStage, requireSend, type Invoice } from '@/lib/escalation';
import { templates } from '@/lib/emails';
import { resend } from '@/lib/email';

export async function POST(req: NextRequest) {
  // Gate: only the cron job may run this (header set by the scheduler).
  if (req.headers.get('x-cron-secret') !== process.env.CRON_SECRET) {
    return NextResponse.json({ error: 'unauthorized' }, { status: 403 });
  }

  const { rows } = await pool.query(
    `SELECT id, status, due_date, customer_email, last_reminded AS last_sent_at
       FROM invoices
      WHERE status NOT IN ('paid', 'bounced', 'opted_out')`
  ) as { rows: (Invoice & { customer_email: string })[] };

  let sent = 0;
  for (const inv of rows) {
    const stage = nextStage(inv);
    if (!stage) continue;
    if (!requireSend(inv, stage)) continue;

    try {
      const tpl = templates[stage](inv);
      await resend.emails.send({
        from: process.env.EMAIL_FROM!,
        to: inv.customer_email, // real address from the row — never a literal
        subject: tpl.subject,
        html: tpl.html,
      });
      // Record the send, advance the status, and timestamp it — all in one transaction
      // so a crash between email and state change can't cause a duplicate send on retry.
      await pool.query(
        `INSERT INTO send_log (invoice_id, stage) VALUES ($1, $2);
         UPDATE invoices SET status = $2, last_reminded = now() WHERE id = $1;`,
        [inv.id, stage]
      );
      sent++;
    } catch (err) {
      console.error('escalation send failed', inv.id, err);
    }
  }
  return NextResponse.json({ ok: true, evaluated: rows.length, sent });
}

Run the job: on the VPS, */30 * * * * curl -fsS -H "x-cron-secret: $CRON_SECRET" https://YOUR-DOMAIN.com/api/cron/escalate.

7. Payment detection

app/api/webhooks/stripe/route.ts:

import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { pool } from '@/lib/db';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: NextRequest) {
  const sig = req.headers.get('stripe-signature');
  if (!sig) return NextResponse.json({ error: 'missing stripe-signature' }, { status: 400 });

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(await req.text(), sig, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch (err) {
    console.error('webhook signature failed', err);
    return NextResponse.json({ error: 'invalid signature' }, { status: 400 });
  }

  try {
    if (event.type === 'invoice.paid' || event.type === 'checkout.session.completed') {
      const obj = event.data.object as Stripe.Invoice | Stripe.Checkout.Session;
      const stripeInvId = 'number' in obj && typeof obj.number === 'string' ? obj.number : String(obj.id);
      const result = await pool.query(
        `UPDATE invoices
            SET status='paid', recovered_at=now()
          WHERE stripe_inv_id = $1
         RETURNING id`,
        [stripeInvId]
      );
      // Unknown invoice: log it for manual review instead of failing the webhook.
      if (!result.rowCount) console.warn('paid for unknown invoice', stripeInvId);
    }
    return NextResponse.json({ received: true });
  } catch (err) {
    console.error('webhook handler failed', err);
    return NextResponse.json({ error: 'handler failed' }, { status: 500 }); // Stripe retries
  }
}
  • Signature verified first; bad signatures get 400 and Stripe knows the event was rejected.
  • Unknown stripe_inv_id → warn log, but the webhook still acks (200): a paid invoice that isn’t in your table is worth investigating, not worth a retry storm.
  • Failures after the ack criteria return 500 so Stripe redelivers.

lib/email.ts follows the exact pattern from the newsletter guide — one Resend client reading RESEND_API_KEY, EMAIL_FROM and APP_URL from env, never literals.

8. Reporting (the retention feature)

app/dashboard/page.tsx — show recovered money, not emails sent:

import { pool } from '@/lib/db';

export default async function Dashboard() {
  const { rows } = await pool.query(`
    SELECT
      count(*) FILTER (WHERE recovered_at IS NOT NULL) AS recovered,
      COALESCE(sum(amount_cents) FILTER (WHERE recovered_at IS NOT NULL), 0) AS recovered_cents,
      count(*) AS total,
      (SELECT count(*) FROM send_log) AS emails_sent
    FROM invoices
  `);
  const r = rows[0];
  return (
    <main>
      <h1>Recovery</h1>
      <p>Recovered: ${(Number(r.recovered_cents) / 100).toFixed(2)} across {r.recovered} invoices</p>
      <p>Emails sent: {r.emails_sent} — {r.total} invoices tracked</p>
    </main>
  );
}

“You recovered $4,200 this month by sending 38 emails” is the number that renews subscriptions — and it comes from real data, so it can’t be argued with.

9. Run locally

docker run -d --name pg --rm -p 5432:5432 -e POSTGRES_PASSWORD=dev postgres:17
export DATABASE_URL=postgres://postgres:dev@localhost:5432/postgres
psql "$DATABASE_URL" -f schema.sql
export $(grep -v '^#' .env | xargs)
npm run dev
# terminal 2:
stripe listen --forward-to localhost:3000/api/webhooks/stripe

Insert a test invoice and trigger the job:

psql "$DATABASE_URL" -c "INSERT INTO invoices (customer_email, amount_cents, due_date) VALUES ('[email protected]', 4900, CURRENT_DATE - 10)"
curl -s -X POST http://localhost:3000/api/cron/escalate -H "x-cron-secret: $CRON_SECRET"
# {"ok":true,"evaluated":1,"sent":1}   -> invoice is overdue 10 days, so reminder_2 fires

10. Dockerize and deploy

Dockerfile (requires output: 'standalone' in next.config.ts):

FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci

FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build

FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
docker build -t invoicedun .
docker run -d --name invd --restart unless-stopped -p 3000:3000 \
  -e DATABASE_URL=... -e STRIPE_SECRET_KEY=... -e STRIPE_WEBHOOK_SECRET=... \
  -e RESEND_API_KEY=... -e CRON_SECRET=... \
  -e APP_URL=https://YOUR-DOMAIN.com \
  -e EMAIL_FROM='Billing <[email protected]>' invoicedun

Add Caddy (reverse_proxy app:3000), point the Stripe webhook at https://YOUR-DOMAIN.com/api/webhooks/stripe, and set the cron line from step 6.

Cost (assumptions: 5,000 tracked invoices, ~1,500 escalations/month)

Service Monthly cost
VPS 1–2 GB $4.49
Resend (3k free, then ~$0.10/email) $0–10
Stripe (already your customer’s processor) $0
Total ≈ $5–15/month

Production checklist

  • Email throttling + per-customer send limits (the requireSend 6 h guard is the floor; add a per-invoice cap)
  • Webhook signature verification (done) — test by replaying an event with a wrong secret
  • Customer opt-out per invoice thread (status='opted_out' checked before any send)
  • Escalation state machine tested for all 5 transitions including the manual hand-off
  • GDPR: delete customer data on request (DELETE ... CASCADE with a nullable customer FK)
  • Cron gated by CRON_SECRET so the endpoint can’t be triggered by strangers

Common problems

  • The nag that churns people: the cadence must be transparent and configurable before customers sign up — the ESCALATION_*_DAYS knobs exist precisely so you can promise “we remind at day 3, 10 and 17”.
  • Duplicate sends: the send_log uniques the stage per invoice and requireSend throttles reruns; re-running the cron at the wrong TZ cannot double-send.
  • Timezone off-by-one: overdue math uses calendar dates (YYYY-MM-DD) compared in UTC — a customer’s local midnight shifts are the payer’s business, not the scheduler’s.
  • Paid but still nagged: the webhook marks paid and the cron filter excludes it — verify by firing invoice.paid in the Stripe CLI test flow.

Improvements

  • Payment-link shortcodes per invoice with click tracking in send_log
  • Recovery cohort report (“40% of final-notice invoices recover”) via the send_log dates
  • Manual follow-up queue with click-to-call notes for status='manual' rows

Conclusion

An invoice follow-up SaaS is a state machine over due dates plus Stripe’s hosted billing. ~$5–15/month to run, every transition and email is tested logic, and the product survives on measured results — so the reporting dashboard came first.

Related guides