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: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,5 @@ These directories already contain source checkouts for some packages:
- Never edit past migrations; create a new migration instead.
- Never run "deploy" scripts to test anything, only if explicitly asked
- NEVER EVER publish or release packages yourself, ask double confirmation
- No need to pass --config paykit.ts into paykitjs CLI, because it's one of the locations resolved by default
- If you just did some changes but later you see them different in the diff, it means the user likely adjusted the code intentionally, you should not restore them without asking the user
8 changes: 3 additions & 5 deletions apps/demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,15 @@
"check:write": "biome check --write .",
"deploy:sandbox": "bun scripts/deploy-sandbox.ts",
"dev": "next dev",
"dev:tunnel": "concurrently -k -n next,tunnel -c blue,magenta \"bun dev\" \"bun tunnel\"",
"env:push:sandbox": "bun scripts/push-sandbox-env.ts",
"preview": "next build && next start",
"push:sandbox": "bun scripts/push-sandbox.ts",
"start": "next start",
"tunnel": "set -a; [ -f .env ] && . ./.env; set +a; if [ -n \"$CF_TUNNEL_ID\" ]; then cloudflared tunnel --url http://localhost:3000 run \"$CF_TUNNEL_ID\"; fi",
"paykitjs": "bun ../../packages/paykit/src/cli/index.ts",
"typecheck": "tsc --noEmit",
"push": "bun push:auth && bun push:paykit && bun push:autumn",
"push:auth": "bunx auth migrate --config src/lib/auth.ts --yes",
"push:paykit": "set -a; [ -f .env ] && . ./.env; set +a; bunx paykitjs push --config paykit.config.ts --yes",
"push": "pnpm push:auth && pnpm push:paykit && pnpm push:autumn",
"push:auth": "auth migrate --config src/lib/auth.ts --yes",
"push:paykit": "pnpm paykitjs push --yes",
"push:autumn": "set -a; [ -f .env ] && . ./.env; set +a; if [ -n \"$AUTUMN_SECRET_KEY\" ]; then atmn push; else printf '%s\\n' 'Skipping Autumn push: provider env incomplete'; fi",
"db:studio": "drizzle-kit studio"
},
Expand Down
4 changes: 0 additions & 4 deletions apps/demo/paykit.config.ts

This file was deleted.

