diff --git a/apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/route.ts b/apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/route.ts index 6ddd0e1301..6440677e35 100644 --- a/apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/route.ts +++ b/apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/route.ts @@ -1,4 +1,4 @@ -import { ensureCustomerExists, getItemQuantityForCustomer } from "@/lib/payments"; +import { ensureClientCanAccessCustomer, ensureCustomerExists, getItemQuantityForCustomer } from "@/lib/payments"; import { getPrismaClientForTenancy } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { KnownErrors } from "@stackframe/stack-shared"; @@ -64,7 +64,16 @@ export const GET = createSmartRouteHandler({ }), }).defined(), }), - handler: async (req) => { + handler: async (req, fullReq) => { + if (req.auth.type === "client") { + await ensureClientCanAccessCustomer({ + customerType: req.params.customer_type, + customerId: req.params.customer_id, + user: fullReq.auth?.user, + tenancy: req.auth.tenancy, + forbiddenMessage: "Clients can only access their own user or team items.", + }); + } const { tenancy } = req.auth; const paymentsConfig = tenancy.config.payments; @@ -100,5 +109,3 @@ export const GET = createSmartRouteHandler({ }; }, }); - - 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 a340af58fa..bb9fb4a0b9 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 @@ -1,4 +1,4 @@ -import { ensureProductIdOrInlineProduct, getOwnedProductsForCustomer, grantProductToCustomer, productToInlineProduct } from "@/lib/payments"; +import { ensureClientCanAccessCustomer, ensureProductIdOrInlineProduct, getOwnedProductsForCustomer, grantProductToCustomer, productToInlineProduct } from "@/lib/payments"; import { getPrismaClientForTenancy } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { adaptSchema, clientOrHigherAuthTypeSchema, inlineProductSchema, serverOrHigherAuthTypeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; @@ -32,7 +32,16 @@ export const GET = createSmartRouteHandler({ bodyType: yupString().oneOf(["json"]).defined(), body: customerProductsListResponseSchema, }), - handler: async ({ auth, params, query }) => { + handler: async ({ auth, params, query }, 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 access their own user or team products.", + }); + } const prisma = await getPrismaClientForTenancy(auth.tenancy); const ownedProducts = await getOwnedProductsForCustomer({ prisma, @@ -191,4 +200,3 @@ export const POST = createSmartRouteHandler({ }; }, }); - diff --git a/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts b/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts index 46f29d90e2..6e4b4e1fab 100644 --- a/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts +++ b/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts @@ -1,4 +1,4 @@ -import { ensureProductIdOrInlineProduct, getCustomerPurchaseContext } from "@/lib/payments"; +import { ensureClientCanAccessCustomer, ensureProductIdOrInlineProduct, getCustomerPurchaseContext } from "@/lib/payments"; import { validateRedirectUrl } from "@/lib/redirect-urls"; import { getStackStripe, getStripeForAccount } from "@/lib/stripe"; import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client"; @@ -66,11 +66,22 @@ export const POST = createSmartRouteHandler({ }), }).defined(), }), - handler: async (req) => { + handler: async (req, fullReq) => { const { tenancy } = req.auth; if (tenancy.config.payments.blockNewPurchases) { throw new KnownErrors.NewPurchasesBlocked(); } + + if (req.auth.type === "client") { + await ensureClientCanAccessCustomer({ + customerType: req.body.customer_type, + customerId: req.body.customer_id, + user: fullReq.auth?.user, + tenancy, + forbiddenMessage: "Clients can only create purchase URLs for their own user or teams they have admin access to.", + }); + } + const stripe = await getStripeForAccount({ tenancy }); const productConfig = await ensureProductIdOrInlineProduct(tenancy, req.auth.type, req.body.product_id, req.body.product_inline); const customerType = productConfig.customerType; diff --git a/apps/backend/src/lib/payments.tsx b/apps/backend/src/lib/payments.tsx index 95c8cefcab..fce17ff31d 100644 --- a/apps/backend/src/lib/payments.tsx +++ b/apps/backend/src/lib/payments.tsx @@ -26,7 +26,7 @@ type ProductWithMetadata = yup.InferType; type SelectedPrice = Exclude[string]; export async function ensureClientCanAccessCustomer(options: { - customerType: "user" | "team", + customerType: "user" | "team" | "custom", customerId: string, user: UsersCrud["Admin"]["Read"] | undefined, tenancy: Tenancy, @@ -36,6 +36,9 @@ export async function ensureClientCanAccessCustomer(options: { if (!currentUser) { throw new KnownErrors.UserAuthenticationRequired(); } + if (options.customerType === "custom") { + throw new StatusError(StatusError.Forbidden, options.forbiddenMessage); + } if (options.customerType === "user") { if (options.customerId !== currentUser.id) { throw new StatusError(StatusError.Forbidden, options.forbiddenMessage); diff --git a/apps/e2e/tests/backend/backend-helpers.ts b/apps/e2e/tests/backend/backend-helpers.ts index 208bfbf034..4bf076a33d 100644 --- a/apps/e2e/tests/backend/backend-helpers.ts +++ b/apps/e2e/tests/backend/backend-helpers.ts @@ -1561,7 +1561,7 @@ export namespace Payments { const { userId } = await User.create(); const response = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", { method: "POST", - accessType: "client", + accessType: "server", body: { customer_type: "user", customer_id: userId, diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.ts index 242ba04e5f..312350e0c1 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { expect } from "vitest"; import { it } from "../../../../../helpers"; -import { Payments, Project, User, niceBackendFetch } from "../../../../backend-helpers"; +import { Auth, Payments, Project, niceBackendFetch } from "../../../../backend-helpers"; function createDefaultPaymentsConfig(testMode: boolean | undefined) { return { @@ -60,7 +60,7 @@ async function createPurchaseCode(options: { userId: string, productId: string } } async function createTestModeTransaction(productId: string, priceId: string) { - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); const code = await createPurchaseCode({ userId, productId }); const response = await niceBackendFetch("/api/latest/internal/payments/test-mode-purchase-session", { accessType: "admin", @@ -136,7 +136,7 @@ it("returns SubscriptionInvoiceNotFound when id does not exist", async () => { it("refunds non-test mode one-time purchases created via Stripe webhooks", async () => { const config = await setupProjectWithPaymentsConfig({ testMode: false }); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); const accountInfo = await niceBackendFetch("/api/latest/internal/payments/stripe/account-info", { accessType: "admin", diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/transactions.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/transactions.test.ts index 5a92725262..5e03b43a8a 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal/transactions.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/transactions.test.ts @@ -1,7 +1,7 @@ import { createHmac } from "node:crypto"; import { expect } from "vitest"; import { it } from "../../../../../helpers"; -import { Payments as PaymentsHelper, Project, Team, User, niceBackendFetch } from "../../../../backend-helpers"; +import { Auth, Payments as PaymentsHelper, Project, Team, User, niceBackendFetch } from "../../../../backend-helpers"; type PaymentsConfigOptions = { extraProducts?: Record, @@ -117,7 +117,7 @@ it("returns empty list for fresh project", async () => { it("includes TEST_MODE subscription", async () => { await setupProjectWithPaymentsConfig(); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); const code = await createPurchaseCode({ userId, productId: "sub-product" }); const testModeRes = await niceBackendFetch("/api/latest/internal/payments/test-mode-purchase-session", { @@ -179,7 +179,7 @@ it("includes TEST_MODE subscription", async () => { it("includes TEST_MODE one-time purchase", async () => { await setupProjectWithPaymentsConfig(); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); const code = await createPurchaseCode({ userId, productId: "otp-product" }); const testModeRes = await niceBackendFetch("/api/latest/internal/payments/test-mode-purchase-session", { @@ -233,7 +233,7 @@ it("includes TEST_MODE one-time purchase", async () => { it("includes item quantity change entries", async () => { await setupProjectWithPaymentsConfig(); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); const changeRes = await niceBackendFetch(`/api/latest/payments/items/user/${userId}/credits/update-quantity`, { accessType: "server", @@ -274,7 +274,7 @@ it("includes item quantity change entries", async () => { it("supports concatenated cursor pagination", async () => { await setupProjectWithPaymentsConfig(); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); // Make a few entries across tables { @@ -318,7 +318,7 @@ it("supports concatenated cursor pagination", async () => { it("omits subscription-renewal entries for subscription creation invoices", async () => { const config = await setupProjectWithPaymentsConfig(); const subProduct = config.products["sub-product"]; - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); const accountInfo = await niceBackendFetch("/api/latest/internal/payments/stripe/account-info", { accessType: "admin", @@ -421,7 +421,7 @@ it("omits subscription-renewal entries for subscription creation invoices", asyn it("filters results by transaction type", async () => { await setupProjectWithPaymentsConfig(); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); const subCode = await createPurchaseCode({ userId, productId: "sub-product" }); await niceBackendFetch("/api/latest/internal/payments/test-mode-purchase-session", { @@ -472,8 +472,8 @@ it("filters results by customer_type across sources", async () => { "team-credits": { displayName: "Team Credits", customerType: "team" }, }, }); - const { userId } = await User.create(); - const { teamId } = await Team.create(); + const { userId } = await Auth.fastSignUp(); + const { teamId } = await Team.create({ accessType: "server", creatorUserId: userId }); const userCode = await createPurchaseCode({ userId, productId: "sub-product" }); await niceBackendFetch("/api/latest/internal/payments/test-mode-purchase-session", { @@ -534,7 +534,7 @@ it("returns server-granted subscriptions in transactions", async () => { }, }, }); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); const grantResponse = await niceBackendFetch(`/api/latest/payments/products/user/${userId}`, { accessType: "server", diff --git a/apps/e2e/tests/backend/endpoints/api/v1/payments/before-catalog-to-product-line-rename/outdated--purchase-session.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/payments/before-catalog-to-product-line-rename/outdated--purchase-session.test.ts index 7d9c25132e..107b17f092 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/payments/before-catalog-to-product-line-rename/outdated--purchase-session.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/payments/before-catalog-to-product-line-rename/outdated--purchase-session.test.ts @@ -4,7 +4,7 @@ * The migration functions in schema.ts should handle the conversion automatically. */ import { it } from "../../../../../../helpers"; -import { Payments, Project, User, niceBackendFetch } from "../../../../../backend-helpers"; +import { Auth, Payments, Project, niceBackendFetch } from "../../../../../backend-helpers"; it("should work with old catalogs config property", async ({ expect }) => { await Project.createAndSwitch(); @@ -34,7 +34,7 @@ it("should work with old catalogs config property", async ({ expect }) => { }, }); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); const createUrlResponse = await niceBackendFetch("/api/v1/payments/purchases/create-purchase-url", { method: "POST", accessType: "client", @@ -92,7 +92,7 @@ it("should block one-time purchase in same group using old catalogs config", asy }, }); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); // Purchase offerA in TEST_MODE const urlA = await niceBackendFetch("/api/v1/payments/purchases/create-purchase-url", { method: "POST", @@ -174,7 +174,7 @@ it("should work with subscription switching using old catalogs config", async ({ }, }); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); // First purchase: Offer A const createUrlA = await niceBackendFetch("/api/v1/payments/purchases/create-purchase-url", { diff --git a/apps/e2e/tests/backend/endpoints/api/v1/payments/before-catalog-to-product-line-rename/outdated--validate-code.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/payments/before-catalog-to-product-line-rename/outdated--validate-code.test.ts index 1793aa61a1..b76fa82d39 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/payments/before-catalog-to-product-line-rename/outdated--validate-code.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/payments/before-catalog-to-product-line-rename/outdated--validate-code.test.ts @@ -3,7 +3,7 @@ * still work correctly for code validation after the rename to `productLines` and `productLineId`. */ import { it } from "../../../../../../helpers"; -import { Payments, Project, User, niceBackendFetch } from "../../../../../backend-helpers"; +import { Auth, Payments, Project, User, niceBackendFetch } from "../../../../../backend-helpers"; it("should validate purchase code with old catalogs config", async ({ expect }) => { await Project.createAndSwitch(); @@ -33,7 +33,7 @@ it("should validate purchase code with old catalogs config", async ({ expect }) }, }); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); const createUrlResponse = await niceBackendFetch("/api/v1/payments/purchases/create-purchase-url", { method: "POST", accessType: "client", @@ -105,7 +105,7 @@ it("should detect conflicting products with old catalogs config", async ({ expec }, }); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); // First, purchase productA in test mode const createUrlA = await niceBackendFetch("/api/v1/payments/purchases/create-purchase-url", { diff --git a/apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--create-purchase-url.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--create-purchase-url.test.ts index b6af3669b1..56b75970f9 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--create-purchase-url.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--create-purchase-url.test.ts @@ -1,17 +1,18 @@ import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids"; import { it } from "../../../../../../helpers"; import { withPortPrefix } from "../../../../../../helpers/ports"; -import { Auth, Payments, Project, User, niceBackendFetch } from "../../../../../backend-helpers"; +import { Auth, Payments, Project, niceBackendFetch } from "../../../../../backend-helpers"; it("should not be able to create purchase URL without offer_id or offer_inline", async ({ expect }) => { await Project.createAndSwitch(); await Payments.setup(); + const { userId } = await Auth.fastSignUp(); const response = await niceBackendFetch("/api/v1/payments/purchases/create-purchase-url", { method: "POST", accessType: "client", body: { customer_type: "user", - customer_id: generateUuid(), + customer_id: userId, }, }); expect(response).toMatchInlineSnapshot(` @@ -46,12 +47,13 @@ it("should error for non-existent offer_id", async ({ expect }) => { }, }); + const { userId } = await Auth.fastSignUp(); const response = await niceBackendFetch("/api/v1/payments/purchases/create-purchase-url", { method: "POST", accessType: "client", body: { customer_type: "user", - customer_id: generateUuid(), + customer_id: userId, offer_id: "non-existent-offer", }, }); @@ -97,10 +99,9 @@ it("should error for invalid customer_id", async ({ expect }) => { }, }); - await Auth.fastSignUp(); const response = await niceBackendFetch("/api/v1/payments/purchases/create-purchase-url", { method: "POST", - accessType: "client", + accessType: "server", body: { customer_type: "team", customer_id: generateUuid(), @@ -150,13 +151,13 @@ it("should error for no connected stripe account", async ({ expect }) => { }, }); - const user = await User.create(); + const { userId } = await Auth.fastSignUp(); const response = await niceBackendFetch("/api/v1/payments/purchases/create-purchase-url", { method: "POST", accessType: "client", body: { customer_type: "user", - customer_id: user.userId, + customer_id: userId, product_id: "test-product", }, }); @@ -221,7 +222,7 @@ it("should error for server-only offer when calling from client", async ({ expec }, }); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); const response = await niceBackendFetch("/api/v1/payments/purchases/create-purchase-url", { method: "POST", accessType: "client", @@ -302,7 +303,7 @@ it("should allow valid offer_id", async ({ expect }) => { }, }); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); const response = await niceBackendFetch("/api/v1/payments/purchases/create-purchase-url", { method: "POST", accessType: "client", diff --git a/apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--purchase-session.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--purchase-session.test.ts index edae75a14a..c1c9e7d013 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--purchase-session.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--purchase-session.test.ts @@ -28,7 +28,43 @@ it("should error on invalid code", async ({ expect }) => { }); it("should error on invalid price_id", async ({ expect }) => { - const { code } = await Payments.createPurchaseUrlAndGetCode(); + await Project.createAndSwitch(); + await Payments.setup(); + await Project.updateConfig({ + payments: { + testMode: false, + products: { + "test-product": { + displayName: "Test Product", + customerType: "user", + serverOnly: false, + stackable: false, + prices: { + "monthly": { + USD: "1000", + interval: [1, "month"], + }, + }, + includedItems: {}, + }, + }, + }, + }); + + const { userId } = await Auth.fastSignUp(); + const createPurchaseUrlRes = await niceBackendFetch("/api/v1/payments/purchases/create-purchase-url", { + method: "POST", + accessType: "client", + body: { + customer_type: "user", + customer_id: userId, + offer_id: "test-product", + }, + }); + expect(createPurchaseUrlRes.status).toBe(200); + const code = (createPurchaseUrlRes.body as { url: string }).url.match(/\/purchase\/([a-z0-9-_]+)/)?.[1]!; + expect(code).toBeDefined(); + const response = await niceBackendFetch("/api/v1/payments/purchases/purchase-session", { method: "POST", accessType: "client", @@ -47,7 +83,43 @@ it("should error on invalid price_id", async ({ expect }) => { }); it("should properly create subscription", async ({ expect }) => { - const { code } = await Payments.createPurchaseUrlAndGetCode(); + await Project.createAndSwitch(); + await Payments.setup(); + await Project.updateConfig({ + payments: { + testMode: false, + products: { + "test-product": { + displayName: "Test Product", + customerType: "user", + serverOnly: false, + stackable: false, + prices: { + "monthly": { + USD: "1000", + interval: [1, "month"], + }, + }, + includedItems: {}, + }, + }, + }, + }); + + const { userId } = await Auth.fastSignUp(); + const createPurchaseUrlRes = await niceBackendFetch("/api/v1/payments/purchases/create-purchase-url", { + method: "POST", + accessType: "client", + body: { + customer_type: "user", + customer_id: userId, + offer_id: "test-product", + }, + }); + expect(createPurchaseUrlRes.status).toBe(200); + const code = (createPurchaseUrlRes.body as { url: string }).url.match(/\/purchase\/([a-z0-9-_]+)/)?.[1]!; + expect(code).toBeDefined(); + const response = await niceBackendFetch("/api/v1/payments/purchases/purchase-session", { method: "POST", accessType: "client", @@ -83,7 +155,7 @@ it("should return client secret for one-time price (no interval)", async ({ expe }, }); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); const urlRes = await niceBackendFetch("/api/v1/payments/purchases/create-purchase-url", { method: "POST", accessType: "client", @@ -129,7 +201,7 @@ it("should error on one-time price quantity > 1 when offer is not stackable", as }, }); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); const urlRes = await niceBackendFetch("/api/v1/payments/purchases/create-purchase-url", { method: "POST", accessType: "client", @@ -190,7 +262,7 @@ it("should return client secret for one-time price even if a conflicting group s }, }); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); // Create test-mode DB-only subscription for subOffer const createUrlRespA = await niceBackendFetch("/api/v1/payments/purchases/create-purchase-url", { @@ -257,7 +329,7 @@ it("test-mode should error on one-time price quantity > 1 when offer is not stac }, }); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); const urlRes = await niceBackendFetch("/api/v1/payments/purchases/create-purchase-url", { method: "POST", accessType: "client", @@ -381,10 +453,11 @@ it("creates subscription in test mode and increases included item quantity", asy }, }); - const { userId } = await User.create(); + const { userId, accessToken, refreshToken } = await Auth.fastSignUp(); const createUrlResponse = await niceBackendFetch("/api/v1/payments/purchases/create-purchase-url", { method: "POST", accessType: "client", + userAuth: { accessToken, refreshToken }, body: { customer_type: "user", customer_id: userId, @@ -399,6 +472,7 @@ it("creates subscription in test mode and increases included item quantity", asy const getBefore = await niceBackendFetch(`/api/v1/payments/items/user/${userId}/test-item`, { accessType: "client", + userAuth: { accessToken, refreshToken }, }); expect(getBefore.status).toBe(200); expect(getBefore.body.quantity).toBe(0); @@ -416,6 +490,7 @@ it("creates subscription in test mode and increases included item quantity", asy const getAfter = await niceBackendFetch(`/api/v1/payments/items/user/${userId}/test-item`, { accessType: "client", + userAuth: { accessToken, refreshToken }, }); expect(getAfter.status).toBe(200); expect(getAfter.body.quantity).toBe(2); @@ -502,10 +577,11 @@ it("allows stackable quantity in test mode and multiplies included items", async }, }); - const { userId } = await User.create(); + const { userId, accessToken, refreshToken } = await Auth.fastSignUp(); const createUrlResponse = await niceBackendFetch("/api/v1/payments/purchases/create-purchase-url", { method: "POST", accessType: "client", + userAuth: { accessToken, refreshToken }, body: { customer_type: "user", customer_id: userId, @@ -520,6 +596,7 @@ it("allows stackable quantity in test mode and multiplies included items", async const getBefore = await niceBackendFetch(`/api/v1/payments/items/user/${userId}/test-item`, { accessType: "client", + userAuth: { accessToken, refreshToken }, }); expect(getBefore.status).toBe(200); expect(getBefore.body.quantity).toBe(0); @@ -538,6 +615,7 @@ it("allows stackable quantity in test mode and multiplies included items", async const getAfter = await niceBackendFetch(`/api/v1/payments/items/user/${userId}/test-item`, { accessType: "client", + userAuth: { accessToken, refreshToken }, }); expect(getAfter.status).toBe(200); expect(getAfter.body.quantity).toBe(6); @@ -585,7 +663,7 @@ it("should update existing stripe subscription when switching offers within a gr }, }); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); // First purchase: Offer A const createUrlA = await niceBackendFetch("/api/v1/payments/purchases/create-purchase-url", { @@ -692,7 +770,7 @@ it("should cancel DB-only subscription then create Stripe subscription when swit }, }); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); // Create test-mode DB-only subscription for offerA const resUrlA = await niceBackendFetch("/api/v1/payments/purchases/create-purchase-url", { @@ -770,7 +848,7 @@ it("should block one-time purchase for same product after prior one-time purchas }, }); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); // First: create code and complete in TEST_MODE (persists OneTimePurchase) const createUrl1 = await niceBackendFetch("/api/v1/payments/purchases/create-purchase-url", { method: "POST", @@ -843,7 +921,7 @@ it("should block one-time purchase in same group after prior one-time purchase i }, }); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); // Purchase offerA in TEST_MODE (persists OneTimePurchase) const urlA = await niceBackendFetch("/api/v1/payments/purchases/create-purchase-url", { method: "POST", diff --git a/apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--validate-code.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--validate-code.test.ts index 578c130689..7fa52f7868 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--validate-code.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--validate-code.test.ts @@ -1,5 +1,5 @@ import { it } from "../../../../../../helpers"; -import { Payments, Project, User, niceBackendFetch } from "../../../../../backend-helpers"; +import { Auth, Payments, Project, User, niceBackendFetch } from "../../../../../backend-helpers"; it("should error on invalid code", async ({ expect }) => { @@ -93,7 +93,7 @@ it("should set already_bought_non_stackable when user already owns non-stackable }, }); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); // Create a code for test-offer and purchase it in test mode (creates DB subscription) const createUrlRes1 = await niceBackendFetch("/api/v1/payments/purchases/create-purchase-url", { method: "POST", @@ -178,7 +178,7 @@ it("should include conflicting_group_offers when switching within the same group }, }); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); // Subscribe to offerA in test mode const resUrlA = await niceBackendFetch("/api/v1/payments/purchases/create-purchase-url", { diff --git a/apps/e2e/tests/backend/endpoints/api/v1/payments/block-new-purchases.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/payments/block-new-purchases.test.ts index d16f928dc4..87528e6a83 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/payments/block-new-purchases.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/payments/block-new-purchases.test.ts @@ -25,7 +25,7 @@ it("should block create-purchase-url when blockNewPurchases is enabled", async ( }, }); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); const response = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", { method: "POST", accessType: "client", @@ -74,7 +74,7 @@ it("should block purchase-session when blockNewPurchases is enabled", async ({ e }); // Create purchase URL before enabling blockNewPurchases - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); const createUrlResponse = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", { method: "POST", accessType: "client", @@ -91,9 +91,7 @@ it("should block purchase-session when blockNewPurchases is enabled", async ({ e // Now enable blockNewPurchases await Project.updateConfig({ - payments: { - blockNewPurchases: true, - }, + "payments.blockNewPurchases": true, }); // Try to use the purchase session @@ -146,7 +144,7 @@ it("should block test-mode-purchase-session when blockNewPurchases is enabled", }); // Create purchase URL before enabling blockNewPurchases - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); const createUrlResponse = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", { method: "POST", accessType: "client", @@ -163,9 +161,7 @@ it("should block test-mode-purchase-session when blockNewPurchases is enabled", // Now enable blockNewPurchases await Project.updateConfig({ - payments: { - blockNewPurchases: true, - }, + "payments.blockNewPurchases": true, }); // Try to use the test-mode purchase session @@ -279,7 +275,7 @@ it("should allow purchases when blockNewPurchases is false (default)", async ({ }, }); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); const response = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", { method: "POST", accessType: "client", @@ -316,7 +312,7 @@ it("should allow purchases when blockNewPurchases is not set (defaults to false) }, }); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); const response = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", { method: "POST", accessType: "client", @@ -354,7 +350,7 @@ it("should allow disabling blockNewPurchases to resume purchases", async ({ expe }, }); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); // First, verify purchases are blocked const blockedResponse = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", { diff --git a/apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts index 2253444213..177cc6505b 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts @@ -1,17 +1,18 @@ import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids"; import { it } from "../../../../../helpers"; import { withPortPrefix } from "../../../../../helpers/ports"; -import { Auth, niceBackendFetch, Payments, Project, User } from "../../../../backend-helpers"; +import { Auth, niceBackendFetch, Payments, Project } from "../../../../backend-helpers"; it("should not be able to create purchase URL without product_id or product_inline", async ({ expect }) => { await Project.createAndSwitch(); await Payments.setup(); + const { userId } = await Auth.fastSignUp(); const response = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", { method: "POST", accessType: "client", body: { customer_type: "user", - customer_id: generateUuid(), + customer_id: userId, }, }); expect(response).toMatchInlineSnapshot(` @@ -46,12 +47,13 @@ it("should error for non-existent product_id", async ({ expect }) => { }, }); + const { userId } = await Auth.fastSignUp(); const response = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", { method: "POST", accessType: "client", body: { customer_type: "user", - customer_id: generateUuid(), + customer_id: userId, product_id: "non-existent-product", }, }); @@ -97,10 +99,9 @@ it("should error for invalid customer_id", async ({ expect }) => { }, }); - await Auth.fastSignUp(); const response = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", { method: "POST", - accessType: "client", + accessType: "server", body: { customer_type: "team", customer_id: generateUuid(), @@ -150,13 +151,13 @@ it("should error for no connected stripe account", async ({ expect }) => { }, }); - const user = await User.create(); + const { userId } = await Auth.fastSignUp(); const response = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", { method: "POST", accessType: "client", body: { customer_type: "user", - customer_id: user.userId, + customer_id: userId, product_id: "test-product", }, }); @@ -221,7 +222,7 @@ it("should error for server-only product when calling from client", async ({ exp }, }); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); const response = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", { method: "POST", accessType: "client", @@ -386,7 +387,7 @@ it("should allow valid product_id", async ({ expect }) => { }, }); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); const response = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", { method: "POST", accessType: "client", @@ -429,7 +430,7 @@ it("should error when customer already owns a non-stackable product", async ({ e }, }); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); const firstResponse = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", { method: "POST", accessType: "client", @@ -488,6 +489,43 @@ it("should error when customer already owns a non-stackable product", async ({ e `); }); +it("should error when client tries to create purchase URL for another user", async ({ expect }) => { + await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + await Payments.setup(); + await Project.updateConfig({ + payments: { + products: { + "test-product": { + displayName: "Test Product", + customerType: "user", + serverOnly: false, + stackable: false, + prices: { + "monthly": { + USD: "1000", + interval: [1, "month"], + }, + }, + includedItems: {}, + }, + }, + }, + }); + + await Auth.fastSignUp(); + const response = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", { + method: "POST", + accessType: "client", + body: { + customer_type: "user", + customer_id: generateUuid(), + product_id: "test-product", + }, + }); + expect(response.status).toBe(403); + expect(response.body).toBe("Clients can only create purchase URLs for their own user or teams they have admin access to."); +}); + it("should error for untrusted return_url", async ({ expect }) => { await Project.createAndSwitch({ config: { magic_link_enabled: true } }); await Payments.setup(); @@ -511,7 +549,7 @@ it("should error for untrusted return_url", async ({ expect }) => { }, }); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); const response = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", { method: "POST", accessType: "client", diff --git a/apps/e2e/tests/backend/endpoints/api/v1/payments/items.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/payments/items.test.ts index dda009d383..c9fd16364d 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/payments/items.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/payments/items.test.ts @@ -49,8 +49,8 @@ it("should be able to get item information with valid customer and item IDs", as }, }); - const user = await User.create(); - const response = await niceBackendFetch(`/api/latest/payments/items/user/${user.userId}/test-item`, { + const { userId } = await Auth.fastSignUp(); + const response = await niceBackendFetch(`/api/latest/payments/items/user/${userId}/test-item`, { accessType: "client", }); expect(response).toMatchInlineSnapshot(` @@ -79,8 +79,8 @@ it("should return ItemNotFound error for non-existent item", async ({ expect }) }, }); - const user = await User.create(); - const response = await niceBackendFetch(`/api/latest/payments/items/user/${user.userId}/non-existent-item`, { + const { userId } = await Auth.fastSignUp(); + const response = await niceBackendFetch(`/api/latest/payments/items/user/${userId}/non-existent-item`, { accessType: "client", }); expect(response).toMatchInlineSnapshot(` @@ -112,8 +112,8 @@ it("should return ItemCustomerTypeDoesNotMatch error for user accessing team ite }, }); - const user = await User.create(); - const response = await niceBackendFetch(`/api/latest/payments/items/user/${user.userId}/test-item`, { + const { userId } = await Auth.fastSignUp(); + const response = await niceBackendFetch(`/api/latest/payments/items/user/${userId}/test-item`, { accessType: "client", }); expect(response).toMatchInlineSnapshot(` @@ -178,16 +178,16 @@ it("aggregates item quantity changes in item quantity", async ({ expect }) => { }, }); - const user = await User.create(); + const { userId } = await Auth.fastSignUp(); - const post1 = await niceBackendFetch(`/api/latest/payments/items/user/${user.userId}/test-item/update-quantity?allow_negative=false`, { + const post1 = await niceBackendFetch(`/api/latest/payments/items/user/${userId}/test-item/update-quantity?allow_negative=false`, { method: "POST", accessType: "admin", body: { delta: 2 }, }); expect(post1.status).toBe(200); - const get1 = await niceBackendFetch(`/api/latest/payments/items/user/${user.userId}/test-item`, { + const get1 = await niceBackendFetch(`/api/latest/payments/items/user/${userId}/test-item`, { accessType: "client", }); expect(get1.status).toBe(200); @@ -207,16 +207,16 @@ it("ignores expired changes", async ({ expect }) => { }, }); - const user = await User.create(); + const { userId } = await Auth.fastSignUp(); - const post = await niceBackendFetch(`/api/latest/payments/items/user/${user.userId}/test-item/update-quantity?allow_negative=false`, { + const post = await niceBackendFetch(`/api/latest/payments/items/user/${userId}/test-item/update-quantity?allow_negative=false`, { method: "POST", accessType: "admin", body: { delta: 4, expires_at: new Date(Date.now() - 1000).toISOString() }, }); expect(post.status).toBe(200); - const get = await niceBackendFetch(`/api/latest/payments/items/user/${user.userId}/test-item`, { + const get = await niceBackendFetch(`/api/latest/payments/items/user/${userId}/test-item`, { accessType: "client", }); expect(get.status).toBe(200); @@ -236,10 +236,10 @@ it("sums multiple non-expired changes", async ({ expect }) => { }, }); - const user = await User.create(); + const { userId } = await Auth.fastSignUp(); for (const q of [2, -1, 5]) { - const r = await niceBackendFetch(`/api/latest/payments/items/user/${user.userId}/test-item/update-quantity?allow_negative=false`, { + const r = await niceBackendFetch(`/api/latest/payments/items/user/${userId}/test-item/update-quantity?allow_negative=false`, { method: "POST", accessType: "admin", body: { delta: q }, @@ -247,7 +247,7 @@ it("sums multiple non-expired changes", async ({ expect }) => { expect(r.status).toBe(200); } - const get = await niceBackendFetch(`/api/latest/payments/items/user/${user.userId}/test-item`, { + const get = await niceBackendFetch(`/api/latest/payments/items/user/${userId}/test-item`, { accessType: "client", }); expect(get.status).toBe(200); @@ -404,9 +404,9 @@ it("should allow negative quantity changes when allow_negative is true", async ( }, }); - const user = await User.create(); + const { userId } = await Auth.fastSignUp(); - const response = await niceBackendFetch(`/api/latest/payments/items/user/${user.userId}/test-item/update-quantity?allow_negative=true`, { + const response = await niceBackendFetch(`/api/latest/payments/items/user/${userId}/test-item/update-quantity?allow_negative=true`, { method: "POST", accessType: "admin", body: { delta: -3 }, @@ -421,7 +421,7 @@ it("should allow negative quantity changes when allow_negative is true", async ( } `); - const getItemResponse = await niceBackendFetch(`/api/latest/payments/items/user/${user.userId}/test-item`, { + const getItemResponse = await niceBackendFetch(`/api/latest/payments/items/user/${userId}/test-item`, { accessType: "client", }); expect(getItemResponse).toMatchInlineSnapshot(` @@ -453,7 +453,7 @@ it("supports custom customer type for items (GET and update-quantity)", async ({ const customCustomerId = "custom-xyz"; const getBefore = await niceBackendFetch(`/api/latest/payments/items/custom/${customCustomerId}/custom-item`, { - accessType: "client", + accessType: "admin", }); expect(getBefore.status).toBe(200); expect(getBefore.body.quantity).toBe(0); @@ -466,7 +466,7 @@ it("supports custom customer type for items (GET and update-quantity)", async ({ expect(postChange.status).toBe(200); const getAfter = await niceBackendFetch(`/api/latest/payments/items/custom/${customCustomerId}/custom-item`, { - accessType: "client", + accessType: "admin", }); expect(getAfter.status).toBe(200); expect(getAfter.body.quantity).toBe(3); 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 4f965bd7ad..76af5a6e93 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 @@ -28,7 +28,7 @@ it("should reject client requests to grant product", async ({ expect }) => { }, }, }); - const { userId } = await User.create(); + const { userId, accessToken, refreshToken } = await Auth.fastSignUp(); const response = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, { method: "POST", accessType: "client", @@ -79,7 +79,7 @@ it("should grant configured subscription product and expose it via listing", asy }, }); - const { userId } = await User.create(); + const { userId, accessToken, refreshToken } = await Auth.fastSignUp(); const grantResponse = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, { method: "POST", accessType: "server", @@ -98,6 +98,7 @@ it("should grant configured subscription product and expose it via listing", asy const listResponse = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, { accessType: "client", + userAuth: { accessToken, refreshToken }, }); expect(listResponse).toMatchInlineSnapshot(` NiceResponse { @@ -462,7 +463,7 @@ it("should hide server-only products from clients while exposing them to servers }, }); - const { userId } = await User.create(); + const { userId, accessToken, refreshToken } = await Auth.fastSignUp(); const grantResponse = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, { method: "POST", accessType: "server", @@ -481,6 +482,7 @@ it("should hide server-only products from clients while exposing them to servers const clientListResponse = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, { accessType: "client", + userAuth: { accessToken, refreshToken }, }); expect(clientListResponse).toMatchInlineSnapshot(` @@ -563,7 +565,7 @@ it("should prevent granting an already owned non-stackable product", async ({ ex }, }); - const { userId } = await User.create(); + const { userId, accessToken, refreshToken } = await Auth.fastSignUp(); const firstGrant = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, { method: "POST", accessType: "server", @@ -621,7 +623,7 @@ it("should allow granting stackable product with custom quantity", async ({ expe }, }); - const { userId } = await User.create(); + const { userId, accessToken, refreshToken } = await Auth.fastSignUp(); const grantResponse = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, { method: "POST", accessType: "server", @@ -634,6 +636,7 @@ it("should allow granting stackable product with custom quantity", async ({ expe const listResponse = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, { accessType: "client", + userAuth: { accessToken, refreshToken }, }); expect(listResponse).toMatchInlineSnapshot(` NiceResponse { @@ -759,7 +762,7 @@ it("should grant inline product without needing configuration", async ({ expect it("should reject requests missing product details", async ({ expect }) => { await Project.createAndSwitch(); await Payments.setup(); - const { userId } = await User.create(); + const { userId, accessToken, refreshToken } = await Auth.fastSignUp(); const response = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, { method: "POST", @@ -798,7 +801,7 @@ it("should reject quantity > 1 for non-stackable product", async ({ expect }) => }, }); - const { userId } = await User.create(); + const { userId, accessToken, refreshToken } = await Auth.fastSignUp(); const response = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, { method: "POST", accessType: "server", @@ -838,7 +841,7 @@ it("should reject product/customer type mismatch", async ({ expect }) => { }, }); - const { userId } = await User.create(); + const { userId, accessToken, refreshToken } = await Auth.fastSignUp(); const response = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, { method: "POST", accessType: "server", @@ -915,7 +918,7 @@ it("should return user not found when granting to missing user", async ({ expect it("listing owned products should require authentication", async ({ expect }) => { await Project.createAndSwitch(); - const { userId } = await User.create(); + const { userId, accessToken, refreshToken } = await Auth.fastSignUp(); const response = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`); expect(response).toMatchInlineSnapshot(` @@ -940,10 +943,11 @@ it("listing owned products should require authentication", async ({ expect }) => it("listing products should return empty list when customer owns no products", async ({ expect }) => { await Project.createAndSwitch(); await Payments.setup(); - const { userId } = await User.create(); + const { userId, accessToken, refreshToken } = await Auth.fastSignUp(); const response = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, { accessType: "client", + userAuth: { accessToken, refreshToken }, }); expect(response).toMatchInlineSnapshot(` @@ -994,7 +998,7 @@ it("listing products should list both subscription and one-time products", async }, }); - const { userId } = await User.create(); + const { userId, accessToken, refreshToken } = await Auth.fastSignUp(); const grantSubscription = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, { method: "POST", @@ -1016,6 +1020,7 @@ it("listing products should list both subscription and one-time products", async const response = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, { accessType: "client", + userAuth: { accessToken, refreshToken }, }); expect(response).toMatchInlineSnapshot(` @@ -1125,7 +1130,7 @@ it("listing products should support cursor pagination", async ({ expect }) => { }, }); - const { userId } = await User.create(); + const { userId, accessToken, refreshToken } = await Auth.fastSignUp(); await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, { method: "POST", @@ -1154,11 +1159,13 @@ it("listing products should support cursor pagination", async ({ expect }) => { const basePath = `/api/v1/payments/products/user/${userId}`; const allResponse = await niceBackendFetch(basePath, { accessType: "client", + userAuth: { accessToken, refreshToken }, }); const firstPage = await niceBackendFetch(`${basePath}?limit=1`, { accessType: "client", + userAuth: { accessToken, refreshToken }, }); expect(firstPage).toMatchInlineSnapshot(` NiceResponse { @@ -1205,6 +1212,7 @@ it("listing products should support cursor pagination", async ({ expect }) => { const cursor = firstPage.body.pagination.next_cursor; const secondPage = await niceBackendFetch(`${basePath}?limit=5&cursor=${encodeURIComponent(cursor)}`, { accessType: "client", + userAuth: { accessToken, refreshToken }, }); expect(secondPage).toMatchInlineSnapshot(` NiceResponse { @@ -1316,7 +1324,7 @@ it("should immediately cancel existing subscriptions when granting a product of }, }); - const { userId } = await User.create(); + const { userId, accessToken, refreshToken } = await Auth.fastSignUp(); const grantBaseResponse = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, { method: "POST", accessType: "server", @@ -1345,6 +1353,7 @@ it("should immediately cancel existing subscriptions when granting a product of const itemQuantities = await niceBackendFetch(`/api/v1/payments/items/user/${userId}/i1`, { accessType: "client", + userAuth: { accessToken, refreshToken }, }); expect(itemQuantities).toMatchInlineSnapshot(` NiceResponse { diff --git a/apps/e2e/tests/backend/endpoints/api/v1/payments/purchase-session.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/payments/purchase-session.test.ts index b1e3aca295..223f1486e1 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/payments/purchase-session.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/payments/purchase-session.test.ts @@ -28,7 +28,42 @@ it("should error on invalid code", async ({ expect }) => { }); it("should error on invalid price_id", async ({ expect }) => { - const { code } = await Payments.createPurchaseUrlAndGetCode(); + await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + await Payments.setup(); + await Project.updateConfig({ + payments: { + testMode: false, + products: { + "test-product": { + displayName: "Test Product", + customerType: "user", + serverOnly: false, + stackable: false, + prices: { + "monthly": { + USD: "1000", + interval: [1, "month"], + }, + }, + includedItems: {}, + }, + }, + }, + }); + const { userId, accessToken, refreshToken } = await Auth.fastSignUp(); + const createUrlResponse = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", { + method: "POST", + accessType: "client", + userAuth: { accessToken, refreshToken }, + body: { + customer_type: "user", + customer_id: userId, + product_id: "test-product", + }, + }); + expect(createUrlResponse.status).toBe(200); + const code = (createUrlResponse.body as { url: string }).url.match(/\/purchase\/([a-z0-9-_]+)/)?.[1]!; + const response = await niceBackendFetch("/api/latest/payments/purchases/purchase-session", { method: "POST", accessType: "client", @@ -47,7 +82,42 @@ it("should error on invalid price_id", async ({ expect }) => { }); it("should properly create subscription", async ({ expect }) => { - const { code } = await Payments.createPurchaseUrlAndGetCode(); + await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + await Payments.setup(); + await Project.updateConfig({ + payments: { + testMode: false, + products: { + "test-product": { + displayName: "Test Product", + customerType: "user", + serverOnly: false, + stackable: false, + prices: { + "monthly": { + USD: "1000", + interval: [1, "month"], + }, + }, + includedItems: {}, + }, + }, + }, + }); + const { userId, accessToken, refreshToken } = await Auth.fastSignUp(); + const createUrlResponse = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", { + method: "POST", + accessType: "client", + userAuth: { accessToken, refreshToken }, + body: { + customer_type: "user", + customer_id: userId, + product_id: "test-product", + }, + }); + expect(createUrlResponse.status).toBe(200); + const code = (createUrlResponse.body as { url: string }).url.match(/\/purchase\/([a-z0-9-_]+)/)?.[1]!; + const response = await niceBackendFetch("/api/latest/payments/purchases/purchase-session", { method: "POST", accessType: "client", @@ -83,7 +153,7 @@ it("should return client secret for one-time price (no interval)", async ({ expe }, }); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); const urlRes = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", { method: "POST", accessType: "client", @@ -129,7 +199,7 @@ it("should error on one-time price quantity > 1 when product is not stackable", }, }); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); const urlRes = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", { method: "POST", accessType: "client", @@ -190,7 +260,7 @@ it("should return client secret for one-time price even if a conflicting group s }, }); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); // Create test-mode DB-only subscription for subProduct const createUrlRespA = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", { @@ -257,7 +327,7 @@ it("test-mode should error on one-time price quantity > 1 when product is not st }, }); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); const urlRes = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", { method: "POST", accessType: "client", @@ -397,10 +467,11 @@ it("creates subscription in test mode and increases included item quantity", asy }, }); - const { userId } = await User.create(); + const { userId, accessToken, refreshToken } = await Auth.fastSignUp(); const createUrlResponse = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", { method: "POST", accessType: "client", + userAuth: { accessToken, refreshToken }, body: { customer_type: "user", customer_id: userId, @@ -415,6 +486,7 @@ it("creates subscription in test mode and increases included item quantity", asy const getBefore = await niceBackendFetch(`/api/latest/payments/items/user/${userId}/test-item`, { accessType: "client", + userAuth: { accessToken, refreshToken }, }); expect(getBefore.status).toBe(200); expect(getBefore.body.quantity).toBe(0); @@ -432,6 +504,7 @@ it("creates subscription in test mode and increases included item quantity", asy const getAfter = await niceBackendFetch(`/api/latest/payments/items/user/${userId}/test-item`, { accessType: "client", + userAuth: { accessToken, refreshToken }, }); expect(getAfter.status).toBe(200); expect(getAfter.body.quantity).toBe(2); @@ -591,10 +664,11 @@ it("allows stackable quantity in test mode and multiplies included items", async }, }); - const { userId } = await User.create(); + const { userId, accessToken, refreshToken } = await Auth.fastSignUp(); const createUrlResponse = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", { method: "POST", accessType: "client", + userAuth: { accessToken, refreshToken }, body: { customer_type: "user", customer_id: userId, @@ -609,6 +683,7 @@ it("allows stackable quantity in test mode and multiplies included items", async const getBefore = await niceBackendFetch(`/api/latest/payments/items/user/${userId}/test-item`, { accessType: "client", + userAuth: { accessToken, refreshToken }, }); expect(getBefore.status).toBe(200); expect(getBefore.body.quantity).toBe(0); @@ -627,6 +702,7 @@ it("allows stackable quantity in test mode and multiplies included items", async const getAfter = await niceBackendFetch(`/api/latest/payments/items/user/${userId}/test-item`, { accessType: "client", + userAuth: { accessToken, refreshToken }, }); expect(getAfter.status).toBe(200); expect(getAfter.body.quantity).toBe(6); @@ -674,7 +750,7 @@ it("should update existing stripe subscription when switching products within a }, }); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); // First purchase: Product A const createUrlA = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", { @@ -781,7 +857,7 @@ it("should cancel DB-only subscription then create Stripe subscription when swit }, }); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); // Create test-mode DB-only subscription for productA const resUrlA = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", { @@ -859,7 +935,7 @@ it("should block one-time purchase for same product after prior one-time purchas }, }); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); // First: create code and complete in TEST_MODE (persists OneTimePurchase) const createUrl1 = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", { method: "POST", @@ -932,7 +1008,7 @@ it("should block one-time purchase in same group after prior one-time purchase i }, }); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); // Purchase productA in TEST_MODE (persists OneTimePurchase) const urlA = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", { method: "POST", diff --git a/apps/e2e/tests/backend/endpoints/api/v1/payments/validate-code.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/payments/validate-code.test.ts index 241cb5adf9..4a8bda72a7 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/payments/validate-code.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/payments/validate-code.test.ts @@ -1,5 +1,5 @@ import { it } from "../../../../../helpers"; -import { Payments, Project, User, niceBackendFetch } from "../../../../backend-helpers"; +import { Auth, Payments, Project, User, niceBackendFetch } from "../../../../backend-helpers"; it("should error on invalid code", async ({ expect }) => { @@ -27,7 +27,45 @@ it("should error on invalid code", async ({ expect }) => { }); it("should allow valid code and return product data", async ({ expect }) => { - const { code } = await Payments.createPurchaseUrlAndGetCode(); + await Project.createAndSwitch(); + await Payments.setup(); + await Project.updateConfig({ + payments: { + testMode: false, + products: { + "test-product": { + displayName: "Test Product", + customerType: "user", + serverOnly: false, + stackable: false, + prices: { + "monthly": { + USD: "1000", + interval: [1, "month"], + }, + }, + includedItems: {}, + }, + }, + }, + }); + + const { userId } = await Auth.fastSignUp(); + const createResponse = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", { + method: "POST", + accessType: "client", + body: { + customer_type: "user", + customer_id: userId, + product_id: "test-product", + }, + }); + expect(createResponse.status).toBe(200); + const url = (createResponse.body as { url: string }).url; + const codeMatch = url.match(/\/purchase\/([a-z0-9-_]+)/); + const code = codeMatch ? codeMatch[1] : undefined; + expect(code).toBeDefined(); + const validateResponse = await niceBackendFetch("/api/latest/payments/purchases/validate-code", { method: "POST", accessType: "client", @@ -93,7 +131,7 @@ it("should set already_bought_non_stackable when user already owns non-stackable }, }); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); // Create a code for test-product and purchase it in test mode (creates DB subscription) const createUrlRes1 = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", { method: "POST", @@ -178,7 +216,7 @@ it("should include conflicting_products when switching within the same group", a }, }); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); // Subscribe to productA in test mode const resUrlA = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", { @@ -271,7 +309,7 @@ it("should reject untrusted return_url and accept trusted return_url", async ({ }, }); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); await Project.updateConfig({ domains: { allowLocalhost: false, diff --git a/apps/e2e/tests/backend/endpoints/api/v1/stripe-webhooks.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/stripe-webhooks.test.ts index b1dfddd36d..013566bfa1 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/stripe-webhooks.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/stripe-webhooks.test.ts @@ -1,7 +1,7 @@ import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { wait } from "@stackframe/stack-shared/dist/utils/promises"; import { it } from "../../../../helpers"; -import { bumpEmailAddress, niceBackendFetch, Payments, Project, User } from "../../../backend-helpers"; +import { Auth, bumpEmailAddress, niceBackendFetch, Payments, Project } from "../../../backend-helpers"; import { getOutboxEmails } from "./emails/email-helpers"; async function waitForOutboxEmail(subject: string) { @@ -139,7 +139,7 @@ it("deduplicates one-time purchase on payment_intent.succeeded retry", async ({ }, }); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); // Before webhook: quantity should be 0 const getBefore = await niceBackendFetch(`/api/latest/payments/items/user/${userId}/${itemId}`, { @@ -240,7 +240,7 @@ it("sends a payment receipt email for one-time purchases", async ({ expect }) => }); const mailbox = await bumpEmailAddress(); - const { userId } = await User.create({ + const { userId } = await Auth.fastSignUp({ primary_email: mailbox.emailAddress, primary_email_verified: true, }); @@ -337,7 +337,7 @@ it("sends a payment failed email for invoice.payment_failed", async ({ expect }) }); const mailbox = await bumpEmailAddress(); - const { userId } = await User.create({ + const { userId } = await Auth.fastSignUp({ primary_email: mailbox.emailAddress, primary_email_verified: true, }); @@ -430,7 +430,7 @@ it("skips payment failed email when invoice is not uncollectible", async ({ expe }); const mailbox = await bumpEmailAddress(); - const { userId } = await User.create({ + const { userId } = await Auth.fastSignUp({ primary_email: mailbox.emailAddress, primary_email_verified: true, }); @@ -519,7 +519,7 @@ it("syncs subscriptions from webhook and is idempotent", async ({ expect }) => { }, }); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); const getBefore = await niceBackendFetch(`/api/latest/payments/items/user/${userId}/${itemId}`, { accessType: "client", @@ -632,7 +632,7 @@ it("updates a user's subscriptions via webhook (add then remove)", async ({ expe }, }); - const { userId } = await User.create(); + const { userId } = await Auth.fastSignUp(); const before = await niceBackendFetch(`/api/latest/payments/items/user/${userId}/${itemId}`, { accessType: "client", diff --git a/apps/e2e/tests/js/payments.test.ts b/apps/e2e/tests/js/payments.test.ts index d12e45651f..cf5efaf32a 100644 --- a/apps/e2e/tests/js/payments.test.ts +++ b/apps/e2e/tests/js/payments.test.ts @@ -71,7 +71,7 @@ it("returns default item quantity for a team", async ({ expect }) => { }); it("root-level getItem works for user and team", async ({ expect }) => { - const { clientApp, serverApp, adminApp } = await createApp({ + const { clientApp, adminApp } = await createApp({ config: { clientTeamCreationEnabled: true }, }); @@ -100,12 +100,12 @@ it("root-level getItem works for user and team", async ({ expect }) => { customerType: "user", }, }); - const userItem = await serverApp.getItem({ itemId: userItemId, userId: user.id }); + const userItem = await clientApp.getItem({ itemId: userItemId, userId: user.id }); expect(userItem.quantity).toBe(0); }, { timeout: 60_000 }); it("customCustomerId is supported via root-level getItem and admin quantity change", async ({ expect }) => { - const { clientApp, adminApp } = await createApp({ + const { serverApp, clientApp, adminApp } = await createApp({ config: {}, }); const project = await adminApp.getProject(); @@ -117,10 +117,10 @@ it("customCustomerId is supported via root-level getItem and admin quantity chan }, }); const customCustomerId = "custom-abc"; - const before = await clientApp.getItem({ itemId, customCustomerId }); + const before = await serverApp.getItem({ itemId, customCustomerId }); expect(before.quantity).toBe(0); await adminApp.createItemQuantityChange({ customCustomerId, itemId, quantity: 5 }); - const after = await clientApp.getItem({ itemId, customCustomerId }); + const after = await serverApp.getItem({ itemId, customCustomerId }); expect(after.quantity).toBe(5); }, { timeout: 60_000 }); @@ -352,7 +352,7 @@ it("supports granting and listing customer products", { timeout: 60_000 }, async } as const; await serverApp.grantProduct({ userId: user.id, product: inlineUserProduct }); - const allUserProducts = await clientApp.listProducts({ userId: user.id }); + const allUserProducts = await serverApp.listProducts({ userId: user.id }); expect(allUserProducts).toHaveLength(2); expect(allUserProducts.nextCursor).toBeNull(); const configGrant = allUserProducts.find((product) => product.displayName === "Config Offer"); @@ -367,7 +367,7 @@ it("supports granting and listing customer products", { timeout: 60_000 }, async expect(nextPage).toHaveLength(1); expect(nextPage.nextCursor).toBeNull(); - const userProductsFromCustomer = await user.listProducts(); + const userProductsFromCustomer = await serverApp.listProducts({ userId: user.id }); expect(userProductsFromCustomer).toHaveLength(2); const team = await user.createTeam({ displayName: "Products Team" }); @@ -388,7 +388,7 @@ it("supports granting and listing customer products", { timeout: 60_000 }, async expect(teamProducts[0].quantity).toBe(1); expect(teamProducts[0].displayName).toBe(inlineTeamProduct.display_name); - const teamProductsFromCustomer = await team.listProducts(); + const teamProductsFromCustomer = await serverApp.listProducts({ teamId: team.id }); expect(teamProductsFromCustomer).toHaveLength(1); expect(teamProductsFromCustomer[0].displayName).toBe(inlineTeamProduct.display_name); @@ -405,7 +405,7 @@ it("supports granting and listing customer products", { timeout: 60_000 }, async } as const; await serverApp.grantProduct({ customCustomerId, product: inlineCustomProduct, quantity: 1 }); - const customProducts = await clientApp.listProducts({ customCustomerId }); + const customProducts = await serverApp.listProducts({ customCustomerId }); expect(customProducts).toHaveLength(1); expect(customProducts[0].quantity).toBe(1); expect(customProducts[0].displayName).toBe(inlineCustomProduct.display_name); diff --git a/packages/stack-shared/src/interface/client-interface.ts b/packages/stack-shared/src/interface/client-interface.ts index 164dff06c8..1495e241b1 100644 --- a/packages/stack-shared/src/interface/client-interface.ts +++ b/packages/stack-shared/src/interface/client-interface.ts @@ -1805,6 +1805,7 @@ export class StackClientInterface { { itemId: string, customCustomerId: string } ), session: InternalSession | null, + requestType: "client" | "server" | "admin" = "client", ): Promise { let customerType: "user" | "team" | "custom"; let customerId: string; @@ -1821,10 +1822,12 @@ export class StackClientInterface { throw new StackAssertionError("getItem requires one of userId, teamId, or customCustomerId"); } - const response = await this.sendClientRequest( + const sendRequest = (requestType === "client" ? this.sendClientRequest : (this as any).sendServerRequest as never).bind(this); + const response = await sendRequest( urlString`/payments/items/${customerType}/${customerId}/${options.itemId}`, {}, session, + requestType, ); return await response.json(); } @@ -1832,16 +1835,19 @@ export class StackClientInterface { async listProducts( options: ListCustomerProductsOptions, session: InternalSession | null, + requestType: "client" | "server" | "admin" = "client", ): Promise { const queryParams = new URLSearchParams(filterUndefined({ cursor: options.cursor, limit: options.limit !== undefined ? options.limit.toString() : undefined, })); const path = urlString`/payments/products/${options.customer_type}/${options.customer_id}`; - const response = await this.sendClientRequest( + const sendRequest = (requestType === "client" ? this.sendClientRequest : (this as any).sendServerRequest as never).bind(this); + const response = await sendRequest( `${path}${queryParams.toString() ? `?${queryParams.toString()}` : ''}`, {}, session, + requestType, ); return await response.json(); } diff --git a/packages/stack-shared/src/interface/server-interface.ts b/packages/stack-shared/src/interface/server-interface.ts index 41885b8414..962c3d0b9a 100644 --- a/packages/stack-shared/src/interface/server-interface.ts +++ b/packages/stack-shared/src/interface/server-interface.ts @@ -132,6 +132,7 @@ export class StackServerInterface extends StackClientInterface { return await response.json(); } + protected async sendServerRequestAndCatchKnownError( path: string, requestOptions: RequestInit, diff --git a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts index c7addefbfb..00c613eb6d 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts @@ -1760,7 +1760,8 @@ export class _StackClientAppImplIncomplete { - const session = await this._getSession(); + const currentUser = await this.getUser(); + const session = currentUser?._internalSession ?? await this._getSession(); if ("userId" in options) { const response = Result.orThrow(await this._userProductsCache.getOrWait([session, options.userId, options.cursor ?? null, options.limit ?? null], "write-only")); return this._customerProductsFromResponse(response); diff --git a/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts index 1d2f2c5721..d4a24cfa5e 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts @@ -26,7 +26,7 @@ import { ApiKey, ApiKeyCreationOptions, ApiKeyUpdateOptions, apiKeyCreationOptio import { ConvexCtx, GetCurrentUserOptions } from "../../common"; import { OAuthConnection } from "../../connected-accounts"; import { ServerContactChannel, ServerContactChannelCreateOptions, ServerContactChannelUpdateOptions, serverContactChannelCreateOptionsToCrud, serverContactChannelUpdateOptionsToCrud } from "../../contact-channels"; -import { Customer, InlineProduct, ServerItem } from "../../customers"; +import { Customer, CustomerProductsList, CustomerProductsRequestOptions, InlineProduct, ServerItem } from "../../customers"; import { DataVaultStore } from "../../data-vault"; import { EmailDeliveryInfo, SendEmailOptions } from "../../email"; import { NotificationCategory } from "../../notification-categories"; @@ -184,19 +184,19 @@ export class _StackServerAppImplIncomplete( async ([teamId, itemId]) => { - return await this._interface.getItem({ teamId, itemId }, null); + return await this._interface.getItem({ teamId, itemId }, null, "server"); } ); private readonly _serverUserItemsCache = createCache<[string, string], ItemCrud['Client']['Read']>( async ([userId, itemId]) => { - return await this._interface.getItem({ userId, itemId }, null); + return await this._interface.getItem({ userId, itemId }, null, "server"); } ); private readonly _serverCustomItemsCache = createCache<[string, string], ItemCrud['Client']['Read']>( async ([customCustomerId, itemId]) => { - return await this._interface.getItem({ customCustomerId, itemId }, null); + return await this._interface.getItem({ customCustomerId, itemId }, null, "server"); } ); @@ -207,7 +207,7 @@ export class _StackServerAppImplIncomplete { + if ("userId" in options) { + const response = Result.orThrow(await this._serverUserProductsCache.getOrWait([options.userId, options.cursor ?? null, options.limit ?? null], "write-only")); + return this._customerProductsFromResponse(response); + } else if ("teamId" in options) { + const response = Result.orThrow(await this._serverTeamProductsCache.getOrWait([options.teamId, options.cursor ?? null, options.limit ?? null], "write-only")); + return this._customerProductsFromResponse(response); + } + const response = Result.orThrow(await this._serverCustomProductsCache.getOrWait([options.customCustomerId, options.cursor ?? null, options.limit ?? null], "write-only")); + return this._customerProductsFromResponse(response); + } + // IF_PLATFORM react-like useItem(options: { itemId: string, userId: string } | { itemId: string, teamId: string } | { itemId: string, customCustomerId: string }): ServerItem { let type: "user" | "team" | "custom";