GenerAI / worldmonitor /convex /__tests__ /dunningEmails.test.ts
amogaddy's picture
Fix build: ripristinate blog-site/tests/e2e/pro-test/convex (referenziate dagli script di build)
56838f4 verified
Raw
History Blame Contribute Delete
26.7 kB
/**
* Dunning + winback email lifecycle (#4932).
*
* Coverage map (every guard has a test because each one, if silently
* removed, emails real customers wrongly):
* - on_hold webhook sets the episode anchor and schedules day-0 exactly
* once (replays keep the anchor, no re-send)
* - send action: per-episode idempotency, suppression skip, recovered-sub
* skip, stale-episode skip, email resolution fallback to customers row
* - daily scan: day-3/day-7 windows, at most ONE step per sub per tick
* (pre-existing holds get a single catch-up email), ledger pre-check
* - winback: 30–60d window, skips while still entitled, skips
* resubscribed users, one-shot
*/
import { convexTest } from "convex-test";
import { afterEach, describe, expect, test, vi } from "vitest";
import schema from "../schema";
import { internal } from "../_generated/api";
import {
DUNNING_DAY3_AGE_MS,
DUNNING_DAY7_AGE_MS,
WINBACK_MIN_AGE_MS,
WINBACK_MAX_AGE_MS,
SEND_SPACING_MS,
resendPacingWaitMs,
} from "../payments/subscriptionEmails";
const modules = import.meta.glob("../**/*.ts");
const DAY_MS = 86_400_000;
const SUB_ID = "sub_dunning_test_1";
const USER_ID = "user_dunning_1";
const EMAIL = "holdout@example.com";
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
delete process.env.RESEND_API_KEY;
});
function mockResend() {
return vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(new Response("{}", { status: 200 }));
}
/** Parse the Resend payloads out of the fetch mock (filters non-Resend calls). */
function resendSends(fetchMock: ReturnType<typeof mockResend>) {
return fetchMock.mock.calls
.filter(([url]) => String(url).includes("api.resend.com"))
.map(([, init]) => JSON.parse(String((init as RequestInit).body)) as {
to: string[];
subject: string;
html: string;
});
}
async function seedSub(
t: ReturnType<typeof convexTest>,
overrides: Partial<{
dodoSubscriptionId: string;
userId: string;
status: "active" | "on_hold" | "cancelled" | "expired";
onHoldAt: number;
cancelledAt: number;
currentPeriodEnd: number;
updatedAt: number;
email: string | null;
planKey: string;
}> = {},
) {
await t.run(async (ctx) => {
await ctx.db.insert("subscriptions", {
userId: overrides.userId ?? USER_ID,
dodoSubscriptionId: overrides.dodoSubscriptionId ?? SUB_ID,
dodoProductId: "pdt_test",
planKey: overrides.planKey ?? "pro_monthly",
status: overrides.status ?? "on_hold",
currentPeriodStart: Date.now() - 20 * DAY_MS,
currentPeriodEnd: overrides.currentPeriodEnd ?? Date.now() + 10 * DAY_MS,
...(overrides.cancelledAt !== undefined ? { cancelledAt: overrides.cancelledAt } : {}),
...(overrides.onHoldAt !== undefined ? { onHoldAt: overrides.onHoldAt } : {}),
rawPayload:
overrides.email === null
? { subscription_id: overrides.dodoSubscriptionId ?? SUB_ID }
: { subscription_id: overrides.dodoSubscriptionId ?? SUB_ID, customer: { email: overrides.email ?? EMAIL } },
updatedAt: overrides.updatedAt ?? Date.now() - 1000,
});
});
}
async function ledgerRows(t: ReturnType<typeof convexTest>) {
return t.run(async (ctx) => ctx.db.query("dunningEmails").collect());
}
describe("on_hold webhook → day-0 email", () => {
test("transition into on_hold sets the anchor and sends day-0 once", async () => {
vi.useFakeTimers();
process.env.RESEND_API_KEY = "re_test";
const fetchMock = mockResend();
const t = convexTest(schema, modules);
await seedSub(t, { status: "active", updatedAt: Date.now() - 5000 });
const eventTs = Date.now();
await t.mutation(internal.payments.webhookMutations.processWebhookEvent, {
webhookId: "wh_hold_1",
eventType: "subscription.on_hold",
rawPayload: { data: { subscription_id: SUB_ID, customer: { email: EMAIL } } },
timestamp: eventTs,
});
await t.finishAllScheduledFunctions(vi.runAllTimers);
const sub = await t.run(async (ctx) =>
ctx.db
.query("subscriptions")
.withIndex("by_dodoSubscriptionId", (q) => q.eq("dodoSubscriptionId", SUB_ID))
.unique(),
);
expect(sub?.status).toBe("on_hold");
expect(sub?.onHoldAt).toBe(eventTs);
const sends = resendSends(fetchMock);
expect(sends).toHaveLength(1);
expect(sends[0]!.to).toEqual([EMAIL]);
expect(sends[0]!.subject).toContain("payment failed");
expect(await ledgerRows(t)).toHaveLength(1);
// Replay: a second on_hold webhook while already on_hold keeps the
// anchor and does NOT re-send day-0 (ledger + enteringHold guard).
await t.mutation(internal.payments.webhookMutations.processWebhookEvent, {
webhookId: "wh_hold_2",
eventType: "subscription.on_hold",
rawPayload: { data: { subscription_id: SUB_ID } },
timestamp: eventTs + 60_000,
});
await t.finishAllScheduledFunctions(vi.runAllTimers);
const sub2 = await t.run(async (ctx) =>
ctx.db
.query("subscriptions")
.withIndex("by_dodoSubscriptionId", (q) => q.eq("dodoSubscriptionId", SUB_ID))
.unique(),
);
expect(sub2?.onHoldAt).toBe(eventTs);
expect(resendSends(fetchMock)).toHaveLength(1);
});
});
describe("sendDunningEmail guards", () => {
test("second invocation for the same step+episode is a no-op", async () => {
vi.useFakeTimers();
process.env.RESEND_API_KEY = "re_test";
const fetchMock = mockResend();
const t = convexTest(schema, modules);
const anchor = Date.now() - 4 * DAY_MS;
await seedSub(t, { onHoldAt: anchor });
const args = { dodoSubscriptionId: SUB_ID, step: "dunning_day3" as const, episodeAt: anchor };
const first = await t.action(internal.payments.subscriptionEmails.sendDunningEmail, args);
expect(first).toEqual({ sent: true });
const second = await t.action(internal.payments.subscriptionEmails.sendDunningEmail, args);
expect(second).toEqual({ sent: false, reason: "already_sent" });
expect(resendSends(fetchMock)).toHaveLength(1);
});
test("suppressed recipient is never emailed", async () => {
vi.useFakeTimers();
process.env.RESEND_API_KEY = "re_test";
const fetchMock = mockResend();
const t = convexTest(schema, modules);
const anchor = Date.now() - 4 * DAY_MS;
await seedSub(t, { onHoldAt: anchor });
await t.run(async (ctx) => {
await ctx.db.insert("emailSuppressions", {
normalizedEmail: EMAIL,
reason: "bounce",
suppressedAt: Date.now(),
});
});
const result = await t.action(internal.payments.subscriptionEmails.sendDunningEmail, {
dodoSubscriptionId: SUB_ID,
step: "dunning_day3",
episodeAt: anchor,
});
expect(result).toEqual({ sent: false, reason: "suppressed" });
expect(resendSends(fetchMock)).toHaveLength(0);
});
test("recovered subscription (active again) skips a scheduled dunning send", async () => {
process.env.RESEND_API_KEY = "re_test";
const fetchMock = mockResend();
const t = convexTest(schema, modules);
const anchor = Date.now() - 4 * DAY_MS;
await seedSub(t, { status: "active", onHoldAt: anchor });
const result = await t.action(internal.payments.subscriptionEmails.sendDunningEmail, {
dodoSubscriptionId: SUB_ID,
step: "dunning_day3",
episodeAt: anchor,
});
expect(result).toEqual({ sent: false, reason: "recovered" });
expect(resendSends(fetchMock)).toHaveLength(0);
});
test("a newer on_hold episode invalidates sends scheduled for the old one", async () => {
process.env.RESEND_API_KEY = "re_test";
const fetchMock = mockResend();
const t = convexTest(schema, modules);
const oldAnchor = Date.now() - 40 * DAY_MS;
const newAnchor = Date.now() - 1 * DAY_MS;
await seedSub(t, { onHoldAt: newAnchor });
const result = await t.action(internal.payments.subscriptionEmails.sendDunningEmail, {
dodoSubscriptionId: SUB_ID,
step: "dunning_day7",
episodeAt: oldAnchor,
});
expect(result).toEqual({ sent: false, reason: "stale_episode" });
expect(resendSends(fetchMock)).toHaveLength(0);
});
test("falls back to the customers row when rawPayload has no email", async () => {
vi.useFakeTimers();
process.env.RESEND_API_KEY = "re_test";
const fetchMock = mockResend();
const t = convexTest(schema, modules);
const anchor = Date.now() - 4 * DAY_MS;
await seedSub(t, { onHoldAt: anchor, email: null });
await t.run(async (ctx) => {
await ctx.db.insert("customers", {
userId: USER_ID,
dodoCustomerId: "cus_test",
email: "fallback@example.com",
createdAt: Date.now(),
updatedAt: Date.now(),
});
});
const result = await t.action(internal.payments.subscriptionEmails.sendDunningEmail, {
dodoSubscriptionId: SUB_ID,
step: "dunning_day3",
episodeAt: anchor,
});
expect(result).toEqual({ sent: true });
expect(resendSends(fetchMock)[0]!.to).toEqual(["fallback@example.com"]);
});
});
describe("runDunningScan windows", () => {
test("4-day-old hold gets day-3 only; scan is idempotent across ticks", async () => {
vi.useFakeTimers();
process.env.RESEND_API_KEY = "re_test";
const fetchMock = mockResend();
const t = convexTest(schema, modules);
await seedSub(t, { onHoldAt: Date.now() - (DUNNING_DAY3_AGE_MS + DAY_MS) });
const summary = await t.mutation(internal.payments.subscriptionEmails.runDunningScan, {});
expect(summary.scheduled).toBe(1);
await t.finishAllScheduledFunctions(vi.runAllTimers);
const sends = resendSends(fetchMock);
expect(sends).toHaveLength(1);
expect(sends[0]!.subject).toContain("Reminder");
const again = await t.mutation(internal.payments.subscriptionEmails.runDunningScan, {});
expect(again.scheduled).toBe(0);
await t.finishAllScheduledFunctions(vi.runAllTimers);
expect(resendSends(fetchMock)).toHaveLength(1);
});
test("pre-existing 20-day hold (no onHoldAt) gets ONE catch-up email — the final notice", async () => {
vi.useFakeTimers();
process.env.RESEND_API_KEY = "re_test";
const fetchMock = mockResend();
const t = convexTest(schema, modules);
// Pre-deploy row shape: on_hold with no onHoldAt; updatedAt is the anchor.
await seedSub(t, { updatedAt: Date.now() - 20 * DAY_MS });
await t.mutation(internal.payments.subscriptionEmails.runDunningScan, {});
await t.finishAllScheduledFunctions(vi.runAllTimers);
const sends = resendSends(fetchMock);
expect(sends).toHaveLength(1);
expect(sends[0]!.subject).toContain("Final notice");
});
test("day-7 fires after day-3 was already sent for the same episode", async () => {
vi.useFakeTimers();
process.env.RESEND_API_KEY = "re_test";
const fetchMock = mockResend();
const t = convexTest(schema, modules);
const anchor = Date.now() - (DUNNING_DAY7_AGE_MS + DAY_MS);
await seedSub(t, { onHoldAt: anchor });
// Simulate the day-3 send having happened earlier in this episode.
await t.run(async (ctx) => {
await ctx.db.insert("dunningEmails", {
dodoSubscriptionId: SUB_ID,
step: "dunning_day3",
episodeAt: anchor,
email: EMAIL,
sentAt: anchor + DUNNING_DAY3_AGE_MS,
});
});
await t.mutation(internal.payments.subscriptionEmails.runDunningScan, {});
await t.finishAllScheduledFunctions(vi.runAllTimers);
const sends = resendSends(fetchMock);
expect(sends).toHaveLength(1);
expect(sends[0]!.subject).toContain("Final notice");
expect(await ledgerRows(t)).toHaveLength(2);
});
test("repeat on_hold webhook does NOT re-open a finished pre-existing episode (PR #4935 F1)", async () => {
vi.useFakeTimers();
process.env.RESEND_API_KEY = "re_test";
const fetchMock = mockResend();
const t = convexTest(schema, modules);
// Pre-#4932 row: on_hold for 20 days, no onHoldAt — updatedAt is the anchor.
const anchor = Date.now() - 20 * DAY_MS;
await seedSub(t, { updatedAt: anchor });
// Catch-up scan sends the final notice for episode `anchor`.
await t.mutation(internal.payments.subscriptionEmails.runDunningScan, {});
await t.finishAllScheduledFunctions(vi.runAllTimers);
expect(resendSends(fetchMock)).toHaveLength(1);
// A Dodo payment-retry failure fires another on_hold webhook. The anchor
// must FREEZE at the pre-patch updatedAt — falling back to the event
// timestamp would restart the 3/7-day clock and re-send the sequence.
await t.mutation(internal.payments.webhookMutations.processWebhookEvent, {
webhookId: "wh_repeat_hold",
eventType: "subscription.on_hold",
rawPayload: { data: { subscription_id: SUB_ID } },
timestamp: Date.now(),
});
await t.finishAllScheduledFunctions(vi.runAllTimers);
const sub = await t.run(async (ctx) =>
ctx.db
.query("subscriptions")
.withIndex("by_dodoSubscriptionId", (q) => q.eq("dodoSubscriptionId", SUB_ID))
.unique(),
);
expect(sub?.onHoldAt).toBe(anchor);
await t.mutation(internal.payments.subscriptionEmails.runDunningScan, {});
await t.finishAllScheduledFunctions(vi.runAllTimers);
expect(resendSends(fetchMock)).toHaveLength(1);
});
test("fresh hold (1 day old) is not emailed by the scan", async () => {
vi.useFakeTimers();
process.env.RESEND_API_KEY = "re_test";
const fetchMock = mockResend();
const t = convexTest(schema, modules);
await seedSub(t, { onHoldAt: Date.now() - DAY_MS });
const summary = await t.mutation(internal.payments.subscriptionEmails.runDunningScan, {});
expect(summary.scheduled).toBe(0);
expect(resendSends(fetchMock)).toHaveLength(0);
});
test("batch scan staggers send START times (first pacing layer, WORLDMONITOR-VH)", async () => {
vi.useFakeTimers();
process.env.RESEND_API_KEY = "re_test";
const t = convexTest(schema, modules);
// 12 distinct subs all past the day-7 window → 12 due sends in one tick.
// Pre-fix every send was scheduled at runAfter(0), bursting concurrently and
// tripping Resend's 10 req/s limit (the 11th+ threw a 429 out of sendEmail).
// Start-staggering spreads the Dodo portal load and the initial Resend load;
// the hard POST-rate guarantee is reserveResendSlot (see the pacing test).
const anchor = Date.now() - (DUNNING_DAY7_AGE_MS + DAY_MS);
const N = 12;
for (let i = 0; i < N; i++) {
await seedSub(t, {
dodoSubscriptionId: `sub_burst_${i}`,
userId: `user_burst_${i}`,
email: `burst${i}@example.com`,
onHoldAt: anchor,
});
}
const summary = await t.mutation(internal.payments.subscriptionEmails.runDunningScan, {});
expect(summary.scheduled).toBe(N);
// Pending scheduled sends: their scheduledTime must be staggered by
// SEND_SPACING_MS, not all identical (the burst bug).
const scheduledTimes = await t.run(async (ctx) => {
const jobs = await ctx.db.system.query("_scheduled_functions").collect();
return jobs
.filter((j) => j.name.endsWith("sendDunningEmail"))
.map((j) => j.scheduledTime)
.sort((a, b) => a - b);
});
expect(scheduledTimes).toHaveLength(N);
// All distinct — pre-fix every send fired at the same instant (Set size 1).
expect(new Set(scheduledTimes).size).toBe(N);
// Consecutive starts are exactly SEND_SPACING_MS apart (≤4/s, under 10/s).
for (let i = 1; i < scheduledTimes.length; i++) {
expect(scheduledTimes[i]! - scheduledTimes[i - 1]!).toBe(SEND_SPACING_MS);
}
});
test("reserveResendSlot paces actual POSTs >= SEND_SPACING_MS apart, portal-latency-independent (WORLDMONITOR-VH)", async () => {
// Staggering only spaces send START times; the real Resend POST happens
// after a variable-latency Dodo portal mint, so start-spacing alone can't
// bound the POST rate. reserveResendSlot is the hard guarantee: it hands out
// the instant each send may POST. Asserting those instants are monotonic and
// exactly SEND_SPACING_MS apart proves the POST rate stays <= 1/SEND_SPACING_MS
// regardless of portal jitter — no timers/portal mocking needed because the
// reserved slot IS the POST schedule.
vi.useFakeTimers();
const t = convexTest(schema, modules);
const N = 12;
const slots: number[] = [];
for (let i = 0; i < N; i++) {
slots.push(await t.mutation(internal.payments.subscriptionEmails.reserveResendSlot, {}));
}
for (let i = 1; i < N; i++) {
expect(slots[i]! - slots[i - 1]!).toBe(SEND_SPACING_MS);
}
// Idle reset: once the reserved window fully elapses, the next slot floors at
// `now` (no wait toward a stale future cursor), not lastSlot + spacing.
vi.advanceTimersByTime(N * SEND_SPACING_MS + 10_000);
const afterIdle = await t.mutation(internal.payments.subscriptionEmails.reserveResendSlot, {});
expect(afterIdle).toBe(Date.now());
});
test("resendPacingWaitMs never clamps a large backlog into a burst (WORLDMONITOR-VH re-review P2)", () => {
const now = 1_000_000_000_000;
expect(resendPacingWaitMs(now, now)).toBe(0);
expect(resendPacingWaitMs(now + SEND_SPACING_MS, now)).toBe(SEND_SPACING_MS);
// A backlog past ~240 reservations reserves a slot >60s out. A fixed 60s cap
// (the earlier bug) would flatten every such tail send to a 60s wait so they
// woke together and re-burst; the wait must equal the FULL distance to the
// slot. 300 * 250ms = 75s, well past the removed ceiling.
const bigBacklogMs = 300 * SEND_SPACING_MS;
expect(bigBacklogMs).toBeGreaterThan(60_000);
expect(resendPacingWaitMs(now + bigBacklogMs, now)).toBe(bigBacklogMs);
// Slot already elapsed (clock moved past it): no negative/oversized wait.
expect(resendPacingWaitMs(now - 5_000, now)).toBe(0);
});
});
describe("winback", () => {
test("access ended 35 days ago gets exactly one winback", async () => {
vi.useFakeTimers();
process.env.RESEND_API_KEY = "re_test";
const fetchMock = mockResend();
const t = convexTest(schema, modules);
await seedSub(t, {
status: "cancelled",
cancelledAt: Date.now() - 40 * DAY_MS,
currentPeriodEnd: Date.now() - (WINBACK_MIN_AGE_MS + 5 * DAY_MS),
});
await t.mutation(internal.payments.subscriptionEmails.runDunningScan, {});
await t.finishAllScheduledFunctions(vi.runAllTimers);
const sends = resendSends(fetchMock);
expect(sends).toHaveLength(1);
expect(sends[0]!.subject).toContain("access has ended");
const again = await t.mutation(internal.payments.subscriptionEmails.runDunningScan, {});
expect(again.scheduled).toBe(0);
});
test("annual who cancelled months early gets the winback once access lapses (round-2 F3)", async () => {
vi.useFakeTimers();
process.env.RESEND_API_KEY = "re_test";
const fetchMock = mockResend();
const t = convexTest(schema, modules);
// Cancelled 8 months ago, but the paid annual period only lapsed 32 days
// ago. A cancelledAt-keyed window would NEVER select this row (paid-
// through inside it, outside it afterwards); the access-end window must.
await seedSub(t, {
status: "cancelled",
cancelledAt: Date.now() - 240 * DAY_MS,
currentPeriodEnd: Date.now() - 32 * DAY_MS,
});
await t.mutation(internal.payments.subscriptionEmails.runDunningScan, {});
await t.finishAllScheduledFunctions(vi.runAllTimers);
const sends = resendSends(fetchMock);
expect(sends).toHaveLength(1);
expect(sends[0]!.subject).toContain("access has ended");
});
test("pending winback for a superseded cancellation episode is dropped (round-2 F1)", async () => {
process.env.RESEND_API_KEY = "re_test";
const fetchMock = mockResend();
const t = convexTest(schema, modules);
const currentEpisode = Date.now() - 40 * DAY_MS;
const staleEpisode = Date.now() - 100 * DAY_MS;
await seedSub(t, {
status: "cancelled",
cancelledAt: currentEpisode,
currentPeriodEnd: Date.now() - 35 * DAY_MS,
});
const result = await t.action(internal.payments.subscriptionEmails.sendDunningEmail, {
dodoSubscriptionId: SUB_ID,
step: "winback_day30",
episodeAt: staleEpisode,
});
expect(result).toEqual({ sent: false, reason: "stale_episode" });
expect(resendSends(fetchMock)).toHaveLength(0);
});
test("cancelled-but-paid-through sibling still counts as covered (round-2 F2)", async () => {
process.env.RESEND_API_KEY = "re_test";
const fetchMock = mockResend();
const t = convexTest(schema, modules);
const cancelledAt = Date.now() - 40 * DAY_MS;
await seedSub(t, {
status: "cancelled",
cancelledAt,
currentPeriodEnd: Date.now() - 35 * DAY_MS,
});
// Sibling annual: cancelled, but paid through for months — the user is
// still entitled, so "your access has ended" must not go out.
await seedSub(t, {
dodoSubscriptionId: "sub_annual_paid_through",
status: "cancelled",
cancelledAt: Date.now() - 10 * DAY_MS,
currentPeriodEnd: Date.now() + 200 * DAY_MS,
});
const result = await t.action(internal.payments.subscriptionEmails.sendDunningEmail, {
dodoSubscriptionId: SUB_ID,
step: "winback_day30",
episodeAt: cancelledAt,
});
expect(result).toEqual({ sent: false, reason: "resubscribed" });
expect(resendSends(fetchMock)).toHaveLength(0);
});
test("still-entitled cancellation (annual paid through) is not winback-emailed", async () => {
vi.useFakeTimers();
process.env.RESEND_API_KEY = "re_test";
const fetchMock = mockResend();
const t = convexTest(schema, modules);
await seedSub(t, {
status: "cancelled",
cancelledAt: Date.now() - (WINBACK_MIN_AGE_MS + 5 * DAY_MS),
currentPeriodEnd: Date.now() + 200 * DAY_MS,
});
const summary = await t.mutation(internal.payments.subscriptionEmails.runDunningScan, {});
expect(summary.scheduled).toBe(0);
expect(resendSends(fetchMock)).toHaveLength(0);
});
test("historic cancellation outside the 60-day window is never mass-mailed", async () => {
vi.useFakeTimers();
process.env.RESEND_API_KEY = "re_test";
const fetchMock = mockResend();
const t = convexTest(schema, modules);
await seedSub(t, {
status: "cancelled",
cancelledAt: Date.now() - (WINBACK_MAX_AGE_MS + 30 * DAY_MS),
currentPeriodEnd: Date.now() - 90 * DAY_MS,
});
const summary = await t.mutation(internal.payments.subscriptionEmails.runDunningScan, {});
expect(summary.scheduled).toBe(0);
expect(resendSends(fetchMock)).toHaveLength(0);
});
test("repeat cancelled-flavored subscription.updated does NOT re-open the winback (round-4 F1)", async () => {
vi.useFakeTimers();
process.env.RESEND_API_KEY = "re_test";
const fetchMock = mockResend();
const t = convexTest(schema, modules);
const anchor = Date.now() - 40 * DAY_MS;
await seedSub(t, {
status: "cancelled",
cancelledAt: anchor,
currentPeriodEnd: Date.now() - 35 * DAY_MS,
updatedAt: anchor,
});
await t.mutation(internal.payments.subscriptionEmails.runDunningScan, {});
await t.finishAllScheduledFunctions(vi.runAllTimers);
expect(resendSends(fetchMock)).toHaveLength(1);
// Dodo re-sends a cancellation-flavored subscription.updated with NO
// cancelled_at. The anchor must freeze — rewriting it to the event
// timestamp re-keys the winback ledger and the one-shot fires again.
await t.mutation(internal.payments.webhookMutations.processWebhookEvent, {
webhookId: "wh_repeat_cancel",
eventType: "subscription.updated",
rawPayload: { data: { subscription_id: SUB_ID, status: "cancelled" } },
timestamp: Date.now(),
});
await t.finishAllScheduledFunctions(vi.runAllTimers);
const sub = await t.run(async (ctx) =>
ctx.db
.query("subscriptions")
.withIndex("by_dodoSubscriptionId", (q) => q.eq("dodoSubscriptionId", SUB_ID))
.unique(),
);
expect(sub?.cancelledAt).toBe(anchor);
await t.mutation(internal.payments.subscriptionEmails.runDunningScan, {});
await t.finishAllScheduledFunctions(vi.runAllTimers);
expect(resendSends(fetchMock)).toHaveLength(1);
});
test("comped user (compUntil in the future) is not winback-emailed (round-4 F5)", async () => {
vi.useFakeTimers();
process.env.RESEND_API_KEY = "re_test";
const fetchMock = mockResend();
const t = convexTest(schema, modules);
const cancelledAt = Date.now() - 40 * DAY_MS;
await seedSub(t, {
status: "cancelled",
cancelledAt,
currentPeriodEnd: Date.now() - 35 * 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"],
mcpAccess: true,
},
validUntil: Date.now() - 35 * DAY_MS,
compUntil: Date.now() + 30 * DAY_MS,
updatedAt: Date.now() - 35 * DAY_MS,
});
});
const result = await t.action(internal.payments.subscriptionEmails.sendDunningEmail, {
dodoSubscriptionId: SUB_ID,
step: "winback_day30",
episodeAt: cancelledAt,
});
expect(result).toEqual({ sent: false, reason: "still_entitled" });
expect(resendSends(fetchMock)).toHaveLength(0);
});
test("resubscribed user (live sibling sub) is not winback-emailed", async () => {
vi.useFakeTimers();
process.env.RESEND_API_KEY = "re_test";
const fetchMock = mockResend();
const t = convexTest(schema, modules);
const cancelledAt = Date.now() - (WINBACK_MIN_AGE_MS + 5 * DAY_MS);
await seedSub(t, {
status: "cancelled",
cancelledAt,
currentPeriodEnd: Date.now() - 5 * DAY_MS,
});
await seedSub(t, {
dodoSubscriptionId: "sub_new_life",
status: "active",
currentPeriodEnd: Date.now() + 20 * DAY_MS,
});
const result = await t.action(internal.payments.subscriptionEmails.sendDunningEmail, {
dodoSubscriptionId: SUB_ID,
step: "winback_day30",
episodeAt: cancelledAt,
});
expect(result).toEqual({ sent: false, reason: "resubscribed" });
expect(resendSends(fetchMock)).toHaveLength(0);
});
});