From 9272bc219dfffb24baf9b4db8d4e04f19956f1af Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Fri, 12 Dec 2025 17:26:06 -0800 Subject: [PATCH 01/16] payouts tab --- .../stripe-widgets/account-session/route.ts | 9 +++++++++ .../payments/{products => }/layout.tsx | 4 ++-- .../[projectId]/payments/payouts/page-client.tsx | 16 ++++++++++++++++ .../[projectId]/payments/payouts/page.tsx | 9 +++++++++ apps/dashboard/src/lib/apps-frontend.tsx | 1 + 5 files changed, 37 insertions(+), 2 deletions(-) rename apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/{products => }/layout.tsx (99%) create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/payouts/page-client.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/payouts/page.tsx diff --git a/apps/backend/src/app/api/latest/internal/payments/stripe-widgets/account-session/route.ts b/apps/backend/src/app/api/latest/internal/payments/stripe-widgets/account-session/route.ts index 556ecc4e06..9319766bba 100644 --- a/apps/backend/src/app/api/latest/internal/payments/stripe-widgets/account-session/route.ts +++ b/apps/backend/src/app/api/latest/internal/payments/stripe-widgets/account-session/route.ts @@ -48,6 +48,15 @@ export const POST = createSmartRouteHandler({ notification_banner: { enabled: true, }, + payouts: { + enabled: true, + features: { + instant_payouts: true, + standard_payouts: true, + edit_payout_schedule: true, + external_account_collection: true, + }, + }, }, }); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/layout.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/layout.tsx similarity index 99% rename from apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/layout.tsx rename to apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/layout.tsx index 143ad86590..60a2a214b9 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/layout.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/layout.tsx @@ -11,8 +11,8 @@ import { ConnectNotificationBanner } from "@stripe/react-connect-js"; import { AlertTriangle, ArrowRight, BarChart3, FlaskConical, Repeat, Shield, Wallet, Webhook } from "lucide-react"; import { useState } from "react"; import * as yup from "yup"; -import { AppEnabledGuard } from "../../app-enabled-guard"; -import { useAdminApp } from "../../use-admin-app"; +import { AppEnabledGuard } from "../app-enabled-guard"; +import { useAdminApp } from "../use-admin-app"; export default function PaymentsLayout({ children }: { children: React.ReactNode }) { return ( diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/payouts/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/payouts/page-client.tsx new file mode 100644 index 0000000000..a8af80d387 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/payouts/page-client.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { ConnectPayouts } from "@stripe/react-connect-js"; +import { PageLayout } from "../../page-layout"; +import { StripeConnectProvider } from "@/components/payments/stripe-connect-provider"; + +export default function PageClient() { + + return ( + + + + + + ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/payouts/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/payouts/page.tsx new file mode 100644 index 0000000000..1be3d9b358 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/payouts/page.tsx @@ -0,0 +1,9 @@ +"use client"; + +import PageClient from "./page-client"; + +export default function Page() { + return ( + + ); +} diff --git a/apps/dashboard/src/lib/apps-frontend.tsx b/apps/dashboard/src/lib/apps-frontend.tsx index 7a1e74418e..9a2bbe93a2 100644 --- a/apps/dashboard/src/lib/apps-frontend.tsx +++ b/apps/dashboard/src/lib/apps-frontend.tsx @@ -139,6 +139,7 @@ export const ALL_APPS_FRONTEND = { { displayName: "Products", href: "./products" }, { displayName: "Customers", href: "./customers" }, { displayName: "Transactions", href: "./transactions" }, + { displayName: "Payouts", href: "./payouts" }, ], screenshots: getScreenshots('payments', 7), storeDescription: ( From 17822379cc9bb17242e89a1938aabe4173474316 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Mon, 15 Dec 2025 10:47:42 -0800 Subject: [PATCH 02/16] cancel subscription endpoint --- .../[customer_type]/[customer_id]/route.ts | 124 ++++++++++++++ .../api/v1/payments/products.test.ts | 156 +++++++++++++++++- apps/e2e/tests/js/payments.test.ts | 57 +++++++ .../src/interface/client-interface.ts | 23 +++ .../apps/implementations/client-app-impl.ts | 20 +++ .../stack-app/apps/interfaces/client-app.ts | 2 + 6 files changed, 381 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/route.ts b/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/route.ts index b1146fdee4..056f5be722 100644 --- a/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/route.ts +++ b/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/route.ts @@ -5,6 +5,10 @@ import { adaptSchema, clientOrHigherAuthTypeSchema, inlineProductSchema, serverO import { KnownErrors } from "@stackframe/stack-shared"; import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; import { customerProductsListResponseSchema } from "@stackframe/stack-shared/dist/interface/crud/products"; +import { SubscriptionStatus } from "@prisma/client"; +import { getStripeForAccount } from "@/lib/stripe"; +import { typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings"; +import { ensureUserTeamPermissionExists } from "@/lib/request-checks"; export const GET = createSmartRouteHandler({ metadata: { @@ -83,6 +87,7 @@ export const GET = createSmartRouteHandler({ }, }); + export const POST = createSmartRouteHandler({ metadata: { summary: "Grant a product to a customer", @@ -151,3 +156,122 @@ export const POST = createSmartRouteHandler({ }; }, }); + + +export const DELETE = createSmartRouteHandler({ + metadata: { + summary: "Cancel a customer's subscription product", + hidden: true, + }, + request: yupObject({ + auth: yupObject({ + type: clientOrHigherAuthTypeSchema.defined(), + project: adaptSchema.defined(), + tenancy: adaptSchema.defined(), + }).defined(), + params: yupObject({ + customer_type: yupString().oneOf(["user", "team", "custom"]).defined(), + customer_id: yupString().defined(), + }).defined(), + body: yupObject({ + product_id: yupString().defined(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + success: yupBoolean().oneOf([true]).defined(), + }).defined(), + }), + handler: async ({ auth, params, body }, fullReq) => { + if (auth.type === "client") { + const currentUser = fullReq.auth?.user; + if (!currentUser) { + throw new KnownErrors.UserAuthenticationRequired(); + } + if (params.customer_type === "user") { + if (params.customer_id !== currentUser.id) { + throw new StatusError(StatusError.Forbidden, "Clients can only cancel their own subscriptions."); + } + } else if (params.customer_type === "team") { + const prisma = await getPrismaClientForTenancy(auth.tenancy); + await ensureUserTeamPermissionExists(prisma, { + tenancy: auth.tenancy, + teamId: params.customer_id, + userId: currentUser.id, + permissionId: "team_admin", + errorType: "required", + recursive: true, + }); + } else { + throw new StatusError(StatusError.Forbidden, "Clients can only cancel user or team subscriptions they control."); + } + } + + const prisma = await getPrismaClientForTenancy(auth.tenancy); + const product = await ensureProductIdOrInlineProduct(auth.tenancy, auth.type, body.product_id, undefined); + if (params.customer_type !== product.customerType) { + throw new KnownErrors.ProductCustomerTypeDoesNotMatch( + body.product_id, + params.customer_id, + product.customerType, + params.customer_type, + ); + } + + const ownedProducts = await getOwnedProductsForCustomer({ + prisma, + tenancy: auth.tenancy, + customerType: params.customer_type, + customerId: params.customer_id, + }); + const ownedProduct = ownedProducts.find((p) => p.id === body.product_id); + if (!ownedProduct) { + throw new StatusError(400, "Customer does not have this product."); + } + if (ownedProduct.type === "one_time") { + throw new StatusError(400, "This product is a one time purchase and cannot be canceled."); + } + + const subscription = await prisma.subscription.findFirst({ + where: { + tenancyId: auth.tenancy.id, + customerType: typedToUppercase(params.customer_type), + customerId: params.customer_id, + productId: body.product_id, + status: { in: [SubscriptionStatus.active, SubscriptionStatus.trialing] }, + }, + orderBy: { createdAt: "desc" }, + }); + if (!subscription) { + throw new StatusError(400, "This subscription cannot be canceled."); + } + + if (subscription.stripeSubscriptionId) { + const stripe = await getStripeForAccount({ tenancy: auth.tenancy }); + await stripe.subscriptions.cancel(subscription.stripeSubscriptionId); + } + await prisma.subscription.update({ + where: { + tenancyId_id: { + tenancyId: auth.tenancy.id, + id: subscription.id, + }, + }, + data: { + status: SubscriptionStatus.canceled, + currentPeriodEnd: new Date(), + cancelAtPeriodEnd: true, + }, + }); + + return { + statusCode: 200, + bodyType: "json", + body: { + success: true, + }, + }; + }, +}); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/payments/products.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/payments/products.test.ts index 659c17d96a..17a30df945 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/payments/products.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/payments/products.test.ts @@ -1,6 +1,6 @@ import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids"; import { it } from "../../../../../helpers"; -import { Auth, niceBackendFetch, Payments, Project, User } from "../../../../backend-helpers"; +import { Auth, niceBackendFetch, Payments, Project, Team, User } from "../../../../backend-helpers"; async function configureProduct(config: any) { await Project.updateConfig({ @@ -136,6 +136,160 @@ it("should grant configured subscription product and expose it via listing", asy `); }); +it("should allow a signed-in user to cancel their own subscription product", async ({ expect }) => { + await Project.createAndSwitch(); + await Payments.setup(); + await configureProduct({ + products: { + "pro-plan": { + displayName: "Pro Plan", + customerType: "user", + serverOnly: false, + stackable: false, + prices: { + monthly: { + USD: "1000", + interval: [1, "month"], + }, + }, + includedItems: {}, + }, + }, + }); + + const { userId } = await Auth.fastSignUp(); + await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, { + method: "POST", + accessType: "server", + body: { + product_id: "pro-plan", + }, + }); + + const cancelResponse = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, { + method: "DELETE", + accessType: "client", + body: { + product_id: "pro-plan", + }, + }); + expect(cancelResponse).toMatchInlineSnapshot(` + NiceResponse { + "status": 200, + "body": { "success": true }, + "headers": Headers {