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"); + }); });