fix(billing): downgrade orgs when Polar subscription is revoked (#759)

* fix(billing): downgrade orgs when Polar subscription is revoked

The Polar webhook handler had subscription.revoked as a no-op, so
subscriptions that expired or failed to renew never got the org's
organization_plan row deactivated, leaving them stuck on a paid plan
indefinitely. Handle subscription.revoked the same way as an inactive
subscription.updated event.

Also add a one-time backfill script (db:downgrade-expired-subscriptions)
that re-checks every currently-active org plan against Polar directly
and downgrades any whose subscription already ended, to fix orgs
affected before this webhook fix shipped.

* Update packages/db/src/scripts/downgrade-expired-subscriptions.ts

---------
This commit is contained in:
Rotimi Best
2026-07-16 00:57:31 +01:00
committed by GitHub
parent 3980aa0a9b
commit c9b7c79365
4 changed files with 149 additions and 1 deletions
@@ -8,7 +8,21 @@ import type {
TOrganization,
TOrganizationPlan
} from '@db/types';
import { and, asc, count, desc, eq, gt, ilike, inArray, isNotNull, notInArray, or, sql } from 'drizzle-orm';
import {
and,
asc,
count,
desc,
eq,
getTableColumns,
gt,
ilike,
inArray,
isNotNull,
notInArray,
or,
sql
} from 'drizzle-orm';
import { PLAN } from '@cio/utils/plans';
import { ROLE } from '@cio/utils/constants';
@@ -1007,6 +1021,33 @@ export const getOrganizationPlanBySubscriptionId = async (
}
};
/**
* Gets every active organization plan that has a Polar subscription ID attached.
* Used by one-off backfills that need to re-verify subscription status against Polar.
* @param dbClient Optional DB or transaction client for use within transactions
* @returns Active organization plans with org name/siteName for readable logging
*/
export const getActiveOrganizationPlansWithSubscription = async (
dbClient: DbOrTxClient = db
): Promise<Array<TOrganizationPlan & { orgName: string; orgSiteName: string | null }>> => {
try {
return await dbClient
.select({
...getTableColumns(schema.organizationPlan),
orgName: schema.organization.name,
orgSiteName: schema.organization.siteName
})
.from(schema.organizationPlan)
.innerJoin(schema.organization, eq(schema.organization.id, schema.organizationPlan.orgId))
.where(and(eq(schema.organizationPlan.isActive, true), isNotNull(schema.organizationPlan.subscriptionId)));
} catch (error) {
console.error('getActiveOrganizationPlansWithSubscription error:', error);
throw new Error(
`Failed to fetch active organization plans: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
};
export const createOrganizationPlan = async (
data: TNewOrganizationPlan,
dbClient: DbOrTxClient = db