diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index cbe8a4039..e41498264 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -95,7 +95,8 @@ export const subscriptions = pgTable("subscriptions", { billingCycleAnchor: bigint("billingCycleAnchor", { mode: "number" }), expiresAt: bigint("expiresAt", { mode: "number" }), trial: boolean("trial").default(false), - type: varchar("type", { length: 50 }) // tier1, tier2, tier3, or license + type: varchar("type", { length: 50 }), // tier1, tier2, tier3, or license + override: boolean("override").default(false) }); export const subscriptionItems = pgTable("subscriptionItems", { diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index b75836e29..f8d2f5f09 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -89,7 +89,8 @@ export const subscriptions = sqliteTable("subscriptions", { expiresAt: integer("expiresAt"), trial: integer("trial", { mode: "boolean" }).default(false), billingCycleAnchor: integer("billingCycleAnchor"), - type: text("type") // tier1, tier2, tier3, or license + type: text("type"), // tier1, tier2, tier3, or license + override: integer("override", { mode: "boolean" }).default(false) }); export const subscriptionItems = sqliteTable("subscriptionItems", { diff --git a/server/private/routers/billing/hooks/handleSubscriptionDeleted.ts b/server/private/routers/billing/hooks/handleSubscriptionDeleted.ts index 962cdd424..dd44f4101 100644 --- a/server/private/routers/billing/hooks/handleSubscriptionDeleted.ts +++ b/server/private/routers/billing/hooks/handleSubscriptionDeleted.ts @@ -53,6 +53,15 @@ export async function handleSubscriptionDeleted( return; } + // If the subscription has been manually overridden, we lock it down + // so Stripe can no longer change (or delete) its status locally. + if (existingSubscription.override === true) { + logger.info( + `Subscription ${subscription.id} is locked (override=true). Ignoring deletion event from Stripe.` + ); + return; + } + await db .delete(subscriptions) .where(eq(subscriptions.subscriptionId, subscription.id)); diff --git a/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts b/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts index e1ec7a7b9..13df7910f 100644 --- a/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts +++ b/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts @@ -68,13 +68,27 @@ export async function handleSubscriptionUpdated( const type = getSubType(fullSubscription); const previousType = existingSubscription.type as SubscriptionType | null; + // If the subscription has been manually overridden, we lock the + // status down so Stripe webhooks can no longer change it. + const isLocked = existingSubscription.override === true; + if (isLocked) { + logger.info( + `Subscription ${subscription.id} is locked (override=true). Ignoring status change from Stripe (would have been ${subscription.status}).` + ); + } + const effectiveStatus = isLocked + ? existingSubscription.status + : subscription.status; + await db .update(subscriptions) .set({ - status: subscription.status, - canceledAt: subscription.canceled_at - ? subscription.canceled_at - : null, + status: effectiveStatus, + canceledAt: isLocked + ? existingSubscription.canceledAt + : subscription.canceled_at + ? subscription.canceled_at + : null, updatedAt: Math.floor(Date.now() / 1000), billingCycleAnchor: subscription.billing_cycle_anchor, type: type @@ -275,23 +289,23 @@ export async function handleSubscriptionUpdated( // we only need to handle the limit lifecycle for saas subscriptions not for the licenses await handleSubscriptionLifesycle( customer.orgId, - subscription.status, + effectiveStatus, type ); // Handle feature lifecycle when subscription is canceled or becomes unpaid if ( - subscription.status === "canceled" || - subscription.status === "unpaid" || - subscription.status === "incomplete_expired" + effectiveStatus === "canceled" || + effectiveStatus === "unpaid" || + effectiveStatus === "incomplete_expired" ) { logger.info( - `Subscription ${subscription.id} for org ${customer.orgId} is ${subscription.status}, disabling paid features` + `Subscription ${subscription.id} for org ${customer.orgId} is ${effectiveStatus}, disabling paid features` ); await handleTierChange(customer.orgId, null, previousType ?? undefined); } } else if (type === "license") { - if (subscription.status === "canceled" || subscription.status == "unpaid" || subscription.status == "incomplete_expired") { + if (effectiveStatus === "canceled" || effectiveStatus == "unpaid" || effectiveStatus == "incomplete_expired") { try { // WARNING: // this invalidates ALL OF THE ENTERPRISE LICENSES for this orgId