Detail Bug Report
https://app.detail.dev/org_ecfbc7cf-6d21-4ce1-8c61-cda1d650ccf7/bugs/bug_c16a8c49-77dc-4ae3-8471-2cc1ab6550af
Introduced in #12097 by @psincraian on Jun 3, 2026
Summary
- Context: Organizations can transition from ACTIVE to REVIEW when they hit review thresholds. This does NOT cancel existing pending payouts. The payout system has a
held status for new payouts created during REVIEW.
- Bug:
trigger_stripe_payout() only checks for canceled status, not held. When an ACTIVE org transitions to REVIEW with pending payouts, those payouts continue processing. The held payouts created during REVIEW can be triggered via backoffice retry, using funds from the pending payouts.
- Actual vs. expected: Held payouts should be completely blocked from triggering Stripe payouts. Instead, they can be retried through the backoffice (UI shows retry button, endpoint allows it), using funds from other payouts in the same Connect account.
- Impact: Financial integrity violation - funds from payout A (pending) are paid out for payout B (held), creating inconsistent state where the ledger shows payout B not funded but Stripe paid out funds.
Code with Bug
# server/polar/payout/service.py
async def trigger_stripe_payout(
self, session: AsyncSession, payout: Payout, account_amount: int | None = None
) -> PayoutAttempt:
if payout.status == PayoutStatus.canceled: # <-- BUG 🔴 only blocks canceled, allows held payouts
raise PayoutCanceled(payout)
# ... creates PayoutAttempt and calls Stripe API ...
# NO CHECK for held status!
# server/polar/backoffice/payouts/endpoints.py
if payout.status == PayoutStatus.canceled: # <-- BUG 🔴 retry endpoint allows held payouts
raise HTTPException(status_code=400, detail="Cannot retry a canceled payout.")
# server/polar/backoffice/payouts/endpoints.py
# can_retry evaluates True for held payouts with no attempts
can_retry = payout.status != PayoutStatus.canceled and (
payout.status == PayoutStatus.failed or len(payout.attempts) == 0 or ...
) # <-- BUG 🔴 shows Retry button for held payouts
Explanation
PayoutStatus.held is intended to block payout processing while an organization is in REVIEW (the transfer() path already skips both canceled and held). However, the manual “retry/trigger” path does not include the same held guard, so held payouts can still reach stripe_service.create_payout().
- The database trigger in
payout_attempt.py prevents updating a held payout’s status, but it fires after the PayoutAttempt flush and does not prevent the subsequent Stripe API call. Result: Stripe payout is created/paid, while the payout remains held in the ledger.
trigger_stripe_payout() only checks overall Stripe Connect account balance, so if there are existing pending payouts that already transferred funds into the Connect account, those funds can be used to pay out a separate held payout—creating a ledger/Stripe reconciliation mismatch.
Codebase Inconsistency
transfer() already blocks held payouts, indicating held payouts are not meant to be payable:
# server/polar/payout/service.py
if payout.status in (PayoutStatus.canceled, PayoutStatus.held):
log.warning("payout.transfer.skipped_not_payable", ...)
return payout
Recommended Fix
- Add
held checks to block triggering/retrying held payouts in:
server/polar/payout/service.py (trigger_stripe_payout should reject held similarly to transfer())
server/polar/backoffice/payouts/endpoints.py (both can_retry UI flag and the retry endpoint guard)
History
This bug was introduced in commit c495bce. The commit added support for held payouts (payouts created for organizations under review) and created a new transfer() function that correctly guards against both canceled and held statuses. However, it failed to update the existing trigger_stripe_payout() function to include the same held check, creating an inconsistency where the normal payout flow is protected but the manual retry path is not. The bug slipped in because the developer focused on the new transfer() guard but overlooked updating the parallel guard in trigger_stripe_payout().
Detail Bug Report
https://app.detail.dev/org_ecfbc7cf-6d21-4ce1-8c61-cda1d650ccf7/bugs/bug_c16a8c49-77dc-4ae3-8471-2cc1ab6550af
Introduced in #12097 by @psincraian on Jun 3, 2026
Summary
heldstatus for new payouts created during REVIEW.trigger_stripe_payout()only checks forcanceledstatus, notheld. When an ACTIVE org transitions to REVIEW with pending payouts, those payouts continue processing. The held payouts created during REVIEW can be triggered via backoffice retry, using funds from the pending payouts.Code with Bug
Explanation
PayoutStatus.heldis intended to block payout processing while an organization is in REVIEW (thetransfer()path already skips bothcanceledandheld). However, the manual “retry/trigger” path does not include the sameheldguard, so held payouts can still reachstripe_service.create_payout().payout_attempt.pyprevents updating a held payout’s status, but it fires after thePayoutAttemptflush and does not prevent the subsequent Stripe API call. Result: Stripe payout is created/paid, while the payout remainsheldin the ledger.trigger_stripe_payout()only checks overall Stripe Connect account balance, so if there are existing pending payouts that already transferred funds into the Connect account, those funds can be used to pay out a separate held payout—creating a ledger/Stripe reconciliation mismatch.Codebase Inconsistency
transfer()already blocks held payouts, indicating held payouts are not meant to be payable:Recommended Fix
heldchecks to block triggering/retrying held payouts in:server/polar/payout/service.py(trigger_stripe_payoutshould rejectheldsimilarly totransfer())server/polar/backoffice/payouts/endpoints.py(bothcan_retryUI flag and the retry endpoint guard)History
This bug was introduced in commit c495bce. The commit added support for
heldpayouts (payouts created for organizations under review) and created a newtransfer()function that correctly guards against bothcanceledandheldstatuses. However, it failed to update the existingtrigger_stripe_payout()function to include the sameheldcheck, creating an inconsistency where the normal payout flow is protected but the manual retry path is not. The bug slipped in because the developer focused on the newtransfer()guard but overlooked updating the parallel guard intrigger_stripe_payout().