Collections Engine

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

Overview

Scheduled Path (Lambda)
  EventBridge (CloudWatch rules)
        │
        ▼
  Collections Scheduler ──► SQS: float-collections ──► Collections Worker ──┐
                                                                             │
Scheduled Path (Fargate)                                                     ▼
  EventBridge Scheduler (weekdays 19:00 ET)                        Payments Service
        │                                                                    │
        ▼                                                                    │
  day-before-ach Fargate task ───────────────────────────────────────────►  │
                                                                             │
  EventBridge Scheduler (daily 22:00 UTC)                                    │
        │                                                                    │
        ▼                                                                    │
  prenote Fargate task ──────────────────────────────────────────────────►  │
                                                                             │
Webhook Path                                                                 │
  SQS: balance-event-tap ──────────► Webhook Worker ─────────────────────►  │
  SQS: plaid-disconnect-event-tap ─► Webhook Worker (pinless only) ───────►  │
                                                                             │
ACH Callback Path                                                            ▼
  Kinesis: prod-payments ──► ACH Handler                          Float status updated
                                                                  DynamoDB attempt logged
The Lambda scheduled path (Collections Scheduler → Collections Worker) handles Due Date and Daily Retry only. T-1 Day ACH and prenotes are both handled exclusively by Fargate batch jobs.

The Collections Scheduler runs on a CloudWatch schedule and queries RDS for floats due at each stage. It enqueues them to SQS for the Collections Worker to process. The Balance Worker reacts to external events without polling. The prenote Fargate task runs on its own daily EventBridge Scheduler schedule and submits zero-dollar prenotes for the subset of users whose floats are due in 5 calendar days and whose floats.prenotes GrowthBook flag is enabled.

T-1 Day Collection

The day before a float’s due date, the day-before-ach Fargate batch job (pkg/collections/jobs) runs at 19:00 ET — approximately two hours before the Usio ACH cutoff — and re-fetches each float after acquiring the billing lock so stale producer state cannot cause a double-collection.

The Fargate job runs six rules per float in order:

  1. ruleFloatStillCollectable — re-fetches the float from RDS; skips if already ACHSENT or COMPLETED (guards the window between producer query and worker dispatch).

  2. ruleSkipIfBlocklisted — skips if the user is on the payments-service blocklist (bank account closed, frozen, or invalid — ACH is guaranteed to return; see the payments-service blocklist docs). 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 already reached the configured ACH attempt limit (excludes manual collection logs).

  4. ruleAchIfInvalidDebitCard — routes to ActionSubmitACHNextDay if the user has no valid primary debit card; otherwise leaves the float in SCHEDULING for the Due Date pinless run.

  5. ruleACHIfFirstFloat — routes to ActionSubmitACHNextDay for users with float rank 0 (no prior completed floats) when the GrowthBook flag floats.collections.day_before.first_float_ach is enabled for them. The flag is a percentage rollout controlled in GrowthBook without a deploy.

  6. ruleACHIfReturningFloat — the rank > 0 counterpart: routes returning floaters (at least one prior completed float) to ActionSubmitACHNextDay when the separate GrowthBook flag floats.collections.day_before.returning_float_ach is enabled for them. Having its own flag lets the returning-floater cohort roll out independently of the first-float cohort. Collection logs for these attempts are tagged TOMORROW_RETURNING_FLOAT (first-float attempts use TOMORROW_FIRST_FLOAT).

If the user has no valid primary debit card, next-day ACH is submitted immediately. If they do have a valid card, the system checks float rank: rank-0 users (no prior completed floats) are routed to ACH when the floats.collections.day_before.first_float_ach GrowthBook flag is enabled for them; rank > 0 users are routed when the floats.collections.day_before.returning_float_ach flag is enabled for them; anyone whose applicable flag is disabled waits for the higher-priority pinless debit on the due date itself.

Float due date is tomorrow
        │
        ▼
Valid primary debit card? ──No──────────────────────────────────────► Submit ACH
        │ Yes                                                               │
        ▼                                                                   │
