diff --git a/src/routes/billing.ts b/src/routes/billing.ts index 8b66921..f99e82d 100644 --- a/src/routes/billing.ts +++ b/src/routes/billing.ts @@ -1,7 +1,7 @@ import { Router, Request, Response } from "express"; import rateLimit from "express-rate-limit"; import Stripe from "stripe"; -import { createProKey, downgradeByCustomer, updateEmailByCustomer } from "../services/keys.js"; +import { createProKey, downgradeByCustomer, updateEmailByCustomer, findKeyByCustomerId } from "../services/keys.js"; import logger from "../services/logger.js"; function escapeHtml(s: string): string { @@ -138,8 +138,30 @@ router.get("/success", async (req: Request, res: Response) => { return; } - const keyInfo = await createProKey(email, customerId); + // Check DB for existing key (survives pod restarts, unlike provisionedSessions Set) + const existingKey = await findKeyByCustomerId(customerId); + if (existingKey) { provisionedSessions.add(session.id); + res.send(` +DocFast Pro — Key Already Provisioned + +
+

✅ Key Already Provisioned

+

A Pro API key has already been created for this purchase.

+

If you lost your key, use the key recovery feature.

+

View API docs →

+
`); + return; + } + + const keyInfo = await createProKey(email, customerId); + provisionedSessions.add(session.id); // Return a nice HTML page instead of raw JSON res.send(` diff --git a/src/services/db.ts b/src/services/db.ts index c052727..373ab00 100644 --- a/src/services/db.ts +++ b/src/services/db.ts @@ -162,6 +162,8 @@ export async function initDatabase(): Promise { ); 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, diff --git a/src/services/keys.ts b/src/services/keys.ts index e0f3b10..f5356da 100644 --- a/src/services/keys.ts +++ b/src/services/keys.ts @@ -86,6 +86,7 @@ export async function createFreeKey(email?: string): Promise { } export async function createProKey(email: string, stripeCustomerId: string): Promise { + // 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 { + // 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]; }