[Docs][Content] API/SK docs for payments (#935)

This commit is contained in:
Madison 2025-10-11 16:47:25 -05:00 committed by GitHub
parent 455e99b82e
commit cd52b36591
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1311 additions and 45 deletions

View File

@ -1,14 +1,17 @@
import { ensureCustomerExists, 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";
export const GET = createSmartRouteHandler({
metadata: {
hidden: true,
hidden: false,
summary: "Get Item",
description: "Retrieves information about a specific item (credits, quotas, etc.) for a customer.",
tags: ["Payments"],
},
request: yupObject({
auth: yupObject({
@ -17,18 +20,48 @@ 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(),
customer_type: yupString().oneOf(["user", "team", "custom"]).defined().meta({
openapiField: {
description: "The type of customer",
exampleValue: "user"
}
}),
customer_id: yupString().defined().meta({
openapiField: {
description: "The ID of the customer",
exampleValue: "user_1234567890abcdef"
}
}),
item_id: yupString().defined().meta({
openapiField: {
description: "The ID of the item to retrieve",
exampleValue: "credits"
}
}),
}).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
id: yupString().defined(),
display_name: yupString().defined(),
quantity: yupNumber().defined(),
id: yupString().defined().meta({
openapiField: {
description: "The ID of the item",
exampleValue: "credits"
}
}),
display_name: yupString().defined().meta({
openapiField: {
description: "The human-readable name of the item",
exampleValue: "API Credits"
}
}),
quantity: yupNumber().defined().meta({
openapiField: {
description: "The current quantity of the item (can be negative)",
exampleValue: 1000
}
}),
}).defined(),
}),
handler: async (req) => {

View File

@ -1,14 +1,17 @@
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: {
hidden: true,
hidden: false,
summary: "Update Item Quantity",
description: "Updates the quantity of an item for a customer. Can increase or decrease quantities, with optional expiration and negative balance control.",
tags: ["Payments"],
},
request: yupObject({
auth: yupObject({
@ -17,25 +20,58 @@ 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(),
customer_type: yupString().oneOf(["user", "team", "custom"]).defined().meta({
openapiField: {
description: "The type of customer",
exampleValue: "user"
}
}),
customer_id: yupString().defined().meta({
openapiField: {
description: "The ID of the customer",
exampleValue: "user_1234567890abcdef"
}
}),
item_id: yupString().defined().meta({
openapiField: {
description: "The ID of the item to update",
exampleValue: "credits"
}
}),
}).defined(),
query: yupObject({
allow_negative: yupString().oneOf(["true", "false"]).defined(),
allow_negative: yupString().oneOf(["true", "false"]).defined().meta({
openapiField: {
description: "Whether to allow the quantity to go negative",
exampleValue: "false"
}
}),
}).defined(),
body: yupObject({
delta: yupNumber().integer().defined(),
expires_at: yupString().optional(),
description: yupString().optional(),
delta: yupNumber().integer().defined().meta({
openapiField: {
description: "The amount to change the quantity by (positive to increase, negative to decrease)",
exampleValue: 100
}
}),
expires_at: yupString().optional().meta({
openapiField: {
description: "Optional expiration date for this quantity change (ISO 8601 format)",
exampleValue: "2024-12-31T23:59:59Z"
}
}),
description: yupString().optional().meta({
openapiField: {
description: "Optional description for this quantity change",
exampleValue: "Monthly subscription renewal"
}
}),
}).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
id: yupString().defined(),
}).defined(),
body: yupObject({}).defined(),
}),
handler: async (req) => {
const { tenancy } = req.auth;
@ -57,7 +93,7 @@ export const POST = createSmartRouteHandler({
customerId: req.params.customer_id,
});
const changeId = await retryTransaction(prisma, async (tx) => {
await retryTransaction(prisma, async (tx) => {
const totalQuantity = await getItemQuantityForCustomer({
prisma: tx,
tenancy,
@ -68,7 +104,7 @@ export const POST = createSmartRouteHandler({
if (!allowNegative && (totalQuantity + req.body.delta < 0)) {
throw new KnownErrors.ItemQuantityInsufficientAmount(req.params.item_id, req.params.customer_id, req.body.delta);
}
const change = await tx.itemQuantityChange.create({
await tx.itemQuantityChange.create({
data: {
tenancyId: tenancy.id,
customerId: req.params.customer_id,
@ -79,13 +115,12 @@ export const POST = createSmartRouteHandler({
expiresAt: req.body.expires_at ? new Date(req.body.expires_at) : null,
},
});
return change.id;
});
return {
statusCode: 200,
bodyType: "json",
body: { id: changeId },
body: {},
};
},
});

View File

@ -12,7 +12,10 @@ import { purchaseUrlVerificationCodeHandler } from "../verification-code-handler
export const POST = createSmartRouteHandler({
metadata: {
hidden: true,
hidden: false,
summary: "Create Purchase URL",
description: "Creates a secure checkout URL for purchasing a product.",
tags: ["Payments"],
},
request: yupObject({
auth: yupObject({
@ -21,18 +24,46 @@ export const POST = createSmartRouteHandler({
tenancy: adaptSchema.defined(),
}).defined(),
body: yupObject({
customer_type: yupString().oneOf(["user", "team", "custom"]).defined(),
customer_id: yupString().defined(),
product_id: yupString().optional(),
product_inline: inlineProductSchema.optional(),
return_url: urlSchema.optional(),
customer_type: yupString().oneOf(["user", "team", "custom"]).defined().meta({
openapiField: {
description: "The type of customer making the purchase",
exampleValue: "user"
}
}),
customer_id: yupString().defined().meta({
openapiField: {
description: "The ID of the customer (user ID, team ID, or custom customer ID)",
exampleValue: "user_1234567890abcdef"
}
}),
product_id: yupString().optional().meta({
openapiField: {
description: "The ID of the product to purchase. Either this or product_inline should be given.",
exampleValue: "prod_premium_monthly"
}
}),
product_inline: inlineProductSchema.optional().meta({
openapiField: {
description: "Inline product definition. Either this or product_id should be given."
}
}),
return_url: urlSchema.optional().meta({
openapiField: {
description: "URL to redirect to after purchase completion. Must be configured as a trusted domain in the project configuration.",
exampleValue: "https://myapp.com/purchase-success"
}
}),
}),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
url: yupString().defined(),
url: yupString().defined().meta({
openapiField: {
description: "The secure checkout URL for completing the purchase"
}
}),
}).defined(),
}),
handler: async (req) => {

View File

@ -10,20 +10,43 @@ import { purchaseUrlVerificationCodeHandler } from "../verification-code-handler
export const POST = createSmartRouteHandler({
metadata: {
hidden: true,
hidden: false,
summary: "Create Purchase Session",
description: "Creates a purchase session for completing a purchase.",
tags: ["Payments"],
},
request: yupObject({
body: yupObject({
full_code: yupString().defined(),
price_id: yupString().defined(),
quantity: yupNumber().integer().min(1).default(1),
full_code: yupString().defined().meta({
openapiField: {
description: "The verification code, given as a query parameter in the purchase URL",
exampleValue: "proj_abc123_def456ghi789"
}
}),
price_id: yupString().defined().meta({
openapiField: {
description: "The Stripe price ID to purchase",
exampleValue: "price_1234567890abcdef"
}
}),
quantity: yupNumber().integer().min(1).default(1).meta({
openapiField: {
description: "The quantity to purchase",
exampleValue: 1
}
}),
}),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
client_secret: yupString().defined(),
client_secret: yupString().defined().meta({
openapiField: {
description: "The Stripe client secret for completing the payment",
exampleValue: "1234567890abcdef_secret_xyz123"
}
}),
}),
}),
async handler({ body }) {

View File

@ -10,12 +10,25 @@ import { purchaseUrlVerificationCodeHandler } from "../verification-code-handler
export const POST = createSmartRouteHandler({
metadata: {
hidden: true,
hidden: false,
summary: "Validate Purchase Code",
description: "Validates a purchase verification code and returns purchase details including available prices.",
tags: ["Payments"],
},
request: yupObject({
body: yupObject({
full_code: yupString().defined(),
return_url: urlSchema.optional(),
full_code: yupString().defined().meta({
openapiField: {
description: "The verification code, given as a query parameter in the purchase URL",
exampleValue: "proj_abc123_def456ghi789"
}
}),
return_url: urlSchema.optional().meta({
openapiField: {
description: "URL to redirect to after purchase completion",
exampleValue: "https://myapp.com/purchase-success"
}
}),
}),
}),
response: yupObject({

View File

@ -206,6 +206,17 @@ function getFieldSchema(field: yup.SchemaFieldDescription, crudOperation?: Capit
case 'array': {
return { type: 'array', items: getFieldSchema((field as any).innerType, crudOperation), ...openapiFieldExtra };
}
case 'tuple': {
// For OpenAPI, treat tuples as arrays since OpenAPI doesn't have native tuple support
// This is commonly used for headers which are arrays of strings
const tupleField = field as any;
if (tupleField.innerType && tupleField.innerType.length > 0) {
// Use the first element's schema as the array item type
return { type: 'array', items: getFieldSchema(tupleField.innerType[0], crudOperation), ...openapiFieldExtra };
}
// Fallback to string array if no inner type
return { type: 'array', items: { type: 'string' }, ...openapiFieldExtra };
}
default: {
throw new Error(`Unsupported field type: ${field.type}`);
}

View File

@ -137,7 +137,7 @@ it("should return ItemCustomerTypeDoesNotMatch error for user accessing team ite
`);
});
it("creates an item quantity change and returns id", async ({ expect }) => {
it("creates an item quantity change successfully", async ({ expect }) => {
await Project.createAndSwitch();
await updateConfig({
payments: {
@ -162,7 +162,7 @@ it("creates an item quantity change and returns id", async ({ expect }) => {
});
expect(response.status).toBe(200);
expect(response.body).toMatchObject({ id: expect.any(String) });
expect(response.body).toMatchObject({});
});
it("aggregates item quantity changes in item quantity", async ({ expect }) => {
@ -415,7 +415,7 @@ it("should allow negative quantity changes when allow_negative is true", async (
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": { "id": "<stripped UUID>" },
"body": {},
"headers": Headers { <some fields may have been hidden> },
}
`);

View File

@ -237,6 +237,12 @@ pages:
- path: sdk/types/email.mdx
platforms: ["next", "react", "js"] # No Python
- path: sdk/types/customer.mdx
platforms: ["next", "react", "js"] # No Python
- path: sdk/types/item.mdx
platforms: ["next", "react", "js"] # No Python
# SDK Hooks (React-like only)
- path: sdk/hooks/use-stack-app.mdx
platforms: ["next", "react"] # No JS or Python

View File

@ -17,6 +17,7 @@ const FUNCTIONAL_TAGS = [
'Oauth', // Note: OpenAPI uses "Oauth" not "OAuth"
'OTP',
'Password',
'Payments',
'Permissions',
'Projects',
'Sessions',

View File

@ -0,0 +1,359 @@
'use client';
import { runAsynchronously } from '@stackframe/stack-shared/dist/utils/promises';
import React from 'react';
import { ClickableTableOfContents, ParamField } from '../mdx/sdk-components';
import { AsideSection, CollapsibleTypesSection, MethodAside, MethodContent, MethodLayout } from '../ui/method-layout';
// Type definitions based on the types.json structure
type TypeMember = {
name: string,
optional: boolean,
sourcePath: string,
line: number,
kind: 'property' | 'method',
type?: string,
description?: string,
signatures?: Array<{
signature: string,
parameters: Array<{
name: string,
type: string,
optional: boolean,
}>,
returnType: string,
}>,
platforms?: string[],
tags?: Array<{
name: string,
text: string,
}>,
};
type TypeInfo = {
name: string,
kind: 'type',
sourcePath: string,
line: number,
category: 'types',
definition: string,
description?: string,
members: TypeMember[],
mixins?: string[],
};
type TypeDocumentationProps = {
typeInfo: TypeInfo,
platform?: string,
};
function formatTypeSignature(type: string): string {
// Clean up long import paths and make types more readable
return type
.replace(/import\([^)]+\)\./g, '') // Remove import() paths
.replace(/\s+/g, ' ') // Normalize whitespace
.trim();
}
function buildAnchorId(typeName: string, memberName: string): string {
const cleanType = typeName.replace(/[^a-z0-9]/gi, '').toLowerCase();
const cleanMember = memberName.replace(/[^a-z0-9]/gi, '').toLowerCase();
return `#${cleanType}${cleanMember}`;
}
function generateTableOfContents(typeInfo: TypeInfo, platform = 'react-like'): string {
const lines: string[] = [];
lines.push(`type ${typeInfo.name} = {`);
typeInfo.members.forEach(member => {
// Skip platform-specific members if they don't match current platform
if (member.platforms && !member.platforms.includes(platform)) {
return;
}
const memberName = member.name;
const isOptional = member.optional ? '?' : '';
const anchorId = buildAnchorId(typeInfo.name, memberName);
if (member.kind === 'property') {
const cleanType = formatTypeSignature(member.type || 'unknown');
lines.push(` ${memberName}${isOptional}: ${cleanType}; //$stack-link-to:${anchorId}`);
} else {
// For methods, show the first signature or a simplified version
const signature = member.signatures?.[0];
if (signature) {
const params = signature.parameters.map(p => {
if (p.optional) {
return `${p.name}?`;
}
return p.name;
}).join(', ');
const returnType = formatTypeSignature(signature.returnType);
lines.push(` ${memberName}(${params}): ${returnType}; //$stack-link-to:${anchorId}`);
} else {
lines.push(` ${memberName}(): unknown; //$stack-link-to:${anchorId}`);
}
}
});
lines.push('};');
return lines.join('\n');
}
function renderMemberDocumentation(typeInfo: TypeInfo, member: TypeMember, platform = 'react-like') {
const memberName = member.name;
const primarySignature = member.signatures?.[0];
// Skip platform-specific members if they don't match current platform
if (member.platforms && !member.platforms.includes(platform)) {
return null;
}
return (
<CollapsibleTypesSection
key={memberName}
type={typeInfo.name}
property={memberName}
signature={member.kind === 'method' && primarySignature
? primarySignature.parameters.map(p => p.name).join(', ')
: undefined
}
defaultOpen={false}
>
<MethodLayout>
<MethodContent>
{member.description && (
<div className="mb-4 text-fd-muted-foreground">
{member.description}
</div>
)}
{member.tags?.some(tag => tag.name === 'deprecated') && (
<div className="mb-4 p-3 bg-yellow-50 dark:bg-yellow-950/20 border border-yellow-200 dark:border-yellow-800/50 rounded-lg">
<div className="text-yellow-800 dark:text-yellow-200 text-sm font-medium mb-1">
Deprecated
</div>
<div className="text-yellow-700 dark:text-yellow-300 text-sm">
{member.tags.find(tag => tag.name === 'deprecated')?.text || 'This method is deprecated.'}
</div>
</div>
)}
{member.kind === 'method' && (
<>
<h4 className="text-sm font-semibold text-fd-foreground mb-3">Parameters</h4>
{(primarySignature?.parameters.length ?? 0) === 0 ? (
<p className="text-sm text-fd-muted-foreground mb-4">No parameters.</p>
) : (
<div className="space-y-3 mb-6">
{(primarySignature?.parameters ?? []).map((param, index) => (
<ParamField
key={index}
path={param.name}
type={formatTypeSignature(param.type)}
required={!param.optional}
>
Parameter of type {formatTypeSignature(param.type)}.
</ParamField>
))}
</div>
)}
<h4 className="text-sm font-semibold text-fd-foreground mb-2">Returns</h4>
<p className="text-sm text-fd-muted-foreground">
<code className="bg-fd-muted px-1.5 py-0.5 rounded text-xs">
{formatTypeSignature(primarySignature?.returnType ?? 'unknown')}
</code>
</p>
</>
)}
{member.kind === 'property' && (
<>
<h4 className="text-sm font-semibold text-fd-foreground mb-2">Type</h4>
<p className="text-sm text-fd-muted-foreground">
<code className="bg-fd-muted px-1.5 py-0.5 rounded text-xs">
{formatTypeSignature(member.type || 'unknown')}
</code>
</p>
</>
)}
</MethodContent>
<MethodAside title="Type Definition">
{member.kind === 'method' && member.signatures ? (
<AsideSection title="Signature">
<pre className="text-xs bg-fd-code p-3 rounded border overflow-x-auto">
<code>
{member.signatures.map((sig, index) => (
<div key={index} className="mb-2 last:mb-0">
{sig.signature}
</div>
))}
</code>
</pre>
</AsideSection>
) : (
<pre className="text-xs bg-fd-code p-3 rounded border overflow-x-auto">
<code>
declare const {memberName}: {formatTypeSignature(member.type || 'unknown')};
</code>
</pre>
)}
<AsideSection title="Source">
<div className="text-xs text-fd-muted-foreground">
<div>File: <code className="bg-fd-muted px-1 py-0.5 rounded">{member.sourcePath}</code></div>
<div>Line: <code className="bg-fd-muted px-1 py-0.5 rounded">{member.line}</code></div>
</div>
</AsideSection>
</MethodAside>
</MethodLayout>
</CollapsibleTypesSection>
);
}
export function TypeDocumentation({ typeInfo, platform = 'react-like' }: TypeDocumentationProps) {
return (
<div className="space-y-6">
{/* Type Header */}
<div className="border-b border-fd-border pb-4">
<h1 className="text-2xl font-bold text-fd-foreground mb-2">
{typeInfo.name}
</h1>
{typeInfo.description && (
<p className="text-fd-muted-foreground mb-4">
{typeInfo.description}
</p>
)}
<div className="flex items-center gap-4 text-sm text-fd-muted-foreground">
<div>
<span className="font-medium">Source:</span>{' '}
<code className="bg-fd-muted px-1.5 py-0.5 rounded">{typeInfo.sourcePath}</code>
</div>
<div>
<span className="font-medium">Line:</span>{' '}
<code className="bg-fd-muted px-1.5 py-0.5 rounded">{typeInfo.line}</code>
</div>
</div>
</div>
{/* Type Definition */}
<div className="space-y-4">
<h2 className="text-lg font-semibold text-fd-foreground">Type Definition</h2>
<div className="bg-fd-code p-4 rounded-lg border border-fd-border overflow-x-auto">
<pre className="text-sm">
<code className="text-fd-foreground">
{typeInfo.definition}
</code>
</pre>
</div>
</div>
{/* Mixins */}
{typeInfo.mixins && typeInfo.mixins.length > 0 && (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-fd-foreground">Extends</h2>
<div className="space-y-2">
{typeInfo.mixins.map((mixin, index) => (
<div key={index} className="bg-fd-muted/50 p-3 rounded border">
<code className="text-sm text-fd-foreground">{mixin}</code>
</div>
))}
</div>
</div>
)}
{/* Table of Contents */}
{typeInfo.members.length > 0 && (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-fd-foreground">Table of Contents</h2>
<ClickableTableOfContents
code={generateTableOfContents(typeInfo, platform)}
platform={platform}
/>
</div>
)}
{/* Members Documentation */}
{typeInfo.members.length > 0 && (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-fd-foreground">Members</h2>
<div className="space-y-2">
{typeInfo.members.map(member =>
renderMemberDocumentation(typeInfo, member, platform)
)}
</div>
</div>
)}
</div>
);
}
// Component to load and display a specific type from types.json
export function TypeFromJson({ typeName, platform = 'react-like' }: { typeName: string, platform?: string }) {
const [typeInfo, setTypeInfo] = React.useState<TypeInfo | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
async function loadTypeInfo() {
try {
setLoading(true);
setError(null);
// Load the types.json file
const response = await fetch('/sdk-docs/types.json');
if (!response.ok) {
throw new Error(`Failed to load types.json: ${response.statusText}`);
}
const typesData = await response.json();
const foundType = typesData[typeName];
if (!foundType) {
throw new Error(`Type "${typeName}" not found in types.json`);
}
setTypeInfo(foundType);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
}
runAsynchronously(loadTypeInfo());
}, [typeName]);
if (loading) {
return (
<div className="flex items-center justify-center py-8">
<div className="text-fd-muted-foreground">Loading type documentation...</div>
</div>
);
}
if (error) {
return (
<div className="bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800/50 rounded-lg p-4">
<div className="text-red-800 dark:text-red-200 font-medium mb-1">Error Loading Type</div>
<div className="text-red-700 dark:text-red-300 text-sm">{error}</div>
</div>
);
}
if (!typeInfo) {
return (
<div className="text-fd-muted-foreground text-center py-8">
Type not found.
</div>
);
}
return <TypeDocumentation typeInfo={typeInfo} platform={platform} />;
}

View File

@ -45,6 +45,14 @@ export const sdkSections = [
{ name: "SendEmailOptions", href: "types/email#sendemailoptions", icon: "type" },
]
},
{
title: "Payments & Items",
items: [
{ name: "Customer", href: "types/customer#customer", icon: "type" },
{ name: "Item", href: "types/item#item", icon: "type" },
{ name: "ServerItem", href: "types/item#serveritem", icon: "type" },
]
},
{
title: "Hooks",
items: [

View File

@ -18,6 +18,8 @@
"types/api-key",
"types/project",
"types/connected-account",
"types/item",
"types/customer",
"---Hooks---",
"hooks/use-stack-app",
"hooks/use-user"

255
docs/templates/sdk/types/customer.mdx vendored Normal file
View File

@ -0,0 +1,255 @@
---
title: Customer
full: true
---
The `Customer` interface provides payment and item management functionality that is shared between users and teams. Both [`CurrentUser`](../types/user.mdx#currentuser) and [`Team`](../types/team.mdx#team) types extend this interface, allowing them to create checkout URLs and manage items.
On this page:
- [Customer](#customer)
---
# `Customer`
The `Customer` interface defines the payment-related functionality available to both users and teams. It provides methods for creating checkout URLs for purchases and managing quantifiable items like credits, API calls, or subscription allowances.
This interface is automatically available on:
- [`CurrentUser`](../types/user.mdx#currentuser) objects
- [`Team`](../types/team.mdx#team) objects
- [`ServerUser`](../types/user.mdx#serveruser) objects (with additional server-side capabilities)
- [`ServerTeam`](../types/team.mdx#serverteam) objects (with additional server-side capabilities)
### Table of Contents
<ClickableTableOfContents code={`interface Customer {
readonly id: string; //$stack-link-to:#customerid
createCheckoutUrl(options): Promise<string>; //$stack-link-to:#customercreatecheckouturl
getItem(itemId): Promise<Item>; //$stack-link-to:#customergetitem
// NEXT_LINE_PLATFORM react-like
⤷ useItem(itemId): Item; //$stack-link-to:#customeruseitem
};`} />
<CollapsibleTypesSection type="customer" property="id" defaultOpen={false}>
<MethodLayout>
<MethodContent>
The unique identifier for the customer. For users, this is the user ID; for teams, this is the team ID.
</MethodContent>
<MethodAside title="Type Definition">
```typescript
declare const id: string;
```
</MethodAside>
</MethodLayout>
</CollapsibleTypesSection>
<CollapsibleTypesSection type="customer" property="createCheckoutUrl" signature="options" defaultOpen={false}>
<MethodLayout>
<MethodContent>
Creates a secure checkout URL for purchasing a product. This method integrates with Stripe to generate a payment link that handles the entire purchase flow.
The checkout URL will redirect users to a Stripe-hosted payment page where they can complete their purchase. After successful payment, users will be redirected back to your application.
### Parameters
<ParamField path="options" type="object" required>
Options for creating the checkout URL.
<Accordion title="Show Properties">
<ParamField path="productId" type="string" required>
The ID of the product to purchase, as configured in your Stack Auth project settings.
</ParamField>
</Accordion>
</ParamField>
### Returns
`Promise<string>`: A secure URL that redirects to the Stripe checkout page for the specified product.
</MethodContent>
<MethodAside>
<AsideSection title="Signature">
```typescript
declare function createCheckoutUrl(options: {
productId: string;
}): Promise<string>;
```
</AsideSection>
<AsideSection title="Examples">
```typescript User purchasing a subscription
const user = useUser({ or: "redirect" });
const handleUpgrade = async () => {
try {
const checkoutUrl = await user.createCheckoutUrl({
productId: "prod_premium_monthly",
});
// Redirect to Stripe checkout
window.location.href = checkoutUrl;
} catch (error) {
console.error("Failed to create checkout URL:", error);
}
};
```
```typescript Team purchasing additional seats
const team = await user.getTeam("team_123");
const purchaseSeats = async () => {
const checkoutUrl = await team.createCheckoutUrl({
productId: "prod_additional_seats",
});
// Open checkout in new tab
window.open(checkoutUrl, '_blank');
};
```
</AsideSection>
</MethodAside>
</MethodLayout>
</CollapsibleTypesSection>
<CollapsibleTypesSection type="customer" property="getItem" signature="itemId" defaultOpen={false}>
<MethodLayout>
<MethodContent>
Retrieves information about a specific item associated with this customer. Items represent quantifiable resources such as credits, API calls, storage quotas, or subscription allowances.
### Parameters
<ParamField path="itemId" type="string" required>
The ID of the item to retrieve, as configured in your Stack Auth project settings.
</ParamField>
### Returns
`Promise<Item>`: An [`Item`](../types/item.mdx#item) object containing the display name, current quantity, and other details.
</MethodContent>
<MethodAside>
<AsideSection title="Signature">
```typescript
declare function getItem(itemId: string): Promise<Item>;
```
</AsideSection>
<AsideSection title="Examples">
```typescript Checking user credits
const user = useUser({ or: "redirect" });
const checkCredits = async () => {
const credits = await user.getItem("credits");
console.log(`Available credits: ${credits.nonNegativeQuantity}`);
console.log(`Actual balance: ${credits.quantity}`);
};
```
```typescript Checking team API quota
const team = await user.getTeam("team_123");
const apiQuota = await team.getItem("api_calls");
if (apiQuota.nonNegativeQuantity < 100) {
console.warn("Team is running low on API calls");
}
```
</AsideSection>
</MethodAside>
</MethodLayout>
</CollapsibleTypesSection>
{/* IF_PLATFORM next */}
<CollapsibleTypesSection type="customer" property="useItem" signature="itemId" defaultOpen={false}>
<MethodLayout>
<MethodContent>
Retrieves information about a specific item associated with this customer, used as a React hook. This provides real-time updates when the item quantity changes.
### Parameters
<ParamField path="itemId" type="string" required>
The ID of the item to retrieve.
</ParamField>
### Returns
`Item`: An [`Item`](../types/item.mdx#item) object containing the display name, current quantity, and other details.
</MethodContent>
<MethodAside>
<AsideSection title="Signature">
```typescript
declare function useItem(itemId: string): Item;
```
</AsideSection>
<AsideSection title="Examples">
```typescript Real-time credits display
function CreditsWidget() {
const user = useUser({ or: "redirect" });
const credits = user.useItem("credits");
return (
<div className="credits-widget">
<h3>Available Credits</h3>
<div className="credits-count">
{credits.nonNegativeQuantity}
</div>
<small>{credits.displayName}</small>
</div>
);
}
```
```typescript Team quota monitoring
function TeamQuotaStatus({ teamId }: { teamId: string }) {
const user = useUser({ or: "redirect" });
const team = user.useTeam(teamId);
const apiCalls = team.useItem("api_calls");
const usagePercentage = (apiCalls.quantity / 10000) * 100;
return (
<div className="quota-status">
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${Math.min(usagePercentage, 100)}%` }}
/>
</div>
<p>
{apiCalls.quantity.toLocaleString()} / 10,000 API calls used
</p>
</div>
);
}
```
</AsideSection>
</MethodAside>
</MethodLayout>
</CollapsibleTypesSection>
{/* END_PLATFORM */}
## Usage Notes
### Payment Flow
When using `createCheckoutUrl()`, the typical flow is:
1. **Create checkout URL**: Call `createCheckoutUrl()` with the desired product ID
2. **Redirect to Stripe**: Direct the user to the returned URL
3. **User completes payment**: Stripe handles the payment process
4. **Webhook processing**: Stack Auth receives webhook notifications from Stripe
5. **Item allocation**: Purchased items are automatically added to the customer's account
6. **User returns**: User is redirected back to your application
### Item Management
Items are automatically managed through the payment system:
- **Purchases**: When a user completes a purchase, associated items are automatically added
- **Subscriptions**: Recurring subscriptions automatically replenish items at the specified intervals
- **Manual allocation**: Server-side code can manually adjust item quantities using [`ServerItem`](../types/item.mdx#serveritem) methods
### Security Considerations
- **Client-side safety**: All payment operations are designed to be safe for client-side use
- **Server validation**: Critical operations should always be validated on the server side
- **Race conditions**: Use [`tryDecreaseQuantity()`](../types/item.mdx#serveritemtrydecreasequantity) for atomic, race-condition-free item consumption

236
docs/templates/sdk/types/item.mdx vendored Normal file
View File

@ -0,0 +1,236 @@
---
title: Item
full: true
---
Items represent quantifiable resources in your application, such as credits, API calls, storage quotas, or subscription allowances. They can be associated with users, teams, or custom customers and are managed through Stack Auth's payment system.
On this page:
- [Item](#item)
- [ServerItem](#serveritem)
---
# `Item`
The `Item` type represents a quantifiable resource that can be consumed or managed within your application. Items are typically obtained through purchases, subscriptions, or manual allocation.
Items can be retrieved through:
- [`user.getItem()`](../types/user.mdx#currentusergetitem)
- [`user.useItem()`](../types/user.mdx#currentuseruseitem) (React hook)
- [`team.getItem()`](../types/team.mdx#teamgetitem)
- [`team.useItem()`](../types/team.mdx#teamuseitem) (React hook)
### Table of Contents
<ClickableTableOfContents code={`type Item = {
displayName: string; //$stack-link-to:#itemdisplayname
quantity: number; //$stack-link-to:#itemquantity
nonNegativeQuantity: number; //$stack-link-to:#itemnonnegativequantity
};`} />
<CollapsibleTypesSection type="item" property="displayName" defaultOpen={false}>
<MethodLayout>
<MethodContent>
The human-readable name of the item as configured in your Stack Auth project settings.
</MethodContent>
<MethodAside title="Type Definition">
```typescript
declare const displayName: string;
```
</MethodAside>
</MethodLayout>
</CollapsibleTypesSection>
<CollapsibleTypesSection type="item" property="quantity" defaultOpen={false}>
<MethodLayout>
<MethodContent>
The current quantity of the item. This value can be negative, which is useful for tracking overdrafts or pending charges.
For example, if a user has 100 credits but makes a purchase that costs 150 credits, the quantity might temporarily be -50 until the purchase is processed.
</MethodContent>
<MethodAside title="Type Definition">
```typescript
declare const quantity: number;
```
</MethodAside>
</MethodLayout>
</CollapsibleTypesSection>
<CollapsibleTypesSection type="item" property="nonNegativeQuantity" defaultOpen={false}>
<MethodLayout>
<MethodContent>
The quantity clamped to a minimum of 0. This is equivalent to `Math.max(0, quantity)` and is useful for display purposes when you don't want to show negative values to users.
Use this when you want to display available resources without confusing users with negative numbers.
</MethodContent>
<MethodAside title="Type Definition">
```typescript
declare const nonNegativeQuantity: number;
```
</MethodAside>
</MethodLayout>
</CollapsibleTypesSection>
---
<div className="mt-16"></div>
# `ServerItem`
The `ServerItem` type extends `Item` with additional server-side methods for modifying quantities. This type is only available in server-side contexts and provides race-condition-safe operations for managing item quantities.
Server items can be retrieved through:
- [`serverUser.getItem()`](../types/user.mdx#serverusergetitem)
- [`serverUser.useItem()`](../types/user.mdx#serveruseruseitem) (React hook)
- [`serverTeam.getItem()`](../types/team.mdx#serverteamgetitem)
- [`serverTeam.useItem()`](../types/team.mdx#serverteamuseitem) (React hook)
### Table of Contents
<ClickableTableOfContents code={`type ServerItem =
// Inherits all functionality from Item
& Item //$stack-link-to:#item
& {
increaseQuantity(amount): Promise<void>; //$stack-link-to:#serveritemincreasequantity
decreaseQuantity(amount): Promise<void>; //$stack-link-to:#serveritemdecreasequantity
tryDecreaseQuantity(amount): Promise<boolean>; //$stack-link-to:#serveritemtrydecreasequantity
};`} />
<CollapsibleTypesSection type="serverItem" property="increaseQuantity" signature="amount" defaultOpen={false}>
<MethodLayout>
<MethodContent>
Increases the item quantity by the specified amount. This operation is atomic and safe for concurrent use.
### Parameters
<ParamField path="amount" type="number" required>
The amount to increase the quantity by. Must be a positive number.
</ParamField>
### Returns
`Promise<void>`
</MethodContent>
<MethodAside>
<AsideSection title="Signature">
```typescript
declare function increaseQuantity(amount: number): Promise<void>;
```
</AsideSection>
<AsideSection title="Examples">
```typescript Adding credits to a user
const user = await stackServerApp.getUser({ userId: "user_123" });
const credits = await user.getItem("credits");
// Add 100 credits
await credits.increaseQuantity(100);
```
</AsideSection>
</MethodAside>
</MethodLayout>
</CollapsibleTypesSection>
<CollapsibleTypesSection type="serverItem" property="decreaseQuantity" signature="amount" defaultOpen={false}>
<MethodLayout>
<MethodContent>
Decreases the item quantity by the specified amount. This operation allows the quantity to go negative.
**Note**: If you want to prevent the quantity from going below zero, use [`tryDecreaseQuantity()`](#serveritemtrydecreasequantity) instead, as it provides race-condition-free protection against negative quantities.
### Parameters
<ParamField path="amount" type="number" required>
The amount to decrease the quantity by. Must be a positive number.
</ParamField>
### Returns
`Promise<void>`
</MethodContent>
<MethodAside>
<AsideSection title="Signature">
```typescript
declare function decreaseQuantity(amount: number): Promise<void>;
```
</AsideSection>
<AsideSection title="Examples">
```typescript Consuming user credits
const user = await stackServerApp.getUser({ userId: "user_123" });
const credits = await user.getItem("credits");
// Consume 50 credits (allows negative balance)
await credits.decreaseQuantity(50);
```
</AsideSection>
</MethodAside>
</MethodLayout>
</CollapsibleTypesSection>
<CollapsibleTypesSection type="serverItem" property="tryDecreaseQuantity" signature="amount" defaultOpen={false}>
<MethodLayout>
<MethodContent>
Attempts to decrease the item quantity by the specified amount, but only if the result would be non-negative. Returns `true` if the operation succeeded, `false` if it would result in a negative quantity.
This method is race-condition-safe and is ideal for implementing prepaid credit systems where you need to ensure sufficient balance before allowing an operation.
### Parameters
<ParamField path="amount" type="number" required>
The amount to decrease the quantity by. Must be a positive number.
</ParamField>
### Returns
`Promise<boolean>`: `true` if the quantity was successfully decreased, `false` if the operation would result in a negative quantity.
</MethodContent>
<MethodAside>
<AsideSection title="Signature">
```typescript
declare function tryDecreaseQuantity(amount: number): Promise<boolean>;
```
</AsideSection>
<AsideSection title="Examples">
```typescript Safe credit consumption
const user = await stackServerApp.getUser({ userId: "user_123" });
const credits = await user.getItem("credits");
// Try to consume 50 credits, only if sufficient balance
const success = await credits.tryDecreaseQuantity(50);
if (success) {
console.log("Credits consumed successfully");
// Proceed with the operation
} else {
console.log("Insufficient credits");
// Handle insufficient balance
throw new Error("Not enough credits available");
}
```
```typescript API rate limiting with credits
async function handleApiCall(userId: string) {
const user = await stackServerApp.getUser({ userId });
const apiCalls = await user.getItem("api_calls");
// Check if user has API calls remaining
const canProceed = await apiCalls.tryDecreaseQuantity(1);
if (!canProceed) {
throw new Error("API rate limit exceeded. Please upgrade your plan.");
}
// Process the API call
return processApiRequest();
}
```
</AsideSection>
</MethodAside>
</MethodLayout>
</CollapsibleTypesSection>

View File

@ -28,8 +28,8 @@ You can get `Team` objects with the
clientMetadata: Json; //$stack-link-to:#teamclientmetadata
clientReadOnlyMetadata: Json; //$stack-link-to:#teamclientreadonlymetadata
update(data): Promise<void>; //$stack-link-to:#teamupdatedata
inviteUser(options): Promise<void>; //$stack-link-to:#teaminviteuseroptions
update(data): Promise<void>; //$stack-link-to:#teamupdate
inviteUser(options): Promise<void>; //$stack-link-to:#teaminviteuser
listUsers(): Promise<TeamUser[]>; //$stack-link-to:#teamlistusers
// NEXT_LINE_PLATFORM react-like
⤷ useUsers(): TeamUser[]; //$stack-link-to:#teamuseusers
@ -37,10 +37,15 @@ You can get `Team` objects with the
// NEXT_LINE_PLATFORM react-like
⤷ useInvitations(): { ... }[]; //$stack-link-to:#teamuseinvitations
createApiKey(options): Promise<TeamApiKeyFirstView>; //$stack-link-to:#teamcreateapikeyoptions
createApiKey(options): Promise<TeamApiKeyFirstView>; //$stack-link-to:#teamcreateapikey
listApiKeys(): Promise<TeamApiKey[]>; //$stack-link-to:#teamlistapikeys
// NEXT_LINE_PLATFORM react-like
⤷ useApiKeys(): TeamApiKey[]; //$stack-link-to:#teamuseapikeys
createCheckoutUrl(options): Promise<string>; //$stack-link-to:#teamcreatecheckouturl
getItem(itemId): Promise<Item>; //$stack-link-to:#teamgetitem
// NEXT_LINE_PLATFORM react-like
⤷ useItem(itemId): Item; //$stack-link-to:#teamuseitem
};`} />
<CollapsibleTypesSection type="team" property="id" defaultOpen={false}>
@ -476,6 +481,130 @@ You can get `Team` objects with the
</CollapsibleTypesSection>
{/* END_PLATFORM */}
<CollapsibleTypesSection type="team" property="createCheckoutUrl" signature="options" defaultOpen={false}>
<MethodLayout>
<MethodContent>
Creates a checkout URL for the team to purchase products. This method integrates with Stripe to generate a secure payment link for team-level purchases.
Note that this operation requires the current user to have appropriate permissions for team purchases. The specific permission requirements depend on your project configuration.
### Parameters
<ParamField path="options" type="object" required>
Options for creating the checkout URL.
<Accordion title="Show Properties">
<ParamField path="productId" type="string" required>
The ID of the product to purchase.
</ParamField>
</Accordion>
</ParamField>
### Returns
`Promise<string>`: A URL that redirects to the Stripe checkout page for the specified product.
</MethodContent>
<MethodAside>
<AsideSection title="Signature">
```typescript
declare function createCheckoutUrl(options: {
productId: string;
}): Promise<string>;
```
</AsideSection>
<AsideSection title="Examples">
```typescript Team purchasing additional seats
const checkoutUrl = await team.createCheckoutUrl({
productId: "prod_team_seats",
});
// Redirect to checkout
window.location.href = checkoutUrl;
```
</AsideSection>
</MethodAside>
</MethodLayout>
</CollapsibleTypesSection>
<CollapsibleTypesSection type="team" property="getItem" signature="itemId" defaultOpen={false}>
<MethodLayout>
<MethodContent>
Retrieves information about a specific item (such as credits, API quotas, storage limits, etc.) for the team.
### Parameters
<ParamField path="itemId" type="string" required>
The ID of the item to retrieve.
</ParamField>
### Returns
`Promise<Item>`: The item object containing display name, quantity, and other details.
</MethodContent>
<MethodAside>
<AsideSection title="Signature">
```typescript
declare function getItem(itemId: string): Promise<Item>;
```
</AsideSection>
<AsideSection title="Examples">
```typescript Checking team API quota
const apiQuota = await team.getItem("api_calls");
console.log(`Team has ${apiQuota.quantity} API calls remaining`);
if (apiQuota.nonNegativeQuantity < 100) {
console.warn("Team is running low on API calls");
}
```
</AsideSection>
</MethodAside>
</MethodLayout>
</CollapsibleTypesSection>
{/* IF_PLATFORM next */}
<CollapsibleTypesSection type="team" property="useItem" signature="itemId" defaultOpen={false}>
<MethodLayout>
<MethodContent>
Retrieves information about a specific item for the team, used as a React hook.
### Parameters
<ParamField path="itemId" type="string" required>
The ID of the item to retrieve.
</ParamField>
### Returns
`Item`: The item object containing display name, quantity, and other details.
</MethodContent>
<MethodAside>
<AsideSection title="Signature">
```typescript
declare function useItem(itemId: string): Item;
```
</AsideSection>
<AsideSection title="Examples">
```typescript Team quota monitoring component
function TeamQuotaDisplay({ team }: { team: Team }) {
const storage = team.useItem("storage_gb");
return (
<div>
<h3>Team Storage Usage</h3>
<p>{storage.quantity} GB used</p>
<p>Plan: {storage.displayName}</p>
</div>
);
}
```
</AsideSection>
</MethodAside>
</MethodLayout>
</CollapsibleTypesSection>
{/* END_PLATFORM */}
---
# `ServerTeam`

View File

@ -64,6 +64,11 @@ You can call `useUser()` or `stackServerApp.getUser()` to get the `CurrentUser`
listApiKeys(): Promise<UserApiKey[]>; //$stack-link-to:#currentuserlistapikeys
// NEXT_LINE_PLATFORM react-like
⤷ useApiKeys(): UserApiKey[]; //$stack-link-to:#currentuseruseapikeys
createCheckoutUrl(options): Promise<string>; //$stack-link-to:#currentusercreatecheckouturl
getItem(itemId): Promise<Item>; //$stack-link-to:#currentusergetitem
// NEXT_LINE_PLATFORM react-like
⤷ useItem(itemId): Item; //$stack-link-to:#currentuseruseitem
};`} />
<CollapsibleTypesSection type="currentUser" property="id" defaultOpen={false}>
@ -1560,6 +1565,125 @@ The `ServerUser` object contains most `CurrentUser` properties and methods with
</CollapsibleTypesSection>
{/* END_PLATFORM */}
<CollapsibleTypesSection type="currentUser" property="createCheckoutUrl" signature="options" defaultOpen={false}>
<MethodLayout>
<MethodContent>
Creates a checkout URL for purchasing a product. This method integrates with Stripe to generate a secure payment link.
### Parameters
<ParamField path="options" type="object" required>
Options for creating the checkout URL.
<Accordion title="Show Properties">
<ParamField path="productId" type="string" required>
The ID of the product to purchase.
</ParamField>
</Accordion>
</ParamField>
### Returns
`Promise<string>`: A URL that redirects to the Stripe checkout page for the specified product.
</MethodContent>
<MethodAside>
<AsideSection title="Signature">
```typescript
declare function createCheckoutUrl(options: {
productId: string;
}): Promise<string>;
```
</AsideSection>
<AsideSection title="Examples">
```typescript Creating a checkout URL
const checkoutUrl = await user.createCheckoutUrl({
productId: "prod_premium_plan",
});
// Redirect user to checkout
window.location.href = checkoutUrl;
```
</AsideSection>
</MethodAside>
</MethodLayout>
</CollapsibleTypesSection>
<CollapsibleTypesSection type="currentUser" property="getItem" signature="itemId" defaultOpen={false}>
<MethodLayout>
<MethodContent>
Retrieves information about a specific item (such as credits, subscription quantities, etc.) for the user.
### Parameters
<ParamField path="itemId" type="string" required>
The ID of the item to retrieve.
</ParamField>
### Returns
`Promise<Item>`: The item object containing display name, quantity, and other details.
</MethodContent>
<MethodAside>
<AsideSection title="Signature">
```typescript
declare function getItem(itemId: string): Promise<Item>;
```
</AsideSection>
<AsideSection title="Examples">
```typescript Getting user credits
const credits = await user.getItem("credits");
console.log(`User has ${credits.quantity} credits`);
console.log(`Non-negative quantity: ${credits.nonNegativeQuantity}`);
```
</AsideSection>
</MethodAside>
</MethodLayout>
</CollapsibleTypesSection>
{/* IF_PLATFORM next */}
<CollapsibleTypesSection type="currentUser" property="useItem" signature="itemId" defaultOpen={false}>
<MethodLayout>
<MethodContent>
Retrieves information about a specific item for the user, used as a React hook.
### Parameters
<ParamField path="itemId" type="string" required>
The ID of the item to retrieve.
</ParamField>
### Returns
`Item`: The item object containing display name, quantity, and other details.
</MethodContent>
<MethodAside>
<AsideSection title="Signature">
```typescript
declare function useItem(itemId: string): Item;
```
</AsideSection>
<AsideSection title="Examples">
```typescript Using credits in a React component
function CreditsDisplay() {
const user = useUser();
const credits = user.useItem("credits");
return (
<div>
<h3>Available Credits: {credits.quantity}</h3>
<p>Display Name: {credits.displayName}</p>
</div>
);
}
```
</AsideSection>
</MethodAside>
</MethodLayout>
</CollapsibleTypesSection>
{/* END_PLATFORM */}
---
<div className="mt-16"></div>