feat: list entities
This commit is contained in:
@@ -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",
|
||||
|
||||
2
packages/openapi/v2.1/contracts/index.ts
vendored
2
packages/openapi/v2.1/contracts/index.ts
vendored
@@ -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,
|
||||
|
||||
|
||||
28
packages/openapi/v2.1/jsDocs/entityJsDocs.ts
vendored
28
packages/openapi/v2.1/jsDocs/entityJsDocs.ts
vendored
@@ -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:
|
||||
|
||||
19
server/src/external/autumn/autumnCli.ts
vendored
19
server/src/external/autumn/autumnCli.ts
vendored
@@ -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,
|
||||
|
||||
@@ -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
|
||||
`;
|
||||
|
||||
@@ -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}
|
||||
`;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
`;
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
140
server/src/internal/entities/handlers/handleListEntitiesV2.ts
Normal file
140
server/src/internal/entities/handlers/handleListEntitiesV2.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
});
|
||||
215
server/src/internal/entities/repos/listEntitiesQuery.ts
Normal file
215
server/src/internal/entities/repos/listEntitiesQuery.ts
Normal 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,
|
||||
}),
|
||||
});
|
||||
};
|
||||
@@ -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" }),
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>) => {
|
||||
|
||||
197
server/tests/integration/crud/entities/list-entities.test.ts
Normal file
197
server/tests/integration/crud/entities/list-entities.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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`,
|
||||
]);
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./createEntityParams.js";
|
||||
export * from "./deleteEntityParams.js";
|
||||
export * from "./getEntityParams.js";
|
||||
export * from "./listEntitiesParams.js";
|
||||
export * from "./updateEntityParams.js";
|
||||
|
||||
38
shared/api/entities/crud/listEntitiesParams.ts
Normal file
38
shared/api/entities/crud/listEntitiesParams.ts
Normal 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>;
|
||||
Reference in New Issue
Block a user