diff --git a/apps/backend/src/lib/payments.test.tsx b/apps/backend/src/lib/payments.test.tsx index 4106999f92..10a0c7b2fa 100644 --- a/apps/backend/src/lib/payments.test.tsx +++ b/apps/backend/src/lib/payments.test.tsx @@ -842,7 +842,7 @@ describe('validatePurchaseSession - one-time purchase rules', () => { }, priceId: 'price-any', quantity: 1, - })).rejects.toThrowError('Customer already has a one-time purchase for this product'); + })).rejects.toThrowError('Customer already has purchased this product; this product is not stackable'); }); it('blocks one-time purchase when another one exists in the same group', async () => { @@ -912,6 +912,157 @@ describe('validatePurchaseSession - one-time purchase rules', () => { expect(res.catalogId).toBe('g1'); expect(res.conflictingCatalogSubscriptions.length).toBe(0); }); + + it('allows duplicate one-time purchase for same productId when product is stackable', async () => { + const tenancy = createMockTenancy({ items: {}, products: {}, catalogs: {} }); + const prisma = createMockPrisma({ + oneTimePurchase: { + findMany: async () => [ + { productId: 'product-stackable', product: { catalogId: undefined }, quantity: 1, createdAt: new Date('2025-01-01T00:00:00.000Z') }, + ], + }, + subscription: { findMany: async () => [] }, + } as any); + + const res = await validatePurchaseSession({ + prisma, + tenancy, + codeData: { + tenancyId: tenancy.id, + customerId: 'cust-1', + productId: 'product-stackable', + product: { + displayName: 'Stackable Product', + catalogId: undefined, + customerType: 'custom', + freeTrial: undefined, + serverOnly: false, + stackable: true, + prices: 'include-by-default', + includedItems: {}, + isAddOnTo: false, + }, + }, + priceId: 'price-any', + quantity: 2, + }); + + expect(res.catalogId).toBeUndefined(); + expect(res.conflictingCatalogSubscriptions.length).toBe(0); + }); + + it('blocks when subscription for same product exists and product is not stackable', async () => { + const tenancy = createMockTenancy({ + items: {}, + catalogs: {}, + products: { + 'product-sub': { + displayName: 'Non-stackable Offer', + catalogId: undefined, + customerType: 'custom', + freeTrial: undefined, + serverOnly: false, + stackable: false, + prices: {}, + includedItems: {}, + isAddOnTo: false, + }, + }, + }); + const prisma = createMockPrisma({ + oneTimePurchase: { findMany: async () => [] }, + subscription: { + findMany: async () => [{ + productId: 'product-sub', + currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), + currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), + quantity: 1, + status: 'active', + }], + }, + } as any); + + await expect(validatePurchaseSession({ + prisma, + tenancy, + codeData: { + tenancyId: tenancy.id, + customerId: 'cust-1', + productId: 'product-sub', + product: { + displayName: 'Non-stackable Offer', + catalogId: undefined, + customerType: 'custom', + freeTrial: undefined, + serverOnly: false, + stackable: false, + prices: 'include-by-default', + includedItems: {}, + isAddOnTo: false, + }, + }, + priceId: 'price-any', + quantity: 1, + })).rejects.toThrowError('Customer already has purchased this product; this product is not stackable'); + }); + + it('allows when subscription for same product exists and product is stackable', async () => { + const tenancy = createMockTenancy({ + items: {}, + catalogs: {}, + products: { + 'product-sub-stackable': { + displayName: 'Stackable Product', + catalogId: undefined, + customerType: 'custom', + freeTrial: undefined, + serverOnly: false, + stackable: true, + prices: {}, + includedItems: {}, + isAddOnTo: false, + }, + }, + }); + const prisma = createMockPrisma({ + oneTimePurchase: { findMany: async () => [] }, + subscription: { + findMany: async () => [{ + productId: 'product-sub-stackable', + currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), + currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), + quantity: 1, + status: 'active', + }], + }, + } as any); + + const res = await validatePurchaseSession({ + prisma, + tenancy, + codeData: { + tenancyId: tenancy.id, + customerId: 'cust-1', + productId: 'product-sub-stackable', + product: { + displayName: 'Stackable Product', + catalogId: undefined, + customerType: 'custom', + freeTrial: undefined, + serverOnly: false, + stackable: true, + prices: 'include-by-default', + includedItems: {}, + isAddOnTo: false, + }, + }, + priceId: 'price-any', + quantity: 2, + }); + + expect(res.catalogId).toBeUndefined(); + expect(res.conflictingCatalogSubscriptions.length).toBe(0); + }); }); describe('combined sources - one-time purchases + manual changes + subscriptions', () => { @@ -967,7 +1118,7 @@ describe('combined sources - one-time purchases + manual changes + subscriptions describe('getSubscriptions - defaults behavior', () => { - it('includes ungrouped include-by-default offers in subscriptions', async () => { + it('includes ungrouped include-by-default products in subscriptions', async () => { const tenancy = createMockTenancy({ items: {}, catalogs: {}, @@ -1012,7 +1163,7 @@ describe('getSubscriptions - defaults behavior', () => { expect(ids).toContain('freeUngrouped'); }); - it('throws error when multiple include-by-default offers exist in same group', async () => { + it('throws error when multiple include-by-default products exist in same catalog', async () => { const tenancy = createMockTenancy({ items: {}, catalogs: { g1: { displayName: 'G1' } }, @@ -1046,12 +1197,12 @@ describe('getSubscriptions - defaults behavior', () => { subscription: { findMany: async () => [] }, } as any); - await getSubscriptions({ + await expect(getSubscriptions({ prisma, tenancy, customerType: 'custom', customerId: 'c-1', - }); + })).rejects.toThrowError('Multiple include-by-default products configured in the same catalog'); }); }); diff --git a/apps/backend/src/lib/payments.tsx b/apps/backend/src/lib/payments.tsx index 2a48af35f4..66b44f13cf 100644 --- a/apps/backend/src/lib/payments.tsx +++ b/apps/backend/src/lib/payments.tsx @@ -301,12 +301,19 @@ export async function getSubscriptions(options: { for (const catalogId of Object.keys(catalogs)) { if (catalogsWithDbSubscriptions.has(catalogId)) continue; const productsInCatalog = typedEntries(products).filter(([_, product]) => product.catalogId === catalogId); - const defaultCatalogProduct = productsInCatalog.find(([_, product]) => product.prices === "include-by-default"); - if (defaultCatalogProduct) { + const defaultCatalogProducts = productsInCatalog.filter(([_, product]) => product.prices === "include-by-default"); + if (defaultCatalogProducts.length > 1) { + throw new StackAssertionError( + "Multiple include-by-default products configured in the same catalog", + { catalogId, productIds: defaultCatalogProducts.map(([id]) => id) }, + ); + } + if (defaultCatalogProducts.length > 0) { + const product = defaultCatalogProducts[0]; subscriptions.push({ id: null, - productId: defaultCatalogProduct[0], - product: defaultCatalogProduct[1], + productId: product[0], + product: product[1], quantity: 1, currentPeriodStart: DEFAULT_PRODUCT_START_DATE, currentPeriodEnd: null, @@ -426,18 +433,19 @@ export async function validatePurchaseSession(options: { }, }); - if (codeData.productId && existingOneTimePurchases.some((p) => p.productId === codeData.productId)) { - throw new StatusError(400, "Customer already has a one-time purchase for this product"); - } - const subscriptions = await getSubscriptions({ prisma, tenancy, customerType: product.customerType, customerId: codeData.customerId, }); - if (subscriptions.find((s) => s.productId === codeData.productId) && product.stackable !== true) { - throw new StatusError(400, "Customer already has a subscription for this product; this product is not stackable"); + + if ( + codeData.productId && + product.stackable !== true && + [...subscriptions, ...existingOneTimePurchases].some((p) => p.productId === codeData.productId) + ) { + throw new StatusError(400, "Customer already has purchased this product; this product is not stackable"); } const addOnProductIds = product.isAddOnTo ? typedKeys(product.isAddOnTo) : []; if (product.isAddOnTo && !subscriptions.some((s) => s.productId && addOnProductIds.includes(s.productId))) { 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 f5c6c0a5e6..8413043a84 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 @@ -800,7 +800,7 @@ it("should block one-time purchase for same product after prior one-time purchas displayName: "One Time Offer", customerType: "user", serverOnly: false, - stackable: true, + stackable: false, prices: { one: { USD: "500" } }, includedItems: {}, }, @@ -842,7 +842,7 @@ it("should block one-time purchase for same product after prior one-time purchas body: { full_code: code2, price_id: "one", quantity: 1 }, }); expect(res.status).toBe(400); - expect(String(res.body)).toContain("one-time purchase for this product"); + expect(String(res.body)).toBe("Customer already has purchased this product; this product is not stackable"); }); it("should block one-time purchase in same group after prior one-time purchase in that group (test-mode persisted)", async ({ expect }) => { 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 e062c0a6d0..e3897cf8e0 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 @@ -800,7 +800,7 @@ it("should block one-time purchase for same product after prior one-time purchas displayName: "One Time Product", customerType: "user", serverOnly: false, - stackable: true, + stackable: false, prices: { one: { USD: "500" } }, includedItems: {}, }, @@ -842,7 +842,7 @@ it("should block one-time purchase for same product after prior one-time purchas body: { full_code: code2, price_id: "one", quantity: 1 }, }); expect(res.status).toBe(400); - expect(String(res.body)).toContain("one-time purchase for this product"); + expect(String(res.body)).toBe("Customer already has purchased this product; this product is not stackable"); }); it("should block one-time purchase in same group after prior one-time purchase in that group (test-mode persisted)", async ({ expect }) => {