Collections Engine

The collections engine recovers outstanding float balances through a scheduled path that runs on a fixed cadence and a webhook-driven path that responds to real-time events. A separate prenote path runs ahead of the due date to validate account numbers with the ACH processor before collection begins.

GrowthBook flag keys are referenced inline on this page; the flag catalog is GrowthBook Flags.

Overview

Path Component Trigger Scope

T-1 Day Collection (day-before-ach)

day-before-ach Fargate job

EventBridge Scheduler, weekdays 19:00 ET

SCHEDULING floats due the next business day

Due Date Collection (initial, TODAY6AM)

initial Fargate job

EventBridge Scheduler, weekdays 06:00 ET

SCHEDULING floats due today or earlier

Daily Retry (RETRY)

daily-retry Fargate job

EventBridge Scheduler, weekdays 05:00 ET

Overdue floats (RETRY/FAILED/ACHFAILED/UNCOLLECTABLE)

Evening blind run

daily-retry --slot=evening Fargate job

EventBridge Scheduler, Thu/Fri 20:00 ET

Overdue floats of the evening blind-debit cohort — blind pinless attempts only

Webhook-Triggered Collection

webhook-worker Lambda

Balance and Plaid-disconnect events via SQS taps

All overdue floats for the event’s user, most overdue first

ACH Prenotes

prenote Fargate job

EventBridge Scheduler, 22:00 UTC Sun/Mon/Thu–Sat

SCHEDULING floats due in 5 calendar days

ACH settlement callbacks for all paths arrive on the prod-payments Kinesis stream and are processed by the ACH Handler — see ACH Processing.

Attempts update float status (status reference); declined runs write an outcome-only collection-history row and leave status unchanged (Skip outcome codes). A pinless submit the payments service blocks before it reaches the processor is a skip as well, not a decline (Blocked pinless submits).

Shared batch loop (RunJob)

All collections-jobs subcommands run through one shared loop (pkg/collections/jobs/run.go). Each job implements the Processor interface (JobTag, RunTitle, ListCandidates, ProcessFloat); RunJob owns the rest: job started/completed/duration metrics, the worker pool and shared rate limiter, run counters, Slack reporting (one generic reporter for every job, Slack run report), and the floats.collections.error per-float hard-error metric. A run whose candidate query returns zero floats completes as a clean no-op (metrics only, no Slack message). Run dates come from the shared SettlementDate helper (midnight-truncated, weekends rolled to Monday).

Candidates load up front as refs — one query returning (loan_id, user_id) pairs (rds.FloatRef, ~120 bytes each; a 500k-float run holds ~60 MB against the task’s 1 GiB) — and each worker fetches the full float only after acquiring the per-user billing lock. That post-lock fetch is the single authoritative read — stale candidate state cannot cause a double-collection; a failed fetch is a fetch_current_float_failed skip picked up on the next run. There is no retry pass: a billing-lock failure (or any other per-float error) counts as a hard error and the float waits for the next scheduled run. After any accepted ACH the job triggers an underwriting recalculation for the user (failures logged, never propagated).

Slack run report

RunJob posts one message per run and edits it in place: a start line, progress updates (at most once per 1% of candidates and every 5 s: processed / total, the totals line, accepted and declined so far, percent complete and an ETA), then the final report. The final message carries:

  • the totals line — processed, attempted and skipped as shares of processed, a "no action" count only when a job left rules-passing floats unsubmitted (day-before-ach), and errors (hard errors plus submit errors);

  • accepted and declined as shares of attempted;

  • the attempts table — one row per rail (pinless, ach, prenote) and attempt reason with attempted, accepted, declined and accept rate, an errors column only when a submit error occurred, and an "excluding blind" row whenever blind_debit attempts are present;

  • the started-at stamp and the run duration.

Three thread replies follow, each only when it has content: skips by reason with share of all skips; declines by processor message with share of all declines (standard ISO 8583 response codes carry a description; declines beyond the tracked messages roll into "other"); and errors by message, hard and submit.

