Openstatus www.openstatus.dev

๐Ÿ’ธ Stripe Integration (#178)

* ๐Ÿšง stripe

* ๐Ÿšง stripe

* ๐Ÿ—‘๏ธ

* ๐Ÿš€

* ๐Ÿš€

* ๐Ÿš€

* ๐Ÿงน

* ๐Ÿงน

* ๐Ÿš€

* ๐Ÿ“ˆ add analytics

authored by

Thibault Le Ouay and committed by
GitHub
5c2c99be 3d87a6b3

+1523 -75
+3 -1
apps/web/package.json
··· 10 10 "tsc": "tsc --noEmit" 11 11 }, 12 12 "dependencies": { 13 - "@clerk/nextjs": "4.23.1", 13 + "@clerk/nextjs": "4.23.2", 14 14 "@hookform/resolvers": "3.1.1", 15 15 "@openstatus/analytics": "workspace:^", 16 16 "@openstatus/api": "workspace:^", ··· 35 35 "@radix-ui/react-tabs": "^1.0.4", 36 36 "@radix-ui/react-toast": "1.1.4", 37 37 "@radix-ui/react-tooltip": "1.0.6", 38 + "@stripe/stripe-js": "2.0.0", 38 39 "@t3-oss/env-nextjs": "0.4.1", 39 40 "@tailwindcss/typography": "0.5.9", 40 41 "@tanstack/react-table": "8.9.3", ··· 67 68 "rehype-pretty-code": "0.10.0", 68 69 "resend": "0.15.3", 69 70 "shiki": "0.14.3", 71 + "stripe": "12.17.0", 70 72 "superjson": "1.13.1", 71 73 "svix": "1.4.12", 72 74 "tailwind-merge": "1.13.2",
+56
apps/web/src/app/api/webhook/stripe/route.ts
··· 1 + import type { NextRequest } from "next/server"; 2 + import { TRPCError } from "@trpc/server"; 3 + import { getHTTPStatusCodeFromError } from "@trpc/server/http"; 4 + 5 + import { createTRPCContext } from "@openstatus/api"; 6 + import { lambdaRouter, stripe } from "@openstatus/api/src/lambda"; 7 + 8 + import { env } from "@/env"; 9 + 10 + export async function POST(req: NextRequest) { 11 + const payload = await req.text(); 12 + const signature = req.headers.get("Stripe-Signature"); 13 + if (!signature) return new Response("No signature", { status: 400 }); 14 + 15 + try { 16 + const event = stripe.webhooks.constructEvent( 17 + payload, 18 + signature, 19 + env.STRIPE_WEBHOOK_SECRET_KEY, 20 + ); 21 + 22 + /** 23 + * Forward to tRPC API to handle the webhook event 24 + */ 25 + const ctx = createTRPCContext({ req }); 26 + const caller = lambdaRouter.createCaller(ctx); 27 + 28 + switch (event.type) { 29 + case "checkout.session.completed": 30 + await caller.stripeRouter.webhooks.sessionCompleted({ event }); 31 + break; 32 + 33 + case "customer.subscription.deleted": 34 + await caller.stripeRouter.webhooks.customerSubscriptionDeleted({ 35 + event, 36 + }); 37 + break; 38 + 39 + default: 40 + throw new Error(`Unhandled event type ${event.type}`); 41 + } 42 + } catch (error) { 43 + if (error instanceof TRPCError) { 44 + const errorCode = getHTTPStatusCodeFromError(error); 45 + console.error("Error in tRPC webhook handler", error); 46 + return new Response(error.message, { status: errorCode }); 47 + } 48 + 49 + const message = error instanceof Error ? error.message : "Unknown error"; 50 + return new Response(`Webhook Error: ${message}`, { 51 + status: 400, 52 + }); 53 + } 54 + 55 + return new Response(null, { status: 200 }); 56 + }
+6 -1
apps/web/src/app/app/(dashboard)/[workspaceSlug]/monitors/page.tsx
··· 23 23 const monitors = await api.monitor.getMonitorsByWorkspace.query({ 24 24 workspaceSlug: params.workspaceSlug, 25 25 }); 26 + const workspace = await api.workspace.getWorkspace.query({ 27 + slug: params.workspaceSlug, 28 + }); 26 29 27 - const isLimit = (monitors?.length || 0) >= limit; 30 + const isLimit = 31 + (monitors?.length || 0) >= 32 + allPlans[workspace?.plan || "free"].limits.monitors; 28 33 29 34 return ( 30 35 <div className="grid gap-6 md:grid-cols-2 md:gap-8">
+1 -1
apps/web/src/app/app/(dashboard)/[workspaceSlug]/page.tsx
··· 5 5 }: { 6 6 params: { workspaceId: string }; 7 7 }) { 8 - return redirect(`/app/${params.workspaceId}/dashboard`); 8 + return redirect(`/app/${params.workspaceId}/monitors`); 9 9 }
+157
apps/web/src/app/app/(dashboard)/[workspaceSlug]/settings/_components/plan.tsx
··· 1 + "use client"; 2 + 3 + import { useTransition } from "react"; 4 + import { useRouter } from "next/navigation"; 5 + import { Check } from "lucide-react"; 6 + import type { z } from "zod"; 7 + 8 + import type { selectWorkspaceSchema } from "@openstatus/db/src/schema/workspace"; 9 + 10 + import { Shell } from "@/components/dashboard/shell"; 11 + import { Button } from "@/components/ui/button"; 12 + import { getStripe } from "@/lib/stripe/client"; 13 + import { cn } from "@/lib/utils"; 14 + import { api } from "@/trpc/client"; 15 + 16 + interface Plan { 17 + title: string; 18 + description: string; 19 + cost: number | string; 20 + features: string[]; 21 + action: { 22 + text: string; 23 + onClick: () => void; 24 + }; 25 + loading?: boolean; 26 + } 27 + 28 + interface Props extends Plan { 29 + className?: string; 30 + } 31 + export const SettingsPlan = ({ 32 + workspaceSlug, 33 + workspaceData, 34 + }: { 35 + workspaceSlug: string; 36 + workspaceData: z.infer<typeof selectWorkspaceSchema>; 37 + }) => { 38 + const router = useRouter(); 39 + 40 + const [isPending, startTransition] = useTransition(); 41 + 42 + const getCheckoutSession = () => { 43 + startTransition(async () => { 44 + const result = await api.stripeRouter.getCheckoutSession.mutate({ 45 + workspaceSlug: workspaceSlug, 46 + }); 47 + if (!result) return; 48 + 49 + const stripe = await getStripe(); 50 + stripe?.redirectToCheckout({ sessionId: result.id }); 51 + }); 52 + }; 53 + const getUserCustomerPortal = async () => { 54 + const url = await api.stripeRouter.getUserCustomerPortal.mutate({ 55 + workspaceSlug: workspaceSlug, 56 + }); 57 + if (!url) return; 58 + router.push(url); 59 + return; 60 + }; 61 + getUserCustomerPortal; 62 + const plans: Record<"free" | "pro", Plan> = { 63 + free: { 64 + title: "Free", 65 + description: "Get started now and upgrade once reaching the limits.", 66 + cost: 0, 67 + features: [ 68 + "5 monitors", 69 + "1 status page", 70 + "subdomain", 71 + "10m, 30m, 1h checks", 72 + ], 73 + action: { 74 + text: workspaceData?.plan === "free" ? "Current plan" : "Downgrade", 75 + onClick: async () => { 76 + await getUserCustomerPortal(); 77 + }, 78 + }, 79 + }, 80 + pro: { 81 + title: "Pro", 82 + description: "Scale and build monitors for all your services.", 83 + cost: 29, 84 + features: [ 85 + "20 monitors", 86 + "5 status page", 87 + "custom domain", 88 + "1m, 5m, 10m, 30m, 1h checks", 89 + "5 team members", 90 + ], 91 + loading: isPending, 92 + action: { 93 + text: workspaceData?.plan === "free" ? "Upgrade" : "Current plan", 94 + onClick: async () => { 95 + await getCheckoutSession(); 96 + }, 97 + }, 98 + }, 99 + }; 100 + 101 + return ( 102 + <Shell className="mt-4 w-full"> 103 + <div className="grid gap-4 md:grid-cols-2 md:gap-0"> 104 + <Plan 105 + {...plans.free} 106 + className="md:border-border/50 md:border-r md:pr-4" 107 + /> 108 + <Plan {...plans.pro} className="md:pl-4" /> 109 + </div> 110 + </Shell> 111 + ); 112 + }; 113 + 114 + export function Plan({ 115 + title, 116 + description, 117 + cost, 118 + features, 119 + action, 120 + loading, 121 + className, 122 + }: Props) { 123 + return ( 124 + <div key={title} className={cn("flex w-full flex-col", className)}> 125 + <div className="flex-1"> 126 + <div className="flex items-end justify-between gap-4"> 127 + <div> 128 + <p className="font-cal mb-2 text-xl">{title}</p> 129 + <p className="text-muted-foreground">{description}</p> 130 + </div> 131 + <p className="shrink-0"> 132 + <span className="font-cal text-2xl">{cost}</span> 133 + {typeof cost === "number" ? ( 134 + <span className="text-muted-foreground font-light">/month</span> 135 + ) : null} 136 + </p> 137 + </div> 138 + <ul className="border-border/50 grid divide-y py-2"> 139 + {features.map((item) => ( 140 + <li 141 + key={item} 142 + className="text-muted-foreground inline-flex items-center py-2 text-sm" 143 + > 144 + <Check className="mr-2 h-4 w-4 text-green-500" /> 145 + {item} 146 + </li> 147 + ))} 148 + </ul> 149 + </div> 150 + <div> 151 + <Button size="sm" onClick={action.onClick} disabled={loading}> 152 + {action.text} 153 + </Button> 154 + </div> 155 + </div> 156 + ); 157 + }
+40
apps/web/src/app/app/(dashboard)/[workspaceSlug]/settings/page.tsx
··· 1 + import * as React from "react"; 2 + 3 + import { Header } from "@/components/dashboard/header"; 4 + import { Badge } from "@/components/ui/badge"; 5 + import { api } from "@/trpc/server"; 6 + import { SettingsPlan } from "./_components/plan"; 7 + 8 + export default async function Page({ 9 + params, 10 + }: { 11 + params: { workspaceSlug: string }; 12 + }) { 13 + const data = await api.workspace.getWorkspace.query({ 14 + slug: params.workspaceSlug, 15 + }); 16 + 17 + if (!data) { 18 + return <>Workspace not found</>; 19 + } 20 + return ( 21 + <div className="grid gap-6 md:grid-cols-2 md:gap-8"> 22 + <Header 23 + title="Settings" 24 + description="Your OpenStatus workspace settings." 25 + ></Header> 26 + <div className="col-span-full"> 27 + <h3 className="text-lg font-medium">Plans</h3> 28 + 29 + <div className="mt-4 flex items-center space-x-2 text-sm"> 30 + Your current plan is{" "} 31 + <Badge className="ml-2">{data?.plan || "free"}</Badge> 32 + </div> 33 + <SettingsPlan 34 + workspaceSlug={params.workspaceSlug} 35 + workspaceData={data} 36 + /> 37 + </div> 38 + </div> 39 + ); 40 + }
+8 -3
apps/web/src/app/app/(dashboard)/[workspaceSlug]/status-pages/page.tsx
··· 13 13 import { ActionButton } from "./_components/action-button"; 14 14 import { EmptyState } from "./_components/empty-state"; 15 15 16 - const limit = allPlans.free.limits["status-pages"]; 17 - 18 16 // export const revalidate = 0; 19 17 export const dynamic = "force-dynamic"; 20 18 ··· 30 28 workspaceSlug: params.workspaceSlug, 31 29 }); 32 30 33 - const isLimit = (pages?.length || 0) >= limit; 31 + const workspace = await api.workspace.getWorkspace.query({ 32 + slug: params.workspaceSlug, 33 + }); 34 + 35 + const isLimit = 36 + (monitors?.length || 0) >= 37 + allPlans[workspace?.plan || "free"].limits["status-pages"]; 38 + 34 39 const disableButton = isLimit || !Boolean(monitors); 35 40 36 41 return (
+2
apps/web/src/components/icons.tsx
··· 1 1 import { 2 2 Activity, 3 3 Calendar, 4 + Cog, 4 5 Fingerprint, 5 6 LayoutDashboard, 6 7 Link, ··· 28 29 "panel-top": PanelTop, 29 30 table: Table, 30 31 "toy-brick": ToyBrick, 32 + cog: Cog, 31 33 search: Search, 32 34 "search-check": SearchCheck, 33 35 fingerprint: Fingerprint,
+40 -22
apps/web/src/components/layout/app-sidebar.tsx
··· 10 10 export function AppSidebar() { 11 11 const pathname = usePathname(); 12 12 const params = useParams(); 13 + 13 14 return ( 14 - <ul className="grid gap-1"> 15 - {pagesConfig.map(({ title, href, icon, disabled }) => { 16 - const Icon = Icons[icon]; 17 - const link = `/app/${params.workspaceSlug}${href}`; 18 - return ( 19 - <li key={title} className="w-full"> 20 - <Link 21 - href={link} 22 - className={cn( 23 - "hover:bg-muted/50 hover:text-foreground text-muted-foreground group flex w-full min-w-[200px] items-center rounded-md border border-transparent px-3 py-1", 24 - pathname.startsWith(link) && 25 - "bg-muted/50 border-border text-foreground", 26 - disabled && "pointer-events-none opacity-60", 27 - )} 28 - > 29 - <Icon className={cn("mr-2 h-4 w-4")} /> 30 - {title} 31 - </Link> 32 - </li> 33 - ); 34 - })} 35 - </ul> 15 + <div className="flex h-full flex-col justify-between"> 16 + <ul className="grid gap-1"> 17 + {pagesConfig.map(({ title, href, icon, disabled }) => { 18 + const Icon = Icons[icon]; 19 + const link = `/app/${params.workspaceSlug}${href}`; 20 + return ( 21 + <li key={title} className="w-full"> 22 + <Link 23 + href={link} 24 + className={cn( 25 + "hover:bg-muted/50 hover:text-foreground text-muted-foreground group flex w-full min-w-[200px] items-center rounded-md border border-transparent px-3 py-1", 26 + pathname.startsWith(link) && 27 + "bg-muted/50 border-border text-foreground", 28 + disabled && "pointer-events-none opacity-60", 29 + )} 30 + > 31 + <Icon className={cn("mr-2 h-4 w-4")} /> 32 + {title} 33 + </Link> 34 + </li> 35 + ); 36 + })} 37 + </ul> 38 + <ul> 39 + <li className="w-full"> 40 + <Link 41 + href={`/app/${params.workspaceSlug}/settings`} 42 + className={cn( 43 + "hover:bg-muted/50 hover:text-foreground text-muted-foreground group flex w-full min-w-[200px] items-center rounded-md border border-transparent px-3 py-1", 44 + pathname.startsWith(`/app/${params.workspaceSlug}/settings`) && 45 + "bg-muted/50 border-border text-foreground", 46 + )} 47 + > 48 + <Icons.cog className={cn("mr-2 h-4 w-4")} /> 49 + Settings 50 + </Link> 51 + </li> 52 + </ul> 53 + </div> 36 54 ); 37 55 }
+5
apps/web/src/env.ts
··· 13 13 QSTASH_CURRENT_SIGNING_KEY: z.string().min(1), 14 14 QSTASH_NEXT_SIGNING_KEY: z.string().min(1), 15 15 QSTASH_TOKEN: z.string().min(1), 16 + STRIPE_WEBHOOK_SECRET_KEY: z.string(), 16 17 }, 17 18 client: { 18 19 NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: z.string().min(1), ··· 20 21 NEXT_PUBLIC_CLERK_SIGN_UP_URL: z.string().min(1), 21 22 NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL: z.string().min(1), 22 23 NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL: z.string().min(1), 24 + NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string(), 23 25 }, 24 26 runtimeEnv: { 25 27 CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY, ··· 37 39 QSTASH_CURRENT_SIGNING_KEY: process.env.QSTASH_CURRENT_SIGNING_KEY, 38 40 QSTASH_NEXT_SIGNING_KEY: process.env.QSTASH_NEXT_SIGNING_KEY, 39 41 QSTASH_TOKEN: process.env.QSTASH_TOKEN, 42 + NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: 43 + process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY, 44 + STRIPE_WEBHOOK_SECRET_KEY: process.env.STRIPE_WEBHOOK_SECRET_KEY, 40 45 }, 41 46 });
+14
apps/web/src/lib/stripe/client.ts
··· 1 + import type { Stripe as StripeProps } from "@stripe/stripe-js"; 2 + import { loadStripe } from "@stripe/stripe-js"; 3 + 4 + import { env } from "@/env"; 5 + 6 + let stripePromise: Promise<StripeProps | null>; 7 + 8 + export const getStripe = () => { 9 + if (!stripePromise) { 10 + stripePromise = loadStripe(env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY); 11 + } 12 + 13 + return stripePromise; 14 + };
+1 -1
apps/web/src/trpc/shared.ts
··· 10 10 return `http://localhost:3000`; 11 11 }; 12 12 13 - const lambdas = ["clerk"]; 13 + const lambdas = ["clerkRouter", "stripeRouter"]; 14 14 15 15 export const endingLink = (opts?: { headers?: HTTPHeaders }) => 16 16 ((runtime) => {
+1
packages/analytics/src/type.ts
··· 13 13 event: "Page Created"; 14 14 slug: string; 15 15 } 16 + | { event: "User Upgraded"; email: string } 16 17 | { event: "User Signed In" };
+2
packages/api/package.json
··· 12 12 "@openstatus/db": "workspace:^", 13 13 "@openstatus/emails": "workspace:^", 14 14 "@openstatus/plans": "workspace:^", 15 + "@t3-oss/env-core": "0.6.0", 15 16 "@trpc/client": "10.35.0", 16 17 "@trpc/server": "10.35.0", 17 18 "random-word-slugs": "0.1.7", 19 + "stripe": "12.17.0", 18 20 "superjson": "1.13.1", 19 21 "zod": "3.21.4" 20 22 },
+16
packages/api/src/env.ts
··· 1 + import { createEnv } from "@t3-oss/env-core"; 2 + import { z } from "zod"; 3 + 4 + export const env = createEnv({ 5 + server: { 6 + STRIPE_SECRET_KEY: z.string(), 7 + STRIPE_PRO_PRODUCT_ID: z.string(), 8 + STRIPE_PRO_MONTHLY_PRICE_ID: z.string(), 9 + }, 10 + 11 + runtimeEnv: { 12 + STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY, 13 + STRIPE_PRO_PRODUCT_ID: process.env.STRIPE_PRO_PRODUCT_ID, 14 + STRIPE_PRO_MONTHLY_PRICE_ID: process.env.STRIPE_PRO_MONTHLY_PRICE_ID, 15 + }, 16 + });
+4
packages/api/src/lambda.ts
··· 1 1 import { clerkRouter } from "./router/clerk/webhook"; 2 + import { stripeRouter } from "./router/stripe"; 2 3 import { createTRPCRouter } from "./trpc"; 3 4 4 5 // Deployed to /trpc/lambda/** 5 6 export const lambdaRouter = createTRPCRouter({ 6 7 clerkRouter: clerkRouter, 8 + stripeRouter: stripeRouter, 7 9 // TODO: Add open api router 8 10 // See trpc-openapi 9 11 }); 12 + 13 + export { stripe } from "./router/stripe/shared";
+141
packages/api/src/router/stripe/index.ts
··· 1 + import { z } from "zod"; 2 + 3 + import { eq } from "@openstatus/db"; 4 + import { user, usersToWorkspaces, workspace } from "@openstatus/db/src/schema"; 5 + 6 + import { createTRPCRouter, protectedProcedure } from "../../trpc"; 7 + import { stripe } from "./shared"; 8 + import { webhookRouter } from "./webhook"; 9 + 10 + const url = 11 + process.env.NODE_ENV === "production" 12 + ? "https://www.openstatus.dev" 13 + : "http://localhost:3000"; 14 + 15 + export const stripeRouter = createTRPCRouter({ 16 + webhooks: webhookRouter, 17 + 18 + getUserCustomerPortal: protectedProcedure 19 + .input(z.object({ workspaceSlug: z.string() })) 20 + .mutation(async (opts) => { 21 + const result = await opts.ctx.db 22 + .select() 23 + .from(workspace) 24 + .where(eq(workspace.slug, opts.input.workspaceSlug)) 25 + .get(); 26 + 27 + if (!result) return; 28 + 29 + const currentUser = opts.ctx.db 30 + .select() 31 + .from(user) 32 + .where(eq(user.tenantId, opts.ctx.auth.userId)) 33 + .as("currentUser"); 34 + const userHasAccess = await opts.ctx.db 35 + .select() 36 + .from(usersToWorkspaces) 37 + .where(eq(usersToWorkspaces.workspaceId, result.id)) 38 + .innerJoin(currentUser, eq(usersToWorkspaces.userId, currentUser.id)) 39 + .get(); 40 + 41 + if (!userHasAccess || !userHasAccess.users_to_workspaces) return; 42 + let stripeId = result.stripeId; 43 + if (!stripeId) { 44 + const customerData: { 45 + metadata: { workspaceId: string }; 46 + email?: string; 47 + } = { 48 + metadata: { 49 + workspaceId: String(workspace.id), 50 + }, 51 + email: opts.ctx.auth.user?.emailAddresses[0].emailAddress || "", 52 + }; 53 + 54 + const stripeUser = await stripe.customers.create(customerData); 55 + 56 + stripeId = stripeUser.id; 57 + await opts.ctx.db 58 + .update(workspace) 59 + .set({ stripeId }) 60 + .where(eq(workspace.id, result.id)) 61 + .run(); 62 + } 63 + 64 + const session = await stripe.billingPortal.sessions.create({ 65 + customer: stripeId || "", 66 + return_url: `${url}/app/${result.slug}/settings`, 67 + }); 68 + 69 + return session.url; 70 + }), 71 + 72 + getCheckoutSession: protectedProcedure 73 + .input(z.object({ workspaceSlug: z.string() })) 74 + .mutation(async (opts) => { 75 + console.log("getCheckoutSession"); 76 + // The following code is duplicated we should extract it 77 + const result = await opts.ctx.db 78 + .select() 79 + .from(workspace) 80 + .where(eq(workspace.slug, opts.input.workspaceSlug)) 81 + .get(); 82 + 83 + if (!result) return; 84 + 85 + const currentUser = opts.ctx.db 86 + .select() 87 + .from(user) 88 + .where(eq(user.tenantId, opts.ctx.auth.userId)) 89 + .as("currentUser"); 90 + const userHasAccess = await opts.ctx.db 91 + .select() 92 + .from(usersToWorkspaces) 93 + .where(eq(usersToWorkspaces.workspaceId, result.id)) 94 + .innerJoin(currentUser, eq(usersToWorkspaces.userId, currentUser.id)) 95 + .get(); 96 + 97 + if (!userHasAccess || !userHasAccess.users_to_workspaces) return; 98 + let stripeId = result.stripeId; 99 + if (!stripeId) { 100 + const currentUser = await opts.ctx.db 101 + .select() 102 + .from(user) 103 + .where(eq(user.tenantId, opts.ctx.auth.userId)) 104 + .get(); 105 + const customerData: { 106 + metadata: { workspaceId: string }; 107 + email?: string; 108 + } = { 109 + metadata: { 110 + workspaceId: String(workspace.id), 111 + }, 112 + email: currentUser.email || "", 113 + }; 114 + const stripeUser = await stripe.customers.create(customerData); 115 + 116 + stripeId = stripeUser.id; 117 + await opts.ctx.db 118 + .update(workspace) 119 + .set({ stripeId }) 120 + .where(eq(workspace.id, result.id)) 121 + .run(); 122 + } 123 + 124 + const session = await stripe.checkout.sessions.create({ 125 + payment_method_types: ["card"], 126 + customer: stripeId, 127 + 128 + line_items: [ 129 + { 130 + price: process.env.STRIPE_PRO_MONTHLY_PRICE_ID, 131 + quantity: 1, 132 + }, 133 + ], 134 + mode: "subscription", 135 + success_url: `${url}/app/${result.slug}/settings?success=true`, 136 + cancel_url: `${url}/app/${result.slug}/settings`, 137 + }); 138 + 139 + return session; 140 + }), 141 + });
+35
packages/api/src/router/stripe/shared.ts
··· 1 + import Stripe from "stripe"; 2 + 3 + import { env } from "../../env"; 4 + 5 + export const stripe = new Stripe(env.STRIPE_SECRET_KEY ?? "", { 6 + apiVersion: "2022-11-15", 7 + appInfo: { 8 + name: "OpenStatus", 9 + version: "0.1.0", 10 + }, 11 + }); 12 + 13 + export async function cancelSubscription(customer?: string) { 14 + if (!customer) return; 15 + 16 + try { 17 + const subscriptionId = await stripe.subscriptions 18 + .list({ 19 + customer, 20 + }) 21 + .then((res) => res.data[0]?.id); 22 + 23 + if (!subscriptionId) return; 24 + 25 + return await stripe.subscriptions.update(subscriptionId, { 26 + cancel_at_period_end: true, 27 + cancellation_details: { 28 + comment: "Customer deleted their OpenStatus project.", 29 + }, 30 + }); 31 + } catch (error) { 32 + console.log("Error cancelling Stripe subscription", error); 33 + return; 34 + } 35 + }
+98
packages/api/src/router/stripe/webhook.ts
··· 1 + import { TRPCError } from "@trpc/server"; 2 + import type Stripe from "stripe"; 3 + import { custom, z } from "zod"; 4 + 5 + import { analytics, trackAnalytics } from "@openstatus/analytics"; 6 + import { eq } from "@openstatus/db"; 7 + import { user, workspace } from "@openstatus/db/src/schema"; 8 + 9 + import { createTRPCRouter, publicProcedure } from "../../trpc"; 10 + import { stripe } from "./shared"; 11 + 12 + const webhookProcedure = publicProcedure.input( 13 + z.object({ 14 + // From type Stripe.Event 15 + event: z.object({ 16 + id: z.string(), 17 + account: z.string().nullish(), 18 + created: z.number(), 19 + data: z.object({ 20 + object: z.record(z.any()), 21 + }), 22 + type: z.string(), 23 + }), 24 + }), 25 + ); 26 + 27 + export const webhookRouter = createTRPCRouter({ 28 + sessionCompleted: webhookProcedure.mutation(async (opts) => { 29 + const session = opts.input.event.data.object as Stripe.Checkout.Session; 30 + if (typeof session.subscription !== "string") { 31 + throw new TRPCError({ 32 + code: "BAD_REQUEST", 33 + message: "Missing or invalid subscription id", 34 + }); 35 + } 36 + const subscription = await stripe.subscriptions.retrieve( 37 + session.subscription, 38 + ); 39 + const customerId = 40 + typeof subscription.customer === "string" 41 + ? subscription.customer 42 + : subscription.customer.id; 43 + const result = await opts.ctx.db 44 + .select() 45 + .from(workspace) 46 + .where(eq(workspace.stripeId, customerId)) 47 + .get(); 48 + if (!result) { 49 + throw new TRPCError({ 50 + code: "BAD_REQUEST", 51 + message: "Workspace not found", 52 + }); 53 + } 54 + await opts.ctx.db 55 + .update(workspace) 56 + .set({ 57 + plan: "pro", 58 + subscriptionId: subscription.id, 59 + endsAt: new Date(subscription.current_period_end * 1000), 60 + paidUntil: new Date(subscription.current_period_end * 1000), 61 + }) 62 + .where(eq(workspace.id, result.id)) 63 + .run(); 64 + const customer = await stripe.customers.retrieve(customerId); 65 + if (!customer.deleted && customer.email) { 66 + const userResult = await opts.ctx.db 67 + .select() 68 + .from(user) 69 + .where(eq(user.email, customer.email)) 70 + .get(); 71 + await analytics.identify(String(userResult.id), { 72 + email: customer.email, 73 + userId: userResult.id, 74 + }); 75 + await trackAnalytics({ 76 + event: "User Upgraded", 77 + email: customer.email, 78 + }); 79 + } 80 + }), 81 + customerSubscriptionDeleted: webhookProcedure.mutation(async (opts) => { 82 + const subscription = opts.input.event.data.object as Stripe.Subscription; 83 + const customerId = 84 + typeof subscription.customer === "string" 85 + ? subscription.customer 86 + : subscription.customer.id; 87 + 88 + await opts.ctx.db 89 + .update(workspace) 90 + .set({ 91 + subscriptionId: null, 92 + plan: "FREE", 93 + paidUntil: null, 94 + }) 95 + .where(eq(workspace.stripeId, customerId)) 96 + .run(); 97 + }), 98 + });
+10 -5
packages/api/src/router/workspace.ts
··· 2 2 import { z } from "zod"; 3 3 4 4 import { eq } from "@openstatus/db"; 5 - import { user, usersToWorkspaces, workspace } from "@openstatus/db/src/schema"; 5 + import { 6 + selectPageSchema, 7 + selectWorkspaceSchema, 8 + user, 9 + usersToWorkspaces, 10 + workspace, 11 + } from "@openstatus/db/src/schema"; 6 12 7 13 import { createTRPCRouter, protectedProcedure } from "../trpc"; 8 14 ··· 41 47 42 48 if (!result.users_to_workspaces) return; 43 49 44 - return await opts.ctx.db.query.workspace.findMany({ 45 - with: { 46 - pages: true, 47 - }, 50 + const data = await opts.ctx.db.query.workspace.findFirst({ 48 51 where: eq(workspace.id, currentWorkspace.id), 49 52 }); 53 + 54 + return selectWorkspaceSchema.parse(data); 50 55 }), 51 56 52 57 createWorkspace: protectedProcedure
+4
packages/db/drizzle/0005_even_baron_strucker.sql
··· 1 + ALTER TABLE workspace ADD `subscription_id` text;--> statement-breakpoint 2 + ALTER TABLE workspace ADD `plan` text(3);--> statement-breakpoint 3 + ALTER TABLE workspace ADD `ends_at` integer;--> statement-breakpoint 4 + ALTER TABLE workspace ADD `paid_until` integer;
+722
packages/db/drizzle/meta/0005_snapshot.json
··· 1 + { 2 + "version": "5", 3 + "dialect": "sqlite", 4 + "id": "84447f0c-af79-4add-93d7-eb6868983258", 5 + "prevId": "c7088c9f-2521-43af-884e-b7b0b81b0cb9", 6 + "tables": { 7 + "incident": { 8 + "name": "incident", 9 + "columns": { 10 + "id": { 11 + "name": "id", 12 + "type": "integer", 13 + "primaryKey": true, 14 + "notNull": true, 15 + "autoincrement": false 16 + }, 17 + "status": { 18 + "name": "status", 19 + "type": "text(4)", 20 + "primaryKey": false, 21 + "notNull": true, 22 + "autoincrement": false 23 + }, 24 + "title": { 25 + "name": "title", 26 + "type": "text(256)", 27 + "primaryKey": false, 28 + "notNull": true, 29 + "autoincrement": false 30 + }, 31 + "workspace_id": { 32 + "name": "workspace_id", 33 + "type": "integer", 34 + "primaryKey": false, 35 + "notNull": false, 36 + "autoincrement": false 37 + }, 38 + "created_at": { 39 + "name": "created_at", 40 + "type": "integer", 41 + "primaryKey": false, 42 + "notNull": false, 43 + "autoincrement": false, 44 + "default": "(strftime('%s', 'now'))" 45 + }, 46 + "updated_at": { 47 + "name": "updated_at", 48 + "type": "integer", 49 + "primaryKey": false, 50 + "notNull": false, 51 + "autoincrement": false, 52 + "default": "(strftime('%s', 'now'))" 53 + } 54 + }, 55 + "indexes": {}, 56 + "foreignKeys": { 57 + "incident_workspace_id_workspace_id_fk": { 58 + "name": "incident_workspace_id_workspace_id_fk", 59 + "tableFrom": "incident", 60 + "tableTo": "workspace", 61 + "columnsFrom": [ 62 + "workspace_id" 63 + ], 64 + "columnsTo": [ 65 + "id" 66 + ], 67 + "onDelete": "no action", 68 + "onUpdate": "no action" 69 + } 70 + }, 71 + "compositePrimaryKeys": {}, 72 + "uniqueConstraints": {} 73 + }, 74 + "incident_update": { 75 + "name": "incident_update", 76 + "columns": { 77 + "id": { 78 + "name": "id", 79 + "type": "integer", 80 + "primaryKey": true, 81 + "notNull": true, 82 + "autoincrement": false 83 + }, 84 + "status": { 85 + "name": "status", 86 + "type": "text(4)", 87 + "primaryKey": false, 88 + "notNull": true, 89 + "autoincrement": false 90 + }, 91 + "date": { 92 + "name": "date", 93 + "type": "integer", 94 + "primaryKey": false, 95 + "notNull": true, 96 + "autoincrement": false 97 + }, 98 + "message": { 99 + "name": "message", 100 + "type": "text", 101 + "primaryKey": false, 102 + "notNull": true, 103 + "autoincrement": false 104 + }, 105 + "incident_id": { 106 + "name": "incident_id", 107 + "type": "integer", 108 + "primaryKey": false, 109 + "notNull": true, 110 + "autoincrement": false 111 + }, 112 + "created_at": { 113 + "name": "created_at", 114 + "type": "integer", 115 + "primaryKey": false, 116 + "notNull": false, 117 + "autoincrement": false, 118 + "default": "(strftime('%s', 'now'))" 119 + }, 120 + "updated_at": { 121 + "name": "updated_at", 122 + "type": "integer", 123 + "primaryKey": false, 124 + "notNull": false, 125 + "autoincrement": false, 126 + "default": "(strftime('%s', 'now'))" 127 + } 128 + }, 129 + "indexes": {}, 130 + "foreignKeys": { 131 + "incident_update_incident_id_incident_id_fk": { 132 + "name": "incident_update_incident_id_incident_id_fk", 133 + "tableFrom": "incident_update", 134 + "tableTo": "incident", 135 + "columnsFrom": [ 136 + "incident_id" 137 + ], 138 + "columnsTo": [ 139 + "id" 140 + ], 141 + "onDelete": "cascade", 142 + "onUpdate": "no action" 143 + } 144 + }, 145 + "compositePrimaryKeys": {}, 146 + "uniqueConstraints": {} 147 + }, 148 + "incidents_to_monitors": { 149 + "name": "incidents_to_monitors", 150 + "columns": { 151 + "monitor_id": { 152 + "name": "monitor_id", 153 + "type": "integer", 154 + "primaryKey": false, 155 + "notNull": true, 156 + "autoincrement": false 157 + }, 158 + "incident_id": { 159 + "name": "incident_id", 160 + "type": "integer", 161 + "primaryKey": false, 162 + "notNull": true, 163 + "autoincrement": false 164 + } 165 + }, 166 + "indexes": {}, 167 + "foreignKeys": { 168 + "incidents_to_monitors_monitor_id_monitor_id_fk": { 169 + "name": "incidents_to_monitors_monitor_id_monitor_id_fk", 170 + "tableFrom": "incidents_to_monitors", 171 + "tableTo": "monitor", 172 + "columnsFrom": [ 173 + "monitor_id" 174 + ], 175 + "columnsTo": [ 176 + "id" 177 + ], 178 + "onDelete": "cascade", 179 + "onUpdate": "no action" 180 + }, 181 + "incidents_to_monitors_incident_id_incident_id_fk": { 182 + "name": "incidents_to_monitors_incident_id_incident_id_fk", 183 + "tableFrom": "incidents_to_monitors", 184 + "tableTo": "incident", 185 + "columnsFrom": [ 186 + "incident_id" 187 + ], 188 + "columnsTo": [ 189 + "id" 190 + ], 191 + "onDelete": "cascade", 192 + "onUpdate": "no action" 193 + } 194 + }, 195 + "compositePrimaryKeys": { 196 + "incidents_to_monitors_monitor_id_incident_id_pk": { 197 + "columns": [ 198 + "incident_id", 199 + "monitor_id" 200 + ] 201 + } 202 + }, 203 + "uniqueConstraints": {} 204 + }, 205 + "page": { 206 + "name": "page", 207 + "columns": { 208 + "id": { 209 + "name": "id", 210 + "type": "integer", 211 + "primaryKey": true, 212 + "notNull": true, 213 + "autoincrement": false 214 + }, 215 + "workspace_id": { 216 + "name": "workspace_id", 217 + "type": "integer", 218 + "primaryKey": false, 219 + "notNull": true, 220 + "autoincrement": false 221 + }, 222 + "title": { 223 + "name": "title", 224 + "type": "text", 225 + "primaryKey": false, 226 + "notNull": true, 227 + "autoincrement": false 228 + }, 229 + "description": { 230 + "name": "description", 231 + "type": "text", 232 + "primaryKey": false, 233 + "notNull": true, 234 + "autoincrement": false 235 + }, 236 + "icon": { 237 + "name": "icon", 238 + "type": "text(256)", 239 + "primaryKey": false, 240 + "notNull": false, 241 + "autoincrement": false, 242 + "default": "''" 243 + }, 244 + "slug": { 245 + "name": "slug", 246 + "type": "text(256)", 247 + "primaryKey": false, 248 + "notNull": true, 249 + "autoincrement": false 250 + }, 251 + "custom_domain": { 252 + "name": "custom_domain", 253 + "type": "text(256)", 254 + "primaryKey": false, 255 + "notNull": true, 256 + "autoincrement": false 257 + }, 258 + "published": { 259 + "name": "published", 260 + "type": "integer", 261 + "primaryKey": false, 262 + "notNull": false, 263 + "autoincrement": false, 264 + "default": false 265 + }, 266 + "created_at": { 267 + "name": "created_at", 268 + "type": "integer", 269 + "primaryKey": false, 270 + "notNull": false, 271 + "autoincrement": false, 272 + "default": "(strftime('%s', 'now'))" 273 + }, 274 + "updated_at": { 275 + "name": "updated_at", 276 + "type": "integer", 277 + "primaryKey": false, 278 + "notNull": false, 279 + "autoincrement": false, 280 + "default": "(strftime('%s', 'now'))" 281 + } 282 + }, 283 + "indexes": { 284 + "page_slug_unique": { 285 + "name": "page_slug_unique", 286 + "columns": [ 287 + "slug" 288 + ], 289 + "isUnique": true 290 + } 291 + }, 292 + "foreignKeys": { 293 + "page_workspace_id_workspace_id_fk": { 294 + "name": "page_workspace_id_workspace_id_fk", 295 + "tableFrom": "page", 296 + "tableTo": "workspace", 297 + "columnsFrom": [ 298 + "workspace_id" 299 + ], 300 + "columnsTo": [ 301 + "id" 302 + ], 303 + "onDelete": "cascade", 304 + "onUpdate": "no action" 305 + } 306 + }, 307 + "compositePrimaryKeys": {}, 308 + "uniqueConstraints": {} 309 + }, 310 + "monitor": { 311 + "name": "monitor", 312 + "columns": { 313 + "id": { 314 + "name": "id", 315 + "type": "integer", 316 + "primaryKey": true, 317 + "notNull": true, 318 + "autoincrement": false 319 + }, 320 + "job_type": { 321 + "name": "job_type", 322 + "type": "text(3)", 323 + "primaryKey": false, 324 + "notNull": true, 325 + "autoincrement": false, 326 + "default": "'other'" 327 + }, 328 + "periodicity": { 329 + "name": "periodicity", 330 + "type": "text(6)", 331 + "primaryKey": false, 332 + "notNull": true, 333 + "autoincrement": false, 334 + "default": "'other'" 335 + }, 336 + "status": { 337 + "name": "status", 338 + "type": "text(2)", 339 + "primaryKey": false, 340 + "notNull": true, 341 + "autoincrement": false, 342 + "default": "'inactive'" 343 + }, 344 + "active": { 345 + "name": "active", 346 + "type": "integer", 347 + "primaryKey": false, 348 + "notNull": false, 349 + "autoincrement": false, 350 + "default": false 351 + }, 352 + "regions": { 353 + "name": "regions", 354 + "type": "text", 355 + "primaryKey": false, 356 + "notNull": true, 357 + "autoincrement": false, 358 + "default": "''" 359 + }, 360 + "url": { 361 + "name": "url", 362 + "type": "text(512)", 363 + "primaryKey": false, 364 + "notNull": true, 365 + "autoincrement": false 366 + }, 367 + "name": { 368 + "name": "name", 369 + "type": "text(256)", 370 + "primaryKey": false, 371 + "notNull": true, 372 + "autoincrement": false, 373 + "default": "''" 374 + }, 375 + "description": { 376 + "name": "description", 377 + "type": "text", 378 + "primaryKey": false, 379 + "notNull": true, 380 + "autoincrement": false, 381 + "default": "''" 382 + }, 383 + "workspace_id": { 384 + "name": "workspace_id", 385 + "type": "integer", 386 + "primaryKey": false, 387 + "notNull": false, 388 + "autoincrement": false 389 + }, 390 + "created_at": { 391 + "name": "created_at", 392 + "type": "integer", 393 + "primaryKey": false, 394 + "notNull": false, 395 + "autoincrement": false, 396 + "default": "(strftime('%s', 'now'))" 397 + }, 398 + "updated_at": { 399 + "name": "updated_at", 400 + "type": "integer", 401 + "primaryKey": false, 402 + "notNull": false, 403 + "autoincrement": false, 404 + "default": "(strftime('%s', 'now'))" 405 + } 406 + }, 407 + "indexes": {}, 408 + "foreignKeys": { 409 + "monitor_workspace_id_workspace_id_fk": { 410 + "name": "monitor_workspace_id_workspace_id_fk", 411 + "tableFrom": "monitor", 412 + "tableTo": "workspace", 413 + "columnsFrom": [ 414 + "workspace_id" 415 + ], 416 + "columnsTo": [ 417 + "id" 418 + ], 419 + "onDelete": "no action", 420 + "onUpdate": "no action" 421 + } 422 + }, 423 + "compositePrimaryKeys": {}, 424 + "uniqueConstraints": {} 425 + }, 426 + "monitors_to_pages": { 427 + "name": "monitors_to_pages", 428 + "columns": { 429 + "monitor_id": { 430 + "name": "monitor_id", 431 + "type": "integer", 432 + "primaryKey": false, 433 + "notNull": true, 434 + "autoincrement": false 435 + }, 436 + "page_id": { 437 + "name": "page_id", 438 + "type": "integer", 439 + "primaryKey": false, 440 + "notNull": true, 441 + "autoincrement": false 442 + } 443 + }, 444 + "indexes": {}, 445 + "foreignKeys": { 446 + "monitors_to_pages_monitor_id_monitor_id_fk": { 447 + "name": "monitors_to_pages_monitor_id_monitor_id_fk", 448 + "tableFrom": "monitors_to_pages", 449 + "tableTo": "monitor", 450 + "columnsFrom": [ 451 + "monitor_id" 452 + ], 453 + "columnsTo": [ 454 + "id" 455 + ], 456 + "onDelete": "cascade", 457 + "onUpdate": "no action" 458 + }, 459 + "monitors_to_pages_page_id_page_id_fk": { 460 + "name": "monitors_to_pages_page_id_page_id_fk", 461 + "tableFrom": "monitors_to_pages", 462 + "tableTo": "page", 463 + "columnsFrom": [ 464 + "page_id" 465 + ], 466 + "columnsTo": [ 467 + "id" 468 + ], 469 + "onDelete": "cascade", 470 + "onUpdate": "no action" 471 + } 472 + }, 473 + "compositePrimaryKeys": { 474 + "monitors_to_pages_monitor_id_page_id_pk": { 475 + "columns": [ 476 + "monitor_id", 477 + "page_id" 478 + ] 479 + } 480 + }, 481 + "uniqueConstraints": {} 482 + }, 483 + "user": { 484 + "name": "user", 485 + "columns": { 486 + "id": { 487 + "name": "id", 488 + "type": "integer", 489 + "primaryKey": true, 490 + "notNull": true, 491 + "autoincrement": false 492 + }, 493 + "tenant_id": { 494 + "name": "tenant_id", 495 + "type": "text(256)", 496 + "primaryKey": false, 497 + "notNull": false, 498 + "autoincrement": false 499 + }, 500 + "first_name": { 501 + "name": "first_name", 502 + "type": "text", 503 + "primaryKey": false, 504 + "notNull": false, 505 + "autoincrement": false, 506 + "default": "''" 507 + }, 508 + "last_name": { 509 + "name": "last_name", 510 + "type": "text", 511 + "primaryKey": false, 512 + "notNull": false, 513 + "autoincrement": false, 514 + "default": "''" 515 + }, 516 + "email": { 517 + "name": "email", 518 + "type": "text", 519 + "primaryKey": false, 520 + "notNull": false, 521 + "autoincrement": false, 522 + "default": "''" 523 + }, 524 + "photo_url": { 525 + "name": "photo_url", 526 + "type": "text", 527 + "primaryKey": false, 528 + "notNull": false, 529 + "autoincrement": false, 530 + "default": "''" 531 + }, 532 + "created_at": { 533 + "name": "created_at", 534 + "type": "integer", 535 + "primaryKey": false, 536 + "notNull": false, 537 + "autoincrement": false, 538 + "default": "(strftime('%s', 'now'))" 539 + }, 540 + "updated_at": { 541 + "name": "updated_at", 542 + "type": "integer", 543 + "primaryKey": false, 544 + "notNull": false, 545 + "autoincrement": false, 546 + "default": "(strftime('%s', 'now'))" 547 + } 548 + }, 549 + "indexes": { 550 + "user_tenant_id_unique": { 551 + "name": "user_tenant_id_unique", 552 + "columns": [ 553 + "tenant_id" 554 + ], 555 + "isUnique": true 556 + } 557 + }, 558 + "foreignKeys": {}, 559 + "compositePrimaryKeys": {}, 560 + "uniqueConstraints": {} 561 + }, 562 + "users_to_workspaces": { 563 + "name": "users_to_workspaces", 564 + "columns": { 565 + "user_id": { 566 + "name": "user_id", 567 + "type": "integer", 568 + "primaryKey": false, 569 + "notNull": true, 570 + "autoincrement": false 571 + }, 572 + "workspace_id": { 573 + "name": "workspace_id", 574 + "type": "integer", 575 + "primaryKey": false, 576 + "notNull": true, 577 + "autoincrement": false 578 + } 579 + }, 580 + "indexes": {}, 581 + "foreignKeys": { 582 + "users_to_workspaces_user_id_user_id_fk": { 583 + "name": "users_to_workspaces_user_id_user_id_fk", 584 + "tableFrom": "users_to_workspaces", 585 + "tableTo": "user", 586 + "columnsFrom": [ 587 + "user_id" 588 + ], 589 + "columnsTo": [ 590 + "id" 591 + ], 592 + "onDelete": "no action", 593 + "onUpdate": "no action" 594 + }, 595 + "users_to_workspaces_workspace_id_workspace_id_fk": { 596 + "name": "users_to_workspaces_workspace_id_workspace_id_fk", 597 + "tableFrom": "users_to_workspaces", 598 + "tableTo": "workspace", 599 + "columnsFrom": [ 600 + "workspace_id" 601 + ], 602 + "columnsTo": [ 603 + "id" 604 + ], 605 + "onDelete": "no action", 606 + "onUpdate": "no action" 607 + } 608 + }, 609 + "compositePrimaryKeys": { 610 + "users_to_workspaces_user_id_workspace_id_pk": { 611 + "columns": [ 612 + "user_id", 613 + "workspace_id" 614 + ] 615 + } 616 + }, 617 + "uniqueConstraints": {} 618 + }, 619 + "workspace": { 620 + "name": "workspace", 621 + "columns": { 622 + "id": { 623 + "name": "id", 624 + "type": "integer", 625 + "primaryKey": true, 626 + "notNull": true, 627 + "autoincrement": false 628 + }, 629 + "slug": { 630 + "name": "slug", 631 + "type": "text", 632 + "primaryKey": false, 633 + "notNull": true, 634 + "autoincrement": false 635 + }, 636 + "name": { 637 + "name": "name", 638 + "type": "text", 639 + "primaryKey": false, 640 + "notNull": false, 641 + "autoincrement": false 642 + }, 643 + "stripe_id": { 644 + "name": "stripe_id", 645 + "type": "text(256)", 646 + "primaryKey": false, 647 + "notNull": false, 648 + "autoincrement": false 649 + }, 650 + "subscription_id": { 651 + "name": "subscription_id", 652 + "type": "text", 653 + "primaryKey": false, 654 + "notNull": false, 655 + "autoincrement": false 656 + }, 657 + "plan": { 658 + "name": "plan", 659 + "type": "text(3)", 660 + "primaryKey": false, 661 + "notNull": false, 662 + "autoincrement": false 663 + }, 664 + "ends_at": { 665 + "name": "ends_at", 666 + "type": "integer", 667 + "primaryKey": false, 668 + "notNull": false, 669 + "autoincrement": false 670 + }, 671 + "paid_until": { 672 + "name": "paid_until", 673 + "type": "integer", 674 + "primaryKey": false, 675 + "notNull": false, 676 + "autoincrement": false 677 + }, 678 + "created_at": { 679 + "name": "created_at", 680 + "type": "integer", 681 + "primaryKey": false, 682 + "notNull": false, 683 + "autoincrement": false, 684 + "default": "(strftime('%s', 'now'))" 685 + }, 686 + "updated_at": { 687 + "name": "updated_at", 688 + "type": "integer", 689 + "primaryKey": false, 690 + "notNull": false, 691 + "autoincrement": false, 692 + "default": "(strftime('%s', 'now'))" 693 + } 694 + }, 695 + "indexes": { 696 + "workspace_slug_unique": { 697 + "name": "workspace_slug_unique", 698 + "columns": [ 699 + "slug" 700 + ], 701 + "isUnique": true 702 + }, 703 + "workspace_stripe_id_unique": { 704 + "name": "workspace_stripe_id_unique", 705 + "columns": [ 706 + "stripe_id" 707 + ], 708 + "isUnique": true 709 + } 710 + }, 711 + "foreignKeys": {}, 712 + "compositePrimaryKeys": {}, 713 + "uniqueConstraints": {} 714 + } 715 + }, 716 + "enums": {}, 717 + "_meta": { 718 + "schemas": {}, 719 + "tables": {}, 720 + "columns": {} 721 + } 722 + }
+7
packages/db/drizzle/meta/_journal.json
··· 36 36 "when": 1691850907670, 37 37 "tag": "0004_fixed_dakota_north", 38 38 "breakpoints": true 39 + }, 40 + { 41 + "idx": 5, 42 + "version": "5", 43 + "when": 1691930414569, 44 + "tag": "0005_even_baron_strucker", 45 + "breakpoints": true 39 46 } 40 47 ] 41 48 }
+5 -5
packages/db/package.json
··· 10 10 "studio": "drizzle-kit studio" 11 11 }, 12 12 "dependencies": { 13 - "@libsql/client": "^0.3.1", 13 + "@libsql/client": "0.3.1", 14 14 "@t3-oss/env-core": "0.6.0", 15 15 "dotenv": "16.3.1", 16 16 "drizzle-orm": "0.27.2", 17 - "drizzle-zod": "^0.4.4", 17 + "drizzle-zod": "0.4.4", 18 18 "zod": "3.21.4" 19 19 }, 20 20 "devDependencies": { ··· 22 22 "drizzle-kit": "0.19.10", 23 23 "tsconfig": "workspace:^", 24 24 "typescript": "5.1.6", 25 - "bufferutil": "^4.0.7", 26 - "utf-8-validate": "^6.0.3", 25 + "bufferutil": "4.0.7", 26 + "utf-8-validate": "6.0.3", 27 27 "encoding": "0.1.13", 28 - "ts-node": "^10.9.1" 28 + "ts-node": "10.9.1" 29 29 }, 30 30 "author": "" 31 31 }
+18 -1
packages/db/src/schema/workspace.ts
··· 1 1 import { relations, sql } from "drizzle-orm"; 2 2 import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; 3 + import { createSelectSchema } from "drizzle-zod"; 4 + import { z } from "zod"; 3 5 4 6 import { page } from "./page"; 5 7 import { usersToWorkspaces } from "./user"; 6 8 9 + const plan = ["free", "pro"] as const; 10 + 7 11 export const workspace = sqliteTable("workspace", { 8 12 id: integer("id").primaryKey(), 9 13 slug: text("slug").notNull().unique(), // we love random words 14 + name: text("name"), 15 + 10 16 stripeId: text("stripe_id", { length: 256 }).unique(), 11 - name: text("name"), 17 + subscriptionId: text("subscription_id"), 18 + plan: text("plan", plan), 19 + endsAt: integer("ends_at", { mode: "timestamp" }), 20 + paidUntil: integer("paid_until", { mode: "timestamp" }), 12 21 13 22 createdAt: integer("created_at", { mode: "timestamp" }).default( 14 23 sql`(strftime('%s', 'now'))`, ··· 22 31 usersToWorkspaces: many(usersToWorkspaces), 23 32 pages: many(page), 24 33 })); 34 + 35 + export const selectWorkspaceSchema = createSelectSchema(workspace).extend({ 36 + plan: z 37 + .enum(plan) 38 + .nullable() 39 + .default("free") 40 + .transform((val) => val ?? "free"), 41 + });
+8 -1
packages/plans/index.ts
··· 10 10 }; 11 11 }; 12 12 13 - export const allPlans: Record<"free", Plan> = { 13 + export const allPlans: Record<"free" | "pro", Plan> = { 14 14 free: { 15 15 limits: { 16 16 monitors: 5, 17 17 "status-pages": 1, 18 18 periodicity: ["10m", "30m", "1h"], 19 + }, 20 + }, 21 + pro: { 22 + limits: { 23 + monitors: 20, 24 + "status-pages": 5, 25 + periodicity: ["1m", "5m", "10m", "30m", "1h"], 19 26 }, 20 27 }, 21 28 };
+119 -34
pnpm-lock.yaml
··· 76 76 apps/web: 77 77 dependencies: 78 78 '@clerk/nextjs': 79 - specifier: 4.23.1 80 - version: 4.23.1(next@13.4.12)(react-dom@18.2.0)(react@18.2.0) 79 + specifier: 4.23.2 80 + version: 4.23.2(next@13.4.12)(react-dom@18.2.0)(react@18.2.0) 81 81 '@hookform/resolvers': 82 82 specifier: 3.1.1 83 83 version: 3.1.1(react-hook-form@7.45.1) ··· 150 150 '@radix-ui/react-tooltip': 151 151 specifier: 1.0.6 152 152 version: 1.0.6(@types/react-dom@18.2.5)(@types/react@18.2.12)(react-dom@18.2.0)(react@18.2.0) 153 + '@stripe/stripe-js': 154 + specifier: 2.0.0 155 + version: 2.0.0 153 156 '@t3-oss/env-nextjs': 154 157 specifier: 0.4.1 155 158 version: 0.4.1(typescript@5.1.6)(zod@3.21.4) ··· 246 249 shiki: 247 250 specifier: 0.14.3 248 251 version: 0.14.3 252 + stripe: 253 + specifier: 12.17.0 254 + version: 12.17.0 249 255 superjson: 250 256 specifier: 1.13.1 251 257 version: 1.13.1 ··· 350 356 '@openstatus/plans': 351 357 specifier: workspace:^ 352 358 version: link:../plans 359 + '@t3-oss/env-core': 360 + specifier: 0.6.0 361 + version: 0.6.0(typescript@5.1.6)(zod@3.21.4) 353 362 '@trpc/client': 354 363 specifier: 10.35.0 355 364 version: 10.35.0(@trpc/server@10.35.0) ··· 359 368 random-word-slugs: 360 369 specifier: 0.1.7 361 370 version: 0.1.7 371 + stripe: 372 + specifier: 12.17.0 373 + version: 12.17.0 362 374 superjson: 363 375 specifier: 1.13.1 364 376 version: 1.13.1 ··· 410 422 packages/db: 411 423 dependencies: 412 424 '@libsql/client': 413 - specifier: ^0.3.1 425 + specifier: 0.3.1 414 426 version: 0.3.1(bufferutil@4.0.7)(encoding@0.1.13)(utf-8-validate@6.0.3) 415 427 '@t3-oss/env-core': 416 428 specifier: 0.6.0 ··· 422 434 specifier: 0.27.2 423 435 version: 0.27.2(@libsql/client@0.3.1) 424 436 drizzle-zod: 425 - specifier: ^0.4.4 437 + specifier: 0.4.4 426 438 version: 0.4.4(drizzle-orm@0.27.2)(zod@3.21.4) 427 439 zod: 428 440 specifier: 3.21.4 ··· 432 444 specifier: 20.3.1 433 445 version: 20.3.1 434 446 bufferutil: 435 - specifier: ^4.0.7 447 + specifier: 4.0.7 436 448 version: 4.0.7 437 449 drizzle-kit: 438 450 specifier: 0.19.10 ··· 441 453 specifier: 0.1.13 442 454 version: 0.1.13 443 455 ts-node: 444 - specifier: ^10.9.1 456 + specifier: 10.9.1 445 457 version: 10.9.1(@types/node@20.3.1)(typescript@5.1.6) 446 458 tsconfig: 447 459 specifier: workspace:^ ··· 450 462 specifier: 5.1.6 451 463 version: 5.1.6 452 464 utf-8-validate: 453 - specifier: ^6.0.3 465 + specifier: 6.0.3 454 466 version: 6.0.3 455 467 456 468 packages/emails: ··· 966 978 tslib: 2.4.1 967 979 dev: false 968 980 981 + /@clerk/backend@0.27.0: 982 + resolution: {integrity: sha512-Sj541JrpqAn1A/UwdyDBxFV3stq2A/Pe/8HdPTG3Cct6briPyavfi46O5s1+L3BSvUcKUY+UbM0+8VsoCNFi4w==} 983 + engines: {node: '>=14'} 984 + dependencies: 985 + '@clerk/types': 3.49.0 986 + '@peculiar/webcrypto': 1.4.1 987 + '@types/node': 16.18.6 988 + cookie: 0.5.0 989 + deepmerge: 4.2.2 990 + node-fetch-native: 1.0.1 991 + snakecase-keys: 5.4.4 992 + tslib: 2.4.1 993 + dev: false 994 + 969 995 /@clerk/clerk-react@4.23.1(react@18.2.0): 970 996 resolution: {integrity: sha512-X0h7I2aPxc3cY7f5ZOaUrrgYjYXSOWbWOCyNuF4rZeW1L6ED8mZEFpQV4+EluUgAomxgGrt8kdMIyMwSsGw3Tg==} 971 997 engines: {node: '>=14'} ··· 976 1002 '@clerk/types': 3.48.1 977 1003 react: 18.2.0 978 1004 swr: 1.3.0(react@18.2.0) 1005 + tslib: 2.4.1 1006 + dev: false 1007 + 1008 + /@clerk/clerk-react@4.23.2(react@18.2.0): 1009 + resolution: {integrity: sha512-6MJa8ecr22qHhTfdkMMIJGctMBqj01fLJ4vmfZvr22tIkwkPXoeYJd5XcFKuSoO2dXc1eHD/F9i/HdCqGm68gw==} 1010 + engines: {node: '>=14'} 1011 + peerDependencies: 1012 + react: '>=16' 1013 + dependencies: 1014 + '@clerk/shared': 0.21.0(react@18.2.0) 1015 + '@clerk/types': 3.49.0 1016 + react: 18.2.0 979 1017 tslib: 2.4.1 980 1018 dev: false 981 1019 ··· 993 1031 tslib: 2.4.1 994 1032 dev: false 995 1033 1034 + /@clerk/clerk-sdk-node@4.12.2: 1035 + resolution: {integrity: sha512-7xYPsLSeGO5XoP0No/9m2dsCMezwtmiYGKOwWzt41ZzJNFlU0rfqYF3VOZEsbtQlc3ZXeU+67ItjoJYrf3kT6A==} 1036 + engines: {node: '>=14'} 1037 + dependencies: 1038 + '@clerk/backend': 0.27.0 1039 + '@clerk/types': 3.49.0 1040 + '@types/cookies': 0.7.7 1041 + '@types/express': 4.17.14 1042 + '@types/node-fetch': 2.6.2 1043 + camelcase-keys: 6.2.2 1044 + snakecase-keys: 3.2.1 1045 + tslib: 2.4.1 1046 + dev: false 1047 + 996 1048 /@clerk/nextjs@4.23.1(next@13.4.12)(react-dom@18.2.0)(react@18.2.0): 997 1049 resolution: {integrity: sha512-r7lSisqvUvyvlakn69AN+3EDBgIuEQQBR7Hn/MR71UT7zfKvdsRCyTENsj+rxDgfb8a4guiytDJGk1lOewLLvQ==} 998 1050 engines: {node: '>=14'} ··· 1012 1064 tslib: 2.4.1 1013 1065 dev: false 1014 1066 1067 + /@clerk/nextjs@4.23.2(next@13.4.12)(react-dom@18.2.0)(react@18.2.0): 1068 + resolution: {integrity: sha512-99bSVu9r1E9MxybO/6mmPAufSKq4KU7SFeMVkylX7UF8sy5t/LE9cLHyc+9jitcCGgZNai9Om4sj1WIgkNOP8w==} 1069 + engines: {node: '>=14'} 1070 + peerDependencies: 1071 + next: '>=10' 1072 + react: ^17.0.2 || ^18.0.0-0 1073 + react-dom: ^17.0.2 || ^18.0.0-0 1074 + dependencies: 1075 + '@clerk/backend': 0.27.0 1076 + '@clerk/clerk-react': 4.23.2(react@18.2.0) 1077 + '@clerk/clerk-sdk-node': 4.12.2 1078 + '@clerk/types': 3.49.0 1079 + next: 13.4.12(@babel/core@7.22.9)(@opentelemetry/api@1.4.1)(react-dom@18.2.0)(react@18.2.0) 1080 + path-to-regexp: 6.2.1 1081 + react: 18.2.0 1082 + react-dom: 18.2.0(react@18.2.0) 1083 + tslib: 2.4.1 1084 + dev: false 1085 + 1015 1086 /@clerk/shared@0.20.0(react@18.2.0): 1016 1087 resolution: {integrity: sha512-VbJZQ3DwF35qPNTa1u2r/UO0vEfk4Gzf8dzbbma6fLPRILAFovQGYb5Cwvmef+Qj7S0XPpOlW1lSYHBHL326gQ==} 1088 + peerDependencies: 1089 + react: '>=16' 1090 + dependencies: 1091 + glob-to-regexp: 0.4.1 1092 + js-cookie: 3.0.1 1093 + react: 18.2.0 1094 + swr: 1.3.0(react@18.2.0) 1095 + dev: false 1096 + 1097 + /@clerk/shared@0.21.0(react@18.2.0): 1098 + resolution: {integrity: sha512-tkV2OAddFMPBHDjcMbtNNrV3NQD+hGKf2hn3TKv1mRJNZ2oR5Bfk8r0bkaqwoqxX8ndkbHLCa9gwR8SWO7GGow==} 1017 1099 peerDependencies: 1018 1100 react: '>=16' 1019 1101 dependencies: ··· 1030 1112 csstype: 3.1.1 1031 1113 dev: false 1032 1114 1115 + /@clerk/types@3.49.0: 1116 + resolution: {integrity: sha512-vAx5R/iYfsgIaIDMiDr6ZKQnAneAmRrUVYz6KCtPG6/hnEAnRYhwXpEUi89e5G0BFmuUfSxe/N/Anfc1PNteXQ==} 1117 + engines: {node: '>=14'} 1118 + dependencies: 1119 + csstype: 3.1.1 1120 + dev: false 1121 + 1033 1122 /@commander-js/extra-typings@9.4.1(commander@9.4.1): 1034 1123 resolution: {integrity: sha512-v0BqORYamk1koxDon6femDGLWSL7P78vYTyOU5nFaALnmNALL+ktgdHvWbxzzBBJIKS7kv3XvM/DqNwiLcgFTA==} 1035 1124 peerDependencies: ··· 1307 1396 cpu: [arm64] 1308 1397 os: [android] 1309 1398 requiresBuild: true 1310 - dev: false 1311 1399 optional: true 1312 1400 1313 1401 /@esbuild/android-arm@0.16.4: ··· 1343 1431 cpu: [arm] 1344 1432 os: [android] 1345 1433 requiresBuild: true 1346 - dev: false 1347 1434 optional: true 1348 1435 1349 1436 /@esbuild/android-x64@0.16.4: ··· 1379 1466 cpu: [x64] 1380 1467 os: [android] 1381 1468 requiresBuild: true 1382 - dev: false 1383 1469 optional: true 1384 1470 1385 1471 /@esbuild/darwin-arm64@0.16.4: ··· 1415 1501 cpu: [arm64] 1416 1502 os: [darwin] 1417 1503 requiresBuild: true 1418 - dev: false 1419 1504 optional: true 1420 1505 1421 1506 /@esbuild/darwin-x64@0.16.4: ··· 1451 1536 cpu: [x64] 1452 1537 os: [darwin] 1453 1538 requiresBuild: true 1454 - dev: false 1455 1539 optional: true 1456 1540 1457 1541 /@esbuild/freebsd-arm64@0.16.4: ··· 1487 1571 cpu: [arm64] 1488 1572 os: [freebsd] 1489 1573 requiresBuild: true 1490 - dev: false 1491 1574 optional: true 1492 1575 1493 1576 /@esbuild/freebsd-x64@0.16.4: ··· 1523 1606 cpu: [x64] 1524 1607 os: [freebsd] 1525 1608 requiresBuild: true 1526 - dev: false 1527 1609 optional: true 1528 1610 1529 1611 /@esbuild/linux-arm64@0.16.4: ··· 1559 1641 cpu: [arm64] 1560 1642 os: [linux] 1561 1643 requiresBuild: true 1562 - dev: false 1563 1644 optional: true 1564 1645 1565 1646 /@esbuild/linux-arm@0.16.4: ··· 1595 1676 cpu: [arm] 1596 1677 os: [linux] 1597 1678 requiresBuild: true 1598 - dev: false 1599 1679 optional: true 1600 1680 1601 1681 /@esbuild/linux-ia32@0.16.4: ··· 1631 1711 cpu: [ia32] 1632 1712 os: [linux] 1633 1713 requiresBuild: true 1634 - dev: false 1635 1714 optional: true 1636 1715 1637 1716 /@esbuild/linux-loong64@0.16.4: ··· 1667 1746 cpu: [loong64] 1668 1747 os: [linux] 1669 1748 requiresBuild: true 1670 - dev: false 1671 1749 optional: true 1672 1750 1673 1751 /@esbuild/linux-mips64el@0.16.4: ··· 1703 1781 cpu: [mips64el] 1704 1782 os: [linux] 1705 1783 requiresBuild: true 1706 - dev: false 1707 1784 optional: true 1708 1785 1709 1786 /@esbuild/linux-ppc64@0.16.4: ··· 1739 1816 cpu: [ppc64] 1740 1817 os: [linux] 1741 1818 requiresBuild: true 1742 - dev: false 1743 1819 optional: true 1744 1820 1745 1821 /@esbuild/linux-riscv64@0.16.4: ··· 1775 1851 cpu: [riscv64] 1776 1852 os: [linux] 1777 1853 requiresBuild: true 1778 - dev: false 1779 1854 optional: true 1780 1855 1781 1856 /@esbuild/linux-s390x@0.16.4: ··· 1811 1886 cpu: [s390x] 1812 1887 os: [linux] 1813 1888 requiresBuild: true 1814 - dev: false 1815 1889 optional: true 1816 1890 1817 1891 /@esbuild/linux-x64@0.16.4: ··· 1847 1921 cpu: [x64] 1848 1922 os: [linux] 1849 1923 requiresBuild: true 1850 - dev: false 1851 1924 optional: true 1852 1925 1853 1926 /@esbuild/netbsd-x64@0.16.4: ··· 1883 1956 cpu: [x64] 1884 1957 os: [netbsd] 1885 1958 requiresBuild: true 1886 - dev: false 1887 1959 optional: true 1888 1960 1889 1961 /@esbuild/openbsd-x64@0.16.4: ··· 1919 1991 cpu: [x64] 1920 1992 os: [openbsd] 1921 1993 requiresBuild: true 1922 - dev: false 1923 1994 optional: true 1924 1995 1925 1996 /@esbuild/sunos-x64@0.16.4: ··· 1955 2026 cpu: [x64] 1956 2027 os: [sunos] 1957 2028 requiresBuild: true 1958 - dev: false 1959 2029 optional: true 1960 2030 1961 2031 /@esbuild/win32-arm64@0.16.4: ··· 1991 2061 cpu: [arm64] 1992 2062 os: [win32] 1993 2063 requiresBuild: true 1994 - dev: false 1995 2064 optional: true 1996 2065 1997 2066 /@esbuild/win32-ia32@0.16.4: ··· 2027 2096 cpu: [ia32] 2028 2097 os: [win32] 2029 2098 requiresBuild: true 2030 - dev: false 2031 2099 optional: true 2032 2100 2033 2101 /@esbuild/win32-x64@0.16.4: ··· 2063 2131 cpu: [x64] 2064 2132 os: [win32] 2065 2133 requiresBuild: true 2066 - dev: false 2067 2134 optional: true 2068 2135 2069 2136 /@eslint-community/eslint-utils@4.4.0(eslint@8.36.0): ··· 4865 4932 resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} 4866 4933 dev: false 4867 4934 4935 + /@stripe/stripe-js@2.0.0: 4936 + resolution: {integrity: sha512-SxZnf192En0uAfgbigUIj7oJYaXgGc5AI1aos59YXvO8DPeLI0AtT4oMg/Wuk17wbpquEv73+akyCe7xdEjGlA==} 4937 + dev: false 4938 + 4868 4939 /@swc/helpers@0.4.14: 4869 4940 resolution: {integrity: sha512-4C7nX/dvpzB7za4Ql9K81xK3HPxCpHMgwTZVyf+9JQ6VUbn9jjZVN7/Nkdz/Ugzs2CSjqnL/UPXroiVBVHUWUw==} 4870 4941 dependencies: ··· 7182 7253 camelcase: 7.0.1 7183 7254 chalk: 5.3.0 7184 7255 commander: 9.5.0 7185 - esbuild: 0.18.16 7186 - esbuild-register: 3.4.2(esbuild@0.18.16) 7256 + esbuild: 0.18.17 7257 + esbuild-register: 3.4.2(esbuild@0.18.17) 7187 7258 glob: 8.1.0 7188 7259 hanji: 0.0.5 7189 7260 json-diff: 0.9.0 ··· 7430 7501 es6-symbol: 3.1.3 7431 7502 dev: true 7432 7503 7433 - /esbuild-register@3.4.2(esbuild@0.18.16): 7504 + /esbuild-register@3.4.2(esbuild@0.18.17): 7434 7505 resolution: {integrity: sha512-kG/XyTDyz6+YDuyfB9ZoSIOOmgyFCH+xPRtsCa8W85HLRV5Csp+o3jWVbOSHgSLfyLc5DmP+KFDNwty4mEjC+Q==} 7435 7506 peerDependencies: 7436 7507 esbuild: '>=0.12 <1' 7437 7508 dependencies: 7438 7509 debug: 4.3.4 7439 - esbuild: 0.18.16 7510 + esbuild: 0.18.17 7440 7511 transitivePeerDependencies: 7441 7512 - supports-color 7442 7513 dev: true ··· 7559 7630 '@esbuild/win32-arm64': 0.18.17 7560 7631 '@esbuild/win32-ia32': 0.18.17 7561 7632 '@esbuild/win32-x64': 0.18.17 7562 - dev: false 7563 7633 7564 7634 /escalade@3.1.1: 7565 7635 resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} ··· 11787 11857 engines: {node: '>=6.0.0'} 11788 11858 dev: false 11789 11859 11860 + /qs@6.11.2: 11861 + resolution: {integrity: sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==} 11862 + engines: {node: '>=0.6'} 11863 + dependencies: 11864 + side-channel: 1.0.4 11865 + dev: false 11866 + 11790 11867 /querystringify@2.2.0: 11791 11868 resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} 11792 11869 dev: false ··· 12771 12848 /strip-json-comments@3.1.1: 12772 12849 resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} 12773 12850 engines: {node: '>=8'} 12851 + 12852 + /stripe@12.17.0: 12853 + resolution: {integrity: sha512-f8GhS2LQlGCDZ1Akyu57txgSWyLUEYf0yZYS7x2aTKJXVua5lLmmgfJFFYHfTgEdS5P6rurf0Ghcf/HVLggv5g==} 12854 + engines: {node: '>=12.*'} 12855 + dependencies: 12856 + '@types/node': 20.3.1 12857 + qs: 6.11.2 12858 + dev: false 12774 12859 12775 12860 /style-to-js@1.1.3: 12776 12861 resolution: {integrity: sha512-zKI5gN/zb7LS/Vm0eUwjmjrXWw8IMtyA8aPBJZdYiQTXj4+wQ3IucOLIOnF7zCHxvW8UhIGh/uZh/t9zEHXNTQ==}