Payday Prediction

Payday prediction returns a user’s next payday and the collection dates derived from it. It runs on GET /{user_id}/insights/employment/payday (the Float Service passes loan_id during float creation; the mobile app sends no body) and inside GET /{user_id}/insights/employment. Request and response shapes are in API Specification.

Entry Points

Caller Behaviour

GET /{user_id}/insights/employment/payday with no body or {}

Cacheable path; earliest_date defaults to now. An empty body is rewritten to {} by the NormalizeEmptyPaydayBody middleware before decoding

GET /{user_id}/insights/employment/payday with earliest_date

Bypasses the cache in both directions (insight.payday.cache result:bypass_earliest_date); an unparseable date returns 400

GET /{user_id}/insights/employment

Cacheable path, no loan_id

The payday endpoint also fills estimated_next_paydays: the next N paydays projected from the chosen cadence, where N is the insights.payday_prediction.next_paydays_to_estimate flag (default 6).

A user with no employment record gets 500 from every entry point — the chain reads the record before running any algorithm.

Same-Day Cache

A prediction is computed at most once per user per UTC day and stored in the payday entity (schema).

  1. Read PAYDAY#<user_id> / PREDICTION. If prediction_date equals today, return the stored chosen prediction (result:hit) and write a PAYDAY_PREDICTION capture row with from_cache: true.

  2. Otherwise run the Prediction Chain (result:miss), store the winner plus every algorithm’s output, and return the winner.

The row is deleted (result:invalidated) whenever the employment record changes: employment create, update, and delete, income verification save, and the institution-change handler. Deletion failures are logged, never surfaced.

Prediction Chain

Order Algorithm Runs when

1

Pave (Pave)

The pay-frequency gate allows the user

2

FloatMe V2 (FloatMe V2)

Pave produced no payday

3

FloatMe V1 (FloatMe V1)

Always computed; chosen when neither of the above produced a payday

Source: pkg/payday/predictor.go (computeNextPayday)

Every run logs Payback prediction comparison with all four outputs (Pave V1, Pave V2, FloatMe V1, FloatMe V2), emits insight.payday.predictor_used tagged predictor:pave|fm_v2|fm_v1, pay_frequency, gated, and is_loan, and writes a PAYDAY_PREDICTION row (Persistence).

Pave

One call to Pave GetIncomeSource returns the user’s recurring income sources; two selectors run over the same list. insight.payday_prediction.pave_v2.rollout (per user) picks V2 when it produced a payday, otherwise V1. The losing selection is still logged and captured (used_pave_v2).

Selector Selection

V1

Sources with next_date on or after yesterday whose last_description matches the employer name (Wagner-Fischer < 5, or < 12 with Jaro-Winkler > 0.8); the highest match wins. Fallback: the first source that is current, has abs(next_amount) >= 150, and is tagged PAYROLL without CASH ADVANCE (or has normalized_income_source = PAYROLL)

V2

Filter to status = active, normalized_frequency in daily, weekly, biweekly, semimonthly, monthly, a non-empty next_date, and abs(avg_amount) >= 50. Rank by: matches the employment record’s cadence (bi-monthly read as semi-monthly); not tagged earned-wage-access or cash-advance; recurrence_strength (VERY_HIGH to VERY_LOW, unknown last); transaction count; amount

Source: pkg/payday/helpers.go (V1), pkg/payday/pave_predictor.go (V2)

Dates from the selected source: next_date when it is not before the earliest date, else the first of Pave’s next_dates that is not, else next_date plus avg_period_days. The earliest date for payday is floored at tomorrow, so a source paying today resolves to its following payday and sets is_today. The extended date is the first next_dates entry inside the extended window, else next_date stepped by avg_period_days until it passes the window start.

FloatMe V1

Arithmetic over the employment record: step payday forward by pay_frequency (weekly 7 days, bi-weekly 14 days, monthly one month; bi-monthly and semi-monthly snap to the 15th or the 1st of next month) until it is not before the earliest date, then move a bank holiday back one day and a Saturday or Sunday back to Friday. Source: pkg/employment/payday.go

FloatMe V2

Predicts cadence and next payday from the primary account’s credit history alone; no employment record or Pave data.

Stage What happens

1. Fetch

93 days of transactions from the Transactions Service; keep credits on the primary account; amounts become positive cents

2. Filter

