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':
|
case 'subscription.active':
|
||||||
break;
|
break;
|
||||||
case 'subscription.revoked':
|
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;
|
break;
|
||||||
case 'subscription.canceled':
|
case 'subscription.canceled':
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -71,6 +71,7 @@
|
|||||||
"db:backfill-profile-email-verification": "tsx src/scripts/backfill-profile-email-verification.ts",
|
"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:update-user-email": "tsx src/scripts/update-user-email.ts",
|
||||||
"db:notify-students-over-limit": "tsx src/scripts/notify-students-over-limit.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",
|
"move-users": "tsx src/scripts/supabase-to-betterauth.ts",
|
||||||
"pull": "drizzle-kit pull --config drizzle.config.ts",
|
"pull": "drizzle-kit pull --config drizzle.config.ts",
|
||||||
"generate-types": "tsx scripts/generate-types.ts && pnpm format",
|
"generate-types": "tsx scripts/generate-types.ts && pnpm format",
|
||||||
|
|||||||
@@ -8,7 +8,21 @@ import type {
|
|||||||
TOrganization,
|
TOrganization,
|
||||||
TOrganizationPlan
|
TOrganizationPlan
|
||||||
} from '@db/types';
|
} 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 { PLAN } from '@cio/utils/plans';
|
||||||
import { ROLE } from '@cio/utils/constants';
|
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 (
|
export const createOrganizationPlan = async (
|
||||||
data: TNewOrganizationPlan,
|
data: TNewOrganizationPlan,
|
||||||
dbClient: DbOrTxClient = db
|
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