feat: ai quotas should reset when a new plan purchase is made or upgraded (#33095)

GitOrigin-RevId: 9034800e067426fc22f8f86f9d7309699797d02e
This commit is contained in:
Jimmy Domagala-Tang
2026-04-29 13:23:48 -04:00
committed by Copybot
parent 730ff8f0ea
commit 41b96ec8d6
6 changed files with 181 additions and 5 deletions

View File

@@ -13,6 +13,8 @@ import UserUpdater from '../User/UserUpdater.mjs'
import Modules from '../../infrastructure/Modules.mjs'
import { AI_ADD_ON_CODE } from './AiHelper.mjs'
import CustomerIoPlanHelpers from './CustomerIoPlanHelpers.mjs'
import WorkbenchRateLimiter from '../../infrastructure/rate-limiters/WorkbenchRateLimiter.mjs'
import AiFeatureUsageRateLimiter from '../../infrastructure/rate-limiters/AiFeatureUsageRateLimiter.mjs'
/**
* @import { PaymentProviderSubscriptionChange } from './PaymentProviderEntities.mjs'
@@ -130,6 +132,13 @@ async function updateSubscription(user, planCode) {
user._id
)
try {
await WorkbenchRateLimiter.resetTokenUsage(user._id)
await AiFeatureUsageRateLimiter.resetFeatureUsage(user._id)
} catch (err) {
logger.error({ err, userId: user._id }, 'failed to reset AI usage limits')
}
if (previousPlanType) {
Modules.promises.hooks
.fire('setUserProperties', user._id, {

View File

@@ -20,7 +20,7 @@ export default class FeatureUsageRateLimiter {
this.featureName = featureName
}
_resetFeatureUsagePipelineSection() {
resetFeatureUsagePipelineSection() {
return {
$set: {
features: {
@@ -62,7 +62,7 @@ export default class FeatureUsageRateLimiter {
const featureUsages = await UserFeatureUsage.findOneAndUpdate(
{ _id: userId },
[
this._resetFeatureUsagePipelineSection(),
this.resetFeatureUsagePipelineSection(),
{
$set: {
features: {
@@ -108,7 +108,7 @@ export default class FeatureUsageRateLimiter {
const featureUsages = await UserFeatureUsage.findOneAndUpdate(
{ _id: userId },
[
this._resetFeatureUsagePipelineSection(),
this.resetFeatureUsagePipelineSection(),
{
$set: {
[`features.${this.featureName}.usage`]: {
@@ -130,6 +130,24 @@ export default class FeatureUsageRateLimiter {
setRateLimitHeaders(res, featureUsage, allowance)
}
/**
* @param {string} userId
*/
async resetFeatureUsage(userId) {
await UserFeatureUsage.findOneAndUpdate(
{ _id: userId },
{
$set: {
[`features.${this.featureName}`]: {
usage: 0,
periodStart: new Date(),
},
},
},
{ upsert: true }
).exec()
}
/**
* @param {string} userId
* @returns {Promise<{[featureName: string]: { remainingUsage: number, resetDate?: string}}>}

View File

@@ -18,7 +18,7 @@ export default class TokenUsageRateLimiter {
this.featureName = featureName
}
_resetFeatureUsagePipelineSection() {
resetTokenUsagePipelineSection() {
return {
$set: {
features: {
@@ -69,7 +69,7 @@ export default class TokenUsageRateLimiter {
const featureUsages = await UserFeatureUsage.findOneAndUpdate(
{ _id: userId },
[
this._resetFeatureUsagePipelineSection(),
this.resetTokenUsagePipelineSection(),
{
$set: {
features: {
@@ -92,6 +92,24 @@ export default class TokenUsageRateLimiter {
this.setRateLimitHeaders(res, featureUsage, allowance)
}
/**
* @param {string} userId
*/
async resetTokenUsage(userId) {
await UserFeatureUsage.findOneAndUpdate(
{ _id: userId },
{
$set: {
[`features.${this.featureName}`]: {
usage: 0,
periodStart: new Date(),
},
},
},
{ upsert: true }
).exec()
}
/**
*
* @param {string} userId

View File

@@ -145,6 +145,14 @@ describe('SubscriptionHandler', function () {
},
}
ctx.WorkbenchRateLimiter = {
resetTokenUsage: sinon.stub().resolves(),
}
ctx.AiFeatureUsageRateLimiter = {
resetFeatureUsage: sinon.stub().resolves(),
}
vi.doMock(
'../../../../app/src/Features/Subscription/RecurlyWrapper',
() => ({
@@ -227,6 +235,20 @@ describe('SubscriptionHandler', function () {
}),
}))
vi.doMock(
'../../../../app/src/infrastructure/rate-limiters/WorkbenchRateLimiter',
() => ({
default: ctx.WorkbenchRateLimiter,
})
)
vi.doMock(
'../../../../app/src/infrastructure/rate-limiters/AiFeatureUsageRateLimiter',
() => ({
default: ctx.AiFeatureUsageRateLimiter,
})
)
ctx.SubscriptionHandler = (await import(MODULE_PATH)).default
})
@@ -379,6 +401,37 @@ describe('SubscriptionHandler', function () {
ctx.user._id
)
})
it('should reset the ai rate limiter usages on a successful update', async function (ctx) {
ctx.LimitationsManager.promises.userHasSubscription.resolves({
hasSubscription: true,
subscription: ctx.subscription,
})
await ctx.SubscriptionHandler.promises.updateSubscription(
ctx.user,
ctx.plan_code
)
expect(ctx.WorkbenchRateLimiter.resetTokenUsage).to.have.been.calledWith(
ctx.user._id
)
expect(
ctx.AiFeatureUsageRateLimiter.resetFeatureUsage
).to.have.been.calledWith(ctx.user._id)
})
it('should not reset the ai rate limiter usages when no subscription exists', async function (ctx) {
ctx.LimitationsManager.promises.userHasSubscription.resolves({
hasSubscription: false,
subscription: null,
})
await ctx.SubscriptionHandler.promises.updateSubscription(
ctx.user,
ctx.plan_code
)
expect(ctx.WorkbenchRateLimiter.resetTokenUsage).to.not.have.been.called
expect(ctx.AiFeatureUsageRateLimiter.resetFeatureUsage).to.not.have.been
.called
})
})
describe('cancelPendingSubscriptionChange', function () {

View File

@@ -174,6 +174,48 @@ describe('FeatureUsageRateLimiter', function () {
})
})
describe('resetFeatureUsage', function () {
beforeEach(function (ctx) {
ctx._getAllowanceStub.resolves(100)
})
describe('with some usage', function () {
beforeEach(async function (ctx) {
await UserFeatureUsage.create({
_id: ctx.userId,
features: {
[MOCKED_FEATURE_NAME]: { usage: 75, periodStart: new Date(0) },
},
})
})
it('should reset usage back to the full allowance', async function (ctx) {
await ctx.FeatureUsageRateLimiter.resetFeatureUsage(ctx.userId)
const usages =
await ctx.FeatureUsageRateLimiter.getRemainingFeatureUses(ctx.userId)
expect(usages[MOCKED_FEATURE_NAME].remainingUsage).to.equal(100)
})
it('should set periodStart to roughly the current time', async function (ctx) {
const before = Date.now()
await ctx.FeatureUsageRateLimiter.resetFeatureUsage(ctx.userId)
const doc = await UserFeatureUsage.findOne({ _id: ctx.userId }).exec()
const periodStart = doc.features[MOCKED_FEATURE_NAME].periodStart
expect(periodStart.getTime()).to.be.at.least(before)
expect(periodStart.getTime()).to.be.at.most(Date.now())
})
})
describe('when no usage record exists', function () {
it('should upsert a fresh usage record with zero usage', async function (ctx) {
await ctx.FeatureUsageRateLimiter.resetFeatureUsage(ctx.userId)
const doc = await UserFeatureUsage.findOne({ _id: ctx.userId }).exec()
expect(doc).to.not.be.null
expect(doc.features[MOCKED_FEATURE_NAME].usage).to.equal(0)
})
})
})
describe('decrementFeatureUsage', function () {
describe('with some usage', function () {
beforeEach(async function (ctx) {

View File

@@ -292,6 +292,42 @@ describe('WorkbenchRateLimiter', function () {
})
})
describe('resetTokenUsage', function () {
beforeEach(async function () {
await UserFeatureUsage.deleteMany({}).exec()
})
it('resets usage to 0 and refreshes periodStart when existing usage is present', async function (ctx) {
const usageRecord = new UserFeatureUsage({
_id: ctx.alphaUserId,
features: {
aiWorkbench: {
usage: 5000000,
periodStart: new Date(new Date().getTime() - 1 * 60 * 60 * 1000),
},
},
})
await usageRecord.save()
const before = Date.now()
await ctx.WorkbenchRateLimiter.resetTokenUsage(ctx.alphaUserId)
const updated = await UserFeatureUsage.findById(ctx.alphaUserId).exec()
expect(updated.features.aiWorkbench.usage).to.equal(0)
expect(updated.features.aiWorkbench.periodStart.getTime()).to.be.at.least(
before
)
})
it('upserts a fresh usage record with zero usage when none exists', async function (ctx) {
await ctx.WorkbenchRateLimiter.resetTokenUsage(ctx.alphaUserId)
const created = await UserFeatureUsage.findById(ctx.alphaUserId).exec()
expect(created).to.exist
expect(created.features.aiWorkbench.usage).to.equal(0)
})
})
describe('recordUsage', function () {
beforeEach(async function (ctx) {
await UserFeatureUsage.deleteMany({}).exec()