diff --git a/api/package.json b/api/package.json index f0bcdd3..ae6d743 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 0000000..f6425a4 --- /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 b89c27b..cc0da9a 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 0000000..2c5a806 --- /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 10db2d7..341cfc6 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 } }