mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-23 17:19:37 +02:00
feat: ai quotas should reset when a new plan purchase is made or upgraded (#33095)
GitOrigin-RevId: 9034800e067426fc22f8f86f9d7309699797d02e
This commit is contained in:
committed by
Copybot
parent
730ff8f0ea
commit
41b96ec8d6
@@ -13,6 +13,8 @@ import UserUpdater from '../User/UserUpdater.mjs'
|
|||||||
import Modules from '../../infrastructure/Modules.mjs'
|
import Modules from '../../infrastructure/Modules.mjs'
|
||||||
import { AI_ADD_ON_CODE } from './AiHelper.mjs'
|
import { AI_ADD_ON_CODE } from './AiHelper.mjs'
|
||||||
import CustomerIoPlanHelpers from './CustomerIoPlanHelpers.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'
|
* @import { PaymentProviderSubscriptionChange } from './PaymentProviderEntities.mjs'
|
||||||
@@ -130,6 +132,13 @@ async function updateSubscription(user, planCode) {
|
|||||||
user._id
|
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) {
|
if (previousPlanType) {
|
||||||
Modules.promises.hooks
|
Modules.promises.hooks
|
||||||
.fire('setUserProperties', user._id, {
|
.fire('setUserProperties', user._id, {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default class FeatureUsageRateLimiter {
|
|||||||
this.featureName = featureName
|
this.featureName = featureName
|
||||||
}
|
}
|
||||||
|
|
||||||
_resetFeatureUsagePipelineSection() {
|
resetFeatureUsagePipelineSection() {
|
||||||
return {
|
return {
|
||||||
$set: {
|
$set: {
|
||||||
features: {
|
features: {
|
||||||
@@ -62,7 +62,7 @@ export default class FeatureUsageRateLimiter {
|
|||||||
const featureUsages = await UserFeatureUsage.findOneAndUpdate(
|
const featureUsages = await UserFeatureUsage.findOneAndUpdate(
|
||||||
{ _id: userId },
|
{ _id: userId },
|
||||||
[
|
[
|
||||||
this._resetFeatureUsagePipelineSection(),
|
this.resetFeatureUsagePipelineSection(),
|
||||||
{
|
{
|
||||||
$set: {
|
$set: {
|
||||||
features: {
|
features: {
|
||||||
@@ -108,7 +108,7 @@ export default class FeatureUsageRateLimiter {
|
|||||||
const featureUsages = await UserFeatureUsage.findOneAndUpdate(
|
const featureUsages = await UserFeatureUsage.findOneAndUpdate(
|
||||||
{ _id: userId },
|
{ _id: userId },
|
||||||
[
|
[
|
||||||
this._resetFeatureUsagePipelineSection(),
|
this.resetFeatureUsagePipelineSection(),
|
||||||
{
|
{
|
||||||
$set: {
|
$set: {
|
||||||
[`features.${this.featureName}.usage`]: {
|
[`features.${this.featureName}.usage`]: {
|
||||||
@@ -130,6 +130,24 @@ export default class FeatureUsageRateLimiter {
|
|||||||
setRateLimitHeaders(res, featureUsage, allowance)
|
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
|
* @param {string} userId
|
||||||
* @returns {Promise<{[featureName: string]: { remainingUsage: number, resetDate?: string}}>}
|
* @returns {Promise<{[featureName: string]: { remainingUsage: number, resetDate?: string}}>}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export default class TokenUsageRateLimiter {
|
|||||||
this.featureName = featureName
|
this.featureName = featureName
|
||||||
}
|
}
|
||||||
|
|
||||||
_resetFeatureUsagePipelineSection() {
|
resetTokenUsagePipelineSection() {
|
||||||
return {
|
return {
|
||||||
$set: {
|
$set: {
|
||||||
features: {
|
features: {
|
||||||
@@ -69,7 +69,7 @@ export default class TokenUsageRateLimiter {
|
|||||||
const featureUsages = await UserFeatureUsage.findOneAndUpdate(
|
const featureUsages = await UserFeatureUsage.findOneAndUpdate(
|
||||||
{ _id: userId },
|
{ _id: userId },
|
||||||
[
|
[
|
||||||
this._resetFeatureUsagePipelineSection(),
|
this.resetTokenUsagePipelineSection(),
|
||||||
{
|
{
|
||||||
$set: {
|
$set: {
|
||||||
features: {
|
features: {
|
||||||
@@ -92,6 +92,24 @@ export default class TokenUsageRateLimiter {
|
|||||||
this.setRateLimitHeaders(res, featureUsage, allowance)
|
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
|
* @param {string} userId
|
||||||
|
|||||||
@@ -145,6 +145,14 @@ describe('SubscriptionHandler', function () {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx.WorkbenchRateLimiter = {
|
||||||
|
resetTokenUsage: sinon.stub().resolves(),
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.AiFeatureUsageRateLimiter = {
|
||||||
|
resetFeatureUsage: sinon.stub().resolves(),
|
||||||
|
}
|
||||||
|
|
||||||
vi.doMock(
|
vi.doMock(
|
||||||
'../../../../app/src/Features/Subscription/RecurlyWrapper',
|
'../../../../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
|
ctx.SubscriptionHandler = (await import(MODULE_PATH)).default
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -379,6 +401,37 @@ describe('SubscriptionHandler', function () {
|
|||||||
ctx.user._id
|
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 () {
|
describe('cancelPendingSubscriptionChange', function () {
|
||||||
|
|||||||
@@ -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('decrementFeatureUsage', function () {
|
||||||
describe('with some usage', function () {
|
describe('with some usage', function () {
|
||||||
beforeEach(async function (ctx) {
|
beforeEach(async function (ctx) {
|
||||||
|
|||||||
@@ -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 () {
|
describe('recordUsage', function () {
|
||||||
beforeEach(async function (ctx) {
|
beforeEach(async function (ctx) {
|
||||||
await UserFeatureUsage.deleteMany({}).exec()
|
await UserFeatureUsage.deleteMany({}).exec()
|
||||||
|
|||||||
Reference in New Issue
Block a user