Files
cfw-autumn/server/tests/unit/billing/setup-feature-quantities/setup-feature-quantities-context.spec.ts
2026-02-17 12:48:11 +00:00

977 lines
25 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import type { UpdateSubscriptionV1Params } from "@autumn/shared";
import { contexts } from "@tests/utils/fixtures/db/contexts";
import { customerProducts } from "@tests/utils/fixtures/db/customerProducts";
import { entitlements } from "@tests/utils/fixtures/db/entitlements";
import { features } from "@tests/utils/fixtures/db/features";
import { prices } from "@tests/utils/fixtures/db/prices";
import { products } from "@tests/utils/fixtures/db/products";
import chalk from "chalk";
import { setupFeatureQuantitiesContext } from "@/internal/billing/v2/setup/setupFeatureQuantitiesContext";
// ============ TESTS ============
describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => {
describe("basic quantity inheritance", () => {
test("1. current has quantity, new params has none → uses current", () => {
const feature = features.create({
id: "credits",
name: "Credits",
});
const entitlement = entitlements.create({
id: "ent_credits",
featureId: "credits",
featureName: "Credits",
allowance: 0,
});
const price = prices.createPrepaid({
id: "price_credits",
featureId: "credits",
entitlementId: "ent_credits",
});
const fullProduct = products.createFull({
prices: [price],
entitlements: [entitlement],
});
const cusProduct = customerProducts.create({
options: [
{
feature_id: "credits",
internal_feature_id: "internal_credits",
quantity: 100,
},
],
customerPrices: [prices.createCustomer({ price })],
});
const params: UpdateSubscriptionV1Params = {
customer_id: "cus_test",
product_id: "prod_test",
// No options provided
};
const ctx = contexts.create({ features: [feature] });
const result = setupFeatureQuantitiesContext({
ctx,
featureQuantitiesParams: params,
fullProduct,
currentCustomerProduct: cusProduct,
});
expect(result).toHaveLength(1);
expect(result[0].feature_id).toBe("credits");
expect(result[0].quantity).toBe(100);
});
test("2. current has no quantity, new params has quantity → uses new", () => {
const feature = features.create({
id: "credits",
name: "Credits",
});
const entitlement = entitlements.create({
id: "ent_credits",
featureId: "credits",
featureName: "Credits",
allowance: 0,
});
const price = prices.createPrepaid({
id: "price_credits",
featureId: "credits",
entitlementId: "ent_credits",
});
const fullProduct = products.createFull({
prices: [price],
entitlements: [entitlement],
});
const cusProduct = customerProducts.create({ options: [] });
const params: UpdateSubscriptionV1Params = {
customer_id: "cus_test",
product_id: "prod_test",
options: [{ feature_id: "credits", quantity: 50 }],
};
const ctx = contexts.create({ features: [feature] });
const result = setupFeatureQuantitiesContext({
ctx,
featureQuantitiesParams: params,
fullProduct,
currentCustomerProduct: cusProduct,
});
expect(result).toHaveLength(1);
expect(result[0].feature_id).toBe("credits");
expect(result[0].quantity).toBe(50);
});
test("3. both have quantity → uses new (new takes precedence)", () => {
const feature = features.create({
id: "credits",
name: "Credits",
});
const entitlement = entitlements.create({
id: "ent_credits",
featureId: "credits",
featureName: "Credits",
allowance: 0,
});
const price = prices.createPrepaid({
id: "price_credits",
featureId: "credits",
entitlementId: "ent_credits",
});
const fullProduct = products.createFull({
prices: [price],
entitlements: [entitlement],
});
const cusProduct = customerProducts.create({
options: [
{
feature_id: "credits",
internal_feature_id: "internal_credits",
quantity: 100,
},
],
});
const params: UpdateSubscriptionV1Params = {
customer_id: "cus_test",
product_id: "prod_test",
options: [{ feature_id: "credits", quantity: 200 }],
};
const ctx = contexts.create({ features: [feature] });
const result = setupFeatureQuantitiesContext({
ctx,
featureQuantitiesParams: params,
fullProduct,
currentCustomerProduct: cusProduct,
});
expect(result).toHaveLength(1);
expect(result[0].feature_id).toBe("credits");
expect(result[0].quantity).toBe(200);
});
});
describe("multiple features", () => {
test("4. previous has all quantities, new has just one → updates only the specified one", () => {
const creditsFeature = features.create({
id: "credits",
name: "Credits",
});
const seatsFeature = features.create({
id: "seats",
name: "Seats",
});
const storageFeature = features.create({
id: "storage",
name: "Storage",
});
const creditsEnt = entitlements.create({
id: "ent_credits",
featureId: "credits",
featureName: "Credits",
allowance: 0,
});
const seatsEnt = entitlements.create({
id: "ent_seats",
featureId: "seats",
featureName: "Seats",
allowance: 0,
});
const storageEnt = entitlements.create({
id: "ent_storage",
featureId: "storage",
featureName: "Storage",
allowance: 0,
});
const creditsPrice = prices.createPrepaid({
id: "price_credits",
featureId: "credits",
entitlementId: "ent_credits",
});
const seatsPrice = prices.createPrepaid({
id: "price_seats",
featureId: "seats",
entitlementId: "ent_seats",
});
const storagePrice = prices.createPrepaid({
id: "price_storage",
featureId: "storage",
entitlementId: "ent_storage",
});
const fullProduct = products.createFull({
prices: [creditsPrice, seatsPrice, storagePrice],
entitlements: [creditsEnt, seatsEnt, storageEnt],
});
const cusProduct = customerProducts.create({
options: [
{
feature_id: "credits",
internal_feature_id: "internal_credits",
quantity: 100,
},
{
feature_id: "seats",
internal_feature_id: "internal_seats",
quantity: 5,
},
{
feature_id: "storage",
internal_feature_id: "internal_storage",
quantity: 50,
},
],
customerPrices: [
prices.createCustomer({ price: creditsPrice }),
prices.createCustomer({ price: seatsPrice }),
prices.createCustomer({ price: storagePrice }),
],
});
const params: UpdateSubscriptionV1Params = {
customer_id: "cus_test",
product_id: "prod_test",
options: [{ feature_id: "seats", quantity: 10 }], // Only updating seats
};
const ctx = contexts.create({
features: [creditsFeature, seatsFeature, storageFeature],
});
const result = setupFeatureQuantitiesContext({
ctx,
featureQuantitiesParams: params,
fullProduct,
currentCustomerProduct: cusProduct,
});
expect(result).toHaveLength(3);
const credits = result.find((r) => r.feature_id === "credits");
const seats = result.find((r) => r.feature_id === "seats");
const storage = result.find((r) => r.feature_id === "storage");
expect(credits?.quantity).toBe(100); // Unchanged
expect(seats?.quantity).toBe(10); // Updated
expect(storage?.quantity).toBe(50); // Unchanged
});
});
describe("billing units handling", () => {
test("5. new params quantity is rounded to billing units", () => {
const feature = features.create({
id: "credits",
name: "Credits",
});
const entitlement = entitlements.create({
id: "ent_credits",
featureId: "credits",
featureName: "Credits",
allowance: 0,
});
const price = prices.createPrepaid({
id: "price_credits",
featureId: "credits",
entitlementId: "ent_credits",
billingUnits: 100, // Billing in units of 100
});
const fullProduct = products.createFull({
prices: [price],
entitlements: [entitlement],
});
const cusProduct = customerProducts.create({ options: [] });
const params: UpdateSubscriptionV1Params = {
customer_id: "cus_test",
product_id: "prod_test",
options: [{ feature_id: "credits", quantity: 150 }], // Should round up to 200
};
const ctx = contexts.create({ features: [feature] });
const result = setupFeatureQuantitiesContext({
ctx,
featureQuantitiesParams: params,
fullProduct,
currentCustomerProduct: cusProduct,
});
expect(result).toHaveLength(1);
expect(result[0].feature_id).toBe("credits");
// 150 rounded up to 200, then divided by billingUnits (100) = 2
expect(result[0].quantity).toBe(2);
});
test("exact billing unit multiple is not changed", () => {
const feature = features.create({
id: "credits",
name: "Credits",
});
const entitlement = entitlements.create({
id: "ent_credits",
featureId: "credits",
featureName: "Credits",
allowance: 0,
});
const price = prices.createPrepaid({
id: "price_credits",
featureId: "credits",
entitlementId: "ent_credits",
billingUnits: 50,
});
const fullProduct = products.createFull({
prices: [price],
entitlements: [entitlement],
});
const cusProduct = customerProducts.create({ options: [] });
const params: UpdateSubscriptionV1Params = {
customer_id: "cus_test",
product_id: "prod_test",
options: [{ feature_id: "credits", quantity: 200 }], // Exact multiple
};
const ctx = contexts.create({ features: [feature] });
const result = setupFeatureQuantitiesContext({
ctx,
featureQuantitiesParams: params,
fullProduct,
currentCustomerProduct: cusProduct,
});
expect(result).toHaveLength(1);
// 200 / 50 = 4
expect(result[0].quantity).toBe(4);
});
test("small quantity rounds up to 1 billing unit", () => {
const feature = features.create({
id: "credits",
name: "Credits",
});
const entitlement = entitlements.create({
id: "ent_credits",
featureId: "credits",
featureName: "Credits",
allowance: 0,
});
const price = prices.createPrepaid({
id: "price_credits",
featureId: "credits",
entitlementId: "ent_credits",
billingUnits: 1000,
});
const fullProduct = products.createFull({
prices: [price],
entitlements: [entitlement],
});
const cusProduct = customerProducts.create({ options: [] });
const params: UpdateSubscriptionV1Params = {
customer_id: "cus_test",
product_id: "prod_test",
options: [{ feature_id: "credits", quantity: 1 }], // Should round to 1000
};
const ctx = contexts.create({ features: [feature] });
const result = setupFeatureQuantitiesContext({
ctx,
featureQuantitiesParams: params,
fullProduct,
currentCustomerProduct: cusProduct,
});
expect(result).toHaveLength(1);
// 1 rounded to 1000, then divided by billingUnits (1000) = 1
expect(result[0].quantity).toBe(1);
});
test("converts current quantity from old billing units to new billing units", () => {
// Scenario: Customer has 5 packs of 100 credits (500 actual credits)
// New price uses packs of 250
// Expected: 500 credits → rounds to 500 (nearest 250) → 2 packs
const feature = features.create({
id: "credits",
name: "Credits",
});
const entitlement = entitlements.create({
id: "ent_credits",
featureId: "credits",
featureName: "Credits",
allowance: 0,
});
// Old price: billing units of 100
const oldPrice = prices.createPrepaid({
id: "price_credits_old",
featureId: "credits",
entitlementId: "ent_credits",
billingUnits: 100,
});
// New price: billing units of 250
const newPrice = prices.createPrepaid({
id: "price_credits_new",
featureId: "credits",
entitlementId: "ent_credits",
billingUnits: 250,
});
const fullProduct = products.createFull({
prices: [newPrice],
entitlements: [entitlement],
});
const cusProduct = customerProducts.create({
options: [
{
feature_id: "credits",
internal_feature_id: "internal_credits",
quantity: 5, // 5 packs of 100 = 500 actual credits
},
],
customerPrices: [prices.createCustomer({ price: oldPrice })],
});
const params: UpdateSubscriptionV1Params = {
customer_id: "cus_test",
product_id: "prod_test",
// No options - should inherit from current
};
const ctx = contexts.create({ features: [feature] });
const result = setupFeatureQuantitiesContext({
ctx,
featureQuantitiesParams: params,
fullProduct,
currentCustomerProduct: cusProduct,
});
expect(result).toHaveLength(1);
expect(result[0].feature_id).toBe("credits");
// Old: 5 packs * 100 = 500 credits
// Round 500 to nearest 250 = 500
// New: 500 / 250 = 2 packs
expect(result[0].quantity).toBe(2);
});
test("converts and rounds up when not exact multiple of new billing units", () => {
// Scenario: Customer has 3 packs of 100 credits (300 actual credits)
// New price uses packs of 250
// Expected: 300 credits → rounds to 500 (nearest 250) → 2 packs
const feature = features.create({
id: "credits",
name: "Credits",
});
const entitlement = entitlements.create({
id: "ent_credits",
featureId: "credits",
featureName: "Credits",
allowance: 0,
});
const oldPrice = prices.createPrepaid({
id: "price_credits_old",
featureId: "credits",
entitlementId: "ent_credits",
billingUnits: 100,
});
const newPrice = prices.createPrepaid({
id: "price_credits_new",
featureId: "credits",
entitlementId: "ent_credits",
billingUnits: 250,
});
const fullProduct = products.createFull({
prices: [newPrice],
entitlements: [entitlement],
});
const cusProduct = customerProducts.create({
options: [
{
feature_id: "credits",
internal_feature_id: "internal_credits",
quantity: 3, // 3 packs of 100 = 300 actual credits
},
],
customerPrices: [prices.createCustomer({ price: oldPrice })],
});
const params: UpdateSubscriptionV1Params = {
customer_id: "cus_test",
product_id: "prod_test",
};
const ctx = contexts.create({ features: [feature] });
const result = setupFeatureQuantitiesContext({
ctx,
featureQuantitiesParams: params,
fullProduct,
currentCustomerProduct: cusProduct,
});
expect(result).toHaveLength(1);
// Old: 3 packs * 100 = 300 credits
// Round 300 to nearest 250 (ceiling) = 500
// New: 500 / 250 = 2 packs
expect(result[0].quantity).toBe(2);
});
test("no old customer price returns undefined (no quantity inherited)", () => {
// Scenario: No old price found - can't interpret the stored quantity
// Should return empty result (no feature quantities)
const feature = features.create({
id: "credits",
name: "Credits",
});
const entitlement = entitlements.create({
id: "ent_credits",
featureId: "credits",
featureName: "Credits",
allowance: 0,
});
const newPrice = prices.createPrepaid({
id: "price_credits_new",
featureId: "credits",
entitlementId: "ent_credits",
billingUnits: 250,
});
const fullProduct = products.createFull({
prices: [newPrice],
entitlements: [entitlement],
});
const cusProduct = customerProducts.create({
options: [
{
feature_id: "credits",
internal_feature_id: "internal_credits",
quantity: 500,
},
],
// No customerPrices - can't interpret stored quantity
});
const params: UpdateSubscriptionV1Params = {
customer_id: "cus_test",
product_id: "prod_test",
};
const ctx = contexts.create({ features: [feature] });
const result = setupFeatureQuantitiesContext({
ctx,
featureQuantitiesParams: params,
fullProduct,
currentCustomerProduct: cusProduct,
});
// No old price means we can't interpret the stored quantity
// So no feature quantity is inherited
expect(result).toHaveLength(0);
});
});
describe("price type filtering", () => {
test("skips non-prepaid prices (fixed prices)", () => {
const feature = features.create({
id: "credits",
name: "Credits",
});
const entitlement = entitlements.create({
id: "ent_credits",
featureId: "credits",
featureName: "Credits",
allowance: 0,
});
const fixedPrice = prices.createFixed({ id: "price_fixed" });
const prepaidPrice = prices.createPrepaid({
id: "price_credits",
featureId: "credits",
entitlementId: "ent_credits",
});
const fullProduct = products.createFull({
prices: [fixedPrice, prepaidPrice],
entitlements: [entitlement],
});
const cusProduct = customerProducts.create({ options: [] });
const params: UpdateSubscriptionV1Params = {
customer_id: "cus_test",
product_id: "prod_test",
options: [{ feature_id: "credits", quantity: 50 }],
};
const ctx = contexts.create({ features: [feature] });
const result = setupFeatureQuantitiesContext({
ctx,
featureQuantitiesParams: params,
fullProduct,
currentCustomerProduct: cusProduct,
});
// Only the prepaid price should be processed
expect(result).toHaveLength(1);
expect(result[0].feature_id).toBe("credits");
});
});
describe("edge cases", () => {
test("empty prices array returns empty array", () => {
const fullProduct = products.createFull({ prices: [], entitlements: [] });
const cusProduct = customerProducts.create({ options: [] });
const params: UpdateSubscriptionV1Params = {
customer_id: "cus_test",
product_id: "prod_test",
};
const ctx = contexts.create({ features: [] });
const result = setupFeatureQuantitiesContext({
ctx,
featureQuantitiesParams: params,
fullProduct,
currentCustomerProduct: cusProduct,
});
expect(result).toHaveLength(0);
});
test("throws error when entitlement not found for price", () => {
const price = prices.createPrepaid({
id: "price_credits",
featureId: "credits",
entitlementId: "ent_credits",
});
// No entitlements provided - entitlement won't be found
const fullProduct = products.createFull({
prices: [price],
entitlements: [],
});
const cusProduct = customerProducts.create({ options: [] });
const params: UpdateSubscriptionV1Params = {
customer_id: "cus_test",
product_id: "prod_test",
};
const ctx = contexts.create({ features: [] });
expect(() =>
setupFeatureQuantitiesContext({
ctx,
featureQuantitiesParams: params,
fullProduct,
currentCustomerProduct: cusProduct,
}),
).toThrow("Entitlement not found for price");
});
test("neither current nor new has quantity → feature not included", () => {
const feature = features.create({
id: "credits",
name: "Credits",
});
const entitlement = entitlements.create({
id: "ent_credits",
featureId: "credits",
featureName: "Credits",
allowance: 0,
});
const price = prices.createPrepaid({
id: "price_credits",
featureId: "credits",
entitlementId: "ent_credits",
});
const fullProduct = products.createFull({
prices: [price],
entitlements: [entitlement],
});
const cusProduct = customerProducts.create({ options: [] }); // No current options
const params: UpdateSubscriptionV1Params = {
customer_id: "cus_test",
product_id: "prod_test",
// No options in params either
};
const ctx = contexts.create({ features: [feature] });
const result = setupFeatureQuantitiesContext({
ctx,
featureQuantitiesParams: params,
fullProduct,
currentCustomerProduct: cusProduct,
});
// Since neither current nor new has quantity, nothing should be included
expect(result).toHaveLength(0);
});
test("params.options explicitly set to empty array → uses current quantities", () => {
const feature = features.create({
id: "credits",
name: "Credits",
});
const entitlement = entitlements.create({
id: "ent_credits",
featureId: "credits",
featureName: "Credits",
allowance: 0,
});
const price = prices.createPrepaid({
id: "price_credits",
featureId: "credits",
entitlementId: "ent_credits",
});
const fullProduct = products.createFull({
prices: [price],
entitlements: [entitlement],
});
const cusProduct = customerProducts.create({
options: [
{
feature_id: "credits",
internal_feature_id: "internal_credits",
quantity: 100,
},
],
customerPrices: [prices.createCustomer({ price })],
});
const params: UpdateSubscriptionV1Params = {
customer_id: "cus_test",
product_id: "prod_test",
options: [], // Explicitly empty
};
const ctx = contexts.create({ features: [feature] });
const result = setupFeatureQuantitiesContext({
ctx,
featureQuantitiesParams: params,
fullProduct,
currentCustomerProduct: cusProduct,
});
expect(result).toHaveLength(1);
expect(result[0].feature_id).toBe("credits");
expect(result[0].quantity).toBe(100); // Falls back to current
});
test("new quantity of 0 is valid and replaces current", () => {
const feature = features.create({
id: "credits",
name: "Credits",
});
const entitlement = entitlements.create({
id: "ent_credits",
featureId: "credits",
featureName: "Credits",
allowance: 0,
});
const price = prices.createPrepaid({
id: "price_credits",
featureId: "credits",
entitlementId: "ent_credits",
});
const fullProduct = products.createFull({
prices: [price],
entitlements: [entitlement],
});
const cusProduct = customerProducts.create({
options: [
{
feature_id: "credits",
internal_feature_id: "internal_credits",
quantity: 100,
},
],
customerPrices: [prices.createCustomer({ price })],
});
const params: UpdateSubscriptionV1Params = {
customer_id: "cus_test",
product_id: "prod_test",
options: [{ feature_id: "credits", quantity: 0 }],
};
const ctx = contexts.create({ features: [feature] });
const result = setupFeatureQuantitiesContext({
ctx,
featureQuantitiesParams: params,
fullProduct,
currentCustomerProduct: cusProduct,
});
expect(result).toHaveLength(1);
expect(result[0].quantity).toBe(0);
});
test("handles feature matched by internal_feature_id in current options", () => {
const feature = features.create({
id: "credits",
internalId: "internal_credits_v2",
name: "Credits",
});
const entitlement = entitlements.create({
id: "ent_credits",
featureId: "credits",
internalFeatureId: "internal_credits_v2",
featureName: "Credits",
allowance: 0,
});
const price = prices.createPrepaid({
id: "price_credits",
featureId: "credits",
internalFeatureId: "internal_credits_v2",
entitlementId: "ent_credits",
});
const fullProduct = products.createFull({
prices: [price],
entitlements: [entitlement],
});
const cusProduct = customerProducts.create({
options: [
{
feature_id: "old_credits_id", // Different feature_id
internal_feature_id: "internal_credits_v2", // Matches by internal_id
quantity: 75,
},
],
customerPrices: [prices.createCustomer({ price })],
});
const params: UpdateSubscriptionV1Params = {
customer_id: "cus_test",
product_id: "prod_test",
};
const ctx = contexts.create({ features: [feature] });
const result = setupFeatureQuantitiesContext({
ctx,
featureQuantitiesParams: params,
fullProduct,
currentCustomerProduct: cusProduct,
});
expect(result).toHaveLength(1);
expect(result[0].quantity).toBe(75);
});
test("current has options with quantity 0, new params has no options → carries over 0", () => {
const feature = features.create({
id: "seats",
name: "Seats",
});
const entitlement = entitlements.create({
id: "ent_seats",
featureId: "seats",
featureName: "Seats",
allowance: 0,
});
const price = prices.createPrepaid({
id: "price_seats",
featureId: "seats",
entitlementId: "ent_seats",
});
const fullProduct = products.createFull({
prices: [price],
entitlements: [entitlement],
});
const cusProduct = customerProducts.create({
options: [
{
feature_id: "seats",
internal_feature_id: "internal_seats",
quantity: 0,
},
],
customerPrices: [prices.createCustomer({ price })],
});
const params: UpdateSubscriptionV1Params = {
customer_id: "cus_test",
product_id: "prod_test",
// No options provided - should carry over from current
};
const ctx = contexts.create({ features: [feature] });
const result = setupFeatureQuantitiesContext({
ctx,
featureQuantitiesParams: params,
fullProduct,
currentCustomerProduct: cusProduct,
});
expect(result).toHaveLength(1);
expect(result[0].feature_id).toBe("seats");
expect(result[0].quantity).toBe(0);
});
});
});