Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,759 changes: 1,418 additions & 1,341 deletions bun.lock

Large diffs are not rendered by default.

204 changes: 104 additions & 100 deletions e2e/core/checkout/resubscribe-after-cancel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,120 +9,124 @@ import {
dumpStateOnFailure,
expectProduct,
expectSingleActivePlanInGroup,
harness,
type TestPayKit,
waitForWebhook,
} from "../../test-utils";

describe("resubscribe-after-cancel: checkout after full cancellation", () => {
let t: TestPayKit;
let customerId: string;
describe.skipIf(!harness.capabilities.testClocks)(
"resubscribe-after-cancel: checkout after full cancellation",
() => {
let t: TestPayKit;
let customerId: string;

beforeAll(async () => {
t = await createTestPayKit();
const customer = await createTestCustomerWithPM({
t,
customer: {
id: "test_resub",
email: "resub@test.com",
name: "Resubscribe Test",
},
});
customerId = customer.customerId;

// Setup: subscribe Pro → cancel to Free → advance clock (full cancellation)
await t.paykit.subscribe({
customerId,
planId: "pro",
successUrl: "https://example.com/success",
});
beforeAll(async () => {
t = await createTestPayKit();
const customer = await createTestCustomerWithPM({
t,
customer: {
id: "test_resub",
email: "resub@test.com",
name: "Resubscribe Test",
},
});
customerId = customer.customerId;

await t.paykit.subscribe({
customerId,
planId: "free",
successUrl: "https://example.com/success",
});
// Setup: subscribe Pro → cancel to Free → advance clock (full cancellation)
await t.paykit.subscribe({
customerId,
planId: "pro",
successUrl: "https://example.com/success",
});

// Advance past period end so subscription fully cancels
const subRows = await t.database
.select({ currentPeriodEndAt: subscription.currentPeriodEndAt })
.from(subscription)
.where(eq(subscription.customerId, customerId))
.orderBy(desc(subscription.updatedAt))
.limit(1);
const periodEnd = new Date(subRows[0]!.currentPeriodEndAt as unknown as string);
const advanceTo = new Date(periodEnd.getTime() + 86_400_000);
await advanceTestClock({
t,
customerId,
frozenTime: advanceTo,
});
await t.paykit.subscribe({
customerId,
planId: "free",
successUrl: "https://example.com/success",
});

// Wait for Free to activate
for (let i = 0; i < 60; i++) {
const rows = await t.database
.select({ status: subscription.status })
// Advance past period end so subscription fully cancels
const subRows = await t.database
.select({ currentPeriodEndAt: subscription.currentPeriodEndAt })
.from(subscription)
.innerJoin(product, eq(product.internalId, subscription.productInternalId))
.where(
and(
eq(subscription.customerId, customerId),
eq(product.id, "free"),
eq(subscription.status, "active"),
),
);
if (rows.length > 0) break;
if (i === 59) throw new Error("Free never activated in setup");
await new Promise((resolve) => setTimeout(resolve, 2000));
}
.where(eq(subscription.customerId, customerId))
.orderBy(desc(subscription.updatedAt))
.limit(1);
const periodEnd = new Date(subRows[0]!.currentPeriodEndAt as unknown as string);
const advanceTo = new Date(periodEnd.getTime() + 86_400_000);
await advanceTestClock({
t,
customerId,
frozenTime: advanceTo,
});

// Clear stale payment method (Stripe removes it on full cancellation)
await t.database.delete(paymentMethod).where(eq(paymentMethod.customerId, customerId));
});
// Wait for Free to activate
for (let i = 0; i < 60; i++) {
const rows = await t.database
.select({ status: subscription.status })
.from(subscription)
.innerJoin(product, eq(product.internalId, subscription.productInternalId))
.where(
and(
eq(subscription.customerId, customerId),
eq(product.id, "free"),
eq(subscription.status, "active"),
),
);
if (rows.length > 0) break;
if (i === 59) throw new Error("Free never activated in setup");
await new Promise((resolve) => setTimeout(resolve, 2000));
}

afterAll(async () => {
await t?.cleanup();
});
// Clear stale payment method (Stripe removes it on full cancellation)
await t.database.delete(paymentMethod).where(eq(paymentMethod.customerId, customerId));
});

it("resubscribing after full cancellation requires checkout", async () => {
try {
const beforeCheckout = new Date();
afterAll(async () => {
await t?.cleanup();
});

const result = await t.paykit.subscribe({
customerId,
planId: "pro",
successUrl: "https://example.com/success",
});
it("resubscribing after full cancellation requires checkout", async () => {
try {
const beforeCheckout = new Date();

// Should require checkout (payment method was cleared)
if (!result.paymentUrl) {
throw new Error("Expected checkout URL but got direct subscription");
}
const result = await t.paykit.subscribe({
customerId,
planId: "pro",
successUrl: "https://example.com/success",
});

await t.harness.completeCheckout(result.paymentUrl);
// Should require checkout (payment method was cleared)
if (!result.paymentUrl) {
throw new Error("Expected checkout URL but got direct subscription");
}

await waitForWebhook({
database: t.database,
eventType: "checkout.completed",
after: beforeCheckout,
timeout: 120_000,
});
await t.harness.completeCheckout(result.paymentUrl);

// Pro is active again
await expectProduct({
database: t.database,
customerId,
planId: "pro",
expected: { status: "active", hasPeriodEnd: true },
});
await expectSingleActivePlanInGroup({
database: t.database,
customerId,
group: "base",
planId: "pro",
});
} catch (error) {
await dumpStateOnFailure(t.database, t.dbPath);
throw error;
}
});
});
await waitForWebhook({
database: t.database,
eventType: "checkout.completed",
after: beforeCheckout,
timeout: 120_000,
});

// Pro is active again
await expectProduct({
database: t.database,
customerId,
planId: "pro",
expected: { status: "active", hasPeriodEnd: true },
});
await expectSingleActivePlanInGroup({
database: t.database,
customerId,
group: "base",
planId: "pro",
});
} catch (error) {
await dumpStateOnFailure(t.database, t.dbPath);
throw error;
}
});
},
);
3 changes: 3 additions & 0 deletions e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@
"test:stripe:watch": "PROVIDER=stripe vitest --project=core",
"test:polar": "PROVIDER=polar vitest run --project=core",
"test:polar:watch": "PROVIDER=polar vitest --project=core",
"test:dodopayments": "PROVIDER=dodopayments vitest run --project=core",
"test:dodopayments:watch": "PROVIDER=dodopayments vitest --project=core",
"test:cli": "vitest run --project=cli",
"test:cli:watch": "vitest --project=cli",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"devDependencies": {
"@paykitjs/dodopayments": "workspace:*",
"@paykitjs/polar": "workspace:*",
"@paykitjs/stripe": "workspace:*",
"@t3-oss/env-core": "^0.12.0",
Expand Down
6 changes: 5 additions & 1 deletion e2e/test-utils/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ config({

export const env = createEnv({
server: {
PROVIDER: z.enum(["stripe", "polar"]).default("stripe"),
PROVIDER: z.enum(["stripe", "polar", "dodopayments"]).default("stripe"),
TEST_DATABASE_URL: z.string().default("postgresql://localhost:5432/postgres"),

// Stripe
Expand All @@ -23,6 +23,10 @@ export const env = createEnv({
// Polar
E2E_POLAR_ACCESS_TOKEN: z.string().optional(),
E2E_POLAR_WHSEC: z.string().optional(),

// DodoPayments
E2E_DODO_BEARER_TOKEN: z.string().optional(),
E2E_DODO_WEBHOOK_SECRET: z.string().optional(),
},
runtimeEnv: process.env,
emptyStringAsUndefined: true,
Expand Down
98 changes: 98 additions & 0 deletions e2e/test-utils/harness/dodopayments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { dodopayments } from "@paykitjs/dodopayments";
import { chromium } from "playwright";

import { env } from "../env";
import type { ProviderHarness } from "./types";

export function createDodopaymentsHarness(): ProviderHarness {
const bearerToken = env.E2E_DODO_BEARER_TOKEN;
const webhookSecret = env.E2E_DODO_WEBHOOK_SECRET;
if (!bearerToken || !webhookSecret) {
throw new Error("E2E_DODO_BEARER_TOKEN and E2E_DODO_WEBHOOK_SECRET must be set");
}

return {
id: "dodopayments",
capabilities: {
testClocks: false,
directSubscription: false,
},

createProviderConfig() {
return dodopayments({ bearerToken, webhookSecret, environment: "test_mode" });
},

async setupCustomerForDirectSubscription(_providerCustomerId: string) {
// DodoPayments doesn't support direct subscription — always goes through checkout.
// This is a no-op; tests will get a paymentUrl and call completeCheckout.
},

async completeCheckout(url: string) {
const browser = await chromium.launch({ headless: false });
const page = await browser.newPage();

try {
await page.goto(url, { waitUntil: "networkidle" });

const addBtn = page.getByRole("button", { name: "Enter address manually" });
if (await addBtn.isVisible().catch(() => false)) {
await addBtn.click();
await page.waitForSelector('input[name="city"]', { timeout: 10000 });

// Submit Address
await page.locator('select[name="country"]').selectOption("United States");
await page.getByRole("textbox", { name: "Address Line" }).fill("177A Bleecker Street");
await page.getByRole("textbox", { name: "City" }).fill("New York");
await page.getByRole("textbox", { name: "Zip Code" }).fill("10012");
await page.getByRole("textbox", { name: "State" }).fill("New York");
const submitButton = page.getByRole("button", { name: "Continue to Payment" });
await submitButton.click();

await page.waitForTimeout(2000);
}

// Wait for payment section
await page.waitForSelector(
'iframe[title*="Dodo"], [data-testid="payment-form"], button:has-text("Pay")',
{ timeout: 15000 },
);

const iframeLocator = page.locator('iframe[title*="Dodo Payments"]');
const iframeForm = iframeLocator.contentFrame();
await iframeForm
.getByRole("button", { name: "Card" })
.waitFor({ state: "visible", timeout: 10000 })
.catch(() => {});
await iframeForm
.getByRole("button", { name: "Card" })
.click()
.catch(() => {});

await iframeForm.getByRole("textbox", { name: "Card number" }).fill("5555555555554444");
await iframeForm.getByRole("textbox", { name: "Expiry" }).fill("06/32");
await iframeForm.getByRole("textbox", { name: "Security code" }).fill("123");
await iframeForm.getByRole("textbox", { name: "Name on card" }).fill("Steven Strange");

await page.getByRole("button", { name: /pay|subscribe/i }).click();

// Wait for redirect to success URL or confirmation
await page.waitForURL("**/success**", { timeout: 30_000 }).catch(() => {
// Some checkouts show a confirmation page rather than redirecting
});
} finally {
await browser.close();
}
},

async cleanup(_ctx) {
// DodoPayments test mode has no test clocks to clean up.
// Subscriptions in test mode are ephemeral.
},

validateEnv() {
if (!env.E2E_DODO_BEARER_TOKEN || !env.E2E_DODO_WEBHOOK_SECRET) {
throw new Error("E2E_DODO_BEARER_TOKEN and E2E_DODO_WEBHOOK_SECRET must be set");
}
},
};
}
3 changes: 3 additions & 0 deletions e2e/test-utils/harness/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { env } from "../env";
import { createDodopaymentsHarness } from "./dodopayments";
import { createPolarHarness } from "./polar";
import { createStripeHarness } from "./stripe";
import type { ProviderHarness } from "./types";
Expand All @@ -13,6 +14,8 @@ export function loadHarness(): ProviderHarness {
return createStripeHarness();
case "polar":
return createPolarHarness();
case "dodopayments":
return createDodopaymentsHarness();
default: {
const _exhaustive: never = provider;
throw new Error(`Unknown provider: ${String(_exhaustive)}`);
Expand Down
Loading
Loading