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

SCHEDULING

Float created and disbursement submitted; awaiting its due date.

Float creation (API)

ACHSENT

ACH debit submitted to the payment processor; awaiting a settlement callback from the prod-payments Kinesis stream. Support cannot move a float out of this status — see Support Debit Edit.

ACH-submitting collection runs; manual payback (ACH option)

COMPLETED

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

RETRY

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)

FAILED

A float operation errored; treated as overdue.

batch-worker

ACHFAILED

Failed-ACH variant; treated as overdue.

None (data value read by overdue queries)

UNCOLLECTABLE

No valid Plaid account and no valid primary debit card; re-evaluated on every Daily Retry run.

Daily Retry

DEFAULTED

Terminal: more than 90 days past due, bannable ACH return, chargeback, or user ban.

Daily Retry; ACH Handler; ban endpoint

HOLD

Collection paused; scheduled batch jobs skip the float.

External tooling (checked in pkg/collections/jobs/rules.go)

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.

  1. Validate the user (User Service): state not restricted (floats.restricted_states), status ACTIVE, email verified where enforced — otherwise 400. A CANCELLED membership returns 403 (PENDING_CANCELLATION); a PENDING_CANCELLATION membership is allowed, since members keep float access until their paid period ends.

  2. Validate subscription (Subscriptions Service): PAST_DUE or STALE returns 400.

  3. Acquire the user’s distributed lock (locks table).

  4. Fetch the linked bank account (Transactions Service).

  5. Check for an active requirements bypass (Requirements Bypass).

  6. Underwriting eligibility check — skipped when a bypass is active; not approved returns 403 (NOT_APPROVED).

  7. Send First Float Accepted when this is the member’s first accepted float request of their lifecycle.

  8. Run fraud checks — see Fraud Detection; a connectivity error returns 500.

  9. Fetch the float profile and fee (Underwriting Service); float not enabled for the user returns 400.

  10. Fetch next payday and payback date (Insight Service); a custom payback date is validated against the allowed window.

  11. Submit the disbursement (Payments Service) as a PINLESS, RTP, or ACH credit; an RTP rejection (reported by Payments as a P04 error) retries as PINLESS only when floats.can_fallback_pinless is enabled for the app build.

  12. Write the float to RDS: status SCHEDULING, the credit confirmation ID, the computed due date (ach_debit_date), and next_payday_date (first payday after the due date).

  13. Send the confirmation email (Segment + Iterable), publish user_float_created (Events), return 201.

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.

  1. Skip when the member already has a float created at or after the later of their date_joined and the membership’s reactivation_date.

  2. Acquire the first_float_accepted marketing lock in the Payments Service; already held → skip. Account closure in the User Service releases it.

  3. Send to Segment with appsflyer_id, app_platform, firebase_install_id, float_id, evaluation_id, float_amount and float_type. A failure releases the lock.

  4. 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.

  1. Fetch the float; it must be at least 24 hours old and not COMPLETED or ACHSENT — otherwise 404.

  2. Acquire the user’s billing lock.

  3. Calculate the fee (PINLESS/RTP floats).

  4. 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 FAILED

RETRY; collection log process=MANUAL

500

ACH submitted

ACHSENT; collection log process=MANUAL; finalized asynchronously by the ACH Handler (ach_processing.adoc#ach-callbacks)

202

Pinless collected

COMPLETED; collection log process=MANUAL; digital receipt (Segment, Iterable, AppsFlyer); underwriting recalculation

202

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.

  1. Fetch the user’s active floats (RETRY, DEFAULTED, SCHEDULING, ACHSENT, UNCOLLECTABLE) and filter to past-due (ach_debit_date ⇐ now).

  2. Total responsibility = sum of amount + fee across the past-due floats; the expected amount is validated before each submission.

  3. Per float, sequentially: submit the reactivation payback (Payments Service; retry-limit bypass applies; the REACTIVATION collection log carries the balance snapshot). ErrFloatNotPayable logs a warning, subtracts the float from the total, and continues; any other error returns 500. A result other than COMPLETED stops the run with 402 Payment Required and {AmountCollected, AmountRemaining, MaskedCardNumber}.

  4. All collected: 200 with {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 Bypassed flag 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):

  • RETRY or SCHEDULING → status DEFAULTED (debit ID user_banned); collection log process=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 COMPLETED with no return code — the money is still collected. Refused with 409 and error_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 REFUNDED on 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 with 503 and nothing is written. A transient failure is worth retrying; a missing or unknown ach_debit_id will 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-payments stream; the float defaults and the user is banned.

  • User ban — the ban endpoint defaults the user’s RETRY and SCHEDULING floats directly, writing a SUPPORT collection 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.