From a89c436d5e8b3d38c6b1928d30083e7de087ac5a Mon Sep 17 00:00:00 2001 From: AHMET BAYHAN BAYRAMOGLU <49499275+ABB65@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:39:53 +0300 Subject: [PATCH] fix(migrate): revoking an already ended subscription is success Polar's subscriptions.revoke throws AlreadyCanceledSubscription (or ResourceNotFound) when an operator already refunded the subscription or an earlier call revoked it but marking the grant fell through. That made the revoke endpoint answer 502 forever while the grant stayed live. The plugin now returns 'already_ended' for those two errors; the endpoint marks the grant revoked and answers subscription_canceled:false. Other failures still throw. --- server/providers/payment/plugins/polar.ts | 13 +++++++-- server/providers/payment/plugins/stripe.ts | 3 +- server/providers/payment/types.ts | 3 +- server/utils/migrate-revoke.ts | 4 +-- tests/unit/migrate-revoke-route.test.ts | 10 ++++++- .../unit/polar-subscription-lifecycle.test.ts | 28 +++++++++++++++++++ 6 files changed, 54 insertions(+), 7 deletions(-) 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') + }) +})