mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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.
This commit is contained in:
282
convex/__tests__/comp-entitlement.test.ts
Normal file
282
convex/__tests__/comp-entitlement.test.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
|
||||
import { v } from "convex/values";
|
||||
import { action, mutation, query, internalAction, internalQuery, type ActionCtx } from "../_generated/server";
|
||||
import { action, mutation, query, internalAction, internalMutation, internalQuery, type ActionCtx } from "../_generated/server";
|
||||
import { internal } from "../_generated/api";
|
||||
import { DodoPayments } from "dodopayments";
|
||||
import { resolveUserId, requireUserId } from "../lib/auth";
|
||||
@@ -387,3 +387,89 @@ export const claimSubscription = mutation({
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Complimentary entitlements (support/goodwill tooling)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Grants a complimentary entitlement to a user.
|
||||
*
|
||||
* Extends both validUntil and compUntil to max(existing, now + days). Never
|
||||
* shrinks — calling twice with small durations won't accidentally shorten an
|
||||
* existing longer comp. compUntil is an independent floor that
|
||||
* handleSubscriptionExpired honours, so Dodo cancellations/expirations don't
|
||||
* wipe the comp before it runs out.
|
||||
*
|
||||
* Typical usage (CLI):
|
||||
* npx convex run 'payments/billing:grantComplimentaryEntitlement' \
|
||||
* '{"userId":"user_XXX","planKey":"pro_monthly","days":90}'
|
||||
*/
|
||||
export const grantComplimentaryEntitlement = internalMutation({
|
||||
args: {
|
||||
userId: v.string(),
|
||||
planKey: v.string(),
|
||||
days: v.number(),
|
||||
reason: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
if (args.days <= 0 || !Number.isFinite(args.days)) {
|
||||
throw new Error(`grantComplimentaryEntitlement: days must be a positive finite number, got ${args.days}`);
|
||||
}
|
||||
if (!PRODUCT_CATALOG[args.planKey]) {
|
||||
throw new Error(
|
||||
`grantComplimentaryEntitlement: unknown planKey "${args.planKey}". Must be in PRODUCT_CATALOG.`,
|
||||
);
|
||||
}
|
||||
const now = Date.now();
|
||||
const until = now + args.days * DAY_MS;
|
||||
const existing = await ctx.db
|
||||
.query("entitlements")
|
||||
.withIndex("by_userId", (q) => q.eq("userId", args.userId))
|
||||
.first();
|
||||
const features = getFeaturesForPlan(args.planKey);
|
||||
const validUntil = Math.max(existing?.validUntil ?? 0, until);
|
||||
const compUntil = Math.max(existing?.compUntil ?? 0, until);
|
||||
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, {
|
||||
planKey: args.planKey,
|
||||
features,
|
||||
validUntil,
|
||||
compUntil,
|
||||
updatedAt: now,
|
||||
});
|
||||
} else {
|
||||
await ctx.db.insert("entitlements", {
|
||||
userId: args.userId,
|
||||
planKey: args.planKey,
|
||||
features,
|
||||
validUntil,
|
||||
compUntil,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[billing] grantComplimentaryEntitlement userId=${args.userId} planKey=${args.planKey} days=${args.days} validUntil=${new Date(validUntil).toISOString()}${args.reason ? ` reason="${args.reason}"` : ""}`,
|
||||
);
|
||||
|
||||
// Sync Redis cache so edge gateway sees the comp without waiting for TTL.
|
||||
if (process.env.UPSTASH_REDIS_REST_URL) {
|
||||
await ctx.scheduler.runAfter(
|
||||
0,
|
||||
internal.payments.cacheActions.syncEntitlementCache,
|
||||
{ userId: args.userId, planKey: args.planKey, features, validUntil },
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
userId: args.userId,
|
||||
planKey: args.planKey,
|
||||
validUntil,
|
||||
compUntil,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -542,6 +542,23 @@ export async function handleSubscriptionExpired(
|
||||
updatedAt: eventTimestamp,
|
||||
});
|
||||
|
||||
// Honour a standing complimentary entitlement before revoking. Support
|
||||
// tooling writes compUntil via grantComplimentaryEntitlement; a Dodo
|
||||
// subscription expiring after a cancellation or refund must not wipe the
|
||||
// goodwill credit before it runs out. If the comp is still valid we
|
||||
// leave the entitlement as-is — compUntil already acts as a floor for
|
||||
// validUntil (grant logic keeps them synced).
|
||||
const entitlement = await ctx.db
|
||||
.query("entitlements")
|
||||
.withIndex("by_userId", (q) => q.eq("userId", existing.userId))
|
||||
.first();
|
||||
if (entitlement?.compUntil && entitlement.compUntil > eventTimestamp) {
|
||||
console.log(
|
||||
`[subscriptionHelpers] subscription.expired for ${existing.userId} — complimentary entitlement still valid until ${new Date(entitlement.compUntil).toISOString()}, preserving`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Revoke entitlements by downgrading to free tier
|
||||
await upsertEntitlements(ctx, existing.userId, "free", eventTimestamp, eventTimestamp);
|
||||
}
|
||||
|
||||
@@ -169,6 +169,10 @@ export default defineSchema({
|
||||
exportFormats: v.array(v.string()),
|
||||
}),
|
||||
validUntil: v.number(),
|
||||
// Optional complimentary-entitlement floor. When set and in the future,
|
||||
// subscription.expired events skip the normal downgrade-to-free so
|
||||
// goodwill credits outlive Dodo subscription cancellations.
|
||||
compUntil: v.optional(v.number()),
|
||||
updatedAt: v.number(),
|
||||
}).index("by_userId", ["userId"]),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user