@borrowbetter/arpcsdk 0.2.0 → 0.4.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 +106 -12
- package/dist/index.d.mts +1154 -169
- package/dist/index.d.ts +1154 -169
- package/dist/index.js +70 -7
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +66 -8
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -19,10 +19,11 @@ pnpm add @borrowbetter/arpcsdk
|
|
|
19
19
|
import { ArpcSDK } from "@borrowbetter/arpcsdk";
|
|
20
20
|
|
|
21
21
|
const sdk = new ArpcSDK({
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
environment: "stg", // "dev" | "stg" | "prd" — drives both hosts
|
|
23
|
+
auth: {
|
|
24
|
+
username: "borrowbetter@seller.com", // OAuth client_id (also the seller_agent_email)
|
|
25
|
+
password: process.env.ARPC_OAUTH_PASSWORD!,
|
|
26
|
+
},
|
|
26
27
|
});
|
|
27
28
|
|
|
28
29
|
// The first api call authenticates automatically — it exchanges your
|
|
@@ -40,7 +41,7 @@ Every operation returns `{ status, data, headers }` and **never throws on a non-
|
|
|
40
41
|
|
|
41
42
|
Four things to hold in your head:
|
|
42
43
|
|
|
43
|
-
- **Two hosts.** Auth happens on the **OAuth host** (`POST /v1/token`, HTTP Basic). Everything else goes to the **API gateway**.
|
|
44
|
+
- **Two hosts, one switch.** Auth happens on the **OAuth host** (`POST /v1/token`, HTTP Basic). Everything else goes to the **API gateway**. You pick an `environment` and the SDK derives both — see [Environments](#environments).
|
|
44
45
|
- **The passport.** `fdr_applicant_id` is minted once at eligibility and threads through *every* subsequent call. You carry it between operations.
|
|
45
46
|
- **Automatic token lifecycle.** Every `api` call carries a bearer JWT (**~900s / 15 min TTL**) that the SDK manages for you: it exchanges your credentials on the first call, caches the token, attaches it to every request, and re-exchanges ~30s before expiry. Concurrent calls share a single exchange. See [Token caching](#token-caching) to persist or share the token.
|
|
46
47
|
- **No-throw responses.** Operations return `{ status, data, headers }`. Branch on `status` — don't wrap every call in try/catch.
|
|
@@ -74,8 +75,10 @@ sequenceDiagram
|
|
|
74
75
|
Note over App,GW: Phase 3 — lead & underwriting
|
|
75
76
|
App->>GW: createLeadSync() POST /v3/application
|
|
76
77
|
App->>GW: readLead() → patchLead() clear hard-conditions
|
|
78
|
+
App->>GW: selectProgram() POST /v1/application/program-selection
|
|
77
79
|
App->>GW: uwSubmissionV2() POST /v2/application/uw-submission/{id}
|
|
78
80
|
GW-->>App: conditional approval (disclosure email suppressed)
|
|
81
|
+
App->>GW: selectProgram() again — UW overwrites the selection
|
|
79
82
|
App->>GW: sendEmail() (only when not auto-sent)
|
|
80
83
|
|
|
81
84
|
Note over App,GW: Phase 4 — enrollment & signing
|
|
@@ -92,8 +95,8 @@ sequenceDiagram
|
|
|
92
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.
|
|
93
96
|
|
|
94
97
|
**Phase 1 — identity**
|
|
95
|
-
- `checkEligibility()` — stateless quote (no PII). Its critical job: it **mints `fdr_applicant_id
|
|
96
|
-
- `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.
|
|
97
100
|
|
|
98
101
|
**Phase 2 — program (poll it yourself)**
|
|
99
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.
|
|
@@ -101,7 +104,9 @@ sequenceDiagram
|
|
|
101
104
|
**Phase 3 — lead & underwriting**
|
|
102
105
|
- `createLeadSync()` — promotes to a real lead. Data authority shifts to FDR/Salesforce here.
|
|
103
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).
|
|
104
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.
|
|
105
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.
|
|
106
111
|
|
|
107
112
|
**Phase 4 — enrollment & signing**
|
|
@@ -119,6 +124,7 @@ sequenceDiagram
|
|
|
119
124
|
| `generateProgramV1` | `POST /v1/application/program` (202 → … → 200 poll) |
|
|
120
125
|
| `createLeadSync` | `POST /v3/application` |
|
|
121
126
|
| `readLead` / `patchLead` | `GET` / `PATCH /v1/application/{id}` |
|
|
127
|
+
| `selectProgram` | `POST /v1/application/program-selection` (commit to a payment schedule) |
|
|
122
128
|
| `uwSubmissionV2` | `POST /v2/application/uw-submission/{id}` |
|
|
123
129
|
| `sendEmail` | `POST /v1/application/send-email/{id}` (only if UW didn't auto-send) |
|
|
124
130
|
| `updateApplicationBankDetails` | `PATCH /v1/application/bank-update/{id}` |
|
|
@@ -127,11 +133,47 @@ sequenceDiagram
|
|
|
127
133
|
| `generateDra` | `POST /v2/application/{id}/dra` (DocuSign embedded signing URL) |
|
|
128
134
|
| `leadTransfer` | `POST /v1/application/lead-transfer` (retail handoff — see below) |
|
|
129
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`. (FDR's spec documents `Monthly [Regular]` and `Twice Monthly [Split]` for the latter two; neither value exists — see [Spec repairs](#spec-repairs).) 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
|
+
|
|
130
169
|
## Error handling & gotchas
|
|
131
170
|
|
|
132
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), so re-read the lead to check what actually happened.
|
|
133
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.
|
|
134
|
-
- **`ER40303` at DRA =
|
|
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. The one case with no workaround today is **`split`**, which always fails on `secondDraftDateMissing` ("Second draft date is required when draft type is split or bi-weekly"); bi-weekly passes the same check via `second_draft_date_bi_weekly`, which has no split equivalent. `bi-weekly` and `regular` complete end-to-end; reported to FDR.
|
|
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
|
+
- **Read readiness 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.
|
|
135
177
|
- **Conditional vs full approval changes the email path** — always check `banking_disclosure_email_sent` before deciding whether to call `sendEmail()`.
|
|
136
178
|
- **Program timing varies** — the poll can take 20s+; don't set a tight timeout around the loop.
|
|
137
179
|
|
|
@@ -141,9 +183,33 @@ sequenceDiagram
|
|
|
141
183
|
|
|
142
184
|
Key property: **no inactivation.** Transferring does *not* close the digital lead — both stay active, and whichever path enrolls first wins. If retail enrolls first, a later digital enroll returns a duplicate / "already enrolled" error (expected). So calling it early is safe.
|
|
143
185
|
|
|
186
|
+
## Environments
|
|
187
|
+
|
|
188
|
+
`environment` is required and selects both hosts. Note they sit on different domains — the OAuth host is `ffngcp.com`, the gateway is `fdrgcp.com`.
|
|
189
|
+
|
|
190
|
+
| `environment` | OAuth host | API gateway |
|
|
191
|
+
| --- | --- | --- |
|
|
192
|
+
| `"dev"` | `https://oauth.dev.ffngcp.com` | `https://apis-gateway-v2.dev.fdrgcp.com` |
|
|
193
|
+
| `"stg"` | `https://oauth.stg.ffngcp.com` | `https://apis-gateway-v2.stg.fdrgcp.com` |
|
|
194
|
+
| `"prd"` | `https://oauth.prd.ffngcp.com` | `https://apis-gateway-v2.prd.fdrgcp.com` |
|
|
195
|
+
|
|
196
|
+
Credentials are issued per environment — a STG client_id will not authenticate against PRD.
|
|
197
|
+
|
|
198
|
+
Point at a host the table doesn't cover — a local mock, say — by overriding either one via `urls`. Anything you omit still comes from `environment`.
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
const sdk = new ArpcSDK({
|
|
202
|
+
environment: "prd",
|
|
203
|
+
auth: { username, password },
|
|
204
|
+
urls: { gateway: "http://localhost:8080" }, // oauth still from "prd"
|
|
205
|
+
});
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
The table is exported as `ARPC_ENDPOINTS` if you need to read a host without constructing an SDK. It's frozen — it backs both host resolution and environment validation, so mutating it isn't a supported way to redirect the SDK. Use `urls` for that.
|
|
209
|
+
|
|
144
210
|
## Token caching
|
|
145
211
|
|
|
146
|
-
By default the bearer token lives in-memory, per-process — good enough for a single long-running service. Pass
|
|
212
|
+
By default the bearer token lives in-memory, per-process — good enough for a single long-running service. Pass `auth.cache` to change where it lives: persist it across restarts, or share one token across workers/instances instead of each exchanging its own.
|
|
147
213
|
|
|
148
214
|
```typescript
|
|
149
215
|
interface TokenCache {
|
|
@@ -173,13 +239,15 @@ const cache: TokenCache = {
|
|
|
173
239
|
},
|
|
174
240
|
};
|
|
175
241
|
|
|
176
|
-
const sdk = new ArpcSDK({
|
|
242
|
+
const sdk = new ArpcSDK({
|
|
243
|
+
environment: "stg",
|
|
244
|
+
auth: { username, password, cache },
|
|
245
|
+
});
|
|
177
246
|
```
|
|
178
247
|
|
|
179
248
|
## Development
|
|
180
249
|
|
|
181
250
|
```bash
|
|
182
|
-
cp .env.example .env # fill in STG credentials
|
|
183
251
|
pnpm install
|
|
184
252
|
pnpm codegen # regenerate the typed client from the OpenAPI spec
|
|
185
253
|
pnpm smoke # drive the full flow end-to-end against live STG
|
|
@@ -187,7 +255,33 @@ pnpm typecheck
|
|
|
187
255
|
pnpm build # codegen + tsup → dist/ (esm + cjs + d.ts)
|
|
188
256
|
```
|
|
189
257
|
|
|
190
|
-
|
|
258
|
+
`pnpm smoke` reads its credentials from `.env` (via dotenv-flow):
|
|
259
|
+
|
|
260
|
+
```bash
|
|
261
|
+
ARPC_OAUTH_USERNAME=...
|
|
262
|
+
ARPC_OAUTH_PASSWORD=...
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
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.
|
|
266
|
+
|
|
267
|
+
The typed client (`src/generated/`) is generated from the committed spec (`openapi/api-v2026.16.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`.
|
|
268
|
+
|
|
269
|
+
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.
|
|
270
|
+
|
|
271
|
+
### Spec repairs
|
|
272
|
+
|
|
273
|
+
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:
|
|
274
|
+
|
|
275
|
+
| Repair | FDR ships | We generate |
|
|
276
|
+
|---|---|---|
|
|
277
|
+
| `program-selection-applicant` | `ProgramSelectionRequest.applicant` typed as the full registration `Applicant` — 10 required fields incl. `ssn`, `network_token` | `ProgramSelectionApplicant`, requiring only `fdr_applicant_id` — all the endpoint actually uses |
|
|
278
|
+
| `readiness-error-envelope` | `ErrorDetails` / `ReadinessErrorResponse` deleted; UW readiness failures typed as `ARPCErrorResponse`, which has no `error_details` | Both schemas restored and the UW `400` repointed, so the readiness breakdown is typeable |
|
|
279
|
+
| `lead-draft-type-values` | `LeadApplication.draft_type` enumerates `Bi-Weekly`, `Monthly [Regular]`, `Twice Monthly [Split]` | `Bi-Weekly`, `Regular`, `Split` — the values the API actually returns, confirmed against STG by selecting each in turn |
|
|
280
|
+
| `select-program-error-responses` | `selectProgram` 401/500 → `ServerResponse`, an empty `{type: object}` | `ARPCErrorResponse`, matching every other operation; `ServerResponse` dropped |
|
|
281
|
+
|
|
282
|
+
Each patched schema carries a `⚠️ Patched locally` note in its TSDoc, so the divergence is visible at the point of use.
|
|
283
|
+
|
|
284
|
+
**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.
|
|
191
285
|
|
|
192
286
|
## Releasing
|
|
193
287
|
|