Skip to content

Repository files navigation

Duely

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).


Table of contents

  1. What it does
  2. Tech stack
  3. System architecture
  4. Request & auth flow (multi-tenancy)
  5. Data model (ER diagram)
  6. The agent pipeline
  7. Integrations
  8. Project structure
  9. Getting started
  10. Environment variables
  11. Scripts
  12. Demo guide
  13. Testing
  14. Security & data handling
  15. Known limitations & roadmap

What it does

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.

Tech stack

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
Email 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)

System architecture

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
Loading

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().


Request & auth flow (multi-tenancy)

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
Loading

Data model (ER diagram)

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
    }
Loading

(Also present: Installment, User. Integration, ConfigEntry use composite keys.)


The agent pipeline

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"]
Loading

1. Morning queue — decide who to chase

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"]
Loading

2. Drafting & sending — act

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"]
Loading

3. Reply loop — react

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)
Loading

4. Voice agent — call & negotiate

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
Loading

Because Vapi runs its own LLM + telephony, the voice agent works even when the app's Gemini key is unavailable.

5. "Watch the agent work" (live stream)

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 }
Loading

Integrations

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.

Project structure

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

Getting started

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:3000

Run only ONE npm run dev at a time, and never next build while it's running. Two dev servers (or a build during dev) corrupt .next and 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.


Environment variables

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

Scripts

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.

Demo guide

A 60-second "show the judges" flow:

  1. Land on the dashboard — stat numbers count up, the collections sparkline glows.
  2. "▶ 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).
  3. ☎️ "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.
  4. ⌘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).

Testing

  • 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/run SSE stream verified end-to-end.

Security & data handling

  • Tenant isolation on every query (tenantId scoping; 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_TO keeps all outbound mail in one safe inbox until a sending domain is verified.

Known limitations & roadmap

  • 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-email handler 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/tick exists 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"]
Loading

About

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.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages