Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 | 23x 23x 414x 23x 491x 473x 47x 426x 395x 160x 235x 210x 207x 23x 46x | import type {
REFERRAL_ACTOR_TYPES,
REFERRAL_STATES,
} from "../../db/schema/enums";
export type ReferralState = (typeof REFERRAL_STATES)[number];
export type ReferralActor = (typeof REFERRAL_ACTOR_TYPES)[number];
/**
* The referral state machine. SRS-PP-001 §6.2, FR-ST-1.
*
* ── Why an adjacency map and not an ordinal rank ─────────────────────────
* `src/lib/rrm/stage.ts` polices its ladder with `STAGE_RANK`, because RRM's
* stages really are a ladder: every legal move is "further along". This one is
* not. `draft` forks to `forwarded` or `expired`; `engaged` forks to `verified`
* or `rejected`; `contacted` forks to `quoted` or `lost`. A rank would happily
* accept `draft → converted`, which is the exact transition that pays ₹1,000
* for a referral nobody ever forwarded. So the legal edges are written out.
*
* ── Why transitions are rejected rather than clamped ─────────────────────
* FR-ST-1: an illegal transition is "rejected and logged, never coerced". A
* machine that silently snaps an illegal move to the nearest legal one turns a
* caller's bug into a money event, and leaves nothing behind to find it by.
* `decide()` therefore returns a reason, mirroring `decideStage`, so the caller
* can record *why* nothing happened.
*/
/** The main line, in order. Documentation for readers; not used to decide. */
export const REFERRAL_MAIN_LINE = [
"draft",
"forwarded",
"engaged",
"verified",
"contacted",
"quoted",
"converted",
] as const;
/**
* States nothing leaves.
*
* `expired` is terminal for the *referral*, not for the homeowner: FR-P-3.5
* tells the partner "Expired — you can add them again", which creates a new
* referral rather than reviving this one. Reviving it would resurrect a
* 72-hour-old `draft` whose `code` may already be in a WhatsApp thread.
*/
export const TERMINAL_REFERRAL_STATES = [
"converted",
"duplicate",
"rejected",
"expired",
"lost",
"opted_out",
] as const;
export type TerminalReferralState = (typeof TERMINAL_REFERRAL_STATES)[number];
export function isTerminalReferralState(
state: ReferralState,
): state is TerminalReferralState {
return (TERMINAL_REFERRAL_STATES as readonly string[]).includes(state);
}
/**
* Legal forward edges, transcribed from the §6.2 transition table.
*
* `duplicate` and `opted_out` are deliberately ABSENT here even though §6.2
* lists them as reachable from "any" — they are handled in `decide()` as
* from-anywhere rules, because writing them into all seven rows would let a
* future edit remove one row and silently create a state that cannot opt out.
*/
const LEGAL_TRANSITIONS: Record<ReferralState, readonly ReferralState[]> = {
// Partner submitted step 1. Nothing has been sent to anyone (FR-P-3.2).
draft: ["forwarded", "expired"],
// Partner tapped forward. The homeowner now has the link; we still have not
// messaged them.
forwarded: ["engaged"],
// The homeowner messaged US. Only the webhook can reach this (API-3).
engaged: ["verified", "rejected"],
// Qualified v2 satisfied (§7). ₹100 accrues at `held`.
verified: ["contacted"],
// Designer handoff done; the 24-hour dispute window is open.
contacted: ["quoted", "lost"],
// Quote issued.
quoted: ["converted", "lost"],
// Terminal states.
converted: [],
duplicate: [],
rejected: [],
expired: [],
lost: [],
opted_out: [],
};
export type ReferralDecision =
| { change: true; state: ReferralState }
| {
change: false;
reason: "unchanged" | "illegal" | "terminal" | "opted_out";
};
export type DecideOptions = {
/**
* True when the trigger is the homeowner themselves. This is the ONLY thing
* that releases `opted_out` — FR-ST-3 makes it "irreversible except by the
* homeowner". Ops cannot set it, and neither can a partner.
*/
viaHomeowner?: boolean;
};
/**
* Decides whether a proposed referral state should be written.
*
* Every state write goes through this.
*
* @param current the state on the row now
* @param proposed the state the caller wants to set
*/
export function decideReferralState(
current: ReferralState,
proposed: ReferralState,
options: DecideOptions = {},
): ReferralDecision {
if (current === proposed) return { change: false, reason: "unchanged" };
// Absorbing, and more strongly than RRM's `do_not_contact`: that one is
// released by any inbound message, because a prospect who messages us has
// re-opened the conversation. An opted-out HOMEOWNER has exercised a DPDP
// right, so only an act by that same person can undo it — never an inbound
// on some other thread, and never an operator tidying up a queue.
if (current === "opted_out") {
return options.viaHomeowner
? { change: true, state: proposed }
: { change: false, reason: "opted_out" };
}
// From-anywhere rules (§6.2 "any"), available from any live state.
//
// `opted_out` outranks `duplicate`: a homeowner who opts out while their
// referral is also a duplicate is opted out. Both suppress payout, but only
// one of them is a legal obligation.
if (proposed === "opted_out") return { change: true, state: proposed };
if (isTerminalReferralState(current)) {
return { change: false, reason: "terminal" };
}
if (proposed === "duplicate") return { change: true, state: proposed };
return LEGAL_TRANSITIONS[current].includes(proposed)
? { change: true, state: proposed }
: { change: false, reason: "illegal" };
}
/** Convenience wrapper for callers that only need the yes/no. */
export function canTransition(
current: ReferralState,
proposed: ReferralState,
options: DecideOptions = {},
): boolean {
return decideReferralState(current, proposed, options).change;
}
/**
* The states in which a referral has earned nothing and never will.
*
* Used by the ledger to refuse an accrual outright rather than creating one it
* would immediately have to reverse. `expired` is here because nothing was
* ever sent; `lost` is NOT, because FR-M's table is explicit that on `lost`
* "₹100 stands; ₹1,000 never accrues" — a lost deal keeps its verification
* money.
*/
export const NON_EARNING_STATES = [
"duplicate",
"rejected",
"expired",
"opted_out",
] as const;
export function canEverEarn(state: ReferralState): boolean {
return !(NON_EARNING_STATES as readonly string[]).includes(state);
}
|