From ed4c2b274183d468cb3bcddae8f2ef76261d3048 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Tue, 7 Oct 2025 19:56:59 -0700 Subject: [PATCH 01/15] dashboard item quantity page --- .../item-quantities/page-client.tsx | 623 ++++++++++++++++++ .../[projectId]/item-quantities/page.tsx | 10 + .../projects/[projectId]/sidebar-layout.tsx | 8 + .../data-table/team-search-table.tsx | 71 ++ .../src/components/data-table/team-table.tsx | 6 + .../src/components/data-table/data-table.tsx | 2 + .../apps/implementations/admin-app-impl.ts | 1 + 7 files changed, 721 insertions(+) create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/item-quantities/page-client.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/item-quantities/page.tsx create mode 100644 apps/dashboard/src/components/data-table/team-search-table.tsx diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/item-quantities/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/item-quantities/page-client.tsx new file mode 100644 index 0000000000..0ebab13acf --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/item-quantities/page-client.tsx @@ -0,0 +1,623 @@ +"use client"; + +import { TeamMemberSearchTable } from "@/components/data-table/team-member-search-table"; +import { TeamSearchTable } from "@/components/data-table/team-search-table"; +import { SmartFormDialog } from "@/components/form-dialog"; +import { PageLayout } from "../page-layout"; +import { useAdminApp } from "../use-admin-app"; +import { KnownErrors } from "@stackframe/stack-shared"; +import { Result } from "@stackframe/stack-shared/dist/utils/results"; +import { + ActionDialog, + Button, + Card, + CardContent, + CardHeader, + CardTitle, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Skeleton, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Typography, + toast, +} from "@stackframe/stack-ui"; +import { Suspense, useEffect, useMemo, useState } from "react"; +import * as yup from "yup"; + +type CustomerType = "user" | "team" | "custom"; + +type SelectedCustomer = { + type: CustomerType, + id: string, + label: string, +}; + +export default function PageClient() { + const adminApp = useAdminApp(); + const project = adminApp.useProject(); + const config = project.useConfig(); + + const [customerType, setCustomerType] = useState("user"); + const [selectedCustomer, setSelectedCustomer] = useState(null); + + const items = useMemo(() => { + const payments = config.payments; + if (!payments) { + return [] as Array<[string, { displayName?: string | null, customerType?: CustomerType }]>; + } + return Object.entries(payments.items ?? {}); + }, [config.payments]); + + const itemsForType = useMemo( + () => items.filter(([, itemConfig]) => itemConfig.customerType === customerType), + [items, customerType], + ); + + const paymentsConfigured = Boolean(config.payments); + + return ( + + + + Customer balances + + +
+ + + +
+ + {!paymentsConfigured && ( + + Payments are not configured for this project yet. Set up payments to define items. + + )} + + {paymentsConfigured && itemsForType.length === 0 && ( + + {customerType === "user" && "No user items are configured yet."} + {customerType === "team" && "No team items are configured yet."} + {customerType === "custom" && "No custom items are configured yet."} + + )} + + {paymentsConfigured && itemsForType.length > 0 && ( + }> + + + )} +
+
+
+ ); +} + +type CustomerSelectorProps = { + customerType: CustomerType, + selectedCustomer: SelectedCustomer | null, + onSelect: (customer: SelectedCustomer) => void, +}; + +function CustomerSelector(props: CustomerSelectorProps) { + const [open, setOpen] = useState(false); + const [customIdDraft, setCustomIdDraft] = useState(""); + + useEffect(() => { + if (open && props.customerType === "custom") { + setCustomIdDraft(props.selectedCustomer?.type === "custom" ? props.selectedCustomer.id : ""); + } + }, [open, props.customerType, props.selectedCustomer]); + + const triggerLabel = props.selectedCustomer + ? props.selectedCustomer.label + : props.customerType === "custom" + ? "Select customer" + : `Select ${props.customerType}`; + + const handleSelect = (customer: SelectedCustomer) => { + props.onSelect(customer); + setOpen(false); + }; + + const dialogTitle = props.customerType === "custom" + ? "Select customer" + : `Select ${props.customerType}`; + + const dialogContent = () => { + if (props.customerType === "user") { + return open ? ( + ( + + )} + /> + ) : null; + } + if (props.customerType === "team") { + return open ? ( + ( + + )} + /> + ) : null; + } + return ( +
+ + Enter the identifier for the custom customer. + + setCustomIdDraft(event.target.value)} + placeholder="customer-123" + /> +
+ ); + }; + + return ( + + {triggerLabel} + + } + title={dialogTitle} + description={props.customerType === "custom" ? "Provide a custom customer identifier to inspect their balances." : undefined} + open={open} + onOpenChange={setOpen} + cancelButton={{ label: "Close" }} + okButton={props.customerType === "custom" ? { + label: "Use customer", + props: { disabled: customIdDraft.trim().length === 0 }, + onClick: async () => { + const trimmed = customIdDraft.trim(); + if (!trimmed) { + return "prevent-close"; + } + handleSelect({ type: "custom", id: trimmed, label: trimmed }); + }, + } : false} + > + {dialogContent()} + + ); +} + +function ItemTable(props: { + items: Array<[string, { displayName?: string | null }]>, + customer: SelectedCustomer | null, +}) { + return ( +
+ + + + Item + Quantity + Actions + + + + {props.items.map(([itemId, itemConfig]) => ( + props.customer ? ( + + ) : ( + + +
+ {itemConfig.displayName ?? itemId} + {itemId} +
+
+ + +
+ ) + ))} +
+
+
+ ); +} + +function ItemRowSuspense(props: ItemRowProps) { + return ( + + +
+ + +
+
+ + + + +
+ + +
+
+ + } + > + +
+ ); +} + +type ItemRowProps = { + itemId: string, + itemDisplayName: string, + customer: SelectedCustomer, +}; + +function ItemRowContent(props: ItemRowProps) { + const adminApp = useAdminApp(); + const [isAdjustOpen, setIsAdjustOpen] = useState(false); + const [isSetOpen, setIsSetOpen] = useState(false); + + const item = useItemForCustomer(adminApp, props.customer, props.itemId); + + return ( + <> + + +
+ {props.itemDisplayName} + {props.itemId} +
+
+ +
+ {item.quantity} + {item.quantity !== item.nonNegativeQuantity && ( + + Available: {item.nonNegativeQuantity} + + )} +
+
+ +
+ + +
+
+
+ + + + + ); +} + +function useItemForCustomer( + adminApp: ReturnType, + customer: SelectedCustomer, + itemId: string, +) { + if (customer.type === "user") { + return adminApp.useItem({ userId: customer.id, itemId }); + } + if (customer.type === "team") { + return adminApp.useItem({ teamId: customer.id, itemId }); + } + return adminApp.useItem({ customCustomerId: customer.id, itemId }); +} + +type QuantityDialogProps = { + open: boolean, + onOpenChange: (open: boolean) => void, + customer: SelectedCustomer, + itemId: string, + itemLabel: string, +}; + +function AdjustItemQuantityDialog(props: QuantityDialogProps) { + const adminApp = useAdminApp(); + + const schema = useMemo(() => yup.object({ + quantity: yup + .number() + .defined() + .label("Quantity change") + .meta({ + stackFormFieldPlaceholder: "Eg. 5 or -3", + }) + .test("non-zero", "Please enter a non-zero amount", (value) => (value ?? 0) !== 0), + description: yup + .string() + .optional() + .label("Description") + .meta({ + type: "textarea", + stackFormFieldPlaceholder: "Optional note for your records", + description: "Appears in transaction history for context.", + }), + expiresAt: yup + .date() + .optional() + .label("Expires at"), + }), []); + + const onSubmit = async (values: yup.InferType) => { + const quantity = values.quantity!; + const customerOptions = customerToMutationOptions(props.customer); + const result = await Result.fromPromise(adminApp.createItemQuantityChange({ + ...customerOptions, + itemId: props.itemId, + quantity, + description: values.description?.trim() ? values.description.trim() : undefined, + expiresAt: values.expiresAt ? values.expiresAt.toISOString() : undefined, + })); + + if (result.status === "ok") { + await refreshItem(adminApp, props.customer, props.itemId); + toast({ title: "Item quantity updated" }); + return; + } + + handleItemQuantityError(result.error); + return "prevent-close"; + }; + + return ( + + ); +} + +function SetItemQuantityDialog( + props: QuantityDialogProps & { currentQuantity: number }, +) { + const adminApp = useAdminApp(); + + const schema = useMemo(() => yup.object({ + quantity: yup + .number() + .defined() + .min(0, "Quantity cannot be negative") + .label("New quantity") + .default(Math.max(0, props.currentQuantity)) + .meta({ + stackFormFieldPlaceholder: "Enter the desired final quantity", + }), + description: yup + .string() + .optional() + .label("Description") + .meta({ + type: "textarea", + stackFormFieldPlaceholder: "Optional note for your records", + description: "Appears in transaction history for context.", + }), + expiresAt: yup + .date() + .optional() + .label("Expires at"), + }), [props.currentQuantity]); + + const onSubmit = async (values: yup.InferType) => { + const desiredQuantity = values.quantity ?? 0; + const delta = desiredQuantity - props.currentQuantity; + + if (delta === 0) { + toast({ title: "No changes applied" }); + return "prevent-close"; + } + + const customerOptions = customerToMutationOptions(props.customer); + const result = await Result.fromPromise(adminApp.createItemQuantityChange({ + ...customerOptions, + itemId: props.itemId, + quantity: delta, + description: values.description?.trim() ? values.description.trim() : undefined, + expiresAt: values.expiresAt ? values.expiresAt.toISOString() : undefined, + })); + + if (result.status === "ok") { + await refreshItem(adminApp, props.customer, props.itemId); + toast({ title: "Item quantity set" }); + return; + } + + handleItemQuantityError(result.error); + return "prevent-close"; + }; + + return ( + + ); +} + +function customerToMutationOptions(customer: SelectedCustomer) { + if (customer.type === "user") { + return { userId: customer.id } as const; + } + if (customer.type === "team") { + return { teamId: customer.id } as const; + } + return { customCustomerId: customer.id } as const; +} + +async function refreshItem( + adminApp: ReturnType, + customer: SelectedCustomer, + itemId: string, +) { + if (customer.type === "user") { + await adminApp.getItem({ userId: customer.id, itemId }); + } else if (customer.type === "team") { + await adminApp.getItem({ teamId: customer.id, itemId }); + } else { + await adminApp.getItem({ customCustomerId: customer.id, itemId }); + } +} + +function handleItemQuantityError(error: unknown) { + if (error instanceof KnownErrors.ItemNotFound) { + toast({ title: "Item not found", variant: "destructive" }); + return; + } + if (error instanceof KnownErrors.UserNotFound) { + toast({ title: "User not found", variant: "destructive" }); + return; + } + if (error instanceof KnownErrors.TeamNotFound) { + toast({ title: "Team not found", variant: "destructive" }); + return; + } + if (error instanceof KnownErrors.ItemCustomerTypeDoesNotMatch) { + toast({ + title: "Customer type mismatch", + description: "This item is not available for the selected customer type.", + variant: "destructive", + }); + return; + } + if (error instanceof KnownErrors.ItemQuantityInsufficientAmount) { + toast({ + title: "Quantity too low", + description: "This change would reduce the quantity below zero.", + variant: "destructive", + }); + return; + } + toast({ title: "Unable to update quantity", variant: "destructive" }); +} + +function ItemTableSkeleton(props: { rows: number }) { + return ( +
+ + + + Item + Quantity + Actions + + + + {Array.from({ length: props.rows }).map((_, index) => ( + + +
+ + +
+
+ + + + +
+ + +
+
+
+ ))} +
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/item-quantities/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/item-quantities/page.tsx new file mode 100644 index 0000000000..7cc5c1fbba --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/item-quantities/page.tsx @@ -0,0 +1,10 @@ +import PageClient from "./page-client"; + +export const metadata = { + title: "Item Quantities", +}; + +export default function Page() { + return ; +} + diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx index 021876f3d6..bb6dea17c1 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx @@ -35,6 +35,7 @@ import { Mail, Menu, Palette, + Package, Receipt, Settings, Settings2, @@ -253,6 +254,13 @@ const navigationItems: (Label | Item | Hidden)[] = [ icon: CreditCard, type: 'item', }, + { + name: "Item Quantities", + href: "/item-quantities", + regex: /^\/projects\/[^\/]+\/item-quantities$/, + icon: Package, + type: 'item', + }, { name: "Transactions", href: "/payments/transactions", diff --git a/apps/dashboard/src/components/data-table/team-search-table.tsx b/apps/dashboard/src/components/data-table/team-search-table.tsx new file mode 100644 index 0000000000..abf3879e87 --- /dev/null +++ b/apps/dashboard/src/components/data-table/team-search-table.tsx @@ -0,0 +1,71 @@ +'use client'; + +import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app"; +import { ServerTeam } from "@stackframe/stack"; +import { + DataTable, + DataTableColumnHeader, + SearchToolbarItem, + TextCell, +} from "@stackframe/stack-ui"; +import { ColumnDef, Table } from "@tanstack/react-table"; +import { useMemo } from "react"; + +function toolbarRender(table: Table) { + return ( + + ); +} + +export function TeamSearchTable(props: { + action: (team: ServerTeam) => React.ReactNode; +}) { + const adminApp = useAdminApp(); + const teams = adminApp.useTeams(); + + const tableColumns = useMemo[]>(() => [ + { + accessorKey: "displayName", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {row.original.displayName ?? "–"} + ), + enableSorting: false, + }, + { + accessorKey: "id", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.id} + + ), + enableSorting: false, + }, + { + id: "actions", + cell: ({ row }) => props.action(row.original), + enableSorting: false, + }, + ], [props.action]); + + return ( + + ); +} diff --git a/apps/dashboard/src/components/data-table/team-table.tsx b/apps/dashboard/src/components/data-table/team-table.tsx index e9ec671b2d..e6e91def1d 100644 --- a/apps/dashboard/src/components/data-table/team-table.tsx +++ b/apps/dashboard/src/components/data-table/team-table.tsx @@ -128,11 +128,17 @@ const columns: ColumnDef[] = [ ]; export function TeamTable(props: { teams: ServerTeam[] }) { + const router = useRouter(); + const stackAdminApp = useAdminApp(); + return { + router.push(`/projects/${encodeURIComponent(stackAdminApp.projectId)}/teams/${encodeURIComponent(row.id)}`); + }} />; } diff --git a/packages/stack-ui/src/components/data-table/data-table.tsx b/packages/stack-ui/src/components/data-table/data-table.tsx index f9b790988b..8fe988ded3 100644 --- a/packages/stack-ui/src/components/data-table/data-table.tsx +++ b/packages/stack-ui/src/components/data-table/data-table.tsx @@ -129,6 +129,7 @@ export function DataTable({ defaultColumnFilters, defaultSorting, showDefaultToolbar = true, + onRowClick, }: DataTableProps) { const [sorting, setSorting] = React.useState(defaultSorting); const [columnFilters, setColumnFilters] = React.useState(defaultColumnFilters); @@ -156,6 +157,7 @@ export function DataTable({ globalFilter={globalFilter} setGlobalFilter={setGlobalFilter} showDefaultToolbar={showDefaultToolbar} + onRowClick={onRowClick} />; } diff --git a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts index 2838c37f78..4d33fb9666 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts @@ -578,6 +578,7 @@ export class _StackAdminAppImplIncomplete Date: Wed, 8 Oct 2025 10:05:11 -0700 Subject: [PATCH 02/15] add items page with adjustments --- .../page-client.tsx | 232 ++++++----------- .../{item-quantities => items}/page.tsx | 0 .../payments/products/item-dialog.tsx | 179 ------------- .../products/page-client-catalogs-view.tsx | 2 +- .../products/page-client-list-view.tsx | 2 +- .../projects/[projectId]/sidebar-layout.tsx | 6 +- .../data-table/payment-item-table.tsx | 186 ------------- .../data-table/team-search-table.tsx | 6 +- .../src/components/payments/item-dialog.tsx | 245 +++++++++++++----- 9 files changed, 266 insertions(+), 592 deletions(-) rename apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/{item-quantities => items}/page-client.tsx (71%) rename apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/{item-quantities => items}/page.tsx (100%) delete mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/item-dialog.tsx delete mode 100644 apps/dashboard/src/components/data-table/payment-item-table.tsx diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/item-quantities/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/items/page-client.tsx similarity index 71% rename from apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/item-quantities/page-client.tsx rename to apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/items/page-client.tsx index 0ebab13acf..d700f66ef5 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/item-quantities/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/items/page-client.tsx @@ -32,6 +32,8 @@ import { } from "@stackframe/stack-ui"; import { Suspense, useEffect, useMemo, useState } from "react"; import * as yup from "yup"; +import { ChevronsUpDown } from "lucide-react"; +import { ItemDialog } from "@/components/payments/item-dialog"; type CustomerType = "user" | "team" | "custom"; @@ -48,13 +50,11 @@ export default function PageClient() { const [customerType, setCustomerType] = useState("user"); const [selectedCustomer, setSelectedCustomer] = useState(null); + const [showItemDialog, setShowItemDialog] = useState(false); const items = useMemo(() => { const payments = config.payments; - if (!payments) { - return [] as Array<[string, { displayName?: string | null, customerType?: CustomerType }]>; - } - return Object.entries(payments.items ?? {}); + return Object.entries(payments.items); }, [config.payments]); const itemsForType = useMemo( @@ -64,63 +64,79 @@ export default function PageClient() { const paymentsConfigured = Boolean(config.payments); + const itemDialogTitle = useMemo(() => { + if (customerType === "user") { + return "Create User Item"; + } + if (customerType === "team") { + return "Create Team Item"; + } + return "Create Custom Item"; + }, [customerType]); + + const handleSaveItem = async (item: { id: string, displayName: string, customerType: 'user' | 'team' | 'custom' }) => { + await project.updateConfig({ [`payments.items.${item.id}`]: { displayName: item.displayName, customerType: item.customerType } }); + setShowItemDialog(false); + }; + return ( setShowItemDialog(true)}>{itemDialogTitle}} > - - - Customer balances - - -
- - - -
- - {!paymentsConfigured && ( - - Payments are not configured for this project yet. Set up payments to define items. - - )} +
+ + + +
- {paymentsConfigured && itemsForType.length === 0 && ( - - {customerType === "user" && "No user items are configured yet."} - {customerType === "team" && "No team items are configured yet."} - {customerType === "custom" && "No custom items are configured yet."} - - )} + {!paymentsConfigured && ( + + Payments are not configured for this project yet. Set up payments to define items. + + )} - {paymentsConfigured && itemsForType.length > 0 && ( - }> - - - )} -
-
+ {paymentsConfigured && itemsForType.length === 0 && ( + + {customerType === "user" && "No user items are configured yet."} + {customerType === "team" && "No team items are configured yet."} + {customerType === "custom" && "No custom items are configured yet."} + + )} + + {paymentsConfigured && itemsForType.length > 0 && ( + }> + + + )} + + id)} + forceCustomerType={customerType} + />
); } @@ -188,7 +204,7 @@ function CustomerSelector(props: CustomerSelectorProps) { handleSelect({ type: "team", id: team.id, - label: team.displayName ?? team.id, + label: team.displayName, })} > Select @@ -214,8 +230,9 @@ function CustomerSelector(props: CustomerSelectorProps) { return ( + } title={dialogTitle} @@ -319,7 +336,6 @@ type ItemRowProps = { function ItemRowContent(props: ItemRowProps) { const adminApp = useAdminApp(); const [isAdjustOpen, setIsAdjustOpen] = useState(false); - const [isSetOpen, setIsSetOpen] = useState(false); const item = useItemForCustomer(adminApp, props.customer, props.itemId); @@ -332,14 +348,9 @@ function ItemRowContent(props: ItemRowProps) { {props.itemId} - +
{item.quantity} - {item.quantity !== item.nonNegativeQuantity && ( - - Available: {item.nonNegativeQuantity} - - )}
@@ -347,9 +358,6 @@ function ItemRowContent(props: ItemRowProps) { - @@ -361,14 +369,6 @@ function ItemRowContent(props: ItemRowProps) { itemId={props.itemId} itemLabel={props.itemDisplayName} /> - ); } @@ -378,13 +378,14 @@ function useItemForCustomer( customer: SelectedCustomer, itemId: string, ) { + let options: Parameters[0] = { customCustomerId: customer.id, itemId }; if (customer.type === "user") { - return adminApp.useItem({ userId: customer.id, itemId }); + options = { userId: customer.id, itemId }; } if (customer.type === "team") { - return adminApp.useItem({ teamId: customer.id, itemId }); + options = { teamId: customer.id, itemId }; } - return adminApp.useItem({ customCustomerId: customer.id, itemId }); + return adminApp.useItem(options); } type QuantityDialogProps = { @@ -406,7 +407,7 @@ function AdjustItemQuantityDialog(props: QuantityDialogProps) { .meta({ stackFormFieldPlaceholder: "Eg. 5 or -3", }) - .test("non-zero", "Please enter a non-zero amount", (value) => (value ?? 0) !== 0), + .test("non-zero", "Please enter a non-zero amount", (value) => (value !== 0)), description: yup .string() .optional() @@ -457,77 +458,6 @@ function AdjustItemQuantityDialog(props: QuantityDialogProps) { ); } -function SetItemQuantityDialog( - props: QuantityDialogProps & { currentQuantity: number }, -) { - const adminApp = useAdminApp(); - - const schema = useMemo(() => yup.object({ - quantity: yup - .number() - .defined() - .min(0, "Quantity cannot be negative") - .label("New quantity") - .default(Math.max(0, props.currentQuantity)) - .meta({ - stackFormFieldPlaceholder: "Enter the desired final quantity", - }), - description: yup - .string() - .optional() - .label("Description") - .meta({ - type: "textarea", - stackFormFieldPlaceholder: "Optional note for your records", - description: "Appears in transaction history for context.", - }), - expiresAt: yup - .date() - .optional() - .label("Expires at"), - }), [props.currentQuantity]); - - const onSubmit = async (values: yup.InferType) => { - const desiredQuantity = values.quantity ?? 0; - const delta = desiredQuantity - props.currentQuantity; - - if (delta === 0) { - toast({ title: "No changes applied" }); - return "prevent-close"; - } - - const customerOptions = customerToMutationOptions(props.customer); - const result = await Result.fromPromise(adminApp.createItemQuantityChange({ - ...customerOptions, - itemId: props.itemId, - quantity: delta, - description: values.description?.trim() ? values.description.trim() : undefined, - expiresAt: values.expiresAt ? values.expiresAt.toISOString() : undefined, - })); - - if (result.status === "ok") { - await refreshItem(adminApp, props.customer, props.itemId); - toast({ title: "Item quantity set" }); - return; - } - - handleItemQuantityError(result.error); - return "prevent-close"; - }; - - return ( - - ); -} function customerToMutationOptions(customer: SelectedCustomer) { if (customer.type === "user") { diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/item-quantities/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/items/page.tsx similarity index 100% rename from apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/item-quantities/page.tsx rename to apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/items/page.tsx diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/item-dialog.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/item-dialog.tsx deleted file mode 100644 index d326291e09..0000000000 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/item-dialog.tsx +++ /dev/null @@ -1,179 +0,0 @@ -"use client"; - -import { cn } from "@/lib/utils"; -import { Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, SimpleTooltip, Typography } from "@stackframe/stack-ui"; -import { useState } from "react"; - -type ItemDialogProps = { - open: boolean, - onOpenChange: (open: boolean) => void, - onSave: (item: { id: string, displayName: string, customerType: 'user' | 'team' | 'custom' }) => Promise, - editingItem?: { - id: string, - displayName: string, - customerType: 'user' | 'team' | 'custom', - }, - existingItemIds?: string[], -}; - -export function ItemDialog({ - open, - onOpenChange, - onSave, - editingItem, - existingItemIds = [] -}: ItemDialogProps) { - const [itemId, setItemId] = useState(editingItem?.id || ""); - const [displayName, setDisplayName] = useState(editingItem?.displayName || ""); - const [customerType, setCustomerType] = useState<'user' | 'team' | 'custom'>(editingItem?.customerType || 'user'); - const [errors, setErrors] = useState>({}); - - const validateAndSave = async () => { - const newErrors: Record = {}; - - // Validate item ID - if (!itemId.trim()) { - newErrors.itemId = "Item ID is required"; - } else if (!/^[a-z0-9-]+$/.test(itemId)) { - newErrors.itemId = "Item ID must contain only lowercase letters, numbers, and hyphens"; - } else if (!editingItem && existingItemIds.includes(itemId)) { - newErrors.itemId = "This item ID already exists"; - } - - // Validate display name - if (!displayName.trim()) { - newErrors.displayName = "Display name is required"; - } - - if (Object.keys(newErrors).length > 0) { - setErrors(newErrors); - return; - } - - await onSave({ - id: itemId.trim(), - displayName: displayName.trim(), - customerType - }); - - handleClose(); - }; - - const handleClose = () => { - if (!editingItem) { - setItemId(""); - setDisplayName(""); - setCustomerType('user'); - } - setErrors({}); - onOpenChange(false); - }; - - return ( - - - - {editingItem ? "Edit Item" : "Create Item"} - - Items are features or services that customers receive. They appear as rows in your pricing table. - - - -
- {/* Item ID */} -
- - { - const nextValue = e.target.value.toLowerCase(); - setItemId(nextValue); - if (errors.itemId) { - setErrors(prev => { - const newErrors = { ...prev }; - delete newErrors.itemId; - return newErrors; - }); - } - }} - placeholder="e.g., api-calls" - disabled={!!editingItem} - className={cn(errors.itemId ? "border-destructive" : "")} - /> - {errors.itemId && ( - - {errors.itemId} - - )} -
- - {/* Display Name */} -
- - { - setDisplayName(e.target.value); - if (errors.displayName) { - setErrors(prev => { - const newErrors = { ...prev }; - delete newErrors.displayName; - return newErrors; - }); - } - }} - placeholder="e.g., API Calls" - className={cn(errors.displayName ? "border-destructive" : "")} - /> - {errors.displayName && ( - - {errors.displayName} - - )} -
- - {/* Customer Type */} -
- - -
-
- - - - - -
-
- ); -} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-catalogs-view.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-catalogs-view.tsx index b88dcca186..60376af68d 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-catalogs-view.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-catalogs-view.tsx @@ -39,7 +39,7 @@ import { Fragment, useEffect, useId, useMemo, useRef, useState } from "react"; import { IllustratedInfo } from "../../../../../../../components/illustrated-info"; import { PageLayout } from "../../page-layout"; import { useAdminApp } from "../../use-admin-app"; -import { ItemDialog } from "./item-dialog"; +import { ItemDialog } from "@/components/payments/item-dialog"; import { ProductDialog } from "./product-dialog"; type Product = CompleteConfig['payments']['products'][keyof CompleteConfig['payments']['products']]; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx index 4b09d5ccc7..231e38a45a 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx @@ -12,7 +12,7 @@ import React, { ReactNode, useEffect, useId, useMemo, useRef, useState } from "r import { IllustratedInfo } from "../../../../../../../components/illustrated-info"; import { PageLayout } from "../../page-layout"; import { useAdminApp } from "../../use-admin-app"; -import { ItemDialog } from "./item-dialog"; +import { ItemDialog } from "@/components/payments/item-dialog"; import { ListSection } from "./list-section"; import { ProductDialog } from "./product-dialog"; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx index bb6dea17c1..b64c6d88cf 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx @@ -255,9 +255,9 @@ const navigationItems: (Label | Item | Hidden)[] = [ type: 'item', }, { - name: "Item Quantities", - href: "/item-quantities", - regex: /^\/projects\/[^\/]+\/item-quantities$/, + name: "Items", + href: "/items", + regex: /^\/projects\/[^\/]+\/items$/, icon: Package, type: 'item', }, diff --git a/apps/dashboard/src/components/data-table/payment-item-table.tsx b/apps/dashboard/src/components/data-table/payment-item-table.tsx deleted file mode 100644 index 9142002ac9..0000000000 --- a/apps/dashboard/src/components/data-table/payment-item-table.tsx +++ /dev/null @@ -1,186 +0,0 @@ -'use client'; -import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app"; -import { SmartFormDialog } from "@/components/form-dialog"; -import { ItemDialog } from "@/components/payments/item-dialog"; -import { KnownErrors } from "@stackframe/stack-shared"; -import { branchPaymentsSchema } from "@stackframe/stack-shared/dist/config/schema"; -import { has } from "@stackframe/stack-shared/dist/utils/objects"; -import { Result } from "@stackframe/stack-shared/dist/utils/results"; -import { ActionCell, ActionDialog, DataTable, DataTableColumnHeader, TextCell, toast } from "@stackframe/stack-ui"; -import { ColumnDef } from "@tanstack/react-table"; -import { useState } from "react"; -import * as yup from "yup"; - -type PaymentItem = { - id: string, -} & yup.InferType["items"][string]; - -const columns: ColumnDef[] = [ - { - accessorKey: "id", - header: ({ column }) => , - cell: ({ row }) => {row.original.id}, - enableSorting: false, - }, - { - accessorKey: "displayName", - header: ({ column }) => , - cell: ({ row }) => {row.original.displayName ?? ""}, - enableSorting: false, - }, - { - accessorKey: "customerType", - header: ({ column }) => , - cell: ({ row }) => {row.original.customerType}, - enableSorting: false, - }, - { - id: "actions", - cell: ({ row }) => , - } -]; - -export function PaymentItemTable({ items }: { items: Record["items"][string]> }) { - const data: PaymentItem[] = Object.entries(items).map(([id, item]) => ({ - id, - ...item, - })); - - return ; -} - -function ActionsCell({ item }: { item: PaymentItem }) { - const [open, setOpen] = useState(false); - const [isEditOpen, setIsEditOpen] = useState(false); - const [isDeleteOpen, setIsDeleteOpen] = useState(false); - const stackAdminApp = useAdminApp(); - const project = stackAdminApp.useProject(); - return ( - <> - setOpen(true), - }, - { - item: "Edit", - onClick: () => setIsEditOpen(true), - }, - '-', - { - item: "Delete", - onClick: () => setIsDeleteOpen(true), - danger: true, - }, - ]} - /> - - - { - const config = await project.getConfig(); - for (const [productId, product] of Object.entries(config.payments.products)) { - if (has(product.includedItems, item.id)) { - toast({ - title: "Item is included in product", - description: `Please remove it from the product "${productId}" before deleting.`, - variant: "destructive", - }); - return "prevent-close"; - } - } - await project.updateConfig({ - [`payments.items.${item.id}`]: null, - }); - toast({ title: "Item deleted" }); - } - }} - /> - - ); -} - -type CreateItemQuantityChangeDialogProps = { - open: boolean, - onOpenChange: (open: boolean) => void, - itemId: string, - customerType: "user" | "team" | "custom" | undefined, -} - -function CreateItemQuantityChangeDialog({ open, onOpenChange, itemId, customerType }: CreateItemQuantityChangeDialogProps) { - const stackAdminApp = useAdminApp(); - - const schema = yup.object({ - customerId: yup.string().defined().label("Customer ID"), - quantity: yup.number().defined().label("Quantity"), - description: yup.string().optional().label("Description"), - expiresAt: yup.date().optional().label("Expires At"), - }); - - const submit = async (values: yup.InferType) => { - const result = await Result.fromPromise(stackAdminApp.createItemQuantityChange({ - ...(customerType === "user" ? - { userId: values.customerId } : - customerType === "team" ? - { teamId: values.customerId } : - { customCustomerId: values.customerId } - ), - itemId, - quantity: values.quantity, - expiresAt: values.expiresAt ? values.expiresAt.toISOString() : undefined, - description: values.description, - })); - if (result.status === "ok") { - toast({ title: "Item quantity change created" }); - return; - } - if (result.error instanceof KnownErrors.ItemNotFound) { - toast({ title: "Item not found", variant: "destructive" }); - } else if (result.error instanceof KnownErrors.UserNotFound) { - toast({ title: "No user found with the given ID", variant: "destructive" }); - } else if (result.error instanceof KnownErrors.TeamNotFound) { - toast({ title: "No team found with the given ID", variant: "destructive" }); - } else { - toast({ title: "An unknown error occurred", variant: "destructive" }); - } - return "prevent-close" as const; - }; - - return ( - - ); -} diff --git a/apps/dashboard/src/components/data-table/team-search-table.tsx b/apps/dashboard/src/components/data-table/team-search-table.tsx index abf3879e87..eea11a5971 100644 --- a/apps/dashboard/src/components/data-table/team-search-table.tsx +++ b/apps/dashboard/src/components/data-table/team-search-table.tsx @@ -23,7 +23,7 @@ function toolbarRender(table: Table) { } export function TeamSearchTable(props: { - action: (team: ServerTeam) => React.ReactNode; + action: (team: ServerTeam) => React.ReactNode, }) { const adminApp = useAdminApp(); const teams = adminApp.useTeams(); @@ -35,7 +35,7 @@ export function TeamSearchTable(props: { ), cell: ({ row }) => ( - {row.original.displayName ?? "–"} + {row.original.displayName} ), enableSorting: false, }, @@ -56,7 +56,7 @@ export function TeamSearchTable(props: { cell: ({ row }) => props.action(row.original), enableSorting: false, }, - ], [props.action]); + ], [props]); return ( void, - project: AdminProject, -} & ( - { - mode: "create", - initial?: undefined, - } | { - mode: "edit", - initial: { - id: string, - value: yup.InferType["items"][string], - }, + onSave: (item: { id: string, displayName: string, customerType: 'user' | 'team' | 'custom' }) => Promise, + editingItem?: { + id: string, + displayName: string, + customerType: 'user' | 'team' | 'custom', + }, + existingItemIds?: string[], + forceCustomerType?: 'user' | 'team' | 'custom', +}; + +export function ItemDialog({ + open, + onOpenChange, + onSave, + editingItem, + existingItemIds = [], + forceCustomerType +}: ItemDialogProps) { + const [itemId, setItemId] = useState(editingItem?.id || ""); + const [displayName, setDisplayName] = useState(editingItem?.displayName || ""); + const [customerType, setCustomerType] = useState<'user' | 'team' | 'custom'>(forceCustomerType || editingItem?.customerType || 'user'); + const [errors, setErrors] = useState>({}); + + const validateAndSave = async () => { + const newErrors: Record = {}; + + // Validate item ID + if (!itemId.trim()) { + newErrors.itemId = "Item ID is required"; + } else if (!/^[a-z0-9-]+$/.test(itemId)) { + newErrors.itemId = "Item ID must contain only lowercase letters, numbers, and hyphens"; + } else if (!editingItem && existingItemIds.includes(itemId)) { + newErrors.itemId = "This item ID already exists"; + } + + // Validate display name + if (!displayName.trim()) { + newErrors.displayName = "Display name is required"; + } + + if (Object.keys(newErrors).length > 0) { + setErrors(newErrors); + return; } - ) -export function ItemDialog({ open, onOpenChange, project, mode, initial }: Props) { - const itemSchema = yup.object({ - itemId: userSpecifiedIdSchema("itemId").defined().label("Item ID"), - displayName: yup.string().optional().label("Display Name"), - customerType: yup.string().oneOf(["user", "team", "custom"]).defined().label("Customer Type"), - }); + await onSave({ + id: itemId.trim(), + displayName: displayName.trim(), + customerType + }); + + handleClose(); + }; + + useEffect(() => { + if (forceCustomerType || editingItem?.customerType) { + setCustomerType(forceCustomerType || editingItem?.customerType || 'user'); + } + }, [forceCustomerType, editingItem]); + + const handleClose = () => { + if (!editingItem) { + setItemId(""); + setDisplayName(""); + setCustomerType('user'); + } + setErrors({}); + onOpenChange(false); + }; return ( - { - if (mode === "create") { - const config = await project.getConfig(); - const itemId = values.itemId; - if (has(config.payments.items, itemId)) { - toast({ title: "An item with this ID already exists", variant: "destructive" }); - return "prevent-close-and-prevent-reset"; - } - } - await project.updateConfig({ - [`payments.items.${values.itemId}`]: { - displayName: values.displayName, - customerType: values.customerType, - }, - }); - }} - render={(form) => ( -
- - - + + + + {editingItem ? "Edit Item" : "Create Item"} + + Items are features or services that customers receive. They appear as rows in your pricing table. + + + +
+ {/* Item ID */} +
+ + { + const nextValue = e.target.value.toLowerCase(); + setItemId(nextValue); + if (errors.itemId) { + setErrors(prev => { + const newErrors = { ...prev }; + delete newErrors.itemId; + return newErrors; + }); + } + }} + placeholder="e.g., api-calls" + disabled={!!editingItem} + className={cn(errors.itemId ? "border-destructive" : "")} + /> + {errors.itemId && ( + + {errors.itemId} + + )} +
+ + {/* Display Name */} +
+ + { + setDisplayName(e.target.value); + if (errors.displayName) { + setErrors(prev => { + const newErrors = { ...prev }; + delete newErrors.displayName; + return newErrors; + }); + } + }} + placeholder="e.g., API Calls" + className={cn(errors.displayName ? "border-destructive" : "")} + /> + {errors.displayName && ( + + {errors.displayName} + + )} +
+ + {/* Customer Type */} +
+ + +
- )} - /> + + + + + +
+
); } - - From 6ab4d15c3bb59ae5fb98e47244cfc9e5b9b27067 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 8 Oct 2025 15:27:55 -0700 Subject: [PATCH 03/15] grant and list product routes --- .../migration.sql | 2 + apps/backend/prisma/schema.prisma | 1 + .../test-mode-purchase-session/route.tsx | 67 +- .../[customer_type]/[customer_id]/route.ts | 160 ++++ .../payments/purchases/validate-code/route.ts | 25 +- apps/backend/src/lib/payments.test.tsx | 6 +- apps/backend/src/lib/payments.tsx | 194 ++++- .../outdated--purchase-session.test.ts | 19 +- .../api/v1/payments/products.test.ts | 741 ++++++++++++++++++ .../api/v1/payments/purchase-session.test.ts | 19 +- packages/stack-shared/src/known-errors.tsx | 15 + packages/stack-shared/src/schema-fields.ts | 3 +- 12 files changed, 1155 insertions(+), 97 deletions(-) create mode 100644 apps/backend/prisma/migrations/20251008182311_api_grant_purchase_source/migration.sql create mode 100644 apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/route.ts create mode 100644 apps/e2e/tests/backend/endpoints/api/v1/payments/products.test.ts diff --git a/apps/backend/prisma/migrations/20251008182311_api_grant_purchase_source/migration.sql b/apps/backend/prisma/migrations/20251008182311_api_grant_purchase_source/migration.sql new file mode 100644 index 0000000000..4eb2cc635f --- /dev/null +++ b/apps/backend/prisma/migrations/20251008182311_api_grant_purchase_source/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "PurchaseCreationSource" ADD VALUE 'API_GRANT'; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index b0505448e6..aa080ab43a 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -756,6 +756,7 @@ enum SubscriptionStatus { enum PurchaseCreationSource { PURCHASE_PAGE TEST_MODE + API_GRANT } model Subscription { diff --git a/apps/backend/src/app/api/latest/internal/payments/test-mode-purchase-session/route.tsx b/apps/backend/src/app/api/latest/internal/payments/test-mode-purchase-session/route.tsx index 76eefd5229..ba01be66f3 100644 --- a/apps/backend/src/app/api/latest/internal/payments/test-mode-purchase-session/route.tsx +++ b/apps/backend/src/app/api/latest/internal/payments/test-mode-purchase-session/route.tsx @@ -1,14 +1,11 @@ import { purchaseUrlVerificationCodeHandler } from "@/app/api/latest/payments/purchases/verification-code-handler"; -import { validatePurchaseSession } from "@/lib/payments"; +import { grantProductToCustomer } from "@/lib/payments"; import { getTenancy } from "@/lib/tenancies"; import { getStripeForAccount } from "@/lib/stripe"; import { getPrismaClientForTenancy } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; -import { SubscriptionStatus } from "@prisma/client"; import { yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; -import { addInterval } from "@stackframe/stack-shared/dist/utils/dates"; import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors"; -import { typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings"; export const POST = createSmartRouteHandler({ metadata: { @@ -38,67 +35,17 @@ export const POST = createSmartRouteHandler({ } const prisma = await getPrismaClientForTenancy(tenancy); - const { selectedPrice, conflictingCatalogSubscriptions } = await validatePurchaseSession({ + await grantProductToCustomer({ prisma, tenancy, - codeData: data, + customerType: data.product.customerType, + customerId: data.customerId, + product: data.product, + productId: data.productId, priceId: price_id, quantity, + creationSource: "TEST_MODE", }); - if (!selectedPrice) { - throw new StackAssertionError("Price not resolved for test mode purchase session"); - } - - if (!selectedPrice.interval) { - await prisma.oneTimePurchase.create({ - data: { - tenancyId: tenancy.id, - customerId: data.customerId, - customerType: typedToUppercase(data.product.customerType), - productId: data.productId, - priceId: price_id, - product: data.product, - quantity, - creationSource: "TEST_MODE", - }, - }); - } else { - // Cancel conflicting subscriptions for TEST_MODE as well, then create new TEST_MODE subscription - if (conflictingCatalogSubscriptions.length > 0) { - const conflicting = conflictingCatalogSubscriptions[0]; - if (conflicting.stripeSubscriptionId) { - const stripe = await getStripeForAccount({ tenancy }); - await stripe.subscriptions.cancel(conflicting.stripeSubscriptionId); - } else if (conflicting.id) { - await prisma.subscription.update({ - where: { - tenancyId_id: { - tenancyId: tenancy.id, - id: conflicting.id, - }, - }, - data: { status: SubscriptionStatus.canceled }, - }); - } - } - - await prisma.subscription.create({ - data: { - tenancyId: tenancy.id, - customerId: data.customerId, - customerType: typedToUppercase(data.product.customerType), - status: "active", - productId: data.productId, - priceId: price_id, - product: data.product, - quantity, - currentPeriodStart: new Date(), - currentPeriodEnd: addInterval(new Date(), selectedPrice.interval!), - cancelAtPeriodEnd: false, - creationSource: "TEST_MODE", - }, - }); - } await purchaseUrlVerificationCodeHandler.revokeCode({ tenancy, id: codeId, diff --git a/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/route.ts b/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/route.ts new file mode 100644 index 0000000000..1b186d75d8 --- /dev/null +++ b/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/route.ts @@ -0,0 +1,160 @@ +import { ensureProductIdOrInlineProduct, getOwnedProductsForCustomer, grantProductToCustomer, productToInlineProduct } from "@/lib/payments"; +import { getStripeForAccount } from "@/lib/stripe"; +import { getPrismaClientForTenancy } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { adaptSchema, clientOrHigherAuthTypeSchema, inlineProductSchema, productSchema, serverOrHigherAuthTypeSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { KnownErrors } from "@stackframe/stack-shared"; +import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; + +export const GET = createSmartRouteHandler({ + metadata: { + summary: "List products owned by a customer", + hidden: true, + }, + request: yupObject({ + auth: yupObject({ + type: clientOrHigherAuthTypeSchema.defined(), + project: adaptSchema.defined(), + tenancy: adaptSchema.defined(), + }).defined(), + params: yupObject({ + customer_type: yupString().oneOf(["user", "team", "custom"]).defined(), + customer_id: yupString().defined(), + }).defined(), + query: yupObject({ + cursor: yupString().optional(), + limit: yupString().optional(), + }).default(() => ({})).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + items: yupArray( + yupObject({ + id: yupString().nullable().defined(), + quantity: yupNumber().defined(), + product: inlineProductSchema.defined(), + }).defined(), + ).defined(), + is_paginated: yupBoolean().oneOf([true]).defined(), + pagination: yupObject({ + next_cursor: yupString().nullable().defined(), + }).defined(), + }).defined(), + }), + handler: async ({ auth, params, query }) => { + const prisma = await getPrismaClientForTenancy(auth.tenancy); + const ownedProducts = await getOwnedProductsForCustomer({ + prisma, + tenancy: auth.tenancy, + customerType: params.customer_type, + customerId: params.customer_id, + }); + + const sorted = ownedProducts + .slice() + .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) + .map((product) => ({ + cursor: product.sourceId, + item: { + id: product.id, + quantity: product.quantity, + product: productToInlineProduct(product.product), + }, + })); + + let startIndex = 0; + if (query.cursor) { + startIndex = sorted.findIndex((entry) => entry.cursor === query.cursor); + if (startIndex === -1) { + throw new StatusError(400, "Invalid cursor"); + } + } + + const limit = yupNumber().min(1).max(100).optional().default(10).validateSync(query.limit); + const pageEntries = sorted.slice(startIndex, startIndex + limit); + const nextCursor = startIndex + limit < sorted.length ? sorted[startIndex + limit].cursor : null; + + return { + statusCode: 200, + bodyType: "json", + body: { + items: pageEntries.map((entry) => entry.item), + is_paginated: true, + pagination: { + next_cursor: nextCursor, + }, + }, + }; + }, +}); + +export const POST = createSmartRouteHandler({ + metadata: { + summary: "Grant a product to a customer", + hidden: true, + }, + request: yupObject({ + auth: yupObject({ + type: serverOrHigherAuthTypeSchema.defined(), + project: adaptSchema.defined(), + tenancy: adaptSchema.defined(), + }).defined(), + params: yupObject({ + customer_type: yupString().oneOf(["user", "team", "custom"]).defined(), + customer_id: yupString().defined(), + }).defined(), + body: yupObject({ + product_id: yupString().optional(), + product_inline: inlineProductSchema.optional(), + quantity: yupNumber().integer().min(1).default(1), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + success: yupBoolean().oneOf([true]).defined(), + }).defined(), + }), + handler: async ({ auth, params, body }) => { + const { tenancy } = auth; + const prisma = await getPrismaClientForTenancy(tenancy); + const product = await ensureProductIdOrInlineProduct( + tenancy, + auth.type, + body.product_id, + body.product_inline, + ); + + if (params.customer_type !== product.customerType) { + throw new KnownErrors.ProductCustomerTypeDoesNotMatch( + body.product_id, + params.customer_id, + product.customerType, + params.customer_type, + ); + } + + await grantProductToCustomer({ + prisma, + tenancy, + customerType: params.customer_type, + customerId: params.customer_id, + product, + productId: body.product_id, + priceId: undefined, + quantity: body.quantity, + creationSource: "API_GRANT", + }); + + return { + statusCode: 200, + bodyType: "json", + body: { + success: true, + }, + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts b/apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts index 7e8169448f..68f5962ba7 100644 --- a/apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts +++ b/apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts @@ -1,22 +1,13 @@ -import { getSubscriptions, isActiveSubscription } from "@/lib/payments"; +import { getSubscriptions, isActiveSubscription, productToInlineProduct } from "@/lib/payments"; import { validateRedirectUrl } from "@/lib/redirect-urls"; import { getTenancy } from "@/lib/tenancies"; import { getPrismaClientForTenancy } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { KnownErrors } from "@stackframe/stack-shared"; import { inlineProductSchema, urlSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; -import { SUPPORTED_CURRENCIES } from "@stackframe/stack-shared/dist/utils/currency-constants"; import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; -import { filterUndefined, getOrUndefined, typedEntries, typedFromEntries } from "@stackframe/stack-shared/dist/utils/objects"; -import * as yup from "yup"; import { purchaseUrlVerificationCodeHandler } from "../verification-code-handler"; -const productDataSchema = inlineProductSchema - .omit(["server_only", "included_items"]) - .concat(yupObject({ - stackable: yupBoolean().defined(), - })); - export const POST = createSmartRouteHandler({ metadata: { hidden: true, @@ -31,7 +22,7 @@ export const POST = createSmartRouteHandler({ statusCode: yupNumber().oneOf([200]).defined(), bodyType: yupString().oneOf(["json"]).defined(), body: yupObject({ - product: productDataSchema, + product: inlineProductSchema, stripe_account_id: yupString().defined(), project_id: yupString().defined(), already_bought_non_stackable: yupBoolean().defined(), @@ -52,16 +43,6 @@ export const POST = createSmartRouteHandler({ throw new KnownErrors.RedirectUrlNotWhitelisted(); } const product = verificationCode.data.product; - const productData: yup.InferType = { - display_name: product.displayName ?? "Product", - customer_type: product.customerType, - stackable: product.stackable === true, - prices: product.prices === "include-by-default" ? {} : typedFromEntries(typedEntries(product.prices).map(([key, value]) => [key, filterUndefined({ - ...typedFromEntries(SUPPORTED_CURRENCIES.map(c => [c.code, getOrUndefined(value, c.code)])), - interval: value.interval, - free_trial: value.freeTrial, - })])), - }; // Compute purchase context info const prisma = await getPrismaClientForTenancy(tenancy); @@ -98,7 +79,7 @@ export const POST = createSmartRouteHandler({ statusCode: 200, bodyType: "json", body: { - product: productData, + product: productToInlineProduct(product), stripe_account_id: verificationCode.data.stripeAccountId, project_id: tenancy.project.id, already_bought_non_stackable: alreadyBoughtNonStackable, diff --git a/apps/backend/src/lib/payments.test.tsx b/apps/backend/src/lib/payments.test.tsx index 10a0c7b2fa..426e73784b 100644 --- a/apps/backend/src/lib/payments.test.tsx +++ b/apps/backend/src/lib/payments.test.tsx @@ -1,4 +1,5 @@ import type { PrismaClientTransaction } from '@/prisma-client'; +import { KnownErrors } from '@stackframe/stack-shared'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getItemQuantityForCustomer, getSubscriptions, validatePurchaseSession } from './payments'; import type { Tenancy } from './tenancies'; @@ -842,7 +843,7 @@ describe('validatePurchaseSession - one-time purchase rules', () => { }, priceId: 'price-any', quantity: 1, - })).rejects.toThrowError('Customer already has purchased this product; this product is not stackable'); + })).rejects.toThrowError(new KnownErrors.ProductAlreadyGranted('product-dup', 'cust-1')); }); it('blocks one-time purchase when another one exists in the same group', async () => { @@ -1003,7 +1004,7 @@ describe('validatePurchaseSession - one-time purchase rules', () => { }, priceId: 'price-any', quantity: 1, - })).rejects.toThrowError('Customer already has purchased this product; this product is not stackable'); + })).rejects.toThrowError(new KnownErrors.ProductAlreadyGranted('product-sub', 'cust-1')); }); it('allows when subscription for same product exists and product is stackable', async () => { @@ -1205,4 +1206,3 @@ describe('getSubscriptions - defaults behavior', () => { })).rejects.toThrowError('Multiple include-by-default products configured in the same catalog'); }); }); - diff --git a/apps/backend/src/lib/payments.tsx b/apps/backend/src/lib/payments.tsx index 1fe63ff870..794fbd20fb 100644 --- a/apps/backend/src/lib/payments.tsx +++ b/apps/backend/src/lib/payments.tsx @@ -1,19 +1,23 @@ import { PrismaClientTransaction } from "@/prisma-client"; -import { SubscriptionStatus } from "@prisma/client"; +import { PurchaseCreationSource, SubscriptionStatus } from "@prisma/client"; import { KnownErrors } from "@stackframe/stack-shared"; import type { inlineProductSchema, productSchema } from "@stackframe/stack-shared/dist/schema-fields"; import { SUPPORTED_CURRENCIES } from "@stackframe/stack-shared/dist/utils/currency-constants"; import { FAR_FUTURE_DATE, addInterval, getIntervalsElapsed } from "@stackframe/stack-shared/dist/utils/dates"; import { StackAssertionError, StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; -import { getOrUndefined, typedEntries, typedFromEntries, typedKeys } from "@stackframe/stack-shared/dist/utils/objects"; +import { filterUndefined, getOrUndefined, typedEntries, typedFromEntries, typedKeys, typedValues } from "@stackframe/stack-shared/dist/utils/objects"; import { typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings"; import { isUuid } from "@stackframe/stack-shared/dist/utils/uuids"; import Stripe from "stripe"; import * as yup from "yup"; import { Tenancy } from "./tenancies"; +import { getStripeForAccount } from "./stripe"; const DEFAULT_PRODUCT_START_DATE = new Date("1973-01-01T12:00:00.000Z"); // monday +type Product = yup.InferType; +type SelectedPrice = Exclude[string]; + export async function ensureProductIdOrInlineProduct( tenancy: Tenancy, accessType: "client" | "server" | "admin", @@ -386,8 +390,20 @@ export async function ensureCustomerExists(options: { } } -type Product = yup.InferType; -type SelectedPrice = Exclude[string]; +export function productToInlineProduct(product: Product): yup.InferType { + return { + display_name: product.displayName ?? "Product", + customer_type: product.customerType, + stackable: product.stackable === true, + server_only: product.serverOnly === true, + included_items: product.includedItems, + prices: product.prices === "include-by-default" ? {} : typedFromEntries(typedEntries(product.prices).map(([key, value]) => [key, filterUndefined({ + ...typedFromEntries(SUPPORTED_CURRENCIES.map(c => [c.code, getOrUndefined(value, c.code)])), + interval: value.interval, + free_trial: value.freeTrial, + })])), + }; +} export async function validatePurchaseSession(options: { prisma: PrismaClientTransaction, @@ -398,7 +414,7 @@ export async function validatePurchaseSession(options: { productId?: string, product: Product, }, - priceId: string, + priceId: string | undefined, quantity: number, }): Promise<{ selectedPrice: SelectedPrice | undefined, @@ -416,7 +432,7 @@ export async function validatePurchaseSession(options: { }); let selectedPrice: SelectedPrice | undefined = undefined; - if (product.prices !== "include-by-default") { + if (priceId && product.prices !== "include-by-default") { const pricesMap = new Map(typedEntries(product.prices)); selectedPrice = pricesMap.get(priceId); if (!selectedPrice) { @@ -448,7 +464,7 @@ export async function validatePurchaseSession(options: { product.stackable !== true && [...subscriptions, ...existingOneTimePurchases].some((p) => p.productId === codeData.productId) ) { - throw new StatusError(400, "Customer already has purchased this product; this product is not stackable"); + throw new KnownErrors.ProductAlreadyGranted(codeData.productId, codeData.customerId); } const addOnProductIds = product.isAddOnTo ? typedKeys(product.isAddOnTo) : []; if (product.isAddOnTo && !subscriptions.some((s) => s.productId && addOnProductIds.includes(s.productId))) { @@ -499,3 +515,167 @@ export function getClientSecretFromStripeSubscription(subscription: Stripe.Subsc } throwErr(500, "No client secret returned from Stripe for subscription"); } + +type GrantProductResult = + | { + type: "one_time", + purchaseId: string | null, + } + | { + type: "subscription", + subscriptionId: string, + }; + +export async function grantProductToCustomer(options: { + prisma: PrismaClientTransaction, + tenancy: Tenancy, + customerType: "user" | "team" | "custom", + customerId: string, + product: Product, + quantity: number, + productId: string | undefined, + priceId: string | undefined, + creationSource: PurchaseCreationSource, +}): Promise { + const { prisma, tenancy, customerId, customerType, product, productId, priceId, quantity, creationSource } = options; + const { selectedPrice, conflictingCatalogSubscriptions } = await validatePurchaseSession({ + prisma, + tenancy, + codeData: { + tenancyId: tenancy.id, + customerId, + productId, + product, + }, + priceId, + quantity, + }); + + if (conflictingCatalogSubscriptions.length > 0) { + const conflicting = conflictingCatalogSubscriptions[0]; + if (conflicting.stripeSubscriptionId) { + const stripe = await getStripeForAccount({ tenancy }); + await stripe.subscriptions.cancel(conflicting.stripeSubscriptionId); + } else if (conflicting.id) { + await prisma.subscription.update({ + where: { + tenancyId_id: { + tenancyId: tenancy.id, + id: conflicting.id, + }, + }, + data: { status: SubscriptionStatus.canceled }, + }); + } + } + + if (!selectedPrice) { + return { type: "one_time", purchaseId: null }; + } + + if (!selectedPrice.interval) { + const purchase = await prisma.oneTimePurchase.create({ + data: { + tenancyId: tenancy.id, + customerId, + customerType: typedToUppercase(customerType), + productId, + priceId, + product, + quantity, + creationSource, + }, + }); + return { type: "one_time", purchaseId: purchase.id }; + } + + const subscription = await prisma.subscription.create({ + data: { + tenancyId: tenancy.id, + customerId, + customerType: typedToUppercase(customerType), + status: "active", + productId, + priceId, + product, + quantity, + currentPeriodStart: new Date(), + currentPeriodEnd: addInterval(new Date(), selectedPrice.interval!), + cancelAtPeriodEnd: false, + creationSource, + }, + }); + + return { type: "subscription", subscriptionId: subscription.id }; +} + +export type OwnedProduct = { + id: string | null, + type: "one_time" | "subscription", + quantity: number, + product: Product, + createdAt: Date, + sourceId: string, +}; + +export async function getOwnedProductsForCustomer(options: { + prisma: PrismaClientTransaction, + tenancy: Tenancy, + customerType: "user" | "team" | "custom", + customerId: string, +}): Promise { + await ensureCustomerExists({ + prisma: options.prisma, + tenancyId: options.tenancy.id, + customerType: options.customerType, + customerId: options.customerId, + }); + + const [subscriptions, oneTimePurchases] = await Promise.all([ + getSubscriptions({ + prisma: options.prisma, + tenancy: options.tenancy, + customerType: options.customerType, + customerId: options.customerId, + }), + options.prisma.oneTimePurchase.findMany({ + where: { + tenancyId: options.tenancy.id, + customerId: options.customerId, + customerType: typedToUppercase(options.customerType), + }, + }), + ]); + + const ownedProducts: OwnedProduct[] = []; + + for (const subscription of subscriptions) { + if (!isActiveSubscription(subscription)) continue; + const sourceId = subscription.id ?? subscription.productId; + if (!sourceId) { + throw new StackAssertionError("Subscription is missing both id and productId", { subscription }); + } + ownedProducts.push({ + id: subscription.productId, + type: "subscription", + quantity: subscription.quantity, + product: subscription.product, + createdAt: subscription.createdAt, + sourceId, + }); + } + + for (const purchase of oneTimePurchases) { + const product = purchase.product as Product; + ownedProducts.push({ + id: purchase.productId ?? null, + type: "one_time", + quantity: purchase.quantity, + product, + createdAt: purchase.createdAt, + sourceId: purchase.id, + }); + } + + return ownedProducts; +} diff --git a/apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--purchase-session.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--purchase-session.test.ts index ba81b64c3e..cefae85dfb 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--purchase-session.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--purchase-session.test.ts @@ -802,8 +802,23 @@ it("should block one-time purchase for same product after prior one-time purchas accessType: "client", body: { full_code: code2, price_id: "one", quantity: 1 }, }); - expect(res.status).toBe(400); - expect(String(res.body)).toBe("Customer already has purchased this product; this product is not stackable"); + expect(res).toMatchInlineSnapshot(` + NiceResponse { + "status": 400, + "body": { + "code": "PRODUCT_ALREADY_GRANTED", + "details": { + "customer_id": "", + "product_id": "ot", + }, + "error": "Customer with ID \\"\\" already owns product \\"ot\\".", + }, + "headers": Headers { + "x-stack-known-error": "PRODUCT_ALREADY_GRANTED", +