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
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:
parent
e074562f73
commit
e9440a4e6a
3 changed files with 71 additions and 12 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue