Skip to content

Current payout system audit ​

Observed: 2026-09-01. All findings below are from the checked-out workspace. No payout data or code was mutated.

Repository and runtime inventory ​

The workspace root is not itself a Git repository. The relevant repositories are:

RepositoryObserved branch/statusPayout relevance
Bloxclips-backend/feat/local-submission-scraper, clean, tracking its originPrisma, auth, earnings, payout routes, rails, jobs, Whop/chat integration
BloxClips-frontend/dev, clean, two commits behind its origincreator balance/cashout/history and admin payout/review UI
metric-scraper/main, cleantechnical metric worker only

The backend uses Express 5, Prisma 5.22, TypeScript 5.9, and @whop/sdk declared as ^1.0.14 and installed as 1.0.14. Existing backend tests cover chat, groups, referrals, and tracking, but no test suite establishes core earning, reservation, dispatch, webhook, or reconciliation invariants.

Configuration evidence:

  • Bloxclips-backend/.env.template:33-48 declares one Whop key, company route/ID, and webhook secret. It does not establish a separate sandbox base URL or environment.
  • Bloxclips-backend/.env.template:60 advertises MINIMUM_PAYOUT_AMOUNT=20, while src/api/routes/payouts.ts and src/utils/payouts/processRequest.ts default to 100 when the variable is absent. Deployment behavior therefore depends on an unverified runtime value.
  • The checked-in Whop client does not pin Api-Version-Date.
  • No secret values were printed or copied during this audit.

What is canonical today? ​

There is no single canonical earning record.

Three overlapping representations are live:

  1. Mutable estimates recomputed from the current Submission and Campaign rows by calculateCampaignEarnings.
  2. Payout-time PayoutItem snapshots created by the creator-request flow, whose decisions and amounts can later become provisional paid counters.
  3. Legacy Submission.paidOut, paidAmount, and payoutId, alongside newer cumulative paidViewsTotal and paidAmountTotal counters.

PayoutItem is not an immutable earning ledger. It is generated only after a cashout request, can be generated again after restart, lacks a unique payout/submission constraint, and is deleted if its submission or payout is deleted through schema cascades. The cumulative submission counters are mutable denormalizations, not journal entries.

End-to-end current flow ​

text
ScrapeJob result or direct payout-time Apify call
  -> mutable Submission current/manual/frozen view fields + ViewSnapshot
  -> calculateCampaignEarnings / processRequest delta calculation
  -> mutable campaign-spend recomputation (creator rate × platform-fee burn)
  -> creator POST /api/payouts/request
  -> SCRAPING -> PayoutItem creation -> READY_FOR_REVIEW
  -> admin item decisions -> approve/commit -> AWAITING_SEND
  -> admin send -> Stripe / PayPal / USDT -> COMPLETED or FAILED

Parallel legacy route:
admin eligible list -> admin process one/bulk
  -> recompute mutable lifetime earnings
  -> Stripe / PayPal / USDT
  -> set Submission.paidOut and create/update Payout

There is no automatic creator-payment scheduler. startPaymentSystemScheduler runs tax-related payment jobs, while creator release remains initiated by a creator request and completed by staff actions.

File and symbol inventory ​

Earnings, rates, and campaign budgets ​

