diff --git a/server/tests/unit/billing/filter-customer-products-by-processor-type.spec.ts b/server/tests/unit/billing/filter-customer-products-by-processor-type.spec.ts new file mode 100644 index 000000000..3932f7926 --- /dev/null +++ b/server/tests/unit/billing/filter-customer-products-by-processor-type.spec.ts @@ -0,0 +1,138 @@ +/** + * Unit tests for filterCustomerProductsByProcessorType. + * + * Critical invariant: an unset `processor` field on a cus product MUST be + * treated as Stripe. Legacy Stripe-managed cus products do not tag the + * processor field; only RevenueCat-managed products explicitly set it. + */ + +import { describe, expect, test } from "bun:test"; +import { + filterCustomerProductsByProcessorType, + type FullCusProduct, + ProcessorType, +} from "@autumn/shared"; +import chalk from "chalk"; + +const baseCusProduct = (id: string): FullCusProduct => + ({ + id, + product: { name: id } as FullCusProduct["product"], + }) as FullCusProduct; + +describe( + chalk.yellowBright("filterCustomerProductsByProcessorType"), + () => { + test("includes cus product with unset processor when filtering for Stripe", () => { + const cp = baseCusProduct("legacy_stripe"); + // processor is undefined — legacy data shape + const result = filterCustomerProductsByProcessorType({ + customerProducts: [cp], + processorType: ProcessorType.Stripe, + }); + expect(result).toHaveLength(1); + expect(result[0].id).toBe("legacy_stripe"); + }); + + test("includes cus product with null processor when filtering for Stripe", () => { + const cp = { + ...baseCusProduct("null_stripe"), + processor: null, + } as unknown as FullCusProduct; + const result = filterCustomerProductsByProcessorType({ + customerProducts: [cp], + processorType: ProcessorType.Stripe, + }); + expect(result).toHaveLength(1); + }); + + test("includes cus product with explicit Stripe processor when filtering for Stripe", () => { + const cp = { + ...baseCusProduct("explicit_stripe"), + processor: { type: ProcessorType.Stripe }, + } as FullCusProduct; + const result = filterCustomerProductsByProcessorType({ + customerProducts: [cp], + processorType: ProcessorType.Stripe, + }); + expect(result).toHaveLength(1); + }); + + test("excludes cus product with unset processor when filtering for RevenueCat", () => { + const cp = baseCusProduct("legacy_stripe_excluded"); + const result = filterCustomerProductsByProcessorType({ + customerProducts: [cp], + processorType: ProcessorType.RevenueCat, + }); + expect(result).toHaveLength(0); + }); + + test("includes cus product with explicit RevenueCat processor when filtering for RevenueCat", () => { + const cp = { + ...baseCusProduct("rc"), + processor: { type: ProcessorType.RevenueCat }, + } as FullCusProduct; + const result = filterCustomerProductsByProcessorType({ + customerProducts: [cp], + processorType: ProcessorType.RevenueCat, + }); + expect(result).toHaveLength(1); + }); + + test("excludes RevenueCat cus product when filtering for Stripe", () => { + const cp = { + ...baseCusProduct("rc"), + processor: { type: ProcessorType.RevenueCat }, + } as FullCusProduct; + const result = filterCustomerProductsByProcessorType({ + customerProducts: [cp], + processorType: ProcessorType.Stripe, + }); + expect(result).toHaveLength(0); + }); + + test("mixed list: filtering for Stripe keeps unset + explicit Stripe, drops RevenueCat", () => { + const legacyStripe = baseCusProduct("legacy_stripe"); + const explicitStripe = { + ...baseCusProduct("explicit_stripe"), + processor: { type: ProcessorType.Stripe }, + } as FullCusProduct; + const rc = { + ...baseCusProduct("rc"), + processor: { type: ProcessorType.RevenueCat }, + } as FullCusProduct; + + const result = filterCustomerProductsByProcessorType({ + customerProducts: [legacyStripe, explicitStripe, rc], + processorType: ProcessorType.Stripe, + }); + expect(result.map((cp) => cp.id).sort()).toEqual( + ["explicit_stripe", "legacy_stripe"].sort(), + ); + }); + + test("mixed list: filtering for RevenueCat keeps only explicit RC", () => { + const legacyStripe = baseCusProduct("legacy_stripe"); + const rc = { + ...baseCusProduct("rc"), + processor: { type: ProcessorType.RevenueCat }, + } as FullCusProduct; + + const result = filterCustomerProductsByProcessorType({ + customerProducts: [legacyStripe, rc], + processorType: ProcessorType.RevenueCat, + }); + expect(result).toHaveLength(1); + expect(result[0].id).toBe("rc"); + }); + + test("empty list returns empty list", () => { + expect( + filterCustomerProductsByProcessorType({ + customerProducts: [], + processorType: ProcessorType.Stripe, + }), + ).toEqual([]); + }); + }, +); diff --git a/server/tests/unit/billing/stripe/subscriptions/build-subscription-items-update-multi-product.spec.ts b/server/tests/unit/billing/stripe/subscriptions/build-subscription-items-update-multi-product.spec.ts index 00aadb6a3..029e67120 100644 --- a/server/tests/unit/billing/stripe/subscriptions/build-subscription-items-update-multi-product.spec.ts +++ b/server/tests/unit/billing/stripe/subscriptions/build-subscription-items-update-multi-product.spec.ts @@ -1247,7 +1247,9 @@ describe( options: stripeAddOn.allOptions, status: CusProductStatus.Active, subscriptionIds: [], - // processor defaults to Stripe via cusProductToProcessorType + // IMPORTANT: processor is intentionally unset here — legacy Stripe-managed + // cus products have no `processor` field. The filter must treat unset as + // Stripe (so this product's items get included). RevenueCat is always tagged. }); const ctx = contexts.create({ features: [] }); diff --git a/shared/utils/cusProductUtils/filterCustomerProducts/filterCustomerProductsByProcessorType.ts b/shared/utils/cusProductUtils/filterCustomerProducts/filterCustomerProductsByProcessorType.ts index c412b02bd..6cd0ba703 100644 --- a/shared/utils/cusProductUtils/filterCustomerProducts/filterCustomerProductsByProcessorType.ts +++ b/shared/utils/cusProductUtils/filterCustomerProducts/filterCustomerProductsByProcessorType.ts @@ -1,13 +1,14 @@ import type { FullCusProduct } from "@models/cusProductModels/cusProductModels"; import { ProcessorType } from "@models/genModels/genEnums"; -import { cusProductToProcessorType } from "../convertCusProduct.js"; /** * Filter customer products by processor type. * - * `cusProductToProcessorType` treats an unset `processor` as Stripe (default), - * so passing `ProcessorType.Stripe` keeps both legacy unset rows and explicit - * Stripe-tagged rows. + * IMPORTANT: a customer product with no `processor` set (or `processor.type` unset) + * is treated as Stripe. Historically, Stripe-managed cus products were created + * without explicitly tagging the processor field, so a null/undefined processor + * defaults to Stripe. RevenueCat-managed products always have + * `processor.type === ProcessorType.RevenueCat` explicitly set. * * @param customerProducts - The customer products to filter * @param processorType - The processor type to keep (e.g. `ProcessorType.Stripe`) @@ -20,8 +21,10 @@ export const filterCustomerProductsByProcessorType = ({ customerProducts: FullCusProduct[]; processorType: ProcessorType; }): FullCusProduct[] => { - return customerProducts.filter( - (customerProduct) => - cusProductToProcessorType(customerProduct) === processorType, - ); + return customerProducts.filter((customerProduct) => { + // Default unset processor to Stripe — RevenueCat is always explicitly tagged. + const cusProductProcessorType = + customerProduct.processor?.type ?? ProcessorType.Stripe; + return cusProductProcessorType === processorType; + }); };