Skip to main content

Funding Workflow Paths — Design Working Doc

Status: Design locked through Tier-2. Remaining work: a handful of small discovery TODOs (see Next section), then the Convex-side implementation pass. Doc is ready to hand off to Claude Code for the underwriting changes. Owner: Kevin Last updated: 2026-04-23

Problem

The post-approval workflow is gated entirely on 7 Qualified* card-decision strings (useContactPageController.tsx:1168-1178), and the client has only 3 workflow templates: prime, paydowns, inq_removal — all card-centric. Clients who qualify for term loan only, MCA/SBA only, or don’t qualify at all have zero post-underwriting workflow representation. The UI goes silent after “Does Not Qualify.” fundingCategories.ts already classifies Creative (cards/term-loan) vs Established (MCA/SBA) vs Both — that classification is not wired into the workflow UI.

Design Model: Products + Prerequisites

The engine emits a set of active primary paths (the products the client is in flight for) plus the overlays (prerequisite actions that gate those products). A client can have multiple primaries active at once — e.g. term_loan AND card_funding when both qualify. Ordering rules decide which primary’s spine the operator works first.
{
  primaryPaths: PrimaryPath[],                                       // ordered; non-empty; see Sequencing below
  overlays: Overlay[],                                               // zero or more, attached to specific primaries
  declineReason?: 'repair_referral' | 'no_fit' | 'insufficient_income'
                  | 'duplicate_file' | 'other',                      // only when primaryPaths === ['decline']
  declineReasonNotes?: string,                                       // required when declineReason === 'other'
  parkedReason?: 'pending_seasoning' | 'recent_credit_activity'
                  | 'thin_file',                                     // only when primaryPaths === ['parked']; more reasons likely as business-funding support grows
  parkedCallbackDate?: number,                                       // unix ms; when Funding Queue should resurface
  nextActions: NextAction[],
  addressFirst: AddressFirstItem[],
}
type PrimaryPath =
  | 'card_funding'
  | 'term_loan'
  | 'established_funding'   // MCA and/or SBA — sub-variant inside the template
  | 'parked'                // client will likely qualify later — sits in a GHL pipeline stage for callback
  | 'decline';

Why products + prerequisites

The existing 3 templates (prime, paydowns, inq_removal) share an identical spine — review → agreement → [middle] → card submission → wait → results — and differ only by 2 optional middle steps. That’s 1 template with 2 prerequisite overlays, not 3 templates. Combinations like Qualified w/ Paydowns/Inq Removal already exist as their own decision strings, which doesn’t scale. Overlays collapse this: the 3 existing templates become 1 (card_funding) with composable overlays, and new overlays = new data, not new code paths. Term loan is treated as its own primary (not an overlay onto cards) because operationally it IS its own product — contact lender, generate offers, client selects, submit, fund. When a client qualifies for both cards and term loan, they’re two primaries that run in sequence (term loan funds first, then card stacking happens — per the existing code’s own warning: “Ensure term loan has funded before moving to next step”).

Overlay shape

type Overlay = {
  id: 'paydowns_required' | 'inq_removal_required',
  insertionPoint: { after: StepId } | { before: StepId },
  blocks: StepId,                 // primary can't advance past this step until overlay is done
  appliesTo: PrimaryPath,         // which primary this overlay attaches to
  steps: WorkflowStep[],
  data: Record<string, unknown>,
}
All remaining overlays are inline + blocking. The earlier side_channel overlay type was removed when credit_repair_referral was folded into the decline primary (see Decisions Log).

Primary path sequencing

Two different concerns: within a track, primaries sequence automatically; across tracks, the operator chooses. Within-track sequencing (automatic):
  1. decline is exclusive. If decline is in the set, it’s the only member.
  2. parked is exclusive. Same reasoning — the file is in a waiting state; no funding primaries co-occur.
  3. term_loan runs before card_funding. Term loan must fund before card stacking (per existing code warning). The combined Creative workflow is: complete all term_loan steps → begin card_funding spine.
Cross-track UX (operator-driven): When a client qualifies for both the Creative track (card_funding / term_loan) AND the Established track (established_funding), both sets of primaries are emitted in primaryPaths, and the UI shows two buttons:
  • “Start Creative Funding Workflow”
  • “Start Established Funding Workflow”
Operator picks which to start.
  • One started, one not: the active workflow renders; the un-started track persists at the top as a link/button.
  • Both started: the UI becomes tabbed. Tab 1: Creative. Tab 2: Established. (Within the Creative tab, term_loan + card_funding still sequence automatically per above — the tab distinction is between tracks, not within one.)
Shared steps (e.g. approve_underwriting) are de-duplicated: approve once, all active primaries see it as complete. Client-side state: the engine emits which primaries qualify; the client tracks which tracks have been “started” by the operator (a small UI state concern, not a server-emitted field).

Manual Review (Pre-Workflow)

When any underwriting evaluator returns a Manual Review - * decision, the file is in a pre-workflow state — no primaryPaths are assigned yet. This is handled today by the existing ReviseUnderwritingDialog (components/funding/ReviseUnderwritingDialog.tsx) and NextActionCard’s operator-facing prompts. Resolution paths:
  • Operator overrides / revises underwriting → underwriting re-runs → deriveWorkflow() emits new primaryPaths based on the revised evaluation → normal workflow engages.
  • Operator closes the file → becomes a decline primary with the appropriate declineReason (typically no_fit, or an operator-entered reason).
Implication for the engine: manual-review cases never hit the workflow engine directly. The engine assumes underwriting has resolved to a terminal state (qualified / declined) before emitting primaryPaths. If underwriting is in manual review, workflow on the underwriting doc is null, and the client surface is NextActionCard’s “needs operator review” prompt — not a workflow template.

Primary Paths

Workflow step patterns (implementation guidance)

Default assumption for steps named send_* or collect_*: they’re trigger-only. Convex assembles the content/payload server-side; the step is just a button that fires the send. Do NOT build composition UIs, do NOT let the operator tweak copy or amounts, unless a specific step explicitly says otherwise. Steps that are NOT trigger-only (operator actively works in them, not just a button click):
  • approve_underwriting — operator reviews the underwriting result and approves or revises.
  • generate_term_loan_offers — operator clicks, system calls Engine API, offers come back for operator review before proceeding.
  • review_and_select_offer — operator + client discussion by phone; selection recorded.
  • track_card_statuses — week-long daily follow-up loop; operator updates the funding plan as the client reports back.
  • Overlay middle-steps (e.g. wait_for_paydowns, pull_new_credit_report) — operator verification work, not triggers.
When in doubt on a specific step, default to the simpler trigger-only pattern and confirm with Kevin before adding composition UI.

card_funding

Replaces prime / paydowns / inq_removal. Single ordered spine:
  1. approve_underwriting — Review & approve underwriting.
  2. send_agreement — Send our consultancy agreement (single template reused across all primaries — see “Agreement note” in the Invoicing / Billing Flow section).
  3. [overlay slot: paydowns_required inserts here, blocks submit_processing]
  4. [overlay slot: inq_removal_required inserts here, blocks submit_processing]
  5. submit_processing — Submit for 0% card processing / Build funding plan (existing partner: 7 Figures Funding).
  6. wait_card_stacking_results — Wait for card stacking results (24–72 hrs). Returns a “funding plan” containing 4–7 card applications, each flagged approved, pending, or declined.
  7. deliver_funding_plan — Operator clicks an “Email Funding Plan” button. Per-card next-steps content is already generated server-side in Convex (approved cards → “arrives in 5–10 days”; pending/declined → “call issuer, request reconsideration”). The operator does NOT compose or edit copy — the step is purely a trigger that fires the existing email-assembly logic.
    • Implementation intent (Claude Code): keep this step simple. Do not build a content-composition UI, do not let the operator tweak per-card messaging. One button, one action. The server decides what the email contains based on the funding plan state.
  8. track_card_statuses — Daily operator follow-up with client; operator updates each card’s status in the funding plan as the client reports back. Soft cutoff at day 7: the system prompts the operator with “Ready to invoice and close out?” — operator can proceed to collect_invoice or choose to keep waiting.
    • Current reality: informal — operator checks in by phone/email, updates the funding plan when they remember to.
    • Target: drive per-card status updates directly from FundingPlanTable UI so the data is first-class (not conversation notes). This is a UX improvement, not an engine change — the workflow step just consumes the same signal either way.
  9. collect_invoice — Operator clicks invoice button; system generates and sends a GHL invoice to the client for operatorFeePercent × totalApprovedAmount (e.g. 9% × 30,000=30,000 = 2,700). This closes the card_funding primary. See “Invoicing / Billing Flow” section.
    • TODO (code): existing onSendInvoice callback on NextActionCard is likely where this fires today; confirm and wire to this step.
