d2caa98248
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.
102 lines
4.6 KiB
JavaScript
102 lines
4.6 KiB
JavaScript
// Phase 1 of self-serve subscriptions: prepaid-period expiry enforcement +
|
|
// the extend-from-current-expiry logic both payment rails will call.
|
|
|
|
import { test, describe, before } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { mkdtempSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
|
|
import { identityTier, isSubscriptionExpired } from "../identity.js";
|
|
import { initCredits, extendUserTier, getUserCreditRow } from "../credits.js";
|
|
|
|
const DAY = 24 * 60 * 60 * 1000;
|
|
|
|
describe("subscription expiry enforcement (pure)", () => {
|
|
test("isSubscriptionExpired: null / future / past / invalid", () => {
|
|
assert.equal(isSubscriptionExpired({}), false);
|
|
assert.equal(isSubscriptionExpired({ subscription_expires_at: null }), false);
|
|
assert.equal(
|
|
isSubscriptionExpired({ subscription_expires_at: new Date(Date.now() + DAY).toISOString() }),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
isSubscriptionExpired({ subscription_expires_at: new Date(Date.now() - DAY).toISOString() }),
|
|
true,
|
|
);
|
|
assert.equal(isSubscriptionExpired({ subscription_expires_at: "not-a-date" }), false);
|
|
});
|
|
|
|
test("identityTier: cloud tier honored until expiry, then Core", () => {
|
|
const cloud = { kind: "cloud" };
|
|
// No expiry = operator comp grant → never expires.
|
|
assert.equal(identityTier(cloud, { tier_snapshot: "max" }), "max");
|
|
// Future expiry → tier honored.
|
|
assert.equal(
|
|
identityTier(cloud, { tier_snapshot: "max", subscription_expires_at: new Date(Date.now() + DAY).toISOString() }),
|
|
"max",
|
|
);
|
|
// Past expiry → effectively Core.
|
|
assert.equal(
|
|
identityTier(cloud, { tier_snapshot: "max", subscription_expires_at: new Date(Date.now() - DAY).toISOString() }),
|
|
"core",
|
|
);
|
|
});
|
|
|
|
test("identityTier: license path is unaffected by expiry", () => {
|
|
assert.equal(identityTier({ kind: "license", license: { tier: "pro" } }, null), "pro");
|
|
});
|
|
});
|
|
|
|
describe("extendUserTier (prepaid periods)", () => {
|
|
before(async () => {
|
|
await initCredits({ dataDir: mkdtempSync(path.join(tmpdir(), "relay-tier-")) });
|
|
});
|
|
|
|
test("first purchase sets the tier + ~periodDays out", async () => {
|
|
const row = await extendUserTier({ userId: "u1", tier: "pro", periodDays: 30 });
|
|
assert.equal(row.tier_snapshot, "pro");
|
|
const days = (new Date(row.subscription_expires_at).getTime() - Date.now()) / DAY;
|
|
assert.ok(days > 29.9 && days < 30.1, `~30 days, got ${days}`);
|
|
});
|
|
|
|
test("early renewal EXTENDS from the current expiry (adds time)", async () => {
|
|
const first = await extendUserTier({ userId: "u2", tier: "max", periodDays: 30 });
|
|
const firstExp = new Date(first.subscription_expires_at).getTime();
|
|
const second = await extendUserTier({ userId: "u2", tier: "max", periodDays: 30 });
|
|
const addedDays = (new Date(second.subscription_expires_at).getTime() - firstExp) / DAY;
|
|
assert.ok(addedDays > 29.9 && addedDays < 30.1, `added ~30 days, got ${addedDays}`);
|
|
});
|
|
|
|
test("renewing AFTER expiry starts fresh from now", async () => {
|
|
const row = await getUserCreditRow("u3");
|
|
row.tier_snapshot = "pro";
|
|
row.subscription_expires_at = new Date(Date.now() - 5 * DAY).toISOString(); // expired 5d ago
|
|
const renewed = await extendUserTier({ userId: "u3", tier: "pro", periodDays: 30 });
|
|
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");
|
|
});
|
|
});
|