Float Lifecycle
A float is a short-term advance disbursed to a user. This page is the canonical reference for float statuses and every lifecycle operation outside automated collection: creation, manual payback, reactivation payback, requirements bypass, user bans, and terminal states. Automated collection is covered in Collections Engine.
Float Statuses
The ach_debit_status column on the float record tracks where a float is in its lifecycle.
| Status | Description | Set by |
|---|---|---|
|
Float created and disbursement submitted; awaiting its due date. |
Float creation (API) |
|
ACH debit submitted to the payment processor; awaiting a settlement callback from the |
ACH-submitting collection runs; manual payback (ACH option) |
|
Fully collected. Support cannot move a float out of this status while the debit remains settled — see Support Debit Edit. |
ACH Handler (settlement); any successful pinless debit |
|
A collection attempt failed; the float is overdue and targeted by retry collection. |
Due-date and retry runs; webhook worker; ACH Handler (non-bannable return) |
|
A float operation errored; treated as overdue. |
batch-worker |
|
Failed-ACH variant; treated as overdue. |
None (data value read by overdue queries) |
|
No valid Plaid account and no valid primary debit card; re-evaluated on every Daily Retry run. |
Daily Retry |
|
Terminal: more than 90 days past due, bannable ACH return, chargeback, or user ban. |
Daily Retry; ACH Handler; ban endpoint |
|
Collection paused; scheduled batch jobs skip the float. |
External tooling (checked in |
Source: pkg/rds/status.go (which also defines CHARGED_BACK — a payment-response value, not a float status: on a chargeback event the ACH Handler sets the float to DEFAULTED and records CHARGED_BACK as the collection outcome; see ACH Callbacks)
The overdue set — statuses targeted by Daily Retry and the webhook collection paths — is RETRY, FAILED, ACHFAILED, UNCOLLECTABLE. Source: pkg/collections/status.go.
Creation & Disbursement
POST /{user_id}/floats runs all checks synchronously; the float record is written to RDS only after the disbursement succeeds.
-
Validate the user (User Service): state not restricted (
floats.restricted_states), statusACTIVE, email verified where enforced — otherwise400. ACANCELLEDmembership returns403(PENDING_CANCELLATION); aPENDING_CANCELLATIONmembership is allowed, since members keep float access until their paid period ends. -
Validate subscription (Subscriptions Service):
PAST_DUEorSTALEreturns400. -
Acquire the user’s distributed lock (locks table).
-
Fetch the linked bank account (Transactions Service).
-
Check for an active requirements bypass (Requirements Bypass).
-
Underwriting eligibility check — skipped when a bypass is active; not approved returns
403(NOT_APPROVED). -
Send First Float Accepted when this is the member’s first accepted float request of their lifecycle.
-
Run fraud checks — see Fraud Detection; a connectivity error returns
500. -
Fetch the float profile and fee (Underwriting Service); float not enabled for the user returns
400. -
Fetch next payday and payback date (Insight Service); a custom payback date is validated against the allowed window.
-
Submit the disbursement (Payments Service) as a
PINLESS,RTP, orACHcredit; an RTP rejection (reported by Payments as aP04error) retries asPINLESSonly whenfloats.can_fallback_pinlessis enabled for the app build. -
Write the float to RDS: status
SCHEDULING, the credit confirmation ID, the computed due date (ach_debit_date), andnext_payday_date(first payday after the due date). -
Send the confirmation email (Segment + Iterable), publish
user_float_created(Events), return201.
| If the disbursement succeeds but the RDS write fails, funds have moved with no float record. The confirmation email and event publish happen only after the RDS write. |
First Float Accepted
With floats.first_float_accepted enabled, the first float request in a member’s lifecycle that underwriting accepts, or that an active requirements bypass lets through, sends first_float_accepted (first_float_accepted_test in test) before the fraud checks run. It goes to Segment, which forwards it to Firebase Analytics through the Google Analytics integration, and to AppsFlyer (server-to-server) for iOS and Android members with an AppsFlyer ID. A request the fraud check then rejects, or that fails at disbursement, has still sent it.
-
Skip when the member already has a float created at or after the later of their
date_joinedand the membership’sreactivation_date. -
Acquire the
first_float_acceptedmarketing lock in the Payments Service; already held → skip. Account closure in the User Service releases it. -
Send to Segment with
appsflyer_id,app_platform,firebase_install_id,float_id,evaluation_id,float_amountandfloat_type. A failure releases the lock. -
Send to AppsFlyer through the iOS or Android client chosen from
app_platform. A missing AppsFlyer ID or any other platform sends to Segment only; AppsFlyer errors keep the lock.
appsflyer_id and app_platform come from the user record, falling back to the X-Floatme-Appsflyer-Id and X-Floatme-Phone-Os request headers. firebase_install_id is the user record’s Firebase app instance ID (firebase_instance_id), sent even when blank. The whole send, including any lock release, is bounded to 5 seconds and never fails or changes the float request. Each check records floats.first_float_accepted tagged outcome (sent, already_sent, prior_float, error) and platform.
Source: pkg/api/first_float_accepted.go
Manual Payback
POST /{user_id}/floats/{float_id}/payback with payback_option: "ACH" | "PINLESS" pays back a single float early.
-
Fetch the float; it must be at least 24 hours old and not
COMPLETEDorACHSENT— otherwise404. -
Acquire the user’s billing lock.
-
Calculate the fee (
PINLESS/RTPfloats). -
Submit the payment (Payments Service) per
payback_option. The collection log carries the primary account’s balances read just before the submit (balance snapshot) and, on a pinless decline, the processor’s partial-auth balance.
Payback submissions set bypass_retry_limit, so member-initiated attempts are never blocked by the Payments Service pinless retry limit.
|
| Result | Float status | Response |
|---|---|---|
Error or |
|
|
ACH submitted |
|
|
Pinless collected |
|
|
Reactivation Payback
POST /{user_id}/floats/payback/reactivate pays off all past-due floats before a cancelled account is reactivated. Pinless debit only, regardless of loan type; no request body.
-
Fetch the user’s active floats (
RETRY,DEFAULTED,SCHEDULING,ACHSENT,UNCOLLECTABLE) and filter to past-due (ach_debit_date ⇐ now). -
Total responsibility = sum of amount + fee across the past-due floats; the expected amount is validated before each submission.
-
Per float, sequentially: submit the reactivation payback (Payments Service; retry-limit bypass applies; the
REACTIVATIONcollection log carries the balance snapshot).ErrFloatNotPayablelogs a warning, subtracts the float from the total, and continues; any other error returns500. A result other thanCOMPLETEDstops the run with402 Payment Requiredand{AmountCollected, AmountRemaining, MaskedCardNumber}. -
All collected:
200with{AmountCollected, AmountRemaining, AmountTotal, MaskedCardNumber}.
A 402 means partial collection — at least one float was paid but another could not be; the caller uses the amounts to decide whether reactivation proceeds.
Requirements Bypass
A per-user record that lets float creation skip the underwriting eligibility check. Granted by internal operations tooling via the bypass endpoints (see API spec); stored in the requirements-bypass DynamoDB table keyed by user_id (schema).
While an unexpired bypass exists:
-
The underwriting eligibility check is skipped.
-
Fraud checks still run, with the
Bypassedflag passed to the Payments Service; fraud results are not saved back for bypassed users.
The record expires on its expiration_date but is not deleted — it remains in DynamoDB as inactive until an explicit DELETE.
User Ban
POST /{user_id}/floats/ban defaults any float that could still be collected. For each active float (RETRY, DEFAULTED, SCHEDULING, ACHSENT, UNCOLLECTABLE):
-
RETRYorSCHEDULING→ statusDEFAULTED(debit IDuser_banned); collection logprocess=SUPPORT,outcome=DEFAULTED. -
ACHSENT,DEFAULTED,UNCOLLECTABLE→ unchanged. An in-flight ACH debit is not cancelled; the ACH Handler finalizes it on settlement.
No active floats returns 200 as a no-op.
Support Debit Edit
PATCH /{user_id}/floats/{float_id} applies debit_status and debit_date from the request body — and fee when positive — to the float’s ach_debit_status, ach_debit_date and fee columns, and writes a collection log with process=SUPPORT. All other body fields are ignored. Support tooling and Fin use it to reschedule a repayment.
Two statuses are protected, because in both the member’s money is already collected or on its way. Re-opening such a float returns it to the collection queue and debits the member a second time.
Both checks apply only to a request that moves the float off that status. A request that sends the same status back is permitted and updates ach_debit_date and fee as normal, since it cannot return the float to the collection queue.
Leaving ACHSENT is refused with 409 and error_code=FLOAT_DEBIT_IN_FLIGHT. The debit is with the processor and settles regardless of what the float record says, so there is nothing to ask the Payments Service yet.
Settling does not make the float editable. The ACH Handler moves a settled debit to COMPLETED, and the rule below then applies — including the refund case, since a payment can only be refunded once it has settled. A returned debit instead moves the float to RETRY or DEFAULTED, neither of which is protected.
Leaving COMPLETED is permitted only when the debit is no longer settled. The handler reads the float’s ach_debit_id from the Payments Service:
-
Payment is
COMPLETEDwith no return code — the money is still collected. Refused with409anderror_code=FLOAT_ALREADY_SETTLED; nothing is written. -
Payment is refunded or carries a return code — the float is genuinely unpaid. The update proceeds, which is how a repayment is rescheduled after a refund. A refund sets the payment’s status to
REFUNDEDon the original record, so no separate lookup is needed. -
The payment state cannot be established — the float has no
ach_debit_id, the Payments Service cannot be reached, or it answers without a payment (unknown confirmation ID, or any non-success response). The request is refused with503and nothing is written. A transient failure is worth retrying; a missing or unknownach_debit_idwill not resolve on its own and needs manual review.
A float in any other status is updated without consulting the Payments Service. Note that DEFAULTED is not protected, so a support edit can return a float to collection after a bannable ACH return.
Default & Uncollectable
DEFAULTED is set on four conditions. The first three are reached by a collection run; the fourth is a support action outside the collections engine:
-
Age — the float is more than 90 days past due (Daily Retry).
-
Bannable ACH return — the debit is returned with a code NACHA prohibits re-initiating; the float defaults and the user is banned (ach_processing.adoc#bannable-return-codes).
-
Chargeback — a chargeback event on the
prod-paymentsstream; the float defaults and the user is banned. -
User ban — the ban endpoint defaults the user’s
RETRYandSCHEDULINGfloats directly, writing aSUPPORTcollection log; no collection run is involved. See User Ban.
UNCOLLECTABLE is set by Daily Retry when the user has neither a valid Plaid account nor a valid primary debit card. It is not terminal: each Daily Retry run re-evaluates these floats, and collection resumes if a valid payment method appears.