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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user