Path and symbolPurpose and callersReads/writes and boundariesAssessment
Bloxclips-backend/src/utils/calculateSubmissionEarnings.ts:43 calculateCampaignEarningsShared mutable campaign calculation used by creator balance and other presentation/business pathsReads supplied campaign budget/rate/caps/fee and accepted submissions' current/manual/frozen views, customRate, caps, and creation time; no transactionUnsafe as accounting. Sorts by createdAt despite an “approval order” intent, rounds per submission, and caps creator RPM earnings against the campaign budget. Current row edits rewrite history. Useful only as a temporary estimate/shadow comparator.
Same file calculateRawEarnings at line 101Chooses view, rate, threshold and cap for one submissionView precedence is manual, then frozen, then current. A truthy customRate wins; otherwise short/long campaign payout; amounts round to centsAmbiguous/incomplete. 0 cannot be an intentional override; no group rate; no immutable rate snapshot; current rate affects prior views.
Same file calculateTotalCampaignSpend at line 164Computes campaign spend from calculated creator earningsMultiplies earnings by a platform-fee burn factor; no durable debitWrong boundary for target model. Campaign CPM and creator RPM cannot remain independent.
Bloxclips-backend/src/utils/campaignBudget.ts:18 getCampaignSpendRecomputes spend from accepted submissionsReads mutable submission views/rates and campaign fee; no journal/reservationUnsafe. Rejecting/deleting/editing a submission can reduce historic spend. Pending/reserved/provider funds are not represented.
Same file computeScrapeBudgetClamp at line 130Caps a scrape result against recomputed remaining budgetReads sibling submissions; caller-dependent transaction behaviorHelpful metric guard, but not a money reservation. Concurrent or alternate paths need a common locked accrual transaction.
Same file checkAndCloseCampaign at line 248Ends or reopens campaign from recomputed spendWrites campaign lifecycleUnsafe for financial finality. A later decrease in mutable computed spend can reopen a depleted campaign.
Bloxclips-backend/src/api/routes/admin.ts:1304,1344,1403,1459 submission mutationsAdmin changes views, custom rate, cap, or review stateAuthenticated admin writes mutable submission fields, generally followed by generic audit middlewareOperationally useful review controls, but they can retroactively change estimates/budget without financial corrections. Replace financial effects with append-only correction/reversal events.
Bloxclips-backend/src/api/routes/admin.ts:2817,2891,2944 campaign create/launch/updateAdmin campaign managementStores budget, payout, payoutLong as strings and mutable caps/lifecycleNo distinct CPM/default RPM snapshot. Retain campaign management, replace money/rate storage and launch validation.

Creator-request payout generation ​

Path and symbolPurpose and callersReads/writes and boundariesAssessment
Bloxclips-backend/src/api/routes/payouts.ts:78 computeUserNetBalanceCreator balance endpoint and payout preflightRecomputes accepted paidOut=false submissions; subtracts gross PayoutItems only in selected approved states; adds affiliate balance later; no transactionUnsafe/duplicated. Mutable, cacheable estimate is presented as balance; status omissions and two paid models can misstate availability.
Same file routes at lines 173, 232, 255GET /balance, GET /me, creator POST /requestCreator-auth boundary. Request checks external Stripe/PayPal/USDT method, tax/cooldown/threshold, creates Payout, and starts async processingThis is a creator withdrawal request plus staff-review workflow, not automatic credit. Email verification is commented out. Remove from primary release path after cutover; history can be adapted to read the new ledger.
Bloxclips-backend/src/utils/payouts/processRequest.ts:15 PAYOUT_STATUSDefines the newer request statesString constants, not schema enumIncomplete state model: no reserved, provider-confirmed, ambiguous/unknown, reversed, or reconciliation state.
Same file fetchEligibleSubmissions line 33 and rateForSubmission line 48Selects accepted legacy-unpaid submissions and current rateReads both Discord and WebUser identity paths, mutable campaign and submission; does not exclude ended/archived campaigns or apply groupsUnsafe/incomplete. The new model still depends on legacy paidOut and ignores group RPM.
Same file processPayoutRequest line 61Direct rescrape, delta calculation, PayoutItem creation, threshold/status updateMultiple separate writes; no encompassing transaction; external calls occur first; createMany has no uniqueness guardRetry duplication risk. A restart from SCRAPING can append the same items again. Partial failure leaves ambiguous work. Suitable only as dev scaffolding.
Same file resumeStuckPayouts line 231Restarts REQUESTED/SCRAPING payouts at server bootFire-and-forget concurrent executionUnsafe durability. It can race another process and reproduce items; there is no lease, attempt table, or owner.
Bloxclips-backend/src/utils/payouts/rescrape.ts:140 rescrapeForPayoutCalls Apify for TikTok/Instagram and another YouTube metric path during cashoutExternal network calls; updates submission views/snapshots and campaign freezing in separate stepsWrong boundary. Payout logic owns scraper selection and metric refresh. Replace with the existing durable ScrapeJob technical path; payout must consume finalized backend-applied metrics only.

Review, reservation, and dispatch ​

