Skip to content

Invoicing agent MVP: six evenings with Expo and Supabase

Six evenings, one Expo app, one Supabase project, one writing agent, and one 47-day-old invoice that finally got paid. Here is what each night actually looked like.

Jacob Molkenboer
Jacob Molkenboer
Founder · A Brand New Company
Published
01 June 2026
Reading time
9 min read
Category
SaaS
Cream envelope tied with chartreuse ribbon on dark green leather blotter, brass paper clip, folded carbon invoice slip.

The invoice was 47 days old. We had sent two polite nudges. The client had moved offices, their finance lead had gone on parental leave, and the original PO was buried in a Slack channel nobody was reading anymore. This is the exact work an invoicing agent is supposed to do, and we had been telling clients to build one for months. So one Sunday evening I opened a fresh Expo project and decided to dogfood the idea.

Six evenings later there was a working mobile app, a Postgres database, a writing agent, a Stripe sync, and an open invoice that had finally been paid. Here is what each evening actually looked like, in the order it happened, including the parts that broke.

The brief on a napkin

I wanted one app on my phone that did three things. Pull every open invoice from Stripe and our bookkeeping tool. Decide which ones needed a nudge tonight. Write the nudge in my voice, send it from my address, and log the reply when it came back. No team accounts. No multi-tenancy. No marketing site. The smallest possible vertical slice of the eventual product, on the smallest possible stack.

The stack I picked: Expo Router for the shell, Supabase for auth and Postgres and storage and edge functions, Resend for transactional email, Stripe for the source of truth, and a writing model behind an edge function. The whole thing fits in one repo and runs for under twenty euros a month at the scale of one studio.

Two evenings per week, three hours each, for three weeks. That was the budget.

Evening one, schema and auth

The first night was boring on purpose. npx create-expo-app, supabase init, then forty minutes staring at a notebook trying to get the data model right before any code was written. I ended up with five tables that have not changed since.

create table contacts (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users not null,
  email text not null,
  name text,
  company text,
  reply_tone text default 'neutral',
  created_at timestamptz default now()
);

create table invoices (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users not null,
  contact_id uuid references contacts,
  external_id text not null,
  source text not null check (source in ('stripe','moneybird','manual')),
  amount_cents bigint not null,
  currency text not null,
  issued_on date not null,
  due_on date not null,
  paid_on date,
  status text not null default 'open',
  unique (user_id, source, external_id)
);

create table nudges (
  id uuid primary key default gen_random_uuid(),
  invoice_id uuid references invoices on delete cascade,
  sent_at timestamptz not null default now(),
  subject text not null,
  body text not null,
  draft_model text,
  tone text,
  reply_received_at timestamptz
);

create table replies (
  id uuid primary key default gen_random_uuid(),
  nudge_id uuid references nudges,
  received_at timestamptz default now(),
  body text,
  sentiment text
);

create table settings (
  user_id uuid primary key references auth.users,
  sender_name text,
  sender_email text,
  signature text,
  cadence_days int[] default array[3,10,21,35]
);

Row Level Security on every table from minute one. If you skip this on a Supabase project you will regret it the day you ship. The Supabase RLS guide is short and worth the read in full before you write a single insert policy.

Auth was magic link. Expo Router handles the deep link callback in about ten lines. I did not bother with email plus password because there is no team yet and there is no support inbox to reset forgotten passwords.

Evening two, invoice render and the PDF problem

I had hoped to skip PDFs. The client reminded me that an attached PDF is the only object an accounting department recognises. Fine.

Rendering PDFs from inside a React Native app is grim. I have done it once before with react-native-pdf and I did not want to do it again. Instead I put a Supabase edge function in front of a tiny HTML template, ran it through a headless Chromium wrapper, and dropped the bytes into Supabase Storage. The mobile app never sees a PDF blob; it sees a signed URL that expires in an hour.

This part of the build is the one I would do differently. Edge function cold starts on the first render of the day were five to seven seconds. By evening six I had moved the rendering to a small Fly machine that stays warm. Lesson: if your edge function spawns a browser, it is not really an edge function any more.

Evening three, the send pipeline

Resend was the easy choice. One API key, a verified domain, a webhook for bounce and complaint events, and a single edge function that takes a draft row and posts it. I did not use MJML or a template language. The body is plain HTML, the subject is plain text, both come straight out of the writing agent, both go into the nudges table before they go to Resend so a delivery failure does not lose the draft.

The bounce webhook writes to a sixth tiny table that holds one column. If an address bounces hard, the contact gets a bounced flag and the agent will not write to them again until I manually clear it.

Evening four, the writing agent

This is the only evening where the build felt different from a 2019 SaaS build. The agent is one edge function. It reads the invoice, the contact, the previous nudges, any replies, and the user's signature and tone settings. It returns a subject line and a body. That is all.

// supabase/functions/draft-nudge/index.ts
import { serve } from "https://deno.land/std/http/server.ts";
import { createClient } from "jsr:@supabase/supabase-js";

serve(async (req) => {
  const { invoice_id } = await req.json();
  const sb = createClient(
    Deno.env.get("SUPABASE_URL")!,
    Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
  );

  const { data: ctx } = await sb.rpc("invoice_context", { iid: invoice_id });
  // ctx = { invoice, contact, prior_nudges, replies, settings }

  const prompt = buildPrompt(ctx);
  const draft  = await callModel(prompt);   // { subject, body, tone }

  const { data: nudge } = await sb
    .from("nudges")
    .insert({
      invoice_id,
      subject: draft.subject,
      body: draft.body,
      tone: draft.tone,
      draft_model: ctx.settings.model_id,
    })
    .select()
    .single();

  return Response.json({ nudge_id: nudge.id });
});

