FlexPoint Docs
Engineering

Initial Closing Disclosure

Architecture of the Initial Closing Disclosure request — the additive value-object blocks, the eligibility read model, the submit flow with its three field writes and two collection syncs, the type-dispatched PDF route, and the MeridianLink field provenance.

The Initial Closing Disclosure (ICD) request is an extension of the Change of Circumstance feature, not a new subsystem. It reuses the LoanRequest model (request_type = icd), the LoanRequestDetails value object, the COC form workspace, and the PDF pipeline — and adds an eligibility pre-flight, a business-purpose block, and two MeridianLink collection syncs (Contacts and Non-Obligor Borrowers). Read the Change of Circumstance engineering page first; this page documents only the deltas.

The data contract

The COC details value object is reused unchanged for the ICD change-item body. Two optional blocks are added, populated only for ICD:

PieceLocation
Additive blocks on the root VOApp\Support\LoanRequests\LoanRequestDetails (contacts, nonObligorBorrowers)
Nested VOsApp\Support\LoanRequests\IcdContact, IcdNonObligorBorrower
Eligibility result VOApp\Support\Requests\IcdEligibility (+ IcdEligibilityCheck)
Contact-role enumApp\Enums\IcdAgentRole (label() + agentRoleCode())
Document categoryApp\Enums\DocumentCategory::InitialClosingDisclosure
Zod mirrorsapps/flex-hub/src/lib/schemas/icd-eligibility.ts, icd-request.ts, and icdAgentRole in enums.ts

The COC-only payload round-trips unchanged — the contacts / non_obligor_borrowers keys are omitted from toArray() when empty, so the SP-219 shape is byte-for-byte preserved (asserted in LoanRequestDetailsTest).

HTTP surface

Thin controllers wire FormRequest → Policy → Action → Resource. The two static ICD routes sit before the {loanRequest} wildcard so the icd segment is not captured.

# apps/flex-hub-api/routes/api.php (loan group)
GET  loans/{loan}/requests/icd/eligibility    # IcdEligibilityController@show
POST loans/{loan}/requests/icd                # IcdRequestController@submit
GET  loans/{loan}/requests/{loanRequest}/pdf  # LoanRequestController@pdf  (shared, type-dispatched)

The saved-request PDF route is type-dispatched

The shared {loanRequest}/pdf route resolves to LoanRequestController@pdf, which renders with the request type's own renderer: an ICD request renders via RenderIcdRequestPdf (so it carries the Contacts + Non-Obligor sections), everything else via CocPdfRenderer. The downloaded copy therefore always matches the copy filed to MeridianLink on submit.

Authorization is data-scoped

Every ICD endpoint authorizes view on the loan (the data-scoped LoanPolicy), in addition to the request permission, so a caller can never act on a loan outside their visibility. The cross-tenant 403 and the cross-loan 404 are both tested.

The eligibility read model

GET …/icd/eligibility (App\Actions\Requests\BuildIcdEligibility, emitted by IcdEligibilityResource) returns { eligible, is_business_purpose, message, checks[] }. It is hybrid: it reuses locally-hydrated loan columns where they exist and reads only the genuinely-missing fields live from MeridianLink with the caller's read ticket.

CheckSource
loan_statusloan.vendor_loan_status (hydrated)
lockedloan.rate_locked_at (hydrated)
initial_approval_dateloan.uw_approved_date (hydrated)
final_hazard_insurancesFinalHazInsPolicyReceivedD (live)
appraisalloan.appraisal_received_date / sSpValuationEffectiveD / sHasPropertyInspectionWaiver (PIW)
business-purposesIsExemptFromAtr (live) → is_business_purpose

A loan with no MeridianLink number, or a degraded live read, returns eligible: false with the live-dependent checks marked not-ok and a "Live loan data is unavailable" detail — it never errors. eligible is true only when every check passes and is_business_purpose is false.

The submit flow

App\Actions\Requests\SubmitIcdRequest mirrors SubmitCocRequest, MLM-first:

  1. Resolve the caller's own session ticket; a missing session fails fast with a 422 (MlmTicketExpiredException).
  2. Re-check eligibility via BuildIcdEligibility; an ineligible or business-purpose loan is rejected with a 422 (IcdNotEligibleException) and nothing is written.
  3. Render the PDF first (no side effects) so a render failure aborts before any persistence.
  4. In one DB::transaction: create the LoanRequest (status = submitted), write the three disclosure fields via LoanFieldProvider::saveFields, sync the two collections, and store the PDF as a LoanDocument.
  5. After commit, dispatch PublishLoanDocumentToMlmJob to upload the PDF.

The ICD-specific field ids live in App\Support\Mlm\MlmIcdFieldMap (sU3LStatD = today, sU3LStatN = "ICD Requested", sEstHUDOd = estimated CD date), kept out of the general loan-editor field map so they don't widen the writable whitelist.

Collection writes

App\Support\Mlm\MlmAgentCollectionMap builds the two collection payloads, both written through the existing LoanFieldProvider::saveCollection / saveCollections port (no new vendor seam):

  • Contacts → sAgentDataSet — each IcdContact becomes a record with the AgentRoleT integer from IcdAgentRole::agentRoleCode(). A contact carrying an existing record id is an edit; one without is an add (LqbTempId_*).
  • Non-Obligor Borrowers → sTitleBorrowersJsonContent — a flat JSON-stringified field (whole-field replace). It is written only when at least one borrower is present, so an existing title set is never clobbered with an empty value.

PDF rendering

App\Actions\Requests\RenderIcdRequestPdf renders resources/views/loans/icd-request-pdf.blade.php to PDF bytes via Spatie Browsershot, reusing the shared config('pricer.pdf.*') binary settings. The Blade template extends the shared COC table markup with Contacts and Non-Obligor Borrowers sections. The same renderer serves the submit path and the saved-request PDF route, so the filed and downloaded copies never drift.

PublishLoanDocumentToMlmJob uploads the document with the doc type resolved from config('vendors.edocs.doc_type_map')initial_closing_disclosure maps to CD - REQUEST FORM. Unlike COC/Doc Order, the ICD upload stamps no extra marker field (its disclosure fields are written inline at submit, not on upload).

Frontend

PieceLocation
Eligibility loaderapps/flex-hub/src/lib/server/icd-eligibility.ts (getIcdEligibility)
Gateapps/flex-hub/src/components/requests/icd-eligibility-gate.tsx
Workspaceapps/flex-hub/src/app/(app)/loans/[id]/icd-request-workspace.tsx
Step componentsicd-contacts-step.tsx, icd-non-obligor-step.tsx
Leaf componentsicd-eligibility-checklist, business-purpose-block, contacts-editor, non-obligor-borrowers-editor (under components/requests/)

The loan Requests tab loads eligibility alongside the COC form context and passes it to RequestsSection, which shows a New ICD request button. Opening it mounts IcdEligibilityGate, which renders the business-purpose block, the ineligibility checklist, or the workspace. A saved request exposes a View PDF link that opens the shared binary PDF route in a new tab.

Some MeridianLink ids are observed, not confirmed

In the MLM knowledge base, the agent-role codes lender 21, loan_officer 19, broker 26, and title 4 are confirmed (live-probe). The remaining roles (closing_agent 33, buyer_agent 32, listing_agent 6, selling_agent 7), the sU3LStatD / sU3LStatN / sEstHUDOd writes, and the sTitleBorrowersJsonContent shape are observed. Confirm them against a live probe — and record the observation — before relying on them in production.

On this page