From d2caa9824880110710b7bf09927c10acf6975c4a Mon Sep 17 00:00:00 2001 From: Keysat Date: Sat, 13 Jun 2026 16:23:26 -0500 Subject: [PATCH] Fix credit-counter reset on early subscription renewal extendUserTier called setUserTier, which unconditionally zeroed monthly_consumed and re-anchored the cycle. A user who renewed mid-cycle (or a webhook double-firing across a restart) got their full monthly allotment back for free. The monthly cycle already rolls on its own anniversary via ensureRenewalRollover, so renewal must not reset it. Add resetCycle to setUserTier (default true, preserving operator-grant behavior); extendUserTier passes false for an in-force subscription and true only for a brand-new or lapsed one. Add regression tests. --- server/credits.js | 38 +++++++++++++++++++++++---------- server/test/tier-expiry.test.js | 22 +++++++++++++++++++ 2 files changed, 49 insertions(+), 11 deletions(-) diff --git a/server/credits.js b/server/credits.js index 600be0d..2885108 100644 --- a/server/credits.js +++ b/server/credits.js @@ -741,21 +741,26 @@ export async function addPurchasedCredits({ // stored on the user's credit row (keyed `user:`). Set by the // operator (today) and the self-serve purchase flow (later slice). -// Operator-set a cloud user's tier. Resets the monthly counters and -// anchors the renewal to now (so the monthly cycle starts on the grant -// date), mirroring applyTierPromotion. `expiresAt` is stored for -// reporting / future self-serve billing but NOT auto-enforced in this -// slice — to revoke, set tier back to "core". -export async function setUserTier({ userId, tier, expiresAt = null }) { +// Operator-set a cloud user's tier. With `resetCycle` (the default) it +// starts a fresh monthly cycle anchored to now — so an operator comp +// grant, or a first/lapsed self-serve purchase, begins its allowance on +// the grant date (mirroring applyTierPromotion). A renewal of an +// in-force subscription passes `resetCycle: false` so it extends the +// expiry WITHOUT zeroing monthly_consumed — see extendUserTier. +// `expiresAt` is stored for reporting / self-serve billing but NOT +// auto-enforced here — to revoke, set tier back to "core". +export async function setUserTier({ userId, tier, expiresAt = null, resetCycle = true }) { if (!userId) throw new Error("setUserTier: userId required"); const t = tier === "pro" || tier === "max" ? tier : "core"; const row = await getOrCreateRow({ creditKey: `user:${userId}` }); const now = new Date(); row.tier_snapshot = t; - row.monthly_consumed = 0; - row.monthly_gemini_consumed = 0; - row.last_renewal_at = now.toISOString(); - row.anniversary_day = now.getUTCDate(); + if (resetCycle) { + row.monthly_consumed = 0; + row.monthly_gemini_consumed = 0; + row.last_renewal_at = now.toISOString(); + row.anniversary_day = now.getUTCDate(); + } row.subscription_expires_at = expiresAt || null; row.last_active_at = now.toISOString(); await persist(); @@ -767,6 +772,13 @@ export async function setUserTier({ userId, tier, expiresAt = null }) { // (still-active) expiry — so paying early ADDS time rather than resetting // it. `periodDays` defaults to 30. Both payment rails (BTCPay + Zaprite) // land here on a settled payment. Returns the updated row. +// +// Renewing an IN-FORCE subscription must NOT reset the monthly credit +// counter — otherwise a user who paid early (or a webhook that double- +// fired across a restart) would get their whole monthly allotment back +// for free. The monthly cycle rolls on its own anniversary via +// ensureRenewalRollover, independent of renewals. Only a brand-new or +// lapsed subscription starts a fresh cycle. export async function extendUserTier({ userId, tier, periodDays = 30 }) { if (!userId) throw new Error("extendUserTier: userId required"); const t = tier === "pro" || tier === "max" ? tier : "core"; @@ -775,11 +787,15 @@ export async function extendUserTier({ userId, tier, periodDays = 30 }) { const curExp = row.subscription_expires_at ? new Date(row.subscription_expires_at).getTime() : 0; + const hasActiveSub = + Number.isFinite(curExp) && + curExp > now && + (row.tier_snapshot === "pro" || row.tier_snapshot === "max"); const base = Math.max(now, Number.isFinite(curExp) ? curExp : 0); const expiresAt = new Date( base + periodDays * 24 * 60 * 60 * 1000, ).toISOString(); - return setUserTier({ userId, tier: t, expiresAt }); + return setUserTier({ userId, tier: t, expiresAt, resetCycle: !hasActiveSub }); } // Read a cloud user's credit row (creates a blank Core row if none yet). diff --git a/server/test/tier-expiry.test.js b/server/test/tier-expiry.test.js index cf08b31..8f4a25a 100644 --- a/server/test/tier-expiry.test.js +++ b/server/test/tier-expiry.test.js @@ -76,4 +76,26 @@ describe("extendUserTier (prepaid periods)", () => { const days = (new Date(renewed.subscription_expires_at).getTime() - Date.now()) / DAY; assert.ok(days > 29.9 && days < 30.1, `fresh ~30 days from now, got ${days}`); }); + + test("early renewal PRESERVES the monthly credit counter (no free reset)", async () => { + await extendUserTier({ userId: "u4", tier: "pro", periodDays: 30 }); + const row = await getUserCreditRow("u4"); + row.monthly_consumed = 7; // simulate credits already spent this cycle + row.monthly_gemini_consumed = 3; + // Pay early / renew while the subscription is still in force. + await extendUserTier({ userId: "u4", tier: "pro", periodDays: 30 }); + const after = await getUserCreditRow("u4"); + assert.equal(after.monthly_consumed, 7, "consumed credits must survive an early renewal"); + assert.equal(after.monthly_gemini_consumed, 3); + }); + + test("resubscribing AFTER a lapse starts a fresh cycle (counter reset)", async () => { + await extendUserTier({ userId: "u5", tier: "pro", periodDays: 30 }); + const row = await getUserCreditRow("u5"); + row.monthly_consumed = 9; + row.subscription_expires_at = new Date(Date.now() - 5 * DAY).toISOString(); // lapsed + await extendUserTier({ userId: "u5", tier: "pro", periodDays: 30 }); + const after = await getUserCreditRow("u5"); + assert.equal(after.monthly_consumed, 0, "a lapsed resubscribe starts a clean cycle"); + }); });