import { SmrtClassOptions } from '@happyvertical/smrt-core'; import { CommissionAdjustmentCollection } from '../collections/CommissionAdjustmentCollection.js'; import { CommissionCollection } from '../collections/CommissionCollection.js'; import { CommissionPayoutCollection } from '../collections/CommissionPayoutCollection.js'; import { EarnerCollection } from '../collections/EarnerCollection.js'; import { CommissionPayout } from '../models/CommissionPayout.js'; import { CommissionPayoutStatus, PayoutMethod } from '../types.js'; /** Collaborators for {@link CommissionPayoutService}. */ export interface CommissionPayoutServiceDeps { earners: EarnerCollection; commissions: CommissionCollection; adjustments: CommissionAdjustmentCollection; payouts: CommissionPayoutCollection; } /** Input for {@link CommissionPayoutService.createPayoutBatch}. */ export interface CreatePayoutBatchInput { earnerId: string; currency: string; /** Informational period bounds recorded on the payout. */ periodStart?: Date; periodEnd?: Date; /** * Idempotency natural key. Defaults to * `` `${earnerId}:${currency}:${periodEnd ISO date}` ``, or * `` `${earnerId}:${currency}:${sourceKind}:${sourceId}:${periodEnd ISO date}` `` * when scoped by source (so a per-network batch and the earner-wide batch * on the same day don't collide). REQUIRED when {@link commissionIds} is * given — an explicit set has no natural default key. Callers running more * than one batch per key/day must supply their own key. */ idempotencyKey?: string; /** * Restrict the batch to commissions from ONE earning source (e.g. a * single ad network). Both `sourceKind` and `sourceId` must be set * together. Only payable, unsettled commissions matching this source are * gathered, and eligible adjustments are narrowed to those whose parent * commission shares the source. Mutually exclusive with * {@link commissionIds}. Omit both to settle the whole earner+currency * (the original behavior). */ sourceKind?: string; sourceId?: string; /** * Restrict the batch to EXACTLY these commissions. Each id is included * only when it is payable, unsettled, and belongs to this earner+currency * — ineligible ids are ignored (inspect `settledCommissionIds` for what * was actually claimed). Adjustments whose parent commission is in this * set come along. Mutually exclusive with {@link sourceKind}/ * {@link sourceId}; requires an explicit {@link idempotencyKey}. */ commissionIds?: string[]; /** Overrides the earner's `payoutThresholdCents`. */ minimumThresholdCents?: number; /** Overrides the earner's `payoutMethod`. */ payoutMethod?: PayoutMethod; /** Clock override for deterministic tests. */ now?: Date; } /** Result of {@link CommissionPayoutService.createPayoutBatch}. */ export interface CreatePayoutBatchResult { /** The created (or, on an idempotent replay, existing) payout — `null` on refusal. */ payout: CommissionPayout | null; /** `true` only when THIS call minted the payout. */ created: boolean; /** Why no payout was created, when refused. */ reason?: 'below_threshold' | 'nothing_payable'; /** Ids of the commissions THIS call stamped onto the payout. */ settledCommissionIds: string[]; /** Ids of the adjustments THIS call stamped onto the payout. */ settledAdjustmentIds: string[]; } /** * Why a payout's membership failed source verification. Every reason is * fail-closed: the payout is excluded from source-scoped listings and * refused source-authorized transitions until repaired. * * - `membership_empty` — no rows are stamped with the payout's id (nothing * proves ownership; e.g. a raced-away batch artifact or a rejected batch * whose rows were released). * - `source_mismatch` — a member commission (or an adjustment's parent * commission) carries a different or empty `(sourceKind, sourceId)`. * - `adjustment_parent_missing` — a member adjustment's parent commission * cannot be loaded, so its ownership cannot be proven. * - `earner_mismatch` / `currency_mismatch` / `tenant_mismatch` — a member * row disagrees with the payout on that axis. */ export type PayoutMembershipRefusalReason = 'membership_empty' | 'source_mismatch' | 'adjustment_parent_missing' | 'earner_mismatch' | 'currency_mismatch' | 'tenant_mismatch'; /** Why {@link CommissionPayoutService.transitionPayoutForSource} refused. */ export type PayoutTransitionRefusalReason = PayoutMembershipRefusalReason | 'payout_not_found' | 'status_conflict' | 'totals_drift' | 'non_positive_total'; /** * Source-authorized lifecycle actions. Targets: * `approve` (pending → approved), `mark_processing` (approved → * processing), `complete` (processing → completed, requires * `paymentReference`), `fail` (approved|processing → failed, requires * `reason`), `reject` (pending|approved → rejected, requires `reason`; * releases the batch's membership). */ export type PayoutSourceTransitionAction = 'approve' | 'mark_processing' | 'complete' | 'fail' | 'reject'; /** Input for {@link CommissionPayoutService.transitionPayoutForSource}. */ export interface TransitionPayoutForSourceInput { payoutId: string; /** * The earning source this transition is authorized against. EVERY member * commission — and every member adjustment through its parent commission * — must belong to exactly this source or the call is refused. */ sourceKind: string; sourceId: string; action: PayoutSourceTransitionAction; /** * Optimistic concurrency guard: refuse with `status_conflict` when the * LOCKED payout's status differs, after source membership is authorized. * Omit to let the action's own from-status rule arbitrate (concurrent * duplicate calls for actions that retain membership then resolve as one * `transitioned` + one `already_applied`; `reject` replays fail * `membership_empty` after releasing that evidence). */ expectedStatus?: CommissionPayoutStatus; /** Required for `complete`. */ paymentReference?: string; /** Required for `fail` and `reject`; appended to the payout's notes. */ reason?: string; /** Clock override for deterministic tests. */ now?: Date; } /** Result of {@link CommissionPayoutService.transitionPayoutForSource}. */ export interface TransitionPayoutForSourceResult { /** * `transitioned` — THIS call performed the transition. * `already_applied` — the payout was already in the action's target * status; nothing was written (terminal completion metadata — * `paymentReference`, `providerRef`, `paidAt` — is never overwritten by * a replay). * `refused` — fail-closed; see {@link refusal}. */ outcome: 'transitioned' | 'already_applied' | 'refused'; /** * The payout re-read AFTER the transaction (bound to the service's own * connection). `null` for `payout_not_found` and every membership * authorization refusal, so an unverified caller receives no payout * lifecycle or settlement data. */ payout: CommissionPayout | null; /** Set exactly when {@link outcome} is `refused`. */ refusal?: { reason: PayoutTransitionRefusalReason; detail: string; }; /** Commissions a `reject` released back to unsettled. */ releasedCommissionIds?: string[]; /** Adjustments a `reject` released back to unsettled. */ releasedAdjustmentIds?: string[]; } /** Input for {@link CommissionPayoutService.getSourcePayoutHistory}. */ export interface SourcePayoutHistoryInput { sourceKind: string; sourceId: string; /** Page size, 1–100. Default 25. */ limit?: number; /** Rows to skip (offset pagination). Default 0. */ offset?: number; } /** One page of {@link CommissionPayoutService.getSourcePayoutHistory}. */ export interface SourcePayoutHistoryPage { /** * The page's VERIFIED payouts, newest first (`created_at DESC, id DESC`). * May hold fewer than `limit` rows even when {@link nextOffset} is set — * rows that failed verification are in {@link excluded} instead. */ payouts: CommissionPayout[]; /** Stamped rows on this page excluded fail-closed, with reasons. */ excluded: { payoutId: string; reason: PayoutMembershipRefusalReason; detail: string; }[]; /** Echo of the requested offset. */ offset: number; /** Echo of the effective page size. */ limit: number; /** * Offset of the next page (advances by the SCANNED count, so excluded * rows never cause skips), or `null` when the history is exhausted. */ nextOffset: number | null; } export declare class CommissionPayoutService { private readonly deps; /** * Canonical UUID shape. Explicit `commissionIds` are filtered against this * before hitting the native-`uuid` `id` column so a malformed external id * can't abort the batch on Postgres/DuckDB. */ private static readonly UUID_RE; constructor(deps: CommissionPayoutServiceDeps); static create(classOptions?: SmrtClassOptions): Promise; /** * Create a settlement batch for one earner in one currency. * * Flow: * 1. **Idempotency + repair** — an existing payout with the (defaulted) * key is returned as `{ payout, created: false }`. A clean replay * touches nothing (new payable work is never swept into an existing * batch). A PENDING payout whose stored totals disagree with the rows * stamped with its id — the signature of an interrupted claim pass — * is repaired: the claim pass re-runs and the totals are reconciled * from the verified membership. Past `pending` the batch is frozen. * 2. **Gather** — payable unsettled commissions for the earner/currency, * plus unsettled adjustments whose parent commission is * earned/approved/payable/paid (same eligibility as the balance * service, so the batch settles exactly what the balance reports). Pass * `sourceKind`/`sourceId` to gather only ONE source's commissions, or * `commissionIds` to gather an explicit set; in both scoped modes the * eligible adjustments are narrowed to the same scope. * 3. **Refuse** — `netTotal <= 0` → `'nothing_payable'`; * `netTotal < threshold` (earner default, overridable) → * `'below_threshold'`. Nothing is minted or stamped on refusal. * 4. **Mint, claim, reconcile** — create the `pending` payout, then * CLAIM the gathered rows through the collections' conditional * `claimForPayout` (rows grabbed by another batch in the interim are * skipped, never double-claimed), and finally store totals computed * from the rows that were VERIFIABLY claimed — the payout's totals * are always reproducible from its member rows. * * Concurrency: claims are conditional with post-save verification, which * narrows but does not eliminate races, and a batch is NOT wrapped in one * DB transaction (the collection layer exposes none). Safe concurrent * settlement therefore relies on SCOPING to DISJOINT sets: two batches * scoped to different sources (or non-overlapping `commissionIds`) gather * disjoint rows and never contend — this is the intended multi-source * (e.g. per-ad-network) settlement pattern. Running OVERLAPPING scopes * concurrently (a source batch and the earner-wide batch, or intersecting * id sets) is the caller's responsibility to serialize: `claimForPayout` * still won't double-own a single row, but a shared commission and its * negative adjustment could split across the two batches, so neither * payout's net would be authoritative. An interrupted claim pass within a * single scope is healed by the repair-on-replay above. */ createPayoutBatch(input: CreatePayoutBatchInput): Promise; /** * Whether a payout's stored totals are reproducible from the rows * actually stamped with its id — the invariant an interrupted claim pass * breaks. Clean replays short-circuit on this; repair runs only when it * fails. */ private membershipConsistent; /** * Claim pass + totals reconciliation for a PENDING payout. * * The claim set is the union of rows already stamped with this payout * (an interrupted earlier pass) and the currently gathered eligible * rows. Claims go through the collections' conditional `claimForPayout` * (rows owned by another batch are skipped); totals are then recomputed * from the claimed rows and saved when they drift from what the payout * carries. In the pathological all-rows-raced-away case the payout keeps * zero totals and a note — auditable, never double-paid. * * The pass also derives the payout's single-source stamp * (`sourceKind`/`sourceId`) from the verified claimed membership — set * when every claimed commission and every claimed adjustment's parent * shares exactly one non-empty source, empty otherwise — which is what * the source-scoped history listing indexes on. * * A fresh status re-read gates the pass: only a payout that is STILL * pending claims rows. This narrows (but, like every claim here, does not * transactionally eliminate) the race against a concurrent lifecycle * transition of the same payout — replaying a batch while its payout is * being approved/rejected is an overlapping concurrent scope the caller * must serialize, same as the documented batch-scope contract. */ private claimAndReconcile; /** * Complete a payout: flip the batch's settled commissions * `payable → paid` FIRST, then `payout.complete(paymentReference)` * (requires status `processing`). Ordering matters for recoverability — * if a member save fails mid-loop the payout is still `processing`, so a * retry finishes the remaining members (already-paid ones are skipped) * and then finalizes; the terminal transition never strands `payable` * members behind a `completed` payout. Adjustments carry no status — * stamping `payoutId` at batch time already settled them. */ completePayout(payoutId: string, paymentReference: string, now?: Date): Promise; /** * Fail a payout (`approved | processing → failed`). The batch's rows stay * stamped — after `resetFromFailed()` the SAME payout retries the SAME * rows; releasing the rows to a different batch would double-pay them if * the failed remittance later settled. */ failPayout(payoutId: string, reason: string): Promise; /** * One page of the payout history belonging to ONE earning source, * newest first (`created_at DESC, id DESC` — deterministic across ties). * * Candidates come from the indexed single-source stamp * (`CommissionPayout.sourceKind`/`sourceId`, maintained from verified * claimed membership at batch/repair time), so database work is bounded * by the page size — a sparse source never forces a scan of the global * payout history. Each page is then RE-VERIFIED against its actual * membership in three batched queries (commissions, adjustments, * adjustment parents — no per-payout N+1): every member commission and * every adjustment's parent must carry exactly the requested source and * agree with the payout on earner/currency/tenant. Rows the stamp alone * cannot prove are excluded fail-closed and reported in `excluded` * (mixed-source membership, missing adjustment parents, memberless * artifacts, released/rejected batches). * * Adjustment-only payouts are first-class: a batch that settled only * CommissionAdjustment rows proves its source through each adjustment's * parent commission and lists normally. * * Payouts minted before the stamp existed carry an empty stamp and are * invisible here until backfilled — see {@link restampPayoutSource}. * * Offset pagination contract: `nextOffset` advances by the SCANNED count * (verified + excluded), so pages never skip rows; a page may hold fewer * than `limit` verified payouts. Newly minted payouts prepend to the * history between calls, as with any offset listing. Tenant interception * applies to every query (candidates, membership, parents), so a tenant * context sees only its own history. */ getSourcePayoutHistory(input: SourcePayoutHistoryInput): Promise; /** * Backfill/repair the derived single-source stamp of ONE payout from its * actual membership — the documented migration path for payouts minted * before the stamp existed (they carry `''`/`''` and are invisible to * {@link getSourcePayoutHistory} until restamped). Safe on any status: * the stamp is derived data, and a payout whose membership is mixed or * unprovable derives back to the empty stamp. * * One-time migration loop: page through `payouts.list({})` and call this * per row (idempotent — an already-correct stamp saves nothing). */ restampPayoutSource(payoutId: string): Promise<{ payout: CommissionPayout | null; sourceKind: string; sourceId: string; changed: boolean; }>; /** * Atomically authorize ONE payout against ONE earning source and perform * a lifecycle transition — the multi-replica-safe alternative to loading * the payout, querying membership, and calling the model transitions * yourself (which leaves a TOCTOU window between authorization and * transition). * * Everything runs on the SAME transaction database: the payout row is * locked (PostgreSQL `SELECT … FOR UPDATE`, so concurrent calls across * app replicas serialize on the row; single-connection engines get * equivalent behavior from the transaction plus an in-process * per-database queue), membership is re-read and re-verified under the * lock, totals are recomputed under the lock, and the transition + every * member write commit or roll back together. * * Under the lock, in order: * * 1. **Hydrate** — load the locked payout. A missing/cross-tenant id * refuses `payout_not_found`. * 2. **Source authorization** — every member commission, and every * member adjustment through its parent commission, must carry exactly * the requested `(sourceKind, sourceId)` and agree with the payout on * earner/currency/tenant; anything unprovable refuses fail-closed * (`source_mismatch`, `adjustment_parent_missing`, mismatches, * `membership_empty` — note this means memberless raced-away batch * artifacts cannot receive any status-derived outcome through this * source-authorized door). * 3. **Status outcome** — after authorization, a payout already in the * action's target status returns `already_applied` WITHOUT writing * (terminal completion metadata — `paymentReference`, `providerRef`, * `paidAt` — is never overwritten by a replay). `expectedStatus`, when * given, must then match the locked status or the call refuses * `status_conflict`; the action's own from-status rule applies last. * Concurrent duplicate calls for actions that RETAIN membership * therefore resolve deterministically: one `transitioned`, the rest * `already_applied` (or `status_conflict` when they raced a DIFFERENT * action). `reject` releases the membership evidence, so a serialized * replay fails closed as `membership_empty` rather than exposing the * rejected status. * 4. **Totals recompute** — commission/adjustment/total amounts are * recomputed from the locked membership; drift refuses `totals_drift` * for the money-forward actions (`approve`, `mark_processing`, * `complete` — repair via a `createPayoutBatch` replay while the * payout is pending). The defensive actions (`fail`, `reject`) * proceed despite drift — rejecting a drifted batch IS the remedy. A * non-positive recomputed total refuses `approve` with * `non_positive_total`. * 5. **Apply** — `complete` flips the batch's payable commissions to * `paid` and completes the payout in the same transaction (no more * retryable-but-partial completion); `reject` RELEASES the batch's * membership (clears `payoutId` on every member commission and * adjustment, model-layer per row) so the rows settle through a * future batch, then marks the payout rejected — terminal. * * Replaying a `createPayoutBatch` for the same payout concurrently with * a transition is an overlapping concurrent scope (same contract as * overlapping batch scopes): the batch side re-checks pending before * claiming, which narrows but does not transactionally close that race — * serialize those two call sites per payout. */ transitionPayoutForSource(input: TransitionPayoutForSourceInput): Promise; /** Target status per action — also the `already_applied` echo test. */ private static readonly TRANSITION_TARGET; /** Legal from-statuses per action (mirrors the model transition guards). */ private static readonly TRANSITION_FROM; private static txOptions; private static txRefusal; /** * A membership refusal means the requested source was never authorized. * Keep the typed reason for callers while withholding both the payout and * member-specific detail (actual source, ids, account, tenant, or money). */ private static txAuthorizationRefusal; /** * RAW stamped-row counts per payout id — deliberately UNSCOPED * (count-only, reviewed): tenant-scoped reads cannot see a foreign * tenant's row stamped onto a payout, so membership verification that * trusted only the visible subset would authorize (or list) incomplete * membership. No row data crosses the tenant boundary — only per-payout * counts, compared against the visible membership; any excess fails * closed as `tenant_mismatch`. Ids must be UUID-shaped (callers pass * validated payout ids), and the payout-id predicate never touches the * empty-FK encoding, so the query is dialect-safe. */ private static countStampedRows; /** * Parents of the given adjustments, keyed by commission id — member * commissions are reused, only the rest are fetched (one `IN` query). */ private loadAdjustmentParents; /** * The single `(sourceKind, sourceId)` a payout's membership provably * belongs to — or the empty stamp when membership is empty, any member's * source is missing, an adjustment parent is unloadable, or more than * one source appears. */ private static deriveMembershipSource; /** * Prove that EVERY member of a payout belongs to the requested source * and agrees with the payout on earner/currency/tenant. Adjustments * prove their source through their parent commission. Fail-closed: the * first unprovable member decides the verdict. */ private static verifySourceMembership; /** The initialized database behind the payout collection. */ private resolveDatabase; /** * Run `fn` inside a database transaction. PostgreSQL transactions get * their own pooled connection, so they run concurrently (the FOR UPDATE * row lock inside `fn` provides the per-payout serialization, replica- * safe). Single-connection engines (SQLite, DuckDB, JSON) multiplex * every transaction over one connection where concurrent BEGIN/COMMIT * pairs would interleave — their transitions chain per database * instance, giving equivalent serialized behavior in-process. An engine * with no transaction support at all still gets the serialized chain. */ private runSerializedTransaction; /** * Unsettled adjustments for the earner/currency whose parent commission * is earned/approved/payable/paid — the same eligibility rule the balance * service applies, so batches settle exactly what balances report. * * When `parentPredicate` is given (a scoped batch), an adjustment is also * kept only when its parent commission satisfies the predicate — so a * source-scoped or explicit-id batch settles only its own adjustments. */ private findEligibleUnsettledAdjustments; /** * The payable, unsettled commissions this batch would settle, honoring * the input scope: an explicit `commissionIds` set (each validated * payable + unsettled + belonging to this earner/currency), a single * `(sourceKind, sourceId)`, or — unscoped — the whole earner/currency. */ private gatherBatchCommissions; /** * Parent-commission predicate that narrows eligible adjustments to the * batch scope: explicit-id batches keep adjustments whose parent is in * the requested id set (even a now-paid parent — a clawback is still * owed); source-scoped batches keep adjustments whose parent shares the * source; unscoped batches keep all (predicate `undefined`). */ private static adjustmentParentPredicate; /** * Validate the batch scope: `sourceKind`/`sourceId` are all-or-nothing * and mutually exclusive with `commissionIds`; an explicit `commissionIds` * batch requires its own `idempotencyKey` (no natural default exists). */ private static assertScope; private requirePayout; /** * `${earnerId}:${currency}:${YYYY-MM-DD}` — or, when scoped by source, a * key that folds the source in so a per-network batch and the earner-wide * batch on the same day get distinct keys. `sourceKind`/`sourceId` are * unconstrained generic strings, so each is LENGTH-PREFIXED (`len:value`) * to keep the encoding unambiguous: a literal `:` inside a source string * can't make two different `(sourceKind, sourceId)` pairs collide (e.g. * `('a:b','c')` → `…:src:3:a:b:1:c:…` vs `('a','b:c')` → `…:src:1:a:3:b:c:…`). * See the input doc. */ private static defaultIdempotencyKey; } export default CommissionPayoutService; //# sourceMappingURL=CommissionPayoutService.d.ts.map