/** * payment-estimate-api.ts * * Types + two tiny fetch helpers for the public Monthly Payment API * (https://payment-estimate-api.pages.dev). No runtime dependencies. * * GET /v1/{brand}/options -> OptionsResponse (the two customer questions, lenders, disclosures) * POST /v1/{brand}/estimate -> EstimateResponse (loan + rent-to-own payments, Apply links, disclosures) * * Guide + display rules: https://payment-estimate-api.pages.dev/ * Live test page: https://payment-estimate-api.pages.dev/test/ * * Every value is pre-computed by the API (rates, eligibility, payments, Apply links, * disclosure text). Display what comes back; do not recompute or retype it. * Types were checked against live responses on 2026-10-13 (apiVersion "2026-10-13"), * re-captured after the same-day API update (loans.notice, upfrontPaymentDisplay, † footnote). */ export const PAYMENT_ESTIMATE_API_BASE = 'https://payment-estimate-api.pages.dev'; export const PAYMENT_ESTIMATE_GUIDE_URL = 'https://payment-estimate-api.pages.dev/'; /* ------------------------------------------------------------------ */ /* Shared pieces */ /* ------------------------------------------------------------------ */ /** Brand slug in the URL. Only "stoltzfus" is live today. */ export type Brand = 'stoltzfus' | (string & {}); /** Loan lender keys. Rent-to-own is reported separately under `rentToOwn`. */ export type LoanLenderKey = 'upgrade' | 'thrive'; /** Small print shown under an Apply button. `html` only contains ///. */ export interface ApplyHint { text: string; html: string; } /** Where the API read its rules from (our published estimator). */ export interface RulesFrom { estimator: string; estimatorVersion: string; readAt: string; source: string; } /* ------------------------------------------------------------------ */ /* Disclosures */ /* ------------------------------------------------------------------ */ export interface DisclosureParagraph { text: string; /** Sanitized server-side: only , , , (and HTML entities). */ html: string; /** Present on `disclosures.all[]` paragraphs only. */ products?: string[]; } /** A block in `disclosures.forThisWidget[]` — render every one, in order. */ export type DisclosureBlock = | { type: 'paragraph'; id: string | null; title: string | null; titleHtml: string | null; paragraphs: DisclosureParagraph[]; } | { type: 'heading'; text: string } | { type: 'section'; id: string; title: string; /** HTML-escaped title (e.g. "&"); may contain /. */ titleHtml: string; paragraphs: DisclosureParagraph[]; }; /** A block in `disclosures.all[]` (the estimator's whole footer, incl. products this widget doesn't offer). */ export type DisclosureCatalogBlock = | { type: 'paragraph'; id: string | null; products: string[]; paragraphs: DisclosureParagraph[] } | { type: 'heading'; text: string } | { type: 'section'; id: string; title: string; titleHtml: string; products: string[]; paragraphs: DisclosureParagraph[]; } | { type: 'company'; id: string | null; products: string[]; paragraphs: DisclosureParagraph[] }; export interface Footnote { /** "1", "*", "2", "3" … */ marker: string; /** How the marker is printed: "¹", "*", "²", "³" … */ display: string; disclosureId: string; title: string | null; appliesToThisWidget: boolean; /** Developer note, e.g. on the "†" (interest-only) footnote. Not customer copy. */ note?: string; } export interface Disclosures { source: string; forThisWidget: DisclosureBlock[]; all: DisclosureCatalogBlock[]; footnotes: Footnote[]; notALender: string; representativeExample: string; rentToOwnStructures: string; } /* ------------------------------------------------------------------ */ /* GET /v1/{brand}/options */ /* ------------------------------------------------------------------ */ export interface QuestionOption { value: string; label: string; } export interface Question { field: 'creditScore' | 'ownProperty'; label: string; placeholder: string; helpText: string; options: QuestionOption[]; } export interface LenderProgram { lender: string; product: string; minCreditScore: number; minAmount: number; maxAmount: number; requiresPropertyOwnership: boolean; notOfferedInStates: string[]; comingSoon: boolean; applyUrl: string; applyHint: ApplyHint; } export interface OptionsResponse { ok: true; apiVersion: string; brand: string; brandName: string; rulesFrom: RulesFrom; questions: { creditScore: Question; ownProperty: Question; }; /** Plain-English description of each estimate input. */ inputs: Record<'zip' | 'price' | 'creditScore' | 'ownProperty', string>; lenders: { upgrade: LenderProgram; thrive: LenderProgram; rto: LenderProgram; }; disclosures: Disclosures; displayRules: string[]; } /* ------------------------------------------------------------------ */ /* POST /v1/{brand}/estimate */ /* ------------------------------------------------------------------ */ /** Test-only switch the API understands (query string or JSON body). */ export type Simulate = 'unavailable' | 'rto_unavailable' | 'invalid' | 'slow'; export interface EstimateRequest { /** 5-digit delivery ZIP (string, keeps leading zeros). */ zip: string; /** Total price in dollars, > 0. */ price: number; /** One of options.questions.creditScore.options[].value */ creditScore: string; /** One of options.questions.ownProperty.options[].value */ ownProperty: string; /** Testing only — never send in production. */ simulate?: Simulate; } export type EstimateField = 'zip' | 'price' | 'creditScore' | 'ownProperty'; /** Interest-only months 1–6 payments (Thrive Building Loan). */ export interface InterestOnlyPayments { halfReleased: number; halfReleasedDisplay: string; fullReleased: number; fullReleasedDisplay: string; } /** Fields shared by every loan payment row. */ export interface LoanPaymentBase { lender: LoanLenderKey; years: number; months: number; /** "5-Year", or e.g. "12-Year Offer" for a counter-offer term. */ label: string; amount: number; apr: number; /** Keep exactly as given, incl. the "*" — e.g. "7.99% APR*". */ aprDisplay: string; monthlyPayment: number; /** e.g. "$372" — always show next to aprDisplay and the term. */ monthlyPaymentDisplay: string; interestOnly: boolean; /** "†" on interest-only terms, otherwise "". Keep it next to the payment. */ footnote: string; paymentNote: string; /** Thrive only. */ estimatorLabel?: string; /** Thrive only. */ counterOffer?: boolean; /** Thrive only (interest-only terms). */ interestOnlyMonths?: number; /** Thrive only (interest-only terms). */ interestOnlyPayments?: InterestOnlyPayments; } /** One lender's offer for a term — show these (with lender names) at the Apply step. */ export interface LoanOffer extends LoanPaymentBase { lenderName: string; product: string; applyUrl: string; /** Point the Apply button here (it counts the click, then forwards to the lender). */ applyLink: string; applyOpensNewTab: boolean; applyHint: ApplyHint; } /** One payment choice per term. Do NOT name the lender on this row. */ export interface LoanTerm extends LoanPaymentBase { /** Lender key whose numbers this row shows. */ bestLender: LoanLenderKey; /** Every lender offering this term. */ offers: LoanOffer[]; } /** A lender's own term rows (in `loans.byLender[x].terms`). */ export type LenderTerm = LoanPaymentBase; export interface LenderStatus { lender: string; product: string; eligible: boolean; comingSoon: boolean; /** e.g. "credit_score", "property_ownership", "amount_below_minimum" (null when eligible). */ reason: string | null; reasonText: string | null; applyUrl: string; applyLink: string; applyOpensNewTab: boolean; applyHint: ApplyHint; terms: LenderTerm[]; /** True when the price exceeds this lender's max (always false when ineligible). */ overMax: boolean; /** e.g. "Over the $100,000 max — payments shown are for $100,000" (null when not over max). */ overMaxText: string | null; } export interface Loans { available: boolean; /** Pre-select this term (null when no loans). */ defaultTermYears: number | null; terms: LoanTerm[]; byLender: { upgrade?: LenderStatus; thrive?: LenderStatus; }; /** Text to show when available = false. */ message: string | null; /** * Set when no lender can cover the full price: `terms` then show the ONE eligible lender * that covers the most, at its capped amount. Show this next to the loan payments. * e.g. "Over the $200,000 max — payments shown are for $200,000" */ notice: string | null; } export interface RentToOwnTerm { months: number; label: string; monthlyPayment: number; /** e.g. "$1,048.77". Never pair with an APR or rate — it's a lease. */ monthlyPaymentDisplay: string; /** Dollars due upfront. */ upfrontPayment: number; /** e.g. "$3,364.00" — display this. */ upfrontPaymentDisplay: string; securityDeposit: number; customerReserveAccount: number; deliveryFee: number; taxIncluded: boolean; } export interface RentToOwn { available: boolean; /** False for state/ZIP/amount reasons; true (with available=false) when only the live quote failed — the customer can still apply. */ eligible: boolean; provider: string; product: string; creditCheck: boolean; /** Amount the payments are for (capped at the RTO max); null when unavailable. */ amount: number | null; defaultTermMonths: number | null; terms: RentToOwnTerm[]; overMax: boolean; overMaxText: string | null; notes: string[]; applyUrl: string; applyLink: string; applyOpensNewTab: boolean; applyHint: ApplyHint; /** e.g. "state", "zip_not_supported", "amount_below_minimum", "quote_unavailable". */ reason: string | null; reasonText: string | null; /** Present when the live rent-to-own quote failed. */ quoteError?: string; } export interface EstimateInput { zip: string; /** Two-letter state (typed nullable defensively; ZIPs that map to no state are now rejected with 400). */ state: string | null; price: number; creditScore: string; creditScoreLabel: string; ownProperty: string; ownPropertyLabel: string; } export interface EstimateResponse { ok: true; apiVersion: string; brand: string; rulesFrom: RulesFrom; input: EstimateInput; loans: Loans; rentToOwn: RentToOwn; disclosures: Disclosures; displayRules: string[]; /** Echoed when a `simulate` value was sent. */ simulated?: Simulate; } /* ------------------------------------------------------------------ */ /* Errors */ /* ------------------------------------------------------------------ */ export type ApiErrorCode = | 'invalid_input' // 400 — show `fields` messages as-is (written for customers) | 'invalid_json' // 400 — malformed POST body | 'estimates_unavailable' // 503 — hide payments, show a short neutral note | 'brand_not_enabled' // 404 | (string & {}); /** Error body returned by the API (HTTP 400 / 404 / 503). */ export interface ApiError { ok: false; error: ApiErrorCode; fields?: Partial>; message?: string; simulated?: Simulate; liveBrands?: string[]; } /** Helper result: never throws. Client-side failures use status 0. */ export type ApiResult = | { ok: true; data: T } | { ok: false; /** HTTP status, or 0 for network/CORS/parse failures and aborts. */ status: number; /** API error code, or 'network_error' | 'bad_response' | 'aborted' for client-side failures. */ error: ApiErrorCode | 'network_error' | 'bad_response' | 'aborted'; fields?: Partial>; message?: string; }; export interface RequestOptions { signal?: AbortSignal; /** Override the API base URL (default https://payment-estimate-api.pages.dev). */ base?: string; } /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ function trimBase(base?: string): string { return (base || PAYMENT_ESTIMATE_API_BASE).replace(/\/+$/, ''); } function isAbort(err: unknown): boolean { return typeof err === 'object' && err !== null && (err as { name?: string }).name === 'AbortError'; } async function request(url: string, init: RequestInit): Promise> { let res: Response; try { res = await fetch(url, init); } catch (err) { return isAbort(err) ? { ok: false, status: 0, error: 'aborted' } : { ok: false, status: 0, error: 'network_error', message: 'Could not reach the payment estimate service.' }; } let body: unknown; try { body = await res.json(); } catch (err) { if (isAbort(err)) return { ok: false, status: 0, error: 'aborted' }; body = null; } const obj = (body && typeof body === 'object' ? body : null) as | (Omit, 'ok'> & { ok?: unknown }) | null; if (res.ok && obj && obj.ok === true) return { ok: true, data: obj as unknown as T }; if (obj && obj.ok === false && typeof obj.error === 'string') { return { ok: false, status: res.status, error: obj.error, fields: obj.fields, message: obj.message }; } return { ok: false, status: res.status, error: res.status === 503 ? 'estimates_unavailable' : 'bad_response', message: 'Unexpected response from the payment estimate service.', }; } /** GET /v1/{brand}/options — the two questions (labels, placeholders, help text, values), lenders, disclosures. */ export function getOptions(brand: Brand = 'stoltzfus', base?: string, signal?: AbortSignal): Promise> { return request(`${trimBase(base)}/v1/${encodeURIComponent(brand)}/options`, { method: 'GET', headers: { Accept: 'application/json' }, signal, }); } /** POST /v1/{brand}/estimate — loan + rent-to-own payments for one structure. Never throws. */ export function getEstimate( brand: Brand, req: EstimateRequest, opts: RequestOptions = {}, ): Promise> { return request(`${trimBase(opts.base)}/v1/${encodeURIComponent(brand)}/estimate`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(req), signal: opts.signal, }); }