Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -100,5 +109,3 @@ export const GET = createSmartRouteHandler({
};
},
});


Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -191,4 +200,3 @@ export const POST = createSmartRouteHandler({
};
},
});

Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down
5 changes: 4 additions & 1 deletion apps/backend/src/lib/payments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ type ProductWithMetadata = yup.InferType<typeof productSchemaWithMetadata>;
type SelectedPrice = Exclude<Product["prices"], "include-by-default">[string];

export async function ensureClientCanAccessCustomer(options: {
customerType: "user" | "team",
customerType: "user" | "team" | "custom",
customerId: string,
user: UsersCrud["Admin"]["Read"] | undefined,
tenancy: Tenancy,
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion apps/e2e/tests/backend/backend-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, any>,
Expand Down Expand Up @@ -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", {
Expand Down Expand Up @@ -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", {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
{
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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", {
Expand Down Expand Up @@ -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", {
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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", {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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", {
Expand Down
Original file line number Diff line number Diff line change
@@ -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(`
Expand Down Expand Up @@ -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",
},
});
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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",
},
});
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Loading