feat: allow users to pass in auto_enable_plan_id to create customer to ensure that they can selectively choose which customers have which product auto enabled.

This commit is contained in:
John Yeo
2026-01-24 19:19:39 +00:00
parent 01d8abc3a4
commit 0f9b67c0e6
9 changed files with 628 additions and 480 deletions

View File

@@ -1,15 +0,0 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "npx ultracite fix"
}
]
}
]
}
}

View File

@@ -39,7 +39,7 @@ export const setupCreateCustomer = async ({
// 3. Fetch default products
const { fullProducts, paidProducts, hasPaidProducts } =
await setupDefaultProductsContext({ ctx, internalOptions });
await setupDefaultProductsContext({ ctx, customerData, internalOptions });
const currentEpochMs = Date.now();

View File

@@ -1,7 +1,10 @@
import {
type CreateCustomerInternalOptions,
type CustomerData,
type FullProduct,
isFreeProduct,
ProductNotFoundError,
RecaseError,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { ProductService } from "@/internal/products/ProductService.js";
@@ -13,15 +16,69 @@ export interface DefaultProductsContext {
hasPaidProducts: boolean;
}
const getOverrideAutoEnableProduct = async ({
ctx,
customerData,
}: {
ctx: AutumnContext;
customerData?: CustomerData;
}): Promise<FullProduct | undefined> => {
const { db, org, env } = ctx;
if (!customerData?.auto_enable_plan_id) return undefined;
const plan = await ProductService.getFull({
db,
orgId: org.id,
env,
idOrInternalId: customerData.auto_enable_plan_id,
});
if (!plan)
throw new ProductNotFoundError({
productId: customerData.auto_enable_plan_id,
});
if (
!isFreeProduct({ prices: plan.prices }) &&
!isDefaultTrialFullProduct({ product: plan })
) {
throw new RecaseError({
message: `Auto-enable plan must be a free product, or have a free trial with 'card_required' as false`,
});
}
return plan;
};
export const setupDefaultProductsContext = async ({
ctx,
customerData,
internalOptions,
}: {
ctx: AutumnContext;
customerData?: CustomerData;
internalOptions?: CreateCustomerInternalOptions;
}): Promise<DefaultProductsContext> => {
const { db, org, env } = ctx;
const autoEnableProduct = await getOverrideAutoEnableProduct({
ctx,
customerData,
});
if (autoEnableProduct) {
const autoEnableIsPaid = !isFreeProduct({
prices: autoEnableProduct.prices,
});
return {
fullProducts: [autoEnableProduct],
paidProducts: autoEnableIsPaid ? [autoEnableProduct] : [],
hasPaidProducts: autoEnableIsPaid,
};
}
const defaultProds = await ProductService.listDefault({
db,
orgId: org.id,

View File

@@ -6,6 +6,7 @@ import {
expectProductTrialing,
} from "@tests/integration/billing/utils/expectCustomerProductTrialing.js";
import { TestFeature } from "@tests/setup/v2Features.js";
import { expectProductNotAttached } from "@tests/utils/expectUtils/expectProductAttached";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
@@ -80,3 +81,68 @@ test.concurrent(`${chalk.yellowBright("defaults: free product with 7-day trial")
// Verify feature balance is still available during trial
expect(customer.features[TestFeature.Messages].balance).toBe(100);
});
// ═══════════════════════════════════════════════════════════════════════════════
// AUTO-ENABLE PLAN OVERRIDE TESTS
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("defaults: auto-enable plan override")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const messagesItemB = items.monthlyMessages({ includedUsage: 200 });
const autoEnableProductA = products.base({
id: "auto-enable-a",
items: [messagesItem],
group: "auto-enable-group-a",
isDefault: true,
});
const autoEnableProductB = products.base({
id: "auto-enable-b",
items: [messagesItemB],
group: "auto-enable-group-b",
isDefault: true,
});
const customerIdA = "auto-enable-override-a";
const customerIdB = "auto-enable-override-b";
const { autumnV1 } = await initScenario({
setup: [
s.deleteCustomer({ customerId: customerIdA }),
s.deleteCustomer({ customerId: customerIdB }),
s.products({ list: [autoEnableProductA, autoEnableProductB] }),
],
actions: [],
});
const customerA = await autumnV1.customers.create({
id: customerIdA,
auto_enable_plan_id: autoEnableProductA.id,
});
const customerB = await autumnV1.customers.create({
id: customerIdB,
auto_enable_plan_id: autoEnableProductB.id,
});
expectProductActive({
customer: customerA,
productId: autoEnableProductA.id,
});
expectProductNotAttached({
customer: customerA,
productId: autoEnableProductB.id,
});
expectProductActive({
customer: customerB,
productId: autoEnableProductB.id,
});
expectProductNotAttached({
customer: customerB,
productId: autoEnableProductA.id,
});
});

View File

@@ -0,0 +1,30 @@
import { test } from "bun:test";
import { ErrCode } from "@autumn/shared";
import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
// ═══════════════════════════════════════════════════════════════════════════════
// AUTO-ENABLE PLAN ERROR TESTS
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("errors: auto_enable_plan_id with non-existent product")}`, async () => {
const customerId = "error-auto-enable-nonexistent";
const { autumnV1 } = await initScenario({
setup: [
s.deleteCustomer({ customerId }),
],
actions: [],
});
await expectAutumnError({
errCode: ErrCode.ProductNotFound,
func: async () => {
await autumnV1.customers.create({
id: customerId,
auto_enable_plan_id: "non-existent-product-id",
});
},
});
});

View File

@@ -1,462 +1,462 @@
import { beforeAll, describe, expect, test } from "bun:test";
import {
type ApiCustomer,
ApiVersion,
ProductItemInterval,
} from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
const testCase = "list-customers";
// Different products for testing multi-plan filtering
const productA = constructProduct({
id: `${testCase}-product-a`,
type: "free",
isDefault: false,
version: 1,
items: [
constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 100,
interval: ProductItemInterval.Month,
}),
],
});
const productB = constructProduct({
id: `${testCase}-product-b`,
type: "free",
isDefault: false,
version: 1,
items: [
constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 200,
interval: ProductItemInterval.Month,
}),
],
});
const otherProduct = constructProduct({
id: `${testCase}-other-product`,
type: "free",
isDefault: false,
items: [
constructFeatureItem({
featureId: TestFeature.Dashboard,
isBoolean: true,
}),
],
});
const customerIds = {
withProductA: `${testCase}-cus-a`,
withProductB: `${testCase}-cus-b`,
withOtherProduct: `${testCase}-cus-other`,
searchable: `${testCase}-searchable-john`,
};
describe(`${chalk.yellowBright("list-customers: Testing list customers endpoint")}`, () => {
const autumn = new AutumnInt({
secretKey: ctx.orgSecretKey,
version: ApiVersion.V1_2,
});
beforeAll(async () => {
// Create products
await initProductsV0({
ctx,
products: [productA, productB, otherProduct],
prefix: "",
customerId: customerIds.withProductA,
});
// Create customers
for (const customerId of Object.values(customerIds)) {
await initCustomerV3({
ctx,
customerId,
withTestClock: false,
withDefault: false,
});
}
// Attach products to customers
await autumn.attach({
customer_id: customerIds.withProductA,
product_id: productA.id,
});
await autumn.attach({
customer_id: customerIds.withProductB,
product_id: productB.id,
});
await autumn.attach({
customer_id: customerIds.withOtherProduct,
product_id: otherProduct.id,
});
// Attach product to searchable customer so it's not filtered out by default status
await autumn.attach({
customer_id: customerIds.searchable,
product_id: productA.id,
});
});
// Pagination Tests
describe("pagination", () => {
test("should return customers with default pagination", async () => {
const result = await autumn.customers.list();
expect(result.list).toBeDefined();
expect(Array.isArray(result.list)).toBe(true);
expect(result.limit).toBe(10);
expect(result.offset).toBe(0);
expect(typeof result.total).toBe("number");
});
test("should respect custom limit", async () => {
const result = await autumn.customers.list({ limit: 20 });
expect(result.limit).toBe(20);
});
test("should respect offset", async () => {
const result = await autumn.customers.list({ offset: 5 });
expect(result.offset).toBe(5);
});
test("should respect max limit of 100", async () => {
const result = await autumn.customers.list({ limit: 100 });
expect(result.limit).toBe(100);
});
});
// Search Tests (V2)
describe("search", () => {
test("should search by customer ID", async () => {
const result = await autumn.customers.listV2({
search: "searchable-john",
});
expect(result.list.length).toBeGreaterThanOrEqual(1);
const found = result.list.find(
(customer: ApiCustomer) => customer.id === customerIds.searchable,
);
expect(found).toBeDefined();
});
test("should search by customer email", async () => {
const result = await autumn.customers.listV2({
search: "searchable-john@example",
});
expect(result.list.length).toBeGreaterThanOrEqual(1);
const found = result.list.find(
(customer: ApiCustomer) => customer.id === customerIds.searchable,
);
expect(found).toBeDefined();
});
test("should return empty list for non-matching search", async () => {
const result = await autumn.customers.listV2({
search: "nonexistent-customer-xyz-123",
});
expect(result.list.length).toBe(0);
});
test("should be case-insensitive", async () => {
const result = await autumn.customers.listV2({
search: "SEARCHABLE-JOHN",
});
expect(result.list.length).toBeGreaterThanOrEqual(1);
});
});
// Response Structure Tests
describe("response structure", () => {
test("should have correct response structure", async () => {
const result = await autumn.customers.list();
expect(result).toHaveProperty("list");
expect(result).toHaveProperty("total");
expect(result).toHaveProperty("limit");
expect(result).toHaveProperty("offset");
});
test("each customer should have expected fields", async () => {
const result = await autumn.customers.list({ limit: 10 });
if (result.list.length > 0) {
const customer = result.list[0];
expect(customer).toHaveProperty("id");
expect(customer).toHaveProperty("created_at");
expect(customer).toHaveProperty("products");
expect(customer).toHaveProperty("features");
}
});
});
// V2 Plans Filter Tests
describe("plans filter (V2)", () => {
test("should filter by single plan and exclude non-matching customers", async () => {
const result = await autumn.customers.listV2({
plans: [{ id: productA.id }],
});
// Should find customers with productA (withProductA and searchable)
const foundA = result.list.find(
(customer: ApiCustomer) => customer.id === customerIds.withProductA,
);
const foundSearchable = result.list.find(
(customer: ApiCustomer) => customer.id === customerIds.searchable,
);
expect(foundA).toBeDefined();
expect(foundSearchable).toBeDefined();
// Should NOT find customers with other products
const foundB = result.list.find(
(customer: ApiCustomer) => customer.id === customerIds.withProductB,
);
const foundOther = result.list.find(
(customer: ApiCustomer) => customer.id === customerIds.withOtherProduct,
);
expect(foundB).toBeUndefined();
expect(foundOther).toBeUndefined();
});
test("should filter by single plan with specific version", async () => {
const result = await autumn.customers.listV2({
plans: [{ id: productA.id, versions: [1] }],
});
const found = result.list.find(
(customer: ApiCustomer) => customer.id === customerIds.withProductA,
);
expect(found).toBeDefined();
// Should NOT find customers with productB (different product)
const foundB = result.list.find(
(customer: ApiCustomer) => customer.id === customerIds.withProductB,
);
expect(foundB).toBeUndefined();
});
test("should return empty list for non-matching version", async () => {
// All products are version 1, so filtering for version 999 should return empty
const result = await autumn.customers.listV2({
plans: [{ id: productA.id, versions: [999] }],
});
expect(result.list.length).toBe(0);
});
test("should filter by multiple plans (OR logic)", async () => {
const result = await autumn.customers.listV2({
plans: [{ id: productA.id }, { id: productB.id }],
});
// Should find customers with productA OR productB
const foundA = result.list.find(
(customer: ApiCustomer) => customer.id === customerIds.withProductA,
);
const foundB = result.list.find(
(customer: ApiCustomer) => customer.id === customerIds.withProductB,
);
expect(foundA).toBeDefined();
expect(foundB).toBeDefined();
// Should NOT find customer with otherProduct (not in filter)
const foundOther = result.list.find(
(customer: ApiCustomer) => customer.id === customerIds.withOtherProduct,
);
expect(foundOther).toBeUndefined();
});
test("should filter by multiple plans including other product", async () => {
const result = await autumn.customers.listV2({
plans: [{ id: productA.id }, { id: otherProduct.id }],
});
const foundA = result.list.find(
(customer: ApiCustomer) => customer.id === customerIds.withProductA,
);
const foundOther = result.list.find(
(customer: ApiCustomer) => customer.id === customerIds.withOtherProduct,
);
expect(foundA).toBeDefined();
expect(foundOther).toBeDefined();
// Should NOT find customer with productB
const foundB = result.list.find(
(customer: ApiCustomer) => customer.id === customerIds.withProductB,
);
expect(foundB).toBeUndefined();
});
test("should filter by plan with version constraint", async () => {
const result = await autumn.customers.listV2({
plans: [{ id: productA.id, versions: [1] }, { id: otherProduct.id }],
});
const foundA = result.list.find(
(customer: ApiCustomer) => customer.id === customerIds.withProductA,
);
const foundOther = result.list.find(
(customer: ApiCustomer) => customer.id === customerIds.withOtherProduct,
);
expect(foundA).toBeDefined();
expect(foundOther).toBeDefined();
// Should NOT find customer with productB
const foundB = result.list.find(
(customer: ApiCustomer) => customer.id === customerIds.withProductB,
);
expect(foundB).toBeUndefined();
});
test("should combine plans filter with search", async () => {
const result = await autumn.customers.listV2({
plans: [{ id: productA.id }],
search: "cus-a",
});
// Should find exactly the customer matching both criteria
expect(result.list.length).toBe(1);
expect(result.list[0].id).toBe(customerIds.withProductA);
});
test("should return empty list for non-existent plan", async () => {
const result = await autumn.customers.listV2({
plans: [{ id: "nonexistent-plan-xyz" }],
});
expect(result.list.length).toBe(0);
});
test("should return empty list for non-existent version", async () => {
const result = await autumn.customers.listV2({
plans: [{ id: productA.id, versions: [999] }],
});
expect(result.list.length).toBe(0);
});
test("should have correct response structure", async () => {
const result = await autumn.customers.listV2({
plans: [{ id: productA.id }],
});
expect(result).toHaveProperty("list");
expect(result).toHaveProperty("total");
expect(result).toHaveProperty("limit");
expect(result).toHaveProperty("offset");
});
test("should return plan_version in product response", async () => {
const result = await autumn.customers.listV2({
plans: [{ id: productA.id }],
});
const found = result.list.find(
(customer: ApiCustomer) => customer.id === customerIds.withProductA,
);
expect(found).toBeDefined();
// Check that version is returned correctly (V1.2 returns as 'products' with 'version' field)
const products = (found as any).products;
expect(products).toBeDefined();
expect(Array.isArray(products)).toBe(true);
const matchingProduct = products.find((p: any) => p.id === productA.id);
expect(matchingProduct).toBeDefined();
expect(matchingProduct.version).toBe(1);
});
});
// V2 Subscription Status Filter Tests
describe("subscription_status filter (V2)", () => {
test("should filter by active status", async () => {
const result = await autumn.customers.listV2({
subscription_status: ["active"],
});
// All our test customers have active products
const foundA = result.list.find(
(customer: ApiCustomer) => customer.id === customerIds.withProductA,
);
const foundB = result.list.find(
(customer: ApiCustomer) => customer.id === customerIds.withProductB,
);
expect(foundA).toBeDefined();
expect(foundB).toBeDefined();
});
test("should filter by multiple statuses", async () => {
const result = await autumn.customers.listV2({
subscription_status: ["active", "scheduled"],
});
// Should include active customers
const foundA = result.list.find(
(customer: ApiCustomer) => customer.id === customerIds.withProductA,
);
expect(foundA).toBeDefined();
});
test("should combine subscription_status with plans filter (AND logic)", async () => {
const result = await autumn.customers.listV2({
plans: [{ id: productA.id }],
subscription_status: ["active"],
});
// Should find customers with productA AND active status
const foundA = result.list.find(
(customer: ApiCustomer) => customer.id === customerIds.withProductA,
);
expect(foundA).toBeDefined();
// Should NOT find customers with other products even if active
const foundB = result.list.find(
(customer: ApiCustomer) => customer.id === customerIds.withProductB,
);
const foundOther = result.list.find(
(customer: ApiCustomer) => customer.id === customerIds.withOtherProduct,
);
expect(foundB).toBeUndefined();
expect(foundOther).toBeUndefined();
});
test("should require BOTH plan AND status to match when combined", async () => {
// Filter for productA with active status
const result = await autumn.customers.listV2({
plans: [{ id: productA.id, versions: [1] }],
subscription_status: ["active"],
});
// Only customers with productA v1 AND active status should be returned
const foundA = result.list.find(
(customer: ApiCustomer) => customer.id === customerIds.withProductA,
);
expect(foundA).toBeDefined();
// ProductB customer should NOT be returned (wrong product)
const foundB = result.list.find(
(customer: ApiCustomer) => customer.id === customerIds.withProductB,
);
expect(foundB).toBeUndefined();
});
});
});
// import { beforeAll, describe, expect, test } from "bun:test";
// import {
// type ApiCustomer,
// ApiVersion,
// ProductItemInterval,
// } from "@autumn/shared";
// import { TestFeature } from "@tests/setup/v2Features.js";
// import ctx from "@tests/utils/testInitUtils/createTestContext.js";
// import chalk from "chalk";
// import { AutumnInt } from "@/external/autumn/autumnCli.js";
// import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
// import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
// import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
// import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
// const testCase = "list-customers";
// // Different products for testing multi-plan filtering
// const productA = constructProduct({
// id: `${testCase}-product-a`,
// type: "free",
// isDefault: false,
// version: 1,
// items: [
// constructFeatureItem({
// featureId: TestFeature.Messages,
// includedUsage: 100,
// interval: ProductItemInterval.Month,
// }),
// ],
// });
// const productB = constructProduct({
// id: `${testCase}-product-b`,
// type: "free",
// isDefault: false,
// version: 1,
// items: [
// constructFeatureItem({
// featureId: TestFeature.Messages,
// includedUsage: 200,
// interval: ProductItemInterval.Month,
// }),
// ],
// });
// const otherProduct = constructProduct({
// id: `${testCase}-other-product`,
// type: "free",
// isDefault: false,
// items: [
// constructFeatureItem({
// featureId: TestFeature.Dashboard,
// isBoolean: true,
// }),
// ],
// });
// const customerIds = {
// withProductA: `${testCase}-cus-a`,
// withProductB: `${testCase}-cus-b`,
// withOtherProduct: `${testCase}-cus-other`,
// searchable: `${testCase}-searchable-john`,
// };
// describe(`${chalk.yellowBright("list-customers: Testing list customers endpoint")}`, () => {
// const autumn = new AutumnInt({
// secretKey: ctx.orgSecretKey,
// version: ApiVersion.V1_2,
// });
// beforeAll(async () => {
// // Create products
// await initProductsV0({
// ctx,
// products: [productA, productB, otherProduct],
// prefix: "",
// customerId: customerIds.withProductA,
// });
// // Create customers
// for (const customerId of Object.values(customerIds)) {
// await initCustomerV3({
// ctx,
// customerId,
// withTestClock: false,
// withDefault: false,
// });
// }
// // Attach products to customers
// await autumn.attach({
// customer_id: customerIds.withProductA,
// product_id: productA.id,
// });
// await autumn.attach({
// customer_id: customerIds.withProductB,
// product_id: productB.id,
// });
// await autumn.attach({
// customer_id: customerIds.withOtherProduct,
// product_id: otherProduct.id,
// });
// // Attach product to searchable customer so it's not filtered out by default status
// await autumn.attach({
// customer_id: customerIds.searchable,
// product_id: productA.id,
// });
// });
// // Pagination Tests
// describe("pagination", () => {
// test("should return customers with default pagination", async () => {
// const result = await autumn.customers.list();
// expect(result.list).toBeDefined();
// expect(Array.isArray(result.list)).toBe(true);
// expect(result.limit).toBe(10);
// expect(result.offset).toBe(0);
// expect(typeof result.total).toBe("number");
// });
// test("should respect custom limit", async () => {
// const result = await autumn.customers.list({ limit: 20 });
// expect(result.limit).toBe(20);
// });
// test("should respect offset", async () => {
// const result = await autumn.customers.list({ offset: 5 });
// expect(result.offset).toBe(5);
// });
// test("should respect max limit of 100", async () => {
// const result = await autumn.customers.list({ limit: 100 });
// expect(result.limit).toBe(100);
// });
// });
// // Search Tests (V2)
// describe("search", () => {
// test("should search by customer ID", async () => {
// const result = await autumn.customers.listV2({
// search: "searchable-john",
// });
// expect(result.list.length).toBeGreaterThanOrEqual(1);
// const found = result.list.find(
// (customer: ApiCustomer) => customer.id === customerIds.searchable,
// );
// expect(found).toBeDefined();
// });
// test("should search by customer email", async () => {
// const result = await autumn.customers.listV2({
// search: "searchable-john@example",
// });
// expect(result.list.length).toBeGreaterThanOrEqual(1);
// const found = result.list.find(
// (customer: ApiCustomer) => customer.id === customerIds.searchable,
// );
// expect(found).toBeDefined();
// });
// test("should return empty list for non-matching search", async () => {
// const result = await autumn.customers.listV2({
// search: "nonexistent-customer-xyz-123",
// });
// expect(result.list.length).toBe(0);
// });
// test("should be case-insensitive", async () => {
// const result = await autumn.customers.listV2({
// search: "SEARCHABLE-JOHN",
// });
// expect(result.list.length).toBeGreaterThanOrEqual(1);
// });
// });
// // Response Structure Tests
// describe("response structure", () => {
// test("should have correct response structure", async () => {
// const result = await autumn.customers.list();
// expect(result).toHaveProperty("list");
// expect(result).toHaveProperty("total");
// expect(result).toHaveProperty("limit");
// expect(result).toHaveProperty("offset");
// });
// test("each customer should have expected fields", async () => {
// const result = await autumn.customers.list({ limit: 10 });
// if (result.list.length > 0) {
// const customer = result.list[0];
// expect(customer).toHaveProperty("id");
// expect(customer).toHaveProperty("created_at");
// expect(customer).toHaveProperty("products");
// expect(customer).toHaveProperty("features");
// }
// });
// });
// // V2 Plans Filter Tests
// describe("plans filter (V2)", () => {
// test("should filter by single plan and exclude non-matching customers", async () => {
// const result = await autumn.customers.listV2({
// plans: [{ id: productA.id }],
// });
// // Should find customers with productA (withProductA and searchable)
// const foundA = result.list.find(
// (customer: ApiCustomer) => customer.id === customerIds.withProductA,
// );
// const foundSearchable = result.list.find(
// (customer: ApiCustomer) => customer.id === customerIds.searchable,
// );
// expect(foundA).toBeDefined();
// expect(foundSearchable).toBeDefined();
// // Should NOT find customers with other products
// const foundB = result.list.find(
// (customer: ApiCustomer) => customer.id === customerIds.withProductB,
// );
// const foundOther = result.list.find(
// (customer: ApiCustomer) => customer.id === customerIds.withOtherProduct,
// );
// expect(foundB).toBeUndefined();
// expect(foundOther).toBeUndefined();
// });
// test("should filter by single plan with specific version", async () => {
// const result = await autumn.customers.listV2({
// plans: [{ id: productA.id, versions: [1] }],
// });
// const found = result.list.find(
// (customer: ApiCustomer) => customer.id === customerIds.withProductA,
// );
// expect(found).toBeDefined();
// // Should NOT find customers with productB (different product)
// const foundB = result.list.find(
// (customer: ApiCustomer) => customer.id === customerIds.withProductB,
// );
// expect(foundB).toBeUndefined();
// });
// test("should return empty list for non-matching version", async () => {
// // All products are version 1, so filtering for version 999 should return empty
// const result = await autumn.customers.listV2({
// plans: [{ id: productA.id, versions: [999] }],
// });
// expect(result.list.length).toBe(0);
// });
// test("should filter by multiple plans (OR logic)", async () => {
// const result = await autumn.customers.listV2({
// plans: [{ id: productA.id }, { id: productB.id }],
// });
// // Should find customers with productA OR productB
// const foundA = result.list.find(
// (customer: ApiCustomer) => customer.id === customerIds.withProductA,
// );
// const foundB = result.list.find(
// (customer: ApiCustomer) => customer.id === customerIds.withProductB,
// );
// expect(foundA).toBeDefined();
// expect(foundB).toBeDefined();
// // Should NOT find customer with otherProduct (not in filter)
// const foundOther = result.list.find(
// (customer: ApiCustomer) => customer.id === customerIds.withOtherProduct,
// );
// expect(foundOther).toBeUndefined();
// });
// test("should filter by multiple plans including other product", async () => {
// const result = await autumn.customers.listV2({
// plans: [{ id: productA.id }, { id: otherProduct.id }],
// });
// const foundA = result.list.find(
// (customer: ApiCustomer) => customer.id === customerIds.withProductA,
// );
// const foundOther = result.list.find(
// (customer: ApiCustomer) => customer.id === customerIds.withOtherProduct,
// );
// expect(foundA).toBeDefined();
// expect(foundOther).toBeDefined();
// // Should NOT find customer with productB
// const foundB = result.list.find(
// (customer: ApiCustomer) => customer.id === customerIds.withProductB,
// );
// expect(foundB).toBeUndefined();
// });
// test("should filter by plan with version constraint", async () => {
// const result = await autumn.customers.listV2({
// plans: [{ id: productA.id, versions: [1] }, { id: otherProduct.id }],
// });
// const foundA = result.list.find(
// (customer: ApiCustomer) => customer.id === customerIds.withProductA,
// );
// const foundOther = result.list.find(
// (customer: ApiCustomer) => customer.id === customerIds.withOtherProduct,
// );
// expect(foundA).toBeDefined();
// expect(foundOther).toBeDefined();
// // Should NOT find customer with productB
// const foundB = result.list.find(
// (customer: ApiCustomer) => customer.id === customerIds.withProductB,
// );
// expect(foundB).toBeUndefined();
// });
// test("should combine plans filter with search", async () => {
// const result = await autumn.customers.listV2({
// plans: [{ id: productA.id }],
// search: "cus-a",
// });
// // Should find exactly the customer matching both criteria
// expect(result.list.length).toBe(1);
// expect(result.list[0].id).toBe(customerIds.withProductA);
// });
// test("should return empty list for non-existent plan", async () => {
// const result = await autumn.customers.listV2({
// plans: [{ id: "nonexistent-plan-xyz" }],
// });
// expect(result.list.length).toBe(0);
// });
// test("should return empty list for non-existent version", async () => {
// const result = await autumn.customers.listV2({
// plans: [{ id: productA.id, versions: [999] }],
// });
// expect(result.list.length).toBe(0);
// });
// test("should have correct response structure", async () => {
// const result = await autumn.customers.listV2({
// plans: [{ id: productA.id }],
// });
// expect(result).toHaveProperty("list");
// expect(result).toHaveProperty("total");
// expect(result).toHaveProperty("limit");
// expect(result).toHaveProperty("offset");
// });
// test("should return plan_version in product response", async () => {
// const result = await autumn.customers.listV2({
// plans: [{ id: productA.id }],
// });
// const found = result.list.find(
// (customer: ApiCustomer) => customer.id === customerIds.withProductA,
// );
// expect(found).toBeDefined();
// // Check that version is returned correctly (V1.2 returns as 'products' with 'version' field)
// const products = (found as any).products;
// expect(products).toBeDefined();
// expect(Array.isArray(products)).toBe(true);
// const matchingProduct = products.find((p: any) => p.id === productA.id);
// expect(matchingProduct).toBeDefined();
// expect(matchingProduct.version).toBe(1);
// });
// });
// // V2 Subscription Status Filter Tests
// describe("subscription_status filter (V2)", () => {
// test("should filter by active status", async () => {
// const result = await autumn.customers.listV2({
// subscription_status: ["active"],
// });
// // All our test customers have active products
// const foundA = result.list.find(
// (customer: ApiCustomer) => customer.id === customerIds.withProductA,
// );
// const foundB = result.list.find(
// (customer: ApiCustomer) => customer.id === customerIds.withProductB,
// );
// expect(foundA).toBeDefined();
// expect(foundB).toBeDefined();
// });
// test("should filter by multiple statuses", async () => {
// const result = await autumn.customers.listV2({
// subscription_status: ["active", "scheduled"],
// });
// // Should include active customers
// const foundA = result.list.find(
// (customer: ApiCustomer) => customer.id === customerIds.withProductA,
// );
// expect(foundA).toBeDefined();
// });
// test("should combine subscription_status with plans filter (AND logic)", async () => {
// const result = await autumn.customers.listV2({
// plans: [{ id: productA.id }],
// subscription_status: ["active"],
// });
// // Should find customers with productA AND active status
// const foundA = result.list.find(
// (customer: ApiCustomer) => customer.id === customerIds.withProductA,
// );
// expect(foundA).toBeDefined();
// // Should NOT find customers with other products even if active
// const foundB = result.list.find(
// (customer: ApiCustomer) => customer.id === customerIds.withProductB,
// );
// const foundOther = result.list.find(
// (customer: ApiCustomer) => customer.id === customerIds.withOtherProduct,
// );
// expect(foundB).toBeUndefined();
// expect(foundOther).toBeUndefined();
// });
// test("should require BOTH plan AND status to match when combined", async () => {
// // Filter for productA with active status
// const result = await autumn.customers.listV2({
// plans: [{ id: productA.id, versions: [1] }],
// subscription_status: ["active"],
// });
// // Only customers with productA v1 AND active status should be returned
// const foundA = result.list.find(
// (customer: ApiCustomer) => customer.id === customerIds.withProductA,
// );
// expect(foundA).toBeDefined();
// // ProductB customer should NOT be returned (wrong product)
// const foundB = result.list.find(
// (customer: ApiCustomer) => customer.id === customerIds.withProductB,
// );
// expect(foundB).toBeUndefined();
// });
// });
// });

View File

@@ -16,6 +16,7 @@ import {
* @param items - Product items (features)
* @param id - Product ID (default: "base")
* @param isDefault - Whether this is a default product (default: false)
* @param group - Optional product group (if set, won't be overridden by test prefix)
* @param trialDays - Optional number of trial days (shorthand)
* @param freeTrial - Optional full free trial config (overrides trialDays)
*/
@@ -24,6 +25,7 @@ const base = ({
id = "base",
isDefault = false,
isAddOn = false,
group,
trialDays,
freeTrial,
}: {
@@ -31,6 +33,7 @@ const base = ({
id?: string;
isDefault?: boolean;
isAddOn?: boolean;
group?: string;
trialDays?: number;
freeTrial?: {
length: number;
@@ -39,7 +42,7 @@ const base = ({
uniqueFingerprint?: boolean;
};
}): ProductV2 => ({
...constructRawProduct({ id, items, isAddOn }),
...constructRawProduct({ id, items, isAddOn, group }),
is_default: isDefault,
...(freeTrial
? {

View File

@@ -20,7 +20,10 @@ export const addPrefixToProducts = ({
for (const product of products) {
product.id = `${product.id}_${prefix}`;
product.name = `${product.name} ${prefix}`;
product.group = prefix;
// Only set group to prefix if not already defined
if (!product.group) {
product.group = prefix;
}
}
return products;

View File

@@ -27,6 +27,10 @@ export const CustomerDataSchema = z
description: "Whether to create the customer in Stripe",
}),
auto_enable_plan_id: z.string().optional().meta({
description: "The ID of the free plan to auto-enable for the customer",
}),
processors: ExternalProcessorsSchema.nullish().meta({
internal: true,
description: "External processors for the customer",