Collections System

Overview

The collections system handles automated payment processing for installment loans. It consists of scheduled jobs that run daily to process payments, send notifications, and handle retries.

Architecture

CloudWatch Events (Daily Schedule)
       │
       │  5:00 AM CST ──► retry
       │  7:00 AM CST ──► due
       │  8:00 AM CST ──► upcoming (look_ahead_days: 3)
       │  9:00 AM CST ──► overdue
       │
       ▼
   Job.Run()
       │
       ▼
  HandleJob()
       │
       ├──► RetryHandler()    [5:00 AM] ──► enqueuePayment()
       │
       ├──► DueHandler()      [7:00 AM] ──┬──► enqueuePayment() (autopay)
       │                                  └──► notifyUsersForPayments() (non-autopay)
       │
       ├──► UpcomingHandler() [8:00 AM] ──► notifyUsersForPayments()
       │
       └──► OverdueHandler()  [9:00 AM] ──► notifyUsersForPayments()

       │
       ▼
  reportToSlack()

Collection Processes

The system supports four main collection processes:

Process Description Creates Payments Sends Notifications

upcoming

Notifies users of payments coming due in the future

No

Yes

due

Processes installments due today - autopay or notify

Yes (autopay)

Yes (non-autopay)

overdue

Notifies users of past-due installments on a schedule

No

Yes (on schedule)

retry

Retries failed payments on a schedule

Yes

No

Upcoming Handler

Purpose: Notify users about upcoming installment payments before they are due.

Trigger: Scheduled job with configurable look_ahead_days parameter (default: 3 days).

Behavior:

  • Queries all installments scheduled for today + look_ahead_days

  • Sends loc_loan_payment_upcoming notification via Iterable to all users

  • Does not create any payments

Example: If look_ahead_days = 3 and today is January 1st, it will notify users with installments due on January 4th.

Due Handler

Purpose: Process installments that are due today.

Trigger: Scheduled job that runs daily at 7:00 AM CST.

Behavior:

  • Queries all installments with a due date of today in SCHEDULED status

  • For each installment:

    • If autopay is enabled:

      • Checks if a payment already exists for today (prevents duplicates)

      • Creates a new payment record with AUTO process and PINLESS method

      • Enqueues the payment to the worker queue for processing

    • If autopay is disabled:

      • Sends loc_loan_payment_due notification via Iterable

Duplicate Prevention: The handler checks for existing payments created on the same day before creating new autopay payments.

Overdue Handler

Purpose: Notify users of past-due installments on a controlled schedule to avoid notification fatigue.

Trigger: Scheduled job that runs daily at 9:00 AM CST.

Behavior:

  • Queries all installments with a due date before today that are still in SCHEDULED status

  • Filters installments based on notification schedule (see table below)

  • Sends loc_loan_payment_overdue notification via Iterable only for eligible installments

Overdue Notification Schedule

Notifications are sent only on even-numbered days since the due date (where day 1 is the day after the due date):

Days Overdue Day Number (days since due + 1) Even/Odd Notification Sent

1 day

2

Even

✅ Yes

2 days

3

Odd

❌ No

3 days

4

Even

✅ Yes

4 days

5

Odd

❌ No

5 days

6

Even

✅ Yes

6 days

7

Odd

❌ No

7 days

8

Even

✅ Yes

Rationale: This schedule ensures users receive reminders every other day, reducing notification fatigue while maintaining regular communication about overdue payments.

Calculation:

daysSinceDue := int(queryDay.Sub(dueDay).Hours() / 24) + 1
if daysSinceDue > 0 && daysSinceDue%2 == 0 {
    // Send notification
}

Retry Handler

Purpose: Retry failed payments on a controlled schedule.

Trigger: Scheduled job that runs daily at 5:00 AM CST.

Behavior:

  • Queries all payments in RETRY status

  • Filters payments based on retry schedule (even days since creation)

  • Enqueues eligible payments to the worker queue for reprocessing

Retry Schedule

Retries occur only on even-numbered days since the payment was created:

