Adds AI-driven conversation tracking to debt negotiation

Introduces comprehensive conversation history with a new table and UI for tracking all negotiation emails, AI analysis, and financial outcomes. Enhances real-time updates, manages negotiation rounds, and supports new statuses for negotiation lifecycle. Integrates AI-powered extraction and response analysis to automate intent detection and outcome calculations, improving transparency and automation of debt resolution.
This commit is contained in:
2025-06-08 00:32:04 -03:00
parent 0c6f72761d
commit bddc3a344d
14 changed files with 2568 additions and 135 deletions

View File

@@ -14,12 +14,12 @@ body:json {
{
"TextBody": "Dear customer, you owe $1,250.50 to CreditPlus for your January utility bills. Please pay by 2025-06-15 to avoid penalties.",
"FromFull": {
"Email": "billing@creditplus.com",
"Email": "contact@francisco-pessano.com",
"Name": "CreditPlus Billing Department"
},
"ToFull": [
{
"Email": "franpessano1@gmail.com",
"Email": "contacto@francisco-pessano.com",
"Name": "",
"MailboxHash": "ahoy"
}

View File

@@ -0,0 +1,586 @@
import React, { useEffect, useState } from "react";
import {
CheckCircle,
Clock,
AlertCircle,
XCircle,
StopCircle,
Mail,
MailOpen,
MessageSquare,
TrendingUp,
TrendingDown,
Calendar,
User,
Building2,
} from "lucide-react";
import { supabase, type Debt } from "../lib/supabase";
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
import { Badge } from "./ui/badge";
import { Separator } from "./ui/separator";
import { toast } from "sonner";
import { formatCurrency } from "../lib/utils";
interface ConversationMessage {
id: string;
debt_id: string;
message_type: string;
direction: "inbound" | "outbound";
subject?: string;
body: string;
from_email?: string;
to_email?: string;
message_id?: string;
ai_analysis?: any;
created_at: string;
updated_at: string;
}
interface ConversationTimelineProps {
debt: Debt;
onDebtUpdate?: (updatedDebt: Debt) => void;
}
const messageTypeLabels = {
initial_debt: "Initial Debt Notice",
negotiation_sent: "Negotiation Sent",
response_received: "Response Received",
counter_offer: "Counter Offer",
acceptance: "Offer Accepted",
rejection: "Offer Rejected",
};
const statusColors = {
received: "text-blue-600 dark:text-blue-400",
negotiating: "text-yellow-600 dark:text-yellow-400",
approved: "text-purple-600 dark:text-purple-400",
sent: "text-orange-600 dark:text-orange-400",
awaiting_response: "text-blue-600 dark:text-blue-400",
counter_negotiating: "text-yellow-600 dark:text-yellow-400",
accepted: "text-green-600 dark:text-green-400",
rejected: "text-red-600 dark:text-red-400",
settled: "text-green-600 dark:text-green-400",
failed: "text-red-600 dark:text-red-400",
opted_out: "text-gray-600 dark:text-gray-400",
};
const statusLabels = {
received: "Received",
negotiating: "Negotiating",
approved: "Approved",
sent: "Sent",
awaiting_response: "Awaiting Response",
counter_negotiating: "Counter Negotiating",
accepted: "Accepted",
rejected: "Rejected",
settled: "Settled",
failed: "Failed",
opted_out: "Opted Out",
};
export function ConversationTimeline({
debt,
onDebtUpdate,
}: ConversationTimelineProps) {
const [messages, setMessages] = useState<ConversationMessage[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchConversationMessages();
// Set up real-time subscription for conversation messages
const channel = supabase
.channel(`conversation_messages:debt_id=eq.${debt.id}`)
.on(
"postgres_changes",
{
event: "*",
schema: "public",
table: "conversation_messages",
filter: `debt_id=eq.${debt.id}`,
},
(payload) => {
console.log("Real-time conversation message update:", payload);
if (payload.eventType === "INSERT") {
// Add new message to the list
const newMessage = payload.new as ConversationMessage;
setMessages((prev) => [...prev, newMessage]);
// Show toast notification for new messages
if (newMessage.direction === "inbound") {
const isAcceptance = newMessage.message_type === "acceptance";
toast.success(
isAcceptance ? "🎉 Offer Accepted!" : "New Response Received",
{
description: isAcceptance
? "Creditor accepted your negotiation offer!"
: `${
messageTypeLabels[newMessage.message_type] ||
"New message"
} from creditor`,
}
);
}
} else if (payload.eventType === "UPDATE") {
// Update existing message
setMessages((prev) =>
prev.map((msg) =>
msg.id === payload.new.id
? (payload.new as ConversationMessage)
: msg
)
);
} else if (payload.eventType === "DELETE") {
// Remove deleted message
setMessages((prev) =>
prev.filter((msg) => msg.id !== payload.old.id)
);
}
}
)
.subscribe();
// Set up real-time subscription for debt status changes
const debtChannel = supabase
.channel(`debt_status:id=eq.${debt.id}`)
.on(
"postgres_changes",
{
event: "UPDATE",
schema: "public",
table: "debts",
filter: `id=eq.${debt.id}`,
},
(payload) => {
console.log("Real-time debt status update:", payload);
// Notify parent component about debt update
if (onDebtUpdate) {
onDebtUpdate(payload.new as Debt);
}
}
)
.subscribe();
// Cleanup subscriptions on unmount
return () => {
supabase.removeChannel(channel);
supabase.removeChannel(debtChannel);
};
}, [debt.id]);
const fetchConversationMessages = async () => {
try {
const { data, error } = await supabase
.from("conversation_messages")
.select("*")
.eq("debt_id", debt.id)
.order("created_at", { ascending: true });
if (error) {
console.error("Error fetching conversation messages:", error);
return;
}
setMessages(data || []);
} catch (error) {
console.error("Error fetching conversation messages:", error);
} finally {
setLoading(false);
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const getMessageIcon = (message: ConversationMessage) => {
if (message.direction === "outbound") {
return <Mail className="h-4 w-4" />;
} else {
switch (message.message_type) {
case "acceptance":
return <CheckCircle className="h-4 w-4" />;
case "rejection":
return <XCircle className="h-4 w-4" />;
case "counter_offer":
return <MessageSquare className="h-4 w-4" />;
default:
return <MailOpen className="h-4 w-4" />;
}
}
};
const getMessageColor = (message: ConversationMessage) => {
if (message.direction === "outbound") {
return "text-blue-600 dark:text-blue-400";
} else {
switch (message.message_type) {
case "acceptance":
return "text-green-600 dark:text-green-400";
case "rejection":
return "text-red-600 dark:text-red-400";
case "counter_offer":
return "text-yellow-600 dark:text-yellow-400";
default:
return "text-gray-600 dark:text-gray-400";
}
}
};
const getSentimentIcon = (sentiment?: string) => {
switch (sentiment) {
case "positive":
return <TrendingUp className="h-3 w-3 text-green-500" />;
case "negative":
return <TrendingDown className="h-3 w-3 text-red-500" />;
default:
return null;
}
};
if (loading) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="h-5 w-5 animate-spin" />
Loading Conversation...
</CardTitle>
</CardHeader>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<div className="flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
Conversation Timeline
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className={statusColors[debt.status]}>
{statusLabels[debt.status]}
</Badge>
<span className="text-sm text-gray-500">
Round {debt.negotiation_round || 1}
</span>
</div>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{messages.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<MessageSquare className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No conversation messages yet</p>
<p className="text-sm">
Messages will appear here as the negotiation progresses
</p>
</div>
) : (
<div className="space-y-4">
{messages.map((message, index) => (
<div key={message.id} className="relative">
{/* Timeline line */}
{index < messages.length - 1 && (
<div className="absolute left-4 top-8 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700" />
)}
<div className="flex gap-4">
{/* Icon */}
<div
className={`
flex items-center justify-center w-8 h-8 rounded-full border-2 bg-white dark:bg-gray-800
${getMessageColor(message)} border-current
`}
>
{getMessageIcon(message)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<h4 className="font-medium text-gray-900 dark:text-foreground">
{messageTypeLabels[message.message_type] ||
message.message_type}
</h4>
{message.ai_analysis?.sentiment &&
getSentimentIcon(message.ai_analysis.sentiment)}
</div>
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<Calendar className="h-3 w-3" />
{formatDate(message.created_at)}
</div>
</div>
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300 mb-2">
{message.direction === "outbound" ? (
<>
<User className="h-3 w-3" />
<span>You {message.to_email}</span>
</>
) : (
<>
<Building2 className="h-3 w-3" />
<span>{message.from_email} You</span>
</>
)}
</div>
{message.subject && (
<p className="text-sm font-medium text-gray-800 dark:text-gray-200 mb-2">
Subject: {message.subject}
</p>
)}
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3 text-sm">
<p className="whitespace-pre-wrap text-gray-700 dark:text-gray-300">
{message.body.length > 200
? `${message.body.substring(0, 200)}...`
: message.body}
</p>
</div>
{/* AI Analysis */}
{message.ai_analysis && (
<div className="mt-2 space-y-2">
{message.ai_analysis.intent && (
<div className="flex items-center gap-2 my-2">
<Badge
variant={
message.ai_analysis.intent === "acceptance"
? "default"
: "secondary"
}
className={`text-xs ${
message.ai_analysis.intent === "acceptance"
? "bg-green-600 text-white"
: ""
}`}
>
Intent: {message.ai_analysis.intent}
</Badge>
{message.ai_analysis.confidence && (
<span className="text-xs text-gray-500">
{Math.round(
message.ai_analysis.confidence * 100
)}
% confidence
</span>
)}
</div>
)}
{!!message.ai_analysis.extractedTerms
?.proposedAmount && (
<div className="text-xs text-gray-600 dark:text-gray-400">
Proposed Amount: $
{message.ai_analysis.extractedTerms.proposedAmount}
</div>
)}
{/* Show financial outcome for accepted offers */}
{message.ai_analysis.intent === "acceptance" &&
debt.metadata?.financialOutcome && (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-3 mt-2">
{debt.metadata.financialOutcome.financialBenefit
?.type === "principal_reduction" ? (
<>
<div className="flex items-center gap-2 mb-2">
<TrendingUp className="h-4 w-4 text-green-600" />
<span className="font-medium text-green-800 dark:text-green-200">
Principal Reduction Achieved
</span>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<span className="text-gray-600 dark:text-gray-400">
Original Debt:
</span>
<div className="font-medium">
$
{
debt.metadata.financialOutcome
.originalAmount
}
</div>
</div>
<div>
<span className="text-gray-600 dark:text-gray-400">
Settlement Amount:
</span>
<div className="font-medium">
$
{
debt.metadata.financialOutcome
.acceptedAmount
}
</div>
</div>
<div>
<span className="text-gray-600 dark:text-gray-400">
Total Savings:
</span>
<div className="font-medium text-green-600">
$
{
debt.metadata.financialOutcome
.actualSavings
}
</div>
</div>
<div>
<span className="text-gray-600 dark:text-gray-400">
Reduction:
</span>
<div className="font-medium text-green-600">
{
debt.metadata.financialOutcome
.financialBenefit.percentage
}
%
</div>
</div>
</div>
</>
) : debt.metadata.financialOutcome
.financialBenefit?.type ===
"payment_restructuring" ? (
<>
<div className="flex items-center gap-2 mb-2">
<Calendar className="h-4 w-4 text-blue-600" />
<span className="font-medium text-blue-800 dark:text-blue-200">
Payment Plan Restructured
</span>
</div>
<div className="space-y-2 text-xs">
<div className="bg-blue-50 dark:bg-blue-900/30 rounded p-2">
<div className="font-medium text-blue-800 dark:text-blue-200 mb-1">
{
debt.metadata.financialOutcome
.financialBenefit.description
}
</div>
{debt.metadata.financialOutcome
.financialBenefit.cashFlowBenefit && (
<div className="text-blue-600 dark:text-blue-300">
💰{" "}
{
debt.metadata.financialOutcome
.financialBenefit.cashFlowBenefit
}
</div>
)}
</div>
{debt.metadata.financialOutcome
.paymentStructure && (
<div className="grid grid-cols-2 gap-2">
<div>
<span className="text-gray-600 dark:text-gray-400">
Monthly Payment:
</span>
<div className="font-medium">
$
{
debt.metadata.financialOutcome
.paymentStructure.monthlyAmount
}
</div>
</div>
<div>
<span className="text-gray-600 dark:text-gray-400">
Term Length:
</span>
<div className="font-medium">
{
debt.metadata.financialOutcome
.paymentStructure
.numberOfPayments
}{" "}
months
</div>
</div>
<div>
<span className="text-gray-600 dark:text-gray-400">
Total Amount:
</span>
<div className="font-medium">
$
{formatCurrency(
debt.metadata.financialOutcome
.paymentStructure.totalAmount
)}
</div>
</div>
<div>
<span className="text-gray-600 dark:text-gray-400">
Frequency:
</span>
<div className="font-medium capitalize">
{
debt.metadata.financialOutcome
.paymentStructure.frequency
}
</div>
</div>
</div>
)}
</div>
</>
) : (
<>
<div className="flex items-center gap-2 mb-2">
<CheckCircle className="h-4 w-4 text-green-600" />
<span className="font-medium text-green-800 dark:text-green-200">
Offer Accepted
</span>
</div>
<div className="text-xs text-gray-600 dark:text-gray-400">
Settlement terms have been agreed upon.
</div>
</>
)}
</div>
)}
</div>
)}
</div>
</div>
</div>
))}
</div>
)}
<Separator className="my-4" />
{/* Summary */}
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-4">
<span className="text-gray-600 dark:text-gray-300">
Total Messages: {messages.length}
</span>
<span className="text-gray-600 dark:text-gray-300">
Negotiation Round: {debt.negotiation_round || 1}
</span>
</div>
{debt.last_message_at && (
<span className="text-gray-500 dark:text-gray-400">
Last Activity: {formatDate(debt.last_message_at)}
</span>
)}
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -1,16 +1,10 @@
import React, { useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { supabase, type Debt, type UserProfile } from "../lib/supabase";
import { Button } from "./ui/button";
import { DebtCard } from "./DebtCard";
import { DebtTimeline } from "./DebtTimeline";
import { ConversationTimeline } from "./ConversationTimeline";
import { OnboardingDialog } from "./OnboardingDialog";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "./ui/card";
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
import { Badge } from "./ui/badge";
import { Separator } from "./ui/separator";
@@ -21,10 +15,9 @@ import {
CheckCircle,
AlertTriangle,
RefreshCw,
BarChart3,
LogOut,
Settings,
} from "lucide-react";
import { formatCurrency } from "../lib/utils";
export function Dashboard() {
const [debts, setDebts] = useState<Debt[]>([]);
@@ -130,10 +123,13 @@ export function Dashboard() {
const calculateStats = () => {
const totalDebts = debts.length;
const totalAmount = debts.reduce((sum, debt) => sum + debt.amount, 0);
const projectedSavings = debts.reduce(
(sum, debt) => sum + debt.projected_savings,
0
);
const projectedSavings = debts.reduce((sum, debt) => {
// Use actual savings for accepted debts, projected for others
if (debt.status === "accepted" && debt.metadata?.actualSavings?.amount) {
return sum + debt.metadata.actualSavings.amount;
}
return sum + debt.projected_savings;
}, 0);
const settledCount = debts.filter(
(debt) => debt.status === "settled"
).length;
@@ -157,23 +153,22 @@ export function Dashboard() {
window.location.href = "/";
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(amount);
};
const groupedDebts = {
all: debts,
active: debts.filter((debt) =>
["received", "negotiating"].includes(debt.status)
[
"received",
"negotiating",
"approved",
"awaiting_response",
"counter_negotiating",
].includes(debt.status)
),
settled: debts.filter((debt) =>
["settled", "approved", "sent"].includes(debt.status)
["settled", "accepted", "sent"].includes(debt.status)
),
failed: debts.filter((debt) =>
["failed", "opted_out"].includes(debt.status)
["failed", "rejected", "opted_out"].includes(debt.status)
),
};
@@ -318,11 +313,14 @@ export function Dashboard() {
{debtList.map((debt) => (
<div key={debt.id} className="space-y-4">
<DebtCard debt={debt} onUpdate={fetchDebts} />
<Card className="bg-gray-50 dark:bg-gray-800/50">
<CardContent className="p-4">
<DebtTimeline debt={debt} />
</CardContent>
</Card>
<ConversationTimeline
debt={debt}
onDebtUpdate={(debt) => {
setDebts(
debts.map((d) => (d.id === debt.id ? debt : d))
);
}}
/>
</div>
))}
</div>

View File

@@ -13,6 +13,7 @@ import { Textarea } from "./ui/textarea";
import { Label } from "./ui/label";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
@@ -45,6 +46,7 @@ import {
} from "lucide-react";
import { supabase, type Debt, type DebtVariable } from "../lib/supabase";
import { toast } from "sonner";
import { formatCurrency } from "../lib/utils";
interface DebtCardProps {
debt: Debt;
@@ -59,6 +61,14 @@ const statusColors = {
approved:
"bg-teal-100 text-teal-800 border-teal-200 dark:bg-teal-900/20 dark:text-teal-300 dark:border-teal-800",
sent: "bg-purple-100 text-purple-800 border-purple-200 dark:bg-purple-900/20 dark:text-purple-300 dark:border-purple-800",
awaiting_response:
"bg-blue-100 text-blue-800 border-blue-200 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-800",
counter_negotiating:
"bg-yellow-100 text-yellow-800 border-yellow-200 dark:bg-yellow-900/20 dark:text-yellow-300 dark:border-yellow-800",
accepted:
"bg-green-100 text-green-800 border-green-200 dark:bg-green-900/20 dark:text-green-300 dark:border-green-800",
rejected:
"bg-red-100 text-red-800 border-red-200 dark:bg-red-900/20 dark:text-red-300 dark:border-red-800",
settled:
"bg-green-100 text-green-800 border-green-200 dark:bg-green-900/20 dark:text-green-300 dark:border-green-800",
failed:
@@ -72,6 +82,10 @@ const statusLabels = {
negotiating: "Negotiating",
approved: "Approved",
sent: "Sent",
awaiting_response: "Awaiting Response",
counter_negotiating: "Counter Negotiating",
accepted: "Accepted",
rejected: "Rejected",
settled: "Settled",
failed: "Failed",
opted_out: "Opted Out",
@@ -81,21 +95,20 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) {
const [isApproving, setIsApproving] = useState(false);
const [isRejecting, setIsRejecting] = useState(false);
const [userProfile, setUserProfile] = useState<any>(null);
const [hasServerToken, setHasServerToken] = useState(false);
const [hasServerToken, setHasServerToken] = useState<boolean | undefined>(
undefined
);
const isReadOnly =
debt.status === "approved" ||
debt.status === "sent" ||
debt.status === "awaiting_response" ||
debt.status === "accepted" ||
debt.status === "rejected" ||
debt.status === "settled" ||
debt.status === "failed" ||
debt.status === "opted_out";
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(amount);
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString("en-US", {
year: "numeric",
@@ -466,9 +479,11 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) {
{/* Action Buttons */}
{!isReadOnly && (
<div className="flex justify-end gap-2 border-t pt-4">
<Button variant="outline" onClick={() => setIsEditing(false)}>
Cancel
</Button>
<DialogClose>
<Button variant="outline" onClick={() => setIsEditing(false)}>
Cancel
</Button>
</DialogClose>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? "Saving..." : "Save Changes"}
</Button>
@@ -663,8 +678,8 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) {
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<DollarSign className="h-5 w-5 text-gray-500 dark:text-gray-400" />
<div className="flex items-center gap-0.5">
<DollarSign className="h-5 w-5 text-gray-500 dark:text-gray-400 my-auto" />
<span className="text-2xl font-bold text-gray-900 dark:text-foreground">
{formatCurrency(debt.amount)}
</span>
@@ -710,7 +725,7 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) {
{/* Approve/Reject Buttons */}
{showApproveRejectButtons() && (
<div className="space-y-2">
{!hasServerToken && (
{hasServerToken === false && (
<div className="flex items-center gap-2 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
<AlertCircle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
<span className="text-sm text-amber-700 dark:text-amber-300 flex-1">
@@ -721,7 +736,7 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) {
variant="outline"
size="sm"
onClick={() => (window.location.href = "/configuration")}
className="text-amber-700 border-amber-300 hover:bg-amber-100"
className="text-amber-700 dark:text-amber-500 border-amber-300"
>
<ExternalLink className="h-3 w-3 mr-1" />
Settings
@@ -757,7 +772,7 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) {
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleApprove()}
className="bg-green-600 hover:bg-green-700"
className="bg-green-600 hover:bg-green-700 text-white"
>
Send Email
</AlertDialogAction>

View File

@@ -1,26 +1,83 @@
import React from "react";
import React, { useState, useEffect } from "react";
import {
CheckCircle,
Clock,
AlertCircle,
XCircle,
StopCircle,
Send,
MessageSquare,
ThumbsUp,
ThumbsDown,
RefreshCw,
} from "lucide-react";
import type { Debt } from "../lib/supabase";
import { createClient } from "@supabase/supabase-js";
interface DebtTimelineProps {
debt: Debt;
}
const timelineSteps = [
{ key: "received", label: "Email Received", icon: CheckCircle },
{ key: "negotiating", label: "Negotiating", icon: Clock },
{ key: "settled", label: "Settled", icon: CheckCircle },
];
interface ConversationMessage {
id: string;
message_type: string;
direction: "inbound" | "outbound";
subject: string;
body: string;
from_email: string;
to_email: string;
ai_analysis?: any;
created_at: string;
}
const getTimelineSteps = (debt: Debt) => {
const baseSteps = [
{ key: "received", label: "Debt Email Received", icon: MessageSquare },
{ key: "negotiating", label: "AI Response Generated", icon: Clock },
{ key: "approved", label: "Response Approved", icon: CheckCircle },
{ key: "sent", label: "Negotiation Email Sent", icon: Send },
];
// Add dynamic steps based on conversation
if (debt.status === "counter_negotiating" || debt.negotiation_round > 1) {
baseSteps.push({
key: "counter_negotiating",
label: "Counter-Negotiating",
icon: RefreshCw,
});
}
if (debt.status === "accepted" || debt.status === "settled") {
baseSteps.push({
key: "accepted",
label: "Offer Accepted",
icon: ThumbsUp,
});
baseSteps.push({
key: "settled",
label: "Debt Settled",
icon: CheckCircle,
});
} else if (debt.status === "rejected") {
baseSteps.push({
key: "rejected",
label: "Offer Rejected",
icon: ThumbsDown,
});
}
return baseSteps;
};
const statusIcons = {
received: CheckCircle,
received: MessageSquare,
negotiating: Clock,
approved: CheckCircle,
sent: Send,
awaiting_response: Clock,
counter_negotiating: RefreshCw,
accepted: ThumbsUp,
rejected: ThumbsDown,
settled: CheckCircle,
failed: XCircle,
opted_out: StopCircle,
@@ -29,20 +86,139 @@ const statusIcons = {
const statusColors = {
received: "text-blue-600 dark:text-blue-400",
negotiating: "text-yellow-600 dark:text-yellow-400",
approved: "text-green-600 dark:text-green-400",
sent: "text-purple-600 dark:text-purple-400",
awaiting_response: "text-orange-600 dark:text-orange-400",
counter_negotiating: "text-indigo-600 dark:text-indigo-400",
accepted: "text-green-600 dark:text-green-400",
rejected: "text-red-600 dark:text-red-400",
settled: "text-green-600 dark:text-green-400",
failed: "text-red-600 dark:text-red-400",
opted_out: "text-gray-600 dark:text-gray-400",
};
export function DebtTimeline({ debt }: DebtTimelineProps) {
const [conversationMessages, setConversationMessages] = useState<
ConversationMessage[]
>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchConversationMessages = async () => {
try {
const supabase = createClient(
import.meta.env.PUBLIC_SUPABASE_URL,
import.meta.env.PUBLIC_SUPABASE_ANON_KEY
);
const { data, error } = await supabase
.from("conversation_messages")
.select("*")
.eq("debt_id", debt.id)
.order("created_at", { ascending: true });
if (error) {
console.error("Error fetching conversation messages:", error);
} else {
setConversationMessages(data || []);
}
} catch (error) {
console.error("Error:", error);
} finally {
setLoading(false);
}
};
fetchConversationMessages();
}, [debt.id]);
const timelineSteps = getTimelineSteps(debt);
const currentStepIndex = timelineSteps.findIndex(
(step) => step.key === debt.status
);
return (
<div className="space-y-4">
<h3 className="text-lg font-semibold">Progress Timeline</h3>
const formatDateTime = (dateString: string) => {
const date = new Date(dateString);
return {
date: date.toLocaleDateString(),
time: date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
};
};
const getStatusDescription = (status: string, round?: number) => {
switch (status) {
case "received":
return "Debt collection email received and parsed";
case "negotiating":
return "AI analyzing debt and generating negotiation strategy";
case "approved":
return "Negotiation response approved and ready to send";
case "sent":
return "Negotiation email sent to creditor";
case "awaiting_response":
return "Waiting for creditor's response";
case "counter_negotiating":
return `Round ${round || 1} - Analyzing creditor's counter-offer`;
case "accepted":
return "Creditor accepted the negotiation terms";
case "rejected":
return "Creditor rejected the offer - manual review needed";
case "settled":
return "Debt successfully settled with agreed terms";
case "failed":
return "Negotiation failed - escalated for manual handling";
default:
return "Processing...";
}
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Negotiation Timeline</h3>
<div className="text-sm text-gray-500 dark:text-gray-400">
Round {debt.negotiation_round || 1} {debt.conversation_count || 0}{" "}
messages
</div>
</div>
{/* Current Status Overview */}
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<div className="flex items-center gap-3">
<div
className={`p-2 rounded-full ${statusColors[
debt.status as keyof typeof statusColors
]
?.replace("text-", "bg-")
.replace("dark:text-", "dark:bg-")} bg-opacity-20`}
>
{React.createElement(
statusIcons[debt.status as keyof typeof statusIcons] || Clock,
{
className: `h-5 w-5 ${
statusColors[debt.status as keyof typeof statusColors]
}`,
}
)}
</div>
<div>
<div className="font-medium capitalize">
{debt.status.replace("_", " ")}
</div>
<div className="text-sm text-gray-600 dark:text-gray-300">
{getStatusDescription(debt.status, debt.negotiation_round)}
</div>
{debt.last_message_at && (
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Last updated: {formatDateTime(debt.last_message_at).date} at{" "}
{formatDateTime(debt.last_message_at).time}
</div>
)}
</div>
</div>
</div>
{/* Timeline Steps */}
<div className="space-y-4">
{timelineSteps.map((step, index) => {
const isCompleted = index <= currentStepIndex;
@@ -50,90 +226,138 @@ export function DebtTimeline({ debt }: DebtTimelineProps) {
const Icon = step.icon;
return (
<div key={step.key} className="flex items-center gap-3">
<div key={step.key} className="flex items-start gap-3">
<div
className={`
flex items-center justify-center w-8 h-8 rounded-full border-2
${
isCompleted
? "bg-primary border-primary text-white"
: "border-gray-300 dark:border-gray-600 text-gray-300 dark:text-gray-600"
}
${isActive ? "ring-2 ring-primary/20" : ""}
`}
flex items-center justify-center w-8 h-8 rounded-full border-2 mt-1
${
isCompleted
? "bg-primary border-primary text-white"
: "border-gray-300 dark:border-gray-600 text-gray-300 dark:text-gray-600"
}
${isActive ? "ring-2 ring-primary/20" : ""}
`}
>
<Icon className="h-4 w-4" />
</div>
<div className="flex-1">
<div className="flex-1 min-w-0">
<div
className={`
font-medium
${
isCompleted
? "text-gray-900 dark:text-foreground"
: "text-gray-400 dark:text-gray-500"
}
`}
font-medium
${
isCompleted
? "text-gray-900 dark:text-foreground"
: "text-gray-400 dark:text-gray-500"
}
`}
>
{step.label}
</div>
{isActive && (
<div className="text-sm text-gray-600 dark:text-gray-300 mt-1">
{debt.status === "received" &&
"Processing email and generating response..."}
{debt.status === "negotiating" &&
"Response generated, waiting for creditor reply"}
{debt.status === "settled" && "Payment plan agreed upon"}
{getStatusDescription(debt.status, debt.negotiation_round)}
</div>
)}
</div>
{isActive && (
<div className="text-sm text-gray-500 dark:text-gray-400">
{new Date(debt.updated_at).toLocaleDateString()}
{isCompleted && debt.updated_at && (
<div className="text-xs text-gray-500 dark:text-gray-400 text-right">
<div>{formatDateTime(debt.updated_at).date}</div>
<div>{formatDateTime(debt.updated_at).time}</div>
</div>
)}
</div>
);
})}
{/* Special cases for failed and opted_out */}
{(debt.status === "failed" || debt.status === "opted_out") && (
<div className="flex items-center gap-3">
<div
className={`
flex items-center justify-center w-8 h-8 rounded-full border-2
${
debt.status === "failed"
? "border-red-500 text-red-500 dark:border-red-400 dark:text-red-400"
: "border-gray-500 text-gray-500 dark:border-gray-400 dark:text-gray-400"
}
`}
>
{React.createElement(statusIcons[debt.status], {
className: "h-4 w-4",
})}
</div>
<div className="flex-1">
<div className={`font-medium ${statusColors[debt.status]}`}>
{debt.status === "failed" ? "Negotiation Failed" : "Opted Out"}
</div>
<div className="text-sm text-gray-600 dark:text-gray-300 mt-1">
{debt.status === "failed"
? "Creditor declined the negotiation proposal"
: "User requested to stop communication"}
</div>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{new Date(debt.updated_at).toLocaleDateString()}
</div>
</div>
)}
</div>
{/* Conversation Messages */}
{!loading && conversationMessages.length > 0 && (
<div className="space-y-4">
<h4 className="text-md font-medium text-gray-700 dark:text-gray-300">
Conversation History
</h4>
<div className="space-y-3">
{conversationMessages.map((message) => {
const isOutbound = message.direction === "outbound";
const datetime = formatDateTime(message.created_at);
return (
<div
key={message.id}
className={`flex ${
isOutbound ? "justify-end" : "justify-start"
}`}
>
<div
className={`max-w-xs lg:max-w-md px-4 py-2 rounded-lg ${
isOutbound
? "bg-primary text-white"
: "bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-gray-100"
}`}
>
<div className="text-xs opacity-75 mb-1">
{isOutbound ? "You" : "Creditor"} {datetime.date}{" "}
{datetime.time}
</div>
<div className="text-sm font-medium mb-1">
{message.subject}
</div>
<div className="text-sm opacity-90">
{message.body.length > 100
? `${message.body.substring(0, 100)}...`
: message.body}
</div>
{message.ai_analysis && (
<div className="text-xs mt-2 opacity-75">
AI: {message.ai_analysis.intent || "Analyzed"}
{message.ai_analysis.confidence &&
` (${Math.round(
message.ai_analysis.confidence * 100
)}% confidence)`}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
)}
{/* Savings Summary */}
{(debt.projected_savings > 0 ||
(debt.actual_savings && debt.actual_savings > 0)) && (
<div className="bg-green-50 dark:bg-green-900/20 rounded-lg p-4">
<h4 className="text-md font-medium text-green-800 dark:text-green-200 mb-2">
Savings Summary
</h4>
<div className="grid grid-cols-2 gap-4 text-sm">
{debt.projected_savings > 0 && (
<div>
<div className="text-green-600 dark:text-green-400 font-medium">
Projected Savings
</div>
<div className="text-green-800 dark:text-green-200">
${debt.projected_savings.toFixed(2)}
</div>
</div>
)}
{debt.actual_savings && debt.actual_savings > 0 && (
<div>
<div className="text-green-600 dark:text-green-400 font-medium">
Actual Savings
</div>
<div className="text-green-800 dark:text-green-200">
${debt.actual_savings.toFixed(2)}
</div>
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,212 @@
import { useState } from "react";
import { Button } from "./ui/button";
import { Textarea } from "./ui/textarea";
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
import { Badge } from "./ui/badge";
import { toast } from "sonner";
import { TestTube, Loader2 } from "lucide-react";
export function ExtractionTester() {
const [testEmail, setTestEmail] = useState(`Thank you for your payment arrangement request. We are pleased to offer you a structured payment plan. We can accept $250 per month for 18 months, totaling $4,500. This arrangement will allow you to resolve this matter with manageable monthly payments.`);
const [result, setResult] = useState<any>(null);
const [isLoading, setIsLoading] = useState(false);
const testExtraction = async () => {
setIsLoading(true);
try {
const response = await fetch('/api/supabase/functions/test-extraction', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ testEmail }),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
setResult(data);
if (data.success) {
toast.success("Extraction Test Complete", {
description: `Found ${Object.keys(data.extractedTerms || {}).length} extracted terms`,
});
} else {
toast.error("Extraction Test Failed", {
description: data.error || "Unknown error",
});
}
} catch (error) {
console.error("Error testing extraction:", error);
toast.error("Test Failed", {
description: "Failed to test extraction",
});
} finally {
setIsLoading(false);
}
};
const presetEmails = [
{
name: "Payment Plan",
content: "Thank you for your payment arrangement request. We are pleased to offer you a structured payment plan. We can accept $250 per month for 18 months, totaling $4,500. This arrangement will allow you to resolve this matter with manageable monthly payments."
},
{
name: "Settlement Offer",
content: "We are pleased to accept your proposed settlement offer of $3,200. We will process the settlement as discussed and consider this matter resolved upon receipt of payment."
},
{
name: "Counter Offer",
content: "Thank you for your proposal. However, we can only accept a settlement of $4,000 or a payment plan of $300 per month for 15 months. Please let us know which option works better for you."
},
{
name: "Rejection",
content: "Thank you for your payment proposal. Unfortunately, we cannot accept the terms you have proposed. We require the full amount of $5,000 to be paid within 30 days."
}
];
return (
<Card className="w-full max-w-4xl mx-auto">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TestTube className="h-5 w-5" />
AI Extraction Tester
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Preset Emails */}
<div>
<label className="text-sm font-medium mb-2 block">Preset Test Emails:</label>
<div className="flex flex-wrap gap-2">
{presetEmails.map((preset) => (
<Button
key={preset.name}
variant="outline"
size="sm"
onClick={() => setTestEmail(preset.content)}
>
{preset.name}
</Button>
))}
</div>
</div>
{/* Email Input */}
<div>
<label className="text-sm font-medium mb-2 block">Test Email Content:</label>
<Textarea
value={testEmail}
onChange={(e) => setTestEmail(e.target.value)}
placeholder="Enter email content to test extraction..."
rows={4}
className="w-full"
/>
</div>
{/* Test Button */}
<Button
onClick={testExtraction}
disabled={isLoading || !testEmail.trim()}
className="w-full"
>
{isLoading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Testing Extraction...
</>
) : (
<>
<TestTube className="h-4 w-4 mr-2" />
Test AI Extraction
</>
)}
</Button>
{/* Results */}
{result && (
<div className="space-y-4">
<div className="border-t pt-4">
<h3 className="font-medium mb-2">Extraction Results:</h3>
{/* Intent & Confidence */}
<div className="flex items-center gap-2 mb-3">
<Badge variant={result.analysis?.intent === "acceptance" ? "default" : "secondary"}>
Intent: {result.analysis?.intent}
</Badge>
<Badge variant="outline">
Confidence: {Math.round((result.analysis?.confidence || 0) * 100)}%
</Badge>
<Badge variant="outline">
Sentiment: {result.analysis?.sentiment}
</Badge>
</div>
{/* Extracted Terms */}
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<h4 className="font-medium mb-2">Extracted Terms:</h4>
<pre className="text-sm overflow-auto">
{JSON.stringify(result.extractedTerms, null, 2)}
</pre>
</div>
{/* Payment Terms Detail */}
{result.extractedTerms?.paymentTerms && (
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
<h4 className="font-medium mb-2">Payment Plan Details:</h4>
<div className="grid grid-cols-2 gap-2 text-sm">
{result.extractedTerms.paymentTerms.monthlyAmount && (
<div>
<span className="text-gray-600 dark:text-gray-400">Monthly:</span>
<div className="font-medium">${result.extractedTerms.paymentTerms.monthlyAmount}</div>
</div>
)}
{result.extractedTerms.paymentTerms.numberOfPayments && (
<div>
<span className="text-gray-600 dark:text-gray-400">Payments:</span>
<div className="font-medium">{result.extractedTerms.paymentTerms.numberOfPayments}</div>
</div>
)}
{result.extractedTerms.paymentTerms.totalAmount && (
<div>
<span className="text-gray-600 dark:text-gray-400">Total:</span>
<div className="font-medium">${result.extractedTerms.paymentTerms.totalAmount}</div>
</div>
)}
{result.extractedTerms.paymentTerms.paymentFrequency && (
<div>
<span className="text-gray-600 dark:text-gray-400">Frequency:</span>
<div className="font-medium">{result.extractedTerms.paymentTerms.paymentFrequency}</div>
</div>
)}
</div>
</div>
)}
{/* Proposed Amount */}
{result.extractedTerms?.proposedAmount && (
<div className="bg-green-50 dark:bg-green-900/20 rounded-lg p-4">
<h4 className="font-medium mb-2">Settlement Amount:</h4>
<div className="text-lg font-bold text-green-600">
${result.extractedTerms.proposedAmount}
</div>
</div>
)}
{/* Reasoning */}
{result.analysis?.reasoning && (
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<h4 className="font-medium mb-2">AI Reasoning:</h4>
<p className="text-sm text-gray-600 dark:text-gray-300">
{result.analysis.reasoning}
</p>
</div>
)}
</div>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,121 @@
import { useState } from "react";
import { supabase } from "../lib/supabase";
import { Button } from "./ui/button";
import { toast } from "sonner";
import { MessageSquare } from "lucide-react";
interface RealtimeTestButtonProps {
debtId: string;
}
export function RealtimeTestButton({ debtId }: RealtimeTestButtonProps) {
const [isSimulating, setIsSimulating] = useState(false);
const simulateIncomingMessage = async () => {
setIsSimulating(true);
try {
// Randomly choose between principal reduction and payment restructuring
const isPaymentPlan = Math.random() > 0.5;
const testMessage = isPaymentPlan ? {
// Payment restructuring scenario
debt_id: debtId,
message_type: "acceptance",
direction: "inbound",
subject: "Re: Payment Arrangement Request - Payment Plan Approved",
body: "Thank you for your payment arrangement request. We are pleased to offer you a structured payment plan. We can accept $250 per month for 18 months, totaling $4,500. This arrangement will allow you to resolve this matter with manageable monthly payments while providing you with extended terms.",
from_email: "collections@testcreditor.com",
to_email: "user@example.com",
ai_analysis: {
intent: "acceptance",
sentiment: "positive",
confidence: 0.92,
extractedTerms: {
proposedAmount: 4500,
proposedPaymentPlan: "monthly payment plan",
paymentTerms: {
monthlyAmount: 250,
numberOfPayments: 18,
totalAmount: 4500,
paymentFrequency: "monthly",
interestRate: 0
},
deadline: null,
conditions: [
"structured payment plan",
"manageable monthly payments",
"extended terms"
],
},
reasoning: "Creditor has accepted a payment restructuring plan with extended terms",
suggestedNextAction: "mark_settled",
requiresUserReview: false,
},
} : {
// Principal reduction scenario
debt_id: debtId,
message_type: "acceptance",
direction: "inbound",
subject: "Re: Payment Arrangement Request - Settlement Accepted",
body: "Thank you for your payment arrangement request. We are pleased to accept your proposed settlement offer of $3,200. We will process the settlement as discussed and consider this matter resolved upon receipt of payment.",
from_email: "collections@testcreditor.com",
to_email: "user@example.com",
ai_analysis: {
intent: "acceptance",
sentiment: "positive",
confidence: 0.95,
extractedTerms: {
proposedAmount: 3200,
proposedPaymentPlan: "lump sum settlement",
deadline: null,
conditions: [
"settlement accepted",
"matter resolved upon payment",
],
},
reasoning: "Creditor has accepted the proposed settlement offer with principal reduction",
suggestedNextAction: "mark_settled",
requiresUserReview: false,
},
};
const { error } = await supabase
.from("conversation_messages")
.insert(testMessage);
if (error) {
throw error;
}
toast.success(
isPaymentPlan ? "🏦 Payment Plan Simulated" : "🎉 Settlement Simulated",
{
description: isPaymentPlan
? "A payment restructuring scenario has been simulated"
: "A principal reduction scenario has been simulated",
}
);
} catch (error) {
console.error("Error simulating message:", error);
toast.error("Simulation Failed", {
description: "Failed to simulate incoming message",
});
} finally {
setIsSimulating(false);
}
};
return (
<Button
variant="outline"
size="sm"
onClick={simulateIncomingMessage}
disabled={isSimulating}
className="flex items-center gap-2"
>
<MessageSquare className="h-4 w-4" />
{isSimulating ? "Simulating..." : "Test Real-time"}
</Button>
);
}

View File

@@ -27,6 +27,10 @@ export type Debt = {
| "negotiating"
| "approved"
| "sent"
| "awaiting_response"
| "counter_negotiating"
| "accepted"
| "rejected"
| "settled"
| "failed"
| "opted_out";
@@ -35,6 +39,11 @@ export type Debt = {
user_id: string;
description?: string | null;
due_date?: string | null;
conversation_count?: number;
last_message_at?: string;
negotiation_round?: number;
prospected_savings?: number;
actual_savings?: number;
metadata?: Record<string, any> | null;
};
@@ -84,3 +93,24 @@ export type DebtVariable = {
created_at: string;
updated_at: string;
};
export type ConversationMessage = {
id: string;
debt_id: string;
message_type:
| "initial_debt"
| "negotiation_sent"
| "response_received"
| "counter_offer"
| "acceptance"
| "rejection";
direction: "inbound" | "outbound";
subject?: string;
body: string;
from_email?: string;
to_email?: string;
message_id?: string;
ai_analysis?: Record<string, any>;
created_at: string;
updated_at: string;
};

View File

@@ -1,6 +1,13 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(amount).split("$")[1];
};

View File

@@ -141,6 +141,171 @@ async function incrementEmailUsage(
}
}
// Check if incoming email is a response to existing negotiation
async function checkForExistingNegotiation(
fromEmail: string,
toEmail: string,
supabaseAdmin: any,
) {
try {
// Look for debts where we've sent emails to this fromEmail and are awaiting response
// Include multiple statuses that indicate we're in an active negotiation
const { data: debts, error } = await supabaseAdmin
.from("debts")
.select("*")
.in("status", ["sent", "awaiting_response", "counter_negotiating"])
.contains("metadata", { fromEmail: fromEmail, toEmail: toEmail })
.order("last_message_at", { ascending: false });
if (error) {
console.error("Error checking for existing negotiation:", error);
return null;
}
// Return the most recent debt that matches
return debts && debts.length > 0 ? debts[0] : null;
} catch (error) {
console.error("Error in checkForExistingNegotiation:", error);
return null;
}
}
// Handle response to existing negotiation
async function handleNegotiationResponse(
debt: any,
emailData: any,
supabaseAdmin: any,
) {
try {
const textBody = emailData.TextBody || emailData.HtmlBody || "";
const fromEmail = emailData.FromFull?.Email || emailData.From || "unknown";
const subject = emailData.Subject || "";
const messageId = emailData.MessageID || `inbound-${Date.now()}`;
// First, record this message in the conversation
await supabaseAdmin.from("conversation_messages").insert({
debt_id: debt.id,
message_type: "response_received",
direction: "inbound",
subject: subject,
body: textBody,
from_email: fromEmail,
to_email: emailData.ToFull?.[0]?.Email || emailData.To || "",
message_id: messageId,
});
// Update debt conversation tracking
await supabaseAdmin
.from("debts")
.update({
conversation_count: debt.conversation_count + 1,
last_message_at: new Date().toISOString(),
status: "counter_negotiating", // Temporary status while analyzing
})
.eq("id", debt.id);
// Call the analyze-response function
const supabaseUrl = process.env.SUPABASE_URL ||
import.meta.env.PUBLIC_SUPABASE_URL;
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY ||
import.meta.env.SUPABASE_SERVICE_ROLE_KEY;
if (supabaseUrl && supabaseServiceKey) {
const analyzeUrl = `${supabaseUrl}/functions/v1/analyze-response`;
try {
const response = await fetch(analyzeUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${supabaseServiceKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
debtId: debt.id,
fromEmail,
subject,
body: textBody,
messageId: messageId,
}),
});
if (response.ok) {
const result = await response.json();
console.log("Response analysis completed:", result);
// Update the conversation message with AI analysis
// !MAYBE NEEDED
// await supabaseAdmin
// .from("conversation_messages")
// .update({
// ai_analysis: result.analysis,
// message_type: result.analysis?.intent === "acceptance"
// ? "acceptance"
// : result.analysis?.intent === "rejection"
// ? "rejection"
// : "response_received",
// })
// .eq("message_id", messageId);
return new Response(
JSON.stringify({
success: true,
message: "Negotiation response processed",
analysis: result.analysis,
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
);
} else {
console.error(
"Error calling analyze-response function:",
await response.text(),
);
}
} catch (analyzeError) {
console.error("Error calling analyze-response function:", analyzeError);
}
}
// Fallback: just log the response and mark for manual review
await supabaseAdmin.from("audit_logs").insert({
debt_id: debt.id,
action: "response_received_fallback",
details: {
fromEmail,
subject,
bodyPreview: textBody.substring(0, 200),
requiresManualReview: true,
},
});
// Update status to require user review
await supabaseAdmin
.from("debts")
.update({ status: "awaiting_response" })
.eq("id", debt.id);
return new Response(
JSON.stringify({ success: true, message: "Response logged" }),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
);
} catch (error) {
console.error("Error handling negotiation response:", error);
return new Response(
JSON.stringify({ error: "Failed to process negotiation response" }),
{
status: 500,
headers: { "Content-Type": "application/json" },
},
);
}
}
export const POST: APIRoute = async ({ request }) => {
try {
// Create service role client for webhook operations (bypasses RLS)
@@ -180,6 +345,21 @@ export const POST: APIRoute = async ({ request }) => {
return new Response("No matching user found", { status: 200 });
}
// Check if this is a response to an existing negotiation
const existingDebt = await checkForExistingNegotiation(
fromEmail,
toEmail,
supabaseAdmin,
);
console.log({ existingDebt, fromEmail, toEmail });
if (existingDebt) {
console.log(
`Found existing negotiation for debt ${existingDebt.id}, analyzing response...`,
);
return await handleNegotiationResponse(existingDebt, data, supabaseAdmin);
}
// Increment email processing usage
await incrementEmailUsage(userId, supabaseAdmin);
@@ -248,6 +428,9 @@ export const POST: APIRoute = async ({ request }) => {
status: "received",
description: debtInfo.description,
due_date: debtInfo.dueDate,
conversation_count: 1,
last_message_at: new Date().toISOString(),
negotiation_round: 1,
metadata: {
isDebtCollection: debtInfo.isDebtCollection,
subject: data.Subject,
@@ -258,6 +441,20 @@ export const POST: APIRoute = async ({ request }) => {
.select()
.single();
if (!insertError && insertedDebt) {
// Record the initial debt email as the first conversation message
await supabaseAdmin.from("conversation_messages").insert({
debt_id: insertedDebt.id,
message_type: "initial_debt",
direction: "inbound",
subject: data.Subject,
body: textBody,
from_email: fromEmail,
to_email: toEmail,
message_id: data.MessageID || `initial-${Date.now()}`,
});
}
if (insertError) {
console.error("Error inserting debt:", insertError);
const errorInfo = handleDatabaseError(insertError);

View File

@@ -0,0 +1,754 @@
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
import { generateObject } from "https://esm.sh/ai@3.4.7";
import { createGoogleGenerativeAI } from "https://esm.sh/@ai-sdk/google@0.0.52";
import { z } from "https://esm.sh/zod@3.22.4";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
};
// Schema for AI response analysis
const responseAnalysisSchema = z.object({
intent: z.enum([
"acceptance",
"rejection",
"counter_offer",
"request_info",
"unclear",
])
.describe("The primary intent of the response"),
sentiment: z.enum(["positive", "negative", "neutral"])
.describe("Overall sentiment of the response"),
confidence: z.number().min(0).max(1)
.describe("Confidence in the intent classification"),
extractedTerms: z.object({
proposedAmount: z.number().optional().describe(
"Any amount mentioned in response",
),
proposedPaymentPlan: z.string().optional().describe(
"Payment plan details if mentioned",
),
paymentTerms: z.object({
monthlyAmount: z.number().optional().describe("Monthly payment amount"),
numberOfPayments: z.number().optional().describe(
"Number of payments/installments",
),
totalAmount: z.number().optional().describe("Total amount to be paid"),
interestRate: z.number().optional().describe(
"Interest rate if applicable",
),
paymentFrequency: z.string().optional().describe(
"Payment frequency (monthly, weekly, etc.)",
),
}).optional().describe("Structured payment plan terms"),
deadline: z.string().optional().describe("Any deadline mentioned"),
conditions: z.array(z.string()).optional().describe(
"Any conditions or requirements mentioned",
),
}).describe("Key terms extracted from the response"),
reasoning: z.string().describe("Explanation of the analysis"),
suggestedNextAction: z.enum([
"accept_offer",
"send_counter",
"request_clarification",
"escalate_to_user",
"mark_settled",
]).describe("Recommended next action"),
requiresUserReview: z.boolean().describe(
"Whether this response needs human review",
),
});
interface EmailResponseData {
debtId: string;
fromEmail: string;
subject: string;
body: string;
messageId?: string;
}
// AI-powered response analysis
async function analyzeEmailResponse(
debtId: string,
fromEmail: string,
subject: string,
body: string,
originalNegotiation?: any,
) {
try {
const googleApiKey = Deno.env.get("GOOGLE_GENERATIVE_AI_API_KEY");
if (!googleApiKey) {
console.warn("Google API key not configured, using fallback analysis");
return getFallbackAnalysis(body);
}
console.log("Analyzing email with AI:", {
fromEmail,
subject,
bodyLength: body.length,
});
console.log({
debtId,
fromEmail,
subject,
body,
originalNegotiation,
});
const system =
`You are an expert financial analysis AI. Your sole function is to meticulously analyze creditor emails and populate a structured JSON object that conforms to the provided schema.
Your entire output MUST be a single, valid JSON object. Do not include any markdown, explanations, or conversational text outside of the JSON structure itself.
--- FIELD-BY-FIELD INSTRUCTIONS ---
1. **intent**: Classify the creditor's primary intent.
- "acceptance": Clear agreement to our original proposal.
- "rejection": Clear refusal of our proposal without offering new terms.
- "counter_offer": Proposes ANY new financial terms (different amount, payment plan, etc.). This is the most common intent besides acceptance.
- "request_info": Asks for more information (e.g., "Can you provide proof of hardship?").
- "unclear": The purpose of the email cannot be determined.
2. **sentiment**: Classify the emotional tone of the email.
- "positive": Cooperative, polite, agreeable language.
- "negative": Hostile, demanding, or threatening language.
- "neutral": Strictly professional, factual, and devoid of emotional language.
3. **confidence**: Provide a score from 0.0 to 1.0 for your "intent" classification. (e.g., 0.95).
4. **extractedTerms**:
- **proposedAmount**: Extract a single, lump-sum settlement amount if offered. If the offer is a payment plan, this field should be null.
- **proposedPaymentPlan**: Capture the payment plan offer as a descriptive string, exactly as the creditor states it. e.g., "$100 a month for 12 months". If no plan is mentioned, this is null.
- **paymentTerms**: If a payment plan is mentioned, break it down into its structured components here. If no plan is mentioned, this entire object should be null.
- **monthlyAmount**: The specific amount for each payment.
- **numberOfPayments**: The number of installments.
- **totalAmount**: The total payout of the plan, ONLY if explicitly stated (e.g., "...totaling $1200").
- **interestRate**: The interest rate as a number (e.g., for "5% interest", extract 5).
- **paymentFrequency**: The frequency (e.g., "monthly", "weekly", "bi-weekly").
- **deadline**: Extract any specific date or timeframe for action (e.g., "by June 30th", "within 10 days").
- **conditions**: Extract all non-financial requirements as an array of strings. Example: ["Payment must be via certified funds", "A settlement agreement must be signed"]. If none, use an empty array [].
5. **reasoning**: Briefly explain the logic behind your "intent" and "suggestedNextAction" classifications, referencing parts of the email.
6. **suggestedNextAction**: Recommend the most logical business action.
- "accept_offer": The creditor's offer matches or is better than our goal.
- "send_counter": The creditor made a counter-offer that we should negotiate.
- "request_clarification": The email is ambiguous or missing key information.
- "escalate_to_user": The response is hostile, contains legal threats, is complex, or requires a human decision.
- "mark_settled": The email confirms the debt is fully settled and no further action is needed.
7. **requiresUserReview**: Set to 'true' if intent is "unclear", sentiment is "negative", confidence is below 0.85, the email contains unusual legal language, or the "suggestedNextAction" is "escalate_to_user". Otherwise, set to 'false'.`;
const prompt =
`Analyze the following email and extract the financial details and intent, populating the JSON object according to your system instructions.
--- EMAIL TO ANALYZE ---
From: ${fromEmail}
Subject: ${subject}
Body: ${body}
${
originalNegotiation
? `--- ORIGINAL CONTEXT FOR YOUR ANALYSIS ---
Our Negotiation Strategy: ${originalNegotiation.strategy}
Our Proposed Amount: $${originalNegotiation.proposedAmount || "N/A"}
Our Proposed Terms: ${originalNegotiation.terms || "N/A"}
Our Reasoning: ${originalNegotiation.reasoning || "N/A"}
Our Latest Email Body: ${originalNegotiation.body || "N/A"}
`
: ""
}`;
console.log("AI Analysis System:", system);
console.log("AI Analysis Prompt:", prompt);
const result = await generateObject({
model: createGoogleGenerativeAI({
apiKey: googleApiKey,
})("gemini-2.5-flash-preview-04-17"),
system,
prompt,
schema: responseAnalysisSchema,
});
console.log("AI Analysis Result:", JSON.stringify(result.object, null, 2));
console.log(
"Extracted Terms:",
JSON.stringify(result.object.extractedTerms, null, 2),
);
return result.object;
} catch (error) {
console.error("AI response analysis error:", error);
console.log("Falling back to regex-based analysis");
return getFallbackAnalysis(body);
}
}
// Calculate financial outcome when offer is accepted
function calculateFinancialOutcome(debt: any, analysis: any): any {
try {
const originalAmount = debt.amount || 0;
let acceptedAmount = originalAmount;
let paymentStructure = null;
let financialBenefit = null;
// Try to extract accepted amount from AI analysis
if (analysis.extractedTerms?.proposedAmount) {
acceptedAmount = analysis.extractedTerms.proposedAmount;
} else if (analysis.extractedTerms?.paymentTerms?.totalAmount) {
acceptedAmount = analysis.extractedTerms.paymentTerms.totalAmount;
} else if (debt.metadata?.prospectedSavings?.amount) {
// Fall back to original negotiation terms if no specific amount mentioned
acceptedAmount = originalAmount - debt.metadata.prospectedSavings.amount;
}
// Analyze payment structure if present
if (analysis.extractedTerms?.paymentTerms) {
const terms = analysis.extractedTerms.paymentTerms;
paymentStructure = {
type: "installment_plan",
monthlyAmount: terms.monthlyAmount,
numberOfPayments: terms.numberOfPayments,
totalAmount: terms.totalAmount || acceptedAmount,
frequency: terms.paymentFrequency || "monthly",
interestRate: terms.interestRate || 0,
};
// Calculate time value and cash flow benefits
if (terms.monthlyAmount && terms.numberOfPayments) {
const totalPayments = terms.monthlyAmount * terms.numberOfPayments;
const timeToComplete = terms.numberOfPayments; // in months
financialBenefit = {
type: "payment_restructuring",
principalReduction: Math.max(0, originalAmount - totalPayments),
cashFlowRelief: {
monthlyReduction: originalAmount - terms.monthlyAmount,
extendedTermMonths: timeToComplete,
totalCashFlowBenefit: (originalAmount - terms.monthlyAmount) *
timeToComplete,
},
timeValueBenefit: calculateTimeValueBenefit(
originalAmount,
terms.monthlyAmount,
timeToComplete,
),
};
}
}
// Calculate actual savings (principal reduction)
const actualSavings = Math.max(0, originalAmount - acceptedAmount);
// Determine the primary financial benefit
if (actualSavings > 0) {
financialBenefit = {
type: "principal_reduction",
amount: actualSavings,
percentage: (actualSavings / originalAmount * 100).toFixed(2),
description: `${
((actualSavings / originalAmount) * 100).toFixed(1)
}% principal reduction`,
};
} else if (paymentStructure && paymentStructure.monthlyAmount) {
// No principal reduction but payment restructuring
const monthlyReduction = originalAmount - paymentStructure.monthlyAmount;
financialBenefit = {
type: "payment_restructuring",
monthlyReduction: monthlyReduction,
extendedTermMonths: paymentStructure.numberOfPayments,
description:
`Payment restructured to $${paymentStructure.monthlyAmount}/month over ${paymentStructure.numberOfPayments} months`,
cashFlowBenefit: monthlyReduction > 0
? `$${monthlyReduction}/month cash flow relief`
: "Extended payment terms",
};
}
console.log(
`Financial outcome: Original: $${originalAmount}, Accepted: $${acceptedAmount}, Savings: $${actualSavings}`,
);
return {
actualSavings,
acceptedAmount,
paymentStructure,
financialBenefit,
originalAmount,
};
} catch (error) {
console.error("Error calculating financial outcome:", error);
// Return basic fallback
return {
actualSavings: debt.projected_savings || 0,
acceptedAmount: debt.amount,
paymentStructure: null,
financialBenefit: null,
originalAmount: debt.amount,
};
}
}
// Calculate time value benefit of extended payment terms
function calculateTimeValueBenefit(
originalAmount: number,
monthlyPayment: number,
months: number,
): any {
// Simple present value calculation assuming 5% annual discount rate
const monthlyRate = 0.05 / 12;
let presentValue = 0;
for (let i = 1; i <= months; i++) {
presentValue += monthlyPayment / Math.pow(1 + monthlyRate, i);
}
const timeValueBenefit = originalAmount - presentValue;
return {
presentValueOfPayments: presentValue.toFixed(2),
timeValueBenefit: timeValueBenefit.toFixed(2),
effectiveDiscount: ((timeValueBenefit / originalAmount) * 100).toFixed(2),
};
}
// Fallback analysis when AI is unavailable
function getFallbackAnalysis(
body: string,
): typeof responseAnalysisSchema._type {
const lowerBody = body.toLowerCase();
// Simple keyword-based analysis
let intent:
| "acceptance"
| "rejection"
| "counter_offer"
| "request_info"
| "unclear" = "unclear";
let sentiment: "positive" | "negative" | "neutral" = "neutral";
if (
lowerBody.includes("accept") || lowerBody.includes("agree") ||
lowerBody.includes("approved")
) {
intent = "acceptance";
sentiment = "positive";
} else if (
lowerBody.includes("reject") || lowerBody.includes("decline") ||
lowerBody.includes("denied")
) {
intent = "rejection";
sentiment = "negative";
} else if (
lowerBody.includes("counter") || lowerBody.includes("instead") ||
lowerBody.includes("however")
) {
intent = "counter_offer";
sentiment = "neutral";
} else if (
lowerBody.includes("information") || lowerBody.includes("clarify") ||
lowerBody.includes("details")
) {
intent = "request_info";
sentiment = "neutral";
}
// Enhanced extraction using multiple regex patterns
const extractFinancialTerms = (text: string) => {
// Extract dollar amounts
const amountMatches = text.match(/\$(\d+(?:,\d{3})*(?:\.\d{2})?)/g);
const amounts =
amountMatches?.map((match) => parseFloat(match.replace(/[$,]/g, ""))) ||
[];
// Extract monthly payment patterns
const monthlyMatch = text.match(
/\$?(\d+(?:,\d{3})*(?:\.\d{2})?)\s*(?:per month|\/month|monthly)/i,
);
const monthlyAmount = monthlyMatch
? parseFloat(monthlyMatch[1].replace(/,/g, ""))
: undefined;
// Extract number of payments/months
const paymentsMatch = text.match(
/(\d+)\s*(?:months?|payments?|installments?)/i,
);
const numberOfPayments = paymentsMatch
? parseInt(paymentsMatch[1])
: undefined;
// Extract total amount patterns
const totalMatch = text.match(
/(?:total|totaling|total amount)\s*(?:of\s*)?\$?(\d+(?:,\d{3})*(?:\.\d{2})?)/i,
);
const totalAmount = totalMatch
? parseFloat(totalMatch[1].replace(/,/g, ""))
: undefined;
// Determine payment frequency
let paymentFrequency = undefined;
if (text.match(/monthly|per month|\/month/i)) paymentFrequency = "monthly";
else if (text.match(/weekly|per week|\/week/i)) paymentFrequency = "weekly";
else if (text.match(/bi-weekly|biweekly|every two weeks/i)) {
paymentFrequency = "bi-weekly";
}
return {
proposedAmount: amounts.length > 0 ? amounts[0] : undefined,
monthlyAmount,
numberOfPayments,
totalAmount,
paymentFrequency,
allAmounts: amounts,
};
};
const terms = extractFinancialTerms(body);
console.log(
"Fallback Analysis - Extracted Terms:",
JSON.stringify(terms, null, 2),
);
const result = {
intent,
sentiment,
confidence: 0.6, // Lower confidence for fallback
extractedTerms: {
proposedAmount: terms.proposedAmount,
proposedPaymentPlan: terms.monthlyAmount ? "payment plan" : undefined,
paymentTerms: (terms.monthlyAmount || terms.numberOfPayments)
? {
monthlyAmount: terms.monthlyAmount,
numberOfPayments: terms.numberOfPayments,
totalAmount: terms.totalAmount,
paymentFrequency: terms.paymentFrequency,
}
: undefined,
deadline: undefined,
conditions: [],
},
reasoning:
`Generated using enhanced keyword-based fallback analysis. Found ${terms.allAmounts.length} amounts.`,
suggestedNextAction: intent === "acceptance"
? "mark_settled"
: intent === "rejection"
? "escalate_to_user"
: intent === "counter_offer"
? "send_counter"
: "escalate_to_user",
requiresUserReview: true, // Always require review for fallback
};
console.log("Fallback Analysis Result:", JSON.stringify(result, null, 2));
return result;
}
serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
try {
const supabaseClient = createClient(
Deno.env.get("SUPABASE_URL") ?? "",
Deno.env.get("SUPABASE_ANON_KEY") ?? "",
{
global: {
headers: { Authorization: req.headers.get("Authorization")! },
},
},
);
const { debtId, fromEmail, subject, body, messageId }: EmailResponseData =
await req.json();
if (!debtId || !fromEmail || !body) {
return new Response(
JSON.stringify({
error: "Missing required fields: debtId, fromEmail, body",
}),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Get the debt record and original negotiation context
const { data: debt, error: debtError } = await supabaseClient
.from("debts")
.select("*")
.eq("id", debtId)
.single();
if (debtError || !debt) {
return new Response(
JSON.stringify({ error: "Debt record not found" }),
{
status: 404,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Analyze the response using AI
const analysis = await analyzeEmailResponse(
debtId,
fromEmail,
subject,
body,
debt.metadata?.aiEmail,
);
// Store the conversation message
const { error: messageError } = await supabaseClient
.from("conversation_messages")
.update({
debt_id: debtId,
message_type: analysis.intent === "acceptance"
? "acceptance"
: analysis.intent === "rejection"
? "rejection"
: analysis.intent === "counter_offer"
? "counter_offer"
: "response_received",
direction: "inbound",
subject,
body,
from_email: fromEmail,
to_email: debt.metadata?.toEmail || debt.metadata?.fromEmail,
ai_analysis: analysis,
})
.eq("message_id", messageId);
if (messageError) {
console.error("Error storing conversation message:", messageError);
}
// Determine next status and actions based on analysis
let newStatus = debt.status;
let newNegotiationRound = debt.negotiation_round || 1;
let financialOutcome = null;
let shouldAutoRespond = false;
let nextAction = null;
switch (analysis.intent) {
case "acceptance":
newStatus = "accepted";
nextAction = "mark_settled";
// Calculate financial outcome when offer is accepted
financialOutcome = calculateFinancialOutcome(debt, analysis);
break;
case "rejection":
newStatus = "rejected";
nextAction = "escalate_to_user";
break;
case "counter_offer":
newStatus = "counter_negotiating";
newNegotiationRound += 1;
shouldAutoRespond = !analysis.requiresUserReview &&
analysis.confidence > 0.8;
nextAction = analysis.suggestedNextAction;
break;
case "request_info":
newStatus = "awaiting_response";
nextAction = "escalate_to_user";
break;
default:
newStatus = "awaiting_response";
nextAction = "escalate_to_user";
}
// Update debt record
const updateData: any = {
status: newStatus,
negotiation_round: newNegotiationRound,
conversation_count: (debt.conversation_count || 0) + 1,
last_message_at: new Date().toISOString(),
metadata: {
...debt.metadata,
lastResponse: {
analysis,
receivedAt: new Date().toISOString(),
fromEmail,
subject,
},
},
};
// Add financial outcome if offer was accepted
if (analysis.intent === "acceptance" && financialOutcome) {
updateData.actual_savings = financialOutcome.actualSavings;
updateData.status = "settled"; // Set final status here instead of separate update
updateData.metadata.financialOutcome = {
...financialOutcome,
calculatedAt: new Date().toISOString(),
};
// Keep backward compatibility with actualSavings field
updateData.metadata.actualSavings = {
amount: financialOutcome.actualSavings,
calculatedAt: new Date().toISOString(),
originalAmount: financialOutcome.originalAmount,
acceptedAmount: financialOutcome.acceptedAmount,
savingsPercentage: financialOutcome.originalAmount > 0
? (financialOutcome.actualSavings / financialOutcome.originalAmount *
100).toFixed(2)
: 0,
};
}
const { error: updateError } = await supabaseClient
.from("debts")
.update(updateData)
.eq("id", debtId);
if (updateError) {
console.error("Error updating debt:", updateError);
}
// Log the action
const auditDetails: any = {
intent: analysis.intent,
sentiment: analysis.sentiment,
confidence: analysis.confidence,
fromEmail,
subject,
suggestedAction: analysis.suggestedNextAction,
requiresReview: analysis.requiresUserReview,
};
// Add financial outcome to audit log if offer was accepted
if (analysis.intent === "acceptance" && financialOutcome) {
auditDetails.financialOutcome = financialOutcome;
auditDetails.actualSavings = financialOutcome.actualSavings;
auditDetails.originalAmount = financialOutcome.originalAmount;
auditDetails.acceptedAmount = financialOutcome.acceptedAmount;
auditDetails.savingsPercentage = financialOutcome.originalAmount > 0
? (financialOutcome.actualSavings / financialOutcome.originalAmount *
100).toFixed(2)
: 0;
}
await supabaseClient
.from("audit_logs")
.insert({
debt_id: debtId,
action: analysis.intent === "acceptance"
? "offer_accepted"
: "response_analyzed",
details: {
...auditDetails,
nextAction,
shouldAutoRespond,
negotiationRound: newNegotiationRound,
reasoning: analysis.reasoning,
},
});
// If this is an acceptance, mark as settled
if (analysis.intent === "acceptance") {
// await supabaseClient
// .from("debts")
// .update({ status: "settled" })
// .eq("id", debtId);
await supabaseClient.from("audit_logs").insert({
debt_id: debtId,
action: "debt_settled",
details: {
finalAmount: financialOutcome?.acceptedAmount || debt.amount,
actualSavings: financialOutcome?.actualSavings || 0,
settlementTerms: analysis.extractedTerms,
},
});
}
// If auto-response is recommended and confidence is high, trigger negotiation
if (
shouldAutoRespond && analysis.confidence > 0.8 &&
analysis.intent === "counter_offer"
) {
try {
const negotiateUrl = `${
Deno.env.get("SUPABASE_URL")
}/functions/v1/negotiate`;
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
if (negotiateUrl && serviceKey) {
await fetch(negotiateUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${serviceKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
record: {
...debt,
status: newStatus,
conversation_count: (debt.conversation_count || 0) + 1,
negotiation_round: newNegotiationRound,
},
counterOfferContext: {
previousResponse: body,
extractedTerms: analysis.extractedTerms,
sentiment: analysis.sentiment,
},
}),
});
await supabaseClient.from("audit_logs").insert({
debt_id: debtId,
action: "auto_counter_triggered",
details: {
confidence: analysis.confidence,
extractedTerms: analysis.extractedTerms,
},
});
}
} catch (autoResponseError) {
console.error("Error triggering auto-response:", autoResponseError);
}
}
const responseData: any = {
success: true,
analysis,
newStatus,
negotiationRound: newNegotiationRound,
};
// Include financial outcome in response if offer was accepted
if (analysis.intent === "acceptance" && financialOutcome) {
responseData.financialOutcome = financialOutcome;
responseData.actualSavings = financialOutcome.actualSavings;
responseData.savingsCalculated = true;
responseData.originalAmount = financialOutcome.originalAmount;
responseData.acceptedAmount = financialOutcome.acceptedAmount;
responseData.savingsPercentage = financialOutcome.originalAmount > 0
? (financialOutcome.actualSavings / financialOutcome.originalAmount *
100).toFixed(2)
: 0;
}
return new Response(
JSON.stringify(responseData),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
} catch (error) {
console.error("Error in analyze-response function:", error);
return new Response(
JSON.stringify({ error: "Internal server error" }),
{
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
});

View File

@@ -31,11 +31,14 @@ interface DebtRecord {
amount: number;
raw_email: string;
negotiated_plan?: string;
projected_savings?: number;
conversation_count?: number;
metadata?: {
aiEmail?: {
subject: string;
body: string;
strategy: string;
confidence?: number;
};
toEmail?: string;
fromEmail?: string;
@@ -243,11 +246,20 @@ Deno.serve(async (req) => {
body,
);
// Update debt status to sent - using authenticated client
// Update debt status to sent and preserve negotiating state
const { data: currentDebt } = await supabaseClient
.from("debts")
.select("conversation_count, negotiation_round")
.eq("id", debtId)
.single();
const { error: updateError } = await supabaseClient
.from("debts")
.update({
status: "sent",
conversation_count: (currentDebt?.conversation_count || 0) + 1,
last_message_at: new Date().toISOString(),
prospected_savings: debt.projected_savings || 0, // Store prospected savings when sent
metadata: {
...debt.metadata,
emailSent: {
@@ -257,6 +269,14 @@ Deno.serve(async (req) => {
from: fromEmail,
subject: subject,
},
prospectedSavings: {
amount: debt.projected_savings || 0,
percentage: debt.amount > 0
? ((debt.projected_savings || 0) / debt.amount * 100).toFixed(2)
: 0,
calculatedAt: new Date().toISOString(),
strategy: debt.metadata?.aiEmail?.strategy || "unknown",
},
},
})
.eq("id", debtId);
@@ -265,20 +285,35 @@ Deno.serve(async (req) => {
console.error("Error updating debt status:", updateError);
}
// Log the action - using authenticated client
await supabaseClient
.from("audit_logs")
.insert({
debt_id: debtId,
action: "email_sent",
details: {
messageId: emailResult.MessageID,
to: toEmail,
from: fromEmail,
subject: subject,
strategy: debt.metadata.aiEmail.strategy,
},
});
// Record the sent email in conversation history
await supabaseClient.from("conversation_messages").insert({
debt_id: debtId,
message_type: "negotiation_sent",
direction: "outbound",
subject: subject,
body: body,
from_email: fromEmail,
to_email: toEmail,
message_id: emailResult.MessageID,
ai_analysis: {
strategy: debt.metadata.aiEmail.strategy,
confidence: debt.metadata.aiEmail.confidence,
projectedSavings: debt.projected_savings,
},
});
// Log the email sending
await supabaseClient.from("audit_logs").insert({
debt_id: debtId,
action: "email_sent",
details: {
to: toEmail,
subject: subject,
postmarkMessageId: emailResult.MessageID,
conversationRound: currentDebt?.negotiation_round || 1,
strategy: debt.metadata.aiEmail.strategy,
},
});
return new Response(
JSON.stringify({

View File

@@ -0,0 +1,170 @@
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { generateObject } from "https://esm.sh/ai@3.4.7";
import { createGoogleGenerativeAI } from "https://esm.sh/@ai-sdk/google@0.0.52";
import { z } from "https://esm.sh/zod@3.22.4";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
};
// Same schema as analyze-response
const responseAnalysisSchema = z.object({
intent: z.enum([
"acceptance",
"rejection",
"counter_offer",
"request_info",
"unclear",
]).describe("The primary intent of the response"),
sentiment: z.enum(["positive", "negative", "neutral"])
.describe("Overall sentiment of the response"),
confidence: z.number().min(0).max(1)
.describe("Confidence in the intent classification"),
extractedTerms: z.object({
proposedAmount: z.number().optional().describe(
"Any amount mentioned in response",
),
proposedPaymentPlan: z.string().optional().describe(
"Payment plan details if mentioned",
),
paymentTerms: z.object({
monthlyAmount: z.number().optional().describe("Monthly payment amount"),
numberOfPayments: z.number().optional().describe(
"Number of payments/installments",
),
totalAmount: z.number().optional().describe("Total amount to be paid"),
interestRate: z.number().optional().describe(
"Interest rate if applicable",
),
paymentFrequency: z.string().optional().describe(
"Payment frequency (monthly, weekly, etc.)",
),
}).optional().describe("Structured payment plan terms"),
deadline: z.string().optional().describe("Any deadline mentioned"),
conditions: z.array(z.string()).optional().describe(
"Any conditions or requirements mentioned",
),
}).describe("Key terms extracted from the response"),
reasoning: z.string().describe("Explanation of the analysis"),
suggestedNextAction: z.enum([
"accept_offer",
"send_counter",
"request_clarification",
"escalate_to_user",
"mark_settled",
]).describe("Recommended next action"),
requiresUserReview: z.boolean().describe(
"Whether this response needs human review",
),
});
serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
try {
const { testEmail } = await req.json();
if (!testEmail) {
return new Response(
JSON.stringify({ error: "testEmail is required" }),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
}
const googleApiKey = Deno.env.get("GOOGLE_GENERATIVE_AI_API_KEY");
if (!googleApiKey) {
return new Response(
JSON.stringify({ error: "Google API key not configured" }),
{
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
}
console.log("Testing extraction with email:", testEmail);
const result = await generateObject({
model: createGoogleGenerativeAI({
apiKey: googleApiKey,
})("gemini-2.5-flash-preview-04-17"),
system: `You are an expert financial analyst specializing in debt collection and negotiation responses.
Your job is to carefully analyze creditor responses and extract ALL financial terms mentioned.
CRITICAL: Always extract financial information when present. Look for:
AMOUNTS:
- Any dollar amounts mentioned ($1,000, $500, etc.)
- Settlement offers or counter-offers
- Monthly payment amounts
- Total payment amounts
PAYMENT PLANS:
- Monthly payment amounts (e.g., "$200 per month", "$150/month")
- Number of payments/installments (e.g., "12 months", "24 payments")
- Payment frequency (monthly, weekly, bi-weekly)
- Total amounts for payment plans
- Interest rates if mentioned
EXTRACT EVERYTHING: Even if amounts seem obvious, always include them in extractedTerms.`,
prompt: `Analyze this test email and extract ALL financial terms:
EMAIL: ${testEmail}
EXTRACTION REQUIREMENTS:
1. Find ANY dollar amounts mentioned in the email
2. Look for payment plan details (monthly amounts, number of payments)
3. Identify payment frequency (monthly, weekly, etc.)
4. Extract total amounts if mentioned
5. Note any interest rates or fees
6. Capture all conditions and requirements
EXAMPLES OF WHAT TO EXTRACT:
- "We can accept $250 per month" → monthlyAmount: 250
- "for 18 months" → numberOfPayments: 18
- "totaling $4,500" → totalAmount: 4500
- "settlement of $3,200" → proposedAmount: 3200
- "monthly payments" → paymentFrequency: "monthly"
Be thorough and extract ALL financial information present in the email.`,
schema: responseAnalysisSchema,
});
console.log("AI Analysis Result:", JSON.stringify(result.object, null, 2));
return new Response(
JSON.stringify({
success: true,
analysis: result.object,
extractedTerms: result.object.extractedTerms,
debug: {
emailLength: testEmail.length,
hasGoogleAPI: !!googleApiKey,
timestamp: new Date().toISOString()
}
}),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
} catch (error) {
console.error("Error in test-extraction function:", error);
return new Response(
JSON.stringify({
error: "Internal server error",
details: error.message
}),
{
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
}
});

View File

@@ -0,0 +1,84 @@
-- Enhanced conversation tracking and negotiation flow
-- This migration adds comprehensive conversation tracking and improved status management
-- Create conversation_messages table to track all email exchanges
CREATE TABLE IF NOT EXISTS conversation_messages (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
debt_id uuid REFERENCES debts(id) ON DELETE CASCADE NOT NULL,
message_type text NOT NULL CHECK (message_type IN ('initial_debt', 'negotiation_sent', 'response_received', 'counter_offer', 'acceptance', 'rejection')),
direction text NOT NULL CHECK (direction IN ('inbound', 'outbound')),
subject text,
body text NOT NULL,
from_email text,
to_email text,
message_id text, -- Postmark message ID for outbound, email ID for inbound
ai_analysis jsonb DEFAULT '{}'::jsonb, -- AI analysis of the message content
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
-- Add indexes for performance
CREATE INDEX IF NOT EXISTS idx_conversation_messages_debt_id ON conversation_messages(debt_id);
CREATE INDEX IF NOT EXISTS idx_conversation_messages_created_at ON conversation_messages(created_at);
CREATE INDEX IF NOT EXISTS idx_conversation_messages_type ON conversation_messages(message_type);
-- Update debts table status constraint to include new statuses
ALTER TABLE debts
DROP CONSTRAINT IF EXISTS debts_status_check;
ALTER TABLE debts
ADD CONSTRAINT debts_status_check
CHECK (status IN (
'received',
'negotiating',
'approved',
'sent',
'awaiting_response',
'counter_negotiating',
'accepted',
'rejected',
'settled',
'failed',
'opted_out'
));
-- Add conversation tracking fields to debts table
ALTER TABLE debts ADD COLUMN IF NOT EXISTS conversation_count integer DEFAULT 0;
ALTER TABLE debts ADD COLUMN IF NOT EXISTS last_message_at timestamptz DEFAULT now();
ALTER TABLE debts ADD COLUMN IF NOT EXISTS negotiation_round integer DEFAULT 1;
ALTER TABLE debts ADD COLUMN IF NOT EXISTS prospected_savings numeric DEFAULT 0;
ALTER TABLE debts ADD COLUMN IF NOT EXISTS actual_savings numeric DEFAULT 0;
-- Create indexes for new columns
CREATE INDEX IF NOT EXISTS idx_debts_last_message_at ON debts(last_message_at);
CREATE INDEX IF NOT EXISTS idx_debts_negotiation_round ON debts(negotiation_round);
-- Enable RLS on conversation_messages
ALTER TABLE conversation_messages ENABLE ROW LEVEL SECURITY;
-- Add RLS policy for conversation_messages (users can only see their own debt conversations)
CREATE POLICY "Users can view their own conversation messages" ON conversation_messages
FOR SELECT USING (
debt_id IN (
SELECT id FROM debts WHERE user_id = auth.uid()
)
);
CREATE POLICY "Users can insert their own conversation messages" ON conversation_messages
FOR INSERT WITH CHECK (
debt_id IN (
SELECT id FROM debts WHERE user_id = auth.uid()
)
);
-- Enable real-time for conversation_messages
ALTER PUBLICATION supabase_realtime ADD TABLE conversation_messages;
-- Add comments for documentation
COMMENT ON TABLE conversation_messages IS 'Tracks all email exchanges in debt negotiations';
COMMENT ON COLUMN conversation_messages.message_type IS 'Type of message in the negotiation flow';
COMMENT ON COLUMN conversation_messages.direction IS 'Whether message was sent (outbound) or received (inbound)';
COMMENT ON COLUMN conversation_messages.ai_analysis IS 'AI analysis results including intent, sentiment, and extracted terms';
COMMENT ON COLUMN debts.conversation_count IS 'Total number of messages in this debt conversation';
COMMENT ON COLUMN debts.last_message_at IS 'Timestamp of the most recent message in conversation';
COMMENT ON COLUMN debts.negotiation_round IS 'Current round of negotiation (increments with each back-and-forth)';