Files
cfw-autumn/server/tests/unit/billing/stripe/subscriptions/build-subscription-items-update.spec.ts
2026-02-17 12:48:11 +00:00

845 lines
26 KiB
TypeScript

/**
* Single product tests for buildStripeSubscriptionItemsUpdate.
*
* These cover fundamental scenarios with a single customer product containing
* all price types (fixed, prepaid, consumable, allocated).
*
* For multi-product scenarios (multiple entities, customer + entity, main + add-on),
* see build-subscription-items-update-multi-product.spec.ts
*/
import { describe, expect, test } from "bun:test";
import { CusProductStatus } from "@autumn/shared";
import { contexts } from "@tests/utils/fixtures/db/contexts";
import { customerProducts } from "@tests/utils/fixtures/db/customerProducts";
import { prices } from "@tests/utils/fixtures/db/prices";
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,
} from "../stripeSubscriptionTestHelpers";
// ============ TESTS ============
describe(
chalk.yellowBright("buildStripeSubscriptionItemsUpdate - Single Product"),
() => {
describe(chalk.cyan("No Existing Subscription - New Products"), () => {
test("Customer-level product with all price types", () => {
const premium = createProductWithAllPriceTypes({
productId: "premium",
productName: "Premium",
customerProductId: "cus_prod_premium",
prepaidQuantity: 100,
allocatedUsage: 5,
});
const customerProduct = 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,
});
const ctx = contexts.create({ features: [] });
const billingContext = contexts.createBilling({
customerProducts: [],
stripeSubscription: undefined,
});
const result = buildStripeSubscriptionItemsUpdate({
ctx,
billingContext,
finalCustomerProducts: [customerProduct],
});
// Should create 4 items (fixed, prepaid, consumable, allocated)
expect(result).toHaveLength(4);
expectSubscriptionItemsUpdate(
result,
getExpectedNewProductItems(premium, { isEntityLevel: false }),
);
});
test("Entity-level product with all price types", () => {
const premium = createProductWithAllPriceTypes({
productId: "premium",
productName: "Premium",
customerProductId: "cus_prod_premium_entity",
prepaidQuantity: 50,
allocatedUsage: 3,
});
const entityCustomerProduct = customerProducts.create({
id: "cus_prod_premium_entity",
productId: "premium",
product: premium.product,
customerPrices: createCustomerPricesForProduct({
prices: premium.allPrices,
customerProductId: "cus_prod_premium_entity",
}),
customerEntitlements: premium.allEntitlements,
options: premium.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: [entityCustomerProduct],
});
// Should create 4 items (fixed, prepaid, consumable empty, allocated)
expect(result).toHaveLength(4);
expectSubscriptionItemsUpdate(
result,
getExpectedNewProductItems(premium, { isEntityLevel: true }),
);
});
test("Customer-level product with custom prepaid quantity", () => {
const premium = createProductWithAllPriceTypes({
productId: "premium",
productName: "Premium",
customerProductId: "cus_prod_premium",
prepaidQuantity: 250, // Custom quantity
allocatedUsage: 8,
});
const customerProduct = 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,
});
const ctx = contexts.create({ features: [] });
const billingContext = contexts.createBilling({
customerProducts: [],
stripeSubscription: undefined,
});
const result = buildStripeSubscriptionItemsUpdate({
ctx,
billingContext,
finalCustomerProducts: [customerProduct],
});
expect(result).toHaveLength(4);
// Verify prepaid quantity is correct
const prepaidItem = result.find((item) =>
item.price?.includes("prepaid"),
);
expect(prepaidItem?.quantity).toBe(250);
// Verify allocated quantity is correct
const allocatedItem = result.find((item) =>
item.price?.includes("allocated"),
);
expect(allocatedItem?.quantity).toBe(8);
});
});
describe(chalk.cyan("Existing Subscription - Quantity Updates"), () => {
test("Update prepaid quantity (increase)", () => {
// Original: prepaid=100, allocated=5
const premiumOriginal = createProductWithAllPriceTypes({
productId: "premium",
productName: "Premium",
customerProductId: "cus_prod_premium",
prepaidQuantity: 100,
allocatedUsage: 5,
});
// Updated: prepaid=200, allocated=5 (same)
const premiumUpdated = createProductWithAllPriceTypes({
productId: "premium",
productName: "Premium",
customerProductId: "cus_prod_premium",
prepaidQuantity: 200,
allocatedUsage: 5,
});
const customerProduct = customerProducts.create({
id: "cus_prod_premium",
productId: "premium",
product: premiumUpdated.product,
customerPrices: createCustomerPricesForProduct({
prices: premiumUpdated.allPrices,
customerProductId: "cus_prod_premium",
}),
customerEntitlements: premiumUpdated.allEntitlements,
options: premiumUpdated.allOptions,
status: CusProductStatus.Active,
subscriptionIds: ["sub_123"],
});
// Note: Customer-level products don't include metered consumable in stripe items
// because metered prices have undefined quantity and can't be meaningfully compared
const stripeSubscription = stripeSubscriptions.create({
id: "sub_123",
items: createStripeItemsFromProduct(premiumOriginal, {
isEntityLevel: false,
}),
});
const ctx = contexts.create({ features: [] });
const billingContext = contexts.createBilling({
customerProducts: [customerProduct],
stripeSubscription,
});
const result = buildStripeSubscriptionItemsUpdate({
ctx,
billingContext,
finalCustomerProducts: [customerProduct],
});
// Should update prepaid and add metered consumable (which wasn't in existing subscription)
expect(result).toHaveLength(2);
const prepaidUpdate = result.find((item) =>
item.id?.includes("prepaid"),
);
expect(prepaidUpdate).toEqual({
id: "si_premium_prepaid",
quantity: 200,
});
// Metered consumable gets added since it wasn't in the stripe subscription
const consumableAdd = result.find((item) =>
item.price?.includes("consumable"),
);
expect(consumableAdd?.price).toBe("stripe_premium_consumable");
});
test("Update allocated usage (increase)", () => {
// Original: prepaid=100, allocated=5
const premiumOriginal = createProductWithAllPriceTypes({
productId: "premium",
productName: "Premium",
customerProductId: "cus_prod_premium",
prepaidQuantity: 100,
allocatedUsage: 5,
});
// Updated: prepaid=100 (same), allocated=10
const premiumUpdated = createProductWithAllPriceTypes({
productId: "premium",
productName: "Premium",
customerProductId: "cus_prod_premium",
prepaidQuantity: 100,
allocatedUsage: 10,
});
const customerProduct = customerProducts.create({
id: "cus_prod_premium",
productId: "premium",
product: premiumUpdated.product,
customerPrices: createCustomerPricesForProduct({
prices: premiumUpdated.allPrices,
customerProductId: "cus_prod_premium",
}),
customerEntitlements: premiumUpdated.allEntitlements,
options: premiumUpdated.allOptions,
status: CusProductStatus.Active,
subscriptionIds: ["sub_123"],
});
const stripeSubscription = stripeSubscriptions.create({
id: "sub_123",
items: createStripeItemsFromProduct(premiumOriginal, {
isEntityLevel: false,
}),
});
const ctx = contexts.create({ features: [] });
const billingContext = contexts.createBilling({
customerProducts: [customerProduct],
stripeSubscription,
});
const result = buildStripeSubscriptionItemsUpdate({
ctx,
billingContext,
finalCustomerProducts: [customerProduct],
});
// Should update allocated and add metered consumable
expect(result).toHaveLength(2);
const allocatedUpdate = result.find((item) =>
item.id?.includes("allocated"),
);
expect(allocatedUpdate).toEqual({
id: "si_premium_allocated",
quantity: 10,
});
});
test("Update multiple quantities (prepaid and allocated)", () => {
// Original: prepaid=100, allocated=5
const premiumOriginal = createProductWithAllPriceTypes({
productId: "premium",
productName: "Premium",
customerProductId: "cus_prod_premium",
prepaidQuantity: 100,
allocatedUsage: 5,
});
// Updated: prepaid=150, allocated=8
const premiumUpdated = createProductWithAllPriceTypes({
productId: "premium",
productName: "Premium",
customerProductId: "cus_prod_premium",
prepaidQuantity: 150,
allocatedUsage: 8,
});
const customerProduct = customerProducts.create({
id: "cus_prod_premium",
productId: "premium",
product: premiumUpdated.product,
customerPrices: createCustomerPricesForProduct({
prices: premiumUpdated.allPrices,
customerProductId: "cus_prod_premium",
}),
customerEntitlements: premiumUpdated.allEntitlements,
options: premiumUpdated.allOptions,
status: CusProductStatus.Active,
subscriptionIds: ["sub_123"],
});
const stripeSubscription = stripeSubscriptions.create({
id: "sub_123",
items: createStripeItemsFromProduct(premiumOriginal, {
isEntityLevel: false,
}),
});
const ctx = contexts.create({ features: [] });
const billingContext = contexts.createBilling({
customerProducts: [customerProduct],
stripeSubscription,
});
const result = buildStripeSubscriptionItemsUpdate({
ctx,
billingContext,
finalCustomerProducts: [customerProduct],
});
// Should update prepaid, allocated, and add metered consumable
expect(result).toHaveLength(3);
const prepaidUpdate = result.find((item) =>
item.id?.includes("prepaid"),
);
expect(prepaidUpdate?.quantity).toBe(150);
const allocatedUpdate = result.find((item) =>
item.id?.includes("allocated"),
);
expect(allocatedUpdate?.quantity).toBe(8);
});
test("Decrease quantity (prepaid)", () => {
// Original: prepaid=200
const premiumOriginal = createProductWithAllPriceTypes({
productId: "premium",
productName: "Premium",
customerProductId: "cus_prod_premium",
prepaidQuantity: 200,
allocatedUsage: 5,
});
// Updated: prepaid=50
const premiumUpdated = createProductWithAllPriceTypes({
productId: "premium",
productName: "Premium",
customerProductId: "cus_prod_premium",
prepaidQuantity: 50,
allocatedUsage: 5,
});
const customerProduct = customerProducts.create({
id: "cus_prod_premium",
productId: "premium",
product: premiumUpdated.product,
customerPrices: createCustomerPricesForProduct({
prices: premiumUpdated.allPrices,
customerProductId: "cus_prod_premium",
}),
customerEntitlements: premiumUpdated.allEntitlements,
options: premiumUpdated.allOptions,
status: CusProductStatus.Active,
subscriptionIds: ["sub_123"],
});
const stripeSubscription = stripeSubscriptions.create({
id: "sub_123",
items: createStripeItemsFromProduct(premiumOriginal, {
isEntityLevel: false,
}),
});
const ctx = contexts.create({ features: [] });
const billingContext = contexts.createBilling({
customerProducts: [customerProduct],
stripeSubscription,
});
const result = buildStripeSubscriptionItemsUpdate({
ctx,
billingContext,
finalCustomerProducts: [customerProduct],
});
// Should update prepaid and add metered consumable
expect(result).toHaveLength(2);
const prepaidUpdate = result.find((item) =>
item.id?.includes("prepaid"),
);
expect(prepaidUpdate).toEqual({
id: "si_premium_prepaid",
quantity: 50,
});
});
});
describe(chalk.cyan("Existing Subscription - No Changes"), () => {
test("Same product, same quantities returns only metered addition", () => {
const premium = createProductWithAllPriceTypes({
productId: "premium",
productName: "Premium",
customerProductId: "cus_prod_premium",
prepaidQuantity: 100,
allocatedUsage: 5,
});
const customerProduct = 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_123"],
});
// Note: Customer-level subscription doesn't include metered consumable
const stripeSubscription = stripeSubscriptions.create({
id: "sub_123",
items: createStripeItemsFromProduct(premium, {
isEntityLevel: false,
}),
});
const ctx = contexts.create({ features: [] });
const billingContext = contexts.createBilling({
customerProducts: [customerProduct],
stripeSubscription,
});
const result = buildStripeSubscriptionItemsUpdate({
ctx,
billingContext,
finalCustomerProducts: [customerProduct],
});
// Only the metered consumable needs to be added (wasn't in existing subscription)
expect(result).toHaveLength(1);
expect(result[0].price).toBe("stripe_premium_consumable");
});
test("Entity product, same quantities returns only metered addition", () => {
const premium = createProductWithAllPriceTypes({
productId: "premium",
productName: "Premium",
customerProductId: "cus_prod_premium_entity",
prepaidQuantity: 100,
allocatedUsage: 5,
});
const entityCustomerProduct = customerProducts.create({
id: "cus_prod_premium_entity",
productId: "premium",
product: premium.product,
customerPrices: createCustomerPricesForProduct({
prices: premium.allPrices,
customerProductId: "cus_prod_premium_entity",
}),
customerEntitlements: premium.allEntitlements,
options: premium.allOptions,
status: CusProductStatus.Active,
subscriptionIds: ["sub_123"],
internalEntityId: "internal_entity_1",
entityId: "entity_1",
});
// Entity-level subscription has 3 items (no metered consumable)
// Note: withEntity is hardcoded to false, so entity products use regular metered prices
const stripeSubscription = stripeSubscriptions.create({
id: "sub_123",
items: createStripeItemsFromProduct(premium, { isEntityLevel: true }),
});
const ctx = contexts.create({ features: [] });
const billingContext = contexts.createBilling({
customerProducts: [entityCustomerProduct],
stripeSubscription,
});
const result = buildStripeSubscriptionItemsUpdate({
ctx,
billingContext,
finalCustomerProducts: [entityCustomerProduct],
});
// Only the metered consumable needs to be added (wasn't in existing subscription)
expect(result).toHaveLength(1);
expect(result[0].price).toBe("stripe_premium_consumable");
});
});
describe(chalk.cyan("Existing Subscription - Product Removal"), () => {
test("Remove all products marks all items as deleted", () => {
const premium = createProductWithAllPriceTypes({
productId: "premium",
productName: "Premium",
customerProductId: "cus_prod_premium",
});
// Customer-level subscription has 3 items (no metered consumable)
const stripeSubscription = stripeSubscriptions.create({
id: "sub_123",
items: createStripeItemsFromProduct(premium, {
isEntityLevel: false,
}),
});
const ctx = contexts.create({ features: [] });
const billingContext = contexts.createBilling({
customerProducts: [],
stripeSubscription,
});
const result = buildStripeSubscriptionItemsUpdate({
ctx,
billingContext,
finalCustomerProducts: [],
});
// Should delete all 3 items (fixed, prepaid, allocated)
expect(result).toHaveLength(3);
expectSubscriptionItemsUpdate(result, [
{ id: "si_premium_fixed", deleted: true },
{ id: "si_premium_prepaid", deleted: true },
{ id: "si_premium_allocated", deleted: true },
]);
});
test("Remove entity product marks all items as deleted", () => {
const premium = createProductWithAllPriceTypes({
productId: "premium",
productName: "Premium",
customerProductId: "cus_prod_premium_entity",
});
// Entity-level subscription has 3 items (no metered consumable - withEntity is hardcoded to false)
const stripeSubscription = stripeSubscriptions.create({
id: "sub_123",
items: createStripeItemsFromProduct(premium, { isEntityLevel: true }),
});
const ctx = contexts.create({ features: [] });
const billingContext = contexts.createBilling({
customerProducts: [],
stripeSubscription,
});
const result = buildStripeSubscriptionItemsUpdate({
ctx,
billingContext,
finalCustomerProducts: [],
});
// Should delete all 3 items (fixed, prepaid, allocated)
expect(result).toHaveLength(3);
expectSubscriptionItemsUpdate(result, [
{ id: "si_premium_fixed", deleted: true },
{ id: "si_premium_prepaid", deleted: true },
{ id: "si_premium_allocated", deleted: true },
]);
});
});
describe(chalk.cyan("Product Transition (Single to Single)"), () => {
test("Switch from Premium to Pro (different products)", () => {
const premium = createProductWithAllPriceTypes({
productId: "premium",
productName: "Premium",
customerProductId: "cus_prod_premium",
});
const pro = createProductWithAllPriceTypes({
productId: "pro",
productName: "Pro",
customerProductId: "cus_prod_pro",
});
// New product (Pro) - needs subscription ID to be included in the update
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"],
});
// Existing subscription has Premium (without metered consumable)
const stripeSubscription = stripeSubscriptions.create({
id: "sub_123",
items: createStripeItemsFromProduct(premium, {
isEntityLevel: false,
}),
});
const ctx = contexts.create({ features: [] });
const billingContext = contexts.createBilling({
customerProducts: [],
stripeSubscription,
});
const result = buildStripeSubscriptionItemsUpdate({
ctx,
billingContext,
finalCustomerProducts: [proCustomerProduct],
});
// Should delete 3 Premium items (fixed, prepaid, allocated - no metered in mock)
// and add 4 Pro items
expect(result).toHaveLength(7);
// Verify deletions (premium items)
const deletions = result.filter((item) => item.deleted === true);
expect(deletions).toHaveLength(3);
// Verify additions (pro items)
const additions = result.filter(
(item) => item.price !== undefined && !item.deleted,
);
expect(additions).toHaveLength(4);
// Verify pro items are added
const proItems = additions.filter((item) =>
item.price?.includes("pro_"),
);
expect(proItems).toHaveLength(4);
});
});
describe(chalk.cyan("One-Off Prices Exclusion"), () => {
test("One-off prices are NOT included in subscription items", () => {
const premium = createProductWithAllPriceTypes({
productId: "premium",
productName: "Premium",
customerProductId: "cus_prod_premium",
});
// Add a one-off price to the product
const oneOffPrice = prices.createOneOff({
id: "premium_oneoff",
stripePriceId: "stripe_premium_oneoff",
});
const allPricesWithOneOff = [...premium.allPrices, oneOffPrice];
const customerProduct = customerProducts.create({
id: "cus_prod_premium",
productId: "premium",
product: {
...premium.product,
prices: allPricesWithOneOff,
},
customerPrices: [
...createCustomerPricesForProduct({
prices: premium.allPrices,
customerProductId: "cus_prod_premium",
}),
prices.createCustomer({
price: oneOffPrice,
customerProductId: "cus_prod_premium",
}),
],
customerEntitlements: premium.allEntitlements,
options: premium.allOptions,
status: CusProductStatus.Active,
});
const ctx = contexts.create({ features: [] });
const billingContext = contexts.createBilling({
customerProducts: [],
stripeSubscription: undefined,
});
const result = buildStripeSubscriptionItemsUpdate({
ctx,
billingContext,
finalCustomerProducts: [customerProduct],
});
// Should only include the 4 recurring items, not the one-off
expect(result).toHaveLength(4);
// Verify one-off price is NOT included
const hasOneOff = result.some(
(item) => item.price === "stripe_premium_oneoff",
);
expect(hasOneOff).toBe(false);
});
});
describe(chalk.cyan("Edge Cases"), () => {
test("No stripe subscription and no products returns empty array", () => {
const ctx = contexts.create({ features: [] });
const billingContext = contexts.createBilling({
customerProducts: [],
stripeSubscription: undefined,
});
const result = buildStripeSubscriptionItemsUpdate({
ctx,
billingContext,
finalCustomerProducts: [],
});
expect(result).toHaveLength(0);
});
test("Entity-level consumable uses regular metered price (withEntity hardcoded to false)", () => {
const premium = createProductWithAllPriceTypes({
productId: "premium",
productName: "Premium",
customerProductId: "cus_prod_premium_entity",
});
const entityCustomerProduct = customerProducts.create({
id: "cus_prod_premium_entity",
productId: "premium",
product: premium.product,
customerPrices: createCustomerPricesForProduct({
prices: premium.allPrices,
customerProductId: "cus_prod_premium_entity",
}),
customerEntitlements: premium.allEntitlements,
options: premium.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: [entityCustomerProduct],
});
// Find the consumable item - now uses regular metered price (withEntity is hardcoded to false)
const consumableItem = result.find((item) =>
item.price?.includes("consumable"),
);
expect(consumableItem).toBeDefined();
expect(consumableItem?.price).toBe("stripe_premium_consumable");
// Metered prices should NOT have quantity
expect("quantity" in (consumableItem ?? {})).toBe(false);
});
test("Customer-level consumable (metered) has no quantity", () => {
const premium = createProductWithAllPriceTypes({
productId: "premium",
productName: "Premium",
customerProductId: "cus_prod_premium",
});
const customerProduct = 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,
});
const ctx = contexts.create({ features: [] });
const billingContext = contexts.createBilling({
customerProducts: [],
stripeSubscription: undefined,
});
const result = buildStripeSubscriptionItemsUpdate({
ctx,
billingContext,
finalCustomerProducts: [customerProduct],
});
// Find the consumable item
const consumableItem = result.find((item) =>
item.price?.includes("consumable"),
);
expect(consumableItem).toBeDefined();
expect(consumableItem?.price).toBe("stripe_premium_consumable");
// Metered prices should NOT have quantity
expect("quantity" in (consumableItem ?? {})).toBe(false);
});
});
},
);