Class: Payment::Gateways::CreditCard

Inherits:
BasePaymentGateway
  • Object
show all
Defined in:
app/services/payment/gateways/credit_card.rb

Overview

Stripe credit-card gateway strategy. Drives PaymentIntent
creation/confirmation, capture, refund, void, and incremental auth
via the Apis::Stripe wrapper, and keeps the local
CreditCardVault in sync with Stripe-side PaymentMethods.

Defined Under Namespace

Classes: ReceiptResult, Result

Constant Summary collapse

DETACHED_PM_PATTERNS =
[
  'was previously used with a PaymentIntent without Customer attachment',
  'was detached from a Customer'
].freeze

Instance Method Summary collapse

Constructor Details

#initialize(payment = nil, _delivery = nil) ⇒ CreditCard

Returns a new instance of CreditCard.



15
16
17
# File 'app/services/payment/gateways/credit_card.rb', line 15

def initialize(payment = nil, _delivery = nil)
  @payment = payment
end

Instance Method Details

#apply_pm_details(vault, pm, card, billing) ⇒ Object

Mirror Stripe-side PaymentMethod / billing details onto a local
CreditCardVault: card brand, masked number, AVS/CVC checks, and
billing address.

Parameters:

  • vault (CreditCardVault)
  • pm (Stripe::PaymentMethod)
  • card (Stripe::Card)
  • billing (Stripe::Address, nil)


772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
# File 'app/services/payment/gateways/credit_card.rb', line 772

def apply_pm_details(vault, pm, card, billing)
  vault.vault_id = pm.id
  vault.card_type = card.brand
  vault.number = "....#{card.last4}"
  vault.name = pm.billing_details&.name
  vault.exp_month = card.exp_month
  vault.exp_year = card.exp_year

  if billing
    vault.address_line1 = billing.line1
    vault.address_line2 = billing.line2 if billing.line2.present?
    vault.address_city = billing.city
    vault.address_state = billing.state
    vault.zip_code = billing.postal_code
    vault.address_country = billing.country
  end

  vault.address_line1_check = card.checks&.address_line1_check
  vault.address_zip_check = card.checks&.address_postal_code_check
  vault.cvc_check = card.checks&.cvc_check
end

#authorizePayment::Gateways::CreditCard::Result

Authorize the card. Routes to a vault-based off-session
auth when a stored card is referenced; otherwise verifies a
client-confirmed Payment Intent (storefront flow).



24
25
26
27
28
29
30
# File 'app/services/payment/gateways/credit_card.rb', line 24

def authorize
  if @payment.vault_id.present?
    authorize_with_saved_card
  else
    verify_confirmed_payment_intent
  end
end

#cancel_authorization(report_fraud = false) ⇒ Payment::Gateways::CreditCard::Result

Cancel an authorized PaymentIntent that hasn't been captured.
Treats "PI doesn't exist" / "already canceled" responses as
success so the local state still flips to voided.

Parameters:

  • report_fraud (Boolean) (defaults to: false)

Returns:



579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
# File 'app/services/payment/gateways/credit_card.rb', line 579

def cancel_authorization(report_fraud = false)
  begin
    reason = report_fraud ? 'fraudulent' : 'requested_by_customer'
    result_obj = Payment::Apis::Stripe.cancel_payment_intent(
      @payment.authorization_code,
      currency: @payment.currency,
      reason: reason
    )
    tx = OrderTransaction.record_stripe(action: 'void', stripe_object: result_obj)
    success = result_obj.status.in?(%w[canceled succeeded pending])
  rescue ::Stripe::InvalidRequestError => e
    if e.message =~ /No such payment_intent/ || e.message =~ /has already been/
      success = true
      tx = OrderTransaction.record_stripe_error(action: 'void', error: e)
    else
      success = false
      tx = OrderTransaction.record_stripe_error(action: 'void', error: e)
    end
  rescue ::Stripe::StripeError => e
    success = false
    tx = OrderTransaction.record_stripe_error(action: 'void', error: e)
  end

  @payment.transactions.push(tx)

  if success
    backfill_payment_intent_id(result_obj&.id)
    logger.info("#{Time.current}: CC Void of Payment ID #{@payment.id} succeeded")
    @payment.payment_voided!
  else
    logger.info("#{Time.current}: CC Void of Payment ID #{@payment.id} failed")
  end
  Result.new(success: success, message: tx&.message)
end

#capture(capture_amount, options = {}) ⇒ Payment::Gateways::CreditCard::Result

Capture funds against an authorized PaymentIntent. Detects
external Stripe-side captures, honors multicapture
(final_capture), retries against amount_capturable when the
original amount exceeds Stripe's allowed capture, and creates the
matching Receipt.

Parameters:

  • capture_amount (BigDecimal, Numeric)
  • options (Hash) (defaults to: {})

    forwards :final_capture