Float rank 0 (no prior completed floats)?                                   │
        │                                                                   │
        ├── Yes ──► first_float_ach flag enabled? ──Yes─────────────────────┤
        │                   │ No                                            │
        │                   └──► No action — wait for Due Date Collection   │
        │                                                                   │
        └── No ───► returning_float_ach flag enabled? ──Yes─────────────────┤
                            │ No                                            │
                            └──► No action — wait for Due Date Collection   │
                                                                            ▼
                                                                     ACH accepted? ──Yes──► Mark float ACHSENT
                                                                            │ No            (awaits settlement)
                                                                            ▼
                                                                     Leave float SCHEDULING
                                                                     (log attempt; due-date
                                                                      pinless run still fires)

Payday Overnight ACH

The payday-ach Fargate batch job (pkg/collections/jobs, a sibling of day-before-ach on the same collections-jobs image) runs at 18:00 ET every weekday — one hour before day-before-ach (19:00 ET) — and submits next-day ACH for overdue floats the night before a user’s predicted payday, so the debit settles when money is most likely in the account.

Cached payday date (next_payday_date)

Calling insight-service’s next-payday prediction for every overdue float each night is expensive, so the prediction is pre-computed and cached on the float row in the next_payday_date column (FloatMeAPI_userloansmodel). It is written in three places:

  • On float creation (pkg/api/floats.go) — set to the first predicted payday strictly after the float’s due date. A float only becomes overdue after its due date, so caching the payday after it means an overdue float already points at the next collection opportunity.

  • On each payday-ach attempt — the worker advances the cache to the payday after the one just submitted for (see below). This is the only per-attempt insight-service call; the job makes none per-candidate.

  • Out-of-band by the insights-worker Lambda — on a user_new_txns_batch_completed event from the Transactions Service refiner, so a prediction that moves between collection attempts is picked up rather than waiting for the next attempt. See Event Flows for that path.

The per-attempt refresh (and the insights-worker refresh) computes the "next payday strictly after date X" via collections.NextPaydayAfter, which considers both the immediate next payday (payday) and the estimated future paydays (estimated_next_paydays) from the prediction, then picks the earliest one after X. estimated_next_paydays omits the immediate upcoming payday, so looking only at that list would skip the nearest opportunity.

The column is nullable: NULL for floats created before the field existed or when no further payday is predicted. Such floats are simply never selected by the payday-ach job, and — because the suppression below is gated on a non-NULL next_payday_date — their ACH is not suppressed either, so the Daily Retry run still collects them normally.

Per-run flow

Candidates are overdue, still-collectable floats whose cached next_payday_date equals tonight’s ACH settlement date (the next banking day, weekend-aware) — statuses RETRY, FAILED, ACHFAILED, and UNCOLLECTABLE. Like day-before-ach, the producer keyset-pages them out of RDS via IterByStatusAndPaydayOn (--page-size rows per query, ordered by loan_id), walking each status in turn and streaming floats through a bounded channel to the workers, so only a page is held in memory at a time rather than the whole overdue set. The payday match is done in SQL against the cached field, not by calling insight-service. Each candidate is processed under the billing lock and runs four rules in order:

  1. ruleFloatStillCollectable — re-fetches the float; skips if already COMPLETED, ACHSENT, or HOLD.

  2. ruleSkipIfBlocklisted — skips if the user is on the payments-service blocklist (bank account closed, frozen, or invalid — ACH is guaranteed to return; same rule as day-before-ach). The float 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 NACHA auto-ACH attempt limit (3 ACHSENT across all processes, excludes manual). This is the only attempt cap; payday-ach has no separate per-method limit, so it can use the full NACHA budget.

  4. rulePaydayACH — gated per-user by the floats.collections.payday_ach.enabled flag; when on, routes to ActionSubmitACHNextDay. It makes no insight-service or collection-log call — the date match already happened in the query and the cap is enforced above.

Two flags: enabled (dry run) and rollout (live)

The method is controlled by two per-user GrowthBook percentage flags so it can run in production for observation before any money moves:

  • floats.collections.payday_ach.enabled — turns the rule on. An enabled float runs the full pipeline (telemetry + the next_payday_date refresh) but only submits real ACH if the rollout flag is also on.

  • floats.collections.payday_ach.rollout — gates the real ACH submission (checked in dispatch). Enabled-on / rollout-off is a dry run: the worker emits the attempt metric (tagged rollout:false, outcome:dry_run), logs the would-be attempt, and advances next_payday_date — but does not submit ACH, change the float status, or write a collection log.

