Files
worldmonitor/convex/__tests__/comp-entitlement.test.ts
Elie Habib ba731b0ede feat(pro): complimentary entitlement tooling + subscription.expired guard
Adds support / goodwill tooling for granting free-tier credits that
survive Dodo subscription cancellations. Triggered by the 2026-04-17/18
duplicate-subscription incident: the customer was granted a manual
extension to validUntil, but that extension is naked — our existing
handleSubscriptionExpired handler unconditionally downgrades to free
when Dodo fires the expired event, which would wipe the credit.

Three coordinated changes:

1. convex/schema.ts — add optional compUntil: number to entitlements.
   Acts as a "don't downgrade me before this" floor independent of the
   subscription billing cycle. Optional, so existing rows are untouched.

2. convex/payments/billing.ts::grantComplimentaryEntitlement —
   new internalMutation callable via `npx convex run`. Upserts the
   entitlement, sets both validUntil and compUntil to max(existing, now +
   days). Never shrinks (calling twice is idempotent upward), validates
   planKey against PRODUCT_CATALOG, and syncs the Redis cache so edge
   gateway sees the comp without waiting for TTL.

3. convex/payments/subscriptionHelpers.ts::handleSubscriptionExpired —
   before the unconditional downgrade, read the current entitlement and
   skip revocation if compUntil > eventTimestamp. This protects comp
   grants from Dodo-originated revocations; normal subscription.expired
   revocation is unchanged when there's no comp or the comp has lapsed.

Tests (convex/__tests__/comp-entitlement.test.ts, 9 new):

  grantComplimentaryEntitlement
    creates row with validUntil == compUntil
    never shrinks an existing longer comp
    upgrades planKey on existing free-tier row
    rejects unknown planKey and non-positive days

  handleSubscriptionExpired comp guard
    revokes to free when no comp set (unchanged)
    revokes to free when comp is already expired
    preserves entitlement when comp is still valid
    end-to-end: grant + expired webhook = entitlement survives

CLI usage (requires npx convex deploy after merge, then):

  npx convex run 'payments/billing:grantComplimentaryEntitlement' \
    '{"userId":"user_XXX","planKey":"pro_monthly","days":90,"reason":"support"}'

Full check suite green: typecheck x2, biome, 5590 data tests,
171 edge tests, 9 convex tests, md lint, version sync.
2026-04-18 16:01:33 +04:00

283 lines
9.1 KiB
TypeScript

