All files / services/rrm earnings-sweep.ts

100% Statements 7/7
100% Branches 3/3
100% Functions 1/1
100% Lines 7/7

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                                                                                                                92x 92x 13x 9x   13x   79x 79x      
/**
 * The earnings release sweep — `accrued` past its hold becomes `released`.
 *
 * This is the only thing that moves an earning across the hold boundary, and
 * it exists as a function rather than as a route because nobody should have to
 * remember to press a button for a partner to get paid. It rides the RRM
 * scheduler's existing 5-minute cron; see the wiring note at the bottom of
 * this comment.
 *
 * ALL of the work is `RrmEarningsDal.releaseDue`. This file is the guard
 * around it, and the guard is the point:
 *
 *   - **Idempotent, and safe against itself.** `releaseDue` is one set-based
 *     UPDATE predicated on `state = 'accrued'`. Two ticks that overlap both
 *     select the same due rows, but the second UPDATE matches nothing — the
 *     first already moved them out of `accrued`. No lock, no lease, no
 *     `in_flight` flag. Adding one would be inventing a race that the state
 *     predicate has already closed. (The returned *count* can over-report in
 *     that window: `releaseDue` counts the ids it aimed at, not rows actually
 *     changed. It is a log line, not a ledger figure — the ledger is the rows.)
 *
 *   - **A failure here never stops the ladder.** The sweep shares a cron tick
 *     with WhatsApp outreach. A money-side error must not take the outreach
 *     down with it, and an outreach error must not skip somebody's release, so
 *     this catches and logs rather than throwing into a shared caller. Nothing
 *     is half-done on the way out: the DAL's writes are per-chunk UPDATEs, so a
 *     mid-sweep failure leaves earlier chunks released and the rest still
 *     `accrued` — which the next tick, five minutes later, picks up.
 *
 * NOT gated on the campaign halt, deliberately. `rrm_config.halt` stops
 * template sends to protect the WhatsApp number's standing with Meta. It says
 * nothing about money we have already told a partner they earned, and freezing
 * releases behind an unrelated safety stop would quietly turn a send incident
 * into a payments incident.
 *
 * WIRING (this file does not own the call site): call it from
 * `rrmSchedulerTick` in `scheduler.service.ts`, next to `runSweeps` — i.e.
 * *outside* the `if (!result.halted && templatesReady)` block, for the reason
 * above — as `await releaseDueEarnings(ctx.db, ctx.now)`.
 */
 
import type { DrizzleD1Database } from "drizzle-orm/d1";
import { RrmEarningsDal } from "../../dal/rrm/earnings.dal";
import type * as schema from "../../db/schema";
import { logger } from "../../lib/logger";
 
/**
 * Release every earning whose hold has expired.
 *
 * @param now the tick's clock, so every sweep in one tick agrees on the time.
 * @returns how many earnings were released; `0` if the sweep failed.
 */
export async function releaseDueEarnings(
	db: DrizzleD1Database<typeof schema>,
	now = new Date(),
): Promise<number> {
	try {
		const released = await new RrmEarningsDal(db).releaseDue(now);
		if (released > 0) {
			logger.info("[RRM earnings] released past hold:", released);
		}
		return released;
	} catch (err) {
		logger.error("[RRM earnings] release sweep failed:", err);
		return 0;
	}
}