On a live submit (success or failure) the worker writes the PAYDAY_ACH collection log, updates status on success, then refreshes next_payday_date to the payday after tonight’s so a still-overdue float is picked up on its next payday. The rule fails open: a disabled flag leaves the float for the Daily Retry run, and a failed cache refresh never fails the attempt — when the prediction call fails the worker clears next_payday_date (rather than leaving a stale value), which both drops the float from future payday runs and lifts its ACH suppression so the Daily Retry run reclaims it.

Overdue-collector ACH suppression

So that payday-ACH can manage a user’s three NACHA ACH attempts strategically, when floats.collections.payday_ach.rollout is on for a user the other overdue collectors skip their own ACH attempts for that user (pinless is unaffected — it doesn’t count toward the ACH cap). Suppression is additionally gated on the float carrying a non-NULL next_payday_date (paydayACHOwnsUserACH checks both): a float payday-ACH will never pick up (no cached payday, or the value was cleared after a failed refresh) keeps its legacy ACH, so it can’t be stranded between the two systems. We deliberately do not backfill next_payday_date, so pre-existing overdue floats keep collecting via the Daily Retry run until they next pass through float creation.

  • Collector.submitACHDebit returns early (a outcomeACHSuppressed sentinel) for overdue-status floats (RETRY/FAILED/ACHFAILED/UNCOLLECTABLE) that have a cached next_payday_date — covering the Daily Retry run. Scoping by status leaves the due-date TODAY6AM run (SCHEDULING floats) untouched; day-before-ach is a separate package and unaffected.

  • WebhookWorker.attemptACHCollection returns early (it only ever handles overdue floats).

In both cases no ACH is submitted, so no ACHSENT row is written and the user’s ACH budget is preserved for payday-ACH; the float stays overdue and is left for payday-ACH (or a pinless attempt) to collect. Suppressions are visible on the float.collections.payment metric with outcome:ach_suppressed_payday.

No stray collection-history row. When the suppressed ACH was the only action (invalid card, so no pinless ran), the retry flow records nothing: submitACHDebit returns the outcomeACHSuppressed sentinel, ProcessRetry maps it to CollectionResult{Suppressed: true}, and ProcessCollection skips both the float-status update and the Collections.Add write — so no misleading RETRY row is written for a non-attempt. When a pinless attempt did run (valid card, only the ACH fallback suppressed), its real outcome is still recorded normally. The balance worker skips its ACH log the same way (its attemptACHCollection returns before building the log).

The shared flag constant, gating helper, and sentinel live in pkg/collections (GBFlagPaydayACHRollout, paydayACHOwnsUserACH, outcomeACHSuppressed) so both the jobs package and the legacy collectors reference them without an import cycle.

Collision safety with the Daily Retry run reuses the same guards as day-before-ach. A successful payday submit moves the float to ACHSENT, which removes it from the overdue query set the next morning; the billing lock, the still-collectable re-fetch, and the shared NACHA ACH cap bound any residual overlap. Running in the evening (before the morning retry) is what keeps the ordering.
Float overdue (RETRY/FAILED/ACHFAILED/UNCOLLECTABLE) AND next_payday_date == settlement date?
        │ (keyset-paged via IterByStatusAndPaydayOn)
        ▼
payday_ach.enabled for user? ──No──► No action (Daily Retry handles it)
        │ Yes
        ▼
payday_ach.rollout for user? ──No──► DRY RUN: metric + log + refresh next_payday_date (no ACH)
        │ Yes
        ▼
Submit next-day ACH ──► ACH accepted? ──Yes──► Mark float ACHSENT (awaits settlement)
        │                     │ No
        │                     ▼
        │              Leave float overdue (log attempt; Daily Retry still fires)
        ▼
Refresh next_payday_date → payday after tonight's (one insight-service call per attempt;
        on prediction failure the field is cleared so Daily Retry reclaims the float)

Due Date Collection

On the float’s due date, the scheduler queries for floats in SCHEDULING status whose due date is today or earlier. Pinless debit is the preferred method; ACH is the fallback when the pinless attempt fails with an NSF error code.

Float is due today
        │
        ▼
Valid primary debit card? ──No──────────────────────────────► Submit ACH
        │ Yes                                                       │
        ▼                                                           │
Submit pinless debit                                                │
        │                                                           │
Payment successful? ──Yes──► Mark float COMPLETED                  │
        │ No                                                        │
        ▼                                                           ▼
