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:
+23
-7
@@ -741,21 +741,26 @@ export async function addPurchasedCredits({
|
||||
// stored on the user's credit row (keyed `user:<id>`). 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;
|
||||
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).
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user