mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
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>
[](https://discord.gg/n3SsVDAW6U)
[
<!-- 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:
parent
4ed486bab1
commit
1751ea424d
@ -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,
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}),
|
||||
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user