diff --git a/apps/backend/prisma/migrations/20250820164831_custom_customer_types/migration.sql b/apps/backend/prisma/migrations/20250820164831_custom_customer_types/migration.sql
new file mode 100644
index 0000000000..2e6eb8bfca
--- /dev/null
+++ b/apps/backend/prisma/migrations/20250820164831_custom_customer_types/migration.sql
@@ -0,0 +1,15 @@
+/*
+ Warnings:
+
+ - Added the required column `customerType` to the `ItemQuantityChange` table without a default value. This is not possible if the table is not empty.
+
+*/
+-- AlterEnum
+ALTER TYPE "CustomerType" ADD VALUE 'CUSTOM';
+
+-- AlterTable
+ALTER TABLE "ItemQuantityChange" ADD COLUMN "customerType" "CustomerType" NOT NULL,
+ALTER COLUMN "customerId" SET DATA TYPE TEXT;
+
+-- AlterTable
+ALTER TABLE "Subscription" ALTER COLUMN "customerId" SET DATA TYPE TEXT;
diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma
index c2f8ff2c4f..3d8320aecf 100644
--- a/apps/backend/prisma/schema.prisma
+++ b/apps/backend/prisma/schema.prisma
@@ -19,7 +19,7 @@ model Project {
displayName String
description String @default("")
isProductionMode Boolean
- ownerTeamId String? @db.Uuid
+ ownerTeamId String? @db.Uuid
logoUrl String?
fullLogoUrl String?
@@ -715,6 +715,7 @@ model ThreadMessage {
enum CustomerType {
USER
TEAM
+ CUSTOM
}
enum SubscriptionStatus {
@@ -731,7 +732,7 @@ enum SubscriptionStatus {
model Subscription {
id String @default(uuid()) @db.Uuid
tenancyId String @db.Uuid
- customerId String @db.Uuid
+ customerId String
customerType CustomerType
offer Json
@@ -749,14 +750,15 @@ model Subscription {
}
model ItemQuantityChange {
- id String @default(uuid()) @db.Uuid
- tenancyId String @db.Uuid
- customerId String @db.Uuid
- itemId String
- quantity Int
- description String?
- expiresAt DateTime?
- createdAt DateTime @default(now())
+ id String @default(uuid()) @db.Uuid
+ tenancyId String @db.Uuid
+ customerType CustomerType
+ customerId String
+ itemId String
+ quantity Int
+ description String?
+ expiresAt DateTime?
+ createdAt DateTime @default(now())
@@id([tenancyId, id])
@@index([tenancyId, customerId, expiresAt])
diff --git a/apps/backend/src/app/api/latest/payments/items/[customer_id]/[item_id]/route.ts b/apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/route.ts
similarity index 75%
rename from apps/backend/src/app/api/latest/payments/items/[customer_id]/[item_id]/route.ts
rename to apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/route.ts
index 2eae2e60b1..9c45afeb59 100644
--- a/apps/backend/src/app/api/latest/payments/items/[customer_id]/[item_id]/route.ts
+++ b/apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/route.ts
@@ -1,8 +1,8 @@
-import { ensureItemCustomerTypeMatches, getItemQuantityForCustomer } from "@/lib/payments";
import { getPrismaClientForTenancy } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { KnownErrors } from "@stackframe/stack-shared";
import { adaptSchema, clientOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
+import { ensureCustomerExists, getItemQuantityForCustomer } from "@/lib/payments";
import { getOrUndefined } from "@stackframe/stack-shared/dist/utils/objects";
@@ -17,6 +17,7 @@ export const GET = createSmartRouteHandler({
tenancy: adaptSchema.defined(),
}).defined(),
params: yupObject({
+ customer_type: yupString().oneOf(["user", "team", "custom"]).defined(),
customer_id: yupString().defined(),
item_id: yupString().defined(),
}).defined(),
@@ -38,16 +39,23 @@ export const GET = createSmartRouteHandler({
if (!itemConfig) {
throw new KnownErrors.ItemNotFound(req.params.item_id);
}
-
- await ensureItemCustomerTypeMatches(req.params.item_id, itemConfig.customerType, req.params.customer_id, tenancy);
+ if (req.params.customer_type !== itemConfig.customerType) {
+ throw new KnownErrors.ItemCustomerTypeDoesNotMatch(req.params.item_id, req.params.customer_id, itemConfig.customerType, req.params.customer_type);
+ }
const prisma = await getPrismaClientForTenancy(tenancy);
+ await ensureCustomerExists({
+ prisma,
+ tenancyId: tenancy.id,
+ customerType: req.params.customer_type,
+ customerId: req.params.customer_id,
+ });
const totalQuantity = await getItemQuantityForCustomer({
prisma,
tenancy,
itemId: req.params.item_id,
customerId: req.params.customer_id,
+ customerType: req.params.customer_type,
});
-
return {
statusCode: 200,
bodyType: "json",
@@ -59,3 +67,5 @@ export const GET = createSmartRouteHandler({
};
},
});
+
+
diff --git a/apps/backend/src/app/api/latest/payments/items/[customer_id]/[item_id]/update-quantity/route.ts b/apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/update-quantity/route.ts
similarity index 78%
rename from apps/backend/src/app/api/latest/payments/items/[customer_id]/[item_id]/update-quantity/route.ts
rename to apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/update-quantity/route.ts
index 0d2efe5394..9a44eb02d3 100644
--- a/apps/backend/src/app/api/latest/payments/items/[customer_id]/[item_id]/update-quantity/route.ts
+++ b/apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/update-quantity/route.ts
@@ -1,9 +1,10 @@
-import { ensureItemCustomerTypeMatches, getItemQuantityForCustomer } from "@/lib/payments";
+import { adaptSchema, serverOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
+import { ensureCustomerExists, getItemQuantityForCustomer } from "@/lib/payments";
import { getPrismaClientForTenancy, retryTransaction } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { KnownErrors } from "@stackframe/stack-shared";
-import { adaptSchema, serverOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { getOrUndefined } from "@stackframe/stack-shared/dist/utils/objects";
+import { typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings";
export const POST = createSmartRouteHandler({
metadata: {
@@ -16,6 +17,7 @@ export const POST = createSmartRouteHandler({
tenancy: adaptSchema.defined(),
}).defined(),
params: yupObject({
+ customer_type: yupString().oneOf(["user", "team", "custom"]).defined(),
customer_id: yupString().defined(),
item_id: yupString().defined(),
}).defined(),
@@ -44,8 +46,16 @@ export const POST = createSmartRouteHandler({
throw new KnownErrors.ItemNotFound(req.params.item_id);
}
- await ensureItemCustomerTypeMatches(req.params.item_id, itemConfig.customerType, req.params.customer_id, tenancy);
+ if (req.params.customer_type !== itemConfig.customerType) {
+ throw new KnownErrors.ItemCustomerTypeDoesNotMatch(req.params.item_id, req.params.customer_id, itemConfig.customerType, req.params.customer_type);
+ }
const prisma = await getPrismaClientForTenancy(tenancy);
+ await ensureCustomerExists({
+ prisma,
+ tenancyId: tenancy.id,
+ customerType: req.params.customer_type,
+ customerId: req.params.customer_id,
+ });
const changeId = await retryTransaction(prisma, async (tx) => {
const totalQuantity = await getItemQuantityForCustomer({
@@ -53,6 +63,7 @@ export const POST = createSmartRouteHandler({
tenancy,
itemId: req.params.item_id,
customerId: req.params.customer_id,
+ customerType: req.params.customer_type,
});
if (!allowNegative && (totalQuantity + req.body.delta < 0)) {
throw new KnownErrors.ItemQuantityInsufficientAmount(req.params.item_id, req.params.customer_id, req.body.delta);
@@ -61,6 +72,7 @@ export const POST = createSmartRouteHandler({
data: {
tenancyId: tenancy.id,
customerId: req.params.customer_id,
+ customerType: typedToUppercase(req.params.customer_type),
itemId: req.params.item_id,
quantity: req.body.delta,
description: req.body.description,
@@ -77,3 +89,5 @@ export const POST = createSmartRouteHandler({
};
},
});
+
+
diff --git a/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts b/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts
index f8bdd1c377..c84ec6d29d 100644
--- a/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts
+++ b/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts
@@ -1,11 +1,12 @@
-import { ensureOfferCustomerTypeMatches, ensureOfferIdOrInlineOffer } from "@/lib/payments";
+import { adaptSchema, clientOrHigherAuthTypeSchema, inlineOfferSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
+import { ensureOfferIdOrInlineOffer } from "@/lib/payments";
import { getStripeForAccount } from "@/lib/stripe";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
-import { adaptSchema, clientOrHigherAuthTypeSchema, inlineOfferSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { purchaseUrlVerificationCodeHandler } from "../verification-code-handler";
import { CustomerType } from "@prisma/client";
+import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors";
export const POST = createSmartRouteHandler({
metadata: {
@@ -18,7 +19,8 @@ export const POST = createSmartRouteHandler({
tenancy: adaptSchema.defined(),
}).defined(),
body: yupObject({
- customer_id: yupString().uuid().defined(),
+ customer_type: yupString().oneOf(["user", "team", "custom"]).defined(),
+ customer_id: yupString().defined(),
offer_id: yupString().optional(),
offer_inline: inlineOfferSchema.optional(),
}),
@@ -34,8 +36,10 @@ export const POST = createSmartRouteHandler({
const { tenancy } = req.auth;
const stripe = getStripeForAccount({ tenancy });
const offerConfig = await ensureOfferIdOrInlineOffer(tenancy, req.auth.type, req.body.offer_id, req.body.offer_inline);
- await ensureOfferCustomerTypeMatches(req.body.offer_id, offerConfig.customerType, req.body.customer_id, tenancy);
const customerType = offerConfig.customerType ?? throwErr("Customer type not found");
+ if (req.body.customer_type !== customerType) {
+ throw new KnownErrors.OfferCustomerTypeDoesNotMatch(req.body.offer_id, req.body.customer_id, customerType, req.body.customer_type);
+ }
const stripeCustomerSearch = await stripe.customers.search({
query: `metadata['customerId']:'${req.body.customer_id}'`,
diff --git a/apps/backend/src/app/api/latest/team-invitations/accept/verification-code-handler.tsx b/apps/backend/src/app/api/latest/team-invitations/accept/verification-code-handler.tsx
index d0c74e2170..2ecceb4bc9 100644
--- a/apps/backend/src/app/api/latest/team-invitations/accept/verification-code-handler.tsx
+++ b/apps/backend/src/app/api/latest/team-invitations/accept/verification-code-handler.tsx
@@ -87,6 +87,7 @@ export const teamInvitationCodeHandler = createVerificationCodeHandler({
tenancy,
customerId: data.team_id,
itemId: "dashboard_admins",
+ customerType: "team",
});
if (currentMemberCount + 1 > maxDashboardAdmins) {
throw new KnownErrors.ItemQuantityInsufficientAmount("dashboard_admins", data.team_id, -1);
diff --git a/apps/backend/src/lib/payments.tsx b/apps/backend/src/lib/payments.tsx
index 952b201f28..993a50a6f3 100644
--- a/apps/backend/src/lib/payments.tsx
+++ b/apps/backend/src/lib/payments.tsx
@@ -1,14 +1,14 @@
-import { teamsCrudHandlers } from "@/app/api/latest/teams/crud";
-import { usersCrudHandlers } from "@/app/api/latest/users/crud";
+import { PrismaClientTransaction } from "@/prisma-client";
+import { SubscriptionStatus } from "@prisma/client";
import { KnownErrors } from "@stackframe/stack-shared";
import { inlineOfferSchema, offerSchema } from "@stackframe/stack-shared/dist/schema-fields";
+import { typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings";
+import { SUPPORTED_CURRENCIES } from "@stackframe/stack-shared/dist/utils/currencies";
import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors";
import { getOrUndefined, typedFromEntries } from "@stackframe/stack-shared/dist/utils/objects";
import * as yup from "yup";
import { Tenancy } from "./tenancies";
-import { SUPPORTED_CURRENCIES } from "@stackframe/stack-shared/dist/utils/currencies";
-import { SubscriptionStatus } from "@prisma/client";
-import { PrismaClientTransaction } from "@/prisma-client";
+import { isUuid } from "@stackframe/stack-shared/dist/utils/uuids";
export async function ensureOfferIdOrInlineOffer(
tenancy: Tenancy,
@@ -56,80 +56,19 @@ export async function ensureOfferIdOrInlineOffer(
}
}
-export async function ensureItemCustomerTypeMatches(itemId: string, itemCustomerType: "user" | "team" | undefined, customerId: string, tenancy: Tenancy) {
- const actualCustomerType = await getCustomerType(tenancy, customerId);
- if (itemCustomerType !== actualCustomerType) {
- throw new KnownErrors.ItemCustomerTypeDoesNotMatch(itemId, customerId, itemCustomerType, actualCustomerType);
- }
-}
-
-export async function ensureOfferCustomerTypeMatches(offerId: string | undefined, offerCustomerType: "user" | "team" | undefined, customerId: string, tenancy: Tenancy) {
- const actualCustomerType = await getCustomerType(tenancy, customerId);
- if (offerCustomerType !== actualCustomerType) {
- throw new KnownErrors.OfferCustomerTypeDoesNotMatch(offerId, customerId, offerCustomerType, actualCustomerType);
- }
-}
-
-export async function getCustomerType(tenancy: Tenancy, customerId: string) {
- let user;
- try {
- user = await usersCrudHandlers.adminRead(
- {
- user_id: customerId,
- tenancy,
- allowedErrorTypes: [
- KnownErrors.UserNotFound,
- ],
- }
- );
- } catch (e) {
- if (KnownErrors.UserNotFound.isInstance(e)) {
- user = null;
- } else {
- throw e;
- }
- }
- let team;
- try {
- team = await teamsCrudHandlers.adminRead({
- team_id: customerId,
- tenancy,
- allowedErrorTypes: [
- KnownErrors.TeamNotFound,
- ],
- });
- } catch (e) {
- if (KnownErrors.TeamNotFound.isInstance(e)) {
- team = null;
- } else {
- throw e;
- }
- }
-
- if (user && team) {
- throw new StackAssertionError("Found a customer that is both user and team at the same time? This should never happen!", { customerId, user, team, tenancy });
- }
-
- if (user) {
- return "user";
- }
- if (team) {
- return "team";
- }
- throw new KnownErrors.CustomerDoesNotExist(customerId);
-}
-
export async function getItemQuantityForCustomer(options: {
prisma: PrismaClientTransaction,
tenancy: Tenancy,
itemId: string,
customerId: string,
+ customerType: "user" | "team" | "custom",
}) {
const itemConfig = getOrUndefined(options.tenancy.config.payments.items, options.itemId);
const defaultQuantity = itemConfig?.default.quantity ?? 0;
const subscriptions = await options.prisma.subscription.findMany({
where: {
tenancyId: options.tenancy.id,
+ customerType: typedToUppercase(options.customerType),
customerId: options.customerId,
status: {
in: [SubscriptionStatus.active, SubscriptionStatus.trialing],
@@ -146,6 +85,7 @@ export async function getItemQuantityForCustomer(options: {
const { _sum } = await options.prisma.itemQuantityChange.aggregate({
where: {
tenancyId: options.tenancy.id,
+ customerType: typedToUppercase(options.customerType),
customerId: options.customerId,
itemId: options.itemId,
OR: [
@@ -159,3 +99,42 @@ export async function getItemQuantityForCustomer(options: {
});
return subscriptionQuantity + (_sum.quantity ?? 0) + defaultQuantity;
}
+
+export async function ensureCustomerExists(options: {
+ prisma: PrismaClientTransaction,
+ tenancyId: string,
+ customerType: "user" | "team" | "custom",
+ customerId: string,
+}) {
+ if (options.customerType === "user") {
+ if (!isUuid(options.customerId)) {
+ throw new KnownErrors.UserNotFound();
+ }
+ const user = await options.prisma.projectUser.findUnique({
+ where: {
+ tenancyId_projectUserId: {
+ tenancyId: options.tenancyId,
+ projectUserId: options.customerId,
+ },
+ },
+ });
+ if (!user) {
+ throw new KnownErrors.UserNotFound();
+ }
+ } else if (options.customerType === "team") {
+ if (!isUuid(options.customerId)) {
+ throw new KnownErrors.TeamNotFound(options.customerId);
+ }
+ const team = await options.prisma.team.findUnique({
+ where: {
+ tenancyId_teamId: {
+ tenancyId: options.tenancyId,
+ teamId: options.customerId,
+ },
+ },
+ });
+ if (!team) {
+ throw new KnownErrors.TeamNotFound(options.customerId);
+ }
+ }
+}
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page-client.tsx
index 3b63ed38c9..ed97f06ee1 100644
--- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page-client.tsx
+++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page-client.tsx
@@ -1,7 +1,5 @@
"use client";
-import * as yup from "yup";
-import { useState } from "react";
import { PaymentItemTable } from "@/components/data-table/payment-item-table";
import { PaymentOfferTable } from "@/components/data-table/payment-offer-table";
import { FormDialog, SmartFormDialog } from "@/components/form-dialog";
@@ -9,24 +7,22 @@ import { InputField, SelectField, SwitchField } from "@/components/form-fields";
import { IncludedItemEditorField } from "@/components/payments/included-item-editor";
import { PriceEditorField } from "@/components/payments/price-editor";
import { AdminProject } from "@stackframe/stack";
-import { KnownErrors } from "@stackframe/stack-shared";
import {
offerPriceSchema,
userSpecifiedIdSchema,
yupRecord
} from "@stackframe/stack-shared/dist/schema-fields";
import { wait } from "@stackframe/stack-shared/dist/utils/promises";
-import { Result } from "@stackframe/stack-shared/dist/utils/results";
import {
- ActionDialog,
Button,
Card,
CardContent,
- InlineCode,
Typography,
toast
} from "@stackframe/stack-ui";
import { ArrowRight, BarChart3, Repeat, Shield, Wallet, Webhook } from "lucide-react";
+import { useState } from "react";
+import * as yup from "yup";
import { PageLayout } from "../page-layout";
import { useAdminApp } from "../use-admin-app";
@@ -172,7 +168,7 @@ function CreateOfferDialog({
const offerSchema = yup.object({
offerId: yup.string().defined().label("Offer ID"),
displayName: yup.string().defined().label("Display Name"),
- customerType: yup.string().oneOf(["user", "team"]).defined().label("Customer Type"),
+ customerType: yup.string().oneOf(["user", "team", "custom"]).defined().label("Customer Type"),
prices: yupRecord(userSpecifiedIdSchema("priceId"), offerPriceSchema).defined().label("Prices").test("at-least-one-price", "At least one price is required", (value) => {
return Object.keys(value).length > 0;
}),
@@ -214,6 +210,7 @@ function CreateOfferDialog({
@@ -233,11 +230,12 @@ function CreateItemDialog({ open, onOpenChange, project }: { open: boolean, onOp
const itemSchema = yup.object({
itemId: yup.string().defined().label("Item ID"),
displayName: yup.string().optional().label("Display Name"),
- customerType: yup.string().oneOf(["user", "team"]).defined().label("Customer Type").meta({
+ customerType: yup.string().oneOf(["user", "team", "custom"]).defined().label("Customer Type").meta({
stackFormFieldRender: (props) => (
),
}),
diff --git a/apps/dashboard/src/components/data-table/payment-item-table.tsx b/apps/dashboard/src/components/data-table/payment-item-table.tsx
index 0a120f37ba..5356fccd58 100644
--- a/apps/dashboard/src/components/data-table/payment-item-table.tsx
+++ b/apps/dashboard/src/components/data-table/payment-item-table.tsx
@@ -8,6 +8,7 @@ import { ActionCell, DataTable, DataTableColumnHeader, TextCell, toast } from "@
import { ColumnDef } from "@tanstack/react-table";
import { useState } from "react";
import * as yup from "yup";
+import { SelectField } from "../form-fields";
type PaymentItem = {
id: string,
@@ -54,7 +55,7 @@ const columns: ColumnDef[] = [
},
{
id: "actions",
- cell: ({ row }) => ,
+ cell: ({ row }) => ,
}
];
@@ -80,7 +81,7 @@ export function PaymentItemTable({
/>;
}
-function ActionsCell({ itemId }: { itemId: string }) {
+function ActionsCell({ item }: { item: PaymentItem }) {
const [open, setOpen] = useState(false);
return (
<>
@@ -100,17 +101,25 @@ function ActionsCell({ itemId }: { itemId: string }) {
>
);
}
-function CreateItemQuantityChangeDialog({ open, onOpenChange, itemId }: { open: boolean, onOpenChange: (open: boolean) => void, itemId: string }) {
+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().uuid().defined().label("Customer ID"),
+ 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"),
@@ -118,7 +127,12 @@ function CreateItemQuantityChangeDialog({ open, onOpenChange, itemId }: { open:
const submit = async (values: yup.InferType) => {
const result = await Result.fromPromise(stackAdminApp.createItemQuantityChange({
- customerId: values.customerId,
+ ...(customerType === "user" ?
+ { userId: values.customerId } :
+ customerType === "team" ?
+ { teamId: values.customerId } :
+ { customId: values.customerId }
+ ),
itemId,
quantity: values.quantity,
expiresAt: values.expiresAt ? values.expiresAt.toISOString() : undefined,
@@ -130,10 +144,10 @@ function CreateItemQuantityChangeDialog({ open, onOpenChange, itemId }: { open:
}
if (result.error instanceof KnownErrors.ItemNotFound) {
toast({ title: "Item not found", variant: "destructive" });
- } else if (result.error instanceof KnownErrors.ItemCustomerTypeDoesNotMatch) {
- toast({ title: "Customer type does not match expected type for this item", variant: "destructive" });
- } else if (result.error instanceof KnownErrors.CustomerDoesNotExist) {
- toast({ title: "Customer does not exist", 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" });
}
diff --git a/apps/e2e/tests/backend/backend-helpers.ts b/apps/e2e/tests/backend/backend-helpers.ts
index ca363f89ab..bf16e59fe1 100644
--- a/apps/e2e/tests/backend/backend-helpers.ts
+++ b/apps/e2e/tests/backend/backend-helpers.ts
@@ -1419,6 +1419,7 @@ export namespace Payments {
method: "POST",
accessType: "client",
body: {
+ customer_type: "user",
customer_id: userId,
offer_id: "test-offer",
},
diff --git a/apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts
index d854b20d7b..2d8c1d9442 100644
--- a/apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts
+++ b/apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts
@@ -13,6 +13,7 @@ it("should not be able to create purchase URL without offer_id or offer_inline",
method: "POST",
accessType: "client",
body: {
+ customer_type: "user",
customer_id: generateUuid(),
},
});
@@ -52,6 +53,7 @@ it("should error for non-existent offer_id", async ({ expect }) => {
method: "POST",
accessType: "client",
body: {
+ customer_type: "user",
customer_id: generateUuid(),
offer_id: "non-existent-offer",
},
@@ -103,24 +105,30 @@ it("should error for invalid customer_id", async ({ expect }) => {
method: "POST",
accessType: "client",
body: {
+ customer_type: "team",
customer_id: generateUuid(),
offer_id: "test-offer",
},
});
expect(response).toMatchInlineSnapshot(`
- NiceResponse {
- "status": 400,
- "body": {
- "code": "CUSTOMER_DOES_NOT_EXIST",
- "details": { "customer_id": "" },
- "error": "Customer with ID \\"\\" does not exist.",
- },
- "headers": Headers {
- "x-stack-known-error": "CUSTOMER_DOES_NOT_EXIST",
- ,
+ NiceResponse {
+ "status": 400,
+ "body": {
+ "code": "OFFER_CUSTOMER_TYPE_DOES_NOT_MATCH",
+ "details": {
+ "actual_customer_type": "team",
+ "customer_id": "",
+ "offer_customer_type": "user",
+ "offer_id": "test-offer",
},
- }
- `);
+ "error": "The team with ID \\"\\" is not a valid customer for the inline offer that has been passed in. The offer is configured to only be available for user customers, but the customer is a team.",
+ },
+ "headers": Headers {
+ "x-stack-known-error": "OFFER_CUSTOMER_TYPE_DOES_NOT_MATCH",
+ ,
+ },
+ }
+ `);
});
it("should error for no connected stripe account", async ({ expect }) => {
@@ -150,6 +158,7 @@ it("should error for no connected stripe account", async ({ expect }) => {
method: "POST",
accessType: "client",
body: {
+ customer_type: "user",
customer_id: user.userId,
offer_id: "test-offer",
},
@@ -177,6 +186,7 @@ it("should not allow offer_inline when calling from client", async ({ expect })
method: "POST",
accessType: "client",
body: {
+ customer_type: "user",
customer_id: userId,
offer_inline: {
display_name: "Inline Test Offer",
@@ -208,6 +218,7 @@ it("should allow offer_inline when calling from server", async ({ expect }) => {
method: "POST",
accessType: "server",
body: {
+ customer_type: "user",
customer_id: userId,
offer_inline: {
display_name: "Inline Test Offer",
@@ -224,8 +235,7 @@ it("should allow offer_inline when calling from server", async ({ expect }) => {
},
});
expect(response.status).toBe(200);
- const body = response.body as { url: string };
- expect(body.url).toMatch(/^https?:\/\/localhost:8101\/purchase\/[a-z0-9-_]+$/);
+ expect(response.body.url).toMatch(/^https?:\/\/localhost:8101\/purchase\/[a-z0-9-_]+$/);
});
it("should allow valid offer_id", async ({ expect }) => {
@@ -256,6 +266,7 @@ it("should allow valid offer_id", async ({ expect }) => {
method: "POST",
accessType: "client",
body: {
+ customer_type: "user",
customer_id: userId,
offer_id: "test-offer",
},
diff --git a/apps/e2e/tests/backend/endpoints/api/v1/payments/items.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/payments/items.test.ts
index 6ffd6ef1df..5892f317cc 100644
--- a/apps/e2e/tests/backend/endpoints/api/v1/payments/items.test.ts
+++ b/apps/e2e/tests/backend/endpoints/api/v1/payments/items.test.ts
@@ -15,7 +15,7 @@ describe("without authentication", () => {
it("should not be able to get item without access type", async ({ expect }) => {
await Project.createAndSwitch();
- const response = await niceBackendFetch("/api/latest/payments/items/user-123/test-item");
+ const response = await niceBackendFetch("/api/latest/payments/items/user/user-123/test-item");
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
@@ -53,7 +53,7 @@ it("should be able to get item information with valid customer and item IDs", as
});
const user = await User.create();
- const response = await niceBackendFetch(`/api/latest/payments/items/${user.userId}/test-item`, {
+ const response = await niceBackendFetch(`/api/latest/payments/items/user/${user.userId}/test-item`, {
accessType: "client",
});
expect(response).toMatchInlineSnapshot(`
@@ -86,7 +86,7 @@ it("should return ItemNotFound error for non-existent item", async ({ expect })
});
const user = await User.create();
- const response = await niceBackendFetch(`/api/latest/payments/items/${user.userId}/non-existent-item`, {
+ const response = await niceBackendFetch(`/api/latest/payments/items/user/${user.userId}/non-existent-item`, {
accessType: "client",
});
expect(response).toMatchInlineSnapshot(`
@@ -122,7 +122,7 @@ it("should return ItemCustomerTypeDoesNotMatch error for user accessing team ite
});
const user = await User.create();
- const response = await niceBackendFetch(`/api/latest/payments/items/${user.userId}/test-item`, {
+ const response = await niceBackendFetch(`/api/latest/payments/items/user/${user.userId}/test-item`, {
accessType: "client",
});
expect(response).toMatchInlineSnapshot(`
@@ -162,7 +162,7 @@ it("creates an item quantity change and returns id", async ({ expect }) => {
const user = await User.create();
- const response = await niceBackendFetch(`/api/latest/payments/items/${user.userId}/test-item/update-quantity?allow_negative=false`, {
+ const response = await niceBackendFetch(`/api/latest/payments/items/user/${user.userId}/test-item/update-quantity?allow_negative=false`, {
method: "POST",
accessType: "admin",
body: {
@@ -191,14 +191,14 @@ it("aggregates item quantity changes in item quantity", async ({ expect }) => {
const user = await User.create();
- const post1 = await niceBackendFetch(`/api/latest/payments/items/${user.userId}/test-item/update-quantity?allow_negative=false`, {
+ const post1 = await niceBackendFetch(`/api/latest/payments/items/user/${user.userId}/test-item/update-quantity?allow_negative=false`, {
method: "POST",
accessType: "admin",
body: { delta: 2 },
});
expect(post1.status).toBe(200);
- const get1 = await niceBackendFetch(`/api/latest/payments/items/${user.userId}/test-item`, {
+ const get1 = await niceBackendFetch(`/api/latest/payments/items/user/${user.userId}/test-item`, {
accessType: "client",
});
expect(get1.status).toBe(200);
@@ -221,14 +221,14 @@ it("ignores expired changes", async ({ expect }) => {
const user = await User.create();
- const post = await niceBackendFetch(`/api/latest/payments/items/${user.userId}/test-item/update-quantity?allow_negative=false`, {
+ const post = await niceBackendFetch(`/api/latest/payments/items/user/${user.userId}/test-item/update-quantity?allow_negative=false`, {
method: "POST",
accessType: "admin",
body: { delta: 4, expires_at: new Date(Date.now() - 1000).toISOString() },
});
expect(post.status).toBe(200);
- const get = await niceBackendFetch(`/api/latest/payments/items/${user.userId}/test-item`, {
+ const get = await niceBackendFetch(`/api/latest/payments/items/user/${user.userId}/test-item`, {
accessType: "client",
});
expect(get.status).toBe(200);
@@ -252,7 +252,7 @@ it("sums multiple non-expired changes", async ({ expect }) => {
const user = await User.create();
for (const q of [2, -1, 5]) {
- const r = await niceBackendFetch(`/api/latest/payments/items/${user.userId}/test-item/update-quantity?allow_negative=false`, {
+ const r = await niceBackendFetch(`/api/latest/payments/items/user/${user.userId}/test-item/update-quantity?allow_negative=false`, {
method: "POST",
accessType: "admin",
body: { delta: q },
@@ -260,7 +260,7 @@ it("sums multiple non-expired changes", async ({ expect }) => {
expect(r.status).toBe(200);
}
- const get = await niceBackendFetch(`/api/latest/payments/items/${user.userId}/test-item`, {
+ const get = await niceBackendFetch(`/api/latest/payments/items/user/${user.userId}/test-item`, {
accessType: "client",
});
expect(get.status).toBe(200);
@@ -282,7 +282,7 @@ it("validates item and customer type", async ({ expect }) => {
});
const user = await User.create();
- const response = await niceBackendFetch(`/api/latest/payments/items/${user.userId}/team-item/update-quantity?allow_negative=true`, {
+ const response = await niceBackendFetch(`/api/latest/payments/items/user/${user.userId}/team-item/update-quantity?allow_negative=true`, {
method: "POST",
accessType: "admin",
body: { delta: 1 },
@@ -324,13 +324,12 @@ it("should error when deducting more quantity than available", async ({ expect }
const user = await User.create();
- const response = await niceBackendFetch(`/api/latest/payments/items/${user.userId}/test-item/update-quantity?allow_negative=false`, {
+ const response = await niceBackendFetch(`/api/latest/payments/items/user/${user.userId}/test-item/update-quantity?allow_negative=false`, {
method: "POST",
accessType: "admin",
body: { delta: -1 },
});
- expect(response.status).toBe(400);
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
@@ -357,7 +356,7 @@ it("allows team admins to be added when item quantity is increased", async ({ ex
const { createProjectResponse } = await Project.create();
const ownerTeamId: string = createProjectResponse.body.owner_team_id;
- await niceBackendFetch(`/api/v1/payments/items/${ownerTeamId}/dashboard_admins/update-quantity?allow_negative=true`, {
+ await niceBackendFetch(`/api/v1/payments/items/team/${ownerTeamId}/dashboard_admins/update-quantity?allow_negative=true`, {
method: "POST",
accessType: "admin",
body: {
@@ -422,7 +421,7 @@ it("should allow negative quantity changes when allow_negative is true", async (
const user = await User.create();
- const response = await niceBackendFetch(`/api/latest/payments/items/${user.userId}/test-item/update-quantity?allow_negative=true`, {
+ const response = await niceBackendFetch(`/api/latest/payments/items/user/${user.userId}/test-item/update-quantity?allow_negative=true`, {
method: "POST",
accessType: "admin",
body: { delta: -3 },
@@ -437,7 +436,7 @@ it("should allow negative quantity changes when allow_negative is true", async (
}
`);
- const getItemResponse = await niceBackendFetch(`/api/latest/payments/items/${user.userId}/test-item`, {
+ const getItemResponse = await niceBackendFetch(`/api/latest/payments/items/user/${user.userId}/test-item`, {
accessType: "client",
});
expect(getItemResponse).toMatchInlineSnapshot(`
@@ -452,3 +451,39 @@ it("should allow negative quantity changes when allow_negative is true", async (
}
`);
});
+
+it("supports custom customer type for items (GET and update-quantity)", async ({ expect }) => {
+ await Project.createAndSwitch();
+ await updateConfig({
+ payments: {
+ items: {
+ "custom-item": {
+ displayName: "Custom Item",
+ customerType: "custom",
+ default: { quantity: 2 },
+ },
+ },
+ },
+ });
+
+ const customId = "custom-xyz";
+
+ const getBefore = await niceBackendFetch(`/api/latest/payments/items/custom/${customId}/custom-item`, {
+ accessType: "client",
+ });
+ expect(getBefore.status).toBe(200);
+ expect(getBefore.body.quantity).toBe(2);
+
+ const postChange = await niceBackendFetch(`/api/latest/payments/items/custom/${customId}/custom-item/update-quantity?allow_negative=false`, {
+ method: "POST",
+ accessType: "admin",
+ body: { delta: 3, description: "grant" },
+ });
+ expect(postChange.status).toBe(200);
+
+ const getAfter = await niceBackendFetch(`/api/latest/payments/items/custom/${customId}/custom-item`, {
+ accessType: "client",
+ });
+ expect(getAfter.status).toBe(200);
+ expect(getAfter.body.quantity).toBe(5);
+});
diff --git a/apps/e2e/tests/backend/endpoints/api/v1/payments/purchase-session.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/payments/purchase-session.test.ts
index 9e957de6ce..1b43a1d610 100644
--- a/apps/e2e/tests/backend/endpoints/api/v1/payments/purchase-session.test.ts
+++ b/apps/e2e/tests/backend/endpoints/api/v1/payments/purchase-session.test.ts
@@ -96,6 +96,7 @@ it("should create purchase URL with inline offer, validate code, and create purc
method: "POST",
accessType: "server",
body: {
+ customer_type: "user",
customer_id: userId,
offer_inline: {
display_name: "Inline Test Offer",
diff --git a/apps/e2e/tests/js/payments.test.ts b/apps/e2e/tests/js/payments.test.ts
index 00c4caf9c5..2509700d53 100644
--- a/apps/e2e/tests/js/payments.test.ts
+++ b/apps/e2e/tests/js/payments.test.ts
@@ -48,6 +48,63 @@ it("returns default item quantity for a team", async ({ expect }) => {
timeout: 40_000,
});
+it("root-level getItem works for user and team", async ({ expect }) => {
+ const { clientApp, serverApp, adminApp } = await createApp({
+ config: { clientTeamCreationEnabled: true },
+ });
+
+ const project = await adminApp.getProject();
+ const itemId = "root_level_item";
+ await project.updateConfig({
+ [`payments.items.${itemId}`]: {
+ displayName: "Root Level Item",
+ customerType: "team",
+ default: { quantity: 1, repeat: "never", expires: "never" },
+ },
+ });
+
+ await clientApp.signUpWithCredential({ email: "rl@test.com", password: "password", verificationCallbackUrl: "http://localhost:3000" });
+ await clientApp.signInWithCredential({ email: "rl@test.com", password: "password" });
+ const user = await clientApp.getUser();
+ if (!user) throw new Error("User not found");
+ const team = await user.createTeam({ displayName: "RL Team" });
+
+ const teamItem = await clientApp.getItem({ itemId, teamId: team.id });
+ expect(teamItem.quantity).toBe(1);
+
+ const userItemId = "root_level_user_item";
+ await project.updateConfig({
+ [`payments.items.${userItemId}`]: {
+ displayName: "Root Level User Item",
+ customerType: "user",
+ default: { quantity: 4, repeat: "never", expires: "never" },
+ },
+ });
+ const userItem = await serverApp.getItem({ itemId: userItemId, userId: user.id });
+ expect(userItem.quantity).toBe(4);
+}, { timeout: 60_000 });
+
+it("customId is supported via root-level getItem and admin quantity change", async ({ expect }) => {
+ const { clientApp, adminApp } = await createApp({
+ config: {},
+ });
+ const project = await adminApp.getProject();
+ const itemId = "custom_item_rl";
+ await project.updateConfig({
+ [`payments.items.${itemId}`]: {
+ displayName: "Custom RL Item",
+ customerType: "custom",
+ default: { quantity: 2, repeat: "never", expires: "never" },
+ },
+ });
+ const customId = "custom-abc";
+ const before = await clientApp.getItem({ itemId, customId });
+ expect(before.quantity).toBe(2);
+ await adminApp.createItemQuantityChange({ customId, itemId, quantity: 5 });
+ const after = await clientApp.getItem({ itemId, customId });
+ expect(after.quantity).toBe(7);
+}, { timeout: 60_000 });
+
it("admin can increase team item quantity and client sees updated value", async ({ expect }) => {
const { clientApp, adminApp } = await createApp({
config: {
@@ -87,7 +144,7 @@ it("admin can increase team item quantity and client sees updated value", async
expect(before.quantity).toBe(1);
// Increase by 3 via admin API
- await adminApp.createItemQuantityChange({ customerId: team.id, itemId, quantity: 3 });
+ await adminApp.createItemQuantityChange({ teamId: team.id, itemId, quantity: 3 });
const after = await team.getItem(itemId);
expect(after.quantity).toBe(4);
@@ -132,7 +189,7 @@ it("cannot decrease team item quantity below zero", async ({ expect }) => {
expect(current.quantity).toBe(0);
// Try to decrease by 1 (should fail with KnownErrors.ItemQuantityInsufficientAmount)
- await expect(adminApp.createItemQuantityChange({ customerId: team.id, itemId, quantity: -1 }))
+ await expect(adminApp.createItemQuantityChange({ teamId: team.id, itemId, quantity: -1 }))
.rejects.toThrow();
const still = await team.getItem(itemId);
diff --git a/packages/stack-shared/src/interface/admin-interface.ts b/packages/stack-shared/src/interface/admin-interface.ts
index 6a706e1fc3..c9361ceb78 100644
--- a/packages/stack-shared/src/interface/admin-interface.ts
+++ b/packages/stack-shared/src/interface/admin-interface.ts
@@ -516,19 +516,4 @@ export class StackAdminInterface extends StackServerInterface {
return await response.json();
}
- async createPurchaseUrl(options: { customer_id: string, offer_id: string }): Promise {
- const response = await this.sendAdminRequest(
- "/payments/purchases/create-purchase-url",
- {
- method: "POST",
- headers: {
- "content-type": "application/json",
- },
- body: JSON.stringify(options),
- },
- null,
- );
- const result = await response.json() as { url: string };
- return result.url;
- }
}
diff --git a/packages/stack-shared/src/interface/client-interface.ts b/packages/stack-shared/src/interface/client-interface.ts
index 859d03c36a..2e174cfa5e 100644
--- a/packages/stack-shared/src/interface/client-interface.ts
+++ b/packages/stack-shared/src/interface/client-interface.ts
@@ -2,6 +2,7 @@ import * as oauth from 'oauth4webapi';
import * as yup from 'yup';
import { KnownError, KnownErrors } from '../known-errors';
+import { inlineOfferSchema } from '../schema-fields';
import { AccessToken, InternalSession, RefreshToken } from '../sessions';
import { generateSecureRandomString } from '../utils/crypto';
import { StackAssertionError, throwErr } from '../utils/errors';
@@ -26,7 +27,7 @@ import { TeamInvitationCrud } from './crud/team-invitation';
import { TeamMemberProfilesCrud } from './crud/team-member-profiles';
import { TeamPermissionsCrud } from './crud/team-permissions';
import { TeamsCrud } from './crud/teams';
-import { inlineOfferSchema } from '../schema-fields';
+import { urlString } from '../utils/urls';
export type ClientInterfaceOptions = {
clientVersion: string,
@@ -1745,14 +1746,31 @@ export class StackClientInterface {
return response.json();
}
- async getItem(options: {
- teamId?: string,
- userId?: string,
- itemId: string,
- }, session: InternalSession | null): Promise {
- const customerId = options.teamId ?? options.userId;
+ async getItem(
+ options: (
+ { itemId: string, userId: string } |
+ { itemId: string, teamId: string } |
+ { itemId: string, customId: string }
+ ),
+ session: InternalSession | null,
+ ): Promise {
+ let customerType: "user" | "team" | "custom";
+ let customerId: string;
+ if ("userId" in options) {
+ customerType = "user";
+ customerId = options.userId;
+ } else if ("teamId" in options) {
+ customerType = "team";
+ customerId = options.teamId;
+ } else if ("customId" in options) {
+ customerType = "custom";
+ customerId = options.customId;
+ } else {
+ throw new StackAssertionError("getItem requires one of userId, teamId, or customId");
+ }
+
const response = await this.sendClientRequest(
- `/payments/items/${customerId}/${options.itemId}`,
+ urlString`/payments/items/${customerType}/${customerId}/${options.itemId}`,
{},
session,
);
@@ -1760,6 +1778,7 @@ export class StackClientInterface {
}
async createCheckoutUrl(
+ customer_type: "user" | "team" | "custom",
customer_id: string,
offerIdOrInline: string | yup.InferType,
session: InternalSession | null,
@@ -1774,7 +1793,7 @@ export class StackClientInterface {
headers: {
"content-type": "application/json",
},
- body: JSON.stringify({ customer_id, ...offerBody }),
+ body: JSON.stringify({ customer_type, customer_id, ...offerBody }),
},
session
);
diff --git a/packages/stack-shared/src/interface/server-interface.ts b/packages/stack-shared/src/interface/server-interface.ts
index e31f97e554..53316cd950 100644
--- a/packages/stack-shared/src/interface/server-interface.ts
+++ b/packages/stack-shared/src/interface/server-interface.ts
@@ -833,23 +833,37 @@ export class StackServerInterface extends StackClientInterface {
}
async updateItemQuantity(
- customerId: string,
- itemId: string,
+ options: (
+ { itemId: string, userId: string } |
+ { itemId: string, teamId: string } |
+ { itemId: string, customId: string }
+ ),
data: ItemCrud['Server']['Update'],
): Promise {
+ let customerType: "user" | "team" | "custom";
+ let customerId: string;
+ const itemId: string = options.itemId;
+
+ if ("userId" in options) {
+ customerType = "user";
+ customerId = options.userId;
+ } else if ("teamId" in options) {
+ customerType = "team";
+ customerId = options.teamId;
+ } else if ("customId" in options) {
+ customerType = "custom";
+ customerId = options.customId;
+ } else {
+ throw new StackAssertionError("updateItemQuantity requires one of userId, teamId, or customId");
+ }
+
const queryParams = new URLSearchParams({ allow_negative: (data.allow_negative ?? false).toString() });
await this.sendServerRequest(
- `/payments/items/${customerId}/${itemId}/update-quantity?${queryParams.toString()}`,
+ `/payments/items/${customerType}/${customerId}/${itemId}/update-quantity?${queryParams.toString()}`,
{
method: "POST",
- headers: {
- "content-type": "application/json",
- },
- body: JSON.stringify({
- delta: data.delta,
- expires_at: data.expires_at,
- description: data.description,
- }),
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ delta: data.delta, expires_at: data.expires_at, description: data.description }),
},
null
);
diff --git a/packages/stack-shared/src/known-errors.tsx b/packages/stack-shared/src/known-errors.tsx
index c3a03dbbc6..77be6e632f 100644
--- a/packages/stack-shared/src/known-errors.tsx
+++ b/packages/stack-shared/src/known-errors.tsx
@@ -1430,7 +1430,7 @@ const ItemNotFound = createKnownErrorConstructor(
const ItemCustomerTypeDoesNotMatch = createKnownErrorConstructor(
KnownError,
"ITEM_CUSTOMER_TYPE_DOES_NOT_MATCH",
- (itemId: string, customerId: string, itemCustomerType: "user" | "team" | undefined, actualCustomerType: "user" | "team") => [
+ (itemId: string, customerId: string, itemCustomerType: "user" | "team" | "custom" | undefined, actualCustomerType: "user" | "team" | "custom") => [
400,
`The ${actualCustomerType} with ID ${JSON.stringify(customerId)} is not a valid customer for the item with ID ${JSON.stringify(itemId)}. ${itemCustomerType ? `The item is configured to only be available for ${itemCustomerType} customers, but the customer is a ${actualCustomerType}.` : `The item is missing a customer type field. Please make sure it is set up correctly in your project configuration.`}`,
{
@@ -1473,7 +1473,7 @@ const OfferDoesNotExist = createKnownErrorConstructor(
const OfferCustomerTypeDoesNotMatch = createKnownErrorConstructor(
KnownError,
"OFFER_CUSTOMER_TYPE_DOES_NOT_MATCH",
- (offerId: string | undefined, customerId: string, offerCustomerType: "user" | "team" | undefined, actualCustomerType: "user" | "team") => [
+ (offerId: string | undefined, customerId: string, offerCustomerType: "user" | "team" | "custom" | undefined, actualCustomerType: "user" | "team" | "custom") => [
400,
`The ${actualCustomerType} with ID ${JSON.stringify(customerId)} is not a valid customer for the inline offer that has been passed in. ${offerCustomerType ? `The offer is configured to only be available for ${offerCustomerType} customers, but the customer is a ${actualCustomerType}.` : `The offer is missing a customer type field. Please make sure it is set up correctly in your project configuration.`}`,
{
diff --git a/packages/stack-shared/src/schema-fields.ts b/packages/stack-shared/src/schema-fields.ts
index 84f674bb11..3d92982f9f 100644
--- a/packages/stack-shared/src/schema-fields.ts
+++ b/packages/stack-shared/src/schema-fields.ts
@@ -544,7 +544,7 @@ export const emailTemplateListSchema = yupRecord(
).meta({ openapiField: { description: 'Record of email template IDs to their display name and source code' } });
// Payments
-export const customerTypeSchema = yupString().oneOf(['user', 'team']);
+export const customerTypeSchema = yupString().oneOf(['user', 'team', 'custom']);
const validateHasAtLeastOneSupportedCurrency = (value: Record, context: any) => {
const currencies = Object.keys(value).filter(key => SUPPORTED_CURRENCIES.some(c => c.code === key));
if (currencies.length === 0) {
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 a7d3e0106f..f170acf4b1 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
@@ -512,17 +512,13 @@ export class _StackAdminAppImplIncomplete {
- return await this._interface.createPurchaseUrl({
- customer_id: options.customerId,
- offer_id: options.offerId,
- });
- }
-
- async createItemQuantityChange(options: { customerId: string, itemId: string, quantity: number, expiresAt?: string, description?: string }): Promise {
+ async createItemQuantityChange(options: (
+ { userId: string, itemId: string, quantity: number, expiresAt?: string, description?: string } |
+ { teamId: string, itemId: string, quantity: number, expiresAt?: string, description?: string } |
+ { customId: string, itemId: string, quantity: number, expiresAt?: string, description?: string }
+ )): Promise {
await this._interface.updateItemQuantity(
- options.customerId,
- options.itemId,
+ { itemId: options.itemId, ...("userId" in options ? { userId: options.userId } : ("teamId" in options ? { teamId: options.teamId } : { customId: options.customId })) },
{
delta: options.quantity,
expires_at: options.expiresAt,
diff --git a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts
index 610991c0b6..f2360627e5 100644
--- a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts
+++ b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts
@@ -213,6 +213,12 @@ export class _StackClientAppImplIncomplete(
+ async (session, [customId, itemId]) => {
+ return await this._interface.getItem({ customId, itemId }, session);
+ }
+ );
+
private _anonymousSignUpInProgress: Promise<{ accessToken: string, refreshToken: string }> | null = null;
protected async _createCookieHelper(): Promise {
@@ -252,15 +258,15 @@ export class _StackClientAppImplIncomplete {
+ protected _ensurePersistentTokenStore(overrideTokenStoreInit?: TokenStoreInit): asserts this is StackClientApp {
if (!this._hasPersistentTokenStore(overrideTokenStoreInit)) {
throw new Error("Cannot call this function on a Stack app without a persistent token store. Make sure the tokenStore option on the constructor is set to a non-null value when initializing Stack.\n\nStack uses token stores to access access tokens of the current user. For example, on web frontends it is commonly the string value 'cookies' for cookie storage.");
}
@@ -673,10 +679,10 @@ export class _StackClientAppImplIncomplete app._clientTeamInvitationFromCrud(session, crud));
},
// END_PLATFORM
- async update(data: TeamUpdateOptions){
+ async update(data: TeamUpdateOptions) {
await app._interface.updateTeam({ data: teamUpdateOptionsToCrud(data), teamId: crud.id }, session);
await app._currentUserTeamsCache.refresh([session]);
},
@@ -1084,7 +1090,7 @@ export class _StackClientAppImplIncomplete {
+ const session = await this._getSession();
+ let crud: ItemCrud['Client']['Read'];
+ if ("userId" in options) {
+ crud = Result.orThrow(await this._userItemCache.getOrWait([session, options.userId, options.itemId], "write-only"));
+ } else if ("teamId" in options) {
+ crud = Result.orThrow(await this._teamItemCache.getOrWait([session, options.teamId, options.itemId], "write-only"));
+ } else {
+ crud = Result.orThrow(await this._customItemCache.getOrWait([session, options.customId, options.itemId], "write-only"));
+ }
+ return this._clientItemFromCrud(crud);
+ }
+
+ // IF_PLATFORM react-like
+ useItem(options: { itemId: string, userId: string } | { itemId: string, teamId: string } | { itemId: string, customId: string }): Item {
+ const session = this._useSession();
+ const [cache, ownerId] =
+ "userId" in options ? [this._userItemCache, options.userId] :
+ "teamId" in options ? [this._teamItemCache, options.teamId] : [this._customItemCache, options.customId];
+ const crud = useAsyncCache(cache, [session, ownerId, options.itemId] as const, "app.useItem()");
+ return this._clientItemFromCrud(crud);
+ }
+ // END_PLATFORM
+
protected _currentUserFromCrud(crud: NonNullable, session: InternalSession): ProjectCurrentUser {
const currentUser = {
...this._createBaseUser(crud),
@@ -1257,10 +1287,10 @@ export class _StackClientAppImplIncomplete window.location.assign(to);
- // IF_PLATFORM next
+ // IF_PLATFORM next
} else if (this._redirectMethod === "nextjs") {
const router = NextNavigation.useRouter();
return (to: string) => router.push(to);
- // END_PLATFORM
+ // END_PLATFORM
} else {
- return (to: string) => {};
+ return (to: string) => { };
}
}
// END_PLATFORM
diff --git a/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts
index faa3361c9c..d83deba0e6 100644
--- a/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts
+++ b/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts
@@ -1,5 +1,6 @@
import { KnownErrors, StackServerInterface } from "@stackframe/stack-shared";
import { ContactChannelsCrud } from "@stackframe/stack-shared/dist/interface/crud/contact-channels";
+import { ItemCrud } from "@stackframe/stack-shared/dist/interface/crud/items";
import { NotificationPreferenceCrud } from "@stackframe/stack-shared/dist/interface/crud/notification-preferences";
import { TeamApiKeysCrud, UserApiKeysCrud, teamApiKeysCreateOutputSchema, userApiKeysCreateOutputSchema } from "@stackframe/stack-shared/dist/interface/crud/project-api-keys";
import { ProjectPermissionDefinitionsCrud, ProjectPermissionsCrud } from "@stackframe/stack-shared/dist/interface/crud/project-permissions";
@@ -8,8 +9,8 @@ import { TeamMemberProfilesCrud } from "@stackframe/stack-shared/dist/interface/
import { TeamPermissionDefinitionsCrud, TeamPermissionsCrud } from "@stackframe/stack-shared/dist/interface/crud/team-permissions";
import { TeamsCrud } from "@stackframe/stack-shared/dist/interface/crud/teams";
import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users";
-import { ItemCrud } from "@stackframe/stack-shared/dist/interface/crud/items";
import { InternalSession } from "@stackframe/stack-shared/dist/sessions";
+import type { AsyncCache } from "@stackframe/stack-shared/dist/utils/caches";
import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { ProviderType } from "@stackframe/stack-shared/dist/utils/oauth";
import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
@@ -22,6 +23,7 @@ import { ApiKey, ApiKeyCreationOptions, ApiKeyUpdateOptions, apiKeyCreationOptio
import { GetUserOptions, HandlerUrls, OAuthScopesOnSignIn, TokenStoreInit } from "../../common";
import { OAuthConnection } from "../../connected-accounts";
import { ServerContactChannel, ServerContactChannelCreateOptions, ServerContactChannelUpdateOptions, serverContactChannelCreateOptionsToCrud, serverContactChannelUpdateOptionsToCrud } from "../../contact-channels";
+import { SendEmailOptions } from "../../email";
import { NotificationCategory } from "../../notification-categories";
import { AdminProjectPermissionDefinition, AdminTeamPermission, AdminTeamPermissionDefinition } from "../../permissions";
import { EditableTeamMemberProfile, ServerListUsersOptions, ServerTeam, ServerTeamCreateOptions, ServerTeamUpdateOptions, ServerTeamUser, Team, TeamInvitation, serverTeamCreateOptionsToCrud, serverTeamUpdateOptionsToCrud } from "../../teams";
@@ -29,11 +31,10 @@ import { ProjectCurrentServerUser, ServerUser, ServerUserCreateOptions, ServerUs
import { StackServerAppConstructorOptions } from "../interfaces/server-app";
import { _StackClientAppImplIncomplete } from "./client-app-impl";
import { clientVersion, createCache, createCacheBySession, getBaseUrl, getDefaultProjectId, getDefaultPublishableClientKey, getDefaultSecretServerKey } from "./common";
-import { SendEmailOptions } from "../../email";
+import { InlineOffer, ServerItem } from "../../customers";
// NEXT_LINE_PLATFORM react-like
import { useAsyncCache } from "./common";
-import { InlineOffer, ServerItem } from "../../customers";
export class _StackServerAppImplIncomplete extends _StackClientAppImplIncomplete {
declare protected _interface: StackServerInterface;
@@ -168,6 +169,12 @@ export class _StackServerAppImplIncomplete(
+ async ([customId, itemId]) => {
+ return await this._interface.getItem({ customId, itemId }, null);
+ }
+ );
+
private async _updateServerUser(userId: string, update: ServerUserUpdateOptions): Promise {
const result = await this._interface.updateServerUser(userId, serverUserUpdateOptionsToCrud(update));
await this._refreshUsers();
@@ -569,16 +576,16 @@ export class _StackServerAppImplIncomplete app._serverItemFromCrud(crud.id, result), [result]);
+ return useMemo(() => app._serverItemFromCrud({ type: "user", id: crud.id }, result), [result]);
},
// END_PLATFORM
};
@@ -698,38 +705,59 @@ export class _StackServerAppImplIncomplete app._serverItemFromCrud(crud.id, result), [result]);
+ return useMemo(() => app._serverItemFromCrud({ type: "team", id: crud.id }, result), [result]);
},
// END_PLATFORM
async createCheckoutUrl(offerIdOrInline: string | InlineOffer) {
- return await app._interface.createCheckoutUrl(crud.id, offerIdOrInline, null);
+ return await app._interface.createCheckoutUrl("team", crud.id, offerIdOrInline, null);
},
};
}
- protected _serverItemFromCrud(customerId: string, crud: ItemCrud['Client']['Read']): ServerItem {
+ protected _serverItemFromCrud(customer: { type: "user" | "team" | "custom", id: string }, crud: ItemCrud['Client']['Read']): ServerItem {
const app = this;
return {
displayName: crud.display_name,
quantity: crud.quantity,
nonNegativeQuantity: Math.max(0, crud.quantity),
increaseQuantity: async (delta: number) => {
- await app._interface.updateItemQuantity(customerId, crud.id, { delta });
- await app._serverUserItemsCache.refresh([customerId, crud.id]);
+ const updateOptions = customer.type === "user"
+ ? { itemId: crud.id, userId: customer.id }
+ : customer.type === "team"
+ ? { itemId: crud.id, teamId: customer.id }
+ : { itemId: crud.id, customId: customer.id };
+ await app._interface.updateItemQuantity(updateOptions, { delta });
+ if (customer.type === "user") await app._serverUserItemsCache.refresh([customer.id, crud.id]);
+ else if (customer.type === "team") await app._serverTeamItemsCache.refresh([customer.id, crud.id]);
+ else await app._serverCustomItemsCache.refresh([customer.id, crud.id]);
},
decreaseQuantity: async (delta: number) => {
- await app._interface.updateItemQuantity(customerId, crud.id, { delta: -delta, allow_negative: true });
- await app._serverUserItemsCache.refresh([customerId, crud.id]);
+ const updateOptions = customer.type === "user"
+ ? { itemId: crud.id, userId: customer.id }
+ : customer.type === "team"
+ ? { itemId: crud.id, teamId: customer.id }
+ : { itemId: crud.id, customId: customer.id };
+ await app._interface.updateItemQuantity(updateOptions, { delta: -delta, allow_negative: true });
+ if (customer.type === "user") await app._serverUserItemsCache.refresh([customer.id, crud.id]);
+ else if (customer.type === "team") await app._serverTeamItemsCache.refresh([customer.id, crud.id]);
+ else await app._serverCustomItemsCache.refresh([customer.id, crud.id]);
},
tryDecreaseQuantity: async (delta: number) => {
try {
- await app._interface.updateItemQuantity(customerId, crud.id, { delta: -delta });
- await app._serverUserItemsCache.refresh([customerId, crud.id]);
+ const updateOptions = customer.type === "user"
+ ? { itemId: crud.id, userId: customer.id }
+ : customer.type === "team"
+ ? { itemId: crud.id, teamId: customer.id }
+ : { itemId: crud.id, customId: customer.id };
+ await app._interface.updateItemQuantity(updateOptions, { delta: -delta });
+ if (customer.type === "user") await app._serverUserItemsCache.refresh([customer.id, crud.id]);
+ else if (customer.type === "team") await app._serverTeamItemsCache.refresh([customer.id, crud.id]);
+ else await app._serverCustomItemsCache.refresh([customer.id, crud.id]);
return true;
} catch (error) {
if (error instanceof KnownErrors.ItemQuantityInsufficientAmount) {
@@ -967,6 +995,45 @@ export class _StackServerAppImplIncomplete this._serverTeamFromCrud(t));
}
+ async getItem(options: { itemId: string, userId: string } | { itemId: string, teamId: string } | { itemId: string, customId: string }): Promise {
+ if ("userId" in options) {
+ const result = Result.orThrow(await this._serverUserItemsCache.getOrWait([options.userId, options.itemId], "write-only"));
+ return this._serverItemFromCrud({ type: "user", id: options.userId }, result);
+ } else if ("teamId" in options) {
+ const result = Result.orThrow(await this._serverTeamItemsCache.getOrWait([options.teamId, options.itemId], "write-only"));
+ return this._serverItemFromCrud({ type: "team", id: options.teamId }, result);
+ } else {
+ const result = Result.orThrow(await this._serverCustomItemsCache.getOrWait([options.customId, options.itemId], "write-only"));
+ return this._serverItemFromCrud({ type: "custom", id: options.customId }, result);
+ }
+ }
+
+ // IF_PLATFORM react-like
+ useItem(options: { itemId: string, userId: string } | { itemId: string, teamId: string } | { itemId: string, customId: string }): ServerItem {
+ let type: "user" | "team" | "custom";
+ let id: string;
+ let cache: AsyncCache<[string, string], Result>;
+ if ("userId" in options) {
+ type = "user";
+ id = options.userId;
+ cache = this._serverUserItemsCache;
+ } else if ("teamId" in options) {
+ type = "team";
+ id = options.teamId;
+ cache = this._serverTeamItemsCache;
+ } else {
+ type = "custom";
+ id = options.customId;
+ cache = this._serverCustomItemsCache;
+ }
+
+ const cacheKey = [id, options.itemId] as [string, string];
+ const debugLabel = `app.useItem(${type})`;
+ const result = useAsyncCache(cache, cacheKey, debugLabel);
+ return useMemo(() => this._serverItemFromCrud({ type, id }, result), [result]);
+ }
+ // END_PLATFORM
+
async createTeam(data: ServerTeamCreateOptions): Promise {
const team = await this._interface.createServerTeam(serverTeamCreateOptionsToCrud(data));
await this._serverTeamsCache.refresh([undefined]);
diff --git a/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts b/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts
index f7c0fbc862..6b833c25ed 100644
--- a/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts
+++ b/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts
@@ -76,8 +76,11 @@ export type StackAdminApp,
createStripeWidgetAccountSession(): Promise<{ client_secret: string }>,
- createPurchaseUrl(options: { customerId: string, offerId: string }): Promise,
- createItemQuantityChange(options: { customerId: string, itemId: string, quantity: number, expiresAt?: string, description?: string }): Promise,
+ createItemQuantityChange(options: (
+ { userId: string, itemId: string, quantity: number, expiresAt?: string, description?: string } |
+ { teamId: string, itemId: string, quantity: number, expiresAt?: string, description?: string } |
+ { customId: string, itemId: string, quantity: number, expiresAt?: string, description?: string }
+ )): Promise,
}
& StackServerApp
);
diff --git a/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts b/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts
index d58e80cd98..0c47a82052 100644
--- a/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts
+++ b/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts
@@ -2,6 +2,7 @@ import { KnownErrors } from "@stackframe/stack-shared";
import { CurrentUserCrud } from "@stackframe/stack-shared/dist/interface/crud/current-user";
import { Result } from "@stackframe/stack-shared/dist/utils/results";
import { AsyncStoreProperty, GetUserOptions, HandlerUrls, OAuthScopesOnSignIn, RedirectMethod, RedirectToOptions, TokenStoreInit, stackAppInternalsSymbol } from "../../common";
+import { Item } from "../../customers";
import { Project } from "../../projects";
import { ProjectCurrentUser } from "../../users";
import { _StackClientAppImpl } from "../implementations";
@@ -76,6 +77,12 @@ export type StackClientApp
+ & AsyncStoreProperty<
+ "item",
+ [{ itemId: string, userId: string } | { itemId: string, teamId: string } | { itemId: string, customId: string }],
+ Item,
+ false
+ >
& { [K in `redirectTo${Capitalize>}`]: (options?: RedirectToOptions) => Promise }
);
export type StackClientAppConstructor = {
diff --git a/packages/template/src/lib/stack-app/apps/interfaces/server-app.ts b/packages/template/src/lib/stack-app/apps/interfaces/server-app.ts
index de358074a7..0dfa9214c4 100644
--- a/packages/template/src/lib/stack-app/apps/interfaces/server-app.ts
+++ b/packages/template/src/lib/stack-app/apps/interfaces/server-app.ts
@@ -1,11 +1,12 @@
+import { KnownErrors } from "@stackframe/stack-shared";
import { Result } from "@stackframe/stack-shared/dist/utils/results";
import { AsyncStoreProperty, GetUserOptions } from "../../common";
+import { ServerItem } from "../../customers";
+import { SendEmailOptions } from "../../email";
import { ServerListUsersOptions, ServerTeam, ServerTeamCreateOptions } from "../../teams";
import { ProjectCurrentServerUser, ServerUser, ServerUserCreateOptions } from "../../users";
import { _StackServerAppImpl } from "../implementations";
import { StackClientApp, StackClientAppConstructorOptions } from "./client-app";
-import { KnownErrors } from "@stackframe/stack-shared";
-import { SendEmailOptions } from "../../email";
export type StackServerAppConstructorOptions = StackClientAppConstructorOptions & {
@@ -54,6 +55,12 @@ export type StackServerApp
& Omit, "listUsers" | "useUsers">
& AsyncStoreProperty<"teams", [], ServerTeam[], true>
+ & AsyncStoreProperty<
+ "item",
+ [{ itemId: string, userId: string } | { itemId: string, teamId: string } | { itemId: string, customId: string }],
+ ServerItem,
+ false
+ >
& StackClientApp
);
export type StackServerAppConstructor = {