Merge pull request #237 from vernu/dev

retain paid plan until period end on cancellation
This commit is contained in:
vernu
2026-06-18 09:04:42 +03:00
committed by GitHub
5 changed files with 375 additions and 2 deletions

View File

@@ -90,6 +90,9 @@
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"moduleNameMapper": {
"^src/(.*)$": "<rootDir>/$1"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],

View File

@@ -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' }),
)
})
})

View File

@@ -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:

View File

@@ -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()
})
})
})

View File

@@ -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 }
}