Live demo → settle-qweffys-projects.vercel.app · deployed on Vercel + Neon, seeded with the Summit Waste demo data.
Settle is a modern Accounts Payable / Bill Pay product — where a finance team manages the full life of a vendor bill: intake → code → approve → schedule → pay, plus AP aging and an AI bill review. It's built in the spirit of Ramp Bill Pay × Stripe Dashboard, and the demo data is themed for a waste‑hauling company (Summit Waste Services) paying the vendors a hauler actually pays: landfill tipping fees, fleet fuel, truck maintenance, leasing, insurance.
Why a hauler? This take‑home is for Trashlab, "the operating system for waste haulers." Trashlab owns the hauler's money‑in (billing their customers). Settle is the other half of the ledger — the money‑out (paying their vendors). Same domain, opposite side.
The bill cockpit — invoice image, line-item GL coding, and an auditable comment timeline with @-mentions on one screen; large bills hit a server-side approval gate (here, Requires Controller):
A finance team lives in four workflows, and Settle is organized around them:
- Intake → Draft. Bills arrive (upload a PDF, forward to a dedicated AP inbox — the Simulate inbound button on Capture demos this end-to-end — create manually, or bulk-import a CSV). On the Capture screen, an AI pass reads the invoice, pre‑fills a coded draft, and flags anomalies; saving the draft persists a real bill (line items + flags) straight into the approval queue and opens its cockpit. Manual entry is a full New bill form (vendor + line-item GL coding + dates + tax) reachable from the topbar, the bills table, the command palette, or Cmd-N — and the same form edits an existing bill from its cockpit (logging an audit event onto the timeline).
- Code → Approve. The Bill cockpit is a 3‑panel workspace — invoice viewer + line‑item GL coding + a unified, auditable timeline with comments and @‑mentions. The AI Bill Review surfaces issues (surcharge spikes, new fees, missing POs, possible duplicates, vendor bank changes). Approvers sign off from the Approvals queue, grouped by urgency.
- Schedule → Pay. Approved bills get a payment scheduled and then marked paid (simulated rail), with a hint to schedule a vendor's open bills together in one pass.
- Monitor. The Dashboard (scorecards, needs‑review, expected‑but‑missing bills, cash‑out by week, activity feed) and the AP Aging report keep the whole thing under control.
- AI Bill Review — the invoice isn't just OCR'd, it's scrutinized against the vendor's history. Each flag carries a plain‑English reason + severity. This directly attacks the #1 hauler AP pain: hidden markups in tipping/fuel invoices.
- Invoice‑centric cockpit — image + coding + a shared, timestamped, auditable comment thread on one screen (the Stampli pattern), so the "why is this surcharge higher?" conversation lives on the bill instead of in scattered email.
- AP controls — code-side duplicate detection (warns when the same vendor + invoice # arrives twice) and an approval-rules engine that routes large bills to a senior role (over $10k → Approver, over $50k → Controller), enforced server-side and surfaced as a gate in the cockpit.
Beyond the core, the build also ships a multi-entity switcher (the topbar org switch scopes every screen — each entity has its own vendors, bills and people), multi-select table filters and saved views, recurring schedules (draft the next bill on a cadence), line-item splits (one line across multiple GL accounts) with reusable allocation templates, expense vs. item line coding, a simulated AP forwarding inbox, a Settings page (chart of accounts + recurring schedules + approval rules), bulk submit/approve/schedule/pay over a table selection, real CSV export, and a ⌘K palette + keyboard shortcuts (c for a new bill, g-then-key to navigate).
| Priority | Workflow | Why |
|---|---|---|
| 1 | Approve / reject with a real state machine + audit log | This is where the business logic and the controls live — the heart of AP. |
| 2 | AI bill review (flags from vendor history) | The headline differentiator; turns intake into anomaly detection. |
| 3 | Invoice cockpit + collaboration | The highest‑value UX pattern from the competitor benchmark (Ramp/Brex/Bill.com/Tipalti/Melio/Stampli). |
| 4 | Schedule / mark paid | Closes the loop; simulated (not a real banking rail). |
| 5 | Dashboard + AP aging | The "is everything under control?" surface. |
Stack: Next.js 16 (App Router) · React 19 · TypeScript · Tailwind v4 · Drizzle ORM · Neon Postgres · next-themes (light + dark). One full‑stack repo, deploys to Vercel.
- Money is integer cents, everywhere. No floats in financial math; formatted in
lib/format.ts. - One lifecycle state machine (
lib/status.ts). A single allowed‑transitions map guards every status change (assertTransition), andderiveDisplayStatus()computes the UI pill fromstatus + reviewStatus + dueDate + payment(overdue/due‑soon/needs‑review are derived, not stored). This keeps the model honest and the pills consistent. - Server Components read, Server Actions write. Pages are
force-dynamicserver components that query Drizzle (lib/queries/*) and pass typed data to'use client'views. Mutations are Server Actions (lib/actions/*) that validate input with zod (lib/validation.ts), enforce the state machine (transition + edit guards), write the change, append an append‑only audit row, andrevalidatePath. User-facing failures (the approval gate, the non-editable guard) return a typed{ ok, error }result (lib/result.ts) so the real message reaches the user in production — Next.js masks thrown server-action errors there. The client usesuseTransition+router.refresh()for optimistic UX backed by the real result. - Error handling is a system, not an afterthought. The user-facing write paths run through one wrapper (
runActioninlib/result.ts): expected, user-safe failuresthrow new ActionError(msg)and surface verbatim; anything unexpected is logged server-side and returns a specific fallback ("Couldn't save this bill — please try again") — never a leaked stack or a flat "Something went wrong". (The remaining writes validate with zod and surface failures through the same client boundary.) Bulk import/advance harden per row — one bad row is skipped and logged, never sinking the batch. On the client, every route has an error boundary (app/(app)/error.tsx), plusnot-found.tsxand a rootglobal-error.tsx, so a render error degrades to a graceful in-shell screen instead of Next's white page. It all follows one small design system — name the cause, then the fix · no dead ends · keep the shell · fail at the smallest scope: full-page states, a tone-aware toast with Retry, an inline payment-failed recovery card in the cockpit, and a non-blocking QuickBooks sync banner on the dashboard. - Demo auth = a role switcher, on purpose. No login. The topbar "viewing as" (AP Clerk / Approver / Controller) sets a cookie that becomes the actor for every action, so an evaluator experiences the multi‑role approval flow instantly — no signup, no email verification. Users/roles are still modeled in the DB, so swapping in real auth (Clerk) later is a thin integration, not a rewrite. Real auth adds friction and no visible product value here.
- AI Bill Review = reasoning, not just OCR. The Capture flow sends the invoice to Claude (vision + structured output) and feeds the vendor's prior bills in as context, so Claude can reason about what's anomalous. Deterministic checks (duplicate, vendor‑bank‑change, missing‑recurring) stay in code — cheaper and exact. Falls back to a deterministic mock when no
ANTHROPIC_API_KEYis set, so the hosted demo never breaks. - Stable demo clock.
lib/demo.tspins "today" to the seed's reference date so overdue / aging / time‑ago are stable regardless of when the demo runs.
Data model (db/schema.ts, 15 tables): organizations, users, vendors, glAccounts, bills, billLineItems, lineItemSplits (GL splits), billFlags (AI review), approvalEvents, payments, billComments (collaboration), activityLog (audit), recurringBillTemplates, savedViews, allocationTemplates. FKs + Drizzle relations throughout.
- Real auth — replaced by the demo role switcher (above); no login. Multi-entity is real, though: the topbar switcher persists the active org in a cookie and every query scopes by it across three seeded entities — what's out is auth-isolated multi-tenancy (a login per tenant), which is an auth concern, not a data-model one.
- A real payment rail / bank integration — payments are simulated; not where the product judgment is, and out of scope for a take‑home.
- 2‑way accounting sync (QuickBooks/NetSuite) — realistic in Ramp but not meaningfully mockable in the timebox; the activity feed shows a representative "synced from QuickBooks" event, and the dashboard carries a non-blocking sync-failure banner so the failure path is designed, not just the happy one.
- Global/FX mass payments + a tax engine (1099/W‑8/VAT) — deliberately dropped after the competitor benchmark: overkill for a US‑domestic hauler.
- Historical series for scorecard deltas/sparklines — the scorecard values are real (live DB aggregates); the small delta % and sparkline are illustrative, since there's no time‑series table yet.
- Live ERP / accounting import — the bills Import ingests a CSV (downloadable template → a validating preview that resolves vendors by name and flags bad rows → draft bills); a 2-way QuickBooks/NetSuite pull stays out of scope per the sync note above. Everything from the plan shipped — CSV import, saved views, recurring schedules, line-item splits + allocation templates, expense/item coding, the AP forwarding inbox, a Settings page, bulk actions, duplicate detection, the approval-rules engine, and keyboard shortcuts.
Requirements: Node 20.9+, a Neon Postgres database (free at neon.tech).
npm install
cp .env.example .env.local # then fill in DATABASE_URL (and optionally ANTHROPIC_API_KEY)
npm run db:push # create tables in your Neon DB
npm run db:seed # load the Summit Waste demo data
npm run dev # http://localhost:3000.env.local:
DATABASE_URL="postgresql://...neon.tech/...?sslmode=require"
ANTHROPIC_API_KEY="" # optional — Capture falls back to a mock parse without it
Scripts: dev · build · start · lint · typecheck · test · test:e2e · db:push · db:seed · db:generate · db:migrate · db:studio.
Live at settle-qweffys-projects.vercel.app.
- Push this repo to GitHub and Import it in Vercel.
- Set env vars in Vercel:
DATABASE_URL(your Neon string) and optionallyANTHROPIC_API_KEY. - Deploy. Run
npm run db:push && npm run db:seedonce against the same database to populate it.
Three layers, weighted toward end-to-end coverage of the real workflows. A manual walkthrough of every feature (each with its matching spec) is in docs/E2E-FLOWS.md.
| Layer | Tool | What it covers |
|---|---|---|
| Unit | Vitest | Pure domain logic, no DB or network — the lifecycle state machine (lib/status.ts), the approval-rules engine (lib/approval-rules.ts), the zod input schemas (lib/validation.ts), the centralized action-result wrapper (lib/result.ts), money/date formatting, and the AI invoice parser's deterministic fallback. ~55 tests, <2s. |
| Integration | (folded into e2e) | The e2e layer drives the real Server Actions and database through the UI, so it is the integration layer. A separate mock-DB suite would be brittle against Drizzle and low-signal, so it's deliberately omitted. |
| E2E | Playwright | Key user flows against a production build with a real, seeded database: OCR capture → persisted bill, manual bill creation, CSV import (validating preview → draft bills), saved views (capture → restore filter state), the $50k approval gate (role-gated, server-side), the post-approval edit guard, the AP forwarding inbox, allocation templates, expense/item line coding, bulk mark-paid, duplicate detection, the vendor directory, navigation + 404s, and the error-state surfaces (not-found, the payment-failed recovery card, the QuickBooks sync banner). 14 specs / 19 cases. |
npm run test # unit (Vitest)
npm run test:e2e # e2e (Playwright) — needs a database (below)The e2e database. The app uses Neon's HTTP driver, which speaks HTTP rather than the Postgres wire protocol — so e2e runs against a self-contained Neon HTTP proxy (a Postgres container + local-neon-http-proxy) rather than a plain local Postgres. No external database or secrets needed:
docker compose -f docker-compose.test.yml up -d --wait # Postgres applies the schema on first boot
export DATABASE_URL=postgres://postgres:postgres@db.localtest.me:5432/main
npm run db:seed
npm run test:e2eThe schema is mounted into the Postgres container's docker-entrypoint-initdb.d, so it's created the moment the container boots — drizzle-kit (whose neon-http driver can't reach a plain Postgres) never enters the loop. Local dev against real Neon still uses npm run db:push.
CI. Every push runs three parallel jobs — verify (typecheck · lint · build), unit, and e2e. The e2e job spins up the Neon proxy, seeds a fresh database, builds, and runs Playwright, uploading the HTML report as an artifact. Keeping e2e in its own job means an infra hiccup never reds the core gates. (The e2e suite already earned its keep: it caught a real /dashboard 500 — an activity type the icon map didn't cover — before it could ship.)
app/(app)/ route group with the shared shell (sidebar + topbar + ⌘K) as the layout
dashboard|bills|bills/cockpit|approvals|payments|vendors|reports|capture|settings/
page.tsx server component: queries the DB, renders the view
*-view.tsx 'use client' view: receives typed data by props
*.css screen-scoped styles (under .screen-<name>)
components/ app-shell, icon, theme-provider
db/ schema.ts, index.ts (Neon client), seed.ts
lib/
queries/ read-side DB access (Server Components)
data/ shared view types + screen constants (nav, status maps…)
actions/ write-side Server Actions (bills, ocr, vendors, recurring, views, allocation-templates, settings, session)
status.ts state machine + display-status derivation
result.ts runAction + ActionError (centralized error handling)
validation.ts zod input schemas
approval-rules.ts format.ts dates.ts demo.ts
app/styles/ tokens.css (design system) + shell.css
Built design‑first: the design system and all nine screens were designed in Claude Design, then ported pixel‑faithfully into the app (tokens + screen‑scoped CSS), and finally wired to the live database.






