feat: 🎸 boom

This commit is contained in:
amianthus
2026-01-02 11:10:55 +00:00
parent 1649d91a7c
commit b0920f5eda
32 changed files with 915 additions and 337 deletions

View File

@@ -42,7 +42,7 @@ const cusPrefixedUrls = [
* Note: /balances/update is NOT included because it updates Redis directly
* to avoid race conditions with batched track syncs
*/
const coreUrls = [
const coreUrls: { method: string; url: string; source?: string }[] = [
{
method: "POST",
url: "/attach",
@@ -51,6 +51,11 @@ const coreUrls = [
method: "POST",
url: "/cancel",
},
{
method: "POST",
url: "/balances/create",
source: "handleCreateBalance",
}
];
/**
@@ -115,8 +120,7 @@ export const refreshCacheMiddleware = async (
customerId: body.customer_id,
orgId: org.id,
env: env,
source: "refreshCacheMiddleware",
logger,
source: coreMatch.source || undefined,
});
}
}

View File

@@ -1,44 +1,48 @@
import { CreateBalanceSchema } from "@autumn/shared";
import {
ErrCode,
type Feature,
FeatureSchema,
FeatureType,
type FullCustomer,
RecaseError,
ResetInterval,
ValidateCreateBalanceParamsSchema,
} from "@shared/index";
import { StatusCodes } from "http-status-codes";
import z from "zod/v4";
import type { z } from "zod/v4";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService";
export const CreateBalanceForValidation = CreateBalanceSchema.extend({
feature: FeatureSchema,
}).refine((data) => {
if (!data.feature) {
return false;
}
export const validateCreateBalanceParams = async ({
ctx,
feature,
internalCustomerId,
granted_balance,
unlimited,
reset,
fullCustomer,
}: {
ctx: AutumnContext;
feature: Feature;
internalCustomerId: string;
granted_balance: number | undefined;
unlimited: boolean | undefined;
reset: z.infer<typeof ValidateCreateBalanceParamsSchema>["reset"];
fullCustomer: FullCustomer;
}) => {
ValidateCreateBalanceParamsSchema.parse({
feature,
granted_balance,
unlimited,
reset,
customer_id: internalCustomerId,
feature_id: feature.id,
});
if (data.feature.type === FeatureType.Boolean) {
if (data.granted_balance || data.unlimited || data.reset?.interval) {
return false;
}
}
if (data.feature.type === FeatureType.Metered) {
if (!data.granted_balance && !data.unlimited) {
return false;
}
if (data.granted_balance && data.unlimited) {
return false;
}
if (data.unlimited && data.reset?.interval) {
return false;
}
}
return true;
});
await validateBooleanEntitlementConflict({
ctx,
feature,
internalCustomerId: fullCustomer.internal_id,
});
};
export const validateBooleanEntitlementConflict = async ({
ctx,

View File

@@ -1,15 +1,16 @@
import { CreateBalanceSchema } from "@autumn/shared";
import { CustomerNotFoundError, FeatureNotFoundError } from "@shared/index";
import {
CreateBalanceSchema
} from "@autumn/shared";
import { FeatureNotFoundError } from "@shared/index";
import { createRoute } from "@/honoMiddlewares/routeHandler";
import { CusService } from "@/internal/customers/CusService";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService";
import { deleteCachedApiCustomer } from "@/internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer";
import { EntitlementService } from "@/internal/products/entitlements/EntitlementService";
import { prepareNewBalanceForInsertion } from "../createNewBalance/prepareNewBalanceForInsertion";
import { prepareNewBalanceForInsertion } from "../createBalance/prepareNewBalanceForInsertion";
import {
CreateBalanceForValidation,
validateBooleanEntitlementConflict,
} from "../createNewBalance/validationUtilsForNewBalances";
validateCreateBalanceParams
} from "../createBalance/validateCreateBalance";
export const handleCreateBalance = createRoute({
body: CreateBalanceSchema,
@@ -22,7 +23,7 @@ export const handleCreateBalance = createRoute({
if (!feature) {
throw new FeatureNotFoundError({ featureId: feature_id });
}
34;
const fullCustomer = await CusService.getFull({
db: ctx.db,
idOrInternalId: customer_id,
@@ -30,24 +31,14 @@ export const handleCreateBalance = createRoute({
env: ctx.env,
});
if (!fullCustomer) {
throw new CustomerNotFoundError({ customerId: customer_id });
}
// This should throw an error if the data is invalid
CreateBalanceForValidation.parse({
feature: feature,
granted_balance,
unlimited,
reset,
customer_id,
feature_id,
});
await validateBooleanEntitlementConflict({
await validateCreateBalanceParams({
ctx,
feature,
internalCustomerId: fullCustomer.internal_id,
granted_balance,
unlimited,
reset,
fullCustomer,
});
const { newEntitlement, newCustomerEntitlement } =

View File

@@ -14,7 +14,7 @@ import {
features,
type ResetCusEnt,
} from "@autumn/shared";
import { and, eq, lt, sql } from "drizzle-orm";
import { and, eq, isNull, lt, sql } from "drizzle-orm";
import { StatusCodes } from "http-status-codes";
import { buildConflictUpdateColumns } from "@/db/dbUtils.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
@@ -149,6 +149,72 @@ export class CusEntService {
return allResults as ResetCusEnt[];
}
static async getLooseResetPassed({
db,
customDateUnix,
batchSize = 1000,
}: {
db: DrizzleCli;
customDateUnix?: number;
batchSize?: number;
}) {
const allResults: ResetCusEnt[] = [];
let offset = 0;
let hasMore = true;
while (hasMore) {
const data = await db
.select()
.from(customerEntitlements)
.innerJoin(
entitlements,
eq(customerEntitlements.entitlement_id, entitlements.id),
)
.innerJoin(
features,
eq(entitlements.internal_feature_id, features.internal_id),
)
.innerJoin(
customers,
eq(customerEntitlements.internal_customer_id, customers.internal_id),
)
.where(
and(
isNull(customerEntitlements.customer_product_id),
lt(
customerEntitlements.next_reset_at,
customDateUnix ?? Date.now(),
),
),
)
.limit(batchSize)
.offset(offset);
if (data.length === 0) {
hasMore = false;
} else {
const mappedData = data.map((item) => ({
...item.customer_entitlements,
entitlement: {
...item.entitlements,
feature: item.features,
},
customer_product: null,
customer: item.customers,
replaceables: [],
rollovers: [],
})) as ResetCusEnt[];
allResults.push(...mappedData);
offset += batchSize;
hasMore = data.length === batchSize;
console.log(`Fetched ${allResults.length} entitlements to reset`);
}
}
return allResults;
}
static async update({
db,
id,

View File

@@ -7,7 +7,6 @@ import {
entIntvToResetIntv,
type Feature,
type FullCusEntWithFullCusProduct,
type FullCusEntWithOptionalProduct,
getRolloverFields,
isContUseFeature,
notNullish,
@@ -17,7 +16,7 @@ import {
export const cusEntsToNextResetAt = ({
cusEnts,
}: {
cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[];
cusEnts: FullCusEntWithFullCusProduct[];
}) => {
const result = cusEnts.reduce((acc, curr) => {
if (curr.next_reset_at && curr.next_reset_at < acc) {
@@ -35,7 +34,7 @@ export const cusEntsToReset = ({
cusEnts,
feature,
}: {
cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[];
cusEnts: FullCusEntWithFullCusProduct[];
feature: Feature;
}): ApiBalanceReset | null => {
// 1. If feature is allocated, null
@@ -68,7 +67,7 @@ export const cusEntsToRollovers = ({
cusEnts,
entityId,
}: {
cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[];
cusEnts: FullCusEntWithFullCusProduct[];
entityId?: string;
}): ApiBalanceRollover[] | undefined => {
// If all cus ents no rollover, return undefined
@@ -95,7 +94,7 @@ export const getBooleanApiBalance = ({
cusEnts,
apiFeature,
}: {
cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[];
cusEnts: FullCusEntWithFullCusProduct[];
apiFeature?: ApiFeatureV1;
}): ApiBalance => {
const feature = cusEnts[0].entitlement.feature;
@@ -141,7 +140,7 @@ export const getUnlimitedApiBalance = ({
cusEnts,
}: {
apiFeature?: ApiFeatureV1;
cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[];
cusEnts: FullCusEntWithFullCusProduct[];
}): ApiBalance => {
const feature = cusEnts[0].entitlement.feature;
const planId = cusEntsToPlanId({ cusEnts });

View File

@@ -1,7 +1,6 @@
import type {
ApiBalance,
FullCusEntWithFullCusProduct,
FullCusEntWithOptionalProduct,
FullCustomer,
} from "@autumn/shared";
import {
@@ -42,19 +41,14 @@ const cusEntsToBreakdown = ({
cusEnts,
}: {
ctx: RequestContext;
cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[];
cusEnts: (FullCusEntWithFullCusProduct)[];
fullCus: FullCustomer;
}):
| {
key: string;
breakdown: ApiBalanceBreakdown;
prepaidQuantity: number;
}[]
| undefined => {
const keyToCusEnts: Record<
string,
(FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[]
> = {};
}): {
key: string;
breakdown: ApiBalanceBreakdown;
prepaidQuantity: number;
}[] => {
const keyToCusEnts: Record<string, FullCusEntWithFullCusProduct[]> = {};
for (const cusEnt of cusEnts) {
const key = cusEntToKey({ cusEnt });
keyToCusEnts[key] = [...(keyToCusEnts[key] || []), cusEnt];
@@ -81,8 +75,8 @@ const cusEntsToBreakdown = ({
includeBreakdown: false,
});
const prepaidQuantity = cusEntsToPrepaidQuantity({ cusEnts, feature });
const planId = cusEnts[0].customer_product?.product.id ?? null;
const prepaidQuantity = cusEntsToPrepaidQuantity({ cusEnts });
const planId = cusEntsToPlanId({ cusEnts });
breakdown.push({
key,
@@ -119,7 +113,7 @@ export const getApiBalance = ({
}: {
ctx: RequestContext;
fullCus: FullCustomer;
cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[];
cusEnts: (FullCusEntWithFullCusProduct)[];
feature: Feature;
includeRollovers?: boolean;
includeBreakdown?: boolean;

View File

@@ -1,7 +1,7 @@
import {
type ApiBalance,
type CusFeatureLegacyData,
type FullCusEntWithOptionalProduct,
type FullCusEntWithFullCusProduct,
type FullCustomer,
fullCustomerToCustomerEntitlements,
orgToInStatuses,
@@ -19,14 +19,19 @@ export const getApiBalances = async ({
}) => {
const { org } = ctx;
const cusEntsWithCusProduct = fullCustomerToCustomerEntitlements({
const allCusEntsFromFullCustomer = fullCustomerToCustomerEntitlements({
fullCustomer: fullCus,
inStatuses: orgToInStatuses({ org }),
entity: fullCus.entity,
});
// Filter out loose entitlements (customer_product is null) - they come from extra_customer_entitlements
const cusEntsWithCusProduct = allCusEntsFromFullCustomer.filter(
(ent) => ent.customer_product !== null,
);
// Add extra entitlements (loose entitlements not tied to a product)
const extraEnts: FullCusEntWithOptionalProduct[] = (
const extraEnts: FullCusEntWithFullCusProduct[] = (
fullCus.extra_customer_entitlements || []
).map((ent) => ({
...ent,
@@ -34,12 +39,12 @@ export const getApiBalances = async ({
}));
// Combine both sources
const allCusEnts: FullCusEntWithOptionalProduct[] = [
const allCusEnts: FullCusEntWithFullCusProduct[] = [
...cusEntsWithCusProduct,
...extraEnts,
];
const featureToCusEnt: Record<string, FullCusEntWithOptionalProduct[]> = {};
const featureToCusEnt: Record<string, FullCusEntWithFullCusProduct[]> = {};
for (const cusEnt of allCusEnts) {
const featureId = cusEnt.entitlement.feature.id;
featureToCusEnt[featureId] = [

View File

@@ -1,21 +1,21 @@
import type {
AppEnv,
CusProductStatus,
ListCustomersV2Params,
AppEnv,
CusProductStatus,
ListCustomersV2Params,
} from "@autumn/shared";
import { type SQL, sql } from "drizzle-orm";
const buildOptimizedCusProductsCTE = (inStatuses?: CusProductStatus[]) => {
const withStatusFilter = () => {
return inStatuses
? sql`AND cp.status = ANY(ARRAY[${sql.join(
inStatuses.map((status) => sql`${status}`),
sql`, `,
)}])`
: sql``;
};
const withStatusFilter = () => {
return inStatuses
? sql`AND cp.status = ANY(ARRAY[${sql.join(
inStatuses.map((status) => sql`${status}`),
sql`, `,
)}])`
: sql``;
};
return sql`
return sql`
customer_products_with_prices AS (
SELECT
cp.*,
@@ -83,11 +83,11 @@ const buildOptimizedCusProductsCTE = (inStatuses?: CusProductStatus[]) => {
};
const buildEntitiesCTE = (withEntities: boolean) => {
if (!withEntities) {
return sql``;
}
if (!withEntities) {
return sql``;
}
return sql`
return sql`
customer_entities AS (
SELECT
COALESCE(
@@ -105,11 +105,11 @@ const buildEntitiesCTE = (withEntities: boolean) => {
};
const buildEntityCTE = (entityId?: string) => {
if (!entityId) {
return sql``;
}
if (!entityId) {
return sql``;
}
return sql`
return sql`
entity_record AS (
SELECT * FROM entities e
WHERE e.internal_customer_id = (SELECT internal_id FROM customer_record)
@@ -122,15 +122,15 @@ const buildEntityCTE = (entityId?: string) => {
};
const buildTrialsUsedCTE = (
withTrialsUsed: boolean,
orgId: string,
env: AppEnv,
withTrialsUsed: boolean,
orgId: string,
env: AppEnv,
) => {
if (!withTrialsUsed) {
return sql``;
}
if (!withTrialsUsed) {
return sql``;
}
return sql`
return sql`
customer_trials_used AS (
SELECT
COALESCE(
@@ -153,14 +153,14 @@ const buildTrialsUsedCTE = (
};
const buildSubscriptionsCTE = (
withSubs: boolean,
inStatuses?: CusProductStatus[],
withSubs: boolean,
inStatuses?: CusProductStatus[],
) => {
if (!withSubs) {
return sql``;
}
if (!withSubs) {
return sql``;
}
return sql`
return sql`
customer_subscriptions AS (
SELECT
COALESCE(
@@ -177,7 +177,7 @@ const buildSubscriptionsCTE = (
};
const buildExtraEntitlementsCTE = () => {
return sql`
return sql`
extra_customer_entitlements AS (
SELECT
COALESCE(
@@ -221,15 +221,15 @@ const buildExtraEntitlementsCTE = () => {
};
const buildInvoicesCTE = (hasEntityCTE: boolean) => {
const entityFilter = hasEntityCTE
? sql`AND (
const entityFilter = hasEntityCTE
? sql`AND (
NOT EXISTS (SELECT 1 FROM entity_record)
OR i.internal_entity_id = (SELECT internal_id FROM entity_record LIMIT 1)
OR i.internal_entity_id IS NULL
)`
: sql``;
: sql``;
return sql`
return sql`
customer_invoices AS (
SELECT
COALESCE(
@@ -245,21 +245,21 @@ const buildInvoicesCTE = (hasEntityCTE: boolean) => {
};
export const getFullCusQuery = (
idOrInternalId: string,
orgId: string,
env: AppEnv,
inStatuses: CusProductStatus[],
includeInvoices: boolean,
withEntities: boolean,
withTrialsUsed: boolean,
withSubs: boolean,
withEvents: boolean,
entityId?: string,
idOrInternalId: string,
orgId: string,
env: AppEnv,
inStatuses: CusProductStatus[],
includeInvoices: boolean,
withEntities: boolean,
withTrialsUsed: boolean,
withSubs: boolean,
withEvents: boolean,
entityId?: string,
) => {
const sqlChunks: SQL[] = [];
const sqlChunks: SQL[] = [];
// Step 1: Get customer record
sqlChunks.push(sql`
// Step 1: Get customer record
sqlChunks.push(sql`
WITH customer_record AS (
SELECT * FROM customers c
WHERE (
@@ -272,49 +272,49 @@ export const getFullCusQuery = (
)
`);
// Step 2: Get entities
if (withEntities) {
sqlChunks.push(sql`, `);
sqlChunks.push(buildEntitiesCTE(withEntities));
}
// Step 2: Get entities
if (withEntities) {
sqlChunks.push(sql`, `);
sqlChunks.push(buildEntitiesCTE(withEntities));
}
// Step 3: Get entity
if (entityId) {
sqlChunks.push(sql`, `);
sqlChunks.push(buildEntityCTE(entityId));
}
// Step 3: Get entity
if (entityId) {
sqlChunks.push(sql`, `);
sqlChunks.push(buildEntityCTE(entityId));
}
// Add customer products CTE
sqlChunks.push(sql`, `);
// sqlChunks.push(buildCusProductsCTE(inStatuses));
sqlChunks.push(buildOptimizedCusProductsCTE(inStatuses));
// Add customer products CTE
sqlChunks.push(sql`, `);
// sqlChunks.push(buildCusProductsCTE(inStatuses));
sqlChunks.push(buildOptimizedCusProductsCTE(inStatuses));
// Conditionally add trials used CTE
if (withTrialsUsed) {
sqlChunks.push(sql`, `);
sqlChunks.push(buildTrialsUsedCTE(withTrialsUsed, orgId, env));
}
// Conditionally add trials used CTE
if (withTrialsUsed) {
sqlChunks.push(sql`, `);
sqlChunks.push(buildTrialsUsedCTE(withTrialsUsed, orgId, env));
}
// Conditionally add subscriptions CTE
if (withSubs) {
sqlChunks.push(sql`, `);
sqlChunks.push(buildSubscriptionsCTE(withSubs, inStatuses));
}
// Conditionally add subscriptions CTE
if (withSubs) {
sqlChunks.push(sql`, `);
sqlChunks.push(buildSubscriptionsCTE(withSubs, inStatuses));
}
// Conditionally add extra entitlements CTE
sqlChunks.push(sql`, `);
sqlChunks.push(buildExtraEntitlementsCTE());
// Conditionally add extra entitlements CTE
sqlChunks.push(sql`, `);
sqlChunks.push(buildExtraEntitlementsCTE());
// Conditionally add invoices CTE
if (includeInvoices) {
sqlChunks.push(sql`, `);
sqlChunks.push(buildInvoicesCTE(!!entityId));
}
// Conditionally add invoices CTE
if (includeInvoices) {
sqlChunks.push(sql`, `);
sqlChunks.push(buildInvoicesCTE(!!entityId));
}
// Conditionally add events CTE
if (withEvents) {
sqlChunks.push(sql`, `);
sqlChunks.push(sql`
// Conditionally add events CTE
if (withEvents) {
sqlChunks.push(sql`, `);
sqlChunks.push(sql`
customer_events AS (
SELECT
COALESCE(
@@ -335,11 +335,11 @@ export const getFullCusQuery = (
AND e.set_usage = false
)
`);
}
}
// Build final SELECT
const selectFieldsChunks: SQL[] = [];
selectFieldsChunks.push(sql`
// Build final SELECT
const selectFieldsChunks: SQL[] = [];
selectFieldsChunks.push(sql`
cr.*,
COALESCE(
(SELECT json_agg(cpwp) FROM customer_products_with_prices cpwp),
@@ -347,157 +347,155 @@ export const getFullCusQuery = (
) AS customer_products
`);
// Add entities to SELECT if withEntities is true
if (withEntities) {
selectFieldsChunks.push(sql`,
// Add entities to SELECT if withEntities is true
if (withEntities) {
selectFieldsChunks.push(sql`,
(SELECT entities FROM customer_entities) AS entities`);
}
}
// Add entity to SELECT if entityId is provided
if (entityId) {
selectFieldsChunks.push(sql`,
// Add entity to SELECT if entityId is provided
if (entityId) {
selectFieldsChunks.push(sql`,
(SELECT row_to_json(er) FROM entity_record er LIMIT 1) AS entity`);
}
}
// Add trials used to SELECT if withTrialsUsed is true
if (withTrialsUsed) {
selectFieldsChunks.push(sql`,
// Add trials used to SELECT if withTrialsUsed is true
if (withTrialsUsed) {
selectFieldsChunks.push(sql`,
(SELECT trials_used FROM customer_trials_used) AS trials_used`);
}
}
// Add subscriptions to SELECT if withSubs is true
if (withSubs) {
selectFieldsChunks.push(sql`,
// Add subscriptions to SELECT if withSubs is true
if (withSubs) {
selectFieldsChunks.push(sql`,
(SELECT subscriptions FROM customer_subscriptions) AS subscriptions`);
}
}
// Add extra entitlements to SELECT if withExtraEntitlements is true
selectFieldsChunks.push(sql`,
selectFieldsChunks.push(sql`,
(SELECT extra_customer_entitlements FROM extra_customer_entitlements) AS extra_customer_entitlements`);
if (includeInvoices) {
selectFieldsChunks.push(sql`,
if (includeInvoices) {
selectFieldsChunks.push(sql`,
(SELECT invoices FROM customer_invoices) AS invoices`);
}
}
if (withEvents) {
selectFieldsChunks.push(sql`,
if (withEvents) {
selectFieldsChunks.push(sql`,
(SELECT events FROM customer_events) AS events`);
}
}
sqlChunks.push(sql`
sqlChunks.push(sql`
SELECT ${sql.join(selectFieldsChunks, sql``)}
FROM customer_record cr
`);
return sql.join(sqlChunks, sql``);
return sql.join(sqlChunks, sql``);
};
export const getPaginatedFullCusQuery = ({
orgId,
env,
inStatuses,
includeInvoices,
withEntities,
withTrialsUsed,
withSubs,
limit = 10,
offset = 0,
withEvents = false,
entityId,
internalCustomerIds,
plans,
search,
orgId,
env,
inStatuses,
includeInvoices,
withEntities,
withTrialsUsed,
withSubs,
limit = 10,
offset = 0,
withEvents = false,
entityId,
internalCustomerIds,
plans,
search,
}: {
orgId: string;
env: AppEnv;
inStatuses?: CusProductStatus[];
includeInvoices: boolean;
withEntities: boolean;
withTrialsUsed: boolean;
withSubs: boolean;
limit: number;
offset: number;
withEvents?: boolean;
entityId?: string;
internalCustomerIds?: string[];
plans?: ListCustomersV2Params["plans"];
search?: string;
orgId: string;
env: AppEnv;
inStatuses?: CusProductStatus[];
includeInvoices: boolean;
withEntities: boolean;
withTrialsUsed: boolean;
withSubs: boolean;
limit: number;
offset: number;
withEvents?: boolean;
entityId?: string;
internalCustomerIds?: string[];
plans?: ListCustomersV2Params["plans"];
search?: string;
}) => {
const withStatusFilter = () => {
return inStatuses?.length
? sql`AND cp.status = ANY(ARRAY[${sql.join(
inStatuses.map((status) => sql`${status}`),
sql`, `,
)}])`
: sql``;
};
const withStatusFilter = () => {
return inStatuses?.length
? sql`AND cp.status = ANY(ARRAY[${sql.join(
inStatuses.map((status) => sql`${status}`),
sql`, `,
)}])`
: sql``;
};
const withCustomerProductFilter = () => {
const hasStatusFilter = inStatuses && inStatuses.length > 0;
const hasPlansFilter = plans && plans.length > 0;
const withCustomerProductFilter = () => {
const hasStatusFilter = inStatuses && inStatuses.length > 0;
const hasPlansFilter = plans && plans.length > 0;
if (!hasStatusFilter && !hasPlansFilter) return sql``;
if (!hasStatusFilter && !hasPlansFilter) return sql``;
const conditions: SQL[] = [];
const conditions: SQL[] = [];
if (hasStatusFilter) {
conditions.push(
sql`cp_filter.status = ANY(ARRAY[${sql.join(
inStatuses.map((s) => sql`${s}`),
sql`, `,
)}])`,
);
}
if (hasStatusFilter) {
conditions.push(
sql`cp_filter.status = ANY(ARRAY[${sql.join(
inStatuses.map((s) => sql`${s}`),
sql`, `,
)}])`,
);
}
if (hasPlansFilter) {
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((v) => sql`${v}`),
sql`, `,
)}))`;
}
return sql`p_filter.id = ${plan.id}`;
});
if (hasPlansFilter) {
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((v) => sql`${v}`),
sql`, `,
)}))`;
}
return sql`p_filter.id = ${plan.id}`;
});
conditions.push(sql`(${sql.join(planConditions, sql` OR `)})`);
}
conditions.push(sql`(${sql.join(planConditions, sql` OR `)})`);
}
const needsProductJoin = hasPlansFilter;
const needsProductJoin = hasPlansFilter;
return sql`AND EXISTS (
return sql`AND EXISTS (
SELECT 1 FROM customer_products cp_filter
${needsProductJoin ? sql`JOIN products p_filter ON cp_filter.internal_product_id = p_filter.internal_id` : sql``}
WHERE cp_filter.internal_customer_id = c.internal_id
AND ${sql.join(conditions, sql` AND `)}
)`;
};
};
const withSearchFilter = () => {
if (!search) return sql``;
const pattern = `%${search}%`;
return sql`AND (
const withSearchFilter = () => {
if (!search) return sql``;
const pattern = `%${search}%`;
return sql`AND (
c.id ILIKE ${pattern}
OR c.name ILIKE ${pattern}
OR c.email ILIKE ${pattern}
)`;
};
};
return sql`
return sql`
WITH customer_records AS (
SELECT c.*
FROM customers c
WHERE c.org_id = ${orgId}
AND c.env = ${env}
${
internalCustomerIds && internalCustomerIds.length > 0
? sql`AND c.internal_id IN (${sql.join(
internalCustomerIds.map((id) => sql`${id}`),
sql`, `,
)})`
: sql``
}
${internalCustomerIds && internalCustomerIds.length > 0
? sql`AND c.internal_id IN (${sql.join(
internalCustomerIds.map((id) => sql`${id}`),
sql`, `,
)})`
: sql``
}
${withCustomerProductFilter()}
${withSearchFilter()}
ORDER BY c.created_at DESC
@@ -576,9 +574,8 @@ export const getPaginatedFullCusQuery = ({
GROUP BY cpwp.internal_customer_id
)
${
withSubs
? sql`, customer_subscriptions AS (
${withSubs
? sql`, customer_subscriptions AS (
SELECT
cpwp.internal_customer_id,
COALESCE(
@@ -589,12 +586,11 @@ export const getPaginatedFullCusQuery = ({
JOIN subscriptions s ON s.stripe_id = ANY(cpwp.subscription_ids)
GROUP BY cpwp.internal_customer_id
)`
: sql``
}
: sql``
}
${
withEntities
? sql`, customer_entities AS (
${withEntities
? sql`, customer_entities AS (
SELECT
e.internal_customer_id,
COALESCE(
@@ -605,12 +601,11 @@ export const getPaginatedFullCusQuery = ({
WHERE e.internal_customer_id IN (SELECT internal_id FROM customer_records)
GROUP BY e.internal_customer_id
)`
: sql``
}
: sql``
}
${
includeInvoices
? sql`, customer_invoices AS (
${includeInvoices
? sql`, customer_invoices AS (
SELECT
i.internal_customer_id,
COALESCE(
@@ -621,12 +616,11 @@ export const getPaginatedFullCusQuery = ({
WHERE i.internal_customer_id IN (SELECT internal_id FROM customer_records)
GROUP BY i.internal_customer_id
)`
: sql``
}
: sql``
}
${
withTrialsUsed
? sql`, customer_trials_used AS (
${withTrialsUsed
? sql`, customer_trials_used AS (
SELECT
cp.internal_customer_id,
json_agg(json_build_object(
@@ -641,8 +635,8 @@ export const getPaginatedFullCusQuery = ({
AND cp.free_trial_id IS NOT NULL
GROUP BY cp.internal_customer_id
)`
: sql``
}
: sql``
}
SELECT
cr.*,

View File

@@ -50,7 +50,7 @@ describe(`${chalk.yellowBright("check-loose3: mixed product + loose entitlement"
await autumnV1.balances.create({
customer_id: customerId,
feature_id: TestFeature.Messages,
granted_balance: "500",
granted_balance: 500,
});
});

View File

@@ -1,5 +1,5 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion, type CheckResponseV2, EntInterval, ResetInterval } from "@autumn/shared";
import { ApiVersion, type CheckResponseV2, ResetInterval } from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
@@ -44,9 +44,9 @@ describe(`${chalk.yellowBright("check-loose4: loose entitlement with reset inter
await autumnV1.balances.create({
customer_id: customerId,
feature_id: TestFeature.Action1,
granted_balance: "1000",
granted_balance: 1000,
reset: {
interval: EntInterval.Month,
interval: ResetInterval.Month,
interval_count: 1,
},
});

View File

@@ -0,0 +1,53 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion, type CheckResponseV2 } from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
describe(`${chalk.yellowBright("loose-basic: basic track with loose entitlement")}`, () => {
const customerId = "loose-basic";
const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 });
const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 });
beforeAll(async () => {
await initCustomerV3({
ctx,
customerId,
withTestClock: false,
});
// Create loose entitlement with 100 messages
await autumnV1.balances.create({
customer_id: customerId,
feature_id: TestFeature.Messages,
granted_balance: 100,
});
});
test("should deduct from loose entitlement", async () => {
// Track 10 usage
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 10,
});
// Wait for sync
await new Promise((resolve) => setTimeout(resolve, 2000));
// Check balance
const res = (await autumnV2.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
})) as unknown as CheckResponseV2;
expect(res.allowed).toBe(true);
expect(res.balance).toBeDefined();
expect(res.balance?.plan_id).toBeNull();
expect(res.balance?.granted_balance).toBe(100);
expect(res.balance?.current_balance).toBe(90);
expect(res.balance?.usage).toBe(10);
});
});

View File

@@ -0,0 +1,49 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion, type CheckResponseV2 } from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
describe(`${chalk.yellowBright("loose-exact: track exact balance amount")}`, () => {
const customerId = "loose-exact";
const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 });
const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 });
beforeAll(async () => {
await initCustomerV3({
ctx,
customerId,
withTestClock: false,
});
// Create loose entitlement with 50 messages
await autumnV1.balances.create({
customer_id: customerId,
feature_id: TestFeature.Messages,
granted_balance: 50,
});
});
test("should deduct exact balance amount leaving 0", async () => {
// Track exactly 50 (all balance)
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 50,
});
await new Promise((resolve) => setTimeout(resolve, 2000));
const res = (await autumnV2.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
})) as unknown as CheckResponseV2;
expect(res.allowed).toBe(false); // No balance left
expect(res.balance?.granted_balance).toBe(50);
expect(res.balance?.current_balance).toBe(0);
expect(res.balance?.usage).toBe(50);
});
});

View File

@@ -0,0 +1,64 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion, type CheckResponseV2 } from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
describe(`${chalk.yellowBright("loose-incremental: multiple tracks accumulate")}`, () => {
const customerId = "loose-incremental";
const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 });
const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 });
beforeAll(async () => {
await initCustomerV3({
ctx,
customerId,
withTestClock: false,
});
// Create loose entitlement with 100 messages
await autumnV1.balances.create({
customer_id: customerId,
feature_id: TestFeature.Messages,
granted_balance: 100,
});
});
test("should deduct with first track", async () => {
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 20,
});
await new Promise((resolve) => setTimeout(resolve, 2000));
const res = (await autumnV2.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
})) as unknown as CheckResponseV2;
expect(res.balance?.current_balance).toBe(80); // 100 - 20
expect(res.balance?.usage).toBe(20);
});
test("should accumulate with second track", async () => {
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 30,
});
await new Promise((resolve) => setTimeout(resolve, 2000));
const res = (await autumnV2.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
})) as unknown as CheckResponseV2;
expect(res.balance?.current_balance).toBe(50); // 80 - 30
expect(res.balance?.usage).toBe(50); // 20 + 30
});
});

View File

@@ -0,0 +1,65 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion, type CheckResponseV2 } from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
describe(`${chalk.yellowBright("loose-mixed: multiple loose ents for same feature")}`, () => {
const customerId = "loose-mixed";
const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 });
const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 });
beforeAll(async () => {
await initCustomerV3({
ctx,
customerId,
withTestClock: false,
});
// Create first loose entitlement
await autumnV1.balances.create({
customer_id: customerId,
feature_id: TestFeature.Messages,
granted_balance: 100,
});
// Create second loose entitlement for same feature
await autumnV1.balances.create({
customer_id: customerId,
feature_id: TestFeature.Messages,
granted_balance: 50,
});
});
test("should combine multiple loose ents in balance", async () => {
const res = (await autumnV2.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
})) as unknown as CheckResponseV2;
expect(res.allowed).toBe(true);
expect(res.balance?.granted_balance).toBe(150); // 100 + 50
expect(res.balance?.current_balance).toBe(150);
});
test("should deduct across multiple loose ents", async () => {
// Track 120 (needs both ents)
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 120,
});
await new Promise((resolve) => setTimeout(resolve, 2000));
const res = (await autumnV2.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
})) as unknown as CheckResponseV2;
expect(res.balance?.current_balance).toBe(30); // 150 - 120
expect(res.balance?.usage).toBe(120);
});
});

View File

@@ -0,0 +1,48 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion, type CheckResponseV2 } from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
describe(`${chalk.yellowBright("loose-overage: track more than balance caps at 0")}`, () => {
const customerId = "loose-overage";
const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 });
const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 });
beforeAll(async () => {
await initCustomerV3({
ctx,
customerId,
withTestClock: false,
});
// Create loose entitlement with 20 messages
await autumnV1.balances.create({
customer_id: customerId,
feature_id: TestFeature.Messages,
granted_balance: 20,
});
});
test("should cap at 0 when tracking more than balance (no overage charge)", async () => {
// Track 50 (more than 20 balance) - should cap at 0, not go negative
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 50,
});
await new Promise((resolve) => setTimeout(resolve, 2000));
const res = (await autumnV2.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
})) as unknown as CheckResponseV2;
// Loose entitlements cap at 0 - no negative balance, no overage charge
expect(res.balance?.current_balance).toBe(0);
expect(res.balance?.usage).toBe(20); // Only deducted what was available
});
});

View File

@@ -0,0 +1,110 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion, type CheckResponseV2 } from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
const testCase = "loose-product-first";
const messagesFeature = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 10,
});
const testProduct = constructProduct({
type: "free",
isDefault: false,
items: [messagesFeature],
});
describe(`${chalk.yellowBright("loose-product-first: deducts from product before loose entitlement")}`, () => {
const customerId = testCase;
const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 });
const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 });
beforeAll(async () => {
await initCustomerV3({
ctx,
customerId,
withTestClock: false,
});
// Create product with 10 messages
await initProductsV0({
ctx,
products: [testProduct],
prefix: testCase,
});
// Attach product to customer
await autumnV1.attach({
customer_id: customerId,
product_id: testProduct.id,
});
// Wait for product attachment
await new Promise((resolve) => setTimeout(resolve, 1000));
// Create loose entitlement with 50 messages (created AFTER product, so deducted second)
await autumnV1.balances.create({
customer_id: customerId,
feature_id: TestFeature.Messages,
granted_balance: 50,
});
});
test("should have combined balance of 60", async () => {
const res = (await autumnV2.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
})) as unknown as CheckResponseV2;
expect(res.allowed).toBe(true);
expect(res.balance?.current_balance).toBe(60); // 10 from product + 50 from loose
});
test("should deduct from product first, then loose entitlement", async () => {
// Track 15 messages (should use all 10 from product, then 5 from loose)
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 15,
});
await new Promise((resolve) => setTimeout(resolve, 2000));
const res = (await autumnV2.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
})) as unknown as CheckResponseV2;
expect(res.allowed).toBe(true);
expect(res.balance?.current_balance).toBe(45); // 60 - 15
expect(res.balance?.usage).toBe(15);
});
test("should continue deducting from loose after product exhausted", async () => {
// Track 30 more messages (all from loose since product is exhausted)
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 30,
});
await new Promise((resolve) => setTimeout(resolve, 2000));
const res = (await autumnV2.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
})) as unknown as CheckResponseV2;
expect(res.allowed).toBe(true);
expect(res.balance?.current_balance).toBe(15); // 45 - 30
expect(res.balance?.usage).toBe(45); // 15 + 30
});
});

View File

@@ -0,0 +1,47 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion, type CheckResponseV2 } from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
describe(`${chalk.yellowBright("loose-unlimited: unlimited loose entitlement")}`, () => {
const customerId = "loose-unlimited";
const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 });
const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 });
beforeAll(async () => {
await initCustomerV3({
ctx,
customerId,
withTestClock: false,
});
// Create unlimited loose entitlement
await autumnV1.balances.create({
customer_id: customerId,
feature_id: TestFeature.Messages,
unlimited: true,
});
});
test("should allow any track amount with unlimited", async () => {
// Track large amount
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 999999,
});
await new Promise((resolve) => setTimeout(resolve, 2000));
const res = (await autumnV2.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
})) as unknown as CheckResponseV2;
expect(res.allowed).toBe(true);
expect(res.balance?.unlimited).toBe(true);
});
});

View File

@@ -0,0 +1,66 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion, type CheckResponseV2 } from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
describe(`${chalk.yellowBright("loose-zero: track when balance is zero")}`, () => {
const customerId = "loose-zero";
const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 });
const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 });
beforeAll(async () => {
await initCustomerV3({
ctx,
customerId,
withTestClock: false,
});
// Create loose entitlement with 10 balance, then use it all
await autumnV1.balances.create({
customer_id: customerId,
feature_id: TestFeature.Messages,
granted_balance: 10,
});
// Use all balance
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 10,
});
await new Promise((resolve) => setTimeout(resolve, 2000));
});
test("should have no effect when balance is already zero", async () => {
// Verify balance is 0
let res = (await autumnV2.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
})) as unknown as CheckResponseV2;
expect(res.balance?.current_balance).toBe(0);
expect(res.balance?.usage).toBe(10);
// Try to track more - should succeed but have no effect (already at 0)
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 10,
});
await new Promise((resolve) => setTimeout(resolve, 2000));
res = (await autumnV2.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
})) as unknown as CheckResponseV2;
// Still at 0, usage unchanged (capped)
expect(res.balance?.current_balance).toBe(0);
expect(res.balance?.usage).toBe(10);
});
});

View File

@@ -1,4 +1,4 @@
import { ResetInterval } from "@autumn/shared";
import { FeatureSchema, FeatureType, ResetInterval } from "@autumn/shared";
import { z } from "zod/v4";
export const CreateBalanceSchema = z.object({
@@ -14,4 +14,32 @@ export const CreateBalanceSchema = z.object({
customer_id: z.string(),
});
export const ValidateCreateBalanceParamsSchema = CreateBalanceSchema.extend({
feature: FeatureSchema,
}).refine((data) => {
if (!data.feature) {
return false;
}
if (data.feature.type === FeatureType.Boolean) {
if (data.granted_balance || data.unlimited || data.reset?.interval) {
return false;
}
}
if (data.feature.type === FeatureType.Metered) {
if (!data.granted_balance && !data.unlimited) {
return false;
}
if (data.granted_balance && data.unlimited) {
return false;
}
if (data.unlimited && data.reset?.interval) {
return false;
}
}
return true;
});
export type CreateBalanceParams = z.infer<typeof CreateBalanceSchema>;

View File

@@ -13,15 +13,7 @@ export const FullCusEntWithFullCusProductSchema =
customer_product: FullCusProductSchema.nullable(),
});
export const FullCusEntWithOptionalProductSchema =
FullCustomerEntitlementSchema.extend({
customer_product: FullCusProductSchema.nullable(),
});
export type FullCusEntWithProduct = z.infer<typeof FullCusEntWithProductSchema>;
export type FullCusEntWithFullCusProduct = z.infer<
typeof FullCusEntWithFullCusProductSchema
>;
export type FullCusEntWithOptionalProduct = z.infer<
typeof FullCusEntWithOptionalProductSchema
>;

View File

@@ -7,5 +7,5 @@ import {
export type ResetCusEnt = FullCustomerEntitlement & {
customer: Customer;
customer_product: CusProduct;
customer_product: CusProduct | null;
};

View File

@@ -1,5 +1,5 @@
import { Decimal } from "decimal.js";
import type { FullCusEntWithFullCusProduct, FullCusEntWithOptionalProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct.js";
import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct.js";
import { BillingType } from "../../../models/productModels/priceModels/priceEnums.js";
import {
cusEntToCusPrice,
@@ -13,12 +13,12 @@ export const cusEntToPurchasedBalance = ({
cusEnt,
entityId,
}: {
cusEnt: FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct;
cusEnt: FullCusEntWithFullCusProduct;
entityId?: string;
}) => {
// return 0;
// 1. If prepaid
const cusPrice = cusEntToCusPrice({ cusEnt });
if (!cusEnt.customer_product) return 0;
if (nullish(cusPrice)) {
const { balance } = getCusEntBalance({
cusEnt,

View File

@@ -1,4 +1,4 @@
import type { FullCusEntWithFullCusProduct, FullCusEntWithOptionalProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct";
import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct";
import { sumValues } from "../../utils";
import { cusEntToBalance } from "../convertCusEntUtils";
@@ -7,7 +7,7 @@ export const cusEntsToBalance = ({
entityId,
withRollovers = false,
}: {
cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[];
cusEnts: FullCusEntWithFullCusProduct[];
entityId?: string;
withRollovers?: boolean;
}) => {

View File

@@ -1,4 +1,4 @@
import type { FullCusEntWithFullCusProduct, FullCusEntWithOptionalProduct } from "../../../../models/cusProductModels/cusEntModels/cusEntWithProduct";
import type { FullCusEntWithFullCusProduct } from "../../../../models/cusProductModels/cusEntModels/cusEntWithProduct";
import { sumValues } from "../../../utils";
import { getCusEntBalance } from "../../balanceUtils";
@@ -6,7 +6,7 @@ export const cusEntsToAdjustment = ({
cusEnts,
entityId,
}: {
cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[];
cusEnts: FullCusEntWithFullCusProduct[];
entityId?: string;
}) => {
return sumValues(

View File

@@ -1,5 +1,5 @@
import { Decimal } from "decimal.js";
import type { FullCusEntWithFullCusProduct, FullCusEntWithOptionalProduct } from "../../../../models/cusProductModels/cusEntModels/cusEntWithProduct";
import type { FullCusEntWithFullCusProduct } from "../../../../models/cusProductModels/cusEntModels/cusEntWithProduct";
import { sumValues } from "../../../utils";
import { getCusEntBalance } from "../../balanceUtils";
import { getRolloverFields } from "../../getRolloverFields";
@@ -10,7 +10,7 @@ export const cusEntsToAllowance = ({
entityId,
withRollovers = false,
}: {
cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[];
cusEnts: FullCusEntWithFullCusProduct[];
entityId?: string;
withRollovers?: boolean;
}) => {
@@ -19,7 +19,7 @@ export const cusEntsToAllowance = ({
entityId,
withRollovers = false,
}: {
cusEnt: FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct;
cusEnt: FullCusEntWithFullCusProduct;
entityId?: string;
withRollovers?: boolean;
}) => {

View File

@@ -1,10 +1,7 @@
import { Decimal } from "decimal.js";
import type { ApiBalanceBreakdown } from "../../api/customers/cusFeatures/apiBalance.js";
import type { FullCustomerEntitlement } from "../../models/cusProductModels/cusEntModels/cusEntModels.js";
import type {
FullCusEntWithFullCusProduct,
FullCusEntWithOptionalProduct,
} from "../../models/cusProductModels/cusEntModels/cusEntWithProduct.js";
import type { FullCusEntWithFullCusProduct } from "../../models/cusProductModels/cusEntModels/cusEntWithProduct.js";
import { resetIntvToEntIntv } from "../planFeatureUtils/planFeatureIntervals.js";
import {
cusEntToCusPrice,
@@ -17,7 +14,7 @@ import { getStartingBalance } from "./getStartingBalance.js";
export const cusEntToKey = ({
cusEnt,
}: {
cusEnt: FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct;
cusEnt: FullCusEntWithFullCusProduct;
}) => {
// Interval
const interval = `${cusEnt.entitlement.interval_count ?? 1}:${cusEnt.entitlement.interval}`;
@@ -34,7 +31,7 @@ export const cusEntToKey = ({
export const cusEntsToPlanId = ({
cusEnts,
}: {
cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[];
cusEnts: (FullCusEntWithFullCusProduct)[];
}) => {
// Get number of keys
const uniquePlanIds = new Set<string>();
@@ -82,10 +79,12 @@ export const cusEntToIncludedUsage = ({
entityId,
withRollovers = false,
}: {
cusEnt: FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct;
cusEnt: FullCusEntWithFullCusProduct;
entityId?: string;
withRollovers?: boolean;
}) => {
if (!cusEnt.customer_product) return 0;
const rollover = getRolloverFields({
cusEnt,
entityId,

View File

@@ -2,7 +2,6 @@ import { Decimal } from "decimal.js";
import {
cusEntToIncludedUsage,
type FullCusEntWithFullCusProduct,
type FullCusEntWithOptionalProduct,
isPrepaidCusEnt,
notNullish,
nullish,
@@ -12,7 +11,7 @@ export const cusEntsToMaxPurchase = ({
cusEnts,
entityId,
}: {
cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[];
cusEnts: FullCusEntWithFullCusProduct[];
entityId?: string;
}): number | null => {
// 1. If there's usage-based cus ent, return undefined

View File

@@ -1,7 +1,7 @@
import type { FullCustomerEntitlement } from "@models/cusProductModels/cusEntModels/cusEntModels.js";
import type { PgDeductionUpdate } from "../../api/balances/track/trackTypes/pgDeductionUpdate.js";
import type { FullCustomer } from "../../models/cusModels/fullCusModel.js";
import type { FullCusEntWithFullCusProduct, FullCusEntWithOptionalProduct } from "../../models/cusProductModels/cusEntModels/cusEntWithProduct.js";
import type { FullCusEntWithFullCusProduct } from "../../models/cusProductModels/cusEntModels/cusEntWithProduct.js";
import { cusEntToCusPrice } from "../productUtils/convertUtils.js";
import { isPrepaidPrice } from "../productUtils/priceUtils.js";
@@ -104,12 +104,13 @@ export const updateCusEntInFullCus = ({
export const isPrepaidCusEnt = ({
cusEnt,
}: {
cusEnt: FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct;
cusEnt: FullCusEntWithFullCusProduct;
}) => {
// 2. If cus ent is not prepaid, skip
const cusPrice = cusEntToCusPrice({ cusEnt });
if (!cusPrice || !isPrepaidPrice({ price: cusPrice.price })) return false;
if (!cusEnt.customer_product) return false;
// 3. Get quantity
const options = cusEnt.customer_product?.options?.find(
(option) =>

View File

@@ -1,4 +1,4 @@
import type { FullCusEntWithFullCusProduct, FullCusEntWithOptionalProduct } from "@models/cusProductModels/cusEntModels/cusEntWithProduct.js";
import type { FullCusEntWithFullCusProduct } from "@models/cusProductModels/cusEntModels/cusEntWithProduct.js";
import type { FullCustomerPrice } from "@models/cusProductModels/cusPriceModels/cusPriceModels.js";
import type { FeatureOptions } from "@models/cusProductModels/cusProductModels.js";
import type {
@@ -74,7 +74,7 @@ export const entToOptions = ({
export const cusEntToCusPrice = ({
cusEnt,
}: {
cusEnt: FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct;
cusEnt: FullCusEntWithFullCusProduct;
}) => {
const cusProduct = cusEnt.customer_product;
const cusPrices = cusProduct?.customer_prices ?? [];