Trigger: Creative track + qualifies for cards (target: underwriting-derived flag, not string-matching on reportStatus). Overlay applicability: paydowns_required ✓, inq_removal_required ✓. Sequencing note: if term_loan is also active, it runs first. The generate_term_loan_offers step is NOT on the card_funding spine anymore — it lives entirely in the term_loan primary.

term_loan

Trigger: tlDecision starts with "TL Qualified". Applies both when term loan is the only option AND when it’s alongside card_funding — same primary, same spine, runs first in the combined case. Integration partner: Engine by MoneyLion (engine.tech). Offers are generated by calling the Engine API, which fans out to Engine’s lender panel and returns multiple offers in one response. Client accepts a selected offer via an Engine-hosted acceptance flow. First API-integrated partner in the workflow engine — everywhere else (MCA, SBA, card stacking) still runs on manual email. Dev docs: https://engine.tech/docs/api-reference/#personal-loans and https://engine.tech/developer-center. Spine: confirmed 2026-04-23 — remaining TODOs in-line
  1. approve_underwriting — Review & approve underwriting. (Reuses existing step, autoDetect: true. Shared with card_funding when both are active — approve once, both primaries see it as complete.)
  2. send_agreement — Send our consultancy agreement (single template reused across all primaries — see “Agreement note” in the Invoicing / Billing Flow section).
  3. generate_term_loan_offers — Operator clicks “Generate Term Loan Offers” in the CRM. System calls the Engine API with the applicant’s data; Engine fans out to its lender panel; response returns multiple lender offers. (Existing button — TODO: confirm whether it already calls Engine or the Engine integration is still to-build.)
  4. review_and_select_offer — Operator reviews the offer list WITH the client (typically by phone). They discuss terms, pick one. Operator records the selection. This happens BEFORE any email goes to the client — the client doesn’t see the raw offer list, they see only the chosen offer.
  5. send_offer_link — Operator emails the client the link for the selected offer’s Engine-hosted acceptance page. Client clicks through to review terms and accept.
  6. await_acceptance — Client accepts in Engine’s portal. State advances when Engine notifies us.
    • Target: webhook from Engine on acceptance → auto-advance. TODO (Kevin): confirm from Engine docs whether an acceptance webhook exists or whether we’d have to poll a status endpoint.
    • Fallback (if no webhook): operator manually marks accepted based on client confirmation.
  7. wait_for_funding — Lender funds the loan; the operator / system confirms.
    • Current practice: client typically tells the operator when funds arrive; operator manually marks complete.
    • Target once API integration lands: webhook or status field from Engine on disbursement → auto-advance. TODO (Kevin): grep Engine docs for funded, disbursement, status, webhook events.
    • Fallback: keep the manual “client-confirmed deposit” path.
  8. collect_invoice — Operator clicks invoice button; system generates and sends a GHL invoice to the client for operatorFeePercent × termLoanAmount (e.g. 9% × 20,000=20,000 = 1,800). Client pays the invoice from the loan proceeds; the remainder is what they’ll deploy on paydowns or other use. See “Invoicing / Billing Flow” section.
  9. send_next_steps — Final handoff message. Content varies:
    • Solo (term_loan only, ~20%): closure / congrats — “your funds are in, here’s what’s next for your business.”
    • Combined with card_funding + paydowns_required (~80%): effectively absorbed by the paydowns_required overlay’s step 1 messaging, which tells the client exactly which accounts to pay down using the term loan proceeds. In the combined case this step may collapse to a no-op or just advance the state.
Overlay applicability:
  • inq_removal_required — YES. "TL Qualified w/ Inq Removal" proves the combo is real. Insertion: after send_agreement, before generate_term_loan_offers.
  • paydowns_requiredNO, confirmed 2026-04-23. A term loan is never gated on paydowns. In fact, the reverse is typically true — see below.
Why term loan usually runs before cards (operational context): When term_loan and card_funding are both active AND card_funding has the paydowns_required overlay, ~80% of the time the term loan IS the funding source for the paydowns. The client qualifies for cards but needs to bring utilization down; the term loan provides the cash to do that. Sequence in the combined case:
  1. term_loan spine runs to completion — funds arrive in the client’s account.
  2. Client uses term loan proceeds to pay down the accounts flagged by paydowns_required.
  3. paydowns_required step 2 completes (operator verifies paydowns made + pulls fresh credit report).
  4. card_funding advances to submit_processing.
The remaining ~20%: term loan proceeds go elsewhere (working capital, consolidation, whatever the client wants — we don’t restrict use). In those cases, if paydowns are also required on the card side, the client pays them down from their own funds. Either way, the engine treats this as two primaries with a sequencing rule; the operational coupling (term loan funds → paydowns) is surfaced through the paydowns_required overlay’s messaging, not through engine logic. Implication for Partners & Referrals section: the FM Lender DB’s term-loan entries (BHG Financial, iBusiness Funding, ARF Financial) may become partially or fully redundant once Engine’s lender panel is in place, depending on whether Engine’s coverage overlaps with those. Confirm during the Engine integration.

established_funding

Trigger: evaluateEstablished() returns mcaEligible: true and/or sbaEligible: true. Design concern to resolve before building: MCA and SBA are materially different products. MCA is typically days-to-fund with light docs (bank statements, voided check). SBA is weeks-to-months with heavy docs (2–3 yrs tax returns, P&L, balance sheet) and a separate lender process. Collapsing both into one spine may pretend they’re more similar than they are. Two options:
  • (A) One primary, branch inside the templateestablished_funding with sub-variant 'mca' | 'sba' | 'both'. Cleaner naming; some conditional rendering inside the template.
  • (B) Split into two primariesmca_funding and sba_funding. Cleaner templates. When client qualifies for both, they become two active primaries that operator sequences.
Decision (2026-04-23): variant (A). Handles MCA-only, SBA-only, and both cleanly via the sub-variant. When only one qualifies, select_product collapses to a no-op and the template renders the product-specific sub-path (docs, partners, agreement). No issue with MCA-only or SBA-only cases. Key distinction from the other primaries: established_funding is LENDER-PAID, not client-paid. For term_loan and card_funding, the client pays the operator an invoice (9% of funding). For established_funding, the lender pays the operator directly (referral fee / commission); there is no client-side invoice. FM’s involvement is typically a short handoff: we submit, the lender takes over with the client, and eventually we get a commission check which the operator records manually. This shapes the spine significantly — shorter and handoff-oriented, not end-to-end. Spine (variant A): confirmed 2026-04-23
  1. approve_underwriting — Review & approve underwriting. (Shared with other primaries when combined — approve once.)
  2. send_agreement — Send our consultancy agreement (same template reused across all primaries — see “Agreement note” in the Invoicing / Billing Flow section).
    • TODO (legal/Kevin): the agreement template’s fee clauses assume client-paid compensation (% of funding). For established_funding, compensation comes from the lender, not the client. Confirm the agreement text accommodates both models, or whether a product-scoped clause is needed.
  3. select_product REMOVED in implementation. Sub-variant (MCA / SBA / both) is now picked at confirm time via ApproveEstablishedDialog, mirroring how Creative captures product selection in ApproveUnderwritingDialog. The choice writes establishedSubVariant on the underwriting row at approval, and downstream steps (gather_documentation, submit_to_partner) read it from there. Step removed because operators picking inside the workflow created a two-step approval gesture for what’s a single decision.
  4. gather_documentation — Collect docs from client. System should show the required list per sub-variant.
    • MCA: 3–6 months bank statements. (Other standard docs — voided check, ID, business formation — are typically collected during the lender’s own application, not by us upfront.)
    • SBA: TODO (Kevin) — typical is 2–3 yrs tax returns, P&L, balance sheet, possibly business plan. Confirm the exact required list.
  5. submit_to_partner — System filters the FM Lender DB by sub-variant + eligibility criteria (revenue, FICO, TIB) and presents a shortlist. Operator confirms and submits. No API integration today — submission is email to the Contact listed in the DB.
    • MCA shortlist (current DB): Credibly, Kapitus, OnDeck, plus 3 “Revenue-based financing” rows.
    • SBA shortlist (current DB): Cadence Bank, Grasshopper Bank.
    • TODO (code): the filter criteria (revenue threshold, FICO minimum, TIB minimum) need to be encoded on the lender DB entries so the system can filter. Confirm existing data model during implementation.
  6. handoff_to_lender — From here, the lender works with the client directly on their application, review, approval, and funding. FM is typically out of the flow. Some lenders send us status updates; others don’t. Treated as out-of-band for v1.
  7. record_payment_received — Lender pays the operator a commission / referral fee directly (no client invoice). When the payment arrives, operator records it manually in GHL using GHL’s manual-payment-recording feature (see GHL help). This step closes the established_funding primary.
    • Implementation intent (Claude Code): this is a simple “log receipt” step. Don’t build an invoice-generation UI — the commission is paid by the lender, not charged to the client. The step just captures the record.
Overlay applicability:
  • paydowns_required — NO (card-specific; doesn’t affect MCA/SBA underwriting).
  • inq_removal_required — NO (established funding is revenue/TIB-based, not credit-score-based; inquiries don’t affect it). Confirmed 2026-04-23.

parked

Trigger: Client doesn’t qualify today but is likely to qualify after a time-based condition resolves (e.g. new accounts seasoning). Sits in a dedicated GHL pipeline stage; the forthcoming Funding Queue mode (Brock’s feature) will let operators batch-call through parked files when their callback date arrives. Exclusive primary — doesn’t co-occur with funding primaries. Parked reasons (parkedReason enum):
  • pending_seasoning — client has established credit but specific accounts need to age (e.g. recently-opened cards need 6+ months). Cards underwriting already emits "Qualified - Pending Seasoning". Passive wait — no action needed from client. Default callback: 6 months.
  • too_many_new_accounts — client has opened too many credit accounts too recently. FICO penalty is on velocity, not account age specifically. Remedy: wait AND don’t open any more accounts in the meantime — email template should explicitly instruct client not to open new credit during this window. Default callback: 6 months. TODO: confirm the underwriting signal this maps to in cardUnderwriting.ts — likely an existing “Manual Review - Too Many Recent Inquiries” or similar threshold being crossed.
  • thin_file — not enough credit history to underwrite (few or no tradelines). Remedy differs from seasoning: client needs to actively build credit (open accounts, use them responsibly, build history). Email template should coach this. Default callback: 6 months (configurable — may want shorter for actively-building clients). Moved from decline → parked 2026-04-23 based on Funding Queue context.
  • TODO (future): pending_tib (business time-in-business not yet sufficient), pending_revenue (deposit history needs more months). Enumerate as business-funding parked cases are added.
Distinction between parked reasons lives in:
  • the parkedReason enum value (data identity; Funding Queue sorts/filters on this),
  • the email template sent at send_parked_email (per-reason messaging),
  • the default callback date (per-reason; all 6 months for now, configurable),
  • the operator-facing reason label in Funding Queue (so operator knows context for the callback).
All parked reasons share ONE GHL pipeline stage — the operator workflow in Funding Queue is the same regardless of reason (call, re-underwrite, see if they qualify). Separating stages per reason would add CRM config without helping. parkedReason is a contact field/tag, not a pipeline stage. Spine: tiny by design; the heavy lifting happens in Funding Queue
  1. send_parked_email — Templated email, one template per parkedReason (messaging differs — see “Distinction between parked reasons” above). Explains why we can’t fund today and when we’ll follow up.
  2. mark_parked — Atomic: set the GHL “parked” pipeline stage (name TBD — see GHL Pipeline Stage Mapping section) + set parkedReason tag + set parkedCallbackDate on the contact. File is now visible in Funding Queue.
No further operator steps on this primary’s workflow. When the callback date arrives, Funding Queue surfaces the file and operator re-underwrites via the existing flow. Re-underwriting triggers a fresh deriveWorkflow() run; if conditions have improved, new primaryPaths engage. If still parked, parkedCallbackDate rolls forward. Overlay applicability: none. Open — should repair_referral also move to parked? Deferred decision; stays as a decline reason for now. See Open Questions.

decline

Trigger: No qualifying product path AND no time-based recovery expected, OR any underwriting evaluator flags credit repair (per 2026-04-23 decision: credit-repair-required = decline, full stop — no “qualified with credit repair” state). Decline reasons (declineReason enum): finalized 2026-04-23
  • repair_referral — credit repair is needed. The ASAP referral steps live in this primary’s spine (see below). Decision 2026-04-23: stays in decline, does NOT move to parked. Rationale: referred clients typically don’t come back (ASAP takes over the relationship); better to treat as closed than keep stale files in Funding Queue.
  • no_fit — nothing to offer, no time-based recovery expected. Hard decline.
  • insufficient_income — personal stated income too low to support any product. Distinct from business pending_revenue (which is parked, because business revenue grows) — personal income is relatively stable, so this is a hard decline, not a park.
  • duplicate_file — same client applied through multiple operators, or we have a duplicate record. Closed as duplicate (no client-facing comms needed beyond the decline email if one hasn’t already been sent).
  • other — operator-entered freeform reason for edge cases not enumerated above. Requires an accompanying declineReasonNotes: string field on the workflow so the operator can record context.
Note: pending_seasoning, thin_file, too_many_new_accounts, AND credit_not_found are NOT decline reasons — they all live in the parked primary. Specifically "Credit Not Found" is already routed to thin_file by the existing convex/lib/fundingPath.ts:86 (confirmed by test at tests/underwriting.test.ts:566), so no separate decline reason is needed for the no-credit-file case. Note — manual review is NOT a decline reason. See the “Manual Review (Pre-Workflow)” section. When underwriting returns Manual Review - *, the file is pre-workflow; operator override/revise re-runs underwriting, or close becomes a standard decline. Spine: confirmed 2026-04-23 — decline means closed, no nurture When declineReason === 'repair_referral':
  1. send_repair_referral — Send client the ASAP Credit Repair affiliate referral link.
  2. send_decline_email — Generic decline email (see template note below).
  3. mark_in_credit_repair — Tag the file for ASAP handoff accounting / commission tracking.
  4. close_file — Close with declineReason: 'repair_referral'. Terminal. No follow-up.
When declineReason is anything else (no_fit, insufficient_income, duplicate_file, other):
  1. send_decline_email — Generic decline email.
  2. close_file — Close with the appropriate declineReason. Terminal. No follow-up.
Email template: one generic template covers all decline reasons (confirmed 2026-04-23). Reason may appear in the email body as context, but the template itself is the same across reasons — no per-reason variants to maintain. Template may not exist today — implementation pass will check and create if needed. No nurture, no follow-up (confirmed 2026-04-23). Declined = closed. If a previously-declined client comes back later, they re-enter underwriting as effectively a new lead. Overlay applicability: none. All overlays require a qualifying primary to attach to.

Overlays

paydowns_required

  • Applies to: card_funding
  • Insertion: after send_agreement, blocks submit_processing
  • Steps (from existing paydowns template):
    1. Review Over Utilized & email paydown requirements
    2. Confirm paydowns made & pull new credit report
  • Data: paydownAmountNeeded, topPaydownAccounts[] (already computed server-side in nextActions[]).
  • Interaction with term_loan primary: when term_loan is also active, ~80% of the time the term loan proceeds are specifically intended to fund the paydowns. In that case:
    • Step 1’s email copy should reference the incoming term loan proceeds (“use your term loan funds to pay down accounts X, Y, Z”).
    • Step 2 is unchanged — operator still verifies paydowns actually happened (clients don’t always use funds as planned) and pulls a fresh credit report.
    • The overlay step structure doesn’t change; only messaging conditionally adapts. No engine logic changes; the UI decides copy based on whether term_loan is also active.

inq_removal_required

  • Applies to: card_funding, term_loan
  • Insertion: after send_agreement, blocks the next product-specific step (submit_processing on card_funding; generate_term_loan_offers on term_loan).
  • Steps (from existing inq_removal template):
    1. Dispute recent inquiries with bureaus
    2. Confirm inquiries removed & pull new credit report
  • Data: list of eligible inquiries.

Partners & Referrals

Captured from the FM Lender database + CEO Funding Database (uploaded 2026-04-23) and the credit repair affiliate signup link. No API integrations exist today — all partner handoffs are manual email to the Contact listed.

Card stacking / 0% funding

  • 7 Figures Funding — primary card-stacking partner. Referenced in code as "Submit file to 7 Figures" in the submit_processing step. Affiliate portal: https://portal.7figurespartners.com/affiliates/login.php#login. Focus: all industries, startups. Offers personal term loans up to $100k + business/personal revolving credit lines at 0% for up to 18 months.

Term loan lenders

  • Primary integration: Engine by MoneyLion (engine.tech). API-mediated; fans out to Engine’s lender panel on offer-generation and returns multiple offers in one response. Client acceptance is hosted by Engine. This replaces the direct-lender-email flow for term loans specifically.
  • Legacy (FM Lender DB direct) — may become redundant once Engine is in place, depending on panel overlap:
    • BHG Financial — up to $500k, 660+ FICO, funds in ~3 days.
    • iBusiness Funding — term loan.
    • ARF Financial — LOC + Term Loan combo, 50k50k–1M, 600+ FICO, “a few business days.”

MCA / revenue-based funders

  • Credibly, Kapitus, OnDeck — standard MCA funders.
  • 3 additional “Revenue-based financing” rows — TODO: verify which are MCA specifically vs. other revenue-based products.

SBA lenders

  • Cadence Bank — SBA 7(a), up to $5M, 650+ FICO, ~10 days to fund.
  • Grasshopper Bank — SBA.
  • Gap: only 2 SBA partners in the DB.

Equipment financing (not currently mapped to a primary path but available)

  • Balboa, ClickLease, Everlasting Capital.

Credit repair referral

  • ASAP Credit Repair USA — sole referral partner.
  • Operator affiliate signup: https://portal.asapcreditrepairusa.com/affiliate-application/6.
  • Each operator signs up for their own affiliate account so commissions attribute correctly.
  • Used ONLY in the decline primary, reason repair_referral. Not an overlay — the referral steps are baked into the decline spine.

Open questions

  • Should the lender list live in the doc or the DB? Inline for now; long-term the authoritative list is the FM Lender database and submit_to_partner should query the DB by loan type + eligibility criteria, not a hardcoded list. (Equipment / Real Estate paths question is tracked in top-level Open Questions, not duplicated here.)

Invoicing / Billing Flow

Captured 2026-04-23 during the Tier-2 walkthroughs. Two different compensation models depending on the primary:
  • Client-paid (applies to term_loan, card_funding): operator generates an invoice to the client; client pays the operator; FM takes a cut of the operator’s invoice.
  • Lender-paid (applies to established_funding): lender pays the operator a commission/referral fee directly; no client invoice. Operator records the received payment manually in GHL for bookkeeping.

Client-paid model (term_loan, card_funding)

  • Operator charges client a percentage fee of total funding received. Typical: 9% (configurable per operator).
  • Invoice 1 fires at term_loan.collect_invoicefeePct × termLoanAmount. E.g. 20,000termloan@920,000 term loan @ 9% → 1,800. Client pays from loan proceeds; remainder is what they deploy on paydowns or other use.
  • Invoice 2 fires at card_funding.collect_invoicefeePct × totalApprovedAmount (summed across all cards in the funding plan). E.g. 30,000approvals@930,000 approvals @ 9% → 2,700.
  • Invoice system: GHL invoicing, already built into the codebase. The existing onSendInvoice callback on NextActionCard is likely where this fires today. TODO (code): confirm during implementation and wire the new invoice steps to this code path.

Lender-paid model (established_funding)

  • Operator receives a commission or referral fee from the lender directly when a deal funds. Amount varies by lender and product; tracked on the lender relationship, not on the workflow.
  • No client-side invoice generated. The client’s funding terms are set by the lender, not by us; our fee is paid separately by the lender.
  • Payment recording: at established_funding.record_payment_received, the operator logs the received commission manually in GHL. GHL supports this via its “manual payment recording” feature — see GHL help.
  • Implementation intent (Claude Code): this is a “log what was received” action, not an invoice-generation action. Do not build a client-invoicing UI for this primary.

FM → operator reconciliation (internal; NOT a workflow step)

  • FundingMachine charges operators 15% of the operator’s received compensation on portions where FM (via 7 Figures) did the processing.
  • Example from Kevin’s walkthrough — client gets 20ktermloan+20k term loan + 30k card approvals:
    • Operator Invoice 1: 1,800(termloan).Engine(notFM)handledprocessingFMcharges1,800 (term loan). Engine (not FM) handled processing → **FM charges 0.**
    • Operator Invoice 2: 2,700(cards).7Figures(=FM)handledprocessingFMcharges152,700 (cards). 7 Figures (= FM) handled processing → **FM charges 15% × 2,700 = $405.**
  • For established_funding, the same 15% rule applies IF FM did any processing work; otherwise (pure referral, no FM involvement) → $0.
  • This is an internal reconciliation concern, not a workflow engine concern. Documented here so the engine design doesn’t accidentally bake processing ownership into per-workflow data; it’s a property of which partner handled the product, tracked separately.

Fee configurability

  • The 9% figure is typical but not universal — should be per-operator configurable.
  • TODO: where is the fee % stored today — on the operator record, per-file, or hardcoded? Confirm during implementation.

Agreement note (applies to all primaries)

  • One consultancy agreement template covers all products. Operator → client agreement is signed once (regardless of which primaries activate) and applies to the engagement. When multiple primaries are active, the send_agreement step de-duplicates — the client signs once, all active primaries see it as complete.
  • TODO (legal/Kevin): the agreement’s fee clauses assume client-paid compensation. For established_funding where compensation comes from the lender, confirm the template already handles this (e.g. via a product-scoped clause) or whether a revision is needed.

Underwriting Changes Required

Captures what needs to change on the Convex side. (Kevin will do these via Claude Code.)

Prior art: convex/lib/fundingPath.ts — to be replaced

A determineFundingPath() function currently exists and returns values like "prime" | "near_prime" | "thin_file" | "seasoning" | "credit_repair" | "mca_nuclear" — a finer-grained taxonomy than our primaryPaths (e.g. "prime" and "near_prime" are both the card_funding primary with different overlays attached). Decision: replace, don’t wrap. The primary goal of this refactor is one source of truth for workflow routing. Wrapping fundingPath.ts inside deriveWorkflow() would leave two coexisting path-determination systems with different taxonomies, each requiring updates whenever a new case is added — exactly the overlap we’re trying to eliminate. deriveWorkflow() becomes the single entry point; determineFundingPath() is deprecated. Routing semantics to preserve (rewrite as internal helpers / branches inside deriveWorkflow(), not as calls to the old function):
  • "Credit Not Found" reportStatus → parkedReason: 'thin_file'. (Old code: fundingPath.ts:86"thin_file".)
  • Bankruptcy / sub-600 / recent-severe-lates → declineReason: 'repair_referral' (hard-stop; evaluates BEFORE thin-file check).
  • Thin-file check evaluates BEFORE the "Doesn't Qualify" catch-all (a thin-file DQ goes to parked / thin_file, not to decline / repair_referral).
  • Qualified statuses map based on which Qualified w/ * variant they are (Paydowns → paydowns overlay; Inq Removal → inq_removal overlay; Seasoning → parked / pending_seasoning).
Migration plan:
  1. Write deriveWorkflow() with the routing semantics inlined. Reuse determineFundingPath() source as reference, not as a dependency.
  2. Enumerate callers of determineFundingPath() (at minimum: the underwriting recommendation system, tests/underwriting.test.ts). Migrate each to read from the new workflow field on the underwriting doc.
  3. Migrate tests/underwriting.test.ts — rewrite assertions to check deriveWorkflow() output (primaryPaths, overlays, parkedReason, declineReason) instead of the old determineFundingPath() string. Semantic cases carry over unchanged; only the assertion shape differs.
  4. Delete convex/lib/fundingPath.ts once no callers remain.

convex/lib/cardUnderwriting.ts

  • Remove creditRepairFlag as a side-output on qualified decisions. Today evaluateCards() can return { reportStatus: 'Qualified w/ ...', creditRepairFlag: {...} }. After this refactor: if credit-repair conditions are met, reportStatus becomes "Doesn't Qualify" (or a new explicit "Decline - Credit Repair Required" variant). There is no “qualified but needs repair” state. SUPERSEDED 2026-04-27 by the audit-driven scope cut — the audit found only 2/4312 (0.05%) files have the Qualified + creditRepairFlag combo, so the strict rule was dropped. evaluateCards() continues to emit creditRepairFlag as today on qualified decisions; deriveWorkflow() only routes decline / repair_referral when reportStatus === "Doesn't Qualify" with a credit-repair-class declineReason. See Decisions Log “Dropped the credit-repair-as-decline blanket rule” entry.
  • Keep emitting the existing Qualified* / Qualified w/ Paydowns / Qualified w/ Inq Removal strings — they’ll still be used inside deriveWorkflow() to detect card-funding eligibility and set overlay flags.
  • In scope (this pass, 2026-04-27): delete the "Too many recent inquiries" decline-reason branch (rare/dead-code-ish per the same call), and add recentBankruptcies > 0 to the DQ entry gate as part of the BK age-threshold work. Both shipped.

convex/lib/termLoanUnderwriting.ts

  • Same rule: if credit-repair conditions are met, tlDecision becomes a "TL Declined - *" variant. No “TL Qualified but needs repair.” (Low-risk change — the code already has "TL Declined - *" terminal states; this just adds one more decline reason.) SUPERSEDED 2026-04-27 alongside the cardUnderwriting change above. The blanket “credit repair blocks all funding paths” rule was dropped on audit evidence; termLoanUnderwriting continues to gate on score / income / unsecured / inquiries as today, with no credit-repair propagation. Implication Codex correctly flagged: a file can land on cards-side decline / repair_referral while simultaneously emitting an active term_loan workflow — the operator pursues the qualified product. Future readers: if you want to re-enable strict cross-track exclusivity, this is the place — gate basicTLProfileOK on credit-repair signals propagated from upstream. See Decisions Log “exclusivity is not strictly enforced across tracks” entry.

convex/lib/establishedUnderwriting.ts

  • Same rule: credit repair flagged → mcaEligible: false, sbaEligible: false (or add a separate creditRepairDecline: true to be explicit). SUPERSEDED 2026-04-27 alongside the cardUnderwriting/termLoanUnderwriting changes above. Same rationale: dropped on audit evidence. establishedUnderwriting continues to gate on score / monthly revenue / TIB only, with no credit-repair propagation. Same cross-track-non-exclusivity caveat as termLoanUnderwriting above. See Decisions Log.

convex/underwriting.ts (top-level)

  • Add deriveWorkflow() that composes:
    • primaryPaths: PrimaryPath[] — derived from the three evaluator outputs. E.g. card qualifies AND term-loan qualifies → ['term_loan', 'card_funding']. Nothing qualifies AND credit-repair-required → ['decline'] with declineReason: 'repair_referral'. "Qualified - Pending Seasoning"['parked'] with parkedReason: 'pending_seasoning'. Too many recent accounts / inquiries over threshold → ['parked'] with parkedReason: 'too_many_new_accounts' (TODO: confirm exact underwriting signal). Thin file OR "Credit Not Found"['parked'] with parkedReason: 'thin_file' (existing fundingPath.ts already routes Credit Not Found to thin_file).
    • overlays: Overlay[] — derived from reportStatus (paydowns / inq-removal flags) and which primaries they apply to.
    • declineReason (when primaryPaths === [‘decline’]).
    • parkedReason + parkedCallbackDate (when primaryPaths === [‘parked’]). parkedCallbackDate computed from the seasoning requirement — default 6 months for pending_seasoning.
  • Store the derived workflow on the underwriting document.
  • Keep nextActions[] and addressFirst[]. Make deriveWorkflow() the single source for which workflow UI renders — not string-matching on decisions in the client.
  • GHL side-effect: the mark_parked step on the parked primary writes the GHL pipeline stage + callback date via existing GHL sync. See “GHL Pipeline Stage Mapping” section.

Schema / storage

  • underwritingDoc gains workflow: { primaryPaths, overlays, declineReason?, parkedReason?, parkedCallbackDate? } | null. Null when underwriting is in manual review (pre-workflow — see “Manual Review (Pre-Workflow)” section).
  • New GHL pipeline stage for parked primary (all reasons). Name TBD — candidates: “Parked - Callback Scheduled”, “Funding Queue”. Required so mark_parked has a target stage. parkedReason is a separate contact field/tag, not its own stage. See “GHL Pipeline Stage Mapping” section.
  • Existing fields (reportStatus, tlDecision, creditRepairFlag, inquiryRemovalFlagged) stay temporarily for backward compatibility during the client-side migration. Remove after the client is fully off them.

Tests / edge cases to add

  • Client in manual review → workflow: null. Client UI shows NextActionCard “needs operator review” prompt.
  • Client qualifies for cards + term loan (no paydowns/inq issues) → primaryPaths: ['term_loan', 'card_funding'], no overlays. One workflow (Creative tab), sequenced.
  • Client qualifies for cards w/ paydowns → primaryPaths: ['card_funding'], overlays: [{ id: 'paydowns_required', appliesTo: 'card_funding' }].
  • Client qualifies for cards + term loan w/ inq removal → primaryPaths: ['term_loan', 'card_funding'], overlays: [{ id: 'inq_removal_required', appliesTo: 'term_loan' }] (inq-removal runs before term loan; card funding picks up the already-improved profile).
  • Client qualifies for cards + established_funding (both tracks) → primaryPaths: ['card_funding', 'established_funding']. Client UI shows two start buttons (Creative / Established); tabs if both started.
  • Client was card-qualified under old rules but credit-repair-flagged → primaryPaths: ['decline'], reason 'repair_referral'.
  • Client qualifies for MCA only → primaryPaths: ['established_funding'], sub-variant 'mca'. select_product step is a no-op.
  • Client qualifies for SBA + MCA → primaryPaths: ['established_funding'], sub-variant 'both'. select_product step is active.
  • Client hits "Qualified - Pending Seasoning"primaryPaths: ['parked'], parkedReason: 'pending_seasoning', parkedCallbackDate: now + 6 months.
  • Client has too many recently-opened accounts → primaryPaths: ['parked'], parkedReason: 'too_many_new_accounts', parkedCallbackDate: now + 6 months. Parked-email warns against opening any new accounts during the wait.
  • Client has a thin credit file → primaryPaths: ['parked'], parkedReason: 'thin_file', parkedCallbackDate: now + 6 months. Parked-email coaches on actively building credit.
  • Client’s credit file couldn’t be pulled ("Credit Not Found" reportStatus) → primaryPaths: ['parked'], parkedReason: 'thin_file' (routed by existing convex/lib/fundingPath.ts). Same UX as any other thin-file client.
  • Client qualifies for nothing, no time-based recovery → primaryPaths: ['decline'], declineReason: 'no_fit'.
  • Client’s personal stated income is too low to support any product → primaryPaths: ['decline'], declineReason: 'insufficient_income'.
  • Duplicate record detected → primaryPaths: ['decline'], declineReason: 'duplicate_file'.
  • Operator closes file for a reason not in the enum → primaryPaths: ['decline'], declineReason: 'other', declineReasonNotes: '<operator text>'.

GHL Pipeline Stage Mapping