Attempt outcomes are classified from the job’s Result: accepted — the processor took the submit (pinless collected, ACH or prenote sent); declined — the payments service reported Failed with the processor’s message (Result.Declined), which is also the collection-history row outcome; submit error — the submit failed before a processor verdict (transport or payments-service error), counted together with the per-float hard errors (lock, dependency, status-write failures) under "Errors". Messages are truncated to 25 characters and at most ten distinct messages are tracked per category; detail is in the logs.

Business day

Every collection calendar decision — which day a run belongs to, which weekday it is, and how many days past due a float is — is anchored on America/New_York, and every collections-jobs schedule is pinned to the same zone. A collection attempt at 9pm ET therefore belongs to that ET day, not to the UTC day that has already begun.

Two paths deliberately use a different zone:

  • ACH cutoffs are the processor’s wall clock and live in America/Chicago — see ACH submission policy. The policy converts whatever instant it is given, so callers pass business-zone times freely.

  • Prenotes run on UTC, because the processor keys the NACHA waiting period to the UTC calendar date a prenote is submitted. The prenote schedule is UTC-pinned to match — see ACH Prenotes.

Collection-history rows are unaffected: run_date and runtime are written in UTC as a stable data-interchange contract. Anything that needs the business day derives it from runtime, which is an absolute stamp.

Source: pkg/collections/shared.go (Eastern, BusinessDay, SameBusinessDay).

QA job runner

POST /qa/jobs/run (pkg/api/qa_jobs.go, test environment only) runs one float through a subcommand’s ProcessFloat — initial, daily-retry (morning slot), day-before-ach, or prenote — outside the batch loop: no candidate query, no rate limiter, no metrics or Slack. The float’s due date is not checked; the caller chooses the float. The response carries the job’s Result (skipped/skip_reason, skip_row_outcome, action/chosen_reason, submit_failed/submit_error). The API wires the pinless-capable jobs with the receipt clients so a successful pinless debit sends its receipt exactly as the Fargate run does. Used by qa-automation, whose float collection simulation drives initial and daily-retry per float.

ACH submission policy

collections.ACHPolicy decides whether an overdue float still gets an ACH and in which settlement window. The Balance update webhook path applies it to the ACH that follows an unsuccessful pinless attempt; the batch jobs do not use it. Given a float’s due date and the clock, it answers whether an ACH should go out and in which settlement window. Windows are local time in America/Chicago (DST-aware; tzdata is embedded in the binaries) on banking days — ACH does not settle at the weekend, so Saturday and Sunday never submit one:

Window Row reason Decision

Banking day, before 3:00pm

SAMEDAYACH

ACH submitted for same-day settlement.

Banking day, 3:00pm–8:30pm

ACHFIRST

Regular next-day ACH.

Past 8:30pm, or Sat/Sun

(none)

No ACH — past the cutoff a next-day submission waits an extra day to process.

Ahead of the windows the policy checks the float’s age: more than floats.collections.ach.days_past_due days (default 28) past its due date and no ACH is submitted whatever the hour — collection rates fall off sharply after that while a late ACH still risks a return fee. Age is counted in whole calendar days on the business day; the limit day itself still submits.

A withheld ACH records outcome:ach_skipped_past_due or outcome:ach_skipped_outside_window on float.collections.payment; the pinless result that preceded it stands. Per-float gates that are not about timing — the payments blocklist, the NACHA cap, pinless-only institutions — stay with the caller and are applied after the policy.

Source: pkg/collections/ach_policy.go.

T-1 Day Collection (day-before-ach)

