Skip to content
Merged
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 changes: 1 addition & 1 deletion .changeset/secure-billing-state.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
"paykitjs": patch
"paykitjs": minor
Comment thread
maxktz marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
---

Harden browser return URLs and customer mutations, reject test clocks with live Stripe keys, make customer deletion and billing upserts race-safe, and enforce webhook claim ownership. The database migration deduplicates existing Stripe billing rows before adding unique indexes.
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# e2e
TEST_DATABASE_URL=postgresql://localhost:5432/postgres
CF_TUNNEL_TOKEN=
E2E_STRIPE_SK=
E2E_STRIPE_WHSEC=

Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,14 @@ jobs:

- name: Build
run: pnpm build
env:
APP_URL: https://example.invalid
AUTH_DATABASE_URL: postgresql://ci:ci@127.0.0.1:5432/ci
BETTER_AUTH_SECRET: ci-build-placeholder-not-for-runtime-0000000000000000
PAYKIT_DATABASE_URL: postgresql://ci:ci@127.0.0.1:5432/ci
RESEND_API_KEY: ci-build-placeholder
STRIPE_SECRET_KEY: ci-build-placeholder
STRIPE_WEBHOOK_SECRET: ci-build-placeholder

unit:
runs-on: ubuntu-latest
Expand Down
60 changes: 1 addition & 59 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -122,26 +122,9 @@ jobs:
- name: Install workspace dependencies
run: pnpm install --frozen-lockfile

- name: Validate Stripe E2E configuration
env:
CF_TUNNEL_TOKEN: ${{ secrets.CF_TUNNEL_TOKEN_STRIPE }}
E2E_STRIPE_SK: ${{ secrets.E2E_STRIPE_SK }}
E2E_STRIPE_WHSEC: ${{ secrets.E2E_STRIPE_WHSEC }}
run: |
set -euo pipefail
for name in CF_TUNNEL_TOKEN E2E_STRIPE_SK E2E_STRIPE_WHSEC TEST_DATABASE_URL; do
if [ -z "${!name:-}" ]; then
echo "::error::Missing required configuration: $name"
exit 1
fi
done

- name: Setup cloudflared
uses: AnimMouse/setup-cloudflared@b80ad7aa7850e1ec9960d75831f32ac9855df988 # v2

- name: Check cloudflared version
run: cloudflared --version

- name: Install Playwright Chromium
run: pnpm --filter e2e exec playwright install --with-deps chromium

Expand All @@ -150,48 +133,7 @@ jobs:
CF_TUNNEL_TOKEN: ${{ secrets.CF_TUNNEL_TOKEN_STRIPE }}
E2E_STRIPE_SK: ${{ secrets.E2E_STRIPE_SK }}
E2E_STRIPE_WHSEC: ${{ secrets.E2E_STRIPE_WHSEC }}
run: |
set -euo pipefail
cloudflared tunnel --url http://127.0.0.1:4567 run --token "$CF_TUNNEL_TOKEN" > cloudflared.log 2>&1 &
cloudflared_pid=$!

cleanup() {
if kill -0 "$cloudflared_pid" 2>/dev/null; then
kill "$cloudflared_pid" || true
wait "$cloudflared_pid" || true
fi
}

trap cleanup EXIT

readiness_timeout_s=30
readiness_deadline=$((SECONDS + readiness_timeout_s))

while true; do
if ! kill -0 "$cloudflared_pid" 2>/dev/null; then
echo "::error::cloudflared exited before tests started"
if [ -f cloudflared.log ]; then
cat cloudflared.log
fi
exit 1
fi

if [ -f cloudflared.log ] && grep -Eq 'Registered tunnel|Connection [A-Za-z0-9]+ registered|INF.*Registered tunnel connection' cloudflared.log; then
break
fi

if [ "$SECONDS" -ge "$readiness_deadline" ]; then
echo "::error::Timed out waiting for cloudflared readiness"
if [ -f cloudflared.log ]; then
cat cloudflared.log
fi
exit 1
fi

sleep 0.5
done

pnpm --filter e2e test:stripe
run: pnpm --filter e2e test:stripe

- name: Upload cloudflared log
if: failure()
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ dist
npm-debug.log*
yarn-debug.log*
yarn-error.log*
cloudflared.log