Path and symbolPurpose and callersReads/writes and boundariesAssessment
Bloxclips-backend/src/api/routes/adminPayoutReview.ts:42-352Admin list/detail, per-item review, bulk approve, rejectrequireAuth + requireAdmin; item decisions and payout state writesRetain UX concepts, not the accounting implementation. The queue is manual and its states do not prove fund reservation.
Same file prepareApprove line 456 and approveHandler line 577Validates and approves chosen itemsTransaction increments submission paid totals, allocates referral commissions, changes payout to AWAITING_SEND; audit write is not consistently part of the same transactionConcurrency risk. Non-locking reads and updates can let simultaneous approvals double-increment counters. It records payment before provider dispatch without a durable provider operation.
Same file POST /:id/send line 714Staff-triggered external dispatchrequireAdmin + TOTP; changes to PROCESSING, calls dispatcher, then marks resultDouble-send/uncertain-outcome risk. Concurrent calls can both pass; no compare-and-swap claim, provider-operation row, unknown state, or reconciliation-before-retry rule.
Bloxclips-backend/src/utils/rails/dispatcher.ts:61 dispatchPayoutChooses Stripe, PayPal, or USDTExternal provider call outside a database transactionNo Whop transfer rail. Stripe uses a payout-derived idempotency value, but PayPal uses time-derived batch identity, so repeat calls can move twice. This abstraction assumes external withdrawals and should be replaced, not expanded casually.
Bloxclips-backend/src/utils/payments/preflight.ts assertCanPayoutValidates legacy method/tax prerequisitesReads Stripe/PayPal/USDT user configuration and tax stateNot reusable for Whop balance credit except the general idea of a server-side eligibility gate.
Bloxclips-backend/src/utils/payments/scheduler.ts:23 startPaymentSystemSchedulerStarts payment-adjacent scheduled jobsTax/payment maintenance onlyNot a creator payout scheduler.

Parallel legacy payout path ​

Path and symbolPurpose and callersReads/writes and boundariesAssessment
Bloxclips-backend/src/api/routes/admin.ts:145 calculateUserEarningsLegacy admin eligible/process calculationReads accepted paidOut=false submissions and mutable rates/views; selected campaign projection omits platformFeeRate, invoking a different defaultDuplicated and contradictory. It can disagree with creator balance/review.
Same file GET /payouts/eligible line 1669, POST /payouts/process/:userId line 1745, bulk at 1929Lists and immediately pays creators through old railsAdmin + TOTP + generic audit; provider calls and then fragmented local paidOut/Payout writesCritical duplicate-money risk. New completed payouts do not consistently set legacy paidOut; the same submissions can remain eligible here. Provider success can precede a failed local update. Disable only through the gated later cutover.
Same file DELETE /submissions/:id line 3115Hard-deletes a submissionCascades related records under current schemaDestroys financial evidence and can lower recomputed spend/reopen capacity. Financially referenced submissions must become non-deletable/soft-deleted.

Scraper, Whop, auth, and audit ​

Path and symbolPurpose and callersReads/writes and boundariesAssessment
Bloxclips-backend/src/utils/tracking/scrapeJobs.ts:60-260Enqueues and reconciles durable technical metric jobsScrapeJob, submission metrics, ViewSnapshot; appliedAt prevents reapplication; reconciliation locks affected campaign workCorrect direction. Extend the backend application transaction to create immutable accrual/budget entries. The scraper remains technical-only.
Bloxclips-backend/src/utils/tracking/runTrackingTick.ts:174Runs due metric trackingTechnical scheduling and applicationRetain as metric producer, not payout scheduler or policy owner.
metric-scraper/ workerFetches platform metrics and writes job resultHas no payout policy ownershipRetain this boundary. Do not add rates, eligibility, budget, or provider decisions.
Bloxclips-backend/src/lib/whopSupportChat.ts:175 ensureWhopIdentityProvisions/fetches a child Whop company for embedded support chat and maps its owner userServer Whop calls; writes WhopIdentity; uses verified BloxClips email during provision and may fall back to connected-account name/title discoveryNot payment proof. An existing row is returned without payment-recipient revalidation; the mapped child owner is not proven to be the creator's intended personal balance. Reuse only as a candidate signal after explicit payment verification.
Bloxclips-backend/src/api/routes/webhooks/whop.ts:32Verifies Whop webhook and handles chat.messageRaw request body, Whop SDK unwrap, chat effects; no persisted webhook-event deduplicationSignature helper pattern is useful. Add an isolated transfer event inbox with DB dedupe and reconciliation; do not mix money events into chat side effects.
Bloxclips-backend/src/utils/whopClient.tsSingleton Whop SDK clientServer secret; no explicit API date or sandbox/production guardRetain a client factory concept, but create money-specific configuration that pins API date, origin ID, environment, timeout, and retry ownership.
Bloxclips-backend/src/api/middleware/auth.ts and admin middlewareMaps Google/Discord-backed app identity and rolesWebUser, auth accounts/session, admin roleApplication authentication is separate from Whop recipient identity. Keep that separation explicit.
AdminAuditLog/AuditLog helpers and tablesGeneral request/action auditMiddleware/best-effort writes, generic JSON metadataInsufficient financial audit. Cannot reliably prove every state transition, actor, reason, provider observation, and correlation atomically. Add a mandatory append-only payout audit event.

