De factuur was 47 dagen oud. We hadden al twee vriendelijke herinneringen gestuurd. De klant was verhuisd, hun finance lead was met ouderschapsverlof, en de oorspronkelijke PO lag begraven in een Slack-kanaal dat niemand meer las. Dit is precies het werk waar een facturatie-agent voor bedoeld is, en we vertelden klanten al maanden dat ze er één moesten bouwen. Dus op een zondagavond opende ik een nieuw Expo-project en besloot het idee zelf te gebruiken.
Zes avonden later was er een werkende mobiele app, een Postgres-database, een schrijvende agent, een Stripe-sync, en een openstaande factuur die eindelijk betaald was. Hier is hoe elke avond er echt uitzag, in de volgorde waarin het gebeurde, inclusief de dingen die kapot gingen.
Het plan op een bierviltje
Ik wilde één app op mijn telefoon die drie dingen deed. Alle open facturen ophalen uit Stripe en onze boekhouding. Beslissen welke vanavond een nudge nodig hadden. De nudge schrijven in mijn stem, versturen vanaf mijn adres, en het antwoord loggen als het binnenkwam. Geen team-accounts. Geen multi-tenancy. Geen marketingsite. De kleinst mogelijke verticale slice van het uiteindelijke product, op de kleinst mogelijke stack.
De stack die ik koos: Expo Router als shell, Supabase voor auth en Postgres en storage en edge functions, Resend voor transactionele mail, Stripe als bron van waarheid, en een schrijvend model achter een edge function. Het hele ding past in één repo en draait voor minder dan twintig euro per maand op de schaal van één studio.
Twee avonden per week, drie uur per avond, drie weken lang. Dat was het budget.
Avond één, schema en auth
De eerste avond was bewust saai. npx create-expo-app, supabase init, en daarna veertig minuten met een notitieboekje om het datamodel kloppend te krijgen voordat er ook maar een regel code geschreven werd. Ik kwam uit op vijf tabellen die sindsdien niet veranderd zijn.
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 op elke tabel vanaf minuut één. Sla je dit over op een Supabase-project, dan heb je er spijt van op de dag dat je live gaat. De Supabase RLS-gids is kort en de moeite waard om volledig te lezen voordat je één enkele insert policy schrijft.
Auth was magic link. Expo Router handelt de deep link callback af in ongeveer tien regels. Ik heb me niet beziggehouden met e-mail plus wachtwoord, omdat er nog geen team is en er geen support-inbox is om vergeten wachtwoorden te resetten.
Avond twee, factuur renderen en het PDF-probleem
Ik had gehoopt PDF's te kunnen overslaan. De klant herinnerde me eraan dat een bijgevoegde PDF het enige object is dat een boekhoudafdeling herkent. Prima.
PDF's renderen vanuit een React Native-app is grimmig. Ik heb het ooit eerder gedaan met react-native-pdf en ik wilde het niet nog eens doen. In plaats daarvan zette ik een Supabase edge function voor een klein HTML-template, draaide het door een headless Chromium-wrapper, en dropte de bytes in Supabase Storage. De mobiele app ziet nooit een PDF-blob; hij ziet een signed URL die binnen een uur verloopt.
Dit deel van de build is het stukje dat ik anders zou doen. Cold starts van de edge function bij de eerste render van de dag waren vijf tot zeven seconden. Tegen avond zes had ik het renderen verplaatst naar een kleine Fly-machine die warm blijft. Les: als je edge function een browser spawnt, is het eigenlijk geen edge function meer.
Avond drie, de verstuurpijplijn
Resend was de makkelijke keuze. Eén API key, een geverifieerd domein, een webhook voor bounce- en complaint-events, en één edge function die een draft-row pakt en verstuurt. Ik heb geen MJML of templatetaal gebruikt. De body is platte HTML, het onderwerp is platte tekst, beide komen rechtstreeks uit de schrijvende agent, en beide gaan in de nudges-tabel voordat ze naar Resend gaan, zodat een afleverfout de draft niet kwijtraakt.
De bounce-webhook schrijft naar een zesde mini-tabel met één kolom. Bounct een adres hard, dan krijgt de contact een bounced-flag en zal de agent er niet meer naartoe schrijven totdat ik dat handmatig opheft.
Avond vier, de schrijvende agent
Dit is de enige avond waarop de build anders aanvoelde dan een SaaS-build uit 2019. De agent is één edge function. Hij leest de factuur, het contact, de eerdere nudges, eventuele antwoorden, en de signature- en tone-instellingen van de gebruiker. Hij retourneert een onderwerpregel en een body. Meer niet.
// 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 });
});
De truc zit in de context-functie. Hij retourneert alles wat het model nodig heeft in één Postgres-call, inclusief het aantal eerdere nudges, zodat de prompt om een andere toon kan vragen bij poging drie dan bij poging één. De eerste draft die het model produceerde voor de factuur van 47 dagen oud, was beter dan de mail die ik moe op een zondagavond zou hebben geschreven. Dat was het moment waarop ik wist dat het product reden van bestaan had.
Het interessante is niet dat een model een mail kan schrijven. Het interessante is dat het werk om de schrijfomgeving te bouwen, de context-fetch, de tone-instellingen, het reply-log, het audit trail, ongeveer net zo lang duurt als in 2019. Het model zelf is het saaie stuk. De essays over prototyping-snelheid die nu rondgaan op HN missen dat punt, in mijn lezing: de vloer van het werk is verschoven, het plafond niet.
Ben je in de verleiding om dit als een Google Sheets-add-on te bouwen, denk dan na over wie het sheet kan zien. Elke third-party AI-surface in een gedeeld spreadsheet erft standaard het publiek van dat sheet, en het faalmoment is stil. Een native app met scoped tokens per gebruiker heeft een veel kleinere blast radius.
Avond vijf, Stripe-sync en de webhook-dans
De Stripe-kant bestaat uit twee delen. Een scheduled function in Supabase draait elke nacht om 03:00 Europe/Amsterdam en haalt elke factuur op die in de afgelopen 24 uur is gewijzigd. Een webhook-endpoint luistert naar invoice.paid, zodat een open nudge-cyclus sluit op het moment dat het geld binnenkomt.
De scheduled function gebruikt upsert op (user_id, source, external_id), en daarom bestaat die unique constraint in het schema. Stripe is de bron van waarheid; verandert een getal aan hun kant, dan wordt het bij ons overschreven. De webhook wordt geverifieerd met de officiële signature-header. Sla deze stap niet over. De Stripe-docs over webhook signature verification zijn helder en de code is zes regels.
Ik heb Stripe Connect overwogen voor de boekhoudkant, omdat de klant factureert via Stripe én via een Nederlandse boekhoudtool. Uiteindelijk schreef ik een Moneybird-adapter van 200 regels die elke ochtend een CSV ophaalt. Niet glamoureus, werkt wel.
Avond zes, deploy en de eerste echte verzending
EAS build, EAS submit, TestFlight, op mijn telefoon installeren, inloggen, syncen. Er stonden zeventien achterstallige facturen in onze eigen boeken. Ik opende die ene die 47 dagen oud was, tikte op Draft, las wat de agent had geschreven, paste één zin aan, en tikte op Verzenden. Het antwoord kwam veertig minuten later binnen, met een betaalbevestiging en een excuus over het ouderschapsverlof.
Totale bouwtijd over zes avonden: ongeveer zeventien uur. Totale kosten van de stack in de eerste maand: elf euro voor Supabase Pro, vier euro voor Resend, de Stripe- en boekhoudaccounts bestonden al, en ongeveer negen euro aan modelkosten voor zo'n 400 drafts.
Wat brak dat ik verwachtte
Timezones braken twee keer. Eén keer omdat Supabase date opslaat zonder timezone en ik het als UTC behandelde, en één keer omdat de EAS build-server in een andere regio staat dan ik, waardoor de scheduled function de eerste week om 04:00 mijn tijd vuurde. Allebei binnen een uur opgelost, maar het is het noemen waard: elke agent die beslist wat vanavond moet gebeuren, moet weten wiens vanavond het is.
Resend rate limits raakten me op dag één, omdat ik de agent voor alle zeventien achterstallige facturen tegelijk liet schrijven. De fix was een queue-tabel en een worker die er één tegelijk doet, wat ik meteen had moeten bouwen. De limits van Resend staan gedocumenteerd; ik had ze niet gelezen.
Wat brak dat ik niet verwachtte
Twee dingen. Ten eerste, de App Store review. Apple vond het niet leuk dat de app namens de gebruiker mail verstuurde zonder in-app preview van elke verzending. Een verplicht preview-scherm toevoegen kostte een avond die ik niet had begroot. Het is ook de juiste productbeslissing en ik had er meteen mee moeten shippen.
Ten tweede, mobile safe area op iOS. Het opstelscherm had een Verzenden-knop die op de Pro Max precies onder de home indicator zat, wat betekende dat er om de drie pogingen per ongeluk werd verzonden. Een korte lees van de MDN-referentie over env() en safe-area-inset en één padding-tweak losten het op. Een bookmark waard als je iets shipt met een vaste onderbalk.
Wat ik bij poging twee anders zou bouwen
Drie dingen, in volgorde. Verplaats het PDF-renderen meteen van edge functions naar een warme container. Voeg op avond één een queue-tabel toe, niet op avond zes. En splits de prompt van de schrijvende agent vanaf de eerste draft op naar pogingsnummer, want het verschil in toon tussen nudge één en nudge vier is precies de reden waarom een mens ze sowieso anders zou hebben geschreven.
Als we dit voor een betalende klant zouden bouwen in plaats van voor onszelf, zouden team-accounts en een gedeelde inbox de volgende avond zijn. Daarna een desktopweergave. De mobile-first vorm was juist voor de studio use case (we kijken naar achterstallige facturen op de bank op zondagavond, niet op kantoor). Voor een finance-team van 20 mensen zou het verkeerd zijn.
Afsluitende noten
Toen we de email-chaser in onze eigen facturatie-agent bouwden, liepen we ertegenaan dat het model de derde nudge in dezelfde toon schrijft als de eerste, tenzij je het expliciet anders vertelt. We hebben het opgelost door het aantal eerdere nudges in de prompt te voeden en het tone-veld te laten zwaaien van "vriendelijke herinnering" naar "dit blokkeert nu onze boeken". Datzelfde patroon, context erin, toon eruit, draft gelogd vóór verzending, is de ruggengraat van elke AI-agent die we dit jaar voor klanten hebben opgeleverd.
Het kleinste wat je vanavond zou kunnen doen: open je eigen lijst met achterstallige facturen, tel hoeveel er meer dan 30 dagen te laat zijn, en schrijf de zin op waarvan je zou willen dat iemand anders 'm voor je stuurde. Die zin is de prompt.




