feat: 🎸 boom
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -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 } =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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] = [
|
||||
|
||||
@@ -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.*,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
53
server/tests/balances/track/loose/loose-basic.test.ts
Normal file
53
server/tests/balances/track/loose/loose-basic.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
49
server/tests/balances/track/loose/loose-exact.test.ts
Normal file
49
server/tests/balances/track/loose/loose-exact.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
64
server/tests/balances/track/loose/loose-incremental.test.ts
Normal file
64
server/tests/balances/track/loose/loose-incremental.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
65
server/tests/balances/track/loose/loose-mixed.test.ts
Normal file
65
server/tests/balances/track/loose/loose-mixed.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
110
server/tests/balances/track/loose/loose-product-first.test.ts
Normal file
110
server/tests/balances/track/loose/loose-product-first.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
47
server/tests/balances/track/loose/loose-unlimited.test.ts
Normal file
47
server/tests/balances/track/loose/loose-unlimited.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
66
server/tests/balances/track/loose/loose-zero.test.ts
Normal file
66
server/tests/balances/track/loose/loose-zero.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
|
||||
@@ -7,5 +7,5 @@ import {
|
||||
|
||||
export type ResetCusEnt = FullCustomerEntitlement & {
|
||||
customer: Customer;
|
||||
customer_product: CusProduct;
|
||||
customer_product: CusProduct | null;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}) => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 ?? [];
|
||||
|
||||
Reference in New Issue
Block a user