Class: Payment::DuplicateChargeGuard

Inherits:
BaseService show all
Defined in:
app/services/payment/duplicate_charge_guard.rb

Overview

Guards CRM-initiated card charges against operator-driven duplicates
(e.g. an apparent decline in HW that actually succeeded at Stripe,
followed by an operator retry). Queries Stripe — not just the local
DB — because the failure mode this guard targets is exactly the
local DB lost track of the first charge
.

Decisions to flag a candidate as a duplicate are based on Stripe state
alone (customer + amount + status:succeeded inside a rolling window).
The only Heatwave-side input is the optional exclude_pi_id: — the
PaymentIntent the current request is itself recording, which must be
filtered out so we never flag the in-flight charge as a duplicate of
itself. Previous versions of this guard tried to do the equivalent
by inspecting Payment rows in state authorized/captured/pending; a
pending Payment orphaned by a prior 4xx then silenced the guard on a
real duplicate (incident 2026-06-02, customer 47895, PI pi_3TdvEK…
orphaned at $1,925.57, second card charge let through 44 minutes
later). Excluding by PI id eliminates that bug class entirely.

Examples:

guard = Payment::DuplicateChargeGuard.new(
  stripe_customer_id: customer.stripe_customer_id,
  amount_cents: 60914,
  currency: 'USD',
  exclude_pi_id: params.dig(:receipt, :auth_code),
)
matches = guard.potential_duplicates
if matches.any? && params[:confirm_duplicate].blank?
  # render warning with matches
end

Defined Under Namespace

Classes: Match

Constant Summary collapse

WINDOW =
7.days

Instance Attribute Summary

Attributes inherited from BaseService

#options

Instance Method Summary collapse

Methods inherited from BaseService

#log_debug, #log_error, #log_info, #log_warning, #logger, #process, #tagged_logger

Constructor Details

#initialize(stripe_customer_id:, amount_cents:, currency:, exclude_pi_id: nil, window: WINDOW, description_match: nil) ⇒ DuplicateChargeGuard

Returns a new instance of DuplicateChargeGuard.

Parameters:

  • stripe_customer_id (String, nil)

    Stripe customer ID; guard
    skips when blank because there's nothing to dedupe against

  • amount_cents (Integer)

    charge amount in minor units

  • currency (String)

    used to pick the US/CA Stripe account

  • exclude_pi_id (String, nil) (defaults to: nil)

    the PaymentIntent id this request
    is itself recording (e.g. params[:receipt][:auth_code] or
    @payment.stripe_payment_intent_id). Filtered out of the matches
    so the in-flight charge is never flagged as a duplicate of itself.

  • window (ActiveSupport::Duration) (defaults to: WINDOW)

    lookback window

  • description_match (String, nil) (defaults to: nil)

    optional substring to require
    in the candidate's description



65
66
67
68
69
70
71
72
# File 'app/services/payment/duplicate_charge_guard.rb', line 65

def initialize(stripe_customer_id:, amount_cents:, currency:, exclude_pi_id: nil, window: WINDOW, description_match: nil)
  @stripe_customer_id = stripe_customer_id
  @amount_cents = amount_cents.to_i
  @currency = currency
  @exclude_pi_id = exclude_pi_id.presence
  @window = window
  @description_match = description_match.presence
end

Instance Method Details

#potential_duplicatesArray<Match>

Stripe payments matching the guard criteria, minus the in-flight PI
the caller is currently recording. An empty array means "safe to
charge" — any remaining match is a real, operator-actionable
duplicate candidate.

Returns:



80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
# File 'app/services/payment/duplicate_charge_guard.rb', line 80

def potential_duplicates
  return [] if @stripe_customer_id.blank?
  return [] unless @amount_cents.positive?

  pis = Payment::Apis::Stripe.search_payment_intents(stripe_query, currency: @currency, limit: 25)

  matches = pis.map { |pi| build_match(pi) }
  matches.reject! { |m| m.payment_intent_id == @exclude_pi_id } if @exclude_pi_id
  matches.reject! { |m| @description_match && !m.description.to_s.include?(@description_match) }
  matches
rescue ::Stripe::StripeError => e
  Rails.logger.warn("DuplicateChargeGuard: Stripe search failed (#{e.class}: #{e.message}) — allowing the charge through")
  # Also surface to AppSignal as a warning so a sustained Stripe-search
  # outage is visible — without this, we silently charge through every
  # would-be duplicate the moment the search API hiccups. AppSignal
  # aggregates by message; identical errors collapse into one incident.
  ErrorReporting.warning('DuplicateChargeGuard: Stripe search failed — charge allowed through',
                         { stripe_customer_id: @stripe_customer_id,
                           amount_cents: @amount_cents,
                           currency: @currency,
                           error_class: e.class.to_s,
                           error_message: e.message })
  []
end