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,13 +1,13 @@
import { ensureProductIdOrInlineProduct } from "@/lib/payments";
import { ensureProductIdOrInlineProduct, getCustomerPurchaseContext } from "@/lib/payments";
import { validateRedirectUrl } from "@/lib/redirect-urls";
import { getStripeForAccount } from "@/lib/stripe";
import { globalPrismaClient } from "@/prisma-client";
import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { CustomerType } from "@prisma/client";
import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors";
import { adaptSchema, clientOrHigherAuthTypeSchema, inlineProductSchema, urlSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { purchaseUrlVerificationCodeHandler } from "../verification-code-handler";

export const POST = createSmartRouteHandler({
Expand Down Expand Up @@ -44,6 +44,20 @@ export const POST = createSmartRouteHandler({
throw new KnownErrors.ProductCustomerTypeDoesNotMatch(req.body.product_id, req.body.customer_id, customerType, req.body.customer_type);
}

if (req.body.product_id && productConfig.stackable !== true) {
Comment thread
BilalG1 marked this conversation as resolved.
const prisma = await getPrismaClientForTenancy(tenancy);
const { alreadyOwnsProduct } = await getCustomerPurchaseContext({
prisma,
tenancy,
customerType,
customerId: req.body.customer_id,
productId: req.body.product_id,
});
if (alreadyOwnsProduct) {
throw new StatusError(400, "Customer already has purchased this product; this product is not stackable");
Comment thread
BilalG1 marked this conversation as resolved.
Comment thread
BilalG1 marked this conversation as resolved.
}
}

const stripeCustomerSearch = await stripe.customers.search({
query: `metadata['customerId']:'${req.body.customer_id}'`,
});
Expand Down
54 changes: 36 additions & 18 deletions apps/backend/src/lib/payments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { inlineProductSchema, productSchema } from "@stackframe/stack-share
import { SUPPORTED_CURRENCIES } from "@stackframe/stack-shared/dist/utils/currency-constants";
import { FAR_FUTURE_DATE, addInterval, getIntervalsElapsed } from "@stackframe/stack-shared/dist/utils/dates";
import { StackAssertionError, StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { getOrUndefined, typedEntries, typedFromEntries, typedKeys } from "@stackframe/stack-shared/dist/utils/objects";
import { getOrUndefined, has, typedEntries, typedFromEntries, typedKeys } from "@stackframe/stack-shared/dist/utils/objects";
import { typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings";
import { isUuid } from "@stackframe/stack-shared/dist/utils/uuids";
import Stripe from "stripe";
Expand All @@ -32,10 +32,11 @@ export async function ensureProductIdOrInlineProduct(
if (productId) {
const product = getOrUndefined(tenancy.config.payments.products, productId);
if (!product) {
throw new KnownErrors.ProductDoesNotExist(productId, accessType);
const itemExists = has(tenancy.config.payments.items, productId);
throw new KnownErrors.ProductDoesNotExist(productId, itemExists ? "item_exists" : null);
}
if (product.serverOnly && accessType === "client") {
throw new StatusError(400, "This product is marked as server-only and cannot be accessed client side!");
throw new KnownErrors.ProductDoesNotExist(productId, "server_only");
}
return product;
} else {
Expand Down Expand Up @@ -347,6 +348,35 @@ export async function getSubscriptions(options: {
return subscriptions;
}

export async function getCustomerPurchaseContext(options: {
prisma: PrismaClientTransaction,
tenancy: Tenancy,
customerType: "user" | "team" | "custom",
customerId: string,
productId?: string,
}) {
const existingOneTimePurchases = await options.prisma.oneTimePurchase.findMany({
where: {
tenancyId: options.tenancy.id,
customerId: options.customerId,
customerType: typedToUppercase(options.customerType),
},
});

const subscriptions = await getSubscriptions({
prisma: options.prisma,
tenancy: options.tenancy,
customerType: options.customerType,
customerId: options.customerId,
});

const alreadyOwnsProduct = options.productId
? [...subscriptions, ...existingOneTimePurchases].some((p) => p.productId === options.productId)
: false;

return { existingOneTimePurchases, subscriptions, alreadyOwnsProduct };
}

export async function ensureCustomerExists(options: {
prisma: PrismaClientTransaction,
tenancyId: string,
Expand Down Expand Up @@ -427,27 +457,15 @@ export async function validatePurchaseSession(options: {
throw new StatusError(400, "This product is not stackable; quantity must be 1");
}

// Block based on prior one-time purchases for same customer and customerType
const existingOneTimePurchases = await prisma.oneTimePurchase.findMany({
where: {
tenancyId: tenancy.id,
customerId: codeData.customerId,
customerType: typedToUppercase(product.customerType),
},
});

const subscriptions = await getSubscriptions({
const { existingOneTimePurchases, subscriptions, alreadyOwnsProduct } = await getCustomerPurchaseContext({
prisma,
tenancy,
customerType: product.customerType,
customerId: codeData.customerId,
productId: codeData.productId,
});

if (
codeData.productId &&
product.stackable !== true &&
[...subscriptions, ...existingOneTimePurchases].some((p) => p.productId === codeData.productId)
) {
if (product.stackable !== true && alreadyOwnsProduct) {
throw new StatusError(400, "Customer already has purchased this product; this product is not stackable");
}
const addOnProductIds = product.isAddOnTo ? typedKeys(product.isAddOnTo) : [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,22 +55,22 @@ it("should error for non-existent offer_id", async ({ expect }) => {
},
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "PRODUCT_DOES_NOT_EXIST",
"details": {
"access_type": "client",
"product_id": "non-existent-offer",
},
"error": "Product with ID \\"non-existent-offer\\" does not exist or you don't have permissions to access it.",
},
"headers": Headers {
"x-stack-known-error": "PRODUCT_DOES_NOT_EXIST",
<some fields may have been hidden>,
NiceResponse {
"status": 400,
"body": {
"code": "PRODUCT_DOES_NOT_EXIST",
"details": {
"context": null,
"product_id": "non-existent-offer",
},
}
`);
"error": "Product with ID \\"non-existent-offer\\" does not exist.",
},
"headers": Headers {
"x-stack-known-error": "PRODUCT_DOES_NOT_EXIST",
<some fields may have been hidden>,
},
}
`);
});

it("should error for invalid customer_id", async ({ expect }) => {
Expand Down Expand Up @@ -233,8 +233,18 @@ it("should error for server-only offer when calling from client", async ({ expec
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": "This product is marked as server-only and cannot be accessed client side!",
"headers": Headers { <some fields may have been hidden> },
"body": {
"code": "PRODUCT_DOES_NOT_EXIST",
"details": {
"context": "server_only",
"product_id": "test-offer",
},
"error": "Product with ID \\"test-offer\\" is marked as server-only and cannot be accessed client side.",
},
"headers": Headers {
"x-stack-known-error": "PRODUCT_DOES_NOT_EXIST",
<some fields may have been hidden>,
},
}
`);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -793,17 +793,13 @@ it("should block one-time purchase for same product after prior one-time purchas
accessType: "client",
body: { customer_type: "user", customer_id: userId, offer_id: "ot" },
});
expect(createUrl2.status).toBe(200);
const code2 = (createUrl2.body as { url: string }).url.match(/\/purchase\/([a-z0-9-_]+)/)?.[1];
expect(code2).toBeDefined();

const res = await niceBackendFetch("/api/v1/payments/purchases/purchase-session", {
method: "POST",
accessType: "client",
body: { full_code: code2, price_id: "one", quantity: 1 },
});
expect(res.status).toBe(400);
expect(String(res.body)).toBe("Customer already has purchased this product; this product is not stackable");
expect(createUrl2).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": "Customer already has purchased this product; this product is not stackable",
"headers": Headers { <some fields may have been hidden> },
}
`);
});

it("should block one-time purchase in same group after prior one-time purchase in that group (test-mode persisted)", async ({ expect }) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,39 +122,10 @@ it("should set already_bought_non_stackable when user already owns non-stackable
offer_id: "test-offer",
},
});
expect(createUrlRes2.status).toBe(200);
const code2 = (createUrlRes2.body as { url: string }).url.match(/\/purchase\/([a-z0-9-_]+)/)?.[1];
expect(code2).toBeDefined();

const validateResponse = await niceBackendFetch("/api/v1/payments/purchases/validate-code", {
method: "POST",
accessType: "client",
body: { full_code: code2 },
});
expect(validateResponse).toMatchInlineSnapshot(`
expect(createUrlRes2).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"already_bought_non_stackable": true,
"conflicting_products": [],
"product": {
"customer_type": "user",
"display_name": "Test Offer",
"prices": {
"monthly": {
"USD": "1000",
"interval": [
1,
"month",
],
},
},
"stackable": false,
},
"project_id": "<stripped UUID>",
"stripe_account_id": <stripped field 'stripe_account_id'>,
"test_mode": true,
},
"status": 400,
"body": "Customer already has purchased this product; this product is not stackable",
"headers": Headers { <some fields may have been hidden> },
}
`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,10 @@ it("should error for non-existent product_id", async ({ expect }) => {
"body": {
"code": "PRODUCT_DOES_NOT_EXIST",
"details": {
"access_type": "client",
"context": null,
"product_id": "non-existent-product",
},
"error": "Product with ID \\"non-existent-product\\" does not exist or you don't have permissions to access it.",
"error": "Product with ID \\"non-existent-product\\" does not exist.",
},
"headers": Headers {
"x-stack-known-error": "PRODUCT_DOES_NOT_EXIST",
Expand Down Expand Up @@ -233,8 +233,18 @@ it("should error for server-only product when calling from client", async ({ exp
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": "This product is marked as server-only and cannot be accessed client side!",
"headers": Headers { <some fields may have been hidden> },
"body": {
"code": "PRODUCT_DOES_NOT_EXIST",
"details": {
"context": "server_only",
"product_id": "test-product",
},
"error": "Product with ID \\"test-product\\" is marked as server-only and cannot be accessed client side.",
},
"headers": Headers {
"x-stack-known-error": "PRODUCT_DOES_NOT_EXIST",
<some fields may have been hidden>,
},
}
`);
});
Expand Down Expand Up @@ -310,6 +320,73 @@ it("should allow valid product_id", async ({ expect }) => {
expect(returnUrl).toBe("http://stack-test.localhost/after-purchase");
});

it("should error when customer already owns a non-stackable product", async ({ expect }) => {
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
await Payments.setup();
await Project.updateConfig({
payments: {
testMode: true,
products: {
"test-product": {
displayName: "Test Product",
customerType: "user",
serverOnly: false,
stackable: false,
prices: {
"monthly": {
USD: "1000",
interval: [1, "month"],
},
},
includedItems: {},
},
},
},
});

const { userId } = await User.create();
const firstResponse = 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(firstResponse.status).toBe(200);
const firstBody = firstResponse.body as { url: string };
const firstUrl = new URL(firstBody.url);
const fullCode = firstUrl.pathname.split("/").pop();
expect(fullCode).toBeDefined();
if (!fullCode) {
throw new Error("Expected full purchase code");
}

const purchaseResponse = await niceBackendFetch("/api/latest/internal/payments/test-mode-purchase-session", {
method: "POST",
accessType: "admin",
body: {
full_code: fullCode,
price_id: "monthly",
quantity: 1,
},
});
expect(purchaseResponse.status).toBe(200);

const secondResponse = 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(secondResponse.status).toBe(400);
expect(secondResponse.body).toBe("Customer already has purchased this product; this product is not stackable");
Comment thread
BilalG1 marked this conversation as resolved.
});

it("should error for untrusted return_url", async ({ expect }) => {
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
await Payments.setup();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -809,17 +809,13 @@ it("should block one-time purchase for same product after prior one-time purchas
accessType: "client",
body: { customer_type: "user", customer_id: userId, product_id: "ot" },
});
expect(createUrl2.status).toBe(200);
const code2 = (createUrl2.body as { url: string }).url.match(/\/purchase\/([a-z0-9-_]+)/)?.[1];
expect(code2).toBeDefined();

const res = await niceBackendFetch("/api/latest/payments/purchases/purchase-session", {
method: "POST",
accessType: "client",
body: { full_code: code2, price_id: "one", quantity: 1 },
});
expect(res.status).toBe(400);
expect(String(res.body)).toBe("Customer already has purchased this product; this product is not stackable");
expect(createUrl2).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": "Customer already has purchased this product; this product is not stackable",
"headers": Headers { <some fields may have been hidden> },
}
`);
});

it("should block one-time purchase in same group after prior one-time purchase in that group (test-mode persisted)", async ({ expect }) => {
Expand Down
Loading