The day before a float’s due date, the day-before-ach Fargate job (pkg/collections/jobs) runs at 19:00 ET — approximately 2.5 hours before the Usio next-day ACH cutoff (20:30 America/Chicago = 21:30 ET, see ACH submission policy). The job loads SCHEDULING floats due the next business day (Friday targets Monday) from the RDS replica (Shared batch loop (RunJob)); workers process each float under the per-user billing lock and run six rules in order, first route wins:

  1. ruleFloatStillCollectable — reads the post-lock fetch (Shared batch loop (RunJob)); skips if already ACHSENT or COMPLETED.

  2. ruleSkipIfBlocklisted — skips if the user is on the payments-service blocklist (bank account closed, frozen, or invalid). The float stays in SCHEDULING for the due-date pinless run and is picked up again once the user links a new bank account. A blocklist lookup error also skips.

  3. ruleMaxACHAttempts — skips if the float has reached the ACH attempt limit (floats.collections.max_ach_attempts, default 3; excludes manual collection logs).

  4. ruleAchIfInvalidDebitCard — routes to next-day ACH if the user has no valid primary debit card.

  5. ruleACHIfFirstOrSecondFloat — routes rank 0 and rank 1 users (at most one prior completed float) to next-day ACH unconditionally. Logs tagged TOMORROW_FIRST_FLOAT.

  6. ruleACHIfReturnFloatersFloatRank — routes rank >= 7 users to next-day ACH when floats.collections.day_before.returning_float_ach is enabled for them. Logs tagged TOMORROW_RETURNING_FLOAT.

Ranks 2 through 6 are owned by neither rule: the float falls through to the due-date pinless run.

Source: pkg/collections/jobs/day_before_ach.go, rules.go.

If no rule routes the float, it stays in SCHEDULING for the due-date pinless run. An accepted ACH marks the float ACHSENT; a failed submission leaves it in SCHEDULING with the attempt logged (process TOMORROW, or the first/returning-float tag), and the due-date run still fires.

Due Date Collection (initial, TODAY6AM)

Each weekday at 06:00 ET — one hour after the daily retry — the initial Fargate job (pkg/collections/jobs/initial.go, Shared batch loop (RunJob)) loads SCHEDULING floats whose due date is today or earlier and gives each its first attempt. Status writes and collection-history rows carry process TODAY6AM.

Pinless debit leads when the user has a valid primary debit card; ACH is submitted directly when there is no valid card, and as a fallback when the pinless attempt fails with an NSF code (62 or 05) or is blocked before submission. Outcomes: pinless success → COMPLETED; accepted ACH → ACHSENT; anything else → RETRY, moving the float into the Daily Retry (RETRY) pool. Just before each submit the run reads the primary account’s balances from txn-service for the row’s balance snapshot; nothing is gated on them.

The payments blocklist is the only ACH gate on this run — pinless is never gated. The NACHA cap is not applied: a SCHEDULING float has not been collected yet, so it holds no automated ACHSENT row to count against the cap. This job never writes DEFAULTED.

A blocklisted user with a valid debit card still gets the pinless attempt; only the ACH route is withheld, and a withheld pinless→ACH fallback records the pinless decline (or the block’s skip outcome) as the attempt’s outcome.

A blocklisted user with no valid debit card has no route left, so the float is moved to RETRY and handed to the daily retry, which owns the 90-day DEFAULTED rule. No collection-history row is written — nothing was attempted. Leaving it in SCHEDULING would strand it: only this job selects SCHEDULING, and SCHEDULING is outside the overdue set the retry pool draws from.

A float skipped for a transient reason — a debit-card fetch error, or a blocklist lookup failure, neither of which establishes the user’s status — stays in SCHEDULING and is a candidate again the next morning.

The task definition passes --workers=18 (deploy/fargate_collections_jobs_initial.tf), sized the same way as the daily retry.

Daily Retry (RETRY)

Overdue floats (RETRY, FAILED, UNCOLLECTABLE, ACHFAILED) due today or earlier get one retry pass each weekday morning from the daily-retry Fargate job (pkg/collections/jobs/daily_retry.go, 05:00 ET, Shared batch loop (RunJob)). Status writes and collection-history rows carry process RETRY (RETRY_EVENING for the evening run).

