Skip to content

[Detail Bug] Payout cancellation charges merchants a payout fee when no bank payout occurred #12230

@detail-app

Description

@detail-app

Detail Bug Report

https://app.detail.dev/org_ecfbc7cf-6d21-4ce1-8c61-cda1d650ccf7/bugs/bug_2956ae47-2914-4a1f-8387-bfb97fd16325

Introduced in #12097 by @psincraian on Jun 3, 2026

Summary

  • Context: The cancel() method in the payout service reverses payouts and refunds the merchant's balance.
  • Bug: When canceling a pending payout that has a Stripe transfer but no bank payout (transfer_id is not None but no payout attempt), the payout fee is NOT returned even though Stripe has not charged it.
  • Actual vs. expected: Actual: cancel reverses the Stripe transfer and marks payout canceled, but leaves the platform payout fee debited from the merchant balance. Expected: if no bank payout was attempted, the payout fee should be returned to the merchant.
  • Impact: Merchants are charged payout fees for canceled payouts where no bank payout occurred. The reconciliation system does NOT automatically refund these fees.

Code with Bug

# server/polar/payout/service.py (cancel())

if (
    payout.processor == PayoutAccountType.stripe
    and payout_transaction.transfer_id is not None
):
    # Transfer was made - reverse it in Stripe
    stripe_reversal = await stripe_service.reverse_transfer(...)
    # <-- BUG 🔴 Fees are not reversed here; transfer existence is treated as proof payout fee was charged
else:
    # No transfer ran, so the per-payout fees were only reserved, never
    # paid to Stripe. Return them so the merchant is made whole.
    await platform_fee_transaction_service.create_payout_fees_reversal_balances(
        session, payout=payout
    )

Explanation

Stripe charges the payout fee when a bank payout is triggered (i.e., stripe.payout()), not when a transfer is created (stripe.transfer()). The cancel logic uses transfer_id is not None as the gate for fee reversal, so a payout that reached “transfer created” but never reached “bank payout attempted” will have its transfer reversed but will not have its payout fee returned.

This scenario is real and modeled in tests: a pending payout can have transfer_id="STRIPE_TRANSFER_ID" with attempts=[] (no bank payout attempt). It can also occur in production when pending payouts are canceled due to organization state changes.

The repository’s own comment indicates the intended behavior (“fees were only reserved, never paid to Stripe”), but the condition is wrong: it checks for a transfer instead of checking whether a bank payout attempt occurred.

Codebase Inconsistency

create_payout_fees_reversal_balances() reverses all payout-associated platform fees and is documented as being used when a payout “never ran its Stripe transfer,” but the bug scenario is “transfer ran, bank payout did not.” The cancel logic currently never calls this reversal path in that scenario, leaving the merchant-side fee debited with no automated refund via sync_stripe_fees().

Recommended Fix

At minimum, return payout fees when there was no bank payout attempt:

  • After reversing the Stripe transfer, check if not payout.attempts: and call platform_fee_transaction_service.create_payout_fees_reversal_balances(session, payout=payout).

History

This bug was introduced in commit c495bce. The change added fee-reversal logic for held payouts but conflated “Stripe transfer ran” with “payout fee was charged,” using transfer_id is not None instead of checking for a bank payout attempt (payout.attempts).

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