Returns:



181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
# File 'app/services/payment/gateways/credit_card.rb', line 181

def capture(capture_amount, options = {})
  funds_captured = false

  if @payment.captured_on_stripe?
    # On a shared PI (split-order multicapture) `amount_captured_on_stripe`
    # is the PI's `amount_received` — the SUM of every sibling Payment's
    # capture. Without subtracting what siblings already accounted for,
    # this method would attribute the sibling's capture to THIS payment
    # and create a phantom Receipt (incident: PI
    # pi_3TZJhGHKwX57gwKi19wJHQYs, Payment 285775, Receipt 145477 —
    # $773.96 double-counted with sibling Payment 285776).
    stripe_captured_cents = @payment.amount_captured_on_stripe.to_i
    sibling_captured_cents = @payment.all_pi_siblings
      .where.not(state: %w[declined expired])
      .sum { |s| (s.total_captured * 100).to_i }
    our_captured_cents = (@payment.total_captured * 100).to_i
    unaccounted_cents = stripe_captured_cents - sibling_captured_cents - our_captured_cents

    if unaccounted_cents.positive?
      funds_captured = true
      capture_amount = unaccounted_cents / 100.0
      logger.info("#{Time.current}: Payment #{@payment.id} was captured externally on Stripe — syncing #{capture_amount} (PI received #{stripe_captured_cents}, siblings #{sibling_captured_cents}, ours #{our_captured_cents})")
    else
      logger.info("#{Time.current}: Payment #{@payment.id} has no unaccounted funds on Stripe (PI received #{stripe_captured_cents}, siblings #{sibling_captured_cents}, ours #{our_captured_cents}) — remaining authorization released")
      return Result.new(success: false, message: 'The remaining authorization has been released by Stripe. No additional funds can be captured on this payment.')
    end
  else
    resolver = Payment::StrategyResolver.new(@payment)
    order = @payment.order
    capture_opts = order ? resolver.capture_options(order: order) : Payment::StrategyResolver::CaptureOptions.new
    final_capture = options.key?(:final_capture) ? options[:final_capture] : capture_opts.final_capture
    pi_id = @payment.stripe_payment_intent_id.presence || @payment.authorization_code

    begin
      pi = Payment::Apis::Stripe.capture_payment_intent(
        pi_id,
        currency: @payment.currency,
        amount_cents: (capture_amount * 100).to_i,
        final_capture: final_capture
      )
      tx = OrderTransaction.record_stripe(action: 'capture', stripe_object: pi, amount_cents: (capture_amount * 100).to_i)
      funds_captured = pi.status.in?(%w[succeeded requires_capture])
      backfill_payment_intent_id(pi.id) if funds_captured
    rescue ::Stripe::StripeError => e
      tx = OrderTransaction.record_stripe_error(action: 'capture', error: e, amount_cents: (capture_amount * 100).to_i)
      funds_captured = false
      @payment.transactions.push(tx)

      if e.message.include?('capture amount is greater than the amount you can capture')
        retry_result = retry_capture_with_available_amount(pi_id, capture_amount)
        if retry_result
          tx = retry_result[:tx]
          capture_amount = retry_result[:capture_amount]
          funds_captured = true
          # Retry drained the full `amount_capturable`, so the PI is now
          # spent. Flip `final_capture` so the post-capture state-machine
          # branch below marks the payment captured instead of leaving it
          # in "partial capture (multicapture)" / authorized — which would
          # again trip the FUNDS CAPTURE ERROR mail on the next invoice
          # capture pass.
          final_capture = true
        end
      end
    end
    @payment.transactions.push(tx) unless @payment.transactions.include?(tx)
  end

  if funds_captured
    logger.info("#{Time.current}: #{@payment.authorization_type} payment of #{capture_amount} captured for payment id: #{@payment.id}")
    @payment.reload
    if @payment.total_captured >= @payment.amount
      @payment.payment_captured!
    elsif !final_capture
      logger.info("#{Time.current}: Partial capture (multicapture) for payment id: #{@payment.id}, remaining authorized")
    else
      @payment.payment_captured!
    end
    create_receipt(@payment.invoice, capture_amount, nil)
  else
    logger.info("#{Time.current}: Problem capturing #{@payment.authorization_type} funds for payment id: #{@payment.id}, please review transactions log")
    if @payment.total_captured.positive?
      logger.warn("#{Time.current}: Capture failed but payment #{@payment.id} already has #{@payment.total_captured} captured — keeping current state")
    else
      @payment.transaction_declined!
    end
  end
  Result.new(success: funds_captured)
end

#copy_vault_attrs(source, target) ⇒ Object