Frontend payout and financial displays ​

Path and symbolPurpose and backend dependencyAssessment
BloxClips-frontend/features/payouts/api/payouts.ts:5,29 fetchPayoutOverview/createPayoutRequest and hooks/usePayoutOverview.ts:40Reads /api/payouts/me and mutable /balance; posts creator /request; interprets tax and Stripe/PayPal/USDT method errorsImplements the withdrawal-request model that the target explicitly replaces. Network success text says the request is under review, not that money is scheduled automatically.
surfaces/content-rewards/screens/payouts/PayoutOverviewScreen.tsx:25 and components/PayoutBalanceCard.tsx:18-55Presents “Cash out,” combines clipper net and affiliate pending, and labels it availableThe displayed “available” amount is a mutable estimate and includes a different earning class. It does not distinguish held/payable/reserved/provider-unknown.
surfaces/content-rewards/screens/overview/components/PayoutPanel.tsx:14-37Dashboard overview uses balance.balance/eligible onlyIt omits the affiliate-inclusive combinedNet/combinedEligible used on the payout screen and backend request gate, so two screens can disagree.
features/payouts/api/payoutMethods.ts and surfaces/.../payouts/method/PayoutMethodScreen.tsx:80-119Connect/manage Stripe, PayPal, and USDT withdrawal destinationsWrong UX for Whop-balance credit. Retire from creator-content payout flow; do not add Stripe to the replacement.
features/payouts/lib/status.ts:3 getPayoutStatusLabel/toneMaps only selected request states and otherwise humanizes raw stringsAWAITING_SEND, PROCESSING, BELOW_THRESHOLD, and future ambiguous/reversal states receive generic treatment; “COMPLETED” trusts local state without reconciliation evidence.
features/admin/payout-detail/api/adminPayoutDetail.ts and related hook/screenStaff item decisions and approve action against adminPayoutReviewMirrors manual request review. Useful evidence UI can be redesigned, but its sums are float netAmount values and its approval has unsafe backend concurrency.
features/admin/payout-list/api/adminPayoutList.ts:30 and AdminPayoutListSendButton.tsxTOTP-protected staff /send actionDirectly exposes the race-prone staff money trigger. Remove after the gated cutover; replacement admin actions reconcile, not mint a fresh send.
features/admin/user-detail/api/adminUserDetail.ts:9 processAdminUserDetailPayoutCalls the parallel legacy /api/admin/payouts/process/:discordIdCritical evidence that the legacy provider path remains reachable from current UI.
surfaces/.../admin/user-detail/components/AdminUserDetailSubmissionRow.tsx:147-180Shows customRate/campaign rate multiplied by 1,000 as dollars per 1M and mutable earningsContradicts backend schema/calculation per-1,000 semantics and can mislead an admin editing a real rate.
features/admin/campaign-management/ and features/admin/clipper-groups/Campaign/group configuration and lifecycle UICampaign forms expose current budget/payout conventions; group UI has lifecycle/membership but no group RPM. Extend later with explicitly labeled CPM/RPM versions and effective dates.

Registration, auth, manifests, migrations, and tests ​

  • Bloxclips-backend/src/api/index.ts:185 registers the raw-body Whop webhook before JSON parsing, then mounts admin review, legacy admin, and creator payout routes together around lines 314–332. startApiServer starts tax/payment maintenance and tracking schedulers, not creator payout automation.
  • Bloxclips-backend/src/api/server.ts invokes resumeStuckPayouts on startup, creating the restart/repeat path described above.
  • Backend authentication is BloxClips session identity (WebUser plus Google/Discord AuthAccount linkage). requireAuth, requireAdmin, and requireTOTP create useful application boundaries, but none prove a Whop payment recipient.
  • Bloxclips-backend/package.json/lockfile resolve Prisma 5.22, Express 5.2.1, TypeScript 5.9, and official @whop/sdk 1.0.14. Frontend uses Next 16.2.4. The scraper package remains an independent Crawlee/Postgres technical worker.
  • Payout migrations add the request/review item model and a partial one-open-request index, but no immutable earning/budget/provider-operation ledger. Group migrations add campaign-scoped lifecycle and membership without RPM.
  • Backend payout-critical paths have no dedicated earning arithmetic, campaign-allocation concurrency, payout state-machine, duplicate dispatch, timeout, webhook dedupe, or reconciliation tests. Existing relevant tests validate tracking-job idempotency and group/chat domains only; those do not establish money safety.

