complete payments setup warning (#960)

<!--

Make sure you've read the CONTRIBUTING.md guidelines:
https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md

-->

<!-- RECURSEML_SUMMARY:START -->
## High-level PR Summary
This PR adds a warning mechanism for incomplete Stripe payments setup by
checking if `charges_enabled` is true on the connected Stripe account.
The backend now retrieves and passes the `charges_enabled` status
through the purchase flow, and the frontend checkout form displays an
error message when payments are not fully enabled, preventing users from
attempting purchases on misconfigured accounts. Additionally, minor
cleanup was performed including removing unused test mode toggle state
management and fixing a description typo.

⏱️ Estimated Review Time: 15-30 minutes

<details>
<summary>💡 Review Order Suggestion</summary>

| Order | File Path |
|-------|-----------|
| 1 |
`apps/backend/src/app/api/latest/payments/purchases/verification-code-handler.tsx`
|
| 2 |
`apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts`
|
| 3 |
`apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts`
|
| 4 | `apps/dashboard/src/components/payments/checkout.tsx` |
| 5 | `apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx` |
| 6 |
`apps/e2e/tests/backend/endpoints/api/v1/payments/validate-code.test.ts`
|
| 7 |
`apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--validate-code.test.ts`
|
| 8 |
`apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx`
|
| 9 |
`apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-catalogs-view.tsx`
|
</details>

<details>
<summary>⚠️ Inconsistent Changes Detected</summary>

| File Path | Warning |
|-----------|---------|
|
`apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-catalogs-view.tsx`
| Removes error handling and loading state management for test mode
toggle, which seems unrelated to the charges_enabled warning feature |
|
`apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx`
| Changes description from 'Stripe price ID' to 'Stack auth price ID'
which is a documentation change unrelated to the charges_enabled warning
|
</details>

[![Need help? Join our
Discord](https://img.shields.io/badge/Need%20help%3F%20Join%20our%20Discord-5865F2?style=plastic&logo=discord&logoColor=white)](https://discord.gg/n3SsVDAW6U)


[![Analyze latest
changes](4240d8f2d6/?repo_owner=stack-auth&repo_name=stack-auth&pr_number=960)
<!-- RECURSEML_SUMMARY:END -->

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

## Summary by CodeRabbit

* **New Features**
* Checkout interface now displays a "Payments not enabled" message with
guidance when charges are not enabled on the payment account.

* **Documentation**
* Clarified price ID field description in payment session documentation.

* **Tests**
* Updated payment validation endpoint test expectations to reflect new
response fields.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
BilalG1 2025-10-16 19:38:37 -07:00 committed by GitHub
parent 4ed486bab1
commit 1751ea424d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 37 additions and 18 deletions

View File

@ -1,6 +1,6 @@
import { ensureProductIdOrInlineProduct, getCustomerPurchaseContext } from "@/lib/payments";
import { validateRedirectUrl } from "@/lib/redirect-urls";
import { getStripeForAccount } from "@/lib/stripe";
import { getStackStripe, getStripeForAccount } from "@/lib/stripe";
import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { CustomerType } from "@prisma/client";
@ -106,7 +106,9 @@ export const POST = createSmartRouteHandler({
where: { id: tenancy.project.id },
select: { stripeAccountId: true },
});
const stripeAccountId = project?.stripeAccountId ?? throwErr("Stripe account not configured");
const stackStripe = getStackStripe();
const connectedAccount = await stackStripe.accounts.retrieve(stripeAccountId);
const { code } = await purchaseUrlVerificationCodeHandler.createCode({
tenancy,
expiresInMs: 1000 * 60 * 60 * 24,
@ -116,7 +118,8 @@ export const POST = createSmartRouteHandler({
productId: req.body.product_id,
product: productConfig,
stripeCustomerId: stripeCustomer.id,
stripeAccountId: project?.stripeAccountId ?? throwErr("Stripe account not configured"),
stripeAccountId,
chargesEnabled: connectedAccount.charges_enabled,
},
method: {},
callbackUrl: undefined,

View File

@ -25,7 +25,7 @@ export const POST = createSmartRouteHandler({
}),
price_id: yupString().defined().meta({
openapiField: {
description: "The Stripe price ID to purchase",
description: "The Stack auth price ID to purchase",
exampleValue: "price_1234567890abcdef"
}
}),

View File

@ -44,6 +44,7 @@ export const POST = createSmartRouteHandler({
display_name: yupString().defined(),
}).defined()).defined(),
test_mode: yupBoolean().defined(),
charges_enabled: yupBoolean().defined(),
}).defined(),
}),
async handler({ body }) {
@ -98,6 +99,7 @@ export const POST = createSmartRouteHandler({
already_bought_non_stackable: alreadyBoughtNonStackable,
conflicting_products: conflictingCatalogProducts,
test_mode: tenancy.config.payments.testMode === true,
charges_enabled: verificationCode.data.chargesEnabled,
},
};
},

View File

@ -1,6 +1,6 @@
import { createVerificationCodeHandler } from "@/route-handlers/verification-code-handler";
import { VerificationCodeType } from "@prisma/client";
import { productSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { productSchema, yupBoolean, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
export const purchaseUrlVerificationCodeHandler = createVerificationCodeHandler({
type: VerificationCodeType.PURCHASE_URL,
@ -12,6 +12,7 @@ export const purchaseUrlVerificationCodeHandler = createVerificationCodeHandler(
product: productSchema,
stripeCustomerId: yupString().defined(),
stripeAccountId: yupString().defined(),
chargesEnabled: yupBoolean().defined(),
}),
// @ts-ignore TODO: fix this
async handler(_, __, data) {

View File

@ -1438,7 +1438,6 @@ export default function PageClient({ onViewChange }: { onViewChange: (view: "lis
const config = project.useConfig();
const switchId = useId();
const testModeSwitchId = useId();
const [isUpdatingTestMode, setIsUpdatingTestMode] = useState(false);
const paymentsConfig: CompleteConfig['payments'] = config.payments;
@ -1568,15 +1567,8 @@ export default function PageClient({ onViewChange }: { onViewChange: (view: "lis
};
const handleToggleTestMode = async (enabled: boolean) => {
setIsUpdatingTestMode(true);
try {
await project.updateConfig({ "payments.testMode": enabled });
toast({ title: enabled ? "Test mode enabled" : "Test mode disabled" });
} catch (_error) {
toast({ title: "Failed to update test mode", variant: "destructive" });
} finally {
setIsUpdatingTestMode(false);
}
await project.updateConfig({ "payments.testMode": enabled });
toast({ title: enabled ? "Test mode enabled" : "Test mode disabled" });
};
@ -1597,8 +1589,7 @@ export default function PageClient({ onViewChange }: { onViewChange: (view: "lis
<Switch
id={testModeSwitchId}
checked={paymentsConfig.testMode === true}
disabled={isUpdatingTestMode}
onCheckedChange={(checked) => void handleToggleTestMode(checked)}
onCheckedChange={(checked) => handleToggleTestMode(checked)}
/>
</div>
</div>

View File

@ -19,6 +19,7 @@ type ProductData = {
already_bought_non_stackable?: boolean,
conflicting_products?: { product_id: string, display_name: string }[],
test_mode: boolean,
charges_enabled: boolean,
};
const apiUrl = getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") ?? throwErr("NEXT_PUBLIC_STACK_API_URL is not set");
@ -282,6 +283,7 @@ export default function PageClient({ code }: { code: string }) {
setupSubscription={setupSubscription}
returnUrl={returnUrl ?? undefined}
disabled={quantityNumber < 1 || isTooLarge || data.already_bought_non_stackable === true}
chargesEnabled={data.charges_enabled}
onTestModeBypass={data.test_mode ? handleBypass : undefined}
/>
</StripeElementsProvider>

View File

@ -24,6 +24,7 @@ type Props = {
returnUrl?: string,
disabled?: boolean,
onTestModeBypass?: () => Promise<void>,
chargesEnabled: boolean,
};
export function CheckoutForm({
@ -33,6 +34,7 @@ export function CheckoutForm({
returnUrl,
disabled,
onTestModeBypass,
chargesEnabled,
}: Props) {
const stripe = useStripe();
const elements = useElements();
@ -96,11 +98,24 @@ export function CheckoutForm({
);
}
if (!chargesEnabled) {
return (
<div className="flex flex-col gap-4 max-w-md w-full p-6 rounded-md bg-background">
<div className="space-y-1">
<Typography type="h3" variant="destructive">Payments not enabled</Typography>
<p className="text-sm text-muted-foreground">
This project does not have payments enabled yet. Please contact the app developer to finish setting up payments.
</p>
</div>
</div>
);
}
return (
<div className="flex flex-col gap-6 max-w-md w-full p-6 rounded-md bg-background">
<PaymentElement options={paymentElementOptions} />
<Button
disabled={!stripe || !elements || disabled}
disabled={!stripe || !elements || disabled || !chargesEnabled}
onClick={handleSubmit}
>
Submit

View File

@ -38,6 +38,7 @@ it("should allow valid code and return offer data", async ({ expect }) => {
"status": 200,
"body": {
"already_bought_non_stackable": false,
"charges_enabled": false,
"conflicting_products": [],
"product": {
"customer_type": "user",
@ -212,6 +213,7 @@ it("should include conflicting_group_offers when switching within the same group
"status": 200,
"body": {
"already_bought_non_stackable": false,
"charges_enabled": false,
"conflicting_products": [
{
"display_name": "Offer A",

View File

@ -38,6 +38,7 @@ it("should allow valid code and return product data", async ({ expect }) => {
"status": 200,
"body": {
"already_bought_non_stackable": false,
"charges_enabled": false,
"conflicting_products": [],
"product": {
"customer_type": "user",
@ -212,6 +213,7 @@ it("should include conflicting_products when switching within the same group", a
"status": 200,
"body": {
"already_bought_non_stackable": false,
"charges_enabled": false,
"conflicting_products": [
{
"display_name": "Product A",
@ -308,6 +310,7 @@ it("should reject untrusted return_url and accept trusted return_url", async ({
"status": 200,
"body": {
"already_bought_non_stackable": false,
"charges_enabled": false,
"conflicting_products": [],
"product": {
"customer_type": "user",