Skip to content

fix: deduplicate Stripe webhook credits via ledger reference index#124

Open
antojoseph wants to merge 2 commits into
masterfrom
security/f007-stripe-idempotent-webhook
Open

fix: deduplicate Stripe webhook credits via ledger reference index#124
antojoseph wants to merge 2 commits into
masterfrom
security/f007-stripe-idempotent-webhook

Conversation

@antojoseph
Copy link
Copy Markdown

Summary

  • Adds a unique partial index idx_ledger_reference on ledger_entries(entry_type, reference) WHERE reference <> '' so duplicate Stripe webhook deliveries cannot double-credit an account
  • Updates creditTx to pre-check for an existing entry and use ON CONFLICT DO NOTHING on insert, returning ErrAlreadyCredited on duplicate

Security impact

Without this fix a Stripe checkout.session.completed webhook replayed by Stripe (or by an attacker replaying the raw POST) would credit the same account multiple times. The index makes the operation idempotent at the database level.

Test plan

  • Run coordinator unit tests: go test ./internal/store/...
  • Manually trigger duplicate webhook delivery in Stripe test dashboard and verify balance credited only once

Add a unique partial index on ledger_entries(entry_type, reference) where
reference is non-empty. This enforces at the DB level that a given Stripe
session ID can only be credited once, even under concurrent or duplicate
webhook delivery.

creditTx now also checks for an existing reference before updating the
balance, returning early without crediting if a duplicate is detected.
The ON CONFLICT DO NOTHING on the ledger insert is a final race-safe guard.
@vercel
Copy link
Copy Markdown

vercel Bot commented May 4, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
d-inference Ready Ready Preview May 4, 2026 3:41am
d-inference-console-ui-dev Ready Ready Preview May 4, 2026 3:41am
d-inference-landing Ready Ready Preview May 4, 2026 3:41am

Request Review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 51e28db177

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread coordinator/internal/store/postgres.go Outdated
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_ledger_account ON ledger_entries(account_id, created_at DESC)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_reference ON ledger_entries(entry_type, reference) WHERE reference <> ''`,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Scope reference uniqueness to avoid dropping valid credits

The new unique index on (entry_type, reference) is global, so any repeated non-empty reference for the same entry type will suppress later credits across all accounts. This now conflicts with existing callers that intentionally reuse references (for example LedgerRefund with "reservation_refund" in coordinator/internal/api/consumer.go and default "admin_credit" in coordinator/internal/api/billing_handlers.go), causing legitimate credits to be silently skipped after the first one.

Useful? React with 👍 / 👎.

Comment thread coordinator/internal/store/postgres.go Outdated
`INSERT INTO ledger_entries (account_id, entry_type, amount_micro_usd, balance_after, reference, created_at)
VALUES ($1, $2, $3, $4, $5, COALESCE($6, NOW()))`,
VALUES ($1, $2, $3, $4, $5, COALESCE($6, NOW()))
ON CONFLICT (entry_type, reference) WHERE reference <> '' DO NOTHING`,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Abort transaction when dedupe conflict drops ledger row

This ON CONFLICT ... DO NOTHING runs after the balance increment, and the result is not checked. Under concurrent duplicate deliveries with the same reference, both transactions can pass the pre-check and credit balances, but one ledger insert will be skipped here and still return success, committing an extra balance increase with no corresponding ledger entry.

Useful? React with 👍 / 👎.

Three changes:
1. Unique index now covers (account_id, entry_type, reference) — not the
   previous global (entry_type, reference) — so reused generic references
   like 'reservation_refund' across different accounts remain valid
2. Pre-check also scopes to account_id
3. Ledger insert no longer uses ON CONFLICT DO NOTHING; instead the unique
   index causes a constraint error if a race slips through the pre-check,
   rolling back the balance update so no orphaned credit can occur.
   ErrAlreadyCredited is returned for clean idempotent duplicates.

if err := creditTx(ctx, tx, accountID, amountMicroUSD, entryType, reference, time.Time{}); err != nil {
if errors.Is(err, ErrAlreadyCredited) {
return nil
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 This makes all repeated Credit calls with the same (account_id, entry_type, reference) silently no-op, not just Stripe checkout deposits. Existing callers reuse references inside one account: reservation refunds use "reservation_refund", and admin credits default to "admin_credit". After this change, a user with two early-failed reserved requests only gets the first refund; the second LedgerRefund is swallowed here. Either scope idempotency to external deposit refs / LedgerStripeDeposit, or make those other credit callers pass truly unique references before enforcing this globally.

)`,
`CREATE INDEX IF NOT EXISTS idx_ledger_account ON ledger_entries(account_id, created_at DESC)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_reference ON ledger_entries(account_id, entry_type, reference) WHERE reference <> ''`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 This startup migration will fail if production already has duplicate non-empty (account_id, entry_type, reference) rows. Given existing reused references like reservation_refund / default admin_credit, that seems plausible. Add a preflight/dedupe migration or narrow the unique index predicate to the Stripe deposit reference shape so deploys do not fail closed on existing ledger history.

// ErrAlreadyCredited is returned by creditTx when the (account_id, entry_type,
// reference) tuple already exists, indicating an idempotent duplicate.
var ErrAlreadyCredited = errors.New("store: credit already applied for this reference")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The Store contract now differs between implementations: PostgresStore.Credit is idempotent for duplicate references, but MemoryStore.Credit still applies every duplicate. Most billing/API tests use memory, so the Stripe replay invariant and the legitimate repeated-refund/admin-credit cases are not covered. Please add focused tests for duplicate LedgerStripeDeposit and for non-Stripe repeated credit refs, ideally exercising Postgres semantics.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants