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
@@ -157,6 +157,16 @@ async function onPayload(payload: PolarWebhookPayload) {
case 'subscription.active':
break;
case 'subscription.revoked':
try {
const result = await OrgPlanApiServer.cancelOrgPlan({
subscriptionId,
payload: data as unknown as Record<string, unknown>
});
console.log('Subscription revoked', result);
} catch (error) {
console.error('Error revoking org plan', error);
}
break;
case 'subscription.canceled':
break;
+1
View File
@@ -71,6 +71,7 @@
"db:backfill-profile-email-verification": "tsx src/scripts/backfill-profile-email-verification.ts",
"db:update-user-email": "tsx src/scripts/update-user-email.ts",
"db:notify-students-over-limit": "tsx src/scripts/notify-students-over-limit.ts",
"db:downgrade-expired-subscriptions": "tsx src/scripts/downgrade-expired-subscriptions.ts",
"move-users": "tsx src/scripts/supabase-to-betterauth.ts",
"pull": "drizzle-kit pull --config drizzle.config.ts",
"generate-types": "tsx scripts/generate-types.ts && pnpm format",
@@ -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
@@ -0,0 +1,96 @@
/**
* One-time (manual, not cron) backfill: for every org with an `organization_plan`
* row still marked active, re-check the real subscription status with Polar and
* downgrade (deactivate the plan row) any whose subscription has actually ended
* — covers subscriptions that expired or didn't renew before the webhook handler
* started reacting to `subscription.revoked`. Dry-run by default; pass --execute
* to actually deactivate.
*
* Usage:
* pnpm db:downgrade-expired-subscriptions # dry run (default)
* pnpm db:downgrade-expired-subscriptions -- --execute # deactivate
*
* Env:
* POLAR_ACCESS_TOKEN - required
* POLAR_SERVER - "production" (default) or "sandbox"
*/
import 'dotenv/config';
import {
cancelOrganizationPlan,
getActiveOrganizationPlansWithSubscription
} from '../queries/organization/organization';
const POLAR_SERVER = process.env.POLAR_SERVER === 'sandbox' ? 'sandbox' : 'production';
const POLAR_API_BASE = POLAR_SERVER === 'sandbox' ? 'https://sandbox-api.polar.sh' : 'https://api.polar.sh';
async function fetchSubscription(subscriptionId: string, accessToken: string): Promise<Record<string, unknown>> {
const response = await fetch(`${POLAR_API_BASE}/v1/subscriptions/${subscriptionId}`, {
headers: { Authorization: `Bearer ${accessToken}` }
});
if (!response.ok) {
throw new Error(`Polar responded ${response.status} for subscription ${subscriptionId}`);
}
return response.json();
}
async function main() {
const execute = process.argv.includes('--execute');
const accessToken = process.env.POLAR_ACCESS_TOKEN;
if (!accessToken) {
throw new Error('POLAR_ACCESS_TOKEN is required');
}
console.log(`Checking active org plans against Polar (${POLAR_SERVER})...`);
const activePlans = await getActiveOrganizationPlansWithSubscription();
let downgraded = 0;
let stillActive = 0;
let errored = 0;
for (const plan of activePlans) {
const subscriptionId = plan.subscriptionId;
if (!subscriptionId) continue;
try {
const subscription = await fetchSubscription(subscriptionId, accessToken);
if (subscription.status === 'active') {
stillActive++;
continue;
}
console.log(
`${execute ? 'Downgrading' : '[dry-run] Would downgrade'} ${plan.orgName} (${plan.orgId}): ` +
`subscription ${subscriptionId} is "${subscription.status}"`
);
if (execute) {
await cancelOrganizationPlan(subscriptionId, subscription);
}
downgraded++;
} catch (error) {
errored++;
console.error(`Skipping ${plan.orgName} (${plan.orgId}): ${error instanceof Error ? error.message : error}`);
}
}
console.log(
`\nChecked ${activePlans.length} active org plan(s): ${stillActive} still active, ` +
`${downgraded} to downgrade${errored ? `, ${errored} error(s)` : ''}.`
);
console.log(execute ? `Done. Downgraded ${downgraded} org(s).` : 'Dry run — re-run with --execute to downgrade.');
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});