Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions server/providers/payment/plugins/polar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,8 +360,17 @@ function createPolarProvider(config: PaymentPluginConfig): PaymentProvider {
return { productId: targetProductId, alreadyOnList: false }
},

async cancelSubscription(subscriptionId: string): Promise<void> {
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<void> {
Expand Down
3 changes: 2 additions & 1 deletion server/providers/payment/plugins/stripe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,8 +233,9 @@ function createStripeProvider(config: PaymentPluginConfig): PaymentProvider {
throw new Error('The Migrate bundle is Polar-only')
},

async cancelSubscription(subscriptionId: string): Promise<void> {
async cancelSubscription(subscriptionId: string): Promise<'canceled' | 'already_ended'> {
await stripe.subscriptions.cancel(subscriptionId)
return 'canceled'
},

async ingestUsageEvent(_input: UsageEventInput): Promise<void> {
Expand Down
3 changes: 2 additions & 1 deletion server/providers/payment/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,8 @@ export interface PaymentProvider {
handleWebhook: (payload: string, headers: Record<string, string | undefined>) => Promise<WebhookResult>

/** Cancel a subscription (immediate). */
cancelSubscription: (subscriptionId: string) => Promise<void>
/** 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
Expand Down
4 changes: 2 additions & 2 deletions server/utils/migrate-revoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion tests/unit/migrate-revoke-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down Expand Up @@ -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(() => {})
Expand Down
28 changes: 28 additions & 0 deletions tests/unit/polar-subscription-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('@polar-sh/sdk')>()
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', () => ({
Expand Down Expand Up @@ -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')
})
})
Loading