Schema and status inventory ​

Relevant persisted models ​

  • prisma/schema.prisma:178 WhopIdentity: unique webUserId, connected company ID, and Whop user ID for chat provisioning; no payment verification status, verification evidence, or stale/duplicate controls across provider identities.
  • :283 Campaign: budget, payout, payoutLong are strings; there is no separate CPM/default RPM or immutable rate version. platformFeeRate is a float.
  • :331 Submission: string review status; mutable metrics and overrides; customRate Float; legacy single-shot paid fields and newer cumulative paid counters coexist.
  • :408 ScrapeJob: durable technical metric work with application timestamps. This is the correct ingestion boundary.
  • :484 Payout: float amount fields and both legacy Discord userId and WebUser ID; external method fields and a broad string status set.
  • :563 PayoutItem: payout-time mutable snapshots with float amounts and decisions; no unique (payoutId, submissionId) or source-accrual reservation constraint.
  • :650 AdminAuditLog and :1046 AuditLog: general audits, not an enforced financial journal.
  • :667 ViewSnapshot: technical historical metric evidence, but not a finalized earning.
  • :830 ReferralCommission: append-oriented commission record with a useful unique source-payout pattern; it is still coupled to the unsafe payout flow.
  • :1112 ClipperGroup and :1130 membership: campaign-scoped lifecycle exists, but no group RPM field or rate version exists. docs/clipper-groups-plan.md explicitly deferred rate precedence.

Migrations 20260502000000_add_user_initiated_payout_review, 20260504000000_add_payout_request_security, and 20260505200000_add_awaiting_send_status explain the newer workflow. A partial unique index prevents one open request under its enumerated statuses, but it does not protect earnings, approval counters, provider calls, or legacy routes.

Exact current status vocabulary ​

Legacy code uses PENDING, PROCESSING, COMPLETED, and FAILED. The request/review flow additionally uses REQUESTED, SCRAPING, READY_FOR_REVIEW, BELOW_THRESHOLD, AWAITING_SEND, and REJECTED.

Observed meanings are inconsistent:

StatusCurrent observed meaningTerminal?Ambiguity
REQUESTEDcreator request createdNoasync work may not have started
SCRAPINGdirect payout rescrape/item build startedNorestart can repeat item creation
READY_FOR_REVIEWitems generated above thresholdNono money reserved
BELOW_THRESHOLDcurrent request calculation below threshold/no itemsEffectively yeslater mutable views can make balance return; no clean retry lifecycle
AWAITING_SENDadmin approved and counters/commissions provisionally updatedNoprovider operation does not yet exist
PROCESSINGsend route began or legacy provider operation in progressNono distinction between not sent, accepted, timeout, or provider pending
COMPLETEDlocal path believes provider succeededYes in UImay not synchronize legacy paid flags; no ongoing reconciliation
FAILEDcalculation or provider path failedAmbiguousno retryability/uncertain-money distinction
REJECTEDadmin rejected requestYesmutable source earnings remain and can reappear
PENDINGlegacy payout created/pendingAmbiguousoverlaps newer request concepts

There is no modeled cancellation, provider confirmation distinct from final completion, unknown timeout, reversal, recovery, or reconciled state.

