fix: webhook idempotency — unique index on stripe_customer_id + UPSERT + DB dedup on success page
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 12m44s

- Add partial unique index on api_keys(stripe_customer_id) WHERE NOT NULL
- Use INSERT ... ON CONFLICT in createProKey for cross-pod dedup
- Add findKeyByCustomerId() to query DB directly
- Success page checks DB before creating key (survives pod restarts)
- Refresh in-memory cache after UPSERT
This commit is contained in:
DocFast Bot 2026-02-20 16:03:17 +00:00
parent e074562f73
commit e9440a4e6a
3 changed files with 71 additions and 12 deletions

View file

@ -162,6 +162,8 @@ export async function initDatabase(): Promise<void> {
);
CREATE INDEX IF NOT EXISTS idx_api_keys_email ON api_keys(email);
CREATE INDEX IF NOT EXISTS idx_api_keys_stripe ON api_keys(stripe_customer_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_api_keys_stripe_unique
ON api_keys(stripe_customer_id) WHERE stripe_customer_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS verifications (
id SERIAL PRIMARY KEY,

View file

@ -86,6 +86,7 @@ export async function createFreeKey(email?: string): Promise<ApiKey> {
}
export async function createProKey(email: string, stripeCustomerId: string): Promise<ApiKey> {
// Check in-memory cache first (fast path)
const existing = keysCache.find((k) => k.stripeCustomerId === stripeCustomerId);
if (existing) {
existing.tier = "pro";
@ -93,19 +94,36 @@ export async function createProKey(email: string, stripeCustomerId: string): Pro
return existing;
}
// UPSERT: handles duplicate webhooks across pods via DB unique index
const newKey = generateKey("df_pro");
const now = new Date().toISOString();
const result = await queryWithRetry(
`INSERT INTO api_keys (key, tier, email, created_at, stripe_customer_id)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (stripe_customer_id) WHERE stripe_customer_id IS NOT NULL
DO UPDATE SET tier = 'pro'
RETURNING key, tier, email, created_at, stripe_customer_id`,
[newKey, "pro", email, now, stripeCustomerId]
);
const row = result.rows[0];
const entry: ApiKey = {
key: generateKey("df_pro"),
tier: "pro",
email,
createdAt: new Date().toISOString(),
stripeCustomerId,
key: row.key,
tier: row.tier as "free" | "pro",
email: row.email,
createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at,
stripeCustomerId: row.stripe_customer_id || undefined,
};
await queryWithRetry(
"INSERT INTO api_keys (key, tier, email, created_at, stripe_customer_id) VALUES ($1, $2, $3, $4, $5)",
[entry.key, entry.tier, entry.email, entry.createdAt, entry.stripeCustomerId]
);
keysCache.push(entry);
// Refresh in-memory cache
const cacheIdx = keysCache.findIndex((k) => k.stripeCustomerId === stripeCustomerId);
if (cacheIdx >= 0) {
keysCache[cacheIdx] = entry;
} else {
keysCache.push(entry);
}
return entry;
}
@ -119,6 +137,23 @@ export async function downgradeByCustomer(stripeCustomerId: string): Promise<boo
return false;
}
export async function findKeyByCustomerId(stripeCustomerId: string): Promise<ApiKey | null> {
// Check DB directly — survives pod restarts unlike in-memory cache
const result = await queryWithRetry(
"SELECT key, tier, email, created_at, stripe_customer_id FROM api_keys WHERE stripe_customer_id = $1 LIMIT 1",
[stripeCustomerId]
);
if (result.rows.length === 0) return null;
const r = result.rows[0];
return {
key: r.key,
tier: r.tier as "free" | "pro",
email: r.email,
createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at,
stripeCustomerId: r.stripe_customer_id || undefined,
};
}
export function getAllKeys(): ApiKey[] {
return [...keysCache];
}