diff --git a/server/providers/payment/plugins/polar.ts b/server/providers/payment/plugins/polar.ts index 18811df4..38be0b0e 100644 --- a/server/providers/payment/plugins/polar.ts +++ b/server/providers/payment/plugins/polar.ts @@ -360,8 +360,17 @@ function createPolarProvider(config: PaymentPluginConfig): PaymentProvider { return { productId: targetProductId, alreadyOnList: false } }, - async cancelSubscription(subscriptionId: string): Promise { - await polar.subscriptions.revoke({ id: subscriptionId }) + async cancelSubscription(subscriptionId: string): Promise<'canceled' | 'already_ended'> { + try { + await polar.subscriptions.revoke({ id: subscriptionId }) + return 'canceled' + } + catch (err) { + // Already ended (an operator refunded it, or an earlier call revoked it) or gone: nothing left to cancel. + const kind = (err as { error?: unknown }).error + if (kind === 'AlreadyCanceledSubscription' || kind === 'ResourceNotFound') return 'already_ended' + throw err + } }, async ingestUsageEvent(input: UsageEventInput): Promise { diff --git a/server/providers/payment/plugins/stripe.ts b/server/providers/payment/plugins/stripe.ts index 4b293105..e790874d 100644 --- a/server/providers/payment/plugins/stripe.ts +++ b/server/providers/payment/plugins/stripe.ts @@ -233,8 +233,9 @@ function createStripeProvider(config: PaymentPluginConfig): PaymentProvider { throw new Error('The Migrate bundle is Polar-only') }, - async cancelSubscription(subscriptionId: string): Promise { + async cancelSubscription(subscriptionId: string): Promise<'canceled' | 'already_ended'> { await stripe.subscriptions.cancel(subscriptionId) + return 'canceled' }, async ingestUsageEvent(_input: UsageEventInput): Promise { diff --git a/server/providers/payment/types.ts b/server/providers/payment/types.ts index f1ebe262..4feb80e9 100644 --- a/server/providers/payment/types.ts +++ b/server/providers/payment/types.ts @@ -170,7 +170,8 @@ export interface PaymentProvider { handleWebhook: (payload: string, headers: Record) => Promise /** Cancel a subscription (immediate). */ - cancelSubscription: (subscriptionId: string) => Promise + /** Ends the subscription now. `already_ended` when the provider reports it was already ended or is gone (not an error: the goal is met). */ + cancelSubscription: (subscriptionId: string) => Promise<'canceled' | 'already_ended'> /** * Open a Migrate bundle checkout (see `BundleCheckoutInput`). Providers diff --git a/server/utils/migrate-revoke.ts b/server/utils/migrate-revoke.ts index 3b23f3c5..bbbbf500 100644 --- a/server/utils/migrate-revoke.ts +++ b/server/utils/migrate-revoke.ts @@ -29,8 +29,8 @@ export async function revokeMigrateGrant(grant: DatabaseRow, reason: MigrateRevo const payment = usePaymentProvider() if (!payment) throw createError({ statusCode: 503, message: errorMessage('generic.server_error') }) try { - await payment.cancelSubscription(subscriptionId) - canceled = true + // An already ended subscription is the goal met, not a failure: the grant is still marked below. + canceled = (await payment.cancelSubscription(subscriptionId)) === 'canceled' } catch (err) { // eslint-disable-next-line no-console -- ops visibility: Migrate retries the call diff --git a/tests/unit/migrate-revoke-route.test.ts b/tests/unit/migrate-revoke-route.test.ts index f354eca9..462746e7 100644 --- a/tests/unit/migrate-revoke-route.test.ts +++ b/tests/unit/migrate-revoke-route.test.ts @@ -44,7 +44,7 @@ describe('POST /api/migrate/grants/revoke', () => { vi.resetModules() taken.clear() config.migrate.claimPublicKey = publicPem - payment = { cancelSubscription: vi.fn().mockResolvedValue(undefined) } + payment = { cancelSubscription: vi.fn().mockResolvedValue('canceled') } db = { getMigrateGrantByOrderId: vi.fn().mockResolvedValue(grant()), getWorkspaceById: vi.fn().mockResolvedValue({ id: 'ws-1', github_installation_id: null }), @@ -104,6 +104,14 @@ describe('POST /api/migrate/grants/revoke', () => { expect(db.markMigrateGrantRevoked).not.toHaveBeenCalled() }) + it('treats a subscription Polar already ended as done: grant revoked, nothing canceled, no 502 loop', async () => { + payment!.cancelSubscription.mockResolvedValue('already_ended') + await request() + expect(await call()).toEqual({ state: 'revoked', installed: false, subscription_canceled: false }) + expect(db.markMigrateGrantRevoked).toHaveBeenCalledWith('grant-1', 'refund_before_delivery') + expect(db.releaseMigrateS2sJti).not.toHaveBeenCalled() + }) + it('leaves the grant live and gives the token back when Polar fails, so Migrate can retry', async () => { payment!.cancelSubscription.mockRejectedValue(new Error('polar down')) vi.spyOn(console, 'error').mockImplementation(() => {}) diff --git a/tests/unit/polar-subscription-lifecycle.test.ts b/tests/unit/polar-subscription-lifecycle.test.ts index 92857fb4..50fd4768 100644 --- a/tests/unit/polar-subscription-lifecycle.test.ts +++ b/tests/unit/polar-subscription-lifecycle.test.ts @@ -1,5 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +const revoke = vi.fn() +vi.mock('@polar-sh/sdk', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, Polar: class { subscriptions = { revoke: (...a: unknown[]) => revoke(...a) } } } +}) + // The webhook signature is not under test: hand the plugin the parsed event. const validateEvent = vi.fn() vi.mock('@polar-sh/sdk/webhooks', () => ({ @@ -104,3 +110,25 @@ describe('polar plugin — cancel and revoke keep what was paid for', () => { expect(result).toMatchObject({ event: 'subscription.updated', subscriptionStatus: 'past_due' }) }) }) + +describe('polar plugin — cancelSubscription', () => { + const provider = async () => { + const { polarPlugin } = await import('../../server/providers/payment/plugins/polar') + return polarPlugin.create({ polar: { accessToken: 'tok', webhookSecret: 'sec', proProductId: 'prod_pro' } } as never) + } + + it('answers canceled when Polar revokes it', async () => { + revoke.mockResolvedValue({}) + expect(await (await provider()).cancelSubscription('sub_1')).toBe('canceled') + }) + + it.each(['AlreadyCanceledSubscription', 'ResourceNotFound'])('answers already_ended for Polar\'s %s', async (error) => { + revoke.mockRejectedValue(Object.assign(new Error(error), { error })) + expect(await (await provider()).cancelSubscription('sub_1')).toBe('already_ended') + }) + + it('still throws any other provider failure', async () => { + revoke.mockRejectedValue(new Error('polar down')) + await expect((await provider()).cancelSubscription('sub_1')).rejects.toThrow('polar down') + }) +})