From ef454211502d6ce53a16b27c0211312165b02a3d Mon Sep 17 00:00:00 2001 From: isra el Date: Thu, 18 Jun 2026 09:02:39 +0300 Subject: [PATCH] fix(billing): retain paid plan until period end on cancellation Polar's subscription.canceled signals a scheduled cancellation (access continues until the period end), while subscription.revoked is the event that should actually remove access. The webhook handler was downgrading immediately on cancel and never handled revoke, so a Pro user dropped to Free the moment they canceled instead of at their real period end. - cancelSubscription now records cancelAtPeriodEnd + currentPeriodEnd and keeps the subscription active instead of deactivating it - add revokeSubscription for the real downgrade, wired to the subscription.revoked event in the Polar webhook handler - add controller + service specs covering event dispatch and the record-vs-downgrade DB semantics - add jest moduleNameMapper so specs can resolve src/* path aliases Co-Authored-By: Claude Opus 4.8 --- api/package.json | 3 + api/src/billing/billing.controller.spec.ts | 165 +++++++++++++++++++++ api/src/billing/billing.controller.ts | 19 ++- api/src/billing/billing.service.spec.ts | 141 ++++++++++++++++++ api/src/billing/billing.service.ts | 49 +++++- 5 files changed, 375 insertions(+), 2 deletions(-) create mode 100644 api/src/billing/billing.controller.spec.ts create mode 100644 api/src/billing/billing.service.spec.ts diff --git a/api/package.json b/api/package.json index f0bcdd3a..ae6d7431 100644 --- a/api/package.json +++ b/api/package.json @@ -90,6 +90,9 @@ "transform": { "^.+\\.(t|j)s$": "ts-jest" }, + "moduleNameMapper": { + "^src/(.*)$": "/$1" + }, "collectCoverageFrom": [ "**/*.(t|j)s" ], diff --git a/api/src/billing/billing.controller.spec.ts b/api/src/billing/billing.controller.spec.ts new file mode 100644 index 00000000..f6425a40 --- /dev/null +++ b/api/src/billing/billing.controller.spec.ts @@ -0,0 +1,165 @@ +import { Test, TestingModule } from '@nestjs/testing' +import { BillingController } from './billing.controller' +import { BillingService } from './billing.service' +import { BillingNotificationsService } from './billing-notifications.service' +import { AuthGuard } from '../auth/guards/auth.guard' + +describe('BillingController - handlePolarWebhook', () => { + let controller: BillingController + + const mockBillingService = { + validatePolarWebhookPayload: jest.fn(), + storePolarWebhookPayload: jest.fn(), + switchPlan: jest.fn(), + cancelSubscription: jest.fn(), + revokeSubscription: jest.fn(), + } + const mockBillingNotifications = { + listForUser: jest.fn(), + } + + const req = { headers: { 'webhook-id': 'wh_1' } } + + // Builds a Polar webhook payload. `data` overrides let each test tweak + // the event type, ids, and the cancel/period fields under test. + const makePayload = (type: string, data: Record = {}) => ({ + type, + data: { + id: 'sub_123', + product: { id: 'prod_pro_monthly' }, + customer: { externalId: 'user_ext_1' }, + metadata: { userId: 'user_meta_1' }, + status: 'active', + currentPeriodStart: '2026-06-17T00:00:00.000Z', + currentPeriodEnd: '2026-07-17T00:00:00.000Z', + cancelAtPeriodEnd: false, + createdAt: '2026-06-17T00:00:00.000Z', + canceledAt: null, + amount: 1900, + currency: 'usd', + recurringInterval: 'month', + customerId: 'cust_1', + ...data, + }, + }) + + const handle = async (payload: any) => { + mockBillingService.validatePolarWebhookPayload.mockResolvedValue(payload) + await controller.handlePolarWebhook({ any: 'rawBody' }, req) + } + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [BillingController], + providers: [ + { provide: BillingService, useValue: mockBillingService }, + { + provide: BillingNotificationsService, + useValue: mockBillingNotifications, + }, + ], + }) + // The webhook route is unguarded, but the controller's other routes use + // AuthGuard (JwtService/UsersService/AuthService). Override it so the + // test module doesn't need to wire up the whole auth stack. + .overrideGuard(AuthGuard) + .useValue({ canActivate: () => true }) + .compile() + + controller = module.get(BillingController) + + jest.clearAllMocks() + mockBillingService.storePolarWebhookPayload.mockResolvedValue(undefined) + mockBillingService.switchPlan.mockResolvedValue({ success: true }) + mockBillingService.cancelSubscription.mockResolvedValue({ success: true }) + mockBillingService.revokeSubscription.mockResolvedValue({ success: true }) + }) + + it('validates and stores every incoming payload', async () => { + await handle(makePayload('subscription.created')) + + expect(mockBillingService.validatePolarWebhookPayload).toHaveBeenCalledWith( + { any: 'rawBody' }, + req.headers, + ) + expect(mockBillingService.storePolarWebhookPayload).toHaveBeenCalledTimes(1) + }) + + it('routes subscription.created to switchPlan with the period fields', async () => { + await handle(makePayload('subscription.created')) + + expect(mockBillingService.switchPlan).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user_meta_1', + newPlanPolarProductId: 'prod_pro_monthly', + currentPeriodEnd: '2026-07-17T00:00:00.000Z', + cancelAtPeriodEnd: false, + }), + ) + expect(mockBillingService.cancelSubscription).not.toHaveBeenCalled() + expect(mockBillingService.revokeSubscription).not.toHaveBeenCalled() + }) + + it('routes subscription.updated to switchPlan, forwarding cancelAtPeriodEnd', async () => { + await handle(makePayload('subscription.updated', { cancelAtPeriodEnd: true })) + + expect(mockBillingService.switchPlan).toHaveBeenCalledWith( + expect.objectContaining({ + cancelAtPeriodEnd: true, + currentPeriodEnd: '2026-07-17T00:00:00.000Z', + }), + ) + }) + + it('routes subscription.canceled to cancelSubscription, forwarding the cancel/period fields and NOT downgrading', async () => { + await handle( + makePayload('subscription.canceled', { cancelAtPeriodEnd: true }), + ) + + expect(mockBillingService.cancelSubscription).toHaveBeenCalledWith({ + userId: 'user_meta_1', + polarProductId: 'prod_pro_monthly', + cancelAtPeriodEnd: true, + currentPeriodEnd: '2026-07-17T00:00:00.000Z', + status: 'active', + }) + // A scheduled cancellation must not route to the downgrade or switchPlan. + expect(mockBillingService.revokeSubscription).not.toHaveBeenCalled() + expect(mockBillingService.switchPlan).not.toHaveBeenCalled() + }) + + it('routes the alternate spelling subscription.cancelled to cancelSubscription', async () => { + await handle(makePayload('subscription.cancelled')) + + expect(mockBillingService.cancelSubscription).toHaveBeenCalledTimes(1) + expect(mockBillingService.revokeSubscription).not.toHaveBeenCalled() + }) + + it('routes subscription.revoked to revokeSubscription (the real downgrade)', async () => { + await handle(makePayload('subscription.revoked')) + + expect(mockBillingService.revokeSubscription).toHaveBeenCalledWith({ + userId: 'user_meta_1', + polarProductId: 'prod_pro_monthly', + }) + expect(mockBillingService.cancelSubscription).not.toHaveBeenCalled() + }) + + it('does not mutate any subscription for an unhandled event type', async () => { + await handle(makePayload('checkout.created')) + + expect(mockBillingService.switchPlan).not.toHaveBeenCalled() + expect(mockBillingService.cancelSubscription).not.toHaveBeenCalled() + expect(mockBillingService.revokeSubscription).not.toHaveBeenCalled() + // ...but the payload is still validated and stored. + expect(mockBillingService.storePolarWebhookPayload).toHaveBeenCalledTimes(1) + }) + + it('falls back to customer.externalId when metadata.userId is absent', async () => { + await handle(makePayload('subscription.revoked', { metadata: {} })) + + expect(mockBillingService.revokeSubscription).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user_ext_1' }), + ) + }) +}) diff --git a/api/src/billing/billing.controller.ts b/api/src/billing/billing.controller.ts index b89c27b9..cc0da9ab 100644 --- a/api/src/billing/billing.controller.ts +++ b/api/src/billing/billing.controller.ts @@ -101,13 +101,30 @@ export class BillingController { case 'subscription.cancelled': // @ts-ignore case 'subscription.canceled': - // case 'subscription.revoked': console.log('polar webhook event', payload.type) console.log(payload) + // Cancellation is SCHEDULED here — access continues until period end. + // Record the intent without downgrading; the actual downgrade happens + // on "subscription.revoked". await this.billingService.cancelSubscription({ userId: (payload.data?.metadata?.userId || payload.data?.customer?.externalId) as string, polarProductId: payload.data?.product?.id, + cancelAtPeriodEnd: payload.data?.cancelAtPeriodEnd, + currentPeriodEnd: payload.data?.currentPeriodEnd, + status: payload.data?.status, + }) + break + + // @ts-ignore + case 'subscription.revoked': + console.log('polar webhook event', payload.type) + console.log(payload) + // Access should actually end now — perform the real downgrade. + await this.billingService.revokeSubscription({ + userId: (payload.data?.metadata?.userId || + payload.data?.customer?.externalId) as string, + polarProductId: payload.data?.product?.id, }) break default: diff --git a/api/src/billing/billing.service.spec.ts b/api/src/billing/billing.service.spec.ts new file mode 100644 index 00000000..2c5a806c --- /dev/null +++ b/api/src/billing/billing.service.spec.ts @@ -0,0 +1,141 @@ +import { Test, TestingModule } from '@nestjs/testing' +import { getModelToken } from '@nestjs/mongoose' +import { Types } from 'mongoose' +import { BillingService } from './billing.service' +import { Plan } from './schemas/plan.schema' +import { Subscription } from './schemas/subscription.schema' +import { User } from '../users/schemas/user.schema' +import { SMS } from '../gateway/schemas/sms.schema' +import { PolarWebhookPayload } from './schemas/polar-webhook-payload.schema' +import { CheckoutSession } from './schemas/checkout-session.schema' +import { BillingNotificationsService } from './billing-notifications.service' + +describe('BillingService - cancellation handling', () => { + let service: BillingService + + // 24-hex string so `new Types.ObjectId(userId)` succeeds. + const userId = '507f1f77bcf86cd799439011' + const proPlan = { _id: 'plan_pro', name: 'pro' } + const polarProductId = 'prod_pro_monthly' + + const mockPlanModel = { + findOne: jest.fn(), + } + const mockSubscriptionModel = { + updateOne: jest.fn(), + } + const emptyModel = {} + const mockBillingNotifications = {} + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + BillingService, + { provide: getModelToken(Plan.name), useValue: mockPlanModel }, + { + provide: getModelToken(Subscription.name), + useValue: mockSubscriptionModel, + }, + { provide: getModelToken(User.name), useValue: emptyModel }, + { provide: getModelToken(SMS.name), useValue: emptyModel }, + { + provide: getModelToken(PolarWebhookPayload.name), + useValue: emptyModel, + }, + { + provide: getModelToken(CheckoutSession.name), + useValue: emptyModel, + }, + { + provide: BillingNotificationsService, + useValue: mockBillingNotifications, + }, + ], + }).compile() + + service = module.get(BillingService) + + jest.clearAllMocks() + mockPlanModel.findOne.mockResolvedValue(proPlan) + mockSubscriptionModel.updateOne.mockResolvedValue({ modifiedCount: 1 }) + }) + + describe('cancelSubscription', () => { + it('records the scheduled cancellation WITHOUT downgrading (keeps the plan active)', async () => { + const currentPeriodEnd = new Date('2026-07-17T00:00:00.000Z') + + await service.cancelSubscription({ + userId, + polarProductId, + cancelAtPeriodEnd: true, + currentPeriodEnd, + status: 'active', + }) + + expect(mockSubscriptionModel.updateOne).toHaveBeenCalledTimes(1) + const [filter, update] = mockSubscriptionModel.updateOne.mock.calls[0] + + // Filter targets the user's active subscription for this plan. + expect(filter).toEqual({ + user: expect.any(Types.ObjectId), + plan: proPlan._id, + isActive: true, + }) + + // The fix: the cancellation is recorded with the real period end, and + // the subscription stays active. It must NOT flip isActive to false. + expect(update).toEqual({ + cancelAtPeriodEnd: true, + currentPeriodEnd, + subscriptionEndDate: currentPeriodEnd, + status: 'active', + }) + expect(update).not.toHaveProperty('isActive') + }) + + it('defaults cancelAtPeriodEnd to true and omits period fields when not provided', async () => { + await service.cancelSubscription({ userId, polarProductId }) + + const [, update] = mockSubscriptionModel.updateOne.mock.calls[0] + expect(update).toEqual({ cancelAtPeriodEnd: true }) + expect(update).not.toHaveProperty('currentPeriodEnd') + expect(update).not.toHaveProperty('subscriptionEndDate') + expect(update).not.toHaveProperty('isActive') + }) + + it('throws when no plan matches the Polar product id', async () => { + mockPlanModel.findOne.mockResolvedValue(null) + + await expect( + service.cancelSubscription({ userId, polarProductId: 'unknown' }), + ).rejects.toThrow('No plan found for product ID: unknown') + expect(mockSubscriptionModel.updateOne).not.toHaveBeenCalled() + }) + }) + + describe('revokeSubscription', () => { + it('performs the real downgrade by deactivating the subscription', async () => { + await service.revokeSubscription({ userId, polarProductId }) + + expect(mockSubscriptionModel.updateOne).toHaveBeenCalledTimes(1) + const [filter, update] = mockSubscriptionModel.updateOne.mock.calls[0] + + expect(filter).toEqual({ + user: expect.any(Types.ObjectId), + plan: proPlan._id, + isActive: true, + }) + expect(update.isActive).toBe(false) + expect(update.subscriptionEndDate).toBeInstanceOf(Date) + }) + + it('throws when no plan matches the Polar product id', async () => { + mockPlanModel.findOne.mockResolvedValue(null) + + await expect( + service.revokeSubscription({ userId, polarProductId: 'unknown' }), + ).rejects.toThrow('No plan found for product ID: unknown') + expect(mockSubscriptionModel.updateOne).not.toHaveBeenCalled() + }) + }) +}) diff --git a/api/src/billing/billing.service.ts b/api/src/billing/billing.service.ts index 10db2d7d..341cfc68 100644 --- a/api/src/billing/billing.service.ts +++ b/api/src/billing/billing.service.ts @@ -837,6 +837,53 @@ export class BillingService { async cancelSubscription({ userId, polarProductId, + cancelAtPeriodEnd, + currentPeriodEnd, + status, + }: { + userId: string + polarProductId?: string + cancelAtPeriodEnd?: boolean + currentPeriodEnd?: Date + status?: string + }) { + const userObjectId = new Types.ObjectId(userId) + + const plan = await this.planModel.findOne({ + $or: [ + { polarMonthlyProductId: polarProductId }, + { polarYearlyProductId: polarProductId }, + ], + }) + + if (!plan) { + throw new Error(`No plan found for product ID: ${polarProductId}`) + } + + // Polar "subscription.canceled" = cancellation SCHEDULED. The subscription + // stays active until period end. Record the intent; do NOT downgrade here. + // The actual downgrade happens on the "subscription.revoked" event. + await this.subscriptionModel.updateOne( + { user: userObjectId, plan: plan._id, isActive: true }, + { + cancelAtPeriodEnd: cancelAtPeriodEnd ?? true, + ...(currentPeriodEnd && { + currentPeriodEnd, + subscriptionEndDate: currentPeriodEnd, + }), + ...(status && { status }), + }, + ) + + console.log( + `Recorded scheduled cancellation for user ${userId} on plan ${plan.name} (ends ${currentPeriodEnd ?? 'unknown'})`, + ) + return { success: true, plan: plan.name } + } + + async revokeSubscription({ + userId, + polarProductId, }: { userId: string polarProductId?: string @@ -859,7 +906,7 @@ export class BillingService { { isActive: false, subscriptionEndDate: new Date() }, ) - console.log(`Cancelled subscription for user ${userId} on plan ${plan.name}`) + console.log(`Revoked subscription for user ${userId} on plan ${plan.name}`) return { success: true, plan: plan.name } }