The run evaluates exit conditions in strict precedence — first match wins — then attempts collection with the same pinless-then-ACH routing as the due-date run:

  1. More than 90 days past due → mark DEFAULTED (terminal). Reaching the NACHA ACH cap does not default a float: the cap gates the ACH route (see below) and the float keeps receiving pinless attempts until the 90-day rule ages it out.

  2. A real (non-skip) balance-worker attempt on this float within the last 24h → skip, outcome RECENTATTEMPT.

  3. Debit-card fetch returns 404 → skip, outcome NOCARD.

  4. Classify the primary Plaid item (single txn-service ListItems call with include_accounts=true, which also supplies the balances): broken connection (item status error/removed or a non-empty error_code) → reason PLAIDLINK; last successful transactions update older than the freshness threshold (floats.collections.retry.stale_hours, default 36) → reason STALEDATA; no primary item or no readable main-account balance → reason INVALIDPLAID. A missing or unparseable timestamp counts as fresh.

  5. Reason PLAIDLINK/INVALIDPLAID and the debit card is invalid → mark UNCOLLECTABLE (both rails dead).

  6. Gate on the last-known balance against float amount + fee + buffer (floats.collections.retry.balance_buffer, default $10). Healthy data and stale snapshots gate; a broken or unresolvable connection (reason PLAIDLINK/INVALIDPLAID) never does:

    • Gate passes → normal attempt: valid card → pinless (success COMPLETED; NSF or block → ACH; other failure RETRY); no valid card → ACH (accepted ACHSENT, rejected RETRY).

    • Gate fails on healthy data → skip, outcome LOWBALANCE.

    • Gate fails on stale data, or the connection never gated → a blind pinless attempt when eligible (Blind debit attempts), else skip with outcome PLAIDLINK/STALEDATA/INVALIDPLAID.

ACH routes are additionally gated — pinless never is. Two ACH-only gates apply: the payments blocklist and the NACHA cap (floats.collections.max_ach_attempts, default 3, automated ACHSENT rows only). A gated ACH is silently withheld — no row, no status change; when the pinless→ACH fallback is withheld, the pinless decline (or the block’s skip outcome) is recorded as the attempt’s outcome.

The task definition passes --workers=18 (deploy/fargate_collections_jobs_daily_retry.tf). The submit rate is the Usio processor cap, not a tuning knob — workers are sized to keep that cap saturated across the submit round trip, and cannot breach it. At the default of 5 workers this run takes roughly 10 hours on Thursday and Friday, the blind-debit days when the most attempts fire.

Evening blind run

On Thursday and Friday a second daily-retry task runs at 20:00 ET with --slot=evening (deploy/fargate_collections_jobs_daily_retry_evening.tf). It loads the same candidates and takes the same per-user billing lock, but makes blind pinless attempts only: a leading cohort rule halts every float whose user floats.collections.retry.blind_debit_evening.rollout does not assign to the evening slot, before any card, Plaid, or collection-history fetch; the 90-day DEFAULTED rule does not run; every other decision is the morning’s, unchanged, except that a float whose balance gate passes is skipped (reason balance_gated) instead of attempted — the balance-gated attempt is the morning run’s — so only an untrusted-data float the gate did not clear takes the blind path, under the same card and weekday gates as the morning. Only submitted attempts write collection-history rows, with process RETRY_EVENING — rule skips, Plaid-fetch errors, and blocked submits leave metrics only. Metrics carry job:daily-retry-evening, blind submits are tagged process:daily_blind_evening on float.collections.payment, and the Slack report is titled "Daily Retry Run - Evening Blind".

Skip outcome codes

Declined runs record a collection-history outcome; these are never float status values, and a skip leaves status unchanged.

Outcome Recorded when

RECENTATTEMPT

A real balance-worker attempt on this float within the last 24h (rolling, keyed off collection-log run_time).

NOCARD

The get-debit-card call returned 404 / not found.

PLAIDLINK

Primary Plaid item in a broken connection state and no blind attempt fired.

STALEDATA

Balance snapshot older than the freshness threshold and the balance gate failed (or no balance available).

INVALIDPLAID

No usable primary Plaid account, card still valid.

LOWBALANCE

Balance below amount + fee + buffer on healthy, fresh Plaid data.

UNEXPECTEDERROR

Data/dependency error before an attempt could be made (non-404 card-fetch failure, txn-service error). Raw error text is never written to collection history.

BLOCKED_RETRY_LIMIT / BLOCKED_CARD_INVALID / BLOCKED_CARD_SUSPENDED

