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 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..ce624a05 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 @@ -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/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..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: , @@ -80,7 +78,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 = { @@ -98,100 +96,6 @@ const providerPageIcons = { /> ), - paypal: ( - - - - - - - - ), - polar: ( - - - - - - - ), - lemonsqueezy: ( - - - - ), - paddle: ( - - - - ), - creem: , } as const; function normalizeCategoryName(name: string): string { 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..649858b2 100644 --- a/packages/paykit/src/cli/commands/status.ts +++ b/packages/paykit/src/cli/commands/status.ts @@ -4,8 +4,8 @@ import * as p from "@clack/prompts"; import { Command } from "commander"; import picocolors from "picocolors"; +import { assertValidPayKitOptions } from "../../core/validate-options"; import { - checkActiveSubscriptionsOnOtherProvider, checkDatabase, checkProvider, checkProviderCustomers, @@ -38,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); @@ -46,15 +47,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); @@ -63,128 +64,135 @@ async function statusAction(options: { // Database + Provider in parallel const database = createPool(deps, config.options.database); const connStr = deps.getConnectionString(database as never); + let databaseClosed = false; + const closeDatabase = async () => { + if (databaseClosed) return; + databaseClosed = true; + await database.end(); + }; - const [dbResult, providerResult] = await Promise.all([ - checkDatabase(database, deps), - checkProvider(config.options.provider), - ]); + 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 database.end(); - process.exit(1); - } + 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); + } - if (!providerResult.account.ok) { - s.stop(""); - p.log.error(`Provider\n ${picocolors.red("✖")} ${providerResult.account.message}`); - p.outro("Fix provider issues before continuing"); - 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 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)"); - } + 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 providerId = config.options.provider.id; - const [subscriptionErrors, customerErrors] = await Promise.all([ - checkActiveSubscriptionsOnOtherProvider(ctx, providerId), - checkProviderCustomers(ctx, providerResult.customerSample), - ]); - preflightErrors = [...preflightErrors, ...subscriptionErrors, ...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("✔")} Provider 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( - `Provider\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/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 94% rename from packages/stripe/src/stripe-provider.ts rename to packages/paykit/src/stripe/stripe-provider.ts index 0375f5c2..1e2b54e6 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 & { @@ -87,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; @@ -179,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); } @@ -565,13 +582,15 @@ 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 { id: "stripe", name: "Stripe", - capabilities: { testClocks: true }, async createCustomer(data) { let testClock: ProviderTestClock | undefined; @@ -953,17 +972,22 @@ 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 + ? 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 [ ...(await createCheckoutCompletedEvents(client, event)), ...(await createSubscriptionEvents(event)), @@ -993,7 +1017,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 +1077,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 +1093,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); } 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"));