Answers to required audit questions ​

  1. Canonical earnings: none. Current balances are mutable recomputations; PayoutItem is a payout-time snapshot, not a complete immutable ledger.
  2. Snapshot/finality: PayoutItem snapshots views/rate/amount only when requested, while rate and budget before that remain mutable. There is no immutable earning accrual or rate-policy version for every eligible view delta.
  3. Rate meaning: campaign payout/payoutLong behaves as creator RPM and also as the basis for budget burn. Submission.customRate wins if truthy. CPM is not represented independently. Group RPM is absent. Frontend admin code treats custom rate in at least one path as per-million by scaling 1,000, while backend/schema comments calculate per-1,000—an explicit unit contradiction.
  4. Precedence: only individual nonzero customRate over short/long campaign payout is implemented. Campaign/group/individual precedence is otherwise missing.
  5. Budget decrease: no durable decrease occurs. Spend and remaining capacity are recalculated from current accepted submissions and rate/fee assumptions; scraper clamps/freeze logic derives from that value.
  6. Double count/overspend: yes. Two payout generations, nonunique item creation, concurrent approval/send gaps, mutable budget calculations, and legacy paid flags permit both local double counting and provider duplicate risk.
  7. Lifecycle changes: rejection or hard deletion removes/reduces computed spend; unapproval makes earnings disappear; later views increase mutable balance; payout-time and scheduled scraping use different paths; ended/frozen campaigns are not a consistent eligibility boundary. Hard deletion can cascade payout evidence. There is no formal correction/reversal policy.
  8. Current payout concept: a mixture of creator withdrawal request, internal mutable earning claim, staff review queue, and staff-triggered external provider payout. The parallel legacy route is a direct staff payout.
  9. Provider abstractions: Stripe/PayPal/USDT external-withdrawal dispatchers exist. Their identity, preflight, idempotency, and response models do not fit crediting Whop balances. Retain only generic lessons, not the interface.
  10. Retain/replace: retain campaign/submission review concepts, authenticated roles, technical ScrapeJob boundary, view snapshots, and the useful UI idea of history/reconciliation. Replace financial calculations, money storage, reservation/status model, direct payout rescrape, both send paths, and payment identity proof after a clean gated cutover.
  11. Retry duplicate risk: yes. Startup item regeneration and concurrent/repeated send endpoints can duplicate; PayPal's time-derived request identity is especially unsafe. A provider timeout has no UNKNOWN state or mandatory reconciliation.
  12. Status model: listed above; terminal and ambiguous meanings are not enforced by schema transitions.
  13. Audit: no complete atomic financial audit. Generic audits cannot always answer who/what/why for every transition or prove provider request/response/webhook history.
  14. Frontend truth: creator pages show mutable “balance”/estimated earnings and cashout concepts; affiliate inclusion differs by screen; payment methods remain Stripe/PayPal/USDT; some raw/unmapped payout statuses can surface; admin user detail can invoke the legacy send path; custom-rate units conflict.
  15. Scraper decisions in payout: yes, rescrapeForPayout directly selects/calls scraping providers. Cut over to ScrapeJob as the only technical metric acquisition mechanism; the backend reconciliation transaction validates monotonic/capped deltas and posts local earnings/budget journals. Payout selection then reads only finalized journal entries.

Risk ranking ​

SeverityRiskConcrete consequence
CriticalLegacy and new payout paths can both consider the same submission unpaidSame creator earnings can be sent twice
CriticalNo atomic provider-operation claim or uncertain-outcome reconciliationConcurrent sends or timeout retries can move money twice
CriticalCampaign budget is mutable recomputation, not a locked journalOverspend, spend rollback after edits/deletes, or accidental campaign reopening
HighRates/views are mutable and group RPM does not existPrior earnings change retroactively; margin and creator liability cannot be proven
HighFloat/string money and per-submission cent roundingDrift, inconsistent totals, and no exact invariant across accrual/budget/transfer
HighPayout flow directly rescrapesMoney state depends on an ad hoc external call path and can repeat after crash
HighChat WhopIdentity is treated as tempting but unproven payment identityFunds could be sent to a provisioned/support identity rather than the creator's intended balance
HighHard deletion/cascade of financial sourcesAudit evidence can be destroyed
MediumGeneric/best-effort auditing and incomplete frontend mappingsStaff and creators cannot reliably explain balances or transitions

Clean-cutover recommendation ​

Do not patch Whop into either current send route. Build the new journals and payout operation records alongside the existing dev-only data, run deterministic and concurrency tests, then shadow-accrue without dispatch. Once invariants pass, freeze both POST /api/payouts/request and the legacy admin process routes behind feature flags, settle/cancel any dev-only in-flight records, switch reads to the new projections, and only then enable sandbox dispatch. Existing records need not be migrated, but this audit did not delete or mutate them.