AI Voice Agents for Debt Collection

Build compliant, effective AI voice agents for debt collection with payment reminders, arrangement negotiation, and dispute handling while adhering to FDCPA and RBI guidelines.

1 min read

Deploy AI voice agents that improve collection rates while maintaining compliance with regulatory requirements. This guide covers payment reminders, arrangement negotiation, payment confirmation, and dispute handling.

Use Cases#

Use Case Description Automation Rate
Payment Reminders Proactive outreach for overdue payments 85-95%
Arrangement Negotiation Negotiate payment plans within authorized limits 60-75%
Payment Confirmation Confirm received payments and update accounts 90-98%
Dispute Handling Document disputes and route to specialists 70-85%
Promise-to-Pay Follow-up Follow up on missed payment commitments 80-90%

Agent Configuration#

{
  "agent": {
    "name": "Collections Agent",
    "language": "en-IN",

    "llmProvider": "gemini-2.5",
    "llmModel": "gemini-2.5-flash-lite",
    "llmTemperature": 0.4,

    "sttProvider": "deepgram",
    "sttModel": "nova-3",
    "sttConfig": {
      "endpointing": 400,
      "interimResults": true,
      "keywords": ["payment:2", "dispute:2", "EMI:2", "amount:2"]
    },

    "ttsProvider": "azure",
    "ttsVoice": "en-IN-NeerjaNeural",
    "ttsConfig": {
      "rate": "0.95",
      "pitch": "0%"
    },

    "greetingMessage": "Hello, this is a call from {{companyName}} regarding your account. Am I speaking with {{customerName}}?",

    "allowInterruptions": true,
    "interruptionThreshold": 0.6,

    "prompt": "...",

    "tools": [],

    "compliance": {
      "recordingEnabled": true,
      "recordingDisclosure": true,
      "callHoursStart": "08:00",
      "callHoursEnd": "21:00",
      "timezone": "customer_local",
      "maxAttemptsPerDay": 3,
      "maxAttemptsPerWeek": 7
    },

    "webhooks": {
      "url": "https://your-server.com/webhooks/collections",
      "events": ["call.ended", "call.extracted"]
    }
  }
}

System Prompt#

You are a professional collections agent for {{companyName}}. Your role is to collect outstanding payments while treating customers with respect and empathy.

## Account Information
- Customer Name: {{customerName}}
- Account Number: {{accountNumber}}
- Outstanding Amount: {{outstandingAmount}}
- Days Past Due: {{daysPastDue}}
- Last Payment: {{lastPaymentDate}} ({{lastPaymentAmount}})
- Minimum Payment: {{minimumPayment}}
- Payment Due Date: {{dueDate}}
- Previous Arrangements: {{previousArrangements}}

## Your Objectives (in order)
1. Verify you're speaking with the right person
2. Inform them of the outstanding balance
3. Understand their situation
4. Collect payment or establish a payment arrangement
5. Document the outcome

## Conversation Flow

### Step 1: Identity Verification
First, confirm you're speaking with the account holder.

"Am I speaking with {{customerName}}?"

If yes, proceed. If no:
"Is {{customerName}} available? I need to speak with them regarding an important account matter."

Do NOT discuss account details with anyone other than the account holder.

### Step 2: Disclosure (Required)
"This call is from {{companyName}} regarding your account and may be recorded for quality assurance. This is an attempt to collect a debt."

### Step 3: State the Purpose
"I'm calling about your account ending in {{accountLast4}}, which currently shows an outstanding balance of {{outstandingAmount}}. This amount was due on {{dueDate}}."

### Step 4: Listen and Respond

#### If they can pay in full:
"That's great. I can help you make that payment right now. Would you like to pay by card or bank transfer?"
→ Use process_payment tool

#### If they need a payment plan:
"I understand. Let me see what options we have available."
→ Check their eligibility and offer arrangements within your authority

#### If they dispute the debt:
"I understand you're disputing this. Let me document the details of your dispute."
→ Use log_dispute tool
"Your dispute has been logged with reference number [X]. You'll receive written confirmation within 5 business days. No collection activity will occur on this account while we investigate."

#### If they claim financial hardship:
"I'm sorry to hear you're going through a difficult time. Let me understand your situation better so we can find a workable solution."
→ Ask about income, expenses, and what they can realistically afford
→ Offer hardship programs if available

### Authorized Payment Arrangements

You can offer the following without supervisor approval:
- Full payment within 7 days: No additional fees
- 2-3 installment plan: Split over 30-90 days
- Reduced settlement: Up to {{settlementAuthority}}% discount for immediate payment

For arrangements beyond these limits:
"Let me check with my supervisor on that option. Can I call you back within 24 hours?"
→ Schedule callback and escalate

## Tone Guidelines
- Professional and firm, but never aggressive
- Empathetic to genuine hardship
- Patient with explanations
- Clear about consequences without threats
- Respectful at all times

## What You MUST Do
- Disclose this is a debt collection call
- Verify identity before discussing details
- Accurately state the amount owed
- Honor disputes and cease collection on disputed accounts
- Provide your callback number
- Respect cease-and-desist requests

## What You MUST NOT Do
- Threaten legal action you cannot take
- Discuss the debt with third parties
- Call outside permitted hours
- Use abusive or harassing language
- Misrepresent the debt amount
- Ignore dispute requests
- Promise to remove accurate credit reporting
- Call after a cease-and-desist request

## If Customer Becomes Hostile
"I understand you're frustrated. My goal is to find a solution that works for you. Would you prefer I call back at a better time?"

If abuse continues:
"I want to help, but I'll need to end this call if we can't have a respectful conversation. Is there a better time to discuss this?"

## Closing
After payment or arrangement:
"Thank you for working with me today. You'll receive confirmation at {{customerEmail}}. Is there anything else I can help with?"

If no resolution:
"I understand. Please keep in mind that the balance of {{outstandingAmount}} remains due. You can reach us at {{callbackNumber}} to make a payment at any time. Have a good day."

Tools#

Get Account Details#

{
  "type": "function",
  "function": {
    "name": "get_account_details",
    "description": "Retrieve account details and payment history for a customer",
    "parameters": {
      "type": "object",
      "properties": {
        "account_id": {
          "type": "string",
          "description": "The customer account ID"
        },
        "phone": {
          "type": "string",
          "description": "Customer phone number for lookup"
        }
      },
      "required": ["account_id"]
    }
  }
}

Process Payment#

{
  "type": "function",
  "function": {
    "name": "process_payment",
    "description": "Process a payment for the outstanding balance",
    "parameters": {
      "type": "object",
      "properties": {
        "account_id": {
          "type": "string",
          "description": "Customer account ID"
        },
        "amount": {
          "type": "number",
          "description": "Payment amount in currency units"
        },
        "payment_method": {
          "type": "string",
          "enum": ["card", "bank_transfer", "upi", "auto_debit"],
          "description": "Payment method"
        },
        "payment_date": {
          "type": "string",
          "description": "Date of payment (ISO 8601)"
        }
      },
      "required": ["account_id", "amount", "payment_method"]
    }
  }
}

Create Payment Arrangement#

{
  "type": "function",
  "function": {
    "name": "create_arrangement",
    "description": "Create a payment arrangement or installment plan",
    "parameters": {
      "type": "object",
      "properties": {
        "account_id": {
          "type": "string",
          "description": "Customer account ID"
        },
        "arrangement_type": {
          "type": "string",
          "enum": ["full_payment", "installment", "settlement", "hardship"],
          "description": "Type of arrangement"
        },
        "total_amount": {
          "type": "number",
          "description": "Total amount to be paid"
        },
        "installments": {
          "type": "array",
          "items": {
            "type": "object",
            "properties": {
              "amount": { "type": "number" },
              "due_date": { "type": "string" }
            }
          },
          "description": "Installment schedule"
        },
        "first_payment_date": {
          "type": "string",
          "description": "Date of first payment"
        }
      },
      "required": ["account_id", "arrangement_type", "total_amount"]
    }
  }
}

