JK
All work

Invoicio

Invoicing SaaS for freelancers and SMBs — create invoices, set up recurring billing, accept Paystack payments (card, bank transfer, mobile money), and generate PDFs via an async BullMQ worker. Built as a Next.js + Express monorepo.

Next.jsExpressPrismaPostgreSQLPaystackBullMQ

Overview

Invoicio is an invoicing SaaS for freelancers and small businesses. Users create clients, send branded invoices, take card and bank payments via Paystack, and set up recurring billing — all from a single workspace.

The codebase is a pnpm monorepo with three runtime services (apps/web, apps/api, apps/worker) sharing a typed Prisma schema and types package.

Marketing landing — hero, payment-method strip, and product preview
Marketing landing — hero, payment-method strip, and product preview

Architecture

Next.js 16 (web)  ──►  Express API  ──►  PostgreSQL (Prisma)
                              │
                              └──►  Redis  ──►  BullMQ Worker  ──►  Resend / PDF render

The split is deliberate. The Next.js app owns the UI, marketing site, and public invoice payment view. The Express API owns every mutation, webhook, and integration. The BullMQ worker handles anything slow or retryable — PDF generation, email delivery, recurring-invoice cron — so the API stays responsive.

Prisma is the source of truth for the schema. Shared TypeScript types live in packages/types so request and response shapes stay aligned between the Next.js client, the Express handlers, and the worker.

Workspace dashboard — revenue, outstanding balance, status donut, and recent activity
Workspace dashboard — revenue, outstanding balance, status donut, and recent activity

Payments

Paystack handles card, bank transfer, and mobile money on the public invoice page. The integration is fully webhook-driven — the API exposes /webhooks/paystack and treats the webhook (not the redirect) as the source of truth for payment status. Signatures are verified on every event, idempotency keys prevent double-application, and a PaymentEvent audit row is written for every webhook regardless of whether it changes invoice state.

The public payment view describes payment methods generically rather than naming the gateway — a small thing that keeps the option open to add Stripe or Flutterwave later without rewriting the customer-facing copy.


Recurring Invoices

Recurring schedules are stored as cron-like specs on a RecurringInvoice record. A scheduled BullMQ job sweeps due schedules every hour, materialises the next invoice instance, and enqueues PDF generation and email delivery as downstream jobs. Each step is its own job with its own retry policy, so a Resend outage doesn't lose the invoice — it sits in the queue until delivery succeeds.


Key Features

  • Workspaces — multi-tenant from day one; every record is scoped by workspaceId and enforced at the service layer
  • Clients & invoices — line-item invoices with tax, discount, and currency support
  • Public payment page — clients pay without signing up; the URL contains a signed token
  • Paystack integration — card, bank transfer, and mobile money via hosted checkout, reconciled by webhook
  • Recurring billing — schedule invoices to generate and send on cron-like intervals
  • PDF generation — branded invoice PDFs rendered in the worker and stored in R2
  • Email delivery — transactional sends via Resend with branded templates
  • Dashboard — revenue, outstanding balance, top clients, and recent activity
Invoice detail — line items, totals, and activity timeline
Invoice detail — line items, totals, and activity timeline
Client record — contact, billing summary, and invoice history
Client record — contact, billing summary, and invoice history

Tradeoffs

Splitting the API out of Next.js gives up the ergonomics of server actions but buys two things that matter for a billing product: webhooks land on a process that isn't bound to a serverless cold-start budget, and the worker can share the exact same service layer as the API without any HTTP indirection. For a CRUD-only app the split would be overkill; for one that has to reliably process webhooks and queued jobs, the boundary pays for itself.

The marketing site lives inside apps/web rather than as a separate Astro or static project. Sharing the same Next.js app keeps the design system unified and removes a deployment, at the cost of slightly more JavaScript on landing pages than a static site would ship.


Retrospective

The biggest win was treating webhooks as the source of truth from day one. Every Paystack event is logged before it's processed, so reconciliation is a matter of replaying events rather than reasoning about UI redirect state. The next thing to build is a self-service subscription tier on top of Paystack Plans so the SaaS billing closes the loop on itself.