Skip to content
A Bekkour
Writing

Charging a card once in Rails: the guard I shipped, and the gaps I didn't close

5 min read

I’m going to do something unusual for a blog post about payments: show you code that’s only partly right, and tell you exactly where it isn’t. Because “charge the card exactly once” is the kind of claim that’s easy to write and hard to actually deliver, and pretending otherwise is how you end up defending a fantasy in a code review. This is the real state of a charge flow I built on a bookkeeping product — the guard that works, the gaps it leaves, and what closing them looks like.

The guard that’s there

A charge lives on its own record, and that record knows whether it’s been charged — a charged one has a stripe_charge_id. Re-running the charge on an already-charged record is refused, and the work runs in a transaction:

def perform_charge(source)
  raise ChargeAlreadyPerformedError, "Charge already performed: #{stripe_charge_id}" if charge_performed?
  self.class.transaction { do_perform_charge(source) }
end

def charge_performed?
  stripe_charge_id.present?
end

Inside, a single card charge is three writes that have to stay together: the charge id on this record, an income record for the money that came in, and an expense record for the processor’s fee — so the books actually balance. They share one transaction:

charge = gateway.create_charge(source:, amount: amount + surcharge, currency:, description:)
self.stripe_charge_id = charge.charge_id

income_record = issuer.income_records.create!(
  amount: charge.received_amount,          # what actually landed (post-conversion)
  amount_in_currency: charge.charged_amount, # what we asked for
  invoice:, invoice_stripe_payment: self)
self.income_record = income_record

issuer.expense_records.create!(
  amount: charge.fee_amount,
  description: "Stripe payment fee: Invoice #{invoice.name}",
  supplier: issuer.suppliers.find_or_create_by(name: 'Stripe'),
  expense_category: issuer.expense_categories.find_or_create_by(name: 'Credit card fees'))

self.save!

The received_amount vs charged_amount split is worth noticing — a customer can pay in one currency and the account settle in another, so income is booked at what landed, not what was requested.

The gaps — and I mean it

Here’s where I’d stop an interviewer before they have to catch me.

The guard doesn’t stop a double-click. The controller creates a new payment record per request and then charges it. Two fast clicks make two records, each with a nil stripe_charge_id, so both pass charge_performed? and both call Stripe. The guard only protects against re-charging the same persisted, already-charged record — not against two records racing. There’s no with_lock, no unique index enforcing one live charge per invoice.

There’s no Stripe idempotency key. Stripe will happily collapse a retried charge if you send an idempotency key — and this flow doesn’t send one. So the provider-level safety net that would make the charge truly exactly-once isn’t in place.

The external call is inside the transaction. Stripe::Charge.create runs within self.class.transaction. If the card is charged but a later create! or the commit fails, the database rolls back while the money does not — charge taken, no stripe_charge_id saved, and because there’s no idempotency key, the retry charges again. That’s a half-charged state, which is exactly what the transaction was supposed to prevent and doesn’t, because a DB transaction can’t roll back Stripe.

None of these has bitten hard in practice — the double-submit window is small, retries are rare — but “hasn’t bitten yet” is not “handled.”

How I’d close them

The fixes are individually small; the discipline is doing them before you need them:

  • Send a deterministic Stripe idempotency key derived from the payment record, so a retry — from a job, a double-click, or a network blip — collapses to one charge on Stripe’s side. This is the real exactly-once guarantee; everything else is defense in depth around it.
  • Take a row lock or a unique constraint so two requests can’t both create a live charge for the same invoice: with_lock on the invoice around the create, or a partial unique index on pending charges.
  • Move the external call out of the transaction (or make the write-back idempotent on the charge id), so a commit failure after a successful charge can be reconciled instead of silently double-charging.

What the code does get right: side-effect idempotency and throttling

The charge is the hard case. The easier — and fully handled — cases are the side effects around it. A “your invoice was paid” email sent twice is a smaller bug, but still a bug, so notifications run through a small idempotency guard:

class IdempotentOp
  class Data < ApplicationRecord
    self.table_name = 'idempotent_ops'
  end
  private_constant :Data

  def self.perform(key:, &block)
    return if Data.where(key: key).exists?
    Data.create!(key: key, status: 'started')
    begin
      block.call
      Data.create!(key: key, status: 'finished')
    rescue => e
      Data.create!(key: key, status: 'error'); raise
    end
  end
end

This one also has an honest edge: the exists?-then-create! check has a race — two processes can both read “not present.” It removes the common cases (a retried job, a refreshed page) but it isn’t a lock. And note it writes multiple rows per key (started, then finished/error), so the naive hardening — “add a unique index on key” — would break it on the second insert. If you need it airtight, the fix is a unique index on a single row per key with the status as an UPDATE, or a Postgres advisory lock around the block — not a unique index bolted onto this multi-row shape.

Throttling is the outer bound — stopping too many distinct actions (brute-forced logins, an email spike). Rather than a gem, there’s a small declarative DSL: a rule names its limits and what to key them by, and limits can flex per plan.

Throttled.rule(:manual_bank_sync) do
  limit_to 10, every: 3.hours
  throttle_by ->(user:){ [{ user_id: user.id }] }
end

It’s fixed-window (the bucket is Time.now.to_i / period), backed by a pluggable store — Redis in production, in-memory for tests, a black hole you flip on with an env var. Simple, legible, and every rule’s behavior lives in one file instead of scattered middleware.

The reason to write all this down rather than claim “exactly-once payments”: the version where I list the gaps and how I’d close them is the one that survives scrutiny, and honestly it’s the more useful post. Anyone can wire up a charge. Knowing precisely where it’s still unsafe is the job.


I build billing and financial flows in Rails where “it charged twice” is a real, expensive incident — and where knowing the failure modes matters as much as the happy path. If that’s your world, let’s talk.

Al Mokhtar Bekkour

Senior Rails & Go engineer in Quebec. I build multi-tenant SaaS and financial integrations, and ship my own products — Constat, Railyard, and Recall. Open to work.

← All writing