feat: list entities

This commit is contained in:
John Yeo
2026-05-08 14:38:52 +08:00
parent 8632da3209
commit 5d74684945
17 changed files with 1682 additions and 400 deletions

View File

@@ -1,17 +1,21 @@
import { SuccessResponseSchema } from "@api/common/commonResponses.js";
import { createPagePaginatedResponseSchema } from "@api/common/pagePaginationSchemas.js";
import {
API_BALANCE_V1_EXAMPLE,
ApiEntityV2Schema,
CreateEntityParamsV1Schema,
DeleteEntityParamsV0Schema,
GetEntityParamsV0Schema,
ListEntitiesParamsSchema,
UpdateEntityParamsSchema,
} from "@autumn/shared";
import { oc } from "@orpc/contract";
import { z } from "zod/v4";
import {
createEntityJsDoc,
deleteEntityJsDoc,
getEntityJsDoc,
listEntityJsDoc,
updateEntityJsDoc,
} from "../jsDocs/entityJsDocs";
@@ -108,6 +112,61 @@ export const getEntityContract = oc
}),
);
export const listEntitiesContract = oc
.route({
method: "POST",
path: "/v1/entities.list",
operationId: "listEntities",
tags: ["entities"],
description: listEntityJsDoc,
spec: (spec) => ({
...spec,
"x-speakeasy-name-override": "list",
}),
})
.input(
ListEntitiesParamsSchema.optional().meta({
title: "ListEntitiesParams",
examples: [
{
limit: 10,
offset: 0,
},
{
plans: [{ id: "pro_plan" }],
},
],
}),
)
.output(
createPagePaginatedResponseSchema(ApiEntityV2Schema)
.extend({
total_count: z
.number()
.describe(
"Total number of entities available in the current organization and environment",
),
total_filtered_count: z
.number()
.describe(
"Total number of entities matching the current filter before pagination is applied",
),
})
.meta({
examples: [
{
list: [API_ENTITY_V2_EXAMPLE],
has_more: false,
offset: 0,
total: 1,
limit: 10,
total_count: 100,
total_filtered_count: 42,
},
],
}),
);
export const updateEntityContract = oc
.route({
method: "POST",

View File

@@ -29,6 +29,7 @@ import {
createEntityContract,
deleteEntityContract,
getEntityContract,
listEntitiesContract,
updateEntityContract,
} from "./entitiesContract.js";
import {
@@ -102,6 +103,7 @@ export const v2_1ContractRouter = oc.router({
// Entities
entitiesCreate: createEntityContract,
entitiesGet: getEntityContract,
entitiesList: listEntitiesContract,
entitiesUpdate: updateEntityContract,
entitiesDelete: deleteEntityContract,

View File

@@ -2,6 +2,7 @@ import {
CreateEntityParamsV1Schema,
DeleteEntityParamsV0Schema,
GetEntityParamsV0Schema,
ListEntitiesParamsSchema,
UpdateEntityParamsSchema,
} from "@autumn/shared";
import { createJSDocDescription, example } from "../../utils/jsDocs/index.js";
@@ -53,6 +54,33 @@ export const getEntityJsDoc = createJSDocDescription({
"The entity object including its current subscriptions, purchases, and balances.",
});
export const listEntityJsDoc = createJSDocDescription({
description:
"Lists entities across the organization with pagination and optional filters.",
whenToUse:
"Use this to page through entities globally, including filtering by plans inherited from parent customers or attached directly to entities.",
body: ListEntitiesParamsSchema,
examples: [
example({
description: "List entities on a plan",
values: {
plans: [{ id: "pro_plan" }],
limit: 10,
offset: 0,
},
}),
example({
description: "Search entities by ID or name",
values: {
search: "workspace",
},
}),
],
methodName: "entities.list",
returns:
"A paginated list of entity objects including their current subscriptions, purchases, balances, and flags.",
});
export const deleteEntityJsDoc = createJSDocDescription({
description: "Deletes an entity by entity ID.",
whenToUse:

View File

@@ -33,6 +33,7 @@ import {
ErrCode,
type FinalizeLockParamsV0,
type LegacyVersion,
type ListEntitiesParams,
type OrgConfig,
type ProductItem,
type RestoreParamsV1,
@@ -659,6 +660,24 @@ export class AutumnInt {
};
entitiesV2 = {
list: async <TResponse = any>(
params?: Partial<ListEntitiesParams> & {
keepInternalFields?: boolean;
},
) => {
const { keepInternalFields, ...listParams } = params ?? {};
const headers: Record<string, string> = {};
if (keepInternalFields) {
headers["x-strip-internal"] = "false";
}
return (await this.post(
`/entities.list`,
listParams,
Object.keys(headers).length > 0 ? headers : undefined,
)) as TResponse;
},
create: async ({
customer_id,
entity_id,

View File

@@ -50,7 +50,9 @@ export const getEntityAggregateFragments = ({
const featureFilter =
internalFeatureIds && internalFeatureIds.length > 0
? sql`AND ce.internal_feature_id = ANY(ARRAY[${sql.join(
internalFeatureIds.map((internalFeatureId) => sql`${internalFeatureId}`),
internalFeatureIds.map(
(internalFeatureId) => sql`${internalFeatureId}`,
),
sql`, `,
)}])`
: sql``;
@@ -237,13 +239,17 @@ export const getEntityAggregateFragments = ({
const productRefsUnion = sql`
UNION ALL
SELECT ecp.internal_customer_id, ecp.internal_product_id
SELECT
ecp.internal_customer_id AS subject_key,
ecp.internal_customer_id,
ecp.internal_product_id
FROM entity_distinct_cus_products ecp
`;
const entitlementRefsUnion = sql`
UNION
SELECT DISTINCT
ce.internal_customer_id AS subject_key,
ce.internal_customer_id,
ce.entitlement_id
FROM entity_level_cus_ents ce
@@ -251,7 +257,10 @@ export const getEntityAggregateFragments = ({
const priceRefsUnion = sql`
UNION ALL
SELECT ecpr.price_id, ecp.internal_customer_id
SELECT
ecp.internal_customer_id AS subject_key,
ecpr.price_id,
ecp.internal_customer_id
FROM entity_cus_prices ecpr
JOIN entity_distinct_cus_products ecp
ON ecp.id = ecpr.customer_product_id
@@ -259,7 +268,10 @@ export const getEntityAggregateFragments = ({
const freeTrialRefsUnion = sql`
UNION ALL
SELECT ecp.free_trial_id, ecp.internal_customer_id
SELECT
ecp.internal_customer_id AS subject_key,
ecp.free_trial_id,
ecp.internal_customer_id
FROM entity_distinct_cus_products ecp
WHERE ecp.free_trial_id IS NOT NULL
`;

View File

@@ -1,71 +1,152 @@
import type { AppEnv, CusProductStatus } from "@autumn/shared";
import { type SQL, sql } from "drizzle-orm";
import { RELEVANT_STATUSES } from "../../cusProducts/CusProductService.js";
import { getEntityAggregateFragments } from "./getEntityAggregateFragments.js";
import { getFullSubjectRowsQuery } from "./getFullSubjectRowsQuery.js";
const getCustomerOrEntityCTE = ({
const getCustomerSubjectRecordsCte = ({
orgId,
env,
entityId,
entityOnlyLookup,
customerId,
customerFilter,
customerPagination,
}: {
orgId: string;
env: AppEnv;
entityId?: string;
entityOnlyLookup: boolean;
customerId?: string;
customerFilter: SQL;
customerPagination: SQL;
}): SQL => {
if (entityOnlyLookup && entityId) {
}) => sql`
WITH customer_records AS (
SELECT c.*
FROM customers c
WHERE c.org_id = ${orgId}
AND c.env = ${env}
${customerFilter}
${customerPagination}
),
subject_records AS (
SELECT
c.internal_id AS subject_key,
c.internal_id AS internal_customer_id,
NULL::text AS internal_entity_id,
ROW_NUMBER() OVER (
ORDER BY ${
customerId ? sql`(c.id = ${customerId}) DESC` : sql`c.created_at DESC`
}
) AS subject_order
FROM customer_records c
)
`;
const getEntityOnlySubjectRecordsCte = ({
orgId,
env,
entityId,
}: {
orgId: string;
env: AppEnv;
entityId: string;
}) => sql`
WITH entity_record AS (
SELECT e.*
FROM entities e
WHERE e.org_id = ${orgId}
AND e.env = ${env}
AND (e.id = ${entityId} OR e.internal_id = ${entityId})
LIMIT 1
),
subject_records AS (
SELECT
e.internal_id AS subject_key,
e.internal_customer_id,
e.internal_id AS internal_entity_id,
1 AS subject_order
FROM entity_record e
)
`;
const getCustomerEntitySubjectRecordsCte = ({
orgId,
env,
entityId,
customerFilter,
customerPagination,
allowMissingEntity,
}: {
orgId: string;
env: AppEnv;
entityId: string;
customerFilter: SQL;
customerPagination: SQL;
allowMissingEntity: boolean;
}) => {
if (allowMissingEntity) {
return sql`
WITH entity_record AS (
SELECT e.*
FROM entities e
WHERE e.org_id = ${orgId}
AND e.env = ${env}
AND (e.id = ${entityId} OR e.internal_id = ${entityId})
LIMIT 1
),
subject_customer_records AS (
SELECT c.*
FROM customers c
WHERE c.internal_id = (SELECT internal_customer_id FROM entity_record LIMIT 1)
)`;
WITH customer_records AS (
SELECT c.*
FROM customers c
WHERE c.org_id = ${orgId}
AND c.env = ${env}
${customerFilter}
${customerPagination}
),
entity_record AS (
SELECT e.*
FROM entities e
WHERE e.internal_customer_id IN (
SELECT internal_id
FROM customer_records
)
AND (e.id = ${entityId} OR e.internal_id = ${entityId})
LIMIT 1
),
subject_records AS (
SELECT
COALESCE(e.internal_id, c.internal_id) AS subject_key,
c.internal_id AS internal_customer_id,
e.internal_id AS internal_entity_id,
1 AS subject_order
FROM customer_records c
LEFT JOIN entity_record e
ON e.internal_customer_id = c.internal_id
)
`;
}
if (entityId) {
return sql`
WITH subject_customer_records AS (
SELECT *
return sql`
WITH customer_records AS (
SELECT c.*
FROM customers c
WHERE c.org_id = ${orgId}
AND c.env = ${env}
${customerFilter}
${customerPagination}
),
entity_record AS (
SELECT e.*
FROM entities e
WHERE e.internal_customer_id IN (
SELECT internal_id
FROM subject_customer_records
FROM customer_records
)
AND (e.id = ${entityId} OR e.internal_id = ${entityId})
LIMIT 1
)`;
}
),
return sql`
WITH subject_customer_records AS (
SELECT *
FROM customers c
WHERE c.org_id = ${orgId}
AND c.env = ${env}
${customerFilter}
${customerPagination}
)`;
subject_records AS (
SELECT
e.internal_id AS subject_key,
e.internal_customer_id,
e.internal_id AS internal_entity_id,
1 AS subject_order
FROM entity_record e
)
`;
};
export const getFullSubjectQuery = ({
@@ -98,14 +179,6 @@ export const getFullSubjectQuery = ({
const offset = pagination.offset ?? 0;
const entityOnlyLookup = !!entityId && !customerId;
const statusFilter =
inStatuses.length > 0
? sql`AND cp.status = ANY(ARRAY[${sql.join(
inStatuses.map((status) => sql`${status}`),
sql`, `,
)}])`
: sql``;
const customerFilter = customerId
? sql`AND (c.id = ${customerId} OR c.internal_id = ${customerId})`
: sql``;
@@ -121,356 +194,34 @@ export const getFullSubjectQuery = ({
OFFSET ${offset}
`;
const leadingCtes = getCustomerOrEntityCTE({
orgId,
env,
entityId,
entityOnlyLookup,
customerFilter,
customerPagination,
const leadingCtes =
entityOnlyLookup && entityId
? getEntityOnlySubjectRecordsCte({
orgId,
env,
entityId,
})
: entityId
? getCustomerEntitySubjectRecordsCte({
orgId,
env,
entityId,
customerFilter,
customerPagination,
allowMissingEntity,
})
: getCustomerSubjectRecordsCte({
orgId,
env,
customerId,
customerFilter,
customerPagination,
});
return getFullSubjectRowsQuery({
leadingCtes,
inStatuses,
includeInvoices: !entityId,
includeEntityAggregations: !entityId,
});
const customerProductEntityFilter = entityId
? sql`AND (cp.internal_entity_id = (SELECT internal_id FROM entity_record LIMIT 1)
OR cp.internal_entity_id IS NULL)`
: sql`AND cp.internal_entity_id IS NULL`;
const entityFragments = getEntityAggregateFragments({
entityId,
statusFilter,
});
const allowEntityFallback =
allowMissingEntity && !!customerId && !entityOnlyLookup;
const subjectCustomerFilter =
entityId && !allowEntityFallback
? sql`
WHERE scr.internal_id = (
SELECT internal_customer_id
FROM entity_record
LIMIT 1
)
`
: sql``;
const extraCustomerEntitlementEntityFilter = entityId
? sql`
AND (
ce.internal_entity_id IS NULL
OR ce.internal_entity_id = (SELECT internal_id FROM entity_record LIMIT 1)
)
`
: sql`AND ce.internal_entity_id IS NULL`;
const subscriptionsCte = sql`,
customer_subscriptions AS (
SELECT DISTINCT s.*
FROM cus_products cp
JOIN LATERAL unnest(cp.subscription_ids) AS cp_sub(stripe_id) ON true
JOIN subscriptions s ON s.stripe_id = cp_sub.stripe_id
)`;
const invoicesCte = entityId
? sql``
: sql`,
customer_invoices AS (
SELECT *
FROM invoices i
WHERE i.internal_customer_id IN (SELECT internal_id FROM subject_customer_records)
ORDER BY i.created_at DESC, i.id DESC
LIMIT 10
)`;
const subscriptionsSelect = sql`,
COALESCE(
(
SELECT json_agg(row_to_json(cs)) FILTER (WHERE cs.stripe_id IS NOT NULL)
FROM customer_subscriptions cs
),
'[]'::json
) AS subscriptions`;
const invoicesSelect = entityId
? sql``
: sql`,
COALESCE(
(
SELECT json_agg(row_to_json(ci) ORDER BY ci.created_at DESC, ci.id DESC)
FILTER (WHERE ci.id IS NOT NULL)
FROM customer_invoices ci
WHERE ci.internal_customer_id = scr.internal_id
),
'[]'::json
) AS invoices`;
const entitySelect = entityId
? sql`,
(SELECT row_to_json(er) FROM entity_record er LIMIT 1) AS entity`
: sql``;
return sql`
${leadingCtes}
,
cus_products AS (
SELECT cp.*
FROM customer_products cp
JOIN subject_customer_records scr
ON cp.internal_customer_id = scr.internal_id
WHERE 1 = 1
${customerProductEntityFilter}
${statusFilter}
),
cus_entitlements AS (
SELECT ce.*
FROM customer_entitlements ce
WHERE ce.customer_product_id IN (SELECT id FROM cus_products)
),
extra_cus_entitlements AS (
SELECT ce.*
FROM customer_entitlements ce
JOIN subject_customer_records scr
ON ce.internal_customer_id = scr.internal_id
WHERE ce.customer_product_id IS NULL
AND (ce.expires_at IS NULL OR ce.expires_at > EXTRACT(EPOCH FROM now()) * 1000)
AND (
ce.balance != 0
OR ce.unlimited IS TRUE
OR EXISTS (
SELECT 1
FROM entitlements e
JOIN features f ON f.internal_id = e.internal_feature_id
WHERE e.id = ce.entitlement_id
AND f.type = 'boolean'
)
)
${extraCustomerEntitlementEntityFilter}
LIMIT 20
),
all_cus_ent_ids AS (
SELECT id FROM cus_entitlements
UNION ALL
SELECT id FROM extra_cus_entitlements
),
cus_rollovers AS (
SELECT ro.*
FROM rollovers ro
WHERE ro.cus_ent_id IN (SELECT id FROM all_cus_ent_ids)
AND (ro.expires_at IS NULL OR ro.expires_at > EXTRACT(EPOCH FROM now()) * 1000)
),
cus_replaceables AS (
SELECT rep.*
FROM replaceables rep
WHERE rep.cus_ent_id IN (SELECT id FROM all_cus_ent_ids)
),
cus_prices AS (
SELECT cpr.*
FROM customer_prices cpr
WHERE cpr.customer_product_id IN (SELECT id FROM cus_products)
)
${subscriptionsCte}
${invoicesCte}
${entityFragments.ctes}
,
distinct_products AS (
SELECT DISTINCT ON (src.internal_customer_id, p.internal_id)
src.internal_customer_id,
p.*
FROM products p
JOIN (
SELECT cp.internal_customer_id, cp.internal_product_id FROM cus_products cp
${entityFragments.productRefsUnion}
) src ON p.internal_id = src.internal_product_id
ORDER BY src.internal_customer_id, p.internal_id
),
relevant_entitlement_records AS (
SELECT DISTINCT
ce.internal_customer_id,
ce.entitlement_id
FROM cus_entitlements ce
UNION
SELECT DISTINCT
ece.internal_customer_id,
ece.entitlement_id
FROM extra_cus_entitlements ece
${entityFragments.entitlementRefsUnion}
),
distinct_entitlements AS (
SELECT
rer.internal_customer_id,
e.*,
row_to_json(f) AS feature
FROM relevant_entitlement_records rer
JOIN entitlements e
ON e.id = rer.entitlement_id
JOIN features f
ON e.internal_feature_id = f.internal_id
),
distinct_prices AS (
SELECT DISTINCT ON (src.internal_customer_id, p.id)
src.internal_customer_id,
p.*
FROM prices p
JOIN (
SELECT cpr.price_id, cp.internal_customer_id
FROM cus_prices cpr
JOIN cus_products cp ON cp.id = cpr.customer_product_id
${entityFragments.priceRefsUnion}
) src ON p.id = src.price_id
ORDER BY src.internal_customer_id, p.id
),
distinct_free_trials AS (
SELECT DISTINCT ON (src.internal_customer_id, ft.id)
src.internal_customer_id,
ft.*
FROM free_trials ft
JOIN (
SELECT cp.free_trial_id, cp.internal_customer_id
FROM cus_products cp
WHERE cp.free_trial_id IS NOT NULL
${entityFragments.freeTrialRefsUnion}
) src ON ft.id = src.free_trial_id
ORDER BY src.internal_customer_id, ft.id
)
SELECT
row_to_json(scr) AS customer,
COALESCE(
(
SELECT json_agg(row_to_json(cp))
FROM cus_products cp
WHERE cp.internal_customer_id = scr.internal_id
),
'[]'::json
) AS customer_products,
COALESCE(
(
SELECT json_agg(row_to_json(ce))
FROM cus_entitlements ce
WHERE ce.internal_customer_id = scr.internal_id
),
'[]'::json
) AS customer_entitlements,
COALESCE(
(
SELECT json_agg(row_to_json(cpr))
FROM cus_prices cpr
JOIN cus_products cp
ON cp.id = cpr.customer_product_id
WHERE cp.internal_customer_id = scr.internal_id
),
'[]'::json
) AS customer_prices,
COALESCE(
(
SELECT json_agg(row_to_json(ece))
FROM extra_cus_entitlements ece
WHERE ece.internal_customer_id = scr.internal_id
),
'[]'::json
) AS extra_customer_entitlements,
COALESCE(
(
SELECT json_agg(row_to_json(rep) ORDER BY rep.created_at ASC, rep.id ASC)
FROM cus_replaceables rep
WHERE rep.cus_ent_id IN (
SELECT ce.id
FROM cus_entitlements ce
WHERE ce.internal_customer_id = scr.internal_id
UNION ALL
SELECT ece.id
FROM extra_cus_entitlements ece
WHERE ece.internal_customer_id = scr.internal_id
)
),
'[]'::json
) AS replaceables,
COALESCE(
(
SELECT json_agg(
row_to_json(ro)
ORDER BY ro.expires_at ASC NULLS LAST, ro.id ASC
)
FROM cus_rollovers ro
WHERE ro.cus_ent_id IN (
SELECT ce.id
FROM cus_entitlements ce
WHERE ce.internal_customer_id = scr.internal_id
UNION ALL
SELECT ece.id
FROM extra_cus_entitlements ece
WHERE ece.internal_customer_id = scr.internal_id
)
),
'[]'::json
) AS rollovers,
COALESCE(
(
SELECT json_agg((row_to_json(p)::jsonb - 'internal_customer_id')::json)
FROM distinct_products p
WHERE p.internal_customer_id = scr.internal_id
),
'[]'::json
) AS products,
COALESCE(
(
SELECT json_agg((row_to_json(ent)::jsonb - 'internal_customer_id')::json)
FROM distinct_entitlements ent
WHERE ent.internal_customer_id = scr.internal_id
),
'[]'::json
) AS entitlements,
COALESCE(
(
SELECT json_agg((row_to_json(pr)::jsonb - 'internal_customer_id')::json)
FROM distinct_prices pr
WHERE pr.internal_customer_id = scr.internal_id
),
'[]'::json
) AS prices,
COALESCE(
(
SELECT json_agg((row_to_json(ft)::jsonb - 'internal_customer_id')::json)
FROM distinct_free_trials ft
WHERE ft.internal_customer_id = scr.internal_id
),
'[]'::json
) AS free_trials
${subscriptionsSelect}
${invoicesSelect}
${entitySelect}
${entityFragments.selectColumns}
FROM subject_customer_records scr
${subjectCustomerFilter}
`;
};

View File

@@ -0,0 +1,462 @@
import { type CusProductStatus, RELEVANT_STATUSES } from "@autumn/shared";
import { type SQL, sql } from "drizzle-orm";
import { getEntityAggregateFragments } from "./getEntityAggregateFragments.js";
const CUSTOMER_PRODUCT_LIMIT = 50;
const EXTRA_CUSTOMER_ENTITLEMENT_LIMIT = 30;
const emptyEntityFragments = {
ctes: sql``,
productRefsUnion: sql``,
entitlementRefsUnion: sql``,
priceRefsUnion: sql``,
freeTrialRefsUnion: sql``,
selectColumns: sql``,
};
export const getFullSubjectRowsQuery = ({
leadingCtes,
inStatuses,
includeInvoices,
includeEntityAggregations,
}: {
leadingCtes: SQL;
inStatuses: CusProductStatus[];
includeInvoices: boolean;
includeEntityAggregations: boolean;
}) => {
const statusFilter =
inStatuses.length > 0
? sql`AND cp.status = ANY(ARRAY[${sql.join(
inStatuses.map((status) => sql`${status}`),
sql`, `,
)}])`
: sql``;
const relevantStatusFirst = sql`CASE WHEN cp.status = ANY(ARRAY[${sql.join(
RELEVANT_STATUSES.map((status) => sql`${status}`),
sql`, `,
)}]) THEN 0 ELSE 1 END`;
const hasCustomerPrices = sql`EXISTS (
SELECT 1
FROM customer_prices cpr_exists
WHERE cpr_exists.customer_product_id = cp.id
)`;
const entityFragments = includeEntityAggregations
? getEntityAggregateFragments({
statusFilter,
})
: emptyEntityFragments;
const invoicesCte = includeInvoices
? sql`,
customer_invoices AS (
SELECT *
FROM invoices i
WHERE i.internal_customer_id IN (
SELECT internal_customer_id
FROM subject_records
)
ORDER BY i.created_at DESC, i.id DESC
LIMIT 10
)`
: sql``;
const invoicesSelect = includeInvoices
? sql`,
COALESCE(
(
SELECT json_agg(row_to_json(ci) ORDER BY ci.created_at DESC, ci.id DESC)
FILTER (WHERE ci.id IS NOT NULL)
FROM customer_invoices ci
WHERE ci.internal_customer_id = sr.internal_customer_id
),
'[]'::json
) AS invoices`
: sql``;
return sql`
${leadingCtes}
,
subject_customer_records AS (
SELECT DISTINCT c.*
FROM customers c
JOIN subject_records sr
ON sr.internal_customer_id = c.internal_id
),
all_cus_products AS (
SELECT
cp_candidates.*,
ROW_NUMBER() OVER (
PARTITION BY cp_candidates.subject_key
ORDER BY
cp_candidates.subject_entity_priority ASC,
cp_candidates.status_priority ASC,
cp_candidates.has_customer_prices DESC,
cp_candidates.product_is_add_on ASC,
cp_candidates.created_at DESC
) AS subject_rank
FROM subject_records sr
JOIN LATERAL (
SELECT
sr.subject_key,
CASE
WHEN sr.internal_entity_id IS NOT NULL
AND cp.internal_entity_id = sr.internal_entity_id
THEN 0
ELSE 1
END AS subject_entity_priority,
${relevantStatusFirst} AS status_priority,
${hasCustomerPrices} AS has_customer_prices,
prod.is_add_on AS product_is_add_on,
cp.*
FROM customer_products cp
JOIN products prod
ON prod.internal_id = cp.internal_product_id
WHERE cp.internal_customer_id = sr.internal_customer_id
AND (
(sr.internal_entity_id IS NULL AND cp.internal_entity_id IS NULL)
OR
(sr.internal_entity_id IS NOT NULL AND (
cp.internal_entity_id IS NULL
OR cp.internal_entity_id = sr.internal_entity_id
))
)
${statusFilter}
) cp_candidates ON true
),
cus_products AS (
SELECT *
FROM all_cus_products
WHERE subject_rank <= ${CUSTOMER_PRODUCT_LIMIT}
),
cus_entitlements AS (
SELECT
cp.subject_key,
ce.*
FROM customer_entitlements ce
JOIN cus_products cp
ON cp.id = ce.customer_product_id
),
extra_cus_entitlements AS (
SELECT ce_ordered.*
FROM subject_records sr
JOIN LATERAL (
SELECT
sr.subject_key,
CASE
WHEN sr.internal_entity_id IS NOT NULL
AND ce.internal_entity_id = sr.internal_entity_id
THEN 0
ELSE 1
END AS subject_entity_priority,
ce.*
FROM customer_entitlements ce
WHERE ce.internal_customer_id = sr.internal_customer_id
AND ce.customer_product_id IS NULL
AND (ce.expires_at IS NULL OR ce.expires_at > EXTRACT(EPOCH FROM now()) * 1000)
AND (
ce.balance != 0
OR ce.unlimited IS TRUE
OR EXISTS (
SELECT 1
FROM entitlements e
JOIN features f ON f.internal_id = e.internal_feature_id
WHERE e.id = ce.entitlement_id
AND f.type = 'boolean'
)
)
AND (
(sr.internal_entity_id IS NULL AND ce.internal_entity_id IS NULL)
OR
(sr.internal_entity_id IS NOT NULL AND (
ce.internal_entity_id IS NULL
OR ce.internal_entity_id = sr.internal_entity_id
))
)
ORDER BY subject_entity_priority ASC, ce.id DESC
LIMIT ${EXTRA_CUSTOMER_ENTITLEMENT_LIMIT}
) ce_ordered ON true
),
all_cus_ent_ids AS (
SELECT subject_key, id FROM cus_entitlements
UNION ALL
SELECT subject_key, id FROM extra_cus_entitlements
),
cus_rollovers AS (
SELECT ro.*
FROM rollovers ro
WHERE ro.cus_ent_id IN (SELECT id FROM all_cus_ent_ids)
AND (ro.expires_at IS NULL OR ro.expires_at > EXTRACT(EPOCH FROM now()) * 1000)
),
cus_replaceables AS (
SELECT rep.*
FROM replaceables rep
WHERE rep.cus_ent_id IN (SELECT id FROM all_cus_ent_ids)
),
cus_prices AS (
SELECT
cp.subject_key,
cpr.*
FROM customer_prices cpr
JOIN cus_products cp
ON cp.id = cpr.customer_product_id
)
${invoicesCte}
${entityFragments.ctes}
,
distinct_products AS (
SELECT DISTINCT ON (src.subject_key, p.internal_id)
src.subject_key,
src.internal_customer_id,
p.*
FROM products p
JOIN (
SELECT
cp.subject_key,
cp.internal_customer_id,
cp.internal_product_id
FROM cus_products cp
${entityFragments.productRefsUnion}
) src ON p.internal_id = src.internal_product_id
ORDER BY src.subject_key, p.internal_id
),
relevant_entitlement_records AS (
SELECT DISTINCT
ce.subject_key,
ce.internal_customer_id,
ce.entitlement_id
FROM cus_entitlements ce
UNION
SELECT DISTINCT
ece.subject_key,
ece.internal_customer_id,
ece.entitlement_id
FROM extra_cus_entitlements ece
${entityFragments.entitlementRefsUnion}
),
distinct_entitlements AS (
SELECT
rer.subject_key,
rer.internal_customer_id,
e.*,
row_to_json(f) AS feature
FROM relevant_entitlement_records rer
JOIN entitlements e
ON e.id = rer.entitlement_id
JOIN features f
ON e.internal_feature_id = f.internal_id
),
distinct_prices AS (
SELECT DISTINCT ON (src.subject_key, p.id)
src.subject_key,
src.internal_customer_id,
p.*
FROM prices p
JOIN (
SELECT
cpr.subject_key,
cpr.price_id,
cp.internal_customer_id
FROM cus_prices cpr
JOIN cus_products cp ON cp.id = cpr.customer_product_id
AND cp.subject_key = cpr.subject_key
${entityFragments.priceRefsUnion}
) src ON p.id = src.price_id
ORDER BY src.subject_key, p.id
),
distinct_free_trials AS (
SELECT DISTINCT ON (src.subject_key, ft.id)
src.subject_key,
src.internal_customer_id,
ft.*
FROM free_trials ft
JOIN (
SELECT
cp.subject_key,
cp.free_trial_id,
cp.internal_customer_id
FROM cus_products cp
WHERE cp.free_trial_id IS NOT NULL
${entityFragments.freeTrialRefsUnion}
) src ON ft.id = src.free_trial_id
ORDER BY src.subject_key, ft.id
)
SELECT
row_to_json(scr) AS customer,
COALESCE(
(
SELECT json_agg(
(
row_to_json(cp)::jsonb
- 'subject_key'
- 'subject_entity_priority'
- 'status_priority'
- 'has_customer_prices'
- 'product_is_add_on'
- 'subject_rank'
)::json
ORDER BY
cp.subject_entity_priority ASC,
cp.status_priority ASC,
cp.has_customer_prices DESC,
cp.product_is_add_on ASC,
cp.created_at DESC
)
FROM cus_products cp
WHERE cp.subject_key = sr.subject_key
),
'[]'::json
) AS customer_products,
COALESCE(
(
SELECT json_agg((row_to_json(ce)::jsonb - 'subject_key')::json)
FROM cus_entitlements ce
WHERE ce.subject_key = sr.subject_key
),
'[]'::json
) AS customer_entitlements,
COALESCE(
(
SELECT json_agg((row_to_json(cpr)::jsonb - 'subject_key')::json)
FROM cus_prices cpr
WHERE cpr.subject_key = sr.subject_key
),
'[]'::json
) AS customer_prices,
COALESCE(
(
SELECT json_agg(
(
row_to_json(ece)::jsonb
- 'subject_key'
- 'subject_entity_priority'
)::json
ORDER BY ece.subject_entity_priority ASC, ece.id DESC
)
FROM extra_cus_entitlements ece
WHERE ece.subject_key = sr.subject_key
),
'[]'::json
) AS extra_customer_entitlements,
COALESCE(
(
SELECT json_agg(row_to_json(rep) ORDER BY rep.created_at ASC, rep.id ASC)
FROM cus_replaceables rep
WHERE rep.cus_ent_id IN (
SELECT ace.id
FROM all_cus_ent_ids ace
WHERE ace.subject_key = sr.subject_key
)
),
'[]'::json
) AS replaceables,
COALESCE(
(
SELECT json_agg(
row_to_json(ro)
ORDER BY ro.expires_at ASC NULLS LAST, ro.id ASC
)
FROM cus_rollovers ro
WHERE ro.cus_ent_id IN (
SELECT ace.id
FROM all_cus_ent_ids ace
WHERE ace.subject_key = sr.subject_key
)
),
'[]'::json
) AS rollovers,
COALESCE(
(
SELECT json_agg((row_to_json(p)::jsonb - 'internal_customer_id' - 'subject_key')::json)
FROM distinct_products p
WHERE p.subject_key = sr.subject_key
),
'[]'::json
) AS products,
COALESCE(
(
SELECT json_agg((row_to_json(ent)::jsonb - 'internal_customer_id' - 'subject_key')::json)
FROM distinct_entitlements ent
WHERE ent.subject_key = sr.subject_key
),
'[]'::json
) AS entitlements,
COALESCE(
(
SELECT json_agg((row_to_json(pr)::jsonb - 'internal_customer_id' - 'subject_key')::json)
FROM distinct_prices pr
WHERE pr.subject_key = sr.subject_key
),
'[]'::json
) AS prices,
COALESCE(
(
SELECT json_agg((row_to_json(ft)::jsonb - 'internal_customer_id' - 'subject_key')::json)
FROM distinct_free_trials ft
WHERE ft.subject_key = sr.subject_key
),
'[]'::json
) AS free_trials,
COALESCE(
(
SELECT json_agg(row_to_json(cs)) FILTER (WHERE cs.stripe_id IS NOT NULL)
FROM (
SELECT DISTINCT s.*
FROM cus_products cp
JOIN LATERAL unnest(cp.subscription_ids) AS cp_sub(stripe_id) ON true
JOIN subscriptions s
ON s.stripe_id = cp_sub.stripe_id
WHERE cp.subject_key = sr.subject_key
) cs
),
'[]'::json
) AS subscriptions
${invoicesSelect},
CASE
WHEN er.internal_id IS NULL THEN NULL
ELSE row_to_json(er)
END AS entity
${entityFragments.selectColumns}
FROM subject_records sr
JOIN subject_customer_records scr
ON scr.internal_id = sr.internal_customer_id
LEFT JOIN entities er
ON er.internal_id = sr.internal_entity_id
ORDER BY sr.subject_order
`;
};

View File

@@ -7,6 +7,7 @@ import { handleDeleteEntityV2 } from "./handlers/handleDeleteEntity/handleDelete
import { handleGetEntity } from "./handlers/handleGetEntity/handleGetEntity.js";
import { handleGetEntityV2 } from "./handlers/handleGetEntity/handleGetEntityV2.js";
import { handleListEntities } from "./handlers/handleListEntities.js";
import { handleListEntitiesV2 } from "./handlers/handleListEntitiesV2.js";
import { handleUpdateEntity } from "./handlers/handleUpdateEntity/handleUpdateEntity.js";
export const entityRouter = new Hono<HonoEnv>();
@@ -30,5 +31,6 @@ entityRouter.get("/customers/:customer_id/entities", ...handleListEntities);
export const entityRpcRouter = new Hono<HonoEnv>();
entityRpcRouter.post("/entities.create", ...handleCreateEntityV2);
entityRpcRouter.post("/entities.get", ...handleGetEntityV2);
entityRpcRouter.post("/entities.list", ...handleListEntitiesV2);
entityRpcRouter.post("/entities.delete", ...handleDeleteEntityV2);
entityRpcRouter.post("/entities.update", ...handleUpdateEntity);

View File

@@ -0,0 +1,140 @@
import {
AffectedResource,
ApiVersion,
type ApiEntityV2,
applyResponseVersionChanges,
type CusProductStatus,
type EntityLegacyData,
ListEntitiesParamsSchema,
type PagePaginatedResponse,
Scopes,
type SubjectQueryRow,
} from "@autumn/shared";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import {
ACTIVE_STATUSES,
RELEVANT_STATUSES,
} from "@/internal/customers/cusProducts/CusProductService.js";
import { lazyResetSubjectEntitlements } from "@/internal/customers/actions/resetCustomerEntitlementsV2/lazyResetSubjectEntitlements.js";
import { resultToFullSubject } from "@/internal/customers/repos/getFullSubject/index.js";
import { getApiEntityBaseV2 } from "../entityUtils/getApiEntityV2/getApiEntityBaseV2.js";
import {
countEntitiesByOrgIdAndEnv,
countFilteredEntitiesByOrgIdAndEnv,
getPaginatedEntitySubjectsQuery,
hasEntityListFilters,
} from "../repos/listEntitiesQuery.js";
const getListEntitiesStatuses = ({
subscriptionStatus,
}: {
subscriptionStatus?: "active" | "scheduled";
}): CusProductStatus[] => {
if (subscriptionStatus === "active") {
return ACTIVE_STATUSES;
}
if (subscriptionStatus) {
return [subscriptionStatus as CusProductStatus];
}
return RELEVANT_STATUSES;
};
export const handleListEntitiesV2 = createRoute({
scopes: [Scopes.Customers.Read],
versionedBody: {
latest: ListEntitiesParamsSchema,
[ApiVersion.V2_0]: ListEntitiesParamsSchema,
},
resource: AffectedResource.Entity,
handler: async (c) => {
const ctx = c.get("ctx");
const body = c.req.valid("json");
const inStatuses = getListEntitiesStatuses({
subscriptionStatus: body.subscription_status,
});
const hasFilteredQuery = hasEntityListFilters({
plans: body.plans,
processors: body.processors,
search: body.search,
});
const [subjectRows, totalCount] = await Promise.all([
ctx.db.execute(
getPaginatedEntitySubjectsQuery({
orgId: ctx.org.id,
env: ctx.env,
query: body,
inStatuses,
}),
),
countEntitiesByOrgIdAndEnv({ ctx }),
]);
const totalFilteredCount = hasFilteredQuery
? await countFilteredEntitiesByOrgIdAndEnv({
ctx,
query: {
plans: body.plans,
processors: body.processors,
search: body.search,
},
inStatuses,
})
: totalCount;
const entities = [];
for (const row of subjectRows) {
const fullSubject = resultToFullSubject({
row: row as unknown as SubjectQueryRow,
entityIdRequested: true,
});
await lazyResetSubjectEntitlements({
ctx,
fullSubject,
});
const { apiEntity: baseEntity, legacyData } = await getApiEntityBaseV2({
ctx,
fullSubject,
withAutumnId: false,
});
const cleanedEntity: ApiEntityV2 = {
...baseEntity,
feature_id: baseEntity.feature_id || undefined,
autumn_id: undefined,
invoices: undefined,
};
entities.push(
applyResponseVersionChanges<ApiEntityV2, EntityLegacyData>({
input: cleanedEntity,
targetVersion: ctx.apiVersion,
resource: AffectedResource.Entity,
legacyData,
ctx,
}),
);
}
const hasMore = subjectRows.length === body.limit;
return c.json<
PagePaginatedResponse<(typeof entities)[number]> & {
total_count: number;
total_filtered_count: number;
}
>({
list: entities,
total: entities.length,
total_count: totalCount,
total_filtered_count: totalFilteredCount,
limit: body.limit,
offset: body.offset,
has_more: hasMore,
});
},
});

View File

@@ -0,0 +1,215 @@
import type {
AppEnv,
CusProductStatus,
ListEntitiesParams,
} from "@autumn/shared";
import { type SQL, sql } from "drizzle-orm";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { getFullSubjectRowsQuery } from "@/internal/customers/repos/getFullSubject/getFullSubjectRowsQuery.js";
export const hasEntityListFilters = ({
plans,
processors,
search,
}: Pick<ListEntitiesParams, "plans" | "processors" | "search">) => {
return Boolean(
(plans && plans.length > 0) ||
(processors && processors.length > 0) ||
search?.trim(),
);
};
const getEntityListFilterSql = ({
plans,
processors,
search,
inStatuses,
}: Pick<ListEntitiesParams, "plans" | "processors" | "search"> & {
inStatuses: CusProductStatus[];
}) => {
const filters: SQL[] = [];
if (plans && plans.length > 0) {
const planConditions = plans.map((plan) => {
if (plan.versions && plan.versions.length > 0) {
return sql`(p_filter.id = ${plan.id} AND p_filter.version IN (${sql.join(
plan.versions.map((version) => sql`${version}`),
sql`, `,
)}))`;
}
return sql`p_filter.id = ${plan.id}`;
});
filters.push(sql`AND EXISTS (
SELECT 1
FROM customer_products cp_filter
JOIN products p_filter
ON p_filter.internal_id = cp_filter.internal_product_id
WHERE cp_filter.internal_customer_id = e.internal_customer_id
AND (
cp_filter.internal_entity_id IS NULL
OR cp_filter.internal_entity_id = e.internal_id
)
AND cp_filter.status = ANY(ARRAY[${sql.join(
inStatuses.map((status) => sql`${status}`),
sql`, `,
)}])
AND (${sql.join(planConditions, sql` OR `)})
)`);
}
const trimmedSearch = search?.trim();
if (trimmedSearch) {
const pattern = `%${trimmedSearch}%`;
filters.push(sql`AND (
e.id ILIKE ${pattern}
OR e.name ILIKE ${pattern}
)`);
}
if (processors && processors.length > 0) {
const processorConditions = processors
.map((proc) => {
if (proc === "stripe") return sql`(c.processor->>'id' IS NOT NULL)`;
if (proc === "revenuecat")
return sql`EXISTS (
SELECT 1
FROM customer_products cp_processor
WHERE cp_processor.internal_customer_id = c.internal_id
AND cp_processor.processor->>'type' = 'revenuecat'
)`;
if (proc === "vercel")
return sql`(c.processors->>'vercel' IS NOT NULL)`;
return null;
})
.filter((condition): condition is SQL => condition !== null);
if (processorConditions.length > 0) {
filters.push(sql`AND (${sql.join(processorConditions, sql` OR `)})`);
}
}
return sql.join(filters, sql` `);
};
const getEntityListBaseSql = ({
orgId,
env,
filterSql,
}: {
orgId: string;
env: AppEnv;
filterSql: SQL;
}) => sql`
FROM entities e
JOIN customers c
ON c.internal_id = e.internal_customer_id
WHERE e.org_id = ${orgId}
AND e.env = ${env}
AND c.org_id = ${orgId}
AND c.env = ${env}
${filterSql}
`;
export const getPaginatedEntitySubjectsQuery = ({
orgId,
env,
query,
inStatuses,
}: {
orgId: string;
env: AppEnv;
query: ListEntitiesParams;
inStatuses: CusProductStatus[];
}) => {
const filterSql = getEntityListFilterSql({
plans: query.plans,
processors: query.processors,
search: query.search,
inStatuses,
});
const leadingCtes = sql`
WITH entity_records AS (
SELECT e.*
${getEntityListBaseSql({
orgId,
env,
filterSql,
})}
ORDER BY e.internal_id DESC
LIMIT ${query.limit}
OFFSET ${query.offset}
),
subject_records AS (
SELECT
er.internal_id AS subject_key,
er.internal_customer_id,
er.internal_id AS internal_entity_id,
ROW_NUMBER() OVER (ORDER BY er.internal_id DESC) AS subject_order
FROM entity_records er
)
`;
return getFullSubjectRowsQuery({
leadingCtes,
inStatuses,
includeInvoices: false,
includeEntityAggregations: false,
});
};
const countEntities = async ({
ctx,
filterSql,
}: {
ctx: AutumnContext;
filterSql: SQL;
}) => {
const rows = await ctx.db.execute(sql`
SELECT COUNT(*) AS total_count
${getEntityListBaseSql({
orgId: ctx.org.id,
env: ctx.env,
filterSql,
})}
`);
const rawCount = (rows[0] as { total_count?: string | number } | undefined)
?.total_count;
return Number(rawCount ?? 0);
};
export const countEntitiesByOrgIdAndEnv = async ({
ctx,
}: {
ctx: AutumnContext;
}) => {
return countEntities({ ctx, filterSql: sql`` });
};
export const countFilteredEntitiesByOrgIdAndEnv = async ({
ctx,
query,
inStatuses,
}: {
ctx: AutumnContext;
query: Pick<ListEntitiesParams, "plans" | "processors" | "search">;
inStatuses: CusProductStatus[];
}) => {
if (!hasEntityListFilters(query)) {
return countEntitiesByOrgIdAndEnv({ ctx });
}
return countEntities({
ctx,
filterSql: getEntityListFilterSql({
plans: query.plans,
processors: query.processors,
search: query.search,
inStatuses,
}),
});
};

View File

@@ -58,6 +58,7 @@ const RATE_LIMIT_ROUTE_GROUPS: RateLimitRouteGroup[] = [
route({ method: "GET", url: "/v1/customers" }),
route({ method: "POST", url: "/v1/customers/list" }),
route({ method: "POST", url: "/v1/customers.list" }),
route({ method: "POST", url: "/v1/entities.list" }),
],
},
{

View File

@@ -15,6 +15,7 @@ const route = ({ method, url }: RoutePattern): RoutePattern => ({
const REPLICA_ROUTE_PATTERNS: RoutePattern[] = [
route({ method: "POST", url: "/v1/customers/list" }),
route({ method: "POST", url: "/v1/customers.list" }),
route({ method: "POST", url: "/v1/entities.list" }),
];
export const shouldUseReplicaDb = (c: Context<HonoEnv>) => {

View File

@@ -0,0 +1,197 @@
/**
* TDD test for entities.list.
*
* Contract under test:
* New endpoints:
* - POST /v1/entities.list -> page-paginated full entity responses.
* New behaviors:
* - search matches entity id/name only, not parent customer fields.
* - plan filters include inherited customer-level products.
* - plan filters include matching entity-level products only for that entity.
* - trialing entity products are returned in full entity list responses.
* - subscription_status alone shapes hydrated products but does not reduce entity selection.
* Side effects:
* - none; endpoint is read-only.
*
* Pre-impl red: the RPC route and list handler do not exist.
* Post-impl green: all assertions pass through the FullSubject-backed entity list path.
*/
import { expect, test } from "bun:test";
import {
type ApiEntityV1,
ApiEntityV1Schema,
type ApiEntityV2,
type PagePaginatedResponse,
} from "@autumn/shared";
import { ApiEntityV2Schema } from "@shared/api/entities/apiEntityV2.js";
import { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { timeout } from "@tests/utils/genUtils.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
type ListEntitiesResponse<T> = PagePaginatedResponse<T> & {
total_count: number;
total_filtered_count: number;
};
test.concurrent(`${chalk.yellowBright("list entities: filters inherited and entity-level plans with trial entities")}`, async () => {
const customerId = "list-entities-contract";
const entityPrefix = `${customerId}-entity`;
const alphaEntityId = `${entityPrefix}-alpha`;
const betaEntityId = `${entityPrefix}-beta`;
const gammaEntityId = `${entityPrefix}-gamma`;
const customerOnlyNeedle = "list-entities-customer-only-needle";
const inheritedProduct = products.base({
id: "list-inherited",
items: [items.dashboard(), items.monthlyMessages({ includedUsage: 100 })],
});
const trialEntityProduct = products.baseWithTrial({
id: "list-trial-entity",
trialDays: 7,
cardRequired: false,
items: [items.monthlyCredits({ includedUsage: 200 })],
});
const regularEntityProduct = products.base({
id: "list-regular-entity",
items: [items.monthlyCredits({ includedUsage: 50 })],
});
const { autumnV1, autumnV2, autumnV2_1 } = await initScenario({
customerId,
setup: [
s.customer({
name: customerOnlyNeedle,
}),
s.products({
list: [inheritedProduct, trialEntityProduct, regularEntityProduct],
}),
],
actions: [s.billing.attach({ productId: inheritedProduct.id })],
});
await autumnV2_1.entitiesV2.create({
customer_id: customerId,
entity_id: alphaEntityId,
feature_id: TestFeature.Users,
name: "List Entities Alpha",
});
await autumnV2_1.entitiesV2.create({
customer_id: customerId,
entity_id: betaEntityId,
feature_id: TestFeature.Users,
name: "List Entities Beta Trial",
});
await autumnV2_1.entitiesV2.create({
customer_id: customerId,
entity_id: gammaEntityId,
feature_id: TestFeature.Users,
name: "List Entities Gamma",
});
await autumnV1.billing.attach({
customer_id: customerId,
product_id: trialEntityProduct.id,
entity_id: betaEntityId,
});
await autumnV1.billing.attach({
customer_id: customerId,
product_id: regularEntityProduct.id,
entity_id: gammaEntityId,
});
// Contract: full V2.1 entity responses, pagination, and entity-only search.
const firstPage = await autumnV2_1.entitiesV2.list<
ListEntitiesResponse<ApiEntityV2>
>({
search: entityPrefix,
limit: 2,
offset: 0,
keepInternalFields: true,
});
expect(firstPage.total).toBe(2);
expect(firstPage.limit).toBe(2);
expect(firstPage.offset).toBe(0);
expect(firstPage.has_more).toBe(true);
expect(firstPage.total_filtered_count).toBe(3);
expect(firstPage.total_count).toBeGreaterThanOrEqual(3);
for (const entity of firstPage.list) {
ApiEntityV2Schema.parse(entity);
expect(entity.customer_id).toBe(customerId);
}
const customerSearch = await autumnV2_1.entitiesV2.list<
ListEntitiesResponse<ApiEntityV2>
>({
search: customerOnlyNeedle,
keepInternalFields: true,
});
expect(customerSearch.total).toBe(0);
expect(customerSearch.total_filtered_count).toBe(0);
// Contract: inherited customer-level product makes all three entities match.
const inheritedPlanPage = await autumnV2_1.entitiesV2.list<
ListEntitiesResponse<ApiEntityV2>
>({
search: entityPrefix,
plans: [{ id: inheritedProduct.id }],
limit: 10,
keepInternalFields: true,
});
expect(inheritedPlanPage.total_filtered_count).toBe(3);
expect(inheritedPlanPage.list.map((entity) => entity.id).sort()).toEqual([
alphaEntityId,
betaEntityId,
gammaEntityId,
]);
// Contract: entity-level product only matches its owning entity, including trial products.
const trialPlanPage = await autumnV2_1.entitiesV2.list<
ListEntitiesResponse<ApiEntityV2>
>({
search: entityPrefix,
plans: [{ id: trialEntityProduct.id }],
keepInternalFields: true,
});
expect(trialPlanPage.total).toBe(1);
expect(trialPlanPage.total_filtered_count).toBe(1);
const trialEntity = trialPlanPage.list[0];
ApiEntityV2Schema.parse(trialEntity);
expect(trialEntity.id).toBe(betaEntityId);
const trialSubscription = trialEntity.subscriptions.find(
(subscription) => subscription.plan_id === trialEntityProduct.id,
);
expect(trialSubscription).toBeDefined();
expect(trialSubscription?.trial_ends_at).toBeNumber();
expect(trialEntity.balances[TestFeature.Credits]).toMatchObject({
remaining: 200,
usage: 0,
});
// Contract: subscription_status alone does not reduce selected entities.
const scheduledOnlySelection = await autumnV2_1.entitiesV2.list<
ListEntitiesResponse<ApiEntityV2>
>({
search: entityPrefix,
subscription_status: "scheduled",
limit: 10,
keepInternalFields: true,
});
expect(scheduledOnlySelection.total).toBe(3);
expect(scheduledOnlySelection.total_filtered_count).toBe(3);
// Contract: response versioning applies to each listed entity.
await timeout(1100);
const v2_0Page = await autumnV2.entitiesV2.list<
ListEntitiesResponse<ApiEntityV1>
>({
search: betaEntityId,
keepInternalFields: true,
});
expect(v2_0Page.total).toBe(1);
ApiEntityV1Schema.parse(v2_0Page.list[0]);
expect(v2_0Page.list[0].id).toBe(betaEntityId);
});

View File

@@ -0,0 +1,335 @@
import { describe, expect, test } from "bun:test";
import { CusProductStatus } from "@autumn/shared";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import { getFullSubject } from "@/internal/customers/repos/getFullSubject/index.js";
import { buildEntitySubjectScenario } from "./utils/fullSubjectScenarioBuilders.js";
import { withInsertedScenario } from "./utils/withInsertedScenario.js";
describe(`${chalk.yellowBright("fullSubject ordering and limits")}`, () => {
test("orders and limits customer products and loose entitlements per subject", async () => {
const scenario = buildEntitySubjectScenario({
ctx,
name: "fullsubject-order-limit",
});
const baseTime = Date.now();
const key = scenario.ids.internalCustomerId;
const parentProduct = scenario.products[0]!;
const entityProduct = scenario.products[1]!;
const parentEntitlement = scenario.entitlements[0]!;
const parentPrice = scenario.prices[0]!;
const parentCustomerProduct = {
...scenario.customerProducts[0]!,
created_at: baseTime - 20_000,
status: CusProductStatus.Active,
};
const entityCustomerProduct = {
...scenario.customerProducts[1]!,
created_at: baseTime - 100_000,
status: CusProductStatus.Active,
};
const unrelatedEntityCustomerProduct = {
...scenario.customerProducts[2]!,
created_at: baseTime,
status: CusProductStatus.Active,
};
const addOnProduct = {
...parentProduct,
internal_id: `${parentProduct.internal_id}_addon`,
id: `${parentProduct.id}_addon`,
name: `${parentProduct.name} Add-on`,
is_add_on: true,
created_at: baseTime,
};
const freeProduct = {
...parentProduct,
internal_id: `${parentProduct.internal_id}_free`,
id: `${parentProduct.id}_free`,
name: `${parentProduct.name} Free`,
is_add_on: false,
created_at: baseTime,
};
const expiredProduct = {
...parentProduct,
internal_id: `${parentProduct.internal_id}_expired`,
id: `${parentProduct.id}_expired`,
name: `${parentProduct.name} Expired`,
is_add_on: false,
created_at: baseTime,
};
const addOnEntitlement = {
...parentEntitlement,
id: `${parentEntitlement.id}_addon`,
internal_product_id: addOnProduct.internal_id,
created_at: baseTime,
};
const expiredEntitlement = {
...parentEntitlement,
id: `${parentEntitlement.id}_expired`,
internal_product_id: expiredProduct.internal_id,
created_at: baseTime,
};
const addOnPrice = {
...parentPrice,
id: `${parentPrice.id}_addon`,
internal_product_id: addOnProduct.internal_id,
entitlement_id: addOnEntitlement.id,
created_at: baseTime,
};
const expiredPrice = {
...parentPrice,
id: `${parentPrice.id}_expired`,
internal_product_id: expiredProduct.internal_id,
entitlement_id: expiredEntitlement.id,
created_at: baseTime,
};
const addOnCustomerProduct = {
...parentCustomerProduct,
id: `${parentCustomerProduct.id}_addon`,
internal_product_id: addOnProduct.internal_id,
product_id: addOnProduct.id,
created_at: baseTime,
status: CusProductStatus.Active,
};
const freeCustomerProduct = {
...parentCustomerProduct,
id: `${parentCustomerProduct.id}_free`,
internal_product_id: freeProduct.internal_id,
product_id: freeProduct.id,
created_at: baseTime + 1_000,
status: CusProductStatus.Active,
};
const expiredCustomerProduct = {
...parentCustomerProduct,
id: `${parentCustomerProduct.id}_expired`,
internal_product_id: expiredProduct.internal_id,
product_id: expiredProduct.id,
created_at: baseTime + 2_000,
status: CusProductStatus.Expired,
};
const addOnCustomerPrice = {
...scenario.customerPrices[0]!,
id: `${scenario.customerPrices[0]!.id}_addon`,
customer_product_id: addOnCustomerProduct.id,
price_id: addOnPrice.id,
created_at: baseTime,
};
const expiredCustomerPrice = {
...scenario.customerPrices[0]!,
id: `${scenario.customerPrices[0]!.id}_expired`,
customer_product_id: expiredCustomerProduct.id,
price_id: expiredPrice.id,
created_at: baseTime,
};
const addOnCustomerEntitlement = {
...scenario.customerEntitlements[0]!,
id: `${scenario.customerEntitlements[0]!.id}_addon`,
customer_product_id: addOnCustomerProduct.id,
entitlement_id: addOnEntitlement.id,
created_at: baseTime,
external_id: `${scenario.customerEntitlements[0]!.external_id}_addon`,
};
const expiredCustomerEntitlement = {
...scenario.customerEntitlements[0]!,
id: `${scenario.customerEntitlements[0]!.id}_expired`,
customer_product_id: expiredCustomerProduct.id,
entitlement_id: expiredEntitlement.id,
created_at: baseTime,
external_id: `${scenario.customerEntitlements[0]!.external_id}_expired`,
};
const fillerProducts = Array.from({ length: 55 }, (_, index) => {
const suffix = index.toString().padStart(2, "0");
return {
...parentProduct,
internal_id: `${parentProduct.internal_id}_filler_${suffix}`,
id: `${parentProduct.id}_filler_${suffix}`,
name: `${parentProduct.name} Filler ${suffix}`,
is_add_on: false,
created_at: baseTime - index,
};
});
const fillerCustomerProducts = fillerProducts.map((product, index) => ({
...parentCustomerProduct,
id: `${parentCustomerProduct.id}_filler_${index
.toString()
.padStart(2, "0")}`,
internal_product_id: product.internal_id,
product_id: product.id,
created_at: baseTime - index,
status: CusProductStatus.Expired,
}));
const customerLooseEntitlements = Array.from({ length: 35 }, (_, index) => {
const suffix = index.toString().padStart(2, "0");
return {
...scenario.customerEntitlements[0]!,
id: `ce_${key}_loose_customer_${suffix}`,
customer_product_id: null,
entitlement_id: parentEntitlement.id,
internal_entity_id: null,
balance: index + 1,
created_at: baseTime + index,
external_id: `bal_${key}_loose_customer_${suffix}`,
};
});
const entityLooseEntitlements = Array.from({ length: 2 }, (_, index) => {
const suffix = index.toString().padStart(2, "0");
return {
...scenario.customerEntitlements[0]!,
id: `ce_${key}_loose_entity_${suffix}`,
customer_product_id: null,
entitlement_id: parentEntitlement.id,
internal_entity_id: scenario.ids.internalEntityIds[0]!,
balance: index + 1,
created_at: baseTime + index,
external_id: `bal_${key}_loose_entity_${suffix}`,
};
});
const orderingScenario = {
...scenario,
products: [
parentProduct,
entityProduct,
scenario.products[2]!,
addOnProduct,
freeProduct,
expiredProduct,
...fillerProducts,
],
entitlements: [
parentEntitlement,
scenario.entitlements[1]!,
scenario.entitlements[2]!,
addOnEntitlement,
expiredEntitlement,
],
prices: [
parentPrice,
scenario.prices[1]!,
scenario.prices[2]!,
addOnPrice,
expiredPrice,
],
customerProducts: [
parentCustomerProduct,
entityCustomerProduct,
unrelatedEntityCustomerProduct,
addOnCustomerProduct,
freeCustomerProduct,
expiredCustomerProduct,
...fillerCustomerProducts,
],
customerPrices: [
scenario.customerPrices[0]!,
scenario.customerPrices[1]!,
scenario.customerPrices[2]!,
addOnCustomerPrice,
expiredCustomerPrice,
],
customerEntitlements: [
scenario.customerEntitlements[0]!,
scenario.customerEntitlements[1]!,
scenario.customerEntitlements[2]!,
addOnCustomerEntitlement,
expiredCustomerEntitlement,
...customerLooseEntitlements,
...entityLooseEntitlements,
],
ids: {
...scenario.ids,
productInternalIds: [
...scenario.ids.productInternalIds,
addOnProduct.internal_id,
freeProduct.internal_id,
expiredProduct.internal_id,
...fillerProducts.map((product) => product.internal_id),
],
productIds: [
...scenario.ids.productIds,
addOnProduct.id,
freeProduct.id,
expiredProduct.id,
...fillerProducts.map((product) => product.id),
],
},
};
await withInsertedScenario({
ctx,
scenario: orderingScenario,
run: async () => {
const inStatuses = [CusProductStatus.Active, CusProductStatus.Expired];
const customerSubject = (await getFullSubject({
ctx,
customerId: scenario.ids.customerId,
inStatuses,
}))!;
const entitySubject = (await getFullSubject({
ctx,
customerId: scenario.ids.customerId,
entityId: scenario.ids.entityIds[0],
inStatuses,
}))!;
expect(customerSubject.customer_products).toHaveLength(50);
expect(
customerSubject.customer_products
.slice(0, 4)
.map((customerProduct) => customerProduct.id),
).toEqual([
parentCustomerProduct.id,
addOnCustomerProduct.id,
freeCustomerProduct.id,
expiredCustomerProduct.id,
]);
expect(entitySubject.customer_products).toHaveLength(50);
expect(
entitySubject.customer_products
.slice(0, 5)
.map((customerProduct) => customerProduct.id),
).toEqual([
entityCustomerProduct.id,
parentCustomerProduct.id,
addOnCustomerProduct.id,
freeCustomerProduct.id,
expiredCustomerProduct.id,
]);
expect(
entitySubject.customer_products.map(
(customerProduct) => customerProduct.id,
),
).not.toContain(unrelatedEntityCustomerProduct.id);
expect(customerSubject.extra_customer_entitlements).toHaveLength(30);
expect(
customerSubject.extra_customer_entitlements
.slice(0, 3)
.map((customerEntitlement) => customerEntitlement.id),
).toEqual([
`ce_${key}_loose_customer_34`,
`ce_${key}_loose_customer_33`,
`ce_${key}_loose_customer_32`,
]);
expect(entitySubject.extra_customer_entitlements).toHaveLength(30);
expect(
entitySubject.extra_customer_entitlements
.slice(0, 4)
.map((customerEntitlement) => customerEntitlement.id),
).toEqual([
`ce_${key}_loose_entity_01`,
`ce_${key}_loose_entity_00`,
`ce_${key}_loose_customer_34`,
`ce_${key}_loose_customer_33`,
]);
},
});
});
});

View File

@@ -1976,6 +1976,19 @@ const ROUTES = [
needsScopes: true,
isWebhookExempt: false,
},
{
handlerName: "handleListEntitiesV2",
handlerFile: "src/internal/entities/handlers/handleListEntitiesV2.ts",
method: "POST",
path: "/v1/entities.list",
style: "RPC",
group: "v1/entities",
mountChain: ["/v1", "", "", "/entities.list"],
sourceRouterFile: "src/internal/entities/entityRouter.ts",
routeKind: "createRoute",
needsScopes: true,
isWebhookExempt: false,
},
{
handlerName: "handleUpdateEntity",
handlerFile:
@@ -4456,6 +4469,12 @@ const SCOPE_DECISIONS: Record<
shape: "array",
decidedAt: "2026-04-24T15:39:15.066Z",
},
"POST|/v1/entities.list|handleListEntitiesV2": {
decision: "decided",
scopes: ["customers:read"],
shape: "array",
decidedAt: "2026-05-08T00:00:00.000Z",
},
"POST|/v1/entities.update|handleUpdateEntity": {
decision: "decided",
scopes: ["customers:write"],

View File

@@ -1,4 +1,5 @@
export * from "./createEntityParams.js";
export * from "./deleteEntityParams.js";
export * from "./getEntityParams.js";
export * from "./listEntitiesParams.js";
export * from "./updateEntityParams.js";

View File

@@ -0,0 +1,38 @@
import { z } from "zod/v4";
import { createPaginationParamsSchema } from "../../common/pagePaginationSchemas";
export const ListEntitiesParamsSchema = createPaginationParamsSchema({
defaultLimit: 10,
}).extend({
plans: z
.array(
z.object({
id: z.string(),
versions: z.number().array().optional(),
}),
)
.optional()
.meta({
description:
"Filter by plan ID and version. Returns entities with active subscriptions to this plan, including plans inherited from the parent customer.",
}),
subscription_status: z.enum(["active", "scheduled"]).optional().meta({
description:
"Filter customer products used for entity hydration and plan matching. Defaults to active and scheduled.",
}),
search: z.string().optional().meta({
description: "Search entities by id or name.",
}),
processors: z
.array(z.enum(["stripe", "revenuecat", "vercel"]))
.optional()
.meta({
description:
"Filter by parent customer processor type (stripe, revenuecat, vercel).",
}),
});
export type ListEntitiesParams = z.infer<typeof ListEntitiesParamsSchema>;