The payments service blocked the pinless submit before the processor (Blocked pinless submits); BLOCKED records a block_reason this service does not know. Written by every automated flow, not only the daily retry.

Source: pkg/collections/shared.go

Skip rows carry no confirmation_id — that is how a real attempt (submitted debit, success or decline, always carrying the processor’s confirmation ID) is told apart from a skip. Skips are excluded from the NACHA ACH cap and the RECENTATTEMPT recency check.

A genuine attempt the processor rejects records its real failure outcome; UNEXPECTEDERROR covers only pre-attempt data/dependency paths.

Blind debit attempts

When an untrusted-data reason (PLAIDLINK/STALEDATA/INVALIDPLAID) left no balance-gated attempt, the run is on a Thursday or Friday (business day), and the primary debit card is valid, the float is eligible for a blind pinless attempt — a debit-card submit with no balance check. floats.collections.retry.blind_debit_evening.rollout decides which of the day’s two runs fires it: off, the 05:00 ET morning run; on, the evening run. The other run skips the float without writing a row (skip reason blind_other_slot). Blind attempts are strictly net-new: they run only where the retry would otherwise have skipped, never replacing a balance-gated attempt. Each blind attempt writes the untrusted-data reason to the collection-history row’s reason attribute; balance-gated attempts write no reason.

Shared balance selection

The Daily Retry and the balance webhook gate on the same balance definition (collections.SelectBalance): the bank-reported available balance when non-zero, else the miner’s calculated-available fallback (current minus pending), else the current balance. The choice is recorded as the row’s balance_used (AVAILABLE / CALC_AVAILABLE / CURRENT, Balance snapshot on collection history).

Balance snapshot on collection history

