mirror of
https://github.com/vernu/textbee.git
synced 2026-06-28 06:45:47 +00:00
Merge pull request #237 from vernu/dev
retain paid plan until period end on cancellation
This commit is contained in:
@@ -90,6 +90,9 @@
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"moduleNameMapper": {
|
||||
"^src/(.*)$": "<rootDir>/$1"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
|
||||
165
api/src/billing/billing.controller.spec.ts
Normal file
165
api/src/billing/billing.controller.spec.ts
Normal 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' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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:
|
||||
|
||||
141
api/src/billing/billing.service.spec.ts
Normal file
141
api/src/billing/billing.service.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user