The trick is the context function. It returns everything the model needs in one Postgres call, including the count of previous nudges so the prompt can ask for a different tone on attempt three than on attempt one. The first draft the model produced for the 47-day-old invoice was better than the email I would have written tired on a Sunday night. That was the moment I knew the product had a reason to exist.

The interesting bit is not that a model can write an email. It is that the work of building the writing surface, the context fetch, the tone settings, the reply log, the audit trail, takes about as long as it did in 2019. The model itself is the boring part. The prototyping-speed essays going around HN right now miss that point, in my reading: the floor of the work moved, the ceiling did not.

Warning

If you are tempted to build this as a Google Sheets add-on instead, think about who can see the sheet. Any third-party AI surface inside a shared spreadsheet inherits the sheet's audience by default, and the failure mode is silent. A native app with scoped tokens per user has a much smaller blast radius.

Evening five, Stripe sync and the webhook dance

The Stripe side is two pieces. A Supabase scheduled function runs nightly at 03:00 Europe/Amsterdam and pulls every invoice modified in the last 24 hours. A webhook endpoint listens for invoice.paid so an open nudge cycle closes the moment the money lands.

The scheduled function uses upsert on (user_id, source, external_id), which is why that unique constraint exists in the schema. Stripe is the source of truth; if a number changes on their side it gets overwritten on ours. The webhook is verified with the official signature header. Do not skip this step. The Stripe docs on webhook signature verification are clear and the code is six lines.

I considered Stripe Connect for the bookkeeping side because the client invoices through Stripe and through a Dutch bookkeeping tool. In the end I wrote a 200-line Moneybird adapter that pulls a CSV every morning. Not glamorous, works.

Evening six, deploy and the first real send

EAS build, EAS submit, TestFlight, install on my phone, sign in, sync. There were seventeen overdue invoices in our own books. I opened the one that was 47 days old, hit Draft, read what the agent had written, changed one sentence, and tapped Send. The reply came back forty minutes later with a payment confirmation and an apology about the parental leave.

Total build time across six evenings: about seventeen hours. Total cost of the stack in the first month: eleven euros for Supabase Pro, four euros for Resend, the Stripe and bookkeeping accounts already existed, and roughly nine euros in model spend across about 400 drafts.

What broke that I expected

Timezones broke twice. Once because Supabase stores date without a timezone and I treated it as UTC, and once because the EAS build server is in a different region than I am, which made the scheduled function fire at 04:00 my time for the first week. Both fixed in under an hour, but worth flagging: any agent that decides what to do tonight needs to know whose tonight it means.

Resend rate limits hit on day one because I let the agent draft for all seventeen overdue invoices in parallel. The fix was a queue table and a one-at-a-time worker, which I should have built first. Resend's limits are documented; I had not read them.

What broke that I did not expect

Two things. First, App Store review. Apple did not like that the app sent email on the user's behalf without an in-app preview of every send. Adding a mandatory preview screen took an evening I had not budgeted for. It is also the right product decision and I should have shipped with it.

Second, mobile safe area on iOS. The compose screen had a Send button that sat exactly under the home indicator on the Pro Max, which meant accidental sends every third try. A short read of the MDN reference on env() and safe-area-inset and a single padding tweak fixed it. Worth the bookmark if you ship anything that has a fixed bottom bar.

What I would build differently on attempt two

Three things, in order. Move the PDF rendering off edge functions and onto a warm container from the start. Add a queue table on evening one, not evening six. And split the writing agent's prompt by attempt number from the first draft, because the tone gap between nudge one and nudge four is the entire reason a human would have written them differently in the first place.

If we were building this for a paying customer rather than for ourselves, the next evening would be team accounts and a shared inbox. After that, a desktop view. The mobile-first shape was right for the studio use case (we look at overdue invoices on the couch on Sunday night, not at a desk). For a 20-person finance team it would be wrong.

Closing notes

When we built the email chaser inside the studio invoicing agent, the thing we ran into was that the model writes the third nudge in the same tone as the first unless you explicitly tell it not to. We solved it by feeding the prior nudge count into the prompt and letting the tone field swing from "friendly reminder" to "this is now blocking our books". That same pattern, context in, tone out, draft logged before send, is the spine of every AI agent we have shipped for clients this year.

The smallest thing you could do tonight: open your own overdue invoice list, count how many are more than 30 days late, and write down the sentence you wish someone else would send for you. That sentence is the prompt.

Frequently asked

Why Expo instead of a web app for an invoicing MVP?+
Because the user behaviour we were modelling is glancing at overdue invoices on a phone on Sunday night, not sitting at a desk on Monday. For a finance team it would be the wrong shape.
Why Supabase edge functions instead of a separate API?+
One repo, one auth model, one billing line. The only place this hurt was PDF rendering, where cold starts on a browser-spawning function are too slow and need a warm container.
How much did the first month actually cost?+
Eleven euros for Supabase Pro, four euros for Resend, around nine euros in model spend across 400 drafts. Stripe and the bookkeeping tool were already paid for.
Could a non-developer build this?+
Not in six evenings. The schema and the webhook verification are the boring parts that gate everything else. With a developer pair, an operations lead can absolutely own the prompt and the cadence.

Want to build something similar?

Send us one paragraph about the process that eats the most of your week. We'll reply with an honest plan — within 4h on weekdays.