Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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",
},
}),
}),
}),
Expand Down Expand Up @@ -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");
Comment thread
nams1570 marked this conversation as resolved.
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// 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,
Expand All @@ -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' },
Expand All @@ -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);
Expand Down Expand Up @@ -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',
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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> = {};

Expand Down Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -787,7 +787,6 @@ ${Object.entries(prices).map(([id, price]) => {
hasError={!!errors.prices}
errorMessage={errors.prices}
variant="form"
isFree={isFreePrices(prices)}
onMakeFree={() => {
setPrices(createFreePrice());
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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']];
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -966,6 +1034,7 @@ export default function PageClient() {
return (
<>
<ProductsWithoutPricesAlert products={paymentsConfig.products} projectId={projectId} />
<ProductsWithInvalidPricesAlert products={paymentsConfig.products} projectId={projectId} />
{innerContent}

{/* Product Dialog */}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -66,6 +79,8 @@ export function PriceEditDialog({
onOpenChange(false);
};

const amountError = editingPrice ? validateEditingPriceAmount(editingPrice) : null;

return (
<Dialog open={open} onOpenChange={(isOpen) => {
if (!isOpen) {
Expand Down Expand Up @@ -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 */}
Expand Down Expand Up @@ -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>
Expand Down
Loading
Loading