Skip to main content

FUND-1918 — Migration duplication + missing underwriting: findings & fix scope

Status: investigation (no production data writes). Internal design note — not part of the Mintlify docs site (docs/docs.json). Source: Miranda Antonio (New Day For You Financial) 30-min strategy call 2026-05-05; resurfaces canceled FUND-1240. Related: FUND-1166 (auth-link redirect, Done).

Reported symptoms

  1. During the Zoho → CRM data migration, notes and funding data are not transferring correctly and/or are duplicating.
  2. Migrated applicants/clients come over, but the full underwriting does not show on the review/credit-file view the way it should. It should be able to locate the CRS credit-report id in our admin Zoho, then pull/show underwriting from there — or, if the partner is already past initial underwriting, reuse what they already have.

How to gather live evidence (read-only)

Two temporary internalQuery helpers were added to convex/migrations.ts. They only read (bounded .take() scans) and are not exposed to the client.
# 1) Resolve the affected sub-account's locationId by name
bunx convex run migrations:findInstallationsByName '{"nameQuery":"New Day"}' --deployment <prod-target>

# 2) Health report for that location's migration
bunx convex run migrations:diagnoseMigration '{"locationId":"<id>"}' --deployment <prod-target>
Run against the production deployment that holds the partner’s data. Do not change .env.local; pass --deployment explicitly. Remove or gate both helpers before the fix PR. diagnoseMigration returns:
  • logs.duplicateSuccessRecordCount / duplicateSuccessSamples — Zoho records processed to success more than once (re-run duplication).
  • opportunityOverwrite.contactsWithSharedOpportunity — contacts whose Lead and Deal logs collapsed into one ghlOpportunityId (a Deal stomped the Lead’s Applicants opportunity).
  • migratedContacts.duplicateZohoRecordCount — duplicate migratedContacts rows.
  • underwritingCoverage.{withCreditReport,withUnderwriting,withFundingPlan} vs sampledContacts — the underwriting/CRS gap (symptom #2).
  • fundingPlans.{totalForLocation,dealLogsScanned,dealLogsWithFundingPlanSubform} — whether funding-plan data was even captured during the run.

Candidate root causes (validated by code review)

A. Duplication

  • The active Convex worker never calls the existing idempotency guard isRecordMigrated (convex/migrations.ts ~L431). Re-runs reprocess every Zoho record.
  • migrationLogs and migratedContacts are append-only; the by_zoho_record indexes are non-unique and there is no upsert/skip on re-insert (convex/migrationWorker.ts ~L234, convex/migrations.ts ~L1296).
  • The duplicate-opportunity fallback findOpportunityForContact (convex/lib/ghlApi.ts ~L730) is pipeline-agnostic — it returns the first opportunity for the contact, so in a full migration the Deal pass can update the Lead’s Applicants opportunity instead of creating a separate Clients one.
  • The wizard defaults to create_and_update, but the settings page still tells operators duplicates are skipped by default — operators re-run expecting skips and get updates.

B. Missing underwriting (the CRS angle)

The migration worker copies credit/underwriting custom fields onto the CRM contact, but the full underwriting result (underwritingResults) is produced by our engine off a credit report (creditReportRequests, keyed by requestId = the CRS report id). There is already post-migration CRS plumbing:
  • convex/migrationWorker.ts: scheduleCreditReportBackfill (~L113) → backfillCreditReportsAfterMigration, syncCrsCreditReportIds (~L157), patchZohoCreditReportCrsIds (~L176), patchCreditReportRequestIds (~L194).
  • convex/migrations/creditReportMigration.ts: pages Zoho credit reports, resolves the CRS/requestId (getResolvedRequestId, getFmCreditReportLookupIdFM_Credit_Report_Zoho_ID), and calls api.creditReports.backfillUnderwritingFromZoho (~L948, ~L1400).
So the intended behavior exists. The likely gaps for this partner:
  1. The credit-report backfill / CRS sync phase did not complete for their job (it is scheduled runAfter(0) and phase-guarded; a failed/cancelled job leaves contacts migrated but underwriting unlinked).
  2. The CRS id could not be resolved from their Zoho records (missing FM_Credit_Report_Zoho_ID / Report_Request_ID), so backfillUnderwritingFromZoho had nothing to link — the review view then shows the contact but no underwriting.
  3. If they are already past initial underwriting, we should reuse their existing underwritingResults rather than re-running — confirm diagnoseMigration withUnderwriting count before deciding to re-pull.
diagnoseMigration.underwritingCoverage tells us which of these we are in: low withCreditReport ⇒ CRS not located/synced; high withCreditReport but low withUnderwriting ⇒ backfill step did not run/complete.
  1. Idempotency: call isRecordMigrated (or check GHL_Contact_ID/existing migratedContacts) before processing in the worker; make migratedContacts an upsert keyed on (locationId, zohoRecordId).
  2. Opportunity correctness: make the duplicate-opportunity fallback pipeline-aware so Deal and Lead opportunities don’t collapse.
  3. Underwriting/CRS: ensure the credit-report backfill + syncCrsCreditReportIds runs (and can be re-run idempotently) for an already-migrated location; backfill underwriting from the located CRS report, and reuse existing underwritingResults when the partner is past initial underwriting instead of re-pulling.
  4. Settings copy: align the migration settings page with the actual create_and_update default.
  5. (If in scope) notes + funding-plan-subform transfer — currently not built; the worker fetches deals without the Funding_Plan subform (convex/lib/zohoApi.ts DEAL_FIELDS).
Decide dedup-only vs build-transfer vs both once diagnoseMigration output is in.