| import { convexTest } from "convex-test"; |
| import { expect, test, describe } from "vitest"; |
| import schema from "../schema"; |
| import { api, internal } from "../_generated/api"; |
| import { PRODUCT_CATALOG } from "../config/productCatalog"; |
|
|
| const modules = import.meta.glob("../**/*.ts"); |
|
|
| |
| |
| |
|
|
| const BASE_TIMESTAMP = new Date("2026-03-21T10:00:00Z").getTime(); |
| const TEST_USER_ID = "user_checkout_test_001"; |
| const TEST_CUSTOMER_ID = "cust_checkout_e2e"; |
|
|
| |
| |
| |
| async function seedAndListPlans(t: ReturnType<typeof convexTest>) { |
| await t.mutation(internal.payments.seedProductPlans.seedProductPlans, {}); |
| return t.query(api.payments.seedProductPlans.listProductPlans, {}); |
| } |
|
|
| |
| |
| |
| |
| |
| async function seedCustomer(t: ReturnType<typeof convexTest>) { |
| await t.run(async (ctx) => { |
| await ctx.db.insert("customers", { |
| userId: TEST_USER_ID, |
| dodoCustomerId: TEST_CUSTOMER_ID, |
| email: "test@example.com", |
| createdAt: BASE_TIMESTAMP, |
| updatedAt: BASE_TIMESTAMP, |
| }); |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| async function simulateSubscriptionWebhook( |
| t: ReturnType<typeof convexTest>, |
| opts: { |
| webhookId: string; |
| subscriptionId: string; |
| productId: string; |
| customerId?: string; |
| previousBillingDate?: string; |
| nextBillingDate?: string; |
| timestamp?: number; |
| }, |
| ) { |
| await t.mutation( |
| internal.payments.webhookMutations.processWebhookEvent, |
| { |
| webhookId: opts.webhookId, |
| eventType: "subscription.active", |
| rawPayload: { |
| type: "subscription.active", |
| data: { |
| subscription_id: opts.subscriptionId, |
| product_id: opts.productId, |
| customer: { |
| customer_id: opts.customerId ?? TEST_CUSTOMER_ID, |
| }, |
| previous_billing_date: |
| opts.previousBillingDate ?? new Date().toISOString(), |
| next_billing_date: |
| opts.nextBillingDate ?? |
| new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), |
| metadata: {}, |
| }, |
| }, |
| timestamp: opts.timestamp ?? BASE_TIMESTAMP, |
| }, |
| ); |
| } |
|
|
| |
| |
| |
|
|
| describe("E2E checkout-to-entitlement contract", () => { |
| test("product plans can be seeded and queried", async () => { |
| const t = convexTest(schema, modules); |
|
|
| const plans = await seedAndListPlans(t); |
|
|
| |
| expect(plans.length).toBeGreaterThanOrEqual(5); |
|
|
| |
| const proMonthly = plans.find((p) => p.planKey === "pro_monthly"); |
| expect(proMonthly).toBeDefined(); |
| expect(proMonthly!.displayName).toBe("Pro Monthly"); |
|
|
| const proAnnual = plans.find((p) => p.planKey === "pro_annual"); |
| expect(proAnnual).toBeDefined(); |
| expect(proAnnual!.displayName).toBe("Pro Annual"); |
|
|
| const apiStarter = plans.find((p) => p.planKey === "api_starter"); |
| expect(apiStarter).toBeDefined(); |
|
|
| const apiBusiness = plans.find((p) => p.planKey === "api_business"); |
| expect(apiBusiness).toBeDefined(); |
|
|
| const enterprise = plans.find((p) => p.planKey === "enterprise"); |
| expect(enterprise).toBeDefined(); |
| }); |
|
|
| test("checkout -> subscription.active webhook -> entitlements granted for pro_monthly", async () => { |
| const t = convexTest(schema, modules); |
|
|
| |
| const plans = await seedAndListPlans(t); |
| await seedCustomer(t); |
| const proMonthly = plans.find((p) => p.planKey === "pro_monthly"); |
| expect(proMonthly).toBeDefined(); |
|
|
| |
| const futureDate = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); |
| await simulateSubscriptionWebhook(t, { |
| webhookId: "wh_checkout_e2e_001", |
| subscriptionId: "sub_checkout_e2e_001", |
| productId: proMonthly!.dodoProductId, |
| nextBillingDate: futureDate.toISOString(), |
| }); |
|
|
| |
| const entitlements = await t.query( |
| internal.entitlements.getEntitlementsByUserId, |
| { userId: TEST_USER_ID }, |
| ); |
|
|
| |
| expect(entitlements.planKey).toBe("pro_monthly"); |
| expect(entitlements.features.tier).toBe(1); |
| expect(entitlements.features.apiAccess).toBe(false); |
| expect(entitlements.features.maxDashboards).toBe(10); |
| }); |
|
|
| test("checkout -> subscription.active webhook -> entitlements granted for api_starter", async () => { |
| const t = convexTest(schema, modules); |
|
|
| |
| const plans = await seedAndListPlans(t); |
| await seedCustomer(t); |
| const apiStarter = plans.find((p) => p.planKey === "api_starter"); |
| expect(apiStarter).toBeDefined(); |
|
|
| |
| const futureDate = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); |
| await simulateSubscriptionWebhook(t, { |
| webhookId: "wh_checkout_e2e_002", |
| subscriptionId: "sub_checkout_e2e_002", |
| productId: apiStarter!.dodoProductId, |
| nextBillingDate: futureDate.toISOString(), |
| }); |
|
|
| |
| const entitlements = await t.query( |
| internal.entitlements.getEntitlementsByUserId, |
| { userId: TEST_USER_ID }, |
| ); |
|
|
| |
| expect(entitlements.planKey).toBe("api_starter"); |
| expect(entitlements.features.tier).toBe(2); |
| expect(entitlements.features.apiAccess).toBe(true); |
| expect(entitlements.features.apiRateLimit).toBeGreaterThan(0); |
| expect(entitlements.features.apiRateLimit).toBe(60); |
| expect(entitlements.features.maxDashboards).toBe(25); |
| }); |
|
|
| test("expired entitlements fall back to free tier", async () => { |
| const t = convexTest(schema, modules); |
|
|
| |
| const plans = await seedAndListPlans(t); |
| await seedCustomer(t); |
| const proMonthly = plans.find((p) => p.planKey === "pro_monthly"); |
| expect(proMonthly).toBeDefined(); |
|
|
| |
| const pastStart = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000); |
| const pastEnd = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000); |
|
|
| await simulateSubscriptionWebhook(t, { |
| webhookId: "wh_checkout_e2e_003", |
| subscriptionId: "sub_checkout_e2e_003", |
| productId: proMonthly!.dodoProductId, |
| previousBillingDate: pastStart.toISOString(), |
| nextBillingDate: pastEnd.toISOString(), |
| }); |
|
|
| |
| const entitlements = await t.query( |
| internal.entitlements.getEntitlementsByUserId, |
| { userId: TEST_USER_ID }, |
| ); |
|
|
| |
| expect(entitlements.planKey).toBe("free"); |
| expect(entitlements.features.tier).toBe(0); |
| expect(entitlements.features.apiAccess).toBe(false); |
| expect(entitlements.validUntil).toBe(0); |
| }); |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| const NOW = Date.now(); |
| const MIN_MS = 60 * 1000; |
|
|
| async function seedPendingPayment( |
| t: ReturnType<typeof convexTest>, |
| opts: { planKey: string; suffix: string; occurredAt?: number }, |
| ) { |
| await t.run(async (ctx) => { |
| await ctx.db.insert("paymentEvents", { |
| userId: TEST_USER_ID, |
| dodoPaymentId: `pay_pending_${opts.suffix}`, |
| type: "charge", |
| amount: 3999, |
| currency: "USD", |
| status: "requires_customer_action", |
| planKey: opts.planKey, |
| rawPayload: {}, |
| occurredAt: opts.occurredAt ?? NOW - 2 * MIN_MS, |
| }); |
| }); |
| } |
|
|
| describe("checkout action pending-payment enforcement (#4438)", () => { |
| test("internalCreateCheckout returns a PAYMENT_IN_PROGRESS block for a recent pending same-tier payment", async () => { |
| const t = convexTest(schema, modules); |
| await seedPendingPayment(t, { planKey: "pro_monthly", suffix: "block" }); |
|
|
| const result = await t.action( |
| internal.payments.checkout.internalCreateCheckout, |
| { |
| userId: TEST_USER_ID, |
| productId: PRODUCT_CATALOG.pro_annual.dodoProductId!, |
| }, |
| ); |
|
|
| expect(result).toMatchObject({ |
| blocked: true, |
| code: "PAYMENT_IN_PROGRESS", |
| }); |
| }); |
|
|
| test("subscription guard wins: an active same-tier subscription is returned before the pending guard fires", async () => { |
| const t = convexTest(schema, modules); |
| |
| await seedPendingPayment(t, { planKey: "pro_monthly", suffix: "precedence" }); |
| await t.run(async (ctx) => { |
| await ctx.db.insert("subscriptions", { |
| userId: TEST_USER_ID, |
| dodoSubscriptionId: "sub_precedence_001", |
| dodoProductId: PRODUCT_CATALOG.pro_monthly.dodoProductId!, |
| planKey: "pro_monthly", |
| status: "active", |
| currentPeriodStart: NOW - 5 * MIN_MS, |
| currentPeriodEnd: NOW + 30 * 24 * 60 * MIN_MS, |
| rawPayload: {}, |
| updatedAt: NOW, |
| }); |
| }); |
|
|
| const result = await t.action( |
| internal.payments.checkout.internalCreateCheckout, |
| { |
| userId: TEST_USER_ID, |
| productId: PRODUCT_CATALOG.pro_annual.dodoProductId!, |
| }, |
| ); |
|
|
| expect(result).toMatchObject({ |
| blocked: true, |
| code: "ACTIVE_SUBSCRIPTION_EXISTS", |
| }); |
| }); |
|
|
| test("bypassPendingGuard skips the pending block (reaches session creation)", async () => { |
| const t = convexTest(schema, modules); |
| await seedPendingPayment(t, { planKey: "pro_monthly", suffix: "bypass" }); |
|
|
| |
| |
| |
| await expect( |
| t.action(internal.payments.checkout.internalCreateCheckout, { |
| userId: TEST_USER_ID, |
| productId: PRODUCT_CATALOG.pro_annual.dodoProductId!, |
| bypassPendingGuard: true, |
| }), |
| ).rejects.toThrow(); |
| }); |
|
|
| test("a pending Pro payment does not block an API checkout (different tier group)", async () => { |
| const t = convexTest(schema, modules); |
| await seedPendingPayment(t, { planKey: "pro_monthly", suffix: "cross_tier" }); |
|
|
| |
| |
| await expect( |
| t.action(internal.payments.checkout.internalCreateCheckout, { |
| userId: TEST_USER_ID, |
| productId: PRODUCT_CATALOG.api_starter.dodoProductId!, |
| }), |
| ).rejects.toThrow(); |
| }); |
| }); |
|
|