[Fix]: Payments App Sundry Fixes (#1455)

### Summary of Changes
You can now edit items on a product view.
The "Make free" button is less obtuse, and it clearly tells you what
it's going to do.
Additionally, we found out while working on this PR that you cannot
create a `paymentIntent` on stripe that is < 0.5$. So, you can't create
an OTP for a "free" product. We add safeguards to protect against that.
Also, 0 dollar subscriptions don't create a subscription invoice.
Additionally, the old code relied on being able to fetch the stripe
client secret, which would be null for a 0 dollar subscription so we
create a carve out.



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Better free-product checkout handling: $0 subscriptions return an
empty success response without a payment client secret; non-free
subscriptions include client secret when needed.
* UI: “Make free” flow, “Free · {amount}” with price ID, per-price
checkout error indicators/tooltips, and an alert for products with
invalid prices.
  * Client- and server-side Stripe one-time minimum checks.

* **Bug Fixes**
* Included-item dialog now resets form state when opened to avoid stale
values.

* **Documentation**
* OpenAPI: clarified client_secret may be omitted when no customer
confirmation is required.

* **Tests**
  * Added end-to-end tests covering $0 purchase-session flows.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/hexclave/stack-auth/pull/1455?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Aman Ganapathy 2026-05-20 19:33:14 -07:00 committed by GitHub
parent 01aacd2dd4
commit 0e85b05c3d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 554 additions and 96 deletions

View File

@ -8,8 +8,9 @@ import { getTenancy } from "@/lib/tenancies";
import { getPrismaClientForTenancy } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { KnownErrors } from "@stackframe/stack-shared";
import { getStripeOneTimeMinAmount } from "@stackframe/stack-shared/dist/payments/stripe-limits";
import { yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { StackAssertionError, StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { purchaseUrlVerificationCodeHandler } from "../verification-code-handler";
export const POST = createSmartRouteHandler({
@ -45,11 +46,11 @@ export const POST = createSmartRouteHandler({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
client_secret: yupString().defined().meta({
client_secret: yupString().optional().meta({
openapiField: {
description: "The Stripe client secret for completing the payment",
exampleValue: "1234567890abcdef_secret_xyz123"
}
description: "Stripe client secret used by the browser to confirm payment via Stripe Elements. Omitted when no payment step is required from the customer; in that case the purchase is being settled without a confirmation step and the caller should skip mounting Stripe Elements.",
exampleValue: "1234567890abcdef_secret_xyz123",
},
}),
}),
}),
@ -79,6 +80,30 @@ export const POST = createSmartRouteHandler({
throw new StackAssertionError("Price not resolved for purchase session");
}
// Validate the price amount up-front so a malformed config can't slip past
// the Stripe-minimum guards below and produce a raw Stripe error at
// PaymentIntent/Subscription.create time.
const priceAmount = Number(selectedPrice.USD);
if (!Number.isFinite(priceAmount) || priceAmount < 0) {
throw new StatusError(400, `Price amount must be a finite, non-negative number (got ${JSON.stringify(selectedPrice.USD)})`);
}
// TODO(default-plans): when default/free plans become first-class, route
// these directly via an ensureDefaultPlan-style grant instead of forcing
// callers to configure an interval just to make Stripe happy.
const isFreePrice = priceAmount === 0;
if (isFreePrice && !selectedPrice.interval) {
throw new StatusError(400, "Free products must have a billing interval");
}
// Mirror Stripe's per-currency one-time minimum (shared with the dashboard
// UI via stack-shared/payments/stripe-limits so the two can't drift apart)
// and return a clean 400 instead of a raw Stripe error at
// PaymentIntent.create time. Recurring sub items don't have this minimum
// (handled above for the $0 case).
const stripeOneTimeMin = getStripeOneTimeMinAmount('USD');
if (!selectedPrice.interval && priceAmount > 0 && priceAmount < stripeOneTimeMin) {
throw new StatusError(400, `One-time prices must be at least $${stripeOneTimeMin.toFixed(2)} (Stripe minimum)`);
}
const productVersionId = await upsertProductVersion({
prisma,
tenancyId: tenancy.id,
@ -94,6 +119,11 @@ export const POST = createSmartRouteHandler({
const product = await stripe.products.create({ name: data.product.displayName ?? "Subscription" });
if (selectedPrice.interval) {
const applicationFeePercent = getApplicationFeePercentOrUndefined(tenancy.project.id);
// TODO(default-plans): $0 subs currently piggyback on the Stripe
// subscription lifecycle. Once default plans land, free subs should be
// granted directly (Prisma insert + bulldozer write, mirroring
// ensureFreePlanForBillingTeam) and skip Stripe entirely.
//
const updated = await stripe.subscriptions.update(conflicting.stripeSubscriptionId, {
payment_behavior: 'default_incomplete',
payment_settings: { save_default_payment_method: 'on_subscription' },
@ -118,11 +148,24 @@ export const POST = createSmartRouteHandler({
},
...(applicationFeePercent !== undefined ? { application_fee_percent: applicationFeePercent } : {}),
});
if (isFreePrice) {
// Stripe activates $0 subs synchronously (status=active, invoice=paid)
// and produces no PaymentIntent / confirmation_secret, so we have
// nothing to hand to Stripe Elements. The DB row is written when
// the `invoice.paid` webhook lands, exactly like paid purchases
// after card confirmation.
await purchaseUrlVerificationCodeHandler.revokeCode({ tenancy, id: codeId });
return { statusCode: 200, bodyType: "json", body: {} };
}
// Extract the client secret BEFORE revoking the code: if Stripe
// returns a malformed sub (no secret), we throw 500 here and the
// customer can retry with the same code. Revoking first would burn
// the code on every transient Stripe anomaly.
const clientSecretUpdated = getClientSecretFromStripeSubscription(updated);
await purchaseUrlVerificationCodeHandler.revokeCode({ tenancy, id: codeId });
if (typeof clientSecretUpdated !== "string") {
throwErr(500, "No client secret returned from Stripe for subscription");
}
await purchaseUrlVerificationCodeHandler.revokeCode({ tenancy, id: codeId });
return { statusCode: 200, bodyType: "json", body: { client_secret: clientSecretUpdated } };
} else {
await stripe.subscriptions.cancel(conflicting.stripeSubscriptionId);
@ -181,6 +224,14 @@ export const POST = createSmartRouteHandler({
name: data.product.displayName ?? "Subscription",
});
const applicationFeePercent = getApplicationFeePercentOrUndefined(tenancy.project.id);
// TODO(default-plans): $0 subs currently piggyback on the Stripe
// subscription lifecycle. Once default plans land, free subs should be
// granted directly (Prisma insert + bulldozer write, mirroring
// ensureFreePlanForBillingTeam) and skip Stripe entirely.
//
// Note on $0 subs: Stripe auto-activates them on create (status="active",
// invoice="paid") regardless of `default_incomplete` so we keep the same
// call shape and only diverge in how we read the response below.
const created = await stripe.subscriptions.create({
customer: data.stripeCustomerId,
payment_behavior: 'default_incomplete',
@ -205,15 +256,28 @@ export const POST = createSmartRouteHandler({
},
...(applicationFeePercent !== undefined ? { application_fee_percent: applicationFeePercent } : {}),
});
if (isFreePrice) {
// Stripe activates $0 subs synchronously (status=active, invoice=paid)
// and produces no PaymentIntent / confirmation_secret, so we have
// nothing to hand to Stripe Elements. The DB row is written when the
// `invoice.paid` webhook lands, exactly like paid purchases after card
// confirmation.
await purchaseUrlVerificationCodeHandler.revokeCode({ tenancy, id: codeId });
return {
statusCode: 200,
bodyType: "json",
body: {},
};
}
// Extract the client secret BEFORE revoking the code: if Stripe returns a
// malformed sub (no secret), we throw 500 here and the customer can retry
// with the same code. Revoking first would burn the code on every
// transient Stripe anomaly.
const clientSecret = getClientSecretFromStripeSubscription(created);
if (typeof clientSecret !== "string") {
throwErr(500, "No client secret returned from Stripe for subscription");
}
await purchaseUrlVerificationCodeHandler.revokeCode({
tenancy,
id: codeId,
});
await purchaseUrlVerificationCodeHandler.revokeCode({ tenancy, id: codeId });
return {
statusCode: 200,
bodyType: "json",

View File

@ -379,7 +379,6 @@ function EditProductForm({ productId, existingProduct }: { productId: string, ex
hasError={!!errors.prices}
errorMessage={errors.prices}
variant="form"
isFree={isFreePrices(prices)}
onMakeFree={() => {
setPrices(createFreePrice());
}}

View File

@ -3,7 +3,7 @@
import { cn } from "@/lib/utils";
import { CompleteConfig } from "@stackframe/stack-shared/dist/config/schema";
import { Button, Checkbox, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, SimpleTooltip, Typography } from "@/components/ui";
import { useState } from "react";
import { useEffect, useState } from "react";
type Interval = [number, 'day' | 'week' | 'month' | 'year'] | 'never';
type ExpiresOption = 'never' | 'when-purchase-expires' | 'when-repeated';
@ -70,6 +70,30 @@ export function IncludedItemDialog({
const [expires, setExpires] = useState<ExpiresOption>(editingItem?.expires || 'never');
const [errors, setErrors] = useState<Record<string, string>>({});
// Sync internal state whenever the dialog opens or the user switches which
// item is being edited. We intentionally key off `open` + `editingItemId`
// (stable identities) and NOT `editingItem` itself: if the parent re-derives
// `editingItem` as a fresh object on each render, including it here would
// re-run this effect mid-edit and silently wipe whatever the user has typed.
// The latest `editingItem` is still read via the closure when this fires.
useEffect(() => {
if (!open) return;
setSelectedItemId(editingItemId || "");
setQuantity(editingItem?.quantity.toString() || "1");
const hasRepeatValue = editingItem?.repeat !== undefined && editingItem.repeat !== 'never';
setHasRepeat(hasRepeatValue);
if (editingItem?.repeat && editingItem.repeat !== 'never') {
setRepeatCount(editingItem.repeat[0].toString());
setRepeatUnit(editingItem.repeat[1]);
} else {
setRepeatCount("1");
setRepeatUnit("month");
}
setExpires(editingItem?.expires || 'never');
setErrors({});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, editingItemId]);
const validateAndSave = () => {
const newErrors: Record<string, string> = {};
@ -143,7 +167,10 @@ export function IncludedItemDialog({
{/* Item Selection */}
<div className="grid gap-2">
<Label htmlFor="item-select">
<SimpleTooltip tooltip="Choose which item to include with this product">
<SimpleTooltip
tooltip="Choose which item to include with this product"
disabled={!!editingItem}
>
Select Item
</SimpleTooltip>
</Label>

View File

@ -787,7 +787,6 @@ ${Object.entries(prices).map(([id, price]) => {
hasError={!!errors.prices}
errorMessage={errors.prices}
variant="form"
isFree={isFreePrices(prices)}
onMakeFree={() => {
setPrices(createFreePrice());
}}

View File

@ -17,6 +17,7 @@ import React, { ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { useAdminApp, useProjectId } from "../../use-admin-app";
import { ListSection } from "./list-section";
import { ProductDialog } from "./product-dialog";
import { getPriceCheckoutError } from "./utils";
type Product = CompleteConfig['payments']['products'][keyof CompleteConfig['payments']['products']];
type Item = CompleteConfig['payments']['items'][keyof CompleteConfig['payments']['items']];
@ -654,6 +655,73 @@ function ProductsWithoutPricesAlert({
);
}
// Surfaces products with prices that Stripe will reject at checkout time
// (e.g. $0 one-time, sub-$0.50 one-time). These typically slipped past pre-
// validation history and only fail when a customer actually tries to buy.
function ProductsWithInvalidPricesAlert({
products,
projectId,
}: {
products: CompleteConfig['payments']['products'],
projectId: string,
}) {
const productsWithInvalidPrices = useMemo(() => {
return typedEntries(products)
.flatMap(([id, product]) => {
const issues = typedEntries(product.prices)
.map(([priceId, price]) => ({ priceId, error: getPriceCheckoutError(price) }))
.filter((x): x is { priceId: string, error: string } => x.error !== null);
if (issues.length === 0) return [];
return [{ id, displayName: product.displayName || id, issues }];
})
.sort((a, b) => stringCompare(a.id, b.id));
}, [products]);
if (productsWithInvalidPrices.length === 0) return null;
const previewLimit = 5;
const preview = productsWithInvalidPrices.slice(0, previewLimit);
const overflow = productsWithInvalidPrices.length - preview.length;
return (
<Alert variant="destructive" className="mb-4">
<AlertTitle>
{productsWithInvalidPrices.length === 1
? "1 product has a price customers can't check out"
: `${productsWithInvalidPrices.length} products have prices customers can't check out`}
</AlertTitle>
<AlertDescription className="space-y-2">
<div>
Stripe rejects these prices at checkout (e.g. $0 one-time, or one-time
charges below $0.50). Open each product and either change the amount
or switch to a recurring interval.
</div>
<ul className="list-disc pl-5 space-y-0.5">
{preview.map(({ id, displayName, issues }) => (
<li key={id}>
<Link
href={`/projects/${projectId}/payments/products/${id}/edit`}
className="underline hover:no-underline"
>
{displayName}
</Link>
{displayName !== id && <span className="ml-1 font-mono text-xs opacity-70">({id})</span>}
<span className="ml-1 opacity-80">
{issues.length === 1 ? `price “${issues[0].priceId}` : `${issues.length} prices`}
</span>
</li>
))}
{overflow > 0 && (
<li className="opacity-80">
and {overflow} more
</li>
)}
</ul>
</AlertDescription>
</Alert>
);
}
export default function PageClient() {
const projectId = useProjectId();
const router = useRouter();
@ -966,6 +1034,7 @@ export default function PageClient() {
return (
<>
<ProductsWithoutPricesAlert products={paymentsConfig.products} projectId={projectId} />
<ProductsWithInvalidPricesAlert products={paymentsConfig.products} projectId={projectId} />
{innerContent}
{/* Product Dialog */}

View File

@ -26,7 +26,20 @@ import { ClockIcon, HardDriveIcon } from "@phosphor-icons/react";
import type { DayInterval } from "@stackframe/stack-shared/dist/utils/dates";
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
import { useState } from "react";
import { DEFAULT_INTERVAL_UNITS, PRICE_INTERVAL_UNITS, type Price } from "./utils";
import { DEFAULT_INTERVAL_UNITS, getPriceCheckoutError, PRICE_INTERVAL_UNITS, type Price } from "./utils";
/**
* Validates the form's editing state. Catches form-only issues here (empty
* input) and delegates the rest to `getPriceCheckoutError`, which is the same
* validator used to flag already-saved prices on the products page so the
* dialog and the warning banners can't disagree.
*/
function validateEditingPriceAmount(editing: EditingPrice): string | null {
if (editing.amount === '' || Number.isNaN(Number(editing.amount))) {
return "Enter a price";
}
return getPriceCheckoutError(editingPriceToPrice(editing));
}
export type EditingPrice = {
priceId: string,
@ -66,6 +79,8 @@ export function PriceEditDialog({
onOpenChange(false);
};
const amountError = editingPrice ? validateEditingPriceAmount(editingPrice) : null;
return (
<Dialog open={open} onOpenChange={(isOpen) => {
if (!isOpen) {
@ -121,6 +136,9 @@ export function PriceEditDialog({
onIntervalUnitChange={(v) => onEditingPriceChange({ ...editingPrice, priceInterval: v })}
allowedUnits={PRICE_INTERVAL_UNITS}
/>
{amountError && (
<p className="text-xs text-destructive">{amountError}</p>
)}
</div>
{/* Free Trial & Server Only as EditableGrid */}
@ -236,7 +254,10 @@ export function PriceEditDialog({
<Button variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button onClick={editingPrice ? () => runAsynchronouslyWithAlert(() => onSave(editingPrice, isAdding)) : undefined}>
<Button
disabled={!editingPrice || amountError !== null}
onClick={editingPrice && amountError === null ? () => runAsynchronouslyWithAlert(() => onSave(editingPrice, isAdding)) : undefined}
>
{isAdding ? "Add Price" : "Save Changes"}
</Button>
</DialogFooter>

View File

@ -1,8 +1,8 @@
"use client";
import { Button, Typography } from "@/components/ui";
import { Button, SimpleTooltip, Typography } from "@/components/ui";
import { cn } from "@/lib/utils";
import { GiftIcon, PlusIcon, TrashIcon } from "@phosphor-icons/react";
import { GiftIcon, PlusIcon, TrashIcon, WarningIcon } from "@phosphor-icons/react";
import { useState } from "react";
import {
createNewEditingPrice,
@ -11,7 +11,7 @@ import {
priceToEditingPrice,
type EditingPrice,
} from "./price-edit-dialog";
import { formatPriceDisplay, generateUniqueId, type Price } from "./utils";
import { formatPriceDisplay, generateUniqueId, getPriceCheckoutError, isFreePrices, type Price } from "./utils";
type PricingSectionProps = {
prices: Record<string, Price>,
@ -19,8 +19,10 @@ type PricingSectionProps = {
hasError?: boolean,
errorMessage?: string,
variant?: 'form' | 'dialog',
// Free product handling
isFree?: boolean,
// Optional "Make Free" handler. When provided, a button is rendered that
// replaces the current prices with a single $0 recurring entry. When the
// current `prices` already match isFreePrices(), the Free card is shown
// instead of the price list.
onMakeFree?: () => void,
};
@ -30,9 +32,9 @@ export function PricingSection({
hasError,
errorMessage,
variant = 'form',
isFree = false,
onMakeFree,
}: PricingSectionProps) {
const isFree = isFreePrices(prices);
const [editingPrice, setEditingPrice] = useState<EditingPrice | null>(null);
const [isAddingPrice, setIsAddingPrice] = useState(false);
@ -147,8 +149,12 @@ export function PricingSection({
}
// Form variant - compact card style
// Free product state - styled like a price card
// Free product state - styled like a price card, but surfaces the underlying
// $0 price entry so users can see that "Free" is just a regular price row
// (and isn't doing anything magical under the hood).
if (isFree) {
// isFreePrices() guarantees exactly one entry, so destructuring is safe.
const [freePriceId, freePrice] = Object.entries(prices)[0];
return (
<div
className={cn(
@ -158,7 +164,10 @@ export function PricingSection({
)}
>
<div className="flex-1">
<div className="font-medium text-sm">Free</div>
<div className="font-medium text-sm">
Free <span className="text-foreground/50 font-normal">· {formatPriceDisplay(freePrice)}</span>
</div>
<div className="text-xs text-foreground/30 font-mono">{freePriceId}</div>
</div>
<div className="flex items-center gap-1">
<Button
@ -192,13 +201,15 @@ export function PricingSection({
Add Price
</Button>
{onMakeFree && (
<Button
variant="outline"
onClick={onMakeFree}
>
<GiftIcon className="h-4 w-4 mr-2" />
Make Free
</Button>
<SimpleTooltip tooltip="Mark this product as free. Customers won't be charged, and no prices can be added.">
<Button
variant="outline"
onClick={onMakeFree}
>
<GiftIcon className="h-4 w-4 mr-2" />
Make Free
</Button>
</SimpleTooltip>
)}
</div>
{hasError && errorMessage && (
@ -209,37 +220,48 @@ export function PricingSection({
</div>
) : (
<div className="space-y-2">
{Object.entries(prices).map(([priceId, price]) => (
<div
key={priceId}
className={cn(
{Object.entries(prices).map(([priceId, price]) => {
const checkoutError = getPriceCheckoutError(price);
return (
<div
key={priceId}
className={cn(
"flex items-center justify-between p-2.5 rounded-lg",
"bg-foreground/[0.02] border border-border/30",
"hover:bg-foreground/[0.04] transition-colors duration-150 hover:transition-none"
"hover:bg-foreground/[0.04] transition-colors duration-150 hover:transition-none",
checkoutError && "border-destructive/40 bg-destructive/[0.03]"
)}
>
<div className="flex-1">
<div className="font-medium text-sm">{formatPriceDisplay(price)}</div>
<div className="text-xs text-foreground/30 font-mono">{priceId}</div>
>
<div className="flex-1">
<div className="font-medium text-sm flex items-center gap-1.5">
{formatPriceDisplay(price)}
{checkoutError && (
<SimpleTooltip tooltip={checkoutError}>
<WarningIcon className="h-4 w-4 text-destructive" weight="fill" />
</SimpleTooltip>
)}
</div>
<div className="text-xs text-foreground/30 font-mono">{priceId}</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleEditClick(priceId)}
>
Edit
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemovePrice(priceId)}
>
<TrashIcon className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleEditClick(priceId)}
>
Edit
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemovePrice(priceId)}
>
<TrashIcon className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
))}
);
})}
<div className="flex items-center gap-2">
<Button
variant="outline"
@ -250,13 +272,15 @@ export function PricingSection({
Add Price
</Button>
{onMakeFree && (
<Button
variant="outline"
onClick={onMakeFree}
>
<GiftIcon className="h-4 w-4 mr-2" />
Make Free
</Button>
<SimpleTooltip tooltip="Replace all configured prices with a single free tier. Customers won't be charged.">
<Button
variant="outline"
onClick={onMakeFree}
>
<GiftIcon className="h-4 w-4 mr-2" />
Make Free
</Button>
</SimpleTooltip>
)}
</div>
</div>

View File

@ -10,11 +10,11 @@ import {
SimpleTooltip
} from "@/components/ui";
import { cn } from "@/lib/utils";
import { InfoIcon, XIcon } from "@phosphor-icons/react";
import { InfoIcon, WarningIcon, XIcon } from "@phosphor-icons/react";
import type { DayInterval } from "@stackframe/stack-shared/dist/utils/dates";
import { useEffect, useState } from "react";
import { IntervalPopover } from "./components";
import { buildPriceUpdate, DEFAULT_INTERVAL_UNITS, freeTrialLabel, intervalLabel, PRICE_INTERVAL_UNITS, Product } from "./utils";
import { buildPriceUpdate, DEFAULT_INTERVAL_UNITS, freeTrialLabel, getPriceCheckoutError, intervalLabel, PRICE_INTERVAL_UNITS, Product } from "./utils";
/**
* Label with optional info tooltip
@ -116,13 +116,18 @@ export function ProductPriceRow({
onSave(undefined, updated);
};
const checkoutError = getPriceCheckoutError(price);
return (
<div
className={cn(
"relative rounded-2xl px-4 py-4",
isEditing
? "flex flex-col gap-4 border border-border/60 dark:border-foreground/[0.12] bg-background/60 dark:bg-[hsl(240,10%,7%)]"
: "items-center justify-center text-center"
: "items-center justify-center text-center",
checkoutError && (isEditing
? "border-destructive/40 bg-destructive/[0.03]"
: "ring-1 ring-destructive/30 bg-destructive/[0.02]")
)}
>
{isEditing ? (
@ -320,8 +325,13 @@ export function ProductPriceRow({
) : (
// View mode - minimal, centered display
<div className="flex flex-col items-center gap-0.5">
<div className="text-2xl font-semibold tabular-nums tracking-tight">
<div className="text-2xl font-semibold tabular-nums tracking-tight flex items-center gap-1.5">
{isFree ? 'Free' : `$${niceAmount}`}
{checkoutError && (
<SimpleTooltip tooltip={checkoutError}>
<WarningIcon className="h-4 w-4 text-destructive" weight="fill" />
</SimpleTooltip>
)}
</div>
{!isFree && (
<div className="text-xs text-muted-foreground capitalize">{intervalText ?? 'One-time'}</div>

View File

@ -1,4 +1,5 @@
import { CompleteConfig } from "@stackframe/stack-shared/dist/config/schema";
import { getStripeOneTimeMinAmount } from "@stackframe/stack-shared/dist/payments/stripe-limits";
import { isValidUserSpecifiedId, sanitizeUserSpecifiedId } from "@stackframe/stack-shared/dist/schema-fields";
import type { DayInterval } from "@stackframe/stack-shared/dist/utils/dates";
@ -107,13 +108,18 @@ export function buildPriceUpdate(params: {
}
/**
* Formats a price for display (e.g., "$9.99 / month (7 days free)")
* Formats a price for display (e.g., "$9.99 / month (7 days free)").
* Always disambiguates between recurring and one-time charges so a bare
* amount like "$0.00" or "$9.99" never appears (which would leave users
* guessing whether it's monthly, yearly, or a one-off).
*/
export function formatPriceDisplay(price: Price): string {
let display = `$${price.USD}`;
if (price.interval) {
const [count, unit] = price.interval;
display += count === 1 ? ` / ${unit}` : ` / ${count} ${unit}s`;
} else {
display += ' one-time';
}
if (price.freeTrial) {
const [count, unit] = price.freeTrial;
@ -124,27 +130,64 @@ export function formatPriceDisplay(price: Price): string {
/**
* Builds a fresh $0 price entry. Used as the "Make free" handler on product forms.
*
* We model "free" as a monthly recurring $0 subscription rather than a $0
* one-time charge because Stripe rejects PaymentIntents below the per-currency
* minimum (USD: $0.50) a $0 one-time price is literally unprocessable through
* the checkout flow. Stripe does, however, allow $0 recurring subscription
* items: they create a $0 invoice each cycle with no payment attempt, which
* matches "this product is free for the customer" semantics. The monthly
* interval is arbitrary but matches the most common free-tier expectation; it
* also governs when included items with `expires: 'when-purchase-expires'` or
* `'when-repeated'` get re-granted.
*
* TODO(default-plans): replace the [1, 'month'] interval default with the
* default-plan grant flow once that exists; the interval is only here to
* keep Stripe's recurring-sub path happy.
*/
export function createFreePrice(): { [priceId: string]: Price } {
return { [generateUniqueId('price')]: { USD: '0.00', serverOnly: false } };
return {
[generateUniqueId('price')]: {
USD: '0.00',
serverOnly: false,
interval: [1, 'month'],
},
};
}
/**
* Returns true if `prices` represents a "free" product: exactly one price entry
* whose USD amount is `'0'` or `'0.00'` and which has no interval, free-trial, or
* server-only flag set (any of those would change the semantics meaningfully).
*
* We accept both `'0'` and `'0.00'` for backward-compatibility with rows written
* before we standardized on `createFreePrice()` (which emits `'0.00'`). All three
* product pages (list, edit, create) call this so the "Free" indicator and the
* "Make free" / "Make paid" toggles stay in sync.
* Returns a human-readable error if Stripe would reject this price at checkout,
* or `null` if it's valid. Mirrors the per-currency one-time minimum from
* stack-shared/payments/stripe-limits; recurring $0 subs are allowed.
*/
export function getPriceCheckoutError(price: Price): string | null {
const amount = Number(price.USD);
if (!Number.isFinite(amount) || amount < 0) {
return `Price amount is not a valid non-negative number (got ${JSON.stringify(price.USD)})`;
}
if (!price.interval) {
const minOneTime = getStripeOneTimeMinAmount('USD');
if (amount === 0) {
return "$0 one-time prices can't be checked out — switch to a recurring interval to offer it for free.";
}
if (amount < minOneTime) {
return `One-time prices must be at least $${minOneTime.toFixed(2)} (Stripe minimum) — customers can't complete checkout below this amount.`;
}
}
return null;
}
/**
* Returns true if `prices` is the canonical "free product" shape: exactly one
* entry with USD `'0'`/`'0.00'`, no free-trial, no server-only. Accepts both
* `'0'` and `'0.00'` so rows written before `createFreePrice()` still match.
* An interval is allowed (a free product is a $0 recurring sub).
*/
export function isFreePrices(prices: PricesObject): boolean {
const entries = Object.values(prices);
if (entries.length !== 1) return false;
const [price] = entries;
return (price.USD === '0' || price.USD === '0.00')
&& !price.interval
&& !price.freeTrial
&& !price.serverOnly;
}

View File

@ -1044,3 +1044,196 @@ it("should block one-time purchase in same group after prior one-time purchase i
expect(resB.status).toBe(400);
expect(String(resB.body)).toContain("one-time purchase in this product line");
});
it("creates a $0 recurring subscription without requiring a payment intent", async ({ expect }) => {
// TODO(default-plans): revisit when default products land - $0 may no
// longer flow through purchase-session at all.
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
await Payments.setup();
await Project.updateConfig({
payments: {
testMode: false,
products: {
"free-product": {
displayName: "Free Product",
customerType: "user",
serverOnly: false,
stackable: false,
prices: {
"monthly": {
USD: "0",
interval: [1, "month"],
},
},
includedItems: {},
},
},
},
});
const { userId, accessToken, refreshToken } = await Auth.fastSignUp();
const createUrlResponse = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", {
method: "POST",
accessType: "client",
userAuth: { accessToken, refreshToken },
body: {
customer_type: "user",
customer_id: userId,
product_id: "free-product",
},
});
expect(createUrlResponse.status).toBe(200);
const code = (createUrlResponse.body as { url: string }).url.match(/\/purchase\/([a-z0-9-_]+)/)?.[1]!;
const response = await niceBackendFetch("/api/latest/payments/purchases/purchase-session", {
method: "POST",
accessType: "client",
body: {
full_code: code,
price_id: "monthly",
quantity: 1,
},
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {},
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("rejects a $0 one-time price with a clear 400", async ({ expect }) => {
// TODO(default-plans): revisit when default products land - $0 may no
// longer flow through purchase-session at all.
await Project.createAndSwitch();
await Payments.setup();
await Project.updateConfig({
payments: {
testMode: false,
products: {
"free-otp": {
displayName: "Free One Time",
customerType: "user",
serverOnly: false,
stackable: false,
prices: {
one: { USD: "0" },
},
includedItems: {},
},
},
},
});
const { userId } = await Auth.fastSignUp();
const urlRes = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", {
method: "POST",
accessType: "client",
body: {
customer_type: "user",
customer_id: userId,
product_id: "free-otp",
},
});
expect(urlRes.status).toBe(200);
const code = (urlRes.body as { url: string }).url.match(/\/purchase\/([a-z0-9-_]+)/)?.[1]!;
const res = await niceBackendFetch("/api/latest/payments/purchases/purchase-session", {
method: "POST",
accessType: "client",
body: {
full_code: code,
price_id: "one",
quantity: 1,
},
});
expect(res).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": "Free products must have a billing interval",
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("switches from an existing paid subscription to a $0 subscription in the same product line", async ({ expect }) => {
// TODO(default-plans): revisit when default products land - $0 may no
// longer flow through purchase-session at all.
//
// Note: this test seeds the existing paid sub via test-mode-purchase-session,
// so the conflict goes through the DB-only cancel branch in route.tsx (it
// falls through to the regular CREATE path that test 1 covers). The Stripe
// `subscriptions.update` branch (the OTHER conflict branch) gets the same
// patch, but exercising it from e2e would require sending a signed Stripe
// webhook to seed an active Stripe-backed sub -- and that path is currently
// flaky in this repo (see existing failures in switch-plans.test.ts with
// "Invalid stripe-signature header"). Code-review verifies symmetry of the
// patch across both conflict branches.
await Project.createAndSwitch();
await Payments.setup();
await Project.updateConfig({
payments: {
testMode: true,
productLines: { plans: { displayName: "Plans" } },
products: {
paid: {
displayName: "Paid",
customerType: "user",
serverOnly: false,
stackable: false,
productLineId: "plans",
prices: { monthly: { USD: "1000", interval: [1, "month"] } },
includedItems: {},
},
free: {
displayName: "Free",
customerType: "user",
serverOnly: false,
stackable: false,
productLineId: "plans",
prices: { monthly: { USD: "0", interval: [1, "month"] } },
includedItems: {},
},
},
},
});
const { userId } = await Auth.fastSignUp();
// Seed an active paid sub via test-mode (DB-only, no Stripe round-trip).
const createUrlPaid = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", {
method: "POST",
accessType: "client",
body: { customer_type: "user", customer_id: userId, product_id: "paid" },
});
expect(createUrlPaid.status).toBe(200);
const codePaid = (createUrlPaid.body as { url: string }).url.match(/\/purchase\/([a-z0-9-_]+)/)?.[1]!;
const testModeRes = await niceBackendFetch("/api/latest/internal/payments/test-mode-purchase-session", {
method: "POST",
accessType: "admin",
body: { full_code: codePaid, price_id: "monthly", quantity: 1 },
});
expect(testModeRes.status).toBe(200);
// Now switch by purchasing the free product in the same line via the real route.
const createUrlFree = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", {
method: "POST",
accessType: "client",
body: { customer_type: "user", customer_id: userId, product_id: "free" },
});
expect(createUrlFree.status).toBe(200);
const codeFree = (createUrlFree.body as { url: string }).url.match(/\/purchase\/([a-z0-9-_]+)/)?.[1]!;
const switchRes = await niceBackendFetch("/api/latest/payments/purchases/purchase-session", {
method: "POST",
accessType: "client",
body: { full_code: codeFree, price_id: "monthly", quantity: 1 },
});
expect(switchRes).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {},
"headers": Headers { <some fields may have been hidden> },
}
`);
});

View File

@ -6657,12 +6657,9 @@
"client_secret": {
"type": "string",
"example": "1234567890abcdef_secret_xyz123",
"description": "The Stripe client secret for completing the payment"
"description": "Stripe client secret used by the browser to confirm payment via Stripe Elements. Omitted when no payment step is required from the customer; in that case the purchase is being settled without a confirmation step and the caller should skip mounting Stripe Elements."
}
},
"required": [
"client_secret"
]
}
}
}
}

View File

@ -6172,12 +6172,9 @@
"client_secret": {
"type": "string",
"example": "1234567890abcdef_secret_xyz123",
"description": "The Stripe client secret for completing the payment"
"description": "Stripe client secret used by the browser to confirm payment via Stripe Elements. Omitted when no payment step is required from the customer; in that case the purchase is being settled without a confirmation step and the caller should skip mounting Stripe Elements."
}
},
"required": [
"client_secret"
]
}
}
}
}

View File

@ -6565,12 +6565,9 @@
"client_secret": {
"type": "string",
"example": "1234567890abcdef_secret_xyz123",
"description": "The Stripe client secret for completing the payment"
"description": "Stripe client secret used by the browser to confirm payment via Stripe Elements. Omitted when no payment step is required from the customer; in that case the purchase is being settled without a confirmation step and the caller should skip mounting Stripe Elements."
}
},
"required": [
"client_secret"
]
}
}
}
}

View File

@ -0,0 +1,18 @@
/**
* Stripe API limits shared by backend and frontend.
* See https://docs.stripe.com/currencies#minimum-and-maximum-charge-amounts.
*/
/**
* Per-currency minimum for one-time PaymentIntents, in the major unit.
* Recurring subs have no minimum ($0 subs are allowed).
*/
export const STRIPE_ONE_TIME_MIN_AMOUNT_BY_CURRENCY = {
USD: 0.50,
} as const;
export type StripeSupportedCurrency = keyof typeof STRIPE_ONE_TIME_MIN_AMOUNT_BY_CURRENCY;
export function getStripeOneTimeMinAmount(currency: StripeSupportedCurrency): number {
return STRIPE_ONE_TIME_MIN_AMOUNT_BY_CURRENCY[currency];
}