| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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 })); |
| } |
|
|
| |
| 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); |
|
|
| |
| |
| 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); |
| |
| 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 }); |
| |
| 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); |
| |
| const anchor = Date.now() - 20 * DAY_MS; |
| await seedSub(t, { updatedAt: anchor }); |
|
|
| |
| await t.mutation(internal.payments.subscriptionEmails.runDunningScan, {}); |
| await t.finishAllScheduledFunctions(vi.runAllTimers); |
| expect(resendSends(fetchMock)).toHaveLength(1); |
|
|
| |
| |
| |
| 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); |
| |
| |
| |
| |
| |
| 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); |
|
|
| |
| |
| 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); |
| |
| expect(new Set(scheduledTimes).size).toBe(N); |
| |
| 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 () => { |
| |
| |
| |
| |
| |
| |
| |
| 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); |
| } |
| |
| |
| 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); |
| |
| |
| |
| |
| const bigBacklogMs = 300 * SEND_SPACING_MS; |
| expect(bigBacklogMs).toBeGreaterThan(60_000); |
| expect(resendPacingWaitMs(now + bigBacklogMs, now)).toBe(bigBacklogMs); |
| |
| 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); |
| |
| |
| |
| 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, |
| }); |
| |
| |
| 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); |
|
|
| |
| |
| |
| 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); |
| }); |
| }); |
|
|