The workflow engine is the source of truth for “where this file is.” GHL pipeline stages mirror that state via sync — they’re downstream, not authoritative. Mapping (draft — exact GHL stage names TBD with Brock; names below are placeholders to be reconciled against the actual pipeline config):
Primary pathGHL stage
(pre-workflow, manual review)“Manual Review” / “Needs Review” stage (confirm exact name in current pipeline)
card_funding active”Funding - Card Stacking” stage (confirm name)
term_loan active”Funding - Term Loan” stage (confirm name)
established_funding active”Funding - MCA/SBA” stage (confirm name; sub-variant determines nuance)
parked (all reasons)NEW stage — name TBD (candidates: “Parked - Callback Scheduled”, “Funding Queue”). One stage covers all parked reasons; parkedReason is a contact field/tag, not a separate stage.
declinerepair_referral”Credit Repair Referral” stage if it exists, otherwise decline stage + repair_referral tag
decline — other reasons”Declined” / “Closed Lost” stage (confirm name)
New stage needed:
  • One stage for the parked primary, covering pending_seasoning, too_many_new_accounts, thin_file, and any future parkedReason values. Funding Queue mode (Brock’s feature) operates on this stage, filtering by parkedReason + parkedCallbackDate as needed.
  • TODO (Kevin): pick the stage name. “Pending Seasoning” no longer fits since multiple reasons route through it.
Why one stage for all parked reasons: the operator workflow in Funding Queue is the same regardless of reason (callback → re-underwrite → engage or roll callback). Separating stages per reason would add CRM config without adding value. The reason drives messaging and callback date, not pipeline location. Implementation notes:
  • mark_parked step is the write point — atomic update: set GHL stage + parkedReason tag + parkedCallbackDate.
  • Sync direction is engine → GHL, not the other way. If an operator manually moves a contact in GHL, the engine’s workflow field should take precedence on next underwriting run.
  • TODO with Brock: confirm the stage name, Funding Queue filter criteria (by reason? by date? by operator ownership?), and whether the Queue needs any additional fields on the contact to function.

Decisions Log

Entries are chronological within 2026-04-23. Where a later entry supersedes an earlier one, the earlier entry is marked with strikethrough and a pointer rather than deleted — this preserves the reasoning trail so future readers can see why the design evolved. When scanning for the current state, trust the latest entry on any given topic.
  • 2026-04-23: Decided to use primary path + overlay model instead of flat 7-enum. Collapses existing 3 templates into card_funding primary.
  • 2026-04-23: Credit repair referral partner is ASAP Credit Repair USA; operators sign up as affiliates to get attributed commissions.
  • 2026-04-23: Partner submission is manual email today — no API integrations in the FM Lender DB. The workflow engine should not assume automated submission.
  • 2026-04-23: 7 Figures Funding confirmed as the card-stacking partner.
  • 2026-04-23: Term loan promoted to primary, not overlay. Rename term_loan_onlyterm_loan. Client can have multiple primaries active; primaryPaths is a list. Term loan sequences before card_funding per existing code invariant. Eliminates the “term loan is both primary and overlay” asymmetry.
  • 2026-04-23: Credit repair = decline, full stop. No “Qualified with credit repair” state. creditRepairFlag on any underwriting evaluator collapses to a decline with reason: 'repair_referral'. Supersedes the earlier “credit repair can run parallel” and “credit repair referral is a side-channel overlay” working assumptions. The credit_repair_referral overlay is removed; its steps live directly in the decline primary spine under declineReason: 'repair_referral'.
  • 2026-04-23: MCA vs SBA: one primary (variant A). established_funding with sub-variant 'mca' | 'sba' | 'both'. MCA-only and SBA-only are handled by collapsing select_product to a no-op and rendering the product-specific sub-path.
  • 2026-04-23: pending_seasoning, thin_file, and credit_not_found are all decline reasonsSUPERSEDED later same day. Original call was to treat all three as decline reasons. Revised across three later entries: pending_seasoningparked (Funding Queue context), thin_fileparked, credit_not_found → collapses into thin_file via existing fundingPath.ts routing. Kept here to preserve the reasoning trail; see superseding entries below for the final state.
  • 2026-04-23: Manual review is pre-workflow, not a workflow state. Manual Review - * underwriting decisions surface on NextActionCard with the existing ReviseUnderwritingDialog. Operator override → re-underwrite → new primary paths. Operator close → decline (standard reasons). manual_review_blocked is NOT a decline reason. workflow on the underwriting doc is null while in manual review.
  • 2026-04-23: Cross-track UX: operator chooses. When client qualifies for both Creative (card_funding / term_loan) and Established tracks, primaryPaths contains both and the UI shows two start buttons. Once both are started, tabbed UI — Tab 1: Creative, Tab 2: Established. Within a track, primaries still sequence automatically (term_loan → card_funding).
  • 2026-04-23 (later): pending_seasoning moved from decline reason to new parked primary. Context: Brock is implementing a Funding Queue mode that lets operators revisit parked clients after their callback date (default 6 months for seasoning). parked is a new exclusive primary, adds parkedReason and parkedCallbackDate to the workflow shape. Supersedes the earlier “pending_seasoning is a decline reason” call.
  • 2026-04-23 (later): GHL pipeline stage mapping is an explicit concern. Each primary path maps to a GHL stage; new stage needed for parked. Engine is source of truth; GHL is downstream via sync. Exact stage name TBD.
  • 2026-04-23 (later still): thin_file moved from decline → parked. Remedy is time-based (client builds credit); Funding Queue callback is the right mechanism. Repair referral stays in decline for now (deferred decision).
  • 2026-04-23 (later still): Added too_many_new_accounts as a parkedReason. Distinct from pending_seasoning — same “wait” remedy but different client messaging (explicit “don’t open any new accounts” guidance). Underwriting signal source TBD during Convex-side changes.
  • 2026-04-23 (later still): All parked reasons share ONE GHL pipeline stage. parkedReason is stored as a contact field/tag, not a pipeline stage. Rationale: operator workflow is identical per reason; separate stages would add CRM config without value.
  • 2026-04-23 (later still): credit_not_found is NOT a decline reason. Existing convex/lib/fundingPath.ts:86 already routes "Credit Not Found" reportStatus → "thin_file" path (test at tests/underwriting.test.ts:566 enforces this). Under the new model, that maps to primaryPaths: ['parked'] with parkedReason: 'thin_file'. Supersedes the earlier “credit_not_found is a decline reason” entry in the enum.
  • 2026-04-23 (later still): Prior art discovered: convex/lib/fundingPath.ts::determineFundingPath() already exists with a more granular taxonomy (prime / near_prime / thin_file / seasoning / credit_repair / mca_nuclear).
  • 2026-04-23 (later still): Decision: replace determineFundingPath(), don’t wrap it. Consolidating to a single source of truth is the primary goal of this refactor; wrapping would recreate the exact overlap we’re eliminating. deriveWorkflow() absorbs the routing semantics (Credit Not Found → thin_file, hard-stop precedence, thin-file-before-DQ ordering); tests migrate to assert on the new shape; fundingPath.ts is deleted once callers migrate. Supersedes an initial “wrap first, consolidate later” recommendation.
  • 2026-04-23 (Tier-2 term_loan pass): Spine reshaped. Step order is now: approve → agreement → generate offers (Engine API) → review-and-select WITH client (before any email goes out) → send link to selected offer → client accepts (Engine portal) → wait for funding → next steps. The old submit_to_lender step is gone — Engine handles submission via the generate-offers API call and the client’s acceptance.
  • 2026-04-23 (Tier-2 term_loan pass): Engine by MoneyLion is the term-loan integration partner. First API integration inside the workflow engine — everywhere else (MCA, SBA, card stacking) still runs on manual email. Supersedes the earlier blanket “no API integrations today” in Partners & Referrals for term loans specifically. Engine docs: https://engine.tech/docs/api-reference/#personal-loans, dev center: https://engine.tech/developer-center. Webhook/status endpoints for acceptance and funding confirmation need to be verified from Engine docs — operator-confirmed fallback is the interim path.
  • 2026-04-23 (Tier-2 term_loan pass): paydowns_required overlay does NOT apply to term_loan (definitive). Operational context: ~80% of the time, the term loan IS the mechanism for funding paydowns on a combined card_funding + term_loan workflow — term loan proceeds pay down balances, freeing utilization for card stacking. When both primaries are active, paydowns_required overlay stays on card_funding but its messaging conditionally references the incoming term loan proceeds.
  • 2026-04-23 (Tier-2 invoicing pass): Invoicing is first-class workflow, not just bookkeeping. Both term_loan and card_funding spines gain a collect_invoice step firing after funding (term loan) / finalization (cards). Operator → client invoicing uses GHL invoicing, already built into the codebase (existing onSendInvoice callback). Fee is typically 9% of total funding received per product. FM → operator billing (15% of operator invoice on FM-processed portions) is internal reconciliation, not a workflow step.
  • 2026-04-23 (Tier-2 invoicing pass): One consultancy agreement across all primaries. send_agreement de-duplicates — client signs one template regardless of which primaries activate; all active primaries see the step as complete.
  • 2026-04-23 (Tier-2 invoicing pass): card_funding post-stacking flow expanded from a single send_results_next_steps step into five: deliver_funding_plan, track_card_statuses (daily follow-up loop, soft cutoff prompt at day 7), collect_invoice. The existing single step was under-representing a ~week-long operator phase.
  • 2026-04-23: deliver_funding_plan is a trigger-only step — Convex already generates per-card next-steps content server-side; the workflow step is just the button that fires the email. Do NOT build a composition UI. Flagged explicitly to prevent Claude Code from over-scoping this step during implementation.
  • 2026-04-23 (Tier-2 established_funding pass): Established funding is LENDER-PAID, not client-paid. Lender pays the operator a commission/referral fee directly; there is no client-side invoice. Shapes the spine significantly — after submit_to_partner we hand off to the lender and the workflow is short. Closure is record_payment_received where the operator logs the received commission manually in GHL.
  • 2026-04-23 (Tier-2 established_funding pass): Spine finalized to 7 steps: approve → agreement → select_product (MCA/SBA/both) → gather_documentation → submit_to_partner (system-filtered shortlist, operator confirms) → handoff_to_lender → record_payment_received.
  • 2026-04-23 (Tier-2 established_funding pass): Lender picking is system-filtered + operator-confirmed. System filters FM Lender DB by sub-variant + eligibility criteria (revenue, FICO, TIB); operator picks from the shortlist. Matches the “system filters, operator confirms” pattern; same model should likely extend to term_loan’s submit_to_lender once Engine integration matures.
  • 2026-04-23 (Tier-2 established_funding pass): Overlays confirmed NO for established_funding. Neither paydowns_required (card-specific) nor inq_removal_required (credit-score-specific; established is revenue/TIB-based) applies. Overlay applicability matrix updated.
  • 2026-04-23 (Tier-2 decline pass): Decline is terminal — no nurture, no follow-up. Declined files close immediately after the decline email. If a previously-declined client returns later, they re-enter underwriting as a new lead. Removes the earlier “nurture_followup” and “long_dated_followup” step placeholders.
  • 2026-04-23 (Tier-2 decline pass): One generic decline email template covers all decline reasons. No per-reason variants to maintain. Template may not exist today; implementation pass will check and write if needed.
  • 2026-04-23 (Tier-2 decline pass): Decline reasons finalized: repair_referral, no_fit, insufficient_income, duplicate_file, other (with freeform declineReasonNotes). Fraud was explicitly NOT enumerated as a distinct reason — operators can use other with notes if needed.
  • 2026-04-23 (Tier-2 decline pass): repair_referral stays in decline, does NOT move to parked even with Funding Queue available. Rationale: ASAP takes over the client relationship, stale files in the queue add noise without value.

