antiwork/gumroad · critical · ChargeProcessorCardError

paypal_capture_failure

paypal_capture_failure

Error message

PayPal transaction failed with status #{capture.status}

What it means

ChargeProcessorCardError (code paypal_capture_failure) raised in PaypalChargeProcessor#capture_order after the PayPal Orders API capture call returns. The first capture resource's status is inspected: COMPLETED proceeds (with amount verification), PENDING with reason PENDING_REVIEW proceeds, PENDING with reason ECHECK is first auto-refunded then raised here, and every other status — most commonly DECLINED — raises with the raw PayPal status in the message and charge_id (capture.id) attached. Mapping it to a *Card* error makes downstream handling treat it as a buyer-side, retry-with-another-method failure rather than a processor outage.

Source

Thrown at app/business/payments/charging/implementations/paypal/paypal_charge_processor.rb:685

          ensure_captured_amount_matches!(capture, expected_purchase_unit_info)
        rescue ChargeProcessorError => e
          refund_mismatched_capture!(paypal_transaction, capture)
          raise e
        end
      end
      charge = PaypalCharge.new(paypal_transaction_id: capture.id,
                                order_api_used: true,
                                payment_details: paypal_transaction)
      PaypalChargeIntent.new(charge:)
    else
      if capture.status.downcase == PaypalApiPaymentStatus::PENDING.downcase &&
          capture.status_details.reason.upcase == "ECHECK"
        merchant_id = paypal_transaction.purchase_units[0].payee.merchant_id
        refund!(capture.id,
                merchant_account: MerchantAccount.find_by(charge_processor_merchant_id: merchant_id),
                paypal_order_purchase_unit_refund: true)
      end
      raise ChargeProcessorCardError.new("paypal_capture_failure",
                                         "PayPal transaction failed with status #{capture.status}",
                                         charge_id: capture.id)
    end
  end

  def ensure_captured_amount_matches!(capture, expected_purchase_unit_info)
    captured_amount = capture.amount
    captured_currency = captured_amount&.currency_code
    captured_value = captured_amount&.value
    expected_currency = expected_purchase_unit_info[:currency].to_s.upcase

    if captured_currency.blank? || captured_value.blank? || !captured_currency.casecmp?(expected_currency)
      raise ChargeProcessorError, "PayPal captured amount does not match Gumroad order amount"
    end

    begin
      captured_total = BigDecimal(captured_value.to_s)
      expected_total = BigDecimal(expected_purchase_unit_info[:total].to_s)

View on GitHub (pinned to afeacbd394)

Solutions

  1. Surface a retry-with-different-payment-method flow to the buyer — this is modeled as a card-style decline on purpose.
  2. Look up capture.id (attached to the error) in the PayPal dashboard to read status_details.reason — the real cause.
  3. For eCheck (PENDING/ECHECK): this branch already refunded the funds; ask the buyer to retry with an instant funding source.
  4. Audit the calling code for double-capture: one order_id must be captured exactly once; persist capture state before any retry.
  5. If additional statuses (e.g. specific PENDING reasons) should be accepted, extend the status allowlist in capture_order deliberately, not by deleting the raise.

Example fix

# caller, before: retry capture on the same PayPal order after failure
capture_order(order_id: charge.paypal_order_id)
# after: treat as buyer decline — never reuse the consumed order_id
begin
  intent = capture_order(order_id:, expected_purchase_unit_info:)
rescue ChargeProcessorCardError => e
  raise if e.code != "paypal_capture_failure"
  charge.update!(paypal_order_id: nil) # force a fresh order on the buyer's next attempt
  raise
end
Defensive patterns

Strategy: try-catch

Validate before calling

# Guard the caller's retry path: never capture the same PayPal order twice
raise "order already captured" if charge.paypal_order_id.present? && charge.captured?

Try / catch

begin
  intent = processor.capture_order(order_id:, expected_purchase_unit_info:)
rescue ChargeProcessorCardError => e
  if e.code == "paypal_capture_failure"
    # buyer-side: capture.status was not COMPLETED/PENDING_REVIEW; ECHECK was already refunded
    mark_purchase_failed_and_allow_retry(e.message, charge_id: e.charge_id)
  else
    raise
  end
end

Prevention

When it happens

Trigger: Capture status DECLINED (buyer's PayPal funding source or backup card refused); PENDING with a reason other than PENDING_REVIEW/ECHECK (e.g. risk hold or receiving-preference rules); eCheck funding (auto-refunded, then raised with status PENDING); or capturing an order that was already captured/refunded/voided.

Common situations: German bank-transfer eCheck payments; PayPal risk reviews on new buyer accounts; buyers whose linked card expired; and caller bugs that capture the same paypal_order_id twice, where the second capture hits a consumed order.

Related errors


AI-assisted analysis of antiwork/gumroad@afeacbd394 (2026-08-21). Data as JSON: /api/errors/26dc3a6a7de8a0ee. Report an issue: GitHub.