-
Notifications
You must be signed in to change notification settings - Fork 23
fix: deduplicate Stripe webhook credits via ledger reference index #124
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -195,6 +195,7 @@ func (s *PostgresStore) migrate(ctx context.Context) error { | |
| 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 <> ''`, | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 This startup migration will fail if production already has duplicate non-empty |
||
| // Referral system tables | ||
| `CREATE TABLE IF NOT EXISTS referrers ( | ||
|
|
@@ -838,6 +839,24 @@ func nullableCreatedAt(ts time.Time) any { | |
| } | ||
|
|
||
| func creditTx(ctx context.Context, tx pgx.Tx, accountID string, amountMicroUSD int64, entryType LedgerEntryType, reference string, createdAt time.Time) error { | ||
| // Idempotency guard: if a non-empty reference has already been recorded for | ||
| // this entry_type, skip the credit entirely. Prevents double-crediting on | ||
| // duplicate Stripe webhook deliveries. The unique index on | ||
| // (entry_type, reference) enforces this at the DB level even under races. | ||
| if reference != "" { | ||
| var exists bool | ||
| err := tx.QueryRow(ctx, | ||
| `SELECT EXISTS(SELECT 1 FROM ledger_entries WHERE entry_type = $1 AND reference = $2)`, | ||
| string(entryType), reference, | ||
| ).Scan(&exists) | ||
| if err != nil { | ||
| return fmt.Errorf("store: check ledger reference: %w", err) | ||
| } | ||
| if exists { | ||
| return nil | ||
| } | ||
| } | ||
|
|
||
| _, err := tx.Exec(ctx, | ||
| `INSERT INTO balances (account_id, balance_micro_usd, updated_at) | ||
| VALUES ($1, $2, NOW()) | ||
|
|
@@ -860,7 +879,8 @@ func creditTx(ctx context.Context, tx pgx.Tx, accountID string, amountMicroUSD i | |
|
|
||
| _, err = tx.Exec(ctx, | ||
| `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`, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This Useful? React with 👍 / 👎. |
||
| accountID, string(entryType), amountMicroUSD, balanceAfter, reference, nullableCreatedAt(createdAt), | ||
| ) | ||
| if err != nil { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 exampleLedgerRefundwith"reservation_refund"incoordinator/internal/api/consumer.goand default"admin_credit"incoordinator/internal/api/billing_handlers.go), causing legitimate credits to be silently skipped after the first one.Useful? React with 👍 / 👎.