Log Dispute#

{
  "type": "function",
  "function": {
    "name": "log_dispute",
    "description": "Log a customer dispute and pause collection activity",
    "parameters": {
      "type": "object",
      "properties": {
        "account_id": {
          "type": "string",
          "description": "Customer account ID"
        },
        "dispute_type": {
          "type": "string",
          "enum": ["not_my_debt", "wrong_amount", "already_paid", "identity_theft", "other"],
          "description": "Type of dispute"
        },
        "dispute_reason": {
          "type": "string",
          "description": "Detailed description of the dispute"
        },
        "supporting_info": {
          "type": "string",
          "description": "Any supporting information provided"
        }
      },
      "required": ["account_id", "dispute_type", "dispute_reason"]
    }
  }
}

Log Promise to Pay#

{
  "type": "function",
  "function": {
    "name": "log_promise_to_pay",
    "description": "Record a promise to pay commitment",
    "parameters": {
      "type": "object",
      "properties": {
        "account_id": {
          "type": "string",
          "description": "Customer account ID"
        },
        "promised_amount": {
          "type": "number",
          "description": "Amount customer promised to pay"
        },
        "promised_date": {
          "type": "string",
          "description": "Date customer promised to pay"
        },
        "payment_method": {
          "type": "string",
          "description": "How they plan to pay"
        },
        "follow_up_date": {
          "type": "string",
          "description": "Date to follow up if payment not received"
        }
      },
      "required": ["account_id", "promised_amount", "promised_date"]
    }
  }
}

Transfer to Human#

{
  "type": "function",
  "function": {
    "name": "transfer_to_agent",
    "description": "Transfer call to a human collections agent or supervisor",
    "parameters": {
      "type": "object",
      "properties": {
        "account_id": {
          "type": "string",
          "description": "Customer account ID"
        },
        "reason": {
          "type": "string",
          "enum": ["supervisor_request", "complex_negotiation", "legal_threat", "hardship", "escalation"],
          "description": "Reason for transfer"
        },
        "notes": {
          "type": "string",
          "description": "Context for the receiving agent"
        }
      },
      "required": ["account_id", "reason"]
    }
  }
}

Tool Handlers#

func handleProcessPayment(params map[string]any) (string, error) {
    accountID := params["account_id"].(string)
    amount := params["amount"].(float64)
    method := params["payment_method"].(string)

    // Validate payment against account balance
    account, err := db.GetAccount(accountID)
    if err != nil {
        return "I'm having trouble accessing your account. Let me transfer you to a colleague.", err
    }

    if amount > account.OutstandingBalance {
        return fmt.Sprintf("The amount of %.2f exceeds your outstanding balance of %.2f. Would you like to pay the full balance?",
            amount, account.OutstandingBalance), nil
    }

    // For immediate card payments
    if method == "card" {
        return "I'll need your card details to process this payment. Please provide the 16-digit card number.", nil
    }

    // For bank transfers and UPI
    if method == "bank_transfer" || method == "upi" {
        paymentRef := generatePaymentReference(accountID)
        return fmt.Sprintf(
            "To complete the bank transfer of %.2f, please use reference number %s. "+
            "Payment details will be sent to your registered mobile. "+
            "The payment will reflect within 24 hours.",
            amount, paymentRef), nil
    }

    return "Payment method not supported", nil
}

