FlexPoint Docs
Engineering

Change of Circumstance

Architecture of the Change of Circumstance request — the typed details value object, the form-context read model, the submit/PDF flow, the documentation-only MeridianLink writes, and the binary PDF routes.

The Change of Circumstance (COC) request is an extension of the loan-request graph, not a new subsystem. It reuses the existing LoanRequest model (request_type = coc) and its lifecycle, and adds a typed payload, a form-context read model, a submit action, and server-rendered PDF.

Documentation-only writes

Submitting writes only three things to MeridianLink: sU3LStatD (disclosure-request date), sU3LStatN (comment "COC Requested"), and the uploaded PDF tagged with custLoan593. The per-field "new values" are captured in the PDF + the local loan_requests.details record only — never pushed as field edits. This is intentional; applying the changes stays manual for now.

The data contract

loan_requests.details is cast to a typed value object — never a bare array — so the structured payload is type-safe end to end (FormRequest validates on write, the cast guarantees the shape, the Resource emits toArray(), Zod re-validates at the BFF edge).

PieceLocation
Root value objectApp\Support\LoanRequests\LoanRequestDetails
Nested VOsCocChangedItem, CocCreditScoreChange, CocExistingFee, CocAdditionalFee, CocCompensation (same namespace)
CastApp\Casts\AsLoanRequestDetails
Reason enumApp\Enums\ComplianceCocCategory (8 codes + code() / label() / options())
Zod mirrorapps/flex-hub/src/lib/schemas/coc-request.ts (loanRequestDetailsSchema, cocFormContextSchema)

The payload holds reason_category, reason_detail, requested_date, requested_by_name, an array of generic changed_items (key / label / current_value / new_value), per-borrower credit_scores, a fees (existing + additional) block, and a compensation (source + current + new) block.

HTTP surface

Thin controllers wire FormRequest → Policy → Action → Resource. All four routes sit before the {loanRequest} wildcard so the static coc segment is not captured.

# apps/flex-hub-api/routes/api.php (loan group)
GET  loans/{loan}/requests/coc/form-context        # CocFormContextController@show
POST loans/{loan}/requests/coc                     # CocRequestController@submit
POST loans/{loan}/requests/coc/preview-pdf         # CocRequestController@previewPdf  (no persist)
GET  loans/{loan}/requests/{loanRequest}/pdf       # CocRequestController@pdf

Authorization is data-scoped

LoanRequestPolicy is permission-only, so every COC endpoint also authorizes view on the loan (the data-scoped LoanPolicy, which ANDs the permission with the per-user visibility scope). A caller can never act on a loan outside their visibility — the cross-tenant 403 is tested.

Form context (the read model)

GET …/coc/form-context (App\Actions\Requests\BuildCocFormContext) assembles what the form pre-fills with: current values (rate, amount, occupancy, property type, purpose, appraised value, program, term, doc type), per-borrower credit scores, fees over $0, current compensation, the reason categories, and the enum-backed option lists for occupancy / property type / loan purpose.

Compensation detail values (percent / flat / total) aren't stored as columns, so they are read on demand from MeridianLink with the caller's read ticket — best-effort: a missing session or a still-provisional field id degrades to null, never an error. The program / term / doc-type option lists are supplied client-side from the loan-program catalog, so they are intentionally absent from this payload.

The submit flow

App\Actions\Requests\SubmitCocRequest runs MLM-first, mirroring SaveMlmLoanFields:

  1. Render the PDF first (no side effects) so an environment/render failure aborts before anything is persisted.
  2. If the loan is MeridianLink-linked, resolve the caller's own session ticket up front — a missing session fails fast with a 422.
  3. In one DB::transaction: create the LoanRequest (status = submitted), write the two disclosure flat fields via LoanFieldProvider::saveFields (a vendor throw rolls the whole transaction back), and store the PDF as a LoanDocument.
  4. After commit, dispatch PublishLoanDocumentToMlmJob, which uploads the PDF and — on a successful Change-of-Circumstance upload — stamps custLoan593 with the upload date via a follow-up saveFields (best-effort; a marker failure must never re-upload the document).

The COC-specific MeridianLink field ids live in App\Support\Mlm\MlmCocFieldMap (kept out of the general loan-editor field map so they don't widen the writable whitelist).

The MLM ids are PROVISIONAL

sU3LStatD, sU3LStatN, custLoan593, and the compensation read ids come from the SP-219 ticket and are recorded in the MLM knowledge base as provisional. Confirm them against a live probe (and record the observation) before relying on them in production.

PDF rendering

App\Services\LoanRequests\CocPdfRenderer renders resources/views/loans/coc-request-pdf.blade.php to PDF bytes via Spatie Browsershot (headless Chromium), reusing the shared config('pricer.pdf.*') binary settings. The same renderer serves both the draft preview and the uploaded copy, so they can never drift.

Because the generic /api/v1/[...path] BFF proxy reads upstream bodies as text (which corrupts binary), the PDF is served through dedicated binary route handlers that stream arrayBuffer():

RoutePurpose
apps/flex-hub/src/app/api/v1/loans/[id]/requests/coc/preview-pdf/route.tsPOST the draft → preview PDF
apps/flex-hub/src/app/api/v1/loans/[id]/requests/[requestId]/pdf/route.tsGET a saved request's PDF

Frontend

PieceLocation
Form-context loaderapps/flex-hub/src/lib/server/coc-request.ts (getCocFormContext)
Workspaceapps/flex-hub/src/app/(app)/loans/[id]/coc-request-workspace.tsx
Value table (generic rows)apps/flex-hub/src/components/requests/coc-value-table.tsx
Leaf componentsppt-picker, compensation-calculator, additional-fees-editor, credit-score-warning (under components/requests/)

The workspace is mounted from the Requests tab (requests-section.tsx shows a New COC request button when form-context is available). It assembles the request-info section, the changed-items grid, the value table, and the structured rows; Submit POSTs the details payload; Preview PDF POSTs the same draft to preview-pdf and opens the returned blob.

Reuse for the Initial Closing Disclosure

The Initial Closing Disclosure (ICD) request reuses this contract, form, and PDF pipeline — the same LoanRequestDetails value object and workspace, parameterized by request type — so the two request forms stay consistent.

On this page