diff --git a/apps/dashboard/src/routes/api/polar/webhook/+server.ts b/apps/dashboard/src/routes/api/polar/webhook/+server.ts index edc335b9..65741fb2 100644 --- a/apps/dashboard/src/routes/api/polar/webhook/+server.ts +++ b/apps/dashboard/src/routes/api/polar/webhook/+server.ts @@ -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 + }); + console.log('Subscription revoked', result); + } catch (error) { + console.error('Error revoking org plan', error); + } + break; case 'subscription.canceled': break; diff --git a/packages/db/package.json b/packages/db/package.json index 6bafcee5..ba15865d 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -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", diff --git a/packages/db/src/queries/organization/organization.ts b/packages/db/src/queries/organization/organization.ts index d94c7b72..d76b1519 100644 --- a/packages/db/src/queries/organization/organization.ts +++ b/packages/db/src/queries/organization/organization.ts @@ -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> => { + 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 diff --git a/packages/db/src/scripts/downgrade-expired-subscriptions.ts b/packages/db/src/scripts/downgrade-expired-subscriptions.ts new file mode 100644 index 00000000..50ef8fd2 --- /dev/null +++ b/packages/db/src/scripts/downgrade-expired-subscriptions.ts @@ -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> { + 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); + });