@borrowbetter/arpcsdk 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -75,8 +75,10 @@ sequenceDiagram
75
75
  Note over App,GW: Phase 3 — lead & underwriting
76
76
  App->>GW: createLeadSync() POST /v3/application
77
77
  App->>GW: readLead() → patchLead() clear hard-conditions
78
+ App->>GW: selectProgram() POST /v1/application/program-selection
78
79
  App->>GW: uwSubmissionV2() POST /v2/application/uw-submission/{id}
79
80
  GW-->>App: conditional approval (disclosure email suppressed)
81
+ App->>GW: selectProgram() again — UW overwrites the selection
80
82
  App->>GW: sendEmail() (only when not auto-sent)
81
83
 
82
84
  Note over App,GW: Phase 4 — enrollment & signing
@@ -93,8 +95,8 @@ sequenceDiagram
93
95
  **Auth** — handled for you. The SDK authenticates on the first `api` call, caches the token, and refreshes it automatically. See [Token caching](#token-caching) to control where the token lives.
94
96
 
95
97
  **Phase 1 — identity**
96
- - `checkEligibility()` — stateless quote (no PII). Its critical job: it **mints `fdr_applicant_id`**. Registration doesn't return the id yet, so call eligibility first. *(FDR is working on making registerV2 return the id — not shipped yet.)*
97
- - `registerApplicantV2()` — writes applicant PII and **kicks off an async credit pull**, which is why Phase 2 polls.
98
+ - `checkEligibility()` — stateless quote (no PII). Its critical job: it **mints `fdr_applicant_id`**, which the rest of the flow threads through.
99
+ - `registerApplicantV2()` — writes applicant PII and **kicks off an async credit pull**, which is why Phase 2 polls. As of spec 2026.16.0 it also returns `fdr_applicant_id` (generated server-side when the request omits it), so registration-first is now viable if you don't need the quote.
98
100
 
99
101
  **Phase 2 — program (poll it yourself)**
100
102
  - `generateProgramV1()` returns `202` + a `Retry-After` while the credit pull runs, then `200` with the program. **Loop until 200**, honoring `Retry-After` (usually ~12s, occasionally 20s+). See `scripts/smoke.ts` for the reference loop.
@@ -102,7 +104,9 @@ sequenceDiagram
102
104
  **Phase 3 — lead & underwriting**
103
105
  - `createLeadSync()` — promotes to a real lead. Data authority shifts to FDR/Salesforce here.
104
106
  - `readLead()` → `patchLead()` — the lead comes back with **hard-conditions** that block underwriting. Read their ids from `readLead()`, then `patchLead()` with each set `verified_by_debt_consultant: true`.
107
+ - `selectProgram()` — commits the applicant to a payment schedule. Pick from the **lead's** `application.payment_schedule_options` (three tiers — Most Affordable / Best Value / Fastest — each with `regular`, `split` and `bi-weekly` schedules), then send the chosen `draft_type` + `estimated_monthly_payment`. See [Choosing a payment schedule](#choosing-a-payment-schedule).
105
108
  - `uwSubmissionV2()` — for these leads it comes back as **conditional approval almost every time**, which *suppresses* the automatic banking-disclosure email.
109
+ - `selectProgram()` **again** — UW submission overwrites the selection with `Bi-Weekly`. Re-send the same schedule after UW, every time. See the gotcha below.
106
110
  - `sendEmail()` — send the disclosure manually **only when UW didn't auto-send** (check `uw.data.banking_disclosure_email_sent`). Full approval auto-sends; conditional approval suppresses.
107
111
 
108
112
  **Phase 4 — enrollment & signing**
@@ -120,6 +124,7 @@ sequenceDiagram
120
124
  | `generateProgramV1` | `POST /v1/application/program` (202 → … → 200 poll) |
121
125
  | `createLeadSync` | `POST /v3/application` |
122
126
  | `readLead` / `patchLead` | `GET` / `PATCH /v1/application/{id}` |
127
+ | `selectProgram` | `POST /v1/application/program-selection` (commit to a payment schedule) |
123
128
  | `uwSubmissionV2` | `POST /v2/application/uw-submission/{id}` |
124
129
  | `sendEmail` | `POST /v1/application/send-email/{id}` (only if UW didn't auto-send) |
125
130
  | `updateApplicationBankDetails` | `PATCH /v1/application/bank-update/{id}` |
@@ -128,11 +133,48 @@ sequenceDiagram
128
133
  | `generateDra` | `POST /v2/application/{id}/dra` (DocuSign embedded signing URL) |
129
134
  | `leadTransfer` | `POST /v1/application/lead-transfer` (retail handoff — see below) |
130
135
 
136
+ ## Choosing a payment schedule
137
+
138
+ `payment_schedule_options[]` appears on the eligibility, program and lead applications. Each entry is a tier (`evaluation_type`: `Most Affordable` / `Best Value` / `Fastest`) carrying `program_length`, `program_cost`, and a `schedules[]` breakdown by draft frequency (`regular`, `split`, `bi-weekly`) with `draft_deposit` and `estimated_monthly_payment`.
139
+
140
+ ```typescript
141
+ const lead = await sdk.api.readLead(faid);
142
+ const tier = lead.data.application?.payment_schedule_options
143
+ ?.find((o) => o?.evaluation_type === "Best Value");
144
+ const schedule = tier?.schedules?.find((s) => s.draft_type === "bi-weekly");
145
+ if (!schedule) throw new Error("Best Value / bi-weekly not offered");
146
+
147
+ await sdk.api.selectProgram({
148
+ applicant: { fdr_applicant_id: faid },
149
+ draft_type: schedule.draft_type, // "bi-weekly"
150
+ estimated_monthly_payment: schedule.estimated_monthly_payment,
151
+ });
152
+ ```
153
+
154
+ Two things to know:
155
+
156
+ - **Select from the lead's options, not the quote's.** Eligibility and `generateProgramV1()` return pre-lead estimates. `createLeadSync()` submits the full income/expense picture and FDR **recalculates the program**, so the lead's tiers differ — in a smoke run, `Best Value` moved from `$380/mo` to `$560/mo`. Pick after the lead exists.
157
+ - **Request and response speak different vocabularies.** You send `draft_type: "bi-weekly" | "regular" | "split"`; the lead reports a capitalised label back on `application.draft_type` — `Bi-Weekly`, `Regular`, `Split`. (Through 2026.16.0 the spec documented `Monthly [Regular]` and `Twice Monthly [Split]` for the latter two, neither of which the API returns; 16.1 corrected the enum. The two vocabularies still differ, so the mapping stays.) The SDK exports the mapping so you don't hand-roll it:
158
+
159
+ ```typescript
160
+ import { DRAFT_TYPE_CODE, DRAFT_TYPE_LABEL, draftTypeOf } from "@borrowbetter/arpcsdk";
161
+
162
+ DRAFT_TYPE_LABEL["bi-weekly"]; // "Bi-Weekly" — what the lead will report
163
+ DRAFT_TYPE_CODE.Regular; // "regular" — what selectProgram accepts
164
+ draftTypeOf(lead.data.application); // "bi-weekly" | undefined
165
+ ```
166
+
167
+ `draftTypeOf()` closes the round-trip: read a lead, get a value you can hand straight back to `selectProgram`. It returns `undefined` for a lead with no selection *and* for a label we don't recognise (the server can ship a new picklist value before we've regenerated).
168
+
131
169
  ## Error handling & gotchas
132
170
 
133
171
  - **Branch on `res.status`, not exceptions.** A gated call returns e.g. `{ status: 400, data: { error_code: "ER40301", … } }`.
172
+ - **UW submission rewrites the program selection to `Bi-Weekly`.** Not a clear — an overwrite, with the draft amount changed to match. Observed on both `regular` and `split`. Always call `selectProgram()` again after `uwSubmissionV2()`. It's invisible if you selected bi-weekly in the first place, which is why it's easy to miss. Note the UW response's own `application` is a thin projection (identity + status, no program fields — typed as `UwSubmissionV2Application` since 2026.16.1), so re-read the lead to check what actually happened.
134
173
  - **`ER40301` at UW is often transient** — the readiness check hasn't caught up with the condition-verify you just did (live eventual consistency). Retry the UW submission after a short delay before treating it as terminal.
135
- - **`ER40303` at DRA = missing draft/program dates** — a known FDR-side gap: the `dra` gate can reject on empty draft/program dates that no request field sets (they're backend-derived). Not a bug in your call.
174
+ - **`ER40303` at DRA = the `dra` readiness gate rejected.** Read `error_details[]` for the specific field. The draft/program dates it checks are backend-derived — no request field sets them — so they're populated by `selectProgram()` and `createProgramSummaryTask()` upstream rather than by the DRA call itself. **`split` needs one extra call.** It fails the `secondDraftDateMissing` check ("Second draft date is required when draft type is split or bi-weekly") unless you `patchLead()` a `second_draft_date_split` **after UW submission and before generating the DRA** — added in 2026.16.1 in response to our report. Bi-weekly needs nothing: `second_draft_date_bi_weekly` is populated automatically at UW approval, and there is no patch field for it. `bi-weekly` and `regular` are what the smoke run exercises end-to-end; the `split` patch is documented but not yet driven by it.
175
+ - **Hardship and goal fields are UW-required despite being spec-optional.** `CreateLeadRequest` marks nothing required, and lead creation happily returns `200` without them — then UW fails with `ER40301` / `HARDSHIP_CHECK_FAILED`. Empirically the gate needs **all three** of `hardship_category`, `hardship_category_other` and `goal_category`; `goal_category_other` is genuinely optional. Note `hardship_category_other` is required even when the category isn't `Other`, contradicting its own description — reported to FDR. Also don't trust the `field` on that error: it has been observed naming a field that *was* supplied.
176
+ - **Readiness gates return the breakdown on `error_details`.** Since 2026.16.1 `ARPCErrorResponse.error_details` is a typed `ErrorDetails` (`checks[]`, `is_program_refresh_required`, `is_uw_approved`, `message`), so `ER40301` at UW and `ER40303`/`ER40305` at DRA are readable without a cast. Failures are documented as **`400`**; the `422` is now a separate generic `ER42200` for seller-config/upstream failures.
177
+ - **Read per-check failures off `error_details[]`, not `errors[]`.** Each `ReadinessCheck` now carries structured entries with a stable `reason_code` (`REQUIRED`, `DUPLICATE_EMAIL`, `CONDITION_NOT_VERIFIED`, `DEBT_TOTAL_MISMATCH`, …) and a dotted `field` path. `errors[]` is free text kept for compat — its wording changes.
136
178
  - **Conditional vs full approval changes the email path** — always check `banking_disclosure_email_sent` before deciding whether to call `sendEmail()`.
137
179
  - **Program timing varies** — the poll can take 20s+; don't set a tight timeout around the loop.
138
180
 
@@ -210,6 +252,8 @@ const sdk = new ArpcSDK({
210
252
  pnpm install
211
253
  pnpm codegen # regenerate the typed client from the OpenAPI spec
212
254
  pnpm smoke # drive the full flow end-to-end against live STG
255
+ pnpm smoke <user-id> # ... as a specific test identity (see scripts/test-users.ts)
256
+ pnpm smoke --force-hard-conditions # ... auto-verifying hard-conditions (test-only shortcut)
213
257
  pnpm typecheck
214
258
  pnpm build # codegen + tsup → dist/ (esm + cjs + d.ts)
215
259
  ```
@@ -223,7 +267,24 @@ ARPC_OAUTH_PASSWORD=...
223
267
 
224
268
  These are the smoke script's, not the SDK's — the library never reads `process.env`. The script pins `environment: "stg"` in code rather than taking it from `.env`: it drives a real enrollment against the canned STG test identity, so it must not be pointable at PRD.
225
269
 
226
- The typed client (`src/generated/`) is generated from the committed spec (`openapi/api-v2026.15.0.json`) and is not checked in — `pnpm codegen` (run automatically by `build`) reproduces it. To move to a newer spec, drop the JSON in `openapi/` and update `input.target` in `codegen.ts`.
270
+ The typed client (`src/generated/`) is generated from the committed spec (`openapi/api-v2026.16.1.json`) and is not checked in — `pnpm codegen` (run automatically by `build`) reproduces it. To move to a newer spec, drop the JSON in `openapi/` and update `input.target` in `codegen.ts`.
271
+
272
+ The spec's `Authentication` tag is excluded from codegen (`input.filters`): it documents `POST /v1/token`, which lives on the OAuth host with HTTP Basic and a form-encoded body, while every generated operation runs through the ky mutator and targets the gateway. `src/auth.ts` owns that exchange.
273
+
274
+ ### Spec repairs
275
+
276
+ The vendored spec is byte-identical to what FDR published. Defects we've reported to them are patched at codegen time by `openapi/repairs.ts` (wired in as orval's `input.override.transformer`), so **the generated types intentionally differ from FDR's published docs** in these places:
277
+
278
+ | Repair | FDR ships | We generate |
279
+ |---|---|---|
280
+ | `select-program-error-responses` | `selectProgram` 401/500 → `ServerResponse`, an empty `{type: object}` | `ARPCErrorResponse`, matching every other operation; `ServerResponse` dropped |
281
+ | `program-selection-applicant-id` | `ProgramSelectionApplicant.fdr_applicant_id` optional, so `{ applicant: {} }` type-checks and 400s at runtime | the one field the endpoint reads, marked `required` |
282
+
283
+ 2026.16.1 fixed three of the four repairs this file carried against 16.0 — the `draft_type` response enum, the `ProgramSelectionRequest.applicant` reference, and the readiness error envelope (`ARPCErrorResponse.error_details`). Their guards fired on the version bump and the repairs were deleted, which is the mechanism working as designed. Note `ReadinessErrorResponse` is no longer exported: readiness detail now hangs off `ARPCErrorResponse.error_details` as FDR's own `ErrorDetails`.
284
+
285
+ Each patched schema carries a `⚠️ Patched locally` note in its TSDoc, so the divergence is visible at the point of use.
286
+
287
+ **Repairs are designed to expire loudly.** Every one asserts the defect is still present before patching, and `pnpm codegen` fails with an actionable message if it isn't — so a fix on FDR's side breaks the build instead of leaving a dead patch behind. There's also a version gate: bumping the spec throws until each repair has been re-validated and `VERIFIED_AGAINST` is updated. That speed bump is deliberate; don't downgrade it to a warning.
227
288
 
228
289
  ## Releasing
229
290