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
+
+`);
+ 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];
}