All files / services/rrm earnings.service.ts

96.55% Statements 28/29
94.44% Branches 17/18
100% Functions 3/3
96.42% Lines 27/28

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 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270                                                                                                                3x     3x                                                                                                               59x 3x       56x                                       36x     36x               36x 36x                   34x 4x     30x   30x 30x 30x             30x 30x           30x 30x                     3x 3x                           24x                                     24x                                   24x                           4x       4x 4x    
/**
 * Partner earnings — the layer that knows the RATES (F-21, RRM Phase 1).
 *
 * Three layers, and the split is deliberate:
 *
 *   lib/rrm/earnings.ts   arithmetic. No clock, no database.
 *   dal/rrm/earnings.dal  persistence, and the unique index that makes a
 *                         double credit impossible.
 *   this file             what a referral is WORTH, read from `rrm_config` at
 *                         the moment the outcome is recorded.
 *
 * The rate is read per outcome rather than baked in as a constant because
 * `rrm_config` is where an operator changes it without a deploy — and once the
 * amount is written onto the earning row, that row keeps the rate it was
 * created at forever. A later rate change moves future earnings only, which is
 * the only version of "we changed what a referral pays" that can be explained
 * to a partner afterwards.
 *
 * PAISE, ALWAYS. Integers. There is no float on this path.
 */
 
import { eq } from "drizzle-orm";
import type { DrizzleD1Database } from "drizzle-orm/d1";
import { ReferralsDal } from "../../dal/partners/referrals.dal";
import {
	RRM_CONFIG_DEFAULTS,
	RRM_CONFIG_KEYS,
	RrmConfigDal,
	type RrmConfigKey,
} from "../../dal/rrm/config.dal";
import {
	type CreditableEarningKind,
	DuplicateEarningError,
	RrmEarningsDal,
} from "../../dal/rrm/earnings.dal";
import { RrmEventsDal } from "../../dal/rrm/events.dal";
import * as schema from "../../db/schema";
import type { RrmEarning } from "../../db/schema/rrm";
import { ValidationError } from "../../lib/errors";
import { canEverEarn, type ReferralState } from "../../lib/partners/state";
import { type Balance, summarise } from "../../lib/rrm/earnings";
 
/**
 * The timeline event an outcome writes.
 *
 * `RRM_EVENT_TYPES` has no earnings entry and this module does not own
 * migrations, so it uses the type already in the enum for "an operator
 * recorded something about this prospect" — same reasoning inbound.service.ts
 * gives for reusing `message_received`.
 *
 * Explicitly NOT `intent_confirmed`: `ramp.routes.ts` and `metrics.routes.ts`
 * count that type, and it is the programme's gating success metric. Writing it
 * from here would inflate the number that decides whether Phase 2 gets built.
 * Nothing in RRM counts `note_added`, so it is metrically inert — it shows in
 * the timeline and changes no funnel figure.
 */
const OUTCOME_EVENT_TYPE = "note_added" as const;
 
/** Which config key holds the rate for each outcome. */
const RATE_KEY = {
	validated: RRM_CONFIG_KEYS.validatedAmountPaise,
	converted: RRM_CONFIG_KEYS.convertedAmountPaise,
} as const satisfies Record<CreditableEarningKind, RrmConfigKey>;
 
export type RrmEarningsContext = {
	db: DrizzleD1Database<typeof schema>;
	/** Injectable clock. Defaults to now. */
	now?: Date;
};
 
export type RecordOutcomeInput = {
	referralId: string;
	kind: CreditableEarningKind;
	/** The operator who marked the outcome. Lands on both the row and the event. */
	actorId: string;
	notes?: string | null;
};
 
/**
 * A typed refusal rather than an exception.
 *
 * `already_credited` is the common case, not an error case: an operator
 * double-clicking "mark validated" must see "already credited", not a 500.
 */
export type RecordOutcomeResult =
	| { ok: true; earning: RrmEarning }
	| {
			ok: false;
			reason: "already_credited" | "referral_not_found" | "not_earnable";
	  };
 
export type PartnerBalance = Balance & {
	/** The threshold the balance was measured against, for the partner page. */
	thresholdPaise: number;
};
 
/**
 * Config is operator-typed text, and `RrmConfigDal.getNumber` only rejects the
 * non-finite — a fractional or negative row reaches this file intact, whether
 * it arrived by seed, by Drizzle Studio, or from a future caller that skips the
 * PATCH route's range table.
 *
 * Both numbers on this path need the same check. A fractional rate must never
 * reach the ledger — SQLite would happily store `100.5` in an INTEGER column,
 * and from then on the partner is owed a float. A fractional `hold_days` makes
 * `holdUntilFrom` throw a RangeError from inside `accrue`, which is not a
 * `DuplicateEarningError`, so it escapes the catch below and every "mark
 * validated" for every referral becomes a 500 that never says which key is
 * wrong.
 *
 * `ValidationError` rather than `RangeError` for exactly that reason: it is the
 * only shape `handleError` will let out with its message intact, and the
 * operator reading it is the person who can go and fix the row.
 */
function requireWholeNonNegative(key: RrmConfigKey, value: number): number {
	if (!Number.isInteger(value) || value < 0) {
		throw new ValidationError(
			`Config "${key}" must be a non-negative whole number, got ${value}`,
		);
	}
	return value;
}
 
