An autonomous accounts-receivable (AR) agent for small businesses.
Duely reads your overdue invoices, ranks who to chase by risk, drafts the outreach, sends it, handles the replies (email and live phone calls), negotiates payment plans, schedules reminders around promised dates, and stops the moment an invoice is paid — turning collections into a two-minute-a-day approval queue.
Built as a full-stack, multi-tenant product: marketing site, auth, onboarding, a cinematic dashboard, a real backend pipeline, and four live integrations (Gemini, Resend, Razorpay, Vapi).
- What it does
- Tech stack
- System architecture
- Request & auth flow (multi-tenancy)
- Data model (ER diagram)
- The agent pipeline
- Integrations
- Project structure
- Getting started
- Environment variables
- Scripts
- Demo guide
- Testing
- Security & data handling
- Known limitations & roadmap
| Capability | Description |
|---|---|
| Ingest | Import invoices via CSV or pull them live from Razorpay. |
| Risk & ranking | Scores every overdue invoice (payment history, days overdue, amount at risk) into low / medium / high and ranks the day's queue. |
| Drafting | Generates a tailored outreach email per invoice with Gemini (tone scales with how overdue it is); falls back to templates if the LLM is unavailable. |
| Sending | Delivers via Resend, enforcing quiet hours and stop-on-payment. |
| Reply handling | Any customer reply (email or call) pauses the chase, is classified, and the agent proposes the next action for one-click approval. |
| Negotiation | Promise-to-pay, payment plans, disputes, and "already paid" claims — each wired to a real ledger effect. |
| Voice agent | Places a real outbound phone call (Vapi) that negotiates the invoice out loud, then writes the structured outcome back to the dashboard. |
| Reminders | A promised payment date schedules an automatic follow-up that re-enters the queue. |
| Insights & audit | Outstanding, overdue, DSO, aging, collections trend, and a complete append-only audit trail. |
| Multi-tenant | Every row is tenant-scoped; users are fully isolated. |
| Layer | Choice |
|---|---|
| Framework | Next.js 15 (App Router, RSC) + React 19 + TypeScript |
| Styling | Tailwind CSS v3 + shadcn-derived primitives, custom dark "fintech" theme |
| Motion / charts | Framer Motion-free custom rAF count-ups, CSS keyframes, hand-rolled SVG charts (sparkline / area / donut) |
| Auth | Clerk (custom sign-in/up flows, OAuth, multi-tenant ready) |
| Database | Prisma 6 ORM + SQLite (file-based; swappable for Postgres) |
| LLM | Gemini 3.1 Pro (gemini-3.1-pro-preview) with deterministic template fallback |
| Resend | |
| Payments | Razorpay (test mode; per-tenant keys, encrypted) |
| Voice | Vapi (outbound calls + structured outcome extraction) |
| Secrets at rest | AES-256-GCM (lib/crypto.ts) |
flowchart TB
subgraph Client["Browser"]
LP["Marketing site /"]
AUTH["Auth /login /sign-up"]
DASH["Dashboard /dashboard/*<br/>Queue · Replies · Invoices · Insights · Activity · Settings"]
end
subgraph Next["Next.js App (App Router)"]
MW["middleware.ts<br/>Clerk · route protection"]
RSC["Server Components<br/>(read via services)"]
API["Route Handlers /api/*<br/>(mutations + SSE)"]
TEN["lib/tenant.ts<br/>getTenantId()"]
end
subgraph Domain["Domain services (lib/services)"]
ING["ingest"]
RISK["risk"]
QUEUE["queue"]
OUT["outreach"]
SEND["sender"]
REPLY["replies"]
VOICE["voice"]
PAY["payments"]
INS["insights / trends / ledger"]
AUD["audit"]
INTEG["integrations"]
CFG["config"]
end
DB[("SQLite via Prisma<br/>14 tenant-scoped tables")]
subgraph Ext["External services"]
CLERK["Clerk (auth)"]
GEM["Gemini (LLM)"]
RES["Resend (email)"]
RZP["Razorpay (payments)"]
VAPI["Vapi (voice)"]
end
Client --> MW --> RSC & API
RSC --> Domain
API --> Domain
RSC --> TEN
API --> TEN
TEN --> CLERK
Domain --> DB
OUT --> GEM
REPLY --> GEM
SEND --> RES
ING --> RZP
INTEG --> RZP
VOICE --> VAPI
MW --> CLERK
Read/write split: dashboard pages are React Server Components that read directly
through domain services (fast, no client fetch). All mutations and the live agent
stream go through /api/* route handlers, and the client re-syncs with
router.refresh(). Every entry point resolves the tenant first via getTenantId().
Tenancy key is tenantId = orgId ?? userId (Clerk-Organizations-ready), with a
DEV_TENANT_ID fallback in development. Every table carries tenantId and every
query is scoped to it; cross-tenant reads/writes use findFirstOrThrow({ id, tenantId })
so a wrong tenant simply gets "not found".
sequenceDiagram
participant U as Browser
participant MW as middleware (Clerk)
participant P as Page / Route
participant T as getTenantId()
participant S as Service
participant DB as Prisma (SQLite)
U->>MW: request /dashboard or /api/*
MW->>MW: clerkMiddleware()
alt protected & signed out
MW-->>U: redirect /login
else authenticated
MW->>P: continue
P->>T: resolve tenant
T->>T: orgId ?? userId ?? DEV_TENANT_ID
T-->>P: tenantId
P->>S: call(tenantId, ...)
S->>DB: where { tenantId, ... }
DB-->>S: rows (this tenant only)
S-->>P: data
P-->>U: render / JSON
end
SQLite has no enums, so status-ish columns are strings validated at the boundary by
the unions in lib/contracts/types.ts. Tables are prefixed ar_dg_* (AR domain),
sys_dg_* (system), auth_dg_* (auth).
erDiagram
Customer ||--o{ Invoice : has
Customer ||--o{ Reply : sends
Customer ||--o{ PaymentPlan : on
Customer ||--o{ PromiseToPay : makes
Invoice ||--o{ Message : "outreach"
Invoice ||--o{ Reply : "replies to"
Invoice ||--o{ RiskScore : scored
Invoice ||--o{ QueueItem : queued
Invoice ||--o{ PaymentPlan : "plan for"
Invoice ||--o{ PromiseToPay : "promise for"
AgentRun ||--o{ QueueItem : produces
QueueItem ||--o| Message : drafts
Message ||--o{ Reply : "replied to"
Reply ||--o{ PaymentPlan : triggers
Reply ||--o{ PromiseToPay : triggers
PaymentPlan ||--o{ Installment : splits
Customer {
string id PK
string tenantId
string name
string email
string source
}
Invoice {
string id PK
string tenantId
int amount
int balance
string status
datetime dueAt
int cadenceStep
datetime nextActionAt
string cadencePausedReason
}
QueueItem {
string id PK
string tenantId
int rank
string tone
int riskScore
string status
string messageId FK
}
Message {
string id PK
string tenantId
string direction
string status
string sentVia
}
Reply {
string id PK
string tenantId
string intent
float confidence
string status
string proposedAction
}
PromiseToPay {
string id PK
datetime promisedAt
string status
}
PaymentPlan {
string id PK
int totalAmount
string status
}
Integration {
string tenantId PK
string provider PK
string keyId
string secretEnc
}
AuditLog {
string id PK
string tenantId
string actor
string action
}
ConfigEntry {
string tenantId PK
string key PK
string value
}
(Also present: Installment, User. Integration, ConfigEntry use composite keys.)
The product is one autonomous loop: perceive → decide → act → react.
flowchart LR
A["Ingest<br/>CSV / Razorpay"] --> B["Risk score<br/>& rank"]
B --> C["Morning queue"]
C --> D["Draft (Gemini)"]
D --> E{"Approve?"}
E -->|"approve / batch"| F["Send (Resend)<br/>quiet hours + stop-on-pay"]
E -->|skip| C
F --> G["Customer replies<br/>(email / voice)"]
G --> H["Pause chase<br/>+ classify intent"]
H --> I{"Outcome"}
I -->|promise| J["Log promise<br/>+ schedule reminder"]
I -->|plan| K["Create payment plan"]
I -->|dispute| L["Flag + pause"]
I -->|paid| M["Mark paid + stop"]
J --> C
M --> N["Done"]
buildMorningQueue(tenantId) (lib/services/queue.ts)
flowchart TB
S["Open / partial invoices<br/>not paused, due for action"] --> F{"overdue ≥ grace?"}
F -->|no| SKIP["skip"]
F -->|yes| H["getPaymentHistory()"]
H --> R["scoreInvoice() → 0-100"]
R --> BAND["band: ≥60 high · ≥30 medium · else low"]
BAND --> T["tone from days overdue<br/>(0-7 gentle · 8-30 firm · 31+ urgent)"]
T --> RANK["sort by risk, then amount"]
RANK --> Q["AgentRun + ranked QueueItems<br/>each with a plain-English reason"]
outreach.ts drafts with Gemini; sender.ts enforces the guardrails:
flowchart TB
A["approveQueueItem()"] --> P{"invoice paid?"}
P -->|yes| STOP["cancel · stop-on-payment"]
P -->|no| Q{"quiet hours?"}
Q -->|yes| SCHED["status = scheduled<br/>(sent later by scheduler tick)"]
Q -->|no| SEND["Resend → status = sent<br/>(routed to EMAIL_OVERRIDE_TO in dev)"]
SEND --> ADV["advance cadence step<br/>set next nextActionAt"]
Any inbound reply stops the agent so it never talks over a responding customer.
sequenceDiagram
participant C as Customer
participant ING as ingestReply()
participant DB as Ledger
participant H as Human (you)
participant EX as executeProposal()
C->>ING: reply (email sim / voice transcript)
ING->>DB: pause cadence (customer_reply)<br/>cancel in-flight messages<br/>stop queue items
ING->>ING: classify intent + propose action
ING->>DB: store Reply (status: proposed)
Note over H: appears on Replies screen + badge
H->>EX: approve
alt promise_to_pay
EX->>DB: log promise · un-pause · reminder = promisedDate + 1d
else payment_plan
EX->>DB: create plan + installments · pause
else dispute
EX->>DB: status = disputed · pause
else paid_claim
EX->>DB: markInvoicePaid · stop
end
EX->>DB: Reply status = handled (+ audit)
The killer feature: a real outbound call via Vapi. No public webhook needed — the result is polled, so it works on localhost.
sequenceDiagram
participant UI as Call modal
participant API as /api/voice/call
participant V as Vapi
participant Ph as Customer phone
participant DB as Ledger
UI->>API: POST { invoiceId, toNumber }
API->>V: POST /call (inline assistant + invoice context)
V->>Ph: dials & negotiates (LLM + voice)
loop poll every 2.5s
UI->>API: GET /api/voice/call/{id}
API->>V: GET /call/{id}
V-->>API: status (+ transcript when ended)
end
Note over V: call ends → structured outcome extracted
UI->>API: POST /api/voice/call/{id} (apply)
API->>DB: applyCallOutcome() → promise / plan / dispute / paid<br/>+ Reply row (channel: voice) + audit
API-->>UI: outcome + transcript
Because Vapi runs its own LLM + telephony, the voice agent works even when the app's Gemini key is unavailable.
A demo-grade Server-Sent Events run that executes the real pipeline and streams
each step (/api/agent/run):
sequenceDiagram
participant UI as Console (EventSource)
participant SSE as /api/agent/run
participant DB as Ledger
UI->>SSE: GET (text/event-stream)
SSE-->>UI: step "Scanning ledger" → "21 open · 18 overdue"
SSE-->>UI: step "Scoring risk" → buildMorningQueue → "ranked 14"
SSE-->>UI: step "Drafting"
loop each invoice
SSE->>DB: draftForQueueItem()
SSE-->>UI: draft { customer, band, balance, n/total }
end
SSE-->>UI: done { drafted, atRisk }
| Service | Used for | Notes |
|---|---|---|
| Clerk | Auth, sessions, OAuth | Custom forms; tenant = orgId ?? userId. |
| Gemini 3.1 Pro | Draft emails, classify replies | Graceful template/keyword fallback on error or spend cap. |
| Resend | Outbound email | Dev routes everything to EMAIL_OVERRIDE_TO; needs a verified domain for real customer sends. |
| Razorpay | Pull invoices, webhooks | Per-tenant keys stored AES-256-GCM encrypted; falls back to env keys in dev. |
| Vapi | Outbound voice calls | Inline assistant; structured outcome → ledger. |
Duely/
├── README.md ← this file
└── web/ ← the Next.js app
├── app/
│ ├── (marketing) landing page + sections
│ ├── login, sign-up Clerk custom auth
│ ├── dashboard/ product UI
│ │ ├── page.tsx Today's Queue (home) + onboarding empty-state
│ │ ├── replies/ reply handling + simulate
│ │ ├── invoices/ ledger tables (invoices + customers)
│ │ ├── insights/ metrics + charts
│ │ ├── activity/ audit timeline
│ │ └── settings/ config + integrations
│ └── api/ route handlers (see list below)
├── components/
│ ├── dashboard/ shell, sidebar, queue, charts, motion,
│ │ command-palette, agent-run-console, voice-call
│ └── ui/ shadcn-derived primitives (v3-ported)
├── lib/
│ ├── services/ domain logic (see below)
│ ├── contracts/types.ts status unions / DTOs
│ ├── config.ts per-tenant settings + defaults
│ ├── tenant.ts getTenantId()
│ ├── crypto.ts AES-256-GCM
│ ├── llm.ts Gemini wrapper + fallbacks
│ └── money.ts currency/date formatting
├── prisma/schema.prisma 14 models
└── scripts/seed.ts demo ledger via the real ingest pipeline
Domain services (lib/services/): ingest, risk, queue, outreach,
sender, replies, voice, payments, insights, trends, ledger, activity,
integrations, audit, demo.
API routes (app/api/):
agent/run queue voice/call
audit queue/batch-approve voice/call/[id]
badges queue/draft voice/candidates
ingest/csv queue/items/[id]/approve integrations
ingest/razorpay queue/items/[id]/skip integrations/razorpay
insights replies onboarding/demo
invoices/[id]/mark-paid replies/[id]/execute settings
scheduler/tick replies/simulate webhooks/razorpay
dev/inject-reply dev/mark-paid
cd web
npm install
# create web/.env (see Environment variables below), then:
npx prisma db push # create the SQLite schema
npm run db:seed # load a realistic demo ledger
npm run dev # http://localhost:3000Run only ONE
npm run devat a time, and nevernext buildwhile it's running. Two dev servers (or a build during dev) corrupt.nextand the page renders unstyled.
Sign in at /login. A brand-new tenant lands on onboarding (load demo / upload
CSV / connect Razorpay); once it has invoices it shows the Queue.
web/.env:
# Database
DATABASE_URL="file:./dev.db"
# Auth (Clerk)
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=...
CLERK_SECRET_KEY=...
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/login
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/dashboard
NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL=/dashboard
DEV_TENANT_ID=... # dev fallback tenant when unauthenticated
# LLM
GEMINI_API_KEY=...
# Email (Resend)
RESEND_API_KEY=...
EMAIL_FROM="Duely <onboarding@resend.dev>"
EMAIL_OVERRIDE_TO=you@example.com # all dev sends routed here
# Payments (Razorpay, test mode)
RAZORPAY_KEY_ID=...
RAZORPAY_KEY_SECRET=...
RAZORPAY_WEBHOOK_ID=...
RAZORPAY_WEBHOOK_SECRET=...
# Secrets-at-rest (32-byte base64) for per-tenant integration keys
ENCRYPTION_KEY=...
# Voice agent (Vapi) — optional; feature is inert until set
VAPI_API_KEY=...
VAPI_PHONE_NUMBER_ID=...
VAPI_VOICE_ID=Neha| Command | What it does |
|---|---|
npm run dev |
Start the dev server (one at a time!). |
npm run build |
Production build. |
npm run start |
Serve the production build. |
npm run db:seed |
Seed a realistic demo ledger through the real ingest pipeline. |
npm run db:reset |
Reset the DB and re-seed. |
SEED_TENANT_ID=<id> npm run db:seed |
Seed a specific tenant. |
A 60-second "show the judges" flow:
- Land on the dashboard — stat numbers count up, the collections sparkline glows.
- "▶ Watch the agent work" — a live console streams: scanning → scoring risk → drafting each email one-by-one → "N emails ready, ₹X recovered." (real backend work).
- ☎️ "Call a customer" — a real phone rings (on speaker); the AI negotiates the invoice; the customer says "I'll pay Friday" → the promise, transcript, and paused chase appear live on the dashboard.
- ⌘K command palette → jump to Insights (the collections line draws itself in, aging donut animates) and Replies ("Simulate a customer reply" to show the email side of the loop).
- Type safety:
npx tsc --noEmit(clean). - Build:
npx next build(green; run with the dev server stopped). - Integration suite: a throwaway-tenant harness exercises the full reply loop
(pause → classify → promise → reminder re-queue, dispute, paid, plan), the read
services, settings round-trip, encrypted-creds round-trip, and tenant isolation
(a second tenant sees nothing). Run with the LLM disabled for deterministic
classification fallbacks:
GEMINI_API_KEY= npx tsx <itest>.ts. - Live checks performed: a real Resend email delivered to
EMAIL_OVERRIDE_TO; the/api/agent/runSSE stream verified end-to-end.
- Tenant isolation on every query (
tenantIdscoping; cross-tenant access → not found). - Secrets at rest: per-tenant provider keys (e.g. Razorpay) are AES-256-GCM
encrypted (
lib/crypto.ts,ENCRYPTION_KEY); only a masked label is ever returned. - Webhook verification: Razorpay webhooks are HMAC-verified.
- Stop-on-payment is a system-locked config (always on) — outreach halts the moment an invoice is paid.
- Quiet hours prevent sends outside an allowed window.
- Audit trail: every agent/human/system action is appended to
AuditLog. - Dev email guard:
EMAIL_OVERRIDE_TOkeeps all outbound mail in one safe inbox until a sending domain is verified.
- Real inbound email isn't wired (needs an owned domain + MX records + a deployed
webhook). Today inbound is demonstrated via the "simulate a customer reply" panel
and the voice channel. An
/api/webhooks/inbound-emailhandler is the drop-in. - Gemini spend cap: if the LLM key is over budget, drafting/classification fall back to deterministic templates/keywords (nothing breaks). Voice is unaffected (own LLM).
- Razorpay OAuth ("Connect with Razorpay") is deferred behind KYC; the paste-your-own encrypted-keys flow is live.
- SQLite is great for local/demo; swap the Prisma datasource to Postgres for production/concurrency.
- Scheduler:
/api/scheduler/tickexists for quiet-hours flushes and daily queue builds; wire it to a cron in production. - Vapi free trial may require verifying the destination number before outbound dialing.
Duely — get paid without nagging your customers.
flowchart LR
I["📥 Invoices"] --> A["🤖 Agent"] --> O["✅ Paid"]