2026-04-27 — pre-implementation refinements

These entries reflect decisions made during the Convex-side implementation pass (writing deriveWorkflow()). Where they supersede earlier calls, the earlier entries above are not deleted — read both for context, but trust the later entry.
  • 2026-04-27: "Credit Not Found"workflow: null (pre-workflow), NOT parked / thin_file. Supersedes 2026-04-23 entries at lines ~389, 421, 437, 489, 513. Rationale: Credit Not Found means the bureau couldn’t match the submitted PII to an existing file — it’s a data-mismatch issue requiring operator action (verify info and re-pull), not a credit issue. The client may have plenty of credit; we just couldn’t find it. Treating it as thin_file mis-routed it as if the client had no credit history. UI was already correctly handling this via the recent c370942 / fe61396 commits (“Re-try Credit Pull” prompt); the Convex-side routing now matches.
  • 2026-04-27: Dropped the “credit-repair conditions = decline” blanket rule from this pass. Supersedes the 2026-04-23 entry “Credit repair = decline, full stop.” Rationale: a sanity-audit query (convex/audits.ts::countQualifiedWithRepairFlag) found only 2 of 4,312 underwriting results have the “Qualified + creditRepairFlag” combo (0.05%) — the doc’s premise that this combo is common was wrong. Closer review showed: of those 2, one is a strong file (722 FICO with a single old 30-day late) where forcing a decline would unfairly reject a fundable client; the other (677 FICO, 10 30-day lates, 1 60-day late) appears to be an independent classification bug in cardUnderwriting.ts that the strict rule would mask rather than fix. Implication: evaluators (cardUnderwriting, termLoanUnderwriting, establishedUnderwriting) keep emitting creditRepairFlag as today on qualified decisions. deriveWorkflow() routes decline / repair_referral only when reportStatus is already "Doesn't Qualify" with a credit-repair-class declineReason (Recent Bankruptcy, Recent Severe Late Payment History, Credit Score Too Low, Too Many Collections/Charge-Offs, Too Many Late Payments). Separate ticket opened to investigate the 677-FICO mis-classification.
  • 2026-04-27: too_many_new_accounts and too_many_recent_inquiries collapsed into a single parkedReason: recent_credit_activity. Same callback messaging (wait for activity to age, don’t open new credit), no operational distinction worth two enum values. The “Too many recent inquiries” decline branch in cardUnderwriting.ts is also being deleted in this pass — the path is reachable only in narrow multi-issue cases where a more dominant decline reason exists, and “Qualified w/ Inq Removal” already handles the inquiries-as-only-issue case.
  • 2026-04-27: parkedReason enum finalized: pending_seasoning | recent_credit_activity | thin_file (3 values). Supersedes prior 4-value working set that included separate too_many_new_accounts and too_many_recent_inquiries entries.
  • 2026-04-27: declineReason enum finalized: repair_referral | no_fit | insufficient_income | duplicate_file | other (5 values, unchanged from 2026-04-23 Tier-2 decline pass). too_many_new_accounts is NOT in this enum — it routes to parked / recent_credit_activity instead of decline.
  • 2026-04-27: Callback formula for recent_credit_activity: parkedCallbackDate = max(youngestAccountOpenedDate + 13 months, today + 30 days). The 13-month pad gives a 1-month buffer past the rolling 12-month FICO velocity penalty so the credit pull on callback unambiguously shows the issue resolved. Floor at +30 days prevents scheduling a callback in the past for accounts that just barely tripped the rolling window. Inquiry dates are NOT consulted (inquiries-only-decline path is being removed in this pass).
  • 2026-04-27: workflow field is added alongside the existing workflowPath field — both coexist. Naming clarification for future readers: workflowPath (existing) is a string union ("prime" | "paydowns" | "inq_removal" | "seasoning" | null) consumed by the existing FundingWorkflow templates; workflow (new) is the unified shape ({ primaryPaths, overlays, declineReason?, parkedReason?, parkedCallbackDate? } | null) that the next-generation router will read. They represent different generations of the same idea. workflow is emitted but inert this pass; client rewires to read it later, at which point workflowPath deprecates.
  • 2026-04-27: buildSystemTwoNextAction() and hasCreditRepairRelatedAction() are absorbed into deriveWorkflow(), not deleted outright. Earlier discovery flagged prime_approval / paydown_strategy / mca_option / credit_repair_required action types as having registered renderers in components/funding/FundingRecommendations.tsx (4 renderers + caution-state swap logic). Deleting the helpers without rewiring the UI would break those renderers. Mitigation: deriveWorkflow() keeps emitting the same action types into the existing top-level nextActions[] field while ALSO emitting the new workflow shape. UI stays unchanged this pass; deprecation happens when the client rewires off nextActions[] action-type strings.
  • 2026-04-27: NextAction and PaydownRecommendation types relocate to lib/types/underwriting-result.ts BEFORE convex/lib/fundingPath.ts is deleted. Both types are imported by cardUnderwriting.ts, termLoanUnderwriting.ts, estimateBuilder.ts, and convex/underwriting.ts. Deletion order matters — relocation is its own step (3a in implementation order).
  • 2026-04-27: Zoho route app/api/zoho/credit-report/route.ts aligned in this pass. Has its own duplicated cards decline ladder; 'Too many recent inquiries' decline reason removed from line 1047 to keep the two underwriting paths emitting consistent reasons. The TL 'TL Declined - Too Many Recent Inquiries' at line 1096 stays — different concept (TL decision vs. cards decline reason).
  • 2026-04-27 (post-review refinements): Three follow-ups from a code review of the routing engine:
    • Inq-removal overlay no longer double-attaches. Previously, when both cards and TL emitted a “Qualified w/ Inq Removal” status, deriveWorkflow() pushed two separate inq_removal_required overlays — one for card_funding, one for term_loan. The combined-case rule (line 429 of this doc) is that the overlay attaches to term_loan only, because TL runs first, the dispute happens before TL submission, and card_funding picks up the already-improved profile. Fixed in convex/lib/deriveWorkflow.ts.
    • decline / repair_referral is not strictly exclusive across tracks. Codex flagged that a file can route as card_funding declined (credit-repair-class reason) while simultaneously becoming an active term_loan or established_funding workflow — the doc’s “decline is exclusive” rule isn’t enforced. This is the documented behavior, not a bug. The audit-driven scope cut (2026-04-27 entry above) explicitly dropped the “credit-repair conditions block all funding paths” rule because (a) the Qualified + creditRepairFlag combo affects 0.05% of files, and (b) when one product (cards) is declined for credit-repair-class reasons but another (term loan) qualifies, the operator can legitimately pursue the qualified product. The strict exclusivity rule would over-decline. Future readers: if you want to re-enable strict exclusivity, the change is in the evaluators (termLoanUnderwriting, establishedUnderwriting) — they must zero out their qualifications when credit-repair signals are present, not in deriveWorkflow().
    • Trace narration still uses the legacy fundingPath string. buildPathRationale() in convex/lib/buildUnderwritingTrace.ts (and the AI prompt builders in aiPrompts.ts) reads the legacy 5-value taxonomy, so a file routed under the new model as parked / recent_credit_activity is still narrated as “credit repair path.” Comment in code documents the gap; the fix lands with the client-rewire pass that consumes workflow for UI routing — same pass should update narration, since both surfaces (UI cards + AI prompts) need the new taxonomy together.