func handleCreateArrangement(params map[string]any) (string, error) {
    accountID := params["account_id"].(string)
    arrangementType := params["arrangement_type"].(string)
    totalAmount := params["total_amount"].(float64)

    account, _ := db.GetAccount(accountID)

    // Validate arrangement is within authorized limits
    if !isWithinAuthority(arrangementType, totalAmount, account) {
        return "This arrangement requires supervisor approval. Let me schedule a callback within 24 hours to confirm.", nil
    }

    // Create the arrangement
    arrangement := &Arrangement{
        AccountID:   accountID,
        Type:        arrangementType,
        TotalAmount: totalAmount,
        CreatedAt:   time.Now(),
        Status:      "active",
    }

    if installments, ok := params["installments"].([]any); ok {
        arrangement.Installments = parseInstallments(installments)
    }

    err := db.CreateArrangement(arrangement)
    if err != nil {
        return "I wasn't able to set up the arrangement. Let me transfer you to a colleague who can help.", err
    }

    // Generate confirmation
    confirmationNumber := arrangement.ID[:8]

    // Send confirmation SMS/email
    sendArrangementConfirmation(account, arrangement)

    return fmt.Sprintf(
        "Your payment arrangement has been set up. Confirmation number: %s. "+
        "You'll receive the details via SMS and email. "+
        "Your first payment of %.2f is due on %s.",
        confirmationNumber,
        arrangement.Installments[0].Amount,
        arrangement.Installments[0].DueDate.Format("January 2"),
    ), nil
}

func handleLogDispute(params map[string]any) (string, error) {
    accountID := params["account_id"].(string)
    disputeType := params["dispute_type"].(string)
    reason := params["dispute_reason"].(string)

    dispute := &Dispute{
        AccountID:  accountID,
        Type:       disputeType,
        Reason:     reason,
        Status:     "open",
        CreatedAt:  time.Now(),
    }

    err := db.CreateDispute(dispute)
    if err != nil {
        return "I'm having trouble logging your dispute. Please contact us at our main number.", err
    }

    // Mark account as disputed - stop collections
    db.UpdateAccountStatus(accountID, "disputed")

    // Trigger dispute workflow
    triggerDisputeWorkflow(dispute)

    return fmt.Sprintf(
        "Your dispute has been logged with reference number %s. "+
        "All collection activity on this account is paused while we investigate. "+
        "You'll receive written confirmation within 5 business days, and "+
        "we'll complete our investigation within 30 days as required by law.",
        dispute.ID[:8],
    ), nil
}

Compliance Requirements#

FDCPA Compliance (United States)#

type FDCPACompliance struct {
    // Time restrictions
    CallHoursStart    int  // 8 AM local time
    CallHoursEnd      int  // 9 PM local time

    // Disclosure requirements
    MiniMirandaRequired bool // Must identify as debt collector

    // Contact restrictions
    HonorCeaseContact   bool // Must stop if requested
    NoWorkplaceCalls    bool // Unless permitted
    NoThirdPartyDisclosure bool // Cannot discuss with others

    // Dispute handling
    VerificationRequired bool // Must verify if disputed
    PauseOnDispute       bool // Must pause collection on dispute
}

func validateFDCPACompliance(call *Call) error {
    // Check calling hours
    localTime := call.Customer.GetLocalTime()
    if localTime.Hour() < 8 || localTime.Hour() >= 21 {
        return errors.New("call outside permitted hours (8 AM - 9 PM local)")
    }

    // Check cease and desist
    if call.Customer.CeaseContactRequested {
        return errors.New("customer has requested no contact")
    }

    // Check dispute status
    if call.Account.DisputeStatus == "open" {
        return errors.New("account has open dispute - collection paused")
    }

    return nil
}

RBI Guidelines (India)#

type RBICompliance struct {
    // Time restrictions (RBI Circular 2022)
    CallHoursStart    int  // 8 AM
    CallHoursEnd      int  // 7 PM (stricter than FDCPA)

    // Frequency limits
    MaxCallsPerDay    int  // Maximum 3 calls per day
    MaxCallsPerWeek   int  // Maximum 7 calls per week

    // Disclosure
    AgentIDRequired   bool // Must identify with ID
    CompanyNameRequired bool

    // Language
    LocalLanguageOption bool // Must offer local language

    // Recording
    CallRecordingRequired bool
    RetentionPeriod       int // 2 years minimum
}

