[web] Fix negative AI quota countdown after reset period elapses (#35690)

* Fix negative AI quota countdown after reset period elapses

Backend (`FeatureUsageRateLimiter`, `TokenUsageRateLimiter`): once the
24h period had lapsed, `remainingUsage`/`remainingTokens` reset to the
full allowance but `resetDate` kept pointing at the old `periodStart +
24h` (i.e. in the past). Advance to `now + 24h` in that case.

Frontend (`NewPlanPaywall`, `ErrorGetPremium`): the "wait X hours and Y
minutes" copy computed `restoreTime - now` without clamping, so a past
`restoreTime` produced negative hours/minutes (users saw "wait -118
hours and -35 minutes"). Clamp to 0.

* Address PR review on quota reset fix

- Keep `resetDate` tied to the stored `periodStart + PERIOD` rather
  than jumping to `now + PERIOD` when the period has lapsed. The
  moving-target variant unnecessarily reinterpreted a stable field
  (existing consumers such as `use-ai-access-gate` check whether the
  reset date is in the past).
- Clamp `remainingUsage` / `remainingTokens` at 0 so a concurrent
  over-usage never leaks a negative count.
- Align the boundary check with `_checkRateLimit` / `checkUsage`
  (inclusive `<=`).
- Cover the period boundary and the clamp in unit tests for both
  `FeatureUsageRateLimiter` and `WorkbenchRateLimiter`.

* Truncate test fixtures to whole seconds

`resetDate` is serialized with `Date.toString()`, which has second
resolution, so fixtures with a millisecond component failed the
boundary assertions.

* Revert code changes; keep tests that document the bug

The fixes masked the symptom (negative countdown) without addressing
the underlying issue, and it is not fully understood yet. Revert the
rate limiters and the writefull paywall components to their original
behaviour, and instead pin the current behaviour in tests:

- `it.fails` tests document that `remainingUsage`/`remainingTokens`
  go negative when usage exceeds the allowance — these flip red when
  the bug is fixed, at which point they should become plain `it`.
- The lapsed-period tests assert (with explanatory comments) that
  `resetDate` keeps pointing at the end of the lapsed period, which is
  the server-side ingredient of the negative-countdown support ticket.

* Reapply quota fixes

Restores the post-review state of the rate limiters (clamp remaining
counts at 0, inclusive period boundary, resetDate tied to periodStart)
and the writefull paywall components (clamp countdown at 0).

Tests intentionally untouched in this commit; the two `it.fails` tests
now need flipping to plain `it` in a follow-up.

* Flip bug-documenting tests to plain assertions

The clamp fixes are reapplied, so the `it.fails` tests now assert the
fixed behaviour directly. Trim the explanatory comments.

* Set resetDate to `Date.now() + PERIOD_IN_MILLISECONDS` instead of the expired/stale date.

Co-authored-by: Mathias Jakobsen <mathias.jakobsen@overleaf.com>

* Update lapsed-period tests for the fresh resetDate

`resetDate` is now `Date.now() + PERIOD` once the period has lapsed, so
assert an approximately-fresh date instead of the stale period end.

---------

Co-authored-by: Mathias Jakobsen <mathias.jakobsen@overleaf.com>
GitOrigin-RevId: ad39627ab9e5ceccb2a61443b2b7d39c68600594
This commit is contained in:
Antoine Clausse
2026-09-17 11:49:47 +00:00
committed by Copybot
co-authored by Mathias Jakobsen
parent 6b3d95cea4
commit 87fd22cefc
4 changed files with 146 additions and 6 deletions
@@ -170,6 +170,68 @@ describe('FeatureUsageRateLimiter', function () {
const usages =
await ctx.FeatureUsageRateLimiter.getRemainingFeatureUses(ctx.userId)
expect(usages[MOCKED_FEATURE_NAME].remainingUsage).to.equal(70)
expect(
new Date(usages[MOCKED_FEATURE_NAME].resetDate).getTime()
).to.be.greaterThan(Date.now())
})
})
describe('at the period boundary', function () {
const PERIOD_MS = 24 * 60 * 60 * 1000
it('just inside the period returns usesLeft and future resetDate', async function (ctx) {
// whole second: `resetDate` round-trips through Date.toString()
const periodStart = new Date(
Math.floor((Date.now() - PERIOD_MS + 60 * 1000) / 1000) * 1000
)
await UserFeatureUsage.create({
_id: ctx.userId,
features: {
[MOCKED_FEATURE_NAME]: { usage: 40, periodStart },
},
})
const usages =
await ctx.FeatureUsageRateLimiter.getRemainingFeatureUses(ctx.userId)
expect(usages[MOCKED_FEATURE_NAME].remainingUsage).to.equal(60)
expect(
new Date(usages[MOCKED_FEATURE_NAME].resetDate).getTime()
).to.equal(periodStart.getTime() + PERIOD_MS)
})
it('once the period has lapsed reports the full allowance and a fresh resetDate', async function (ctx) {
// whole second: `resetDate` round-trips through Date.toString()
const periodStart = new Date(
Math.floor((Date.now() - PERIOD_MS - 60 * 1000) / 1000) * 1000
)
await UserFeatureUsage.create({
_id: ctx.userId,
features: {
[MOCKED_FEATURE_NAME]: { usage: 40, periodStart },
},
})
const usages =
await ctx.FeatureUsageRateLimiter.getRemainingFeatureUses(ctx.userId)
expect(usages[MOCKED_FEATURE_NAME].remainingUsage).to.equal(100)
expect(
new Date(usages[MOCKED_FEATURE_NAME].resetDate).getTime()
).to.be.approximately(Date.now() + PERIOD_MS, 5000)
})
})
describe('when usage exceeds allowance', function () {
beforeEach(async function (ctx) {
await UserFeatureUsage.create({
_id: ctx.userId,
features: {
[MOCKED_FEATURE_NAME]: { usage: 150, periodStart: new Date() },
},
})
})
it('should clamp remainingUsage to 0 rather than leak a negative', async function (ctx) {
const usages =
await ctx.FeatureUsageRateLimiter.getRemainingFeatureUses(ctx.userId)
expect(usages[MOCKED_FEATURE_NAME].remainingUsage).to.equal(0)
})
})
})