← Back to work

Full-stack engineer (solo build) · 2026

LedgerNudge

An AI-native B2B invoice follow-up tool: Claude drafts respectful payment reminders, a human approves them, Stripe takes payment, and Claude reads each reply to decide whether to keep going or pause for a dispute.

Chases unpaid invoices on its own, but a person signs off every message before it sends.

Source ↗
Cover image for LedgerNudge

The problem

Chasing unpaid B2B invoices (dunning) is mostly manual, repetitive, and easy to get wrong: send too softly and nobody pays, send too aggressively and you damage the relationship. I wanted to see how much of that follow-up loop could become software where a person still controls anything that leaves the building, and a language model handles the parts it is genuinely good at: writing the message and reading the reply. I also set myself a constraint: build it solo in Laravel and PHP, a stack I had not written before, to prove I can ramp fast on a new language with AI directing the work. LedgerNudge is a deliberately scoped slice of that idea, presented honestly as a portfolio project rather than a product.

Approach

The whole design is human-in-the-loop. Claude drafts each reminder from the invoice, the debtor's history, and a per-account tone policy; an operator approves or edits the draft before it is ever sent; approved messages go out over a queue. Stripe takes the payment through a hosted link, and a webhook reconciles the result back onto the invoice. Inbound replies feed back in: Claude classifies each one, and a dispute (or anything the model is not confident about) pauses the sequence and flags a human. The bias is deliberately toward pausing: when in doubt, stop and ask a person.

Architecture

LedgerNudge dunning loop A scheduled dunning:advance job puts past-due steps on a Redis queue. The queue feeds DunningDraftService, which drafts a message with Anthropic Claude and writes it to PostgreSQL as pending_approval. An operator inbox reads from Postgres; approving returns the job to the queue, which dispatches MessageSender to send email or SMS through Twilio. Inbound replies go to InboundReplyService, which classifies them with Claude and records disputes to Postgres, pausing the sequence. Separately the operator creates a Stripe payment link, and Stripe webhooks are reconciled back into Postgres. past-due step draft job message draft send job email / SMS inbound reply classify pending_approval approve create link webhook dispute pauses sequence dunning:advancescheduled Redis queue DunningDraftService AnthropicClaude MessageSender Twilio + email InboundReplyService Operator inbox PostgreSQL Stripe WebhookReconciler
The dunning loop: draft, approve, send, reconcile, classify, pause.

How I built it

I delivered it in SCRUM-style sprints, each one leaving the project working and tested and landing as a single commit, so the git history reads like a delivery log. Every push runs CI (the test suite plus linters for PHP and the frontend). Because I had never written PHP or Laravel before, I used Claude Code to direct the work and learned the framework by shipping it, one reviewable slice at a time.

What I'd do differently

The thin adapters that wrap the real Stripe, Claude, and Twilio SDKs are not unit-tested, because that needs live credentials; the seams around them are, which is where the logic actually lives. The email inbound route trusts its payload as a simplified stand-in for verifying a mail provider's signature. Voice follow-ups, multi-tenant brand isolation beyond the tone-policy field, and a real jurisdiction-aware compliance rules engine are named in the README as the next layers, not built.