Copy mirror-able card/billing attributes from source vault to
target (used after consenting an existing hidden vault so the
caller's transient new-vault object reflects the persisted one).

Parameters:



800
801
802
803
804
805
# File 'app/services/payment/gateways/credit_card.rb', line 800

def copy_vault_attrs(source, target)
  %i[card_type number name exp_month exp_year address_line1 address_line2
     address_city address_state zip_code address_country].each do |attr|
    target.send(:"#{attr}=", source.send(attr))
  end
end

#create_receipt(invoice, amount, _balance) ⇒ Payment::Gateways::CreditCard::ReceiptResult

Build the Receipt that mirrors a successful CC capture/purchase
and stamp the auth code on it. Falls back to attaching the
receipt to the Order when there is no invoice yet.

Parameters:

  • invoice (Invoice, nil)
  • amount (BigDecimal, Numeric)
  • _balance (BigDecimal, nil)

    retained for API parity

Returns:



827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
# File 'app/services/payment/gateways/credit_card.rb', line 827

def create_receipt(invoice, amount, _balance)
  resource = invoice
  resource ||= @payment.order

  new_receipt = @payment.receipts.new(company: resource.company,
                                      customer: resource.customer,
                                      category: 'Credit Card',
                                      amount: amount,
                                      reference: @payment.reference,
                                      card_type: @payment.card_type,
                                      currency: @payment.currency,
                                      payment: @payment,
                                      bank_account: resource.company.,
                                      gl_date: Date.current,
                                      receipt_date: Date.current,
                                      email: @payment.email,
                                      remark: @payment.authorization_code.present? ? "Auth Code: #{@payment.authorization_code}" : nil)

  if resource.is_a?(Invoice)
    new_receipt.receipt_details << ReceiptDetail.new(category: 'Invoice', invoice: resource, amount: amount,
                                                     gl_date: Date.current)
  end

  begin
    new_receipt.save!
    logger.info("#{Time.current}: Created new receipt id: #{new_receipt.id}")
    success = true
  rescue StandardError => e
    success = false
    report_exception e, payment_id: @payment.id, message: 'Payment purchase completed but no receipt'
  end
  ReceiptResult.new(success: success, receipt: new_receipt)
end

#creditObject

Reserved for ActiveMerchant compatibility; not in use.



615
616
617
# File 'app/services/payment/gateways/credit_card.rb', line 615

def credit
  # Not in use
end

#detach_old_pm(old_vault_id, currency) ⇒ Object

Detach an old PaymentMethod from the Stripe customer when its
local vault has been replaced. Logs Stripe failures but does not
raise.

Parameters:

  • old_vault_id (String)
  • currency (String)


813
814
815
816
817
# File 'app/services/payment/gateways/credit_card.rb', line 813

def detach_old_pm(old_vault_id, currency)
  Payment::Apis::Stripe.detach_payment_method(stripe_payment_method_id(old_vault_id), currency: currency)
rescue ::Stripe::StripeError => e
  logger.warn("#{Time.current}: Failed to detach old PM #{old_vault_id}: #{e.message}")
end

#fallback_capture_amountBigDecimal

Note:

When @payment.delivery is nil or delivery.total is missing
(legacy / orphaned payments), the cap falls back to this_remaining
only — equivalent to the original "capture full remaining auth"
behavior — since we can't compute a delivery-scoped share.

Note:

BigDecimal coercion on every arm; sources can be Float, Numeric,
or BigDecimal depending on caller and ORM column type. Avoids
floating-point drift when shipping/tax recalcs introduce trailing
cents.

Cap the reauth-failure fallback capture at the delivery's uncovered
share so a payment whose delivery total dropped (shipping recalc,
discount, etc.) doesn't capture more than the delivery owes.

Logic Details

Cap = min(this_remaining, delivery_remaining) where:

  • this_remaining = @payment.amount − @payment.total_captured
    (Stripe rejects anything larger anyway — the PI's remaining
    amount_capturable).
  • delivery_remaining = delivery.total − sum(total_captured) across all payments on this delivery, clamped at zero. The sum includes
    any already-captured portion of @payment itself, so the formula
    does not double-count.

Returning the min of the two means:

  • If the delivery total dropped below the auth, we capture only what
    the delivery owes (the SO725263 / Apr–May 2026 overcharge case).
  • If the delivery still needs at least the full remaining auth, we
    capture all of it (preserves the "grab funds before they expire"
    intent for multi-payment / multi-delivery orders where this payment
    covers only its slice).

Returns:

  • (BigDecimal)


502
503
504
505
506
507
508
509
510
# File 'app/services/payment/gateways/credit_card.rb', line 502

def fallback_capture_amount
  this_remaining = BigDecimal(@payment.amount.to_s) - BigDecimal(@payment.total_captured.to_s)
  delivery = @payment.delivery
  return [this_remaining, BigDecimal('0')].max unless delivery&.total

  already_captured_on_delivery = delivery.payments.sum(&:total_captured)
  delivery_remaining = [BigDecimal(delivery.total.to_s) - BigDecimal(already_captured_on_delivery.to_s), BigDecimal('0')].max
  [delivery_remaining, this_remaining].min
end

#find_matching_hidden_vault(customer, card) ⇒ CreditCardVault?

Look up an existing hidden, non-consented CreditCardVault on
customer that matches card's last4/exp/brand — used by
#store to upgrade an old hidden vault to a consented one
rather than duplicate the card.

Parameters:

  • customer (Customer)
  • card (Stripe::Card)

Returns:



755
756
757
758
759
760
761
762
# File 'app/services/payment/gateways/credit_card.rb', line 755

def find_matching_hidden_vault(customer, card)
  customer.credit_card_vaults.hidden_without_consent.find_by(
    number: "....#{card.last4}",
    exp_month: card.exp_month,
    exp_year: card.exp_year,
    card_type: card.brand
  )
end

#increment_authorization(new_payment_amount) ⇒ Payment::Gateways::CreditCard::Result

Increase the authorized total on a Stripe PaymentIntent that
supports incremental authorization (e.g. when the order grows
post-auth). Refuses if a sibling capture has already run on the
PI. Updates @payment.amount to match on success.

Parameters:

  • new_payment_amount (BigDecimal, Numeric)

    new total in dollars

Returns:



277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
# File 'app/services/payment/gateways/credit_card.rb', line 277

def increment_authorization(new_payment_amount)
  return Result.new(success: false, message: 'No PaymentIntent ID') if @payment.stripe_payment_intent_id.blank?

  return Result.new(success: false, message: 'Cannot increment authorization after a capture has been made on this PaymentIntent') if any_capture_on_shared_pi?

  return Result.new(success: false, message: 'New amount must be greater than current payment amount') if new_payment_amount <= @payment.amount

  target_pi_cents = (new_payment_amount * 100).to_i

  begin
    current_pi = Payment::Apis::Stripe.retrieve_payment_intent(
      @payment.stripe_payment_intent_id,
      currency: @payment.currency,
      expand: []
    )

    if current_pi.amount >= target_pi_cents
      @payment.update!(amount: new_payment_amount)
      store_stripe_capabilities(current_pi)
      logger.info("#{Time.current}: Payment #{@payment.id} amount updated to #{new_payment_amount} — Stripe PI already at #{current_pi.amount / 100.0}, no increment needed")
      return Result.new(success: true)
    end

    pi = Payment::Apis::Stripe.increment_authorization(
      @payment.stripe_payment_intent_id,
      amount_cents: target_pi_cents,
      currency: @payment.currency,
      description: "Incremented for order #{@payment.order&.reference_number}"
    )
    tx = OrderTransaction.record_stripe(
      action: 'incremental_authorization',
      stripe_object: pi,
      amount_cents: target_pi_cents
    )
  rescue ::Stripe::StripeError => e
    tx = OrderTransaction.record_stripe_error(
      action: 'incremental_authorization',
      error: e,
      amount_cents: target_pi_cents
    )
  end

  @payment.transactions.push(tx)

  if tx.success?
    @payment.update!(amount: new_payment_amount)
    store_stripe_capabilities(pi)
    logger.info("#{Time.current}: Incremental auth for payment #{@payment.id} increased to #{new_payment_amount} (PI total: #{target_pi_cents / 100.0})")
    Result.new(success: true)
  else
    logger.info("#{Time.current}: Incremental auth failed for payment #{@payment.id}: #{tx.message}")
    Result.new(success: false, message: tx.message)
  end
end

#increment_authorization_to_pi_total(new_pi_total) ⇒ Payment::Gateways::CreditCard::Result

Raise the Stripe PaymentIntent's authorized total to new_pi_total.
Used by Order::Splitter when sibling Payments sharing one PI
collectively claim more than the PI's current authorization
(typical cause: per-split tax recalculation overshooting the parent
auth by a few cents). Unlike #increment_authorization, this method
does NOT mutate any local Payment.amount — the splitter's sibling
rows already sum to new_pi_total; only the PI needs to catch up.

Receives @payment only as a handle for stripe_payment_intent_id
and as the row to attach the resulting OrderTransaction to. The
target_pi_cents/current_pi.amount comparison is now
apples-to-apples (PI total vs. PI total), unlike the
per-payment-scoped shortcut in #increment_authorization which
silently succeeded on shared-PI shortfalls (see invoice 272239
incident).

Parameters:

  • new_pi_total (BigDecimal, Numeric)

    desired PI total in dollars

Returns:



350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
# File 'app/services/payment/gateways/credit_card.rb', line 350

def increment_authorization_to_pi_total(new_pi_total)
  return Result.new(success: false, message: 'No PaymentIntent ID') if @payment.stripe_payment_intent_id.blank?

  return Result.new(success: false, message: 'Cannot increment authorization after a capture has been made on this PaymentIntent') if any_capture_on_shared_pi?

  target_pi_cents = (new_pi_total * 100).to_i

  begin
    current_pi = Payment::Apis::Stripe.retrieve_payment_intent(
      @payment.stripe_payment_intent_id,
      currency: @payment.currency,
      expand: []
    )

    if current_pi.amount >= target_pi_cents
      store_stripe_capabilities(current_pi)
      logger.info("#{Time.current}: PI #{@payment.stripe_payment_intent_id} already at #{current_pi.amount / 100.0}, target #{new_pi_total} — no increment needed")
      return Result.new(success: true)
    end

    pi = Payment::Apis::Stripe.increment_authorization(
      @payment.stripe_payment_intent_id,
      amount_cents: target_pi_cents,
      currency: @payment.currency,
      description: "Incremented for shared-PI split (order #{@payment.order&.reference_number})"
    )
    tx = OrderTransaction.record_stripe(
      action: 'incremental_authorization',
      stripe_object: pi,
      amount_cents: target_pi_cents
    )
  rescue ::Stripe::StripeError => e
    tx = OrderTransaction.record_stripe_error(
      action: 'incremental_authorization',
      error: e,
      amount_cents: target_pi_cents
    )
  end

  @payment.transactions.push(tx)

  if tx.success?
    store_stripe_capabilities(pi)
    logger.info("#{Time.current}: PI #{@payment.stripe_payment_intent_id} incremented to #{new_pi_total} (covering shared-PI sibling claims)")
    Result.new(success: true)
  else
    logger.info("#{Time.current}: Incremental auth failed for PI #{@payment.stripe_payment_intent_id}: #{tx.message}")
    Result.new(success: false, message: tx.message)
  end
end

#purchase(receipt, _options = {}) ⇒ Payment::Gateways::CreditCard::Result

Create a PaymentIntent with capture_method: 'automatic' (or use
an existing client-confirmed PI) and immediately mint a Receipt
against receipt. Handles vault creation when the customer opted
to save the card. Idempotently degrades when the receipt save
fails (purchase succeeds, receipt is reported to AppSignal).

Parameters:

  • receipt (Receipt, nil)

    caller-prepped receipt skeleton; nil
    skips the receipt entirely (e.g. payment-only flows)

  • options (Hash)

    reserved

Returns:



42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
# File 'app/services/payment/gateways/credit_card.rb', line 42

def purchase(receipt, _options = {})
  # `vault_id.blank?` (not `.nil?`) — the receipt/payment form
  # submits an empty hidden vault_id field when the operator is
  # typing a fresh card, so the value arrives as `""` rather than
  # nil. Treating `""` as "vault is set" misroutes a client-confirmed
  # PaymentIntent into the create-new-PI branch, where Stripe
  # rejects the PI id as a PaymentMethod id while the customer's
  # card has already been charged by the client-side confirm.
  # (This was the root cause of the May 5 / invoice 271390
  # duplicate-charge incident — see
  # `doc/tasks/202605111200_DUPLICATE_CHARGE_PROTECTION.md`.)
  client_confirmed = @payment.card_token&.start_with?('pi_') && @payment.vault_id.blank?
  confirmed_pi = nil

  # Defense in depth: if a client-confirmed PI is in card_token,
  # persist it on the payment row up-front so an orphan charge in
  # Stripe always has a breadcrumb back to this row even when the
  # gateway call below fails before reaching backfill_payment_intent_id.
  backfill_payment_intent_id(@payment.card_token) if client_confirmed

  if @payment.vault_id.blank? && @payment.card_token.present? && @payment.store_card.to_b
    card_token_for_vault = @payment.card_token

    if client_confirmed
      confirmed_pi = Payment::Apis::Stripe.retrieve_payment_intent(@payment.card_token, currency: @payment.currency)
      pm = confirmed_pi.payment_method
      card_token_for_vault = pm.is_a?(String) ? pm : pm&.id
    end

    if card_token_for_vault.present?
      cc_vault = @payment.customer.credit_card_vaults.new(
        card_token: card_token_for_vault, description: @payment.store_card_name,
        hidden: false, issuer_number: @payment.issuer_number,
        consent_given_at: Time.current,
        consent_channel: @payment.try(:consent_channel) || 'crm_phone'
      )
      res = cc_vault.store_card(@payment.email)
      if res == false
        # Early return — vault store failed. Without this transition
        # the persisted Payment stays in `state="pending"` indefinitely
        # (this is what produced orphan Payment 286252 on 2026-06-02 —
        # see the duplicate_charge_guard.rb header for the full incident).
        message = "Error processing card: #{cc_vault.errors_to_s}"
        decline_payment_safely(message)
        return Result.new(success: false, message: message)
      end

      @payment.vault_id = cc_vault.vault_id
    end
  end

  stripe_error = nil
  begin
    pi = if client_confirmed
           confirmed_pi || Payment::Apis::Stripe.retrieve_payment_intent(@payment.card_token, currency: @payment.currency)
         else
           Payment::Apis::Stripe.create_payment_intent(
             amount_cents: (@payment.amount * 100).to_i,
             currency: @payment.currency,
             customer_id: resolve_stripe_customer_id,
             payment_method: stripe_payment_method_id(@payment.vault_id.presence) || @payment.card_token,
             capture_method: 'automatic',
             confirm: true,
             off_session: true,
             metadata: ,
             description: stripe_description,
             statement_descriptor: stripe_statement_descriptor
           )
         end
    backfill_payment_intent_id(pi&.id)
    tx = OrderTransaction.record_stripe(action: 'purchase', stripe_object: pi, amount_cents: pi.amount)
  rescue ::Stripe::StripeError => e
    stripe_error = e
    backfill_payment_intent_id(stripe_error_pi_id(e))
    tx = OrderTransaction.record_stripe_error(action: 'purchase', error: e, amount_cents: (@payment.amount * 100).to_i)
    mark_vault_detached_if_applicable(e) unless client_confirmed
  end

  @payment.transactions.push(tx)

  if indeterminate_stripe_error?(stripe_error)
    report_indeterminate_stripe_failure('purchase', stripe_error)
    # Indeterminate failure: we genuinely don't know whether the card
    # was charged on Stripe. Leave the row in `pending` so manual
    # reconciliation can resolve it (capturing or refunding as
    # appropriate). A non-indeterminate failure goes through
    # process_stripe_tx_results / transaction_declined! below.
    return Result.new(success: false, message: indeterminate_message(stripe_error))
  end

  process_stripe_tx_results(tx, pi)

  if tx.success?
    logger.info("#{Time.current}: CC Purchase of Payment ID #{@payment.id} Amount #{@payment.amount} succeeded")
    @payment.purchase_complete!
    if receipt.nil?
      logger.info("#{Time.current}: Skipping receipt for Payment ID #{@payment.id} as this is for a Payment")
      receipt_ok = true
      message = 'ok'
    else
      receipt.reference = @payment.reference
      receipt.card_type = @payment.card_type
      if @payment.authorization_code.present?
        trans_id = "Auth Code: #{@payment.authorization_code}"
        receipt.remark = (receipt.remark.present? ? "#{receipt.remark} / #{trans_id}" : trans_id)
      end
      receipt.payment = @payment
      message = 'ok'
      begin
        receipt.save!
        receipt_ok = true
        logger.info("#{Time.current}: Created new receipt id: #{receipt.id}")
      rescue StandardError => e
        receipt_ok = false
        report_exception e, payment_id: @payment.id, message: 'Payment purchase completed but no receipt'
      end
    end
  else
    receipt_ok = false
    logger.info("#{Time.current}: CC Purchase of Payment ID #{@payment.id} Amount #{@payment.amount} failed")
    @payment.transaction_declined!
    @payment.last_response = tx.message
    message = tx.message
  end
  Result.new(success: tx.success? && receipt_ok == true, message: message, receipt: receipt)
rescue ActiveRecord::ActiveRecordError => e
  decline_payment_safely(tx&.message || e.message)
  Result.new(success: false, message: tx&.message || e.message)
end

#reauthorizePayment::Gateways::CreditCard::Result

Re-authorize an expiring auth by running a fresh
OrderProcessor pass against the on-file vault, then
cancelling the old PI. Falls back to capturing the existing
authorization (final_capture) when reauth fails so funds are not
lost.

The processor pass excludes @payment from delivery-balance math so
an over-sized existing auth doesn't make the delivery look already-paid
and short-circuit the reauth (the recurring overcharge mode from
SO725263 / Apr–May 2026). The fallback capture is capped at the
delivery's uncovered share so it never charges more than the customer
actually owes on that delivery.



437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
# File 'app/services/payment/gateways/credit_card.rb', line 437

def reauthorize
  order = @payment.order
  return Result.new(success: false, message: 'not authorized') unless @payment.authorized?
  return Result.new(success: false, message: 'no vault on file') if @payment.credit_card_vault.blank?

  return Result.new(success: false, message: 'already captured on Stripe') if @payment.captured_on_stripe?

  attrs = {
    currency: @payment.currency,
    category: @payment.category,
    po_number: @payment.po_number,
    amount: @payment.amount,
    authorization_type: @payment.authorization_type,
    vault_id: @payment.vault_id,
    email: @payment.email,
    remote_ip_address: @payment.remote_ip_address
  }
  res = Payment::OrderProcessor.new(order, attrs, nil, nil, false, excluding_payment: @payment).process

  if res.result.in?(%w[fully_authorized partially_authorized])
    cancel_old_authorization
    @payment.payment_expired!
    Result.new(success: true)
  else
    fallback_amount = fallback_capture_amount
    logger.warn("Payment #{@payment.id}: reauth failed (#{res.error}), capturing #{fallback_amount} of existing authorization as fallback")
    capture(fallback_amount, final_capture: true)
  end
end

#refund(refund_amount, credit_memo) ⇒ Payment::Gateways::CreditCard::Result

Refund a captured credit-card payment against credit_memo and
build the matching Receipt. Refund failures are recorded on the
transaction; receipt failures are reported to AppSignal so the
refund itself isn't lost.

Parameters:

  • refund_amount (BigDecimal, Numeric)
  • credit_memo (CreditMemo)

Returns:



627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
# File 'app/services/payment/gateways/credit_card.rb', line 627

def refund(refund_amount, credit_memo)
  begin
    refund_obj = Payment::Apis::Stripe.create_refund(
      payment_intent_id: @payment.authorization_code,
      currency: @payment.currency,
      amount_cents: (refund_amount * 100).to_i
    )
    tx = OrderTransaction.record_stripe(action: 'refund', stripe_object: refund_obj, amount_cents: (refund_amount * 100).to_i)
  rescue ::Stripe::StripeError => e
    tx = OrderTransaction.record_stripe_error(action: 'refund', error: e, amount_cents: (refund_amount * 100).to_i)
  end

  @payment.transactions.push(tx)

  if tx.success?
    backfill_payment_intent_id(refund_obj&.payment_intent)
    logger.info("#{Time.current}: #{@payment.authorization_type} Refund of Payment ID #{@payment.id} Amount #{refund_amount} succeeded")
    @payment.payment_refunded!
     = @payment.invoice&.company&. || credit_memo.company.
    new_receipt = Receipt.new(company: credit_memo.company,
                              customer: credit_memo.customer,
                              category: 'Credit Card',
                              amount: -refund_amount,
                              reference: @payment.reference,
                              card_type: @payment.card_type,
                              currency: @payment.currency,
                              payment: @payment,
                              bank_account: ,
                              gl_date: Date.current,
                              receipt_date: Date.current)
    new_receipt.receipt_details << ReceiptDetail.new(category: 'Credit Memo', credit_memo: credit_memo, amount: -refund_amount,
                                                     gl_date: Date.current)
    begin
      new_receipt.save!
      receipt_ok = true
      logger.info("#{Time.current}: Created new receipt id: #{new_receipt.id}")
    rescue StandardError => e
      receipt_ok = false
      report_exception e, payment_id: @payment.id, message: 'Payment refunded but no receipt'
    end
  else
    logger.info("#{Time.current}: Refund of Payment ID #{@payment.id} Amount #{refund_amount} failed")
  end
  Result.new(success: tx.success? && receipt_ok)
end

#register_shared_authorization(payment_intent, amount_cents:) ⇒ Payment::Gateways::CreditCard::Result

Attach this Payment to an existing Stripe PaymentIntent (the
split-order multicapture pattern) without re-authorizing.

Parameters:

  • payment_intent (Stripe::PaymentIntent)
  • amount_cents (Integer)

    this payment's slice of the PI

Returns:



407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
# File 'app/services/payment/gateways/credit_card.rb', line 407

def register_shared_authorization(payment_intent, amount_cents:)
  tx = OrderTransaction.record_stripe(
    action: 'authorization',
    stripe_object: payment_intent,
    amount_cents: amount_cents
  )
  @payment.transactions.push(tx)
  process_stripe_tx_results(tx, payment_intent)
  store_stripe_capabilities(payment_intent)
  @payment.payment_authorized!
  Result.new(success: true)
rescue StandardError => e
  logger.error("Failed to register shared auth for payment #{@payment.id}: #{e.message}")
  Result.new(success: false, message: e.message)
end

#release_remaining_authorizationPayment::Gateways::CreditCard::Result

Release the still-uncaptured portion of a partially-captured
PaymentIntent (or refund a non-PI charge) and mark the Payment
voided. Captured funds stay intact.



535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
# File 'app/services/payment/gateways/credit_card.rb', line 535

def release_remaining_authorization
  pi_id = @payment.stripe_payment_intent_id.presence || @payment.authorization_code

  begin
    if Payment::Apis::Stripe.payment_intent?(pi_id)
      result_obj = Payment::Apis::Stripe.capture_payment_intent(
        pi_id,
        currency: @payment.currency,
        amount_cents: 0,
        final_capture: true
      )
      tx = OrderTransaction.record_stripe(action: 'void', stripe_object: result_obj, amount_cents: 0)
      success = result_obj.status.in?(%w[succeeded canceled])
    else
      result_obj = Payment::Apis::Stripe.refund_charge(
        pi_id,
        currency: @payment.currency
      )
      tx = OrderTransaction.record_stripe(action: 'void', stripe_object: result_obj, amount_cents: 0)
      success = result_obj.status.in?(%w[succeeded pending])
    end
  rescue ::Stripe::StripeError => e
    tx = OrderTransaction.record_stripe_error(action: 'void', error: e)
    success = false
  end

  @payment.transactions.push(tx)

  if success
    backfill_payment_intent_id(result_obj&.id)
    logger.info("#{Time.current}: Released remaining authorization for payment #{@payment.id} (captured: #{@payment.total_captured})")
    @payment.payment_voided!
  else
    logger.info("#{Time.current}: Failed to release remaining authorization for payment #{@payment.id}")
  end
  Result.new(success: success, message: tx&.message)
end

#store(cc_vault, email) ⇒ Payment::Gateways::CreditCard::Result

Persist a card to Stripe's customer-PaymentMethod relationship and
mirror the metadata onto the local CreditCardVault. Reuses an
existing hidden vault for the same card to avoid duplicating the
PM on the customer when consent is later granted.

Parameters:

  • cc_vault (CreditCardVault)

    new vault carrying the card_token

  • email (String, nil)

Returns:



681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
# File 'app/services/payment/gateways/credit_card.rb', line 681

def store(cc_vault, email)
  currency = cc_vault.customer.catalog&.currency || 'USD'
  customer_id = cc_vault.customer.stripe_customer_id

  if customer_id.blank?
    begin
      stripe_customer = Payment::Apis::Stripe.create_customer(
        currency: currency,
        email: email,
        name: cc_vault.customer.full_name,
        metadata: { customer_id: cc_vault.customer.id }
      )
      customer_id = stripe_customer.id
      cc_vault.customer.update(stripe_customer_id: customer_id)
    rescue ::Stripe::StripeError => e
      cc_vault.errors.add(:base, e.message)
      return Result.new(success: false, message: e.message)
    end
  end

  begin
    pm = Payment::Apis::Stripe.attach_payment_method(
      cc_vault.card_token, customer_id: customer_id, currency: currency
    )

    card = pm.card
    billing = pm.billing_details&.address

    existing = find_matching_hidden_vault(cc_vault.customer, card) if cc_vault.consented?
    if existing
      old_vault_id = existing.vault_id
      apply_pm_details(existing, pm, card, billing)
      existing.hidden = false
      existing.description = cc_vault.description if cc_vault.description.present?
      existing.consent_given_at = cc_vault.consent_given_at
      existing.consent_channel = cc_vault.consent_channel
      success = existing.save
      detach_old_pm(old_vault_id, currency) if success && old_vault_id != pm.id
      cc_vault.vault_id = existing.vault_id
      copy_vault_attrs(existing, cc_vault)
      Result.new(success: success, message: success ? 'ok' : existing.errors.full_messages.join(', '))
    else
      apply_pm_details(cc_vault, pm, card, billing)
      success = cc_vault.save
      Result.new(success: success, message: success ? 'ok' : cc_vault.errors.full_messages.join(', '))
    end
  rescue ::Stripe::StripeError => e
    cc_vault.errors.add(:base, e.message)
    Result.new(success: false, message: e.message)
  end
end

#unstore(cc_vault) ⇒ Object

Detach the PaymentMethod from the Stripe customer when a vault is
being removed locally. Stripe-side errors are logged, not raised,
because the local destroy must still succeed.

Parameters:



738
739
740
741
742
743
744
745
# File 'app/services/payment/gateways/credit_card.rb', line 738

def unstore(cc_vault)
  return if cc_vault.vault_id.blank?

  currency = cc_vault.customer&.catalog&.currency || 'USD'
  Payment::Apis::Stripe.detach_payment_method(stripe_payment_method_id(cc_vault.vault_id), currency: currency)
rescue ::Stripe::StripeError => e
  logger.warn("#{Time.current}: Failed to detach PaymentMethod #{cc_vault.vault_id}: #{e.message}")
end

#void(report_fraud = false) ⇒ Payment::Gateways::CreditCard::Result

Void an authorized credit-card payment. Routes to
#release_remaining_authorization for partial-capture cases
(preserves what was already captured) and to
#cancel_authorization for never-captured payments.

Parameters:

  • report_fraud (Boolean) (defaults to: false)

    when true, the cancel reason is
    set to "fraudulent" on the Stripe side

Returns:



520
521
522
523
524
525
526
527
528
# File 'app/services/payment/gateways/credit_card.rb', line 520

def void(report_fraud = false)
  return Result.new(success: false) unless @payment.authorized?

  if @payment.total_captured.positive?
    release_remaining_authorization
  else
    cancel_authorization(report_fraud)
  end
end