Skip to content
Draft
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,777 changes: 1,435 additions & 1,342 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();
Comment on lines +30 to +32
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect whether this repo already provisions a headed Playwright environment.
rg -n --iglob '*.{ts,json,yml,yaml}' 'chromium\.launch|playwright|xvfb|DISPLAY'

Repository: getpaykit/paykit

Length of output: 645


🏁 Script executed:

#!/bin/bash
# Check for CI configuration files
fd -t f '\.(yml|yaml|json)$' -e yml -e yaml -e json | grep -E '(github|gitlab|circle|travis|actions|ci|test)' | head -20

Repository: getpaykit/paykit

Length of output: 42


🏁 Script executed:

#!/bin/bash
# Check for playwright configuration files and env setup
fd -t f 'playwright.config' -o '.env*' 2>/dev/null | head -10
# Also search for any existing Playwright env var patterns
rg -n 'process\.env.*PLAYWRIGHT|PLAYWRIGHT.*process\.env' --type ts --type js 2>/dev/null

Repository: getpaykit/paykit

Length of output: 42


🏁 Script executed:

#!/bin/bash
# Understand the context around the dodopayments completeCheckout method
cat -n e2e/test-utils/harness/dodopayments.ts | sed -n '1,50p'

Repository: getpaykit/paykit

Length of output: 2438


🏁 Script executed:

#!/bin/bash
# Get the complete completeCheckout method
cat -n e2e/test-utils/harness/dodopayments.ts | sed -n '30,100p'

Repository: getpaykit/paykit

Length of output: 3362


🏁 Script executed:

#!/bin/bash
# Check for any .github or workflows directory structure
find . -type d -name '.github' -o -name 'workflows' 2>/dev/null | head -5
# Also look for any root-level config files
ls -la | grep -E '\.yml|\.yaml|package\.json|tsconfig' | head -10

Repository: getpaykit/paykit

Length of output: 292


🏁 Script executed:

#!/bin/bash
# Search for comments or discussion around the headless setting
rg -B3 -A3 'headless.*false|headed.*mode' --type ts --type js 2>/dev/null

Repository: getpaykit/paykit

Length of output: 506


🏁 Script executed:

#!/bin/bash
# Check GitHub workflows for e2e test execution
find .github/workflows -type f -name '*.yml' -o -name '*.yaml' 2>/dev/null | xargs grep -l 'e2e\|playwright' 2>/dev/null

Repository: getpaykit/paykit

Length of output: 86


🏁 Script executed:

#!/bin/bash
# Read the e2e workflow configuration
cat .github/workflows/e2e.yml

Repository: getpaykit/paykit

Length of output: 9247


Run Playwright headless by default.

Hard-coding headless: false on line 31 will break this harness on CI runners without a display server (like ubuntu-latest in your GitHub Actions workflow). All other payment harnesses (stripe, polar) use headless: true and work correctly. Headed mode is not needed for automated form filling and payment submission—make it opt-in for local debugging instead.

🔧 Suggested change
-      const browser = await chromium.launch({ headless: false });
+      const browser = await chromium.launch({
+        headless: process.env.PLAYWRIGHT_HEADLESS !== "false",
+      });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async completeCheckout(url: string) {
const browser = await chromium.launch({ headless: false });
const page = await browser.newPage();
async completeCheckout(url: string) {
const browser = await chromium.launch({
headless: process.env.PLAYWRIGHT_HEADLESS !== "false",
});
const page = await browser.newPage();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/test-utils/harness/dodopayments.ts` around lines 30 - 32, The harness
hard-codes headed mode in async completeCheckout by calling chromium.launch({
headless: false }); change this to run headless by default (e.g.,
chromium.launch({ headless: true })) and make headed mode opt-in for local
debugging (toggle via an environment variable or parameter passed into
completeCheckout). Update the chromium.launch call in completeCheckout to read
the flag (e.g., process.env.PLAYWRIGHT_HEADLESS or a function argument) so CI
uses headless=true while developers can opt into headful mode when needed.


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
});
Comment on lines +78 to +81
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't treat a timed-out success wait as a passed checkout.

If waitForURL("**/success**") times out, the catch block currently returns control as if payment completed. That turns real checkout failures into false positives until a later assertion hangs or flakes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/test-utils/harness/dodopayments.ts` around lines 78 - 81, The current
catch on page.waitForURL("**/success**") swallows timeouts and treats them as
success; change the logic in the dodopayments harness so a timed-out URL wait
does not silently pass: remove the empty catch and instead attempt a
deterministic fallback (e.g. call page.waitForSelector or page.locator(...) for
the known confirmation DOM element/text used by the checkout flow), and if both
page.waitForURL("**/success**") and the fallback wait time out, throw an error
(or return a failed/promise-rejection) so the test fails immediately; reference
the existing page.waitForURL call and add a page.waitForSelector/page.locator
check and explicit throw when neither condition is met.

} 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,8 +14,10 @@
return createStripeHarness();
case "polar":
return createPolarHarness();
case "dodopayments":
return createDodopaymentsHarness();
default: {
const _exhaustive: never = provider;

Check warning on line 20 in e2e/test-utils/harness/index.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(no-underscore-dangle)

Unexpected dangling '_' in '`_exhaustive`'.
throw new Error(`Unknown provider: ${String(_exhaustive)}`);
}
}
Expand Down
Loading
Loading