Float Profiles

The FloatProfile is a user’s float/loan configuration: which tiers are enabled, whether floats, loans, and CFI are enabled at all, and the $200 test markers (200_test_available, true for everyone; 200_test_offered, the $200 qualification). Storage shape: DynamoDB. Profiles are versioned — each CFI change writes a new PROFILE#<created_on> item; reads take the latest.

Default profile

Created by the profile handler on new_bank_account_added (idempotent — skipped when a profile exists; metric underwriting.profile.created), and used in-memory whenever a user has no stored profile.

  • is_float_enabled: true, is_loan_enabled: false, cfi_enabled: true

  • Float tiers (dollars), IDs 1–9: $10 ✓, $20 ✓, $30, $40, $50, $80, $100, $150, $200 — only the first two enabled

  • Loan tiers (cents): 20000, 25000, 30000, 35000 — all disabled

Source: pkg/dynamo/float_profile.go (GetDefaultFloatProfileSettings), pkg/floatprofile/handler.go

Limits

A user’s float limit is the highest enabled tier (GetLimit). A stored $75 amount is normalized to $80 on read (CorrectFloatAmount). Tier changes are made by the CFI increase check: SetToLimit enables every tier at or below the target and disables the rest. For users with an enforced Float Tiers assignment the stored limit is never consulted: responses are shaped from the assignment, or carry no float limit without one (Profile ladder); CFI still maintains it.

$150 has no limit of its own

$150 has no IncreaseChecks row — no user’s float limit is ever set to $150. It exists only as a lower amount for users whose limit reaches $200 (via the response ladder: an enforced Float Tiers assignment or the $200 qualification), so SetToLimit enables it exactly when $200 is enabled; a user is never enabled for $150 but not $200. API responses omit the row entirely when it is not enabled (toAPIFloatProfile), so users at $100 and below never see a $150 option. The invariant is locked in by TestNoIncreaseCheckEnablesOneFiftyWithoutTwoHundred (pkg/rulerunner/increase_test.go), which walks every IncreaseChecks row.

Fees

Fee values are not in this repo. The schedule comes from the GrowthBook feature global.loans.fees.config (Feature Flags) — a JSON-encoded string that decodes to an object keyed by lowercased US state (falling back to "default"), then by amount — dollars for floats (e.g. "80"), cents for loans (e.g. "25000"). Each entry carries type, fee_cents, fee_percent, and MLA variants (mla_fee_cents, mla_fee_percent) applied for military users. A missing or empty schedule fails the request (ErrLoanFeeScheduleNotFound); a missing amount key yields a $0 fee (plain map read, no guard) — the config should carry "150" and "200" float entries, since every user’s response can include those rows.

Source: pkg/api/float_profile.go, pkg/dynamo/float_profile.go:32-43

Premium fee waiver

Float fees are zeroed on API responses when the user’s active membership tier is premium (user-service GET /memberships) and their tier version in global.tiers.config carries the WaiveInstantFloatFees feature. Only the response fields change — the stored profile and the schedule are untouched; loan fees are unaffected. A failed membership or config lookup charges the standard fee.

Source: pkg/api/fee_waiver.go

API responses

Responses never carry the stored floats array. pkg/floatdecision rebuilds the ladder from the canonical 9 tiers (GetDefaultFloatProfileSettings) enabled up to the limit limitFor chose, and toAPIFloatProfile (shared by the profile, eligibility, and loan endpoints) attaches fee/fee_cents per tier from the schedule above. All tiers appear in ID order, disabled rows included — except $150, which is returned only when enabled (see $150 has no limit of its own), so users below $200 receive eight rows, with $200 renumbered from ID 9 back to 8 to keep IDs contiguous. A float_tiers object marks a ladder float tiers shaped (Float Tiers). Response schemas: API spec.

Source: pkg/floatdecision/resolver.go, pkg/api/float_profile.go