Jasmine Frantz · 2026.10.27 · 8 min read
A small agency refers a client to one of its partners and earns a fee for it. They send the invoice for that fee from the finance screen, then open the dashboard. The setup card still tells them to send their first invoice.
The card was waiting for a click inside the checklist. The invoice already existed.
You get this bug whenever an onboarding checklist turns into a second place to record that something is done. The work happened in the product. The checklist just never heard about it.
We rebuilt the setup checklist in Introzy, the platform we build for running referral and partner programs, around that idea. It's the list a new customer's own team sees on the dashboard after their first referral with an expected value, showing what's left in setup. This post covers how it works out what you've already done, and how an org eventually leaves setup. The dashboard card and the events behind it get their own posts.
Onboarding in Introzy moves through three phases: activating, optimizing and engaged. An org starts optimizing once someone creates a referral with an expected value above zero. Phase 2 is the stretch after that, when we want to help them get the rest of their setup in place.
What "the rest" means depends on the org. We sort orgs into three rungs, and each rung's list is longer than its headline. A business that refers clients and earns a fee (refer_and_earn) starts with a fee plan and a first invoice. One that pays its own referrers (reward_referrers) starts by bringing partners in and attaching agreements. One running a whole network (run_your_network) has to invite partners onto a portal and get one of them to submit a referral.
Skipping this setup has a real cost. The referrals end up in Introzy, but who owes what, and who's been paid, still lives in a spreadsheet.
So the milestone list can't be hardcoded. It lives in one registry file, and resolvePhase2Milestones hands back the rows for a given rung. An org with no rung gets an empty list and the card renders nothing.
phase-2-milestones.ts
export function resolvePhase2Milestones(rung: OnboardingRung | null): Phase2Milestone[] {
if (rung == null) return []
const core = PHASE_2_CORE_ORDER[rung]
const extras = PHASE_2_EXTRA_ORDER[rung]
if (!core || !extras) return []
return [...core, ...extras].map(id => {
const row = PHASE_2_MILESTONES[id]
return { ...row, rungs: [...row.rungs] }
})
}Each row has an id, its copy, and a link to the screen where the work actually gets done. send_first_invoice links to /invoices. Reordering rows, or editing the copy, is a one-file change. Teaching a new row to notice work in the product takes a second file, the signal query. Without that, the row sits on the card until someone dismisses it.
The checklist never sends the invoice and never asks you to tick a box saying you did. It checks whether an invoice has already gone out.
"Sent" is a status on the invoice, scoped to the org you're signed into. A draft doesn't count, and neither does an invoice that's only ready to send, or one that's been voided. SENT, VIEWED, PARTIAL, PAID, REFUNDED and OVERDUE all do. That org id comes from the signed-in session every time. The route never reads one from the request.
phase-2-milestone-signals.ts
if (wanted.has('send_first_invoice')) {
checks.push(
prisma.invoice
.count({
where: {
organizationId,
status: { in: [...SENT_INVOICE_STATUSES] },
},
})
.then(count => {
if (count > 0) done.add('send_first_invoice')
})
)
}One sent invoice is enough. The resolver returns the set of milestone ids that are true right now, and it only runs the queries it's asked for. wanted holds the milestones that are still pending, so once a milestone is settled, it never touches the invoice table again.
None of this was new, as it turned out. Partner onboarding already worked out its steps from cheap per-signal counts, and Phase 2 reuses that pattern instead of inventing a second one.
Once the checklist sees a sent invoice, it writes that down and stops asking. We call this the latch. It's one JSON key, phase2:send_first_invoice, holding a completedAt timestamp.
The key lives on the per-user, per-org progress record that partner onboarding already used. Partner steps share that same JSON document, which is why the key carries a phase2: prefix: the two sets of keys can't collide.
Before the route calls the resolver, it builds the pending list. A milestone is pending if it has a signal, the user hasn't dismissed it, and nothing has latched it yet.
route.ts
const pendingSignalIds = milestones
.map(milestone => milestone.id)
.filter(isPhase2SignalId)
.filter(id => !isPhase2MilestoneDismissed(stepCompletions, id))
.filter(id => stepCompletions[phase2LatchKey(id)] === undefined)
if (pendingSignalIds.length > 0) {
const completedSignals = await resolveCompletedPhase2MilestoneSignals({
userId: user.id,
organizationId,
milestoneIds: pendingSignalIds,
})
if (completedSignals.size > 0) {
const completedAt = new Date().toISOString()
const latched = { ...stepCompletions }
for (const id of completedSignals) {
latched[phase2LatchKey(id)] = { completedAt }
newlyLatchedIds.push(id)
}
// …write `latched` back to the progress record
}
}The first checklist load that sees a sent invoice writes the key. Every load after that skips the query.
There's a tradeoff here, and we took it on purpose. If you delete the invoice, or void it, the milestone stays checked. The latch records that you did this once, not that the invoice still exists. The write only ever adds keys, and nothing in this path removes one. A checklist that un-ticks itself because someone cleaned up a test invoice would be more confusing than one that remembers.
Dismissing a milestone leaves a different mark. It writes a completedAt and a skippedAt on the same key, hides the row, and keeps it out of the pending list, so a dismissed milestone doesn't get queried either. A milestone that's already latched can't be dismissed.
One detail surprises people. Progress is stored per staff member, and the invoice belongs to the org. Partners never see this card. If two people on the staff open it, each of them gets their own latch the first time they load it, even though only one of them sent the invoice.
Ticking the last box doesn't end setup on its own. Moving an org out of Phase 2 is a separate decision, made by a small pure function.
phase-2-graduation.ts
export function decidePhase2Graduation(input: {
phase: 'activating' | 'optimizing' | 'engaged'
rungMilestoneCount: number
nonDismissedIncompleteCount: number
activatedAt: Date | null
now: Date
}): { graduate: false } | { graduate: true; reason: Phase2GraduationReason } {
if (input.phase !== 'optimizing') return { graduate: false }
if (input.rungMilestoneCount > 0 && input.nonDismissedIncompleteCount === 0) {
return { graduate: true, reason: 'all_complete' }
}
if (
input.activatedAt != null &&
input.now.getTime() - input.activatedAt.getTime() >= PHASE_2_GRADUATION_WINDOW_MS
) {
return { graduate: true, reason: 'thirty_day_cap' }
}
return { graduate: false }
}An optimizing org graduates in one of two ways. Either every milestone still on the list is latched, or 30 days have passed since it activated. Dismissed rows are already off the list, and they count as done, so skipping one can't keep an org stuck in setup forever.
The 30 days start at that activation, not at signup. Someone who took 25 days to create the referral shouldn't be pushed out after five days of Phase 2.
A few edge cases fall out of the function. An org that never activated never hits the cap. An org whose rung has no milestones doesn't graduate just because its incomplete count is zero. And the write that flips onboardingPhase from optimizing to engaged only applies if the row is still optimizing, so two loads racing each other can't graduate the same org twice.
We check that on every checklist load while the org is still optimizing and its rung has milestones. That load is what graduates a finished checklist. It misses the people the cap exists for: an org that has stalled isn't opening the dashboard. A daily job sweeps for those orgs and graduates them on the 30-day clock, without reading the checklist.
There's one number we kept out on purpose. The card also shows referral progress toward the Phase 2 goal: three referrals in 14 days. It would be easy to make that a graduation condition. We didn't. The count comes back on the same response so the card can show it, but it never decides when setup ends. An org can leave Phase 2 with three referrals or with none.
Once an org graduates, the checklist comes back as an empty list and the card disappears from the dashboard. It doesn't come back, even if something underneath changes later.
The checklist has a narrow job: notice work that already happened in the product, and remember that it noticed. Deciding when setup is over is a separate write on the org, with its own clock.
That's the part nobody sees. Next up is the part everyone does: the card on the dashboard, and what happens when a milestone ticks itself off while you're looking at it.
Introzy helps businesses run referral and partner programs, keeping referrals, fee agreements, invoices and payouts in one place. See how it works.
Introzy is free to start. No card required.