NSF error? (code 62 or 05) ──Yes──► Submit ACH               ACH accepted? ──Yes──► Mark float ACHSENT
        │ No                                                   │ No                   (awaits settlement)
        ▼                                                      ▼
Mark float RETRY ◄─────────────────────────────────────── ACH rejected

Daily Retry

Every morning the scheduler queries for floats in RETRY, FAILED, UNCOLLECTABLE, and ACHFAILED statuses whose due date has passed. Exit conditions are checked first; floats that pass are attempted using the same pinless-then-ACH routing as the Due Date stage.

Float is in RETRY (or similar) status
        │
        ▼
ACH attempts ≥ configured limit? ──Yes──────────────────────► Mark float DEFAULTED
        │ No
        ▼
Days since due date > 90? ──Yes─────────────────────────────► Mark float DEFAULTED
        │ No
        ▼
Real balance-worker attempt in last 24h? ──Yes──────────────► Skip, record RECENTATTEMPT
        │ No
        ▼
Debit card fetch returns 404 (no card)? ──Yes───────────────► Skip, record NOCARD
        │ No
        ▼
Classify primary Plaid item (untrusted-data reason, "" when healthy):
  connection broken (error/removed status or error_code) ────► reason PLAIDLINK
  balance snapshot older than freshness threshold ───────────► reason STALEDATA
  no primary item, or main account balance unavailable ──────► reason INVALIDPLAID
        │
        ▼
Reason PLAIDLINK / INVALIDPLAID and card invalid? ──Yes─────► Mark float UNCOLLECTABLE
        │ No                                                  (both rails dead)
        ▼
Balance ≥ float amount + fee + buffer (default $10)?
        │   (last-known balance, even when untrusted; 0 when none could be read)
        │
        ├── No ──► Reason set? ──No────────────────────────► Skip, record LOWBALANCE
        │               │ Yes
        │               ▼
        │          Valid card + blind-debit flag on? ──Yes──► Blind pinless attempt
        │               │ No                                  (see Blind debit attempts)
        │               └───► Skip, record PLAIDLINK / STALEDATA / INVALIDPLAID
        │
        └── Yes
                      │
                      ▼
               Valid primary debit card?
                      │
                      ├── Yes ──► Submit pinless debit
                      │               │
                      │         Payment successful? ──Yes──► Mark float COMPLETED
                      │               │ No
                      │               ▼
                      │         NSF error? ──Yes──► Submit ACH ──► (see below)
                      │               │ No
                      │               └──────────► Mark float RETRY
                      │
                      └── No ──► Submit ACH
                                      │
                               ACH accepted? ──Yes──► Mark float ACHSENT
                                      │ No
                                      └──────────► Mark float RETRY
An untrusted-data condition (PLAIDLINK / STALEDATA / INVALIDPLAID) does not skip on its own — the run still gates on the last-known balance (0 when none could be read) and attempts when it passes, preserving the pre-outcome-codes attempt volume. When the gate fails on untrusted data, the run gets one last shot: a blind pinless attempt when the blind-debit flag is on and the card is valid — a true net-new attempt, fired only where the retry would otherwise skip — else the untrusted-data reason is the recorded skip outcome. See Blind debit attempts.

Skip outcome codes and precedence

