-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathengine.js
More file actions
421 lines (380 loc) · 21.4 KB
/
Copy pathengine.js
File metadata and controls
421 lines (380 loc) · 21.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
// engine.js - the agent's brain: strategy planning, per-step decisioning,
// message drafting, reply classification, and plain-language reasoning.
// Uses the Anthropic API when a key is set, with deterministic rule-based
// fallbacks so the demo runs anywhere. Hard guards are enforced in code.
const { daysBetween, addDays } = require("./store");
const API_KEY = process.env.ANTHROPIC_API_KEY || "";
const MODEL = process.env.ANTHROPIC_MODEL || "claude-sonnet-4-6";
// ---- Tier ladder -----------------------------------------------------------
const SPEED_FACTOR = { gentle: 1.5, standard: 1.0, fast: 0.7 };
function thresholds(speed) {
const f = SPEED_FACTOR[speed] || 1.0;
return { t1: Math.round(3 * f), t2: Math.round(10 * f), t3: Math.round(20 * f) };
}
function tierFor(daysOverdue, speed) {
const th = thresholds(speed);
if (daysOverdue < 0) return 0;
if (daysOverdue <= th.t1) return 1;
if (daysOverdue <= th.t2) return 2;
if (daysOverdue <= th.t3) return 3;
return 4;
}
const TIER_META = {
0: { label: "Heads up", channel: "email", tone: "friendly", identity: "business" },
1: { label: "Reminder", channel: "email", tone: "friendly", identity: "business" },
2: { label: "Firm nudge", channel: "whatsapp", tone: "firmer", identity: "business" },
3: { label: "Call", channel: "call", tone: "formal", identity: "accounts desk" },
4: { label: "Formal notice", channel: "email", tone: "formal", identity: "accounts desk" },
};
// ---- Commission buckets ----------------------------------------------------
const BUCKETS = [
{ label: "0-15 days", min: 0, max: 15, feePct: 2 },
{ label: "16-30 days", min: 16, max: 30, feePct: 5 },
{ label: "31-60 days", min: 31, max: 60, feePct: 10 },
{ label: "60+ days", min: 61, max: Infinity, feePct: 18 },
];
function bucketFor(daysOverdue) {
const d = Math.max(0, daysOverdue);
return BUCKETS.find((b) => d >= b.min && d <= b.max) || BUCKETS[BUCKETS.length - 1];
}
// ---- Money formatting ------------------------------------------------------
function fmtMoney(amount, currency) {
if (currency === "₹") {
const s = Math.round(amount).toString();
if (s.length <= 3) return currency + s;
const last3 = s.slice(-3);
const rest = s.slice(0, -3).replace(/\B(?=(\d{2})+(?!\d))/g, ",");
return currency + rest + "," + last3;
}
return currency + Math.round(amount).toLocaleString("en-US");
}
// ---- Guards + should-act ---------------------------------------------------
const FOLLOWUP_GAP_DAYS = 3;
function isException(invoice, settings) {
return settings.exceptions.some((e) => e.toLowerCase() === invoice.customer.toLowerCase());
}
function shouldAct(invoice, settings, simDate) {
// Hard guards, enforced in code (PRD §8): dispute freeze, exception list,
// pending-approval freeze, post-notice freeze, frequency caps.
if (invoice.pendingApproval) return { act: false, reason: "awaiting_approval" };
if (invoice.tier >= 4) return { act: false, reason: "post_notice_human_led" };
if (invoice.status !== "active") {
if (invoice.status === "paused_promise" && invoice.promiseDate) {
if (daysBetween(invoice.promiseDate, simDate) > 0) {
return { act: true, reason: "promise_missed" };
}
}
return { act: false };
}
if (isException(invoice, settings)) return { act: false, reason: "exception_account" };
if (invoice.lastActionDay === simDate) return { act: false, reason: "daily_cap" };
const daysOverdue = daysBetween(invoice.dueDate, simDate);
let newTier = tierFor(daysOverdue, settings.escalationSpeed);
if (invoice.declinedNotice) newTier = Math.min(newTier, 3);
// pre-due heads-up in the 2-day window before due, once
if (daysOverdue >= -2 && daysOverdue < 0 && invoice.history.length === 0) {
return { act: true, reason: "heads_up", tier: 0, daysOverdue };
}
if (daysOverdue < 0) return { act: false };
const lastDay = invoice.lastActionDay;
const gap = lastDay ? daysBetween(lastDay, simDate) : Infinity;
if (newTier > invoice.tier) {
if (newTier === 1 && daysOverdue === 0 && gap < 3) return { act: false };
return { act: true, reason: "tier_escalation", tier: newTier, daysOverdue };
}
if (gap >= FOLLOWUP_GAP_DAYS) {
return { act: true, reason: "cadence_followup", tier: Math.max(newTier, 1), daysOverdue };
}
return { act: false };
}
// ---- Debtor intelligence: responsiveness, risk, plan -------------------------
function responsivenessProfile(invoice) {
const stats = {};
for (const h of invoice.history) {
if (h.type === "agent" && h.channel) {
stats[h.channel] = stats[h.channel] || { sent: 0, replies: 0 };
stats[h.channel].sent++;
}
if (h.type === "reply" && h.channel) {
stats[h.channel] = stats[h.channel] || { sent: 0, replies: 0 };
stats[h.channel].replies++;
}
}
const totalSent = Object.values(stats).reduce((s, c) => s + c.sent, 0);
const totalReplies = Object.values(stats).reduce((s, c) => s + c.replies, 0);
let summary;
if (totalReplies > 0) {
const best = Object.entries(stats).sort((a, b) => b[1].replies - a[1].replies)[0][0];
const label = { email: "email", whatsapp: "WhatsApp", call: "calls" }[best] || best;
summary = `Responds on ${label}`;
} else if (totalSent >= 2) summary = "Silent so far";
else if (totalSent === 1) summary = "First touch made";
else summary = "No history yet";
return { stats, summary, totalSent, totalReplies };
}
function riskScore(invoice, daysOverdue) {
if (["paid", "closed"].includes(invoice.status)) return 0;
let s = 0;
if (daysOverdue > 0) s += Math.min(40, daysOverdue * 1.5);
s += (invoice.missedPromiseCount || 0) * 20;
const resp = responsivenessProfile(invoice);
if (resp.totalSent >= 2 && resp.totalReplies === 0) s += 25;
if (invoice.status === "disputed") s += 30;
return Math.min(100, Math.round(s));
}
// Next planned action + date (the agent's forecast shown on dashboard + plan panel)
function nextActionForecast(invoice, settings, simDate) {
if (invoice.status === "paid") return { label: "Journey complete", date: null };
if (invoice.status === "closed") return { label: "Closed", date: null };
if (invoice.status === "disputed") return { label: "On hold, dispute with you", date: null };
if (invoice.status === "paused_manual") return { label: "Paused by you", date: null };
if (invoice.pendingApproval) return { label: "Formal notice awaiting your approval", date: null };
if (invoice.tier >= 4) return { label: "Notice sent, next step is yours", date: null };
if (isException(invoice, settings)) return { label: "Never-contact account, agent stands down", date: null };
if (invoice.status === "paused_promise") {
return { label: "Resume if unpaid, citing their commitment", date: addDays(invoice.promiseDate, 1) };
}
const th = thresholds(settings.escalationSpeed);
const daysOverdue = daysBetween(invoice.dueDate, simDate);
if (daysOverdue < -2 && invoice.history.length === 0) {
return { label: "Heads-up email (business voice)", date: addDays(invoice.dueDate, -2) };
}
const candidates = [];
const escalations = [
{ tier: 1, date: invoice.dueDate },
{ tier: 2, date: addDays(invoice.dueDate, th.t1 + 1) },
{ tier: 3, date: addDays(invoice.dueDate, th.t2 + 1) },
{ tier: 4, date: addDays(invoice.dueDate, th.t3 + 1) },
];
for (const e of escalations) {
if (e.tier > invoice.tier && daysBetween(simDate, e.date) >= 0 && !(invoice.declinedNotice && e.tier >= 4)) {
candidates.push(e);
break;
}
}
if (invoice.lastActionDay && invoice.tier >= 1) {
const next = addDays(invoice.lastActionDay, FOLLOWUP_GAP_DAYS);
if (daysBetween(simDate, next) >= 0) candidates.push({ tier: invoice.tier, date: next, cadence: true });
}
if (!candidates.length) return { label: "Watching", date: null };
candidates.sort((a, b) => (a.date < b.date ? -1 : 1));
const c = candidates[0];
const meta = TIER_META[c.tier];
const label =
c.tier >= 4 ? "Draft formal notice (needs your approval)"
: c.cadence ? `Follow-up · ${meta.channel === "call" ? "call" : meta.channel}`
: `${meta.label} · ${meta.channel === "call" ? "call" : meta.channel}`;
return { label, date: c.date };
}
function planFor(invoice, settings, simDate) {
const resp = responsivenessProfile(invoice);
const daysOverdue = daysBetween(invoice.dueDate, simDate);
const risk = riskScore(invoice, daysOverdue);
const next = nextActionForecast(invoice, settings, simDate);
const bits = [];
if (isException(invoice, settings)) {
bits.push("Never-contact account per policy. The agent will not reach out; this stays with you.");
} else if (invoice.status === "paid") {
bits.push("Collected and closed with a thank-you.");
} else if (invoice.history.length === 0 && daysOverdue < 0) {
bits.push(`Heads-up email ~2 days before due; if unpaid, open the journey on email at day 0, add WhatsApp around day ${thresholds(settings.escalationSpeed).t1 + 1}.`);
} else {
bits.push(`Currently at ${TIER_META[Math.min(invoice.tier, 4)].label.toLowerCase()} stage.`);
if (resp.totalReplies > 0) bits.push(`${resp.summary}, the agent leads with that channel.`);
else if (resp.totalSent >= 2) bits.push("Silent across channels so far, so each step adds signal (new channel or firmer tone).");
}
if (invoice.missedPromiseCount > 0) bits.push(`${invoice.missedPromiseCount} missed promise${invoice.missedPromiseCount > 1 ? "s" : ""}, risk raised, escalation accelerated.`);
if (invoice.notes) bits.push(`Notes: ${invoice.notes}`);
return { summary: bits.join(" "), next, risk, responsiveness: resp.summary };
}
// ---- Fallback message drafting (no API key needed) ---------------------------
function draftFallback(ctx) {
const { invoice, settings, tier, daysOverdue, reason } = ctx;
const money = fmtMoney(invoice.amount, settings.currency);
const first = invoice.contactName.split(" ")[0];
const meta = TIER_META[tier];
const soft = (invoice.accountTone || settings.tone) === "relationship";
const hard = (invoice.accountTone || settings.tone) === "firm";
let subject = null;
let message = "";
let reasoning = "";
let channel = meta.channel;
if (tier === 0) {
const daysToDue = Math.abs(daysOverdue);
const dueTxt = daysToDue === 1 ? "tomorrow" : `in ${daysToDue} days`;
subject = `Heads up: ${invoice.number} due ${dueTxt}`;
message = `Hi ${first},\n\nA quick note that invoice ${invoice.number} for ${money} falls due ${dueTxt}. If it is already scheduled on your side, please ignore this.\n\nThanks,\n${settings.senderName}`;
reasoning = `${daysToDue} day${daysToDue > 1 ? "s" : ""} before due date. First contact, so a light heads-up on email keeps it frictionless. Sent as the business.`;
} else if (tier === 1) {
subject = `${invoice.number} is now past due`;
message = `Hi ${first},\n\nInvoice ${invoice.number} for ${money} is now ${daysOverdue === 0 ? "due today" : daysOverdue + " day" + (daysOverdue > 1 ? "s" : "") + " past due"}. Could you share the expected payment date? Happy to resend the invoice or bank details if useful.\n\nThanks,\n${settings.senderName}`;
reasoning = `Day ${daysOverdue} overdue. Staying on email with a ${soft ? "warm" : "friendly"} tone, sent as the business. Too early for a second channel.`;
} else if (tier === 2) {
channel = "whatsapp";
message = `Hi ${first}, this is the accounts desk at ${settings.businessName}. Invoice ${invoice.number} (${money}) is now ${daysOverdue} days past due and we have not heard back on email. Could you confirm the payment date today?`;
reasoning = `Day ${daysOverdue} overdue with no response on email. Tier 2: switching lead channel to WhatsApp because email has gone unanswered, and firming the tone while still writing as the business.${hard ? " Firm tone per policy." : ""}`;
} else if (tier === 3) {
channel = "call";
message = `Call placed to ${invoice.phone}. Talking points: reference ${invoice.number} (${money}), now ${daysOverdue} days overdue with no response across email and WhatsApp; request a confirmed payment date on the call; note that a formal notice follows this week without one.`;
reasoning = `Day ${daysOverdue} overdue, silent across two channels. Tier 3: a call is now the highest-signal channel and the identity shifts to ${settings.accountsDeskName || "the accounts team"}. Formal notice is pre-announced to make the escalation predictable, not hostile.`;
} else {
channel = "email";
subject = `Formal notice: ${invoice.number} outstanding for ${daysOverdue} days`;
message = `Dear ${invoice.contactName},\n\nDespite reminders across email, WhatsApp and phone, invoice ${invoice.number} for ${money} remains unpaid ${daysOverdue} days past its due date of ${invoice.dueDate}.\n\nPlease treat this as a formal notice. We request settlement or a written payment plan within 7 days, failing which we will review further steps available to us.\n\n${settings.accountsDeskName || "Accounts Team"}\n${settings.businessName}`;
reasoning = `Day ${daysOverdue} overdue after silence on every channel. Tier 4: formal notice referencing the outstanding terms, sent as the accounts desk. Per policy this draft goes to you for approval before anything is sent.`;
}
if (reason === "promise_missed") {
channel = "whatsapp";
subject = null;
message = `Hi ${first}, we had noted your commitment to clear ${invoice.number} (${money}) by ${invoice.promiseDate}. The payment has not reflected yet. Could you confirm the status today?`;
reasoning = `The promised payment date (${invoice.promiseDate}) has passed without payment. Resuming on WhatsApp, referencing their own commitment. A missed promise moves this account up the risk list${(invoice.missedPromiseCount || 0) >= 1 ? "; this is a repeat miss, so the journey also jumps a tier" : ""}.`;
} else if (reason === "cadence_followup" && tier === 2) {
reasoning = `Day ${daysOverdue} overdue, still at firm-nudge stage with no reply for ${FOLLOWUP_GAP_DAYS}+ days. One more WhatsApp follow-up before moving to a call.`;
message = `${first}, following up on ${invoice.number} (${money}), now ${daysOverdue} days overdue. We need a confirmed payment date this week to avoid moving this to a call from our accounts team.`;
}
return { channel, tone: TIER_META[tier].tone, subject, message, reasoning };
}
// ---- LLM drafting (used when ANTHROPIC_API_KEY is set) -----------------------
async function callClaude(system, user) {
const res = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"content-type": "application/json",
"x-api-key": API_KEY,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: MODEL,
max_tokens: 700,
system,
messages: [{ role: "user", content: user }],
}),
});
if (!res.ok) throw new Error("Anthropic API " + res.status);
const data = await res.json();
const text = (data.content || [])
.filter((c) => c.type === "text")
.map((c) => c.text)
.join("\n");
const clean = text.replace(/```json|```/g, "").trim();
return JSON.parse(clean);
}
async function draftWithLLM(ctx) {
const { invoice, settings, tier, daysOverdue, reason } = ctx;
const meta = TIER_META[tier];
const historySummary = invoice.history
.slice(-6)
.map((h) =>
h.type === "agent"
? `[${h.day}] agent -> ${h.channel} (tier ${h.tier}): ${String(h.message).slice(0, 120)}`
: h.type === "reply"
? `[${h.day}] customer replied via ${h.channel} (${h.classification}): ${String(h.message).slice(0, 120)}`
: `[${h.day}] business owner: ${String(h.message).slice(0, 120)}`
)
.join("\n");
const system = `You are Recoup, a digital collections employee for B2B receivables. You draft one outreach step at a time and explain your judgment. You are professional, never threatening, never harassing, and you always preserve the commercial relationship. Respond ONLY with JSON: {"channel":"email|whatsapp|call","subject":string|null,"message":string,"reasoning":string}. subject only for email. For a call, message = a call brief with talking points. reasoning = 1-3 sentences of your judgment (why this channel, this tone, this step), written plainly. Never use em-dashes.`;
const user = `Business: ${settings.businessName} (sender: ${settings.senderName}; accounts desk identity for tier 3+: ${settings.accountsDeskName}). Policy tone: ${invoice.accountTone || settings.tone}. Escalation speed: ${settings.escalationSpeed}.
Invoice ${invoice.number}: ${fmtMoney(invoice.amount, settings.currency)} to ${invoice.customer}, contact ${invoice.contactName}, due ${invoice.dueDate}, ${daysOverdue >= 0 ? daysOverdue + " days overdue" : Math.abs(daysOverdue) + " days before due"}.
Current step: tier ${tier} (${meta.label}), suggested channel ${meta.channel}, identity: ${meta.identity}. Trigger: ${reason}.${invoice.promiseDate ? " Customer had promised payment by " + invoice.promiseDate + "." : ""}${invoice.notes ? " Account notes: " + invoice.notes : ""}
Recent history:\n${historySummary || "(no prior contact)"}
Draft this step.`;
return await callClaude(system, user);
}
async function decide(invoice, settings, simDate, trigger) {
const daysOverdue = daysBetween(invoice.dueDate, simDate);
const tier = trigger.tier != null ? trigger.tier : tierFor(daysOverdue, settings.escalationSpeed);
const ctx = { invoice, settings, tier, daysOverdue, reason: trigger.reason };
let draft;
let engineUsed = "rules";
if (API_KEY) {
try {
draft = await draftWithLLM(ctx);
engineUsed = "claude";
} catch (e) {
draft = draftFallback(ctx);
}
} else {
draft = draftFallback(ctx);
}
return { ...draft, tier, daysOverdue, engineUsed };
}
// ---- Reply classification -----------------------------------------------------
function classifyFallback(text, simDate) {
const t = text.toLowerCase();
if (/(utr|neft|rtgs|imps|transferred|paid|payment (done|released|made)|cleared)/.test(t)) {
return {
classification: "paid",
promiseDate: null,
reasoning:
"The reply contains a payment confirmation (transfer reference or 'paid/released' language). Marking as paid pending reconciliation and closing the outreach journey.",
};
}
if (/(dispute|wrong|incorrect|damag|quality|not received|missing|short (shipped|supplied)|credit note)/.test(t)) {
return {
classification: "dispute",
promiseDate: null,
reasoning:
"The reply raises a dispute about the invoice or the goods. All collection outreach stops immediately: an agent must never keep dunning a disputing customer. Routing to you with the disputed points.",
};
}
const inst = t.match(/(\d+)\s*(installment|instalment|emi|part)/);
if (inst || /(payment plan|installments|instalments|part payment|pay half|50%|split (it|the payment))/.test(t)) {
const installments = inst ? Math.max(2, parseInt(inst[1], 10)) : 2;
return {
classification: "plan_request",
promiseDate: null,
installments,
reasoning: `The customer is asking to pay in parts (~${installments} installments). Checking settlement authority: within policy the agent restructures on its own, beyond it this goes to you with a recommendation.`,
};
}
const m = t.match(/(\d+)\s*day/);
const dateWords = /(tomorrow|next week|monday|tuesday|wednesday|thursday|friday|saturday|by |month end|end of month|next month)/;
if (m || dateWords.test(t) || /will pay|can pay|clear (it|this)/.test(t)) {
let days = 7;
if (m) days = Math.min(30, parseInt(m[1], 10));
else if (/tomorrow/.test(t)) days = 1;
else if (/next week/.test(t)) days = 7;
else if (/(month end|end of month|next month)/.test(t)) days = 14;
return {
classification: "promise",
promiseDate: addDays(simDate, days),
reasoning: `The reply commits to a payment date. Pausing all outreach until ${addDays(simDate, days)}. If payment does not arrive by then, the journey resumes automatically, referencing their own commitment.`,
};
}
return {
classification: "unclear",
promiseDate: null,
reasoning:
"The reply does not clearly confirm payment, commit to a date, request a plan, or raise a dispute. Flagging for you to read: ambiguous replies are exactly where automated systems damage relationships.",
};
}
async function classifyReply(invoice, settings, simDate, channel, text) {
if (API_KEY) {
try {
const system = `You classify a customer's reply in a B2B collections thread. Respond ONLY with JSON: {"classification":"paid|promise|dispute|plan_request|unclear","promiseDate":"YYYY-MM-DD"|null,"installments":number|null,"reasoning":string}. Today (simulation date) is ${simDate}. promiseDate only when classification is promise; infer a concrete date from the text, defaulting to +7 days if vague. installments only for plan_request. reasoning = 1-3 plain sentences on your judgment and what the journey should do next. Never use em-dashes.`;
const user = `Invoice ${invoice.number}, ${fmtMoney(invoice.amount, settings.currency)}, due ${invoice.dueDate}. Customer ${invoice.customer} replied via ${channel}:\n"""${text}"""`;
const out = await callClaude(system, user);
return { ...out, engineUsed: "claude" };
} catch (e) {
return { ...classifyFallback(text, simDate), engineUsed: "rules" };
}
}
return { ...classifyFallback(text, simDate), engineUsed: "rules" };
}
module.exports = {
tierFor,
thresholds,
TIER_META,
BUCKETS,
bucketFor,
fmtMoney,
isException,
shouldAct,
decide,
classifyReply,
planFor,
riskScore,
responsivenessProfile,
nextActionForecast,
FOLLOWUP_GAP_DAYS,
};