Every collection-history row a run writes while it has a bank balance in hand carries that balance — available_balance_cents, current_balance_cents, calc_available_balance_cents, balance_used and balance_updated_at (dynamo.adoc#collection-history) — so skips and declines can be read against the balance the run saw:

  • Daily Retry — the primary account’s balances read for the gate, on every row written from that point: attempts, LOWBALANCE, STALEDATA, PLAIDLINK and UNCOLLECTABLE (the last two when the broken item still reports a last-known balance). Rows written before the read — RECENTATTEMPT, NOCARD, DEFAULTED, UNEXPECTEDERROR — and INVALIDPLAID (no readable balance by definition) carry none.

  • Due Date run — the primary account’s balances, read from txn-service just before each submit purely for the record: nothing is gated on them, and a failed read leaves the row without a snapshot rather than withholding the attempt.

  • Balance webhook — the event’s balances, on the pinless row, the LOWBALANCE skip and the fallback ACH row. Later floats in the event are gated on the balance less what the event already committed, but record the balance as the event reported it.

  • Manual and reactivation paybacks (MANUAL / REACTIVATION) — the primary account’s balances, read from txn-service just before the submit purely for the record, as on the due-date run.

The day-before run and the Plaid-disconnected path read no balance and record none.

Every pinless decline — automated or a manual payback — additionally records partial_auth_balance_cents: the card balance the processor reports on an insufficient-funds decline when partial authorization is enabled for the merchant, absent when it reported none. When the pinless→ACH fallback replaces the decline as the row’s outcome, the pinless decline’s partial-auth balance stays on the row.

Source: pkg/collections/shared.go (SelectBalance, NewCollectionBalance, PrimaryBalanceSnapshot), pkg/collections/jobs/attempt.go (newCollectionRow, loadBalanceSnapshot), pkg/payback/payback.go (balanceSnapshot).

UNCOLLECTABLE is not a terminal status. The Daily Retry re-queues such floats on subsequent runs; if a valid debit card or Plaid account later appears, collection resumes.

Webhook-Triggered Collection

The webhook-worker Lambda consumes multiple EventBridge-tapped triggers from the Transactions Service — one SQS tap/DLQ/rule per trigger — and dispatches per record by the envelope’s detail-type: new_account → the balance path; plaid_item_connection_lost / plaid_item_connection_revoked → the Plaid-disconnected path.

Both paths take the user’s billing lock once, then walk every overdue float the user has (RETRY/FAILED/ACHFAILED/UNCOLLECTABLE), oldest due date first. The event moves on to the next float after a pinless COMPLETED or an accepted ACH, and ends at the first float it does not collect from — a failed gate, a submit error, or a pinless miss (no usable card, a decline, a blocked submit) with no ACH to fall back on — leaving the remaining floats untouched. Every row a float after the first writes carries reason SUBSEQUENTFLOAT. User, debit card, blocklist status and pinless-only institutions are read once per event; collection history, fee and the ACH decision are per float.

Balance update

On a new_account balance event (source txn-service.feeder) for a user with an overdue float, the worker runs these checks against each of the user’s overdue floats in turn — a failure ends the event:

  1. Attempts today below the per-day cap (floats.collections.max_attempts_on_day, default 3), counted on the business day. LOWBALANCE and blocked-submit skip rows are excluded from this cap.

  2. Balance above float amount + fee + buffer (floats.webhook.balance.buffer, default $20), less the amount and fee this event’s earlier successful attempts already committed — on failure, a skip row is written (process WEBHOOKS_BALANCE, outcome LOWBALANCE) and no money moves.

Pinless debit leads whenever the user has a valid primary debit card: success marks the float COMPLETED; a decline other than insufficient funds records its code and ends the event with the float left overdue. Only the event’s first float may fall back to ACH — when there is no valid card, the pinless debit is declined for insufficient funds (code 51), or the payments service blocked the submit. Every later float is pinless only, so an event never submits more than one ACH for a user.

The fallback ACH goes out only when the shared ACH submission policy allows one, in the window it names, and past three ACH-only gates — ACH attempts at the cap (floats.collections.max_ach_attempts, default 3), the user on the payments blocklist, or the institution listed in floats.pinless.institutions (pinless-only institutions). A policy refusal or a failed gate leaves the pinless result as the attempt’s outcome and ends the event. An accepted ACH marks the float ACHSENT; ACH is the last resort on this path, so a synchronous decline ends the event.

The lead float’s ACH row carries its window (SAMEDAYACH / ACHFIRST) in reason; its pinless rows and LOWBALANCE skips carry none, and rows for the event’s later floats carry SUBSEQUENTFLOAT. ACH attempts on the float.collections.payment metric are tagged same_day:true|false.

Plaid connection lost / revoked

On plaid_item_connection_lost (bank login broke, e.g. ITEM_LOGIN_REQUIRED) or plaid_item_connection_revoked (user revoked access), both from txn-service.miner, the worker makes pinless-only attempts on the user’s overdue floats — there is no ACH fallback on this path. Both events drive the same attempt; the payload’s error_code/reason is telemetry only. Gates, in order:

  1. floats.collections.plaid_disconnected.rollout enabled for the user — off means the worker parses the event and no-ops.

  2. User has at least one overdue float.

  3. Attempts today below the per-day cap (shared with the balance path), evaluated per float.

  4. Valid primary debit card — the card is per user, so a missing/invalid one writes a skip row for the first float (process WEBHOOKS_PLAID_DISCONNECT, outcome NOCARD) and stops.

A successful pinless debit marks the float COMPLETED, sends the receipt, recalculates underwriting, and moves the event on to the user’s next overdue float; a failure records its outcome, leaves the float overdue and ends the event, as does a blocked submit with its skip outcome.

Blocked pinless submits

The payments service can refuse a pinless debit before it reaches the processor: its rolling-window retry limiter (block_reason RETRY_LIMIT_EXCEEDED, transient — clears as the windows roll or on any approval) or its card gate (CARD_INVALID, CARD_SUSPENDED, until the member provides another card). The response is FAILED with a block_reason and no confirmation ID: no network call was made and no payment record exists. Only pinless submits can be blocked; ACH is unaffected, and member-initiated paybacks bypass the limiter (retry-limit bypass).

Every automated flow branches on block_reason (collections.BlockReason), never on error_message, and treats a block as a skip:

  • The collection-history row records BLOCKED_RETRY_LIMIT, BLOCKED_CARD_INVALID, or BLOCKED_CARD_SUSPENDED (BLOCKED for a reason this service does not know) with no confirmation_id, so it is excluded from the webhook per-day cap and the RECENTATTEMPT check like any other skip (Skip outcome codes).

  • No receipt or member communication fires. The Fargate jobs write the float RETRY (a due-date float must not stay in SCHEDULING; a retry-pool float stays where it is); the webhook paths and the batch worker leave the float’s status untouched.

  • Where the flow has a pinless→ACH fallback — the due-date run, the balance-gated daily retry, and the lead float of a balance webhook event — any block triggers it under the usual ACH gates (and, on the webhook, the ACH submission policy); the limiter governs only the card rails. Flows without one (blind attempts, later floats in a webhook event, Plaid-disconnected, batch) end quietly.

  • float.collections.payment records outcome:pinless_blocked tagged block_reason:retry_limit_exceeded|card_invalid|card_suspended|unknown; the batch worker records float.collections.batch.debit.blocked with the same tag. In the jobs' run report a block with no fallback counts as a skip (pinless_blocked), and an ACH fallback after a block as an attempt with chosen reason pinless_blocked.

CARD_INVALID and CARD_SUSPENDED need no float-side state: the payments service has already invalidated the card, so the existing card-validity checks route the next run to ACH where allowed.

Source: pkg/collections/shared.go (BlockReason, BlockedOutcome, BlockedError, ShouldRetryAsACH), pkg/collections/jobs/base.go.

ACH Prenotes

Prenotes are zero-dollar ACH verification transactions submitted 5 calendar days before a float’s due date, giving the processor time to surface returns (e.g. closed accounts) before real debits go out. The prenote path is decoupled from collection: it does not change float status, acquire the billing lock, or write collection history. Eligibility (an ACH-routable bank account) is enforced by the Payments Service at submission.

Waiting period

The processor keys the wait to the UTC calendar date a prenote is submitted (submission time of day is irrelevant, weekends and holidays count): a prenote submitted on date P becomes eligible for a live debit on P+4. Prenoting at D−5 for a float due on D makes the live debit eligible at D−1, one day before the due-date run. NACHA’s underlying rule counts three banking days from the prenote’s settlement date (FR Doc. 2017-19135); the 5-calendar-day lead clears both interpretations.

Fargate job

The prenote subcommand of the collections-jobs Fargate task (pkg/collections/jobs/prenote.go) is triggered at 22:00 UTC on Sun/Mon/Thu/Fri/Sat — late in the UTC day, keeping each submission on the current date’s waiting-period clock. Tue/Wed runs would target due dates whose ACH day falls on a weekend, when ACH does not settle. Each run targets floats whose ACH submission date (last weekday strictly before the due date) equals P+4:

Run day P+4 (ACH day) Due dates targeted

Monday

Friday

Saturday, Sunday, and Monday (all have Friday as their ACH day)

Tuesday / Wednesday

Sat / Sun

(none — run exits immediately)

Thursday

Monday

Tuesday

Friday

Tuesday

Wednesday

Saturday

Wednesday

Thursday

Sunday

Thursday

Friday

The task runs through the shared loop (Shared batch loop (RunJob)): refs of SCHEDULING floats for each target due date load from the RDS replica, and the float row itself is never fetched (only the user ID is needed). Workers fetch the user’s identity from the User Service and submit a USIO prenote via the Payments Service (UsioAccount: float-debit). Every candidate is submitted; there is no per-user gate. Submissions and failures emit the float.prenotes metric with submitted: and submitted_reason: tags.

The prenote task definition passes no flags, so it runs on the code defaults: --workers 5 and --payment-submit-rate 10/s (token bucket shared across workers).

Distributed Locking

Every collection path acquires a per-user lock (loan-processing:user_id:{userID}, 60-second lease) in the locks DynamoDB table before processing; when the lock is held, the attempt is skipped without a status update. The webhook paths hold it across every float they attempt for the event. The prenote job acquires no lock — prenotes are zero-dollar and touch no float state. Schema: dynamo.adoc#locks.