# Misc
.DS_Store
Expand Down
24 changes: 18 additions & 6 deletions e2e/cli/push.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,27 @@ describe("paykitjs push", () => {
.from(product)
.orderBy(asc(product.id));
expect(dbRows).toEqual([
{ id: "free", name: "Free", group: "base", is_default: true, priceCurrency: null },
{ id: "pro", name: "Pro", group: "base", is_default: false, priceCurrency: "usd" },
{
id: fixture.planIds.free,
name: "Free",
group: "base",
is_default: true,
priceCurrency: null,
},
{
id: fixture.planIds.pro,
name: "Pro",
group: "base",
is_default: false,
priceCurrency: "usd",
},
]);

// Verify paid plan (pro) was synced to Stripe.
const proRows = await ctx.database
.select({ id: product.id, stripeProductId: product.stripeProductId })
.from(product)
.where(eq(product.id, "pro"))
.where(eq(product.id, fixture.planIds.pro))
.orderBy(desc(product.version))
.limit(1);
const proProduct = proRows[0] as { id: string; stripeProductId: string | null } | undefined;
Expand Down Expand Up @@ -132,7 +144,7 @@ describe("paykitjs push", () => {
});
const results = await syncProducts(ctx);

const proResult = results.find((r) => r.id === "pro");
const proResult = results.find((r) => r.id === fixture.planIds.pro);
expect(proResult).toMatchObject({ action: "created", version: 2 });

const proRows = await ctx.database
Expand All @@ -141,7 +153,7 @@ describe("paykitjs push", () => {
stripePriceId: product.stripePriceId,
})
.from(product)
.where(eq(product.id, "pro"))
.where(eq(product.id, fixture.planIds.pro))
.orderBy(desc(product.version))
.limit(1);
const proProduct = proRows[0];
Expand All @@ -154,7 +166,7 @@ describe("paykitjs push", () => {
expect(stripePrice.currency).toBe("eur");

const diffs = await dryRunSyncProducts(ctx);
expect(diffs.find((d) => d.id === "pro")?.action).toBe("unchanged");
expect(diffs.find((d) => d.id === fixture.planIds.pro)?.action).toBe("unchanged");
} finally {
await database.end();
}
Expand Down
36 changes: 20 additions & 16 deletions e2e/cli/setup.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
Expand All @@ -16,6 +17,7 @@ export interface CliTestFixture {
cwd: string;
dbName: string;
dbUrl: string;
planIds: { free: string; pro: string };
stripeClient: Stripe;
cleanup: () => Promise<void>;
}
Expand All @@ -33,8 +35,11 @@ export async function createCliFixture(_globalKey: string): Promise<CliTestFixtu

const stripeClient = new Stripe(secretKey, { maxNetworkRetries: 3 });

const fixtureId = randomUUID().replaceAll("-", "");
const planIds = { free: `free_${fixtureId}`, pro: `pro_${fixtureId}` };

// Create a fresh test database
const dbName = `paykit_cli_${String(Date.now())}`;
const dbName = `paykit_cli_${fixtureId}`;
const adminUrl = env.TEST_DATABASE_URL;
const adminPool = new Pool({ connectionString: adminUrl });
await adminPool.query(`CREATE DATABASE "${dbName}"`);
Expand Down Expand Up @@ -62,15 +67,15 @@ export async function createCliFixture(_globalKey: string): Promise<CliTestFixtu
`const messagesFeature = feature({ id: "messages", type: "metered" });`,
"",
`const free = plan({`,
` id: "free",`,
` id: ${JSON.stringify(planIds.free)},`,
` name: "Free",`,
` group: "base",`,
` default: true,`,
` includes: [messagesFeature({ limit: 50, reset: "month" })],`,
`});`,
"",
`const pro = plan({`,
` id: "pro",`,
` id: ${JSON.stringify(planIds.pro)},`,
` name: "Pro",`,
` group: "base",`,
` price: { amount: 2000, interval: "month" },`,
Expand All @@ -89,21 +94,20 @@ export async function createCliFixture(_globalKey: string): Promise<CliTestFixtu
);

const cleanup = async () => {
// Clean up Stripe products created by push
// Clean up only Stripe products tagged with this fixture's unique plan ID.
try {
const products = await stripeClient.products.list({ limit: 100 });
for (const product of products.data) {
const paykitId = product.metadata.paykit_product_id;
if (paykitId === "free" || paykitId === "pro") {
// Archive prices first
const prices = await stripeClient.prices.list({ product: product.id, limit: 100 });
for (const price of prices.data) {
if (price.active) {
await stripeClient.prices.update(price.id, { active: false });
}
for await (const stripeProduct of stripeClient.products.list({ limit: 100 })) {
if (stripeProduct.metadata.paykit_product_id !== planIds.pro) continue;

for await (const price of stripeClient.prices.list({
product: stripeProduct.id,
limit: 100,
})) {
if (price.active) {
await stripeClient.prices.update(price.id, { active: false });
}
await stripeClient.products.update(product.id, { active: false });
}
await stripeClient.products.update(stripeProduct.id, { active: false });
}
} catch {
// Best effort cleanup
Expand All @@ -118,5 +122,5 @@ export async function createCliFixture(_globalKey: string): Promise<CliTestFixtu
await fs.rm(cwd, { force: true, recursive: true });
};

return { cwd, dbName, dbUrl, stripeClient, cleanup };
return { cwd, dbName, dbUrl, planIds, stripeClient, cleanup };
}
33 changes: 23 additions & 10 deletions e2e/core/subscribe/cancel-end-of-cycle.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { and, desc, eq } from "drizzle-orm";
import { and, desc, eq, gt } from "drizzle-orm";
import { afterAll, beforeAll, describe, it } from "vitest";

import { product, subscription } from "../../../packages/paykit/src/database/schema";
import { product, subscription, webhookEvent } from "../../../packages/paykit/src/database/schema";
import {
advanceTestClock,
createTestCustomerWithPM,
Expand All @@ -13,7 +13,6 @@ import {
expectSingleActivePlanInGroup,
subscribeCustomer,
type TestPayKit,
waitForWebhook,
} from "../../test-utils";

describe("cancel-end-of-cycle: pro → free + clock advance", () => {
Expand Down Expand Up @@ -61,15 +60,19 @@ describe("cancel-end-of-cycle: pro → free + clock advance", () => {
customerId,
frozenTime: advanceTo,
});
await waitForWebhook({
after: beforeAdvance,
database: t.database,
eventType: "subscription.deleted",
timeout: 30_000,
});

// Poll until Free is active after the forwarded deletion event is processed
// Poll until webhook processing applies the scheduled plan transition.
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
for (let i = 0; i < 60; i++) {
const failedWebhook = await t.database.query.webhookEvent.findFirst({
where: and(eq(webhookEvent.status, "failed"), gt(webhookEvent.receivedAt, beforeAdvance)),
orderBy: (event, { desc: descending }) => [descending(event.receivedAt)],
});
if (failedWebhook) {
throw new Error(
`Webhook ${failedWebhook.type} failed after clock advance: ${String(failedWebhook.error)}`,
);
}

const rows = await t.database
.select({ status: subscription.status })
.from(subscription)
Expand Down Expand Up @@ -122,6 +125,16 @@ describe("cancel-end-of-cycle: pro → free + clock advance", () => {
limit: 100,
remaining: 100,
});

const failedWebhook = await t.database.query.webhookEvent.findFirst({
where: and(eq(webhookEvent.status, "failed"), gt(webhookEvent.receivedAt, beforeAdvance)),
orderBy: (event, { desc: descending }) => [descending(event.receivedAt)],
});
if (failedWebhook) {
throw new Error(
`Webhook ${failedWebhook.type} failed after clock advance: ${String(failedWebhook.error)}`,
);
}
} catch (error) {
await dumpStateOnFailure(t.database, t.dbPath);
throw error;
Expand Down
4 changes: 2 additions & 2 deletions e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
"private": true,
"type": "module",
"scripts": {
"test:stripe": "PROVIDER=stripe vitest run --project=core",
"test:stripe:watch": "PROVIDER=stripe vitest --project=core",
"test:stripe": "node ./scripts/run-stripe-e2e.mjs",
"test:stripe:watch": "node ./scripts/run-stripe-e2e.mjs --watch",
"test:cli": "vitest run --project=cli",
"test:cli:watch": "vitest --project=cli",
"test:database": "vitest run --project=database",
Expand Down
Loading
Loading