All files / lib/partners velocity.ts

100% Statements 10/10
100% Branches 4/4
100% Functions 1/1
100% Lines 9/9

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                                                                          3x                             3x 3x                                 47x   45x 45x 45x 37x     8x              
/**
 * The velocity flag (FR-F-5).
 *
 * > "Submissions/day exceeding the partner's stated monthly closings band
 * >  raises a flag for review (flag, not auto-block)."
 *
 * FLAG, NOT BLOCK — said twice in the requirement and honoured here: nothing in
 * this module refuses anything. A partner having a good week looks exactly like
 * a partner inventing referrals, and the difference is a judgement an operator
 * makes with the thread in front of them.
 *
 * THE DENOMINATOR IS OFTEN MISSING. `partners.closings_band` is optional at
 * signup and is set nowhere else, so a large share of partners have no stated
 * band at all. The rule therefore returns `no_band` explicitly rather than
 * silently passing — an operator reading "not flagged" for a partner we could
 * never have flagged is being told something false.
 *
 * Arithmetic only: no database, no clock.
 */
 
import type { PARTNER_CLOSINGS_BANDS } from "../../db/schema/enums";
 
export type ClosingsBand = (typeof PARTNER_CLOSINGS_BANDS)[number];
 
/**
 * The TOP of each band, as monthly closings.
 *
 * The top, not the middle, because this is a fraud trigger and the cost of the
 * two errors is not symmetric: flagging an honest partner wastes an operator's
 * afternoon and insults someone doing well, while missing one costs money that
 * has already gone out. Reading "2-3" as 3 makes the rule fire later and less
 * often, which is the right direction for a signal a human then reads.
 *
 * `7+` is open-ended, so its top is a stated assumption rather than a fact —
 * 12 is roughly three a week, past which "closings" and "referrals" have
 * stopped meaning the same thing anyway.
 */
const BAND_TOP: Record<ClosingsBand, number> = {
	"0-1": 1,
	"2-3": 3,
	"4-6": 6,
	"7+": 12,
};
 
/**
 * How many referrals in a day it takes to look unlike the band.
 *
 * A partner who closes 3 deals a month does not introduce 3 people a day, but
 * they might well introduce several in one afternoon after a site visit. The
 * multiple keeps a good day from being an accusation; the floor keeps the
 * smallest band from firing on two referrals.
 */
const DAILY_MULTIPLE = 2;
const MINIMUM_TO_FLAG = 4;
 
export type VelocityVerdict =
	| { flagged: false; reason: "no_band" | "within_band" }
	| {
			flagged: true;
			referralsToday: number;
			/** What the band says they close in a month. */
			bandTop: number;
			band: ClosingsBand;
	  };
 
export function velocityFlag(input: {
	band: ClosingsBand | null;
	referralsToday: number;
}): VelocityVerdict {
	// Not "fine" — unanswerable. The distinction has to survive to the screen.
	if (!input.band) return { flagged: false, reason: "no_band" };
 
	const bandTop = BAND_TOP[input.band];
	const threshold = Math.max(MINIMUM_TO_FLAG, bandTop * DAILY_MULTIPLE);
	if (input.referralsToday < threshold) {
		return { flagged: false, reason: "within_band" };
	}
 
	return {
		flagged: true,
		referralsToday: input.referralsToday,
		bandTop,
		band: input.band,
	};
}