Skip to content

Admin Payout Operations Runbook

Internal — operators only

This page documents Admin → Payout Management for FundlyHub platform administrators. It describes tools that move real money and write to the financial ledger. Read the money-safety rules before running anything. The destructive actions require the manage_system_settings permission.

Payout Management lives at the admin Payout Management page and has three tabs: Transfers (the donation-by-donation ledger), Creator Payouts (Stripe payouts to creator bank accounts), and Connected Accounts. This runbook focuses on the Transfers tab, where releases and remediation happen.


The evidence-based transfer status model

Every donation row on the Transfers tab carries one of five status badges. The status is derived from evidence, not from a single flag — a row only reads "money moved" when Stripe actually proves it did. This is the core invariant that prevents the platform from double-paying or silently stranding funds.

BadgeMeaningRelease-eligible?
HeldFunds are held by the platform and the underlying charge has settled and the creator has an active connected account. Ready to release now.Yes
Pending settlementHeld, but the underlying charge has not settled yet. It will become Held automatically once Stripe settles it.Not yet
TransferredA transfer audit ledger row with a real tr_… id exists — Stripe verifiably moved the money.Done
DirectA destination charge: Stripe moved the money to the creator at charge time. No separate transfer needed.Done
AnomalyThe red badge. The ledger claims money moved, but nothing in Stripe proves it (the #1126 bug class). Needs remediation.Needs review

Why "Held" is two-part: a row is only counted as release-eligible when the charge has settled and the creator's connected account is active (has_connected_account && charges_enabled). Settled-but-not-onboarded funds are tracked separately as Held — awaiting onboarding — the money is safe, it just can't be transferred until the creator finishes Stripe Connect.

The status filter at the top of the Transfers tab lets you scope the grid to any single status (Held, Pending settlement, Transferred, Direct, Anomaly).


The KPI tiles

Four primary tiles sit above the grid. The first three are clickable — clicking drills straight into the Transfers tab filtered to that status.

TileWhat it countsClick action
Ready to releaseHeld rows that are settled and have an active connected account — releasable right now. Hint shows the net dollar amount.Filters Transfers to held
Awaiting settlementPending-settlement rows — they release automatically once settled.Filters Transfers to pending_settlement
AnomaliesRows whose ledger claims money moved with no Stripe evidence. When 0, the hint reads "ledger clean".Filters Transfers to anomaly
Connected accountsTotal connected accounts, with N verified · M pending setup in the hint.

Below the tiles, a secondary strip carries lower-weight metrics:

  • Monthly volume — transfers this month
  • Platform fees — tips this month
  • Held — awaiting onboarding — settled held funds the platform can't transfer yet because the creator hasn't connected Stripe (net dollar amount in the hint)
  • Transferred — Stripe-verified + direct, all time
  • Total donations — all time

The Transfers tab label itself shows live badges: N ready (warning) and N anomalies (error) when those counts are non-zero.


The Actions menu

The Actions dropdown (top-right of the Transfers tab) is gated behind manage_system_settings. It has four items.

Release N ready ($X)

Creates real Stripe transfers for every Held row that is release-eligible. Behaviour:

  • Walks every creator with held funds and runs the per-donation release path for those whose connected account is active. Creators without an active connected account are skipped (counted separately, not failed).
  • Each transfer fires one per donation with source_transaction set, so each donation appears as its own line in the creator's connected account.
  • Transfers are net of fees — the held ledger amount is already the creator-payable net.
  • Adopt, don't double-pay: before creating a transfer, the release path checks Stripe for a pre-existing live transfer for that charge (via charge.transfer or the per-donation transfer_group). If one already exists, it adopts it — writes the audit row and flips the ledger entry with zero new money movement — instead of creating a second transfer.
  • Rows whose charge hasn't settled yet are rejected by Stripe and left Held; the webhook retries them on the next balance.available.

A confirmation dialog summarizes the scope before anything fires. After the run, the grid and the KPI counts refetch from the server (no optimistic flips — the badge only changes if the DB ledger update actually committed).

Remediate anomalies

Opens the Remediate transfer anomalies dialog. This is the recovery tool for Anomaly rows — the ledger says money moved but there's no Stripe evidence. It never creates Stripe transfers.

The workflow is Detect → review → Execute (with Attest as an operator override):

  1. Creator user ID (optional). Empty scans the whole platform for anomalies. Scoping to a single creator also includes that creator's still-held rows, so confirmed manual payouts can be attested.

  2. Detect — read-only. Verifies each anomaly against Stripe and classifies it. Review the classification chips:

    ChipMeaningExecute behaviour
    restore_to_heldVerifiably stranded — no Stripe transfer found.Restored to held → re-enters the normal release pipeline.
    already_transferredA real Stripe transfer exists; only the audit row was missing.Audit row backfilled (no money moves).
    direct_confirmedConfirmed destination charge.Stamped as direct (no money moves).
    subscription_excludedSubscription entry, excluded from this path.No change.
    held_unprovenStill held, no Stripe proof either way — attest only.Not touched by Execute.
    attestedMarked paid off-platform by an operator.
    errorStripe/DB error on this row; printed under the row. Nothing changed.
  3. Execute — restore N ($X) + backfill M — applies the non-attest classifications: restores unproven non-held rows to Held, backfills audit rows for evidenced transfers, stamps confirmed destination charges. No Stripe transfers are created. The dialog stays open showing the post-execute classification (so write errors aren't hidden behind a success toast).

  4. Attest paid off-platform — N ($X) — operator override, only shown when a creator is scoped and there are attest candidates. Marks the creator's donations as paid WITHOUT Stripe evidence. Use only when you have personally verified the money reached the creator (e.g. a manual Stripe dashboard transfer or bank payout). It records the payout as evidence without moving money, and prompts for a reason that goes to the audit log.

Restore vs. Attest — the double-pay rule

Execute-restore puts unproven rows back into Held so the normal pipeline pays them. Attest records that they were already paid off-platform and moves no money. NEVER Execute-restore a creator you already paid manually — that pays them twice. If you are not 100% sure the money already moved, attest is the wrong tool; if you are 100% sure it moved, restore is the wrong tool. When in doubt, cancel and verify in the Stripe dashboard first.

Reconcile held with Stripe

Walks every Held row and asks Stripe whether a live transfer already exists for its charge — resolved from either evidence source: charge.transfer (source-transaction era) or the per-donation transfer_group (donation-<id>, the plain-transfer era). If a live, non-reversed transfer is found, it adopts it: writes the audit row and flips the entry to Transferred with zero money movement.

This is the recovery tool for "Stripe paid the creator but a DB rollback left the row Held". It is safe to run any time — it only ever adopts existing transfers, never creates new ones. The toast reports checked / reconciled (matched existing transfers) / genuinely held / skipped / errors.

Backfill settlement data

Old held donations predate the settlement webhooks, so their stripe_available_on is NULL — they render Pending settlement forever and the release path skips them. This action pulls the real stripe_available_on from Stripe for those rows, and repairs false destination-charge stamps. Idempotent — safe to re-run. The toast reports how many of the missing rows were backfilled and how many still remain.


Decision tree

Row shows Anomaly → Open Remediate anomalies, run Detect (scope to the creator if you know it). Review the chips: already_transferred / direct_confirmed rows just need Execute to backfill evidence (no money moves). restore_to_held rows are genuinely stranded — Execute restores them to Held so the pipeline pays them, unless you have evidence the creator was already paid manually, in which case Attest instead. Never restore a row for a creator you paid by hand.

Row shows Held but the creator says they were paid → Do not release. First run Reconcile held with Stripe — if Stripe already has a live transfer for that charge, reconcile adopts it and the row flips to Transferred with no money moved. If reconcile finds nothing but you have personally verified an off-platform payout, scope Remediate anomalies to that creator and Attest paid off-platform with a reason. Releasing instead would double-pay.

Pending settlement from months ago → The row likely predates the settlement webhooks and has a NULL settlement date. Run Backfill settlement data to pull stripe_available_on from Stripe. Once backfilled and settled, it moves to Held and becomes release-eligible (or releases automatically on the next sweep).


Money-safety rules

Read before running any action

  • Every status flip needs evidence. A row only flips to Transferred/Direct when there is a real Stripe tr_…, a charge.transfer, a matching transfer_group, or an audited operator attestation. No flag flips on metadata alone.
  • Remediation never moves money. Detect is read-only; Execute, Reconcile, and Backfill only adopt existing transfers, backfill audit rows, or stamp confirmed states. None of them ever call transfers.create. Only Release N ready creates Stripe transfers.
  • Exact-match guards. Execute and Attest carry this Detect run's numbers (expectedCandidates / expectedRestoreCents) into a server-side exact-match guard. If the ledger changed between Detect and Execute, the server returns 409 → "State changed — re-run Detect". When you see a 409, do not retry blindly — re-run Detect and review the fresh classification before acting.
  • Never double-pay. Do not Execute-restore a creator already paid manually. Use Attest for confirmed off-platform payouts; use Restore only for genuinely stranded funds.
  • Release adopts before it pays. The release path checks Stripe for a pre-existing live transfer and adopts it rather than creating a second one — but this safety net is no substitute for the rules above.

Questions about a specific row or a stuck remediation run? Email tech@cyty.io with the donation ID and the Detect/Execute toast text.

Built with VitePress