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:
-
ruleFloatStillCollectable— re-fetches the float from RDS; skips if alreadyACHSENTorCOMPLETED(guards the window between producer query and worker dispatch). -
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 inSCHEDULINGfor the due-date pinless run and is picked up again once the user links a new bank account. A blocklist lookup error also skips. -
ruleMaxACHAttempts— skips if the float has already reached the configured ACH attempt limit (excludes manual collection logs). -
ruleAchIfInvalidDebitCard— routes toActionSubmitACHNextDayif the user has no valid primary debit card; otherwise leaves the float inSCHEDULINGfor the Due Date pinless run. -
ruleACHIfFirstFloat— routes toActionSubmitACHNextDayfor users with float rank 0 (no prior completed floats) when the GrowthBook flagfloats.collections.day_before.first_float_achis enabled for them. The flag is a percentage rollout controlled in GrowthBook without a deploy. -
ruleACHIfReturningFloat— the rank > 0 counterpart: routes returning floaters (at least one prior completed float) toActionSubmitACHNextDaywhen the separate GrowthBook flagfloats.collections.day_before.returning_float_achis 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 taggedTOMORROW_RETURNING_FLOAT(first-float attempts useTOMORROW_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-workerLambda — on auser_new_txns_batch_completedevent 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:
-
ruleFloatStillCollectable— re-fetches the float; skips if alreadyCOMPLETED,ACHSENT, orHOLD. -
ruleSkipIfBlocklisted— skips if the user is on the payments-service blocklist (bank account closed, frozen, or invalid — ACH is guaranteed to return; same rule asday-before-ach). The float is picked up again once the user links a new bank account. A blocklist lookup error also skips. -
ruleMaxACHAttempts— skips if the float has reached the NACHA auto-ACH attempt limit (3ACHSENTacross 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. -
rulePaydayACH— gated per-user by thefloats.collections.payday_ach.enabledflag; when on, routes toActionSubmitACHNextDay. 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 + thenext_payday_daterefresh) but only submits real ACH if the rollout flag is also on. -
floats.collections.payday_ach.rollout— gates the real ACH submission (checked indispatch). Enabled-on / rollout-off is a dry run: the worker emits the attempt metric (taggedrollout:false,outcome:dry_run), logs the would-be attempt, and advancesnext_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.submitACHDebitreturns early (aoutcomeACHSuppressedsentinel) for overdue-status floats (RETRY/FAILED/ACHFAILED/UNCOLLECTABLE) that have a cachednext_payday_date— covering the Daily Retry run. Scoping by status leaves the due-dateTODAY6AMrun (SCHEDULING floats) untouched;day-before-achis a separate package and unaffected. -
WebhookWorker.attemptACHCollectionreturns 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:
-
DEFAULTED— ACH cap reached or > 90 days past due (terminal status transition). -
RECENTATTEMPT(new) — a real (non-skip) balance-worker attempt on this float within the last 24h (rolling window, keyed off the collection-logrun_time). Evaluated before any external call so a redundant retry short-circuits early. -
NOCARD(new) — the get-debit-card call returned 404 / not found. -
PLAIDLINK(new) — the user’s primary Plaid item is in a broken connection state (itemstatuserror/removed, or a non-emptyerror_codesuch asITEM_LOGIN_REQUIRED), read up front via txn-serviceListItems, 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 terminalUNCOLLECTABLEinstead (regardless of balance). -
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). -
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 byPLAIDLINK. -
UNCOLLECTABLE— Plaid unusable and the primary debit card invalid. -
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, soLOWBALANCEalways 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:
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 |
|---|---|
|
Boolean flag evaluated per |
|
Number of goroutines consuming from the producer channel. |
|
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. |
|
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 |
|---|---|---|
|
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. |
|
T-1 Day, Due Date, Daily Retry, Balance worker |
ACH debit submitted to the payment processor. Awaiting a settlement callback via the |
|
ACH Handler (success callback); Due Date and Daily Retry (pinless success) |
Float fully collected. The |
|
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 |
|
Daily Retry (ACH attempts ≥ limit or > 90 days past due) |
Float has exceeded the maximum automated collection attempts or age threshold. |
|
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.
Related Pages
-
Float Lifecycle — End-to-end float status state machine
-
DynamoDB Tables — Collection history and locks table schemas
-
PostgreSQL Schema — Float statuses and column reference
-
Architecture — Collections system context diagram