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
161 changes: 156 additions & 5 deletions apps/backend/src/lib/payments.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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: {},
Expand Down Expand Up @@ -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' } },
Expand Down Expand Up @@ -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');
});
});

28 changes: 18 additions & 10 deletions apps/backend/src/lib/payments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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))) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {},
},
Expand Down Expand Up @@ -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 }) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {},
},
Expand Down Expand Up @@ -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 }) => {
Expand Down