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