();
+
+ useEffect(() => {
+ setMarkdown(undefined);
+ }, [markdownUrl]);
const onClick = useCallback(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
- void fetch(markdownUrl)
- .then((res) => {
- if (!res.ok) throw new Error("fetch failed");
- return res.text();
+ void Promise.resolve(markdown)
+ .then((cached) => {
+ if (cached !== undefined) return cached;
+
+ return fetch(markdownUrl).then((res) => {
+ if (!res.ok) throw new Error("fetch failed");
+ return res.text();
+ });
+ })
+ .then((text) => {
+ setMarkdown(text);
+ return navigator.clipboard.writeText(text);
})
- .then((text) => navigator.clipboard.writeText(text))
.catch(() => {
toast.error("Failed to copy markdown");
setCopied(false);
});
- }, [markdownUrl]);
+ }, [markdown, markdownUrl]);
return (
-
- {copied ? : }
- {copied ? "Copied" : "Copy Markdown"}
-
+
+
+ {copied ? : }
+ {copied ? "Copied" : "Copy page"}
+
+
+
+ }
+ >
+
+
+
+ }>
+
+ View as markdown
+
+ }>
+
+ View llms.txt
+
+ }
+ >
+
+ View llms-full.txt
+
+
+
+
);
}
diff --git a/apps/web/src/components/docs/docs-code-surface.tsx b/apps/web/src/components/docs/docs-code-surface.tsx
new file mode 100644
index 00000000..8e7e4826
--- /dev/null
+++ b/apps/web/src/components/docs/docs-code-surface.tsx
@@ -0,0 +1,23 @@
+import type { ComponentProps } from "react";
+
+import { cn } from "@/lib/utils";
+
+/**
+ * Renders the styled `` wrapper used by docs code blocks.
+ *
+ * @param props - Standard pre props, including `className` and `tabIndex`.
+ * Remaining props are spread onto the `` element. Defaults `tabIndex` to
+ * `0` when undefined so code blocks are keyboard focusable.
+ */
+export function DocsCodeSurface({ className, tabIndex, ...props }: ComponentProps<"pre">) {
+ return (
+ code]:flex [&>code]:w-max [&>code]:min-w-full [&>code]:flex-col [&>code]:px-0! [&_.line]:px-3",
+ )}
+ />
+ );
+}
diff --git a/apps/web/src/components/docs/docs-icons.tsx b/apps/web/src/components/docs/docs-icons.tsx
index 56ec33b8..189da22e 100644
--- a/apps/web/src/components/docs/docs-icons.tsx
+++ b/apps/web/src/components/docs/docs-icons.tsx
@@ -1,86 +1,83 @@
-import {
- Blocks,
- BookMarked,
- BookOpen,
- Bot,
- ChevronDown,
- Code2,
- Coins,
- Compass,
- CreditCard,
- Database,
- Download,
- Gauge,
- GitCompareArrows,
- LayoutDashboard,
- Monitor,
- Package,
- ReceiptText,
- Repeat,
- Route,
- Rocket,
- Server,
- Shield,
- ShoppingCart,
- Terminal,
- Users,
- WalletCards,
- Webhook,
- BookText,
-} from "lucide-react";
import type { ReactElement } from "react";
-
-import { CreemIcon } from "@/components/icons/creem";
+import {
+ RiArrowDownSLine,
+ RiBankCardLine,
+ RiBookMarkedLine,
+ RiBookOpenLine,
+ RiBookReadLine,
+ RiBox3Line,
+ RiCodeSSlashLine,
+ RiCoinsLine,
+ RiCompassLine,
+ RiComputerLine,
+ RiDashboardLine,
+ RiDatabase2Line,
+ RiDownloadLine,
+ RiGitForkLine,
+ RiGroupLine,
+ RiPuzzle2Line,
+ RiReceiptLine,
+ RiRepeatLine,
+ RiRobot2Line,
+ RiRocketLine,
+ RiRouteLine,
+ RiServerLine,
+ RiShieldLine,
+ RiShoppingCartLine,
+ RiSpeedUpLine,
+ RiTerminalBoxLine,
+ RiWalletLine,
+ RiWebhookLine,
+} from "react-icons/ri";
const categoryIcons = {
- "get started": ,
- concepts: ,
- flows: ,
- providers: ,
- databases: ,
- integrations: ,
- plugins: ,
- guides: ,
+ "get started": ,
+ concepts: ,
+ flows: ,
+ providers: ,
+ databases: ,
+ integrations: ,
+ plugins: ,
+ guides: ,
} as const;
const pageIcons = {
- introduction: ,
- comparison: ,
- installation: ,
- quickstart: ,
- "server api": ,
- "react client": ,
- "webhook events": ,
- "basic usage": ,
- usage: ,
- database: ,
- typescript: ,
- "payment providers": ,
- checkout: ,
- "payment methods": ,
- charges: ,
- postgres: ,
- sqlite: ,
- "drizzle adapter": ,
- "prisma adapter": ,
- nextjs: ,
- "next js": ,
+ introduction: ,
+ comparison: ,
+ installation: ,
+ quickstart: ,
+ "server api": ,
+ "react client": ,
+ "webhook events": ,
+ "basic usage": ,
+ usage: ,
+ database: ,
+ typescript: ,
+ checkout: ,
+ "payment methods": ,
+ charges: ,
+ postgres: ,
+ sqlite: ,
+ "drizzle adapter": ,
+ "prisma adapter": ,
+ nextjs: ,
+ "next js": ,
- "create a payment provider": ,
- "plans & features": ,
- customers: ,
- subscriptions: ,
- entitlements: ,
- plugins: ,
- client: ,
- cli: ,
- "subscription billing": ,
- "metered usage": ,
- dashboard: ,
- skills: ,
+ "create a payment provider": ,
+ "plans & features": ,
+ customers: ,
+ subscriptions: ,
+ entitlements: ,
+ plugins: ,
+ client: ,
+ cli: ,
+ "subscription billing": ,
+ "metered usage": ,
+ dashboard: ,
+ 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 +95,6 @@ const providerPageIcons = {
/>
),
- paypal: (
-
-
-
-
-
-
-
- ),
- polar: (
-
-
-
-
-
-
- ),
- lemonsqueezy: (
-
-
-
- ),
- paddle: (
-
-
-
- ),
- creem: ,
} as const;
function normalizeCategoryName(name: string): string {
@@ -238,7 +141,7 @@ export function CategoryFolderIcon({ icon }: { icon?: ReactElement }) {
return (
{icon}
-
+
);
}
diff --git a/apps/web/src/components/docs/docs-layout.tsx b/apps/web/src/components/docs/docs-layout.tsx
new file mode 100644
index 00000000..f768fea2
--- /dev/null
+++ b/apps/web/src/components/docs/docs-layout.tsx
@@ -0,0 +1,491 @@
+"use client";
+
+import { useHotkey } from "@tanstack/react-hotkeys";
+import type { Root } from "fumadocs-core/page-tree";
+import type * as PageTree from "fumadocs-core/page-tree";
+import { useSearchContext } from "fumadocs-ui/contexts/search";
+import { TreeContextProvider } from "fumadocs-ui/contexts/tree";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import type { CSSProperties } from "react";
+import type { ReactNode } from "react";
+import { useEffect } from "react";
+import { useState } from "react";
+import { RiExternalLinkLine, RiRobot2Line, RiSearchLine, RiSideBarLine } from "react-icons/ri";
+
+import { getDocsPageIcon } from "@/components/docs/docs-icons";
+import { ThemeSwitcher } from "@/components/theme-switcher";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet";
+import { BrandMenu } from "@/components/web/brand-menu";
+import { cn } from "@/lib/utils";
+
+type DocsLayoutStyle = CSSProperties & {
+ "--fd-layout-width": string;
+ "--fd-sidebar-col": string;
+};
+
+function SearchButton({ className }: { className?: string }) {
+ const { setOpenSearch } = useSearchContext();
+
+ return (
+ setOpenSearch(true)}
+ >
+
+ Search
+
+
+ ⌘
+
+
+ K
+
+
+
+ );
+}
+
+function SidebarContent({
+ onItemClick,
+ pathname,
+ tree,
+}: {
+ onItemClick?: () => void;
+ pathname: string;
+ tree: Root;
+}) {
+ const sections = getSidebarSections(tree.children);
+
+ return (
+
+ {sections.map((section, index) => {
+ return (
+
+ {section.separator ?
: null}
+
+ {section.children.map((node, childIndex) => (
+
+ ))}
+
+
+ );
+ })}
+
+ );
+}
+
+function LlmsDropdown() {
+ return (
+
+
+ }
+ >
+
+
+
+ }>
+
+ llms.txt
+
+ }>
+
+ llms-full.txt
+
+
+
+ );
+}
+
+function DocsSidebar({
+ onCollapse,
+ open,
+ tree,
+}: {
+ onCollapse: () => void;
+ open: boolean;
+ tree: Root;
+}) {
+ const pathname = usePathname();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function SidebarIsland({
+ onOpen,
+ onSearch,
+ visible,
+}: {
+ onOpen: () => void;
+ onSearch: () => void;
+ visible: boolean;
+}) {
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
+function MobileSidebar({
+ onOpenChange,
+ open,
+ tree,
+}: {
+ onOpenChange: (open: boolean) => void;
+ open: boolean;
+ tree: Root;
+}) {
+ const pathname = usePathname();
+
+ return (
+
+
+
+ Documentation navigation
+ Browse docs pages and sections.
+
+
+
+
+
+
onOpenChange(false)} pathname={pathname} tree={tree} />
+
+
+
+ );
+}
+
+interface SidebarSection {
+ key?: string;
+ separator?: PageTree.Separator;
+ children: PageTree.Node[];
+}
+
+function getSidebarSections(nodes: PageTree.Node[]): SidebarSection[] {
+ const sections: SidebarSection[] = [];
+ let current: SidebarSection | undefined;
+
+ for (const [index, node] of nodes.entries()) {
+ const next = nodes[index + 1];
+
+ if (node.type === "separator") {
+ if (isDuplicateFolderSeparator(node, next)) {
+ current = undefined;
+ continue;
+ }
+
+ current = {
+ key: node.$id,
+ separator: node,
+ children: [],
+ };
+ sections.push(current);
+ continue;
+ }
+
+ if (!current) {
+ current = {
+ key: node.$id,
+ children: [],
+ };
+ sections.push(current);
+ }
+
+ current.children.push(node);
+ }
+
+ return sections.filter((section) => section.separator || section.children.length > 0);
+}
+
+function isDuplicateFolderSeparator(
+ separator: PageTree.Separator,
+ next: PageTree.Node | undefined,
+): boolean {
+ return (
+ next?.type === "folder" &&
+ String(separator.name).toLowerCase() === String(next.name).toLowerCase()
+ );
+}
+
+function SidebarSeparator({ separator }: { separator: PageTree.Separator }) {
+ return (
+
+ {separator.name}
+
+ );
+}
+
+function SidebarNode({
+ node,
+ onItemClick,
+ pathname,
+}: {
+ node: PageTree.Node;
+ onItemClick?: () => void;
+ pathname: string;
+}) {
+ if (node.type === "separator") {
+ return ;
+ }
+
+ if (node.type === "folder") {
+ return ;
+ }
+
+ return ;
+}
+
+function SidebarFolder({
+ folder,
+ onItemClick,
+ pathname,
+}: {
+ folder: PageTree.Folder;
+ onItemClick?: () => void;
+ pathname: string;
+}) {
+ return (
+
+
+ {folder.name}
+
+
+ {folder.index ? (
+
+ ) : null}
+ {folder.children.map((node, index) => (
+
+ ))}
+
+
+ );
+}
+
+function SidebarItem({
+ item,
+ onItemClick,
+ pathname,
+}: {
+ item: PageTree.Item;
+ onItemClick?: () => void;
+ pathname: string;
+}) {
+ const active = pathname === item.url;
+
+ return (
+
+ {getDocsPageIcon(String(item.name))}
+ {item.name}
+
+ );
+}
+
+export function DocsLayout({ children, tree }: { children: ReactNode; tree: Root }) {
+ const [sidebarOpen, setSidebarOpen] = useState(true);
+ const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
+ const [previousSidebarOpen, setPreviousSidebarOpen] = useState(sidebarOpen);
+ const { setOpenSearch } = useSearchContext();
+ const isColumnChanged = previousSidebarOpen !== sidebarOpen;
+
+ useHotkey(
+ "Mod+B",
+ () => {
+ setSidebarOpen((open) => !open);
+ },
+ {
+ ignoreInputs: true,
+ preventDefault: true,
+ },
+ );
+
+ useEffect(() => {
+ if (isColumnChanged) setPreviousSidebarOpen(sidebarOpen);
+ }, [isColumnChanged, sidebarOpen]);
+
+ const layoutStyle = {
+ "--fd-layout-width": "90rem",
+ "--fd-sidebar-col": sidebarOpen ? "var(--fd-sidebar-width)" : "0px",
+ gridTemplateAreas: `
+ "sidebar sidebar header toc toc"
+ "sidebar sidebar toc-popover toc toc"
+ "sidebar sidebar main toc toc"
+ `,
+ gridTemplateRows: "var(--fd-header-height) var(--fd-toc-popover-height) 1fr",
+ gridTemplateColumns:
+ "minmax(0, 1fr) var(--fd-sidebar-col) minmax(0, calc(var(--fd-layout-width) - var(--fd-sidebar-width) - var(--fd-toc-width))) var(--fd-toc-width) minmax(0, 1fr)",
+ } satisfies DocsLayoutStyle;
+
+ return (
+
+
+
setSidebarOpen(false)} open={sidebarOpen} tree={tree} />
+ setSidebarOpen(true)}
+ onSearch={() => setOpenSearch(true)}
+ visible={!sidebarOpen}
+ />
+
+
+ {children}
+
+
+ );
+}
diff --git a/apps/web/src/components/docs/docs-mdx-components.tsx b/apps/web/src/components/docs/docs-mdx-components.tsx
new file mode 100644
index 00000000..64831709
--- /dev/null
+++ b/apps/web/src/components/docs/docs-mdx-components.tsx
@@ -0,0 +1,312 @@
+import type { MDXComponents } from "mdx/types";
+import Link from "next/link";
+import type { ComponentPropsWithoutRef, ReactNode } from "react";
+import {
+ RiCheckboxCircleFill,
+ RiCloseCircleFill,
+ RiErrorWarningFill,
+ RiInformationFill,
+ RiLinkM,
+} from "react-icons/ri";
+
+import { Features } from "@/components/docs/features";
+import { MdxTab, MdxTabs, MdxTabsList, MdxTabsPanel, MdxTabsTab } from "@/components/docs/mdx-tabs";
+import {
+ Anchor,
+ InlineCode,
+ MDXLink,
+ Step,
+ StepContent,
+ StepDescription,
+ Steps,
+ StepTitle,
+} from "@/components/docs/mdx-text";
+import { PackageCommandPre } from "@/components/docs/package-command-pre";
+import { extractText } from "@/components/docs/react-node-text";
+import { cn } from "@/lib/utils";
+
+type DocsCalloutType = "info" | "warn" | "error" | "success";
+
+const calloutStyles = {
+ info: {
+ icon: RiInformationFill,
+ tone: "text-blue-500",
+ },
+ warn: {
+ icon: RiErrorWarningFill,
+ tone: "text-amber-500",
+ },
+ error: {
+ icon: RiCloseCircleFill,
+ tone: "text-red-500",
+ },
+ success: {
+ icon: RiCheckboxCircleFill,
+ tone: "text-emerald-500",
+ },
+} satisfies Record;
+
+function Paragraph({ className, ...props }: ComponentPropsWithoutRef<"p">) {
+ return
;
+}
+
+function Strong({ className, ...props }: ComponentPropsWithoutRef<"strong">) {
+ return ;
+}
+
+function getHeadingId(children: ComponentPropsWithoutRef<"h2">["children"]) {
+ const text = extractText(children);
+
+ return (
+ text
+ .toLowerCase()
+ .replace(/[^a-z0-9\s-]/g, "")
+ .trim()
+ .replace(/\s+/g, "-") || "heading"
+ );
+}
+
+function Heading1({ className, children, ...props }: ComponentPropsWithoutRef<"h1">) {
+ return (
+
+ {children}
+
+ );
+}
+
+function Heading2({ className, ...props }: ComponentPropsWithoutRef<"h2">) {
+ // Preserve Fumadocs-generated IDs so TOC active tracking stays in sync.
+ const headingId = typeof props.id === "string" ? props.id : getHeadingId(props.children);
+
+ return (
+
+
+
+
+ );
+}
+
+function Heading3({ className, ...props }: ComponentPropsWithoutRef<"h3">) {
+ return (
+
+ );
+}
+
+function Heading4({ className, ...props }: ComponentPropsWithoutRef<"h4">) {
+ return (
+
+ );
+}
+
+function Heading5({ className, ...props }: ComponentPropsWithoutRef<"h5">) {
+ return (
+
+ );
+}
+
+function Heading6({ className, ...props }: ComponentPropsWithoutRef<"h6">) {
+ return (
+
+ );
+}
+
+function UnorderedList({ className, ...props }: ComponentPropsWithoutRef<"ul">) {
+ return ;
+}
+
+function OrderedList({ className, ...props }: ComponentPropsWithoutRef<"ol">) {
+ return ;
+}
+
+function ListItem({ className, ...props }: ComponentPropsWithoutRef<"li">) {
+ return ;
+}
+
+function Blockquote({ className, ...props }: ComponentPropsWithoutRef<"blockquote">) {
+ return ;
+}
+
+function HorizontalRule({ className, ...props }: ComponentPropsWithoutRef<"hr">) {
+ return ;
+}
+
+function Table({ className, ...props }: ComponentPropsWithoutRef<"table">) {
+ return (
+
+ );
+}
+
+function TableRow({ className, ...props }: ComponentPropsWithoutRef<"tr">) {
+ return ;
+}
+
+function TableHead({ className, ...props }: ComponentPropsWithoutRef<"th">) {
+ return (
+
+ );
+}
+
+function TableCell({ className, ...props }: ComponentPropsWithoutRef<"td">) {
+ return (
+
+ );
+}
+
+function Code({ children, ...props }: ComponentPropsWithoutRef<"code">) {
+ if (typeof children === "string") {
+ return {children} ;
+ }
+
+ return {children};
+}
+
+function Callout({
+ className,
+ type = "info",
+ children,
+ ...props
+}: ComponentPropsWithoutRef<"div"> & { type?: DocsCalloutType }) {
+ const { icon: Icon, tone } = calloutStyles[type];
+
+ return (
+
+ );
+}
+
+function Cards({ className, ...props }: ComponentPropsWithoutRef<"div">) {
+ return
;
+}
+
+function Card({
+ className,
+ href,
+ children,
+ ...props
+}: ComponentPropsWithoutRef<"div"> & { href?: string; children?: ReactNode }) {
+ const content = (
+
+ {children}
+
+ );
+
+ if (!href) return content;
+
+ return (
+
+ {content}
+
+ );
+}
+
+/** MDX component overrides used by documentation pages. Maps standard MDX
+ * elements to styled React components and exposes docs-only components such as
+ * Callout, Card, Tabs, Steps, and Features. Public API for MDX rendering.
+ */
+export const docsMdxComponents = {
+ pre: PackageCommandPre,
+ h1: Heading1,
+ h2: Heading2,
+ h3: Heading3,
+ h4: Heading4,
+ h5: Heading5,
+ h6: Heading6,
+ p: Paragraph,
+ strong: Strong,
+ a: Anchor,
+ code: Code,
+ ul: UnorderedList,
+ ol: OrderedList,
+ li: ListItem,
+ blockquote: Blockquote,
+ hr: HorizontalRule,
+ table: Table,
+ tr: TableRow,
+ th: TableHead,
+ td: TableCell,
+ Callout,
+ Card,
+ Cards,
+ Link: MDXLink,
+ Step,
+ StepContent,
+ StepDescription,
+ Steps,
+ StepTitle,
+ Tab: MdxTab,
+ Tabs: MdxTabs,
+ TabsList: MdxTabsList,
+ TabsPanel: MdxTabsPanel,
+ TabsTab: MdxTabsTab,
+ Features,
+} satisfies MDXComponents;
diff --git a/apps/web/src/components/docs/docs-page.tsx b/apps/web/src/components/docs/docs-page.tsx
new file mode 100644
index 00000000..e9d5256a
--- /dev/null
+++ b/apps/web/src/components/docs/docs-page.tsx
@@ -0,0 +1,224 @@
+"use client";
+
+import { getBreadcrumbItemsFromPath, type BreadcrumbOptions } from "fumadocs-core/breadcrumb";
+import { usePathname } from "fumadocs-core/framework";
+import Link from "fumadocs-core/link";
+import type * as PageTree from "fumadocs-core/page-tree";
+import { useTreeContext, useTreePath } from "fumadocs-ui/contexts/tree";
+import { TOC, TOCProvider, type TOCProviderProps } from "fumadocs-ui/layouts/docs/page/slots/toc";
+import type { ComponentProps, ReactNode } from "react";
+import { Fragment, useMemo } from "react";
+import { RiArrowLeftSLine, RiArrowRightSLine } from "react-icons/ri";
+
+import { cn } from "@/lib/utils";
+
+const tocWidthClassName = "xl:layout:[--fd-toc-width:250px]";
+
+export function DocsPage({
+ children,
+ className,
+ breadcrumb,
+ footer = true,
+ full = false,
+ toc = [],
+ tocFooter,
+}: ComponentProps<"article"> & {
+ breadcrumb?: BreadcrumbOptions & { enabled?: boolean };
+ footer?: boolean;
+ full?: boolean;
+ toc?: TOCProviderProps["toc"];
+ tocFooter?: ReactNode;
+}) {
+ const hasToc = toc.length > 0 || tocFooter !== undefined;
+
+ return (
+
+ {hasToc && }
+
+ {breadcrumb?.enabled !== false && }
+ {children}
+ {footer && }
+
+ {hasToc && }
+
+ );
+}
+
+export function DocsTitle({ children, className, ...props }: ComponentProps<"h1">) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function DocsDescription({ children, className, ...props }: ComponentProps<"p">) {
+ if (children === undefined) return null;
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function DocsBody({ children, className, ...props }: ComponentProps<"div">) {
+ return (
+
+ {children}
+
+ );
+}
+
+function DocsToc({ footer }: { footer?: ReactNode }) {
+ return ;
+}
+
+function DocsTocLayoutMarker() {
+ return
;
+}
+
+function DocsBreadcrumb({
+ includePage,
+ includeRoot,
+ includeSeparator,
+ className,
+ ...props
+}: BreadcrumbOptions & ComponentProps<"div">) {
+ const path = useTreePath();
+ const { root } = useTreeContext();
+ const items = useMemo(
+ () =>
+ getBreadcrumbItemsFromPath(root, path, {
+ includePage,
+ includeRoot,
+ includeSeparator,
+ }),
+ [includePage, includeRoot, includeSeparator, path, root],
+ );
+
+ if (items.length === 0) return null;
+
+ return (
+
+ {items.map((item, index) => {
+ const itemClassName = cn(
+ "truncate",
+ index === items.length - 1 && "font-medium text-primary",
+ );
+
+ return (
+
+ {index !== 0 && }
+ {item.url ? (
+
+ {item.name}
+
+ ) : (
+ {item.name}
+ )}
+
+ );
+ })}
+
+ );
+}
+
+function DocsFooter({ className, ...props }: ComponentProps<"div">) {
+ const pathname = usePathname();
+ const { root } = useTreeContext();
+ const footerList = useMemo(() => flattenFooterItems(root.children), [root.children]);
+ const index = footerList.findIndex((item) => item.url === pathname);
+ const previous = index > 0 ? footerList[index - 1] : undefined;
+ const next = index >= 0 ? footerList[index + 1] : undefined;
+
+ if (!previous && !next) return null;
+
+ return (
+
+ {previous && }
+ {next && }
+
+ );
+}
+
+function DocsFooterItem({ item, index }: { item: FooterItem; index: 0 | 1 }) {
+ const Icon = index === 0 ? RiArrowLeftSLine : RiArrowRightSLine;
+
+ return (
+
+
+
+ {item.description ?? (index === 0 ? "Previous page" : "Next page")}
+
+
+ );
+}
+
+type FooterItem = Pick;
+
+function flattenFooterItems(nodes: PageTree.Node[]): FooterItem[] {
+ const items: FooterItem[] = [];
+
+ for (const node of nodes) {
+ if (node.type === "page" && node.url !== "#") {
+ items.push({
+ description: node.description,
+ name: node.name,
+ url: node.url,
+ });
+ continue;
+ }
+
+ if (node.type === "folder") {
+ items.push(...flattenFooterItems(node.children));
+ }
+ }
+
+ return items;
+}
diff --git a/apps/web/src/components/docs/features.tsx b/apps/web/src/components/docs/features.tsx
index d34a9e44..21e4ab10 100644
--- a/apps/web/src/components/docs/features.tsx
+++ b/apps/web/src/components/docs/features.tsx
@@ -1,61 +1,63 @@
"use client";
-import {
- Blocks,
- Cable,
- Database,
- Gauge,
- Monitor,
- Package,
- ShieldCheck,
- Terminal,
- Webhook,
-} from "lucide-react";
import type { ReactNode } from "react";
+import {
+ RiBox3Line,
+ RiComputerLine,
+ RiDatabase2Line,
+ RiPlug2Line,
+ RiPuzzle2Line,
+ RiShieldCheckLine,
+ RiSpeedUpLine,
+ RiTerminalBoxLine,
+ RiWebhookLine,
+} from "react-icons/ri";
+
+import { FrameCorners } from "@/components/ui/frame-corners";
const features: { icon: ReactNode; title: string; description: string }[] = [
{
- icon: ,
+ icon: ,
title: "Products in Code",
description: "Define plans and features as typed primitives.",
},
{
- icon: ,
+ icon: ,
title: "Webhooks Handled",
description: "Verified, deduplicated, synced to your database automatically.",
},
{
- icon: ,
+ icon: ,
title: "Usage Billing",
description: "Metered features with check() and report().",
},
{
- icon: ,
- title: "Any Provider",
- description: "Stripe, Polar, Creem, or your own. Swap with one import.",
+ icon: ,
+ title: "Built For Stripe",
+ description: "Stripe subscriptions, webhooks, portal, and product sync built in.",
},
{
- icon: ,
+ icon: ,
title: "Plugin Ecosystem",
description: "Dashboard, analytics, or build your own plugin.",
},
{
- icon: ,
+ icon: ,
title: "Local Billing State",
description: "Billing state in your Postgres, joinable with your tables.",
},
{
- icon: ,
+ icon: ,
title: "CLI",
description: "Init, push, and status. Scaffold, migrate, validate.",
},
{
- icon: ,
+ icon: ,
title: "Client SDK",
description: "Browser-side billing calls with full type inference.",
},
{
- icon: ,
+ icon: ,
title: "Type-safe",
description: "Plan IDs, feature IDs, events — all inferred from your schema.",
},
@@ -63,22 +65,19 @@ const features: { icon: ReactNode; title: string; description: string }[] = [
export function Features() {
return (
-
+
{features.map((feature) => (
-
-
- {feature.icon}
-
-
-
{feature.title}
-
- {feature.description}
-
-
+
+
+ {feature.icon}
+
+
+
{feature.title}
+
{feature.description}
))}
diff --git a/apps/web/src/components/docs/mdx-tabs.tsx b/apps/web/src/components/docs/mdx-tabs.tsx
new file mode 100644
index 00000000..94d5a710
--- /dev/null
+++ b/apps/web/src/components/docs/mdx-tabs.tsx
@@ -0,0 +1,161 @@
+"use client";
+
+import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
+import {
+ createContext,
+ useContext,
+ useMemo,
+ useState,
+ type ComponentProps,
+ type ReactNode,
+} from "react";
+
+import { cn } from "@/lib/utils";
+
+interface MdxTabsContextValue {
+ items?: string[];
+}
+
+const MdxTabsContext = createContext
(null);
+
+function getDefaultValue(items?: string[], defaultIndex = 0, defaultValue?: string) {
+ return defaultValue ?? items?.[defaultIndex];
+}
+
+export interface MdxTabsProps extends Omit {
+ items?: string[];
+ defaultIndex?: number;
+ defaultValue?: string;
+ label?: ReactNode;
+}
+
+/** Root tabs component for MDX content.
+ *
+ * Supports controlled selection via `items`, `defaultIndex`, `defaultValue`, and optional `label`.
+ */
+export function MdxTabs({
+ className,
+ items,
+ defaultIndex = 0,
+ defaultValue,
+ label,
+ children,
+ ...props
+}: MdxTabsProps) {
+ const [value, setValue] = useState(() => getDefaultValue(items, defaultIndex, defaultValue));
+ const context = useMemo(() => ({ items }), [items]);
+
+ return (
+ {
+ if (items && !items.includes(nextValue)) {
+ if (process.env.NODE_ENV !== "production") {
+ console.warn(
+ `Ignoring unknown MDX tab value "${nextValue}". Expected one of: ${items.join(", ")}.`,
+ );
+ }
+ return;
+ }
+ setValue(nextValue);
+ }}
+ {...props}
+ >
+ {items ? (
+
+ {label ? {label} : null}
+ {items.map((item) => (
+
+ {item}
+
+ ))}
+
+ ) : null}
+ {children}
+
+ );
+}
+
+/** Tab list container with the active-tab indicator. */
+export function MdxTabsList({
+ indicatorClassName,
+ className,
+ children,
+ ...props
+}: TabsPrimitive.List.Props & {
+ indicatorClassName?: string;
+}) {
+ return (
+
+ {children}
+
+
+ );
+}
+
+/** Individual MDX tab trigger.
+ *
+ * Pass `value` to connect the trigger to a matching panel.
+ */
+export function MdxTabsTab({ className, ...props }: TabsPrimitive.Tab.Props) {
+ return (
+
+ );
+}
+
+/** Content panel for an MDX tab value. */
+export function MdxTabsPanel({ className, ...props }: TabsPrimitive.Panel.Props) {
+ return (
+ .docs-codeblock:first-child]:mt-0 [&>p:first-child]:mt-[3px] [&_h3]:text-base [&_h3]:font-medium [&>.steps]:mt-6",
+ className,
+ )}
+ data-slot="tabs-content"
+ {...props}
+ />
+ );
+}
+
+/** Convenience tab panel that resolves `value` from props or tab context.
+ *
+ * @throws If no tab value can be resolved.
+ */
+export function MdxTab({ value, ...props }: ComponentProps) {
+ const context = useContext(MdxTabsContext);
+ const resolvedValue = value ?? context?.items?.[0];
+
+ if (!resolvedValue) {
+ throw new Error("Failed to resolve tab value. Pass a value prop to .");
+ }
+
+ return ;
+}
diff --git a/apps/web/src/components/docs/mdx-text.tsx b/apps/web/src/components/docs/mdx-text.tsx
new file mode 100644
index 00000000..10f600a1
--- /dev/null
+++ b/apps/web/src/components/docs/mdx-text.tsx
@@ -0,0 +1,149 @@
+"use client";
+
+import Link from "next/link";
+import type { ComponentProps, HTMLAttributes, ReactNode } from "react";
+import { Children, cloneElement, isValidElement } from "react";
+import { RiLinkM } from "react-icons/ri";
+
+import { cn } from "@/lib/utils";
+
+const linkDecorationClassName = "decoration-primary/50 group-hover:decoration-primary";
+const linkIconClassName =
+ "group-hover:text-primary text-muted-foreground mb-0.5 ml-px inline size-3 duration-100";
+
+export function Anchor({ className, ...props }: ComponentProps<"a">) {
+ return (
+
+
+ {props.children}
+
+
+
+ );
+}
+
+export function MDXLink({
+ children,
+ className,
+ href,
+ _blank,
+}: {
+ _blank?: boolean;
+ children: ReactNode;
+ className?: string;
+ href: string;
+}) {
+ return (
+
+
+ {children}
+
+
+
+ );
+}
+
+export function InlineCode({ className, ...props }: ComponentProps<"code">) {
+ return (
+
+ );
+}
+
+export function Steps({ className, children, ...props }: HTMLAttributes) {
+ const steps = Children.toArray(children).filter((child) => isValidElement(child));
+
+ return (
+
+ {steps.map((child, index) => {
+ const step = child as React.ReactElement
;
+ const isFirstStep = index === 0;
+ const isLastStep = index === steps.length - 1;
+
+ return (
+
+
+
+ {index + 1}
+
+ {cloneElement(step, {
+ ...step.props,
+ className: cn(step.props.className, "relative", isFirstStep && "mt-0"),
+ })}
+
+ );
+ })}
+
+ );
+}
+
+type StepProps = HTMLAttributes;
+
+export function Step({ className, children, ...props }: StepProps) {
+ return (
+
+ );
+}
+
+export function StepTitle({ className, children }: { children: string; className?: string }) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function StepDescription({
+ className,
+ children,
+}: {
+ children: ReactNode;
+ className?: string;
+}) {
+ return (
+ p]:leading-relaxed",
+ )}
+ >
+ {children}
+
+ );
+}
+
+export function StepContent({ children, className, ...props }: HTMLAttributes) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/web/src/components/docs/package-command-pre.tsx b/apps/web/src/components/docs/package-command-pre.tsx
new file mode 100644
index 00000000..600a0d2c
--- /dev/null
+++ b/apps/web/src/components/docs/package-command-pre.tsx
@@ -0,0 +1,87 @@
+import { highlight } from "fumadocs-core/highlight";
+import type { ComponentProps, ReactNode } from "react";
+
+import { DocsCodeSurface } from "@/components/docs/docs-code-surface";
+import { DefaultPre, PackageManagerCommandBlock } from "@/components/docs/package-command";
+import { commandForManager, type PackageCommand } from "@/components/docs/package-command-utils";
+import { packageManagers, type PackageManager } from "@/components/docs/package-manager-state";
+import { extractText } from "@/components/docs/react-node-text";
+import { shikiHighlightOptions } from "@/lib/shiki-themes";
+
+function normalizeCommand(value: string): string {
+ return value.replace(/\n+$/, "").trim();
+}
+
+function hasSingleLine(value: string): boolean {
+ return value.split("\n").filter((line) => line.trim().length > 0).length === 1;
+}
+
+function isShellLanguage(language: unknown): boolean {
+ if (typeof language !== "string") return false;
+ return ["bash", "sh", "shell", "zsh"].includes(language);
+}
+
+function parsePackageCommand(command: string): PackageCommand | null {
+ const trimmed = normalizeCommand(command);
+
+ for (const prefix of ["npm install ", "npm i "]) {
+ if (trimmed.startsWith(prefix)) return { kind: "install", args: trimmed.slice(prefix.length) };
+ }
+
+ if (trimmed.startsWith("npx ")) return { kind: "dlx", args: trimmed.slice("npx ".length) };
+ if (trimmed.startsWith("npm create ")) {
+ return { kind: "create", args: trimmed.slice("npm create ".length) };
+ }
+ if (trimmed.startsWith("npm run ")) {
+ return { kind: "run", args: trimmed.slice("npm run ".length) };
+ }
+
+ return null;
+}
+
+async function highlightShellCommand(command: string) {
+ return highlight(command, {
+ lang: "bash",
+ ...shikiHighlightOptions,
+ components: {
+ pre: (props: ComponentProps<"pre">) => ,
+ },
+ });
+}
+
+/**
+ * Renders docs code fences, upgrading single-line shell package commands.
+ *
+ * Normalizes/extracts text, parses supported package-manager commands, checks
+ * the code fence language, asynchronously highlights each manager variant, and
+ * renders either `PackageManagerCommandBlock` or `DefaultPre`.
+ *
+ * @param props - Pre props plus optional `data-language` from the code fence.
+ * @returns Highlighted package command block or the default pre element.
+ */
+export async function PackageCommandPre(
+ props: ComponentProps<"pre"> & { "data-language"?: string },
+) {
+ const commandText = normalizeCommand(extractText(props.children));
+ const command = hasSingleLine(commandText) ? parsePackageCommand(commandText) : null;
+
+ if (command && isShellLanguage(props["data-language"])) {
+ const highlightedCommandEntries = await Promise.all(
+ packageManagers.map(async (manager) => [
+ manager,
+ await highlightShellCommand(commandForManager(command, manager)),
+ ]),
+ );
+
+ return (
+
+ }
+ />
+ );
+ }
+
+ return ;
+}
diff --git a/apps/web/src/components/docs/package-command-utils.ts b/apps/web/src/components/docs/package-command-utils.ts
new file mode 100644
index 00000000..0a55ccb4
--- /dev/null
+++ b/apps/web/src/components/docs/package-command-utils.ts
@@ -0,0 +1,36 @@
+import type { PackageManager } from "@/components/docs/package-manager-state";
+
+type CommandKind = "install" | "dlx" | "create" | "run";
+
+/** Parsed package-manager command metadata. */
+export interface PackageCommand {
+ /** Command category parsed from a shell snippet. */
+ kind: CommandKind;
+ /** Arguments after the package-manager command prefix. */
+ args: string;
+}
+
+/**
+ * Formats a parsed command for the selected package manager.
+ *
+ * @param command - Parsed package command.
+ * @param manager - Target package manager.
+ * @returns Shell command for npm, yarn, bun, or pnpm. `dlx` defaults to pnpm.
+ */
+export function commandForManager(command: PackageCommand, manager: PackageManager): string {
+ switch (command.kind) {
+ case "install":
+ return manager === "npm" ? `npm install ${command.args}` : `${manager} add ${command.args}`;
+ case "dlx":
+ if (manager === "npm") return `npx ${command.args}`;
+ if (manager === "yarn") return `yarn dlx ${command.args}`;
+ if (manager === "bun") return `bunx --bun ${command.args}`;
+ return `pnpm dlx ${command.args}`;
+ case "create":
+ return `${manager} create ${command.args}`;
+ case "run":
+ if (manager === "npm") return `npm run ${command.args}`;
+ if (manager === "bun") return `bun run ${command.args}`;
+ return `${manager} ${command.args}`;
+ }
+}
diff --git a/apps/web/src/components/docs/package-command.tsx b/apps/web/src/components/docs/package-command.tsx
index 3c4ad3b7..2a46e535 100644
--- a/apps/web/src/components/docs/package-command.tsx
+++ b/apps/web/src/components/docs/package-command.tsx
@@ -1,56 +1,423 @@
-import { highlight } from "fumadocs-core/highlight";
-import { CodeBlock, Pre } from "fumadocs-ui/components/codeblock";
-import { Tab, Tabs } from "fumadocs-ui/components/tabs";
+"use client";
-import { shikiHighlightOptions } from "@/lib/shiki-themes";
+import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
+import type { ComponentProps, ReactNode, SVGProps } from "react";
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useLayoutEffect,
+ useState,
+} from "react";
+import { RiCheckLine, RiFileCopyLine } from "react-icons/ri";
-const managers = ["pnpm", "bun", "npm"] as const;
+import { DocsCodeSurface } from "@/components/docs/docs-code-surface";
+import { commandForManager, type PackageCommand } from "@/components/docs/package-command-utils";
+import {
+ fallbackPackageManager,
+ isPackageManager,
+ packageManagerStorageKey,
+ packageManagers,
+ type PackageManager,
+} from "@/components/docs/package-manager-state";
+import { extractText } from "@/components/docs/react-node-text";
+import { buttonVariants } from "@/components/ui/button";
+import { useCopyButton } from "@/components/ui/use-copy-button";
+import { cn } from "@/lib/utils";
-function installCommand(pkg: string, manager: string) {
- if (manager === "npm") return `npm install ${pkg}`;
- return `${manager} add ${pkg}`;
+type TabsVariant = "default" | "underline";
+type PackageManagerListener = (value: PackageManager) => void;
+
+const packageManagerListeners = new Set();
+const PackageManagerContext = createContext(fallbackPackageManager);
+
+function readStoredManager(): PackageManager | null {
+ const storedValue = localStorage.getItem(packageManagerStorageKey);
+
+ return isPackageManager(storedValue) ? storedValue : null;
}
-function runCommand(command: string, manager: string) {
- if (manager === "pnpm") return `pnpm dlx ${command}`;
- if (manager === "bun") return `bunx ${command}`;
- return `npx ${command}`;
+function setStoredManager(value: PackageManager) {
+ localStorage.setItem(packageManagerStorageKey, value);
+
+ for (const listener of packageManagerListeners) {
+ listener(value);
+ }
}
-async function HighlightedCode({ code }: { code: string }) {
- return highlight(code, {
- lang: "bash",
- ...shikiHighlightOptions,
- components: {
- pre: (props) => (
-
- {props.children}
-
- ),
- },
- });
+function usePackageManager() {
+ const initialManager = useContext(PackageManagerContext);
+ const [manager, setManagerState] = useState(initialManager);
+
+ useLayoutEffect(() => {
+ const storedManager = readStoredManager();
+ if (storedManager) setManagerState(storedManager);
+
+ packageManagerListeners.add(setManagerState);
+
+ return () => {
+ packageManagerListeners.delete(setManagerState);
+ };
+ }, []);
+
+ const setManager = useCallback((value: PackageManager) => {
+ setManagerState(value);
+ setStoredManager(value);
+ }, []);
+
+ return [manager, setManager] as const;
}
-export async function PackageInstall({ package: pkg }: { package: string }) {
+export function PackageManagerProvider({
+ initialManager,
+ children,
+}: {
+ initialManager: PackageManager;
+ children: ReactNode;
+}) {
return (
-
- {managers.map((m) => (
-
-
-
- ))}
-
+
+ {children}
+
+ );
+}
+
+function Tabs({ className, ...props }: TabsPrimitive.Root.Props) {
+ return (
+
+ );
+}
+
+function TabsList({
+ variant = "default",
+ indicatorClassName,
+ className,
+ children,
+ ...props
+}: TabsPrimitive.List.Props & {
+ variant?: TabsVariant;
+ indicatorClassName?: string;
+}) {
+ const [enableIndicatorTransition, setEnableIndicatorTransition] = useState(false);
+
+ useEffect(() => {
+ const timeout = window.setTimeout(() => setEnableIndicatorTransition(true), 80);
+
+ return () => window.clearTimeout(timeout);
+ }, []);
+
+ return (
+
+ {children}
+
+
+ );
+}
+
+function TabsTab({ className, ...props }: TabsPrimitive.Tab.Props) {
+ return (
+
+ );
+}
+
+function TabsPanel({ className, ...props }: TabsPrimitive.Panel.Props) {
+ return (
+
+ );
+}
+
+function cleanCopyText(value: string): string {
+ return value
+ .replace(/\s*\/\/\s*\[!code\s+[^\]]+\]\s*$/gm, "")
+ .replace(/\s*\{?\s*\/\*\s*\[!code\s+[^\]]+\]\s*\*\/\s*\}?\s*$/gm, "")
+ .replace(/\s*\s*$/gm, "")
+ .replace(/\n+$/, "");
+}
+
+function CopyButton({ className, code }: { className?: string; code: string }) {
+ const [checked, onClick] = useCopyButton(() => navigator.clipboard.writeText(code));
+
+ return (
+
+
+
+
);
}
-export async function PackageRun({ command }: { command: string }) {
+export function PackageManagerCommandBlock({
+ command,
+ highlightedCommands,
+}: {
+ command: PackageCommand;
+ highlightedCommands: Record;
+}) {
+ const [manager, setManager] = usePackageManager();
+ const activeCommand = commandForManager(command, manager);
+
return (
-
- {managers.map((m) => (
-
-
-
- ))}
+ {
+ if (isPackageManager(value)) setManager(value);
+ }}
+ >
+
+
+
+
+
+ pnpm
+
+
+
+ npm
+
+
+
+ bun
+
+
+
+ yarn
+
+
+
+
+ {packageManagers.map((item) => (
+
+ {highlightedCommands[item]}
+
+ ))}
+
);
}
+
+export function DefaultPre({
+ children,
+ className,
+ title,
+ icon,
+ ...props
+}: ComponentProps<"pre"> & { icon?: ReactNode }) {
+ const code = cleanCopyText(extractText(children));
+ const hasTitle = title !== undefined;
+
+ return (
+
+ {hasTitle ? (
+
+
+ {typeof icon === "string" ? (
+
+ ) : (
+ icon
+ )}
+
{title}
+
+
+
+ ) : null}
+ {!hasTitle ? (
+
+ ) : null}
+
+ {children}
+
+
+ );
+}
+
+function NpmIcon({
+ fill = "currentColor",
+ width = "1em",
+ height = "1em",
+ ...props
+}: SVGProps) {
+ return (
+
+
+
+ );
+}
+
+function YarnIcon({
+ fill = "currentColor",
+ width = "1em",
+ height = "1em",
+ ...props
+}: SVGProps) {
+ return (
+
+
+
+ );
+}
+
+function BunIcon({
+ fill = "currentColor",
+ width = "1em",
+ height = "1em",
+ ...props
+}: SVGProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function PnpmIcon({
+ fill = "currentColor",
+ width = "1em",
+ height = "1em",
+ ...props
+}: SVGProps) {
+ return (
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/docs/package-manager-state.ts b/apps/web/src/components/docs/package-manager-state.ts
new file mode 100644
index 00000000..b902cd0a
--- /dev/null
+++ b/apps/web/src/components/docs/package-manager-state.ts
@@ -0,0 +1,38 @@
+/** Supported package manager identifiers. */
+export type PackageManager = "npm" | "yarn" | "bun" | "pnpm";
+
+/** Ordered package managers displayed by command switchers. */
+export const packageManagers = [
+ "pnpm",
+ "npm",
+ "bun",
+ "yarn",
+] as const satisfies readonly PackageManager[];
+
+/** Storage key for the selected package manager preference. */
+export const packageManagerStorageKey = "paykit-package-manager";
+
+/** Default package manager used when no valid preference exists. */
+export const fallbackPackageManager: PackageManager = "pnpm";
+
+/**
+ * Checks whether a value is a supported package manager.
+ *
+ * @param value - Candidate package manager string.
+ * @returns Whether `value` is a PackageManager.
+ */
+export function isPackageManager(value: string | null): value is PackageManager {
+ return packageManagers.includes(value as PackageManager);
+}
+
+/**
+ * Parses a package manager value, falling back to pnpm when invalid.
+ *
+ * @param value - Candidate package manager string.
+ * @returns A supported package manager.
+ */
+export function parsePackageManager(value: string | null | undefined): PackageManager {
+ const candidate = value ?? null;
+ if (isPackageManager(candidate)) return candidate;
+ return fallbackPackageManager;
+}
diff --git a/apps/web/src/components/docs/react-node-text.ts b/apps/web/src/components/docs/react-node-text.ts
new file mode 100644
index 00000000..58df8766
--- /dev/null
+++ b/apps/web/src/components/docs/react-node-text.ts
@@ -0,0 +1,11 @@
+import type { ReactNode } from "react";
+import { isValidElement } from "react";
+
+/** Extracts plain text from nested React nodes. */
+export function extractText(node: ReactNode): string {
+ if (node === null || node === undefined || typeof node === "boolean") return "";
+ if (typeof node === "string" || typeof node === "number") return String(node);
+ if (Array.isArray(node)) return node.map(extractText).join("");
+ if (isValidElement<{ children?: ReactNode }>(node)) return extractText(node.props.children);
+ return "";
+}
diff --git a/apps/web/src/components/docs/sidebar-category-accordion.tsx b/apps/web/src/components/docs/sidebar-category-accordion.tsx
deleted file mode 100644
index 3d4bcc94..00000000
--- a/apps/web/src/components/docs/sidebar-category-accordion.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-"use client";
-
-import { useEffect } from "react";
-
-const isAutoCollapseEnabled = false;
-
-function isCategoryButton(button: HTMLButtonElement): boolean {
- return button.querySelector(".docs-category-chevron") !== null;
-}
-
-export function SidebarCategoryAccordion() {
- useEffect(() => {
- if (!isAutoCollapseEnabled) return;
-
- const onClick = (event: MouseEvent) => {
- if (!(event.target instanceof Element)) return;
-
- const button = event.target.closest("button[aria-expanded]") as HTMLButtonElement | null;
- if (!button || !isCategoryButton(button)) return;
-
- const sidebarRoot = button.closest("#nd-sidebar, #nd-sidebar-mobile");
- if (!sidebarRoot) return;
-
- // Wait for Fumadocs to update expanded state, then collapse siblings.
- queueMicrotask(() => {
- if (button.getAttribute("aria-expanded") !== "true") return;
-
- const categoryButtons = Array.from(
- sidebarRoot.querySelectorAll("button[aria-expanded]"),
- ).filter(
- (item): item is HTMLButtonElement =>
- item instanceof HTMLButtonElement && isCategoryButton(item),
- );
-
- for (const sibling of categoryButtons) {
- if (sibling !== button && sibling.getAttribute("aria-expanded") === "true") {
- sibling.click();
- }
- }
- });
- };
-
- document.addEventListener("click", onClick);
- return () => {
- document.removeEventListener("click", onClick);
- };
- }, []);
-
- return null;
-}
diff --git a/apps/web/src/components/docs/sidebar-collapse-button.tsx b/apps/web/src/components/docs/sidebar-collapse-button.tsx
deleted file mode 100644
index 98161298..00000000
--- a/apps/web/src/components/docs/sidebar-collapse-button.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-"use client";
-
-import { useSidebar } from "fumadocs-ui/components/sidebar/base";
-import { PanelLeft } from "lucide-react";
-
-import { Button } from "@/components/ui/button";
-
-export function SidebarCollapseButton() {
- const { collapsed, setCollapsed } = useSidebar();
-
- return (
- {
- setCollapsed((prev) => !prev);
- }}
- >
-
-
- );
-}
diff --git a/apps/web/src/components/docs/toc-footer.tsx b/apps/web/src/components/docs/toc-footer.tsx
index c85c8220..f7d910da 100644
--- a/apps/web/src/components/docs/toc-footer.tsx
+++ b/apps/web/src/components/docs/toc-footer.tsx
@@ -2,8 +2,14 @@ import Link from "next/link";
import { URLs } from "@/lib/consts";
-const progressValue = 15;
+const progressValue = 65;
+/** Docs table-of-contents footer with roadmap progress link.
+ *
+ * Opens the roadmap in a new tab and displays `progressValue`.
+ *
+ * @returns JSX.Element
+ */
export function TocFooter() {
return (
diff --git a/apps/web/src/components/icons/index.tsx b/apps/web/src/components/icons/index.tsx
index 2f91087c..2d587a58 100644
--- a/apps/web/src/components/icons/index.tsx
+++ b/apps/web/src/components/icons/index.tsx
@@ -1,4 +1,5 @@
import type { SVGProps } from "react";
+import { RiGithubFill, RiRobot2Line } from "react-icons/ri";
import { cn } from "@/lib/utils";
@@ -570,6 +571,8 @@ export const Icons = {
),
+ GitHubIcon: ({ className }: { className?: string }) =>
,
+ LlmsIcon: ({ className }: { className?: string }) =>
,
ClaudeIcon: ({ className }: { className?: string }) => (
-
+
@@ -28,7 +31,7 @@ export function MiniNavBar() {
>
-
+
diff --git a/apps/web/src/components/layout/navigation-bar.tsx b/apps/web/src/components/layout/navigation-bar.tsx
index fa5c5434..be42e69b 100644
--- a/apps/web/src/components/layout/navigation-bar.tsx
+++ b/apps/web/src/components/layout/navigation-bar.tsx
@@ -1,23 +1,23 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
-import { ChevronDown, ExternalLink, Github, Menu, X } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
+import { RiArrowDownSLine, RiCloseLine, RiExternalLinkLine, RiMenuLine } from "react-icons/ri";
+import { Icons } from "@/components/icons";
import { SectionShell } from "@/components/layout/section";
import { Button } from "@/components/ui/button";
import { BrandMenu } from "@/components/web/brand-menu";
import { URLs } from "@/lib/consts";
-// ─── Shared nav link ─────────────────────────────────────────────────
-
interface NavItem {
name: string;
href: string;
path?: string;
external?: boolean;
+ icon?: React.ReactNode;
}
function NavLink({
@@ -46,19 +46,32 @@ function NavLink({
);
}
-// ─── Data ────────────────────────────────────────────────────────────
-
const navTabs: NavItem[] = [
- { name: "readme", href: "/" },
{ name: "docs", href: "/docs", path: "/docs" },
+ { name: "blog", href: "/blog", path: "/blog" },
+ { name: "sponsors", href: "/sponsor", path: "/sponsor" },
];
const dropdownLinks: NavItem[] = [
- { name: "Discord", href: URLs.discord, external: true },
- { name: "Twitter / X", href: URLs.x, external: true },
- { name: "LinkedIn", href: URLs.linkedin, external: true },
- { name: "Donate", href: "/donate", external: true },
- { name: "llms.txt", href: "/llms.txt", external: true },
+ {
+ name: "Discord",
+ href: URLs.discord,
+ external: true,
+ icon: ,
+ },
+ { name: "Twitter / X", href: URLs.x, external: true, icon: },
+ {
+ name: "LinkedIn",
+ href: URLs.linkedin,
+ external: true,
+ icon: ,
+ },
+ {
+ name: "llms.txt",
+ href: "/llms.txt",
+ external: true,
+ icon: ,
+ },
];
const mobileLinks: NavItem[] = [
@@ -66,19 +79,15 @@ const mobileLinks: NavItem[] = [
...dropdownLinks.map((l) => ({ ...l, name: l.name.toLowerCase() })),
];
-// ─── Tab styles ──────────────────────────────────────────────────────
-
const tabBase =
- "group/tab relative flex h-full items-center justify-center gap-1.5 px-5.5 py-3.5 transition-colors duration-150";
-const tabActive = "bg-background border-b-2 border-b-foreground/60";
+ "group/tab relative flex h-full items-center justify-center gap-1.5 px-4 py-3.5 transition-colors duration-150";
+const tabActive = "bg-background";
const tabInactive =
"hover:bg-foreground/[0.03] bg-transparent text-foreground/60 dark:text-foreground/40 hover:text-foreground/70";
const labelBase =
"text-sm tracking-wider whitespace-nowrap uppercase transition-colors duration-150";
-// ─── Component ───────────────────────────────────────────────────────
-
-export function NavigationBar({ stars: _stars }: { stars: number | null }) {
+export function NavigationBar({ stars }: { stars?: number | null }) {
const routerPathname = usePathname();
const [pathname, setPathname] = useState("/");
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
@@ -101,6 +110,8 @@ export function NavigationBar({ stars: _stars }: { stars: number | null }) {
closeTimeout.current = setTimeout(() => setLinksOpen(false), 150);
}, []);
+ const linksMenuId = "header-links-menu";
+
return (
<>
@@ -109,20 +120,22 @@ export function NavigationBar({ stars: _stars }: { stars: number | null }) {
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.28, ease: "easeOut" }}
- className="bg-background border-border pointer-events-auto w-full border-b lg:hidden"
+ className="bg-background pointer-events-auto w-full lg:hidden"
>
-
-
- setMobileMenuOpen((prev) => !prev)}
- className="text-foreground/65 dark:text-foreground/50 hover:text-foreground/80 px-5 py-3 transition-colors"
- >
- {mobileMenuOpen ? : }
-
+
+
+
+ setMobileMenuOpen((prev) => !prev)}
+ className="text-foreground/65 dark:text-foreground/50 hover:text-foreground/80 -mr-5.5 h-full px-5.5 transition-colors"
+ >
+ {mobileMenuOpen ? : }
+
+
@@ -131,12 +144,12 @@ export function NavigationBar({ stars: _stars }: { stars: number | null }) {
initial={{ y: -10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.28, delay: 0.04, ease: "easeOut" }}
- className="bg-background border-border pointer-events-auto relative hidden w-full items-stretch justify-center border-b lg:flex"
+ className="bg-background pointer-events-auto relative hidden w-full items-stretch justify-center lg:flex"
>
-
+
{/* Logo */}
-
+
{/* Center tabs */}
@@ -152,7 +165,7 @@ export function NavigationBar({ stars: _stars }: { stars: number | null }) {
>
{item.name}
@@ -174,13 +187,29 @@ export function NavigationBar({ stars: _stars }: { stars: number | null }) {
className="relative"
onMouseEnter={openLinks}
onMouseLeave={closeLinks}
+ onFocus={openLinks}
+ onBlur={(event) => {
+ if (!event.currentTarget.contains(event.relatedTarget)) closeLinks();
+ }}
>
{
+ if (event.key === "Escape") {
+ setLinksOpen(false);
+ return;
+ }
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ setLinksOpen((open) => !open);
+ }
+ }}
>
links
-
@@ -192,6 +221,7 @@ export function NavigationBar({ stars: _stars }: { stars: number | null }) {
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 4 }}
transition={{ duration: 0.15 }}
+ id={linksMenuId}
className="bg-background border-foreground/8 absolute top-full left-1/2 z-50 mt-px min-w-45 -translate-x-1/2 border py-1 shadow-lg"
>
{dropdownLinks.map((link) => (
@@ -201,10 +231,13 @@ export function NavigationBar({ stars: _stars }: { stars: number | null }) {
className="text-foreground/60 hover:text-foreground hover:bg-foreground/3 flex items-center justify-between px-4 py-2 text-sm transition-colors"
onClick={() => setLinksOpen(false)}
>
- {link.name}
- {link.external && (
-
- )}
+
+
+ {link.icon}
+
+ {link.name}
+
+
))}
@@ -215,15 +248,16 @@ export function NavigationBar({ stars: _stars }: { stars: number | null }) {
{/* Right */}
-
+
}
nativeButton={false}
- variant={"outline"}
+ variant="ghost"
size="sm"
+ className="px-2 -mr-2"
>
-
- GitHub
+
+ {stars ?? "1k"}
@@ -242,7 +276,7 @@ export function NavigationBar({ stars: _stars }: { stars: number | null }) {
transition={{ duration: 0.15 }}
className="bg-background/95 pointer-events-auto fixed inset-0 z-98 backdrop-blur-sm lg:hidden"
>
-
+
{mobileLinks.map((item, i) => (
setMobileMenuOpen(false)}
>
+ {item.icon && (
+
+ {item.icon}
+
+ )}
{item.external && (
-
+
)}
diff --git a/apps/web/src/components/layout/section.tsx b/apps/web/src/components/layout/section.tsx
index e17c620f..47a5ea62 100644
--- a/apps/web/src/components/layout/section.tsx
+++ b/apps/web/src/components/layout/section.tsx
@@ -5,8 +5,6 @@ import { cn } from "@/lib/utils";
export const sectionShellWidth =
"w-[calc(100%-1rem)] sm:w-[calc(100%-2rem)] md:w-[calc(100%-3rem)] lg:w-[calc(100%-4rem)] xl:w-full";
-// ─── Shared section line ─────────────────────────────────────────────
-
export function SectionLine({ orientation }: { orientation: "horizontal" | "vertical" }) {
const isH = orientation === "horizontal";
return (
@@ -21,7 +19,7 @@ export function SectionLine({ orientation }: { orientation: "horizontal" | "vert
export function SectionShell({ children, className }: { children: ReactNode; className?: string }) {
return (
-
+
@@ -31,8 +29,6 @@ export function SectionShell({ children, className }: { children: ReactNode; cla
);
}
-// ─── Section (outer wrapper with solid borders) ──────────────────────
-
export function Section({
children,
className,
@@ -45,7 +41,7 @@ export function Section({
return (
{!last && (
-
+
)}
@@ -54,8 +50,6 @@ export function Section({
);
}
-// ─── SectionContent (padded content area) ────────────────────────────
-
export function SectionContent({
children,
className,
@@ -66,11 +60,9 @@ export function SectionContent({
return
{children}
;
}
-// ─── SectionSeparator (full viewport-width solid line) ───────────────
-
export function SectionSeparator() {
return (
-
+
);
diff --git a/apps/web/src/components/providers.tsx b/apps/web/src/components/providers.tsx
index f827b390..6b43f0c2 100644
--- a/apps/web/src/components/providers.tsx
+++ b/apps/web/src/components/providers.tsx
@@ -7,6 +7,11 @@ import { Toaster } from "sonner";
export function Providers({ children }: { children: ReactNode }) {
return (
-
+
Ready to add billing?
-
+
One command to get started. Define your plans, connect Stripe, and ship billing in
minutes.
-
} nativeButton={false} size="lg" className="px-4">
+
} nativeButton={false} size="lg" className="px-5">
Get Started
-
+
) : hovered ? (
-
+
) : (
-
+
)}
diff --git a/apps/web/src/components/sections/demo/demo-app-window.tsx b/apps/web/src/components/sections/demo/demo-app-window.tsx
index c57ac4d8..3ba38a75 100644
--- a/apps/web/src/components/sections/demo/demo-app-window.tsx
+++ b/apps/web/src/components/sections/demo/demo-app-window.tsx
@@ -1,8 +1,8 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
-import { Loader2, Send } from "lucide-react";
import type { RefObject } from "react";
+import { RiLoader4Line, RiSendPlaneLine } from "react-icons/ri";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -19,13 +19,8 @@ function WindowChrome({
className?: string;
}) {
return (
-
-
+
+
@@ -92,7 +87,7 @@ export function DemoAppWindow({
>
{/* Top bar */}
-
+
{/* Billing panel — hidden on mobile, sidebar on desktop */}
-
+
{busy === "downgrade" ? (
<>
-
+
Downgrading...
>
) : plan === "free" ? (
@@ -169,12 +164,12 @@ export function DemoAppWindow({
>
{busy === "upgrade" ? (
<>
-
+
Upgrading...
>
) : busy === "resubscribe" ? (
<>
-
+
Resubscribing...
>
) : plan === "pro" && downgradeScheduled ? (
@@ -259,7 +254,7 @@ export function DemoAppWindow({
{/* Input */}
-
+
{autoTyping ? (
{input}
@@ -285,7 +280,7 @@ export function DemoAppWindow({
disabled={blocked || aiState !== "idle" || !input.trim()}
className="text-foreground/35 hover:text-foreground/60 disabled:opacity-20"
>
-
+
@@ -317,8 +312,8 @@ function PlanCard({
active
? variant === "pro"
? "border-emerald-500/20 bg-emerald-500/[0.03]"
- : "border-foreground/[0.12] bg-foreground/[0.02]"
- : "border-foreground/[0.06]",
+ : "border-border bg-foreground/[0.02]"
+ : "border-border",
)}
>
diff --git a/apps/web/src/components/sections/demo/demo-backend-panel.tsx b/apps/web/src/components/sections/demo/demo-backend-panel.tsx
index 8c53c1d0..3e4f3fda 100644
--- a/apps/web/src/components/sections/demo/demo-backend-panel.tsx
+++ b/apps/web/src/components/sections/demo/demo-backend-panel.tsx
@@ -1,8 +1,8 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
-import { Loader2, User } from "lucide-react";
import type { ReactNode } from "react";
+import { RiLoader4Line, RiUserLine } from "react-icons/ri";
import { cn } from "@/lib/utils";
@@ -19,13 +19,8 @@ export function DemoBackendPanel({
className?: string;
}) {
return (
-
-
+
+
Backend
@@ -60,10 +55,10 @@ function FlowLog({
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
transition={{ duration: 0.3, ease: "easeOut" }}
- className="border-foreground/[0.08] shrink-0 overflow-hidden rounded-md border"
+ className="shrink-0 overflow-hidden rounded-xs border"
>
-
-
+
+
{card.trigger}
@@ -82,13 +77,13 @@ function FlowLog({
initial={{ opacity: 0.5 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
- className="bg-foreground/[0.03] flex items-center overflow-hidden rounded px-2 py-2"
+ className="bg-foreground/[0.03] flex items-center overflow-hidden rounded-xs px-2 py-2"
>
{snippets[entry.snippet]}
) : entry.type === "pending" ? (
-
+
{entry.label}
) : (
diff --git a/apps/web/src/components/sections/demo/demo-types.tsx b/apps/web/src/components/sections/demo/demo-types.tsx
index 8619630b..69306905 100644
--- a/apps/web/src/components/sections/demo/demo-types.tsx
+++ b/apps/web/src/components/sections/demo/demo-types.tsx
@@ -1,18 +1,18 @@
-import {
- CalendarCheck,
- CalendarX,
- CreditCard,
- Database,
- ExternalLink,
- Link2,
- RefreshCw,
- Shield,
- ShieldAlert,
- Sparkles,
- UserCheck,
- Webhook,
-} from "lucide-react";
import type { ReactNode } from "react";
+import {
+ RiBankCardLine,
+ RiCalendarCheckLine,
+ RiCalendarCloseLine,
+ RiDatabase2Line,
+ RiExternalLinkLine,
+ RiLink,
+ RiRefreshLine,
+ RiShieldFlashLine,
+ RiShieldLine,
+ RiSparklingLine,
+ RiUserFollowLine,
+ RiWebhookLine,
+} from "react-icons/ri";
export type SnippetKey = "subscribe" | "check" | "report" | "portal" | "downgrade" | "resubscribe";
@@ -47,18 +47,18 @@ export function nextCardId() {
}
export const stepIcons: Record
= {
- user: ,
- "credit-card": ,
- webhook: ,
- database: ,
- link: ,
- "external-link": ,
- "calendar-x": ,
- "calendar-check": ,
- sparkles: ,
- shield: ,
- "shield-alert": ,
- refresh: ,
+ user: ,
+ "credit-card": ,
+ webhook: ,
+ database: ,
+ link: ,
+ "external-link": ,
+ "calendar-x": ,
+ "calendar-check": ,
+ sparkles: ,
+ shield: ,
+ "shield-alert": ,
+ refresh: ,
};
// Scripted replies for auto-play
@@ -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/demo/index.tsx b/apps/web/src/components/sections/demo/index.tsx
index 0e20d417..8e7a7a3e 100644
--- a/apps/web/src/components/sections/demo/index.tsx
+++ b/apps/web/src/components/sections/demo/index.tsx
@@ -294,49 +294,47 @@ export function DemoSection({ snippets }: { snippets: Record
-
+
-
+
How it works
-
- Click around the app below. Every interaction shows the PayKit code that runs and the
- steps it orchestrates, in real time.
-
+
+
-
- void handleUpgrade()}
- onDowngrade={() => void handleDowngrade()}
- onResubscribe={() => void handleResubscribe()}
- onPortal={() => void handlePortal()}
- />
-
-
-
-
-
+
void handleUpgrade()}
+ onDowngrade={() => void handleDowngrade()}
+ onResubscribe={() => void handleResubscribe()}
+ onPortal={() => void handlePortal()}
+ />
+
+
diff --git a/apps/web/src/components/sections/features-section.tsx b/apps/web/src/components/sections/features-section.tsx
deleted file mode 100644
index bdade0a6..00000000
--- a/apps/web/src/components/sections/features-section.tsx
+++ /dev/null
@@ -1,73 +0,0 @@
-import { Blocks, Cable, Database, Gauge, ShieldCheck, Webhook } from "lucide-react";
-
-import { Section, SectionContent } from "@/components/layout/section";
-
-const features = [
- {
- icon: ,
- title: "Usage billing",
- description: "Metered features with check() and report(). Zero network latency.",
- },
- {
- icon: ,
- title: "Webhooks handled",
- description: "Verified, deduplicated, synced to your database automatically.",
- },
- {
- icon: ,
- title: "Any provider",
- description: "Stripe, Polar, Creem, or your own custom provider. Swap with one import.",
- },
- {
- icon: ,
- title: "Plugins",
- description: "Extend PayKit with dashboard, analytics, or build your own plugin.",
- },
- {
- icon: ,
- title: "Your database",
- description: "Billing state in your Postgres, low latency, joinable with your tables.",
- },
- {
- icon: ,
- title: "Type-safe",
- description: "Plan IDs, feature IDs, events. All inferred from your schema.",
- },
-];
-
-export function FeaturesSection() {
- return (
-
-
-
-
- Features
-
-
- Everything you need to add billing to your app. Nothing you don't.
-
-
-
- {features.map((feature) => (
-
-
-
- {feature.icon}
-
-
-
{feature.title}
-
- {feature.description}
-
-
-
-
- ))}
-
-
-
- );
-}
diff --git a/apps/web/src/components/sections/feedback-content.ts b/apps/web/src/components/sections/feedback-content.ts
new file mode 100644
index 00000000..cd1c3047
--- /dev/null
+++ b/apps/web/src/components/sections/feedback-content.ts
@@ -0,0 +1,120 @@
+export type Tweet = {
+ column: number;
+ name: string;
+ handle: string;
+ link: string;
+ avatar: string;
+ text: string;
+ checkmark: boolean;
+};
+
+export const tweets: Tweet[] = [
+ {
+ column: 1,
+ name: "Guillermo Rauch",
+ handle: "rauchg",
+ link: "https://x.com/rauchg/status/2047218849754571200?s=20",
+ avatar: "https://pbs.twimg.com/profile_images/1783856060249595904/8TfcCN0r_200x200.jpg",
+ text: "👀",
+ checkmark: true,
+ },
+ {
+ column: 1,
+ name: "Gruz",
+ handle: "damnGruz",
+ link: "https://x.com/damnGruz/status/2042264666135756991?s=20",
+ avatar: "https://pbs.twimg.com/profile_images/2008805464834973696/xjQGOfAs_200x200.jpg",
+ text: "sick!!! will try it",
+ checkmark: true,
+ },
+ {
+ column: 2,
+ name: "Andrew Qu",
+ handle: "andrewqu",
+ link: "https://x.com/andrewqu/status/2057463195061948506?s=20",
+ avatar: "https://pbs.twimg.com/profile_images/1976812562143641600/e_E_wlXX_200x200.jpg",
+ text: "Paykit and opensec are too good",
+ checkmark: true,
+ },
+ {
+ column: 4,
+ name: "Creem 🍦",
+ handle: "creem_io",
+ link: "https://x.com/creem_io/status/2042265241157857483?s=20",
+ avatar: "https://pbs.twimg.com/profile_images/2003816759975944192/3C1xR6B8_200x200.jpg",
+ text: "Looks amazing Max! 🙌",
+ checkmark: true,
+ },
+ {
+ column: 2,
+ name: "Matteo Scotto",
+ handle: "442utopy",
+ link: "https://x.com/442utopy/status/2042500989660418128?s=20",
+ avatar: "https://pbs.twimg.com/profile_images/1898711224180674560/BrICyHKA_200x200.jpg",
+ text: "that looks amazing! Congrats for the launch",
+ checkmark: true,
+ },
+ {
+ column: 2,
+ name: "Jonathan Wilke",
+ handle: "jonathan_wilke",
+ link: "https://x.com/jonathan_wilke/status/2042492766270234850?s=20",
+ avatar: "https://pbs.twimg.com/profile_images/1884529433979068416/AhfbeVEh_200x200.jpg",
+ text: "Honestly, this is probably the best lib project I have come across since better-auth.",
+ checkmark: true,
+ },
+ {
+ column: 4,
+ name: "jan",
+ handle: "miaugladiator1",
+ link: "https://x.com/miaugladiator1/status/2039394313059086710?s=20",
+ avatar: "https://pbs.twimg.com/profile_images/2053193399180705794/7IMR_hhx_200x200.jpg",
+ text: "just saw paykit and it looks actually soooo cool have to try this out asap",
+ checkmark: true,
+ },
+ {
+ column: 1,
+ name: "jordi",
+ handle: "jordienr",
+ link: "https://x.com/jordienr/status/2039374608286007503?s=20",
+ avatar: "https://pbs.twimg.com/profile_images/2053541405121769475/TUDez6zL_200x200.jpg",
+ text: "paykit looks very interesting, like a really good abstraction without lock in",
+ checkmark: true,
+ },
+ {
+ column: 3,
+ name: "Lasse",
+ handle: "lassejv",
+ link: "https://x.com/lassejv/status/2042902834509656507?s=20",
+ avatar: "https://pbs.twimg.com/profile_images/2056849088126070784/MCWM6EuB_200x200.jpg",
+ text: "Holy shit paykit by @maxktz is really good.",
+ checkmark: true,
+ },
+ {
+ column: 3,
+ name: "lakshmi",
+ handle: "simhskal",
+ link: "https://x.com/simhskal/status/2042818621492334663?s=20",
+ avatar: "https://pbs.twimg.com/profile_images/1997855512210427904/Spg8avde_200x200.jpg",
+ text: "very cool",
+ checkmark: true,
+ },
+ {
+ column: 3,
+ name: "Leo",
+ handle: "leodev",
+ link: "https://x.com/leodev/status/2052489939292499986?s=20",
+ avatar: "https://pbs.twimg.com/profile_images/2060684155013201929/n2cPRDs7_200x200.jpg",
+ text: "You should check it out, really cool billing framework",
+ checkmark: true,
+ },
+ {
+ column: 4,
+ name: "Saïd Aitmbarek",
+ handle: "SaidAitmbarek",
+ link: "https://x.com/SaidAitmbarek/status/2042568317169193320?s=20",
+ avatar: "https://pbs.twimg.com/profile_images/1891564978177454080/YzRSDzkw_200x200.jpg",
+ text: "Gotta try this one, brilliant work mate.",
+ checkmark: true,
+ },
+];
diff --git a/apps/web/src/components/sections/feedback-section.tsx b/apps/web/src/components/sections/feedback-section.tsx
new file mode 100644
index 00000000..cbae9356
--- /dev/null
+++ b/apps/web/src/components/sections/feedback-section.tsx
@@ -0,0 +1,87 @@
+import { Section, SectionContent } from "@/components/layout/section";
+import { tweets } from "@/components/sections/feedback-content";
+import type { Tweet } from "@/components/sections/feedback-content";
+
+const columns = [1, 2, 3, 4].map((column) => tweets.filter((tweet) => tweet.column === column));
+
+function VerifiedIcon() {
+ return (
+
+
+
+
+ );
+}
+
+function TweetCard({ tweet }: { tweet: Tweet }) {
+ return (
+
+
+ View tweet by {tweet.name}
+
+
+
+
+
+
+
+ {tweet.name}
+ {tweet.checkmark && }
+
+
+ @{tweet.handle}
+
+
+
+
+
+ );
+}
+
+export function FeedbackSection() {
+ return (
+
+
+
+
+ Feedback
+
+
+
+
+
+
+
+ {columns.map((column, columnIndex) => (
+
+ {column.map((tweet) => (
+
+ ))}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/sections/footer-section.tsx b/apps/web/src/components/sections/footer-section.tsx
index 0541f986..6e92aeee 100644
--- a/apps/web/src/components/sections/footer-section.tsx
+++ b/apps/web/src/components/sections/footer-section.tsx
@@ -1,10 +1,10 @@
"use client";
-import { Github } from "lucide-react";
import Link from "next/link";
import { Icons } from "@/components/icons";
import { Section, SectionContent } from "@/components/layout/section";
+import { ThemeSwitcher } from "@/components/theme-switcher";
import { URLs } from "@/lib/consts";
const navLinks = [
@@ -17,7 +17,7 @@ const socialLinks = [
{ label: "Discord", href: URLs.discord, icon: },
{ label: "Twitter/X", href: URLs.x, icon: },
{ label: "LinkedIn", href: URLs.linkedin, icon: },
- { label: "GitHub", href: URLs.githubRepo, icon: },
+ { label: "GitHub", href: URLs.githubRepo, icon: },
];
const prompt = encodeURIComponent(`Explain what PayKit (paykit.sh) is and why I should use it.
@@ -93,6 +93,10 @@ export function FooterSection() {
+
{socialLinks.map((link) => (
-
+
{
+ "subscription.activated": async ({ customer }) => {
await sendEmail(customer.email, "Welcome to Pro!")
},
}
diff --git a/apps/web/src/components/sections/testimonials-section.tsx b/apps/web/src/components/sections/testimonials-section.tsx
deleted file mode 100644
index dc678477..00000000
--- a/apps/web/src/components/sections/testimonials-section.tsx
+++ /dev/null
@@ -1,112 +0,0 @@
-import { Icons } from "@/components/icons";
-import { Section, SectionContent } from "@/components/layout/section";
-import { cn } from "@/lib/utils";
-
-const testimonials = [
- {
- handle: "@alexdev",
- avatar: "/testimonials/placeholder-1.png",
- text: "Just integrated PayKit into our SaaS. Went from zero billing to subscriptions + usage limits in under an hour. The DX is insane.",
- },
- {
- handle: "@sarahbuilds",
- avatar: "/testimonials/placeholder-2.png",
- text: "PayKit replaced 800 lines of Stripe webhook code with a single subscribe() call. I'm never going back.",
- },
- {
- handle: "@marcuseng",
- avatar: "/testimonials/placeholder-3.png",
- text: "The fact that billing state lives in my own Postgres and I can just JOIN it with my tables is a game changer.",
- },
- {
- handle: "@devpriya",
- avatar: "/testimonials/placeholder-4.png",
- text: "We switched from Stripe's raw API to PayKit. Took 30 minutes. Our billing code went from 3 files to 1.",
- },
- {
- handle: "@joshcodes",
- avatar: "/testimonials/placeholder-5.png",
- text: "check() and report() for usage billing is exactly what I needed. No more custom middleware to gate features.",
- },
- {
- handle: "@emmaoss",
- avatar: "/testimonials/placeholder-6.png",
- text: "Open source billing that actually works. No vendor lock-in, no separate dashboard, just npm install and go.",
- },
- {
- handle: "@ryanships",
- avatar: "/testimonials/placeholder-7.png",
- text: "The type safety is incredible. Typo a plan ID and TypeScript catches it before you even run the code.",
- },
- {
- handle: "@linadev",
- avatar: "/testimonials/placeholder-8.png",
- text: "PayKit feels like what Stripe should have been for framework developers. Simple, embedded, type-safe.",
- },
-];
-
-// Split into 3 columns for masonry layout
-const columns = [
- testimonials.filter((_, i) => i % 3 === 0),
- testimonials.filter((_, i) => i % 3 === 1),
- testimonials.filter((_, i) => i % 3 === 2),
-];
-
-function TestimonialCard({ handle, text }: { handle: string; avatar: string; text: string }) {
- return (
-
- );
-}
-
-export function TestimonialsSection() {
- return (
-
-
-
-
- Loved by developers
-
-
- See what developers are saying about PayKit.
-
-
-
- {/* Masonry columns with fade at edges */}
-
-
-
- {columns.map((column, colIdx) => (
-
- {column.map((testimonial) => (
-
- ))}
-
- ))}
-
-
-
-
- );
-}
diff --git a/apps/web/src/components/sidebar-content.tsx b/apps/web/src/components/sidebar-content.tsx
index d9c17192..6850b70d 100644
--- a/apps/web/src/components/sidebar-content.tsx
+++ b/apps/web/src/components/sidebar-content.tsx
@@ -1,25 +1,25 @@
import type { Folder, Root } from "fumadocs-core/page-tree";
-import type { LucideIcon } from "lucide-react";
-import {
- Binoculars,
- Book,
- CircleHelp,
- Database,
- Gauge,
- Key,
- KeyRound,
- LucideAArrowDown,
- Mail,
- Mailbox,
- Phone,
- ScanFace,
- ShieldCheck,
- TriangleAlertIcon,
- UserCircle,
- UserSquare2,
- Users2,
-} from "lucide-react";
import type { ReactNode, SVGProps } from "react";
+import type { IconType } from "react-icons";
+import {
+ RiAccountBoxLine,
+ RiAccountCircleLine,
+ RiBookLine,
+ RiDatabase2Line,
+ RiErrorWarningLine,
+ RiInboxLine,
+ RiKey2Line,
+ RiKeyLine,
+ RiMailLine,
+ RiPhoneLine,
+ RiQuestionLine,
+ RiSearchEyeLine,
+ RiShieldCheckLine,
+ RiSortAlphabetAsc,
+ RiSpeedUpLine,
+ RiTeamLine,
+ RiUserSearchLine,
+} from "react-icons/ri";
import { Icons } from "./icons";
@@ -31,7 +31,7 @@ export interface SubpageItem {
export interface ListItem {
title: string;
href: string;
- icon: ((props?: SVGProps) => ReactNode) | LucideIcon;
+ icon: ((props?: SVGProps) => ReactNode) | IconType;
group?: boolean;
separator?: boolean;
isNew?: boolean;
@@ -42,7 +42,7 @@ export interface ListItem {
interface Content {
title: string;
href?: string;
- Icon: ((props?: SVGProps) => ReactNode) | LucideIcon;
+ Icon: ((props?: SVGProps) => ReactNode) | IconType;
isNew?: boolean;
list: ListItem[];
}
@@ -418,7 +418,7 @@ export const contents: Content[] = [
{
title: "Social Sign-On",
group: true,
- icon: LucideAArrowDown,
+ icon: RiSortAlphabetAsc,
href: "",
},
{
@@ -1090,13 +1090,13 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2,
{
title: "Other Relational Databases",
href: "/docs/adapters/other-relational-databases",
- icon: () => ,
+ icon: () => ,
},
{
group: true,
title: "Adapters",
href: "",
- icon: () => ,
+ icon: () => ,
},
{
title: "Drizzle",
@@ -1182,7 +1182,7 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2,
group: true,
title: "Others",
href: "",
- icon: () => ,
+ icon: () => ,
},
{
title: "Community Adapters",
@@ -1227,7 +1227,7 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2,
group: true,
title: "Full Stack",
href: "",
- icon: LucideAArrowDown,
+ icon: RiSortAlphabetAsc,
},
{
title: "Astro",
@@ -1269,7 +1269,7 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2,
group: true,
title: "Backend",
href: "",
- icon: LucideAArrowDown,
+ icon: RiSortAlphabetAsc,
},
{
title: "Hono",
@@ -1310,7 +1310,7 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2,
group: true,
title: "Mobile & Desktop",
href: "",
- icon: LucideAArrowDown,
+ icon: RiSortAlphabetAsc,
},
{
title: "Expo",
@@ -1342,38 +1342,38 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2,
title: "Authentication",
group: true,
href: "",
- icon: () => ,
+ icon: () => ,
},
{
title: "Two Factor",
- icon: () => ,
+ icon: () => ,
href: "/docs/plugins/2fa",
},
{
title: "Username",
- icon: () => ,
+ icon: () => ,
href: "/docs/plugins/username",
},
{
title: "Anonymous",
- icon: () => ,
+ icon: () => ,
href: "/docs/plugins/anonymous",
},
{
title: "Phone Number",
- icon: () => ,
+ icon: () => ,
href: "/docs/plugins/phone-number",
},
{
title: "Magic Link",
href: "/docs/plugins/magic-link",
- icon: () => ,
+ icon: () => ,
},
{
title: "Email OTP",
href: "/docs/plugins/email-otp",
- icon: () => ,
+ icon: () => ,
},
{
title: "Passkey",
@@ -1436,7 +1436,7 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2,
title: "Authorization",
group: true,
href: "",
- icon: () => ,
+ icon: () => ,
},
{
title: "Admin",
@@ -1458,7 +1458,7 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2,
{
title: "API Key",
href: "/docs/plugins/api-key",
- icon: () => ,
+ icon: () => ,
},
{
title: "MCP",
@@ -1494,7 +1494,7 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2,
},
{
title: "Organization",
- icon: () => ,
+ icon: () => ,
href: "/docs/plugins/organization",
},
{
@@ -1566,11 +1566,11 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2,
title: "Utility",
group: true,
href: "",
- icon: () => ,
+ icon: () => ,
},
{
title: "Bearer",
- icon: () => ,
+ icon: () => ,
href: "/docs/plugins/bearer",
},
{
@@ -1707,7 +1707,7 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2,
title: "Payments",
group: true,
href: "",
- icon: () => ,
+ icon: () => ,
},
{
title: "Stripe",
@@ -1935,7 +1935,7 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2,
{
title: "Create a Database Adapter",
href: "/docs/guides/create-a-db-adapter",
- icon: () => ,
+ icon: () => ,
},
{
title: "Browser Extension Guide",
@@ -1976,7 +1976,7 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2,
{
title: "Optimize for Performance",
href: "/docs/guides/optimizing-for-performance",
- icon: () => ,
+ icon: () => ,
},
{
title: "Migration",
@@ -2210,7 +2210,7 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2,
{
title: "Errors",
href: "/docs/reference/errors",
- icon: () => ,
+ icon: () => ,
hasSubpages: true,
subpages: [
{
@@ -2275,23 +2275,23 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2,
{
title: "Resources",
href: "/docs/reference/resources",
- icon: () => ,
+ icon: () => ,
},
{
title: "Security",
href: "/docs/reference/security",
- icon: () => ,
+ icon: () => ,
},
{
title: "Telemetry",
href: "/docs/reference/telemetry",
- icon: () => ,
+ icon: () => ,
},
{
title: "FAQ",
href: "/docs/reference/faq",
- icon: () => ,
+ icon: () => ,
},
],
},
diff --git a/apps/web/src/components/theme-switcher.tsx b/apps/web/src/components/theme-switcher.tsx
index 46393d10..5056954f 100644
--- a/apps/web/src/components/theme-switcher.tsx
+++ b/apps/web/src/components/theme-switcher.tsx
@@ -1,28 +1,30 @@
"use client";
-import { Moon, Sun } from "lucide-react";
+import type { ComponentProps } from "react";
+import { RiMoonLine, RiSunLine } from "react-icons/ri";
import { Button } from "@/components/ui/button";
import { useThemeTransition } from "@/components/use-theme-transition";
+import { cn } from "@/lib/utils";
-export function ThemeSwitcher() {
- const { activeTheme, mounted, toggleLabel, toggleTheme } = useThemeTransition();
- const buttonTheme = mounted ? activeTheme : "light";
+export function ThemeSwitcher({
+ className,
+ size = "icon",
+ variant = "ghost",
+}: Pick, "className" | "size" | "variant">) {
+ const { toggleLabel, toggleTheme } = useThemeTransition();
return (
- {buttonTheme === "dark" ? (
-
- ) : (
-
- )}
+
+
{toggleLabel}
diff --git a/apps/web/src/components/ui/accordion.tsx b/apps/web/src/components/ui/accordion.tsx
index 075a115c..ae55d76a 100644
--- a/apps/web/src/components/ui/accordion.tsx
+++ b/apps/web/src/components/ui/accordion.tsx
@@ -1,7 +1,7 @@
"use client";
import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion";
-import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
+import { RiArrowDownSLine, RiArrowUpSLine } from "react-icons/ri";
import { cn } from "@/lib/utils";
@@ -37,11 +37,11 @@ function AccordionTrigger({ className, children, ...props }: AccordionPrimitive.
{...props}
>
{children}
-
-
diff --git a/apps/web/src/components/ui/breadcrumb.tsx b/apps/web/src/components/ui/breadcrumb.tsx
index b6381016..1f4e60b9 100644
--- a/apps/web/src/components/ui/breadcrumb.tsx
+++ b/apps/web/src/components/ui/breadcrumb.tsx
@@ -1,7 +1,7 @@
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
-import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react";
import * as React from "react";
+import { RiArrowRightSLine, RiMoreLine } from "react-icons/ri";
import { cn } from "@/lib/utils";
@@ -72,7 +72,7 @@ function BreadcrumbSeparator({ children, className, ...props }: React.ComponentP
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
- {children ?? }
+ {children ?? }
);
}
@@ -86,7 +86,7 @@ function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<"span"
className={cn("flex size-5 items-center justify-center [&>svg]:size-4", className)}
{...props}
>
-
+
More
);
diff --git a/apps/web/src/components/ui/button-group.tsx b/apps/web/src/components/ui/button-group.tsx
index 2588d5b9..14e925cb 100644
--- a/apps/web/src/components/ui/button-group.tsx
+++ b/apps/web/src/components/ui/button-group.tsx
@@ -11,9 +11,9 @@ const buttonGroupVariants = cva(
variants: {
orientation: {
horizontal:
- "*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0",
+ "*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-sm! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0 [&>[data-slot]~[data-slot]]:after:border-l-0",
vertical:
- "flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
+ "flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-sm! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0 [&>[data-slot]~[data-slot]]:after:border-t-0",
},
},
defaultVariants: {
diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx
index 4d891bbe..f05da617 100644
--- a/apps/web/src/components/ui/button.tsx
+++ b/apps/web/src/components/ui/button.tsx
@@ -6,7 +6,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
- "group/button inline-flex shrink-0 items-center justify-center rounded-sm bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ "group/button inline-flex shrink-0 items-center justify-center rounded-sm bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:scale-[0.99] disabled:pointer-events-none disabled:opacity-50 aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
@@ -16,7 +16,7 @@ const buttonVariants = cva(
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
- "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
+ "text-primary/70 hover:bg-muted hover:text-primary/90 aria-expanded:bg-muted aria-expanded:text-primary/70 dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
diff --git a/apps/web/src/components/ui/calendar.tsx b/apps/web/src/components/ui/calendar.tsx
index 47d9a1e9..a58f2233 100644
--- a/apps/web/src/components/ui/calendar.tsx
+++ b/apps/web/src/components/ui/calendar.tsx
@@ -1,8 +1,8 @@
"use client";
-import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react";
import * as React from "react";
import { DayPicker, getDefaultClassNames, type DayButton, type Locale } from "react-day-picker";
+import { RiArrowDownSLine, RiArrowLeftSLine, RiArrowRightSLine } from "react-icons/ri";
import { Button, buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -118,14 +118,14 @@ function Calendar({
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
- return ;
+ return ;
}
if (orientation === "right") {
- return ;
+ return ;
}
- return ;
+ return ;
},
DayButton: ({ ...props }) => ,
WeekNumber: ({ children, ...props }) => {
diff --git a/apps/web/src/components/ui/carousel.tsx b/apps/web/src/components/ui/carousel.tsx
index 73e2e927..6af48cfe 100644
--- a/apps/web/src/components/ui/carousel.tsx
+++ b/apps/web/src/components/ui/carousel.tsx
@@ -1,8 +1,8 @@
"use client";
import useEmblaCarousel, { type UseEmblaCarouselType } from "embla-carousel-react";
-import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import * as React from "react";
+import { RiArrowLeftSLine, RiArrowRightSLine } from "react-icons/ri";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -184,7 +184,7 @@ function CarouselPrevious({
onClick={scrollPrev}
{...props}
>
-
+
Previous slide
);
@@ -214,7 +214,7 @@ function CarouselNext({
onClick={scrollNext}
{...props}
>
-
+
Next slide
);
diff --git a/apps/web/src/components/ui/checkbox.tsx b/apps/web/src/components/ui/checkbox.tsx
index 8180b82a..cac7705e 100644
--- a/apps/web/src/components/ui/checkbox.tsx
+++ b/apps/web/src/components/ui/checkbox.tsx
@@ -1,7 +1,7 @@
"use client";
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox";
-import { CheckIcon } from "lucide-react";
+import { RiCheckLine } from "react-icons/ri";
import { cn } from "@/lib/utils";
@@ -19,7 +19,7 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
-
+
);
diff --git a/apps/web/src/components/ui/code-block-content.tsx b/apps/web/src/components/ui/code-block-content.tsx
index 678e289f..c767e477 100644
--- a/apps/web/src/components/ui/code-block-content.tsx
+++ b/apps/web/src/components/ui/code-block-content.tsx
@@ -1,17 +1,11 @@
import { highlight } from "fumadocs-core/highlight";
import type { HighlightOptions } from "fumadocs-core/highlight";
import type { ComponentProps } from "react";
-import type { BundledLanguage, BundledTheme } from "shiki";
+import type { BundledLanguage } from "shiki";
import { CodeBlock, type CodeBlockProps, Pre } from "@/components/ui/code-block";
+import { shikiHighlightOptions, shikiThemes } from "@/lib/shiki-themes";
import { cn } from "@/lib/utils";
-const defaultThemes = {
- themes: {
- light: "github-light" satisfies BundledTheme,
- dark: "one-dark-pro" satisfies BundledTheme,
- },
- defaultColor: false as const,
-};
const defaultCodeBlockProps: CodeBlockProps = {
className:
@@ -40,18 +34,33 @@ function createPre(codeblock: CodeBlockProps, allowCopy: boolean) {
export async function InlineCode({ lang, code }: { lang: string; code: string }) {
const { codeToTokens } = await import("shiki");
- const { tokens } = await codeToTokens(code, {
- lang: lang as BundledLanguage,
- theme: "one-dark-pro",
- });
+ const [{ tokens: lightTokens }, { tokens: darkTokens }] = await Promise.all([
+ codeToTokens(code, {
+ lang: lang as BundledLanguage,
+ theme: shikiThemes.light,
+ }),
+ codeToTokens(code, {
+ lang: lang as BundledLanguage,
+ theme: shikiThemes.dark,
+ }),
+ ]);
return (
- {tokens[0]?.map((token, i) => (
-
- {token.content}
-
- ))}
+
+ {lightTokens[0]?.map((token, i) => (
+
+ {token.content}
+
+ ))}
+
+
+ {darkTokens[0]?.map((token, i) => (
+
+ {token.content}
+
+ ))}
+
);
}
@@ -73,7 +82,7 @@ export async function CodeBlockContent({
const highlighted = await highlight(code, {
lang,
- ...defaultThemes,
+ ...shikiHighlightOptions,
...options,
components: {
pre: createPre(merged, allowCopy),
diff --git a/apps/web/src/components/ui/code-block.tsx b/apps/web/src/components/ui/code-block.tsx
index 575a9b43..87e75206 100644
--- a/apps/web/src/components/ui/code-block.tsx
+++ b/apps/web/src/components/ui/code-block.tsx
@@ -1,5 +1,5 @@
"use client";
-import { Check, Copy } from "lucide-react";
+
import type {
ButtonHTMLAttributes,
ComponentProps,
@@ -9,6 +9,7 @@ import type {
RefObject,
} from "react";
import { createContext, forwardRef, useCallback, useContext, useMemo, useRef } from "react";
+import { RiCheckLine, RiFileCopyLine } from "react-icons/ri";
import { buttonVariants } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -93,7 +94,7 @@ export function CodeBlock({
const areaRef = useRef(null);
allowCopy ??= !isTab;
const bg = cn(
- "bg-fd-secondary",
+ "bg-secondary",
keepBackground && "bg-(--shiki-light-bg) dark:bg-(--shiki-dark-bg)",
);
const onCopy = useCallback(() => {
@@ -111,15 +112,15 @@ export function CodeBlock({
dir="ltr"
{...props}
className={cn(
- isTab ? [bg, "rounded-lg"] : "my-4 rounded-lg bg-fd-card",
- "group shiki relative border shadow-sm outline-none not-prose overflow-hidden text-sm",
+ isTab ? [bg, "rounded-lg"] : "my-4 rounded-lg bg-card",
+ "group shiki relative overflow-hidden border text-sm shadow-sm outline-none",
props.className,
)}
>
{title ? (
@@ -140,7 +141,7 @@ export function CodeBlock({
) : (
Actions({
- className: "absolute top-1 right-1 z-2 text-fd-muted-foreground",
+ className: "absolute top-1 right-1 z-2 text-muted-foreground",
children: allowCopy && ,
})
)}
@@ -149,7 +150,7 @@ export function CodeBlock({
{...viewportProps}
className={cn(
!isTab && [bg, "rounded-none border border-x-0 border-b-0"],
- "text-sm overflow-auto max-h-[600px] bg-fd-muted/50 fd-scroll-container",
+ "max-h-[600px] overflow-auto bg-muted/50 text-sm",
viewportProps.className,
!title && "border-t-0",
)}
@@ -195,8 +196,10 @@ function CopyButton({
onClick={onClick}
{...props}
>
-
-
+
+
);
}
@@ -209,7 +212,7 @@ export function CodeBlockTabs({ ref, ...props }: ComponentProps) {
ref={mergeRefs(containerRef, ref)}
{...props}
className={cn(
- "bg-fd-card p-1 rounded-lg border overflow-hidden",
+ "overflow-hidden rounded-lg border bg-card p-1",
!nested && "my-4",
props.className,
)}
@@ -233,7 +236,7 @@ export function CodeBlockTabsList(props: ComponentProps) {
@@ -247,11 +250,11 @@ export function CodeBlockTabsTrigger({ children, ...props }: ComponentProps
-
+
{children}
);
@@ -281,16 +284,16 @@ export const CodeBlockOld = forwardRef(
ref={ref}
{...props}
className={cn(
- "not-prose group fd-codeblock relative my-6 overflow-hidden rounded-lg border bg-fd-secondary/50 text-sm",
+ "group relative my-6 overflow-hidden rounded-lg border bg-secondary/50 text-sm",
keepBackground && "bg-[var(--shiki-light-bg)] dark:bg-[var(--shiki-dark-bg)]",
props.className,
)}
>
{title ? (
-
+
{icon ? (
(
{typeof icon !== "string" ? icon : null}
) : null}
-
{title}
+
{title}
{allowCopy ?
: null}
) : (
diff --git a/apps/web/src/components/ui/combobox.tsx b/apps/web/src/components/ui/combobox.tsx
index 6addd25d..dc5e635c 100644
--- a/apps/web/src/components/ui/combobox.tsx
+++ b/apps/web/src/components/ui/combobox.tsx
@@ -1,8 +1,8 @@
"use client";
import { Combobox as ComboboxPrimitive } from "@base-ui/react";
-import { ChevronDownIcon, XIcon, CheckIcon } from "lucide-react";
import * as React from "react";
+import { RiArrowDownSLine, RiCheckLine, RiCloseLine } from "react-icons/ri";
import { Button } from "@/components/ui/button";
import {
@@ -27,7 +27,7 @@ function ComboboxTrigger({ className, children, ...props }: ComboboxPrimitive.Tr
{...props}
>
{children}
-
+
);
}
@@ -40,7 +40,7 @@ function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
className={cn(className)}
{...props}
>
-
+
);
}
@@ -143,7 +143,7 @@ function ComboboxItem({ className, children, ...props }: ComboboxPrimitive.Item.
}
>
-
+
);
@@ -232,7 +232,7 @@ function ComboboxChip({
className="-ml-1 opacity-50 hover:opacity-100"
data-slot="combobox-chip-remove"
>
-
+
)}
diff --git a/apps/web/src/components/ui/command.tsx b/apps/web/src/components/ui/command.tsx
index 78e76940..04c49fbc 100644
--- a/apps/web/src/components/ui/command.tsx
+++ b/apps/web/src/components/ui/command.tsx
@@ -1,8 +1,8 @@
"use client";
import { Command as CommandPrimitive } from "cmdk";
-import { SearchIcon, CheckIcon } from "lucide-react";
import * as React from "react";
+import { RiCheckLine, RiSearchLine } from "react-icons/ri";
import {
Dialog,
@@ -73,7 +73,7 @@ function CommandInput({
{...props}
/>
-
+
@@ -150,7 +150,7 @@ function CommandItem({
{...props}
>
{children}
-
+
);
}
diff --git a/apps/web/src/components/ui/context-menu.tsx b/apps/web/src/components/ui/context-menu.tsx
index d6b56246..2ce2d233 100644
--- a/apps/web/src/components/ui/context-menu.tsx
+++ b/apps/web/src/components/ui/context-menu.tsx
@@ -1,8 +1,8 @@
"use client";
import { ContextMenu as ContextMenuPrimitive } from "@base-ui/react/context-menu";
-import { ChevronRightIcon, CheckIcon } from "lucide-react";
import * as React from "react";
+import { RiArrowRightSLine, RiCheckLine } from "react-icons/ri";
import { cn } from "@/lib/utils";
@@ -135,7 +135,7 @@ function ContextMenuSubTrigger({
{...props}
>
{children}
-
+
);
}
@@ -173,7 +173,7 @@ function ContextMenuCheckboxItem({
>
-
+
{children}
@@ -205,7 +205,7 @@ function ContextMenuRadioItem({
>
-
+
{children}
diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx
index 3b29b13d..41745c24 100644
--- a/apps/web/src/components/ui/dialog.tsx
+++ b/apps/web/src/components/ui/dialog.tsx
@@ -1,8 +1,8 @@
"use client";
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
-import { XIcon } from "lucide-react";
import * as React from "react";
+import { RiCloseLine } from "react-icons/ri";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -61,7 +61,7 @@ function DialogContent({
data-slot="dialog-close"
render={ }
>
-
+
Close
)}
diff --git a/apps/web/src/components/ui/dropdown-menu.tsx b/apps/web/src/components/ui/dropdown-menu.tsx
index d926a98c..72e75160 100644
--- a/apps/web/src/components/ui/dropdown-menu.tsx
+++ b/apps/web/src/components/ui/dropdown-menu.tsx
@@ -1,8 +1,8 @@
"use client";
import { Menu as MenuPrimitive } from "@base-ui/react/menu";
-import { ChevronRightIcon, CheckIcon } from "lucide-react";
import * as React from "react";
+import { RiArrowRightSLine, RiCheckLine } from "react-icons/ri";
import { cn } from "@/lib/utils";
@@ -119,7 +119,7 @@ function DropdownMenuSubTrigger({
{...props}
>
{children}
-
+
);
}
@@ -173,7 +173,7 @@ function DropdownMenuCheckboxItem({
data-slot="dropdown-menu-checkbox-item-indicator"
>
-
+
{children}
@@ -208,7 +208,7 @@ function DropdownMenuRadioItem({
data-slot="dropdown-menu-radio-item-indicator"
>
-
+
{children}
diff --git a/apps/web/src/components/ui/dynamic-code-block.tsx b/apps/web/src/components/ui/dynamic-code-block.tsx
index 1f394382..3d7ed434 100644
--- a/apps/web/src/components/ui/dynamic-code-block.tsx
+++ b/apps/web/src/components/ui/dynamic-code-block.tsx
@@ -3,20 +3,12 @@ import type { HighlightOptions } from "fumadocs-core/highlight";
import { useShiki } from "fumadocs-core/highlight/client";
import type { ComponentProps, FC } from "react";
import { createContext, Suspense, use } from "react";
-import type { BundledTheme } from "shiki";
import type { CodeBlockProps } from "@/components/ui/code-block";
import { CodeBlock, Pre } from "@/components/ui/code-block";
+import { shikiHighlightOptions } from "@/lib/shiki-themes";
import { cn } from "@/lib/utils";
-const defaultThemes = {
- themes: {
- light: "github-light" satisfies BundledTheme,
- dark: "one-dark-pro" satisfies BundledTheme,
- },
- defaultColor: false as const,
-};
-
export interface DynamicCodeblockProps {
lang: string;
code: string;
@@ -67,7 +59,7 @@ export function DynamicCodeBlock({
}: DynamicCodeblockProps) {
const shikiOptions = {
lang,
- ...defaultThemes,
+ ...shikiHighlightOptions,
...options,
components: {
pre: DefaultPre,
diff --git a/apps/web/src/components/ui/frame-corners.tsx b/apps/web/src/components/ui/frame-corners.tsx
new file mode 100644
index 00000000..0855cc10
--- /dev/null
+++ b/apps/web/src/components/ui/frame-corners.tsx
@@ -0,0 +1,12 @@
+import { cn } from "@/lib/utils";
+
+export function FrameCorners({ className }: { className?: string }) {
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/ui/input-otp.tsx b/apps/web/src/components/ui/input-otp.tsx
index 64a7c98d..92a7b7c6 100644
--- a/apps/web/src/components/ui/input-otp.tsx
+++ b/apps/web/src/components/ui/input-otp.tsx
@@ -1,8 +1,8 @@
"use client";
import { OTPInput, OTPInputContext } from "input-otp";
-import { MinusIcon } from "lucide-react";
import * as React from "react";
+import { RiSubtractLine } from "react-icons/ri";
import { cn } from "@/lib/utils";
@@ -78,7 +78,7 @@ function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
role="separator"
{...props}
>
-
+
);
}
diff --git a/apps/web/src/components/ui/menubar.tsx b/apps/web/src/components/ui/menubar.tsx
index bedfd43f..a63ce43f 100644
--- a/apps/web/src/components/ui/menubar.tsx
+++ b/apps/web/src/components/ui/menubar.tsx
@@ -2,8 +2,8 @@
import { Menu as MenuPrimitive } from "@base-ui/react/menu";
import { Menubar as MenubarPrimitive } from "@base-ui/react/menubar";
-import { CheckIcon } from "lucide-react";
import * as React from "react";
+import { RiCheckLine } from "react-icons/ri";
import {
DropdownMenu,
@@ -121,7 +121,7 @@ function MenubarCheckboxItem({
>
-
+
{children}
@@ -153,7 +153,7 @@ function MenubarRadioItem({
>
-
+
{children}
diff --git a/apps/web/src/components/ui/native-select.tsx b/apps/web/src/components/ui/native-select.tsx
index cc077ccf..9cb9ab56 100644
--- a/apps/web/src/components/ui/native-select.tsx
+++ b/apps/web/src/components/ui/native-select.tsx
@@ -1,5 +1,5 @@
-import { ChevronDownIcon } from "lucide-react";
import * as React from "react";
+import { RiArrowDownSLine } from "react-icons/ri";
import { cn } from "@/lib/utils";
@@ -23,7 +23,7 @@ function NativeSelect({ className, size = "default", ...props }: NativeSelectPro
className="h-8 w-full min-w-0 appearance-none rounded-lg border border-input bg-transparent py-1 pr-8 pl-2.5 text-sm transition-colors outline-none select-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-[size=sm]:py-0.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40"
{...props}
/>
-
{children}{" "}
-
@@ -75,6 +83,7 @@ function NavigationMenuTrigger({
);
}
+/** Animated content panel rendered inside the navigation menu viewport. */
function NavigationMenuContent({ className, ...props }: NavigationMenuPrimitive.Content.Props) {
return (
) {
return (
) {
);
}
+/** List container for pagination items. */
function PaginationContent({ className, ...props }: React.ComponentProps<"ul">) {
return (
)
);
}
+/** Individual pagination list item. */
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return ;
}
@@ -35,6 +41,10 @@ type PaginationLinkProps = {
} & Pick, "size"> &
React.ComponentProps<"a">;
+/** Pagination link rendered through Button.
+ *
+ * @param props - Link props plus `isActive` and optional Button `size`.
+ */
function PaginationLink({ className, isActive, size = "icon", ...props }: PaginationLinkProps) {
return (
-
+
{text}
);
}
+/** Next-page pagination link with accessible label. */
function PaginationNext({
className,
text = "Next",
@@ -85,11 +97,12 @@ function PaginationNext({
{...props}
>
{text}
-
+
);
}
+/** Non-interactive pagination ellipsis hidden from assistive tech. */
function PaginationEllipsis({ className, ...props }: React.ComponentProps<"span">) {
return (
-
+
More pages
);
diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx
index 46f4f335..ce0b224e 100644
--- a/apps/web/src/components/ui/select.tsx
+++ b/apps/web/src/components/ui/select.tsx
@@ -1,8 +1,8 @@
"use client";
import { Select as SelectPrimitive } from "@base-ui/react/select";
-import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react";
import * as React from "react";
+import { RiArrowDownSLine, RiArrowUpSLine, RiCheckLine } from "react-icons/ri";
import { cn } from "@/lib/utils";
@@ -48,7 +48,7 @@ function SelectTrigger({
>
{children}
}
+ render={ }
/>
);
@@ -124,7 +124,7 @@ function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Prop
}
>
-
+
);
@@ -153,7 +153,7 @@ function SelectScrollUpButton({
)}
{...props}
>
-
+
);
}
@@ -171,7 +171,7 @@ function SelectScrollDownButton({
)}
{...props}
>
-
+
);
}
diff --git a/apps/web/src/components/ui/sheet.tsx b/apps/web/src/components/ui/sheet.tsx
index 5971505d..6ffb02c0 100644
--- a/apps/web/src/components/ui/sheet.tsx
+++ b/apps/web/src/components/ui/sheet.tsx
@@ -1,8 +1,8 @@
"use client";
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog";
-import { XIcon } from "lucide-react";
import * as React from "react";
+import { RiCloseLine } from "react-icons/ri";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -64,7 +64,7 @@ function SheetContent({
data-slot="sheet-close"
render={ }
>
-
+
Close
)}
diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx
index 0542e54e..6b0b842a 100644
--- a/apps/web/src/components/ui/sidebar.tsx
+++ b/apps/web/src/components/ui/sidebar.tsx
@@ -3,8 +3,8 @@
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
-import { PanelLeftIcon } from "lucide-react";
import * as React from "react";
+import { RiSideBarLine } from "react-icons/ri";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -260,7 +260,7 @@ function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps
-
+
Toggle Sidebar
);
diff --git a/apps/web/src/components/ui/sonner.tsx b/apps/web/src/components/ui/sonner.tsx
index de3aadfd..a7a9280a 100644
--- a/apps/web/src/components/ui/sonner.tsx
+++ b/apps/web/src/components/ui/sonner.tsx
@@ -1,15 +1,21 @@
"use client";
-import {
- CircleCheckIcon,
- InfoIcon,
- TriangleAlertIcon,
- OctagonXIcon,
- Loader2Icon,
-} from "lucide-react";
import { useTheme } from "next-themes";
-import { Toaster as Sonner, type ToasterProps } from "sonner";
+import {
+ RiCheckboxCircleLine,
+ RiCloseCircleLine,
+ RiErrorWarningLine,
+ RiInformationLine,
+ RiLoader4Line,
+} from "react-icons/ri";
+import { Toaster as Sonner } from "sonner";
+import type { ToasterProps } from "sonner";
+/** Renders the app toast provider with theme-aware Sonner defaults.
+ *
+ * @param props - Sonner toaster props spread onto the underlying component.
+ * @returns JSX.Element
+ */
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
@@ -18,11 +24,11 @@ const Toaster = ({ ...props }: ToasterProps) => {
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
- success: ,
- info: ,
- warning: ,
- error: ,
- loading: ,
+ success: ,
+ info: ,
+ warning: ,
+ error: ,
+ loading: ,
}}
style={
{
diff --git a/apps/web/src/components/ui/spinner.tsx b/apps/web/src/components/ui/spinner.tsx
index 055ac767..bec569ab 100644
--- a/apps/web/src/components/ui/spinner.tsx
+++ b/apps/web/src/components/ui/spinner.tsx
@@ -1,10 +1,10 @@
-import { Loader2Icon } from "lucide-react";
+import { RiLoader4Line } from "react-icons/ri";
import { cn } from "@/lib/utils";
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
- (null);
@@ -37,7 +50,7 @@ export function BrandMenu({
try {
await navigator.clipboard.writeText(brandAssets[asset]);
toast.success(`${asset} SVG code copied to clipboard.`, {
- icon: ,
+ icon: ,
});
} catch {
toast.error("Failed to copy to clipboard.");
@@ -56,7 +69,10 @@ export function BrandMenu({
aria-label="PayKit home"
className={cn("flex items-center py-1.5", linkClassName)}
>
-
+
}
/>
@@ -73,7 +89,7 @@ export function BrandMenu({
Copy logo as SVG
copyAsSvg("Wordmark")}>
- Copy wordmark as SVG
+ Copy wordmark as SVG
diff --git a/apps/web/src/components/web/footer.tsx b/apps/web/src/components/web/footer.tsx
index a7b8e19c..c46de8cc 100644
--- a/apps/web/src/components/web/footer.tsx
+++ b/apps/web/src/components/web/footer.tsx
@@ -1,10 +1,18 @@
-import { Github } from "lucide-react";
import Link from "next/link";
import { Icons } from "@/components/icons";
import { ThemeToggle } from "@/components/theme-toggle";
import { URLs } from "@/lib/consts";
+/**
+ * Site footer with watermark artwork, decorative grid, copyright, social links,
+ * and theme toggle.
+ *
+ * Decorative elements are aria-hidden; links provide aria labels.
+ *
+ * @component
+ * @returns JSX.Element
+ */
export default function Footer() {
return (
@@ -55,7 +63,7 @@ export default function Footer() {
aria-label="GitHub"
className="text-foreground/30 hover:text-foreground/60 transition-colors"
>
-
+
|
diff --git a/apps/web/src/components/web/hero-code-block.tsx b/apps/web/src/components/web/hero-code-block.tsx
index 01d355d9..40c66d21 100644
--- a/apps/web/src/components/web/hero-code-block.tsx
+++ b/apps/web/src/components/web/hero-code-block.tsx
@@ -1,11 +1,11 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
-import { ChevronLeft, Loader2, Terminal } from "lucide-react";
import type { ReactNode } from "react";
import { useCallback, useState } from "react";
+import { RiLoader4Line } from "react-icons/ri";
-import { Button } from "@/components/ui/button";
+import { FrameCorners } from "@/components/ui/frame-corners";
import { cn } from "@/lib/utils";
type View = "code" | "terminal";
@@ -13,10 +13,10 @@ type View = "code" | "terminal";
type Segment = { text: string; color?: string };
type PushStep = { segments: Segment[]; type: string; delay?: number };
-const bar = "text-white/15";
-const normal = "text-white/85";
-const green = "text-emerald-400";
-const purple = "text-violet-400";
+const bar = "text-muted-foreground/30";
+const normal = "text-foreground/85";
+const green = "text-foreground";
+const purple = "text-muted-foreground";
const pushSteps: PushStep[] = [
{
@@ -133,75 +133,74 @@ export function HeroCodeBlock({
setPushing(false);
}, [pushing]);
- const backToCode = useCallback(() => {
+ const selectCodeTab = useCallback((tab: "plans" | "config") => {
+ setActiveTab(tab);
setView("code");
- setTerminalLines([]);
}, []);
+ const selectTerminalTab = useCallback(() => {
+ if (view === "terminal") return;
+
+ void runPush();
+ }, [runPush, view]);
+
return (
-
-
+
+
+
{/* Tab bar */}
- {view === "code" ? (
- <>
- setActiveTab("plans")}
- className={cn(
- "relative px-3.5 py-2 text-sm transition-colors",
- activeTab === "plans"
- ? "text-foreground/80"
- : "text-foreground/40 hover:text-foreground/60",
- )}
- >
- products.ts
- {activeTab === "plans" && (
-
- )}
-
- setActiveTab("config")}
- className={cn(
- "relative px-3.5 py-2 text-sm transition-colors",
- activeTab === "config"
- ? "text-foreground/80"
- : "text-foreground/40 hover:text-foreground/60",
- )}
- >
- paykit.ts
- {activeTab === "config" && (
-
- )}
-
- >
- ) : (
- Terminal
- )}
+ selectCodeTab("plans")}
+ className={cn(
+ "relative px-3.5 py-2 text-sm transition-colors",
+ view === "code" && activeTab === "plans"
+ ? "text-foreground/80"
+ : "text-foreground/40 hover:text-foreground/60",
+ )}
+ >
+ products.ts
+ {view === "code" && activeTab === "plans" && (
+
+ )}
+
+ selectCodeTab("config")}
+ className={cn(
+ "relative px-3.5 py-2 text-sm transition-colors",
+ view === "code" && activeTab === "config"
+ ? "text-foreground/80"
+ : "text-foreground/40 hover:text-foreground/60",
+ )}
+ >
+ paykit.ts
+ {view === "code" && activeTab === "config" && (
+
+ )}
+
+
+ terminal
+ {view === "terminal" && (
+
+ )}
+
{/* Content — fixed height */}
- {/* Push / back button */}
-
- void runPush() : backToCode}
- disabled={pushing}
- className={"not-hover:bg-secondary/80!"}
- >
- {view === "code" ? (
-
- ) : (
-
- )}
- {view === "code" ? "Terminal" : "Back to code"}
-
-
{view === "code" ? (
<>
@@ -213,7 +212,7 @@ export function HeroCodeBlock({
>
) : (
-
+
{terminalLines.map((line, i) => (
{pushing && terminalLines.length > 0 && (
-
+
)}
)}
diff --git a/apps/web/src/components/web/hero-title.tsx b/apps/web/src/components/web/hero-title.tsx
index 1d9b7e5a..b4226dc8 100644
--- a/apps/web/src/components/web/hero-title.tsx
+++ b/apps/web/src/components/web/hero-title.tsx
@@ -1,9 +1,9 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
-import { Check, ChevronRight, Copy, Sparkle } from "lucide-react";
import Link from "next/link";
import { useCallback, useState } from "react";
+import { RiArrowRightSLine, RiCheckLine, RiFileCopyLine } from "react-icons/ri";
import { Button } from "../ui/button";
@@ -19,22 +19,13 @@ export function HeroTitle() {
return (
-
-
-
-
- Own your payments
-
-
-
+
+
The billing framework
for TypeScript
-
+
Define plans and features in code. PayKit handles Stripe, webhooks, and usage state - runs
inside your app.
@@ -44,41 +35,20 @@ export function HeroTitle() {
render={
}
nativeButton={false}
size="lg"
- className="px-4 h-9.5"
+ className="h-9.5 px-5"
variant="default"
>
Read Docs
setHovered(true)}
onMouseLeave={() => setHovered(false)}
- className="group relative gap-1.5 rounded-none border-transparent pr-3.5 h-9.5 text-xs font-medium text-neutral-600 hover:bg-transparent sm:text-sm dark:text-neutral-400 dark:text-foreground/75 dark:hover:bg-transparent"
+ className="h-9.5 pr-4"
>
- {/* Diagonal lines background */}
-
- {/* Top border */}
-
- {/* Bottom border */}
-
- {/* Left border */}
-
- {/* Right border */}
-
-
+
{copied ? (
-
+
) : hovered ? (
-
+
) : (
-
+
)}
- npx paykitjs init
+ npx paykitjs init
diff --git a/apps/web/src/env.js b/apps/web/src/env.js
index 96eac3ea..f336e399 100644
--- a/apps/web/src/env.js
+++ b/apps/web/src/env.js
@@ -8,12 +8,22 @@ export const env = createEnv({
RESEND_FROM_EMAIL: z.string().email().default("contact@paykit.sh"),
RESEND_TO_EMAIL: z.string().email().default("contact@paykit.sh"),
},
- client: {},
+ client: {
+ NEXT_PUBLIC_APP_URL: z
+ .string()
+ .url()
+ .default(
+ process.env.NODE_ENV === "development" ? "http://localhost:3000" : "https://paykit.sh",
+ ),
+ },
runtimeEnv: {
NODE_ENV: process.env.NODE_ENV,
RESEND_API_KEY: process.env.RESEND_API_KEY,
RESEND_FROM_EMAIL: process.env.RESEND_FROM_EMAIL,
RESEND_TO_EMAIL: process.env.RESEND_TO_EMAIL,
+ NEXT_PUBLIC_APP_URL:
+ process.env.NEXT_PUBLIC_SITE_URL ??
+ (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : undefined),
},
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
emptyStringAsUndefined: true,
diff --git a/apps/web/src/lib/consts.ts b/apps/web/src/lib/consts.ts
index 8065ac03..d4e967f8 100644
--- a/apps/web/src/lib/consts.ts
+++ b/apps/web/src/lib/consts.ts
@@ -1,5 +1,7 @@
import type { FAQPage, Organization, SoftwareApplication, WebSite, WithContext } from "schema-dts";
+import { env } from "@/env";
+
export const OG_IMAGE_PATH = "/brand/og.png";
export const SITE_NAME = "PayKit";
@@ -12,7 +14,7 @@ export const OG_DESCRIPTION =
"Define plans and features in code. Handles Stripe, webhooks, and usage state. Runs inside your app, writes to your database. Open source.";
export const URLs = {
- site: "https://paykit.sh",
+ site: env.NEXT_PUBLIC_APP_URL,
githubOrg: "https://github.com/getpaykit",
githubRepo: "https://github.com/getpaykit/paykit",
roadmap: "https://github.com/orgs/getpaykit/projects/1",
@@ -23,8 +25,6 @@ export const URLs = {
authorX: "https://x.com/maxktz",
} as const;
-export const VERSION_TEXT = "v0.1 beta";
-
export const websiteSchema: WithContext
= {
"@context": "https://schema.org",
"@type": "WebSite",
diff --git a/apps/web/src/lib/lucide-react-remix-shim.ts b/apps/web/src/lib/lucide-react-remix-shim.ts
new file mode 100644
index 00000000..58367c66
--- /dev/null
+++ b/apps/web/src/lib/lucide-react-remix-shim.ts
@@ -0,0 +1,38 @@
+export {
+ RiComputerLine as Airplay,
+ RiCheckLine as Check,
+ RiArrowDownSLine as ChevronDown,
+ RiArrowLeftSLine as ChevronLeft,
+ RiArrowRightSLine as ChevronRight,
+ RiExpandUpDownLine as ChevronsUpDown,
+ RiCheckboxCircleLine as CircleCheck,
+ RiCloseCircleLine as CircleX,
+ RiFileCopyLine as Clipboard,
+ RiFileCopyLine as Copy,
+ RiCheckLine as CopyCheckIcon,
+ RiEditLine as Edit,
+ RiExternalLinkLine as ExternalLink,
+ RiExternalLinkLine as ExternalLinkIcon,
+ RiFileLine as FileIcon,
+ RiFolderLine as FolderIcon,
+ RiFolderOpenLine as FolderOpen,
+ RiGitForkLine as GitFork,
+ RiHashtag as Hash,
+ RiHomeLine as HomeIcon,
+ RiInformationLine as Info,
+ RiTranslate2 as Languages,
+ RiLightbulbLine as Lightbulb,
+ RiLink as LinkIcon,
+ RiMoonLine as Moon,
+ RiSearchLine as Search,
+ RiSearchLine as SearchIcon,
+ RiSideBarLine as Sidebar,
+ RiSideBarLine as SidebarIcon,
+ RiStarLine as Star,
+ RiSunLine as Sun,
+ RiAlignLeft as Text,
+ RiAlignLeft as TextIcon,
+ RiErrorWarningLine as TriangleAlert,
+ RiCloseLine as X,
+ RiCloseLine as XIcon,
+} from "react-icons/ri";
diff --git a/apps/web/src/lib/shiki-themes.ts b/apps/web/src/lib/shiki-themes.ts
index 4b6c50b0..5b1671d2 100644
--- a/apps/web/src/lib/shiki-themes.ts
+++ b/apps/web/src/lib/shiki-themes.ts
@@ -1,6 +1,8 @@
+import type { BundledTheme } from "shiki";
+
export const shikiThemes = {
- light: "github-light",
- dark: "one-dark-pro",
+ light: "github-light" satisfies BundledTheme,
+ dark: "github-dark" satisfies BundledTheme,
} as const;
export const shikiHighlightOptions = {
diff --git a/apps/web/src/lib/shiki-themes/shiki-aura-theme.ts b/apps/web/src/lib/shiki-themes/shiki-aura-theme.ts
new file mode 100644
index 00000000..c752885e
--- /dev/null
+++ b/apps/web/src/lib/shiki-themes/shiki-aura-theme.ts
@@ -0,0 +1,336 @@
+// NOTE: This theme is intentionally kept as a local Shiki theme because Aura is
+// not bundled with Shiki. Source: https://github.com/daltonmenezes/aura-theme
+
+import type { ThemeRegistrationRaw } from "shiki";
+
+/**
+ * Aura Dark theme for Shiki, converted from the official VS Code theme.
+ * @see https://github.com/daltonmenezes/aura-theme
+ */
+const auraDark: ThemeRegistrationRaw = {
+ name: "aura-dark",
+ type: "dark",
+ colors: {
+ focusBorder: "#a394f033",
+ foreground: "#edecee",
+ errorForeground: "#ff6767",
+ "widget.shadow": "#0f0f0f",
+ "selection.background": "#3d375e7f",
+ "titleBar.activeBackground": "#121016",
+ "titleBar.border": "#000000",
+ "titleBar.inactiveBackground": "#2d2b38",
+ "statusBar.background": "#121016",
+ "statusBar.foreground": "#adacae",
+ "statusBar.border": "#000000",
+ "statusBar.debuggingForeground": "#15141b",
+ "statusBar.debuggingBackground": "#a19c77",
+ "statusBarItem.activeBackground": "#a277ff",
+ "dropdown.background": "#15141b",
+ "dropdown.border": "#3b334b",
+ "dropdown.foreground": "#cdccce",
+ "input.background": "#15141b",
+ "input.border": "#3b334b",
+ "input.foreground": "#cdccce",
+ "input.placeholderForeground": "#af8aff7f",
+ "inputOption.activeBorder": "#a277ff",
+ "button.background": "#61ffca",
+ "button.foreground": "#15141b",
+ "button.hoverBackground": "#49c29a",
+ "list.activeSelectionBackground": "#2e2b38",
+ "list.activeSelectionForeground": "#a277ff",
+ "list.focusBackground": "#3b334b",
+ "list.focusForeground": "#a277ff",
+ "list.highlightForeground": "#ffca85",
+ "list.hoverBackground": "#3b334b",
+ "list.hoverForeground": "#edecee",
+ "list.inactiveSelectionBackground": "#2e2b38",
+ "list.inactiveSelectionForeground": "#a277ff",
+ "editor.background": "#15141b",
+ "editor.foreground": "#edecee",
+ "editorLineNumber.foreground": "#a394f033",
+ "editorIndentGuide.activeBackground": "#6d6d6d",
+ "editorCursor.foreground": "#a277ff",
+ "editor.selectionBackground": "#3d375e7f",
+ "editor.selectionHighlightBackground": "#3d375e7f",
+ "editor.inactiveSelectionBackground": "#3d375e7f",
+ "editor.wordHighlightBackground": "#3d375e7f",
+ "editor.wordHighlightStrongBackground": "#3d375e7f",
+ "editor.findMatchBackground": "#3d375e7f",
+ "editor.findMatchHighlightBackground": "#3d375e7f",
+ "editor.findRangeHighlightBackground": "#3d375e7f",
+ "editor.lineHighlightBackground": "#a394f033",
+ "editor.lineHighlightBorder": "#a394f000",
+ "editor.stackFrameHighlightBackground": "#353424",
+ "editorInlayHint.background": "#2e2b38",
+ "editorInlayHint.foreground": "#cdccce",
+ "editorLink.activeForeground": "#a277ff",
+ "editorWhitespace.foreground": "#2d2d2d",
+ "editorIndentGuide.background": "#2d2d2d",
+ "editorBracketMatch.border": "#a277ff",
+ "editorError.foreground": "#ff6767",
+ "editorError.border": "#ffffff00",
+ "editorWarning.foreground": "#ffca85",
+ "editorWarning.border": "#ffffff00",
+ "editorGutter.modifiedBackground": "#ffca85",
+ "editorGutter.addedBackground": "#61ffca",
+ "editorGutter.deletedBackground": "#ff6767",
+ "editorWidget.background": "#121016",
+ "editorWidget.border": "#2d2d2d",
+ "editorSuggestWidget.background": "#121016",
+ "editorSuggestWidget.border": "#2d2d2d",
+ "editorSuggestWidget.foreground": "#cdccce",
+ "editorSuggestWidget.highlightForeground": "#61ffca",
+ "editorSuggestWidget.selectedBackground": "#4d4d4d",
+ "editorHoverWidget.background": "#121016",
+ "editorHoverWidget.border": "#2d2d2d",
+ "editorGroup.border": "#000000",
+ "editorGroup.dropBackground": "#3d375e7f",
+ "editorGroupHeader.tabsBackground": "#15141b",
+ "editorGroupHeader.tabsBorder": "#000000",
+ "tab.activeBackground": "#00000000",
+ "tab.activeForeground": "#61ffca",
+ "tab.border": "#000000",
+ "tab.inactiveBackground": "#15141b",
+ "tab.inactiveForeground": "#6d6d6d",
+ "tab.activeBorderTop": "#61ffca",
+ "panel.border": "#000000",
+ "panelTitle.activeBorder": "#61ffca",
+ "panelTitle.activeForeground": "#cdccce",
+ "activityBar.background": "#15141b",
+ "activityBar.foreground": "#61ffca",
+ "activityBar.inactiveForeground": "#525156",
+ "activityBar.border": "#000000",
+ "activityBarBadge.background": "#a277ff",
+ "activityBarBadge.foreground": "#15141b",
+ "activityBar.activeBorder": "#a277ff",
+ "badge.foreground": "#15141b",
+ "badge.background": "#a277ff",
+ "sideBar.background": "#110f18",
+ "sideBar.foreground": "#cdccce",
+ "sideBar.border": "#000000",
+ "sideBarTitle.foreground": "#adacae",
+ "sideBarSectionHeader.background": "#15141b",
+ "sideBarSectionHeader.foreground": "#adacae",
+ "progressBar.background": "#61ffca",
+ "scrollbar.shadow": "#15141b",
+ "scrollbarSlider.activeBackground": "#3d375e7f",
+ "scrollbarSlider.background": "#a394f033",
+ "scrollbarSlider.hoverBackground": "#a394f033",
+ "terminal.background": "#15141b",
+ "terminal.foreground": "#cdccce",
+ "terminal.ansiBlack": "#15141b",
+ "terminal.ansiBlue": "#a277ff",
+ "terminal.ansiBrightBlack": "#2d2d2d",
+ "terminal.ansiBrightBlue": "#a277ff",
+ "terminal.ansiBrightCyan": "#61ffca",
+ "terminal.ansiBrightGreen": "#a277ff",
+ "terminal.ansiBrightMagenta": "#61ffca",
+ "terminal.ansiBrightRed": "#ffca85",
+ "terminal.ansiBrightWhite": "#edecee",
+ "terminal.ansiBrightYellow": "#ffca85",
+ "terminal.ansiCyan": "#a277ff",
+ "terminal.ansiGreen": "#61ffca",
+ "terminal.ansiMagenta": "#61ffca",
+ "terminal.ansiRed": "#ff6767",
+ "terminal.ansiWhite": "#cdccce",
+ "terminal.ansiYellow": "#ffca85",
+ "terminal.selectionBackground": "#3d375e7f",
+ "terminalCursor.background": "#2d2d2d",
+ "terminalCursor.foreground": "#adacae",
+ "gitDecoration.modifiedResourceForeground": "#ffca85",
+ "gitDecoration.deletedResourceForeground": "#ff6767",
+ "gitDecoration.untrackedResourceForeground": "#61ffca",
+ "gitDecoration.ignoredResourceForeground": "#4d4d4d",
+ "gitDecoration.conflictingResourceForeground": "#a277ff",
+ "diffEditor.insertedTextBackground": "#00d89023",
+ "diffEditor.removedTextBackground": "#ff474720",
+ "tree.indentGuidesStroke": "#4d4d4d",
+ },
+ settings: [
+ {
+ name: "Accent1",
+ scope: [
+ "keyword",
+ "storage",
+ "support",
+ "entity.name.tag",
+ "variable.language",
+ "keyword.control.flow",
+ "storage.modifier",
+ "keyword.operator",
+ "entity.other.attribute-name.class.css",
+ "entity.other.keyframe-offset",
+ "markup.heading",
+ "markup.underline.link",
+ "variable.other.env",
+ "punctuation.definition.list.begin.markdown",
+ "punctuation.definition.bold.markdown",
+ "punctuation.definition.italic.markdown",
+ "punctuation.definition.markdown",
+ "punctuation.definition.quote.begin.markdown",
+ "punctuation.definition.raw.markdown",
+ "constant.length.units.css",
+ "constant.percentage.units.css",
+ ],
+ settings: {
+ foreground: "#a277ff",
+ },
+ },
+ {
+ name: "Accent2",
+ scope: [
+ "string",
+ "markup.inserted",
+ "markup.raw",
+ "constant",
+ "source.env",
+ "support.type.builtin.graphql",
+ "variable.other.quoted.double",
+ "markup.inline.raw.string.markdown",
+ "entity.other.attribute-name.id.css",
+ "JSXNested",
+ ],
+ settings: {
+ foreground: "#61ffca",
+ },
+ },
+ {
+ name: "Accent3",
+ scope: [
+ "markup.changed",
+ "entity",
+ "entity.name.function",
+ "entity.name.function.elixir",
+ "entity.name.function-call.elixir",
+ "support.class.component.tsx",
+ "support.class.component.open.jsx",
+ "support.class.component.close.jsx",
+ "meta.function-call.generic.python",
+ "entity.name.section.markdown",
+ "storage.type.annotation.dart",
+ ],
+ settings: {
+ foreground: "#ffca85",
+ },
+ },
+ {
+ name: "Accent5",
+ scope: ["invalid", "markup.deleted"],
+ settings: {
+ foreground: "#ff6767",
+ },
+ },
+ {
+ name: "Accent6",
+ scope: [
+ "string.unquoted",
+ "punctuation.separator",
+ "entity.other.attribute-name",
+ "meta.object-literal.key",
+ "variable.object.property",
+ "variable.other.property",
+ "variable.other.object.property",
+ "variable.other.constant.property",
+ "meta.type.annotation",
+ "support.type.property-name.css",
+ "support.type.vendored",
+ "constant.language.symbol.elixir",
+ "variable.graphql",
+ "meta.attribute.python",
+ "source.dart",
+ ],
+ settings: {
+ foreground: "#f694ff",
+ },
+ },
+ {
+ name: "Accent7",
+ scope: [
+ "variable",
+ "markup.list",
+ "support.constant.property-value.css",
+ "variable.parameter.keyframe-list.css",
+ "source.css",
+ "support.constant.font-name",
+ "support.constant.vendored.property-value",
+ "variable.parameter",
+ "meta.class",
+ "meta.method.declaration",
+ "parameter.variable.function.elixir",
+ "punctuation.definition.tag",
+ "punctuation.section.embedded",
+ "meta.embedded.expression",
+ "punctuation.terminator.dart",
+ "punctuation.dot.dart",
+ "meta.jsx.children",
+ ],
+ settings: {
+ foreground: "#edecee",
+ },
+ },
+ {
+ name: "Accent8",
+ scope: ["comment", "string.quoted.docstring.multi.python"],
+ settings: {
+ foreground: "#6d6d6d",
+ },
+ },
+ {
+ name: "Accent31",
+ scope: ["entity.name.function", "support.function"],
+ settings: {
+ foreground: "#ffca85",
+ },
+ },
+ {
+ name: "Accent32",
+ scope: [
+ "entity.name.type",
+ "entity.name.class",
+ "support.class.builtin",
+ "punctuation.definition.template-expression.begin",
+ "punctuation.definition.template-expression.end",
+ "punctuation.quasi.element.begin",
+ "punctuation.quasi.element.end",
+ "entity.other.inherited-class",
+ "variable.other.constant.elixir",
+ "entity.other.attribute-name.class.css",
+ "support.class.dart",
+ ],
+ settings: {
+ foreground: "#82e2ff",
+ },
+ },
+ {
+ name: "Italics",
+ scope: [
+ "meta.parameters",
+ "meta.type.parameters",
+ "meta.return.type",
+ "entity.name.type.interface",
+ "meta.type.annotation",
+ "meta.function.parameters",
+ "markup.italic.markdown",
+ ],
+ settings: {
+ fontStyle: "italic",
+ },
+ },
+ {
+ name: "Markup Underline",
+ scope: "markup.underline",
+ settings: {
+ fontStyle: "underline",
+ },
+ },
+ {
+ name: "Bold",
+ scope: ["markup.bold.markdown", "storage.type.annotation.dart"],
+ settings: {
+ fontStyle: "bold",
+ },
+ },
+ ],
+};
+
+export { auraDark };
diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css
index 65cdcf1c..d901dd1f 100644
--- a/apps/web/src/styles/globals.css
+++ b/apps/web/src/styles/globals.css
@@ -120,6 +120,8 @@
--scrollbar-thumb: var(--border);
--scrollbar-thumb-hover: var(--ring);
--scrollbar-track: transparent;
+ --selection: #4f4f4f;
+ --selection-foreground: #ffffff;
--radius: 0.45rem;
--fd-nav-height: 56px;
--fd-banner-height: 0px;
@@ -141,24 +143,24 @@
}
.dark {
- --background: oklch(0.17 0 0);
- --foreground: oklch(0.985 0 0);
- --card: oklch(0.1871 0 0);
- --card-foreground: oklch(0.985 0 0);
- --popover: oklch(0.205 0 0);
- --popover-foreground: oklch(0.985 0 0);
- --primary: oklch(0.87 0 0);
- --primary-foreground: oklch(0.205 0 0);
- --secondary: oklch(0.24 0 0);
- --secondary-foreground: oklch(0.985 0 0);
+ --background: oklch(12.9% 0 0);
+ --foreground: oklch(0.97 0 0);
+ --card: oklch(12.9% 0 0);
+ --card-foreground: oklch(0.97 0 0);
+ --popover: oklch(0.147 0 0);
+ --popover-foreground: oklch(0.97 0 0);
+ --primary: oklch(0.97 0 0);
+ --primary-foreground: oklch(0.168 0 0);
+ --secondary: oklch(0.168 0 0);
+ --secondary-foreground: oklch(0.97 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
- --accent: oklch(0.371 0 0);
- --accent-foreground: oklch(0.985 0 0);
+ --accent: oklch(0.27 0 0);
+ --accent-foreground: oklch(0.97 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.637 0.237 25.331);
- --border: oklch(26% 0 0);
- --input: oklch(1 0 0 / 15%);
+ --border: oklch(0.239 0 0);
+ --input: oklch(1 0 0 / 10%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
@@ -169,10 +171,12 @@
--sidebar-foreground: var(--foreground);
--sidebar-primary: var(--primary);
--sidebar-primary-foreground: var(--foreground);
- --sidebar-accent: var(--muted);
+ --sidebar-accent: var(--accent);
--sidebar-accent-foreground: var(--accent-foreground);
--sidebar-border: var(--border);
--sidebar-ring: var(--ring);
+ --selection: #4f4f4f;
+ --selection-foreground: #ffffff;
--theme-transition-line: oklch(0.985 0 0 / 0.22);
}
@@ -194,19 +198,6 @@
}
}
-/*
- * Fumadocs ships several docs surfaces with hard-coded `rounded-xl`.
- * Normalize them at the docs container level so light and dark use the same radius.
- */
-#nd-page :is(.rounded-xl, .rounded-2xl) {
- border-radius: var(--radius-lg);
-}
-
-#nd-page .last\:rounded-b-xl:last-child {
- border-bottom-right-radius: var(--radius-lg);
- border-bottom-left-radius: var(--radius-lg);
-}
-
@theme inline {
--breakpoint-navbar: 64rem;
--color-background: var(--background);
@@ -292,12 +283,18 @@
}
html {
- @apply overflow-x-hidden;
+ @apply overflow-x-hidden overflow-y-scroll;
+
max-width: 100vw;
}
body {
- @apply selection:bg-foreground selection:text-background overscroll-none bg-background text-foreground;
+ @apply overscroll-none bg-background text-foreground;
+ }
+
+ ::selection {
+ background-color: var(--selection);
+ color: var(--selection-foreground);
}
button:not([disabled]),
@@ -307,6 +304,18 @@
html {
@apply font-sans;
}
+
+ code,
+ pre,
+ kbd,
+ samp,
+ .font-mono {
+ font-kerning: none;
+ font-variant-ligatures: none;
+ font-feature-settings:
+ "liga" 0,
+ "calt" 0;
+ }
}
html {
@@ -354,387 +363,69 @@ html:not([data-anchor-scrolling]) {
}
}
-@layer utilities {
- #nd-sidebar [data-radix-scroll-area-viewport] > *,
- #nd-sidebar-mobile [data-radix-scroll-area-viewport] > * {
- gap: 0 !important;
- }
-}
-
-.prose code:not(pre code),
-.prose a {
- overflow-wrap: anywhere;
+.docs-sidebar-content {
+ -ms-overflow-style: none;
+ scrollbar-width: none;
}
-#nd-sidebar
- > div.flex.flex-col.gap-3.p-4.pb-2
- > div:first-child
- > button[aria-label="Collapse Sidebar"]:not(.docs-sidebar-collapse-button) {
+.docs-sidebar-content::-webkit-scrollbar {
display: none;
+ width: 0;
+ height: 0;
}
-/* Sidebar: fixed positioning so it never scrolls with content, but only when expanded */
-@media (min-width: 768px) {
- [data-sidebar-placeholder]:not(:has(#nd-sidebar[data-collapsed="true"])) {
- position: fixed !important;
- width: var(--fd-sidebar-width) !important;
- inset-inline-start: max(0px, calc((100vw - var(--fd-layout-width, 97rem)) / 2)) !important;
- }
-}
-
-@media (max-width: 767px) {
- :root {
- --subnav-height: 3.5rem;
- }
-
- #nd-subnav {
- position: fixed !important;
- top: var(--fd-docs-row-1, 0px) !important;
- inset-inline-start: 0 !important;
- inset-inline-end: 0 !important;
- }
-
- /* Compensate for fixed #nd-subnav leaving grid flow */
- [data-toc-popover] {
- margin-top: var(--subnav-height);
- }
-}
-
-[data-toc-popover] > header {
- position: fixed !important;
- top: var(--fd-docs-row-2, 0px) !important;
- inset-inline-start: 0 !important;
- inset-inline-end: 0 !important;
- z-index: 10 !important;
-}
-
-@media (min-width: 768px) {
- [data-toc-popover] > header {
- inset-inline-start: var(--fd-sidebar-col, 0px) !important;
- }
+.docs-body code:not(pre code),
+.docs-body a {
+ overflow-wrap: anywhere;
}
-/* Fumadocs sidebar: edge-to-edge item rows with explicit horizontal padding */
-#nd-sidebar [data-radix-scroll-area-viewport],
-#nd-sidebar-mobile [data-radix-scroll-area-viewport] {
+.docs-codeblock pre code {
+ background: transparent !important;
+ border: 0 !important;
+ border-radius: 0 !important;
+ box-shadow: none !important;
padding: 0 !important;
- -webkit-mask-image: none !important;
- mask-image: none !important;
}
-#nd-sidebar [style*="mask-image"],
-#nd-sidebar-mobile [style*="mask-image"],
-#nd-sidebar [style*="-webkit-mask-image"],
-#nd-sidebar-mobile [style*="-webkit-mask-image"] {
- -webkit-mask-image: none !important;
- mask-image: none !important;
+.docs-codeblock pre {
+ overflow-y: hidden;
+ overscroll-behavior-x: none;
}
-#nd-sidebar:not([data-collapsed="true"]) > div.flex.flex-col.gap-3.p-4.pb-2,
-#nd-sidebar:not([data-collapsed="true"]) > .min-h-0.flex-1,
-#nd-sidebar:not([data-collapsed="true"]) > div.flex.flex-col.p-4.pt-2,
-#nd-sidebar-mobile > div.flex.flex-col.gap-3.p-4.pb-2,
-#nd-sidebar-mobile > .min-h-0.flex-1,
-#nd-sidebar-mobile > div.flex.flex-col.p-4.pt-2 {
- @apply border-border border-l;
+.docs-codeblock pre.shiki {
+ --padding-left: 0.875rem;
+ --padding-right: 0.875rem;
}
-#nd-sidebar > div.flex.flex-col.p-4.pt-2:has(.docs-sidebar-github-button),
-#nd-sidebar-mobile > div.flex.flex-col.p-4.pt-2:has(.docs-sidebar-github-button) {
- @apply border-border flex flex-row items-center justify-start gap-2 border-t pt-2 pb-3;
+.docs-codeblock pre.shiki[data-copy-overlay] {
+ --padding-right: 2.5rem;
}
-#nd-sidebar .docs-sidebar-github-button,
-#nd-sidebar-mobile .docs-sidebar-github-button {
- @apply rounded-[var(--radius)] border-0;
+.docs-codeblock .shiki code span {
+ color: var(--shiki-light);
+ font-style: var(--shiki-light-font-style);
}
-#nd-sidebar > div.flex.flex-col.p-4.pt-2 > div:last-child > :is(a, button),
-#nd-sidebar-mobile > div.flex.flex-col.p-4.pt-2 > div:last-child > :is(a, button) {
- @apply size-7 rounded-md;
-}
-
-#nd-sidebar,
-#nd-sidebar-mobile {
- background-color: var(--sidebar);
- --animate-fd-collapsible-down: fd-collapsible-down 220ms cubic-bezier(0.22, 1, 0.36, 1);
- --animate-fd-collapsible-up: fd-collapsible-up 170ms ease-out;
-}
-
-#nd-toc {
- @apply pe-5;
-}
-
-#nd-toc .bg-fd-primary.rounded-full {
- display: none;
+.dark .docs-codeblock .shiki code span {
+ color: var(--shiki-dark);
+ font-style: var(--shiki-dark-font-style);
}
-#nd-toc a[href^="#"] {
- padding-inline-start: 14px !important;
+.docs-codeblock .shiki .line {
+ position: relative;
}
-#nd-toc a[href^="#"] > .inset-y-0 {
- inset-inline-start: 0px !important;
+.docs-codeblock .shiki .line:empty {
+ height: 1lh;
}
-#nd-toc a[href^="#"] > .absolute {
- inset-inline-start: 0px !important;
+.docs-codeblock .shiki .highlighted {
+ background-color: color-mix(in oklab, var(--color-fd-primary) 2.2%, transparent);
}
-#nd-toc .stroke-fd-primary {
- @apply stroke-fd-muted-foreground;
- stroke-width: 2;
- transform: translateX(-7px);
-}
-
-#nd-toc [data-hidden] {
- inset-inline-start: -8.5px !important;
-}
-
-[data-toc-popover] {
- @apply h-11.5;
-}
-
-[data-toc-popover-trigger] {
- @apply h-11.5 py-0;
-}
-
-[data-toc-popover],
-[data-toc-popover-trigger],
-[data-toc-popover-trigger] > svg[role="progressbar"] {
- overflow: visible !important;
-}
-
-#nd-sidebar [data-radix-scroll-area-viewport] :is(a, button).flex.flex-row.items-center.gap-2,
-#nd-sidebar-mobile
- [data-radix-scroll-area-viewport]
- :is(a, button).flex.flex-row.items-center.gap-2 {
- @apply w-full rounded-none;
- padding-inline: 1.25rem !important;
-}
-
-#nd-sidebar
- [data-radix-scroll-area-viewport]
- > div
- > [data-state]
- > :is(button, a).flex.flex-row.items-center.gap-2,
-#nd-sidebar-mobile
- [data-radix-scroll-area-viewport]
- > div
- > [data-state]
- > :is(button, a).flex.flex-row.items-center.gap-2 {
- @apply border-border;
- border-top: 1px solid;
- border-bottom: 1px solid;
- border-color: var(--border) !important;
- transition-property: background-color, color !important;
- min-height: 41px !important;
-}
-
-#nd-sidebar
- [data-radix-scroll-area-viewport]
- > div
- > [data-state]:first-child
- > :is(button, a).flex.flex-row.items-center.gap-2,
-#nd-sidebar-mobile
- [data-radix-scroll-area-viewport]
- > div
- > [data-state]:first-child
- > :is(button, a).flex.flex-row.items-center.gap-2 {
- border-top: 0;
-}
-
-#nd-sidebar
- [data-radix-scroll-area-viewport]
- > div
- > [data-state="closed"]
- + [data-state]
- > :is(button, a).flex.flex-row.items-center.gap-2,
-#nd-sidebar-mobile
- [data-radix-scroll-area-viewport]
- > div
- > [data-state="closed"]
- + [data-state]
- > :is(button, a).flex.flex-row.items-center.gap-2 {
- border-top: 0;
-}
-
-/* Docs sidebar categories: replace default folder chevron with Hugeicons */
-#nd-sidebar
- button[aria-expanded]:not([data-slot="dropdown-menu-trigger"])
- > svg:not(.docs-category-chevron):not(.docs-category-icon),
-#nd-sidebar-mobile
- button[aria-expanded]:not([data-slot="dropdown-menu-trigger"])
- > svg:not(.docs-category-chevron):not(.docs-category-icon) {
- display: none !important;
-}
-
-#nd-sidebar button[aria-expanded="true"] .docs-category-chevron,
-#nd-sidebar-mobile button[aria-expanded="true"] .docs-category-chevron {
- transform: rotate(180deg);
-}
-
-/* Category triggers: top divider only, except for the first item */
-#nd-sidebar
- [data-radix-scroll-area-viewport]
- > div
- > [data-state]
- > button[aria-expanded]:not([data-slot="dropdown-menu-trigger"]).flex.flex-row.items-center.gap-2,
-#nd-sidebar-mobile
- [data-radix-scroll-area-viewport]
- > div
- > [data-state]
- > button[aria-expanded]:not(
- [data-slot="dropdown-menu-trigger"]
- ).flex.flex-row.items-center.gap-2 {
- border-bottom: 0;
-}
-
-#nd-sidebar
- [data-radix-scroll-area-viewport]
- > div
- > [data-state]:first-child
- > button[aria-expanded]:not([data-slot="dropdown-menu-trigger"]).flex.flex-row.items-center.gap-2,
-#nd-sidebar-mobile
- [data-radix-scroll-area-viewport]
- > div
- > [data-state]:first-child
- > button[aria-expanded]:not(
- [data-slot="dropdown-menu-trigger"]
- ).flex.flex-row.items-center.gap-2 {
- border-top: 0;
-}
-
-#nd-sidebar
- [data-radix-scroll-area-viewport]
- > div
- > [data-state="closed"]
- + [data-state]
- > button[aria-expanded]:not([data-slot="dropdown-menu-trigger"]).flex.flex-row.items-center.gap-2,
-#nd-sidebar-mobile
- [data-radix-scroll-area-viewport]
- > div
- > [data-state="closed"]
- + [data-state]
- > button[aria-expanded]:not(
- [data-slot="dropdown-menu-trigger"]
- ).flex.flex-row.items-center.gap-2 {
- border-top: 1px solid var(--border);
-}
-
-/* Expanded category trigger: add divider below the trigger, not above the content */
-#nd-sidebar
- [data-radix-scroll-area-viewport]
- > div
- > [data-state="open"]
- > button[aria-expanded="true"]:not(
- [data-slot="dropdown-menu-trigger"]
- ).flex.flex-row.items-center.gap-2,
-#nd-sidebar-mobile
- [data-radix-scroll-area-viewport]
- > div
- > [data-state="open"]
- > button[aria-expanded="true"]:not(
- [data-slot="dropdown-menu-trigger"]
- ).flex.flex-row.items-center.gap-2 {
- @apply relative;
-}
-
-#nd-sidebar
- [data-radix-scroll-area-viewport]
- > div
- > [data-state="open"]
- > button[aria-expanded="true"]:not(
- [data-slot="dropdown-menu-trigger"]
- ).flex.flex-row.items-center.gap-2::after,
-#nd-sidebar-mobile
- [data-radix-scroll-area-viewport]
- > div
- > [data-state="open"]
- > button[aria-expanded="true"]:not(
- [data-slot="dropdown-menu-trigger"]
- ).flex.flex-row.items-center.gap-2::after {
- content: "";
- @apply border-border pointer-events-none absolute right-0 bottom-0 left-0 border-b;
-}
-
-/* Sidebar search row: full width like category rows */
-#nd-sidebar > div.flex.flex-col.gap-3.p-4.pb-2,
-#nd-sidebar-mobile > div.flex.flex-col.gap-3.p-4.pb-2 {
- @apply px-0;
- padding-top: calc(0.5rem + 2px) !important;
- @apply pb-0;
- row-gap: 0.5rem !important;
-}
-
-#nd-sidebar > div.flex.flex-col.gap-3.p-4.pb-2 > div:first-child,
-#nd-sidebar-mobile > div.flex.flex-col.gap-3.p-4.pb-2 > div:first-child {
- padding-inline-start: 1.25rem;
- padding-inline-end: 1rem;
-}
-
-#nd-sidebar [data-search-full],
-#nd-sidebar-mobile [data-search-full] {
- @apply border-border w-full rounded-none border-x-0 border-y shadow-none;
- min-height: 40px !important;
- padding-inline: 1.25rem !important;
- padding-block: 0.5rem !important;
- filter: none !important;
-}
-
-#nd-sidebar [data-search-full] > svg,
-#nd-sidebar-mobile [data-search-full] > svg {
- @apply size-3.5 shrink-0;
-}
-
-#nd-sidebar
- [data-radix-scroll-area-viewport]
- .before\:bg-fd-border
- :is(a, button).flex.flex-row.items-center.gap-2,
-#nd-sidebar-mobile
- [data-radix-scroll-area-viewport]
- .before\:bg-fd-border
- :is(a, button).flex.flex-row.items-center.gap-2 {
- padding-block: 0.4rem !important;
-}
-
-/* Non-collapsible inner folders: render as separator-like labels */
-#nd-sidebar [data-radix-scroll-area-viewport] div.relative.flex.flex-row.items-center.gap-2.w-full,
-#nd-sidebar-mobile
- [data-radix-scroll-area-viewport]
- div.relative.flex.flex-row.items-center.gap-2.w-full {
- @apply text-foreground/35 flex h-[1.2rem] items-center gap-2 overflow-hidden rounded-none px-5 text-[0.6875rem] leading-[0.8rem] tracking-[0.06em] whitespace-nowrap uppercase;
- padding-top: 0;
- padding-bottom: 0;
-}
-
-#nd-sidebar
- [data-radix-scroll-area-viewport]
- div.relative.flex.flex-row.items-center.gap-2.w-full::after,
-#nd-sidebar-mobile
- [data-radix-scroll-area-viewport]
- div.relative.flex.flex-row.items-center.gap-2.w-full::after {
- @apply bg-border h-px flex-1;
- content: "";
-}
-
-/* Remove folder guide rail (left vertical trace) */
-#nd-sidebar [data-radix-scroll-area-viewport] .before\:bg-fd-border::before,
-#nd-sidebar-mobile [data-radix-scroll-area-viewport] .before\:bg-fd-border::before {
- display: none !important;
-}
-
-/* Remove active item left marker */
-#nd-sidebar [data-radix-scroll-area-viewport] a[data-active="true"]::before,
-#nd-sidebar-mobile [data-radix-scroll-area-viewport] a[data-active="true"]::before {
- content: none !important;
- display: none !important;
-}
-
-/* Disabled sidebar items (url="#") */
-#nd-sidebar [data-radix-scroll-area-viewport] a[href="#"],
-#nd-sidebar-mobile [data-radix-scroll-area-viewport] a[href="#"] {
- @apply pointer-events-none opacity-50;
+.docs-codeblock .shiki .highlighted::after {
+ display: none;
+ border-left: 1px solid color-mix(in oklab, var(--primary) 50%, transparent);
}
input[type="search"]::-webkit-search-cancel-button,
@@ -745,89 +436,6 @@ input[type="search"]::-webkit-search-results-decoration {
display: none;
}
-/* Code blocks — flat near-black, rounded edges, no gradients */
-.dark .shiki {
- background-color: var(--card) !important;
- border-color: rgba(255, 255, 255, 0.06);
- border-radius: var(--radius-lg) !important;
-}
-
-/* Tab/card wrappers containing code blocks */
-.dark .bg-fd-card:has(.shiki),
-.dark [data-slot="tabs"]:has(.shiki),
-.dark div:has(> [role="tablist"]):has(.shiki) {
- background-color: var(--card) !important;
- border-color: rgba(255, 255, 255, 0.06);
- border-radius: var(--radius-lg) !important;
-}
-
-/* Override when keepBackground is enabled */
-.dark .bg-\(--shiki-dark-bg\),
-.dark [class*="bg-(--shiki-dark-bg)"] {
- background-color: var(--card) !important;
-}
-
-/* Code block title bar / tab list — flat, no gradient */
-.dark figure.shiki > div:first-child,
-.dark [role="tablist"]:has(+ [role="tabpanel"] .shiki),
-.dark [role="tablist"]:has(+ div .shiki) {
- background: transparent;
- border-color: rgba(255, 255, 255, 0.06);
- border-radius: var(--radius-lg) !important;
-}
-
-/* Tab content panel background */
-.dark [role="tabpanel"]:has(.shiki) {
- background-color: var(--card) !important;
- border-radius: var(--radius-lg) !important;
-}
-
-/* Code block inside tabs — match parent, no extra border/shadow */
-.dark [role="tabpanel"] .shiki {
- background-color: var(--card) !important;
- border: none;
- box-shadow: none;
- border-radius: var(--radius-lg) !important;
-}
-
-/* Code block content viewport — force near-black in dark mode */
-.dark .fd-scroll-container,
-.dark figure.shiki .fd-scroll-container,
-.dark figure:has(.shiki) > div:last-child {
- background-color: var(--card) !important;
-}
-
-/* Code block figure bg-fd-secondary / bg-fd-card wrappers */
-.dark figure.shiki,
-.dark figure:has(pre) {
- background-color: var(--card) !important;
- border-color: rgba(255, 255, 255, 0.06);
-}
-
-/* All code block figures — rounded corners (docs only) */
-#nd-page figure:has(.shiki),
-#nd-page figure.shiki,
-#nd-page [data-slot="tabs"]:has(.shiki),
-#nd-page .bg-fd-card:has(.shiki) {
- border-radius: var(--radius-lg) !important;
- overflow: hidden;
-}
-
-#nd-page figure.shiki > div:first-child,
-#nd-page [role="tablist"]:has(+ [role="tabpanel"] .shiki),
-#nd-page [role="tablist"]:has(+ div .shiki) {
- border-radius: var(--radius-lg) !important;
-}
-
-#nd-page [role="tabpanel"]:has(.shiki),
-#nd-page [role="tabpanel"] .shiki {
- border-radius: var(--radius-lg) !important;
-}
-
-.prose blockquote {
- @apply rounded-lg;
-}
-
/* Respect prefers-reduced-motion for accessibility (WCAG 2.3.3) */
@media (prefers-reduced-motion: reduce) {
*,
@@ -838,44 +446,6 @@ input[type="search"]::-webkit-search-results-decoration {
}
}
-/* Ensure sidebar category animation stays visible and smooth */
-#nd-sidebar [class*="animate-fd-collapsible"][data-state="open"],
-#nd-sidebar-mobile [class*="animate-fd-collapsible"][data-state="open"] {
- animation-duration: 220ms !important;
- animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1) !important;
-}
-
-#nd-sidebar [data-radix-scroll-area-viewport] > div > [data-state="open"] > div[data-state="open"],
-#nd-sidebar-mobile
- [data-radix-scroll-area-viewport]
- > div
- > [data-state="open"]
- > div[data-state="open"] {
- @apply pt-1 pb-1;
-}
-
-#nd-sidebar
- [data-radix-scroll-area-viewport]
- > div
- > [data-state="open"]
- > div[data-state="open"]
- > div,
-#nd-sidebar-mobile
- [data-radix-scroll-area-viewport]
- > div
- > [data-state="open"]
- > div[data-state="open"]
- > div {
- gap: 0 !important;
- padding-top: 0 !important;
-}
-
-#nd-sidebar [class*="animate-fd-collapsible"][data-state="closed"],
-#nd-sidebar-mobile [class*="animate-fd-collapsible"][data-state="closed"] {
- animation-duration: 170ms !important;
- animation-timing-function: ease-out !important;
-}
-
/* Lift context menu positioner above fixed nav (z-99) */
:has(> [data-slot="context-menu-content"]) {
z-index: 100 !important;
diff --git a/dev/UI_DESIGN.md b/dev/DESIGN.md
similarity index 95%
rename from dev/UI_DESIGN.md
rename to dev/DESIGN.md
index 3a4735b7..24411d75 100644
--- a/dev/UI_DESIGN.md
+++ b/dev/DESIGN.md
@@ -1,6 +1,13 @@
# UI Design Principles
-## Design References
+## PayKit's Style
+
+https://efferd.com/view/dashboard-9
+https://efferd.com/view/auth-2
+https://efferd.com/view/auth-4
+https://efferd.com/view/auth-12
+
+## Other References
These are web pages we admire and draw inspiration from. We don't copy any of them directly — PayKit has its own identity. Use these as a reference for quality, tone, and craft.
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..c7a1253b 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)
@@ -79,10 +73,10 @@ importers:
version: 11.17.0(typescript@5.9.3)
autumn-js:
specifier: ^1.2.2
- version: 1.2.27(better-auth@1.6.11(@cloudflare/workers-types@4.20260522.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260522.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.21.0))(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.21.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.7(@types/node@20.19.41)(msw@2.14.6(@types/node@20.19.41)(typescript@5.9.3))(vite@8.0.14(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0))))(better-call@1.3.5(zod@4.4.3))(express@5.2.1)(hono@4.12.22)(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
+ version: 1.2.27(better-auth@1.6.11(@cloudflare/workers-types@4.20260522.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260522.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.21.0))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.21.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.7(@types/node@20.19.41)(msw@2.14.6(@types/node@20.19.41)(typescript@5.9.3))(vite@8.0.14(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0))))(better-call@1.3.5(zod@4.4.3))(express@5.2.1)(hono@4.12.22)(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
better-auth:
specifier: ^1.6.2
- version: 1.6.11(@cloudflare/workers-types@4.20260522.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260522.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.21.0))(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.21.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.7(@types/node@20.19.41)(msw@2.14.6(@types/node@20.19.41)(typescript@5.9.3))(vite@8.0.14(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)))
+ version: 1.6.11(@cloudflare/workers-types@4.20260522.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260522.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.21.0))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.21.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.7(@types/node@20.19.41)(msw@2.14.6(@types/node@20.19.41)(typescript@5.9.3))(vite@8.0.14(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)))
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -219,15 +213,18 @@ importers:
'@t3-oss/env-nextjs':
specifier: ^0.12.0
version: 0.12.0(typescript@5.9.3)(zod@3.25.76)
+ '@tanstack/react-hotkeys':
+ specifier: ^0.10.0
+ version: 0.10.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@types/mdx':
specifier: ^2.0.13
version: 2.0.13
'@vercel/analytics':
specifier: ^1.6.1
- version: 1.6.1(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
+ version: 1.6.1(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
'@vercel/speed-insights':
specifier: ^2.0.0
- version: 2.0.0(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
+ version: 2.0.0(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -247,17 +244,17 @@ importers:
specifier: ^12.34.3
version: 12.40.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
fumadocs-core:
- specifier: ^16.7.11
- version: 16.9.0(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@0.575.0(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@3.25.76)
+ specifier: ^16.9.3
+ version: 16.9.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@0.575.0(react@19.2.6))(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@3.25.76)
fumadocs-mdx:
- specifier: ^14.2.11
- version: 14.3.2(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.15)(fumadocs-core@16.9.0(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@0.575.0(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@3.25.76))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(vite@8.0.14(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0))
+ specifier: ^15.0.10
+ version: 15.0.10(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.15)(fumadocs-core@16.9.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@0.575.0(react@19.2.6))(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@3.25.76))(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(rolldown@1.0.2)(vite@8.0.14(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0))
fumadocs-ui:
- specifier: ^16.7.11
- version: 16.9.0(@tailwindcss/oxide@4.3.0)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(fumadocs-core@16.9.0(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@0.575.0(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@3.25.76))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tailwindcss@4.3.0)
+ specifier: ^16.9.3
+ version: 16.9.3(@tailwindcss/oxide@4.3.0)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(fumadocs-core@16.9.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@0.575.0(react@19.2.6))(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@3.25.76))(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tailwindcss@4.3.0)
geist:
specifier: ^1.3.1
- version: 1.7.1(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))
+ version: 1.7.1(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))
input-otp:
specifier: ^1.4.2
version: 1.4.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -368,12 +365,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 +495,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 +518,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 +2398,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==}
@@ -3571,14 +3524,34 @@ packages:
peerDependencies:
vite: ^5.2.0 || ^6 || ^7 || ^8
+ '@tanstack/hotkeys@0.8.0':
+ resolution: {integrity: sha512-vqH7X9nb0MTJ/O08++dB5bP9jgj4+BIPOUu/U+6myG86lDsirZSVSobpq5UQpE7nBuk62i8eIYeOhd+OMl/UrA==}
+ engines: {node: '>=18'}
+
'@tanstack/query-core@5.100.13':
resolution: {integrity: sha512-mlKVKMTzZWGTKAC1CKOgt7axAjJ921emkEvYIp27I/PdP1yEYL/BteLY8iK35gn8hoYeKB4mgJ/ve3lrDI6/Fw==}
+ '@tanstack/react-hotkeys@0.10.0':
+ resolution: {integrity: sha512-GwOSndI5j3qBVYTmgP1mYyRTnlxb2MS17cwGlsavSxMQPSnmDf+m3LzMIpRMs+3zzQMjg3cYhHsFYizYlFI2tw==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ react: '>=16.8'
+ react-dom: '>=16.8'
+
'@tanstack/react-query@5.100.13':
resolution: {integrity: sha512-HSBr8CycQEAoXsJR7KNDawBnINJEJ96Eme8oE0hCXjyodE2I97vg3IDzDJBDu18LsbzpVVJcKo80eqLfVCykxw==}
peerDependencies:
react: ^18 || ^19
+ '@tanstack/react-store@0.11.0':
+ resolution: {integrity: sha512-tX4YXh3PDkmpvGQWkWqKpzs/MSqbtuwY9dWdWhtV9Q50PmO+jOkUKIWIX4G85dwt7lxdHLXsiaEKPdKmC8F41w==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
+ '@tanstack/store@0.11.0':
+ resolution: {integrity: sha512-WlzzCt3xi0G6pCAJu1U+2jiECwabETDpQDi3hfkFZvJii9AuZqEKbOiVarX1/bWhTNjU486yQtJCCasi/0q+Cw==}
+
'@tokenizer/inflate@0.4.1':
resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==}
engines: {node: '>=18'}
@@ -5006,8 +4979,8 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
- fumadocs-core@16.9.0:
- resolution: {integrity: sha512-L5bXpKsN0m7kK483KqviQ7g0l6PA7M32K5yCXS0KUrVWJDUu038ASb08G59RFwWjxhahCAHf0H0ntwzF91lqnQ==}
+ fumadocs-core@16.9.3:
+ resolution: {integrity: sha512-8RVzKnzBJR5o+tJCccY28ntekfMQYBoYiz7alnYb/d9YJc+XpnsINzTl63lQ1eBMZ9gdhm2MqRtgUjh/8rUrbw==}
peerDependencies:
'@mdx-js/mdx': '*'
'@mixedbread/sdk': 0.x.x
@@ -5065,18 +5038,19 @@ packages:
zod:
optional: true
- fumadocs-mdx@14.3.2:
- resolution: {integrity: sha512-73SoZkbUuqnD91G/0zBcaQdM1TMnYw5JJzKgkGvQTiZbtLQFuWTt8/uRqnzFMuNIUu/WY9Lo9d1iZ8G+jOVieA==}
+ fumadocs-mdx@15.0.10:
+ resolution: {integrity: sha512-kH3S7ESS9yXTAaCkA8dDugsCK/MbnpgyZ5qBEL7cWoavV0O/T4+4YTYFkvNknz7cw+T/r+OG0p2BvlVhkk4fww==}
hasBin: true
peerDependencies:
'@types/mdast': '*'
'@types/mdx': '*'
'@types/react': '*'
- fumadocs-core: ^15.0.0 || ^16.0.0
+ fumadocs-core: ^16.7.0
mdast-util-directive: '*'
next: ^15.3.0 || ^16.0.0
react: ^19.2.0
- vite: 6.x.x || 7.x.x || 8.x.x
+ rolldown: '*'
+ vite: 7.x.x || 8.x.x
peerDependenciesMeta:
'@types/mdast':
optional: true
@@ -5090,16 +5064,18 @@ packages:
optional: true
react:
optional: true
+ rolldown:
+ optional: true
vite:
optional: true
- fumadocs-ui@16.9.0:
- resolution: {integrity: sha512-z4w93vuhrsNp1cup7mcEoo8GSDQRCgDMkGnIvUEz+YB7IwSDhrUqf3lXuSKDaqPLXPdRvR0Cf+BAqARBW71c0Q==}
+ fumadocs-ui@16.9.3:
+ resolution: {integrity: sha512-eoVKj1H+ATut0su+WIoPWBLRqzPMGD0hekIBr4GopWvUg1lS997HL4kP+Leyf+3CYlZtFgyXb6ylbvRLFtEj6Q==}
peerDependencies:
'@takumi-rs/image-response': '*'
'@types/mdx': '*'
'@types/react': '*'
- fumadocs-core: 16.9.0
+ fumadocs-core: 16.9.3
next: 16.x.x
react: ^19.2.0
react-dom: ^19.2.0
@@ -5791,8 +5767,8 @@ packages:
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
- lucide-react@1.16.0:
- resolution: {integrity: sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==}
+ lucide-react@1.17.0:
+ resolution: {integrity: sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
@@ -7164,6 +7140,10 @@ packages:
resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==}
engines: {node: '>=18'}
+ tinyexec@1.2.4:
+ resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==}
+ engines: {node: '>=18'}
+
tinyglobby@0.2.16:
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
engines: {node: '>=12.0.0'}
@@ -9202,11 +9182,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
@@ -10268,13 +10243,33 @@ snapshots:
tailwindcss: 4.3.0
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)
+ '@tanstack/hotkeys@0.8.0':
+ dependencies:
+ '@tanstack/store': 0.11.0
+
'@tanstack/query-core@5.100.13': {}
+ '@tanstack/react-hotkeys@0.10.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
+ dependencies:
+ '@tanstack/hotkeys': 0.8.0
+ '@tanstack/react-store': 0.11.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
+
'@tanstack/react-query@5.100.13(react@19.2.6)':
dependencies:
'@tanstack/query-core': 5.100.13
react: 19.2.6
+ '@tanstack/react-store@0.11.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
+ dependencies:
+ '@tanstack/store': 0.11.0
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
+ use-sync-external-store: 1.6.0(react@19.2.6)
+
+ '@tanstack/store@0.11.0': {}
+
'@tokenizer/inflate@0.4.1':
dependencies:
debug: 4.4.3
@@ -10429,12 +10424,12 @@ snapshots:
'@ungap/structured-clone@1.3.1': {}
- '@vercel/analytics@1.6.1(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)':
+ '@vercel/analytics@1.6.1(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)':
optionalDependencies:
next: 16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react: 19.2.6
- '@vercel/speed-insights@2.0.0(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)':
+ '@vercel/speed-insights@2.0.0(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)':
optionalDependencies:
next: 16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react: 19.2.6
@@ -10638,13 +10633,13 @@ snapshots:
auto-bind@5.0.1: {}
- autumn-js@1.2.27(better-auth@1.6.11(@cloudflare/workers-types@4.20260522.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260522.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.21.0))(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.21.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.7(@types/node@20.19.41)(msw@2.14.6(@types/node@20.19.41)(typescript@5.9.3))(vite@8.0.14(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0))))(better-call@1.3.5(zod@4.4.3))(express@5.2.1)(hono@4.12.22)(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6):
+ autumn-js@1.2.27(better-auth@1.6.11(@cloudflare/workers-types@4.20260522.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260522.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.21.0))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.21.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.7(@types/node@20.19.41)(msw@2.14.6(@types/node@20.19.41)(typescript@5.9.3))(vite@8.0.14(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0))))(better-call@1.3.5(zod@4.4.3))(express@5.2.1)(hono@4.12.22)(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6):
dependencies:
query-string: 9.3.1
rou3: 0.6.3
zod: 4.4.3
optionalDependencies:
- better-auth: 1.6.11(@cloudflare/workers-types@4.20260522.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260522.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.21.0))(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.21.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.7(@types/node@20.19.41)(msw@2.14.6(@types/node@20.19.41)(typescript@5.9.3))(vite@8.0.14(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)))
+ better-auth: 1.6.11(@cloudflare/workers-types@4.20260522.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260522.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.21.0))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.21.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.7(@types/node@20.19.41)(msw@2.14.6(@types/node@20.19.41)(typescript@5.9.3))(vite@8.0.14(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)))
better-call: 1.3.5(zod@4.4.3)
express: 5.2.1
hono: 4.12.22
@@ -10669,7 +10664,7 @@ snapshots:
baseline-browser-mapping@2.10.32: {}
- better-auth@1.6.11(@cloudflare/workers-types@4.20260522.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260522.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.21.0))(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.21.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.7(@types/node@20.19.41)(msw@2.14.6(@types/node@20.19.41)(typescript@5.9.3))(vite@8.0.14(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0))):
+ better-auth@1.6.11(@cloudflare/workers-types@4.20260522.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260522.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.21.0))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.21.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.7(@types/node@20.19.41)(msw@2.14.6(@types/node@20.19.41)(typescript@5.9.3))(vite@8.0.14(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0))):
dependencies:
'@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260522.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)
'@better-auth/drizzle-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260522.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260522.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.21.0))
@@ -11619,7 +11614,7 @@ snapshots:
fsevents@2.3.3:
optional: true
- fumadocs-core@16.9.0(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@0.575.0(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@3.25.76):
+ fumadocs-core@16.9.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@0.575.0(react@19.2.6))(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@3.25.76):
dependencies:
'@orama/orama': 3.1.18
estree-util-value-to-estree: 3.5.0
@@ -11652,20 +11647,19 @@ snapshots:
transitivePeerDependencies:
- supports-color
- fumadocs-mdx@14.3.2(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.15)(fumadocs-core@16.9.0(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@0.575.0(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@3.25.76))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(vite@8.0.14(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)):
+ fumadocs-mdx@15.0.10(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.15)(fumadocs-core@16.9.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@0.575.0(react@19.2.6))(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@3.25.76))(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(rolldown@1.0.2)(vite@8.0.14(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)):
dependencies:
'@mdx-js/mdx': 3.1.1
'@standard-schema/spec': 1.1.0
chokidar: 5.0.0
esbuild: 0.28.0
estree-util-value-to-estree: 3.5.0
- fumadocs-core: 16.9.0(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@0.575.0(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@3.25.76)
+ fumadocs-core: 16.9.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@0.575.0(react@19.2.6))(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@3.25.76)
js-yaml: 4.1.1
mdast-util-mdx: 3.0.0
- mdast-util-to-markdown: 2.1.2
picocolors: 1.1.1
picomatch: 4.0.4
- tinyexec: 1.1.2
+ tinyexec: 1.2.4
tinyglobby: 0.2.16
unified: 11.0.5
unist-util-remove-position: 5.0.0
@@ -11678,11 +11672,12 @@ snapshots:
'@types/react': 19.2.15
next: 16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react: 19.2.6
+ rolldown: 1.0.2
vite: 8.0.14(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)
transitivePeerDependencies:
- supports-color
- fumadocs-ui@16.9.0(@tailwindcss/oxide@4.3.0)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(fumadocs-core@16.9.0(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@0.575.0(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@3.25.76))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tailwindcss@4.3.0):
+ fumadocs-ui@16.9.3(@tailwindcss/oxide@4.3.0)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(fumadocs-core@16.9.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@0.575.0(react@19.2.6))(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@3.25.76))(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tailwindcss@4.3.0):
dependencies:
'@fumadocs/tailwind': 0.0.5(@tailwindcss/oxide@4.3.0)(tailwindcss@4.3.0)
'@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -11696,8 +11691,8 @@ snapshots:
'@radix-ui/react-slot': 1.2.4(@types/react@19.2.15)(react@19.2.6)
'@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
class-variance-authority: 0.7.1
- fumadocs-core: 16.9.0(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@0.575.0(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@3.25.76)
- lucide-react: 1.16.0(react@19.2.6)
+ fumadocs-core: 16.9.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@0.575.0(react@19.2.6))(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@3.25.76)
+ lucide-react: 1.17.0(react@19.2.6)
motion: 12.40.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
next-themes: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react: 19.2.6
@@ -11722,7 +11717,7 @@ snapshots:
fuzzysort@3.1.0: {}
- geist@1.7.1(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)):
+ geist@1.7.1(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)):
dependencies:
next: 16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -12389,7 +12384,7 @@ snapshots:
dependencies:
react: 19.2.6
- lucide-react@1.16.0(react@19.2.6):
+ lucide-react@1.17.0(react@19.2.6):
dependencies:
react: 19.2.6
@@ -14313,6 +14308,8 @@ snapshots:
tinyexec@1.1.2: {}
+ tinyexec@1.2.4: {}
+
tinyglobby@0.2.16:
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
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"));