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
3 changes: 3 additions & 0 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"moduleNameMapper": {
"^src/(.*)$": "<rootDir>/$1"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
Expand Down
165 changes: 165 additions & 0 deletions api/src/billing/billing.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, any> = {}) => ({
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>(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' }),
)
})
})
19 changes: 18 additions & 1 deletion api/src/billing/billing.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
141 changes: 141 additions & 0 deletions api/src/billing/billing.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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>(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()
})
})
})
Loading
Loading