Class: Payment::DuplicateChargeGuard
- Inherits:
-
BaseService
- Object
- BaseService
- Payment::DuplicateChargeGuard
- 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.
Defined Under Namespace
Classes: Match
Constant Summary collapse
- WINDOW =
7.days
Instance Attribute Summary
Attributes inherited from BaseService
Instance Method Summary collapse
-
#initialize(stripe_customer_id:, amount_cents:, currency:, exclude_pi_id: nil, window: WINDOW, description_match: nil) ⇒ DuplicateChargeGuard
constructor
A new instance of DuplicateChargeGuard.
-
#potential_duplicates ⇒ Array<Match>
Stripe payments matching the guard criteria, minus the in-flight PI the caller is currently recording.
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.
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_duplicates ⇒ Array<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.
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.}) — 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. }) [] end |