/**
 * Credit a partner for a referral outcome.
 *
 * Two writes, sequential — D1 has no transactions (see apps/api/CLAUDE.md).
 * Ordered ledger-first because the earning is the load-bearing row and the
 * event is its display: if the event append fails, a partner is correctly
 * credited and one timeline line is missing. The reverse order could show a
 * timeline entry for money that was never credited.
 *
 * A retry after a failed event append returns `already_credited` and does not
 * backfill the missing event. Accepted: the ledger — the half that decides
 * what is owed — is right either way.
 */
export async function recordOutcome(
	ctx: RrmEarningsContext,
	input: RecordOutcomeInput,
): Promise<RecordOutcomeResult> {
	const now = ctx.now ?? new Date();
 
	// Narrow read: the owner and the status, nothing else.
	const rows = await ctx.db
		.select({
			partnerId: schema.referrals.partnerId,
			status: schema.referrals.status,
		})
		.from(schema.referrals)
		.where(eq(schema.referrals.id, input.referralId))
		.limit(1);
	const referral = rows[0];
	if (!referral) return { ok: false, reason: "referral_not_found" };
 
	// A referral that is a duplicate, was rejected, expired without ever being
	// forwarded, or whose homeowner opted out can never earn. Refused here
	// rather than accrued and reversed a day later: an accrual is visible to
	// the partner the moment it exists, and taking money back off someone's
	// dashboard is a worse experience than never having shown it.
	//
	// `lost` is deliberately NOT in that set — FR-M's transition table is
	// explicit that on a lost deal "₹100 stands; ₹1,000 never accrues".
	if (!canEverEarn(referral.status as ReferralState)) {
		return { ok: false, reason: "not_earnable" };
	}
 
	const partnerId = referral.partnerId;
 
	const config = new RrmConfigDal(ctx.db);
	const rateKey = RATE_KEY[input.kind];
	const [rawAmount, rawHoldDays] = await Promise.all([
		config.getNumber(rateKey, RRM_CONFIG_DEFAULTS[rateKey]),
		config.getNumber(
			RRM_CONFIG_KEYS.holdDays,
			RRM_CONFIG_DEFAULTS[RRM_CONFIG_KEYS.holdDays],
		),
	]);
	const amountPaise = requireWholeNonNegative(rateKey, rawAmount);
	const holdDays = requireWholeNonNegative(
		RRM_CONFIG_KEYS.holdDays,
		rawHoldDays,
	);
 
	let earning: RrmEarning;
	try {
		earning = await new RrmEarningsDal(ctx.db).accrue({
			partnerId,
			referralId: input.referralId,
			kind: input.kind,
			amountPaise,
			holdDays,
			actorId: input.actorId,
			notes: input.notes ?? null,
			now,
		});
	} catch (error) {
		Eif (error instanceof DuplicateEarningError) {
			return { ok: false, reason: "already_credited" };
		}
		throw error;
	}
 
	// TWO event logs, on purpose, and they are not duplicates of each other.
	//
	// `referral_events` is the referral's own history and the ONLY source the
	// partner's timeline reads (FR-ST-2). Without this write, being credited —
	// the single moment the whole programme exists to produce — would never
	// appear on the partner's screen: they would see the money arrive in their
	// balance with nothing in the timeline saying why.
	//
	// Written before the RRM event because it is the one a partner sees.
	await new ReferralsDal(ctx.db).addEvent({
		referralId: input.referralId,
		type:
			input.kind === "converted" ? "referral.converted" : "referral.verified",
		actorType: "ops",
		actorId: input.actorId,
		payload: { earningId: earning.id, amountPaise },
		now,
	});
 
	// `rrm_prospect_events` is the RECRUITMENT funnel's log, which the campaign
	// metrics read. It is keyed by prospect id, and `partnerId` is not one — a
	// self-registered partner has no prospect row at all, so this row simply
	// will not join to anything for them.
	//
	// Kept rather than fixed here because the funnel query and this key are the
	// events DAL's contract, not this service's, and changing it blind would
	// silently drop outcomes out of the campaign metrics. Flagged for the B5
	// admin work, which is where that funnel gets rebuilt.
	await new RrmEventsDal(ctx.db).append({
		prospectId: partnerId,
		type: OUTCOME_EVENT_TYPE,
		actorType: "operator",
		actorId: input.actorId,
		payload: {
			outcome: input.kind,
			earningId: earning.id,
			referralId: input.referralId,
			amountPaise,
			holdUntil: earning.holdUntil.toISOString(),
			notes: input.notes ?? null,
		},
		// Same instant as the row it describes, or the pair reads as
		// inconsistent in the timeline.
		occurredAt: now,
	});
 
	return { ok: true, earning };
}
 
// releaseDueEarnings deliberately does NOT live here. The cron needs the
// try/catch and logging in services/rrm/earnings-sweep.ts — a sweep that throws
// inside a scheduler tick takes the rest of the tick with it. Two exports of the
// same name in one module tree is the kind of thing that gets imported wrongly
// at 3am, so there is exactly one.
 
/** The balance the partner page shows, measured against the configured threshold. */
export async function balanceFor(
	ctx: RrmEarningsContext,
	prospectId: string,
): Promise<PartnerBalance> {
	const thresholdPaise = await new RrmConfigDal(ctx.db).getNumber(
		RRM_CONFIG_KEYS.payoutThresholdPaise,
		RRM_CONFIG_DEFAULTS[RRM_CONFIG_KEYS.payoutThresholdPaise],
	);
	const earnings = await new RrmEarningsDal(ctx.db).listForPartner(prospectId);
	return { ...summarise(earnings, thresholdPaise), thresholdPaise };
}