From 855068a01124b447d0b0f7ddcc05146864eb6d86 Mon Sep 17 00:00:00 2001 From: DocFast Bot Date: Tue, 17 Feb 2026 10:46:12 +0000 Subject: [PATCH] fix: downgrade instead of delete key on subscription cancel - Replace revokeByCustomer with downgradeByCustomer in keys.ts - Sets tier='free' in cache and DB (UPDATE, not DELETE) - Add isDocFastSubscription() product filter helper in billing.ts - Filters all subscription events by prod_TygeG8tQPtEAdE - Handle customer.subscription.updated event - Downgrades on status=canceled/past_due/unpaid or cancel_at_period_end=true - Handle customer.subscription.deleted with product filter - Downgrades to free (was incorrectly deleting the key) Fixes revenue integrity bug: cancelled Pro subscribers kept Pro access. --- src/routes/billing.ts | 55 +++++++++++++++++++++++++++++++++++++++---- src/services/keys.ts | 11 ++++----- 2 files changed, 56 insertions(+), 10 deletions(-) diff --git a/src/routes/billing.ts b/src/routes/billing.ts index d721bdd..3bf9c9d 100644 --- a/src/routes/billing.ts +++ b/src/routes/billing.ts @@ -1,6 +1,6 @@ import { Router, Request, Response } from "express"; import Stripe from "stripe"; -import { createProKey, revokeByCustomer } from "../services/keys.js"; +import { createProKey, downgradeByCustomer } from "../services/keys.js"; import logger from "../services/logger.js"; function escapeHtml(s: string): string { @@ -22,6 +22,29 @@ const router = Router(); // Track provisioned session IDs to prevent duplicate key creation const provisionedSessions = new Set(); +const DOCFAST_PRODUCT_ID = "prod_TygeG8tQPtEAdE"; + +// Returns true if the given Stripe subscription contains a DocFast product. +// Used to filter webhook events — this Stripe account is shared with other projects. +async function isDocFastSubscription(subscriptionId: string): Promise { + try { + const sub = await getStripe().subscriptions.retrieve(subscriptionId, { + expand: ["items.data.price.product"], + }); + return sub.items.data.some((item) => { + const price = item.price as Stripe.Price | null; + const productId = + typeof price?.product === "string" + ? price.product + : (price?.product as Stripe.Product | null)?.id; + return productId === DOCFAST_PRODUCT_ID; + }); + } catch (err: any) { + logger.error({ err, subscriptionId }, "isDocFastSubscription: failed to retrieve subscription"); + return false; + } +} + // Create a Stripe Checkout session for Pro subscription router.post("/checkout", async (_req: Request, res: Response) => { try { @@ -126,7 +149,6 @@ router.post("/webhook", async (req: Request, res: Response) => { const email = session.customer_details?.email; // Filter by product — this Stripe account is shared with other projects - const DOCFAST_PRODUCT_ID = "prod_TygeG8tQPtEAdE"; try { const fullSession = await getStripe().checkout.sessions.retrieve(session.id, { expand: ["line_items"], @@ -156,11 +178,36 @@ router.post("/webhook", async (req: Request, res: Response) => { logger.info({ email, customerId }, "checkout.session.completed: provisioned pro key"); break; } + case "customer.subscription.updated": { + const sub = event.data.object as Stripe.Subscription; + const customerId = sub.customer as string; + const shouldDowngrade = + sub.status === "canceled" || + sub.status === "past_due" || + sub.status === "unpaid" || + sub.cancel_at_period_end === true; + + if (shouldDowngrade) { + if (!(await isDocFastSubscription(sub.id))) { + logger.info({ subscriptionId: sub.id }, "customer.subscription.updated: ignoring event for different product"); + break; + } + await downgradeByCustomer(customerId); + logger.info({ customerId, status: sub.status, cancelAtPeriodEnd: sub.cancel_at_period_end }, + "customer.subscription.updated: downgraded key to free tier"); + } + break; + } case "customer.subscription.deleted": { const sub = event.data.object as Stripe.Subscription; const customerId = sub.customer as string; - await revokeByCustomer(customerId); - logger.info({ customerId }, "Subscription cancelled, key revoked"); + + if (!(await isDocFastSubscription(sub.id))) { + logger.info({ subscriptionId: sub.id }, "customer.subscription.deleted: ignoring event for different product"); + break; + } + await downgradeByCustomer(customerId); + logger.info({ customerId }, "customer.subscription.deleted: downgraded key to free tier"); break; } default: diff --git a/src/services/keys.ts b/src/services/keys.ts index 0737c6a..2218441 100644 --- a/src/services/keys.ts +++ b/src/services/keys.ts @@ -108,12 +108,11 @@ export async function createProKey(email: string, stripeCustomerId: string): Pro return entry; } -export async function revokeByCustomer(stripeCustomerId: string): Promise { - const idx = keysCache.findIndex((k) => k.stripeCustomerId === stripeCustomerId); - if (idx >= 0) { - const key = keysCache[idx].key; - keysCache.splice(idx, 1); - await pool.query("DELETE FROM api_keys WHERE key = $1", [key]); +export async function downgradeByCustomer(stripeCustomerId: string): Promise { + const entry = keysCache.find((k) => k.stripeCustomerId === stripeCustomerId); + if (entry) { + entry.tier = "free"; + await pool.query("UPDATE api_keys SET tier = 'free' WHERE stripe_customer_id = $1", [stripeCustomerId]); return true; } return false;