From 8cf1953dc7dfde0a7031c1b42e65faece029c053 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Tue, 2 Jun 2026 19:01:48 +0400 Subject: [PATCH 01/82] Remove Polar and make PayKit Stripe-only --- README.md | 7 +- apps/demo/drizzle.config.ts | 3 +- apps/demo/next.config.js | 2 +- apps/demo/package.json | 7 +- apps/demo/paykit.config.ts | 4 + apps/demo/paykit.polar.config.ts | 4 - apps/demo/paykit.stripe.config.ts | 4 - apps/demo/scripts/push-sandbox.ts | 20 +- apps/demo/scripts/sandbox.ts | 2 +- .../app/_components/checkout-page-content.tsx | 40 +- .../src/app/_components/features-panel.tsx | 15 +- .../src/app/_components/subscribe-panel.tsx | 87 +- .../src/app/paykit-polar/[[...slug]]/route.ts | 12 - .../app/paykit-stripe/[[...slug]]/route.ts | 12 - apps/demo/src/app/paykit/[[...slug]]/route.ts | 4 + apps/demo/src/env.js | 14 +- apps/demo/src/lib/paykit-client.ts | 13 +- apps/demo/src/lib/paykit-scenarios.ts | 6 - apps/demo/src/lib/paykit.ts | 31 +- apps/demo/src/lib/paykit/polar.ts | 52 - apps/demo/src/lib/paykit/stripe.ts | 52 - apps/demo/src/lib/scenario-config.ts | 24 - apps/demo/src/server/api/root.ts | 6 +- .../src/server/api/routers/paykit-route.ts | 9 +- apps/demo/src/server/db.ts | 21 +- apps/web/content/docs/concepts/cli.mdx | 2 +- .../docs/concepts/payment-providers.mdx | 24 +- apps/web/content/docs/get-started/index.mdx | 2 +- .../content/docs/get-started/installation.mdx | 14 +- apps/web/content/docs/providers/creem.mdx | 32 - .../content/docs/providers/lemonsqueezy.mdx | 25 - apps/web/content/docs/providers/meta.json | 2 +- apps/web/content/docs/providers/paddle.mdx | 32 - apps/web/content/docs/providers/paypal.mdx | 36 - apps/web/content/docs/providers/polar.mdx | 140 -- apps/web/content/docs/providers/stripe.mdx | 21 +- apps/web/src/components/docs/docs-icons.tsx | 2 +- apps/web/src/components/docs/features.tsx | 4 +- .../components/sections/demo/demo-types.tsx | 2 +- .../components/sections/features-section.tsx | 4 +- .../sections/readme-code-content.ts | 10 +- e2e/cli/init.test.ts | 9 +- e2e/cli/push.test.ts | 18 +- e2e/cli/setup.ts | 6 +- e2e/cli/status.test.ts | 2 +- .../subscribe/cancel-end-of-cycle.test.ts | 210 ++- e2e/core/subscribe/renewal.test.ts | 206 ++- e2e/core/webhook/duplicate-webhook.test.ts | 6 +- e2e/core/webhook/subscription-deleted.test.ts | 12 +- e2e/package.json | 4 - e2e/test-utils/env.ts | 6 +- e2e/test-utils/harness/index.ts | 5 +- e2e/test-utils/harness/polar.ts | 77 - e2e/test-utils/harness/stripe.ts | 49 +- e2e/test-utils/harness/types.ts | 10 +- e2e/test-utils/setup.ts | 18 +- e2e/vitest.config.ts | 6 +- packages/paykit/package.json | 10 +- .../src/api/__tests__/define-route.test.ts | 9 +- .../paykit/src/api/__tests__/methods.test.ts | 9 +- packages/paykit/src/api/methods.ts | 8 +- .../paykit/src/cli/__tests__/init.test.ts | 17 + packages/paykit/src/cli/commands/init.ts | 124 +- packages/paykit/src/cli/commands/listen.ts | 77 +- packages/paykit/src/cli/commands/push.ts | 21 +- packages/paykit/src/cli/commands/status.ts | 25 +- packages/paykit/src/cli/utils/get-config.ts | 2 +- packages/paykit/src/cli/utils/shared.ts | 38 +- .../paykit/src/core/__tests__/context.test.ts | 22 +- .../src/core/__tests__/create-paykit.test.ts | 81 + packages/paykit/src/core/context.ts | 5 +- packages/paykit/src/core/create-paykit.ts | 30 +- .../__tests__/customer.service.test.ts | 74 +- .../paykit/src/customer/customer.service.ts | 44 +- .../migrations/0001_stripe_only_schema.sql | 158 ++ .../migrations/meta/0001_snapshot.json | 1346 +++++++++++++++++ .../database/migrations/meta/_journal.json | 9 +- packages/paykit/src/database/schema.ts | 62 +- packages/paykit/src/index.ts | 11 +- .../paykit/src/invoice/invoice.service.ts | 18 +- .../payment-method/payment-method.service.ts | 44 +- .../paykit/src/payment/payment.service.ts | 18 +- .../paykit/src/product/product.service.ts | 47 +- packages/paykit/src/providers/provider.ts | 14 +- .../src/stripe}/stripe-provider.ts | 49 +- .../src/subscription/subscription.service.ts | 131 +- .../paykit/src/testing/testing.service.ts | 4 - packages/paykit/src/types/instance.ts | 9 +- packages/paykit/src/types/options.ts | 8 +- .../dependencies/paykit-package-list.ts | 2 +- packages/paykit/src/webhook/webhook.api.ts | 6 +- .../paykit/src/webhook/webhook.service.ts | 17 +- packages/polar/package.json | 36 - packages/polar/src/index.ts | 2 - packages/polar/src/polar-provider.ts | 569 ------- packages/polar/tsconfig.json | 7 - packages/polar/tsdown.config.ts | 14 - packages/stripe/package.json | 36 - .../src/__tests__/stripe-provider.test.ts | 30 - packages/stripe/src/__tests__/stripe.test.ts | 219 --- packages/stripe/src/index.ts | 3 - packages/stripe/tsconfig.json | 7 - packages/stripe/tsdown.config.ts | 14 - pnpm-lock.yaml | 61 +- scripts/publish-dist.mjs | 2 +- 105 files changed, 2419 insertions(+), 2577 deletions(-) create mode 100644 apps/demo/paykit.config.ts delete mode 100644 apps/demo/paykit.polar.config.ts delete mode 100644 apps/demo/paykit.stripe.config.ts delete mode 100644 apps/demo/src/app/paykit-polar/[[...slug]]/route.ts delete mode 100644 apps/demo/src/app/paykit-stripe/[[...slug]]/route.ts create mode 100644 apps/demo/src/app/paykit/[[...slug]]/route.ts delete mode 100644 apps/demo/src/lib/paykit-scenarios.ts delete mode 100644 apps/demo/src/lib/paykit/polar.ts delete mode 100644 apps/demo/src/lib/paykit/stripe.ts delete mode 100644 apps/web/content/docs/providers/creem.mdx delete mode 100644 apps/web/content/docs/providers/lemonsqueezy.mdx delete mode 100644 apps/web/content/docs/providers/paddle.mdx delete mode 100644 apps/web/content/docs/providers/paypal.mdx delete mode 100644 apps/web/content/docs/providers/polar.mdx delete mode 100644 e2e/test-utils/harness/polar.ts create mode 100644 packages/paykit/src/cli/__tests__/init.test.ts create mode 100644 packages/paykit/src/core/__tests__/create-paykit.test.ts create mode 100644 packages/paykit/src/database/migrations/0001_stripe_only_schema.sql create mode 100644 packages/paykit/src/database/migrations/meta/0001_snapshot.json rename packages/{stripe/src => paykit/src/stripe}/stripe-provider.ts (96%) delete mode 100644 packages/polar/package.json delete mode 100644 packages/polar/src/index.ts delete mode 100644 packages/polar/src/polar-provider.ts delete mode 100644 packages/polar/tsconfig.json delete mode 100644 packages/polar/tsdown.config.ts delete mode 100644 packages/stripe/package.json delete mode 100644 packages/stripe/src/__tests__/stripe-provider.test.ts delete mode 100644 packages/stripe/src/__tests__/stripe.test.ts delete mode 100644 packages/stripe/src/index.ts delete mode 100644 packages/stripe/tsconfig.json delete mode 100644 packages/stripe/tsdown.config.ts diff --git a/README.md b/README.md index bf184ef4..a29e7224 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@

The billing framework for TypeScript

- Define products in code. Any provider. Gate features. Track usage. + Define plans in code. Gate features. Track usage. Webhooks handled for you.

@@ -37,7 +37,6 @@ PayKit is an embedded billing framework for TypeScript apps. It sits inside your app, uses your database, and gives you a single API to manage products, subscriptions, entitlements, and usage billing without touching provider dashboards. ```ts -import { stripe } from "@paykitjs/stripe"; import { createPayKit, feature, plan } from "paykitjs"; const messages = feature({ id: "messages", type: "metered" }); @@ -57,10 +56,10 @@ const pro = plan({ }); export const paykit = createPayKit({ - provider: stripe({ + stripe: { secretKey: process.env.STRIPE_SECRET_KEY!, webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!, - }), + }, database: process.env.DATABASE_URL!, products: [free, pro], }); diff --git a/apps/demo/drizzle.config.ts b/apps/demo/drizzle.config.ts index fb6c47b1..7d52f3c5 100644 --- a/apps/demo/drizzle.config.ts +++ b/apps/demo/drizzle.config.ts @@ -4,8 +4,9 @@ import "dotenv/config"; export default defineConfig({ dialect: "postgresql", schema: "../../packages/paykit/src/database/schema.ts", + out: "../../packages/paykit/src/database/migrations", dbCredentials: { - url: process.env.POLAR_DATABASE_URL!, + url: process.env.PAYKIT_DATABASE_URL!, }, migrations: { schema: "public", diff --git a/apps/demo/next.config.js b/apps/demo/next.config.js index 4669aed0..f6c58677 100644 --- a/apps/demo/next.config.js +++ b/apps/demo/next.config.js @@ -7,7 +7,7 @@ import { fileURLToPath } from "node:url"; /** @type {import("next").NextConfig} */ const config = { - transpilePackages: ["paykitjs", "@paykitjs/polar", "@paykitjs/stripe", "autumn-js"], + transpilePackages: ["paykitjs", "autumn-js"], serverExternalPackages: ["pg"], turbopack: { root: fileURLToPath(new URL("../..", import.meta.url)), diff --git a/apps/demo/package.json b/apps/demo/package.json index 1b02511c..490de3ab 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -18,17 +18,14 @@ "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:polar && bun push:paykit:stripe && bun push:autumn", + "push": "bun push:auth && bun push:paykit && bun push:autumn", "push:auth": "bunx auth migrate --config src/lib/auth.ts --yes", - "push:paykit:polar": "set -a; [ -f .env ] && . ./.env; set +a; if [ -n \"$POLAR_DATABASE_URL\" ] && [ -n \"$POLAR_ACCESS_TOKEN\" ] && [ -n \"$POLAR_WEBHOOK_SECRET\" ]; then bunx paykitjs push --config paykit.polar.config.ts --yes; else printf '%s\\n' 'Skipping PayKit Polar push: provider env incomplete'; fi", - "push:paykit:stripe": "set -a; [ -f .env ] && . ./.env; set +a; if [ -n \"$STRIPE_DATABASE_URL\" ] && [ -n \"$STRIPE_SECRET_KEY\" ] && [ -n \"$STRIPE_WEBHOOK_SECRET\" ]; then bunx paykitjs push --config paykit.stripe.config.ts --yes; else printf '%s\\n' 'Skipping PayKit Stripe push: provider env incomplete'; fi", + "push:paykit": "set -a; [ -f .env ] && . ./.env; set +a; bunx paykitjs push --config paykit.config.ts --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" }, "dependencies": { "@base-ui/react": "^1.2.0", - "@paykitjs/polar": "workspace:*", - "@paykitjs/stripe": "workspace:*", "@t3-oss/env-nextjs": "^0.12.0", "@tanstack/react-query": "^5.69.0", "@trpc/client": "^11.0.0", diff --git a/apps/demo/paykit.config.ts b/apps/demo/paykit.config.ts new file mode 100644 index 00000000..4196ea45 --- /dev/null +++ b/apps/demo/paykit.config.ts @@ -0,0 +1,4 @@ +import { paykit } from "./src/lib/paykit"; + +export { paykit }; +export default paykit; diff --git a/apps/demo/paykit.polar.config.ts b/apps/demo/paykit.polar.config.ts deleted file mode 100644 index a3c394ef..00000000 --- a/apps/demo/paykit.polar.config.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { requirePaykitPolar } from "./src/lib/paykit/polar"; - -export const paykit = requirePaykitPolar(); -export default paykit; diff --git a/apps/demo/paykit.stripe.config.ts b/apps/demo/paykit.stripe.config.ts deleted file mode 100644 index 587ddd69..00000000 --- a/apps/demo/paykit.stripe.config.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { requirePaykitStripe } from "./src/lib/paykit/stripe"; - -export const paykit = requirePaykitStripe(); -export default paykit; diff --git a/apps/demo/scripts/push-sandbox.ts b/apps/demo/scripts/push-sandbox.ts index b84b56d8..cd7c06be 100644 --- a/apps/demo/scripts/push-sandbox.ts +++ b/apps/demo/scripts/push-sandbox.ts @@ -30,28 +30,16 @@ async function main() { env, ); - if (hasEnv(values, ["POLAR_DATABASE_URL", "POLAR_ACCESS_TOKEN", "POLAR_WEBHOOK_SECRET"])) { - console.log("Push PayKit Polar config"); + if (hasEnv(values, ["PAYKIT_DATABASE_URL", "STRIPE_SECRET_KEY", "STRIPE_WEBHOOK_SECRET"])) { + console.log("Push PayKit config"); await runCommand( "bunx", - ["paykitjs", "push", "--config", "paykit.polar.config.ts", "--yes"], + ["paykitjs", "push", "--config", "paykit.config.ts", "--yes"], demoDir, env, ); } else { - console.log("Skipping PayKit Polar push: provider env incomplete"); - } - - if (hasEnv(values, ["STRIPE_DATABASE_URL", "STRIPE_SECRET_KEY", "STRIPE_WEBHOOK_SECRET"])) { - console.log("Push PayKit Stripe config"); - await runCommand( - "bunx", - ["paykitjs", "push", "--config", "paykit.stripe.config.ts", "--yes"], - demoDir, - env, - ); - } else { - console.log("Skipping PayKit Stripe push: provider env incomplete"); + console.log("Skipping PayKit push: env incomplete"); } if (hasEnv(values, ["AUTUMN_SECRET_KEY"])) { diff --git a/apps/demo/scripts/sandbox.ts b/apps/demo/scripts/sandbox.ts index ffa1b54d..b1f88a76 100644 --- a/apps/demo/scripts/sandbox.ts +++ b/apps/demo/scripts/sandbox.ts @@ -15,7 +15,7 @@ export const sandboxConfig = { target: "production", } as const; -export const paykitPackages = ["paykitjs", "@paykitjs/polar", "@paykitjs/stripe"] as const; +export const paykitPackages = ["paykitjs"] as const; const scriptsDir = fileURLToPath(new URL(".", import.meta.url)); diff --git a/apps/demo/src/app/_components/checkout-page-content.tsx b/apps/demo/src/app/_components/checkout-page-content.tsx index 3c121f64..73275a99 100644 --- a/apps/demo/src/app/_components/checkout-page-content.tsx +++ b/apps/demo/src/app/_components/checkout-page-content.tsx @@ -13,18 +13,16 @@ import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { authClient } from "@/lib/auth-client"; -import { paykitScenarios } from "@/lib/paykit-scenarios"; -import type { PayKitScenario } from "@/lib/paykit-scenarios"; import { api } from "@/trpc/react"; -function PayKitTabContent({ scenario }: { scenario: PayKitScenario }) { +function PayKitTabContent() { return ( - - + +

Features

- +
); @@ -37,14 +35,8 @@ export function CheckoutPageContent() { const router = useRouter(); const toastShown = useRef(false); - const configuredPaykitScenarios = paykitScenarios.filter( - (scenario) => scenarios.data?.[scenario.id]?.configured, - ); const hasAutumn = scenarios.data?.autumn?.configured === true; - const availableTabs = [ - ...configuredPaykitScenarios.map((scenario) => scenario.tab), - ...(hasAutumn ? ["autumn-stripe"] : []), - ]; + const availableTabs = ["paykit", ...(hasAutumn ? ["autumn-stripe"] : [])]; const tab = searchParams.get("tab"); const activeTab = tab && availableTabs.includes(tab) ? tab : availableTabs[0]; const setTab = useCallback( @@ -109,18 +101,6 @@ export function CheckoutPageContent() { ); } - if (!activeTab) { - return ( -
-

Billing

-

- No billing providers are configured. Add a complete provider env group and restart the - demo. -

-
- ); - } - return (
@@ -146,16 +126,10 @@ export function CheckoutPageContent() { - {configuredPaykitScenarios.map((scenario) => ( - - {scenario.label} - - ))} + PayKit {hasAutumn ? Autumn Stripe : null} - {configuredPaykitScenarios.map((scenario) => ( - - ))} + {hasAutumn ? ( diff --git a/apps/demo/src/app/_components/features-panel.tsx b/apps/demo/src/app/_components/features-panel.tsx index 5efaa405..9a9799bc 100644 --- a/apps/demo/src/app/_components/features-panel.tsx +++ b/apps/demo/src/app/_components/features-panel.tsx @@ -7,11 +7,9 @@ import { Badge } from "@/components/ui/badge"; import { Progress } from "@/components/ui/progress"; import { Skeleton } from "@/components/ui/skeleton"; import { featureCatalog } from "@/lib/demo-catalog"; -import type { PayKitScenario } from "@/lib/paykit-scenarios"; import { api } from "@/trpc/react"; function MeteredFeatureRow({ - scenario, featureId, name, description, @@ -19,11 +17,10 @@ function MeteredFeatureRow({ description: string; featureId: string; name: string; - scenario: PayKitScenario; }) { const utils = api.useUtils(); - const paykitApi = scenario === "polar" ? api.paykitPolar : api.paykitStripe; - const paykitUtils = scenario === "polar" ? utils.paykitPolar : utils.paykitStripe; + const paykitApi = api.paykit; + const paykitUtils = utils.paykit; const { data, isLoading } = paykitApi.checkFeature.useQuery({ featureId, }); @@ -78,7 +75,6 @@ function MeteredFeatureRow({ } function BooleanFeatureRow({ - scenario, featureId, name, description, @@ -86,9 +82,8 @@ function BooleanFeatureRow({ description: string; featureId: string; name: string; - scenario: PayKitScenario; }) { - const paykitApi = scenario === "polar" ? api.paykitPolar : api.paykitStripe; + const paykitApi = api.paykit; const { data, isLoading } = paykitApi.checkFeature.useQuery({ featureId, }); @@ -110,14 +105,13 @@ function BooleanFeatureRow({ ); } -export function FeaturesPanel({ scenario }: { scenario: PayKitScenario }) { +export function FeaturesPanel() { return (
{featureCatalog.map((feat) => feat.type === "metered" ? ( - - - Test clock - Not supported - - - This provider does not support test clocks, so billing time cannot be advanced in the - demo. - - - - ); -} - -function TestClockLoadingPanel() { - return ( - - - Test clock - Checking provider support. - - - - - - - ); -} - -function TestClockErrorPanel({ message }: { message: string }) { - return ( - - - Test clock - Failed to determine test clock support. - - -

{message}

-
-
- ); -} - function StripeTestClockPanel() { const utils = api.useUtils(); const queryClient = useQueryClient(); const testClock = useQuery({ - queryFn: async () => paykitStripeClient.getTestClock({}), + queryFn: async () => paykitClient.getTestClock({}), queryKey: stripeTestClockQueryKey, }); const advanceClock = useMutation({ mutationFn: async (frozenTime: Date) => { - const result = await paykitStripeClient.advanceTestClock({ frozenTime }); + const result = await paykitClient.advanceTestClock({ frozenTime }); return result; }, onError: (error) => { @@ -151,8 +104,8 @@ function StripeTestClockPanel() { toast.success("Advanced test clock"); await Promise.all([ queryClient.invalidateQueries({ queryKey: stripeTestClockQueryKey }), - utils.paykitStripe.currentPlans.invalidate(), - utils.paykitStripe.checkFeature.invalidate(), + utils.paykit.currentPlans.invalidate(), + utils.paykit.checkFeature.invalidate(), ]); }, }); @@ -231,12 +184,10 @@ function StripeTestClockPanel() { ); } -export function SubscribePanel({ scenario }: { scenario: PayKitScenario }) { +export function SubscribePanel() { const utils = api.useUtils(); - const paykitApi = scenario === "polar" ? api.paykitPolar : api.paykitStripe; - const paykitUtils = scenario === "polar" ? utils.paykitPolar : utils.paykitStripe; - const paykitClient = scenario === "polar" ? paykitPolarClient : paykitStripeClient; - const capabilities = paykitApi.capabilities.useQuery(); + const paykitApi = api.paykit; + const paykitUtils = utils.paykit; const { data: currentPlans, isLoading: isLoadingPlans } = paykitApi.currentPlans.useQuery(); const activePlan = currentPlans?.find((plan) => ["active", "trialing", "past_due"].includes(plan.status)) ?? null; @@ -258,8 +209,8 @@ export function SubscribePanel({ scenario }: { scenario: PayKitScenario }) { mutationFn: async ({ planId }: { planId: PlanId }) => { const result = await paykitClient.subscribe({ planId, - successUrl: `/?tab=paykit-${scenario}&checkout=success`, - cancelUrl: `/?tab=paykit-${scenario}&checkout=canceled`, + successUrl: "/?tab=paykit&checkout=success", + cancelUrl: "/?tab=paykit&checkout=canceled", }); return { planId, result }; }, @@ -332,21 +283,7 @@ export function SubscribePanel({ scenario }: { scenario: PayKitScenario }) { )} - {capabilities.isLoading ? ( - - ) : capabilities.isError ? ( - - ) : capabilities.data?.testClocks && scenario === "stripe" ? ( - - ) : ( - - )} + diff --git a/apps/demo/src/app/paykit-polar/[[...slug]]/route.ts b/apps/demo/src/app/paykit-polar/[[...slug]]/route.ts deleted file mode 100644 index 1f13e1b8..00000000 --- a/apps/demo/src/app/paykit-polar/[[...slug]]/route.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { getPaykitPolar } from "@/lib/paykit/polar"; - -function handle(request: Request) { - const paykit = getPaykitPolar(); - if (!paykit) { - return Response.json({ error: "PayKit Polar is not configured" }, { status: 404 }); - } - return paykit.handler(request); -} - -export const GET = handle; -export const POST = handle; diff --git a/apps/demo/src/app/paykit-stripe/[[...slug]]/route.ts b/apps/demo/src/app/paykit-stripe/[[...slug]]/route.ts deleted file mode 100644 index f19e5676..00000000 --- a/apps/demo/src/app/paykit-stripe/[[...slug]]/route.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { getPaykitStripe } from "@/lib/paykit/stripe"; - -function handle(request: Request) { - const paykit = getPaykitStripe(); - if (!paykit) { - return Response.json({ error: "PayKit Stripe is not configured" }, { status: 404 }); - } - return paykit.handler(request); -} - -export const GET = handle; -export const POST = handle; diff --git a/apps/demo/src/app/paykit/[[...slug]]/route.ts b/apps/demo/src/app/paykit/[[...slug]]/route.ts new file mode 100644 index 00000000..216398fc --- /dev/null +++ b/apps/demo/src/app/paykit/[[...slug]]/route.ts @@ -0,0 +1,4 @@ +import { paykit } from "@/lib/paykit"; + +export const GET = paykit.handler; +export const POST = paykit.handler; diff --git a/apps/demo/src/env.js b/apps/demo/src/env.js index 54847974..a3988a46 100644 --- a/apps/demo/src/env.js +++ b/apps/demo/src/env.js @@ -10,12 +10,9 @@ export const env = createEnv({ APP_URL: z.url(), AUTH_DATABASE_URL: z.string().min(1), NODE_ENV: z.enum(["development", "test", "production"]).default("development"), - POLAR_DATABASE_URL: z.string().min(1).optional(), - POLAR_ACCESS_TOKEN: z.string().min(1).optional(), - POLAR_WEBHOOK_SECRET: z.string().min(1).optional(), - STRIPE_DATABASE_URL: z.string().min(1).optional(), - STRIPE_SECRET_KEY: z.string().min(1).optional(), - STRIPE_WEBHOOK_SECRET: z.string().min(1).optional(), + PAYKIT_DATABASE_URL: z.string().min(1), + STRIPE_SECRET_KEY: z.string().min(1), + STRIPE_WEBHOOK_SECRET: z.string().min(1), BETTER_AUTH_SECRET: z.string().min(1), AUTUMN_SECRET_KEY: z.string().min(1).optional(), }, @@ -37,10 +34,7 @@ export const env = createEnv({ APP_URL: process.env.APP_URL, AUTH_DATABASE_URL: process.env.AUTH_DATABASE_URL, NODE_ENV: process.env.NODE_ENV, - POLAR_DATABASE_URL: process.env.POLAR_DATABASE_URL, - POLAR_ACCESS_TOKEN: process.env.POLAR_ACCESS_TOKEN, - POLAR_WEBHOOK_SECRET: process.env.POLAR_WEBHOOK_SECRET, - STRIPE_DATABASE_URL: process.env.STRIPE_DATABASE_URL, + PAYKIT_DATABASE_URL: process.env.PAYKIT_DATABASE_URL, STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET, BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET, diff --git a/apps/demo/src/lib/paykit-client.ts b/apps/demo/src/lib/paykit-client.ts index 885238f1..a057ebc9 100644 --- a/apps/demo/src/lib/paykit-client.ts +++ b/apps/demo/src/lib/paykit-client.ts @@ -1,14 +1,9 @@ import { createPayKitClient } from "paykitjs/client"; -import type { PaykitPolarInstance } from "@/lib/paykit/polar"; -import type { PaykitStripeInstance } from "@/lib/paykit/stripe"; +import type { PayKitInstance } from "@/lib/paykit"; -type ClientInstance = T & { options: { identify: (...args: never[]) => unknown } }; +type ClientInstance = T & { options: T extends { options: infer TOptions } ? TOptions : never }; -export const paykitPolarClient = createPayKitClient>({ - baseURL: "/paykit-polar", -}); - -export const paykitStripeClient = createPayKitClient>({ - baseURL: "/paykit-stripe", +export const paykitClient = createPayKitClient>({ + baseURL: "/paykit", }); diff --git a/apps/demo/src/lib/paykit-scenarios.ts b/apps/demo/src/lib/paykit-scenarios.ts deleted file mode 100644 index a02a7738..00000000 --- a/apps/demo/src/lib/paykit-scenarios.ts +++ /dev/null @@ -1,6 +0,0 @@ -export type PayKitScenario = "polar" | "stripe"; - -export const paykitScenarios = [ - { id: "polar", label: "PayKit Polar", tab: "paykit-polar" }, - { id: "stripe", label: "PayKit Stripe", tab: "paykit-stripe" }, -] as const satisfies ReadonlyArray<{ id: PayKitScenario; label: string; tab: string }>; diff --git a/apps/demo/src/lib/paykit.ts b/apps/demo/src/lib/paykit.ts index 6262c908..51475c24 100644 --- a/apps/demo/src/lib/paykit.ts +++ b/apps/demo/src/lib/paykit.ts @@ -1,2 +1,29 @@ -export { requirePaykitPolar as paykit } from "@/lib/paykit/polar"; -export type { PayKitPolar as PayKit } from "@/lib/paykit/polar"; +import { createPayKit } from "paykitjs"; + +import { env } from "@/env"; +import { auth } from "@/lib/auth"; +import { free, pro, ultra } from "@/lib/paykit-products"; +import { paykitPool } from "@/server/db"; + +export const paykit = createPayKit({ + basePath: "/paykit", + database: paykitPool, + stripe: { + secretKey: env.STRIPE_SECRET_KEY, + webhookSecret: env.STRIPE_WEBHOOK_SECRET, + }, + testing: { enabled: true }, + products: [pro, ultra, free], + identify: async (request) => { + const session = await auth.api.getSession({ headers: request.headers }); + if (!session) return null; + return { + customerId: session.user.id, + email: session.user.email, + name: session.user.name ?? undefined, + }; + }, +}); + +export type PayKit = (typeof paykit)["$infer"]; +export type PayKitInstance = typeof paykit; diff --git a/apps/demo/src/lib/paykit/polar.ts b/apps/demo/src/lib/paykit/polar.ts deleted file mode 100644 index 1d679f06..00000000 --- a/apps/demo/src/lib/paykit/polar.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { polar } from "@paykitjs/polar"; -import { createPayKit } from "paykitjs"; - -import { env } from "@/env"; -import { auth } from "@/lib/auth"; -import { free, pro, ultra } from "@/lib/paykit-products"; -import { requireScenarioEnv, scenarioConfig } from "@/lib/scenario-config"; -import { getPaykitPolarPool } from "@/server/db"; - -function createPaykitPolar() { - return createPayKit({ - basePath: "/paykit-polar", - database: getPaykitPolarPool(), - provider: polar({ - accessToken: requireScenarioEnv(env.POLAR_ACCESS_TOKEN, "POLAR_ACCESS_TOKEN"), - webhookSecret: requireScenarioEnv(env.POLAR_WEBHOOK_SECRET, "POLAR_WEBHOOK_SECRET"), - server: "sandbox", - }), - products: [pro, ultra, free], - identify: async (request) => { - const session = await auth.api.getSession({ headers: request.headers }); - if (!session) return null; - return { - customerId: session.user.id, - email: session.user.email, - name: session.user.name ?? undefined, - }; - }, - }); -} - -export type PaykitPolarInstance = ReturnType; - -let paykitPolar: PaykitPolarInstance | undefined; - -export function isPaykitPolarConfigured() { - return scenarioConfig.polar.configured; -} - -export function getPaykitPolar() { - if (!isPaykitPolarConfigured()) return null; - paykitPolar ??= createPaykitPolar(); - return paykitPolar; -} - -export function requirePaykitPolar() { - const paykit = getPaykitPolar(); - if (!paykit) throw new Error("PayKit Polar is not configured"); - return paykit; -} - -export type PayKitPolar = PaykitPolarInstance["$infer"]; diff --git a/apps/demo/src/lib/paykit/stripe.ts b/apps/demo/src/lib/paykit/stripe.ts deleted file mode 100644 index 359cb76f..00000000 --- a/apps/demo/src/lib/paykit/stripe.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { stripe } from "@paykitjs/stripe"; -import { createPayKit } from "paykitjs"; - -import { env } from "@/env"; -import { auth } from "@/lib/auth"; -import { free, pro, ultra } from "@/lib/paykit-products"; -import { requireScenarioEnv, scenarioConfig } from "@/lib/scenario-config"; -import { getPaykitStripePool } from "@/server/db"; - -function createPaykitStripe() { - return createPayKit({ - basePath: "/paykit-stripe", - database: getPaykitStripePool(), - provider: stripe({ - secretKey: requireScenarioEnv(env.STRIPE_SECRET_KEY, "STRIPE_SECRET_KEY"), - webhookSecret: requireScenarioEnv(env.STRIPE_WEBHOOK_SECRET, "STRIPE_WEBHOOK_SECRET"), - }), - testing: { enabled: true }, - products: [pro, ultra, free], - identify: async (request) => { - const session = await auth.api.getSession({ headers: request.headers }); - if (!session) return null; - return { - customerId: session.user.id, - email: session.user.email, - name: session.user.name ?? undefined, - }; - }, - }); -} - -export type PaykitStripeInstance = ReturnType; - -let paykitStripe: PaykitStripeInstance | undefined; - -export function isPaykitStripeConfigured() { - return scenarioConfig.stripe.configured; -} - -export function getPaykitStripe() { - if (!isPaykitStripeConfigured()) return null; - paykitStripe ??= createPaykitStripe(); - return paykitStripe; -} - -export function requirePaykitStripe() { - const paykit = getPaykitStripe(); - if (!paykit) throw new Error("PayKit Stripe is not configured"); - return paykit; -} - -export type PayKitStripe = PaykitStripeInstance["$infer"]; diff --git a/apps/demo/src/lib/scenario-config.ts b/apps/demo/src/lib/scenario-config.ts index e69405f4..d14eca9e 100644 --- a/apps/demo/src/lib/scenario-config.ts +++ b/apps/demo/src/lib/scenario-config.ts @@ -6,20 +6,6 @@ export const scenarioConfig = { label: "Autumn Stripe", tab: "autumn-stripe", }, - polar: { - configured: Boolean( - env.POLAR_DATABASE_URL && env.POLAR_ACCESS_TOKEN && env.POLAR_WEBHOOK_SECRET, - ), - label: "PayKit Polar", - tab: "paykit-polar", - }, - stripe: { - configured: Boolean( - env.STRIPE_DATABASE_URL && env.STRIPE_SECRET_KEY && env.STRIPE_WEBHOOK_SECRET, - ), - label: "PayKit Stripe", - tab: "paykit-stripe", - }, } as const; export type ScenarioConfig = typeof scenarioConfig; @@ -29,13 +15,3 @@ export function getConfiguredScenarios() { Object.entries(scenarioConfig).filter(([, scenario]) => scenario.configured), ) as Partial; } - -export function requireScenarioEnv( - value: T, - name: string, -): NonNullable { - if (!value) { - throw new Error(`Missing ${name}`); - } - return value; -} diff --git a/apps/demo/src/server/api/root.ts b/apps/demo/src/server/api/root.ts index 00fa59aa..d71150b9 100644 --- a/apps/demo/src/server/api/root.ts +++ b/apps/demo/src/server/api/root.ts @@ -1,5 +1,4 @@ -import { getPaykitPolar } from "@/lib/paykit/polar"; -import { getPaykitStripe } from "@/lib/paykit/stripe"; +import { paykit } from "@/lib/paykit"; import { getConfiguredScenarios } from "@/lib/scenario-config"; import { autumnRouter } from "@/server/api/routers/autumn"; import { createPaykitRouter } from "@/server/api/routers/paykit-route"; @@ -13,8 +12,7 @@ import { createCallerFactory, createTRPCRouter, publicProcedure } from "@/server */ export const appRouter = createTRPCRouter({ autumn: autumnRouter, - paykitPolar: createPaykitRouter(getPaykitPolar), - paykitStripe: createPaykitRouter(getPaykitStripe), + paykit: createPaykitRouter(() => paykit), post: postRouter, scenarios: createTRPCRouter({ list: publicProcedure.query(() => getConfiguredScenarios()), diff --git a/apps/demo/src/server/api/routers/paykit-route.ts b/apps/demo/src/server/api/routers/paykit-route.ts index 836de574..8885c927 100644 --- a/apps/demo/src/server/api/routers/paykit-route.ts +++ b/apps/demo/src/server/api/routers/paykit-route.ts @@ -7,7 +7,7 @@ import { createTRPCRouter, publicProcedure } from "@/server/api/trpc"; type DemoPayKit = { $infer: { featureId: TFeatureId }; - options: Pick; + options: Pick; check(input: { customerId: string; featureId: TFeatureId; @@ -29,17 +29,12 @@ export function createPaykitRouter( function requirePaykit() { const paykit = getPaykit(); if (!paykit) { - throw new TRPCError({ code: "NOT_FOUND", message: "PayKit provider is not configured" }); + throw new TRPCError({ code: "NOT_FOUND", message: "PayKit is not configured" }); } return paykit; } return createTRPCRouter({ - capabilities: publicProcedure.query(() => { - const paykit = getPaykit(); - return paykit?.options.provider.capabilities ?? { testClocks: false }; - }), - createCustomer: publicProcedure.mutation(async ({ ctx }) => { const paykit = requirePaykit(); const session = await auth.api.getSession({ headers: ctx.headers }); diff --git a/apps/demo/src/server/db.ts b/apps/demo/src/server/db.ts index ee29704d..df1f24ee 100644 --- a/apps/demo/src/server/db.ts +++ b/apps/demo/src/server/db.ts @@ -4,29 +4,16 @@ import { env } from "@/env"; const globalForPool = globalThis as typeof globalThis & { demoAuthPool?: Pool; - demoPaykitPolarPool?: Pool; - demoPaykitStripePool?: Pool; + demoPaykitPool?: Pool; }; export const authPool = globalForPool.demoAuthPool ?? new Pool({ connectionString: env.AUTH_DATABASE_URL }); -export function getPaykitPolarPool() { - if (!env.POLAR_DATABASE_URL) { - throw new Error("Missing POLAR_DATABASE_URL"); - } - globalForPool.demoPaykitPolarPool ??= new Pool({ connectionString: env.POLAR_DATABASE_URL }); - return globalForPool.demoPaykitPolarPool; -} - -export function getPaykitStripePool() { - if (!env.STRIPE_DATABASE_URL) { - throw new Error("Missing STRIPE_DATABASE_URL"); - } - globalForPool.demoPaykitStripePool ??= new Pool({ connectionString: env.STRIPE_DATABASE_URL }); - return globalForPool.demoPaykitStripePool; -} +export const paykitPool = + globalForPool.demoPaykitPool ?? new Pool({ connectionString: env.PAYKIT_DATABASE_URL }); if (process.env.NODE_ENV !== "production") { globalForPool.demoAuthPool = authPool; + globalForPool.demoPaykitPool = paykitPool; } diff --git a/apps/web/content/docs/concepts/cli.mdx b/apps/web/content/docs/concepts/cli.mdx index d239f1d5..dcbade5e 100644 --- a/apps/web/content/docs/concepts/cli.mdx +++ b/apps/web/content/docs/concepts/cli.mdx @@ -9,7 +9,7 @@ PayKit includes a CLI tool for project setup, database migrations, and plan sync -An interactive setup wizard that scaffolds everything you need to get started. It asks you to pick a provider (Stripe, Polar, or Creem), then generates: +An interactive setup wizard that scaffolds everything you need to get started. It configures Stripe, then generates: - A `paykit.ts` config file with an example plan structure - A route handler for webhooks diff --git a/apps/web/content/docs/concepts/payment-providers.mdx b/apps/web/content/docs/concepts/payment-providers.mdx index 73c7c8bf..cf61106f 100644 --- a/apps/web/content/docs/concepts/payment-providers.mdx +++ b/apps/web/content/docs/concepts/payment-providers.mdx @@ -1,33 +1,31 @@ --- title: Payment Providers -description: How PayKit abstracts provider integration and keeps provider-native IDs internal. +description: How PayKit keeps Stripe-native IDs internal. --- -PayKit uses a provider abstraction to communicate with payment processors. Your app works with plans, customers, and subscriptions. PayKit translates those into provider-native operations behind the scenes. +PayKit uses Stripe for billing. Your app works with plans, customers, and subscriptions. PayKit translates those into Stripe-native operations behind the scenes. ## How it works -You install a provider adapter package and pass it to `createPayKit({ provider })`. The adapter handles all provider-specific API calls, webhook normalization, and product syncing. Your app code doesn't change if you swap providers. +Pass your Stripe keys to `createPayKit({ stripe })`. PayKit handles Stripe API calls, webhook processing, and product syncing. ```ts title="paykit.ts" -import { stripe } from "@paykitjs/stripe"; - export const paykit = createPayKit({ // ... - provider: stripe({ + stripe: { secretKey: process.env.STRIPE_SECRET_KEY!, webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!, - }), + }, }); ``` ## Stripe -Stripe is currently the primary supported provider. It covers subscriptions, usage-based billing, webhooks, and product syncing out of the box. +Stripe covers subscriptions, usage-based billing, webhooks, and product syncing out of the box. See the [Stripe provider page](/docs/providers/stripe) for full setup instructions, webhook configuration, and available options. -## Provider-native IDs stay internal +## Stripe IDs stay internal Your app identifies customers and plans with its own IDs. PayKit maps them to Stripe customer IDs, product IDs, and price IDs internally. You never store or reference Stripe IDs in your app code. @@ -39,11 +37,5 @@ await paykit.subscribe({ customerId: "user_123", planId: "pro" }); ``` - This mapping means you can migrate to a different provider without changing any of your application code. - - -## Future providers - - - Support for additional providers including PayPal and regional PSPs is planned. The adapter interface is stable, so community-built providers will work with the same API. + This mapping keeps Stripe details out of your application code. diff --git a/apps/web/content/docs/get-started/index.mdx b/apps/web/content/docs/get-started/index.mdx index d891c3ba..3ebffd07 100644 --- a/apps/web/content/docs/get-started/index.mdx +++ b/apps/web/content/docs/get-started/index.mdx @@ -3,7 +3,7 @@ title: Introduction description: Introduction to PayKit. --- -PayKit is an embedded billing framework for TypeScript apps. It provides a comprehensive set of features out of the box and includes a plugin ecosystem that simplifies adding advanced billing capabilities. Whether you need subscription management, usage-based billing, entitlement checks, or plan management across multiple providers, it lets you focus on building your product instead of wiring up payment provider APIs. +PayKit is an embedded Stripe billing framework for TypeScript apps. It provides subscription management, usage-based billing, entitlement checks, and plan management so you can focus on building your product instead of wiring up Stripe lifecycle code. ## Features diff --git a/apps/web/content/docs/get-started/installation.mdx b/apps/web/content/docs/get-started/installation.mdx index 49c68daa..8fd9e721 100644 --- a/apps/web/content/docs/get-started/installation.mdx +++ b/apps/web/content/docs/get-started/installation.mdx @@ -42,23 +42,17 @@ export const paykit = createPayKit({ -## Configure provider +## Configure Stripe -PayKit can operate with different payment providers. Here's an example with Stripe, but you can easily configure another one, like Polar, Creem, and custom! - -Install the provider adapter and plug it into your instance. - - +PayKit uses Stripe for billing. Pass your Stripe keys directly to the PayKit instance. ```ts title="paykit.ts" -import { stripe } from "@paykitjs/stripe"; - export const paykit = createPayKit({ // ... - provider: stripe({ // [!code highlight] + stripe: { // [!code highlight] secretKey: process.env.STRIPE_SECRET_KEY!, // [!code highlight] webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,// [!code highlight] - }),// [!code highlight] + },// [!code highlight] }); ``` diff --git a/apps/web/content/docs/providers/creem.mdx b/apps/web/content/docs/providers/creem.mdx deleted file mode 100644 index 1bac8358..00000000 --- a/apps/web/content/docs/providers/creem.mdx +++ /dev/null @@ -1,32 +0,0 @@ ---- -title: Creem -description: Creem is a planned provider for teams that need a modern merchant workflow with the same PayKit API. ---- - - - Creem support is planned. This page documents the intended shape, not a released adapter. - - -Creem fits the broader PayKit goal of letting apps depend on one billing model even when the -underlying provider differs. - -## Planned setup - -```ts -import { creem } from "paykitjs/providers/creem"; - -const provider = creem({ - apiKey: process.env.CREEM_API_KEY!, - webhookSecret: process.env.CREEM_WEBHOOK_SECRET!, -}); -``` - -## Planned focus - -- provider-hosted checkout -- webhook verification and event normalization -- customer and charge sync into the PayKit local model - -- provider-hosted checkout -- webhook verification and event normalization -- customer and charge sync into the PayKit local model diff --git a/apps/web/content/docs/providers/lemonsqueezy.mdx b/apps/web/content/docs/providers/lemonsqueezy.mdx deleted file mode 100644 index 7e25d37e..00000000 --- a/apps/web/content/docs/providers/lemonsqueezy.mdx +++ /dev/null @@ -1,25 +0,0 @@ ---- -title: Lemon Squeezy -description: Lemon Squeezy is planned as a future provider adapter in the broader docs tree. ---- - - - Lemon Squeezy is not in the first shipping set. This page exists as a docs placeholder for the - future provider matrix. - - -## Planned setup - -```ts -import { lemonSqueezy } from "paykitjs/providers/lemonsqueezy"; - -const provider = lemonSqueezy({ - apiKey: process.env.LEMONSQUEEZY_API_KEY!, - webhookSecret: process.env.LEMONSQUEEZY_WEBHOOK_SECRET!, -}); -``` - -## Expected role - -The eventual adapter should expose the same app-facing PayKit primitives for checkout, synced -customers, charges, and normalized webhook events. diff --git a/apps/web/content/docs/providers/meta.json b/apps/web/content/docs/providers/meta.json index 1778c933..a058739d 100644 --- a/apps/web/content/docs/providers/meta.json +++ b/apps/web/content/docs/providers/meta.json @@ -1,4 +1,4 @@ { "title": "Providers", - "pages": ["stripe", "polar", "paypal", "creem", "paddle"] + "pages": ["stripe"] } diff --git a/apps/web/content/docs/providers/paddle.mdx b/apps/web/content/docs/providers/paddle.mdx deleted file mode 100644 index e70d77ee..00000000 --- a/apps/web/content/docs/providers/paddle.mdx +++ /dev/null @@ -1,32 +0,0 @@ ---- -title: Paddle -description: Paddle is a future provider target for teams that need the same orchestration layer on top. ---- - - - Paddle is listed as a future integration. The examples below are placeholders for the docs tree. - - -Paddle would fit the same contract as other adapters: provider-native execution with PayKit-owned -local billing state. - -## Planned setup - -```ts -import { paddle } from "paykitjs/providers/paddle"; - -const provider = paddle({ - apiKey: process.env.PADDLE_API_KEY!, - webhookSecret: process.env.PADDLE_WEBHOOK_SECRET!, -}); -``` - -## Expected MVP coverage - -- checkout creation -- webhook verification -- local sync for customers, payment methods when supported, and charges - -- checkout creation -- webhook verification -- local sync for customers, payment methods when supported, and charges diff --git a/apps/web/content/docs/providers/paypal.mdx b/apps/web/content/docs/providers/paypal.mdx deleted file mode 100644 index 6ada1245..00000000 --- a/apps/web/content/docs/providers/paypal.mdx +++ /dev/null @@ -1,36 +0,0 @@ ---- -title: PayPal -description: PayPal is a planned first-party provider with the same normalized API shape as Stripe. ---- - - - PayPal support is planned, but the API examples on this page are still mock documentation. - - -PayPal matters because many SaaS teams need it alongside Stripe for regional coverage and customer -preference. PayKit's goal is to keep the app-facing shape the same even when provider capabilities -vary. - -## Planned setup - -```ts -import { paypal } from "paykitjs/providers/paypal"; - -const provider = paypal({ - clientId: process.env.PAYPAL_CLIENT_ID!, - clientSecret: process.env.PAYPAL_CLIENT_SECRET!, - webhookId: process.env.PAYPAL_WEBHOOK_ID!, -}); -``` - -## Planned scope - -- hosted checkout flows -- customer mapping and provider account sync -- saved payment methods where the provider flow supports them -- webhook normalization into the same PayKit event model - -## Design goal - -Your business code should still call `paykit.checkout.create(...)` or -`paykit.paymentMethod.list(...)` without branching on provider-native webhook names or customer IDs. diff --git a/apps/web/content/docs/providers/polar.mdx b/apps/web/content/docs/providers/polar.mdx deleted file mode 100644 index 4a653336..00000000 --- a/apps/web/content/docs/providers/polar.mdx +++ /dev/null @@ -1,140 +0,0 @@ ---- -title: Polar -description: Configure Polar for PayKit, set up webhooks, sync products, and use the customer portal. ---- - -Polar is a developer-first payment platform. The `@paykitjs/polar` adapter handles all Polar API interactions, webhook processing, and product syncing. - -## Installation - - - -## Configuration - -Pass the `polar()` adapter to `createPayKit` with your access token and webhook secret. - -```ts title="paykit.ts" -import { polar } from "@paykitjs/polar"; -import { createPayKit } from "paykitjs"; - -export const paykit = createPayKit({ - // ... - provider: polar({ - accessToken: process.env.POLAR_ACCESS_TOKEN!, - webhookSecret: process.env.POLAR_WEBHOOK_SECRET!, - server: "sandbox", // or "production" - }), -}); -``` - -## Environment variables - -Add these variables to your `.env` file: - -```bash title=".env" -POLAR_ACCESS_TOKEN=polar_oat_... -POLAR_WEBHOOK_SECRET=... -``` - -- `POLAR_ACCESS_TOKEN`: create one in [Polar Settings](https://polar.sh/settings) under **Access Tokens**. The token needs the following scopes: `products:read`, `products:write`, `customers:read`, `customers:write`, `customer_sessions:write`, `subscriptions:read`, `subscriptions:write`, `checkouts:write`, `organizations:read`, `organizations:write`. -- `POLAR_WEBHOOK_SECRET`: generated when you create a webhook endpoint. See the section below. - -## Webhook setup - -In the Polar Dashboard, go to **Settings > Webhooks** and create a new endpoint pointing to: - -``` -https://your-app.com/paykit/webhook -``` - -Enable the following events: - -- `checkout.created`, `checkout.updated` -- `subscription.created`, `subscription.updated`, `subscription.active`, `subscription.canceled`, `subscription.uncanceled`, `subscription.revoked` - -You can also select all events. PayKit silently ignores any events it doesn't need. After saving, Polar displays the signing secret. Copy it as your `POLAR_WEBHOOK_SECRET`. - - - Polar uses the [Standard Webhooks](https://www.standardwebhooks.com) specification for signature verification and event deduplication. - - -## Local development - -Use the Polar CLI to forward webhook events to your local server: - -```bash -polar listen http://localhost:3000/paykit/webhook -``` - -The CLI prints a webhook signing secret at startup. Use that as `POLAR_WEBHOOK_SECRET` in your local `.env`. - - - Install the Polar CLI with `curl -fsSL https://polar.sh/install.sh | bash`. You'll need to run `polar login` once to authenticate. - - -## Product syncing - -`paykitjs push` creates and updates Polar products to match your plan definitions. You don't need to touch the Polar Dashboard for product management. - - - -On every push, PayKit automatically: - -- Creates or updates products to match your plans -- Sets all products to **private** visibility (only purchasable via PayKit checkout, not the customer portal) -- Archives orphan products not managed by PayKit -- Configures your Polar organization settings (multiple subscriptions enabled, portal plan changes disabled) - - - Run this once on setup, and again every time you change your plans or pricing. - - -## Customer portal - -PayKit can open Polar's customer portal so users can view their subscriptions and invoices. - - - - ```ts - const { url } = await paykit.customerPortal({ - customerId: "user_123", - returnUrl: "https://myapp.com/billing", - }); - - // redirect the user to `url` - ``` - - - ```ts - const { url } = await paykitClient.customerPortal({ - returnUrl: window.location.href, - }); - - window.location.href = url; - ``` - - - - - Plan changes are disabled in the portal automatically by PayKit. Subscriptions should be managed through PayKit's API to keep state in sync. - - -## Dedicated account - -PayKit requires full ownership of your Polar account. The `push` command will block if it detects customers on Polar that are not managed by PayKit. Use a dedicated Polar organization for your PayKit integration. - -## Sandbox mode - -Pass `server: "sandbox"` to test with Polar's sandbox environment. This uses separate sandbox API endpoints and test data. - -```ts -provider: polar({ - accessToken: process.env.POLAR_ACCESS_TOKEN!, - webhookSecret: process.env.POLAR_WEBHOOK_SECRET!, - server: "sandbox", -}), -``` - - - Use a sandbox access token when testing. Sandbox and production are completely isolated in Polar. - diff --git a/apps/web/content/docs/providers/stripe.mdx b/apps/web/content/docs/providers/stripe.mdx index 948a413a..a7e18b23 100644 --- a/apps/web/content/docs/providers/stripe.mdx +++ b/apps/web/content/docs/providers/stripe.mdx @@ -3,26 +3,21 @@ title: Stripe description: Configure Stripe for PayKit, set up webhooks, sync products, and use the customer portal. --- -Stripe is PayKit's primary payment provider. The `@paykitjs/stripe` adapter handles all Stripe API interactions, webhook processing, and product syncing. - -## Installation - - +Stripe is built into PayKit. PayKit handles Stripe API interactions, webhook processing, and product syncing. ## Configuration -Pass the `stripe()` adapter to `createPayKit` with your secret key and webhook secret. +Pass Stripe config to `createPayKit` with your secret key and webhook secret. ```ts title="paykit.ts" -import { stripe } from "@paykitjs/stripe"; import { createPayKit } from "paykitjs"; export const paykit = createPayKit({ // ... - provider: stripe({ + stripe: { secretKey: process.env.STRIPE_SECRET_KEY!, webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!, - }), + }, }); ``` @@ -141,11 +136,11 @@ PayKit pins the Stripe SDK to a known-good API version so upstream changes don't ```ts title="paykit.ts" export const paykit = createPayKit({ // ... - provider: stripe({ + stripe: { secretKey: process.env.STRIPE_SECRET_KEY!, webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!, apiVersion: "2026-03-04.preview", - }), + }, }); ``` @@ -158,12 +153,12 @@ Stripe Managed Payments is a preview feature where Stripe takes over tax calcula ```ts title="paykit.ts" export const paykit = createPayKit({ // ... - provider: stripe({ + stripe: { secretKey: process.env.STRIPE_SECRET_KEY!, webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!, apiVersion: "2026-03-04.preview", managedPayments: true, - }), + }, }); ``` diff --git a/apps/web/src/components/docs/docs-icons.tsx b/apps/web/src/components/docs/docs-icons.tsx index 56ec33b8..79bf4996 100644 --- a/apps/web/src/components/docs/docs-icons.tsx +++ b/apps/web/src/components/docs/docs-icons.tsx @@ -80,7 +80,7 @@ const pageIcons = { skills: , } as const; -const enabledProviders = new Set(["stripe", "polar"]); +const enabledProviders = new Set(["stripe"]); const soonPages = new Set(["drizzleadapter", "prismaadapter", "dashboard"]); const providerPageIcons = { diff --git a/apps/web/src/components/docs/features.tsx b/apps/web/src/components/docs/features.tsx index d34a9e44..ea0a9cdf 100644 --- a/apps/web/src/components/docs/features.tsx +++ b/apps/web/src/components/docs/features.tsx @@ -31,8 +31,8 @@ const features: { icon: ReactNode; title: string; description: string }[] = [ }, { icon: , - title: "Any Provider", - description: "Stripe, Polar, Creem, or your own. Swap with one import.", + title: "Built For Stripe", + description: "Stripe subscriptions, webhooks, portal, and product sync built in.", }, { icon: , diff --git a/apps/web/src/components/sections/demo/demo-types.tsx b/apps/web/src/components/sections/demo/demo-types.tsx index 8619630b..6d6838e8 100644 --- a/apps/web/src/components/sections/demo/demo-types.tsx +++ b/apps/web/src/components/sections/demo/demo-types.tsx @@ -74,7 +74,7 @@ export const interactiveReplies = [ "Your plans are type-safe. Typo a plan ID and TypeScript catches it at build time.", "The dashboard mounts at /paykit in your app. No separate service to deploy.", "Webhooks are verified and deduplicated in the same DB transaction. No double charges.", - "You can swap from Stripe to Polar by changing one import. Your billing logic stays identical.", + "Stripe details stay inside PayKit. Your app keeps using plans, customers, and features.", "Every entitlement check is a single function call. No complex permission logic needed.", "PayKit runs inside your app. It's a library, not a platform. One npm install and you're set.", ]; diff --git a/apps/web/src/components/sections/features-section.tsx b/apps/web/src/components/sections/features-section.tsx index bdade0a6..b995e894 100644 --- a/apps/web/src/components/sections/features-section.tsx +++ b/apps/web/src/components/sections/features-section.tsx @@ -15,8 +15,8 @@ const features = [ }, { icon: , - title: "Any provider", - description: "Stripe, Polar, Creem, or your own custom provider. Swap with one import.", + title: "Stripe built in", + description: "Subscriptions, webhooks, portal, and product sync without adapter setup.", }, { icon: , diff --git a/apps/web/src/components/sections/readme-code-content.ts b/apps/web/src/components/sections/readme-code-content.ts index 791e8173..28f27686 100644 --- a/apps/web/src/components/sections/readme-code-content.ts +++ b/apps/web/src/components/sections/readme-code-content.ts @@ -22,20 +22,18 @@ export const pro = plan({ })`; // Hero config tab -export const heroConfigCode = `import { stripe } from "@paykitjs/stripe" -import { createPayKit } from "paykitjs" +export const heroConfigCode = `import { createPayKit } from "paykitjs" import { free, pro } from "./products" export const paykit = createPayKit({ - // Any provider: (Stripe / Polar / Creem) - provider: stripe({ + stripe: { secretKey: env.STRIPE_SECRET_KEY, webhookSecret: env.STRIPE_WEBHOOK_SECRET, - }), + }, database: env.DATABASE_URL, products: [free, pro], on: { - "subscription.activated": ({ customer, plan }) => { + "subscription.activated": async ({ customer, plan }) => { await sendEmail(customer.email, "Welcome to Pro!") }, } diff --git a/e2e/cli/init.test.ts b/e2e/cli/init.test.ts index 80f02101..194d869d 100644 --- a/e2e/cli/init.test.ts +++ b/e2e/cli/init.test.ts @@ -39,16 +39,15 @@ describe("paykitjs init", () => { const envPath = ".env"; // Write config file - const configContent = `import { stripe } from "@paykitjs/stripe"; -import { createPayKit } from "paykitjs"; + const configContent = `import { createPayKit } from "paykitjs"; import { free, pro } from "./paykit-products"; export const paykit = createPayKit({ database: process.env.DATABASE_URL!, - provider: stripe({ + stripe: { secretKey: process.env.STRIPE_SECRET_KEY!, webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!, - }), + }, products: [free, pro], identify: async (request) => { return null; @@ -62,7 +61,7 @@ export const paykit = createPayKit({ const written = await fs.readFile(configFullPath, "utf-8"); expect(written).toContain("createPayKit"); - expect(written).toContain("@paykitjs/stripe"); + expect(written).toContain("stripe:"); expect(written).toContain("paykit-products"); expect(written).toContain("products: [free, pro]"); diff --git a/e2e/cli/push.test.ts b/e2e/cli/push.test.ts index 66fd9828..0b9dc9b0 100644 --- a/e2e/cli/push.test.ts +++ b/e2e/cli/push.test.ts @@ -81,24 +81,22 @@ describe("paykitjs push", () => { { id: "pro", name: "Pro", group: "base", is_default: false }, ]); - // Verify paid plan (pro) was synced to Stripe via provider JSONB - const providerRows = await ctx.database - .select({ id: product.id, provider: product.provider }) + // 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")) .orderBy(desc(product.version)) .limit(1); - const proProduct = providerRows[0] as - | { id: string; provider: Record } - | undefined; + const proProduct = proRows[0] as { id: string; stripeProductId: string | null } | undefined; expect(proProduct).toBeTruthy(); - const stripeInfo = proProduct?.provider.stripe; - expect(stripeInfo).toBeTruthy(); - if (!stripeInfo) { + if (!proProduct?.stripeProductId) { throw new Error("Missing Stripe product metadata for synced plan"); } - const stripeProduct = await fixture.stripeClient.products.retrieve(stripeInfo.productId); + const stripeProduct = await fixture.stripeClient.products.retrieve( + proProduct.stripeProductId, + ); expect(stripeProduct.active).toBe(true); } finally { await database.end(); diff --git a/e2e/cli/setup.ts b/e2e/cli/setup.ts index 1aca6162..dd98fa73 100644 --- a/e2e/cli/setup.ts +++ b/e2e/cli/setup.ts @@ -11,7 +11,6 @@ process.env.PAYKIT_CLI = "1"; const packageRoot = path.resolve(import.meta.dirname, "../../packages/paykit"); const createPayKitPath = path.resolve(packageRoot, "src/index.ts"); -const stripePath = path.resolve(import.meta.dirname, "../../packages/stripe/src/index.ts"); export interface CliTestFixture { cwd: string; @@ -56,7 +55,6 @@ export async function createCliFixture(_globalKey: string): Promise { try { const planCount = config.options.products ? Object.values(config.options.products).length : 0; expect(planCount).toBe(2); - expect(config.options.provider).toBeTruthy(); + expect(config.options.stripe).toBeTruthy(); } finally { await database.end(); } diff --git a/e2e/core/subscribe/cancel-end-of-cycle.test.ts b/e2e/core/subscribe/cancel-end-of-cycle.test.ts index 4bedf016..d831af87 100644 --- a/e2e/core/subscribe/cancel-end-of-cycle.test.ts +++ b/e2e/core/subscribe/cancel-end-of-cycle.test.ts @@ -11,124 +11,120 @@ import { expectNoScheduledPlanInGroup, expectProduct, expectSingleActivePlanInGroup, - harness, subscribeCustomer, type TestPayKit, waitForWebhook, } from "../../test-utils"; -describe.skipIf(!harness.capabilities.testClocks)( - "cancel-end-of-cycle: pro → free + clock advance", - () => { - let t: TestPayKit; - let customerId: string; +describe("cancel-end-of-cycle: pro → free + clock advance", () => { + let t: TestPayKit; + let customerId: string; - beforeAll(async () => { - t = await createTestPayKit(); - const customer = await createTestCustomerWithPM({ - t, - customer: { - id: "test_cancel_eoc", - email: "cancel-eoc@test.com", - name: "Cancel EOC Test", - }, - }); - customerId = customer.customerId; - - // Setup: subscribe to Pro, then schedule downgrade to Free - await subscribeCustomer({ t, customerId, planId: "pro" }); - - await subscribeCustomer({ t, customerId, planId: "free" }); + beforeAll(async () => { + t = await createTestPayKit(); + const customer = await createTestCustomerWithPM({ + t, + customer: { + id: "test_cancel_eoc", + email: "cancel-eoc@test.com", + name: "Cancel EOC Test", + }, }); + customerId = customer.customerId; - afterAll(async () => { - await t?.cleanup(); - }); + // Setup: subscribe to Pro, then schedule downgrade to Free + await subscribeCustomer({ t, customerId, planId: "pro" }); - it("advancing past period end activates the free plan", async () => { - try { - // Get period end to advance past - 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); + await subscribeCustomer({ t, customerId, planId: "free" }); + }); - // Advance clock 1 day past period end - const advanceTo = new Date(periodEnd.getTime() + 86_400_000); - const beforeAdvance = new Date(); - await advanceTestClock({ - t, - customerId, - frozenTime: advanceTo, - }); - await waitForWebhook({ - after: beforeAdvance, - database: t.database, - eventType: "subscription.deleted", - timeout: 30_000, - }); + afterAll(async () => { + await t?.cleanup(); + }); - // Poll until Free is active after the forwarded deletion event is processed - 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 plan never activated after clock advance"); - await new Promise((resolve) => setTimeout(resolve, 2000)); - } + it("advancing past period end activates the free plan", async () => { + try { + // Get period end to advance past + 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); - // Pro is canceled/ended - await expectProduct({ - database: t.database, - customerId, - planId: "pro", - expected: { canceled: true, status: "canceled" }, - }); + // Advance clock 1 day past period end + const advanceTo = new Date(periodEnd.getTime() + 86_400_000); + const beforeAdvance = new Date(); + await advanceTestClock({ + t, + customerId, + frozenTime: advanceTo, + }); + await waitForWebhook({ + after: beforeAdvance, + database: t.database, + eventType: "subscription.deleted", + timeout: 30_000, + }); - // Free is active with no period end (no billing cycle) - await expectProduct({ - database: t.database, - customerId, - planId: "free", - expected: { - status: "active", - hasPeriodEnd: false, - }, - }); - await expectSingleActivePlanInGroup({ - database: t.database, - customerId, - group: "base", - planId: "free", - }); - await expectNoScheduledPlanInGroup({ - database: t.database, - customerId, - group: "base", - }); - await expectExactMeteredBalance({ - paykit: t.paykit, - customerId, - featureId: "messages", - limit: 100, - remaining: 100, - }); - } catch (error) { - await dumpStateOnFailure(t.database, t.dbPath); - throw error; + // Poll until Free is active after the forwarded deletion event is processed + 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 plan never activated after clock advance"); + await new Promise((resolve) => setTimeout(resolve, 2000)); } - }); - }, -); + + // Pro is canceled/ended + await expectProduct({ + database: t.database, + customerId, + planId: "pro", + expected: { canceled: true, status: "canceled" }, + }); + + // Free is active with no period end (no billing cycle) + await expectProduct({ + database: t.database, + customerId, + planId: "free", + expected: { + status: "active", + hasPeriodEnd: false, + }, + }); + await expectSingleActivePlanInGroup({ + database: t.database, + customerId, + group: "base", + planId: "free", + }); + await expectNoScheduledPlanInGroup({ + database: t.database, + customerId, + group: "base", + }); + await expectExactMeteredBalance({ + paykit: t.paykit, + customerId, + featureId: "messages", + limit: 100, + remaining: 100, + }); + } catch (error) { + await dumpStateOnFailure(t.database, t.dbPath); + throw error; + } + }); +}); diff --git a/e2e/core/subscribe/renewal.test.ts b/e2e/core/subscribe/renewal.test.ts index fd6af866..ed03bfa4 100644 --- a/e2e/core/subscribe/renewal.test.ts +++ b/e2e/core/subscribe/renewal.test.ts @@ -10,126 +10,122 @@ import { expectExactMeteredBalance, expectProduct, expectSingleActivePlanInGroup, - harness, subscribeCustomer, type TestPayKit, waitForWebhook, } from "../../test-utils"; -describe.skipIf(!harness.capabilities.testClocks)( - "renewal: pro subscription renews after 1 month", - () => { - let t: TestPayKit; - let customerId: string; +describe("renewal: pro subscription renews after 1 month", () => { + let t: TestPayKit; + let customerId: string; - beforeAll(async () => { - t = await createTestPayKit(); - const customer = await createTestCustomerWithPM({ - t, - customer: { - id: "test_renewal", - email: "renewal@test.com", - name: "Renewal Test", - }, - }); - customerId = customer.customerId; - - // Setup: subscribe to Pro - await subscribeCustomer({ t, customerId, planId: "pro" }); + beforeAll(async () => { + t = await createTestPayKit(); + const customer = await createTestCustomerWithPM({ + t, + customer: { + id: "test_renewal", + email: "renewal@test.com", + name: "Renewal Test", + }, }); + customerId = customer.customerId; - afterAll(async () => { - await t?.cleanup(); - }); + // Setup: subscribe to Pro + await subscribeCustomer({ t, customerId, planId: "pro" }); + }); + + afterAll(async () => { + await t?.cleanup(); + }); - it("advancing clock 1 month rolls period dates forward and resets usage", async () => { - try { - const usage = await t.paykit.report({ - customerId, - featureId: "messages", - amount: 37, - }); - expect(usage.success).toBe(true); - await expectExactMeteredBalance({ - paykit: t.paykit, - customerId, - featureId: "messages", - limit: 500, - remaining: 463, - }); + it("advancing clock 1 month rolls period dates forward and resets usage", async () => { + try { + const usage = await t.paykit.report({ + customerId, + featureId: "messages", + amount: 37, + }); + expect(usage.success).toBe(true); + await expectExactMeteredBalance({ + paykit: t.paykit, + customerId, + featureId: "messages", + limit: 500, + remaining: 463, + }); + + // Record current period end + 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); + + // Advance clock 1 day past period end + const advanceTo = new Date(periodEnd.getTime() + 86_400_000); + const beforeAdvance = new Date(); + await advanceTestClock({ + t, + customerId, + frozenTime: advanceTo, + }); + await waitForWebhook({ + after: beforeAdvance, + database: t.database, + eventType: "subscription.updated", + timeout: 30_000, + }); - // Record current period end - const subRows = await t.database + // Poll until period dates change after the forwarded renewal event is processed + let newPeriodEnd = periodEnd; + for (let i = 0; i < 60; i++) { + const rows = await t.database .select({ currentPeriodEndAt: subscription.currentPeriodEndAt }) .from(subscription) - .where(eq(subscription.customerId, customerId)) + .where(and(eq(subscription.customerId, customerId), eq(subscription.status, "active"))) .orderBy(desc(subscription.updatedAt)) .limit(1); - const periodEnd = new Date(subRows[0]!.currentPeriodEndAt as unknown as string); - - // Advance clock 1 day past period end - const advanceTo = new Date(periodEnd.getTime() + 86_400_000); - const beforeAdvance = new Date(); - await advanceTestClock({ - t, - customerId, - frozenTime: advanceTo, - }); - await waitForWebhook({ - after: beforeAdvance, - database: t.database, - eventType: "subscription.updated", - timeout: 30_000, - }); - - // Poll until period dates change after the forwarded renewal event is processed - let newPeriodEnd = periodEnd; - for (let i = 0; i < 60; i++) { - const rows = await t.database - .select({ currentPeriodEndAt: subscription.currentPeriodEndAt }) - .from(subscription) - .where(and(eq(subscription.customerId, customerId), eq(subscription.status, "active"))) - .orderBy(desc(subscription.updatedAt)) - .limit(1); - const row = rows[0]; - if (row?.currentPeriodEndAt) { - const end = new Date(row.currentPeriodEndAt as unknown as string); - if (end.getTime() > periodEnd.getTime()) { - newPeriodEnd = end; - break; - } + const row = rows[0]; + if (row?.currentPeriodEndAt) { + const end = new Date(row.currentPeriodEndAt as unknown as string); + if (end.getTime() > periodEnd.getTime()) { + newPeriodEnd = end; + break; } - if (i === 59) throw new Error("Period dates never rolled forward after clock advance"); - await new Promise((resolve) => setTimeout(resolve, 2000)); } + if (i === 59) throw new Error("Period dates never rolled forward after clock advance"); + await new Promise((resolve) => setTimeout(resolve, 2000)); + } - // Period end moved forward - expect(newPeriodEnd.getTime()).toBeGreaterThan(periodEnd.getTime()); + // Period end moved forward + expect(newPeriodEnd.getTime()).toBeGreaterThan(periodEnd.getTime()); - // Pro is still active - await expectProduct({ - database: t.database, - customerId, - planId: "pro", - expected: { status: "active" }, - }); - await expectSingleActivePlanInGroup({ - database: t.database, - customerId, - group: "base", - planId: "pro", - }); - await expectExactMeteredBalance({ - paykit: t.paykit, - customerId, - featureId: "messages", - limit: 500, - remaining: 500, - }); - } catch (error) { - await dumpStateOnFailure(t.database, t.dbPath); - throw error; - } - }); - }, -); + // Pro is still active + await expectProduct({ + database: t.database, + customerId, + planId: "pro", + expected: { status: "active" }, + }); + await expectSingleActivePlanInGroup({ + database: t.database, + customerId, + group: "base", + planId: "pro", + }); + await expectExactMeteredBalance({ + paykit: t.paykit, + customerId, + featureId: "messages", + limit: 500, + remaining: 500, + }); + } catch (error) { + await dumpStateOnFailure(t.database, t.dbPath); + throw error; + } + }); +}); diff --git a/e2e/core/webhook/duplicate-webhook.test.ts b/e2e/core/webhook/duplicate-webhook.test.ts index a614bba3..ad987829 100644 --- a/e2e/core/webhook/duplicate-webhook.test.ts +++ b/e2e/core/webhook/duplicate-webhook.test.ts @@ -49,7 +49,7 @@ describe("duplicate-webhook: same event delivered twice", () => { database: t.database, eventType: "subscription.updated", }); - const providerEventId = String(subscriptionWebhook.providerEventId); + const providerEventId = String(subscriptionWebhook.stripeEventId); const forwardedRequest = await waitForForwardedWebhookRequest({ after: beforeSubscribe, providerEventId, @@ -65,7 +65,7 @@ describe("duplicate-webhook: same event delivered twice", () => { const webhookCountBeforeRows = await t.database .select({ count: count() }) .from(webhookEvent) - .where(eq(webhookEvent.providerEventId, providerEventId)); + .where(eq(webhookEvent.stripeEventId, providerEventId)); const webhookCountBefore = webhookCountBeforeRows[0]?.count ?? 0; const subscriptionCountBeforeRows = await t.database @@ -105,7 +105,7 @@ describe("duplicate-webhook: same event delivered twice", () => { const webhookCountAfterRows = await t.database .select({ count: count() }) .from(webhookEvent) - .where(eq(webhookEvent.providerEventId, providerEventId)); + .where(eq(webhookEvent.stripeEventId, providerEventId)); const webhookCountAfter = webhookCountAfterRows[0]?.count ?? 0; const subscriptionCountAfterRows = await t.database diff --git a/e2e/core/webhook/subscription-deleted.test.ts b/e2e/core/webhook/subscription-deleted.test.ts index b77972d8..7eb43754 100644 --- a/e2e/core/webhook/subscription-deleted.test.ts +++ b/e2e/core/webhook/subscription-deleted.test.ts @@ -40,18 +40,18 @@ describe.skipIf(harness.id !== "stripe")( // Setup: subscribe to Pro await subscribeCustomer({ t, customerId, planId: "pro" }); - // Get provider subscription ID from provider_data JSONB + // Get Stripe subscription ID from the stored subscription row. const subRows = await t.database - .select({ providerData: subscription.providerData }) + .select({ stripeSubscriptionId: subscription.stripeSubscriptionId }) .from(subscription) .where(eq(subscription.customerId, customerId)) .orderBy(desc(subscription.updatedAt)) .limit(1); - const providerData = subRows[0]?.providerData as { subscriptionId: string } | null; - if (!providerData?.subscriptionId) { - throw new Error("Expected providerData with subscriptionId on subscription row"); + const stripeSubscriptionId = subRows[0]?.stripeSubscriptionId; + if (!stripeSubscriptionId) { + throw new Error("Expected stripeSubscriptionId on subscription row"); } - providerSubscriptionId = providerData.subscriptionId; + providerSubscriptionId = stripeSubscriptionId; }); afterAll(async () => { diff --git a/e2e/package.json b/e2e/package.json index df4ada7e..2fefc55b 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -5,15 +5,11 @@ "scripts": { "test:stripe": "PROVIDER=stripe vitest run --project=core", "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:cli": "vitest run --project=cli", "test:cli:watch": "vitest --project=cli", "typecheck": "tsc -p tsconfig.json --noEmit" }, "devDependencies": { - "@paykitjs/polar": "workspace:*", - "@paykitjs/stripe": "workspace:*", "@t3-oss/env-core": "^0.12.0", "@types/pg": "^8.18.0", "dotenv": "^17.3.1", diff --git a/e2e/test-utils/env.ts b/e2e/test-utils/env.ts index bf644fec..19bdcdf3 100644 --- a/e2e/test-utils/env.ts +++ b/e2e/test-utils/env.ts @@ -13,16 +13,12 @@ config({ export const env = createEnv({ server: { - PROVIDER: z.enum(["stripe", "polar"]).default("stripe"), + PROVIDER: z.enum(["stripe"]).default("stripe"), TEST_DATABASE_URL: z.string().default("postgresql://localhost:5432/postgres"), // Stripe E2E_STRIPE_SK: z.string().optional(), E2E_STRIPE_WHSEC: z.string().optional(), - - // Polar - E2E_POLAR_ACCESS_TOKEN: z.string().optional(), - E2E_POLAR_WHSEC: z.string().optional(), }, runtimeEnv: process.env, emptyStringAsUndefined: true, diff --git a/e2e/test-utils/harness/index.ts b/e2e/test-utils/harness/index.ts index 9b3424e6..88f51c81 100644 --- a/e2e/test-utils/harness/index.ts +++ b/e2e/test-utils/harness/index.ts @@ -1,9 +1,8 @@ import { env } from "../env"; -import { createPolarHarness } from "./polar"; import { createStripeHarness } from "./stripe"; import type { ProviderHarness } from "./types"; -export type { ProviderCapabilities, ProviderHarness } from "./types"; +export type { ProviderHarness } from "./types"; export function loadHarness(): ProviderHarness { const provider = env.PROVIDER; @@ -11,8 +10,6 @@ export function loadHarness(): ProviderHarness { switch (provider) { case "stripe": return createStripeHarness(); - case "polar": - return createPolarHarness(); default: { const _exhaustive: never = provider; throw new Error(`Unknown provider: ${String(_exhaustive)}`); diff --git a/e2e/test-utils/harness/polar.ts b/e2e/test-utils/harness/polar.ts deleted file mode 100644 index 86185cfa..00000000 --- a/e2e/test-utils/harness/polar.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { polar } from "@paykitjs/polar"; -import { chromium } from "playwright"; - -import { env } from "../env"; -import type { ProviderHarness } from "./types"; - -export function createPolarHarness(): ProviderHarness { - const accessToken = env.E2E_POLAR_ACCESS_TOKEN; - const webhookSecret = env.E2E_POLAR_WHSEC; - if (!accessToken || !webhookSecret) { - throw new Error("E2E_POLAR_ACCESS_TOKEN and E2E_POLAR_WHSEC must be set"); - } - - return { - id: "polar", - capabilities: { - testClocks: false, - directSubscription: false, - }, - - createProviderConfig() { - return polar({ accessToken, webhookSecret, server: "sandbox" }); - }, - - async setupCustomerForDirectSubscription(_providerCustomerId: string) { - // Polar 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: true }); - const page = await browser.newPage(); - - try { - await page.goto(url, { waitUntil: "networkidle" }); - - // Polar sandbox checkout — fill test card details - await page.fill( - '[data-testid="card-number"], input[name="cardNumber"], input[placeholder*="card number" i]', - "4242424242424242", - ); - await page.fill( - '[data-testid="card-expiry"], input[name="cardExpiry"], input[placeholder*="MM" i]', - "12/30", - ); - await page.fill( - '[data-testid="card-cvc"], input[name="cardCvc"], input[placeholder*="CVC" i]', - "123", - ); - - // Submit payment - const submitButton = page.locator( - 'button[type="submit"], button:has-text("Pay"), button:has-text("Subscribe")', - ); - await submitButton.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) { - // Polar sandbox has no test clocks to clean up. - // Subscriptions in sandbox are ephemeral. - }, - - validateEnv() { - if (!env.E2E_POLAR_ACCESS_TOKEN || !env.E2E_POLAR_WHSEC) { - throw new Error("E2E_POLAR_ACCESS_TOKEN and E2E_POLAR_WHSEC must be set"); - } - }, - }; -} diff --git a/e2e/test-utils/harness/stripe.ts b/e2e/test-utils/harness/stripe.ts index 2c0c5ff6..e321003f 100644 --- a/e2e/test-utils/harness/stripe.ts +++ b/e2e/test-utils/harness/stripe.ts @@ -1,4 +1,3 @@ -import { stripe } from "@paykitjs/stripe"; import { chromium } from "playwright"; import { default as Stripe } from "stripe"; @@ -15,13 +14,9 @@ export function createStripeHarness(): ProviderHarness { return { id: "stripe", - capabilities: { - testClocks: true, - directSubscription: true, - }, - createProviderConfig() { - return stripe({ secretKey, webhookSecret }); + createStripeOptions() { + return { secretKey, webhookSecret }; }, applyTestingOverrides(ctx) { @@ -93,6 +88,12 @@ export function createStripeHarness(): ProviderHarness { const page = await browser.newPage(); await page.goto(url, { waitUntil: "domcontentloaded" }); + const cardPaymentButton = page.locator('[data-testid="card-accordion-item-button"]'); + if ((await cardPaymentButton.count()) > 0) { + await cardPaymentButton.first().waitFor({ state: "visible" }); + await cardPaymentButton.first().click(); + } + // Stripe's hosted checkout uses custom inputs that require per-key events; // fill() does not dispatch them correctly, so use pressSequentially. const cardNumber = page.locator("#cardNumber"); @@ -108,12 +109,40 @@ export function createStripeHarness(): ProviderHarness { await cardCvc.pressSequentially("123"); const billingName = page.locator("#billingName"); - if (await billingName.isVisible().catch(() => false)) { + if ((await billingName.count()) > 0) { + await billingName.waitFor({ timeout: 30_000 }); await billingName.pressSequentially("Test Customer"); } - const submitBtn = page.locator(".SubmitButton-TextContainer").first(); - await submitBtn.click(); + const email = page.locator("#email"); + if ((await email.count()) > 0) { + await email.pressSequentially("checkout@example.com"); + } + + const country = page.locator("#billingCountry"); + if ((await country.count()) > 0) { + await country.selectOption("US").catch(() => {}); + } + + const postalCode = page.locator("#billingPostalCode"); + if ((await postalCode.count()) > 0) { + await postalCode.pressSequentially("10001"); + } + + await page.waitForSelector(".SubmitButton-TextContainer", { + state: "attached", + timeout: 30_000, + }); + await page.evaluate(() => { + const button = + document.querySelector("button.SubmitButton") ?? + document.querySelector('button[type="submit"]') ?? + document.querySelector(".SubmitButton-TextContainer")?.closest("button"); + if (!(button instanceof HTMLElement)) { + throw new Error("Stripe Checkout submit button not found"); + } + button.click(); + }); // Wait for Stripe to navigate away from the checkout page (success redirect // or embedded confirmation). Don't fail the test if this times out — the diff --git a/e2e/test-utils/harness/types.ts b/e2e/test-utils/harness/types.ts index e249eef2..4e0cf056 100644 --- a/e2e/test-utils/harness/types.ts +++ b/e2e/test-utils/harness/types.ts @@ -1,17 +1,11 @@ -import type { PayKitProviderConfig } from "paykitjs"; +import type { StripeOptions } from "paykitjs"; import type { PayKitContext } from "../../../packages/paykit/src/core/context"; -export interface ProviderCapabilities { - testClocks: boolean; - directSubscription: boolean; -} - export interface ProviderHarness { id: string; - capabilities: ProviderCapabilities; - createProviderConfig(): PayKitProviderConfig; + createStripeOptions(): StripeOptions; /** * Apply testing-only overrides to the PayKit provider (e.g., Stripe's diff --git a/e2e/test-utils/setup.ts b/e2e/test-utils/setup.ts index 6e210ff5..809b9132 100644 --- a/e2e/test-utils/setup.ts +++ b/e2e/test-utils/setup.ts @@ -30,7 +30,7 @@ type TestPayKitInstance = ReturnType< typeof createPayKit<{ database: Pool; products: typeof allProducts; - provider: ReturnType; + stripe: ReturnType; testing: { enabled: true }; }> >; @@ -86,11 +86,11 @@ export async function createTestPayKit(): Promise { await migrateDatabase(pool); // 3. Create PayKit instance with the active provider - const providerConfig = harness.createProviderConfig(); + const stripeOptions = harness.createStripeOptions(); const paykit = createPayKit({ database: pool, products: allProducts, - provider: providerConfig, + stripe: stripeOptions, testing: { enabled: true }, }); @@ -122,9 +122,7 @@ export async function createTestPayKit(): Promise { const customerRows = await ctx.database.query.customer.findMany(); const idSet = new Set(); for (const row of customerRows) { - const providerMap = (row.provider ?? {}) as Record; - const entry = providerMap[harness.id]; - if (entry?.id) idSet.add(entry.id); + if (row.stripeCustomerId) idSet.add(row.stripeCustomerId); } await harness.cleanup({ providerCustomerIds: [...idSet] }); @@ -169,8 +167,7 @@ export async function createTestCustomer(input: { const row = await input.t.database.query.customer.findFirst({ where: eq(customer.id, uniqueId), }); - const providerMap = (row?.provider ?? {}) as Record; - const providerCustomerId = providerMap[input.t.harness.id]?.id; + const providerCustomerId = row?.stripeCustomerId; if (!providerCustomerId) { throw new Error( @@ -229,7 +226,7 @@ export async function createTestCustomerWithPM(input: { /** * Subscribe a customer to a plan, handling checkout flow if the provider requires it. * For providers with direct subscription (Stripe with PM): returns immediately. - * For providers requiring checkout (Polar): completes checkout via Playwright and waits for webhook. + * For checkout flows: completes Stripe Checkout via Playwright and waits for webhook. */ export async function subscribeCustomer(input: { t: TestPayKit; @@ -711,7 +708,8 @@ export async function dumpStateOnFailure(database: PayKitDatabase, dbPath: strin scheduledProductId: subscription.scheduledProductId, cancelAtPeriodEnd: subscription.cancelAtPeriodEnd, canceledAt: subscription.canceledAt, - providerData: subscription.providerData, + stripeSubscriptionId: subscription.stripeSubscriptionId, + stripeSubscriptionScheduleId: subscription.stripeSubscriptionScheduleId, }) .from(subscription) .orderBy(desc(subscription.updatedAt)); diff --git a/e2e/vitest.config.ts b/e2e/vitest.config.ts index b9c5fd5c..a51df0dd 100644 --- a/e2e/vitest.config.ts +++ b/e2e/vitest.config.ts @@ -1,14 +1,11 @@ import { defineConfig } from "vitest/config"; -const provider = process.env.PROVIDER; -const isPolar = provider === "polar"; - export default defineConfig({ test: { // Cap parallel workers — Stripe test mode rate-limits at 25 ops/sec; too many // workers starting syncProducts simultaneously trips it. Paired with Stripe // SDK maxNetworkRetries for headroom. - maxWorkers: isPolar ? 1 : 6, + maxWorkers: 6, projects: [ { test: { @@ -17,7 +14,6 @@ export default defineConfig({ globalSetup: ["./test-utils/hub.ts"], hookTimeout: 180_000, include: ["core/**/*.test.ts"], - sequence: isPolar ? { concurrent: false } : undefined, testTimeout: 600_000, }, }, diff --git a/packages/paykit/package.json b/packages/paykit/package.json index 86491a94..cdc8f23c 100644 --- a/packages/paykit/package.json +++ b/packages/paykit/package.json @@ -1,14 +1,9 @@ { "name": "paykitjs", "version": "0.0.6", - "description": "TypeScript-first payments orchestration framework for modern SaaS", + "description": "Stripe billing framework for TypeScript apps", "keywords": [ - "creem", - "orchestration", - "paddle", - "payments", - "paypal", - "polar", + "billing", "stripe", "subscriptions", "typescript" @@ -68,6 +63,7 @@ "pino": "^10.3.1", "pino-pretty": "^13.1.3", "posthog-node": "^5.28.8", + "stripe": "^19.1.0", "typescript": "^5.9.2", "yocto-spinner": "^0.2.1", "zod": "^4.0.0" diff --git a/packages/paykit/src/api/__tests__/define-route.test.ts b/packages/paykit/src/api/__tests__/define-route.test.ts index 0a370ca4..a1aecc1d 100644 --- a/packages/paykit/src/api/__tests__/define-route.test.ts +++ b/packages/paykit/src/api/__tests__/define-route.test.ts @@ -8,12 +8,9 @@ function createTestContext(trustedOrigins?: string[]) { return { options: { database: "postgres://paykit:test@localhost:5432/paykit", - provider: { - createAdapter: () => { - throw new Error("not used in test"); - }, - id: "stripe", - name: "Stripe", + stripe: { + secretKey: "sk_test_123", + webhookSecret: "whsec_123", }, trustedOrigins, }, diff --git a/packages/paykit/src/api/__tests__/methods.test.ts b/packages/paykit/src/api/__tests__/methods.test.ts index 73847740..32773b1a 100644 --- a/packages/paykit/src/api/__tests__/methods.test.ts +++ b/packages/paykit/src/api/__tests__/methods.test.ts @@ -30,10 +30,9 @@ function createTestContext() { }, }, ], - provider: { - createAdapter: vi.fn(), - id: "stripe", - name: "Stripe", + stripe: { + secretKey: "sk_test_123", + webhookSecret: "whsec_123", }, }, products: { plans: [] }, @@ -83,7 +82,7 @@ describe("api/methods router", () => { expect(response.status).toBe(200); expect(await response.json()).toEqual({ received: true }); expect(handleWebhook).toHaveBeenCalledWith({ - allowStaleSignatures: false, + allowUnsignedPayload: false, body: '{"ok":true}', headers: { "content-type": "text/plain;charset=UTF-8", diff --git a/packages/paykit/src/api/methods.ts b/packages/paykit/src/api/methods.ts index d9b881c1..5e1370fc 100644 --- a/packages/paykit/src/api/methods.ts +++ b/packages/paykit/src/api/methods.ts @@ -140,20 +140,20 @@ function isTestingEnabled(options: Pick): boolean { return options.testing?.enabled === true; } -function isTestingAvailable(options: Pick): boolean { - return isTestingEnabled(options) && options.provider.capabilities.testClocks; +function isTestingAvailable(options: Pick): boolean { + return isTestingEnabled(options); } export function getClientApi( ctx: PayKitContext | Promise, - options: Pick, + options: Pick, ) { return wrapMethods(isTestingAvailable(options) ? allClientMethods : baseClientMethods, ctx); } export function getApi( ctx: PayKitContext | Promise, - options: Pick, + options: Pick, ) { return wrapMethods( isTestingAvailable(options) diff --git a/packages/paykit/src/cli/__tests__/init.test.ts b/packages/paykit/src/cli/__tests__/init.test.ts new file mode 100644 index 00000000..439cabf0 --- /dev/null +++ b/packages/paykit/src/cli/__tests__/init.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; + +import { getWebhookListenCommand } 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", + ); + }); + + it("falls back to stripe listen when the PayKit CLI is unavailable", () => { + expect(getWebhookListenCommand(3000, false)).toBe( + "stripe listen --forward-to localhost:3000/paykit/webhook", + ); + }); +}); diff --git a/packages/paykit/src/cli/commands/init.ts b/packages/paykit/src/cli/commands/init.ts index 5ca74f8c..100ed71a 100644 --- a/packages/paykit/src/cli/commands/init.ts +++ b/packages/paykit/src/cli/commands/init.ts @@ -1,10 +1,8 @@ import { exec } from "node:child_process"; -import { promisify } from "node:util"; - -const execAsync = promisify(exec); - import fs from "node:fs"; +import { createRequire } from "node:module"; import path from "node:path"; +import { promisify } from "node:util"; import * as p from "@clack/prompts"; import { Command } from "commander"; @@ -32,6 +30,9 @@ import { } from "../utils/env"; import { capture } from "../utils/telemetry"; +const execAsync = promisify(exec); +const require = createRequire(import.meta.url); + function ensureDir(filePath: string): void { const dir = path.dirname(filePath); if (!fs.existsSync(dir)) { @@ -41,7 +42,6 @@ function ensureDir(filePath: string): void { const POSSIBLE_CONFIG_PATHS = buildPossiblePaths(["paykit.ts", "paykit.config.ts"]); const POSSIBLE_CLIENT_PATHS = buildPossiblePaths(["paykit-client.ts"]); -type InitProvider = "stripe" | "polar"; function buildPossiblePaths(basePaths: string[]): string[] { const dirs = ["", "lib/", "server/", "utils/"]; @@ -58,46 +58,30 @@ function findExistingFile(cwd: string, candidates: string[]): string | null { return null; } -function detectExistingProvider(cwd: string, configPath: string | null): InitProvider | null { - if (!configPath) return null; - - const content = fs.readFileSync(path.join(cwd, configPath), "utf8"); - if (content.includes("@paykitjs/polar") || /provider:\s*polar\s*\(/.test(content)) { - return "polar"; - } - if (content.includes("@paykitjs/stripe") || /provider:\s*stripe\s*\(/.test(content)) { - return "stripe"; - } - - return null; -} - -function providerImport(provider: InitProvider): string { - return provider === "polar" - ? `import { polar } from "@paykitjs/polar";` - : `import { stripe } from "@paykitjs/stripe";`; +function stripeConfig(): string { + return `{ + secretKey: process.env.STRIPE_SECRET_KEY!, + webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!, + }`; } -function providerConfig(provider: InitProvider): string { - if (provider === "polar") { - return `polar({ - accessToken: process.env.POLAR_ACCESS_TOKEN!, - webhookSecret: process.env.POLAR_WEBHOOK_SECRET!, - server: process.env.POLAR_SERVER === "sandbox" ? "sandbox" : "production", - })`; +export function detectPaykitCli(): boolean { + try { + require.resolve("paykitjs/package.json"); + return true; + } catch { + return false; } +} - return `stripe({ - secretKey: process.env.STRIPE_SECRET_KEY!, - webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!, - })`; +export function getWebhookListenCommand(port: number, hasPaykitCli = detectPaykitCli()): string { + const path = `localhost:${String(port)}/paykit/webhook`; + return hasPaykitCli + ? `paykitjs listen --forward-to ${path}` + : `stripe listen --forward-to ${path}`; } -function generateConfigFile( - templateId: string, - includeIdentify: boolean, - provider: InitProvider, -): string { +function generateConfigFile(templateId: string, includeIdentify: boolean): string { const productImports = templateId === "saas-starter" ? ["free", "pro"] @@ -127,12 +111,11 @@ function generateConfigFile( },` : ""; - return `${providerImport(provider)} -import { createPayKit } from "paykitjs";${importLine} + return `import { createPayKit } from "paykitjs";${importLine} export const paykit = createPayKit({ database: process.env.DATABASE_URL!, - provider: ${providerConfig(provider)},${productsLine}${identifyBlock} + stripe: ${stripeConfig()},${productsLine}${identifyBlock} }); `; } @@ -172,7 +155,6 @@ function detectExistingProductsModule(content: string): string[] | null { function generateConfigFileFromProductsModule( productNames: string[], includeIdentify: boolean, - provider: InitProvider, productsImportPath = "./paykit-products", ): string { const uniqueProductNames = Array.from(new Set(productNames)); @@ -198,12 +180,11 @@ function generateConfigFileFromProductsModule( },` : ""; - return `${providerImport(provider)} -import { createPayKit } from "paykitjs";${importLine} + return `import { createPayKit } from "paykitjs";${importLine} export const paykit = createPayKit({ database: process.env.DATABASE_URL!, - provider: ${providerConfig(provider)},${productsLine}${identifyBlock} + stripe: ${stripeConfig()},${productsLine}${identifyBlock} }); `; } @@ -261,17 +242,10 @@ interface FileToWrite { const ENV_VARS = [{ key: "DATABASE_URL", line: "DATABASE_URL=" }]; -const PROVIDER_ENV_VARS: Record = { - polar: [ - { key: "POLAR_ACCESS_TOKEN", line: "POLAR_ACCESS_TOKEN=" }, - { key: "POLAR_WEBHOOK_SECRET", line: "POLAR_WEBHOOK_SECRET=" }, - { key: "POLAR_SERVER", line: "POLAR_SERVER=sandbox" }, - ], - stripe: [ - { key: "STRIPE_SECRET_KEY", line: "STRIPE_SECRET_KEY=" }, - { key: "STRIPE_WEBHOOK_SECRET", line: "STRIPE_WEBHOOK_SECRET=" }, - ], -}; +const STRIPE_ENV_VARS = [ + { key: "STRIPE_SECRET_KEY", line: "STRIPE_SECRET_KEY=" }, + { key: "STRIPE_WEBHOOK_SECRET", line: "STRIPE_WEBHOOK_SECRET=" }, +]; function frameworksList(): string { const c = picocolors.cyan; @@ -334,29 +308,8 @@ async function initAction(options: { cwd: string; defaults: boolean }): Promise< // Check what already exists const existingConfig = findExistingFile(cwd, POSSIBLE_CONFIG_PATHS); const existingClient = findExistingFile(cwd, POSSIBLE_CLIENT_PATHS); - const existingProvider = detectExistingProvider(cwd, existingConfig); - - let provider: string | symbol = "stripe"; - if (existingProvider) { - provider = existingProvider; - } else if (!existingConfig && !useDefaults) { - provider = await p.select({ - message: "Select payment provider", - options: [ - { value: "stripe", label: "Stripe" }, - { value: "polar", label: "Polar" }, - { value: "creem", label: "Creem", hint: "coming soon", disabled: true }, - ], - }); - - if (p.isCancel(provider)) { - p.cancel("Aborted"); - process.exit(0); - } - } - const selectedProvider: InitProvider = provider === "polar" ? "polar" : "stripe"; - const envVars = [...ENV_VARS, ...PROVIDER_ENV_VARS[selectedProvider]]; + const envVars = [...ENV_VARS, ...STRIPE_ENV_VARS]; const envLineByKey = new Map(envVars.map((v) => [v.key, v.line])); const envFiles = getEnvFiles(cwd); const envVarsToAdd = envVars.map((v) => v.key); @@ -525,8 +478,7 @@ async function initAction(options: { cwd: string; defaults: boolean }): Promise< } } - const providerPackage = selectedProvider === "polar" ? "@paykitjs/polar" : "@paykitjs/stripe"; - const packages = ["paykitjs", providerPackage]; + const packages = ["paykitjs"]; const toInstall = packages.filter((pkg) => !isPackageInstalled(cwd, pkg)); if (toInstall.length > 0) { @@ -557,10 +509,9 @@ async function initAction(options: { cwd: string; defaults: boolean }): Promise< ? generateConfigFileFromProductsModule( existingProductsModule, clientPath !== null, - selectedProvider, existingProductsImportPath, ) - : generateConfigFile(templateId as string, clientPath !== null, selectedProvider), + : generateConfigFile(templateId as string, clientPath !== null), }); } @@ -604,7 +555,7 @@ async function initAction(options: { cwd: string; defaults: boolean }): Promise< capture("cli_command", { command: "init", - provider: provider as string, + provider: "stripe", framework: framework.id, template: templateId as string, filesCreated: files.length, @@ -614,10 +565,7 @@ async function initAction(options: { cwd: string; defaults: boolean }): Promise< const exec = getExecPrefix(pm); const c = picocolors.cyan; const b = picocolors.bold; - const webhookCommand = - selectedProvider === "polar" - ? "polar listen http://localhost:3000/paykit/webhook" - : "stripe listen --forward-to localhost:3000/paykit/webhook"; + const webhookCommand = getWebhookListenCommand(3000); const isRerun = files.length === 0; const heading = isRerun diff --git a/packages/paykit/src/cli/commands/listen.ts b/packages/paykit/src/cli/commands/listen.ts index 125554e7..31408b49 100644 --- a/packages/paykit/src/cli/commands/listen.ts +++ b/packages/paykit/src/cli/commands/listen.ts @@ -1,9 +1,11 @@ import path from "node:path"; import { Command } from "commander"; +import dotenv from "dotenv"; import picocolors from "picocolors"; import type { PaymentProvider } from "../../providers/provider"; +import { createStripeAdapter } from "../../stripe/stripe-provider"; import { createDevLogger } from "../utils/dev-logger"; import { getOrCreateDeviceToken } from "../utils/device-token"; import { getPayKitConfig } from "../utils/get-config"; @@ -78,11 +80,35 @@ type TunnelServerMessage = interface RelayRuntimeContext { account: TunnelAccountSummary; - config: Awaited>; + basePath: string; + config?: Awaited>; deviceToken: string; provider: TunnelCapableProvider; } +function loadDotEnv(cwd: string): void { + dotenv.config({ path: path.join(cwd, ".env"), quiet: true }); + dotenv.config({ override: true, path: path.join(cwd, ".env.local"), quiet: true }); +} + +function getEnvStripeOptions(): { secretKey: string; webhookSecret?: string } { + const secretKey = process.env.E2E_STRIPE_SK ?? process.env.STRIPE_SECRET_KEY; + if (!secretKey) { + throw new Error( + "No PayKit config found and no Stripe secret key found in env. Set E2E_STRIPE_SK or STRIPE_SECRET_KEY, or pass --config.", + ); + } + + return { + secretKey, + webhookSecret: process.env.E2E_STRIPE_WHSEC ?? process.env.STRIPE_WEBHOOK_SECRET, + }; +} + +function isConfigNotFound(error: unknown): boolean { + return error instanceof Error && error.message.startsWith("No PayKit configuration file found."); +} + function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -374,7 +400,7 @@ async function connectTunnelSocket(params: { } async function consumeTunnelSocket(params: { - config: Awaited>; + config?: Awaited>; devLogger: ReturnType; forwardTo?: string; onReplayComplete: () => void; @@ -586,7 +612,7 @@ async function applyDeliveryDirectly(params: { }): Promise { try { await params.config.paykit.handleWebhook({ - allowStaleSignatures: true, + allowUnsignedPayload: true, body: params.delivery.body, headers: params.delivery.headers, }); @@ -597,7 +623,7 @@ async function applyDeliveryDirectly(params: { } async function deliverWebhook(params: { - config: Awaited>; + config?: Awaited>; delivery: DeliveryResponse; forwardTo?: string; }): Promise { @@ -609,6 +635,10 @@ async function deliverWebhook(params: { }); } + if (!params.config) { + return { error: "No PayKit config loaded for direct webhook delivery", ok: false }; + } + return applyDeliveryDirectly({ config: params.config, delivery: params.delivery }); } @@ -645,10 +675,26 @@ async function loadRelayRuntimeContext(params: { configPath?: string; cwd: string; devLogger: ReturnType; + requireConfig?: boolean; }): Promise { params.devLogger.start("Loading PayKit config"); - const config = await getPayKitConfig({ configPath: params.configPath, cwd: params.cwd }); - const provider = assertTunnelProvider(config.options.provider.createAdapter()); + let config: Awaited> | undefined; + let basePath = "/paykit"; + let stripeOptions; + + try { + config = await getPayKitConfig({ configPath: params.configPath, cwd: params.cwd }); + basePath = config.options.basePath ?? basePath; + stripeOptions = config.options.stripe; + } catch (error) { + if (params.configPath || params.requireConfig || !isConfigNotFound(error)) { + throw error; + } + loadDotEnv(params.cwd); + stripeOptions = getEnvStripeOptions(); + } + + const provider = assertTunnelProvider(createStripeAdapter(stripeOptions)); const deviceToken = getOrCreateDeviceToken(); params.devLogger.update("Connecting to Stripe"); @@ -657,6 +703,7 @@ async function loadRelayRuntimeContext(params: { return { account, + basePath, config, deviceToken, provider, @@ -675,10 +722,11 @@ async function listenAction(options: { const retryWindowMs = parseRetryWindowMs(options.retry); const relayStartedAt = Date.now(); - const { account, config, deviceToken, provider } = await loadRelayRuntimeContext({ + const { account, basePath, config, deviceToken, provider } = await loadRelayRuntimeContext({ configPath: options.config, cwd, devLogger, + requireConfig: !options.forwardTo, }); const tunnel = await ensureTunnel({ account, @@ -697,10 +745,7 @@ async function listenAction(options: { const { webhookSecret } = await syncProviderWebhook({ deviceToken, provider, tunnel }); const localWebhookUrl = options.forwardTo - ? buildLocalWebhookUrl( - normalizeLocalOrigin(options.forwardTo), - config.options.basePath ?? "/paykit", - ) + ? buildLocalWebhookUrl(normalizeLocalOrigin(options.forwardTo), basePath) : undefined; devLogger.stop(); printReadyBlock(devLogger, { @@ -778,6 +823,7 @@ async function enableAction(options: { config?: string; cwd: string }): Promise< configPath: options.config, cwd, devLogger, + requireConfig: true, }); const tunnel = await ensureTunnel({ account, @@ -811,6 +857,7 @@ async function disableAction(options: { config?: string; cwd: string }): Promise configPath: options.config, cwd, devLogger, + requireConfig: true, }); const tunnel = await ensureTunnel({ account, @@ -851,16 +898,14 @@ async function retryAction(options: { capture("cli_command", { command: "listen_retry" }); const devLogger = createDevLogger(); - const { config, deviceToken } = await loadRelayRuntimeContext({ + const { basePath, config, deviceToken } = await loadRelayRuntimeContext({ configPath: options.config, cwd, devLogger, + requireConfig: !options.forwardTo, }); const forwardTo = options.forwardTo - ? buildLocalWebhookUrl( - normalizeLocalOrigin(options.forwardTo), - config.options.basePath ?? "/paykit", - ) + ? buildLocalWebhookUrl(normalizeLocalOrigin(options.forwardTo), basePath) : undefined; const delivery = await getDelivery({ deliveryId: options.deliveryId, deviceToken }); devLogger.stop(); diff --git a/packages/paykit/src/cli/commands/push.ts b/packages/paykit/src/cli/commands/push.ts index 435c4ab1..b09d34e4 100644 --- a/packages/paykit/src/cli/commands/push.ts +++ b/packages/paykit/src/cli/commands/push.ts @@ -6,7 +6,6 @@ import picocolors from "picocolors"; import { assertValidPayKitOptions } from "../../core/validate-options"; import { - checkActiveSubscriptionsOnOtherProvider, checkProvider, checkProviderCustomers, createPool, @@ -33,22 +32,22 @@ async function pushAction(options: { config?: string; cwd: string; yes?: boolean const database = createPool(deps, config.options.database); try { - if (!config.options.provider) { + if (!config.options.stripe) { s.stop(""); - p.log.error(`Config\n ${picocolors.red("✖")} No provider configured`); + p.log.error(`Config\n ${picocolors.red("✖")} No Stripe config found`); p.cancel("Push failed"); process.exit(1); } const connStr = deps.getConnectionString(database as never); const [providerResult, pendingMigrations] = await Promise.all([ - checkProvider(config.options.provider), + checkProvider(config.options.stripe), deps.getPendingMigrationCount(database), ]); if (!providerResult.account.ok) { s.stop(""); - p.log.error(`Provider\n ${picocolors.red("✖")} ${providerResult.account.message}`); + p.log.error(`Stripe\n ${picocolors.red("✖")} ${providerResult.account.message}`); p.cancel("Push failed"); process.exit(1); } @@ -66,12 +65,8 @@ async function pushAction(options: { config?: string; cwd: string; yes?: boolean // Preflight checks s.message("Running preflight checks"); - const providerId = config.options.provider.id; - const [subscriptionErrors, customerErrors] = await Promise.all([ - checkActiveSubscriptionsOnOtherProvider(ctx, providerId), - checkProviderCustomers(ctx, providerResult.customerSample), - ]); - const allErrors = [...providerResult.errors, ...subscriptionErrors, ...customerErrors]; + const customerErrors = await checkProviderCustomers(ctx, providerResult.customerSample); + const allErrors = [...providerResult.errors, ...customerErrors]; if (allErrors.length > 0) { s.stop(""); @@ -93,7 +88,7 @@ async function pushAction(options: { config?: string; cwd: string; yes?: boolean p.log.info(`Database\n ${picocolors.green("✔")} ${connStr}\n ${migrationStatus}`); p.log.info( - `Provider\n ${picocolors.green("✔")} ${providerResult.account.displayName} (${providerResult.account.mode})`, + `Stripe\n ${picocolors.green("✔")} ${providerResult.account.displayName} (${providerResult.account.mode})`, ); if (diffs.length > 0) { @@ -144,7 +139,7 @@ async function pushAction(options: { config?: string; cwd: string; yes?: boolean } export const pushCommand = new Command("push") - .description("Apply migrations and sync products to database and payment provider") + .description("Apply migrations and sync products to database and Stripe") .option( "-c, --cwd ", "the working directory. defaults to the current directory.", diff --git a/packages/paykit/src/cli/commands/status.ts b/packages/paykit/src/cli/commands/status.ts index e1564c88..610ed5cf 100644 --- a/packages/paykit/src/cli/commands/status.ts +++ b/packages/paykit/src/cli/commands/status.ts @@ -5,7 +5,6 @@ import { Command } from "commander"; import picocolors from "picocolors"; import { - checkActiveSubscriptionsOnOtherProvider, checkDatabase, checkProvider, checkProviderCustomers, @@ -46,15 +45,15 @@ async function statusAction(options: { } const planCount = config.options.products ? Object.values(config.options.products).length : 0; - const hasProvider = Boolean(config.options.provider); + const hasStripe = Boolean(config.options.stripe); - if (!hasProvider) { + if (!hasStripe) { s.stop(""); p.log.error( `Config\n` + ` ${picocolors.green("✔")} ${picocolors.dim(config.path)}\n` + ` ${picocolors.green("✔")} ${String(planCount)} plan${planCount === 1 ? "" : "s"} defined\n` + - ` ${picocolors.red("✖")} No provider configured`, + ` ${picocolors.red("✖")} No Stripe config found`, ); p.outro("Fix config issues before continuing"); process.exit(1); @@ -66,7 +65,7 @@ async function statusAction(options: { const [dbResult, providerResult] = await Promise.all([ checkDatabase(database, deps), - checkProvider(config.options.provider), + checkProvider(config.options.stripe), ]); if (!dbResult.ok) { @@ -79,8 +78,8 @@ async function statusAction(options: { if (!providerResult.account.ok) { s.stop(""); - p.log.error(`Provider\n ${picocolors.red("✖")} ${providerResult.account.message}`); - p.outro("Fix provider issues before continuing"); + p.log.error(`Stripe\n ${picocolors.red("✖")} ${providerResult.account.message}`); + p.outro("Fix Stripe issues before continuing"); await database.end(); process.exit(1); } @@ -111,12 +110,8 @@ async function statusAction(options: { } else { const { ctx, diffs } = await loadProductDiffs(config, deps); - const providerId = config.options.provider.id; - const [subscriptionErrors, customerErrors] = await Promise.all([ - checkActiveSubscriptionsOnOtherProvider(ctx, providerId), - checkProviderCustomers(ctx, providerResult.customerSample), - ]); - preflightErrors = [...preflightErrors, ...subscriptionErrors, ...customerErrors]; + const customerErrors = await checkProviderCustomers(ctx, providerResult.customerSample); + preflightErrors = [...preflightErrors, ...customerErrors]; if (diffs.length === 0) { productsBlock = `Products\n ${picocolors.dim("No products defined")}`; @@ -147,13 +142,13 @@ async function statusAction(options: { `Config\n` + ` ${picocolors.green("✔")} ${picocolors.dim(config.path)}\n` + ` ${picocolors.green("✔")} ${String(planCount)} plan${planCount === 1 ? "" : "s"} defined\n` + - ` ${picocolors.green("✔")} Provider configured`, + ` ${picocolors.green("✔")} Stripe configured`, ); p.log.info(`Database\n ${picocolors.green("✔")} ${connStr}\n ${migrationStatus}`); p.log.info( - `Provider\n` + + `Stripe\n` + ` ${picocolors.green("✔")} ${providerResult.account.displayName} (${providerResult.account.mode})\n` + ` ${webhookStatus}`, ); diff --git a/packages/paykit/src/cli/utils/get-config.ts b/packages/paykit/src/cli/utils/get-config.ts index c2be1732..7188f211 100644 --- a/packages/paykit/src/cli/utils/get-config.ts +++ b/packages/paykit/src/cli/utils/get-config.ts @@ -134,7 +134,7 @@ async function loadModule(cwd: string, configPath: string): Promise { type ConfiguredPayKit = { handleWebhook(input: { - allowStaleSignatures?: boolean; + allowUnsignedPayload?: boolean; body: string; headers: Record; }): Promise; diff --git a/packages/paykit/src/cli/utils/shared.ts b/packages/paykit/src/cli/utils/shared.ts index 970d1c3c..f45d1bd5 100644 --- a/packages/paykit/src/cli/utils/shared.ts +++ b/packages/paykit/src/cli/utils/shared.ts @@ -3,7 +3,7 @@ import type { Pool } from "pg"; import type { createContext, PayKitContext } from "../../core/context"; import type { getPendingMigrationCount, migrateDatabase } from "../../database/index"; import type { dryRunSyncProducts, syncProducts } from "../../product/product-sync.service"; -import type { PayKitProviderConfig } from "../../providers/provider"; +import { createStripeAdapter, type StripeOptions } from "../../stripe/stripe-provider"; import type { PayKitOptions } from "../../types/options"; import type { NormalizedPlan } from "../../types/schema"; import type { detectPackageManager, getInstallCommand, getRunCommand } from "./detect"; @@ -108,16 +108,14 @@ export interface ProviderCheckResult { webhookEndpoints: Array<{ url: string; status: string }> | null; } -export async function checkProvider( - providerConfig: PayKitProviderConfig, -): Promise { +export async function checkProvider(stripeOptions: StripeOptions): Promise { try { - const adapter = providerConfig.createAdapter(); + const adapter = createStripeAdapter(stripeOptions); const result = await adapter.check?.(); if (!result) { return { - account: { ok: true, displayName: providerConfig.name, mode: "unknown" }, + account: { ok: true, displayName: "Stripe", mode: "unknown" }, customerSample: [], errors: [], webhookEndpoints: null, @@ -180,34 +178,6 @@ export async function checkProviderCustomers( return []; } -export async function checkActiveSubscriptionsOnOtherProvider( - ctx: PayKitContext, - currentProviderId: string, -): Promise { - const errors: string[] = []; - const { subscription } = await import("../../database/schema"); - const { and, ne, isNotNull, inArray, count } = await import("drizzle-orm"); - const rows = await ctx.database - .select({ count: count(), providerId: subscription.providerId }) - .from(subscription) - .where( - and( - inArray(subscription.status, ["active", "trialing", "past_due"]), - isNotNull(subscription.providerId), - ne(subscription.providerId, currentProviderId), - ), - ) - .groupBy(subscription.providerId); - for (const row of rows) { - if (row.count > 0 && row.providerId) { - errors.push( - `Found ${String(row.count)} subscription${row.count === 1 ? "" : "s"} (active, trialing, or past_due) linked to "${row.providerId}" but current provider is "${currentProviderId}". Existing subscriptions must be canceled before switching providers.`, - ); - } - } - return errors; -} - export async function loadProductDiffs( config: LoadedConfig, deps: Pick, diff --git a/packages/paykit/src/core/__tests__/context.test.ts b/packages/paykit/src/core/__tests__/context.test.ts index 89e99f5d..5a1c9aaf 100644 --- a/packages/paykit/src/core/__tests__/context.test.ts +++ b/packages/paykit/src/core/__tests__/context.test.ts @@ -1,11 +1,10 @@ import type { Pool } from "pg"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { PayKitProviderConfig, PaymentProvider } from "../../providers/provider"; - const mocks = vi.hoisted(() => ({ createDatabase: vi.fn(), createPayKitLogger: vi.fn(), + createStripeAdapter: vi.fn(), })); vi.mock("../../database/index", () => ({ @@ -16,14 +15,20 @@ vi.mock("../logger", () => ({ createPayKitLogger: mocks.createPayKitLogger, })); +vi.mock("../../stripe/stripe-provider", () => ({ + createStripeAdapter: mocks.createStripeAdapter, +})); + import { createContext } from "../context"; describe("core/context", () => { beforeEach(() => { mocks.createDatabase.mockReset(); mocks.createPayKitLogger.mockReset(); + mocks.createStripeAdapter.mockReset(); mocks.createDatabase.mockResolvedValue({ kind: "database" }); mocks.createPayKitLogger.mockReturnValue({ kind: "logger" }); + mocks.createStripeAdapter.mockReturnValue({ id: "stripe", name: "Stripe" }); }); it("passes logging options into the logger factory", async () => { @@ -31,23 +36,18 @@ describe("core/context", () => { level: "debug", } as const; const database = {} as Pool; - const adapter = { id: "test", name: "Test" } as unknown as PaymentProvider; - const provider: PayKitProviderConfig = { - capabilities: { testClocks: false }, - id: "test", - name: "Test", - createAdapter: () => adapter, - }; + const stripe = { secretKey: "sk_test_123", webhookSecret: "whsec_123" }; const context = await createContext({ database, logging, - provider, + stripe, }); expect(mocks.createDatabase).toHaveBeenCalledWith(database); + expect(mocks.createStripeAdapter).toHaveBeenCalledWith(stripe); expect(mocks.createPayKitLogger).toHaveBeenCalledWith(logging); expect(context.logger).toEqual({ kind: "logger" }); - expect(context.provider).toBe(adapter); + expect(context.provider).toEqual({ id: "stripe", name: "Stripe" }); }); }); diff --git a/packages/paykit/src/core/__tests__/create-paykit.test.ts b/packages/paykit/src/core/__tests__/create-paykit.test.ts new file mode 100644 index 00000000..c7e7f4db --- /dev/null +++ b/packages/paykit/src/core/__tests__/create-paykit.test.ts @@ -0,0 +1,81 @@ +import type { Pool } from "pg"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + createContext: vi.fn(), + getApi: vi.fn(), + getPendingMigrationCount: vi.fn(), +})); + +vi.mock("../context", () => ({ + createContext: mocks.createContext, +})); + +vi.mock("../../api/methods", () => ({ + createPayKitRouter: vi.fn(), + getApi: mocks.getApi, +})); + +vi.mock("../../database/index", () => ({ + getPendingMigrationCount: mocks.getPendingMigrationCount, +})); + +vi.mock("../../product/product-sync.service", () => ({ + dryRunSyncProducts: vi.fn().mockResolvedValue([]), +})); + +import { createPayKit } from "../create-paykit"; + +describe("core/create-paykit", () => { + const originalNodeEnv = process.env.NODE_ENV; + const originalPayKitCli = process.env.PAYKIT_CLI; + + beforeEach(() => { + vi.clearAllMocks(); + delete process.env.PAYKIT_CLI; + process.env.NODE_ENV = "development"; + mocks.createContext.mockResolvedValue({ kind: "context" }); + mocks.getApi.mockReturnValue({}); + mocks.getPendingMigrationCount.mockResolvedValue(0); + }); + + afterEach(() => { + if (originalNodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = originalNodeEnv; + } + + if (originalPayKitCli === undefined) { + delete process.env.PAYKIT_CLI; + } else { + process.env.PAYKIT_CLI = originalPayKitCli; + } + }); + + it("throws in development when migrations are pending", async () => { + const database = {} as Pool; + mocks.getPendingMigrationCount.mockResolvedValue(1); + + const paykit = createPayKit({ + database, + stripe: { secretKey: "sk_test_123", webhookSecret: "whsec_123" }, + }); + + await expect(paykit.$context).rejects.toThrow("1 pending migration"); + expect(mocks.createContext).not.toHaveBeenCalled(); + }); + + it("skips the migration assertion in production", async () => { + process.env.NODE_ENV = "production"; + const database = {} as Pool; + + const paykit = createPayKit({ + database, + stripe: { secretKey: "sk_test_123", webhookSecret: "whsec_123" }, + }); + + await expect(paykit.$context).resolves.toEqual({ kind: "context" }); + expect(mocks.getPendingMigrationCount).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/paykit/src/core/context.ts b/packages/paykit/src/core/context.ts index a83fa37e..f31b5112 100644 --- a/packages/paykit/src/core/context.ts +++ b/packages/paykit/src/core/context.ts @@ -2,6 +2,7 @@ import { Pool } from "pg"; import { createDatabase, type PayKitDatabase } from "../database/index"; import type { PaymentProvider } from "../providers/provider"; +import { createStripeAdapter } from "../stripe/stripe-provider"; import type { PayKitOptions } from "../types/options"; import { normalizeSchema, type NormalizedSchema } from "../types/schema"; import { PayKitError, PAYKIT_ERROR_CODES } from "./errors"; @@ -17,7 +18,7 @@ export interface PayKitContext { } export async function createContext(options: PayKitOptions): Promise { - if (!options.provider) { + if (!options.stripe) { throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.PROVIDER_REQUIRED); } @@ -34,7 +35,7 @@ export async function createContext(options: PayKitOptions): Promise Promise; -async function runDevChecks(ctx: PayKitContext, pool: Pool): Promise { +async function runDevChecks(ctx: PayKitContext): Promise { if (_global.__paykitDevChecksRan) return; _global.__paykitDevChecksRan = true; if (process.env.PAYKIT_DISABLE_DEPENDENCY_CHECKER !== "1") { @@ -37,13 +37,6 @@ async function runDevChecks(ctx: PayKitContext, pool: Pool): Promise { } await Promise.allSettled([ - getPendingMigrationCount(pool).then((count) => { - if (count > 0) { - console.warn( - `${picocolors.yellow("[paykit]")} ${count} pending migration${count === 1 ? "" : "s"}. Run ${picocolors.bold("paykitjs push")} to apply.`, - ); - } - }), dryRunSyncProducts(ctx).then((results) => { const outOfSync = results.filter((r) => r.action !== "unchanged"); if (outOfSync.length > 0) { @@ -55,6 +48,15 @@ async function runDevChecks(ctx: PayKitContext, pool: Pool): Promise { ]); } +async function assertNoPendingMigrations(pool: Pool): Promise { + const count = await getPendingMigrationCount(pool); + if (count > 0) { + throw new Error( + `${picocolors.yellow("[paykit]")} ${count} pending migration${count === 1 ? "" : "s"}. Run ${picocolors.bold("paykitjs push")} before starting your app.`, + ); + } +} + async function initContext(options: PayKitOptions): Promise { assertValidPayKitOptions(options); @@ -62,10 +64,15 @@ async function initContext(options: PayKitOptions): Promise { typeof options.database === "string" ? new Pool({ connectionString: options.database }) : options.database; + + if (process.env.NODE_ENV !== "production" && !process.env.PAYKIT_CLI) { + await assertNoPendingMigrations(pool); + } + const ctx = await createContext({ ...options, database: pool }); if (process.env.NODE_ENV !== "production" && !process.env.PAYKIT_CLI) { - runDevChecks(ctx, pool).catch(() => {}); + runDevChecks(ctx).catch(() => {}); } return ctx; @@ -76,7 +83,10 @@ export function createPayKit( ): PayKitInstance { let contextPromise: Promise | undefined; const getContext = () => { - contextPromise ??= initContext(options); + if (!contextPromise) { + contextPromise = initContext(options); + contextPromise.catch(() => {}); + } return contextPromise; }; diff --git a/packages/paykit/src/customer/__tests__/customer.service.test.ts b/packages/paykit/src/customer/__tests__/customer.service.test.ts index c782588a..6fb13db8 100644 --- a/packages/paykit/src/customer/__tests__/customer.service.test.ts +++ b/packages/paykit/src/customer/__tests__/customer.service.test.ts @@ -21,7 +21,12 @@ function createCustomerRow(overrides: Partial = {}): Customer { id: "customer_123", metadata: null, name: null, - provider: {}, + stripeCustomerId: null, + stripeFrozenTime: null, + stripeSyncedEmail: null, + stripeSyncedMetadata: null, + stripeSyncedName: null, + stripeTestClockId: null, updatedAt: now, ...overrides, }; @@ -110,16 +115,11 @@ describe("customer/service", () => { warn: vi.fn(), }, options: { - provider: { - id: "stripe", - name: "Stripe", - createAdapter: vi.fn(), - }, + stripe: { secretKey: "sk_test_123", webhookSecret: "whsec_123" }, testing: { enabled: true }, }, products: emptyProducts, provider: { - capabilities: { testClocks: true }, id: "stripe", name: "Stripe", ...stripe, @@ -141,16 +141,12 @@ describe("customer/service", () => { name: undefined, }); expect(providerUpdate.set).toHaveBeenCalledWith({ - provider: { - stripe: { - frozenTime: expect.any(String), - id: "cus_123", - testClockId: "clock_123", - syncedEmail: "test@example.com", - syncedName: null, - syncedMetadata: null, - }, - }, + stripeCustomerId: "cus_123", + stripeFrozenTime: expect.any(Date), + stripeSyncedEmail: "test@example.com", + stripeSyncedMetadata: null, + stripeSyncedName: null, + stripeTestClockId: "clock_123", updatedAt: expect.any(Date), }); }); @@ -207,15 +203,10 @@ describe("customer/service", () => { warn: vi.fn(), }, options: { - provider: { - id: "stripe", - name: "Stripe", - createAdapter: vi.fn(), - }, + stripe: { secretKey: "sk_test_123", webhookSecret: "whsec_123" }, }, products: emptyProducts, provider: { - capabilities: { testClocks: true }, id: "stripe", name: "Stripe", ...stripe, @@ -360,14 +351,10 @@ describe("customer/service", () => { const existingCustomer = createCustomerRow({ email: "same@example.com", name: "Same", - provider: { - stripe: { - id: "cus_existing", - syncedEmail: "same@example.com", - syncedName: "Same", - syncedMetadata: null, - }, - }, + stripeCustomerId: "cus_existing", + stripeSyncedEmail: "same@example.com", + stripeSyncedMetadata: null, + stripeSyncedName: "Same", }); const syncUpdate = createUpdateChain([existingCustomer]); const findFirst = vi @@ -401,21 +388,17 @@ describe("customer/service", () => { expect(providerMock.createCustomer).not.toHaveBeenCalled(); expect(providerMock.updateCustomer).not.toHaveBeenCalled(); - expect(result.provider).toEqual(existingCustomer.provider); + expect(result.stripeCustomerId).toBe("cus_existing"); }); it("calls provider when email changes from snapshot", async () => { const existingCustomer = createCustomerRow({ email: "new@example.com", name: "Same", - provider: { - stripe: { - id: "cus_existing", - syncedEmail: "old@example.com", - syncedName: "Same", - syncedMetadata: null, - }, - }, + stripeCustomerId: "cus_existing", + stripeSyncedEmail: "old@example.com", + stripeSyncedMetadata: null, + stripeSyncedName: "Same", }); const syncUpdate = createUpdateChain([existingCustomer]); const providerUpdate = createUpdateChain(undefined); @@ -460,9 +443,7 @@ describe("customer/service", () => { it("calls provider when no snapshot exists (first sync)", async () => { const existingCustomer = createCustomerRow({ email: "test@example.com", - provider: { - stripe: { id: "cus_existing" }, - }, + stripeCustomerId: "cus_existing", }); const syncUpdate = createUpdateChain([existingCustomer]); const providerUpdate = createUpdateChain(undefined); @@ -502,11 +483,8 @@ describe("customer/service", () => { expect(providerMock.updateCustomer).toHaveBeenCalled(); expect(providerUpdate.set).toHaveBeenCalledWith( expect.objectContaining({ - provider: expect.objectContaining({ - stripe: expect.objectContaining({ - syncedEmail: "test@example.com", - }), - }), + stripeCustomerId: "cus_existing", + stripeSyncedEmail: "test@example.com", }), ); }); diff --git a/packages/paykit/src/customer/customer.service.ts b/packages/paykit/src/customer/customer.service.ts index a0743c15..6e3f3b9b 100644 --- a/packages/paykit/src/customer/customer.service.ts +++ b/packages/paykit/src/customer/customer.service.ts @@ -12,7 +12,7 @@ import { subscription, } from "../database/schema"; import { getProductByHash } from "../product/product.service"; -import type { ProviderCustomer, ProviderCustomerMap } from "../providers/provider"; +import type { ProviderCustomer } from "../providers/provider"; import { getActiveSubscriptionInGroup, getCurrentSubscriptions, @@ -310,24 +310,39 @@ export async function getCustomerWithDetails( export function getProviderCustomer( customerRow: Customer, - providerId: string, + _providerId: string, ): ProviderCustomer | null { - const providerMap = (customerRow.provider ?? {}) as ProviderCustomerMap; - return providerMap[providerId] ?? null; + if (!customerRow.stripeCustomerId) { + return null; + } + + return { + frozenTime: customerRow.stripeFrozenTime?.toISOString(), + id: customerRow.stripeCustomerId, + syncedEmail: customerRow.stripeSyncedEmail, + syncedMetadata: customerRow.stripeSyncedMetadata, + syncedName: customerRow.stripeSyncedName, + testClockId: customerRow.stripeTestClockId ?? undefined, + }; } export async function setProviderCustomer( database: PayKitDatabase, input: { customerId: string; providerCustomer: ProviderCustomer; providerId: string }, ): Promise { - const existingCustomer = await getCustomerByIdOrThrow(database, input.customerId); - const providerMap = (existingCustomer.provider ?? {}) as ProviderCustomerMap; - providerMap[input.providerId] = input.providerCustomer; - - await database - .update(customer) - .set({ provider: providerMap, updatedAt: new Date() }) - .where(eq(customer.id, input.customerId)); + const values: Partial = { + stripeCustomerId: input.providerCustomer.id, + stripeFrozenTime: input.providerCustomer.frozenTime + ? new Date(input.providerCustomer.frozenTime) + : null, + stripeSyncedEmail: input.providerCustomer.syncedEmail ?? null, + stripeSyncedMetadata: input.providerCustomer.syncedMetadata ?? null, + stripeSyncedName: input.providerCustomer.syncedName ?? null, + stripeTestClockId: input.providerCustomer.testClockId ?? null, + updatedAt: new Date(), + }; + + await database.update(customer).set(values).where(eq(customer.id, input.customerId)); } export function getProviderCustomerId(customerRow: Customer, providerId: string): string | null { @@ -351,7 +366,7 @@ export async function findCustomerByProviderCustomerId( ): Promise { return ( (await database.query.customer.findFirst({ - where: sql`${customer.provider}->${input.providerId}->>'id' = ${input.providerCustomerId}`, + where: eq(customer.stripeCustomerId, input.providerCustomerId), })) ?? null ); } @@ -401,8 +416,7 @@ export async function upsertProviderCustomer( providerCustomer = { ...existingProviderCustomer!, id: existingProviderCustomerId }; } else { const result = await ctx.provider.createCustomer({ - createTestClock: - ctx.options.testing?.enabled === true && ctx.provider.capabilities.testClocks, + createTestClock: ctx.options.testing?.enabled === true, id: existingCustomer.id, email: existingCustomer.email ?? undefined, name: existingCustomer.name ?? undefined, diff --git a/packages/paykit/src/database/migrations/0001_stripe_only_schema.sql b/packages/paykit/src/database/migrations/0001_stripe_only_schema.sql new file mode 100644 index 00000000..6b3f4ebc --- /dev/null +++ b/packages/paykit/src/database/migrations/0001_stripe_only_schema.sql @@ -0,0 +1,158 @@ +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM "paykit_customer", jsonb_object_keys("provider") AS provider_key + WHERE provider_key <> 'stripe' + ) OR EXISTS ( + SELECT 1 + FROM "paykit_product", jsonb_object_keys("provider") AS provider_key + WHERE provider_key <> 'stripe' + ) OR EXISTS ( + SELECT 1 FROM "paykit_payment_method" WHERE "provider_id" <> 'stripe' + ) OR EXISTS ( + SELECT 1 FROM "paykit_subscription" WHERE "provider_id" IS NOT NULL AND "provider_id" <> 'stripe' + ) OR EXISTS ( + SELECT 1 FROM "paykit_invoice" WHERE "provider_id" <> 'stripe' + ) OR EXISTS ( + SELECT 1 FROM "paykit_metadata" WHERE "provider_id" <> 'stripe' + ) OR EXISTS ( + SELECT 1 FROM "paykit_webhook_event" WHERE "provider_id" <> 'stripe' + ) THEN + RAISE EXCEPTION 'PayKit stripe-only migration cannot run because non-Stripe provider data exists. Migration aborted without removing provider data.'; + END IF; +END $$; +--> statement-breakpoint +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM ( + SELECT "provider_checkout_session_id" AS id + FROM "paykit_metadata" + WHERE "provider_id" = 'stripe' AND "provider_checkout_session_id" IS NOT NULL + GROUP BY 1 + HAVING count(*) > 1 + ) duplicates + ) THEN + RAISE EXCEPTION 'PayKit stripe-only migration cannot run because duplicate Stripe checkout session IDs exist.'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM ( + SELECT "provider_event_id" AS id + FROM "paykit_webhook_event" + WHERE "provider_id" = 'stripe' + GROUP BY 1 + HAVING count(*) > 1 + ) duplicates + ) THEN + RAISE EXCEPTION 'PayKit stripe-only migration cannot run because duplicate Stripe webhook event IDs exist.'; + END IF; +END $$; +--> statement-breakpoint +ALTER TABLE "paykit_customer" ADD COLUMN "stripe_customer_id" text;--> statement-breakpoint +ALTER TABLE "paykit_customer" ADD COLUMN "stripe_test_clock_id" text;--> statement-breakpoint +ALTER TABLE "paykit_customer" ADD COLUMN "stripe_frozen_time" timestamptz;--> statement-breakpoint +ALTER TABLE "paykit_customer" ADD COLUMN "stripe_synced_email" text;--> statement-breakpoint +ALTER TABLE "paykit_customer" ADD COLUMN "stripe_synced_name" text;--> statement-breakpoint +ALTER TABLE "paykit_customer" ADD COLUMN "stripe_synced_metadata" jsonb;--> statement-breakpoint +ALTER TABLE "paykit_invoice" ADD COLUMN "stripe_invoice_id" text;--> statement-breakpoint +ALTER TABLE "paykit_invoice" ADD COLUMN "stripe_payment_id" text;--> statement-breakpoint +ALTER TABLE "paykit_invoice" ADD COLUMN "stripe_payment_method_id" text;--> statement-breakpoint +ALTER TABLE "paykit_metadata" ADD COLUMN "stripe_checkout_session_id" text;--> statement-breakpoint +ALTER TABLE "paykit_payment_method" ADD COLUMN "stripe_payment_method_id" text;--> statement-breakpoint +ALTER TABLE "paykit_payment_method" ADD COLUMN "type" text;--> statement-breakpoint +ALTER TABLE "paykit_payment_method" ADD COLUMN "brand" text;--> statement-breakpoint +ALTER TABLE "paykit_payment_method" ADD COLUMN "last4" text;--> statement-breakpoint +ALTER TABLE "paykit_payment_method" ADD COLUMN "expiry_month" integer;--> statement-breakpoint +ALTER TABLE "paykit_payment_method" ADD COLUMN "expiry_year" integer;--> statement-breakpoint +ALTER TABLE "paykit_product" ADD COLUMN "stripe_product_id" text;--> statement-breakpoint +ALTER TABLE "paykit_product" ADD COLUMN "stripe_price_id" text;--> statement-breakpoint +ALTER TABLE "paykit_subscription" ADD COLUMN "stripe_subscription_id" text;--> statement-breakpoint +ALTER TABLE "paykit_subscription" ADD COLUMN "stripe_subscription_schedule_id" text;--> statement-breakpoint +ALTER TABLE "paykit_webhook_event" ADD COLUMN "stripe_event_id" text;--> statement-breakpoint +UPDATE "paykit_customer" +SET + "stripe_customer_id" = "provider"->'stripe'->>'id', + "stripe_test_clock_id" = "provider"->'stripe'->>'testClockId', + "stripe_frozen_time" = CASE + WHEN "provider"->'stripe'->>'frozenTime' ~ '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}' + THEN ("provider"->'stripe'->>'frozenTime')::timestamptz + ELSE NULL + END, + "stripe_synced_email" = "provider"->'stripe'->>'syncedEmail', + "stripe_synced_name" = "provider"->'stripe'->>'syncedName', + "stripe_synced_metadata" = "provider"->'stripe'->'syncedMetadata' +WHERE "provider" ? 'stripe';--> statement-breakpoint +UPDATE "paykit_payment_method" +SET + "stripe_payment_method_id" = "provider_data"->>'methodId', + "type" = "provider_data"->>'type', + "brand" = "provider_data"->>'brand', + "last4" = "provider_data"->>'last4', + "expiry_month" = CASE + WHEN "provider_data"->>'expiryMonth' ~ '^\d+$' + THEN ("provider_data"->>'expiryMonth')::integer + ELSE NULL + END, + "expiry_year" = CASE + WHEN "provider_data"->>'expiryYear' ~ '^\d+$' + THEN ("provider_data"->>'expiryYear')::integer + ELSE NULL + END +WHERE "provider_id" = 'stripe';--> statement-breakpoint +UPDATE "paykit_product" +SET + "stripe_product_id" = "provider"->'stripe'->>'productId', + "stripe_price_id" = "provider"->'stripe'->>'priceId' +WHERE "provider" ? 'stripe';--> statement-breakpoint +UPDATE "paykit_subscription" +SET + "stripe_subscription_id" = "provider_data"->>'subscriptionId', + "stripe_subscription_schedule_id" = "provider_data"->>'subscriptionScheduleId' +WHERE "provider_id" = 'stripe';--> statement-breakpoint +UPDATE "paykit_invoice" +SET + "stripe_invoice_id" = "provider_data"->>'invoiceId', + "stripe_payment_id" = "provider_data"->>'paymentId', + "stripe_payment_method_id" = "provider_data"->>'methodId' +WHERE "provider_id" = 'stripe';--> statement-breakpoint +UPDATE "paykit_metadata" +SET "stripe_checkout_session_id" = "provider_checkout_session_id" +WHERE "provider_id" = 'stripe';--> statement-breakpoint +UPDATE "paykit_webhook_event" +SET "stripe_event_id" = "provider_event_id" +WHERE "provider_id" = 'stripe';--> statement-breakpoint +CREATE INDEX "paykit_customer_stripe_customer_idx" ON "paykit_customer" USING btree ("stripe_customer_id");--> statement-breakpoint +CREATE INDEX "paykit_customer_stripe_test_clock_idx" ON "paykit_customer" USING btree ("stripe_test_clock_id");--> statement-breakpoint +CREATE INDEX "paykit_invoice_stripe_invoice_idx" ON "paykit_invoice" USING btree ("stripe_invoice_id");--> statement-breakpoint +CREATE INDEX "paykit_invoice_stripe_payment_idx" ON "paykit_invoice" USING btree ("stripe_payment_id");--> statement-breakpoint +CREATE UNIQUE INDEX "paykit_metadata_stripe_checkout_session_unique" ON "paykit_metadata" USING btree ("stripe_checkout_session_id");--> statement-breakpoint +CREATE INDEX "paykit_payment_method_stripe_payment_method_idx" ON "paykit_payment_method" USING btree ("stripe_payment_method_id");--> statement-breakpoint +CREATE INDEX "paykit_product_stripe_product_idx" ON "paykit_product" USING btree ("stripe_product_id");--> statement-breakpoint +CREATE INDEX "paykit_product_stripe_price_idx" ON "paykit_product" USING btree ("stripe_price_id");--> statement-breakpoint +CREATE INDEX "paykit_subscription_stripe_subscription_idx" ON "paykit_subscription" USING btree ("stripe_subscription_id");--> statement-breakpoint +CREATE INDEX "paykit_subscription_stripe_schedule_idx" ON "paykit_subscription" USING btree ("stripe_subscription_schedule_id");--> statement-breakpoint +CREATE UNIQUE INDEX "paykit_webhook_event_stripe_event_id_unique" ON "paykit_webhook_event" USING btree ("stripe_event_id");--> statement-breakpoint +CREATE INDEX "paykit_webhook_event_stripe_status_idx" ON "paykit_webhook_event" USING btree ("status");--> statement-breakpoint +DROP INDEX "paykit_invoice_provider_idx";--> statement-breakpoint +DROP INDEX "paykit_metadata_checkout_session_unique";--> statement-breakpoint +DROP INDEX "paykit_payment_method_provider_idx";--> statement-breakpoint +DROP INDEX "paykit_subscription_provider_idx";--> statement-breakpoint +DROP INDEX "paykit_webhook_event_provider_unique";--> statement-breakpoint +DROP INDEX "paykit_webhook_event_status_idx";--> statement-breakpoint +ALTER TABLE "paykit_customer" DROP COLUMN "provider";--> statement-breakpoint +ALTER TABLE "paykit_product" DROP COLUMN "provider";--> statement-breakpoint +ALTER TABLE "paykit_payment_method" DROP COLUMN "provider_id";--> statement-breakpoint +ALTER TABLE "paykit_payment_method" DROP COLUMN "provider_data";--> statement-breakpoint +ALTER TABLE "paykit_subscription" DROP COLUMN "provider_id";--> statement-breakpoint +ALTER TABLE "paykit_subscription" DROP COLUMN "provider_data";--> statement-breakpoint +ALTER TABLE "paykit_invoice" DROP COLUMN "provider_id";--> statement-breakpoint +ALTER TABLE "paykit_invoice" DROP COLUMN "provider_data";--> statement-breakpoint +ALTER TABLE "paykit_metadata" DROP COLUMN "provider_id";--> statement-breakpoint +ALTER TABLE "paykit_metadata" DROP COLUMN "provider_checkout_session_id";--> statement-breakpoint +ALTER TABLE "paykit_webhook_event" DROP COLUMN "provider_id";--> statement-breakpoint +ALTER TABLE "paykit_webhook_event" DROP COLUMN "provider_event_id";--> statement-breakpoint +ALTER TABLE "paykit_webhook_event" ALTER COLUMN "stripe_event_id" SET NOT NULL; diff --git a/packages/paykit/src/database/migrations/meta/0001_snapshot.json b/packages/paykit/src/database/migrations/meta/0001_snapshot.json new file mode 100644 index 00000000..a14f0454 --- /dev/null +++ b/packages/paykit/src/database/migrations/meta/0001_snapshot.json @@ -0,0 +1,1346 @@ +{ + "id": "f20b685c-77de-49ad-903d-6dc82acf06be", + "prevId": "476e3c1e-e8b7-4c5b-9d43-f10c4173b9ee", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.paykit_customer": { + "name": "paykit_customer", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_test_clock_id": { + "name": "stripe_test_clock_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_frozen_time": { + "name": "stripe_frozen_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stripe_synced_email": { + "name": "stripe_synced_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_synced_name": { + "name": "stripe_synced_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_synced_metadata": { + "name": "stripe_synced_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "paykit_customer_deleted_at_idx": { + "name": "paykit_customer_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paykit_customer_stripe_customer_idx": { + "name": "paykit_customer_stripe_customer_idx", + "columns": [ + { + "expression": "stripe_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paykit_customer_stripe_test_clock_idx": { + "name": "paykit_customer_stripe_test_clock_idx", + "columns": [ + { + "expression": "stripe_test_clock_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paykit_entitlement": { + "name": "paykit_entitlement", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "limit": { + "name": "limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "balance": { + "name": "balance", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_reset_at": { + "name": "next_reset_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "paykit_entitlement_subscription_idx": { + "name": "paykit_entitlement_subscription_idx", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paykit_entitlement_customer_feature_idx": { + "name": "paykit_entitlement_customer_feature_idx", + "columns": [ + { + "expression": "customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "feature_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paykit_entitlement_next_reset_idx": { + "name": "paykit_entitlement_next_reset_idx", + "columns": [ + { + "expression": "next_reset_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paykit_entitlement_subscription_id_paykit_subscription_id_fk": { + "name": "paykit_entitlement_subscription_id_paykit_subscription_id_fk", + "tableFrom": "paykit_entitlement", + "tableTo": "paykit_subscription", + "columnsFrom": [ + "subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "paykit_entitlement_customer_id_paykit_customer_id_fk": { + "name": "paykit_entitlement_customer_id_paykit_customer_id_fk", + "tableFrom": "paykit_entitlement", + "tableTo": "paykit_customer", + "columnsFrom": [ + "customer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "paykit_entitlement_feature_id_paykit_feature_id_fk": { + "name": "paykit_entitlement_feature_id_paykit_feature_id_fk", + "tableFrom": "paykit_entitlement", + "tableTo": "paykit_feature", + "columnsFrom": [ + "feature_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paykit_feature": { + "name": "paykit_feature", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paykit_invoice": { + "name": "paykit_invoice", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hosted_url": { + "name": "hosted_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_payment_id": { + "name": "stripe_payment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_payment_method_id": { + "name": "stripe_payment_method_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start_at": { + "name": "period_start_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end_at": { + "name": "period_end_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "paykit_invoice_customer_idx": { + "name": "paykit_invoice_customer_idx", + "columns": [ + { + "expression": "customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paykit_invoice_subscription_idx": { + "name": "paykit_invoice_subscription_idx", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paykit_invoice_stripe_invoice_idx": { + "name": "paykit_invoice_stripe_invoice_idx", + "columns": [ + { + "expression": "stripe_invoice_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paykit_invoice_stripe_payment_idx": { + "name": "paykit_invoice_stripe_payment_idx", + "columns": [ + { + "expression": "stripe_payment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paykit_invoice_customer_id_paykit_customer_id_fk": { + "name": "paykit_invoice_customer_id_paykit_customer_id_fk", + "tableFrom": "paykit_invoice", + "tableTo": "paykit_customer", + "columnsFrom": [ + "customer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "paykit_invoice_subscription_id_paykit_subscription_id_fk": { + "name": "paykit_invoice_subscription_id_paykit_subscription_id_fk", + "tableFrom": "paykit_invoice", + "tableTo": "paykit_subscription", + "columnsFrom": [ + "subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paykit_metadata": { + "name": "paykit_metadata", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stripe_checkout_session_id": { + "name": "stripe_checkout_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "paykit_metadata_stripe_checkout_session_unique": { + "name": "paykit_metadata_stripe_checkout_session_unique", + "columns": [ + { + "expression": "stripe_checkout_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paykit_payment_method": { + "name": "paykit_payment_method", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_payment_method_id": { + "name": "stripe_payment_method_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "brand": { + "name": "brand", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last4": { + "name": "last4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expiry_month": { + "name": "expiry_month", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "expiry_year": { + "name": "expiry_year", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "paykit_payment_method_customer_idx": { + "name": "paykit_payment_method_customer_idx", + "columns": [ + { + "expression": "customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paykit_payment_method_stripe_payment_method_idx": { + "name": "paykit_payment_method_stripe_payment_method_idx", + "columns": [ + { + "expression": "stripe_payment_method_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paykit_payment_method_customer_id_paykit_customer_id_fk": { + "name": "paykit_payment_method_customer_id_paykit_customer_id_fk", + "tableFrom": "paykit_payment_method", + "tableTo": "paykit_customer", + "columnsFrom": [ + "customer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paykit_product": { + "name": "paykit_product", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group": { + "name": "group", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "price_amount": { + "name": "price_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "price_interval": { + "name": "price_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_product_id": { + "name": "stripe_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "paykit_product_id_version_unique": { + "name": "paykit_product_id_version_unique", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paykit_product_default_idx": { + "name": "paykit_product_default_idx", + "columns": [ + { + "expression": "is_default", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paykit_product_stripe_product_idx": { + "name": "paykit_product_stripe_product_idx", + "columns": [ + { + "expression": "stripe_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paykit_product_stripe_price_idx": { + "name": "paykit_product_stripe_price_idx", + "columns": [ + { + "expression": "stripe_price_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paykit_product_feature": { + "name": "paykit_product_feature", + "schema": "", + "columns": { + "product_internal_id": { + "name": "product_internal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "limit": { + "name": "limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "reset_interval": { + "name": "reset_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "paykit_product_feature_feature_idx": { + "name": "paykit_product_feature_feature_idx", + "columns": [ + { + "expression": "feature_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paykit_product_feature_product_internal_id_paykit_product_internal_id_fk": { + "name": "paykit_product_feature_product_internal_id_paykit_product_internal_id_fk", + "tableFrom": "paykit_product_feature", + "tableTo": "paykit_product", + "columnsFrom": [ + "product_internal_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "paykit_product_feature_feature_id_paykit_feature_id_fk": { + "name": "paykit_product_feature_feature_id_paykit_feature_id_fk", + "tableFrom": "paykit_product_feature", + "tableTo": "paykit_feature", + "columnsFrom": [ + "feature_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "paykit_product_feature_product_internal_id_feature_id_pk": { + "name": "paykit_product_feature_product_internal_id_feature_id_pk", + "columns": [ + "product_internal_id", + "feature_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paykit_subscription": { + "name": "paykit_subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_internal_id": { + "name": "product_internal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_schedule_id": { + "name": "stripe_subscription_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canceled": { + "name": "canceled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_ends_at": { + "name": "trial_ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "current_period_start_at": { + "name": "current_period_start_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "current_period_end_at": { + "name": "current_period_end_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scheduled_product_id": { + "name": "scheduled_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "paykit_subscription_customer_status_idx": { + "name": "paykit_subscription_customer_status_idx", + "columns": [ + { + "expression": "customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paykit_subscription_product_idx": { + "name": "paykit_subscription_product_idx", + "columns": [ + { + "expression": "product_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paykit_subscription_stripe_subscription_idx": { + "name": "paykit_subscription_stripe_subscription_idx", + "columns": [ + { + "expression": "stripe_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paykit_subscription_stripe_schedule_idx": { + "name": "paykit_subscription_stripe_schedule_idx", + "columns": [ + { + "expression": "stripe_subscription_schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paykit_subscription_customer_id_paykit_customer_id_fk": { + "name": "paykit_subscription_customer_id_paykit_customer_id_fk", + "tableFrom": "paykit_subscription", + "tableTo": "paykit_customer", + "columnsFrom": [ + "customer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "paykit_subscription_product_internal_id_paykit_product_internal_id_fk": { + "name": "paykit_subscription_product_internal_id_paykit_product_internal_id_fk", + "tableFrom": "paykit_subscription", + "tableTo": "paykit_product", + "columnsFrom": [ + "product_internal_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paykit_webhook_event": { + "name": "paykit_webhook_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "stripe_event_id": { + "name": "stripe_event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trace_id": { + "name": "trace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paykit_webhook_event_stripe_event_id_unique": { + "name": "paykit_webhook_event_stripe_event_id_unique", + "columns": [ + { + "expression": "stripe_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paykit_webhook_event_stripe_status_idx": { + "name": "paykit_webhook_event_stripe_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/paykit/src/database/migrations/meta/_journal.json b/packages/paykit/src/database/migrations/meta/_journal.json index ca85e22c..2b1ff3a7 100644 --- a/packages/paykit/src/database/migrations/meta/_journal.json +++ b/packages/paykit/src/database/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1775526333776, "tag": "0000_init", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1780368513363, + "tag": "0001_stripe_only_schema", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/packages/paykit/src/database/schema.ts b/packages/paykit/src/database/schema.ts index 6aa0776a..029fd439 100644 --- a/packages/paykit/src/database/schema.ts +++ b/packages/paykit/src/database/schema.ts @@ -10,8 +10,6 @@ import { uniqueIndex, } from "drizzle-orm/pg-core"; -import type { ProviderCustomerMap } from "../providers/provider"; - const pgTable = pgTableCreator((name) => `paykit_${name}`); const createdAt = timestamp("created_at") @@ -29,12 +27,21 @@ export const customer = pgTable( email: text("email"), name: text("name"), metadata: jsonb("metadata").$type | null>(), - provider: jsonb("provider").$type().notNull().default({}), + stripeCustomerId: text("stripe_customer_id"), + stripeTestClockId: text("stripe_test_clock_id"), + stripeFrozenTime: timestamp("stripe_frozen_time", { withTimezone: true }), + stripeSyncedEmail: text("stripe_synced_email"), + stripeSyncedName: text("stripe_synced_name"), + stripeSyncedMetadata: jsonb("stripe_synced_metadata").$type | null>(), deletedAt: timestamp("deleted_at"), createdAt, updatedAt, }, - (table) => [index("paykit_customer_deleted_at_idx").on(table.deletedAt)], + (table) => [ + index("paykit_customer_deleted_at_idx").on(table.deletedAt), + index("paykit_customer_stripe_customer_idx").on(table.stripeCustomerId), + index("paykit_customer_stripe_test_clock_idx").on(table.stripeTestClockId), + ], ); export const paymentMethod = pgTable( @@ -44,8 +51,12 @@ export const paymentMethod = pgTable( customerId: text("customer_id") .notNull() .references(() => customer.id), - providerId: text("provider_id").notNull(), - providerData: jsonb("provider_data").$type>().notNull(), + stripePaymentMethodId: text("stripe_payment_method_id"), + type: text("type"), + brand: text("brand"), + last4: text("last4"), + expiryMonth: integer("expiry_month"), + expiryYear: integer("expiry_year"), isDefault: boolean("is_default").notNull().default(false), deletedAt: timestamp("deleted_at"), createdAt, @@ -53,7 +64,7 @@ export const paymentMethod = pgTable( }, (table) => [ index("paykit_payment_method_customer_idx").on(table.customerId, table.deletedAt), - index("paykit_payment_method_provider_idx").on(table.providerId), + index("paykit_payment_method_stripe_payment_method_idx").on(table.stripePaymentMethodId), ], ); @@ -64,8 +75,6 @@ export const feature = pgTable("feature", { updatedAt, }); -type ProviderProductMap = Record>; - export const product = pgTable( "product", { @@ -78,13 +87,16 @@ export const product = pgTable( priceAmount: integer("price_amount"), priceInterval: text("price_interval"), hash: text("hash"), - provider: jsonb("provider").$type().notNull().default({}), + stripeProductId: text("stripe_product_id"), + stripePriceId: text("stripe_price_id"), createdAt, updatedAt, }, (table) => [ uniqueIndex("paykit_product_id_version_unique").on(table.id, table.version), index("paykit_product_default_idx").on(table.isDefault), + index("paykit_product_stripe_product_idx").on(table.stripeProductId), + index("paykit_product_stripe_price_idx").on(table.stripePriceId), ], ); @@ -119,8 +131,8 @@ export const subscription = pgTable( productInternalId: text("product_internal_id") .notNull() .references(() => product.internalId), - providerId: text("provider_id"), - providerData: jsonb("provider_data").$type | null>(), + stripeSubscriptionId: text("stripe_subscription_id"), + stripeSubscriptionScheduleId: text("stripe_subscription_schedule_id"), status: text("status").notNull(), canceled: boolean("canceled").notNull().default(false), cancelAtPeriodEnd: boolean("cancel_at_period_end").notNull().default(false), @@ -142,7 +154,8 @@ export const subscription = pgTable( table.endedAt, ), index("paykit_subscription_product_idx").on(table.productInternalId), - index("paykit_subscription_provider_idx").on(table.providerId), + index("paykit_subscription_stripe_subscription_idx").on(table.stripeSubscriptionId), + index("paykit_subscription_stripe_schedule_idx").on(table.stripeSubscriptionScheduleId), ], ); @@ -184,8 +197,9 @@ export const invoice = pgTable( currency: text("currency").notNull(), description: text("description"), hostedUrl: text("hosted_url"), - providerId: text("provider_id").notNull(), - providerData: jsonb("provider_data").$type>().notNull(), + stripeInvoiceId: text("stripe_invoice_id"), + stripePaymentId: text("stripe_payment_id"), + stripePaymentMethodId: text("stripe_payment_method_id"), periodStartAt: timestamp("period_start_at"), periodEndAt: timestamp("period_end_at"), createdAt, @@ -194,7 +208,8 @@ export const invoice = pgTable( (table) => [ index("paykit_invoice_customer_idx").on(table.customerId, table.createdAt), index("paykit_invoice_subscription_idx").on(table.subscriptionId), - index("paykit_invoice_provider_idx").on(table.providerId), + index("paykit_invoice_stripe_invoice_idx").on(table.stripeInvoiceId), + index("paykit_invoice_stripe_payment_idx").on(table.stripePaymentId), ], ); @@ -202,18 +217,14 @@ export const metadata = pgTable( "metadata", { id: text("id").primaryKey(), - providerId: text("provider_id").notNull(), type: text("type").notNull(), data: jsonb("data").$type>().notNull(), - providerCheckoutSessionId: text("provider_checkout_session_id"), + stripeCheckoutSessionId: text("stripe_checkout_session_id"), expiresAt: timestamp("expires_at"), createdAt, }, (table) => [ - uniqueIndex("paykit_metadata_checkout_session_unique").on( - table.providerId, - table.providerCheckoutSessionId, - ), + uniqueIndex("paykit_metadata_stripe_checkout_session_unique").on(table.stripeCheckoutSessionId), ], ); @@ -221,8 +232,7 @@ export const webhookEvent = pgTable( "webhook_event", { id: text("id").primaryKey(), - providerId: text("provider_id").notNull(), - providerEventId: text("provider_event_id").notNull(), + stripeEventId: text("stripe_event_id").notNull(), type: text("type").notNull(), payload: jsonb("payload").$type>().notNull(), status: text("status").notNull(), @@ -232,7 +242,7 @@ export const webhookEvent = pgTable( processedAt: timestamp("processed_at"), }, (table) => [ - uniqueIndex("paykit_webhook_event_provider_unique").on(table.providerId, table.providerEventId), - index("paykit_webhook_event_status_idx").on(table.providerId, table.status), + uniqueIndex("paykit_webhook_event_stripe_event_id_unique").on(table.stripeEventId), + index("paykit_webhook_event_stripe_status_idx").on(table.status), ], ); diff --git a/packages/paykit/src/index.ts b/packages/paykit/src/index.ts index 36130ea6..3219dc0d 100644 --- a/packages/paykit/src/index.ts +++ b/packages/paykit/src/index.ts @@ -25,15 +25,8 @@ export type { EntitlementBalance, ReportResult, } from "./entitlement/entitlement.service"; -export type { - PayKitProviderConfig, - PaymentProvider, - ProviderCustomer, - ProviderCustomerMap, - ProviderTunnelAccount, - ProviderTunnelWebhook, - ProviderTestClock, -} from "./providers/provider"; +export { PAYKIT_STRIPE_API_VERSION } from "./stripe/stripe-provider"; +export type { StripeOptions } from "./stripe/stripe-provider"; export type { Customer, StoredFeature, diff --git a/packages/paykit/src/invoice/invoice.service.ts b/packages/paykit/src/invoice/invoice.service.ts index 597f3cba..ea7e38aa 100644 --- a/packages/paykit/src/invoice/invoice.service.ts +++ b/packages/paykit/src/invoice/invoice.service.ts @@ -1,4 +1,4 @@ -import { and, eq, sql } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import type { PayKitContext } from "../core/context"; import { PayKitError, PAYKIT_ERROR_CODES } from "../core/errors"; @@ -20,15 +20,9 @@ export async function upsertInvoiceRecord( }, ): Promise { const now = new Date(); - const providerData = { - invoiceId: input.invoice.providerInvoiceId, - }; const existing = await database.query.invoice.findFirst({ - where: and( - eq(invoice.providerId, input.providerId), - sql`${invoice.providerData}->>'invoiceId' = ${input.invoice.providerInvoiceId}`, - ), + where: eq(invoice.stripeInvoiceId, input.invoice.providerInvoiceId), }); const values = { @@ -39,8 +33,7 @@ export async function upsertInvoiceRecord( hostedUrl: input.invoice.hostedUrl ?? null, periodEndAt: input.invoice.periodEndAt ?? null, periodStartAt: input.invoice.periodStartAt ?? null, - providerData, - providerId: input.providerId, + stripeInvoiceId: input.invoice.providerInvoiceId, status: input.invoice.status ?? "open", subscriptionId: input.subscriptionId ?? null, type: "subscription" as string, @@ -88,10 +81,7 @@ export async function applyInvoiceWebhookAction( const subscriptionRecord = action.data.providerSubscriptionId ? await ctx.database.query.subscription.findFirst({ - where: and( - eq(subscription.providerId, ctx.provider.id), - sql`${subscription.providerData}->>'subscriptionId' = ${action.data.providerSubscriptionId}`, - ), + where: eq(subscription.stripeSubscriptionId, action.data.providerSubscriptionId), }) : null; diff --git a/packages/paykit/src/payment-method/payment-method.service.ts b/packages/paykit/src/payment-method/payment-method.service.ts index b1fc8b42..6f945612 100644 --- a/packages/paykit/src/payment-method/payment-method.service.ts +++ b/packages/paykit/src/payment-method/payment-method.service.ts @@ -1,4 +1,4 @@ -import { and, eq, isNull, sql } from "drizzle-orm"; +import { and, eq, isNull } from "drizzle-orm"; import type { PayKitContext } from "../core/context"; import { generateId } from "../core/utils"; @@ -23,7 +23,6 @@ export async function getDefaultPaymentMethod( where: and( eq(paymentMethod.customerId, input.customerId), eq(paymentMethod.isDefault, true), - eq(paymentMethod.providerId, input.providerId), isNull(paymentMethod.deletedAt), ), })) ?? null @@ -47,32 +46,15 @@ export async function syncPaymentMethodByProviderCustomer( } const now = new Date(); - const providerData = { - methodId: input.paymentMethod.providerMethodId, - type: input.paymentMethod.type, - last4: input.paymentMethod.last4 ?? null, - expiryMonth: input.paymentMethod.expiryMonth ?? null, - expiryYear: input.paymentMethod.expiryYear ?? null, - }; - const existingRow = await database.query.paymentMethod.findFirst({ - where: and( - eq(paymentMethod.providerId, input.providerId), - sql`${paymentMethod.providerData}->>'methodId' = ${input.paymentMethod.providerMethodId}`, - isNull(paymentMethod.deletedAt), - ), + where: eq(paymentMethod.stripePaymentMethodId, input.paymentMethod.providerMethodId), }); if (input.paymentMethod.isDefault) { await database .update(paymentMethod) .set({ isDefault: false, updatedAt: now }) - .where( - and( - eq(paymentMethod.customerId, customerRow.id), - eq(paymentMethod.providerId, input.providerId), - ), - ); + .where(eq(paymentMethod.customerId, customerRow.id)); } if (existingRow) { @@ -81,8 +63,12 @@ export async function syncPaymentMethodByProviderCustomer( .set({ customerId: customerRow.id, deletedAt: null, + expiryMonth: input.paymentMethod.expiryMonth ?? null, + expiryYear: input.paymentMethod.expiryYear ?? null, isDefault: input.paymentMethod.isDefault ?? existingRow.isDefault, - providerData, + last4: input.paymentMethod.last4 ?? null, + stripePaymentMethodId: input.paymentMethod.providerMethodId, + type: input.paymentMethod.type, updatedAt: now, }) .where(eq(paymentMethod.id, existingRow.id)); @@ -92,10 +78,13 @@ export async function syncPaymentMethodByProviderCustomer( await database.insert(paymentMethod).values({ customerId: customerRow.id, deletedAt: null, + expiryMonth: input.paymentMethod.expiryMonth ?? null, + expiryYear: input.paymentMethod.expiryYear ?? null, id: generateId("pm"), isDefault: input.paymentMethod.isDefault ?? false, - providerId: input.providerId, - providerData, + last4: input.paymentMethod.last4 ?? null, + stripePaymentMethodId: input.paymentMethod.providerMethodId, + type: input.paymentMethod.type, }); } @@ -113,12 +102,7 @@ export async function deletePaymentMethodByProviderId( isDefault: false, updatedAt: new Date(), }) - .where( - and( - eq(paymentMethod.providerId, input.providerId), - sql`${paymentMethod.providerData}->>'methodId' = ${input.providerMethodId}`, - ), - ); + .where(eq(paymentMethod.stripePaymentMethodId, input.providerMethodId)); } export async function applyPaymentMethodWebhookAction( diff --git a/packages/paykit/src/payment/payment.service.ts b/packages/paykit/src/payment/payment.service.ts index 65c64fbe..0efc568c 100644 --- a/packages/paykit/src/payment/payment.service.ts +++ b/packages/paykit/src/payment/payment.service.ts @@ -1,4 +1,4 @@ -import { and, eq, sql } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import type { PayKitContext } from "../core/context"; import { generateId } from "../core/utils"; @@ -23,16 +23,8 @@ export async function syncPaymentByProviderCustomer( return; } - const providerData = { - paymentId: input.payment.providerPaymentId, - methodId: input.payment.providerMethodId ?? null, - }; - const existing = await database.query.invoice.findFirst({ - where: and( - eq(invoice.providerId, input.providerId), - sql`${invoice.providerData}->>'paymentId' = ${input.payment.providerPaymentId}`, - ), + where: eq(invoice.stripePaymentId, input.payment.providerPaymentId), }); if (existing) { @@ -41,6 +33,8 @@ export async function syncPaymentByProviderCustomer( .set({ status: input.payment.status, amount: input.payment.amount, + stripePaymentId: input.payment.providerPaymentId, + stripePaymentMethodId: input.payment.providerMethodId ?? null, updatedAt: new Date(), }) .where(eq(invoice.id, existing.id)); @@ -55,8 +49,8 @@ export async function syncPaymentByProviderCustomer( amount: input.payment.amount, currency: input.payment.currency, description: input.payment.description ?? null, - providerId: input.providerId, - providerData, + stripePaymentId: input.payment.providerPaymentId, + stripePaymentMethodId: input.payment.providerMethodId ?? null, }); } diff --git a/packages/paykit/src/product/product.service.ts b/packages/paykit/src/product/product.service.ts index f387e2e5..3ca0ab88 100644 --- a/packages/paykit/src/product/product.service.ts +++ b/packages/paykit/src/product/product.service.ts @@ -1,4 +1,4 @@ -import { and, desc, eq, sql } from "drizzle-orm"; +import { and, desc, eq } from "drizzle-orm"; import { PayKitError, PAYKIT_ERROR_CODES } from "../core/errors"; import { generateId } from "../core/utils"; @@ -18,13 +18,24 @@ export interface StoredProductWithProvider extends StoredProduct { export function withProviderInfo( storedProduct: StoredProduct, - providerId: string, + _providerId: string, ): StoredProductWithProvider { - const providerMap = (storedProduct.provider ?? {}) as Record>; - const providerInfo = providerMap[providerId]; return { ...storedProduct, - providerProduct: providerInfo ?? null, + providerProduct: getStripeProductInfo(storedProduct), + }; +} + +function getStripeProductInfo( + storedProduct: Pick, +) { + if (!storedProduct.stripePriceId) { + return null; + } + + return { + priceId: storedProduct.stripePriceId, + ...(storedProduct.stripeProductId ? { productId: storedProduct.stripeProductId } : {}), }; } @@ -165,7 +176,8 @@ export async function insertProductVersion( name: input.name, priceAmount: input.priceAmount, priceInterval: input.priceInterval, - provider: {}, + stripePriceId: null, + stripeProductId: null, updatedAt: now, version: input.version, }; @@ -226,15 +238,14 @@ export async function replaceProductFeatures( export async function getProviderProduct( database: PayKitDatabase, productInternalId: string, - providerId: string, + _providerId: string, ): Promise | null> { const row = await database.query.product.findFirst({ where: eq(product.internalId, productInternalId), }); if (!row) return null; - const providerMap = row.provider as Record>; - return providerMap[providerId] ?? null; + return getStripeProductInfo(row); } export async function upsertProviderProduct( @@ -245,17 +256,12 @@ export async function upsertProviderProduct( providerProduct: Record; }, ): Promise { - const existing = await database.query.product.findFirst({ - where: eq(product.internalId, input.productInternalId), - }); - if (!existing) return; - - const providerMap = (existing.provider ?? {}) as Record>; - providerMap[input.providerId] = input.providerProduct; - await database .update(product) - .set({ provider: providerMap }) + .set({ + stripeProductId: input.providerProduct.productId ?? null, + stripePriceId: input.providerProduct.priceId ?? null, + }) .where(eq(product.internalId, input.productInternalId)); } @@ -276,7 +282,10 @@ export async function getProductByProviderData( input: { providerId: string; key: string; value: string }, ): Promise { const row = await database.query.product.findFirst({ - where: sql`${product.provider}->${input.providerId}->>${input.key} = ${input.value}`, + where: + input.key === "productId" + ? eq(product.stripeProductId, input.value) + : eq(product.stripePriceId, input.value), }); return row ?? null; diff --git a/packages/paykit/src/providers/provider.ts b/packages/paykit/src/providers/provider.ts index 73a5a0c9..5a102952 100644 --- a/packages/paykit/src/providers/provider.ts +++ b/packages/paykit/src/providers/provider.ts @@ -27,10 +27,6 @@ export interface ProviderPaymentMethod { isDefault?: boolean; } -export interface PayKitProviderCapabilities { - testClocks: boolean; -} - export interface ProviderTunnelAccount { displayName?: string; environment: string; @@ -82,7 +78,6 @@ export interface ProviderSubscriptionResult { export interface PaymentProvider { readonly id: string; readonly name: string; - readonly capabilities: PayKitProviderCapabilities; createCustomer(data: { createTestClock?: boolean; @@ -173,7 +168,7 @@ export interface PaymentProvider { }>; handleWebhook(data: { - allowStaleSignatures?: boolean; + allowUnsignedPayload?: boolean; body: string; headers: Record; }): Promise; @@ -202,10 +197,3 @@ export interface PaymentProvider { error?: string; }>; } - -export interface PayKitProviderConfig { - id: string; - name: string; - capabilities: PayKitProviderCapabilities; - createAdapter(): PaymentProvider; -} diff --git a/packages/stripe/src/stripe-provider.ts b/packages/paykit/src/stripe/stripe-provider.ts similarity index 96% rename from packages/stripe/src/stripe-provider.ts rename to packages/paykit/src/stripe/stripe-provider.ts index 0375f5c2..ebe4f9cb 100644 --- a/packages/stripe/src/stripe-provider.ts +++ b/packages/paykit/src/stripe/stripe-provider.ts @@ -1,15 +1,12 @@ -import { PayKitError, PAYKIT_ERROR_CODES } from "paykitjs"; -import type { - NormalizedWebhookEvent, - PayKitProviderConfig, - PaymentProvider, - ProviderTestClock, -} from "paykitjs"; import StripeSdk from "stripe"; +import { PayKitError, PAYKIT_ERROR_CODES } from "../core/errors"; +import type { PaymentProvider, ProviderTestClock } from "../providers/provider"; +import type { NormalizedWebhookEvent } from "../types/events"; + /** * Stripe API version PayKit is tested against. Users can override via - * `stripe({ apiVersion })`, e.g. to opt into preview features. + * `createPayKit({ stripe: { apiVersion } })`, e.g. to opt into preview features. */ export const PAYKIT_STRIPE_API_VERSION = "2025-10-29.clover"; @@ -36,8 +33,8 @@ export interface StripeOptions { managedPayments?: boolean; } -export type StripeProviderConfig = PayKitProviderConfig & { - capabilities: { testClocks: true }; +type StripeAdapterOptions = Omit & { + webhookSecret?: string; }; type StripeInvoiceWithExtras = StripeSdk.Invoice & { @@ -571,7 +568,6 @@ export function createStripeProvider(client: StripeSdk, options: StripeOptions): return { id: "stripe", name: "Stripe", - capabilities: { testClocks: true }, async createCustomer(data) { let testClock: ProviderTestClock | undefined; @@ -953,17 +949,15 @@ export function createStripeProvider(client: StripeSdk, options: StripeOptions): (k) => k.toLowerCase() === "stripe-signature", ); const signature = headerKey ? data.headers[headerKey] : undefined; - if (!signature) { - throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.PROVIDER_SIGNATURE_MISSING); - } - const tolerance = data.allowStaleSignatures ? Number.POSITIVE_INFINITY : undefined; - const event = await client.webhooks.constructEventAsync( - data.body, - signature, - options.webhookSecret, - tolerance, - ); + const event = data.allowUnsignedPayload + ? (JSON.parse(data.body) as StripeSdk.Event) + : await (async () => { + if (!signature) { + throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.PROVIDER_SIGNATURE_MISSING); + } + return client.webhooks.constructEventAsync(data.body, signature, options.webhookSecret); + })(); return [ ...(await createCheckoutCompletedEvents(client, event)), ...(await createSubscriptionEvents(event)), @@ -993,7 +987,7 @@ export function createStripeProvider(client: StripeSdk, options: StripeOptions): return { created: false, endpointId: endpoint.id, - webhookSecret: options.webhookSecret, + webhookSecret: options.webhookSecret || undefined, }; } catch (error) { if (!isStripeResourceMissingError(error)) { @@ -1053,7 +1047,7 @@ export function createStripeProvider(client: StripeSdk, options: StripeOptions): }; } -export function stripe(options: StripeOptions): StripeProviderConfig { +export function createStripeAdapter(options: StripeAdapterOptions): PaymentProvider { const apiVersion = options.apiVersion ?? PAYKIT_STRIPE_API_VERSION; if (options.managedPayments) { if (!apiVersion.endsWith(".preview") || apiVersion < STRIPE_MANAGED_PAYMENTS_MIN_VERSION) { @@ -1069,12 +1063,5 @@ export function stripe(options: StripeOptions): StripeProviderConfig { maxNetworkRetries: 3, }); - return { - id: "stripe", - name: "Stripe", - capabilities: { testClocks: true }, - createAdapter(): PaymentProvider { - return createStripeProvider(client, options); - }, - }; + return createStripeProvider(client, { ...options, webhookSecret: options.webhookSecret ?? "" }); } diff --git a/packages/paykit/src/subscription/subscription.service.ts b/packages/paykit/src/subscription/subscription.service.ts index ba4c28e3..5f55629a 100644 --- a/packages/paykit/src/subscription/subscription.service.ts +++ b/packages/paykit/src/subscription/subscription.service.ts @@ -426,8 +426,8 @@ async function activateScheduledSubscriptionForGroup( subscriptionStatus: string; subscriptionCurrentPeriodEndAt?: Date | null; subscriptionCurrentPeriodStartAt?: Date | null; - providerId?: string | null; - providerData?: Record | null; + stripeSubscriptionId?: string | null; + stripeSubscriptionScheduleId?: string | null; }, ): Promise { const activationDate = getSubscriptionEffectiveDate({ @@ -474,8 +474,8 @@ async function activateScheduledSubscriptionForGroup( subscriptionId: targetSub.id, startedAt: targetSub.startedAt ?? activationDate, status: input.subscriptionStatus, - providerData: input.providerData, - providerId: input.providerId, + stripeSubscriptionId: input.stripeSubscriptionId, + stripeSubscriptionScheduleId: input.stripeSubscriptionScheduleId, }); return targetSub.id; @@ -558,10 +558,6 @@ export async function applySubscriptionWebhookAction( ? (ctx.products.planMap.get(storedProduct.id) ?? null) : null; - const providerData = { - subscriptionId: action.data.subscription.providerSubscriptionId, - }; - const targetSub = existingSub ?? (storedProduct && normalizedPlan @@ -571,9 +567,10 @@ export async function applySubscriptionWebhookAction( customerId: customerRow.id, planFeatures: normalizedPlan.includes, productInternalId: storedProduct.internalId, - providerData, - providerId: ctx.provider.id, startedAt: action.data.subscription.currentPeriodStartAt ?? new Date(), + stripeSubscriptionId: action.data.subscription.providerSubscriptionId, + stripeSubscriptionScheduleId: + action.data.subscription.providerSubscriptionScheduleId ?? null, status: action.data.subscription.status, }) : null); @@ -588,7 +585,8 @@ export async function applySubscriptionWebhookAction( }); await syncSubscriptionBillingState(ctx.database, { - providerData, + stripeSubscriptionId: action.data.subscription.providerSubscriptionId, + stripeSubscriptionScheduleId: action.data.subscription.providerSubscriptionScheduleId ?? null, subscriptionId: targetSub.id, }); @@ -689,8 +687,9 @@ export async function applySubscriptionWebhookAction( customerId: customerRow.id, productGroup: storedProduct.group, productInternalId: storedProduct.internalId, - providerData, - providerId: ctx.provider.id, + stripeSubscriptionId: action.data.subscription.providerSubscriptionId, + stripeSubscriptionScheduleId: + action.data.subscription.providerSubscriptionScheduleId ?? null, subscriptionCurrentPeriodEndAt: action.data.subscription.currentPeriodEndAt, subscriptionCurrentPeriodStartAt: action.data.subscription.currentPeriodStartAt, subscriptionStatus: action.data.subscription.status, @@ -709,13 +708,7 @@ export async function applySubscriptionWebhookAction( } function getProviderSubscriptionId(subscription: ActiveSubscription): string | null { - if (subscription?.providerData == null) { - return null; - } - - return typeof (subscription.providerData as Record).subscriptionId === "string" - ? ((subscription.providerData as Record).subscriptionId as string) - : null; + return subscription?.stripeSubscriptionId ?? null; } function hasProviderSubscription(subscription: ActiveSubscription): boolean { @@ -726,21 +719,9 @@ function getProviderSubscriptionRef(subscription: ActiveSubscription): { subscriptionId: string | null; subscriptionScheduleId: string | null; } { - if (subscription?.providerData == null) { - return { - subscriptionId: null, - subscriptionScheduleId: null, - }; - } - - const providerData = subscription.providerData as Record; return { - subscriptionId: - typeof providerData.subscriptionId === "string" ? providerData.subscriptionId : null, - subscriptionScheduleId: - typeof providerData.subscriptionScheduleId === "string" - ? providerData.subscriptionScheduleId - : null, + subscriptionId: subscription?.stripeSubscriptionId ?? null, + subscriptionScheduleId: subscription?.stripeSubscriptionScheduleId ?? null, }; } @@ -784,11 +765,9 @@ async function handleSamePlanSubscribe( await syncSubscriptionBillingState(tx, { currentPeriodEndAt: providerResult.subscription.currentPeriodEndAt, currentPeriodStartAt: providerResult.subscription.currentPeriodStartAt, - providerData: { - subscriptionId: providerResult.subscription.providerSubscriptionId, - subscriptionScheduleId: - providerResult.subscription.providerSubscriptionScheduleId ?? null, - }, + stripeSubscriptionId: providerResult.subscription.providerSubscriptionId, + stripeSubscriptionScheduleId: + providerResult.subscription.providerSubscriptionScheduleId ?? null, status: providerResult.subscription.status, subscriptionId: activeSubscription.id, }); @@ -946,11 +925,9 @@ async function handleCancelToFree( await syncSubscriptionBillingState(tx, { currentPeriodEndAt: providerResult.subscription.currentPeriodEndAt, currentPeriodStartAt: providerResult.subscription.currentPeriodStartAt, - providerData: { - subscriptionId: providerResult.subscription.providerSubscriptionId, - subscriptionScheduleId: - providerResult.subscription.providerSubscriptionScheduleId ?? null, - }, + stripeSubscriptionId: providerResult.subscription.providerSubscriptionId, + stripeSubscriptionScheduleId: + providerResult.subscription.providerSubscriptionScheduleId ?? null, status: providerResult.subscription.status, subscriptionId: activeSubscription.id, }); @@ -1000,11 +977,9 @@ async function handleScheduledDowngrade( await syncSubscriptionBillingState(tx, { currentPeriodEndAt: providerResult.subscription.currentPeriodEndAt, currentPeriodStartAt: providerResult.subscription.currentPeriodStartAt, - providerData: { - subscriptionId: providerResult.subscription.providerSubscriptionId, - subscriptionScheduleId: - providerResult.subscription.providerSubscriptionScheduleId ?? null, - }, + stripeSubscriptionId: providerResult.subscription.providerSubscriptionId, + stripeSubscriptionScheduleId: + providerResult.subscription.providerSubscriptionScheduleId ?? null, status: providerResult.subscription.status, subscriptionId: activeSubscription.id, }); @@ -1094,7 +1069,6 @@ async function insertLocalTargetSubscription( customerId: subCtx.customerId, planFeatures: subCtx.planFeatures, productInternalId: subCtx.storedPlan.internalId, - providerId: subCtx.providerId, startedAt: input.startedAt, status: input.status, }); @@ -1109,11 +1083,6 @@ async function upsertProviderBackedTargetSubscription( }, options?: { deferred?: boolean }, ): Promise { - const providerData = { - subscriptionId: input.subscription.providerSubscriptionId, - subscriptionScheduleId: input.subscription.providerSubscriptionScheduleId ?? null, - }; - let subscriptionId: string | null = null; if (options?.deferred) { const existingSub = await getSubscriptionByProviderSubscriptionId(database, { @@ -1125,7 +1094,8 @@ async function upsertProviderBackedTargetSubscription( await syncSubscriptionBillingState(database, { currentPeriodEndAt: input.subscription.currentPeriodEndAt ?? null, currentPeriodStartAt: input.subscription.currentPeriodStartAt ?? null, - providerData, + stripeSubscriptionId: input.subscription.providerSubscriptionId, + stripeSubscriptionScheduleId: input.subscription.providerSubscriptionScheduleId ?? null, status: input.subscription.status, subscriptionId: existingSub.id, }); @@ -1139,9 +1109,9 @@ async function upsertProviderBackedTargetSubscription( customerId: subCtx.customerId, planFeatures: subCtx.planFeatures, productInternalId: subCtx.storedPlan.internalId, - providerId: subCtx.providerId, - providerData, startedAt: input.subscription.currentPeriodStartAt ?? new Date(), + stripeSubscriptionId: input.subscription.providerSubscriptionId, + stripeSubscriptionScheduleId: input.subscription.providerSubscriptionScheduleId ?? null, status: input.subscription.status, }); subscriptionId = inserted.id; @@ -1202,8 +1172,6 @@ function addResetInterval(date: Date, resetInterval: string): Date { return next; } -type ProviderProductMap = Record>; - export async function warnOnDuplicateActiveSubscriptionGroups( ctx: PayKitContext, customerId: string, @@ -1255,8 +1223,12 @@ function mapJoinRowToSubscriptionWithCatalog(row: { subscription: typeof subscription.$inferSelect; product: typeof product.$inferSelect; }): SubscriptionWithCatalog { - const providerMap = row.product.provider as ProviderProductMap | null; - const providerId = row.subscription.providerId; + const stripeProduct = row.product.stripePriceId + ? { + priceId: row.product.stripePriceId, + ...(row.product.stripeProductId ? { productId: row.product.stripeProductId } : {}), + } + : null; return { ...row.subscription, planGroup: row.product.group, @@ -1265,7 +1237,7 @@ function mapJoinRowToSubscriptionWithCatalog(row: { planName: row.product.name, priceAmount: row.product.priceAmount, priceInterval: row.product.priceInterval, - providerProduct: (providerId ? providerMap?.[providerId] : null) ?? null, + providerProduct: stripeProduct, }; } @@ -1342,10 +1314,7 @@ export async function getSubscriptionByProviderSubscriptionId( return ( (await database.query.subscription.findFirst({ orderBy: (s, { desc: d }) => [d(s.createdAt)], - where: and( - eq(subscription.providerId, input.providerId), - sql`${subscription.providerData}->>'subscriptionId' = ${input.providerSubscriptionId}`, - ), + where: eq(subscription.stripeSubscriptionId, input.providerSubscriptionId), })) ?? null ); } @@ -1369,10 +1338,10 @@ export async function insertSubscriptionRecord( currentPeriodStartAt?: Date | null; planFeatures: readonly NormalizedPlanFeature[]; productInternalId: string; - providerId?: string | null; - providerData?: Record | null; scheduledProductId?: string | null; startedAt?: Date | null; + stripeSubscriptionId?: string | null; + stripeSubscriptionScheduleId?: string | null; status: string; trialEndsAt?: Date | null; }, @@ -1390,11 +1359,11 @@ export async function insertSubscriptionRecord( endedAt: null, id: generateId("sub"), productInternalId: input.productInternalId, - providerData: input.providerData ?? null, - providerId: input.providerId ?? null, quantity: 1, scheduledProductId: input.scheduledProductId ?? null, startedAt: input.startedAt ?? now, + stripeSubscriptionId: input.stripeSubscriptionId ?? null, + stripeSubscriptionScheduleId: input.stripeSubscriptionScheduleId ?? null, status: input.status, trialEndsAt: input.trialEndsAt ?? null, }) @@ -1537,8 +1506,8 @@ export async function activateScheduledSubscription( subscriptionId: string; startedAt?: Date | null; status: string; - providerId?: string | null; - providerData?: Record | null; + stripeSubscriptionId?: string | null; + stripeSubscriptionScheduleId?: string | null; }, ): Promise { await database @@ -1549,9 +1518,9 @@ export async function activateScheduledSubscription( currentPeriodEndAt: input.currentPeriodEndAt ?? null, currentPeriodStartAt: input.currentPeriodStartAt ?? null, endedAt: null, - providerData: input.providerData ?? null, - providerId: input.providerId, startedAt: input.startedAt ?? new Date(), + stripeSubscriptionId: input.stripeSubscriptionId ?? null, + stripeSubscriptionScheduleId: input.stripeSubscriptionScheduleId ?? null, status: input.status, updatedAt: new Date(), }) @@ -1621,8 +1590,9 @@ export async function syncSubscriptionBillingState( subscriptionId: string; currentPeriodEndAt?: Date | null; currentPeriodStartAt?: Date | null; - providerData?: Record | null; startedAt?: Date | null; + stripeSubscriptionId?: string | null; + stripeSubscriptionScheduleId?: string | null; status?: string; }, ): Promise { @@ -1644,8 +1614,15 @@ export async function syncSubscriptionBillingState( input.currentPeriodStartAt !== undefined ? input.currentPeriodStartAt : existing.currentPeriodStartAt, - providerData: input.providerData !== undefined ? input.providerData : existing.providerData, startedAt: input.startedAt !== undefined ? input.startedAt : existing.startedAt, + stripeSubscriptionId: + input.stripeSubscriptionId !== undefined + ? input.stripeSubscriptionId + : existing.stripeSubscriptionId, + stripeSubscriptionScheduleId: + input.stripeSubscriptionScheduleId !== undefined + ? input.stripeSubscriptionScheduleId + : existing.stripeSubscriptionScheduleId, status: input.status ?? existing.status, updatedAt: new Date(), }) diff --git a/packages/paykit/src/testing/testing.service.ts b/packages/paykit/src/testing/testing.service.ts index e3435c0b..66e505d0 100644 --- a/packages/paykit/src/testing/testing.service.ts +++ b/packages/paykit/src/testing/testing.service.ts @@ -11,10 +11,6 @@ function assertTestingEnabled(ctx: PayKitContext): void { if (ctx.options.testing?.enabled !== true) { throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.TESTING_NOT_ENABLED); } - - if (!ctx.provider.capabilities.testClocks) { - throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.TESTING_NOT_ENABLED); - } } export async function getCustomerTestClock(ctx: PayKitContext, customerId: string) { diff --git a/packages/paykit/src/types/instance.ts b/packages/paykit/src/types/instance.ts index 09d6a91f..4679d26a 100644 --- a/packages/paykit/src/types/instance.ts +++ b/packages/paykit/src/types/instance.ts @@ -158,14 +158,7 @@ type TestingEnabled = TOptions["testing"] extend ? true : false; -type TestClocksSupported = TOptions["provider"] extends { - capabilities: { testClocks: true }; -} - ? true - : false; - -type TestingAvailable = - TestingEnabled extends true ? TestClocksSupported : false; +type TestingAvailable = TestingEnabled; type EnabledMethodKeys = TestingAvailable extends true diff --git a/packages/paykit/src/types/options.ts b/packages/paykit/src/types/options.ts index c8c25bdb..3edc8e05 100644 --- a/packages/paykit/src/types/options.ts +++ b/packages/paykit/src/types/options.ts @@ -1,7 +1,7 @@ import type { Pool } from "pg"; import type { LevelWithSilent, Logger } from "pino"; -import type { PayKitProviderConfig } from "../providers/provider"; +import type { StripeOptions } from "../stripe/stripe-provider"; import type { PayKitEventHandlers } from "./events"; import type { PayKitPlugin } from "./plugin"; import type { PayKitProductsModule } from "./schema"; @@ -17,7 +17,11 @@ export interface PayKitTestingOptions { export interface PayKitOptions { database: Pool | string; - provider: PayKitProviderConfig; + stripe: StripeOptions; + /** + * @deprecated PayKit is Stripe-only. Use `stripe` instead. + */ + provider?: never; products?: PayKitProductsModule; /** * PayKit root path, e.g. `/paykit` or `/billing`. diff --git a/packages/paykit/src/utilities/dependencies/paykit-package-list.ts b/packages/paykit/src/utilities/dependencies/paykit-package-list.ts index f5aafbea..13ae6ee3 100644 --- a/packages/paykit/src/utilities/dependencies/paykit-package-list.ts +++ b/packages/paykit/src/utilities/dependencies/paykit-package-list.ts @@ -1 +1 @@ -export const PAYKIT_PACKAGE_LIST = ["paykitjs", "@paykitjs/stripe", "@paykitjs/dash"] as const; +export const PAYKIT_PACKAGE_LIST = ["paykitjs", "@paykitjs/dash"] as const; diff --git a/packages/paykit/src/webhook/webhook.api.ts b/packages/paykit/src/webhook/webhook.api.ts index 2d76bf35..16490805 100644 --- a/packages/paykit/src/webhook/webhook.api.ts +++ b/packages/paykit/src/webhook/webhook.api.ts @@ -9,12 +9,14 @@ function headersToRecord(headers: Headers): Record { return result; } -function shouldAllowStaleSignatures(headers: Headers): boolean { +function shouldAllowUnsignedPayload(headers: Headers): boolean { if (headers.get("x-paykit-cloud-replay") !== "1") { return false; } return ( + process.env.PAYKIT_ALLOW_UNSIGNED_PAYLOADS === "1" || + // Legacy alias kept for local replay compatibility; remove in a future major. process.env.PAYKIT_ALLOW_STALE_SIGNATURES === "1" || process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test" @@ -33,7 +35,7 @@ export const receiveWebhook = definePayKitMethod( resolveInput: async (ctx) => { const headers = ctx.headers ?? new Headers(); return { - allowStaleSignatures: shouldAllowStaleSignatures(headers), + allowUnsignedPayload: shouldAllowUnsignedPayload(headers), body: await ctx.request!.text(), headers: headersToRecord(headers), }; diff --git a/packages/paykit/src/webhook/webhook.service.ts b/packages/paykit/src/webhook/webhook.service.ts index b9ec0064..33151a25 100644 --- a/packages/paykit/src/webhook/webhook.service.ts +++ b/packages/paykit/src/webhook/webhook.service.ts @@ -16,7 +16,7 @@ import { import type { AnyNormalizedWebhookEvent, WebhookApplyAction } from "../types/events"; export interface HandleWebhookInput { - allowStaleSignatures?: boolean; + allowUnsignedPayload?: boolean; body: string; headers: Record; } @@ -35,10 +35,9 @@ async function beginWebhookEvent( id: generateId("evt"), payload: input.payload, processedAt: null, - providerEventId: input.providerEventId, - providerId: ctx.provider.id, receivedAt: new Date(), status: "processing", + stripeEventId: input.providerEventId, traceId: getTraceId(), type: input.type, }); @@ -56,8 +55,7 @@ async function beginWebhookEvent( .set({ error: null, processedAt: null, status: "processing" }) .where( and( - eq(webhookEvent.providerId, ctx.provider.id), - eq(webhookEvent.providerEventId, input.providerEventId), + eq(webhookEvent.stripeEventId, input.providerEventId), sql`(${webhookEvent.status} = 'failed' OR (${webhookEvent.status} = 'processing' AND ${webhookEvent.receivedAt} < now() - interval '5 minutes'))`, ), ) @@ -82,12 +80,7 @@ async function finishWebhookEvent( processedAt: new Date(), status: input.status, }) - .where( - and( - eq(webhookEvent.providerId, ctx.provider.id), - eq(webhookEvent.providerEventId, input.providerEventId), - ), - ); + .where(eq(webhookEvent.stripeEventId, input.providerEventId)); } function getProviderEventId( @@ -206,7 +199,7 @@ export async function handleWebhook( ): Promise<{ received: true }> { return ctx.logger.trace.run("wh", async () => { const events = await ctx.provider.handleWebhook({ - allowStaleSignatures: input.allowStaleSignatures, + allowUnsignedPayload: input.allowUnsignedPayload, body: input.body, headers: input.headers, }); diff --git a/packages/polar/package.json b/packages/polar/package.json deleted file mode 100644 index 1c8ece85..00000000 --- a/packages/polar/package.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "name": "@paykitjs/polar", - "version": "0.0.6", - "description": "Polar provider adapter for PayKit", - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/getpaykit/paykit.git" - }, - "files": [ - "dist" - ], - "type": "module", - "main": "./src/index.ts", - "types": "./src/index.ts", - "exports": { - ".": { - "paykit-source": "./src/index.ts", - "types": "./src/index.ts", - "default": "./src/index.ts" - } - }, - "scripts": { - "build": "tsdown --config tsdown.config.ts", - "typecheck": "tsc --build" - }, - "dependencies": { - "@polar-sh/sdk": "^0.47.0", - "paykitjs": "workspace:*" - }, - "devDependencies": { - "tsdown": "^0.21.1", - "typescript": "^5.9.2", - "vitest": "^4.0.18" - } -} diff --git a/packages/polar/src/index.ts b/packages/polar/src/index.ts deleted file mode 100644 index 03f68ef3..00000000 --- a/packages/polar/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { polar } from "./polar-provider"; -export type { PolarOptions } from "./polar-provider"; diff --git a/packages/polar/src/polar-provider.ts b/packages/polar/src/polar-provider.ts deleted file mode 100644 index ccb668fc..00000000 --- a/packages/polar/src/polar-provider.ts +++ /dev/null @@ -1,569 +0,0 @@ -import { Polar } from "@polar-sh/sdk"; -import { SDKValidationError } from "@polar-sh/sdk/models/errors/sdkvalidationerror"; -import { validateEvent, WebhookVerificationError } from "@polar-sh/sdk/webhooks"; -import { PayKitError, PAYKIT_ERROR_CODES } from "paykitjs"; -import type { NormalizedWebhookEvent, PayKitProviderConfig, PaymentProvider } from "paykitjs"; - -export interface PolarOptions { - accessToken: string; - webhookSecret: string; - server?: "production" | "sandbox"; -} - -export type PolarProviderConfig = PayKitProviderConfig & { - capabilities: { testClocks: false }; -}; - -type PolarWebhookEvent = ReturnType; -type PolarSubscriptionEvent = Extract; -type PolarCheckoutEvent = Extract; - -function toDate(value: Date | string | null | undefined): Date | null { - if (!value) return null; - return value instanceof Date ? value : new Date(value); -} - -function normalizePolarSubscription(sub: PolarSubscriptionEvent["data"]) { - return { - cancelAtPeriodEnd: sub.cancelAtPeriodEnd, - canceledAt: toDate(sub.canceledAt), - currentPeriodEndAt: toDate(sub.currentPeriodEnd), - currentPeriodStartAt: toDate(sub.currentPeriodStart), - endedAt: toDate(sub.endedAt), - providerProduct: { productId: sub.productId }, - providerSubscriptionId: sub.id, - providerSubscriptionScheduleId: null, - status: sub.status, - }; -} - -function createSubscriptionEvents( - event: { type?: string; data: PolarSubscriptionEvent["data"] }, - webhookId: string, -): NormalizedWebhookEvent[] { - const sub = event.data; - - // `subscription.revoked` = immediately terminated (like Stripe delete) - // `subscription.canceled` = will cancel at period end (like Stripe cancel_at_period_end) - if (event.type === "subscription.revoked") { - return [ - { - actions: [ - { - data: { - providerCustomerId: sub.customerId, - providerSubscriptionId: sub.id, - }, - type: "subscription.delete", - }, - ], - name: "subscription.deleted", - payload: { - providerCustomerId: sub.customerId, - providerEventId: webhookId, - providerSubscriptionId: sub.id, - }, - }, - ]; - } - - const normalized = normalizePolarSubscription(sub); - return [ - { - actions: [ - { - data: { - providerCustomerId: sub.customerId, - subscription: normalized, - }, - type: "subscription.upsert", - }, - ], - name: "subscription.updated", - payload: { - providerCustomerId: sub.customerId, - providerEventId: webhookId, - subscription: normalized, - }, - }, - ]; -} - -function createCheckoutEvents( - event: { type?: string; data: PolarCheckoutEvent["data"] }, - webhookId: string, -): NormalizedWebhookEvent[] { - const checkout = event.data; - if (checkout.status !== "succeeded") return []; - - const providerCustomerId = checkout.customerId; - if (!providerCustomerId) return []; - - return [ - { - name: "checkout.completed", - payload: { - checkoutSessionId: checkout.id, - mode: "subscription", - paymentStatus: "paid", - providerCustomerId, - providerEventId: webhookId, - providerSubscriptionId: checkout.subscriptionId ?? undefined, - status: checkout.status, - metadata: checkout.metadata - ? Object.fromEntries(Object.entries(checkout.metadata).map(([k, v]) => [k, String(v)])) - : undefined, - }, - }, - ]; -} - -function notSupported(method: string): never { - throw PayKitError.from( - "BAD_REQUEST", - PAYKIT_ERROR_CODES.PROVIDER_WEBHOOK_INVALID, - `${method} is not supported by the Polar provider`, - ); -} - -export function createPolarProvider(client: Polar, options: PolarOptions): PaymentProvider { - return { - id: "polar", - name: "Polar", - capabilities: { testClocks: false }, - - async createCustomer(data) { - if (!data.email) { - throw PayKitError.from( - "BAD_REQUEST", - PAYKIT_ERROR_CODES.CUSTOMER_CREATE_FAILED, - "Polar requires a non-empty email to create a customer", - ); - } - - const customerMetadata = { - ...data.metadata, - paykitCustomerId: data.id, - }; - - try { - const customer = await client.customers.create({ - email: data.email, - name: data.name, - metadata: customerMetadata, - }); - - return { - providerCustomer: { id: customer.id }, - }; - } catch (error) { - if (!(error instanceof SDKValidationError)) throw error; - - // Duplicate email — find and re-link the existing customer. - const list = await client.customers.list({ query: data.email, limit: 1 }); - const existing = list.result.items[0]; - - if (!existing) { - throw PayKitError.from( - "INTERNAL_SERVER_ERROR", - PAYKIT_ERROR_CODES.PROVIDER_CUSTOMER_NOT_FOUND, - "Failed to create or find customer on Polar", - ); - } - - await client.customers.update({ - id: existing.id, - customerUpdate: { - name: data.name, - metadata: customerMetadata, - }, - }); - - return { - providerCustomer: { id: existing.id }, - }; - } - }, - - async updateCustomer(data) { - await client.customers.update({ - id: data.providerCustomerId, - customerUpdate: { - email: data.email, - name: data.name, - metadata: data.metadata ?? {}, - }, - }); - }, - - async deleteCustomer(data) { - await client.customers.delete({ id: data.providerCustomerId }); - }, - - getTestClock() { - return notSupported("getTestClock"); - }, - - advanceTestClock() { - return notSupported("advanceTestClock"); - }, - - attachPaymentMethod() { - return notSupported("attachPaymentMethod"); - }, - - async createSubscriptionCheckout(data) { - const checkout = await client.checkouts.create({ - products: [data.providerProduct.productId!], - customerId: data.providerCustomerId, - metadata: data.metadata, - successUrl: data.successUrl, - }); - - if (!checkout.url) { - throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.PROVIDER_SESSION_INVALID); - } - - return { - paymentUrl: checkout.url, - providerCheckoutSessionId: checkout.id, - }; - }, - - createSubscription() { - return notSupported("createSubscription (use checkout instead)"); - }, - - async updateSubscription(data) { - const sub = await client.subscriptions.update({ - id: data.providerSubscriptionId, - subscriptionUpdate: { - productId: data.providerProduct.productId!, - prorationBehavior: "invoice", - }, - }); - - return { - paymentUrl: null, - subscription: { - cancelAtPeriodEnd: sub.cancelAtPeriodEnd, - currentPeriodEndAt: sub.currentPeriodEnd ? new Date(sub.currentPeriodEnd) : null, - currentPeriodStartAt: sub.currentPeriodStart ? new Date(sub.currentPeriodStart) : null, - providerSubscriptionId: sub.id, - status: sub.status, - }, - }; - }, - - createInvoice() { - return notSupported("createInvoice"); - }, - - async scheduleSubscriptionChange(data) { - const current = await client.subscriptions.get({ id: data.providerSubscriptionId }); - const wasCanceled = current.cancelAtPeriodEnd; - - // Un-cancel to allow product update (Polar rejects updates on canceled subs) - if (wasCanceled) { - await client.subscriptions.update({ - id: data.providerSubscriptionId, - subscriptionUpdate: { cancelAtPeriodEnd: false }, - }); - } - - await client.subscriptions.update({ - id: data.providerSubscriptionId, - subscriptionUpdate: { - productId: data.providerProduct!.productId!, - prorationBehavior: "next_period", - }, - }); - - // Re-cancel if it was previously canceled (preserve cancel-at-period-end intent) - if (wasCanceled) { - await client.subscriptions.update({ - id: data.providerSubscriptionId, - subscriptionUpdate: { cancelAtPeriodEnd: true }, - }); - } - - const sub = await client.subscriptions.get({ id: data.providerSubscriptionId }); - - return { - paymentUrl: null, - subscription: { - cancelAtPeriodEnd: sub.cancelAtPeriodEnd, - currentPeriodEndAt: sub.currentPeriodEnd ? new Date(sub.currentPeriodEnd) : null, - currentPeriodStartAt: sub.currentPeriodStart ? new Date(sub.currentPeriodStart) : null, - providerSubscriptionId: sub.id, - status: sub.status, - }, - }; - }, - - async cancelSubscription(data) { - const sub = await client.subscriptions.update({ - id: data.providerSubscriptionId, - subscriptionUpdate: { - cancelAtPeriodEnd: true, - }, - }); - - return { - paymentUrl: null, - subscription: { - cancelAtPeriodEnd: sub.cancelAtPeriodEnd, - currentPeriodEndAt: sub.currentPeriodEnd ? new Date(sub.currentPeriodEnd) : null, - currentPeriodStartAt: sub.currentPeriodStart ? new Date(sub.currentPeriodStart) : null, - providerSubscriptionId: sub.id, - status: sub.status, - }, - }; - }, - - async listActiveSubscriptions(data) { - const result = await client.subscriptions.list({ - customerId: data.providerCustomerId, - }); - - return (result.result.items ?? []) - .filter((sub) => sub.status === "active" || sub.status === "trialing") - .map((sub) => ({ providerSubscriptionId: sub.id })); - }, - - async resumeSubscription(data) { - const current = await client.subscriptions.get({ id: data.providerSubscriptionId }); - - // Un-cancel first if pending cancellation - if (current.cancelAtPeriodEnd) { - await client.subscriptions.update({ - id: data.providerSubscriptionId, - subscriptionUpdate: { cancelAtPeriodEnd: false }, - }); - } - - // Clear pending product change if any - const sub = current.pendingUpdate - ? await client.subscriptions.update({ - id: data.providerSubscriptionId, - subscriptionUpdate: { productId: current.productId }, - }) - : await client.subscriptions.get({ id: data.providerSubscriptionId }); - - return { - paymentUrl: null, - subscription: { - cancelAtPeriodEnd: sub.cancelAtPeriodEnd, - currentPeriodEndAt: sub.currentPeriodEnd ? new Date(sub.currentPeriodEnd) : null, - currentPeriodStartAt: sub.currentPeriodStart ? new Date(sub.currentPeriodStart) : null, - providerSubscriptionId: sub.id, - status: sub.status, - }, - }; - }, - - detachPaymentMethod() { - return notSupported("detachPaymentMethod"); - }, - - async syncProducts(data) { - const [allPolarProducts, orgs] = await Promise.all([ - client.products.list({ isArchived: false, limit: 100 }), - client.organizations.list({ limit: 1 }), - ]); - - const org = orgs.result.items?.[0]; - const polarProductMap = new Map((allPolarProducts.result.items ?? []).map((p) => [p.id, p])); - - const activeProductIds = new Set(); - - const results = await Promise.all( - data.products.map(async (product) => { - const existingProductId = product.existingProviderProduct?.productId ?? null; - const existingPolarProduct = existingProductId - ? polarProductMap.get(existingProductId) - : null; - - if (existingPolarProduct) { - const intervalMatches = - existingPolarProduct.recurringInterval === (product.priceInterval ?? null); - - if (intervalMatches) { - const updated = await client.products.update({ - id: existingPolarProduct.id, - productUpdate: { - name: product.name, - visibility: "private", - prices: [ - { - amountType: "fixed" as const, - priceAmount: product.priceAmount, - priceCurrency: "usd", - }, - ], - }, - }); - activeProductIds.add(updated.id); - return { id: product.id, providerProduct: { productId: updated.id } }; - } - - // Interval changed — archive old, create new - await client.products.update({ - id: existingPolarProduct.id, - productUpdate: { isArchived: true }, - }); - } - - const created = await client.products.create({ - name: product.name, - visibility: "private", - recurringInterval: (product.priceInterval as "month" | "year") ?? null, - prices: [ - { - amountType: "fixed" as const, - priceAmount: product.priceAmount, - priceCurrency: "usd", - }, - ], - }); - activeProductIds.add(created.id); - return { id: product.id, providerProduct: { productId: created.id } }; - }), - ); - - // Archive orphans + configure org settings in parallel - const cleanup: Promise[] = []; - - for (const [polarId] of polarProductMap) { - if (!activeProductIds.has(polarId)) { - cleanup.push( - client.products.update({ - id: polarId, - productUpdate: { isArchived: true }, - }), - ); - } - } - - if (org) { - cleanup.push( - client.organizations.update({ - id: org.id, - organizationUpdate: { - subscriptionSettings: { - allowMultipleSubscriptions: true, - allowCustomerUpdates: false, - prorationBehavior: "invoice", - benefitRevocationGracePeriod: org.subscriptionSettings.benefitRevocationGracePeriod, - preventTrialAbuse: org.subscriptionSettings.preventTrialAbuse, - }, - customerPortalSettings: { - subscription: { updateSeats: false, updatePlan: false }, - usage: org.customerPortalSettings.usage, - }, - }, - }), - ); - } - - await Promise.all(cleanup); - - return { results }; - }, - - async handleWebhook(data): Promise { - const webhookIdKey = Object.keys(data.headers).find((k) => k.toLowerCase() === "webhook-id"); - const webhookId = webhookIdKey ? data.headers[webhookIdKey]! : ""; - - let event: ReturnType; - try { - event = validateEvent(data.body, data.headers, options.webhookSecret); - } catch (error) { - if (error instanceof WebhookVerificationError) { - throw PayKitError.from( - "BAD_REQUEST", - PAYKIT_ERROR_CODES.PROVIDER_SIGNATURE_MISSING, - "Invalid Polar webhook signature", - ); - } - // Unknown event types (e.g. member.created) — ignore silently - if (error instanceof SDKValidationError) { - return []; - } - throw error; - } - - switch (event.type) { - case "subscription.created": - case "subscription.updated": - case "subscription.active": - case "subscription.uncanceled": - case "subscription.canceled": - case "subscription.past_due": - case "subscription.revoked": - return createSubscriptionEvents(event, webhookId); - case "checkout.created": - case "checkout.updated": - return createCheckoutEvents(event, webhookId); - default: - return []; - } - }, - - async createPortalSession(data) { - const session = await client.customerSessions.create({ - customerId: data.providerCustomerId, - }); - - return { - url: session.customerPortalUrl, - }; - }, - - async check() { - try { - await client.products.list({ limit: 1 }); - - const customers = await client.customers.list({ - limit: 5, - sorting: ["created_at"], - }); - const customerSample = (customers.result.items ?? []).map((c) => ({ - providerEmail: c.email ?? "", - paykitCustomerId: (c.metadata?.paykitCustomerId as string) ?? null, - })); - - return { - ok: true, - displayName: "Polar", - mode: options.server === "sandbox" ? "sandbox" : "production", - webhookEndpoints: [], - customerSample, - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { - ok: false, - displayName: "Polar", - mode: options.server === "sandbox" ? "sandbox" : "production", - error: message, - }; - } - }, - }; -} - -export function polar(polarOptions: PolarOptions): PolarProviderConfig { - return { - id: "polar", - name: "Polar", - capabilities: { testClocks: false }, - createAdapter(): PaymentProvider { - const client = new Polar({ - accessToken: polarOptions.accessToken, - server: polarOptions.server ?? "production", - }); - return createPolarProvider(client, polarOptions); - }, - }; -} diff --git a/packages/polar/tsconfig.json b/packages/polar/tsconfig.json deleted file mode 100644 index da8829fe..00000000 --- a/packages/polar/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src" - }, - "include": ["src"] -} diff --git a/packages/polar/tsdown.config.ts b/packages/polar/tsdown.config.ts deleted file mode 100644 index 110b5d4f..00000000 --- a/packages/polar/tsdown.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { fileURLToPath } from "node:url"; - -import { defineConfig } from "tsdown"; - -import { createPackageTsdownConfig } from "../../tsdown.base.ts"; - -export default defineConfig( - createPackageTsdownConfig({ - packageRoot: fileURLToPath(new URL(".", import.meta.url)), - entry: { - index: "src/index.ts", - }, - }), -); diff --git a/packages/stripe/package.json b/packages/stripe/package.json deleted file mode 100644 index 1bee1547..00000000 --- a/packages/stripe/package.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "name": "@paykitjs/stripe", - "version": "0.0.6", - "description": "Stripe provider adapter for PayKit", - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/getpaykit/paykit.git" - }, - "files": [ - "dist" - ], - "type": "module", - "main": "./src/index.ts", - "types": "./src/index.ts", - "exports": { - ".": { - "paykit-source": "./src/index.ts", - "types": "./src/index.ts", - "default": "./src/index.ts" - } - }, - "scripts": { - "build": "tsdown --config tsdown.config.ts", - "typecheck": "tsc --build" - }, - "dependencies": { - "paykitjs": "workspace:*", - "stripe": "^19.1.0" - }, - "devDependencies": { - "tsdown": "^0.21.1", - "typescript": "^5.9.2", - "vitest": "^4.0.18" - } -} diff --git a/packages/stripe/src/__tests__/stripe-provider.test.ts b/packages/stripe/src/__tests__/stripe-provider.test.ts deleted file mode 100644 index 0ae26b87..00000000 --- a/packages/stripe/src/__tests__/stripe-provider.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { stripe } from "../stripe-provider"; - -describe("@paykitjs/stripe", () => { - it("should return a provider config with createAdapter", () => { - const config = stripe({ - secretKey: "sk_test_123", - webhookSecret: "whsec_test_123", - }); - - expect(config.id).toBe("stripe"); - expect(config.name).toBe("Stripe"); - expect(typeof config.createAdapter).toBe("function"); - }); - - it("should create a PaymentProvider adapter", () => { - const config = stripe({ - secretKey: "sk_test_123", - webhookSecret: "whsec_test_123", - }); - - const adapter = config.createAdapter(); - expect(adapter.id).toBe("stripe"); - expect(adapter.name).toBe("Stripe"); - expect(typeof adapter.createCustomer).toBe("function"); - expect(typeof adapter.updateCustomer).toBe("function"); - expect(typeof adapter.handleWebhook).toBe("function"); - }); -}); diff --git a/packages/stripe/src/__tests__/stripe.test.ts b/packages/stripe/src/__tests__/stripe.test.ts deleted file mode 100644 index 3592659e..00000000 --- a/packages/stripe/src/__tests__/stripe.test.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { PAYKIT_ERROR_CODES } from "paykitjs"; -import { describe, expect, it, vi } from "vitest"; - -import { createStripeProvider, stripe } from "../stripe-provider"; - -describe("providers/stripe", () => { - it("creates a test clock and stores its id on the provider customer", async () => { - const createClock = vi.fn().mockResolvedValue({ - frozen_time: 1_700_000_000, - id: "clock_123", - name: "customer_123", - status: "ready", - }); - const createCustomer = vi.fn().mockResolvedValue({ id: "cus_123" }); - const runtime = createStripeProvider( - { - customers: { create: createCustomer }, - testHelpers: { - testClocks: { - advance: vi.fn(), - create: createClock, - retrieve: vi.fn(), - }, - }, - } as never, - { - secretKey: "sk_test_123", - webhookSecret: "whsec_123", - }, - ); - - const result = await runtime.createCustomer({ - createTestClock: true, - email: "test@example.com", - id: "customer_123", - metadata: { role: "tester" }, - name: "Tester", - }); - - expect(createClock).toHaveBeenCalledWith({ - frozen_time: expect.any(Number), - name: "customer_123", - }); - expect(createCustomer).toHaveBeenCalledWith({ - email: "test@example.com", - metadata: { - customerId: "customer_123", - role: "tester", - }, - name: "Tester", - test_clock: "clock_123", - }); - expect(result).toEqual({ - providerCustomer: { - frozenTime: expect.any(String), - id: "cus_123", - testClockId: "clock_123", - }, - }); - }); - - it("throws a clear error when testing mode uses a live Stripe key", async () => { - const runtime = createStripeProvider( - { - customers: { create: vi.fn() }, - testHelpers: { - testClocks: { - advance: vi.fn(), - create: vi.fn(), - retrieve: vi.fn(), - }, - }, - } as never, - { - secretKey: "sk_live_123", - webhookSecret: "whsec_123", - }, - ); - - await expect( - runtime.createCustomer({ - createTestClock: true, - id: "customer_123", - }), - ).rejects.toMatchObject({ - code: PAYKIT_ERROR_CODES.PROVIDER_TEST_KEY_REQUIRED.code, - }); - }); - - it("advances a test clock and returns its normalized state", async () => { - const advanceClock = vi.fn().mockResolvedValue(undefined); - const retrieveClock = vi.fn().mockResolvedValue({ - frozen_time: 1_700_086_400, - id: "clock_123", - name: "customer_123", - status: "ready", - }); - const runtime = createStripeProvider( - { - customers: { create: vi.fn() }, - testHelpers: { - testClocks: { - advance: advanceClock, - create: vi.fn(), - retrieve: retrieveClock, - }, - }, - } as never, - { - secretKey: "sk_test_123", - webhookSecret: "whsec_123", - }, - ); - const frozenTime = new Date("2024-01-02T00:00:00.000Z"); - - const result = await runtime.advanceTestClock({ - frozenTime, - testClockId: "clock_123", - }); - - expect(advanceClock).toHaveBeenCalledWith("clock_123", { - frozen_time: Math.floor(frozenTime.getTime() / 1000), - }); - expect(result).toEqual({ - frozenTime: new Date(1_700_086_400 * 1000), - id: "clock_123", - name: "customer_123", - status: "ready", - }); - }); - - /** @see https://github.com/getpaykit/paykit/issues/109 */ - describe("managed payments", () => { - function createCheckoutRuntime( - createSession: ReturnType, - managedPayments: boolean, - ) { - return createStripeProvider( - { - checkout: { sessions: { create: createSession } }, - } as never, - { - managedPayments, - secretKey: "sk_test_123", - webhookSecret: "whsec_123", - }, - ); - } - - it("adds managed_payments to subscription checkout sessions when enabled", async () => { - const createSession = vi - .fn() - .mockResolvedValue({ id: "cs_123", url: "https://checkout.stripe.com/x" }); - const runtime = createCheckoutRuntime(createSession, true); - - await runtime.createSubscriptionCheckout({ - cancelUrl: "https://example.com/cancel", - metadata: {}, - providerCustomerId: "cus_123", - providerProduct: { priceId: "price_123" }, - successUrl: "https://example.com/success", - }); - - expect(createSession).toHaveBeenCalledWith( - expect.objectContaining({ managed_payments: { enabled: true } }), - ); - }); - - it("does not add managed_payments when disabled", async () => { - const createSession = vi - .fn() - .mockResolvedValue({ id: "cs_123", url: "https://checkout.stripe.com/x" }); - const runtime = createCheckoutRuntime(createSession, false); - - await runtime.createSubscriptionCheckout({ - cancelUrl: "https://example.com/cancel", - metadata: {}, - providerCustomerId: "cus_123", - providerProduct: { priceId: "price_123" }, - successUrl: "https://example.com/success", - }); - - const params = createSession.mock.calls[0]?.[0] as Record; - expect(params.managed_payments).toBeUndefined(); - }); - - it("throws when managedPayments is enabled without the preview apiVersion", () => { - expect(() => - stripe({ - managedPayments: true, - secretKey: "sk_test_123", - webhookSecret: "whsec_123", - }), - ).toThrowError(/managedPayments requires apiVersion/); - }); - - it("succeeds with the minimum preview apiVersion", () => { - expect(() => - stripe({ - apiVersion: "2026-03-04.preview", - managedPayments: true, - secretKey: "sk_test_123", - webhookSecret: "whsec_123", - }), - ).not.toThrow(); - }); - - it("succeeds with a newer preview apiVersion", () => { - expect(() => - stripe({ - apiVersion: "2027-01-01.preview", - managedPayments: true, - secretKey: "sk_test_123", - webhookSecret: "whsec_123", - }), - ).not.toThrow(); - }); - }); -}); diff --git a/packages/stripe/src/index.ts b/packages/stripe/src/index.ts deleted file mode 100644 index c0d6bec3..00000000 --- a/packages/stripe/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { stripe, PAYKIT_STRIPE_API_VERSION } from "./stripe-provider"; - -export type { StripeOptions } from "./stripe-provider"; diff --git a/packages/stripe/tsconfig.json b/packages/stripe/tsconfig.json deleted file mode 100644 index da8829fe..00000000 --- a/packages/stripe/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src" - }, - "include": ["src"] -} diff --git a/packages/stripe/tsdown.config.ts b/packages/stripe/tsdown.config.ts deleted file mode 100644 index 110b5d4f..00000000 --- a/packages/stripe/tsdown.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { fileURLToPath } from "node:url"; - -import { defineConfig } from "tsdown"; - -import { createPackageTsdownConfig } from "../../tsdown.base.ts"; - -export default defineConfig( - createPackageTsdownConfig({ - packageRoot: fileURLToPath(new URL(".", import.meta.url)), - entry: { - index: "src/index.ts", - }, - }), -); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 87609a19..94178a91 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,12 +56,6 @@ importers: '@base-ui/react': specifier: ^1.2.0 version: 1.5.0(@date-fns/tz@1.5.0)(@types/react@19.2.15)(date-fns@4.2.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@paykitjs/polar': - specifier: workspace:* - version: link:../../packages/polar - '@paykitjs/stripe': - specifier: workspace:* - version: link:../../packages/stripe '@t3-oss/env-nextjs': specifier: ^0.12.0 version: 0.12.0(typescript@5.9.3)(zod@4.4.3) @@ -368,12 +362,6 @@ importers: e2e: devDependencies: - '@paykitjs/polar': - specifier: workspace:* - version: link:../packages/polar - '@paykitjs/stripe': - specifier: workspace:* - version: link:../packages/stripe '@t3-oss/env-core': specifier: ^0.12.0 version: 0.12.0(typescript@5.9.3)(zod@4.4.3) @@ -504,6 +492,9 @@ importers: posthog-node: specifier: ^5.28.8 version: 5.35.1(rxjs@7.8.2) + stripe: + specifier: ^19.1.0 + version: 19.3.1(@types/node@25.9.1) typescript: specifier: ^5.9.2 version: 5.9.3 @@ -524,44 +515,6 @@ importers: specifier: ^4.0.18 version: 4.1.7(@types/node@25.9.1)(msw@2.14.6(@types/node@25.9.1)(typescript@5.9.3))(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) - packages/polar: - dependencies: - '@polar-sh/sdk': - specifier: ^0.47.0 - version: 0.47.1 - paykitjs: - specifier: workspace:* - version: link:../paykit - devDependencies: - tsdown: - specifier: ^0.21.1 - version: 0.21.10(typescript@5.9.3) - typescript: - specifier: ^5.9.2 - version: 5.9.3 - vitest: - specifier: ^4.0.18 - version: 4.1.7(@types/node@25.9.1)(msw@2.14.6(@types/node@25.9.1)(typescript@5.9.3))(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) - - packages/stripe: - dependencies: - paykitjs: - specifier: workspace:* - version: link:../paykit - stripe: - specifier: ^19.1.0 - version: 19.3.1(@types/node@25.9.1) - devDependencies: - tsdown: - specifier: ^0.21.1 - version: 0.21.10(typescript@5.9.3) - typescript: - specifier: ^5.9.2 - version: 5.9.3 - vitest: - specifier: ^4.0.18 - version: 4.1.7(@types/node@25.9.1)(msw@2.14.6(@types/node@25.9.1)(typescript@5.9.3))(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) - packages: '@alcalzone/ansi-tokenize@0.2.5': @@ -2442,9 +2395,6 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} - '@polar-sh/sdk@0.47.1': - resolution: {integrity: sha512-fkz7wPLbqfuDmY9LxuXpE2uP2TAV6J0q/YN5hJ4UBxpjbkB0hKM6c4R35N89t83dfzMlG6EOlqOn+Rd1T6XrJQ==} - '@poppinss/colors@4.1.6': resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} @@ -9202,11 +9152,6 @@ snapshots: '@pinojs/redact@0.4.0': {} - '@polar-sh/sdk@0.47.1': - dependencies: - standardwebhooks: 1.0.0 - zod: 4.4.3 - '@poppinss/colors@4.1.6': dependencies: kleur: 4.1.5 diff --git a/scripts/publish-dist.mjs b/scripts/publish-dist.mjs index 8c3c255d..c917367c 100644 --- a/scripts/publish-dist.mjs +++ b/scripts/publish-dist.mjs @@ -5,7 +5,7 @@ import { readFileSync } from "node:fs"; // generated package.json with dist-relative exports and resolved versions). // We publish from `dist` rather than the package root because the root // package.json points at `src` for the workspace's source-condition dev setup. -const packageDirs = ["packages/paykit", "packages/polar", "packages/stripe"]; +const packageDirs = ["packages/paykit"]; for (const dir of packageDirs) { const pkg = JSON.parse(readFileSync(`${dir}/dist/package.json`, "utf8")); From 3cc424ffc443d3f7fc5716129f122a79a7668ec7 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Tue, 2 Jun 2026 19:18:21 +0400 Subject: [PATCH 02/82] chore: update agent skill --- .agents/skills/paykit-architecture/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/paykit-architecture/SKILL.md b/.agents/skills/paykit-architecture/SKILL.md index 5903d067..5e844268 100644 --- a/.agents/skills/paykit-architecture/SKILL.md +++ b/.agents/skills/paykit-architecture/SKILL.md @@ -1,6 +1,6 @@ --- name: paykit-architecture -description: Use before architectural, API design, provider integration, billing lifecycle, database model, or product-scope decisions in PayKit. +description: not use automatically --- # PayKit Architecture From 7cfdcb9dd75573fbd12e626e59b8f68ab9d262ca Mon Sep 17 00:00:00 2001 From: Max Katz Date: Tue, 2 Jun 2026 19:24:03 +0400 Subject: [PATCH 03/82] fix: address Stripe-only review feedback --- apps/web/content/docs/concepts/cli.mdx | 2 +- apps/web/src/components/docs/docs-icons.tsx | 96 -------- packages/paykit/src/cli/commands/status.ts | 207 ++++++++++-------- packages/paykit/src/stripe/stripe-provider.ts | 38 +++- 4 files changed, 145 insertions(+), 198 deletions(-) diff --git a/apps/web/content/docs/concepts/cli.mdx b/apps/web/content/docs/concepts/cli.mdx index dcbade5e..ce624a05 100644 --- a/apps/web/content/docs/concepts/cli.mdx +++ b/apps/web/content/docs/concepts/cli.mdx @@ -24,7 +24,7 @@ Run this once when starting a new project. The command you'll run most often. It does two things: 1. Applies any pending database migrations to keep your schema up to date -2. Syncs your plan definitions to the database and your payment provider, creating or updating products and prices in Stripe (or whichever provider you're using) +2. Syncs your plan definitions to the database and Stripe, creating or updating products and prices in Stripe Run it on initial setup and again whenever you change your plan configuration. diff --git a/apps/web/src/components/docs/docs-icons.tsx b/apps/web/src/components/docs/docs-icons.tsx index 79bf4996..e46a720a 100644 --- a/apps/web/src/components/docs/docs-icons.tsx +++ b/apps/web/src/components/docs/docs-icons.tsx @@ -30,8 +30,6 @@ import { } from "lucide-react"; import type { ReactElement } from "react"; -import { CreemIcon } from "@/components/icons/creem"; - const categoryIcons = { "get started": , concepts: , @@ -98,100 +96,6 @@ const providerPageIcons = { /> ), - paypal: ( - - - - - - - - ), - polar: ( - - - - - - - ), - lemonsqueezy: ( - - - - ), - paddle: ( - - - - ), - creem: , } as const; function normalizeCategoryName(name: string): string { diff --git a/packages/paykit/src/cli/commands/status.ts b/packages/paykit/src/cli/commands/status.ts index 610ed5cf..649858b2 100644 --- a/packages/paykit/src/cli/commands/status.ts +++ b/packages/paykit/src/cli/commands/status.ts @@ -4,6 +4,7 @@ import * as p from "@clack/prompts"; import { Command } from "commander"; import picocolors from "picocolors"; +import { assertValidPayKitOptions } from "../../core/validate-options"; import { checkDatabase, checkProvider, @@ -37,6 +38,7 @@ async function statusAction(options: { let config; try { config = await deps.getPayKitConfig({ configPath: options.config, cwd }); + assertValidPayKitOptions(config.options, { configPath: config.path }); } catch (error) { s.stop(""); const message = error instanceof Error ? error.message : String(error); @@ -62,124 +64,135 @@ async function statusAction(options: { // Database + Provider in parallel const database = createPool(deps, config.options.database); const connStr = deps.getConnectionString(database as never); - - const [dbResult, providerResult] = await Promise.all([ - checkDatabase(database, deps), - checkProvider(config.options.stripe), - ]); - - if (!dbResult.ok) { - s.stop(""); - p.log.error(`Database\n ${picocolors.red("✖")} ${connStr}\n ${dbResult.message}`); - p.outro("Fix database issues before continuing"); + let databaseClosed = false; + const closeDatabase = async () => { + if (databaseClosed) return; + databaseClosed = true; await database.end(); - process.exit(1); - } + }; - if (!providerResult.account.ok) { - s.stop(""); - p.log.error(`Stripe\n ${picocolors.red("✖")} ${providerResult.account.message}`); - p.outro("Fix Stripe issues before continuing"); - await database.end(); - process.exit(1); - } + try { + const [dbResult, providerResult] = await Promise.all([ + checkDatabase(database, deps), + checkProvider(config.options.stripe), + ]); + + if (!dbResult.ok) { + s.stop(""); + p.log.error(`Database\n ${picocolors.red("✖")} ${connStr}\n ${dbResult.message}`); + p.outro("Fix database issues before continuing"); + await closeDatabase(); + process.exit(1); + } - const pendingMigrations = dbResult.pendingMigrations; - - let preflightErrors: string[] = [...providerResult.errors]; - - let webhookStatus: string; - if (providerResult.webhookEndpoints === null) { - webhookStatus = `${picocolors.dim("?")} Could not check webhook status`; - } else if (providerResult.webhookEndpoints.length > 0) { - const lines = providerResult.webhookEndpoints.map((ep) => { - const label = ep.status === "enabled" ? "registered" : `status: ${ep.status}`; - return picocolors.dim(`· Webhook endpoint ${label} (${ep.url})`); - }); - webhookStatus = lines.join("\n "); - } else { - webhookStatus = picocolors.dim("· No webhook endpoint (use provider CLI for local testing)"); - } + if (!providerResult.account.ok) { + s.stop(""); + p.log.error(`Stripe\n ${picocolors.red("✖")} ${providerResult.account.message}`); + p.outro("Fix Stripe issues before continuing"); + await closeDatabase(); + process.exit(1); + } + + const pendingMigrations = dbResult.pendingMigrations; - // Products - let needsSync = false; - let productsBlock: string; + let preflightErrors: string[] = [...providerResult.errors]; - if (pendingMigrations > 0) { - productsBlock = `Products\n ${picocolors.dim("?")} Cannot check sync status until migrations are applied`; - } else { - const { ctx, diffs } = await loadProductDiffs(config, deps); + let webhookStatus: string; + if (providerResult.webhookEndpoints === null) { + webhookStatus = `${picocolors.dim("?")} Could not check webhook status`; + } else if (providerResult.webhookEndpoints.length > 0) { + const lines = providerResult.webhookEndpoints.map((ep) => { + const label = ep.status === "enabled" ? "registered" : `status: ${ep.status}`; + return picocolors.dim(`· Webhook endpoint ${label} (${ep.url})`); + }); + webhookStatus = lines.join("\n "); + } else { + webhookStatus = picocolors.dim("· No webhook endpoint (use provider CLI for local testing)"); + } - const customerErrors = await checkProviderCustomers(ctx, providerResult.customerSample); - preflightErrors = [...preflightErrors, ...customerErrors]; + // Products + let needsSync = false; + let productsBlock: string; - if (diffs.length === 0) { - productsBlock = `Products\n ${picocolors.dim("No products defined")}`; + if (pendingMigrations > 0) { + productsBlock = `Products\n ${picocolors.dim("?")} Cannot check sync status until migrations are applied`; } else { - const allSynced = diffs.every((d) => d.action === "unchanged"); - if (!allSynced) needsSync = true; + const { ctx, diffs } = await loadProductDiffs(config, deps); - const header = allSynced - ? `${picocolors.green("✔")} All synced` - : `${picocolors.red("✖")} Not synced (run ${picocolors.bold(pushCmd)})`; + const customerErrors = await checkProviderCustomers(ctx, providerResult.customerSample); + preflightErrors = [...preflightErrors, ...customerErrors]; - const planLines = formatProductDiffs(diffs, ctx.products.plans, deps); - productsBlock = `Products\n ${header}\n${planLines.join("\n")}`; - } - } + if (diffs.length === 0) { + productsBlock = `Products\n ${picocolors.dim("No products defined")}`; + } else { + const allSynced = diffs.every((d) => d.action === "unchanged"); + if (!allSynced) needsSync = true; - await database.end(); + const header = allSynced + ? `${picocolors.green("✔")} All synced` + : `${picocolors.red("✖")} Not synced (run ${picocolors.bold(pushCmd)})`; - // Render everything at once - const migrationStatus = - pendingMigrations > 0 - ? `${picocolors.red("✖")} Schema needs migration` - : `${picocolors.green("✔")} Schema up to date`; + const planLines = formatProductDiffs(diffs, ctx.products.plans, deps); + productsBlock = `Products\n ${header}\n${planLines.join("\n")}`; + } + } + + // Render everything at once + const migrationStatus = + pendingMigrations > 0 + ? `${picocolors.red("✖")} Schema needs migration` + : `${picocolors.green("✔")} Schema up to date`; - s.stop(""); + s.stop(""); - p.log.info( - `Config\n` + - ` ${picocolors.green("✔")} ${picocolors.dim(config.path)}\n` + - ` ${picocolors.green("✔")} ${String(planCount)} plan${planCount === 1 ? "" : "s"} defined\n` + - ` ${picocolors.green("✔")} Stripe configured`, - ); + p.log.info( + `Config\n` + + ` ${picocolors.green("✔")} ${picocolors.dim(config.path)}\n` + + ` ${picocolors.green("✔")} ${String(planCount)} plan${planCount === 1 ? "" : "s"} defined\n` + + ` ${picocolors.green("✔")} Stripe configured`, + ); - p.log.info(`Database\n ${picocolors.green("✔")} ${connStr}\n ${migrationStatus}`); + p.log.info(`Database\n ${picocolors.green("✔")} ${connStr}\n ${migrationStatus}`); - p.log.info( - `Stripe\n` + - ` ${picocolors.green("✔")} ${providerResult.account.displayName} (${providerResult.account.mode})\n` + - ` ${webhookStatus}`, - ); + p.log.info( + `Stripe\n` + + ` ${picocolors.green("✔")} ${providerResult.account.displayName} (${providerResult.account.mode})\n` + + ` ${webhookStatus}`, + ); - p.log.info(productsBlock); + p.log.info(productsBlock); - if (preflightErrors.length > 0) { - const errorLines = preflightErrors.map((err) => ` ${picocolors.red("✖")} ${err}`); - p.log.error(`Preflight\n${errorLines.join("\n")}`); - } + if (preflightErrors.length > 0) { + const errorLines = preflightErrors.map((err) => ` ${picocolors.red("✖")} ${err}`); + p.log.error(`Preflight\n${errorLines.join("\n")}`); + } - const needsMigration = pendingMigrations > 0; - const hasIssues = needsMigration || needsSync || preflightErrors.length > 0; - - if (hasIssues) { - if (needsMigration || needsSync) { - const action = - needsMigration && needsSync - ? "apply migrations and sync products" - : needsMigration - ? "apply migrations" - : "sync products"; - p.outro(`Run ${picocolors.bold(pushCmd)} to ${action}`); + const needsMigration = pendingMigrations > 0; + const hasIssues = needsMigration || needsSync || preflightErrors.length > 0; + + if (hasIssues) { + if (needsMigration || needsSync) { + const action = + needsMigration && needsSync + ? "apply migrations and sync products" + : needsMigration + ? "apply migrations" + : "sync products"; + p.outro(`Run ${picocolors.bold(pushCmd)} to ${action}`); + } else { + p.outro("Resolve the preflight errors above before continuing"); + } + await printUpdateNotification(updateCheck, deps.getInstallCommand(pm, ["paykitjs@latest"])); + if (options.throw) { + await closeDatabase(); + process.exit(1); + } } else { - p.outro("Resolve the preflight errors above before continuing"); + p.outro("Everything looks good"); + await printUpdateNotification(updateCheck, deps.getInstallCommand(pm, ["paykitjs@latest"])); } - await printUpdateNotification(updateCheck, deps.getInstallCommand(pm, ["paykitjs@latest"])); - if (options.throw) process.exit(1); - } else { - p.outro("Everything looks good"); - await printUpdateNotification(updateCheck, deps.getInstallCommand(pm, ["paykitjs@latest"])); + } finally { + await closeDatabase(); } } diff --git a/packages/paykit/src/stripe/stripe-provider.ts b/packages/paykit/src/stripe/stripe-provider.ts index ebe4f9cb..1e2b54e6 100644 --- a/packages/paykit/src/stripe/stripe-provider.ts +++ b/packages/paykit/src/stripe/stripe-provider.ts @@ -84,6 +84,26 @@ function getStripeCustomerId( return typeof customer === "string" ? customer : customer.id; } +function parseUnsignedStripeEvent(body: string): StripeSdk.Event { + let parsed: unknown; + try { + parsed = JSON.parse(body) as unknown; + } catch { + throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.PROVIDER_WEBHOOK_INVALID); + } + + if ( + !parsed || + typeof parsed !== "object" || + typeof (parsed as { id?: unknown }).id !== "string" || + typeof (parsed as { type?: unknown }).type !== "string" + ) { + throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.PROVIDER_WEBHOOK_INVALID); + } + + return parsed as StripeSdk.Event; +} + function normalizeStripePaymentMethod(paymentMethod: StripeSdk.PaymentMethod): { expiryMonth?: number; expiryYear?: number; @@ -176,7 +196,7 @@ function normalizeStripeTestClock(clock: StripeSdk.TestHelpers.TestClock): Provi }; } -function assertStripeTestKey(options: StripeOptions): void { +function assertStripeTestKey(options: Pick): void { if (!options.secretKey.startsWith("sk_test_")) { throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.PROVIDER_TEST_KEY_REQUIRED); } @@ -562,7 +582,10 @@ function createDetachedPaymentMethodEvents(event: StripeSdk.Event): NormalizedWe ]; } -export function createStripeProvider(client: StripeSdk, options: StripeOptions): PaymentProvider { +export function createStripeProvider( + client: StripeSdk, + options: StripeAdapterOptions, +): PaymentProvider { const currency = "usd"; return { @@ -951,11 +974,18 @@ export function createStripeProvider(client: StripeSdk, options: StripeOptions): const signature = headerKey ? data.headers[headerKey] : undefined; const event = data.allowUnsignedPayload - ? (JSON.parse(data.body) as StripeSdk.Event) + ? parseUnsignedStripeEvent(data.body) : await (async () => { if (!signature) { throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.PROVIDER_SIGNATURE_MISSING); } + if (!options.webhookSecret) { + throw PayKitError.from( + "BAD_REQUEST", + PAYKIT_ERROR_CODES.PROVIDER_INVALID_CONFIG, + "Stripe webhookSecret is required to verify signed webhook payloads.", + ); + } return client.webhooks.constructEventAsync(data.body, signature, options.webhookSecret); })(); return [ @@ -1063,5 +1093,5 @@ export function createStripeAdapter(options: StripeAdapterOptions): PaymentProvi maxNetworkRetries: 3, }); - return createStripeProvider(client, { ...options, webhookSecret: options.webhookSecret ?? "" }); + return createStripeProvider(client, options); } From 2e0074bda35986d81e15f62d95b4b0036f96da94 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Wed, 3 Jun 2026 02:16:58 +0400 Subject: [PATCH 04/82] Set web container width to 72rem --- apps/web/src/components/layout/section.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/layout/section.tsx b/apps/web/src/components/layout/section.tsx index e17c620f..9afc79d7 100644 --- a/apps/web/src/components/layout/section.tsx +++ b/apps/web/src/components/layout/section.tsx @@ -21,7 +21,7 @@ export function SectionLine({ orientation }: { orientation: "horizontal" | "vert export function SectionShell({ children, className }: { children: ReactNode; className?: string }) { return ( -
+
From 8ca8a002ccc5247d2da12c5dcb59d8fbc219a009 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Wed, 3 Jun 2026 02:17:05 +0400 Subject: [PATCH 05/82] Update dark web theme tokens --- apps/web/src/styles/globals.css | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css index 65cdcf1c..74e99938 100644 --- a/apps/web/src/styles/globals.css +++ b/apps/web/src/styles/globals.css @@ -141,15 +141,15 @@ } .dark { - --background: oklch(0.17 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.1871 0 0); + --background: #0a0a0a; + --foreground: #f5f5f5; + --card: #0a0a0a; --card-foreground: oklch(0.985 0 0); --popover: oklch(0.205 0 0); --popover-foreground: oklch(0.985 0 0); --primary: oklch(0.87 0 0); --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.24 0 0); + --secondary: #0a0a0a; --secondary-foreground: oklch(0.985 0 0); --muted: oklch(0.269 0 0); --muted-foreground: oklch(0.708 0 0); @@ -157,7 +157,7 @@ --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.704 0.191 22.216); --destructive-foreground: oklch(0.637 0.237 25.331); - --border: oklch(26% 0 0); + --border: #1f1f1f; --input: oklch(1 0 0 / 15%); --ring: oklch(0.556 0 0); --chart-1: oklch(0.87 0 0); From a9902cd9e04afb991ebafec1d8720a3352f10c46 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Wed, 3 Jun 2026 02:17:14 +0400 Subject: [PATCH 06/82] Use GitHub Dark for code highlighting --- apps/web/src/components/ui/code-block-content.tsx | 4 ++-- apps/web/src/components/ui/dynamic-code-block.tsx | 2 +- apps/web/src/lib/shiki-themes.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ui/code-block-content.tsx b/apps/web/src/components/ui/code-block-content.tsx index 678e289f..2f8f2ba6 100644 --- a/apps/web/src/components/ui/code-block-content.tsx +++ b/apps/web/src/components/ui/code-block-content.tsx @@ -8,7 +8,7 @@ import { cn } from "@/lib/utils"; const defaultThemes = { themes: { light: "github-light" satisfies BundledTheme, - dark: "one-dark-pro" satisfies BundledTheme, + dark: "github-dark" satisfies BundledTheme, }, defaultColor: false as const, }; @@ -42,7 +42,7 @@ export async function InlineCode({ lang, code }: { lang: string; code: string }) const { codeToTokens } = await import("shiki"); const { tokens } = await codeToTokens(code, { lang: lang as BundledLanguage, - theme: "one-dark-pro", + theme: "github-dark", }); return ( diff --git a/apps/web/src/components/ui/dynamic-code-block.tsx b/apps/web/src/components/ui/dynamic-code-block.tsx index 1f394382..751da2f9 100644 --- a/apps/web/src/components/ui/dynamic-code-block.tsx +++ b/apps/web/src/components/ui/dynamic-code-block.tsx @@ -12,7 +12,7 @@ import { cn } from "@/lib/utils"; const defaultThemes = { themes: { light: "github-light" satisfies BundledTheme, - dark: "one-dark-pro" satisfies BundledTheme, + dark: "github-dark" satisfies BundledTheme, }, defaultColor: false as const, }; diff --git a/apps/web/src/lib/shiki-themes.ts b/apps/web/src/lib/shiki-themes.ts index 4b6c50b0..13783173 100644 --- a/apps/web/src/lib/shiki-themes.ts +++ b/apps/web/src/lib/shiki-themes.ts @@ -1,6 +1,6 @@ export const shikiThemes = { light: "github-light", - dark: "one-dark-pro", + dark: "github-dark", } as const; export const shikiHighlightOptions = { From 0cb55491352e95c2e8992bf2c07d378c6320a9b2 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Wed, 3 Jun 2026 02:17:26 +0400 Subject: [PATCH 07/82] Remove providers docs section --- .../docs/concepts/payment-providers.mdx | 2 - .../content/docs/get-started/quickstart.mdx | 2 +- apps/web/content/docs/meta.json | 2 - apps/web/content/docs/providers/meta.json | 4 - apps/web/content/docs/providers/stripe.mdx | 167 ------------------ apps/web/content/drafts/docs-index.mdx | 4 +- 6 files changed, 3 insertions(+), 178 deletions(-) delete mode 100644 apps/web/content/docs/providers/meta.json delete mode 100644 apps/web/content/docs/providers/stripe.mdx diff --git a/apps/web/content/docs/concepts/payment-providers.mdx b/apps/web/content/docs/concepts/payment-providers.mdx index cf61106f..06c8d01f 100644 --- a/apps/web/content/docs/concepts/payment-providers.mdx +++ b/apps/web/content/docs/concepts/payment-providers.mdx @@ -23,8 +23,6 @@ export const paykit = createPayKit({ Stripe covers subscriptions, usage-based billing, webhooks, and product syncing out of the box. -See the [Stripe provider page](/docs/providers/stripe) for full setup instructions, webhook configuration, and available options. - ## Stripe IDs stay internal Your app identifies customers and plans with its own IDs. PayKit maps them to Stripe customer IDs, product IDs, and price IDs internally. You never store or reference Stripe IDs in your app code. diff --git a/apps/web/content/docs/get-started/quickstart.mdx b/apps/web/content/docs/get-started/quickstart.mdx index d9d77b9b..d2af549f 100644 --- a/apps/web/content/docs/get-started/quickstart.mdx +++ b/apps/web/content/docs/get-started/quickstart.mdx @@ -149,4 +149,4 @@ export const paykit = createPayKit({ - [Plans & Features](/docs/concepts/plans-and-features) - plan groups, defaults, and feature types - [Subscriptions](/docs/concepts/subscriptions) - upgrade, downgrade, and cancellation behavior - [Entitlements](/docs/concepts/entitlements) - access checks and metered usage -- [Stripe](/docs/providers/stripe) - provider configuration +- [Payment Providers](/docs/concepts/payment-providers) - Stripe configuration diff --git a/apps/web/content/docs/meta.json b/apps/web/content/docs/meta.json index 8bb3411d..e8de517d 100644 --- a/apps/web/content/docs/meta.json +++ b/apps/web/content/docs/meta.json @@ -7,8 +7,6 @@ "concepts", "---Flows---", "flows", - "---Providers---", - "providers", "---Plugins---", "plugins", "---Guides---", diff --git a/apps/web/content/docs/providers/meta.json b/apps/web/content/docs/providers/meta.json deleted file mode 100644 index a058739d..00000000 --- a/apps/web/content/docs/providers/meta.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "title": "Providers", - "pages": ["stripe"] -} diff --git a/apps/web/content/docs/providers/stripe.mdx b/apps/web/content/docs/providers/stripe.mdx deleted file mode 100644 index a7e18b23..00000000 --- a/apps/web/content/docs/providers/stripe.mdx +++ /dev/null @@ -1,167 +0,0 @@ ---- -title: Stripe -description: Configure Stripe for PayKit, set up webhooks, sync products, and use the customer portal. ---- - -Stripe is built into PayKit. PayKit handles Stripe API interactions, webhook processing, and product syncing. - -## Configuration - -Pass Stripe config to `createPayKit` with your secret key and webhook secret. - -```ts title="paykit.ts" -import { createPayKit } from "paykitjs"; - -export const paykit = createPayKit({ - // ... - stripe: { - secretKey: process.env.STRIPE_SECRET_KEY!, - webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!, - }, -}); -``` - -## Environment variables - -Add these two variables to your `.env` file: - -```bash title=".env" -STRIPE_SECRET_KEY=sk_live_... -STRIPE_WEBHOOK_SECRET=whsec_... -``` - -- `STRIPE_SECRET_KEY`: find this in [Stripe Dashboard](https://dashboard.stripe.com) under **Developers > API keys**. -- `STRIPE_WEBHOOK_SECRET`: generated when you create a webhook endpoint. See the section below. - -## Webhook setup - -In the Stripe Dashboard, go to **Developers > Webhooks** and create a new endpoint pointing to: - -``` -https://your-app.com/paykit/webhook -``` - -Select the following events when creating the endpoint: - -- `checkout.session.completed` -- `customer.subscription.created` -- `customer.subscription.updated` -- `customer.subscription.deleted` -- `invoice.created` -- `invoice.finalized` -- `invoice.paid` -- `invoice.payment_failed` -- `invoice.updated` -- `payment_method.detached` - - - Stripe discourages selecting all events as it may cause performance issues. Only the events listed above are required by PayKit. - - -After saving, Stripe displays the signing secret. Copy it as your `STRIPE_WEBHOOK_SECRET`. - -## Local development - -Use the Stripe CLI to forward webhook events to your local server: - -```bash -stripe listen --forward-to localhost:3000/paykit/webhook -``` - -The CLI prints a webhook signing secret at startup. Use that as `STRIPE_WEBHOOK_SECRET` in your local `.env`. - - - Install the Stripe CLI from [stripe.com/docs/stripe-cli](https://stripe.com/docs/stripe-cli). You'll need to run `stripe login` once to authenticate. - - -## Product syncing - -`paykitjs push` creates and updates Stripe products and prices to match your plan definitions. You don't need to touch the Stripe Dashboard for product management. - - - - - Run this once on setup, and again every time you change your plans or pricing. - - -## Customer portal - -PayKit can open Stripe's built-in customer portal so users can manage payment methods, invoices, and subscriptions. - - - - ```ts - const { url } = await paykit.customerPortal({ - customerId: "user_123", - returnUrl: "https://myapp.com/billing", - }); - - // redirect the user to `url` - ``` - - - ```ts - const { url } = await paykitClient.customerPortal({ - returnUrl: window.location.href, - }); - - window.location.href = url; - ``` - - - -The portal is hosted by Stripe, so no additional UI work is needed on your end. - -## Testing mode - -Enable `testing` on your PayKit instance to use Stripe test clocks during development. This lets you simulate time-based billing events like renewals and trial expirations. - -```ts title="paykit.ts" -export const paykit = createPayKit({ - // ... - testing: { - enabled: true, - }, -}); -``` - - - Use your Stripe test mode keys (`sk_test_...`) when testing. Test mode and live mode are completely isolated in Stripe. - - -## API version - -PayKit pins the Stripe SDK to a known-good API version so upstream changes don't silently break your integration. Override it with `apiVersion` if you need a newer version, for example to opt into a preview feature. - -```ts title="paykit.ts" -export const paykit = createPayKit({ - // ... - stripe: { - secretKey: process.env.STRIPE_SECRET_KEY!, - webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!, - apiVersion: "2026-03-04.preview", - }, -}); -``` - -The pinned default is exported as `PAYKIT_STRIPE_API_VERSION` if you want to reference it in code. - -## Managed Payments - -Stripe Managed Payments is a preview feature where Stripe takes over tax calculation, payment method selection, and billing address collection on subscription checkout sessions. Enable it with `managedPayments: true`, and set `apiVersion` to the preview version that supports it. - -```ts title="paykit.ts" -export const paykit = createPayKit({ - // ... - stripe: { - secretKey: process.env.STRIPE_SECRET_KEY!, - webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!, - apiVersion: "2026-03-04.preview", - managedPayments: true, - }, -}); -``` - - - Managed Payments is a Stripe preview feature and the API version required may change. Review the [Stripe Managed Payments docs](https://docs.stripe.com/payments/managed-payments) before enabling in production. Note: digital products only, no shipping, and several Checkout parameters (`automatic_tax`, `payment_method_types`, Connect fields) are not supported in this mode. - diff --git a/apps/web/content/drafts/docs-index.mdx b/apps/web/content/drafts/docs-index.mdx index 97f7e4a7..1b0f2c26 100644 --- a/apps/web/content/drafts/docs-index.mdx +++ b/apps/web/content/drafts/docs-index.mdx @@ -34,8 +34,8 @@ Subscriptions are intentionally **not** the core docs story right now. - [Code](/docs/code/database) The local billing-state model plus the MVP APIs for checkout, customers, payment methods, charges, and webhooks. -- [Providers](/docs/providers/stripe) - Provider-specific setup notes and current integration status. +- [Payment Providers](/docs/concepts/payment-providers) + Stripe setup notes and current integration status. - [Code](/docs/code/sdks/server-sdk) The planned server SDK, future React SDK direction, and framework handler examples. From 5bc51109c55c1ab232d31d1d012ca3b9c01c8c64 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Wed, 3 Jun 2026 02:28:21 +0400 Subject: [PATCH 08/82] Add Aura Shiki theme --- .../src/lib/shiki-themes/shiki-aura-theme.ts | 336 ++++++++++++++++++ 1 file changed, 336 insertions(+) create mode 100644 apps/web/src/lib/shiki-themes/shiki-aura-theme.ts diff --git a/apps/web/src/lib/shiki-themes/shiki-aura-theme.ts b/apps/web/src/lib/shiki-themes/shiki-aura-theme.ts new file mode 100644 index 00000000..c752885e --- /dev/null +++ b/apps/web/src/lib/shiki-themes/shiki-aura-theme.ts @@ -0,0 +1,336 @@ +// NOTE: This theme is intentionally kept as a local Shiki theme because Aura is +// not bundled with Shiki. Source: https://github.com/daltonmenezes/aura-theme + +import type { ThemeRegistrationRaw } from "shiki"; + +/** + * Aura Dark theme for Shiki, converted from the official VS Code theme. + * @see https://github.com/daltonmenezes/aura-theme + */ +const auraDark: ThemeRegistrationRaw = { + name: "aura-dark", + type: "dark", + colors: { + focusBorder: "#a394f033", + foreground: "#edecee", + errorForeground: "#ff6767", + "widget.shadow": "#0f0f0f", + "selection.background": "#3d375e7f", + "titleBar.activeBackground": "#121016", + "titleBar.border": "#000000", + "titleBar.inactiveBackground": "#2d2b38", + "statusBar.background": "#121016", + "statusBar.foreground": "#adacae", + "statusBar.border": "#000000", + "statusBar.debuggingForeground": "#15141b", + "statusBar.debuggingBackground": "#a19c77", + "statusBarItem.activeBackground": "#a277ff", + "dropdown.background": "#15141b", + "dropdown.border": "#3b334b", + "dropdown.foreground": "#cdccce", + "input.background": "#15141b", + "input.border": "#3b334b", + "input.foreground": "#cdccce", + "input.placeholderForeground": "#af8aff7f", + "inputOption.activeBorder": "#a277ff", + "button.background": "#61ffca", + "button.foreground": "#15141b", + "button.hoverBackground": "#49c29a", + "list.activeSelectionBackground": "#2e2b38", + "list.activeSelectionForeground": "#a277ff", + "list.focusBackground": "#3b334b", + "list.focusForeground": "#a277ff", + "list.highlightForeground": "#ffca85", + "list.hoverBackground": "#3b334b", + "list.hoverForeground": "#edecee", + "list.inactiveSelectionBackground": "#2e2b38", + "list.inactiveSelectionForeground": "#a277ff", + "editor.background": "#15141b", + "editor.foreground": "#edecee", + "editorLineNumber.foreground": "#a394f033", + "editorIndentGuide.activeBackground": "#6d6d6d", + "editorCursor.foreground": "#a277ff", + "editor.selectionBackground": "#3d375e7f", + "editor.selectionHighlightBackground": "#3d375e7f", + "editor.inactiveSelectionBackground": "#3d375e7f", + "editor.wordHighlightBackground": "#3d375e7f", + "editor.wordHighlightStrongBackground": "#3d375e7f", + "editor.findMatchBackground": "#3d375e7f", + "editor.findMatchHighlightBackground": "#3d375e7f", + "editor.findRangeHighlightBackground": "#3d375e7f", + "editor.lineHighlightBackground": "#a394f033", + "editor.lineHighlightBorder": "#a394f000", + "editor.stackFrameHighlightBackground": "#353424", + "editorInlayHint.background": "#2e2b38", + "editorInlayHint.foreground": "#cdccce", + "editorLink.activeForeground": "#a277ff", + "editorWhitespace.foreground": "#2d2d2d", + "editorIndentGuide.background": "#2d2d2d", + "editorBracketMatch.border": "#a277ff", + "editorError.foreground": "#ff6767", + "editorError.border": "#ffffff00", + "editorWarning.foreground": "#ffca85", + "editorWarning.border": "#ffffff00", + "editorGutter.modifiedBackground": "#ffca85", + "editorGutter.addedBackground": "#61ffca", + "editorGutter.deletedBackground": "#ff6767", + "editorWidget.background": "#121016", + "editorWidget.border": "#2d2d2d", + "editorSuggestWidget.background": "#121016", + "editorSuggestWidget.border": "#2d2d2d", + "editorSuggestWidget.foreground": "#cdccce", + "editorSuggestWidget.highlightForeground": "#61ffca", + "editorSuggestWidget.selectedBackground": "#4d4d4d", + "editorHoverWidget.background": "#121016", + "editorHoverWidget.border": "#2d2d2d", + "editorGroup.border": "#000000", + "editorGroup.dropBackground": "#3d375e7f", + "editorGroupHeader.tabsBackground": "#15141b", + "editorGroupHeader.tabsBorder": "#000000", + "tab.activeBackground": "#00000000", + "tab.activeForeground": "#61ffca", + "tab.border": "#000000", + "tab.inactiveBackground": "#15141b", + "tab.inactiveForeground": "#6d6d6d", + "tab.activeBorderTop": "#61ffca", + "panel.border": "#000000", + "panelTitle.activeBorder": "#61ffca", + "panelTitle.activeForeground": "#cdccce", + "activityBar.background": "#15141b", + "activityBar.foreground": "#61ffca", + "activityBar.inactiveForeground": "#525156", + "activityBar.border": "#000000", + "activityBarBadge.background": "#a277ff", + "activityBarBadge.foreground": "#15141b", + "activityBar.activeBorder": "#a277ff", + "badge.foreground": "#15141b", + "badge.background": "#a277ff", + "sideBar.background": "#110f18", + "sideBar.foreground": "#cdccce", + "sideBar.border": "#000000", + "sideBarTitle.foreground": "#adacae", + "sideBarSectionHeader.background": "#15141b", + "sideBarSectionHeader.foreground": "#adacae", + "progressBar.background": "#61ffca", + "scrollbar.shadow": "#15141b", + "scrollbarSlider.activeBackground": "#3d375e7f", + "scrollbarSlider.background": "#a394f033", + "scrollbarSlider.hoverBackground": "#a394f033", + "terminal.background": "#15141b", + "terminal.foreground": "#cdccce", + "terminal.ansiBlack": "#15141b", + "terminal.ansiBlue": "#a277ff", + "terminal.ansiBrightBlack": "#2d2d2d", + "terminal.ansiBrightBlue": "#a277ff", + "terminal.ansiBrightCyan": "#61ffca", + "terminal.ansiBrightGreen": "#a277ff", + "terminal.ansiBrightMagenta": "#61ffca", + "terminal.ansiBrightRed": "#ffca85", + "terminal.ansiBrightWhite": "#edecee", + "terminal.ansiBrightYellow": "#ffca85", + "terminal.ansiCyan": "#a277ff", + "terminal.ansiGreen": "#61ffca", + "terminal.ansiMagenta": "#61ffca", + "terminal.ansiRed": "#ff6767", + "terminal.ansiWhite": "#cdccce", + "terminal.ansiYellow": "#ffca85", + "terminal.selectionBackground": "#3d375e7f", + "terminalCursor.background": "#2d2d2d", + "terminalCursor.foreground": "#adacae", + "gitDecoration.modifiedResourceForeground": "#ffca85", + "gitDecoration.deletedResourceForeground": "#ff6767", + "gitDecoration.untrackedResourceForeground": "#61ffca", + "gitDecoration.ignoredResourceForeground": "#4d4d4d", + "gitDecoration.conflictingResourceForeground": "#a277ff", + "diffEditor.insertedTextBackground": "#00d89023", + "diffEditor.removedTextBackground": "#ff474720", + "tree.indentGuidesStroke": "#4d4d4d", + }, + settings: [ + { + name: "Accent1", + scope: [ + "keyword", + "storage", + "support", + "entity.name.tag", + "variable.language", + "keyword.control.flow", + "storage.modifier", + "keyword.operator", + "entity.other.attribute-name.class.css", + "entity.other.keyframe-offset", + "markup.heading", + "markup.underline.link", + "variable.other.env", + "punctuation.definition.list.begin.markdown", + "punctuation.definition.bold.markdown", + "punctuation.definition.italic.markdown", + "punctuation.definition.markdown", + "punctuation.definition.quote.begin.markdown", + "punctuation.definition.raw.markdown", + "constant.length.units.css", + "constant.percentage.units.css", + ], + settings: { + foreground: "#a277ff", + }, + }, + { + name: "Accent2", + scope: [ + "string", + "markup.inserted", + "markup.raw", + "constant", + "source.env", + "support.type.builtin.graphql", + "variable.other.quoted.double", + "markup.inline.raw.string.markdown", + "entity.other.attribute-name.id.css", + "JSXNested", + ], + settings: { + foreground: "#61ffca", + }, + }, + { + name: "Accent3", + scope: [ + "markup.changed", + "entity", + "entity.name.function", + "entity.name.function.elixir", + "entity.name.function-call.elixir", + "support.class.component.tsx", + "support.class.component.open.jsx", + "support.class.component.close.jsx", + "meta.function-call.generic.python", + "entity.name.section.markdown", + "storage.type.annotation.dart", + ], + settings: { + foreground: "#ffca85", + }, + }, + { + name: "Accent5", + scope: ["invalid", "markup.deleted"], + settings: { + foreground: "#ff6767", + }, + }, + { + name: "Accent6", + scope: [ + "string.unquoted", + "punctuation.separator", + "entity.other.attribute-name", + "meta.object-literal.key", + "variable.object.property", + "variable.other.property", + "variable.other.object.property", + "variable.other.constant.property", + "meta.type.annotation", + "support.type.property-name.css", + "support.type.vendored", + "constant.language.symbol.elixir", + "variable.graphql", + "meta.attribute.python", + "source.dart", + ], + settings: { + foreground: "#f694ff", + }, + }, + { + name: "Accent7", + scope: [ + "variable", + "markup.list", + "support.constant.property-value.css", + "variable.parameter.keyframe-list.css", + "source.css", + "support.constant.font-name", + "support.constant.vendored.property-value", + "variable.parameter", + "meta.class", + "meta.method.declaration", + "parameter.variable.function.elixir", + "punctuation.definition.tag", + "punctuation.section.embedded", + "meta.embedded.expression", + "punctuation.terminator.dart", + "punctuation.dot.dart", + "meta.jsx.children", + ], + settings: { + foreground: "#edecee", + }, + }, + { + name: "Accent8", + scope: ["comment", "string.quoted.docstring.multi.python"], + settings: { + foreground: "#6d6d6d", + }, + }, + { + name: "Accent31", + scope: ["entity.name.function", "support.function"], + settings: { + foreground: "#ffca85", + }, + }, + { + name: "Accent32", + scope: [ + "entity.name.type", + "entity.name.class", + "support.class.builtin", + "punctuation.definition.template-expression.begin", + "punctuation.definition.template-expression.end", + "punctuation.quasi.element.begin", + "punctuation.quasi.element.end", + "entity.other.inherited-class", + "variable.other.constant.elixir", + "entity.other.attribute-name.class.css", + "support.class.dart", + ], + settings: { + foreground: "#82e2ff", + }, + }, + { + name: "Italics", + scope: [ + "meta.parameters", + "meta.type.parameters", + "meta.return.type", + "entity.name.type.interface", + "meta.type.annotation", + "meta.function.parameters", + "markup.italic.markdown", + ], + settings: { + fontStyle: "italic", + }, + }, + { + name: "Markup Underline", + scope: "markup.underline", + settings: { + fontStyle: "underline", + }, + }, + { + name: "Bold", + scope: ["markup.bold.markdown", "storage.type.annotation.dart"], + settings: { + fontStyle: "bold", + }, + }, + ], +}; + +export { auraDark }; From 0dc15838323c3b717e52ec8690e4e550d1b3bb32 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Wed, 3 Jun 2026 06:00:14 +0400 Subject: [PATCH 09/82] Add testimonials section components --- .../sections/testimonials-content.ts | 120 +++++++++++++ .../sections/testimonials-section.tsx | 159 ++++++++---------- 2 files changed, 187 insertions(+), 92 deletions(-) create mode 100644 apps/web/src/components/sections/testimonials-content.ts diff --git a/apps/web/src/components/sections/testimonials-content.ts b/apps/web/src/components/sections/testimonials-content.ts new file mode 100644 index 00000000..cd1c3047 --- /dev/null +++ b/apps/web/src/components/sections/testimonials-content.ts @@ -0,0 +1,120 @@ +export type Tweet = { + column: number; + name: string; + handle: string; + link: string; + avatar: string; + text: string; + checkmark: boolean; +}; + +export const tweets: Tweet[] = [ + { + column: 1, + name: "Guillermo Rauch", + handle: "rauchg", + link: "https://x.com/rauchg/status/2047218849754571200?s=20", + avatar: "https://pbs.twimg.com/profile_images/1783856060249595904/8TfcCN0r_200x200.jpg", + text: "👀", + checkmark: true, + }, + { + column: 1, + name: "Gruz", + handle: "damnGruz", + link: "https://x.com/damnGruz/status/2042264666135756991?s=20", + avatar: "https://pbs.twimg.com/profile_images/2008805464834973696/xjQGOfAs_200x200.jpg", + text: "sick!!! will try it", + checkmark: true, + }, + { + column: 2, + name: "Andrew Qu", + handle: "andrewqu", + link: "https://x.com/andrewqu/status/2057463195061948506?s=20", + avatar: "https://pbs.twimg.com/profile_images/1976812562143641600/e_E_wlXX_200x200.jpg", + text: "Paykit and opensec are too good", + checkmark: true, + }, + { + column: 4, + name: "Creem 🍦", + handle: "creem_io", + link: "https://x.com/creem_io/status/2042265241157857483?s=20", + avatar: "https://pbs.twimg.com/profile_images/2003816759975944192/3C1xR6B8_200x200.jpg", + text: "Looks amazing Max! 🙌", + checkmark: true, + }, + { + column: 2, + name: "Matteo Scotto", + handle: "442utopy", + link: "https://x.com/442utopy/status/2042500989660418128?s=20", + avatar: "https://pbs.twimg.com/profile_images/1898711224180674560/BrICyHKA_200x200.jpg", + text: "that looks amazing! Congrats for the launch", + checkmark: true, + }, + { + column: 2, + name: "Jonathan Wilke", + handle: "jonathan_wilke", + link: "https://x.com/jonathan_wilke/status/2042492766270234850?s=20", + avatar: "https://pbs.twimg.com/profile_images/1884529433979068416/AhfbeVEh_200x200.jpg", + text: "Honestly, this is probably the best lib project I have come across since better-auth.", + checkmark: true, + }, + { + column: 4, + name: "jan", + handle: "miaugladiator1", + link: "https://x.com/miaugladiator1/status/2039394313059086710?s=20", + avatar: "https://pbs.twimg.com/profile_images/2053193399180705794/7IMR_hhx_200x200.jpg", + text: "just saw paykit and it looks actually soooo cool have to try this out asap", + checkmark: true, + }, + { + column: 1, + name: "jordi", + handle: "jordienr", + link: "https://x.com/jordienr/status/2039374608286007503?s=20", + avatar: "https://pbs.twimg.com/profile_images/2053541405121769475/TUDez6zL_200x200.jpg", + text: "paykit looks very interesting, like a really good abstraction without lock in", + checkmark: true, + }, + { + column: 3, + name: "Lasse", + handle: "lassejv", + link: "https://x.com/lassejv/status/2042902834509656507?s=20", + avatar: "https://pbs.twimg.com/profile_images/2056849088126070784/MCWM6EuB_200x200.jpg", + text: "Holy shit paykit by @maxktz is really good.", + checkmark: true, + }, + { + column: 3, + name: "lakshmi", + handle: "simhskal", + link: "https://x.com/simhskal/status/2042818621492334663?s=20", + avatar: "https://pbs.twimg.com/profile_images/1997855512210427904/Spg8avde_200x200.jpg", + text: "very cool", + checkmark: true, + }, + { + column: 3, + name: "Leo", + handle: "leodev", + link: "https://x.com/leodev/status/2052489939292499986?s=20", + avatar: "https://pbs.twimg.com/profile_images/2060684155013201929/n2cPRDs7_200x200.jpg", + text: "You should check it out, really cool billing framework", + checkmark: true, + }, + { + column: 4, + name: "Saïd Aitmbarek", + handle: "SaidAitmbarek", + link: "https://x.com/SaidAitmbarek/status/2042568317169193320?s=20", + avatar: "https://pbs.twimg.com/profile_images/1891564978177454080/YzRSDzkw_200x200.jpg", + text: "Gotta try this one, brilliant work mate.", + checkmark: true, + }, +]; diff --git a/apps/web/src/components/sections/testimonials-section.tsx b/apps/web/src/components/sections/testimonials-section.tsx index dc678477..cc501dec 100644 --- a/apps/web/src/components/sections/testimonials-section.tsx +++ b/apps/web/src/components/sections/testimonials-section.tsx @@ -1,112 +1,87 @@ -import { Icons } from "@/components/icons"; import { Section, SectionContent } from "@/components/layout/section"; -import { cn } from "@/lib/utils"; +import { tweets } from "@/components/sections/testimonials-content"; +import type { Tweet } from "@/components/sections/testimonials-content"; -const testimonials = [ - { - handle: "@alexdev", - avatar: "/testimonials/placeholder-1.png", - text: "Just integrated PayKit into our SaaS. Went from zero billing to subscriptions + usage limits in under an hour. The DX is insane.", - }, - { - handle: "@sarahbuilds", - avatar: "/testimonials/placeholder-2.png", - text: "PayKit replaced 800 lines of Stripe webhook code with a single subscribe() call. I'm never going back.", - }, - { - handle: "@marcuseng", - avatar: "/testimonials/placeholder-3.png", - text: "The fact that billing state lives in my own Postgres and I can just JOIN it with my tables is a game changer.", - }, - { - handle: "@devpriya", - avatar: "/testimonials/placeholder-4.png", - text: "We switched from Stripe's raw API to PayKit. Took 30 minutes. Our billing code went from 3 files to 1.", - }, - { - handle: "@joshcodes", - avatar: "/testimonials/placeholder-5.png", - text: "check() and report() for usage billing is exactly what I needed. No more custom middleware to gate features.", - }, - { - handle: "@emmaoss", - avatar: "/testimonials/placeholder-6.png", - text: "Open source billing that actually works. No vendor lock-in, no separate dashboard, just npm install and go.", - }, - { - handle: "@ryanships", - avatar: "/testimonials/placeholder-7.png", - text: "The type safety is incredible. Typo a plan ID and TypeScript catches it before you even run the code.", - }, - { - handle: "@linadev", - avatar: "/testimonials/placeholder-8.png", - text: "PayKit feels like what Stripe should have been for framework developers. Simple, embedded, type-safe.", - }, -]; +const columns = [1, 2, 3, 4].map((column) => tweets.filter((tweet) => tweet.column === column)); -// Split into 3 columns for masonry layout -const columns = [ - testimonials.filter((_, i) => i % 3 === 0), - testimonials.filter((_, i) => i % 3 === 1), - testimonials.filter((_, i) => i % 3 === 2), -]; +function VerifiedIcon() { + return ( + + + + + ); +} -function TestimonialCard({ handle, text }: { handle: string; avatar: string; text: string }) { +function TweetCard({ tweet }: { tweet: Tweet }) { return ( -
-
-
-
-
- - {handle} +
+ + View tweet by {tweet.name} + +
+ + {`${tweet.name}'s + +
+
+ {tweet.name} + {tweet.checkmark && }
-
-

{text}

+ + @{tweet.handle} + + +
+
+

{tweet.text}

-
+ ); } export function TestimonialsSection() { return (
- -
-

- Loved by developers + +
+

+ Feedback

-

- See what developers are saying about PayKit. -

- - {/* Masonry columns with fade at edges */} -
-
-
- {columns.map((column, colIdx) => ( -
- {column.map((testimonial) => ( - - ))} -
- ))} -
+
+
+ +
+
+ {columns.map((column, columnIndex) => ( +
+ {column.map((tweet) => ( + + ))} +
+ ))} +
+
+

); } From a1a4878eb67214f75ac8801e4717519479781434 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Wed, 3 Jun 2026 06:23:34 +0400 Subject: [PATCH 10/82] chore: update DESIGN.md --- dev/{UI_DESIGN.md => DESIGN.md} | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) rename dev/{UI_DESIGN.md => DESIGN.md} (95%) diff --git a/dev/UI_DESIGN.md b/dev/DESIGN.md similarity index 95% rename from dev/UI_DESIGN.md rename to dev/DESIGN.md index 3a4735b7..24411d75 100644 --- a/dev/UI_DESIGN.md +++ b/dev/DESIGN.md @@ -1,6 +1,13 @@ # UI Design Principles -## Design References +## PayKit's Style + +https://efferd.com/view/dashboard-9 +https://efferd.com/view/auth-2 +https://efferd.com/view/auth-4 +https://efferd.com/view/auth-12 + +## Other References These are web pages we admire and draw inspiration from. We don't copy any of them directly — PayKit has its own identity. Use these as a reference for quality, tone, and craft. From 347e1dccb9c177a2391a81fb727b9d058b41153d Mon Sep 17 00:00:00 2001 From: Max Katz Date: Wed, 3 Jun 2026 06:23:43 +0400 Subject: [PATCH 11/82] chore: update theme --- apps/web/src/styles/globals.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css index 74e99938..548e3dde 100644 --- a/apps/web/src/styles/globals.css +++ b/apps/web/src/styles/globals.css @@ -141,9 +141,9 @@ } .dark { - --background: #0a0a0a; + --background: #070707; --foreground: #f5f5f5; - --card: #0a0a0a; + --card: #070707; --card-foreground: oklch(0.985 0 0); --popover: oklch(0.205 0 0); --popover-foreground: oklch(0.985 0 0); From 8a067fc1bbd04ac06ef672f89621f651c5372643 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Wed, 3 Jun 2026 08:46:04 +0400 Subject: [PATCH 12/82] chore: update version text in docs --- .../sections/{testimonials-content.ts => feedback-content.ts} | 0 .../sections/{testimonials-section.tsx => feedback-section.tsx} | 0 apps/web/src/lib/consts.ts | 2 +- 3 files changed, 1 insertion(+), 1 deletion(-) rename apps/web/src/components/sections/{testimonials-content.ts => feedback-content.ts} (100%) rename apps/web/src/components/sections/{testimonials-section.tsx => feedback-section.tsx} (100%) diff --git a/apps/web/src/components/sections/testimonials-content.ts b/apps/web/src/components/sections/feedback-content.ts similarity index 100% rename from apps/web/src/components/sections/testimonials-content.ts rename to apps/web/src/components/sections/feedback-content.ts diff --git a/apps/web/src/components/sections/testimonials-section.tsx b/apps/web/src/components/sections/feedback-section.tsx similarity index 100% rename from apps/web/src/components/sections/testimonials-section.tsx rename to apps/web/src/components/sections/feedback-section.tsx diff --git a/apps/web/src/lib/consts.ts b/apps/web/src/lib/consts.ts index 8065ac03..a472932d 100644 --- a/apps/web/src/lib/consts.ts +++ b/apps/web/src/lib/consts.ts @@ -23,7 +23,7 @@ export const URLs = { authorX: "https://x.com/maxktz", } as const; -export const VERSION_TEXT = "v0.1 beta"; +export const VERSION_TEXT = "v0.2"; export const websiteSchema: WithContext = { "@context": "https://schema.org", From 53b05d0d4b5654cb484875a5f5ec999b2e795817 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Wed, 3 Jun 2026 08:46:40 +0400 Subject: [PATCH 13/82] Refine button pressed state --- apps/web/src/components/ui/button.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index 4d891bbe..35a66ab2 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -6,7 +6,7 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; const buttonVariants = cva( - "group/button inline-flex shrink-0 items-center justify-center rounded-sm bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "group/button inline-flex shrink-0 items-center justify-center rounded-sm bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:scale-[0.99] disabled:pointer-events-none disabled:opacity-50 aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { From 0474a6da520203427224706464f44f7dd37623cf Mon Sep 17 00:00:00 2001 From: Max Katz Date: Wed, 3 Jun 2026 09:58:03 +0400 Subject: [PATCH 14/82] Update docs copy page actions --- apps/web/src/app/docs/[[...slug]]/page.tsx | 6 +-- .../components/docs/copy-markdown-button.tsx | 45 ++++++++++++++++--- apps/web/src/components/ui/button-group.tsx | 4 +- 3 files changed, 45 insertions(+), 10 deletions(-) diff --git a/apps/web/src/app/docs/[[...slug]]/page.tsx b/apps/web/src/app/docs/[[...slug]]/page.tsx index 09cc1f89..2d62f3fd 100644 --- a/apps/web/src/app/docs/[[...slug]]/page.tsx +++ b/apps/web/src/app/docs/[[...slug]]/page.tsx @@ -53,11 +53,11 @@ export default async function Page({ params }: DocsPageProps) { style: "clerk", }} > - {page.data.title} - {page.data.description} -
+
+ {page.data.title}
+ {page.data.description} - {copied ? : } - {copied ? "Copied" : "Copy Markdown"} - + + + + + } + > + + + + }> + + View as markdown + + }> + + View llms.txt + + } + > + + View llms-full.txt + + + + ); } diff --git a/apps/web/src/components/ui/button-group.tsx b/apps/web/src/components/ui/button-group.tsx index 2588d5b9..14e925cb 100644 --- a/apps/web/src/components/ui/button-group.tsx +++ b/apps/web/src/components/ui/button-group.tsx @@ -11,9 +11,9 @@ const buttonGroupVariants = cva( variants: { orientation: { horizontal: - "*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0", + "*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-sm! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0 [&>[data-slot]~[data-slot]]:after:border-l-0", vertical: - "flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0", + "flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-sm! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0 [&>[data-slot]~[data-slot]]:after:border-t-0", }, }, defaultVariants: { From e2e57d387640ef50d927e2de5b70f041775968ca Mon Sep 17 00:00:00 2001 From: Max Katz Date: Wed, 3 Jun 2026 10:00:15 +0400 Subject: [PATCH 15/82] Update site navigation and section chrome --- apps/web/next.config.js | 1 + .../(marketing)/{donate => sponsor}/page.tsx | 12 +++--- apps/web/src/app/docs/layout.tsx | 5 +-- .../src/components/layout/mini-nav-bar.tsx | 7 +++- .../src/components/layout/navigation-bar.tsx | 37 +++++++++++-------- apps/web/src/components/layout/section.tsx | 6 +-- 6 files changed, 37 insertions(+), 31 deletions(-) rename apps/web/src/app/(marketing)/{donate => sponsor}/page.tsx (85%) diff --git a/apps/web/next.config.js b/apps/web/next.config.js index fa766063..a441df9b 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -43,6 +43,7 @@ const config = { destination: "https://github.com/orgs/getpaykit/projects/1", permanent: false, }, + { source: "/donate", destination: "/sponsor", permanent: true }, ], }; diff --git a/apps/web/src/app/(marketing)/donate/page.tsx b/apps/web/src/app/(marketing)/sponsor/page.tsx similarity index 85% rename from apps/web/src/app/(marketing)/donate/page.tsx rename to apps/web/src/app/(marketing)/sponsor/page.tsx index f428400f..156ec741 100644 --- a/apps/web/src/app/(marketing)/donate/page.tsx +++ b/apps/web/src/app/(marketing)/sponsor/page.tsx @@ -4,20 +4,20 @@ import Link from "next/link"; import { Section, SectionContent } from "@/components/layout/section"; import { FooterSection } from "@/components/sections/footer-section"; -const donateUrl = "https://opencollective.com/maxktz"; +const sponsorUrl = "https://opencollective.com/maxktz"; export const metadata: Metadata = { - title: "Donate", + title: "Sponsor", description: "Support PayKit development through Open Collective.", alternates: { - canonical: "/donate", + canonical: "/sponsor", }, }; -export default function DonatePage() { +export default function SponsorPage() { return (
- +
@@ -34,7 +34,7 @@ export default function DonatePage() {
Continue to Open Collective diff --git a/apps/web/src/app/docs/layout.tsx b/apps/web/src/app/docs/layout.tsx index 8e7f6024..4bc17730 100644 --- a/apps/web/src/app/docs/layout.tsx +++ b/apps/web/src/app/docs/layout.tsx @@ -182,10 +182,7 @@ export default function Layout({ children }: { children: ReactNode }) {
{VERSION_TEXT && ( - + {VERSION_TEXT} )} diff --git a/apps/web/src/components/layout/mini-nav-bar.tsx b/apps/web/src/components/layout/mini-nav-bar.tsx index daca93e8..8aea6652 100644 --- a/apps/web/src/components/layout/mini-nav-bar.tsx +++ b/apps/web/src/components/layout/mini-nav-bar.tsx @@ -16,7 +16,10 @@ export function MiniNavBar() { className="bg-background border-border pointer-events-auto w-full border-b lg:hidden" > - + @@ -28,7 +31,7 @@ export function MiniNavBar() { >
- +
diff --git a/apps/web/src/components/layout/navigation-bar.tsx b/apps/web/src/components/layout/navigation-bar.tsx index fa5c5434..3570fa3e 100644 --- a/apps/web/src/components/layout/navigation-bar.tsx +++ b/apps/web/src/components/layout/navigation-bar.tsx @@ -1,10 +1,11 @@ "use client"; import { AnimatePresence, motion } from "framer-motion"; -import { ChevronDown, ExternalLink, Github, Menu, X } from "lucide-react"; +import { ChevronDown, ExternalLink, Menu, X } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { useCallback, useEffect, useRef, useState } from "react"; +import { FaGithub } from "react-icons/fa"; import { SectionShell } from "@/components/layout/section"; import { Button } from "@/components/ui/button"; @@ -49,15 +50,15 @@ function NavLink({ // ─── Data ──────────────────────────────────────────────────────────── const navTabs: NavItem[] = [ - { name: "readme", href: "/" }, { name: "docs", href: "/docs", path: "/docs" }, + { name: "blog", href: "/blog", path: "/blog" }, + { name: "sponsors", href: "/sponsor", path: "/sponsor" }, ]; const dropdownLinks: NavItem[] = [ { name: "Discord", href: URLs.discord, external: true }, { name: "Twitter / X", href: URLs.x, external: true }, { name: "LinkedIn", href: URLs.linkedin, external: true }, - { name: "Donate", href: "/donate", external: true }, { name: "llms.txt", href: "/llms.txt", external: true }, ]; @@ -69,8 +70,8 @@ const mobileLinks: NavItem[] = [ // ─── Tab styles ────────────────────────────────────────────────────── const tabBase = - "group/tab relative flex h-full items-center justify-center gap-1.5 px-5.5 py-3.5 transition-colors duration-150"; -const tabActive = "bg-background border-b-2 border-b-foreground/60"; + "group/tab relative flex h-full items-center justify-center gap-1.5 px-4 py-3.5 transition-colors duration-150"; +const tabActive = "bg-background"; const tabInactive = "hover:bg-foreground/[0.03] bg-transparent text-foreground/60 dark:text-foreground/40 hover:text-foreground/70"; const labelBase = @@ -109,10 +110,13 @@ export function NavigationBar({ stars: _stars }: { stars: number | null }) { initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.28, ease: "easeOut" }} - className="bg-background border-border pointer-events-auto w-full border-b lg:hidden" + className="bg-background pointer-events-auto w-full lg:hidden" > - - + +
@@ -252,7 +257,7 @@ export function NavigationBar({ stars: _stars }: { stars: number | null }) { > setMobileMenuOpen(false)} diff --git a/apps/web/src/components/layout/section.tsx b/apps/web/src/components/layout/section.tsx index 9afc79d7..90ebd72d 100644 --- a/apps/web/src/components/layout/section.tsx +++ b/apps/web/src/components/layout/section.tsx @@ -45,7 +45,7 @@ export function Section({ return ( {!last && ( -
+
)} @@ -66,11 +66,11 @@ export function SectionContent({ return
{children}
; } -// ─── SectionSeparator (full viewport-width solid line) ─────────────── +// ─── SectionSeparator ──────────────────────────────────────────────── export function SectionSeparator() { return ( -
+
); From 47b032476e7af5a71d2e149531fd651a8b133c1f Mon Sep 17 00:00:00 2001 From: Max Katz Date: Wed, 3 Jun 2026 10:00:31 +0400 Subject: [PATCH 16/82] Refine marketing hero --- .../sections/readme-code-content.ts | 4 +- apps/web/src/components/ui/frame-corners.tsx | 12 ++ .../src/components/web/hero-code-block.tsx | 117 +++++++++--------- apps/web/src/components/web/hero-title.tsx | 56 ++------- 4 files changed, 85 insertions(+), 104 deletions(-) create mode 100644 apps/web/src/components/ui/frame-corners.tsx diff --git a/apps/web/src/components/sections/readme-code-content.ts b/apps/web/src/components/sections/readme-code-content.ts index 28f27686..5224440c 100644 --- a/apps/web/src/components/sections/readme-code-content.ts +++ b/apps/web/src/components/sections/readme-code-content.ts @@ -26,14 +26,14 @@ export const heroConfigCode = `import { createPayKit } from "paykitjs" import { free, pro } from "./products" export const paykit = createPayKit({ + database: env.DATABASE_URL, stripe: { secretKey: env.STRIPE_SECRET_KEY, webhookSecret: env.STRIPE_WEBHOOK_SECRET, }, - database: env.DATABASE_URL, products: [free, pro], on: { - "subscription.activated": async ({ customer, plan }) => { + "subscription.activated": async ({ customer, plan }) => { await sendEmail(customer.email, "Welcome to Pro!") }, } diff --git a/apps/web/src/components/ui/frame-corners.tsx b/apps/web/src/components/ui/frame-corners.tsx new file mode 100644 index 00000000..0855cc10 --- /dev/null +++ b/apps/web/src/components/ui/frame-corners.tsx @@ -0,0 +1,12 @@ +import { cn } from "@/lib/utils"; + +export function FrameCorners({ className }: { className?: string }) { + return ( + + ); +} diff --git a/apps/web/src/components/web/hero-code-block.tsx b/apps/web/src/components/web/hero-code-block.tsx index 01d355d9..f8524c86 100644 --- a/apps/web/src/components/web/hero-code-block.tsx +++ b/apps/web/src/components/web/hero-code-block.tsx @@ -1,11 +1,11 @@ "use client"; import { AnimatePresence, motion } from "framer-motion"; -import { ChevronLeft, Loader2, Terminal } from "lucide-react"; +import { Loader2 } from "lucide-react"; import type { ReactNode } from "react"; import { useCallback, useState } from "react"; -import { Button } from "@/components/ui/button"; +import { FrameCorners } from "@/components/ui/frame-corners"; import { cn } from "@/lib/utils"; type View = "code" | "terminal"; @@ -133,75 +133,74 @@ export function HeroCodeBlock({ setPushing(false); }, [pushing]); - const backToCode = useCallback(() => { + const selectCodeTab = useCallback((tab: "plans" | "config") => { + setActiveTab(tab); setView("code"); - setTerminalLines([]); }, []); + const selectTerminalTab = useCallback(() => { + if (view === "terminal") return; + + void runPush(); + }, [runPush, view]); + return ( -
-
+
+ +
{/* Tab bar */}
- {view === "code" ? ( - <> - - - - ) : ( - Terminal - )} + + +
{/* Content — fixed height */}
- {/* Push / back button */} -
- -
{view === "code" ? ( <> diff --git a/apps/web/src/components/web/hero-title.tsx b/apps/web/src/components/web/hero-title.tsx index 1d9b7e5a..f164ba60 100644 --- a/apps/web/src/components/web/hero-title.tsx +++ b/apps/web/src/components/web/hero-title.tsx @@ -1,7 +1,7 @@ "use client"; import { AnimatePresence, motion } from "framer-motion"; -import { Check, ChevronRight, Copy, Sparkle } from "lucide-react"; +import { Check, ChevronRight, Copy } from "lucide-react"; import Link from "next/link"; import { useCallback, useState } from "react"; @@ -19,22 +19,13 @@ export function HeroTitle() { return (
-
-
-
-

+
+

The billing framework
for TypeScript

-

+

Define plans and features in code. PayKit handles Stripe, webhooks, and usage state - runs inside your app.

@@ -44,41 +35,20 @@ export function HeroTitle() { render={} nativeButton={false} size="lg" - className="px-4 h-9.5" + className="h-9.5 px-5" variant="default" > Read Docs

From f66fb3e06ce0fcaf6cb37e010ed472f437f490ce Mon Sep 17 00:00:00 2001 From: Max Katz Date: Wed, 3 Jun 2026 10:01:12 +0400 Subject: [PATCH 17/82] Refine marketing sections --- apps/web/src/components/docs/features.tsx | 21 +++--- .../src/components/sections/cta-section.tsx | 4 +- .../sections/demo/demo-app-window.tsx | 2 +- .../sections/demo/demo-backend-panel.tsx | 2 +- .../src/components/sections/demo/index.tsx | 68 +++++++++---------- .../components/sections/features-section.tsx | 18 ++--- .../components/sections/feedback-section.tsx | 18 ++--- 7 files changed, 63 insertions(+), 70 deletions(-) diff --git a/apps/web/src/components/docs/features.tsx b/apps/web/src/components/docs/features.tsx index ea0a9cdf..f052d972 100644 --- a/apps/web/src/components/docs/features.tsx +++ b/apps/web/src/components/docs/features.tsx @@ -13,6 +13,8 @@ import { } from "lucide-react"; import type { ReactNode } from "react"; +import { FrameCorners } from "@/components/ui/frame-corners"; + const features: { icon: ReactNode; title: string; description: string }[] = [ { icon: , @@ -67,18 +69,15 @@ export function Features() { {features.map((feature) => (
-
- - {feature.icon} - -
-

{feature.title}

-

- {feature.description} -

-
+ + + {feature.icon} + +
+

{feature.title}

+

{feature.description}

))} diff --git a/apps/web/src/components/sections/cta-section.tsx b/apps/web/src/components/sections/cta-section.tsx index ece27c93..24ce704d 100644 --- a/apps/web/src/components/sections/cta-section.tsx +++ b/apps/web/src/components/sections/cta-section.tsx @@ -22,10 +22,10 @@ export function CTASection() {
-

+

Ready to add billing?

-

+

One command to get started. Define your plans, connect Stripe, and ship billing in minutes.

diff --git a/apps/web/src/components/sections/demo/demo-app-window.tsx b/apps/web/src/components/sections/demo/demo-app-window.tsx index c57ac4d8..4b2e8621 100644 --- a/apps/web/src/components/sections/demo/demo-app-window.tsx +++ b/apps/web/src/components/sections/demo/demo-app-window.tsx @@ -21,7 +21,7 @@ function WindowChrome({ return (
diff --git a/apps/web/src/components/sections/demo/demo-backend-panel.tsx b/apps/web/src/components/sections/demo/demo-backend-panel.tsx index 8c53c1d0..92f466b5 100644 --- a/apps/web/src/components/sections/demo/demo-backend-panel.tsx +++ b/apps/web/src/components/sections/demo/demo-backend-panel.tsx @@ -21,7 +21,7 @@ export function DemoBackendPanel({ return (
diff --git a/apps/web/src/components/sections/demo/index.tsx b/apps/web/src/components/sections/demo/index.tsx index 0e20d417..726be565 100644 --- a/apps/web/src/components/sections/demo/index.tsx +++ b/apps/web/src/components/sections/demo/index.tsx @@ -294,49 +294,47 @@ export function DemoSection({ snippets }: { snippets: Record - +
-

+

How it works

-

- Click around the app below. Every interaction shows the PayKit code that runs and the - steps it orchestrates, in real time. -

+
+
-
- void handleUpgrade()} - onDowngrade={() => void handleDowngrade()} - onResubscribe={() => void handleResubscribe()} - onPortal={() => void handlePortal()} - /> -
- -
- -
+ void handleUpgrade()} + onDowngrade={() => void handleDowngrade()} + onResubscribe={() => void handleResubscribe()} + onPortal={() => void handlePortal()} + /> + +
diff --git a/apps/web/src/components/sections/features-section.tsx b/apps/web/src/components/sections/features-section.tsx index b995e894..ec45a5e2 100644 --- a/apps/web/src/components/sections/features-section.tsx +++ b/apps/web/src/components/sections/features-section.tsx @@ -51,18 +51,14 @@ export function FeaturesSection() { {features.map((feature) => (
-
- - {feature.icon} - -
-

{feature.title}

-

- {feature.description} -

-
+ + {feature.icon} + +
+

{feature.title}

+

{feature.description}

))} diff --git a/apps/web/src/components/sections/feedback-section.tsx b/apps/web/src/components/sections/feedback-section.tsx index cc501dec..f23c72e3 100644 --- a/apps/web/src/components/sections/feedback-section.tsx +++ b/apps/web/src/components/sections/feedback-section.tsx @@ -1,6 +1,6 @@ import { Section, SectionContent } from "@/components/layout/section"; -import { tweets } from "@/components/sections/testimonials-content"; -import type { Tweet } from "@/components/sections/testimonials-content"; +import { tweets } from "@/components/sections/feedback-content"; +import type { Tweet } from "@/components/sections/feedback-content"; const columns = [1, 2, 3, 4].map((column) => tweets.filter((tweet) => tweet.column === column)); @@ -9,7 +9,7 @@ function VerifiedIcon() { +
{tweet.name} {tweet.checkmark && }
- + @{tweet.handle}
-
+

{tweet.text}

); } -export function TestimonialsSection() { +export function FeedbackSection() { return (
@@ -70,7 +70,7 @@ export function TestimonialsSection() {
-
+
{columns.map((column, columnIndex) => (
@@ -80,7 +80,7 @@ export function TestimonialsSection() {
))}
-
+
); From 9b628b2089188cb934d729fac28ad138a627f396 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Wed, 3 Jun 2026 10:01:28 +0400 Subject: [PATCH 18/82] Update homepage feedback content --- apps/web/content/docs/get-started/index.mdx | 2 -- apps/web/src/app/(marketing)/page.tsx | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/web/content/docs/get-started/index.mdx b/apps/web/content/docs/get-started/index.mdx index 3ebffd07..336ff731 100644 --- a/apps/web/content/docs/get-started/index.mdx +++ b/apps/web/content/docs/get-started/index.mdx @@ -10,5 +10,3 @@ PayKit is an embedded Stripe billing framework for TypeScript apps. It provides PayKit aims to be a complete billing framework. It provides a wide range of features out of the box and allows you to extend it with plugins. - -...and more to come! diff --git a/apps/web/src/app/(marketing)/page.tsx b/apps/web/src/app/(marketing)/page.tsx index e648b2ee..95b4819c 100644 --- a/apps/web/src/app/(marketing)/page.tsx +++ b/apps/web/src/app/(marketing)/page.tsx @@ -1,6 +1,6 @@ import { CTASection } from "@/components/sections/cta-section"; import { DemoSection } from "@/components/sections/demo"; -import { FeaturesSection } from "@/components/sections/features-section"; +import { FeedbackSection } from "@/components/sections/feedback-section"; import { FooterSection } from "@/components/sections/footer-section"; import { HeroSection } from "@/components/sections/hero-section"; import { demoSnippets } from "@/components/sections/readme-code-content"; @@ -29,7 +29,7 @@ export default function HomePage() { resubscribe: , }} /> - +
From d9d33ff952503426d72d98da1bc7f9b6cd9947f9 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Wed, 3 Jun 2026 10:09:13 +0400 Subject: [PATCH 19/82] Stabilize docs copy button width --- apps/web/src/components/docs/copy-markdown-button.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/docs/copy-markdown-button.tsx b/apps/web/src/components/docs/copy-markdown-button.tsx index 915b849d..910c7bb8 100644 --- a/apps/web/src/components/docs/copy-markdown-button.tsx +++ b/apps/web/src/components/docs/copy-markdown-button.tsx @@ -35,7 +35,7 @@ export function CopyMarkdownButton({ markdownUrl }: { markdownUrl: string }) { return ( - From 267b6c6f0ac41ef16ae87a429f4e2bd950d8630d Mon Sep 17 00:00:00 2001 From: Max Katz Date: Wed, 3 Jun 2026 10:20:08 +0400 Subject: [PATCH 20/82] Align docs TOC path lines --- apps/web/src/styles/globals.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css index 548e3dde..0e4f0683 100644 --- a/apps/web/src/styles/globals.css +++ b/apps/web/src/styles/globals.css @@ -481,6 +481,10 @@ html:not([data-anchor-scrolling]) { inset-inline-start: 0px !important; } +#nd-toc a[href^="#"] > svg.absolute { + translate: -7px 0; +} + #nd-toc .stroke-fd-primary { @apply stroke-fd-muted-foreground; stroke-width: 2; From 72994624e0d32de090f729e9406d762ce1806929 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Wed, 3 Jun 2026 10:21:21 +0400 Subject: [PATCH 21/82] Update Fumadocs dependencies --- apps/web/package.json | 6 ++-- pnpm-lock.yaml | 83 ++++++++++++++++++++++++------------------- 2 files changed, 49 insertions(+), 40 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 4d970ae2..2450a293 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -30,9 +30,9 @@ "date-fns": "^4.1.0", "embla-carousel-react": "^8.6.0", "framer-motion": "^12.34.3", - "fumadocs-core": "^16.7.11", - "fumadocs-mdx": "^14.2.11", - "fumadocs-ui": "^16.7.11", + "fumadocs-core": "^16.9.3", + "fumadocs-mdx": "^15.0.10", + "fumadocs-ui": "^16.9.3", "geist": "^1.3.1", "input-otp": "^1.4.2", "lucide-react": "^0.575.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 94178a91..aa0926ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -73,10 +73,10 @@ importers: version: 11.17.0(typescript@5.9.3) autumn-js: specifier: ^1.2.2 - version: 1.2.27(better-auth@1.6.11(@cloudflare/workers-types@4.20260522.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260522.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.21.0))(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.21.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.7(@types/node@20.19.41)(msw@2.14.6(@types/node@20.19.41)(typescript@5.9.3))(vite@8.0.14(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0))))(better-call@1.3.5(zod@4.4.3))(express@5.2.1)(hono@4.12.22)(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6) + version: 1.2.27(better-auth@1.6.11(@cloudflare/workers-types@4.20260522.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260522.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.21.0))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.21.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.7(@types/node@20.19.41)(msw@2.14.6(@types/node@20.19.41)(typescript@5.9.3))(vite@8.0.14(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0))))(better-call@1.3.5(zod@4.4.3))(express@5.2.1)(hono@4.12.22)(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6) better-auth: specifier: ^1.6.2 - version: 1.6.11(@cloudflare/workers-types@4.20260522.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260522.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.21.0))(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.21.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.7(@types/node@20.19.41)(msw@2.14.6(@types/node@20.19.41)(typescript@5.9.3))(vite@8.0.14(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0))) + version: 1.6.11(@cloudflare/workers-types@4.20260522.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260522.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.21.0))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.21.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.7(@types/node@20.19.41)(msw@2.14.6(@types/node@20.19.41)(typescript@5.9.3))(vite@8.0.14(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0))) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -218,10 +218,10 @@ importers: version: 2.0.13 '@vercel/analytics': specifier: ^1.6.1 - version: 1.6.1(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6) + version: 1.6.1(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6) '@vercel/speed-insights': specifier: ^2.0.0 - version: 2.0.0(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6) + version: 2.0.0(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -241,17 +241,17 @@ importers: specifier: ^12.34.3 version: 12.40.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) fumadocs-core: - specifier: ^16.7.11 - version: 16.9.0(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@0.575.0(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@3.25.76) + specifier: ^16.9.3 + version: 16.9.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@0.575.0(react@19.2.6))(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@3.25.76) fumadocs-mdx: - specifier: ^14.2.11 - version: 14.3.2(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.15)(fumadocs-core@16.9.0(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@0.575.0(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@3.25.76))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(vite@8.0.14(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) + specifier: ^15.0.10 + version: 15.0.10(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.15)(fumadocs-core@16.9.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@0.575.0(react@19.2.6))(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@3.25.76))(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(rolldown@1.0.2)(vite@8.0.14(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) fumadocs-ui: - specifier: ^16.7.11 - version: 16.9.0(@tailwindcss/oxide@4.3.0)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(fumadocs-core@16.9.0(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@0.575.0(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@3.25.76))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tailwindcss@4.3.0) + specifier: ^16.9.3 + version: 16.9.3(@tailwindcss/oxide@4.3.0)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(fumadocs-core@16.9.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@0.575.0(react@19.2.6))(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@3.25.76))(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tailwindcss@4.3.0) geist: specifier: ^1.3.1 - version: 1.7.1(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)) + version: 1.7.1(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)) input-otp: specifier: ^1.4.2 version: 1.4.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -4956,8 +4956,8 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - fumadocs-core@16.9.0: - resolution: {integrity: sha512-L5bXpKsN0m7kK483KqviQ7g0l6PA7M32K5yCXS0KUrVWJDUu038ASb08G59RFwWjxhahCAHf0H0ntwzF91lqnQ==} + fumadocs-core@16.9.3: + resolution: {integrity: sha512-8RVzKnzBJR5o+tJCccY28ntekfMQYBoYiz7alnYb/d9YJc+XpnsINzTl63lQ1eBMZ9gdhm2MqRtgUjh/8rUrbw==} peerDependencies: '@mdx-js/mdx': '*' '@mixedbread/sdk': 0.x.x @@ -5015,18 +5015,19 @@ packages: zod: optional: true - fumadocs-mdx@14.3.2: - resolution: {integrity: sha512-73SoZkbUuqnD91G/0zBcaQdM1TMnYw5JJzKgkGvQTiZbtLQFuWTt8/uRqnzFMuNIUu/WY9Lo9d1iZ8G+jOVieA==} + fumadocs-mdx@15.0.10: + resolution: {integrity: sha512-kH3S7ESS9yXTAaCkA8dDugsCK/MbnpgyZ5qBEL7cWoavV0O/T4+4YTYFkvNknz7cw+T/r+OG0p2BvlVhkk4fww==} hasBin: true peerDependencies: '@types/mdast': '*' '@types/mdx': '*' '@types/react': '*' - fumadocs-core: ^15.0.0 || ^16.0.0 + fumadocs-core: ^16.7.0 mdast-util-directive: '*' next: ^15.3.0 || ^16.0.0 react: ^19.2.0 - vite: 6.x.x || 7.x.x || 8.x.x + rolldown: '*' + vite: 7.x.x || 8.x.x peerDependenciesMeta: '@types/mdast': optional: true @@ -5040,16 +5041,18 @@ packages: optional: true react: optional: true + rolldown: + optional: true vite: optional: true - fumadocs-ui@16.9.0: - resolution: {integrity: sha512-z4w93vuhrsNp1cup7mcEoo8GSDQRCgDMkGnIvUEz+YB7IwSDhrUqf3lXuSKDaqPLXPdRvR0Cf+BAqARBW71c0Q==} + fumadocs-ui@16.9.3: + resolution: {integrity: sha512-eoVKj1H+ATut0su+WIoPWBLRqzPMGD0hekIBr4GopWvUg1lS997HL4kP+Leyf+3CYlZtFgyXb6ylbvRLFtEj6Q==} peerDependencies: '@takumi-rs/image-response': '*' '@types/mdx': '*' '@types/react': '*' - fumadocs-core: 16.9.0 + fumadocs-core: 16.9.3 next: 16.x.x react: ^19.2.0 react-dom: ^19.2.0 @@ -5741,8 +5744,8 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - lucide-react@1.16.0: - resolution: {integrity: sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==} + lucide-react@1.17.0: + resolution: {integrity: sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==} peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -7114,6 +7117,10 @@ packages: resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} engines: {node: '>=18'} + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + tinyglobby@0.2.16: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} @@ -10374,12 +10381,12 @@ snapshots: '@ungap/structured-clone@1.3.1': {} - '@vercel/analytics@1.6.1(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)': + '@vercel/analytics@1.6.1(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)': optionalDependencies: next: 16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 - '@vercel/speed-insights@2.0.0(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)': + '@vercel/speed-insights@2.0.0(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)': optionalDependencies: next: 16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 @@ -10583,13 +10590,13 @@ snapshots: auto-bind@5.0.1: {} - autumn-js@1.2.27(better-auth@1.6.11(@cloudflare/workers-types@4.20260522.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260522.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.21.0))(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.21.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.7(@types/node@20.19.41)(msw@2.14.6(@types/node@20.19.41)(typescript@5.9.3))(vite@8.0.14(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0))))(better-call@1.3.5(zod@4.4.3))(express@5.2.1)(hono@4.12.22)(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6): + autumn-js@1.2.27(better-auth@1.6.11(@cloudflare/workers-types@4.20260522.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260522.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.21.0))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.21.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.7(@types/node@20.19.41)(msw@2.14.6(@types/node@20.19.41)(typescript@5.9.3))(vite@8.0.14(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0))))(better-call@1.3.5(zod@4.4.3))(express@5.2.1)(hono@4.12.22)(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6): dependencies: query-string: 9.3.1 rou3: 0.6.3 zod: 4.4.3 optionalDependencies: - better-auth: 1.6.11(@cloudflare/workers-types@4.20260522.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260522.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.21.0))(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.21.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.7(@types/node@20.19.41)(msw@2.14.6(@types/node@20.19.41)(typescript@5.9.3))(vite@8.0.14(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0))) + better-auth: 1.6.11(@cloudflare/workers-types@4.20260522.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260522.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.21.0))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.21.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.7(@types/node@20.19.41)(msw@2.14.6(@types/node@20.19.41)(typescript@5.9.3))(vite@8.0.14(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0))) better-call: 1.3.5(zod@4.4.3) express: 5.2.1 hono: 4.12.22 @@ -10614,7 +10621,7 @@ snapshots: baseline-browser-mapping@2.10.32: {} - better-auth@1.6.11(@cloudflare/workers-types@4.20260522.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260522.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.21.0))(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.21.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.7(@types/node@20.19.41)(msw@2.14.6(@types/node@20.19.41)(typescript@5.9.3))(vite@8.0.14(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0))): + better-auth@1.6.11(@cloudflare/workers-types@4.20260522.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260522.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.21.0))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.21.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.7(@types/node@20.19.41)(msw@2.14.6(@types/node@20.19.41)(typescript@5.9.3))(vite@8.0.14(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0))): dependencies: '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260522.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/drizzle-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260522.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260522.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.21.0)) @@ -11564,7 +11571,7 @@ snapshots: fsevents@2.3.3: optional: true - fumadocs-core@16.9.0(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@0.575.0(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@3.25.76): + fumadocs-core@16.9.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@0.575.0(react@19.2.6))(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@3.25.76): dependencies: '@orama/orama': 3.1.18 estree-util-value-to-estree: 3.5.0 @@ -11597,20 +11604,19 @@ snapshots: transitivePeerDependencies: - supports-color - fumadocs-mdx@14.3.2(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.15)(fumadocs-core@16.9.0(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@0.575.0(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@3.25.76))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(vite@8.0.14(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)): + fumadocs-mdx@15.0.10(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.15)(fumadocs-core@16.9.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@0.575.0(react@19.2.6))(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@3.25.76))(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(rolldown@1.0.2)(vite@8.0.14(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.1.0 chokidar: 5.0.0 esbuild: 0.28.0 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 16.9.0(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@0.575.0(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@3.25.76) + fumadocs-core: 16.9.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@0.575.0(react@19.2.6))(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@3.25.76) js-yaml: 4.1.1 mdast-util-mdx: 3.0.0 - mdast-util-to-markdown: 2.1.2 picocolors: 1.1.1 picomatch: 4.0.4 - tinyexec: 1.1.2 + tinyexec: 1.2.4 tinyglobby: 0.2.16 unified: 11.0.5 unist-util-remove-position: 5.0.0 @@ -11623,11 +11629,12 @@ snapshots: '@types/react': 19.2.15 next: 16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 + rolldown: 1.0.2 vite: 8.0.14(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0) transitivePeerDependencies: - supports-color - fumadocs-ui@16.9.0(@tailwindcss/oxide@4.3.0)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(fumadocs-core@16.9.0(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@0.575.0(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@3.25.76))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tailwindcss@4.3.0): + fumadocs-ui@16.9.3(@tailwindcss/oxide@4.3.0)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(fumadocs-core@16.9.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@0.575.0(react@19.2.6))(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@3.25.76))(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tailwindcss@4.3.0): dependencies: '@fumadocs/tailwind': 0.0.5(@tailwindcss/oxide@4.3.0)(tailwindcss@4.3.0) '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -11641,8 +11648,8 @@ snapshots: '@radix-ui/react-slot': 1.2.4(@types/react@19.2.15)(react@19.2.6) '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) class-variance-authority: 0.7.1 - fumadocs-core: 16.9.0(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@0.575.0(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@3.25.76) - lucide-react: 1.16.0(react@19.2.6) + fumadocs-core: 16.9.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@0.575.0(react@19.2.6))(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@3.25.76) + lucide-react: 1.17.0(react@19.2.6) motion: 12.40.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) next-themes: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 @@ -11667,7 +11674,7 @@ snapshots: fuzzysort@3.1.0: {} - geist@1.7.1(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)): + geist@1.7.1(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)): dependencies: next: 16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -12334,7 +12341,7 @@ snapshots: dependencies: react: 19.2.6 - lucide-react@1.16.0(react@19.2.6): + lucide-react@1.17.0(react@19.2.6): dependencies: react: 19.2.6 @@ -14258,6 +14265,8 @@ snapshots: tinyexec@1.1.2: {} + tinyexec@1.2.4: {} + tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4) From 13333f9c83d756fa1327ef8a0c3b623f63ba9a54 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Wed, 3 Jun 2026 10:21:40 +0400 Subject: [PATCH 22/82] Adjust docs TOC footer progress --- apps/web/src/components/docs/toc-footer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/docs/toc-footer.tsx b/apps/web/src/components/docs/toc-footer.tsx index c85c8220..343004cf 100644 --- a/apps/web/src/components/docs/toc-footer.tsx +++ b/apps/web/src/components/docs/toc-footer.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import { URLs } from "@/lib/consts"; -const progressValue = 15; +const progressValue = 65; export function TocFooter() { return ( From 6c380a93c7b64feb188bbf068091bafcd86d9280 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Wed, 3 Jun 2026 11:01:02 +0400 Subject: [PATCH 23/82] Polish docs CTA controls --- apps/web/src/components/docs/copy-markdown-button.tsx | 3 ++- apps/web/src/components/sections/cta-section.tsx | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/docs/copy-markdown-button.tsx b/apps/web/src/components/docs/copy-markdown-button.tsx index 910c7bb8..e52b65b3 100644 --- a/apps/web/src/components/docs/copy-markdown-button.tsx +++ b/apps/web/src/components/docs/copy-markdown-button.tsx @@ -1,6 +1,7 @@ "use client"; import { Check, ChevronDown, Copy, ExternalLink } from "lucide-react"; +import { RiMarkdownLine } from "react-icons/ri"; import Link from "next/link"; import { useCallback, useState } from "react"; import { toast } from "sonner"; @@ -49,7 +50,7 @@ export function CopyMarkdownButton({ markdownUrl }: { markdownUrl: string }) { }> - + View as markdown }> diff --git a/apps/web/src/components/sections/cta-section.tsx b/apps/web/src/components/sections/cta-section.tsx index 24ce704d..8558f693 100644 --- a/apps/web/src/components/sections/cta-section.tsx +++ b/apps/web/src/components/sections/cta-section.tsx @@ -30,7 +30,7 @@ export function CTASection() { minutes.

- )} diff --git a/apps/web/src/app/docs/layout.tsx b/apps/web/src/app/docs/layout.tsx index 4bc17730..8fd85762 100644 --- a/apps/web/src/app/docs/layout.tsx +++ b/apps/web/src/app/docs/layout.tsx @@ -1,8 +1,8 @@ import type * as PageTree from "fumadocs-core/page-tree"; import { DocsLayout } from "fumadocs-ui/layouts/docs"; -import { Github } from "lucide-react"; import { cloneElement } from "react"; import type { ReactElement, ReactNode } from "react"; +import { RiGithubFill } from "react-icons/ri"; import { CategoryFolderIcon, @@ -170,7 +170,7 @@ export default function Layout({ children }: { children: ReactNode }) { size="icon" className="docs-sidebar-github-button text-fd-muted-foreground hover:text-fd-accent-foreground" > -
diff --git a/apps/web/src/app/not-found.tsx b/apps/web/src/app/not-found.tsx index bfc052d1..12251c7b 100644 --- a/apps/web/src/app/not-found.tsx +++ b/apps/web/src/app/not-found.tsx @@ -1,7 +1,7 @@ "use client"; -import { ArrowLeft } from "lucide-react"; import Link from "next/link"; +import { RiArrowLeftLine } from "react-icons/ri"; import { MiniNavBar } from "@/components/layout/mini-nav-bar"; import { PageTransition } from "@/components/layout/page-transition"; @@ -50,7 +50,7 @@ export default function NotFound() {
diff --git a/apps/web/src/components/command-menu.tsx b/apps/web/src/components/command-menu.tsx index 890adfa0..bff6b0a3 100644 --- a/apps/web/src/components/command-menu.tsx +++ b/apps/web/src/components/command-menu.tsx @@ -2,9 +2,9 @@ import { AnimatePresence, motion } from "framer-motion"; import { useDocsSearch } from "fumadocs-core/search/client"; -import { FileText, Hash, Search, Text } from "lucide-react"; import { useRouter } from "next/navigation"; import { createContext, use, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { RiFileTextLine, RiHashtag, RiSearchLine, RiText } from "react-icons/ri"; import { cn } from "@/lib/utils"; @@ -112,7 +112,7 @@ function CommandMenuDialog() { ); } -// ─── Search Mode ───────────────────────────────────────────────────────────── +// ─── RiSearchLine Mode ───────────────────────────────────────────────────────────── function SearchMode({ query, @@ -170,7 +170,7 @@ function SearchMode({ <> {/* Input */}
- + setSelectedIndex(index)} > {item.type === "heading" ? ( - + ) : item.type === "text" ? ( - + ) : ( - + )} {item.content} diff --git a/apps/web/src/components/docs/copy-markdown-button.tsx b/apps/web/src/components/docs/copy-markdown-button.tsx index e52b65b3..46079f5e 100644 --- a/apps/web/src/components/docs/copy-markdown-button.tsx +++ b/apps/web/src/components/docs/copy-markdown-button.tsx @@ -1,9 +1,14 @@ "use client"; -import { Check, ChevronDown, Copy, ExternalLink } from "lucide-react"; -import { RiMarkdownLine } from "react-icons/ri"; import Link from "next/link"; import { useCallback, useState } from "react"; +import { + RiArrowDownSLine, + RiCheckLine, + RiExternalLinkLine, + RiFileCopyLine, + RiMarkdownLine, +} from "react-icons/ri"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -37,7 +42,7 @@ export function CopyMarkdownButton({ markdownUrl }: { markdownUrl: string }) { return ( @@ -46,7 +51,7 @@ export function CopyMarkdownButton({ markdownUrl }: { markdownUrl: string }) { ); } diff --git a/apps/web/src/components/layout/navigation-bar.tsx b/apps/web/src/components/layout/navigation-bar.tsx index 3570fa3e..af0ff1ea 100644 --- a/apps/web/src/components/layout/navigation-bar.tsx +++ b/apps/web/src/components/layout/navigation-bar.tsx @@ -1,11 +1,11 @@ "use client"; import { AnimatePresence, motion } from "framer-motion"; -import { ChevronDown, ExternalLink, Menu, X } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { useCallback, useEffect, useRef, useState } from "react"; import { FaGithub } from "react-icons/fa"; +import { RiArrowDownSLine, RiCloseLine, RiExternalLinkLine, RiMenuLine } from "react-icons/ri"; import { SectionShell } from "@/components/layout/section"; import { Button } from "@/components/ui/button"; @@ -125,7 +125,7 @@ export function NavigationBar({ stars: _stars }: { stars: number | null }) { onClick={() => setMobileMenuOpen((prev) => !prev)} className="text-foreground/65 dark:text-foreground/50 hover:text-foreground/80 px-5 py-3 transition-colors" > - {mobileMenuOpen ? : } + {mobileMenuOpen ? : } @@ -184,7 +184,7 @@ export function NavigationBar({ stars: _stars }: { stars: number | null }) { className={`${tabBase} gap-1 ${linksOpen ? "text-foreground/70" : tabInactive}`} > links - @@ -207,7 +207,7 @@ export function NavigationBar({ stars: _stars }: { stars: number | null }) { > {link.name} {link.external && ( - + )} ))} @@ -272,7 +272,7 @@ export function NavigationBar({ stars: _stars }: { stars: number | null }) { {item.name} {item.external && ( - + )} diff --git a/apps/web/src/components/sections/cta-section.tsx b/apps/web/src/components/sections/cta-section.tsx index 8558f693..a5a77fbf 100644 --- a/apps/web/src/components/sections/cta-section.tsx +++ b/apps/web/src/components/sections/cta-section.tsx @@ -1,9 +1,9 @@ "use client"; import { AnimatePresence, motion } from "framer-motion"; -import { Check, ChevronRight, Copy } from "lucide-react"; import Link from "next/link"; import { useCallback, useState } from "react"; +import { RiArrowRightSLine, RiCheckLine, RiFileCopyLine } from "react-icons/ri"; import { Section, SectionContent } from "@/components/layout/section"; import { Button } from "@/components/ui/button"; @@ -52,7 +52,7 @@ export function CTASection() { transition={{ duration: 0.15 }} className="absolute" > - + ) : hovered ? ( - + ) : ( - + )} diff --git a/apps/web/src/components/sections/demo/demo-app-window.tsx b/apps/web/src/components/sections/demo/demo-app-window.tsx index 4b2e8621..1bb74978 100644 --- a/apps/web/src/components/sections/demo/demo-app-window.tsx +++ b/apps/web/src/components/sections/demo/demo-app-window.tsx @@ -1,8 +1,8 @@ "use client"; import { AnimatePresence, motion } from "framer-motion"; -import { Loader2, Send } from "lucide-react"; import type { RefObject } from "react"; +import { RiLoader4Line, RiSendPlaneLine } from "react-icons/ri"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; @@ -134,7 +134,7 @@ export function DemoAppWindow({ > {busy === "downgrade" ? ( <> - + Downgrading... ) : plan === "free" ? ( @@ -169,12 +169,12 @@ export function DemoAppWindow({ > {busy === "upgrade" ? ( <> - + Upgrading... ) : busy === "resubscribe" ? ( <> - + Resubscribing... ) : plan === "pro" && downgradeScheduled ? ( @@ -285,7 +285,7 @@ export function DemoAppWindow({ disabled={blocked || aiState !== "idle" || !input.trim()} className="text-foreground/35 hover:text-foreground/60 disabled:opacity-20" > - +
diff --git a/apps/web/src/components/sections/demo/demo-backend-panel.tsx b/apps/web/src/components/sections/demo/demo-backend-panel.tsx index 92f466b5..2cd61c53 100644 --- a/apps/web/src/components/sections/demo/demo-backend-panel.tsx +++ b/apps/web/src/components/sections/demo/demo-backend-panel.tsx @@ -1,8 +1,8 @@ "use client"; import { AnimatePresence, motion } from "framer-motion"; -import { Loader2, User } from "lucide-react"; import type { ReactNode } from "react"; +import { RiLoader4Line, RiUserLine } from "react-icons/ri"; import { cn } from "@/lib/utils"; @@ -63,7 +63,7 @@ function FlowLog({ className="border-foreground/[0.08] shrink-0 overflow-hidden rounded-md border" >
- + {card.trigger}
@@ -88,7 +88,7 @@ function FlowLog({ ) : entry.type === "pending" ? (
- + {entry.label}
) : ( diff --git a/apps/web/src/components/sections/demo/demo-types.tsx b/apps/web/src/components/sections/demo/demo-types.tsx index 6d6838e8..69306905 100644 --- a/apps/web/src/components/sections/demo/demo-types.tsx +++ b/apps/web/src/components/sections/demo/demo-types.tsx @@ -1,18 +1,18 @@ -import { - CalendarCheck, - CalendarX, - CreditCard, - Database, - ExternalLink, - Link2, - RefreshCw, - Shield, - ShieldAlert, - Sparkles, - UserCheck, - Webhook, -} from "lucide-react"; import type { ReactNode } from "react"; +import { + RiBankCardLine, + RiCalendarCheckLine, + RiCalendarCloseLine, + RiDatabase2Line, + RiExternalLinkLine, + RiLink, + RiRefreshLine, + RiShieldFlashLine, + RiShieldLine, + RiSparklingLine, + RiUserFollowLine, + RiWebhookLine, +} from "react-icons/ri"; export type SnippetKey = "subscribe" | "check" | "report" | "portal" | "downgrade" | "resubscribe"; @@ -47,18 +47,18 @@ export function nextCardId() { } export const stepIcons: Record = { - user: , - "credit-card": , - webhook: , - database: , - link: , - "external-link": , - "calendar-x": , - "calendar-check": , - sparkles: , - shield: , - "shield-alert": , - refresh: , + user: , + "credit-card": , + webhook: , + database: , + link: , + "external-link": , + "calendar-x": , + "calendar-check": , + sparkles: , + shield: , + "shield-alert": , + refresh: , }; // Scripted replies for auto-play diff --git a/apps/web/src/components/sections/features-section.tsx b/apps/web/src/components/sections/features-section.tsx index ec45a5e2..ecf318a5 100644 --- a/apps/web/src/components/sections/features-section.tsx +++ b/apps/web/src/components/sections/features-section.tsx @@ -1,35 +1,42 @@ -import { Blocks, Cable, Database, Gauge, ShieldCheck, Webhook } from "lucide-react"; +import { + RiDatabase2Line, + RiPlug2Line, + RiPuzzle2Line, + RiShieldCheckLine, + RiSpeedUpLine, + RiWebhookLine, +} from "react-icons/ri"; import { Section, SectionContent } from "@/components/layout/section"; const features = [ { - icon: , + icon: , title: "Usage billing", description: "Metered features with check() and report(). Zero network latency.", }, { - icon: , + icon: , title: "Webhooks handled", description: "Verified, deduplicated, synced to your database automatically.", }, { - icon: , + icon: , title: "Stripe built in", description: "Subscriptions, webhooks, portal, and product sync without adapter setup.", }, { - icon: , + icon: , title: "Plugins", description: "Extend PayKit with dashboard, analytics, or build your own plugin.", }, { - icon: , + icon: , title: "Your database", description: "Billing state in your Postgres, low latency, joinable with your tables.", }, { - icon: , + icon: , title: "Type-safe", description: "Plan IDs, feature IDs, events. All inferred from your schema.", }, diff --git a/apps/web/src/components/sections/footer-section.tsx b/apps/web/src/components/sections/footer-section.tsx index 0541f986..19286e62 100644 --- a/apps/web/src/components/sections/footer-section.tsx +++ b/apps/web/src/components/sections/footer-section.tsx @@ -1,7 +1,7 @@ "use client"; -import { Github } from "lucide-react"; import Link from "next/link"; +import { RiGithubFill } from "react-icons/ri"; import { Icons } from "@/components/icons"; import { Section, SectionContent } from "@/components/layout/section"; @@ -17,7 +17,7 @@ const socialLinks = [ { label: "Discord", href: URLs.discord, icon: }, { label: "Twitter/X", href: URLs.x, icon: }, { label: "LinkedIn", href: URLs.linkedin, icon: }, - { label: "GitHub", href: URLs.githubRepo, icon: }, + { label: "GitHub", href: URLs.githubRepo, icon: }, ]; const prompt = encodeURIComponent(`Explain what PayKit (paykit.sh) is and why I should use it. diff --git a/apps/web/src/components/sidebar-content.tsx b/apps/web/src/components/sidebar-content.tsx index d9c17192..6850b70d 100644 --- a/apps/web/src/components/sidebar-content.tsx +++ b/apps/web/src/components/sidebar-content.tsx @@ -1,25 +1,25 @@ import type { Folder, Root } from "fumadocs-core/page-tree"; -import type { LucideIcon } from "lucide-react"; -import { - Binoculars, - Book, - CircleHelp, - Database, - Gauge, - Key, - KeyRound, - LucideAArrowDown, - Mail, - Mailbox, - Phone, - ScanFace, - ShieldCheck, - TriangleAlertIcon, - UserCircle, - UserSquare2, - Users2, -} from "lucide-react"; import type { ReactNode, SVGProps } from "react"; +import type { IconType } from "react-icons"; +import { + RiAccountBoxLine, + RiAccountCircleLine, + RiBookLine, + RiDatabase2Line, + RiErrorWarningLine, + RiInboxLine, + RiKey2Line, + RiKeyLine, + RiMailLine, + RiPhoneLine, + RiQuestionLine, + RiSearchEyeLine, + RiShieldCheckLine, + RiSortAlphabetAsc, + RiSpeedUpLine, + RiTeamLine, + RiUserSearchLine, +} from "react-icons/ri"; import { Icons } from "./icons"; @@ -31,7 +31,7 @@ export interface SubpageItem { export interface ListItem { title: string; href: string; - icon: ((props?: SVGProps) => ReactNode) | LucideIcon; + icon: ((props?: SVGProps) => ReactNode) | IconType; group?: boolean; separator?: boolean; isNew?: boolean; @@ -42,7 +42,7 @@ export interface ListItem { interface Content { title: string; href?: string; - Icon: ((props?: SVGProps) => ReactNode) | LucideIcon; + Icon: ((props?: SVGProps) => ReactNode) | IconType; isNew?: boolean; list: ListItem[]; } @@ -418,7 +418,7 @@ export const contents: Content[] = [ { title: "Social Sign-On", group: true, - icon: LucideAArrowDown, + icon: RiSortAlphabetAsc, href: "", }, { @@ -1090,13 +1090,13 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2, { title: "Other Relational Databases", href: "/docs/adapters/other-relational-databases", - icon: () => , + icon: () => , }, { group: true, title: "Adapters", href: "", - icon: () => , + icon: () => , }, { title: "Drizzle", @@ -1182,7 +1182,7 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2, group: true, title: "Others", href: "", - icon: () => , + icon: () => , }, { title: "Community Adapters", @@ -1227,7 +1227,7 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2, group: true, title: "Full Stack", href: "", - icon: LucideAArrowDown, + icon: RiSortAlphabetAsc, }, { title: "Astro", @@ -1269,7 +1269,7 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2, group: true, title: "Backend", href: "", - icon: LucideAArrowDown, + icon: RiSortAlphabetAsc, }, { title: "Hono", @@ -1310,7 +1310,7 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2, group: true, title: "Mobile & Desktop", href: "", - icon: LucideAArrowDown, + icon: RiSortAlphabetAsc, }, { title: "Expo", @@ -1342,38 +1342,38 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2, title: "Authentication", group: true, href: "", - icon: () => , + icon: () => , }, { title: "Two Factor", - icon: () => , + icon: () => , href: "/docs/plugins/2fa", }, { title: "Username", - icon: () => , + icon: () => , href: "/docs/plugins/username", }, { title: "Anonymous", - icon: () => , + icon: () => , href: "/docs/plugins/anonymous", }, { title: "Phone Number", - icon: () => , + icon: () => , href: "/docs/plugins/phone-number", }, { title: "Magic Link", href: "/docs/plugins/magic-link", - icon: () => , + icon: () => , }, { title: "Email OTP", href: "/docs/plugins/email-otp", - icon: () => , + icon: () => , }, { title: "Passkey", @@ -1436,7 +1436,7 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2, title: "Authorization", group: true, href: "", - icon: () => , + icon: () => , }, { title: "Admin", @@ -1458,7 +1458,7 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2, { title: "API Key", href: "/docs/plugins/api-key", - icon: () => , + icon: () => , }, { title: "MCP", @@ -1494,7 +1494,7 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2, }, { title: "Organization", - icon: () => , + icon: () => , href: "/docs/plugins/organization", }, { @@ -1566,11 +1566,11 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2, title: "Utility", group: true, href: "", - icon: () => , + icon: () => , }, { title: "Bearer", - icon: () => , + icon: () => , href: "/docs/plugins/bearer", }, { @@ -1707,7 +1707,7 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2, title: "Payments", group: true, href: "", - icon: () => , + icon: () => , }, { title: "Stripe", @@ -1935,7 +1935,7 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2, { title: "Create a Database Adapter", href: "/docs/guides/create-a-db-adapter", - icon: () => , + icon: () => , }, { title: "Browser Extension Guide", @@ -1976,7 +1976,7 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2, { title: "Optimize for Performance", href: "/docs/guides/optimizing-for-performance", - icon: () => , + icon: () => , }, { title: "Migration", @@ -2210,7 +2210,7 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2, { title: "Errors", href: "/docs/reference/errors", - icon: () => , + icon: () => , hasSubpages: true, subpages: [ { @@ -2275,23 +2275,23 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2, { title: "Resources", href: "/docs/reference/resources", - icon: () => , + icon: () => , }, { title: "Security", href: "/docs/reference/security", - icon: () => , + icon: () => , }, { title: "Telemetry", href: "/docs/reference/telemetry", - icon: () => , + icon: () => , }, { title: "FAQ", href: "/docs/reference/faq", - icon: () => , + icon: () => , }, ], }, diff --git a/apps/web/src/components/theme-switcher.tsx b/apps/web/src/components/theme-switcher.tsx index 46393d10..cd2cf53a 100644 --- a/apps/web/src/components/theme-switcher.tsx +++ b/apps/web/src/components/theme-switcher.tsx @@ -1,6 +1,6 @@ "use client"; -import { Moon, Sun } from "lucide-react"; +import { RiMoonLine, RiSunLine } from "react-icons/ri"; import { Button } from "@/components/ui/button"; import { useThemeTransition } from "@/components/use-theme-transition"; @@ -19,9 +19,9 @@ export function ThemeSwitcher() { suppressHydrationWarning > {buttonTheme === "dark" ? ( - + ) : ( - + )} {toggleLabel} diff --git a/apps/web/src/components/ui/accordion.tsx b/apps/web/src/components/ui/accordion.tsx index 075a115c..ae55d76a 100644 --- a/apps/web/src/components/ui/accordion.tsx +++ b/apps/web/src/components/ui/accordion.tsx @@ -1,7 +1,7 @@ "use client"; import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion"; -import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"; +import { RiArrowDownSLine, RiArrowUpSLine } from "react-icons/ri"; import { cn } from "@/lib/utils"; @@ -37,11 +37,11 @@ function AccordionTrigger({ className, children, ...props }: AccordionPrimitive. {...props} > {children} - - diff --git a/apps/web/src/components/ui/breadcrumb.tsx b/apps/web/src/components/ui/breadcrumb.tsx index b6381016..1f4e60b9 100644 --- a/apps/web/src/components/ui/breadcrumb.tsx +++ b/apps/web/src/components/ui/breadcrumb.tsx @@ -1,7 +1,7 @@ import { mergeProps } from "@base-ui/react/merge-props"; import { useRender } from "@base-ui/react/use-render"; -import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"; import * as React from "react"; +import { RiArrowRightSLine, RiMoreLine } from "react-icons/ri"; import { cn } from "@/lib/utils"; @@ -72,7 +72,7 @@ function BreadcrumbSeparator({ children, className, ...props }: React.ComponentP className={cn("[&>svg]:size-3.5", className)} {...props} > - {children ?? } + {children ?? } ); } @@ -86,7 +86,7 @@ function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<"span" className={cn("flex size-5 items-center justify-center [&>svg]:size-4", className)} {...props} > - + More ); diff --git a/apps/web/src/components/ui/calendar.tsx b/apps/web/src/components/ui/calendar.tsx index 47d9a1e9..a58f2233 100644 --- a/apps/web/src/components/ui/calendar.tsx +++ b/apps/web/src/components/ui/calendar.tsx @@ -1,8 +1,8 @@ "use client"; -import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react"; import * as React from "react"; import { DayPicker, getDefaultClassNames, type DayButton, type Locale } from "react-day-picker"; +import { RiArrowDownSLine, RiArrowLeftSLine, RiArrowRightSLine } from "react-icons/ri"; import { Button, buttonVariants } from "@/components/ui/button"; import { cn } from "@/lib/utils"; @@ -118,14 +118,14 @@ function Calendar({ }, Chevron: ({ className, orientation, ...props }) => { if (orientation === "left") { - return ; + return ; } if (orientation === "right") { - return ; + return ; } - return ; + return ; }, DayButton: ({ ...props }) => , WeekNumber: ({ children, ...props }) => { diff --git a/apps/web/src/components/ui/carousel.tsx b/apps/web/src/components/ui/carousel.tsx index 73e2e927..6af48cfe 100644 --- a/apps/web/src/components/ui/carousel.tsx +++ b/apps/web/src/components/ui/carousel.tsx @@ -1,8 +1,8 @@ "use client"; import useEmblaCarousel, { type UseEmblaCarouselType } from "embla-carousel-react"; -import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; import * as React from "react"; +import { RiArrowLeftSLine, RiArrowRightSLine } from "react-icons/ri"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; @@ -184,7 +184,7 @@ function CarouselPrevious({ onClick={scrollPrev} {...props} > - + Previous slide ); @@ -214,7 +214,7 @@ function CarouselNext({ onClick={scrollNext} {...props} > - + Next slide ); diff --git a/apps/web/src/components/ui/checkbox.tsx b/apps/web/src/components/ui/checkbox.tsx index 8180b82a..cac7705e 100644 --- a/apps/web/src/components/ui/checkbox.tsx +++ b/apps/web/src/components/ui/checkbox.tsx @@ -1,7 +1,7 @@ "use client"; import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"; -import { CheckIcon } from "lucide-react"; +import { RiCheckLine } from "react-icons/ri"; import { cn } from "@/lib/utils"; @@ -19,7 +19,7 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) { data-slot="checkbox-indicator" className="grid place-content-center text-current transition-none [&>svg]:size-3.5" > - + ); diff --git a/apps/web/src/components/ui/code-block.tsx b/apps/web/src/components/ui/code-block.tsx index 575a9b43..6dae67be 100644 --- a/apps/web/src/components/ui/code-block.tsx +++ b/apps/web/src/components/ui/code-block.tsx @@ -1,5 +1,5 @@ "use client"; -import { Check, Copy } from "lucide-react"; + import type { ButtonHTMLAttributes, ComponentProps, @@ -9,6 +9,7 @@ import type { RefObject, } from "react"; import { createContext, forwardRef, useCallback, useContext, useMemo, useRef } from "react"; +import { RiCheckLine, RiFileCopyLine } from "react-icons/ri"; import { buttonVariants } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -195,8 +196,10 @@ function CopyButton({ onClick={onClick} {...props} > - - + + ); } diff --git a/apps/web/src/components/ui/combobox.tsx b/apps/web/src/components/ui/combobox.tsx index 6addd25d..dc5e635c 100644 --- a/apps/web/src/components/ui/combobox.tsx +++ b/apps/web/src/components/ui/combobox.tsx @@ -1,8 +1,8 @@ "use client"; import { Combobox as ComboboxPrimitive } from "@base-ui/react"; -import { ChevronDownIcon, XIcon, CheckIcon } from "lucide-react"; import * as React from "react"; +import { RiArrowDownSLine, RiCheckLine, RiCloseLine } from "react-icons/ri"; import { Button } from "@/components/ui/button"; import { @@ -27,7 +27,7 @@ function ComboboxTrigger({ className, children, ...props }: ComboboxPrimitive.Tr {...props} > {children} - + ); } @@ -40,7 +40,7 @@ function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) { className={cn(className)} {...props} > - + ); } @@ -143,7 +143,7 @@ function ComboboxItem({ className, children, ...props }: ComboboxPrimitive.Item. } > - + ); @@ -232,7 +232,7 @@ function ComboboxChip({ className="-ml-1 opacity-50 hover:opacity-100" data-slot="combobox-chip-remove" > - + )} diff --git a/apps/web/src/components/ui/command.tsx b/apps/web/src/components/ui/command.tsx index 78e76940..04c49fbc 100644 --- a/apps/web/src/components/ui/command.tsx +++ b/apps/web/src/components/ui/command.tsx @@ -1,8 +1,8 @@ "use client"; import { Command as CommandPrimitive } from "cmdk"; -import { SearchIcon, CheckIcon } from "lucide-react"; import * as React from "react"; +import { RiCheckLine, RiSearchLine } from "react-icons/ri"; import { Dialog, @@ -73,7 +73,7 @@ function CommandInput({ {...props} /> - +
@@ -150,7 +150,7 @@ function CommandItem({ {...props} > {children} - + ); } diff --git a/apps/web/src/components/ui/context-menu.tsx b/apps/web/src/components/ui/context-menu.tsx index d6b56246..2ce2d233 100644 --- a/apps/web/src/components/ui/context-menu.tsx +++ b/apps/web/src/components/ui/context-menu.tsx @@ -1,8 +1,8 @@ "use client"; import { ContextMenu as ContextMenuPrimitive } from "@base-ui/react/context-menu"; -import { ChevronRightIcon, CheckIcon } from "lucide-react"; import * as React from "react"; +import { RiArrowRightSLine, RiCheckLine } from "react-icons/ri"; import { cn } from "@/lib/utils"; @@ -135,7 +135,7 @@ function ContextMenuSubTrigger({ {...props} > {children} - + ); } @@ -173,7 +173,7 @@ function ContextMenuCheckboxItem({ > - + {children} @@ -205,7 +205,7 @@ function ContextMenuRadioItem({ > - + {children} diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx index 3b29b13d..41745c24 100644 --- a/apps/web/src/components/ui/dialog.tsx +++ b/apps/web/src/components/ui/dialog.tsx @@ -1,8 +1,8 @@ "use client"; import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"; -import { XIcon } from "lucide-react"; import * as React from "react"; +import { RiCloseLine } from "react-icons/ri"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; @@ -61,7 +61,7 @@ function DialogContent({ data-slot="dialog-close" render={
); } diff --git a/apps/web/src/components/ui/menubar.tsx b/apps/web/src/components/ui/menubar.tsx index bedfd43f..a63ce43f 100644 --- a/apps/web/src/components/ui/menubar.tsx +++ b/apps/web/src/components/ui/menubar.tsx @@ -2,8 +2,8 @@ import { Menu as MenuPrimitive } from "@base-ui/react/menu"; import { Menubar as MenubarPrimitive } from "@base-ui/react/menubar"; -import { CheckIcon } from "lucide-react"; import * as React from "react"; +import { RiCheckLine } from "react-icons/ri"; import { DropdownMenu, @@ -121,7 +121,7 @@ function MenubarCheckboxItem({ > - + {children} @@ -153,7 +153,7 @@ function MenubarRadioItem({ > - + {children} diff --git a/apps/web/src/components/ui/native-select.tsx b/apps/web/src/components/ui/native-select.tsx index cc077ccf..9cb9ab56 100644 --- a/apps/web/src/components/ui/native-select.tsx +++ b/apps/web/src/components/ui/native-select.tsx @@ -1,5 +1,5 @@ -import { ChevronDownIcon } from "lucide-react"; import * as React from "react"; +import { RiArrowDownSLine } from "react-icons/ri"; import { cn } from "@/lib/utils"; @@ -23,7 +23,7 @@ function NativeSelect({ className, size = "default", ...props }: NativeSelectPro className="h-8 w-full min-w-0 appearance-none rounded-lg border border-input bg-transparent py-1 pr-8 pl-2.5 text-sm transition-colors outline-none select-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-[size=sm]:py-0.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40" {...props} /> -