Openstatus www.openstatus.dev

chore: quantity addons (#1697)

* wip: quantity addons

* refactor: quantiy selector in modal

* wip: stripe quantity

* fix: spacing

* fix: default value

* page add on

* ci: apply automated fixes

---------

Co-authored-by: Thibault Le Ouay Ducasse <thibaultleouay@gmail.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>

+445 -141
+12 -5
apps/dashboard/src/app/(dashboard)/settings/billing/client.tsx
··· 127 127 128 128 if (!workspace) return null; 129 129 130 - const addons = allPlans[workspace.plan].addons; 130 + const planAddons = allPlans[workspace.plan].addons; 131 131 132 132 return ( 133 133 <SectionGroup> ··· 179 179 </FormCardDescription> 180 180 </FormCardHeader> 181 181 <div className="flex flex-col gap-2 pt-4"> 182 - {/* TODO: redirect to stripe product */} 183 - {addons["email-domain-protection"] ? ( 182 + {planAddons["email-domain-protection"] ? ( 184 183 <BillingAddons 185 184 label="Magic Link (Auth)" 186 185 description="Only allow user with a given email domain to access the status page." ··· 188 187 workspace={workspace} 189 188 /> 190 189 ) : null} 191 - {addons["white-label"] ? ( 190 + {planAddons["white-label"] ? ( 192 191 <BillingAddons 193 192 label="White Label" 194 193 description="Remove the 'powered by openstatus.dev' footer from your status pages." ··· 196 195 workspace={workspace} 197 196 /> 198 197 ) : null} 199 - {Object.keys(addons).length === 0 ? ( 198 + {planAddons["status-pages"] ? ( 199 + <BillingAddons 200 + label="Status Pages" 201 + description="Create and manage status pages for your workspace." 202 + addon="status-pages" 203 + workspace={workspace} 204 + /> 205 + ) : null} 206 + {Object.keys(planAddons).length === 0 ? ( 200 207 <EmptyStateContainer> 201 208 <EmptyStateTitle>No add-ons available</EmptyStateTitle> 202 209 </EmptyStateContainer>
+193 -70
apps/dashboard/src/components/content/billing-addons.tsx
··· 13 13 import { Label } from "@/components/ui/label"; 14 14 import { useCookieState } from "@/hooks/use-cookie-state"; 15 15 16 + import { ButtonGroup } from "@/components/ui/button-group"; 16 17 import { useTRPC } from "@/lib/trpc/client"; 17 18 import type { RouterOutputs } from "@openstatus/api"; 19 + import { allPlans } from "@openstatus/db/src/schema/plan/config"; 18 20 import type { Addons } from "@openstatus/db/src/schema/plan/schema"; 19 21 import { getAddonPriceConfig } from "@openstatus/db/src/schema/plan/utils"; 20 22 import { useMutation, useQueryClient } from "@tanstack/react-query"; 21 23 import { isTRPCClientError } from "@trpc/client"; 22 - import { Check } from "lucide-react"; 23 - import { useState, useTransition } from "react"; 24 + import { Check, MinusIcon, PlusIcon } from "lucide-react"; 25 + import { useEffect, useState, useTransition } from "react"; 24 26 import { toast } from "sonner"; 27 + import { Input } from "../ui/input"; 25 28 26 29 type Workspace = RouterOutputs["workspace"]["get"]; 27 30 ··· 32 35 workspace: Workspace; 33 36 } 34 37 38 + interface PriceConfig { 39 + value: number; 40 + currency: string; 41 + locale: string; 42 + } 43 + 35 44 export function BillingAddons({ 36 45 label, 37 46 description, ··· 52 61 }, 53 62 }), 54 63 ); 55 - 56 64 const plan = workspace.plan; 57 - const value = workspace.limits[addon]; 65 + const defaultLimit = allPlans[workspace.plan].limits[addon]; 66 + const workspaceLimit = workspace.limits[addon]; 67 + const defaultValue = 68 + typeof workspaceLimit === "number" && typeof defaultLimit === "number" 69 + ? // current value - default value to evaluate the difference 70 + workspaceLimit - defaultLimit 71 + : workspaceLimit; 72 + const [value, setValue] = useState<number | boolean>(defaultValue); 58 73 const price = getAddonPriceConfig(plan, addon, currency); 59 74 75 + // Reset value when modal opens 76 + useEffect(() => { 77 + if (open) { 78 + setValue(defaultValue); 79 + } 80 + }, [open, defaultValue]); 81 + 60 82 function submitAction() { 61 83 startTransition(async () => { 62 84 try { 85 + // toggle the value if it's a boolean otherwise use the value 86 + const newValue = typeof value === "boolean" ? !value : value; 63 87 const promise = checkoutSessionMutation.mutateAsync({ 64 88 workspaceSlug: workspace.slug, 65 89 feature: addon, 66 - remove: value, 90 + value: newValue, 67 91 }); 68 92 toast.promise(promise, { 69 93 loading: "Updating...", 70 94 success: () => { 71 95 setOpen(false); 72 - return value ? "Removed" : "Added"; 96 + return "Billing information updated"; 73 97 }, 74 98 error: (error) => { 75 99 if (isTRPCClientError(error)) { ··· 84 108 } 85 109 }); 86 110 } 111 + const hasAddon = 112 + typeof defaultValue === "number" 113 + ? defaultValue > 0 114 + : defaultValue !== defaultLimit; 115 + const isQuantity = typeof value === "number"; 87 116 88 117 return ( 89 - <div className="flex flex-col gap-2"> 90 - <div className="flex flex-col justify-between gap-1.5 sm:flex-row"> 91 - <div className="space-y-0.5 text-sm"> 92 - <Label>{label}</Label> 93 - <div className="truncate text-muted-foreground">{description}</div> 94 - </div> 95 - <div className="flex items-center gap-2"> 96 - {value ? <Check className="size-4 text-success" /> : null} 97 - <span className="font-mono text-foreground text-sm"> 98 - {price 99 - ? new Intl.NumberFormat(price.locale, { 100 - style: "currency", 101 - currency: price.currency, 102 - }).format(price.value) 103 - : "N/A"} 104 - /mo. 105 - </span> 106 - <AlertDialog open={open} onOpenChange={setOpen}> 118 + <AlertDialog open={open} onOpenChange={setOpen}> 119 + <div className="flex flex-col gap-2"> 120 + <div className="grid grid-cols-3 gap-1.5 lg:grid-cols-5"> 121 + <div className="col-span-3 space-y-0.5 text-sm"> 122 + <Label>{label}</Label> 123 + <div className="text-muted-foreground">{description}</div> 124 + </div> 125 + <div className="flex items-center gap-1.5"> 126 + <span className="font-mono text-foreground text-sm"> 127 + {formatPrice(price)} 128 + {isQuantity ? "/mo./each" : "/mo."} 129 + </span> 130 + {hasAddon && !isQuantity ? ( 131 + <Check className="size-4 text-success" /> 132 + ) : null} 133 + {hasAddon && isQuantity ? ( 134 + <span className="font-mono text-success">+{defaultValue}</span> 135 + ) : null} 136 + </div> 137 + <div className="col-span-2 flex items-center justify-end gap-1.5 lg:col-span-1"> 107 138 <AlertDialogTrigger asChild> 108 139 <Button size="sm" variant="secondary"> 109 - {value ? "Remove" : "Add"} 140 + {getButtonLabel(hasAddon, value)} 110 141 </Button> 111 142 </AlertDialogTrigger> 112 - <AlertDialogContent> 113 - <AlertDialogHeader> 114 - <AlertDialogTitle>{label}</AlertDialogTitle> 115 - <AlertDialogDescription> 116 - {value ? ( 117 - <> 118 - {label} will be removed from your subscription. You will 119 - save{" "} 120 - {price 121 - ? new Intl.NumberFormat(price.locale, { 122 - style: "currency", 123 - currency: price.currency, 124 - }).format(price.value) 125 - : "N/A"} 126 - /mo. on your next billing cycle. 127 - </> 128 - ) : ( 129 - <> 130 - {label} will be added to your subscription. You will be 131 - charged an additional{" "} 132 - {price 133 - ? new Intl.NumberFormat(price.locale, { 134 - style: "currency", 135 - currency: price.currency, 136 - }).format(price.value) 137 - : "N/A"} 138 - /mo. on your next billing cycle. 139 - </> 140 - )} 141 - </AlertDialogDescription> 142 - </AlertDialogHeader> 143 - <AlertDialogFooter> 144 - <AlertDialogCancel>Cancel</AlertDialogCancel> 145 - <AlertDialogAction 146 - onClick={(e) => { 147 - e.preventDefault(); 148 - submitAction(); 149 - }} 150 - disabled={isPending} 151 - > 152 - {isPending ? "Updating..." : value ? "Remove" : "Add"} 153 - </AlertDialogAction> 154 - </AlertDialogFooter> 155 - </AlertDialogContent> 156 - </AlertDialog> 143 + </div> 157 144 </div> 158 145 </div> 146 + <AlertDialogContent> 147 + <AlertDialogHeader> 148 + <AlertDialogTitle>{label}</AlertDialogTitle> 149 + <AlertDialogDescription> 150 + {getDialogDescription(label, price, value, hasAddon)} 151 + </AlertDialogDescription> 152 + </AlertDialogHeader> 153 + {isQuantity && 154 + typeof value === "number" && 155 + typeof defaultLimit === "number" ? ( 156 + <QuantityControl 157 + value={value} 158 + setValue={setValue} 159 + defaultLimit={defaultLimit} 160 + /> 161 + ) : null} 162 + <AlertDialogFooter> 163 + <AlertDialogCancel>Cancel</AlertDialogCancel> 164 + <AlertDialogAction 165 + onClick={(e) => { 166 + e.preventDefault(); 167 + submitAction(); 168 + }} 169 + disabled={ 170 + isPending || 171 + (typeof value === "number" && 172 + typeof defaultValue === "number" && 173 + value === defaultValue) 174 + } 175 + > 176 + {getButtonLabel(hasAddon, value, isPending)} 177 + </AlertDialogAction> 178 + </AlertDialogFooter> 179 + </AlertDialogContent> 180 + </AlertDialog> 181 + ); 182 + } 183 + 184 + // NOTE: could move to lib/formatter.ts 185 + function formatPrice(price: PriceConfig | null) { 186 + if (!price) return "N/A"; 187 + return new Intl.NumberFormat(price.locale, { 188 + style: "currency", 189 + currency: price.currency, 190 + }).format(price.value); 191 + } 192 + 193 + function getButtonLabel( 194 + hasAddon: boolean, 195 + value: number | boolean, 196 + isPending = false, 197 + ) { 198 + if (isPending) return "Updating..."; 199 + 200 + const isBoolean = typeof value === "boolean"; 201 + const isQuantity = typeof value === "number"; 202 + 203 + if (isQuantity) return "Update"; 204 + 205 + if (isBoolean) { 206 + return hasAddon ? "Remove" : "Add"; 207 + } 208 + 209 + return null; 210 + } 211 + 212 + function getDialogDescription( 213 + label: string, 214 + price: PriceConfig | null, 215 + value: number | boolean, 216 + hasAddon: boolean, 217 + ) { 218 + const formattedPrice = formatPrice(price); 219 + const isBoolean = typeof value === "boolean"; 220 + const isQuantity = typeof value === "number"; 221 + const priceSuffix = isQuantity ? "/mo./each" : "/mo."; 222 + 223 + if (isBoolean) { 224 + if (hasAddon) { 225 + return `${label} will be removed from your subscription. You will save ${formattedPrice}${priceSuffix} on your next billing cycle.`; 226 + } 227 + return `${label} will be added to your subscription. You will be charged an additional ${formattedPrice}${priceSuffix} on your next billing cycle.`; 228 + } 229 + 230 + if (isQuantity) { 231 + return `${label} will be updated to ${value} on your next billing cycle. You will be charged ${formattedPrice}${priceSuffix} on your next billing cycle.`; 232 + } 233 + } 234 + 235 + function QuantityControl({ 236 + value, 237 + setValue, 238 + defaultLimit, 239 + }: { 240 + value: number; 241 + setValue: (value: number) => void; 242 + defaultLimit: number | boolean; 243 + }) { 244 + const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { 245 + const newValue = Number.parseInt(e.target.value); 246 + if (Number.isNaN(newValue)) { 247 + setValue(typeof defaultLimit === "number" ? defaultLimit : 0); 248 + } else { 249 + setValue( 250 + Math.max(typeof defaultLimit === "number" ? defaultLimit : 0, newValue), 251 + ); 252 + } 253 + }; 254 + 255 + return ( 256 + <div className="flex items-center justify-center gap-2 py-2"> 257 + <ButtonGroup aria-label="Quantity" className="h-fit"> 258 + <Button 259 + variant="outline" 260 + size="icon" 261 + onClick={() => setValue(value - 1)} 262 + disabled={value <= 0} 263 + > 264 + <MinusIcon /> 265 + </Button> 266 + <Input 267 + type="number" 268 + value={value} 269 + className="w-16 text-right" 270 + step={1} 271 + min={0} 272 + onChange={handleChange} 273 + /> 274 + <Button 275 + variant="outline" 276 + size="icon" 277 + onClick={() => setValue(value + 1)} 278 + > 279 + <PlusIcon /> 280 + </Button> 281 + </ButtonGroup> 159 282 </div> 160 283 ); 161 284 }
+18 -7
apps/dashboard/src/components/data-table/billing/data-table.tsx
··· 159 159 currency, 160 160 ); 161 161 if (!price) return null; 162 + 163 + const isNumber = typeof limitValue === "number"; 162 164 return ( 163 165 <div> 164 - <span className="text-muted-foreground"> 165 - add-on{" "} 166 + <span> 167 + {isNumber 168 + ? new Intl.NumberFormat("us") 169 + .format(limitValue) 170 + .toString() 171 + : null} 166 172 </span> 167 173 <span> 168 - {new Intl.NumberFormat(price.locale, { 169 - style: "currency", 170 - currency: price.currency, 171 - }).format(price.value)} 172 - /mo. 174 + <span className="text-muted-foreground"> 175 + {isNumber ? " + " : ""} 176 + </span> 177 + <span> 178 + {new Intl.NumberFormat(price.locale, { 179 + style: "currency", 180 + currency: price.currency, 181 + }).format(price.value)} 182 + {isNumber ? "/mo./each" : "/mo."} 183 + </span> 173 184 </span> 174 185 </div> 175 186 );
+83
apps/dashboard/src/components/ui/button-group.tsx
··· 1 + import { Slot } from "@radix-ui/react-slot"; 2 + import { type VariantProps, cva } from "class-variance-authority"; 3 + 4 + import { Separator } from "@/components/ui/separator"; 5 + import { cn } from "@/lib/utils"; 6 + 7 + const buttonGroupVariants = cva( 8 + "flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2", 9 + { 10 + variants: { 11 + orientation: { 12 + horizontal: 13 + "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none", 14 + vertical: 15 + "flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none", 16 + }, 17 + }, 18 + defaultVariants: { 19 + orientation: "horizontal", 20 + }, 21 + }, 22 + ); 23 + 24 + function ButtonGroup({ 25 + className, 26 + orientation, 27 + ...props 28 + }: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) { 29 + return ( 30 + <div 31 + role="group" 32 + data-slot="button-group" 33 + data-orientation={orientation} 34 + className={cn(buttonGroupVariants({ orientation }), className)} 35 + {...props} 36 + /> 37 + ); 38 + } 39 + 40 + function ButtonGroupText({ 41 + className, 42 + asChild = false, 43 + ...props 44 + }: React.ComponentProps<"div"> & { 45 + asChild?: boolean; 46 + }) { 47 + const Comp = asChild ? Slot : "div"; 48 + 49 + return ( 50 + <Comp 51 + className={cn( 52 + "flex items-center gap-2 rounded-md border bg-muted px-4 font-medium text-sm shadow-xs [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none", 53 + className, 54 + )} 55 + {...props} 56 + /> 57 + ); 58 + } 59 + 60 + function ButtonGroupSeparator({ 61 + className, 62 + orientation = "vertical", 63 + ...props 64 + }: React.ComponentProps<typeof Separator>) { 65 + return ( 66 + <Separator 67 + data-slot="button-group-separator" 68 + orientation={orientation} 69 + className={cn( 70 + "!m-0 relative self-stretch bg-input data-[orientation=vertical]:h-auto", 71 + className, 72 + )} 73 + {...props} 74 + /> 75 + ); 76 + } 77 + 78 + export { 79 + ButtonGroup, 80 + ButtonGroupSeparator, 81 + ButtonGroupText, 82 + buttonGroupVariants, 83 + };
+6 -2
apps/dashboard/src/components/ui/button.tsx
··· 26 26 sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", 27 27 lg: "h-10 rounded-md px-6 has-[>svg]:px-4", 28 28 icon: "size-9", 29 + "icon-sm": "size-8", 30 + "icon-lg": "size-10", 29 31 }, 30 32 }, 31 33 defaultVariants: { ··· 37 39 38 40 function Button({ 39 41 className, 40 - variant, 41 - size, 42 + variant = "default", 43 + size = "default", 42 44 asChild = false, 43 45 ...props 44 46 }: React.ComponentProps<"button"> & ··· 50 52 return ( 51 53 <Comp 52 54 data-slot="button" 55 + data-variant={variant} 56 + data-size={size} 53 57 className={cn(buttonVariants({ variant, size, className }))} 54 58 {...props} 55 59 />
+1 -1
apps/dashboard/src/components/ui/separator.tsx
··· 13 13 }: React.ComponentProps<typeof SeparatorPrimitive.Root>) { 14 14 return ( 15 15 <SeparatorPrimitive.Root 16 - data-slot="separator-root" 16 + data-slot="separator" 17 17 decorative={decorative} 18 18 orientation={orientation} 19 19 className={cn(
+6 -6
apps/web/src/content/pages/unrelated/pricing.mdx
··· 10 10 data={{ 11 11 headers: [ 12 12 "Features comparison", 13 - <>$0/month <span className="text-muted-foreground">| Hobby</span></>, 14 - <>$30/month <span className="text-muted-foreground">| Starter</span></>, 15 - <>$100/month <span className="text-muted-foreground">| Pro</span></>, 13 + <>$0/month<span className="text-muted-foreground">|Hobby</span></>, 14 + <>$30/month<span className="text-muted-foreground">|Starter</span></>, 15 + <>$100/month<span className="text-muted-foreground">|Pro</span></>, 16 16 ], 17 17 rows: [ 18 18 [<a href="/uptime-monitoring"><strong>Monitors</strong></a>, "", "", ""], ··· 27 27 ["OTel Exporter", "", "", "+"], 28 28 ["Number of on-demand checks", "30/mo.", "100/mo.", "300/mo."], 29 29 [<a href="/status-page"><strong>Status Pages</strong></a>, "", "", ""], 30 - ["Number of status pages", "1", "1", "5"], 30 + ["Number of status pages", "1", "1 +$20/mo./each", "5 +$20/mo./each"], 31 31 ["Maintenance status", "+", "+", "+"], 32 32 ["Toggle numbers visibility", "+", "+", "+"], 33 33 ["Subscribers", "", "+", "+"], 34 34 ["Custom domain", "", "+", "+"], 35 - ["White Label", "", "add-on $300/mo.", "add-on $300/mo."], 35 + ["White Label", "", "$300/mo.", "$300/mo."], 36 36 [<strong>Audience</strong>, "", "", ""], 37 37 ["Password Protection", "", "+", "+"], 38 - ["Email Authentification", "", "add-on $100/mo.", "add-on $100/mo."], 38 + ["Email Authentification", "", "$100/mo.", "$100/mo."], 39 39 [<strong>Alerts</strong>, "", "", ""], 40 40 ["Slack, Discord, Email, Webhook, ntfy.sh", "+", "+", "+"], 41 41 ["WhatsApp", "", "+", "+"],
+48 -11
packages/api/src/router/stripe/index.ts
··· 1 1 import { z } from "zod"; 2 2 3 - import { eq } from "@openstatus/db"; 3 + import { count, eq, schema } from "@openstatus/db"; 4 4 import { 5 5 selectWorkspaceSchema, 6 6 user, ··· 9 9 workspacePlans, 10 10 } from "@openstatus/db/src/schema"; 11 11 12 + import { allPlans } from "@openstatus/db/src/schema/plan/config"; 12 13 import { addons } from "@openstatus/db/src/schema/plan/schema"; 13 14 import { updateAddonInLimits } from "@openstatus/db/src/schema/plan/utils"; 14 15 import { TRPCError } from "@trpc/server"; ··· 176 177 z.object({ 177 178 workspaceSlug: z.string(), 178 179 feature: z.enum(addons), 179 - remove: z.boolean().optional(), 180 + value: z.union([z.boolean(), z.number()]), 180 181 }), 181 182 ) 182 183 .mutation(async (opts) => { ··· 226 227 227 228 const priceId = getPriceIdForFeature(opts.input.feature); 228 229 229 - if (opts.input.remove) { 230 - const items = await stripe.subscriptionItems.list({ 231 - subscription: sub.subscriptions?.data[0]?.id, 230 + if (!priceId) { 231 + throw new TRPCError({ 232 + code: "BAD_REQUEST", 233 + message: "Invalid feature", 232 234 }); 233 - const item = items.data.find((item) => item.price.id === priceId); 234 - if (item) { 235 - await stripe.subscriptionItems.del(item.id); 235 + } 236 + 237 + const quantity = 238 + typeof opts.input.value === "number" ? opts.input.value : 1; 239 + 240 + // We need to check the total of status page 241 + if (opts.input.feature === "status-pages") { 242 + const statusPageCt = await opts.ctx.db 243 + .select({ count: count() }) 244 + .from(schema.page) 245 + .where(eq(schema.page.workspaceId, result.id)) 246 + .get(); 247 + const pageCount = statusPageCt?.count ?? 0; 248 + if (pageCount > quantity + allPlans[ws.plan].limits["status-pages"]) { 249 + throw new TRPCError({ 250 + code: "BAD_REQUEST", 251 + message: `You already have ${pageCount} status pages, please delete some status page first.`, 252 + }); 236 253 } 254 + } 255 + 256 + const items = await stripe.subscriptionItems.list({ 257 + subscription: sub.subscriptions?.data[0]?.id, 258 + }); 259 + 260 + const item = items.data.find((item) => item.price.id === priceId); 261 + 262 + if (!opts.input.value && typeof opts.input.value === "boolean" && item) { 263 + await stripe.subscriptionItems.del(item.id); 264 + } else if (typeof opts.input.value === "number" && item) { 265 + await stripe.subscriptionItems.update(item.id, { 266 + quantity, 267 + }); 237 268 } else { 238 269 await stripe.subscriptionItems.create({ 239 270 price: priceId, 240 271 subscription: sub.subscriptions?.data[0]?.id, 241 - quantity: 1, 272 + quantity, 242 273 }); 243 274 } 244 275 245 - // NOTE: update the limits based on the feature type 276 + const defaultLimit = allPlans[ws.plan].limits[opts.input.feature]; 277 + const newValue = 278 + typeof opts.input.value === "number" && typeof defaultLimit === "number" 279 + ? opts.input.value + defaultLimit 280 + : opts.input.value; 246 281 247 282 const newLimits = updateAddonInLimits( 248 283 ws.limits, 249 284 opts.input.feature, 250 - opts.input.remove ? "remove" : "add", 285 + newValue, 251 286 ); 287 + 288 + console.log("new Limits"); 252 289 253 290 await opts.ctx.db 254 291 .update(workspace)
+11
packages/api/src/router/stripe/utils.ts
··· 84 84 }, 85 85 }, 86 86 }, 87 + { 88 + feature: "status-pages", 89 + price: { 90 + monthly: { 91 + priceIds: { 92 + test: "price_1Slrk8BXJcTfzsyJXQxshFU4", 93 + production: "price_1SlrkHBXJcTfzsyJIxHeKUYe", 94 + }, 95 + }, 96 + }, 97 + }, 87 98 ] satisfies Array<{ 88 99 feature: keyof Addons; 89 100 price: {
+41 -22
packages/api/src/router/stripe/webhook.ts
··· 54 54 }); 55 55 } 56 56 57 - for (const item of subscription.items.data) { 58 - console.log(item); 59 - const feature = getFeatureFromPriceId(item.price.id); 60 - if (!feature) { 61 - continue; 62 - } 63 - const _ws = await opts.ctx.db 64 - .select() 65 - .from(workspace) 66 - .where(eq(workspace.stripeId, customerId)) 67 - .get(); 57 + // for (const item of subscription.items.data) { 58 + // const feature = getFeatureFromPriceId(item.price.id); 59 + // if (!feature) { 60 + // continue; 61 + // } 62 + // const _ws = await opts.ctx.db 63 + // .select() 64 + // .from(workspace) 65 + // .where(eq(workspace.stripeId, customerId)) 66 + // .get(); 67 + 68 + // const ws = selectWorkspaceSchema.parse(_ws); 68 69 69 - const ws = selectWorkspaceSchema.parse(_ws); 70 + // const currentValue = ws.limits[feature.feature]; 71 + // const newValue = 72 + // typeof currentValue === "boolean" 73 + // ? true 74 + // : typeof currentValue === "number" 75 + // ? currentValue + 1 76 + // : currentValue; 70 77 71 - const newLimits = updateAddonInLimits(ws.limits, feature.feature, "add"); 78 + // const newLimits = updateAddonInLimits( 79 + // ws.limits, 80 + // feature.feature, 81 + // newValue, 82 + // ); 72 83 73 - await opts.ctx.db 74 - .update(workspace) 75 - .set({ 76 - limits: JSON.stringify(newLimits), 77 - }) 78 - .where(eq(workspace.id, result.id)) 79 - .run(); 80 - } 84 + // await opts.ctx.db 85 + // .update(workspace) 86 + // .set({ 87 + // limits: JSON.stringify(newLimits), 88 + // }) 89 + // .where(eq(workspace.id, result.id)) 90 + // .run(); 91 + // } 81 92 82 93 const customer = await stripe.customers.retrieve(customerId); 83 94 if (!customer.deleted && customer.email) { ··· 137 148 138 149 const ws = selectWorkspaceSchema.parse(_ws); 139 150 151 + const currentValue = ws.limits[feature.feature]; 152 + const newValue = 153 + typeof currentValue === "boolean" 154 + ? true 155 + : typeof currentValue === "number" 156 + ? currentValue + 1 157 + : currentValue; 158 + 140 159 const newLimits = updateAddonInLimits( 141 160 ws.limits, 142 161 feature.feature, 143 - "add", 162 + newValue, 144 163 ); 145 164 146 165 await opts.ctx.db
+14
packages/db/src/schema/plan/config.ts
··· 79 79 INR: 30_000, 80 80 }, 81 81 }, 82 + "status-pages": { 83 + price: { 84 + USD: 20, 85 + EUR: 20, 86 + INR: 2_000, 87 + }, 88 + }, 82 89 }, 83 90 limits: { 84 91 version: undefined, ··· 134 141 USD: 300, 135 142 EUR: 300, 136 143 INR: 30_000, 144 + }, 145 + }, 146 + "status-pages": { 147 + price: { 148 + USD: 20, 149 + EUR: 20, 150 + INR: 2_000, 137 151 }, 138 152 }, 139 153 },
+1
packages/db/src/schema/plan/schema.ts
··· 68 68 export const addons = [ 69 69 "email-domain-protection", 70 70 "white-label", 71 + "status-pages", 71 72 ] as const satisfies Partial<keyof Limits>[]; 72 73 73 74 export const addonsSchema = z.partialRecord(
+11 -17
packages/db/src/schema/plan/utils.ts
··· 129 129 } 130 130 131 131 /** 132 - * Add or remove an addon from limits 133 - * Automatically infers addon type (toggle/quantity) from the limit field type 132 + * Update an addon value in limits 134 133 * @param limits - Current workspace limits 135 - * @param addon - Addon key to add/remove 136 - * @param action - "add" to enable/increment, "remove" to disable/decrement 137 - * @param quantity - Optional quantity for quantity-based addons (defaults to 1) 134 + * @param addon - Addon key to update 135 + * @param value - The value to set (boolean for toggle addons, number for quantity addons) 138 136 * @returns Updated limits object 139 137 */ 140 138 export function updateAddonInLimits( 141 139 limits: Limits, 142 140 addon: keyof Addons, 143 - action: "add" | "remove", 144 - _quantity = 1, 141 + value: boolean | number, 145 142 ): Limits { 146 143 const currentValue = limits[addon]; 147 144 const newLimits = { ...limits }; 148 145 149 - // Infer addon type from the limit field type 150 - if (typeof currentValue === "boolean") { 151 - // Toggle addon: boolean on/off 152 - newLimits[addon] = action === "add"; 153 - } else if (typeof currentValue === "number") { 154 - // Quantity addon: increment/decrement 155 - // newLimits[addon] = Math.max( 156 - // 0, 157 - // currentValue + (action === "add" ? quantity : -quantity) 158 - // ); // Don't go below 0 146 + // Infer addon type from the limit field type and set the value 147 + if (typeof currentValue === "boolean" && typeof value === "boolean") { 148 + // Toggle addon: set boolean value 149 + (newLimits[addon] as boolean) = value; 150 + } else if (typeof currentValue === "number" && typeof value === "number") { 151 + // Quantity addon: set numeric value (ensure it doesn't go below 0) 152 + (newLimits[addon] as number) = Math.max(0, value); 159 153 } 160 154 161 155 return limitsSchema.parse(newLimits);