Open Questions

Structural (all Tier-1 calls resolved 2026-04-23 — see Decisions Log)
  • “Both” track handling — resolved: two start buttons + tabs when both started.
  • MCA vs SBA — one primary or two? — resolved: variant (A), one primary with 'mca' | 'sba' | 'both' sub-variant.
  • pending_seasoning — decline reason or separate primary? — resolved: parked primary (moved out of decline reasons when Funding Queue entered scope). thin_file also moved to parked; credit_not_found maps to thin_file via existing fundingPath.ts routing.
  • manual_review_blocked — workflow concern or upstream? — resolved: upstream, pre-workflow, handled by existing ReviseUnderwritingDialog.
Remaining structural / scope
  • Equipment financing / Real Estate primary paths? FM Lender DB has partners but no primary path planned. In scope eventually, or out of scope for this refactor?
  • Partner selection: operator-picks vs routing logic? Today operators pick manually. Should the engine surface filtered recommendations?
  • Should repair_referral move to parked? Earlier decision was “hand off and forget,” but Funding Queue makes callbacks cheap. Worth revisiting whether we auto-resurface repair referrals at 6–12 months. Deferred.
  • Other parkedReason values? Likely candidates as business-funding support grows: pending_tib (business needs more time in business), pending_revenue (deposit history not yet sufficient). Enumerate as needed.
  • GHL stage name for parked primary — Kevin to pick. Candidates: “Parked - Callback Scheduled”, “Funding Queue.”
  • Default callback intervals per parkedReason — all 6 months for now. Kevin may want to tune (e.g. shorter for thin_file clients who are actively building credit).
  • recent_credit_activity — exact underwriting signal? Likely maps to an existing “Manual Review - Too Many Recent Inquiries” or inquiry-count threshold in cardUnderwriting.ts. Confirm during underwriting pass.
  • Funding Queue filter / UI specifics — Brock owns this. Worth syncing with him once his scope is firm so the engine’s parked shape supports what the Queue needs (e.g. does Queue need parkedReason visible? a freeform note? priority?).
Ops detail needed per primary (details inside each primary’s section)
  • term_loan: offer delivery / acceptance / lender choice / funding trigger / paydowns applicability / agreement reuse / invoicing — resolved Tier-2, 2026-04-23. Remaining sub-TODOs: Engine webhook verification, confirm existing Generate-Offers button wiring.
  • card_funding: post-stacking flow / invoicing / follow-up loop — resolved Tier-2, 2026-04-23.
  • established_funding: docs required per sub-variant, partner routing, offer-presentation mechanism, whether we stay in the flow post-handoff, invoicing trigger — resolved Tier-2, 2026-04-23. Remaining sub-TODOs: SBA doc list (MCA is 3–6 months bank statements; SBA still to specify), agreement clause for lender-paid compensation model, lender DB eligibility-criteria data model for the filter logic.
  • decline: decline email templates, nurture cadence existence/automation, full set of decline reasons — resolved Tier-2, 2026-04-23. One generic email template, no nurture, 5 reasons finalized (repair_referral, no_fit, insufficient_income, duplicate_file, other + notes).
  • Cross-primary: which agreement template applies — resolved: one agreement reused across all primaries.
Engine API TODOs (Kevin to verify from Engine docs)
  • Does Engine expose a webhook on client acceptance? If yes, what event name / payload?
  • Does Engine expose a webhook or status field on loan funding / disbursement? If yes, we can auto-advance wait_for_fundingsend_next_steps without operator intervention.
  • Is the existing “Generate Term Loan Offers” button already wired to Engine, or is the Engine integration still to-build? Affects whether step 3 on the term_loan spine is a refactor or a net-new integration.
Overlay applicability matrix
Overlaycard_fundingterm_loanestablished_fundingdecline
paydowns_required
inq_removal_required

Next

  1. Collapse credit repair into decline — done.
  2. Promote term_loan to primary — done.
  3. Tier-1 structural calls (MCA/SBA split, pending_seasoning, manual_review, cross-track UX) — done 2026-04-23.
  4. Tier-2: ops walkthroughs per primary — done 2026-04-23.
    • card_funding: post-stacking flow, invoicing, follow-up loop — done.
    • term_loan: offer delivery, lender integration (Engine), paydowns applicability, invoicing — done.
    • established_funding: docs, partner routing, handoff model, lender-paid compensation — done.
    • decline: email template, nurture, reasons enum — done.
  5. Finalize overlay applicability matrix — done 2026-04-23.
  6. Remaining small TODOs before the Convex pass:
    • Engine API webhook verification (acceptance + funding events) — Kevin to check Engine docs.
    • Confirm whether the existing “Generate Term Loan Offers” button already calls Engine or needs the integration built.
    • SBA doc list — Kevin.
    • Agreement template clause for lender-paid (established_funding) compensation — Kevin / legal.
    • Lender DB eligibility-criteria schema for submit_to_partner filter logic.
    • Confirm fee % storage location (operator record? per-file? hardcoded?).
  7. Kevin: apply underwriting changes via Claude Code — see “Underwriting Changes Required” section. This is the big Convex-side pass: write deriveWorkflow(), migrate determineFundingPath() callers, migrate tests, delete fundingPath.ts.
  8. Once underwriting emits the new shape: build the client-side router that replaces canStartFundingWorkflow, rewire AddressTheseFirstCard / NextActionCard off direct flag reads, and add the cross-track start-buttons + tabbed UI.
  9. Sync with Brock on Funding Queue — stage name, filter criteria, any extra contact fields. Gated on his scope firming up.