To make declined runs observable and reduce wasted attempts, the Daily Retry records a collection-history outcome for each skip. These are outcome values only — never a float status (a closed enum); a skip leaves the float’s status unchanged. Skip rows carry no confirmation_id and are excluded from both the NACHA ACH attempt cap and the RECENTATTEMPT recency check (see isSkipOutcome). The checks are evaluated in strict precedence, first match wins:

  1. DEFAULTED — ACH cap reached or > 90 days past due (terminal status transition).

  2. RECENTATTEMPT (new) — a real (non-skip) balance-worker attempt on this float within the last 24h (rolling window, keyed off the collection-log run_time). Evaluated before any external call so a redundant retry short-circuits early.

  3. NOCARD (new) — the get-debit-card call returned 404 / not found.

  4. PLAIDLINK (new) — the user’s primary Plaid item is in a broken connection state (item status error/removed, or a non-empty error_code such as ITEM_LOGIN_REQUIRED), read up front via txn-service ListItems, and the collection was declined anyway: either the last-known balance failed the gate, or no balance was available at all. A broken connection whose last-known balance passes the gate still proceeds to a normal balance-gated attempt. When the debit card is also invalid, the float is terminal UNCOLLECTABLE instead (regardless of balance).

  5. STALEDATA (new) — the primary item’s last successful transactions update is older than the configured freshness threshold and the last-known balance failed the gate (or none was available). A stale snapshot whose balance passes the gate still proceeds to a normal balance-gated attempt — staleness changes the skip outcome recorded, not whether the balance is gated. Also serves as the tracking signal for a future re-mine opportunity. A missing/unparseable timestamp is treated as fresh (we don’t over-skip on absent data).

  6. INVALIDPLAID — no usable primary Plaid account with a still-valid card (check again tomorrow). This is the "no primary account linked" case; broken connections are now caught earlier by PLAIDLINK.

  7. UNCOLLECTABLE — Plaid unusable and the primary debit card invalid.

  8. LOWBALANCE — balance below float amount + fee + buffer (default $10) on healthy, fresh Plaid data. When the same gate fails on degraded data, the run records the degraded reason (PLAIDLINK / STALEDATA) instead, so LOWBALANCE always means "trustworthy balance, genuinely too low".

A data or dependency error before an attempt can be made (debit-card fetch failure other than a 404, or a txn-service error/non-200 fetching accounts) records UNEXPECTEDERROR rather than the raw error text, so error-message content never leaks into collection history. A genuine attempt that the payment processor rejects still records its real failure outcome (unchanged) — UNEXPECTEDERROR only covers the pre-attempt data/dependency paths.

The freshness threshold for STALEDATA is read once per SQS batch from the GrowthBook flag floats.collections.stale_hours (default 32), so it is tunable without a deploy.

Both item health and balances come from a single txn-service call: ListItems with include_accounts=true returns each item’s connection status/error_code/last_successful_transactions_update (for PLAIDLINK/STALEDATA) alongside the miner-refined account balances the balance decision reads. The primary bank item is the one with a designated main account (matching txn-service’s own selection).

Blind debit attempts

The three untrusted-data conditions (STALEDATA, PLAIDLINK, INVALIDPLAID) all mean there is no trustworthy balance to gate on. When the balance gate fails under one of them, the per-user GrowthBook flag floats.collections.retry.blind_debit.rollout is on, and the user’s primary debit card is still valid, the run fires a blind attempt — a pinless debit-card submit made without a balance check (the internal balance pre-flight is skipped) while the card is still usable. Blind attempts are strictly net-new: they run only where the retry would otherwise have skipped, never replacing or preceding a balance-gated attempt (an untrusted balance that passes the gate gets the normal pinless/ACH attempt, and the flag is not consulted). To keep blind attempts distinguishable from balance-confirmed retries, each writes a generic reason attribute on the collection-history row recording why the balance was untrusted — STALEDATA, PLAIDLINK, or INVALIDPLAID. Balance-gated attempts on untrusted data do not write reason.

Shared balance selection

The Daily Retry and the Balance Worker gate on the same balance definition (reportedBalance): the bank-reported available balance when non-zero, else the miner’s calculated-available fallback (current minus pending transactions), else the current balance. The Balance Worker previously gated the calculated-available fallback behind the floats.webhook.balance.use_calculated_balance GrowthBook flag; that flag has been removed (it had been set to true in production since the prior year) and the fallback now always applies in both paths.

UNCOLLECTABLE is not a terminal status. Floats in this state are re-queued by the Daily Retry scheduler on subsequent runs. If a valid debit card or Plaid account is later associated with the user, collection will be reattempted.

Webhook-Triggered Collection

The event-driven webhook-worker Lambda responds to real-time signals from the Transactions Service without waiting for the next scheduled run. It is a single Lambda fed by multiple EventBridge-tapped triggers — each on its own SQS tap/DLQ/rule — and dispatches per record by the EventBridge envelope’s detail-type: new_account → the balance path below; plaid_item_connection_lost / plaid_item_connection_revoked → the plaid-disconnected path.

Balance Update

The balance path is triggered when a new_account balance update event arrives from the Transactions Service (source txn-service.feeder). It applies a more conservative balance check and enforces a per-day attempt cap.

Balance update event received for user
        │
        ▼
User has a float in RETRY status? ──No──► Ignore
        │ Yes
        ▼