2 changes: 1 addition & 1 deletion apps/demo/scripts/deploy-sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {

function printHelp() {
console.log("Deploy standalone demo app copy to Vercel sandbox production");
console.log("Usage: bun deploy:sandbox --version <npm-spec>");
console.log("Usage: pnpm deploy:sandbox --version <npm-spec>");
}

async function main() {
Expand Down
11 changes: 3 additions & 8 deletions apps/demo/scripts/push-sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,20 +24,15 @@ async function main() {

console.log("Push auth migrations");
await runCommand(
"bunx",
["auth", "migrate", "--config", "src/lib/auth.ts", "--yes"],
"pnpm",
["exec", "auth", "migrate", "--config", "src/lib/auth.ts", "--yes"],
demoDir,
env,
);

if (hasEnv(values, ["PAYKIT_DATABASE_URL", "STRIPE_SECRET_KEY", "STRIPE_WEBHOOK_SECRET"])) {
console.log("Push PayKit config");
await runCommand(
"bunx",
["paykitjs", "push", "--config", "paykit.config.ts", "--yes"],
demoDir,
env,
);
await runCommand("pnpm", ["paykitjs", "push", "--yes"], demoDir, env);
} else {
console.log("Skipping PayKit push: env incomplete");
}
Expand Down
4 changes: 2 additions & 2 deletions apps/demo/scripts/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,9 +201,9 @@ async function rewriteTempNextConfig(nextConfigPath: string) {

async function writeTempVercelConfig(vercelConfigPath: string) {
const vercelConfig = {
buildCommand: "bun run build",
buildCommand: "pnpm run build",
framework: "nextjs",
installCommand: "bun install",
installCommand: "pnpm install",
};

await writeFile(vercelConfigPath, `${JSON.stringify(vercelConfig, null, 2)}\n`);
Expand Down
115 changes: 114 additions & 1 deletion apps/demo/src/app/_components/auth-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,69 @@

import { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "sonner";

import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { authClient } from "@/lib/auth-client";

function createRandomCredentials() {
const id = crypto.randomUUID().slice(0, 8);
const email = `test-${Date.now()}-${id}@example.com`;
const password = `PayKit-test-${crypto.randomUUID()}`;

return { email, password };
}

function formatCredentials(credentials: { email: string; password: string }) {
return `Email: ${credentials.email}\nPassword: ${credentials.password}`;
}

async function copyCredentials(credentials: { email: string; password: string }) {
const text = formatCredentials(credentials);

if (navigator.clipboard) {
try {
await navigator.clipboard.writeText(text);
return;
} catch {
// Fall back to the textarea path when clipboard permissions reject writes.
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.append(textarea);
textarea.select();

try {
if (!document.execCommand("copy")) {
throw new Error("Clipboard copy failed");
}
} finally {
textarea.remove();
}
}

export function AuthForm({ redirectTo }: { redirectTo: string }) {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isSignUp, setIsSignUp] = useState(false);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [randomAccountLoading, setRandomAccountLoading] = useState(false);

async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (loading || randomAccountLoading) {
return;
}

setError("");
setLoading(true);

Expand Down Expand Up @@ -49,6 +95,65 @@ export function AuthForm({ redirectTo }: { redirectTo: string }) {
}
}

async function handleRandomAccount() {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
if (loading || randomAccountLoading) {
return;
}

setError("");
setRandomAccountLoading(true);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const credentials = createRandomCredentials();
let credentialsCopied = false;

try {
setEmail(credentials.email);
setPassword(credentials.password);

try {
await copyCredentials(credentials);
credentialsCopied = true;
} catch {
credentialsCopied = false;
}

const result = await authClient.signUp.email({
email: credentials.email,
password: credentials.password,
name: "Demo User",
});

if (result.error) {
const message = result.error.message ?? "Random account sign up failed";
setError(message);
toast.error(message, {
description: credentialsCopied
? "Generated credentials were copied, but the account was not created."
: undefined,
});
return;
}

if (credentialsCopied) {
toast.success("Signed up with a random test account", {
description: "Credentials copied to clipboard.",
});
} else {
toast.warning("Signed up with a random test account", {
description: `Clipboard copy failed. ${formatCredentials(credentials)}`,
});
}

router.replace(redirectTo);
} catch (err) {
const message = err instanceof Error ? err.message : "Something went wrong";
setError(message);
toast.error(message);
} finally {
setRandomAccountLoading(false);
}
}

return (
<Card>
<CardHeader>
Expand Down Expand Up @@ -80,9 +185,17 @@ export function AuthForm({ redirectTo }: { redirectTo: string }) {
/>
</div>
{error ? <p className="text-destructive text-sm">{error}</p> : null}
<Button disabled={loading} type="submit">
<Button disabled={loading || randomAccountLoading} type="submit">
{loading ? "Loading..." : isSignUp ? "Sign up" : "Sign in"}
</Button>
<Button
disabled={loading || randomAccountLoading}
onClick={handleRandomAccount}
type="button"
variant="secondary"
>
{randomAccountLoading ? "Creating account..." : "Sign up with random account"}
</Button>
<Button
className="text-muted-foreground"
onClick={() => {
Expand Down
20 changes: 20 additions & 0 deletions apps/web/content/docs/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,26 @@ paykitjs push -y && next build

This is similar to how you'd run database migrations on deploy. The `-y` flag skips the confirmation prompt.

## paykitjs listen
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

```bash
npx paykitjs listen
```

Runs PayKit's local webhook listener. It registers a provider webhook tunnel, replays missed events, and streams new webhooks while you develop.

Pass your app's dev command after `--` to run together with dev server:

```bash
npx paykitjs listen -- pnpm dev
```

Useful options:

- `--config <path>` loads a specific PayKit config file when default discovery is not enough
- `--retry <window>` retries failed deliveries from a recent window, such as `5m`, `30s`, or `none`
- `--forward-to <url>` forwards webhooks to a local app origin instead of applying them directly

## paykitjs status

```bash
Expand Down
14 changes: 14 additions & 0 deletions apps/web/content/docs/webhook-events.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,20 @@ export const paykit = createPayKit({

The webhook endpoint is exposed by paykit handler at `/paykit/webhook` by default. See the [installation page](/docs/installation) for details on mounting it.

## Local development

Use `paykitjs listen` to receive provider webhooks while your app runs locally:

```bash
pnpm paykitjs listen
```

Or run with the web server together:

```bash
pnpm paykitjs listen -- pnpm dev
```

## Idempotency

PayKit records each incoming webhook event and processes it idempotently. If your provider sends the same event more than once, PayKit skips the duplicate. Your `on` handlers won't fire twice for the same event.
Expand Down
11 changes: 6 additions & 5 deletions packages/paykit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,23 +24,23 @@
"types": "./src/index.ts",
"exports": {
".": {
"paykit-source": "./src/index.ts",
"types": "./src/index.ts",
"paykit-source": "./src/index.ts",
"default": "./src/index.ts"
},
"./handlers/next": {
"paykit-source": "./src/handlers/next.ts",
"types": "./src/handlers/next.ts",
"paykit-source": "./src/handlers/next.ts",
"default": "./src/handlers/next.ts"
},
"./client": {
"paykit-source": "./src/client/index.ts",
"types": "./src/client/index.ts",
"paykit-source": "./src/client/index.ts",
"default": "./src/client/index.ts"
},
"./database": {
"paykit-source": "./src/database/schema.ts",
"types": "./src/database/schema.ts",
"paykit-source": "./src/database/schema.ts",
"default": "./src/database/schema.ts"
}
},
Expand All @@ -51,10 +51,11 @@
},
"dependencies": {
"@better-fetch/fetch": "^1.1.21",
"@types/pg": "^8.18.0",
"@clack/prompts": "^1.1.0",
"@types/pg": "^8.18.0",
"better-call": "^2.0.2",
"commander": "^12.1.0",
"concurrently": "^9.2.1",
"dotenv": "^17.3.1",
"drizzle-orm": "^0.45.1",
"jiti": "^2.6.1",
Expand Down
32 changes: 27 additions & 5 deletions packages/paykit/src/cli/__tests__/init.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,39 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

import { describe, expect, it } from "vitest";

import { getWebhookListenCommand } from "../commands/init";
import { detectPaykitCli, getPaykitListenCommand, getStripeListenCommand } from "../commands/init";

describe("cli/init", () => {
it("uses paykitjs listen when the PayKit CLI is available", () => {
expect(getWebhookListenCommand(3000, true)).toBe(
"paykitjs listen --forward-to localhost:3000/paykit/webhook",
);
expect(getPaykitListenCommand("pnpm")).toBe("pnpm paykitjs listen -- pnpm dev");
expect(getPaykitListenCommand("npm")).toBe("npx paykitjs listen -- npm run dev");
});

it("falls back to stripe listen when the PayKit CLI is unavailable", () => {
expect(getWebhookListenCommand(3000, false)).toBe(
expect(getStripeListenCommand(3000)).toBe(
"stripe listen --forward-to localhost:3000/paykit/webhook",
);
});

it("detects the PayKit CLI from the target project", () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "paykit-init-"));

try {
fs.writeFileSync(
path.join(cwd, "package.json"),
JSON.stringify({ dependencies: { paykitjs: "workspace:*" } }),
);
expect(detectPaykitCli(cwd)).toBe(false);

const packageDir = path.join(cwd, "node_modules", "paykitjs");
fs.mkdirSync(packageDir, { recursive: true });
fs.writeFileSync(path.join(packageDir, "package.json"), JSON.stringify({ name: "paykitjs" }));
expect(detectPaykitCli(cwd)).toBe(true);
} finally {
fs.rmSync(cwd, { force: true, recursive: true });
}
});
});
Loading
Loading