func validateRBICompliance(call *Call) error {
    // Check calling hours (8 AM - 7 PM IST)
    ist, _ := time.LoadLocation("Asia/Kolkata")
    localTime := time.Now().In(ist)
    if localTime.Hour() < 8 || localTime.Hour() >= 19 {
        return errors.New("call outside RBI permitted hours (8 AM - 7 PM)")
    }

    // Check daily call frequency
    callsToday := db.GetCallCountToday(call.Customer.Phone)
    if callsToday >= 3 {
        return errors.New("maximum 3 calls per day reached")
    }

    // Check weekly call frequency
    callsThisWeek := db.GetCallCountThisWeek(call.Customer.Phone)
    if callsThisWeek >= 7 {
        return errors.New("maximum 7 calls per week reached")
    }

    return nil
}

Call Recording and Retention#

type RecordingConfig struct {
    Enabled           bool
    DisclosureMessage string
    StorageDuration   time.Duration
    EncryptionEnabled bool
    ConsentRequired   bool
}

func setupCompliantRecording(call *Call) error {
    config := RecordingConfig{
        Enabled:           true,
        DisclosureMessage: "This call may be recorded for quality and training purposes.",
        StorageDuration:   2 * 365 * 24 * time.Hour, // 2 years
        EncryptionEnabled: true,
        ConsentRequired:   false, // One-party consent jurisdiction
    }

    // Start recording with disclosure
    if config.Enabled {
        call.RecordingID = startRecording(call.ID)

        // Log recording start for audit
        auditLog.Record(AuditEvent{
            Type:      "recording_started",
            CallID:    call.ID,
            AccountID: call.Account.ID,
            Timestamp: time.Now(),
        })
    }

    return nil
}

Audit Trail#

type CollectionAuditLog struct {
    ID          string
    CallID      string
    AccountID   string
    Timestamp   time.Time
    EventType   string
    Details     map[string]any
    AgentID     string
    RecordingID string
}

func logCollectionEvent(event CollectionAuditLog) {
    // Required for compliance
    event.Timestamp = time.Now()

    db.InsertAuditLog(event)

    // Real-time compliance monitoring
    if event.EventType == "dispute_raised" ||
       event.EventType == "cease_contact" ||
       event.EventType == "complaint" {
        alertComplianceTeam(event)
    }
}

Metrics and Analytics#

Key Performance Indicators#

type CollectionMetrics struct {
    // Contact Metrics
    TotalAttempts        int
    ContactRate          float64 // Percentage of successful contacts
    RightPartyContacts   int     // Reached actual account holder

    // Collection Metrics
    TotalCollected       float64
    CollectionRate       float64 // Amount collected / Amount attempted
    PaymentArrangements  int
    ArrangementValue     float64

    // Quality Metrics
    AverageCallDuration  time.Duration
    DisputeRate          float64
    EscalationRate       float64
    ComplianceScore      float64

    // Promise Metrics
    PromisesToPay        int
    PromiseKeptRate      float64 // Promises fulfilled / Promises made
}

func calculateCollectionMetrics(campaignID string, period time.Duration) *CollectionMetrics {
    calls := db.GetCampaignCalls(campaignID, period)

    metrics := &CollectionMetrics{}

    var totalAttemptedAmount float64
    var totalCollected float64
    var rightPartyContacts int
    var disputes int

    for _, call := range calls {
        metrics.TotalAttempts++
        totalAttemptedAmount += call.Account.OutstandingBalance

        if call.ContactMade {
            if call.RightPartyContact {
                rightPartyContacts++
            }

            if call.PaymentMade {
                totalCollected += call.PaymentAmount
            }

            if call.ArrangementMade {
                metrics.PaymentArrangements++
                metrics.ArrangementValue += call.ArrangementAmount
            }

            if call.DisputeRaised {
                disputes++
            }
        }

        metrics.AverageCallDuration += call.Duration
    }

    // Calculate rates
    if metrics.TotalAttempts > 0 {
        metrics.ContactRate = float64(rightPartyContacts) / float64(metrics.TotalAttempts)
        metrics.AverageCallDuration /= time.Duration(metrics.TotalAttempts)
    }

    if totalAttemptedAmount > 0 {
        metrics.CollectionRate = totalCollected / totalAttemptedAmount
    }

    if rightPartyContacts > 0 {
        metrics.DisputeRate = float64(disputes) / float64(rightPartyContacts)
    }

    metrics.TotalCollected = totalCollected
    metrics.RightPartyContacts = rightPartyContacts

    return metrics
}

