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 + organizationId with score, scoreBreakdown, and status.
  • Match status — Seven states: suggested, approved, introduced, in_progress, successful, rejected, expired. Transitions are enforced server-side (see below).
  • matchedByalgorithm (created via Find Investors / scoring) or manual (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).

FromCan move to
suggestedapproved, rejected, expired
approvedintroduced, rejected, expired
introducedin_progress, rejected, expired
in_progresssuccessful, rejected, expired
successful(terminal — no further transitions)
rejectedsuggested (reopen for re-evaluation)
expiredsuggested (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 generateMatchesActionservices.generateMatches(opportunityId, userId):

  1. Loads the opportunity and, if linked, its organization (country, cluster, name are exposed to scoring under the organization.* path prefix).
  2. Builds a scoring source object from the opportunity's sector, cluster, investmentType, estimatedValueUsd, and metadata.
  3. Loads every investor profile and every enabled, non-archived scoring dimension (see Matching and scoring configuration).
  4. Scores all candidates with scoreAllCandidates and writes a Match row per candidate.
  5. Regenerate-safe: re-running Find Investors for the same opportunity does not clobber human decisions. Only matches still in suggested status are re-scored; matches already approved/rejected/etc. are left untouched. New candidates get a suggested history 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 status and by opportunityId (query params) via the FilterBar.
  • 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_history list 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-text followUpAction note.
  • Participants tab — add/remove participants with name, optional email, and a role of investor, opportunity_rep, facilitator, or observer. Each participant has an attended flag (shown as a badge).
  • Send calendar invites — shown only if at least one participant has an email. Generates an .ics attachment (when the meeting has a scheduledAt) and emails each participant with venue/link details via sendB2bMeetingInvites. This is an explicit action — invites are never sent automatically. Returns { sent, failed } counts; requires at least one participant with an email or throws No 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 meeting completed, marks the linked match (if any) successful, and creates a new pipeline opportunity titled "{meeting.title} — deal flow", linked to the meeting's organization, with source: "b2b_meeting:{meetingId}". The new opportunity's id is stored back on the meeting's metadata as promotedOpportunityId and surfaced as a "Sourced opportunity" link on the meeting detail page.
  • no_match — marks the linked match rejected. Requires the meeting to have a linked match (This meeting has no linked match to reject. if not) and requires a non-empty reason (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

ActionMinimum role
Upsert investor profilestaff
Generate matches (Find Investors)staff
Update match status (approve/reject/etc.)staff
Create manual matchstaff
Schedule / update B2B meetingstaff
Add / remove meeting participantsstaff
Send calendar invitesstaff
Promote meeting outcomestaff
Create event follow-upstaff
Manage scoring dimensionsadmin — 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

  1. Configure scoring dimensions before the first Find Investors run. Without enabled dimensions, scoreAllCandidates returns an empty list and no matches are created.
  2. 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.
  3. 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.
  4. Use manual matches sparingly. They skip scoring entirely and jump straight to approved — reserve them for known, out-of-band introductions.
  5. 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