Early Advance

An early advance lets a qualified user take a new float while their previous float’s repayment is still in flight — the ACH debit submitted but not settled (ACHSENT). Normally any non-COMPLETED float denies RuleGoodStanding; early advance discounts that one in-processing float for users whose repayment evidence is strong.

Early advance end-to-end flow

Two qualification paths

Users split by float rank (completed-float count); the populations are disjoint, so exactly one path can apply to a user and there is no fallback between them. Both produce the same outcome — only how the user qualifies differs.

Float rank Path What clears the user

0

transactions

The repayment debit is visible in their bank transactions for the current day bucket: a FloatMe debit for exactly float + fee, no return or reversal, no FloatMe credit undoing the debit (float_evaluation.EvaluateEarlyAdvance).

1–6

neither

No early advance is offered.

>= 7

float_rank

Their record: float rank >= 7, last 3 billed subscriptions paid back to back, and at most 1.4 repayment attempts per float over their 3 most recent completed floats (rules.EvaluateEarlyAdvanceFloatRank). The current collection must be on its first debit attempt.

A rank >= 7 user excluded from the experiment, or one who fails a float-rank check, gets nothing — the outcome is recorded, not retried against the other path.

Structural gate

Both paths share one precondition, resolved by the rule runner (it needs float-service and payments-service calls the rules cannot make): the user’s most recent float is ACHSENT with no other outstanding float, its latest collection attempt submitted a debit (carries a payment confirmation ID), and that payment is still an ACHSENT debit. The gate also reports which collection run produced the debit and how many debits exist for the float.

Per-path collection requirements on top of the gate:

  • transactions — the run must be TOMORROW_FIRST_FLOAT (the T-1 day-before-ACH collection for a first float).

  • float_rank — the run must be TOMORROW_RETURNING_FLOAT, and the collection must be on its first debit attempt (denies with collection_retried). "First attempt" counts submitted debits (runs carrying a confirmation ID), not collection log entries — runs that submitted nothing still write logs.

The floats.collections.day_before.returning_float_ach operations flag is not read by either path; the collection log is the record of what ran.

Experiment arms

Each path is gated by a string-valued GrowthBook feature that assigns an arm (Feature Flags): early_advance_transactions_group for rank 0, early_advance_float_rank_group for rank >= 7. The transactions path additionally sits behind the underwriting.rules.early_advance_elligibility_checks kill switch.

Value Behavior

treatment

Evaluated; a cleared user is granted the advance, the grant IDs are persisted, and the notification is sent.

holdout

Evaluated identically and recorded, but never granted — the ACHSENT float still blocks them and no notification is sent.

anything else

Not in the experiment; nothing is evaluated or recorded.

holdout vs treatment is the only valid comparison — unbucketed users are a mixed population, not a control. The live check re-reads the arm, so reassigning a user mid-experiment denies an already-granted advance at the point of use.

The float-rank path’s payments-service lookup (for the attempt ratio) is fetched only for users over the rank bar and in an arm; a failed lookup drops the user from the experiment rather than recording a denial.

Float-rank path checks

Paid-subscription run — the last 3 billed cycles must be COMPLETED back to back. Leading future-dated SCHEDULED cycles are skipped; counting stops at the first non-COMPLETED cycle, so a WAIVED or errored cycle breaks the run. [SCHEDULED, COMPLETED, WAIVED, COMPLETED, COMPLETED] is a run of 1, not 3.

Repayment-attempt ratio — debit attempts divided by the user’s 3 most recent COMPLETED floats; must be non-zero and at most 1.4 (one retry in the window passes, two do not; both constants live in pkg/rules/rule_good_standing.go). A completed float with no debit on record still counts in the denominator, so the ratio can be below 1 — a low value may be a clean run or a payment-data gap; repayment_attempts and floats_considered are persisted to distinguish them. A ratio of exactly zero denies (attempt_ratio_zero). The in-processing float is never in the window. This measure is distinct from RuleCollectionsErrors' error_ratio (failed Usio debits, whole history, includes subscriptions) — they are not interchangeable.

Where each path is evaluated

  • float_rank runs inside RuleGoodStanding (evaluateFloatRankEarlyAdvance) — the rule receives the structural gate on RuleExecutionData and runs the checks itself.

  • transactions runs in the rule runner (rulerunner.resolveEarlyAdvance → qualifyEarlyAdvanceByTransactions), which records the verdict on the gate it hands RuleGoodStanding.