Dashboard Metrics#

func getCollectionsDashboard(teamID string) *Dashboard {
    today := time.Now().Truncate(24 * time.Hour)

    return &Dashboard{
        // Today's Performance
        TodayStats: TodayStats{
            CallsMade:         db.CountCalls(teamID, today),
            AmountCollected:   db.SumPayments(teamID, today),
            ArrangementsSetup: db.CountArrangements(teamID, today),
            ContactRate:       calculateContactRate(teamID, today),
        },

        // Agent Performance
        AgentMetrics: getAgentMetrics(teamID, today),

        // Account Buckets
        AccountBuckets: []AccountBucket{
            {Name: "0-30 DPD", Count: db.CountAccountsByDPD(0, 30)},
            {Name: "31-60 DPD", Count: db.CountAccountsByDPD(31, 60)},
            {Name: "61-90 DPD", Count: db.CountAccountsByDPD(61, 90)},
            {Name: "90+ DPD", Count: db.CountAccountsByDPD(91, 999)},
        },

        // Compliance
        ComplianceAlerts: getComplianceAlerts(teamID),
    }
}

Promise-to-Pay Tracking#

type PromiseToPayMetrics struct {
    TotalPromises     int
    PromisesKept      int
    PromisesBroken    int
    PromisesPending   int
    KeptRate          float64
    AveragePromiseAge time.Duration
}

func trackPromiseToPayMetrics(period time.Duration) *PromiseToPayMetrics {
    promises := db.GetPromises(period)

    metrics := &PromiseToPayMetrics{
        TotalPromises: len(promises),
    }

    for _, p := range promises {
        switch p.Status {
        case "kept":
            metrics.PromisesKept++
        case "broken":
            metrics.PromisesBroken++
        case "pending":
            metrics.PromisesPending++
        }
    }

    if metrics.TotalPromises > 0 {
        metrics.KeptRate = float64(metrics.PromisesKept) /
            float64(metrics.PromisesKept + metrics.PromisesBroken)
    }

    return metrics
}

Campaign Management#

Outbound Campaign Setup#

func runCollectionsCampaign(campaignID string) {
    campaign, _ := db.GetCampaign(campaignID)
    accounts := db.GetCampaignAccounts(campaignID)

    // Sort by priority (higher DPD, higher balance first)
    sort.Slice(accounts, func(i, j int) bool {
        scoreI := accounts[i].DaysPastDue * int(accounts[i].Balance)
        scoreJ := accounts[j].DaysPastDue * int(accounts[j].Balance)
        return scoreI > scoreJ
    })

    for _, account := range accounts {
        // Pre-call compliance check
        if err := validateCompliance(account); err != nil {
            log.Printf("Skipping %s: %v", account.ID, err)
            continue
        }

        // Check optimal calling time
        if !isOptimalCallTime(account) {
            scheduleForLater(account)
            continue
        }

        // Prepare context
        variables := map[string]string{
            "customerName":        account.CustomerName,
            "accountNumber":       account.ID,
            "accountLast4":        account.ID[len(account.ID)-4:],
            "outstandingAmount":   formatCurrency(account.Balance),
            "daysPastDue":         strconv.Itoa(account.DaysPastDue),
            "minimumPayment":      formatCurrency(account.MinimumPayment),
            "dueDate":             account.DueDate.Format("January 2, 2006"),
            "lastPaymentDate":     account.LastPaymentDate.Format("January 2"),
            "lastPaymentAmount":   formatCurrency(account.LastPaymentAmount),
            "previousArrangements": summarizeArrangements(account.Arrangements),
            "settlementAuthority": strconv.Itoa(campaign.SettlementAuthority),
            "companyName":         campaign.CompanyName,
            "callbackNumber":      campaign.CallbackNumber,
            "customerEmail":       account.Email,
        }

        // Initiate call
        initiateCall(campaign.AgentID, account.Phone, variables)

        // Respect pacing
        time.Sleep(campaign.PacingDelay)
    }
}

