{"record":{"id":"26dc3a6a7de8a0ee","repo":"antiwork/gumroad","slug":"paypal-capture-failure","errorCode":"paypal_capture_failure","errorMessage":"PayPal transaction failed with status #{capture.status}","messagePattern":"PayPal transaction failed with status #(.+?)","errorType":"exception","errorClass":"ChargeProcessorCardError","httpStatus":null,"severity":"critical","filePath":"app/business/payments/charging/implementations/paypal/paypal_charge_processor.rb","lineNumber":685,"sourceCode":"          ensure_captured_amount_matches!(capture, expected_purchase_unit_info)\n        rescue ChargeProcessorError => e\n          refund_mismatched_capture!(paypal_transaction, capture)\n          raise e\n        end\n      end\n      charge = PaypalCharge.new(paypal_transaction_id: capture.id,\n                                order_api_used: true,\n                                payment_details: paypal_transaction)\n      PaypalChargeIntent.new(charge:)\n    else\n      if capture.status.downcase == PaypalApiPaymentStatus::PENDING.downcase &&\n          capture.status_details.reason.upcase == \"ECHECK\"\n        merchant_id = paypal_transaction.purchase_units[0].payee.merchant_id\n        refund!(capture.id,\n                merchant_account: MerchantAccount.find_by(charge_processor_merchant_id: merchant_id),\n                paypal_order_purchase_unit_refund: true)\n      end\n      raise ChargeProcessorCardError.new(\"paypal_capture_failure\",\n                                         \"PayPal transaction failed with status #{capture.status}\",\n                                         charge_id: capture.id)\n    end\n  end\n\n  def ensure_captured_amount_matches!(capture, expected_purchase_unit_info)\n    captured_amount = capture.amount\n    captured_currency = captured_amount&.currency_code\n    captured_value = captured_amount&.value\n    expected_currency = expected_purchase_unit_info[:currency].to_s.upcase\n\n    if captured_currency.blank? || captured_value.blank? || !captured_currency.casecmp?(expected_currency)\n      raise ChargeProcessorError, \"PayPal captured amount does not match Gumroad order amount\"\n    end\n\n    begin\n      captured_total = BigDecimal(captured_value.to_s)\n      expected_total = BigDecimal(expected_purchase_unit_info[:total].to_s)","sourceCodeStart":667,"sourceCodeEnd":703,"githubUrl":"https://github.com/antiwork/gumroad/blob/afeacbd394069a1cbf0c6c50ee8e900925050370/app/business/payments/charging/implementations/paypal/paypal_charge_processor.rb#L667-L703","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"# caller, before: retry capture on the same PayPal order after failure\ncapture_order(order_id: charge.paypal_order_id)\n# after: treat as buyer decline — never reuse the consumed order_id\nbegin\n  intent = capture_order(order_id:, expected_purchase_unit_info:)\nrescue ChargeProcessorCardError => e\n  raise if e.code != \"paypal_capture_failure\"\n  charge.update!(paypal_order_id: nil) # force a fresh order on the buyer's next attempt\n  raise\nend","handlingStrategy":"try-catch","validationCode":"# Guard the caller's retry path: never capture the same PayPal order twice\nraise \"order already captured\" if charge.paypal_order_id.present? && charge.captured?","typeGuard":null,"tryCatchPattern":"begin\n  intent = processor.capture_order(order_id:, expected_purchase_unit_info:)\nrescue ChargeProcessorCardError => e\n  if e.code == \"paypal_capture_failure\"\n    # buyer-side: capture.status was not COMPLETED/PENDING_REVIEW; ECHECK was already refunded\n    mark_purchase_failed_and_allow_retry(e.message, charge_id: e.charge_id)\n  else\n    raise\n  end\nend","preventionTips":["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."],"tags":["paypal","payments","capture","orders-api","charge-processing"],"backgroundTag":"payment-capture-declined","analyzedSha":"afeacbd394069a1cbf0c6c50ee8e900925050370","analyzedAt":"2026-08-21T17:58:52.159Z","schemaVersion":2},"datasetVersion":"2026-08-21T18:17:14.833Z"}