Skip to content

ByteStudiosAus/settlemate

Repository files navigation

SettleMate

An AI collections agent for Australian tradies and SMEs, built on Pinch Payments.

SettleMate lets an AI agent — voice or text, autonomous or human-in-the-loop — chase overdue invoices, set up payment plans, and take payments on a business's behalf, without ever holding a raw credential or an unbounded blank cheque. Every action a model can take is expressed as a small, typed tool; every tool call is checked against a per-agent spending grant (per-transaction limit, daily limit, allowed-tool list) before it touches money; every attempt — allowed or refused — is written to an append-only audit log. The agent can propose and act; it can never exceed what the business owner explicitly authorised.

It's built end-to-end on Pinch Payments (getpinch.com.au), an Australian direct-debit and card payment processor: payer/mandate management, scheduled payments, native recurring payment plans, and a signed webhook feed for settlement events — see "How Pinch is used" below.

The problem

Chasing overdue invoices is one of the most common, most annoying jobs in a small business — and one of the most natural to hand to an AI agent (a voice caller, a chat assistant, an autonomous biller). But "let an LLM touch our merchant account" is a correct and immediate objection: LLMs hallucinate, misread numbers, and can be talked into things by a plausible-sounding customer on the phone. SettleMate's answer isn't "trust the model" — it's a permissions engine the model cannot reason its way around, sitting between every tool call and Pinch's real payment rails.

Architecture

┌─────────────────┐   ┌──────────────────┐   ┌───────────────────────┐
│  MCP client      │   │  ElevenLabs      │   │  Dashboard / any      │
│  (Claude, etc.)  │   │  voice agent     │   │  REST client          │
└────────┬─────────┘   └────────┬─────────┘   └───────────┬───────────┘
         │ POST /mcp            │ POST /voice/*            │ /admin/*, /api/*
         ▼                      ▼                          ▼
┌──────────────────────────────────────────────────────────────────────┐
│                     Cloudflare Worker (this repo)                    │
│                                                                        │
│   9 agent-facing tools  ──────────►  permissions.enforce()           │
│   (src/tools.ts)                     grant exists → active →        │
│                                       tool allowed → per-tx limit →   │
│                                       daily limit → execute → audit   │
│                                              │                        │
│                                              ▼                        │
│                                        src/pinch.ts                  │
│                                  (typed Pinch API client)             │
└───────────────────────────────────┬──────────────────────────────────┘
                                     │ OAuth client-credentials
                                     ▼
                        ┌─────────────────────────┐
                        │   Pinch Payments API      │
                        │   (AU direct debit/card)  │
                        └────────────┬──────────────┘
                                     │ signed webhook events
                                     ▼
                        POST /webhooks/pinch (this repo)
                        → settlement lifecycle → audit log

Cloudflare Workers, TypeScript strict throughout, zero servers to manage, KV for all state (no separate database). Five KV namespaces (GRANTS, SPEND, AUDIT, TOKENS, IDEMPOTENCY) hold everything: agent grants, daily spend counters, the audit trail, a cached Pinch OAuth token, and idempotency guards.

The permissions engine (src/permissions.ts)

The whole trust model in one function, enforce():

grant exists? → grant active? → tool in allowedTools? → amount ≤ per-tx limit?
→ amount + today's spend ≤ daily limit? → execute() → record spend → audit

Every money-touching tool goes through this — no exceptions, no bypass. A refusal at any step returns a structured { declined: true, reason, limitCents, attemptedCents } object the calling agent can relay in plain language ("that's over my $2,000 per-payment authority") instead of a stack trace. create_payment_plan re-checks the same chain once against a single preflight snapshot rather than per-instalment (it creates one native Pinch recurring-payment object, not N separate payments — see below) but the guarantee is identical: nothing is created that the grant wouldn't allow.

Four surfaces, one tool layer

  • MCP server (POST /mcp) — the 9 tools over JSON-RPC 2.0, for any MCP-speaking client (Claude Desktop, Claude Code, a custom agent harness).
  • Voice webhooks (/voice/*) — the bridge for an ElevenLabs Conversational AI phone agent: a generic tool-invocation endpoint that runs the exact same enforce() path as MCP and always returns a read-aloud speech string, plus call-start context injection so the agent never has to guess a customer's name, balance, or its own spending authority.
  • REST admin / dashboard facade (/admin/*, /rest/*, /api/*) — grant management, the audit log, recovered-payments totals, ad-hoc debtor/invoice creation, and (via ElevenLabs) triggering an outbound collections call.
  • Pinch webhooks (POST /webhooks/pinch) — receives signed settlement events from Pinch and turns them into audit-log entries (settled / failed / processing), so the dashboard shows real payment lifecycle, not just agent actions.

Every surface bottoms out in the same nine tools — an agent never gets a wider blast radius by switching transport.

The 9 agent-facing tools

Tool What it does
create_payer Create a customer in Pinch
add_bank_account Attach a bank-account direct-debit mandate to a payer
create_invoice Schedule a single future debit
charge_now Charge a payer's account immediately (realtime)
create_payment_plan Split a total into 2–6 instalments — ONE native Pinch Plan + Subscription
cancel_payment_plan Cancel every remaining instalment of a plan in one call
check_payment_status Look up a payment's current status
list_overdue List overdue/dishonoured payments, scoped to a payer or global
get_agent_limits Introspect the calling agent's own remaining authority

Guardrails baked into create_payment_plan specifically (the highest-risk tool, since it commits to a recurring schedule): a startDate in the past is refused outright, and the requested total is sanity-checked against the payer's actual overdue balance — if it's off by more than 20%, nothing is created and the agent gets back a speakable clarification ("that plan totals $570 but the balance is $880 — should I set it for the full amount?") instead of silently committing to the wrong figure.

How Pinch is used

Pinch (docs.getpinch.com.au) is an Australian payment processor — direct debit (bank account) and card, OAuth client-credentials auth, sandbox base https://api.getpinch.com.au/test. This project is built directly on top of it, not alongside it:

  • Payers (POST /payers) — every customer SettleMate can act on is a Pinch payer record (pyr_...).
  • Bank-account mandates (POST /payers/{id}/sources) — the direct-debit authorisation (src_...) a payer must have before any charge or scheduled payment can run against them.
  • Scheduled payments (POST /payments) — a single future-dated debit, used for one-off invoices (create_invoice).
  • Realtime payments (POST /payments/realtime) — an immediate charge (charge_now), with the sandbox's dishonour outcome surfaced back to the agent.
  • Native recurring payment plans (POST /plans + POST /subscriptions) — create_payment_plan doesn't loop N individual POST /payments calls; it creates ONE Pinch Plan (a fixedPayments schedule template — verified live that this, not recurringPayment, is what's needed to preserve an exact per-instalment amount with a remainder on the final instalment) and ONE Subscription binding it to the payer. Pinch generates every instalment payment itself. Cancelling the whole remaining schedule is a single DELETE /subscriptions/{id} call (cancel_payment_plan) instead of N individual cancellations.
  • Webhooks (POST /webhooks, registered once via the API — Pinch has no dashboard UI for this) — SettleMate's POST /webhooks/pinch verifies Pinch's pinch-signature: t=<ts>,v2=<hmac-sha256> header, maps each payment-referencing event to a settlement outcome (transferredsettled, dishonouredfailed, anything else → processing), and writes it to both a KV snapshot and the audit log. GET /admin/recovered prefers this webhook-fed snapshot when one exists (cheaper, reflects real settlement events) and falls back to a live Pinch scan otherwise.
  • Events / transfers (GET /events, GET /transfers/items/{id}) — the raw primitives behind the webhook feed and reconciliation, wrapped by src/pinch.ts.

Every Pinch fact this codebase relies on (auth quirks, response shapes, status vocabulary, date-validation limits, the Plans/Subscriptions behaviour above) was verified live against the sandbox — not taken on faith from the docs alone.

Test suite

103 tests across 6 suites, node:test + tsx, no test framework dependency. Pinch and ElevenLabs are stubbed at the fetch level with response shapes captured from real sandbox calls; KV is an in-memory mock. test/webhooks.test.ts exercises the real HMAC-SHA256 signing/verification path (constructs actual signatures with crypto.subtle, not a mock). test/index.test.ts proves the outer Worker try/catch actually catches async exceptions, not just synchronous ones — every route handler call is awaited specifically so a downstream throw can't leak past it as a raw exception.

npm test              # everything
npm run test:perms    # permissions engine
npm run test:tools    # the 9 tools + guardrails
npm run test:admin    # REST admin / dashboard facade
npm run test:voice    # ElevenLabs voice webhooks
npm run test:webhooks # Pinch webhook signature verification + event handling
npm run test:index    # Worker entry-point error handling
npm run typecheck     # tsc --strict, must pass clean

Setup

# 1. Install
npm install

# 2. Local secrets
cp .dev.vars.example .dev.vars
# then fill in real SANDBOX values — see .dev.vars.example for where each one
# comes from (Pinch dashboard, ElevenLabs dashboard, or generate your own).

# 3. Start the worker
npm run dev

# 4. Seed demo data (creates a grant + 3 payers + overdue invoices)
curl -X POST http://localhost:8787/admin/seed \
  -H "Authorization: Bearer <your ADMIN_KEY from .dev.vars>"

# 5. Run the test suite
npm test

# 6. Run the smoke test against your local worker
ADMIN_KEY=<your ADMIN_KEY> npm run smoke

# 7. Run the agent-to-agent demo (two Claude agents transacting through MCP)
ANTHROPIC_API_KEY=<your key> ADMIN_KEY=<your ADMIN_KEY> npm run a2a
ANTHROPIC_API_KEY=<your key> ADMIN_KEY=<your ADMIN_KEY> npm run a2a -- --scenario=blocked

ANTHROPIC_API_KEY is read directly from the shell (not .dev.vars) — it's only used by the local agents/a2a.ts demo harness, never by the deployed Worker.

Demo Day Runbook

Exact, copy-pasteable sequence for demo mornings: deploy → reset → verify. Run top to bottom. Replace <PLACEHOLDERS>.

1. Deploy

wrangler deploy
# Note the production URL it prints, e.g. https://<your-worker-name>.<subdomain>.workers.dev
export PROD_URL=https://<your-worker-name>.<subdomain>.workers.dev

2. Set production secrets (skip any already set)

wrangler secret put PINCH_SECRET_KEY               # paste sk_test_...
wrangler secret put PINCH_PUBLISHABLE_KEY           # paste the Application ID (app_test_...)
wrangler secret put PINCH_MERCHANT_ID               # paste merchant id (reference only)
wrangler secret put VOICE_SECRET                    # paste any strong shared secret
wrangler secret put ELEVENLABS_API_KEY              # xi-api-key
wrangler secret put ELEVENLABS_AGENT_ID             # agent_...
wrangler secret put ELEVENLABS_PHONE_NUMBER_ID      # phnum_...
wrangler secret put PINCH_WEBHOOK_SECRET            # whsec_... from POST /webhooks

# ADMIN_KEY — generate a fresh one, NEVER a dev default:
openssl rand -hex 24                                 # copy the output
wrangler secret put ADMIN_KEY                       # paste the generated value
export ADMIN_KEY=<the-value-you-just-generated>

3. Reset demo state (ALWAYS, after every deploy)

Stale grants from a previous deploy silently break every tool (no_grant). The reset command cancels all scheduled payments for the demo payers (including any Soapbox invoices from the A2A demo), then re-seeds fresh overdue invoices. Payer IDs are stable across resets — the registry is preserved in KV so payerIds never change between runs.

curl -X POST "$PROD_URL/admin/reset" -H "Authorization: Bearer $ADMIN_KEY"

First deploy ever (no payers registered yet)? Use /admin/seed instead — it creates the payers and registers them. Every subsequent run uses /admin/reset.

# First deploy only:
curl -X POST "$PROD_URL/admin/seed" -H "Authorization: Bearer $ADMIN_KEY"

4. Verify in 60 seconds

# a) health (open route)
curl "$PROD_URL/health"

# b) get_agent_limits via MCP — confirms the seeded grant is live
curl -X POST "$PROD_URL/mcp?agentId=demo-agent" \
  -H "content-type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_agent_limits","arguments":{}}}'

# c) recovered total
curl "$PROD_URL/admin/recovered" -H "Authorization: Bearer $ADMIN_KEY"

# d) payments-by-status breakdown
curl "$PROD_URL/admin/payments/status" -H "Authorization: Bearer $ADMIN_KEY"

# e) voice surface — list seeded debtors (confirms VOICE_SECRET is live)
curl "$PROD_URL/voice/payers" -H "X-Voice-Secret: $VOICE_SECRET"

Optional — voice demo without hardcoding a payerId: /voice/tool already defaults "TARGET"/omitted payerId to the first seeded payer (Dazza Fittings). To point it at someone else instead: curl -X POST "$PROD_URL/admin/target" -H "Authorization: Bearer $ADMIN_KEY" -H "content-type: application/json" -d '{"payerId":"pyr_..."}'.

5. Run smoke against production

BASE_URL="$PROD_URL" ADMIN_KEY="$ADMIN_KEY" npm run smoke

6. A2A demo (both scenarios)

export ANTHROPIC_API_KEY=<your-key>

# normal — $1,200, within limits, charge succeeds
BASE_URL="$PROD_URL" ADMIN_KEY="$ADMIN_KEY" ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
  npx tsx agents/a2a.ts --scenario=normal

# blocked — $9,000, exceeds limit, Agent B relays the decline in natural language
BASE_URL="$PROD_URL" ADMIN_KEY="$ADMIN_KEY" ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
  npx tsx agents/a2a.ts --scenario=blocked

The daily limit is cumulative across runs. If --scenario=normal starts declining on the daily limit after several demo runs, re-seed (step 3) to reset the spend counter.

7. Outbound voice call demo (ElevenLabs)

curl -X POST "$PROD_URL/admin/call" \
  -H "Authorization: Bearer $ADMIN_KEY" -H "content-type: application/json" \
  -d '{"toNumber":"+614XXXXXXXX","payerId":"pyr_..."}'

Sets the target payer, primes the ElevenLabs agent's dynamic_variables (payer name, overdue balance, oldest overdue item, today's date, the agent's own spending authority) directly on the outbound-call request, and places the call.

8. Troubleshooting

Symptom Cause / Fix
Tool returns no_grant Grant missing/stale → re-seed (step 3)
401 on /admin/* Wrong/missing ADMIN_KEY → check the Authorization: Bearer header matches the deployed secret
charge_now → "Bank Account required…" Payer has no saved source → re-seed (or run add_bank_account for that payer first)
/admin/recovered empty / $0.00 Payments not settled yet → use sandbox time-travel to advance settlement, or scope with ?payerId=<pyr_...> to include realtime charges
GET /voice/payers shows $0 / duplicate names An orphaned duplicate payer record from earlier testing — Pinch doesn't enforce payer-name uniqueness; find and cancel its scheduled payments directly

9. Do NOT touch on demo day

  • ❌ No dependency updates (npm install/npm update of anything)
  • ❌ No refactors
  • ❌ No new features
  • ✅ Only fixes to a broken demo path, nothing else

API reference

All /admin/* routes require Authorization: Bearer <ADMIN_KEY>. All /voice/* routes require X-Voice-Secret: <VOICE_SECRET>. POST /webhooks/pinch is authenticated via Pinch's own pinch-signature header, not a shared secret. /health is open.

POST /admin/payers — add a customer

curl -X POST "$PROD_URL/admin/payers" \
  -H "Authorization: Bearer $ADMIN_KEY" -H "content-type: application/json" \
  -d '{"name":"Kev Concreting","email":"kev@example.com","mobile":"0400111222"}'
# -> { "payerId": "pyr_...", "name": "Kev Concreting" }

POST /admin/invoices — raise an invoice

A past dueDate is allowed on purpose — it's how you raise a demo invoice that's already overdue.

curl -X POST "$PROD_URL/admin/invoices" \
  -H "Authorization: Bearer $ADMIN_KEY" -H "content-type: application/json" \
  -d '{"payerId":"pyr_...","amountCents":15000,"description":"Overdue callout","dueDate":"2026-07-01"}'

GET/POST /admin/target — the demo target payer

Which payer /voice/tool resolves to when a voice script omits payerId or passes the literal string "TARGET".

curl "$PROD_URL/admin/target" -H "Authorization: Bearer $ADMIN_KEY"
curl -X POST "$PROD_URL/admin/target" \
  -H "Authorization: Bearer $ADMIN_KEY" -H "content-type: application/json" \
  -d '{"payerId":"pyr_..."}'

POST /admin/call — trigger an outbound voice call

See step 7 of the runbook above.

GET /admin/payments/status — counts by status

curl "$PROD_URL/admin/payments/status" -H "Authorization: Bearer $ADMIN_KEY"
# -> { "totalPayments": 22, "statuses": [
#       { "status": "scheduled", "count": 18, "totalCents": 412300, "totalDisplay": "$4,123.00" },
#       { "status": "transferred", "count": 4, "totalCents": 88000, "totalDisplay": "$880.00" } ] }

GET /admin/recovered — settled-payments total

Sums status === "transferred" payments. Prefers the webhook-fed snapshot (source: "webhook-events") when one exists, falls back to a live Pinch scan (source: "live-scan") otherwise. Without ?payerId=, the live-scan fallback walks scheduled payments only (not realtime charges) — pass ?payerId=<pyr_...> to include a specific payer's charge_now results too.

POST /voice/tool — generic tool invocation

Runs the exact same tool.run → permissions.enforce() path as MCP. Response always carries a speech string. Body: { agentId, tool, params }params may be nested or the body may be flat.

curl -X POST "$PROD_URL/voice/tool" \
  -H "X-Voice-Secret: $VOICE_SECRET" -H "content-type: application/json" \
  -d '{"agentId":"demo-agent","tool":"create_payment_plan","params":{
        "payerId":"pyr_...","totalAmountCents":240000,"instalments":4,
        "frequency":"fortnightly","startDate":"2026-07-25","description":"Overdue balance"}}'

Response shapes: success → { ok: true, result, speech }; decline → { ok: false, declined, speech }; error → { ok: false, error, speech }.

POST /voice/init — ElevenLabs conversation-initiation webhook

Called by ElevenLabs before a call connects; returns dynamic_variables (payer name, top overdue item, total overdue, days overdue, today's date, the agent's own authority) for the agent's system prompt. Served from a 60-second KV cache (warmed by /admin/target and /admin/call) so it responds well under ElevenLabs' latency budget instead of waiting on a live Pinch fetch.

GET /voice/context/:payerId, GET /voice/payers

Call-start context for a specific payer, and a list of seeded debtors with overdue totals — see src/voice.ts for full response shapes.

POST /webhooks/pinch — Pinch settlement events

Registered once via POST https://api.getpinch.com.au/test/webhooks (Pinch has no dashboard UI for this — it's API-only: { uri, eventTypes? }, returns a whsec_... signing secret you store as PINCH_WEBHOOK_SECRET). Verifies the pinch-signature header, writes a KV snapshot + audit entry for every event that references a payment.

KV namespaces

Binding Key pattern Purpose
GRANTS grant:<agentId> Agent authority records
GRANTS seedpayer:<name-slug> Name → stable payerId, so seed/reset reuse payers
GRANTS demo:targetPayerId The payerId /voice/tool resolves "TARGET" to
GRANTS init:context:<payerId> Cached /voice/init payload (60s TTL)
GRANTS pinch:event:<eventId> Webhook delivery dedup log
GRANTS pinch:payment:<paymentId> Latest settlement-status snapshot from webhooks
SPEND spend:<agentId>:<YYYY-MM-DD> Daily spend counters
AUDIT audit:<ISO-ts>:<rand> Append-only audit log (agent actions + webhook events)
TOKENS pinch:token Cached Pinch OAuth token
IDEMPOTENCY idem:plan:<sha256> create_payment_plan replay guard (10 min TTL)

Auth notes

  • Pinch OAuth: client_id must be the Application ID (app_...), stored in PINCH_PUBLISHABLE_KEY. The publishable key (pk_...) and merchant ID are not valid for OAuth.
  • Admin routes: Authorization: Bearer <ADMIN_KEY>.
  • Voice routes: X-Voice-Secret: <VOICE_SECRET>.
  • Pinch webhook: pinch-signature: t=<unix_seconds>,v2=<hmac-sha256> (verified, not a shared secret).
  • MCP: ?agentId=<id> query param or x-agent-id header.

Hard rules

  1. TypeScript strict + noUncheckedIndexedAccesstsc must pass clean.
  2. Amounts are always integer cents — never floats, never dollars in code paths.
  3. Never log full bank account numbers — masked to last 3 digits everywhere.
  4. All errors are structured objects — no raw stack traces to clients (every route handler call in the Worker entry point is awaited inside the outer try/catch, specifically so this holds for async exceptions too, not just synchronous ones).
  5. Sandbox only — base URL is always https://api.getpinch.com.au/test.

About

AI collections agent for Australian tradies/SMEs, built on Pinch Payments — MCP tools + permissions engine + ElevenLabs voice agent

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages