Skip to content

Commit 502963b

Browse files
authored
payouts tab (#1065)
<img width="1299" height="967" alt="Screenshot 2025-12-12 at 5 26 23 PM" src="https://github.com/user-attachments/assets/5a33482a-510c-464c-a770-e71222ffc336" /> <!-- Make sure you've read the CONTRIBUTING.md guidelines: https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added a "Payouts" section to the Payments dashboard with a dedicated page and navigation link. * Integrated a Stripe Connect payouts UI, allowing users to manage and configure payout options (instant payouts, standard payouts, edit payout schedule, external account collection). * **Chores** * Internal module path updates (no user-facing behavior changes). <sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 83dd4cb commit 502963b

12 files changed

Lines changed: 573 additions & 3 deletions

File tree

apps/backend/src/app/api/latest/internal/payments/stripe-widgets/account-session/route.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,15 @@ export const POST = createSmartRouteHandler({
4848
notification_banner: {
4949
enabled: true,
5050
},
51+
payouts: {
52+
enabled: true,
53+
features: {
54+
instant_payouts: true,
55+
standard_payouts: true,
56+
edit_payout_schedule: true,
57+
external_account_collection: true,
58+
},
59+
},
5160
},
5261
});
5362

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { ensureProductIdOrInlineProduct, getOwnedProductsForCustomer } from "@/lib/payments";
2+
import { getPrismaClientForTenancy } from "@/prisma-client";
3+
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
4+
import { adaptSchema, clientOrHigherAuthTypeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
5+
import { KnownErrors } from "@stackframe/stack-shared";
6+
import { StackAssertionError, StatusError, captureError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
7+
import { SubscriptionStatus } from "@/generated/prisma/client";
8+
import { getStripeForAccount } from "@/lib/stripe";
9+
import { typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings";
10+
import { ensureUserTeamPermissionExists } from "@/lib/request-checks";
11+
12+
export const DELETE = createSmartRouteHandler({
13+
metadata: {
14+
summary: "Cancel a customer's subscription product",
15+
hidden: true,
16+
},
17+
request: yupObject({
18+
auth: yupObject({
19+
type: clientOrHigherAuthTypeSchema.defined(),
20+
project: adaptSchema.defined(),
21+
tenancy: adaptSchema.defined(),
22+
}).defined(),
23+
params: yupObject({
24+
customer_type: yupString().oneOf(["user", "team", "custom"]).defined(),
25+
customer_id: yupString().defined(),
26+
product_id: yupString().defined(),
27+
}).defined(),
28+
}),
29+
response: yupObject({
30+
statusCode: yupNumber().oneOf([200]).defined(),
31+
bodyType: yupString().oneOf(["json"]).defined(),
32+
body: yupObject({
33+
success: yupBoolean().oneOf([true]).defined(),
34+
}).defined(),
35+
}),
36+
handler: async ({ auth, params }, fullReq) => {
37+
if (auth.type === "client") {
38+
const currentUser = fullReq.auth?.user;
39+
if (!currentUser) {
40+
throw new KnownErrors.UserAuthenticationRequired();
41+
}
42+
if (params.customer_type === "user") {
43+
if (params.customer_id !== currentUser.id) {
44+
throw new StatusError(StatusError.Forbidden, "Clients can only cancel their own subscriptions.");
45+
}
46+
} else if (params.customer_type === "team") {
47+
const prisma = await getPrismaClientForTenancy(auth.tenancy);
48+
await ensureUserTeamPermissionExists(prisma, {
49+
tenancy: auth.tenancy,
50+
teamId: params.customer_id,
51+
userId: currentUser.id,
52+
permissionId: "team_admin",
53+
errorType: "required",
54+
recursive: true,
55+
});
56+
} else {
57+
throw new StatusError(StatusError.Forbidden, "Clients can only cancel user or team subscriptions they control.");
58+
}
59+
}
60+
61+
const prisma = await getPrismaClientForTenancy(auth.tenancy);
62+
const product = await ensureProductIdOrInlineProduct(auth.tenancy, auth.type, params.product_id, undefined);
63+
if (params.customer_type !== product.customerType) {
64+
throw new KnownErrors.ProductCustomerTypeDoesNotMatch(
65+
params.product_id,
66+
params.customer_id,
67+
product.customerType,
68+
params.customer_type,
69+
);
70+
}
71+
72+
const ownedProducts = await getOwnedProductsForCustomer({
73+
prisma,
74+
tenancy: auth.tenancy,
75+
customerType: params.customer_type,
76+
customerId: params.customer_id,
77+
});
78+
const ownedProductsForProduct = ownedProducts.filter((p) => p.id === params.product_id);
79+
if (ownedProductsForProduct.length === 0) {
80+
throw new StatusError(400, "Customer does not have this product.");
81+
}
82+
if (ownedProductsForProduct.some((product) => product.type === "one_time")) {
83+
throw new StatusError(400, "This product is a one time purchase and cannot be canceled.");
84+
}
85+
86+
const subscriptions = await prisma.subscription.findMany({
87+
where: {
88+
tenancyId: auth.tenancy.id,
89+
customerType: typedToUppercase(params.customer_type),
90+
customerId: params.customer_id,
91+
productId: params.product_id,
92+
status: { in: [SubscriptionStatus.active, SubscriptionStatus.trialing] },
93+
},
94+
});
95+
if (subscriptions.length === 0) {
96+
captureError("cancel-subscription-missing", new StackAssertionError(
97+
"Owned subscription product missing active/trialing subscription record.",
98+
{
99+
customerType: params.customer_type,
100+
customerId: params.customer_id,
101+
productId: params.product_id,
102+
},
103+
));
104+
throw new StatusError(400, "This subscription cannot be canceled.");
105+
}
106+
107+
const hasStripeSubscription = subscriptions.some((subscription) => subscription.stripeSubscriptionId);
108+
const stripe = hasStripeSubscription ? await getStripeForAccount({ tenancy: auth.tenancy }) : undefined;
109+
for (const subscription of subscriptions) {
110+
if (subscription.stripeSubscriptionId) {
111+
const stripeClient = stripe ?? throwErr(500, "Stripe client missing for subscription cancellation.");
112+
await stripeClient.subscriptions.cancel(subscription.stripeSubscriptionId);
113+
continue;
114+
}
115+
await prisma.subscription.update({
116+
where: {
117+
tenancyId_id: {
118+
tenancyId: auth.tenancy.id,
119+
id: subscription.id,
120+
},
121+
},
122+
data: {
123+
status: SubscriptionStatus.canceled,
124+
currentPeriodEnd: new Date(),
125+
cancelAtPeriodEnd: true,
126+
},
127+
});
128+
}
129+
130+
return {
131+
statusCode: 200,
132+
bodyType: "json",
133+
body: {
134+
success: true,
135+
},
136+
};
137+
},
138+
});

apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/route.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ export const GET = createSmartRouteHandler({
8383
},
8484
});
8585

86+
8687
export const POST = createSmartRouteHandler({
8788
metadata: {
8889
summary: "Grant a product to a customer",
@@ -151,3 +152,4 @@ export const POST = createSmartRouteHandler({
151152
};
152153
},
153154
});
155+

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/layout.tsx renamed to apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/layout.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ import { ConnectNotificationBanner } from "@stripe/react-connect-js";
1212
import { usePathname } from "next/navigation";
1313
import { useState } from "react";
1414
import * as yup from "yup";
15-
import { AppEnabledGuard } from "../../app-enabled-guard";
16-
import { useAdminApp } from "../../use-admin-app";
15+
import { AppEnabledGuard } from "../app-enabled-guard";
16+
import { useAdminApp } from "../use-admin-app";
1717

1818
export default function PaymentsLayout({ children }: { children: React.ReactNode }) {
1919
return (
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"use client";
2+
3+
import { ConnectPayouts } from "@stripe/react-connect-js";
4+
import { PageLayout } from "../../page-layout";
5+
import { StripeConnectProvider } from "@/components/payments/stripe-connect-provider";
6+
7+
export default function PageClient() {
8+
9+
return (
10+
<PageLayout title="Payouts">
11+
<StripeConnectProvider>
12+
<ConnectPayouts />
13+
</StripeConnectProvider>
14+
</PageLayout>
15+
);
16+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"use client";
2+
3+
import PageClient from "./page-client";
4+
5+
export default function Page() {
6+
return (
7+
<PageClient />
8+
);
9+
}

apps/dashboard/src/lib/apps-frontend.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ export const ALL_APPS_FRONTEND = {
140140
{ displayName: "Products & Items", href: "./products" },
141141
{ displayName: "Customers", href: "./customers" },
142142
{ displayName: "Transactions", href: "./transactions" },
143+
{ displayName: "Payouts", href: "./payouts" },
143144
],
144145
screenshots: getScreenshots('payments', 7),
145146
storeDescription: (

0 commit comments

Comments
 (0)