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
400and 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
500so 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
requireSend6 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
manualhand-off - GDPR: delete customer data on request (
DELETE ... CASCADEwith a nullable customer FK) - Cron gated by
CRON_SECRETso 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_*_DAYSknobs exist precisely so you can promise “we remind at day 3, 10 and 17”. - Duplicate sends: the
send_loguniques the stage per invoice andrequireSendthrottles 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
paidand the cron filter excludes it — verify by firinginvoice.paidin 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_logdates - 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.