diff --git a/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx b/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx index 6eb7605a3a..a54c3cd592 100644 --- a/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx +++ b/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx @@ -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", diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/edit/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/edit/page-client.tsx index 7e95abc562..88100cee97 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/edit/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/edit/page-client.tsx @@ -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()); }} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/included-item-dialog.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/included-item-dialog.tsx index 8c31bbaaec..0e68d4eb72 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/included-item-dialog.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/included-item-dialog.tsx @@ -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(editingItem?.expires || 'never'); const [errors, setErrors] = useState>({}); + // 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 = {}; @@ -143,7 +167,10 @@ export function IncludedItemDialog({ {/* Item Selection */}
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page-client.tsx index 884b307e82..252b4034d8 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page-client.tsx @@ -787,7 +787,6 @@ ${Object.entries(prices).map(([id, price]) => { hasError={!!errors.prices} errorMessage={errors.prices} variant="form" - isFree={isFreePrices(prices)} onMakeFree={() => { setPrices(createFreePrice()); }} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx index 3a4da49cfd..60e972cc3e 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx @@ -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 ( + + + {productsWithInvalidPrices.length === 1 + ? "1 product has a price customers can't check out" + : `${productsWithInvalidPrices.length} products have prices customers can't check out`} + + +
+ 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. +
+
    + {preview.map(({ id, displayName, issues }) => ( +
  • + + {displayName} + + {displayName !== id && ({id})} + + — {issues.length === 1 ? `price “${issues[0].priceId}”` : `${issues.length} prices`} + +
  • + ))} + {overflow > 0 && ( +
  • + …and {overflow} more +
  • + )} +
+
+
+ ); +} + export default function PageClient() { const projectId = useProjectId(); const router = useRouter(); @@ -966,6 +1034,7 @@ export default function PageClient() { return ( <> + {innerContent} {/* Product Dialog */} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/price-edit-dialog.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/price-edit-dialog.tsx index 520a98949b..9da2f90dfa 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/price-edit-dialog.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/price-edit-dialog.tsx @@ -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 ( { if (!isOpen) { @@ -121,6 +136,9 @@ export function PriceEditDialog({ onIntervalUnitChange={(v) => onEditingPriceChange({ ...editingPrice, priceInterval: v })} allowedUnits={PRICE_INTERVAL_UNITS} /> + {amountError && ( +

{amountError}

+ )}
{/* Free Trial & Server Only as EditableGrid */} @@ -236,7 +254,10 @@ export function PriceEditDialog({ - diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/pricing-section.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/pricing-section.tsx index 00a2ccfd4d..7cd6f67872 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/pricing-section.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/pricing-section.tsx @@ -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, @@ -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(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 (
-
Free
+
+ Free · {formatPriceDisplay(freePrice)} +
+
{freePriceId}
+ + + )}
{hasError && errorMessage && ( @@ -209,37 +220,48 @@ export function PricingSection({
) : (
- {Object.entries(prices).map(([priceId, price]) => ( -
{ + const checkoutError = getPriceCheckoutError(price); + return ( +
-
-
{formatPriceDisplay(price)}
-
{priceId}
-
-
- - + > +
+
+ {formatPriceDisplay(price)} + {checkoutError && ( + + + + )} +
+
{priceId}
+
+
+ + +
-
- ))} + ); + })}
+ + + )}
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-price-row.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-price-row.tsx index aa24b10bf9..c774beb62c 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-price-row.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-price-row.tsx @@ -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 (
{isEditing ? ( @@ -320,8 +325,13 @@ export function ProductPriceRow({ ) : ( // View mode - minimal, centered display
-
+
{isFree ? 'Free' : `$${niceAmount}`} + {checkoutError && ( + + + + )}
{!isFree && (
{intervalText ?? 'One-time'}
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/utils.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/utils.ts index e7f78d88fd..d683d19289 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/utils.ts +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/utils.ts @@ -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; } diff --git a/apps/e2e/tests/backend/endpoints/api/v1/payments/purchase-session.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/payments/purchase-session.test.ts index 223f1486e1..3f13e04dba 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/payments/purchase-session.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/payments/purchase-session.test.ts @@ -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 {