API Integration#

# Initiate collection call
curl -X POST https://voice-agent.edesy.in/api/v1/calls \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "agent_id": "collections_agent",
    "to": "+919876543210",
    "variables": {
      "customerName": "Rahul Kumar",
      "accountNumber": "ACC-2024-12345",
      "outstandingAmount": "Rs. 15,450",
      "daysPastDue": "45",
      "minimumPayment": "Rs. 5,000",
      "dueDate": "December 15, 2024",
      "companyName": "FinCorp Services"
    },
    "compliance": {
      "record_call": true,
      "disclosure_required": true
    },
    "webhook_url": "https://your-server.com/webhooks/collection-complete"
  }'

Best Practices#

1. Right Party Contact Verification#

// Always verify identity before discussing account
func verifyIdentity(session *Session) bool {
    // Ask for identifying information
    session.TTS.Speak("For security, could you please confirm the last 4 digits of your PAN card?")

    response := session.WaitForResponse()

    if verifyPAN(session.Account.PAN, response) {
        return true
    }

    // Alternative verification
    session.TTS.Speak("Let me try another verification. What is your date of birth?")
    response = session.WaitForResponse()

    return verifyDOB(session.Account.DOB, response)
}

2. Empathetic Communication#

// Phrases that acknowledge customer situation
var empathyPhrases = map[string][]string{
    "financial_hardship": {
        "I understand times can be tough financially.",
        "I appreciate you sharing that with me.",
        "Let's see what options we have to work with your situation.",
    },
    "confusion": {
        "I completely understand this can be confusing.",
        "Let me explain that more clearly.",
        "That's a very fair question.",
    },
    "frustration": {
        "I hear your frustration, and I want to help.",
        "I understand this isn't the call you wanted to receive.",
        "Let's work together to find a solution.",
    },
}

3. Tiered Escalation#

type EscalationTier struct {
    Level           int
    AuthorizedDiscount float64
    ArrangementTerms   int // months
    RequiresApproval   bool
}

var escalationTiers = []EscalationTier{
    {Level: 1, AuthorizedDiscount: 0,    ArrangementTerms: 3,  RequiresApproval: false},
    {Level: 2, AuthorizedDiscount: 0.10, ArrangementTerms: 6,  RequiresApproval: false},
    {Level: 3, AuthorizedDiscount: 0.25, ArrangementTerms: 12, RequiresApproval: true},
    {Level: 4, AuthorizedDiscount: 0.50, ArrangementTerms: 24, RequiresApproval: true},
}

func getAuthorizedArrangement(account *Account, requestedDiscount float64) *EscalationTier {
    // Determine tier based on account age, amount, and customer history
    customerScore := calculateCustomerScore(account)

    for _, tier := range escalationTiers {
        if requestedDiscount <= tier.AuthorizedDiscount {
            return &tier
        }
    }

    return nil // Requires supervisor
}

4. Optimal Contact Timing#

func determineOptimalContactTime(account *Account) time.Time {
    // Analyze past successful contacts
    successfulContacts := db.GetSuccessfulContacts(account.ID)

    if len(successfulContacts) > 0 {
        // Return time when we've reached them before
        return analyzeSuccessfulTimes(successfulContacts)
    }

    // Default optimal windows by segment
    if account.Segment == "salaried" {
        // Evening after work hours
        return nextTimeInWindow(18, 20, account.Timezone)
    } else if account.Segment == "business" {
        // Late morning
        return nextTimeInWindow(10, 12, account.Timezone)
    }

    // Default: mid-day
    return nextTimeInWindow(11, 14, account.Timezone)
}

