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
- Surface a retry-with-different-payment-method flow to the buyer — this is modeled as a card-style decline on purpose.
- Look up capture.id (attached to the error) in the PayPal dashboard to read status_details.reason — the real cause.
- For eCheck (PENDING/ECHECK): this branch already refunded the funds; ask the buyer to retry with an instant funding source.
- Audit the calling code for double-capture: one order_id must be captured exactly once; persist capture state before any retry.
- 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
- Persist order/capture ids idempotently and never call capture twice for one order_id.
- Triage using status_details.reason from the PayPal dashboard, not just the status in the message.
- Map this code to buyer-facing 'try another payment method' messaging, not processor-outage handling.
- Alert on unexpected capture statuses — the allowlist (COMPLETED, PENDING/PENDING_REVIEW, ECHECK-refund) is intentionally narrow.
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
- Server returned error response.
- ${response.message}
- Request failed (${response.status})
- Something went wrong.
- This commission's deposit is no longer in a completable stat
AI-assisted analysis of antiwork/gumroad@afeacbd394 (2026-08-21).
Data as JSON: /api/errors/26dc3a6a7de8a0ee.
Report an issue: GitHub.