Days Since Creation Even/Odd Retry Attempted

0 (same day)

-

❌ No

1 day

Odd

❌ No

2 days

Even

✅ Yes

3 days

Odd

❌ No

4 days

Even

✅ Yes

5 days

Odd

❌ No

Rationale: This schedule spaces out retry attempts to give time for underlying issues (insufficient funds, temporary card issues) to resolve.

Collections Worker

The worker Lambda processes payment events from the SQS queue:

SQS Queue
    │
    ▼
Worker.Handle()
    │
    ▼
Engine.Payback()
    │
    ├──► Acquire distributed lock
    │
    ├──► Get loan, installments, user
    │
    ├──► Validate payment
    │
    ├──► Submit to Payments Service
    │
    ├──► Apply to installments
    │
    ├──► Save all changes (transactional)
    │
    └──► Send receipt notification

Payment Engine

The Engine struct in pkg/collections/engine.go handles the core payment logic:

Payback Flow

  1. Lock Acquisition - Acquire distributed lock on the loan to prevent concurrent modifications

  2. Validation - Verify loan is open, has balance, payment amount is valid

  3. Payment Submission - Submit payment to payments service (ACH or Pinless)

  4. Application - Apply successful payment to installments in due date order

  5. Persistence - Save all changes in a transactional write

  6. Notification - Send receipt to user

Error Handling

Error Handling

ErrLoanNotOpen

Payment marked as SKIPPED

ErrLoanAlreadyPaid

Payment marked as SKIPPED

ErrPaybackAmount

Payment marked as FAILED

ErrAchPaymentFailed

Payment marked as RETRY (if autopay)

ErrPinlessPaymentFailed

Payment marked as RETRY (if autopay)

ACH Webhook Handler

The ach-handler Lambda processes payment webhooks from the payments service via Kinesis:

Event Types

Event Action

LOAN_DEBIT_COMPLETED

Mark payment as COMPLETED

LOAN_DEBIT_RETURNED

Undo payment applications, mark as RETRY or RETURNED

LOAN_CREDIT_COMPLETED

Mark disbursement as complete

LOAN_CREDIT_RETURNED

Handle returned disbursement

Undo Payment Flow

When an ACH payment is returned:

  1. Remove payment applications from all affected installments

  2. Recalculate installment outstanding balances

  3. Recalculate loan outstanding balance

  4. Mark payment as RETURNED or RETRY

  5. Update installment statuses (may revert to SCHEDULED or OVERDUE)

Job Schedule

All jobs run daily and are enabled only in production:

Process Schedule (UTC) Schedule (CST) Cron Expression

retry

11:00 UTC

5:00 AM CST

cron(0 11 ? * * *)

due

12:00 UTC

7:00 AM CST

cron(0 12 ? * * *)

upcoming

14:00 UTC

8:00 AM CST

cron(0 14 ? * * *)

overdue

15:00 UTC

9:00 AM CST

cron(0 15 ? * * *)

Pagination

All handlers support pagination through DynamoDB’s LastKey mechanism. The job loop continues processing pages until no LastKey is returned.

Notifications

Notifications are sent via Iterable with the following event types:

Event Description

loc_loan_payment_upcoming

Upcoming payment reminder

loc_loan_payment_due

Payment due today

loc_loan_payment_overdue

Payment is past due

Metrics

Each handler emits DataDog metrics:

  • collections.upcoming - Count of upcoming installments

  • collections.due - Count of due installments

  • collections.overdue - Count of overdue installments

  • collections.retry - Count of payments in retry status

Slack Reporting

After each job completes, a summary is posted to Slack with:

  • Number of payments created

  • Number of notifications sent

  • Any errors encountered

Code Location

Component Path

Job Lambda

cmd/loc-collections-job/main.go

Worker Lambda

cmd/loc-collections-worker/main.go

Job Logic

pkg/collections/job.go

Worker Logic

pkg/collections/worker.go

Payment Engine

pkg/collections/engine.go

Webhook Handler

pkg/collections/webhook_handler.go