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,
};
}
|