5. Follow-up Automation#

type FollowUpRule struct {
    Trigger       string
    DelayHours    int
    Action        string
    MaxAttempts   int
}

var followUpRules = []FollowUpRule{
    {Trigger: "promise_to_pay", DelayHours: 24, Action: "reminder_sms", MaxAttempts: 1},
    {Trigger: "arrangement_due", DelayHours: -24, Action: "reminder_call", MaxAttempts: 2},
    {Trigger: "payment_failed", DelayHours: 4, Action: "call", MaxAttempts: 3},
    {Trigger: "no_contact", DelayHours: 48, Action: "retry_call", MaxAttempts: 5},
    {Trigger: "voicemail", DelayHours: 24, Action: "retry_different_time", MaxAttempts: 3},
}

func scheduleFollowUp(account *Account, trigger string) {
    rule := findRule(trigger)
    if rule == nil {
        return
    }

    // Check attempt limits
    attempts := db.GetFollowUpAttempts(account.ID, trigger)
    if attempts >= rule.MaxAttempts {
        log.Printf("Max follow-up attempts reached for %s", account.ID)
        return
    }

    followUpTime := time.Now().Add(time.Duration(rule.DelayHours) * time.Hour)

    db.ScheduleFollowUp(FollowUp{
        AccountID:  account.ID,
        ScheduledAt: followUpTime,
        Action:     rule.Action,
        Trigger:    trigger,
    })
}

Testing#

Compliance Test Scenarios#

func TestFDCPACompliance(t *testing.T) {
    agent := setupTestAgent()

    // Test calling hours
    t.Run("RejectsCallOutsideHours", func(t *testing.T) {
        call := simulateCall(agent, CallConfig{
            Time: time.Date(2024, 1, 1, 7, 0, 0, 0, time.Local), // 7 AM
        })
        assert.Equal(t, "blocked", call.Status)
        assert.Contains(t, call.BlockReason, "outside permitted hours")
    })

    // Test Mini-Miranda disclosure
    t.Run("IncludesDebtDisclosure", func(t *testing.T) {
        call := simulateCall(agent, CallConfig{
            Time: time.Date(2024, 1, 1, 10, 0, 0, 0, time.Local),
        })
        assert.Contains(t, call.Transcript, "attempt to collect a debt")
    })

    // Test dispute handling
    t.Run("PausesOnDispute", func(t *testing.T) {
        agent.Process("I dispute this debt")
        account := db.GetAccount(testAccountID)
        assert.Equal(t, "disputed", account.Status)
        assert.True(t, agent.ToolCalled("log_dispute"))
    })

    // Test cease contact
    t.Run("HonorsCeaseContact", func(t *testing.T) {
        agent.Process("Stop calling me")
        assert.Contains(t, agent.LastResponse, "won't receive any more calls")
    })
}

Payment Flow Testing#

func TestPaymentArrangement(t *testing.T) {
    agent := setupTestAgent()

    t.Run("CreatesValidArrangement", func(t *testing.T) {
        agent.Process("I can pay 5000 rupees now and the rest next month")

        assert.True(t, agent.ToolCalled("create_arrangement"))

        arrangement := db.GetLatestArrangement(testAccountID)
        assert.Equal(t, "installment", arrangement.Type)
        assert.Len(t, arrangement.Installments, 2)
    })

    t.Run("EscalatesOverLimit", func(t *testing.T) {
        agent.Process("I can only pay 30% of the total")

        assert.Contains(t, agent.LastResponse, "supervisor approval")
        assert.False(t, agent.ToolCalled("create_arrangement"))
    })
}

Next Steps#

Was this page helpful?