Rules stay pure functions of RuleExecutionData — the rule runner resolves the experiment arm and hands it over on the gate (EarlyAdvanceGate.Group), so the recorded arm always matches the arm that gated the data.

What is saved

Where What Notes

early_advance_experiment (EvaluationResult)

Arm, verdict, granted, failed check, float rank, attempt ratio, floats considered, paid subscriptions, float/payment IDs, institution, day bucket, approval time

Written for both arms; the result runner sets granted only from a good-standing outcome that passed.

EvaluationResult top-level grant keys

early_advance_float_id, early_advance_payment_id, early_advance_day, early_advance_institution_name, early_advance_qualification

Superseded by the record above but dual-written and still what the live check reads. Written only for a granted advance.

payments-service

early_advance_eligible_at on the in-flight collection; early_advance_float_id when the advancing float is created

Links eligibility and the taken advance to the collection.

Never record a holdout user’s float/payment under the top-level grant keys — the live check treats their presence as permission, which would hand the holdout arm a working advance. Diagnostics belong in early_advance_experiment only.

Each stored evaluation also reports early_advance_qualification on the summary-history responses (pkg/dynamo/evaluation_results.go, EarlyAdvancePath): the path that cleared the user, null when no path did. It reads early_advance_experiment.granted OR the top-level ID pair, defaulting to transactions for records that predate the qualification field. It records the past decision; the live check below is the authority on current eligibility.

Live check at request time

The float-check and eligibility endpoints re-verify a stored grant along the same path that granted it (early_advance_qualification):

  • Common: the payment must still be an ACHSENT debit, and the user still in treatment.

  • transactions: re-confirms the user is still a first-time floater. While the current day bucket matches the recorded early_advance_day (and the float is still ACHSENT), the stored decision is trusted without re-fetching — any new transaction batch triggers a full rule re-run. Once the bucket advances, the check re-fetches the collection payment by confirmation ID and re-runs the day checks over transactions since its submit date.

  • float_rank: re-confirms the float is still the user’s most recent ACHSENT float and reads the authoritative payments-service status (the float’s cached DebitStatus lags settlement).

The day bucket is the number of business days (weekends and federal bank holidays skipped) between the float’s due date and now, 0 = the due date. Days 0–2 share the same checks: a floatme transaction whose amount equals float + fee, no return/reversal transaction, and no reversing floatme credit. A day with no entry denies. Descriptors are normalized (lowercased, non-alphanumeric stripped) and amounts compared in cents. A stale recorded decision self-corrects when the bucket advances — there is no cleanup job.

The result is surfaced as early_advance_eligible on the float-check response and float_stacking_eligible on the eligibility-check response (name retained for app compatibility), with early_advance_float_id/early_advance_payment_id included when eligible.

Notification

The rule runner builds the early_advance_eligible payload (email, collection-payment metric fields) and carries it on user_rule_run_completed; the result runner decides whether to send, from float_results.approved and early_advance_experiment.granted — carrying a payload is not permission to send, which is what keeps holdout users from being nudged. Sent via Iterable with user_id, float_id, day, and end_date (the business day the bucket rolls over). Failures are logged and never fail the result run.

Decision metric

underwriting.early_advance.decision (Datadog counter), emitted for both arms — filter on group before reading approval rates. Tags:

Tag Values

approval

approve / deny

check

Denying check: transaction day checks (floatme_present, no_returns_or_reversals, no_floatme_return), float-rank checks (collection_retried, float_rank_too_low, insufficient_paid_subscriptions, attempt_ratio_too_high, attempt_ratio_zero), or structural (payment_not_achsent, unparseable_due_date, no_checks_for_day, unparseable_submit_date); empty when approved

qualification

transactions / float_rank

group

treatment / holdout

business_days

Day bucket

institution_name

Bank display name (persisted on the evaluation)

process

process_rule_runner / process_api; the live check’s trusted-bucket path does not re-emit

payments.ach.early_advance_float tracks event:user_notified and event:float_created (tagged payment_amount, payment_processor, business_days, process — not qualification). Every float-rank decision also logs float rank early advance <arm> - <verdict> with all check inputs.

Source: pkg/float_evaluation/early_advance.go, pkg/datadog/metrics.go