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