Docs / Admin
Investor-opportunity matching and B2B meetings
Summary — Staff generate scored Match records between a pipeline opportunity and every investor profile in the system, then move each match through a status pipeline from suggested to successful (or rejected). Approved matches can be scheduled as B2B (bilateral) meetings, and meeting outcomes can promote a new pipeline opportunity or close out the match.
Purpose — Understand how matches are generated and scored, what each match status means and which transitions are legal, and how to schedule and track a B2B meeting from a match.
Audience — Staff, reviewer, lead, and admin roles working the matchmaking and facilitation pipeline stages.
Prerequisites — An opportunity exists in the pipeline (see The investment pipeline lifecycle) and at least one organization has an investor profile. Scoring dimensions should be configured first — see Matching and scoring configuration.
Overview
A Match pairs one opportunity with one investor organization and carries a score (0–100) plus a status. Matches are created either by the scoring algorithm ("Find Investors") or manually.
Key concepts:
- Match — A record linking
opportunityId+organizationIdwithscore,scoreBreakdown, andstatus. - Match status — Seven states:
suggested,approved,introduced,in_progress,successful,rejected,expired. Transitions are enforced server-side (see below). - matchedBy —
algorithm(created via Find Investors / scoring) ormanual(created directly by staff). - B2B meeting — A bilateral meeting record, optionally linked to a match, with its own status and participants.
- Investor profile — The organization-level record (
sectorsOfInterest,investmentTypes,targetGeographies,targetClusters, min/max investment USD,counterpartyType,preferredInstruments,stagePreferences,investmentSizeBand) that the scoring engine reads as the match target.
Screenshot: Match Suggestions page (
/matching) showing the "Generate Matches for Opportunity" form and a table of scored matches sorted by score.
Match statuses and legal transitions
Statuses and transitions are centralized in MATCH_STATUS_TRANSITIONS (src/lib/modules/matching/status-machine.ts) rather than duplicated in the UI (project rule 15).
| From | Can move to |
|---|---|
suggested | approved, rejected, expired |
approved | introduced, rejected, expired |
introduced | in_progress, rejected, expired |
in_progress | successful, rejected, expired |
successful | (terminal — no further transitions) |
rejected | suggested (reopen for re-evaluation) |
expired | suggested (reopen for re-evaluation) |
Rejection requires a reason. Moving a match to rejected with an empty or missing notes field is blocked both client-side (Zod refinement on updateMatchStatusSchema) and server-side (assertValidTransition in the status machine). The exact error is:
A reason is required when rejecting a match.
Illegal transition error:
Cannot move a match from "<from>" to "<to>". Allowed: <list>.
or, if the match is terminal:
Match is "<from>" (terminal) and cannot change status.
Every status change is written to match_status_history (fromStatus, toStatus, changedBy, notes, timestamp) and logged to audit_events under match.status_updated.
Generating matches ("Find Investors")
Path: Opportunity detail → Communications tab → Find Investors, or directly on the Match Suggestions page (/matching) via the "Generate Matches for Opportunity" form.
Calls generateMatchesAction → services.generateMatches(opportunityId, userId):
- Loads the opportunity and, if linked, its organization (
country,cluster,nameare exposed to scoring under theorganization.*path prefix). - Builds a scoring source object from the opportunity's
sector,cluster,investmentType,estimatedValueUsd, andmetadata. - Loads every investor profile and every enabled, non-archived scoring dimension (see Matching and scoring configuration).
- Scores all candidates with
scoreAllCandidatesand writes aMatchrow per candidate. - Regenerate-safe: re-running Find Investors for the same opportunity does not clobber human decisions. Only matches still in
suggestedstatus are re-scored; matches alreadyapproved/rejected/etc. are left untouched. New candidates get asuggestedhistory row; re-scored existing matches do not get a duplicate history row.
After generation, the server redirects to /matching?opportunityId={id} — a filtered, focused view of just this opportunity's matches, with a "Show all matches" link back to the global list.
There is also a reverse direction, generateMatchesForInvestorAction (generateMatchesForInvestor(organizationId, userId)), which scores one investor profile against every active (non-archived) opportunity and creates a top-1 match per opportunity. This is not wired to a dedicated UI button in the routes reviewed — it is exposed as a callable action only.
Every generation run writes an audit event (matches.generated or matches.generated_for_investor) recording the count of matches created/rescored.
Reviewing and acting on matches
Path: /matching (Match Suggestions list) → click a row → /matching/[id] (match detail).
The list page (/matching):
- Shows opportunity title, organization name, score, and status per match, enriched from the pipeline and stakeholders modules.
- Supports filtering by
statusand byopportunityId(query params) via theFilterBar. - Links to B2B Meetings (
/matching/meetings).
The match detail page (/matching/[id]) shows:
- An Entity Header with the score (
Score: {score}/100 · Matched by {matchedBy}) and current status. - Opportunity and Investor Organization cards with quick links back to
/pipeline/{id}and/stakeholders/{id}. - A Score Breakdown card — one bar per scoring dimension key, sourced from
match.scoreBreakdown, only rendered if the breakdown is non-empty. - Tabs: Actions (status update form + notes), History (immutable
match_status_historylist with from → to and notes), B2B Meetings (meetings linked to this match, plus a form to schedule a new one).
Approving or rejecting: Use the Update Status form in the Actions tab. Selecting rejected requires a non-empty reason in the notes field or the server rejects the submission with the verbatim error above.
Manual matches
Staff can create a match directly (bypassing the scoring algorithm) via createManualMatchAction (opportunityId, organizationId, optional notes). Manual matches are created with score: 0, matchedBy: "manual", and are immediately transitioned to approved status (skipping suggested) — this reflects that a human, not the algorithm, is vouching for the pairing. There is no dedicated UI form for this in the routes reviewed; it is available as a server action.
Scheduling and managing B2B meetings
Path: Match detail → B2B Meetings tab → "Schedule B2B Meeting" form, or /matching/meetings for the full log.
A B2B meeting (ScheduleB2bMeetingInput) captures: optional matchId (links back to a match), required opportunityId and organizationId, title, and optional scheduledAt, durationMinutes (5–480), venue, locationUrl.
Meeting statuses: scheduled, completed, cancelled, no_show.
Meeting detail page (/matching/meetings/[id]) provides:
- Status & Outcome form — update status,
outcomeNotes, and a free-textfollowUpActionnote. - Participants tab — add/remove participants with
name, optionalemail, and aroleofinvestor,opportunity_rep,facilitator, orobserver. Each participant has anattendedflag (shown as a badge). - Send calendar invites — shown only if at least one participant has an email. Generates an
.icsattachment (when the meeting has ascheduledAt) and emails each participant with venue/link details viasendB2bMeetingInvites. This is an explicit action — invites are never sent automatically. Returns{ sent, failed }counts; requires at least one participant with an email or throwsNo participants with an email address to invite. - Promote Outcome — see below.
- Create Follow-up — see Investment facilitation for the follow-up tracker this feeds.
Promoting a meeting outcome
The Promote Outcome form on the meeting detail page calls promoteMeetingOutcomeAction with a decision of deal_flow or no_match:
deal_flow— marks the meetingcompleted, marks the linked match (if any)successful, and creates a new pipeline opportunity titled"{meeting.title} — deal flow", linked to the meeting's organization, withsource: "b2b_meeting:{meetingId}". The new opportunity's id is stored back on the meeting's metadata aspromotedOpportunityIdand surfaced as a "Sourced opportunity" link on the meeting detail page.no_match— marks the linked matchrejected. Requires the meeting to have a linked match (This meeting has no linked match to reject.if not) and requires a non-emptyreason(A reason is required to mark a meeting as no-match.). Never auto-creates anything.
Both paths log an audit event (b2b.promoted_to_deal_flow or b2b.no_match).
Roles and permissions
| Action | Minimum role |
|---|---|
| Upsert investor profile | staff |
| Generate matches (Find Investors) | staff |
| Update match status (approve/reject/etc.) | staff |
| Create manual match | staff |
| Schedule / update B2B meeting | staff |
| Add / remove meeting participants | staff |
| Send calendar invites | staff |
| Promote meeting outcome | staff |
| Create event follow-up | staff |
| Manage scoring dimensions | admin — see Matching and scoring configuration |
All matching actions call requireRole("staff", { module: "matching" }) at minimum; there is no reviewer/lead-specific gate inside the matching module itself (unlike the investor-qualification workflow, which does require reviewer or lead for certain qualification transitions).
Best practices
- Configure scoring dimensions before the first Find Investors run. Without enabled dimensions,
scoreAllCandidatesreturns an empty list and no matches are created. - Re-run Find Investors as opportunity data changes. It is safe — it never overwrites a decision you've already made (approved/rejected/etc.), only re-scores matches still in
suggested. - Always give a reason when rejecting. It is enforced, and it is the only place the reason for a "no" is recorded for later reference.
- Use manual matches sparingly. They skip scoring entirely and jump straight to
approved— reserve them for known, out-of-band introductions. - Link B2B meetings to a match when one exists. This is what lets Promote Outcome automatically update match status.
Warnings
successful is terminal. No further status transitions are allowed once a match is marked successful.
Rejected/expired matches can be reopened, but only back to suggested. There is no direct path from rejected to approved.
Promoting to no_match requires a linked match. A meeting scheduled without a matchId cannot use the no_match decision.
Manual matches skip scoring feedback. Because score is 0 and scoreBreakdown is empty for manual matches, the Score Breakdown card will not render for them.
Troubleshooting
"A reason is required when rejecting a match."
Symptom: Submitting the Update Status form with status = Rejected and an empty notes field fails.
Cause: Both the Zod schema (updateMatchStatusSchema) and the server-side status machine (assertValidTransition) require non-empty notes on rejection.
Fix: Enter a reason in the notes field before submitting.
"Cannot move a match from ... to ..."
Symptom: Status update fails with an "Allowed: ..." list.
Cause: The target status is not a legal next state for the match's current status (see the transition table above).
Fix: Pick one of the allowed next statuses. If the match is successful, no further change is possible.
"No participants with an email address to invite"
Symptom: Clicking "Send calendar invites" fails immediately.
Cause: No participant on the meeting has an email address on file.
Fix: Add or edit a participant with a valid email in the Participants tab, then retry.
"This meeting has no linked match to reject."
Symptom: Promote Outcome with decision = "No match" fails.
Cause: The B2B meeting was scheduled without a matchId (for example, created directly from /matching/meetings rather than from a match detail page).
Fix: The no-match decision only applies to meetings linked to a match. If this meeting has no match, update its status manually instead.
FAQ
What is the difference between a match's score and scoreBreakdown?
score is the single composite weighted score (0–100). scoreBreakdown is a per-dimension map (e.g. {"sector": 100, "geography": 50}) shown as bars on the match detail page.
Can I re-generate matches for an opportunity that already has matches?
Yes — it is safe. Matches you've already approved, rejected, introduced, or moved further are left alone; only suggested matches are re-scored.
Does approving a match schedule anything automatically? No. Approving only changes status. Scheduling a B2B meeting is a separate, explicit step from the match detail page.
What happens to the linked match when a B2B meeting is marked "deal_flow"?
The linked match (if any) is set to successful, and a new opportunity is created from the meeting.
Related articles
- The investment pipeline lifecycle — how opportunities reach the
matchmakingstage that this article picks up from. - Investment facilitation — the qualified-investor, follow-up, meetings log, and metrics surfaces built on top of matches and B2B meetings.
- Matching and scoring configuration — how admins configure the scoring dimensions used by Find Investors.