diff --git a/apps/backend/src/app/api/latest/payments/billing/[customer_type]/[customer_id]/route.ts b/apps/backend/src/app/api/latest/payments/billing/[customer_type]/[customer_id]/route.ts new file mode 100644 index 0000000000..65f4f7a9b7 --- /dev/null +++ b/apps/backend/src/app/api/latest/payments/billing/[customer_type]/[customer_id]/route.ts @@ -0,0 +1,100 @@ +import { ensureClientCanAccessCustomer, getDefaultCardPaymentMethodSummary, getStripeCustomerForCustomerOrNull } from "@/lib/payments"; +import { getStripeForAccount } from "@/lib/stripe"; +import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { adaptSchema, clientOrHigherAuthTypeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; + +export const GET = createSmartRouteHandler({ + metadata: { + summary: "Get payment method info", + hidden: true, + tags: ["Payments"], + }, + request: yupObject({ + auth: yupObject({ + type: clientOrHigherAuthTypeSchema.defined(), + project: adaptSchema.defined(), + tenancy: adaptSchema.defined(), + }).defined(), + params: yupObject({ + customer_type: yupString().oneOf(["user", "team"]).defined(), + customer_id: yupString().defined(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + has_customer: yupBoolean().defined(), + default_payment_method: yupObject({ + id: yupString().defined(), + brand: yupString().nullable().defined(), + last4: yupString().nullable().defined(), + exp_month: yupNumber().nullable().defined(), + exp_year: yupNumber().nullable().defined(), + }).nullable().defined(), + }).defined(), + }), + handler: async ({ auth, params }, fullReq) => { + if (auth.type === "client") { + await ensureClientCanAccessCustomer({ + customerType: params.customer_type, + customerId: params.customer_id, + user: fullReq.auth?.user, + tenancy: auth.tenancy, + forbiddenMessage: "Clients can only manage their own billing.", + }); + } + + const project = await globalPrismaClient.project.findUnique({ + where: { id: auth.tenancy.project.id }, + select: { stripeAccountId: true }, + }); + const stripeAccountId = project?.stripeAccountId; + if (!stripeAccountId) { + return { + statusCode: 200, + bodyType: "json", + body: { + has_customer: false, + default_payment_method: null, + }, + }; + } + + const prisma = await getPrismaClientForTenancy(auth.tenancy); + const stripe = await getStripeForAccount({ accountId: stripeAccountId }); + const stripeCustomer = await getStripeCustomerForCustomerOrNull({ + stripe, + prisma, + tenancyId: auth.tenancy.id, + customerType: params.customer_type, + customerId: params.customer_id, + }); + + if (!stripeCustomer) { + return { + statusCode: 200, + bodyType: "json", + body: { + has_customer: false, + default_payment_method: null, + }, + }; + } + + const defaultPaymentMethod = await getDefaultCardPaymentMethodSummary({ + stripe, + stripeCustomer, + }); + + return { + statusCode: 200, + bodyType: "json", + body: { + has_customer: true, + default_payment_method: defaultPaymentMethod, + }, + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/payments/payment-method/[customer_type]/[customer_id]/set-default/route.ts b/apps/backend/src/app/api/latest/payments/payment-method/[customer_type]/[customer_id]/set-default/route.ts new file mode 100644 index 0000000000..f5d59102ee --- /dev/null +++ b/apps/backend/src/app/api/latest/payments/payment-method/[customer_type]/[customer_id]/set-default/route.ts @@ -0,0 +1,99 @@ +import { ensureClientCanAccessCustomer, ensureStripeCustomerForCustomer, getDefaultCardPaymentMethodSummary } from "@/lib/payments"; +import { getStripeForAccount } from "@/lib/stripe"; +import { getPrismaClientForTenancy } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { adaptSchema, clientOrHigherAuthTypeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; + +export const POST = createSmartRouteHandler({ + metadata: { + summary: "Set default payment method from a setup intent", + hidden: true, + tags: ["Payments"], + }, + request: yupObject({ + auth: yupObject({ + type: clientOrHigherAuthTypeSchema.defined(), + project: adaptSchema.defined(), + tenancy: adaptSchema.defined(), + }).defined(), + params: yupObject({ + customer_type: yupString().oneOf(["user", "team"]).defined(), + customer_id: yupString().defined(), + }).defined(), + body: yupObject({ + setup_intent_id: yupString().defined(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + success: yupBoolean().oneOf([true]).defined(), + default_payment_method: yupObject({ + id: yupString().defined(), + brand: yupString().nullable().defined(), + last4: yupString().nullable().defined(), + exp_month: yupNumber().nullable().defined(), + exp_year: yupNumber().nullable().defined(), + }).nullable().defined(), + }).defined(), + }), + handler: async ({ auth, params, body }, fullReq) => { + if (auth.type === "client") { + await ensureClientCanAccessCustomer({ + customerType: params.customer_type, + customerId: params.customer_id, + user: fullReq.auth?.user, + tenancy: auth.tenancy, + forbiddenMessage: "Clients can only manage their own payment method.", + }); + } + + const prisma = await getPrismaClientForTenancy(auth.tenancy); + const stripe = await getStripeForAccount({ tenancy: auth.tenancy }); + const stripeCustomer = await ensureStripeCustomerForCustomer({ + stripe, + prisma, + tenancyId: auth.tenancy.id, + customerType: params.customer_type, + customerId: params.customer_id, + }); + + const setupIntent = await stripe.setupIntents.retrieve(body.setup_intent_id); + if (setupIntent.customer !== stripeCustomer.id) { + throw new StatusError(StatusError.Forbidden, "Setup intent does not belong to this customer."); + } + if (setupIntent.status !== "succeeded") { + throw new StatusError(400, "Setup intent has not succeeded."); + } + if (!setupIntent.payment_method || typeof setupIntent.payment_method !== "string") { + throw new StatusError(500, "Setup intent missing payment method."); + } + + await stripe.customers.update(stripeCustomer.id, { + invoice_settings: { + default_payment_method: setupIntent.payment_method, + }, + }); + + const updatedCustomer = await stripe.customers.retrieve(stripeCustomer.id); + if (updatedCustomer.deleted) { + throw new StatusError(500, "Stripe customer was deleted unexpectedly."); + } + + const summary = await getDefaultCardPaymentMethodSummary({ + stripe, + stripeCustomer: updatedCustomer, + }); + + return { + statusCode: 200, + bodyType: "json", + body: { + success: true, + default_payment_method: summary, + }, + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/payments/payment-method/[customer_type]/[customer_id]/setup-intent/route.ts b/apps/backend/src/app/api/latest/payments/payment-method/[customer_type]/[customer_id]/setup-intent/route.ts new file mode 100644 index 0000000000..3b144d4a5e --- /dev/null +++ b/apps/backend/src/app/api/latest/payments/payment-method/[customer_type]/[customer_id]/setup-intent/route.ts @@ -0,0 +1,82 @@ +import { ensureClientCanAccessCustomer, ensureStripeCustomerForCustomer } from "@/lib/payments"; +import { getStripeForAccount } from "@/lib/stripe"; +import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { adaptSchema, clientOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; + +export const POST = createSmartRouteHandler({ + metadata: { + summary: "Create a setup intent to update default payment method", + hidden: true, + tags: ["Payments"], + }, + request: yupObject({ + auth: yupObject({ + type: clientOrHigherAuthTypeSchema.defined(), + project: adaptSchema.defined(), + tenancy: adaptSchema.defined(), + }).defined(), + params: yupObject({ + customer_type: yupString().oneOf(["user", "team"]).defined(), + customer_id: yupString().defined(), + }).defined(), + body: yupObject({}).default(() => ({})).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + client_secret: yupString().defined(), + stripe_account_id: yupString().defined(), + }).defined(), + }), + handler: async ({ auth, params }, fullReq) => { + if (auth.type === "client") { + await ensureClientCanAccessCustomer({ + customerType: params.customer_type, + customerId: params.customer_id, + user: fullReq.auth?.user, + tenancy: auth.tenancy, + forbiddenMessage: "Clients can only manage their own payment method.", + }); + } + + const prisma = await getPrismaClientForTenancy(auth.tenancy); + const stripe = await getStripeForAccount({ tenancy: auth.tenancy }); + const stripeCustomer = await ensureStripeCustomerForCustomer({ + stripe, + prisma, + tenancyId: auth.tenancy.id, + customerType: params.customer_type, + customerId: params.customer_id, + }); + + const setupIntent = await stripe.setupIntents.create({ + customer: stripeCustomer.id, + usage: "off_session", + payment_method_types: ["card"], + }); + if (!setupIntent.client_secret) { + throw new StatusError(500, "No client secret returned from Stripe."); + } + + const project = await globalPrismaClient.project.findUnique({ + where: { id: auth.tenancy.project.id }, + select: { stripeAccountId: true }, + }); + const stripeAccountId = project?.stripeAccountId; + if (!stripeAccountId) { + throw new StatusError(400, "Payments are not set up in this Stack Auth project."); + } + + return { + statusCode: 200, + bodyType: "json", + body: { + client_secret: setupIntent.client_secret, + stripe_account_id: stripeAccountId, + }, + }; + }, +}); 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 4fe55f0743..07d2f595a6 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 @@ -54,6 +54,12 @@ export const GET = createSmartRouteHandler({ id: product.id, quantity: product.quantity, product: productToInlineProduct(product.product), + type: product.type, + subscription: product.subscription ? { + current_period_end: product.subscription.currentPeriodEnd ? product.subscription.currentPeriodEnd.toISOString() : null, + cancel_at_period_end: product.subscription.cancelAtPeriodEnd, + is_cancelable: product.subscription.isCancelable, + } : null, }, })); diff --git a/apps/backend/src/lib/payments.tsx b/apps/backend/src/lib/payments.tsx index d6c15acf74..2d87535ca8 100644 --- a/apps/backend/src/lib/payments.tsx +++ b/apps/backend/src/lib/payments.tsx @@ -1,6 +1,9 @@ -import { PrismaClientTransaction } from "@/prisma-client"; +import { getPrismaClientForTenancy, PrismaClientTransaction } from "@/prisma-client"; +import { ensureUserTeamPermissionExists } from "@/lib/request-checks"; import { PurchaseCreationSource, SubscriptionStatus } from "@/generated/prisma/client"; +import { CustomerType } from "@/generated/prisma/client"; import { KnownErrors } from "@stackframe/stack-shared"; +import type { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users"; import type { inlineProductSchema, productSchema, productSchemaWithMetadata } from "@stackframe/stack-shared/dist/schema-fields"; import { SUPPORTED_CURRENCIES } from "@stackframe/stack-shared/dist/utils/currency-constants"; import { FAR_FUTURE_DATE, addInterval, getIntervalsElapsed } from "@stackframe/stack-shared/dist/utils/dates"; @@ -19,6 +22,35 @@ type Product = yup.InferType; type ProductWithMetadata = yup.InferType; type SelectedPrice = Exclude[string]; +export async function ensureClientCanAccessCustomer(options: { + customerType: "user" | "team", + customerId: string, + user: UsersCrud["Admin"]["Read"] | undefined, + tenancy: Tenancy, + forbiddenMessage: string, +}): Promise { + const currentUser = options.user; + if (!currentUser) { + throw new KnownErrors.UserAuthenticationRequired(); + } + if (options.customerType === "user") { + if (options.customerId !== currentUser.id) { + throw new StatusError(StatusError.Forbidden, options.forbiddenMessage); + } + return; + } + + const prisma = await getPrismaClientForTenancy(options.tenancy); + await ensureUserTeamPermissionExists(prisma, { + tenancy: options.tenancy, + teamId: options.customerId, + userId: currentUser.id, + permissionId: "team_admin", + errorType: "required", + recursive: true, + }); +} + export async function ensureProductIdOrInlineProduct( tenancy: Tenancy, accessType: "client" | "server" | "admin", @@ -265,6 +297,7 @@ type Subscription = { quantity: number, currentPeriodStart: Date, currentPeriodEnd: Date | null, + cancelAtPeriodEnd: boolean, status: SubscriptionStatus, createdAt: Date, }; @@ -301,6 +334,7 @@ export async function getSubscriptions(options: { quantity: s.quantity, currentPeriodStart: s.currentPeriodStart, currentPeriodEnd: s.currentPeriodEnd, + cancelAtPeriodEnd: s.cancelAtPeriodEnd, status: s.status, createdAt: s.createdAt, stripeSubscriptionId: s.stripeSubscriptionId, @@ -329,6 +363,7 @@ export async function getSubscriptions(options: { quantity: 1, currentPeriodStart: DEFAULT_PRODUCT_START_DATE, currentPeriodEnd: null, + cancelAtPeriodEnd: false, status: SubscriptionStatus.active, createdAt: DEFAULT_PRODUCT_START_DATE, stripeSubscriptionId: null, @@ -347,6 +382,7 @@ export async function getSubscriptions(options: { quantity: 1, currentPeriodStart: DEFAULT_PRODUCT_START_DATE, currentPeriodEnd: null, + cancelAtPeriodEnd: false, status: SubscriptionStatus.active, createdAt: DEFAULT_PRODUCT_START_DATE, stripeSubscriptionId: null, @@ -424,6 +460,117 @@ export async function ensureCustomerExists(options: { } } +function customerTypeToStripeCustomerType(customerType: "user" | "team") { + return customerType === "user" ? CustomerType.USER : CustomerType.TEAM; +} + +export async function getStripeCustomerForCustomerOrNull(options: { + stripe: Stripe, + prisma: PrismaClientTransaction, + tenancyId: string, + customerType: "user" | "team", + customerId: string, +}): Promise { + await ensureCustomerExists({ + prisma: options.prisma, + tenancyId: options.tenancyId, + customerType: options.customerType, + customerId: options.customerId, + }); + + const stripeCustomerType = customerTypeToStripeCustomerType(options.customerType); + const matchesCustomer = (customer: Stripe.Customer) => { + const storedType = customer.metadata.customerType; + if (!storedType) return true; + return storedType === stripeCustomerType; + }; + + const stripeCustomerSearch = await options.stripe.customers.search({ + query: `metadata['customerId']:'${options.customerId}'`, + }); + let matches = stripeCustomerSearch.data.filter(matchesCustomer); + + if (matches.length === 0) { + // Stripe's search is eventually consistent; fall back to listing to ensure we can find a newly created customer. + let startingAfter: string | undefined = undefined; + for (let i = 0; i < 10; i++) { + const page: Stripe.ApiList = await options.stripe.customers.list({ + limit: 100, + ...startingAfter ? { starting_after: startingAfter } : {}, + }); + const exactMatches = page.data.filter((customer) => ( + customer.metadata.customerId === options.customerId && matchesCustomer(customer) + )); + if (exactMatches.length > 0) { + matches = exactMatches; + break; + } + if (!page.has_more || page.data.length === 0) { + break; + } + startingAfter = page.data[page.data.length - 1].id; + } + } + + if (matches.length > 1) { + throw new StackAssertionError("Multiple Stripe customers found for customerId; customerType filtering was ambiguous", { + customerId: options.customerId, + customerType: options.customerType, + stripeCustomerIds: matches.map((c) => c.id), + }); + } + return matches[0] ?? null; +} + +export async function ensureStripeCustomerForCustomer(options: { + stripe: Stripe, + prisma: PrismaClientTransaction, + tenancyId: string, + customerType: "user" | "team", + customerId: string, +}): Promise { + const existing = await getStripeCustomerForCustomerOrNull(options); + if (existing) { + return existing; + } + const stripeCustomerType = customerTypeToStripeCustomerType(options.customerType); + return await options.stripe.customers.create({ + metadata: { + customerId: options.customerId, + customerType: stripeCustomerType, + }, + }); +} + +export type StripeCardPaymentMethodSummary = { + id: string, + brand: string | null, + last4: string | null, + exp_month: number | null, + exp_year: number | null, +}; + +export async function getDefaultCardPaymentMethodSummary(options: { + stripe: Stripe, + stripeCustomer: Stripe.Customer, +}): Promise { + const defaultPaymentMethodId = options.stripeCustomer.invoice_settings.default_payment_method; + if (!defaultPaymentMethodId || typeof defaultPaymentMethodId !== "string") { + return null; + } + const pm = await options.stripe.paymentMethods.retrieve(defaultPaymentMethodId); + if (pm.type !== "card" || !pm.card) { + return null; + } + return { + id: pm.id, + brand: pm.card.brand, + last4: pm.card.last4, + exp_month: pm.card.exp_month, + exp_year: pm.card.exp_year, + }; +} + export function productToInlineProduct(product: ProductWithMetadata): yup.InferType { return { display_name: product.displayName ?? "Product", @@ -648,6 +795,11 @@ export type OwnedProduct = { product: Product, createdAt: Date, sourceId: string, + subscription: null | { + currentPeriodEnd: Date | null, + cancelAtPeriodEnd: boolean, + isCancelable: boolean, + }, }; export async function getOwnedProductsForCustomer(options: { @@ -695,6 +847,11 @@ export async function getOwnedProductsForCustomer(options: { product: subscription.product, createdAt: subscription.createdAt, sourceId, + subscription: { + currentPeriodEnd: subscription.currentPeriodEnd, + cancelAtPeriodEnd: subscription.cancelAtPeriodEnd, + isCancelable: subscription.id !== null && subscription.productId !== null, + }, }); } @@ -707,6 +864,7 @@ export async function getOwnedProductsForCustomer(options: { product, createdAt: purchase.createdAt, sourceId: purchase.id, + subscription: null, }); } diff --git a/apps/e2e/tests/backend/endpoints/api/v1/payments/billing.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/payments/billing.test.ts new file mode 100644 index 0000000000..d144390a84 --- /dev/null +++ b/apps/e2e/tests/backend/endpoints/api/v1/payments/billing.test.ts @@ -0,0 +1,137 @@ +import { it } from "../../../../../helpers"; +import { Auth, niceBackendFetch, Payments, Project, Team, User } from "../../../../backend-helpers"; + +it("should return no customer when payments are not set up", async ({ expect }) => { + await Project.createAndSwitch(); + + const { userId } = await Auth.fastSignUp(); + + const response = await niceBackendFetch(`/api/v1/payments/billing/user/${userId}`, { + accessType: "client", + }); + expect(response).toMatchInlineSnapshot(` + NiceResponse { + "status": 200, + "body": { + "default_payment_method": null, + "has_customer": false, + }, + "headers": Headers {