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.
This commit is contained in:
Keysat
2026-06-13 16:23:26 -05:00
parent 8ad7c54da4
commit d2caa98248
2 changed files with 49 additions and 11 deletions
+23 -7
View File
@@ -741,21 +741,26 @@ export async function addPurchasedCredits({
// stored on the user's credit row (keyed `user:<id>`). Set by the // stored on the user's credit row (keyed `user:<id>`). Set by the
// operator (today) and the self-serve purchase flow (later slice). // operator (today) and the self-serve purchase flow (later slice).
// Operator-set a cloud user's tier. Resets the monthly counters and // Operator-set a cloud user's tier. With `resetCycle` (the default) it
// anchors the renewal to now (so the monthly cycle starts on the grant // starts a fresh monthly cycle anchored to now — so an operator comp
// date), mirroring applyTierPromotion. `expiresAt` is stored for // grant, or a first/lapsed self-serve purchase, begins its allowance on
// reporting / future self-serve billing but NOT auto-enforced in this // the grant date (mirroring applyTierPromotion). A renewal of an
// slice — to revoke, set tier back to "core". // in-force subscription passes `resetCycle: false` so it extends the
export async function setUserTier({ userId, tier, expiresAt = null }) { // 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"); if (!userId) throw new Error("setUserTier: userId required");
const t = tier === "pro" || tier === "max" ? tier : "core"; const t = tier === "pro" || tier === "max" ? tier : "core";
const row = await getOrCreateRow({ creditKey: `user:${userId}` }); const row = await getOrCreateRow({ creditKey: `user:${userId}` });
const now = new Date(); const now = new Date();
row.tier_snapshot = t; row.tier_snapshot = t;
if (resetCycle) {
row.monthly_consumed = 0; row.monthly_consumed = 0;
row.monthly_gemini_consumed = 0; row.monthly_gemini_consumed = 0;
row.last_renewal_at = now.toISOString(); row.last_renewal_at = now.toISOString();
row.anniversary_day = now.getUTCDate(); row.anniversary_day = now.getUTCDate();
}
row.subscription_expires_at = expiresAt || null; row.subscription_expires_at = expiresAt || null;
row.last_active_at = now.toISOString(); row.last_active_at = now.toISOString();
await persist(); 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 // (still-active) expiry — so paying early ADDS time rather than resetting
// it. `periodDays` defaults to 30. Both payment rails (BTCPay + Zaprite) // it. `periodDays` defaults to 30. Both payment rails (BTCPay + Zaprite)
// land here on a settled payment. Returns the updated row. // 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 }) { export async function extendUserTier({ userId, tier, periodDays = 30 }) {
if (!userId) throw new Error("extendUserTier: userId required"); if (!userId) throw new Error("extendUserTier: userId required");
const t = tier === "pro" || tier === "max" ? tier : "core"; 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 const curExp = row.subscription_expires_at
? new Date(row.subscription_expires_at).getTime() ? new Date(row.subscription_expires_at).getTime()
: 0; : 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 base = Math.max(now, Number.isFinite(curExp) ? curExp : 0);
const expiresAt = new Date( const expiresAt = new Date(
base + periodDays * 24 * 60 * 60 * 1000, base + periodDays * 24 * 60 * 60 * 1000,
).toISOString(); ).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). // Read a cloud user's credit row (creates a blank Core row if none yet).
+22
View File
@@ -76,4 +76,26 @@ describe("extendUserTier (prepaid periods)", () => {
const days = (new Date(renewed.subscription_expires_at).getTime() - Date.now()) / DAY; 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}`); 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");
});
}); });