fix: downgrade instead of delete key on subscription cancel
All checks were successful
Deploy to Production / Deploy to Server (push) Successful in 2m43s
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:
parent
2bfd893510
commit
855068a011
2 changed files with 56 additions and 10 deletions
|
|
@ -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<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
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -108,12 +108,11 @@ export async function createProKey(email: string, stripeCustomerId: string): Pro
|
|||
return entry;
|
||||
}
|
||||
|
||||
export async function revokeByCustomer(stripeCustomerId: string): Promise<boolean> {
|
||||
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<boolean> {
|
||||
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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue