All files / lib/partners two-person.ts

100% Statements 7/7
100% Branches 5/5
100% Functions 4/4
100% Lines 6/6

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                                                                                        22x       28x           22x   6x 8x 8x        
/**
 * The two-person rule (FR-A-9).
 *
 * > "A single account cannot both verify a referral and mark its payout paid,
 * >  for accruals above a config threshold."
 *
 * Read literally, and implemented literally. The requirement's headline is
 * "admin actions are RBAC-gated: only finance can approve or mark paid", but
 * there is no finance person and inventing a role for an empty seat protects
 * nobody. What the criterion actually asks for is a SEPARATION, and separation
 * of two identities is enforceable today with no new role and no new column:
 * `rrm_earnings.actor_id` already records who credited each earning.
 *
 * PER ACCRUAL, not per payout. The threshold is on the individual earning
 * because that is what the criterion says, and because it is the right unit —
 * a payout of fifty ₹100 verifications is routine work, and one ₹1,000
 * conversion is the row worth inventing.
 *
 * Arithmetic only: no database, no clock. The caller supplies the earnings.
 */
 
export type TwoPersonBreach = {
	/** The earnings this operator both credited and is trying to pay out. */
	earningIds: string[];
	/** The largest of them, for the message. */
	largestPaise: number;
	thresholdPaise: number;
};
 
/**
 * Which of these earnings the settling operator is not allowed to pay.
 *
 * Returns null when the settle is clean — including when the operator credited
 * nothing in it, and when everything they credited is below the threshold.
 */
export function twoPersonBreach(input: {
	earnings: readonly {
		id: string;
		amountPaise: number;
		actorId: string | null;
	}[];
	settlingActorId: string;
	thresholdPaise: number;
}): TwoPersonBreach | null {
	const conflicts = input.earnings.filter(
		(e) =>
			// A null actor is the SYSTEM — the release sweep, a seed, a migration.
			// Nobody's identity, so nothing to separate from.
			e.actorId !== null &&
			e.actorId === input.settlingActorId &&
			// Strictly above. A threshold of ₹500 means ₹500 is still a one-person
			// job; the config comment says so and the boundary is asserted.
			e.amountPaise > input.thresholdPaise,
	);
	if (conflicts.length === 0) return null;
 
	return {
		earningIds: conflicts.map((e) => e.id),
		largestPaise: Math.max(...conflicts.map((e) => e.amountPaise)),
		thresholdPaise: input.thresholdPaise,
	};
}