Fraud Detection

The Payments Service evaluates fraud signals before allowing a float or installment loan payment. These checks are designed to prevent the same user identity, device, bank account, or debit card from being used to request multiple floats in a short window.

Fraud detection is distinct from the Blocklist system: the blocklist prevents ACH payments for users whose bank accounts have returned. Fraud checks prevent new float requests based on recent activity patterns.

How Checks Work

Two endpoints support fraud detection:

  • GET /fraud-check — performs the full fraud check and returns whether the user can proceed (can_float).

  • POST /fraud-check — saves the result of a completed float (success or failure) to the fraud log for future checks.

Both are called by the float-service and subscription-service as part of the payment setup flow.

Fraud Check Flow

Caller (float-service or subscription-service)
 │  GET /fraud-check
 │  body: { user_id, install_id, float_type, account_id, zip_code }
 ▼
Payments API (Lambda)
 │
 ├─ 1. Bank account activity check
 │     query bank-accounts for duplicate hashes
 │     if too many accounts share the same hash → ErrAccountActivityHigh
 │     (debit-card checks happen in step 4, not here)
 │
 ├─ 2. Install ID check
 │     query float log for successful float in past 24h for this install_id
 │     if found → ErrInstallIDFloated
 │
 ├─ 3. User ID check
 │     query float log for successful float in past 24h for this user_id
 │     if found → error (user already floated)
 │
 └─ 4. Account / card hash check
       float_type = "PINLESS" → hash derived from debit card (masked number + expiry + zip)
       float_type = other    → hash = account_routing_hash from bank account
       query float log for successful float in past 24h for this hash
       if found → error (shared account / card detected)
 │
 ▼
Response: { can_float: true/false, error_message: "..." }

All four checks run on every GET /fraud-check call. The first check to fail returns an error and stops further evaluation.

Fraud Signals

Signal Log Key What It Detects 24h Window?

User ID

USER

Same user requesting multiple floats within 24 hours.

Yes

Install ID

INSTALL_ID

Same device installation requesting multiple floats within 24 hours. Guards against account switching on the same device.

Yes

Bank Account Hash

ACCOUNT_HASH

Same bank account (routing + account number hash) used across multiple user IDs. Detects shared bank accounts.

Yes (on successful float)

Debit Card Hash

CARD_HASH

Same debit card used across multiple user IDs. Derived from masked card number + expiry + zip. Detects shared cards.

Yes (on successful float)

Bank Account Duplicates

N/A — live query

More than MAX_SIMILAR_ACCOUNTS distinct users sharing the same account_routing_hash. Evaluated at check time, not from the log.

No (current state)

For pinless (debit card) floats, the card hash uses the masked card number + expiry + zip code. A secondary hash without zip code is also computed and checked as a monitoring signal (currently logs a warning rather than blocking).

How Results Are Stored

The fraud log is backed by DynamoDB (float-log table, accessed via DDBFloatLogRepository). Each entry records a key-value pair with a type:

Field Values Example Meaning

key

USER, INSTALL_ID, ACCOUNT_HASH, CARD_HASH

ACCOUNT_HASH

Which fraud signal this record represents.

value

Signal value (hashed or raw)

a3f9…​ (SHA-256 hash)

The identifier being checked.

type

REQUEST, SUCCESS, FAILED

SUCCESS

Stage of the float lifecycle.

The PK is HashJoinPath(key, value) and the SK is HashJoinPath(timestamp, type).

At check time, the system queries for a SUCCESS record for each signal within the past 24 hours. A successful float is only saved (via POST /fraud-check) after the float-service confirms the disbursement succeeded — not by the check endpoint itself.

Write Points

Operation What Gets Written

GET /fraud-check (check passes)

REQUEST records for user_id, install_id, and hash. Written immediately to register the pending float attempt.

POST /fraud-check with float_result = "SUCCESS"

SUCCESS records for user_id, install_id, and hash. Written by the caller after successful disbursement.

POST /fraud-check with float_result = "FAILURE"

FAILED records for user_id and hash. Written when disbursement fails.

Relationship to the Blocklist

Fraud detection and the blocklist are independent systems:

System Trigger Effect

Fraud checks

High-frequency float requests from same user/device/account/card

Blocks the current float request. Not persistent — the window expires after 24 hours.

Blocklist

ACH payment returned with a structural return code (R02, R03, R04, R16 / JPM equivalents)

Blocks all future ACH payments until the user provides a new bank account.

A blocked user (blocklist) can still pass fraud checks. A user who fails a fraud check is not added to the blocklist. The two systems do not interact.

  • Blocklist — Automatic and manual blocklisting based on ACH return codes

  • DynamoDB Tablesbank-accounts table (account_routing_hash used for duplicate detection)

  • Feature Summary — Fraud feature overview with endpoint cross-references