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 { 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:
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue