fix: downgrade instead of delete key on subscription cancel
All checks were successful
Deploy to Production / Deploy to Server (push) Successful in 2m43s

- 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.
This commit is contained in:
DocFast Bot 2026-02-17 10:46:12 +00:00
parent 2bfd893510
commit 855068a011
2 changed files with 56 additions and 10 deletions

View file

@ -1,6 +1,6 @@
import { Router, Request, Response } from "express"; import { Router, Request, Response } from "express";
import Stripe from "stripe"; import Stripe from "stripe";
import { createProKey, revokeByCustomer } from "../services/keys.js"; import { createProKey, downgradeByCustomer } from "../services/keys.js";
import logger from "../services/logger.js"; import logger from "../services/logger.js";
function escapeHtml(s: string): string { function escapeHtml(s: string): string {
@ -22,6 +22,29 @@ const router = Router();
// Track provisioned session IDs to prevent duplicate key creation // Track provisioned session IDs to prevent duplicate key creation
const provisionedSessions = new Set<string>(); const provisionedSessions = new Set<string>();
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<boolean> {
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 // Create a Stripe Checkout session for Pro subscription
router.post("/checkout", async (_req: Request, res: Response) => { router.post("/checkout", async (_req: Request, res: Response) => {
try { try {
@ -126,7 +149,6 @@ router.post("/webhook", async (req: Request, res: Response) => {
const email = session.customer_details?.email; const email = session.customer_details?.email;
// Filter by product — this Stripe account is shared with other projects // Filter by product — this Stripe account is shared with other projects
const DOCFAST_PRODUCT_ID = "prod_TygeG8tQPtEAdE";
try { try {
const fullSession = await getStripe().checkout.sessions.retrieve(session.id, { const fullSession = await getStripe().checkout.sessions.retrieve(session.id, {
expand: ["line_items"], 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"); logger.info({ email, customerId }, "checkout.session.completed: provisioned pro key");
break; 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": { case "customer.subscription.deleted": {
const sub = event.data.object as Stripe.Subscription; const sub = event.data.object as Stripe.Subscription;
const customerId = sub.customer as string; 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; break;
} }
default: default:

View file

@ -108,12 +108,11 @@ export async function createProKey(email: string, stripeCustomerId: string): Pro
return entry; return entry;
} }
export async function revokeByCustomer(stripeCustomerId: string): Promise<boolean> { export async function downgradeByCustomer(stripeCustomerId: string): Promise<boolean> {
const idx = keysCache.findIndex((k) => k.stripeCustomerId === stripeCustomerId); const entry = keysCache.find((k) => k.stripeCustomerId === stripeCustomerId);
if (idx >= 0) { if (entry) {
const key = keysCache[idx].key; entry.tier = "free";
keysCache.splice(idx, 1); await pool.query("UPDATE api_keys SET tier = 'free' WHERE stripe_customer_id = $1", [stripeCustomerId]);
await pool.query("DELETE FROM api_keys WHERE key = $1", [key]);
return true; return true;
} }
return false; return false;