Attempts today ≥ 3? ──Yes──► Ignore
        │ No
        ▼
ACH attempts ≥ limit? ──Yes──► Ignore
        │ No
        ▼
Balance > float fee + float amount + $20?  ──No──► Record LOWBALANCE skip
        │ Yes                                       (WEBHOOKS_BALANCE, no money moves)
        ▼
Valid primary debit card?
        │
        ├── Yes ──► Submit pinless debit
        │               │
        │         Payment successful? ──Yes──► Mark float COMPLETED
        │               │ No
        │               └──────────► Mark float RETRY
        │
        └── No ──► Submit ACH (if institution allows ACH
                        │       and user is not blocklisted)
                 ACH accepted? ──Yes──► Mark float ACHSENT
                        │ No
                        └──────────► Mark float RETRY
Both the balance threshold ($20 buffer) and the max ACH/daily attempt limits for the balance worker are configurable via GrowthBook feature flags (floats.webhook.balance.buffer, floats.collections.max_ach_attempts, floats.collections.max_attempts_on_day).
The ACH fallback also checks the payments-service blocklist (checkNotBlocklisted): a user whose bank account is closed, frozen, or invalid is skipped — ACH to that account is guaranteed to return. The status is fetched in populateState alongside the other check inputs. Pinless attempts are unaffected, and the user’s ACH resumes once they link a new bank account (which auto-clears the blocklist).
When the balance check fails (balance below the threshold), the worker writes a collection-history row with process WEBHOOKS_BALANCE and outcome LOWBALANCE — mirroring the Daily Retry run’s low-balance record — so skips made by this worker are observable. No money moves. Because the balance webhook can fire many times a day (unlike Daily Retry, which runs once), these skip rows are excluded from the per-day attempt cap (checkAttemptsToday): a skip is not an attempt, so recording it must not throttle a real collection later once the balance recovers.

Plaid Connection Lost / Revoked

The plaid-disconnected path is triggered when the Transactions Service publishes plaid_item_connection_lost (the bank login broke — e.g. ITEM_LOGIN_REQUIRED) or plaid_item_connection_revoked (the user revoked access, in-app or via the data provider’s portal), both with source txn-service.miner. The bank link is going away, so while the debit card is still usable the worker makes a pinless-only attempt on the user’s overdue float. Both events drive the same attempt; the payload’s error_code/reason is carried for telemetry only.

plaid connection lost/revoked event received for user
        │
        ▼
floats.collections.plaid_disconnected.rollout for user? ──No──► Ignore
        │ Yes
        ▼
User has an overdue float? ──No──► Ignore
        │ Yes
        ▼
Attempts today ≥ cap? ──Yes──► Ignore
        │ No
        ▼
Valid primary debit card? ──No──► Record NOCARD skip
        │ Yes                       (WEBHOOKS_PLAID_DISCONNECT, no money moves)
        ▼
Submit pinless debit  (no ACH fallback)
        │
   Payment successful? ──Yes──► Mark float COMPLETED, send receipt, recalc underwriting
        │ No
        └──────────► Record failure outcome (float left overdue)
This path is pinless-only — there is deliberately no ACH fallback, since a broken/revoked Plaid connection means the linked bank account is exactly what we can no longer rely on. A missing or invalid debit card records a WEBHOOKS_PLAID_DISCONNECT collection-history row with outcome NOCARD (no money moves) and stops. It is gated per-user by the floats.collections.plaid_disconnected.rollout GrowthBook flag and reuses the balance worker’s overdue lookup, billing lock, and per-day attempt cap.

ACH Prenotes

Prenotes are zero-dollar ACH verification transactions submitted to the user’s bank 5 calendar days before a float’s due date. They give the ACH processor a chance to validate the account number and surface returns (e.g. closed accounts) before the actual collection debit, reducing NSF and return rates when the Due Date and Daily Retry stages later submit real debits against the same account.

The prenote path is fully decoupled from the collection path — it is driven by its own EventBridge schedule, its own SQS queue, and a dedicated scheduler/worker pair. It does not change float status, acquire the per-user collection lock, or write to the collection-history table.

Prenote Waiting Period

Per the payment processor, the wait before a live (real-money) debit may follow a prenote is not a rolling 72 hours measured from the submission timestamp. The clock starts at 00:00 UTC on the calendar date the prenote was submitted, three full days must then elapse, and the live transaction may not be submitted until the day after that.

Worked example from the processor: a prenote submitted on 5/25 cannot be followed by a live transaction until 5/29 — i.e. the earliest eligible live-debit date is the submission date + 4 calendar days.

This is why the scheduler prenotes 5 calendar days ahead of the due date (see below). A float due on D is prenoted on D − 5; that submission becomes eligible for a live debit on D − 1, leaving a one-day buffer before the Due Date collection runs against the same account.

The processor measures the waiting period in whole UTC calendar days, so the submission time of day does not matter — only the date. The earliest live-debit date is unaffected by whether a prenote is submitted at 00:01 UTC or 23:59 UTC on a given day.

Per the processor, this waiting period is also not affected by bank holidays or weekends — every UTC calendar day counts toward the three-day wait, including Saturdays, Sundays, and holidays.

This waiting period is described by the processor as a NACHA-compliance requirement, but the mechanics above are the processor’s own implementation, not a verbatim NACHA rule. NACHA’s underlying rule lets an Originator initiate live entries as soon as the third banking day following the prenote’s settlement date (with no Return or NOC received). That differs from the processor’s description on three points:

  • Banking days, not calendar days — NACHA’s count excludes weekends and federal holidays, whereas this processor counts every calendar day.

  • Counted from settlement, not submission — NACHA counts from the prenote’s settlement date (≈1 banking day after submission), whereas this processor counts from the submission date at 00:00 UTC.

  • NACHA’s waiting period was reduced from six banking days to three in 2017.

Other processors may apply the banking-day rule directly, so the exact eligible date can differ between processors for the same prenote. Our 5-calendar-day lead time clears both interpretations comfortably.

The rule is stated verbatim in the 2017 Federal Register amendment that adopted it for federal ACH participation, Federal Register, FR Doc. 2017-19135 (Sep 11 2017): "This change permits an Originator that has originated a Prenotification Entry to a Receiver’s account to initiate subsequent Entries to the Receiver’s account as soon as the third Banking Day following the Settlement Date of the Prenotification Entry, provided that the ODFI has not received a return or NOC related to the Prenotification."

Fargate Job

The prenote run is the prenote subcommand of the collections-jobs Fargate task (pkg/collections/jobs/prenote.go). It is triggered by an EventBridge Scheduler schedule at 22:00 UTC on Sun/Mon/Thu/Fri/Sat — two hours before midnight UTC. Tue/Wed are omitted because prenotes submitted then would target due dates that fall on Sat/Sun, when ACH does not settle.

The run is deliberately scheduled close to the 00:00 UTC cutoff: because the waiting period is keyed to the UTC calendar date a prenote is submitted on (see above), running late in the UTC day lets us prenote as many of that day’s qualifying floats as possible while they still count against the current date’s waiting period, rather than slipping into the next day’s.

The task follows the same in-process producer/worker-pool pattern as day-before-ach: a producer goroutine pages SCHEDULING floats for each target due date from RDS replica and sends them through a bounded channel; worker goroutines consume from the channel. Each float is checked against the floats.prenotes GrowthBook feature flag — floats whose user does not have the flag enabled are skipped. Floats that pass are submitted as USIO prenotes via the Payments Service.

Unlike collection jobs, the prenote job acquires no per-user billing lock and writes no collection log entry, because prenotes are zero-dollar and do not change float state.

Target due-date schedule

A prenote submitted on calendar date P clears after 3 calendar days, so the earliest the live ACH can go out is P+4. Each run targets floats whose ACH submission date equals P+4 — that is, the last day we can prenote them and still be valid. ACH submission date = last weekday strictly before the float’s due date.

Run day T+4 (ACH day) Due dates targeted Notes

Monday

Friday

Saturday (T+5), Sunday (T+6), Monday (T+7)

Saturday, Sunday, and Monday-due floats all have Friday as their ACH day — prenoted together on Monday.

Tuesday

Saturday

(none)

T+4 is not a business day. Run exits immediately.

Wednesday

Sunday

(none)

T+4 is not a business day. Run exits immediately.

Thursday

Monday

Tuesday (T+5)

Friday

Tuesday

Wednesday (T+5)

Saturday

Wednesday

Thursday (T+5)

Sunday

Thursday

Friday (T+5)

EventBridge Scheduler fires (daily, 22:00 UTC)
        │
        ▼
prenote Fargate task starts
        │
        ▼
Determine target due dates
  Mon: {T+5 Sat, T+6 Sun, T+7 Mon}
  Tue/Wed: none → exit
  Thu–Sun: {T+5}
        │
        ▼
Producer: page RDS replica for floats WHERE status = SCHEDULING AND due_date IN (targets)
        │
        ▼
Workers (default 5): for each float
        │
        ▼
Evaluate GrowthBook flag floats.prenotes for user_id
        │
        ├── false ──► skip float (emit float.prenotes: submitted=false, reason=feature_flag)
        │
        ▼
GET user from User Service
        │
        ├── Error ──► skip float (emit float.prenotes: submitted=false, reason=user_fetch_failed)
        │
        ▼
Payments Service: SubmitUsioPrenote
  FirstName, LastName, Email, UserId, UsioAccount = "float-debit"
        │
        ├── Error ──► skip float (emit float.prenotes: submitted=false, reason=submit_failed)
        │
        ▼
Success (emit float.prenotes: submitted=true, provider=usio)

Configuration

Setting Effect

floats.prenotes (GrowthBook feature flag)

Boolean flag evaluated per user_id in the worker pool. Users for whom this is true are prenoted. Default is false, so the path is opt-in per user (or per GrowthBook rollout rule) with no service deploy required.

--workers (default 5)

Number of goroutines consuming from the producer channel.

--payment-submit-rate (default 10)

Prenote submissions per second (token-bucket rate limiter shared across workers). Set to match observed throughput of the retired Lambda path (~8.3/s for 30k floats in ~1 hour), finishing well inside the 2-hour UTC window before midnight.

--page-size (default 50)

Number of floats per RDS page query.

The job does not verify that the user has a valid ACH-eligible account before submitting. Eligibility is enforced by the Payments Service, which rejects prenote submissions for users without an ACH-routable bank account.

Collection Outcomes

The table below lists float status values (the ach_debit_status column, a closed enum). These are distinct from the collection-history outcome values written per run: a successful/failed attempt records its status as the outcome, while a declined run records a skip outcome (RECENTATTEMPT, NOCARD, PLAIDLINK, STALEDATA, INVALIDPLAID, LOWBALANCE, UNEXPECTEDERROR) that leaves status unchanged — see Skip outcome codes and precedence.

Status Set by Description

SCHEDULING

Float creation; T-1 Day (ACH failed)

Initial status. Float has been created and disbursement is being processed. Also retained when a T-1 Day ACH attempt fails — the due-date pinless run will still fire normally.

ACHSENT

T-1 Day, Due Date, Daily Retry, Balance worker

ACH debit submitted to the payment processor. Awaiting a settlement callback via the prod-payments Kinesis stream, which the ACH Handler Lambda consumes.

COMPLETED

ACH Handler (success callback); Due Date and Daily Retry (pinless success)

Float fully collected. The ach_debit_id column holds the reference ID of the successful payment.

RETRY

Due Date (all attempts failed), Daily Retry (attempt failed), Balance worker (NSF or failure)

Collection attempted but unsuccessful. The float will be retried at the next Daily Retry run or on the next qualifying balance event. (A low balance or other declined run does not set RETRY — it records a skip outcome and leaves status unchanged.)

DEFAULTED

Daily Retry (ACH attempts ≥ limit or > 90 days past due)

Float has exceeded the maximum automated collection attempts or age threshold.

UNCOLLECTABLE

Daily Retry (no valid Plaid account and no valid primary debit card)

No valid payment method exists to attempt collection. Re-evaluated on subsequent Daily Retry runs.

Prenote submissions do not change float status. A prenote return — if the account is closed or invalid — surfaces through the Payments Service’s own monitoring, not through the float record.

Distributed Locking

Before processing any float, each collection Lambda acquires a per-user distributed lock stored in the locks DynamoDB table. The lock key is loan-processing:user_id:{userID} with a 60-second lease and a 1-second heartbeat. This prevents two collection paths (e.g., a scheduled run and an incoming balance event) from attempting concurrent collection on the same user’s float.

If a lock cannot be acquired, the attempt is skipped without updating the float status. See DynamoDB Tables for the locks table schema.

The prenote worker does not acquire this lock. Because prenotes are zero-dollar and do not update float state, concurrent prenote and collection activity for the same user is safe.