Employment & Income
The employment domain covers the user’s employment record in RDS, manual income verification in DynamoDB, transaction-based income detection, and the cleanup that runs when a user connects a new bank institution. Endpoint shapes are in API Specification; storage in PostgreSQL Schema and DynamoDB Tables.
Employment Records
POST and PUT /{user_id}/insights/employment behave identically: both insert a new RDS row, and the highest id becomes the current record.
-
Validate the body; failure returns
400. -
When the Pave allow list is non-empty, read the previous record’s pay frequency (
pgx.ErrNoRowsis a known empty value; any other read error marks it unknown). -
Insert the row; failure returns
500. -
Delete the cached payday prediction (Same-Day Cache).
-
Run the Pave Re-mine on Eligibility check when the previous value is known.
-
Delete the user’s income verification row and call Underwriting
Recalculation; both failures are logged only. -
Return
201.
DELETE /{user_id}/insights/employment deletes every row for the user and the cached payday; returns 200.
GET /{user_id}/insights/employment returns the current record with its income verification status and a fresh payday prediction (404 with no record, 500 if prediction fails). GET /{user_id}/insights/v2/employment returns the record and verification as lists, empty when absent, without predicting.
Source: pkg/api/employment.go
Pave Re-mine on Eligibility
When an employment write moves a user from an ineligible to an eligible pay frequency under the pay-frequency gate, their transactions have never reached Pave. The API then calls the Transactions Service re-mine endpoint (POST /{user_id}/transactions/items/{item_id}/remine) for each active item, which republishes the user’s transactions onto the Kinesis stream the feeder consumes.
| Condition | Re-mine |
|---|---|
Gate flag off for the user |
No |
New pay frequency not in the allow list |
No |
Previous pay frequency already in the allow list |
No |
Previous pay frequency unknown (RDS read error) |
No |
Otherwise |
Yes; metric |
Source: pkg/api/employment.go (remineOnPaveEligible)
Income Verification
A manual verification is a single income_verification row per user, reviewed out of band by Ollie or MX.
| Endpoint | Behaviour |
|---|---|
|
|
|
Returns the row; |
|
Called by Ollie and the admin API. Sets |
Source: pkg/api/income.go
Income Detection
POST /{user_id}/insights/employment/detect finds payroll-like transaction groups for onboarding. It always returns 200; failures are encoded in the body (outcome, error_code, low_txns_detected).
-
Fetch 3 months of main-account transactions from the Transactions Service, retrying on
PRODUCT_NOT_READY(backoff 0, 1, 3, 5, 10 s). Failure:outcome: not_found,error_code: NO_TRANSACTIONS,low_txns_detected: true. -
low_txns_detectedis true when there are fewer than 5 distinct transaction dates. -
Run all five methods and write the full result set to
site-incomedetection(schema). -
Merge: C and D, plus the ML income predictions, plus A if non-empty else B.
-
Order
payroll: the merged results first, then the credits the ML model scored but did not predict as income, each group by descending amount (ties: newest date, then name). Drop credits smaller thanMIN_DETECTED_INCOME_AMOUNT, and drop ML-scored credits whose date and amount match a merged result. Empty:not_foundwith noerror_code; any other detector error:error_code: DETECTION_FAILED.
| Method | Tag | Rule |
|---|---|---|
A |
|
Credits at or below |
B |
|
Credits at or below |
C |
|
Credits at or below |
D |
|
Credits whose name matches a gig-platform list and no exclusion term; no amount floor |
ML |
|
SageMaker endpoint over all credits; always invoked when there are transactions. The model returns every credit it scored with a per-transaction prediction: credits predicted as income ( |
Source: pkg/employment/detector.go
Transactions are bucketed by cleaned name with Jaro-Winkler similarity above 0.9. Metrics: insight.employment.detection.success tagged success_reason:<tag>, and insight.employment.detection.failure.
| API env var | prod | test |
|---|---|---|
|
-200 |
-200 |
|
-5 |
-5 |
|
-150 |
-150 |
|
0.8 |
0.8 |
|
6 |
12 |
|
0.5 |
0.75 |
|
2 |
1.25 |
Source: deploy/lambda.tf, deploy/main.tf, deploy/variables.tf
Remediation Triggers (v2)
POST /{user_id}/insights/v2/employment/detect is the onboarding v4 variant. It fetches the same transactions and runs the same detection, but payroll is only the merged results (step 4, ordered and filtered as in step 5); the credits the ML model did not predict as income are not returned. It also returns remediation_triggers, the signals that the connected bank account is unlikely to support a float, in the order below. Window: the 30 days before today; income triggers include today, transaction triggers do not.
| Trigger | Fires when |
|---|---|
|
|
|
|
|
No main-account transactions in the window. Excludes the two triggers below |
|
At most |
|
The window’s transactions fall on at most |
Failures: error_code: NO_TRANSACTIONS evaluates no triggers; error_code: DETECTION_FAILED evaluates only the transaction triggers. Each request logs income remediation triggers with the outcome and remediation_triggers.
Source: pkg/api/employment.go (DetectEmploymentV2)
Results Log
Each run logs income detection results once, after the response is built. Every transaction appears once, in returned or dropped; the per-method lists and the model’s raw arrays are stored in the site-incomedetection row, not logged.
| Field | Content |
|---|---|
|
The response list in order. Each credit carries |
|
Credits a method found, or the model scored, that are not returned, in the same shape plus |
|
Sizes of the merged group, the appended model-rejected group, and |
|
Transactions each method found, keyed by tag |
|
The model invocation’s ID, shared with the |
Source: pkg/employment/detector.go (logDetectionResults)
Institution Change
site-insight-institution-change-handler consumes new_bank_account_added ({user_id, account_id}) tapped from EventBridge — see Events. Per message, in order:
-
Delete the
income_verificationrow. -
Delete the user’s RDS employment rows.
-
Delete the cached payday prediction (failure logged only).
A missing user_id or account_id, or a failure in steps 1 or 2, fails the batch item; the queue’s maxReceiveCount of 1 sends it straight to the DLQ (SQS Queues). Source: pkg/institution-change-handler/handler.go