import { convexTest } from "convex-test";
import { expect, test, describe } from "vitest";
import schema from "../schema";
import { internal } from "../_generated/api";
const modules = import.meta.glob("../**/*.ts");
const USER_ID = "user_comp_test_001";
const DAY_MS = 24 * 60 * 60 * 1000;
async function readEntitlement(t: ReturnType<typeof convexTest>) {
return t.run(async (ctx) => {
return ctx.db
.query("entitlements")
.withIndex("by_userId", (q) => q.eq("userId", USER_ID))
.first();
});
}
// ---------------------------------------------------------------------------
// grantComplimentaryEntitlement
// ---------------------------------------------------------------------------
describe("grantComplimentaryEntitlement", () => {
test("creates a new entitlement with matching validUntil + compUntil", async () => {
const t = convexTest(schema, modules);
const before = Date.now();
const result = await t.mutation(
internal.payments.billing.grantComplimentaryEntitlement,
{ userId: USER_ID, planKey: "pro_monthly", days: 90 },
);
const after = Date.now();
// Grant returns validUntil and compUntil roughly now+90d.
const expectedUntilMin = before + 90 * DAY_MS;
const expectedUntilMax = after + 90 * DAY_MS;
expect(result.validUntil).toBeGreaterThanOrEqual(expectedUntilMin);
expect(result.validUntil).toBeLessThanOrEqual(expectedUntilMax);
expect(result.compUntil).toBe(result.validUntil);
const row = await readEntitlement(t);
expect(row).not.toBeNull();
expect(row!.planKey).toBe("pro_monthly");
expect(row!.features.tier).toBe(1);
expect(row!.validUntil).toBe(result.validUntil);
expect(row!.compUntil).toBe(result.compUntil);
});
test("extends an existing entitlement (never shrinks)", async () => {
const t = convexTest(schema, modules);
// Seed a long-lived entitlement manually.
const longUntil = Date.now() + 365 * DAY_MS;
await t.run(async (ctx) => {
await ctx.db.insert("entitlements", {
userId: USER_ID,
planKey: "pro_monthly",
features: {
tier: 1,
maxDashboards: 10,
apiAccess: false,
apiRateLimit: 0,
prioritySupport: false,
exportFormats: ["csv", "pdf"],
},
validUntil: longUntil,
compUntil: longUntil,
updatedAt: Date.now(),
});
});
// Granting 30 days must NOT shrink the 365-day comp.
const result = await t.mutation(
internal.payments.billing.grantComplimentaryEntitlement,
{ userId: USER_ID, planKey: "pro_monthly", days: 30 },
);
expect(result.validUntil).toBe(longUntil);
expect(result.compUntil).toBe(longUntil);
});
test("upgrades planKey to the requested tier on existing row", async () => {
const t = convexTest(schema, modules);
await t.run(async (ctx) => {
await ctx.db.insert("entitlements", {
userId: USER_ID,
planKey: "free",
features: {
tier: 0,
maxDashboards: 3,
apiAccess: false,
apiRateLimit: 0,
prioritySupport: false,
exportFormats: ["csv"],
},
validUntil: 0,
updatedAt: Date.now(),
});
});
await t.mutation(
internal.payments.billing.grantComplimentaryEntitlement,
{ userId: USER_ID, planKey: "pro_monthly", days: 7 },
);
const row = await readEntitlement(t);
expect(row!.planKey).toBe("pro_monthly");
expect(row!.features.tier).toBe(1);
});
test("rejects unknown planKey", async () => {
const t = convexTest(schema, modules);
await expect(
t.mutation(internal.payments.billing.grantComplimentaryEntitlement, {
userId: USER_ID,
planKey: "unicorn_tier",
days: 30,
}),
).rejects.toThrow(/unknown planKey/i);
});
test("rejects non-positive days", async () => {
const t = convexTest(schema, modules);
await expect(
t.mutation(internal.payments.billing.grantComplimentaryEntitlement, {
userId: USER_ID,
planKey: "pro_monthly",
days: 0,
}),
).rejects.toThrow(/positive finite/i);
await expect(
t.mutation(internal.payments.billing.grantComplimentaryEntitlement, {
userId: USER_ID,
planKey: "pro_monthly",
days: -5,
}),
).rejects.toThrow(/positive finite/i);
});
});
// ---------------------------------------------------------------------------
// handleSubscriptionExpired guard
// ---------------------------------------------------------------------------
async function seedSubAndEntitlement(
t: ReturnType<typeof convexTest>,
opts: {
subscriptionId: string;
compUntil?: number;
entitlementValidUntil: number;
},
) {
await t.run(async (ctx) => {
await ctx.db.insert("subscriptions", {
userId: USER_ID,
dodoSubscriptionId: opts.subscriptionId,
dodoProductId: "pdt_0Nbtt71uObulf7fGXhQup",
planKey: "pro_monthly",
status: "active",
currentPeriodStart: Date.now() - 30 * DAY_MS,
currentPeriodEnd: Date.now() + 1 * DAY_MS,
rawPayload: {},
updatedAt: Date.now() - 1000,
});
await ctx.db.insert("entitlements", {
userId: USER_ID,
planKey: "pro_monthly",
features: {
tier: 1,
maxDashboards: 10,
apiAccess: false,
apiRateLimit: 0,
prioritySupport: false,
exportFormats: ["csv", "pdf"],
},
validUntil: opts.entitlementValidUntil,
...(opts.compUntil !== undefined ? { compUntil: opts.compUntil } : {}),
updatedAt: Date.now() - 1000,
});
});
}
async function fireSubscriptionExpired(
t: ReturnType<typeof convexTest>,
subscriptionId: string,
) {
await t.mutation(
internal.payments.webhookMutations.processWebhookEvent,
{
webhookId: `msg_test_${subscriptionId}_expired`,
eventType: "subscription.expired",
rawPayload: {
type: "subscription.expired",
data: {
subscription_id: subscriptionId,
product_id: "pdt_0Nbtt71uObulf7fGXhQup",
customer: { customer_id: "cus_test" },
metadata: { wm_user_id: USER_ID },
previous_billing_date: new Date(Date.now() - 30 * DAY_MS).toISOString(),
next_billing_date: new Date(Date.now() + DAY_MS).toISOString(),
},
},
timestamp: Date.now(),
},
);
}
describe("handleSubscriptionExpired comp guard", () => {
test("revokes to free when no comp is set (original behavior)", async () => {
const t = convexTest(schema, modules);
await seedSubAndEntitlement(t, {
subscriptionId: "sub_no_comp",
entitlementValidUntil: Date.now() + DAY_MS,
// no compUntil
});
await fireSubscriptionExpired(t, "sub_no_comp");
const row = await readEntitlement(t);
expect(row!.planKey).toBe("free");
expect(row!.features.tier).toBe(0);
});
test("revokes to free when compUntil is already in the past", async () => {
const t = convexTest(schema, modules);
await seedSubAndEntitlement(t, {
subscriptionId: "sub_stale_comp",
entitlementValidUntil: Date.now() + DAY_MS,
compUntil: Date.now() - DAY_MS, // expired comp
});
await fireSubscriptionExpired(t, "sub_stale_comp");
const row = await readEntitlement(t);
expect(row!.planKey).toBe("free");
});
test("preserves the entitlement when compUntil is still in the future", async () => {
const t = convexTest(schema, modules);
const futureCompUntil = Date.now() + 60 * DAY_MS;
await seedSubAndEntitlement(t, {
subscriptionId: "sub_with_comp",
entitlementValidUntil: futureCompUntil,
compUntil: futureCompUntil,
});
await fireSubscriptionExpired(t, "sub_with_comp");
const row = await readEntitlement(t);
// Entitlement must stay pro_monthly; validUntil and compUntil untouched.
expect(row!.planKey).toBe("pro_monthly");
expect(row!.features.tier).toBe(1);
expect(row!.compUntil).toBe(futureCompUntil);
expect(row!.validUntil).toBe(futureCompUntil);
});
test("grantComplimentaryEntitlement + subscription.expired end-to-end: entitlement survives", async () => {
const t = convexTest(schema, modules);
// Seed subscription without touching entitlement yet.
await t.run(async (ctx) => {
await ctx.db.insert("subscriptions", {
userId: USER_ID,
dodoSubscriptionId: "sub_e2e",
dodoProductId: "pdt_0Nbtt71uObulf7fGXhQup",
planKey: "pro_monthly",
status: "active",
currentPeriodStart: Date.now() - 30 * DAY_MS,
currentPeriodEnd: Date.now() + DAY_MS,
rawPayload: {},
updatedAt: Date.now() - 1000,
});
});
// Grant comp via the new mutation.
await t.mutation(
internal.payments.billing.grantComplimentaryEntitlement,
{ userId: USER_ID, planKey: "pro_monthly", days: 90, reason: "support credit" },
);
// Now Dodo fires expired on the sub.
await fireSubscriptionExpired(t, "sub_e2e");
const row = await readEntitlement(t);
expect(row!.planKey).toBe("pro_monthly");
expect(row!.features.tier).toBe(1);
expect(row!.compUntil).toBeGreaterThan(Date.now() + 80 * DAY_MS);
});
});