BillForge

Invoicing where the payments and the bookkeeping are one system, not two.

Built from scratch to demonstrate multi-tenant invoice platform

LaravelStripePayPalXeroGemini AINext.js
BillForge interface
The problem

What this solves

A business taking payments through more than one provider ends up doing bookkeeping twice. Stripe knows about card payments, PayPal knows about its own, and the accounting system knows about neither until somebody types it in.

That manual step is where the errors live. An invoice gets marked paid in one place and not the other, a refund never makes it to the books, and at the end of the month someone reconciles by hand and hopes the totals agree.

I built BillForge to demonstrate the alternative: payments and accounting as a single flow, where recording the money is part of receiving it.

Approach

How I approached it

  • I started from the data model rather than the integrations. Invoices, payments, and customers are owned by a tenant, so adding a second payment provider later does not mean reworking ownership.
  • Four roles with genuinely different views: an owner controls integrations and financial settings, staff get operational access without payment or integration details, customers get a portal to pay invoices and manage saved methods, and a platform admin sees across tenants including AI analytics and webhook health.
  • Stripe goes through the Stripe PHP SDK and PayPal through its REST API, both queued rather than handled inline.
  • Xero sync is bidirectional and runs as background jobs over OAuth 2.0 with PKCE: one pulls invoices in from Xero, another pushes paid status back out, so a payment taken here shows as settled there without anyone retyping it.
  • Xero credentials live in a per-organization integrations record with the access and refresh tokens encrypted at rest, alongside the token expiry, granted scope, and the status of the last sync.
  • Gemini 2.5 Flash categorizes invoices, detects anomalies against vendor statistical baselines, and drafts payment reminders whose tone escalates with how overdue the invoice is. Structured JSON outputs keep the response shape deterministic. The model drafts, a person sends.
The hard part

Payment webhooks fire more than once, and a naive handler double-charges or double-records.

Why the obvious solution fails

The obvious handler reads the event, does the work, and returns 200. That breaks because delivery is at-least-once, not exactly-once. Stripe retries anything that is not a 2xx, and it will re-send an event that did succeed if the acknowledgement was lost in transit. A handler that checks for an existing record and then writes still loses, because two deliveries processed concurrently can both pass the check before either writes. On top of that the endpoint is on a clock: a webhook response has to complete in under five seconds, which is not enough time to do the real work inline.

What I did instead

  • Every delivery is recorded in a webhook_events table keyed by the provider's own event id, and that column carries a unique constraint.
  • The handler checks for an existing row first and acknowledges a repeat delivery immediately with a 200, so the provider stops retrying something already done.
  • The unique constraint is what actually makes it safe. Two deliveries arriving at the same moment can both pass that check, but only one row for a given event id can ever exist, so only one of them proceeds. The guarantee is enforced by the database rather than by the order the application happens to run in.
  • The endpoint then does as little as possible before responding. The real work goes to a queued job, which keeps the response inside the five second budget and lets the work retry on its own schedule instead of depending on the provider redelivering.
  • That split matters: a slow Xero call no longer risks the provider timing out and resending, which is what turns one payment into two records.
Architecture

How the data flows

A Laravel API owns invoicing, payment provider integration, and accounting sync, with a Next.js frontend on top. Authentication is Sanctum over an httpOnly cookie proxied through Next.js. Anything slow or failure-prone runs on a database-backed queue rather than in the request.

  1. A customer pays an invoice through Stripe or PayPal from the customer portal.
  2. The provider sends a webhook. The endpoint verifies it, records the event id under a unique constraint, and responds inside the five second budget.
  3. A repeat delivery of an event already on file is acknowledged without doing the work again.
  4. A first delivery dispatches a queued job that records the payment and updates invoice state.
  5. Paid status is pushed back to Xero by its own job, while a separate job pulls invoices in from Xero, both over OAuth 2.0 with PKCE and retried on their own schedule if Xero is unavailable.
  6. Gemini categorizes the invoice and checks it against vendor statistical baselines to flag anomalies.
  7. Overdue invoices get a drafted reminder whose tone escalates with the number of days overdue, held for a human to send.
At production scale

What I'd do differently

  • The duplicate guard is a check followed by an insert, with the unique constraint underneath it as the real protection. That is correct but not tidy: two simultaneous deliveries both pass the check, and the loser fails on the constraint rather than being turned away cleanly. I would make the write itself the guard, so a concurrent duplicate resolves in one atomic step instead of surfacing as a failed insert.
  • Webhook and event records grow without bound. In production that needs a retention policy, archiving rows older than the provider's replay window rather than keeping everything forever.
  • Xero enforces API rate limits. A tenant importing a large history would need sync jobs throttled per tenant, so one busy account cannot consume the whole rate budget.
  • Gemini calls cost money per invoice. At volume I would batch categorization rather than calling per invoice, and cache category results for recurring vendors.
  • Anomaly detection against vendor baselines needs enough history per vendor to mean anything. A new vendor has no baseline, so the honest behaviour early on is to say so rather than to flag confidently.
Screens
BillForge screen 2
BillForge screen 3
BillForge screen 4
BillForge screen 5

Want something like this built?

I build payment systems and AI features inside Laravel apps. Tell me what you need and I will tell you how I would approach it.

Get in touch