Skip to content

[Detail Bug] Payouts: Backoffice retry can trigger Stripe payouts for held payouts (org under review), causing ledger/Stripe mismatch #12228

@detail-app

Description

@detail-app

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().

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    Status
    No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions