Files
cfw-autumn/server/tests/unit/billing/stripe/subscriptions/build-subscription-items-update-multi-product.spec.ts
amianthus eb2c063a6f fix: 🐛 clean up
2026-04-28 18:56:44 +01:00

1328 lines
41 KiB
TypeScript

/**
* Multi-product tests for buildStripeSubscriptionItemsUpdate.
*
* These cover complex scenarios with multiple customer products:
* - Multiple entities with same/different products
* - Customer + entity combinations
* - Main + add-on products
* - Quantity aggregation
*/
import { describe, expect, test } from "bun:test";
import { CusProductStatus, ProcessorType } from "@autumn/shared";
import { contexts } from "@tests/utils/fixtures/db/contexts";
import { customerProducts } from "@tests/utils/fixtures/db/customerProducts";
import { stripeSubscriptions } from "@tests/utils/fixtures/stripe/subscriptions";
import chalk from "chalk";
import { buildStripeSubscriptionItemsUpdate } from "@/internal/billing/v2/providers/stripe/utils/subscriptionItems/buildStripeSubscriptionItemsUpdate";
import {
createCustomerPricesForProduct,
createProductWithAllPriceTypes,
createStripeItemsFromProduct,
expectSubscriptionItemsUpdate,
getExpectedNewProductItems,
getStripePriceIds,
} from "../stripeSubscriptionTestHelpers";
// ============ TESTS ============
describe(
chalk.yellowBright("buildStripeSubscriptionItemsUpdate - Multi-Product"),
() => {
describe(chalk.cyan("Multiple Entities - Same Product"), () => {
test("Two entities with same product (prices merged, quantities summed)", () => {
// Each entity needs its own product helper with its own customerProductId
const pro1 = createProductWithAllPriceTypes({
productId: "pro",
productName: "Pro",
customerProductId: "cus_prod_pro_entity1",
prepaidQuantity: 100,
allocatedUsage: 5,
});
const pro2 = createProductWithAllPriceTypes({
productId: "pro",
productName: "Pro",
customerProductId: "cus_prod_pro_entity2",
prepaidQuantity: 100,
allocatedUsage: 5,
});
// Entity 1 Pro
const entity1ProProduct = customerProducts.create({
id: "cus_prod_pro_entity1",
productId: "pro",
product: pro1.product,
customerPrices: createCustomerPricesForProduct({
prices: pro1.allPrices,
customerProductId: "cus_prod_pro_entity1",
}),
customerEntitlements: pro1.allEntitlements,
options: pro1.allOptions,
status: CusProductStatus.Active,
internalEntityId: "internal_entity_1",
entityId: "entity_1",
});
// Entity 2 Pro (same product, same quantities)
const entity2ProProduct = customerProducts.create({
id: "cus_prod_pro_entity2",
productId: "pro",
product: pro2.product,
customerPrices: createCustomerPricesForProduct({
prices: pro2.allPrices,
customerProductId: "cus_prod_pro_entity2",
}),
customerEntitlements: pro2.allEntitlements,
options: pro2.allOptions,
status: CusProductStatus.Active,
internalEntityId: "internal_entity_2",
entityId: "entity_2",
});
const ctx = contexts.create({ features: [] });
const billingContext = contexts.createBilling({
customerProducts: [],
stripeSubscription: undefined,
});
const result = buildStripeSubscriptionItemsUpdate({
ctx,
billingContext,
finalCustomerProducts: [entity1ProProduct, entity2ProProduct],
});
// Should have 4 items (prices merged)
expect(result).toHaveLength(4);
// Fixed: 1 + 1 = 2
const fixedItem = result.find((item) => item.price?.includes("fixed"));
expect(fixedItem?.quantity).toBe(2);
// Prepaid: 100 + 100 = 200
const prepaidItem = result.find((item) =>
item.price?.includes("prepaid"),
);
expect(prepaidItem?.quantity).toBe(200);
// Consumable (metered, withEntity hardcoded to false): no quantity
const consumableItem = result.find((item) =>
item.price?.includes("consumable"),
);
expect(consumableItem?.price).toBe("stripe_pro_consumable");
// Metered prices should NOT have quantity
expect("quantity" in (consumableItem ?? {})).toBe(false);
// Allocated: 5 + 5 = 10
const allocatedItem = result.find((item) =>
item.price?.includes("allocated"),
);
expect(allocatedItem?.quantity).toBe(10);
});
test("Three entities with same product, different prepaid quantities", () => {
const pro1 = createProductWithAllPriceTypes({
productId: "pro",
productName: "Pro",
customerProductId: "cus_prod_pro_entity1",
prepaidQuantity: 50,
allocatedUsage: 2,
});
const pro2 = createProductWithAllPriceTypes({
productId: "pro",
productName: "Pro",
customerProductId: "cus_prod_pro_entity2",
prepaidQuantity: 75,
allocatedUsage: 3,
});
const pro3 = createProductWithAllPriceTypes({
productId: "pro",
productName: "Pro",
customerProductId: "cus_prod_pro_entity3",
prepaidQuantity: 125,
allocatedUsage: 5,
});
const entity1ProProduct = customerProducts.create({
id: "cus_prod_pro_entity1",
productId: "pro",
product: pro1.product,
customerPrices: createCustomerPricesForProduct({
prices: pro1.allPrices,
customerProductId: "cus_prod_pro_entity1",
}),
customerEntitlements: pro1.allEntitlements,
options: pro1.allOptions,
status: CusProductStatus.Active,
internalEntityId: "internal_entity_1",
entityId: "entity_1",
});
const entity2ProProduct = customerProducts.create({
id: "cus_prod_pro_entity2",
productId: "pro",
product: pro2.product,
customerPrices: createCustomerPricesForProduct({
prices: pro2.allPrices,
customerProductId: "cus_prod_pro_entity2",
}),
customerEntitlements: pro2.allEntitlements,
options: pro2.allOptions,
status: CusProductStatus.Active,
internalEntityId: "internal_entity_2",
entityId: "entity_2",
});
const entity3ProProduct = customerProducts.create({
id: "cus_prod_pro_entity3",
productId: "pro",
product: pro3.product,
customerPrices: createCustomerPricesForProduct({
prices: pro3.allPrices,
customerProductId: "cus_prod_pro_entity3",
}),
customerEntitlements: pro3.allEntitlements,
options: pro3.allOptions,
status: CusProductStatus.Active,
internalEntityId: "internal_entity_3",
entityId: "entity_3",
});
const ctx = contexts.create({ features: [] });
const billingContext = contexts.createBilling({
customerProducts: [],
stripeSubscription: undefined,
});
const result = buildStripeSubscriptionItemsUpdate({
ctx,
billingContext,
finalCustomerProducts: [
entity1ProProduct,
entity2ProProduct,
entity3ProProduct,
],
});
expect(result).toHaveLength(4);
// Fixed: 1 + 1 + 1 = 3
const fixedItem = result.find((item) => item.price?.includes("fixed"));
expect(fixedItem?.quantity).toBe(3);
// Prepaid: 50 + 75 + 125 = 250
const prepaidItem = result.find((item) =>
item.price?.includes("prepaid"),
);
expect(prepaidItem?.quantity).toBe(250);
// Allocated: 2 + 3 + 5 = 10
const allocatedItem = result.find((item) =>
item.price?.includes("allocated"),
);
expect(allocatedItem?.quantity).toBe(10);
});
test("Add second entity to existing subscription", () => {
// Each entity needs its own product helper
const pro1 = createProductWithAllPriceTypes({
productId: "pro",
productName: "Pro",
customerProductId: "cus_prod_pro_entity1",
prepaidQuantity: 100,
allocatedUsage: 5,
});
const pro2 = createProductWithAllPriceTypes({
productId: "pro",
productName: "Pro",
customerProductId: "cus_prod_pro_entity2",
prepaidQuantity: 100,
allocatedUsage: 5,
});
// Entity 1 - already exists
const entity1ProProduct = customerProducts.create({
id: "cus_prod_pro_entity1",
productId: "pro",
product: pro1.product,
customerPrices: createCustomerPricesForProduct({
prices: pro1.allPrices,
customerProductId: "cus_prod_pro_entity1",
}),
customerEntitlements: pro1.allEntitlements,
options: pro1.allOptions,
status: CusProductStatus.Active,
internalEntityId: "internal_entity_1",
entityId: "entity_1",
subscriptionIds: ["sub_123"],
});
// Entity 2 - being added (needs subscription ID to be included)
const entity2ProProduct = customerProducts.create({
id: "cus_prod_pro_entity2",
productId: "pro",
product: pro2.product,
customerPrices: createCustomerPricesForProduct({
prices: pro2.allPrices,
customerProductId: "cus_prod_pro_entity2",
}),
customerEntitlements: pro2.allEntitlements,
options: pro2.allOptions,
status: CusProductStatus.Active,
internalEntityId: "internal_entity_2",
entityId: "entity_2",
subscriptionIds: ["sub_123"],
});
const stripeSubscription = stripeSubscriptions.create({
id: "sub_123",
items: createStripeItemsFromProduct(pro1, { isEntityLevel: true }),
});
const ctx = contexts.create({ features: [] });
const billingContext = contexts.createBilling({
customerProducts: [entity1ProProduct],
stripeSubscription,
});
const result = buildStripeSubscriptionItemsUpdate({
ctx,
billingContext,
finalCustomerProducts: [entity1ProProduct, entity2ProProduct],
});
// Should update quantities (double them) + add metered consumable
expect(result).toHaveLength(4); // fixed, prepaid, allocated, consumable (metered)
const fixedItem = result.find((item) => item.id?.includes("fixed"));
expect(fixedItem?.quantity).toBe(2);
const prepaidItem = result.find((item) => item.id?.includes("prepaid"));
expect(prepaidItem?.quantity).toBe(200);
const allocatedItem = result.find((item) =>
item.id?.includes("allocated"),
);
expect(allocatedItem?.quantity).toBe(10);
// Metered consumable needs to be added
const consumableItem = result.find((item) =>
item.price?.includes("consumable"),
);
expect(consumableItem).toBeDefined();
});
test("Remove one entity from two-entity subscription", () => {
const pro = createProductWithAllPriceTypes({
productId: "pro",
productName: "Pro",
customerProductId: "cus_prod_pro_entity1",
prepaidQuantity: 100,
allocatedUsage: 5,
});
// Only Entity 1 remains
const entity1ProProduct = customerProducts.create({
id: "cus_prod_pro_entity1",
productId: "pro",
product: pro.product,
customerPrices: createCustomerPricesForProduct({
prices: pro.allPrices,
customerProductId: "cus_prod_pro_entity1",
}),
customerEntitlements: pro.allEntitlements,
options: pro.allOptions,
status: CusProductStatus.Active,
internalEntityId: "internal_entity_1",
entityId: "entity_1",
subscriptionIds: ["sub_123"],
});
// Current subscription has 2 entities worth of quantities (no consumable since withEntity is false)
const stripeSubscription = stripeSubscriptions.create({
id: "sub_123",
items: [
{
id: "si_pro_fixed",
priceId: "stripe_pro_fixed",
quantity: 2,
},
{
id: "si_pro_prepaid",
priceId: "stripe_pro_prepaid",
quantity: 200,
},
{
id: "si_pro_allocated",
priceId: "stripe_pro_allocated",
quantity: 10,
},
],
});
const ctx = contexts.create({ features: [] });
const billingContext = contexts.createBilling({
customerProducts: [entity1ProProduct],
stripeSubscription,
});
const result = buildStripeSubscriptionItemsUpdate({
ctx,
billingContext,
finalCustomerProducts: [entity1ProProduct],
});
// Should update quantities (halve them) + add metered consumable
expect(result).toHaveLength(4); // fixed, prepaid, allocated, consumable
const fixedItem = result.find((item) => item.id?.includes("fixed"));
expect(fixedItem?.quantity).toBe(1);
const prepaidItem = result.find((item) => item.id?.includes("prepaid"));
expect(prepaidItem?.quantity).toBe(100);
const allocatedItem = result.find((item) =>
item.id?.includes("allocated"),
);
expect(allocatedItem?.quantity).toBe(5);
// Metered consumable needs to be added
const consumableItem = result.find((item) =>
item.price?.includes("consumable"),
);
expect(consumableItem).toBeDefined();
});
});
describe(chalk.cyan("Multiple Entities - Different Products"), () => {
test("Two entities with different products", () => {
const pro = createProductWithAllPriceTypes({
productId: "pro",
productName: "Pro",
customerProductId: "cus_prod_pro_entity1",
prepaidQuantity: 100,
allocatedUsage: 5,
});
const premium = createProductWithAllPriceTypes({
productId: "premium",
productName: "Premium",
customerProductId: "cus_prod_premium_entity2",
prepaidQuantity: 200,
allocatedUsage: 10,
});
const entity1ProProduct = customerProducts.create({
id: "cus_prod_pro_entity1",
productId: "pro",
product: pro.product,
customerPrices: createCustomerPricesForProduct({
prices: pro.allPrices,
customerProductId: "cus_prod_pro_entity1",
}),
customerEntitlements: pro.allEntitlements,
options: pro.allOptions,
status: CusProductStatus.Active,
internalEntityId: "internal_entity_1",
entityId: "entity_1",
});
const entity2PremiumProduct = customerProducts.create({
id: "cus_prod_premium_entity2",
productId: "premium",
product: premium.product,
customerPrices: createCustomerPricesForProduct({
prices: premium.allPrices,
customerProductId: "cus_prod_premium_entity2",
}),
customerEntitlements: premium.allEntitlements,
options: premium.allOptions,
status: CusProductStatus.Active,
internalEntityId: "internal_entity_2",
entityId: "entity_2",
});
const ctx = contexts.create({ features: [] });
const billingContext = contexts.createBilling({
customerProducts: [],
stripeSubscription: undefined,
});
const result = buildStripeSubscriptionItemsUpdate({
ctx,
billingContext,
finalCustomerProducts: [entity1ProProduct, entity2PremiumProduct],
});
// Should have 8 items (4 for each product)
expect(result).toHaveLength(8);
// Verify Pro items
const proItems = result.filter((item) => item.price?.includes("pro_"));
expect(proItems).toHaveLength(4);
// Verify Premium items
const premiumItems = result.filter((item) =>
item.price?.includes("premium_"),
);
expect(premiumItems).toHaveLength(4);
});
});
describe(chalk.cyan("Customer + Entity - Same Product"), () => {
test("Customer Pro + Entity Pro (both active)", () => {
const proCustomer = createProductWithAllPriceTypes({
productId: "pro",
productName: "Pro",
customerProductId: "cus_prod_pro_customer",
prepaidQuantity: 100,
allocatedUsage: 5,
});
const proEntity = createProductWithAllPriceTypes({
productId: "pro",
productName: "Pro",
customerProductId: "cus_prod_pro_entity",
prepaidQuantity: 50,
allocatedUsage: 3,
});
// Customer Pro
const customerProProduct = customerProducts.create({
id: "cus_prod_pro_customer",
productId: "pro",
product: proCustomer.product,
customerPrices: createCustomerPricesForProduct({
prices: proCustomer.allPrices,
customerProductId: "cus_prod_pro_customer",
}),
customerEntitlements: proCustomer.allEntitlements,
options: proCustomer.allOptions,
status: CusProductStatus.Active,
});
// Entity Pro
const entityProProduct = customerProducts.create({
id: "cus_prod_pro_entity",
productId: "pro",
product: proEntity.product,
customerPrices: createCustomerPricesForProduct({
prices: proEntity.allPrices,
customerProductId: "cus_prod_pro_entity",
}),
customerEntitlements: proEntity.allEntitlements,
options: proEntity.allOptions,
status: CusProductStatus.Active,
internalEntityId: "internal_entity_1",
entityId: "entity_1",
});
const ctx = contexts.create({ features: [] });
const billingContext = contexts.createBilling({
customerProducts: [],
stripeSubscription: undefined,
});
const result = buildStripeSubscriptionItemsUpdate({
ctx,
billingContext,
finalCustomerProducts: [customerProProduct, entityProProduct],
});
// Should have 4 items (withEntity hardcoded to false, so both use metered):
// - fixed (1+1=2)
// - prepaid (100+50=150)
// - allocated (5+3=8)
// - consumable metered (both customer and entity use regular price)
expect(result).toHaveLength(4);
// Fixed: 1 + 1 = 2
const fixedItem = result.find((item) => item.price?.includes("fixed"));
expect(fixedItem?.quantity).toBe(2);
// Prepaid: 100 + 50 = 150
const prepaidItem = result.find((item) =>
item.price?.includes("prepaid"),
);
expect(prepaidItem?.quantity).toBe(150);
// Allocated: 5 + 3 = 8
const allocatedItem = result.find((item) =>
item.price?.includes("allocated"),
);
expect(allocatedItem?.quantity).toBe(8);
// Only one consumable price (metered, withEntity is false)
const consumableMetered = result.find(
(item) => item.price === "stripe_pro_consumable",
);
expect(consumableMetered).toBeDefined();
// Metered should not have quantity
expect("quantity" in (consumableMetered ?? {})).toBe(false);
});
test("Customer Pro cancels, Entity Pro stays", () => {
const pro = createProductWithAllPriceTypes({
productId: "pro",
productName: "Pro",
customerProductId: "cus_prod_pro_entity",
prepaidQuantity: 100,
allocatedUsage: 5,
});
// Only Entity Pro remains
const entityProProduct = customerProducts.create({
id: "cus_prod_pro_entity",
productId: "pro",
product: pro.product,
customerPrices: createCustomerPricesForProduct({
prices: pro.allPrices,
customerProductId: "cus_prod_pro_entity",
}),
customerEntitlements: pro.allEntitlements,
options: pro.allOptions,
status: CusProductStatus.Active,
internalEntityId: "internal_entity_1",
entityId: "entity_1",
subscriptionIds: ["sub_123"],
});
// Current subscription has customer and entity (both use regular metered price - withEntity is false)
const stripeSubscription = stripeSubscriptions.create({
id: "sub_123",
items: [
{ id: "si_pro_fixed", priceId: "stripe_pro_fixed", quantity: 2 },
{
id: "si_pro_prepaid",
priceId: "stripe_pro_prepaid",
quantity: 200,
},
{
id: "si_pro_allocated",
priceId: "stripe_pro_allocated",
quantity: 10,
},
],
});
const ctx = contexts.create({ features: [] });
const billingContext = contexts.createBilling({
customerProducts: [entityProProduct],
stripeSubscription,
});
const result = buildStripeSubscriptionItemsUpdate({
ctx,
billingContext,
finalCustomerProducts: [entityProProduct],
});
// Should update quantities + add metered consumable (wasn't in subscription)
// Fixed: 2 -> 1
const fixedItem = result.find((item) => item.id === "si_pro_fixed");
expect(fixedItem?.quantity).toBe(1);
// Prepaid: 200 -> 100
const prepaidItem = result.find((item) => item.id === "si_pro_prepaid");
expect(prepaidItem?.quantity).toBe(100);
// Allocated: 10 -> 5
const allocatedItem = result.find(
(item) => item.id === "si_pro_allocated",
);
expect(allocatedItem?.quantity).toBe(5);
// Metered consumable should be added (wasn't in subscription)
const consumableItem = result.find(
(item) => item.price === "stripe_pro_consumable",
);
expect(consumableItem).toBeDefined();
});
});
describe(chalk.cyan("Customer + Entity - Different Products"), () => {
test("Customer Premium + Entity Pro", () => {
const premium = createProductWithAllPriceTypes({
productId: "premium",
productName: "Premium",
customerProductId: "cus_prod_premium_customer",
prepaidQuantity: 200,
allocatedUsage: 10,
});
const pro = createProductWithAllPriceTypes({
productId: "pro",
productName: "Pro",
customerProductId: "cus_prod_pro_entity",
prepaidQuantity: 50,
allocatedUsage: 3,
});
// Customer Premium
const customerPremiumProduct = customerProducts.create({
id: "cus_prod_premium_customer",
productId: "premium",
product: premium.product,
customerPrices: createCustomerPricesForProduct({
prices: premium.allPrices,
customerProductId: "cus_prod_premium_customer",
}),
customerEntitlements: premium.allEntitlements,
options: premium.allOptions,
status: CusProductStatus.Active,
});
// Entity Pro
const entityProProduct = customerProducts.create({
id: "cus_prod_pro_entity",
productId: "pro",
product: pro.product,
customerPrices: createCustomerPricesForProduct({
prices: pro.allPrices,
customerProductId: "cus_prod_pro_entity",
}),
customerEntitlements: pro.allEntitlements,
options: pro.allOptions,
status: CusProductStatus.Active,
internalEntityId: "internal_entity_1",
entityId: "entity_1",
});
const ctx = contexts.create({ features: [] });
const billingContext = contexts.createBilling({
customerProducts: [],
stripeSubscription: undefined,
});
const result = buildStripeSubscriptionItemsUpdate({
ctx,
billingContext,
finalCustomerProducts: [customerPremiumProduct, entityProProduct],
});
// Should have 8 items (4 for each product, different price IDs)
expect(result).toHaveLength(8);
// Verify Premium items
const premiumItems = result.filter((item) =>
item.price?.includes("premium_"),
);
expect(premiumItems).toHaveLength(4);
// Verify Pro items
const proItems = result.filter((item) => item.price?.includes("pro_"));
expect(proItems).toHaveLength(4);
});
});
describe(chalk.cyan("Main + Add-on Products"), () => {
test("Main product Pro + Add-on Credits", () => {
const pro = createProductWithAllPriceTypes({
productId: "pro",
productName: "Pro",
customerProductId: "cus_prod_pro",
prepaidQuantity: 100,
allocatedUsage: 5,
});
const addOnCredits = createProductWithAllPriceTypes({
productId: "addon_credits",
productName: "Add-on Credits",
customerProductId: "cus_prod_addon",
isAddOn: true,
prepaidQuantity: 500,
allocatedUsage: 0,
});
// Main Pro
const proCustomerProduct = customerProducts.create({
id: "cus_prod_pro",
productId: "pro",
product: pro.product,
customerPrices: createCustomerPricesForProduct({
prices: pro.allPrices,
customerProductId: "cus_prod_pro",
}),
customerEntitlements: pro.allEntitlements,
options: pro.allOptions,
status: CusProductStatus.Active,
});
// Add-on Credits
const addOnCustomerProduct = customerProducts.create({
id: "cus_prod_addon",
productId: "addon_credits",
product: addOnCredits.product,
customerPrices: createCustomerPricesForProduct({
prices: addOnCredits.allPrices,
customerProductId: "cus_prod_addon",
}),
customerEntitlements: addOnCredits.allEntitlements,
options: addOnCredits.allOptions,
status: CusProductStatus.Active,
});
const ctx = contexts.create({ features: [] });
const billingContext = contexts.createBilling({
customerProducts: [],
stripeSubscription: undefined,
});
const result = buildStripeSubscriptionItemsUpdate({
ctx,
billingContext,
finalCustomerProducts: [proCustomerProduct, addOnCustomerProduct],
});
// Should have 8 items (4 for main, 4 for add-on)
expect(result).toHaveLength(8);
// Verify Pro items
const proItems = result.filter((item) => item.price?.includes("pro_"));
expect(proItems).toHaveLength(4);
// Verify Add-on items
const addOnItems = result.filter((item) =>
item.price?.includes("addon_"),
);
expect(addOnItems).toHaveLength(4);
});
test("Main Pro + Add-on exists, remove add-on", () => {
const pro = createProductWithAllPriceTypes({
productId: "pro",
productName: "Pro",
customerProductId: "cus_prod_pro",
});
const addOnCredits = createProductWithAllPriceTypes({
productId: "addon_credits",
productName: "Add-on Credits",
customerProductId: "cus_prod_addon",
isAddOn: true,
});
// Only Main Pro remains
const proCustomerProduct = customerProducts.create({
id: "cus_prod_pro",
productId: "pro",
product: pro.product,
customerPrices: createCustomerPricesForProduct({
prices: pro.allPrices,
customerProductId: "cus_prod_pro",
}),
customerEntitlements: pro.allEntitlements,
options: pro.allOptions,
status: CusProductStatus.Active,
subscriptionIds: ["sub_123"],
});
// Subscription has both main and add-on (customer-level, so 3 items each - no metered)
const stripeSubscription = stripeSubscriptions.create({
id: "sub_123",
items: [
...createStripeItemsFromProduct(pro, {
isEntityLevel: false,
itemIdPrefix: "si_pro",
}),
...createStripeItemsFromProduct(addOnCredits, {
isEntityLevel: false,
itemIdPrefix: "si_addon",
}),
],
});
const ctx = contexts.create({ features: [] });
const billingContext = contexts.createBilling({
customerProducts: [proCustomerProduct],
stripeSubscription,
});
const result = buildStripeSubscriptionItemsUpdate({
ctx,
billingContext,
finalCustomerProducts: [proCustomerProduct],
});
// Should delete 3 add-on items (customer-level doesn't include metered)
const deletedItems = result.filter((item) => item.deleted === true);
expect(deletedItems).toHaveLength(3);
// All deleted items should be add-on items
const deletedAddOnItems = deletedItems.filter((item) =>
item.id?.includes("addon"),
);
expect(deletedAddOnItems).toHaveLength(3);
});
test("Add add-on to existing main product", () => {
const pro = createProductWithAllPriceTypes({
productId: "pro",
productName: "Pro",
customerProductId: "cus_prod_pro",
});
const addOnCredits = createProductWithAllPriceTypes({
productId: "addon_credits",
productName: "Add-on Credits",
customerProductId: "cus_prod_addon",
isAddOn: true,
prepaidQuantity: 1000,
});
// Main Pro (existing)
const proCustomerProduct = customerProducts.create({
id: "cus_prod_pro",
productId: "pro",
product: pro.product,
customerPrices: createCustomerPricesForProduct({
prices: pro.allPrices,
customerProductId: "cus_prod_pro",
}),
customerEntitlements: pro.allEntitlements,
options: pro.allOptions,
status: CusProductStatus.Active,
subscriptionIds: ["sub_123"],
});
// Add-on Credits (being added - needs subscription ID to be included)
const addOnCustomerProduct = customerProducts.create({
id: "cus_prod_addon",
productId: "addon_credits",
product: addOnCredits.product,
customerPrices: createCustomerPricesForProduct({
prices: addOnCredits.allPrices,
customerProductId: "cus_prod_addon",
}),
customerEntitlements: addOnCredits.allEntitlements,
options: addOnCredits.allOptions,
status: CusProductStatus.Active,
subscriptionIds: ["sub_123"],
});
// Existing subscription has main Pro only (customer-level, no metered)
const stripeSubscription = stripeSubscriptions.create({
id: "sub_123",
items: createStripeItemsFromProduct(pro, { isEntityLevel: false }),
});
const ctx = contexts.create({ features: [] });
const billingContext = contexts.createBilling({
customerProducts: [proCustomerProduct],
stripeSubscription,
});
const result = buildStripeSubscriptionItemsUpdate({
ctx,
billingContext,
finalCustomerProducts: [proCustomerProduct, addOnCustomerProduct],
});
// Should add 4 new add-on items (+ 1 metered for main pro that wasn't in existing)
const newItems = result.filter(
(item) => item.price !== undefined && !item.deleted,
);
expect(newItems).toHaveLength(5); // 4 add-on + 1 pro metered
// Should have 4 add-on items
const newAddOnItems = newItems.filter((item) =>
item.price?.includes("addon_"),
);
expect(newAddOnItems).toHaveLength(4);
});
});
describe(chalk.cyan("Complex Multi-Entity Scenarios"), () => {
test("Customer Premium + Entity 1 Pro + Entity 2 Pro", () => {
const premium = createProductWithAllPriceTypes({
productId: "premium",
productName: "Premium",
customerProductId: "cus_prod_premium_customer",
prepaidQuantity: 200,
allocatedUsage: 10,
});
const proEntity1 = createProductWithAllPriceTypes({
productId: "pro",
productName: "Pro",
customerProductId: "cus_prod_pro_entity1",
prepaidQuantity: 50,
allocatedUsage: 3,
});
const proEntity2 = createProductWithAllPriceTypes({
productId: "pro",
productName: "Pro",
customerProductId: "cus_prod_pro_entity2",
prepaidQuantity: 50,
allocatedUsage: 3,
});
// Customer Premium
const customerPremiumProduct = customerProducts.create({
id: "cus_prod_premium_customer",
productId: "premium",
product: premium.product,
customerPrices: createCustomerPricesForProduct({
prices: premium.allPrices,
customerProductId: "cus_prod_premium_customer",
}),
customerEntitlements: premium.allEntitlements,
options: premium.allOptions,
status: CusProductStatus.Active,
});
// Entity 1 Pro
const entity1ProProduct = customerProducts.create({
id: "cus_prod_pro_entity1",
productId: "pro",
product: proEntity1.product,
customerPrices: createCustomerPricesForProduct({
prices: proEntity1.allPrices,
customerProductId: "cus_prod_pro_entity1",
}),
customerEntitlements: proEntity1.allEntitlements,
options: proEntity1.allOptions,
status: CusProductStatus.Active,
internalEntityId: "internal_entity_1",
entityId: "entity_1",
});
// Entity 2 Pro
const entity2ProProduct = customerProducts.create({
id: "cus_prod_pro_entity2",
productId: "pro",
product: proEntity2.product,
customerPrices: createCustomerPricesForProduct({
prices: proEntity2.allPrices,
customerProductId: "cus_prod_pro_entity2",
}),
customerEntitlements: proEntity2.allEntitlements,
options: proEntity2.allOptions,
status: CusProductStatus.Active,
internalEntityId: "internal_entity_2",
entityId: "entity_2",
});
const ctx = contexts.create({ features: [] });
const billingContext = contexts.createBilling({
customerProducts: [],
stripeSubscription: undefined,
});
const result = buildStripeSubscriptionItemsUpdate({
ctx,
billingContext,
finalCustomerProducts: [
customerPremiumProduct,
entity1ProProduct,
entity2ProProduct,
],
});
// Premium: 4 items
// Pro: 4 items (entities merged)
expect(result).toHaveLength(8);
// Verify Premium items
const premiumFixed = result.find(
(item) => item.price === "stripe_premium_fixed",
);
expect(premiumFixed?.quantity).toBe(1);
// Verify Pro items (2 entities, so doubled)
const proFixed = result.find(
(item) => item.price === "stripe_pro_fixed",
);
expect(proFixed?.quantity).toBe(2);
const proPrepaid = result.find(
(item) => item.price === "stripe_pro_prepaid",
);
expect(proPrepaid?.quantity).toBe(100); // 50 + 50
});
test("Multiple products with same price ID aggregates correctly", () => {
// Two customer products that share the same stripe price IDs
const pro1 = createProductWithAllPriceTypes({
productId: "pro",
productName: "Pro",
customerProductId: "cus_prod_pro_1",
prepaidQuantity: 100,
allocatedUsage: 5,
});
const pro2 = createProductWithAllPriceTypes({
productId: "pro",
productName: "Pro",
customerProductId: "cus_prod_pro_2",
prepaidQuantity: 150,
allocatedUsage: 8,
});
const proProduct1 = customerProducts.create({
id: "cus_prod_pro_1",
productId: "pro",
product: pro1.product,
customerPrices: createCustomerPricesForProduct({
prices: pro1.allPrices,
customerProductId: "cus_prod_pro_1",
}),
customerEntitlements: pro1.allEntitlements,
options: pro1.allOptions,
status: CusProductStatus.Active,
});
const proProduct2 = customerProducts.create({
id: "cus_prod_pro_2",
productId: "pro",
product: pro2.product,
customerPrices: createCustomerPricesForProduct({
prices: pro2.allPrices,
customerProductId: "cus_prod_pro_2",
}),
customerEntitlements: pro2.allEntitlements,
options: pro2.allOptions,
status: CusProductStatus.Active,
});
const ctx = contexts.create({ features: [] });
const billingContext = contexts.createBilling({
customerProducts: [],
stripeSubscription: undefined,
});
const result = buildStripeSubscriptionItemsUpdate({
ctx,
billingContext,
finalCustomerProducts: [proProduct1, proProduct2],
});
// Same price IDs should be merged:
// - fixed, prepaid, allocated (licensed - quantities summed)
// - consumable metered (both undefined, merged to single item)
expect(result).toHaveLength(4);
// Fixed: 1 + 1 = 2
const fixedItem = result.find((item) => item.price?.includes("fixed"));
expect(fixedItem?.quantity).toBe(2);
// Prepaid: 100 + 150 = 250
const prepaidItem = result.find((item) =>
item.price?.includes("prepaid"),
);
expect(prepaidItem?.quantity).toBe(250);
// Allocated: 5 + 8 = 13
const allocatedItem = result.find((item) =>
item.price?.includes("allocated"),
);
expect(allocatedItem?.quantity).toBe(13);
// Consumable metered (merged, no quantity)
const consumableItem = result.find((item) =>
item.price?.includes("consumable"),
);
expect(consumableItem).toBeDefined();
expect("quantity" in (consumableItem ?? {})).toBe(false);
});
});
describe(chalk.cyan("Subscription Filtering"), () => {
test("Only includes customer products for the target subscription", () => {
const pro = createProductWithAllPriceTypes({
productId: "pro",
productName: "Pro",
customerProductId: "cus_prod_pro",
});
const premium = createProductWithAllPriceTypes({
productId: "premium",
productName: "Premium",
customerProductId: "cus_prod_premium",
});
// Pro on sub_123
const proCustomerProduct = customerProducts.create({
id: "cus_prod_pro",
productId: "pro",
product: pro.product,
customerPrices: createCustomerPricesForProduct({
prices: pro.allPrices,
customerProductId: "cus_prod_pro",
}),
customerEntitlements: pro.allEntitlements,
options: pro.allOptions,
status: CusProductStatus.Active,
subscriptionIds: ["sub_123"],
});
// Premium on sub_456 (different subscription)
const premiumCustomerProduct = customerProducts.create({
id: "cus_prod_premium",
productId: "premium",
product: premium.product,
customerPrices: createCustomerPricesForProduct({
prices: premium.allPrices,
customerProductId: "cus_prod_premium",
}),
customerEntitlements: premium.allEntitlements,
options: premium.allOptions,
status: CusProductStatus.Active,
subscriptionIds: ["sub_456"],
});
// Customer-level subscription has 3 items (no metered consumable)
const stripeSubscription = stripeSubscriptions.create({
id: "sub_123",
items: createStripeItemsFromProduct(pro, { isEntityLevel: false }),
});
const ctx = contexts.create({ features: [] });
const billingContext = contexts.createBilling({
customerProducts: [proCustomerProduct, premiumCustomerProduct],
stripeSubscription,
});
const result = buildStripeSubscriptionItemsUpdate({
ctx,
billingContext,
finalCustomerProducts: [proCustomerProduct, premiumCustomerProduct],
});
// Should only process Pro (on sub_123), not Premium (on sub_456)
// Pro needs to add metered consumable (wasn't in stripe subscription)
// Premium is filtered out entirely
expect(result).toHaveLength(1);
expect(result[0].price).toBe("stripe_pro_consumable");
});
});
describe(chalk.cyan("Processor Filtering"), () => {
test("Excludes RevenueCat-managed products when attaching a Stripe add-on (no existing subscription)", () => {
// Repro for Runable bug: a customer's main product is managed by
// RevenueCat (subscription_ids: []) and they attach a one-off /
// add-on via Stripe. Without filtering by processor type, the RC
// product's prices would leak into the new Stripe subscription.
const rcMain = createProductWithAllPriceTypes({
productId: "runable_pro_25_monthly",
productName: "Pro 25 Monthly (RevenueCat)",
customerProductId: "cus_prod_rc_main",
});
const stripeAddOn = createProductWithAllPriceTypes({
productId: "runable_topup_25",
productName: "Topup 25",
customerProductId: "cus_prod_topup",
isAddOn: true,
});
const rcMainCustomerProduct = customerProducts.create({
id: "cus_prod_rc_main",
productId: "runable_pro_25_monthly",
product: rcMain.product,
customerPrices: createCustomerPricesForProduct({
prices: rcMain.allPrices,
customerProductId: "cus_prod_rc_main",
}),
customerEntitlements: rcMain.allEntitlements,
options: rcMain.allOptions,
status: CusProductStatus.Active,
subscriptionIds: [], // RC products have no Stripe subscription
processorType: ProcessorType.RevenueCat,
});
const stripeAddOnCustomerProduct = customerProducts.create({
id: "cus_prod_topup",
productId: "runable_topup_25",
product: stripeAddOn.product,
customerPrices: createCustomerPricesForProduct({
prices: stripeAddOn.allPrices,
customerProductId: "cus_prod_topup",
}),
customerEntitlements: stripeAddOn.allEntitlements,
options: stripeAddOn.allOptions,
status: CusProductStatus.Active,
subscriptionIds: [],
// 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: [] });
// One-off / add-on attach: no existing Stripe subscription
const billingContext = contexts.createBilling({
customerProducts: [
rcMainCustomerProduct,
stripeAddOnCustomerProduct,
],
stripeSubscription: undefined,
});
const result = buildStripeSubscriptionItemsUpdate({
ctx,
billingContext,
finalCustomerProducts: [
rcMainCustomerProduct,
stripeAddOnCustomerProduct,
],
});
// No item should reference the RC-managed product's prices
const rcPriceIds = getStripePriceIds(rcMain);
for (const item of result) {
expect(rcPriceIds).not.toContain(item.price);
}
// Only the Stripe add-on contributes — get its expected items
const expectedItems = getExpectedNewProductItems(stripeAddOn);
expectSubscriptionItemsUpdate(result, expectedItems);
});
test("Excludes RevenueCat product even when only RC products are present", () => {
// If the only customer product is RC-managed (e.g. attaching a brand-new
// Stripe-managed product to a previously RC-only customer), no items
// should be produced for the RC product.
const rcMain = createProductWithAllPriceTypes({
productId: "rc_only",
productName: "RC Only",
customerProductId: "cus_prod_rc_only",
});
const rcMainCustomerProduct = customerProducts.create({
id: "cus_prod_rc_only",
productId: "rc_only",
product: rcMain.product,
customerPrices: createCustomerPricesForProduct({
prices: rcMain.allPrices,
customerProductId: "cus_prod_rc_only",
}),
customerEntitlements: rcMain.allEntitlements,
options: rcMain.allOptions,
status: CusProductStatus.Active,
subscriptionIds: [],
processorType: ProcessorType.RevenueCat,
});
const ctx = contexts.create({ features: [] });
const billingContext = contexts.createBilling({
customerProducts: [rcMainCustomerProduct],
stripeSubscription: undefined,
});
const result = buildStripeSubscriptionItemsUpdate({
ctx,
billingContext,
finalCustomerProducts: [rcMainCustomerProduct],
});
// No items at all — the only customer product is RC-managed and gets filtered out.
expect(result).toHaveLength(0);
});
});
},
);