Duplicate Payment Guard

Tells a double tap, a retry after a timeout and a deliberate repeat payment apart, so the same payee isn't paid twice by accident.

StopsPaid twice after a timeout

Installation

pnpm dlx shadcn@latest add https://bollard-ui.vercel.app/r/duplicate-payment-guard.json

The files land in your project. They're yours to edit.

Usage

duplicate-payment-guard-usage.tsx
import { DuplicatePaymentGuard, type PastPayment, type PaymentStatus } from "@/components/ui/duplicate-payment-guard"

<DuplicatePaymentGuard
  key={scenario}
  payee={{ id: "ravi.kumar@okaxis", name: "Ravi" }}
  amount={2400}
  payments={history}
  onPay={() => new Promise<void>((resolve) => setTimeout(() => (setLog("Sent ₹2,400 to Ravi."), resolve()), 700))}
  onCheckStatus={() => new Promise<PaymentStatus>((resolve) => setTimeout(() => (setLog("The bank says the first one went through."), resolve("success")), 600))}
/>

What it catches

  • Double taps on the pay button
  • Retries after a timeout while the first payment may still complete
  • Repeat payments to the same payee and amount within a window

Props

DuplicatePaymentGuard props
PropTypeDefault
payee* (required)

Who is being paid; `name` is what the warning shows.

{ id: string; name: string }—
amount* (required)

Amount to pay, in major units.

number—
currency

ISO 4217 currency code.

string"INR"
locale

BCP 47 locale for money and times.

string"en-IN"
payments

Recent payments from your history, any payee, any order. Payments made here are remembered too.

PastPayment[][]
window

A successful payment of the same amount to the same payee within this many ms needs a deliberate second press.

number30 * 60_000
pendingWindow

How long a pending payment keeps blocking a blind retry.

number48 * 3_600_000
doubleTapMs

Presses closer together than this are one tap.

number800
timeoutMs

After this many ms with no answer the attempt counts as pending and the button frees up; a late answer still updates it.

number30_000
onPay* (required)

Sends the money. Resolve with the outcome (nothing means success); a rejection or timeout is treated as pending.

() => void | PaymentStatus | Promise<void | PaymentStatus>—
onCheckStatus

Looks up a pending payment's real status. Without it the pending warning has no Check button.

(payment: PastPayment) => PaymentStatus | Promise<PaymentStatus>—
checkAfterMs

Wait this long after a payment before its status can be checked (NPCI: first status check after 90 s).

number90_000
maxChecks

Status checks allowed per pending payment before the button gives way to a "wait for the bank" note (NPCI allows 3).

number3
aria-describedby

Merged onto the pay button.

string—
messages

Override any visible string.

Partial<DuplicatePaymentGuardMessages>—

* required. Also accepts the native props of <div> (className, aria-*, data-*…), passed through to it.

Accessibility

  • The pay button stays focusable (aria-disabled), sets aria-busy while paying and is described by the hint
  • Escape disarms a second-press state; held Enter repeats are ignored
  • Focus returns to the button after each phase

Only what the source does. Test with your own screen reader and content before shipping.

Limitations

This is a UX guard rail. Enforce on the server: Use idempotency keys and a unique constraint per payment intent on the server; that is what stops a double charge.

  • A disabled or held button reduces accidental repeats; it does not guarantee a payment happens once
  • Recent payments come from your `payments` prop; payments made elsewhere are unseen
  • Status checks depend on your onCheckStatus

What it stops

Paid twice after a timeout

Also known as

duplicate payment · double charge · idempotency · pay button · upi · payment timeout · check status · razorpay

Field type

Dependencies

Pairs well with