Four passes (Filter Passes) over transactions dated before today and under $5,000; union the include passes, subtract the blacklist, keep the highest amount per calendar day

3. Gate

Total at least $3,000 and at least 3 transactions, else ErrNotEnoughIncomeData; earliest_due = today + 2 days. Survivors are captured as FM_PAYDAY_FILTERED_TXNS

4. DOW grid

Weeks x 7 amounts from the Sunday before the first transaction; per-column z-scores; per weekday count (weeks with an amount and z in [-1.5, 2.5]), vol_pct, density, computed over all weeks (global) and the last 6 (recent). Deposits named social security, vacp, or ssa treas are excluded from the grid but counted in total volume

5. DOM grid

Months x 31 amounts; per day count (months hit), vol_pct, roll_3d_pct; eom_score (hits in the last 3 days of a month), bom_score (first 3 days)

6. Predict

Phases in Prediction Phases; result captured as FM_PAYDAY_PREDICTED_PAYDAY

Source: pkg/payday/floatme_predictor.go, pkg/payday/floatme_analyzer.go

Filter Passes

Pass Rule

Non-integer amount

Amount at least $300 and not a whole dollar

Recurring name

Amount at least $800 and the cleaned name appears on at least 2 distinct dates at $800 or more

Override keyword

Cleaned name contains payroll

Blacklist (exclude)

Cleaned name contains a gambling, earned-wage-access, reversal/refund, or brokerage keyword (paydayBlacklistKeywords)

Source: pkg/payday/floatme_predictor.go

Prediction Phases

Phase Rule

0 Gate

Total volume under $3,000: no prediction

1 Anchors

DOM days with count >= 2 and vol_pct >= 0.10

2 Signals

calendar_spike: any DOM vol_pct >= 0.25. habit_unbreakable: any weekday with global vol_pct >= 0.60 or recent density >= 0.50. some_habit: any weekday with global or recent density >= 0.35

3 Calendar mode

semi_monthly: at least 2 anchors, or 1 anchor with eom_score >= 2. Force calendar mode when not habit_unbreakable and either (semi_monthly and not some_habit) or (calendar_spike and not some_habit)

4 DOW waterfall

Skipped in calendar mode. Take the best recent weekday by vol_pct; if at least 0.35, resolve it (when it differs from the best global weekday, walk recent weekdays in rank order requiring count >= 2); else the best global weekday if at least 0.35. Cadence WEEKLY when density is at least 0.60, else BIWEEKLY; payday is the last valid transaction on that weekday plus the cadence, stepped until at least earliest_due

5 DOM fallback

Requires eom_score >= 2 or an anchor. Score each day vol_pct + roll_3d_pct, anchors first. A secondary peak at least 10 days from the primary with vol_pct >= 0.10 gives SEMIMONTHLY (the earlier of the two resolved dates), else MONTHLY. Each day resolves to this or next month at or after earliest_due; weekends shift back to Friday, or forward to Monday when that day’s volume is mostly government deposits. No bank-holiday adjustment

Source: pkg/payday/floatme_analyzer.go

Output cadence is one of WEEKLY, BIWEEKLY, SEMIMONTHLY, MONTHLY (UNKNOWN is defined but never returned); predictor FLOATME_V2_PAYDAY_CALCULATION. FloatMe V2 sets only payday, frequency, and payday_cadence; the collection fields below are empty for it.

Collection Dates

Pave and FloatMe V1 fill four collection fields alongside payday, each by re-running the same algorithm with a different earliest date.

Field Earliest date used

payday

Request earliest_date (default now), floored at tomorrow

next_instant_payday

Now + 24h, plus one day when that lands after 06:00 UTC of its calendar day

next_standard_payday

Now + 96h, with the same 06:00 UTC adjustment

extended_instant_payday, extended_standard_payday

First payday after earliest_date + EXTENDED_PAYBACK_START_DAYS (28); Pave prefers a listed next_dates entry before earliest_date + EXTENDED_PAYBACK_END_DAYS (42)

Source: pkg/payday/helpers.go, pkg/payday/predictor.go

Persistence

Store Written

site-pave payday entity

Winner plus each algorithm’s output, on a cache miss — DynamoDB Tables

site-fmdatacapture

PAYDAY_PREDICTION on every evaluation; FM_PAYDAY_FILTERED_TXNS and FM_PAYDAY_PREDICTED_PAYDAY when FloatMe V2 runs — DynamoDB Tables