working on get api cus feature

This commit is contained in:
John Yeo
2025-10-06 14:16:51 +01:00
parent a997947db8
commit feca672fab
84 changed files with 2591 additions and 1984 deletions

View File

@@ -4,6 +4,7 @@ import {
EntInterval,
type FullCusEntWithProduct,
type FullEntitlement,
getStartingBalance,
type Organization,
type ResetCusEnt,
} from "@autumn/shared";
@@ -15,10 +16,7 @@ import { createStripeCli } from "@/external/stripe/utils.js";
import { deleteCusCache } from "@/internal/customers/cusCache/updateCachedCus.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
import {
getRelatedCusPrice,
getResetBalance,
} from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js";
import { getRelatedCusPrice } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js";
import { getRolloverUpdates } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.js";
import { getResetBalancesUpdate } from "@/internal/customers/cusProducts/cusEnts/groupByUtils.js";
import { CusPriceService } from "@/internal/customers/cusProducts/cusPrices/CusPriceService.js";
@@ -61,7 +59,10 @@ const checkSubAnchor = async ({
const org = cusProduct.product.org as Organization;
const stripeCli = createStripeCli({ org, env });
if (!cusProduct.subscription_ids || cusProduct.subscription_ids.length == 0) {
if (
!cusProduct.subscription_ids ||
cusProduct.subscription_ids.length === 0
) {
return nextResetAt;
}
@@ -164,7 +165,7 @@ export const resetCustomerEntitlement = async ({
const ent = cusEnt.entitlement as FullEntitlement;
if (
ent.allowance_type == AllowanceType.Fixed &&
ent.allowance_type === AllowanceType.Fixed &&
shortDurations.includes(ent.interval as EntInterval)
) {
return await handleShortDurationCusEnt({
@@ -232,9 +233,9 @@ export const resetCustomerEntitlement = async ({
return;
}
const resetBalance = getResetBalance({
const resetBalance = getStartingBalance({
entitlement: cusEnt.entitlement,
options: entOptions,
options: entOptions || undefined,
relatedPrice: undefined,
productQuantity: cusEnt.customer_product.quantity,
});

View File

@@ -23,13 +23,13 @@ export const createStripeCli = ({
legacyVersion?: boolean;
}) => {
const encrypted =
env == AppEnv.Sandbox
env === AppEnv.Sandbox
? org.stripe_config?.test_api_key
: org.stripe_config?.live_api_key;
if (!encrypted) {
throw new RecaseError({
message: `Please connect your Stripe ${env == AppEnv.Sandbox ? "test" : "live"} secret key. You can find it here: https://dashboard.stripe.com${env == AppEnv.Sandbox ? "/test" : ""}/apikeys`,
message: `Please connect your Stripe ${env === AppEnv.Sandbox ? "test" : "live"} secret key. You can find it here: https://dashboard.stripe.com${env === AppEnv.Sandbox ? "/test" : ""}/apikeys`,
code: ErrCode.StripeConfigNotFound,
statusCode: 400,
});
@@ -66,8 +66,8 @@ export const calculateMetered1Price = ({
for (let i = 0; i < usageConfig.usage_tiers.length; i++) {
const tier = usageConfig.usage_tiers[i];
let amtUsed;
if (tier.to == -1 || tier.to == Infinite) {
let amtUsed: number;
if (tier.to === -1 || tier.to === Infinite) {
amtUsed = usage;
} else {
amtUsed = Math.min(usage, tier.to);
@@ -89,14 +89,17 @@ export const subToAutumnInterval = (sub: Stripe.Subscription) => {
};
}
if (!recuringItem.price.recurring) {
return {
interval: BillingInterval.OneOff,
intervalCount: 1,
};
}
return {
interval: recuringItem.price.recurring!.interval as BillingInterval,
intervalCount: recuringItem.price.recurring!.interval_count || 1,
interval: recuringItem.price.recurring.interval as BillingInterval,
intervalCount: recuringItem.price.recurring.interval_count || 1,
};
// return stripeToAutumnInterval({
// interval: recuringItem.price.recurring!.interval,
// intervalCount: recuringItem.price.recurring!.interval_count,
// });
};
// export const stripeToAutumnInterval = ({

View File

@@ -1,21 +1,21 @@
import { DrizzleCli } from "@/db/initDrizzle.js";
import {
type Customer,
EntInterval,
type FeatureOptions,
type FullCusProduct,
type FullCustomerPrice,
type UsagePriceConfig,
} from "@autumn/shared";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
import { RolloverService } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.js";
import { getRolloverUpdates } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.js";
import { getResetBalancesUpdate } from "@/internal/customers/cusProducts/cusEnts/groupByUtils.js";
import { getRelatedCusEnt } from "@/internal/customers/cusProducts/cusPrices/cusPriceUtils.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { getEntOptions } from "@/internal/products/prices/priceUtils.js";
import { notNullish } from "@/utils/genUtils.js";
import {
Customer,
EntInterval,
FeatureOptions,
FullCusProduct,
FullCustomerPrice,
UsagePriceConfig,
} from "@autumn/shared";
import Stripe from "stripe";
import { subToPeriodStartEnd } from "../../stripeSubUtils/convertSubUtils.js";
export const handlePrepaidPrices = async ({
@@ -58,20 +58,10 @@ export const handlePrepaidPrices = async ({
const options = getEntOptions(cusProduct.options, cusEnt.entitlement);
// const resetBalance = getResetBalance({
// entitlement: cusEnt.entitlement,
// options: notNullish(options?.upcoming_quantity)
// ? {
// feature_id: options?.feature_id!,
// quantity: options?.upcoming_quantity!,
// }
// : options,
// relatedPrice: cusPrice.price,
// });
let resetQuantity = options?.upcoming_quantity || options?.quantity!;
let config = cusPrice.price.config as UsagePriceConfig;
let billingUnits = config.billing_units || 1;
let newAllowance =
const resetQuantity = options?.upcoming_quantity || options?.quantity!;
const config = cusPrice.price.config as UsagePriceConfig;
const billingUnits = config.billing_units || 1;
const newAllowance =
resetQuantity * billingUnits + (cusEnt.entitlement.allowance || 0);
const resetUpdate = getResetBalancesUpdate({
@@ -88,7 +78,7 @@ export const handlePrepaidPrices = async ({
const ent = cusEnt.entitlement;
let rolloverUpdate = getRolloverUpdates({
const rolloverUpdate = getRolloverUpdates({
cusEnt,
nextResetAt: end * 1000,
});
@@ -114,7 +104,7 @@ export const handlePrepaidPrices = async ({
});
if (ent.interval == EntInterval.Lifetime) {
let difference = options?.quantity! - options?.upcoming_quantity!;
const difference = options?.quantity! - options?.upcoming_quantity!;
await CusEntService.decrement({
db,
id: cusEnt.id,

View File

@@ -130,7 +130,7 @@ export const handleUsagePrices = async ({
});
}
if (relatedCusEnt.entitlement.interval == EntInterval.Lifetime) {
if (relatedCusEnt.entitlement.interval === EntInterval.Lifetime) {
return;
}
@@ -138,7 +138,7 @@ export const handleUsagePrices = async ({
const resetBalancesUpdate = getResetBalancesUpdate({
cusEnt: relatedCusEnt,
allowance: ent.interval == EntInterval.Lifetime ? 0 : ent.allowance!,
allowance: ent.interval === EntInterval.Lifetime ? 0 : ent.allowance!,
});
const { end } = subToPeriodStartEnd({ sub: usageSub });

View File

@@ -5,6 +5,7 @@ import Stripe from "stripe";
import { ZodError } from "zod/v4";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import RecaseError, { formatZodError } from "@/utils/errorUtils.js";
import { matchRoute } from "./middlewareUtils.js";
/**
* Handle special error cases that should use warn instead of error logging
@@ -99,6 +100,28 @@ const handleSpecialErrorCases = (
);
}
// Special case 6: CustomerNotFound on customer routes
const pathname = new URL(url).pathname;
if (
err instanceof RecaseError &&
err.code === ErrCode.CustomerNotFound &&
matchRoute({
url: pathname,
method: c.req.method,
pattern: { url: "/customers/:customer_id", method: "GET" },
})
) {
logger.warn(`${err.message}, org: ${ctx.org?.slug || "unknown"}`);
return c.json(
{
message: err.message,
code: err.code,
env: ctx.env,
},
404,
);
}
// No special case matched
return null;
};
@@ -170,6 +193,7 @@ export const errorMiddleware = (err: Error, c: Context<HonoEnv>) => {
}
// 3. Handle Zod validation errors
if (err instanceof ZodError) {
const formattedError = formatZodError(err);

View File

@@ -0,0 +1,39 @@
/**
* Checks if an actual URL matches a route pattern with parameters
*
* @example
* matchRoute({
* url: "/customers/cus_123",
* method: "GET",
* pattern: { url: "/customers/:customer_id", method: "GET" }
* }) // Returns true
*
* matchRoute({
* url: "/customers/cus_123/events",
* method: "GET",
* pattern: { url: "/customers/:customer_id", method: "GET" }
* }) // Returns false
*/
export const matchRoute = ({
url,
method,
pattern,
}: {
url: string;
method: string;
pattern: { url: string; method: string };
}): boolean => {
// Check if method matches
if (pattern.method !== method) {
return false;
}
// Convert route pattern to regex
// "/customers/:customer_id" -> "^\/customers\/([^/]+)$"
const regexPattern = pattern.url
.replace(/:[^/]+/g, "([^/]+)") // Replace :param with capturing group
.replace(/\//g, "\\/"); // Escape forward slashes
const regex = new RegExp(`^${regexPattern}$`);
return regex.test(url);
};

View File

@@ -0,0 +1,115 @@
import type { Context, Next } from "hono";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { deleteCusCache } from "@/internal/customers/cusCache/updateCachedCus.js";
import { matchRoute } from "./middlewareUtils.js";
/**
* Route patterns that trigger customer cache deletion
*/
const cusPrefixedUrls = [
{
method: "POST",
url: "/customers/:customer_id",
},
{
method: "DELETE",
url: "/customers/:customer_id",
},
{
method: "POST",
url: "/customers/:customer_id/balances",
},
{
method: "POST",
url: "/customers/:customer_id/entitlements/:customer_entitlement_id",
},
{
method: "POST",
url: "/customers/:customer_id/entities",
},
{
method: "DELETE",
url: "/customers/:customer_id/entities/:entity_id",
},
{
method: "POST",
url: "/customers/:customer_id/transfer_product",
},
];
/**
* Core routes that trigger cache deletion when customer_id is in body
*/
const coreUrls = [
{
method: "POST",
url: "/attach",
},
{
method: "POST",
url: "/cancel",
},
];
/**
* Hono middleware that clears customer cache after successful responses
* for specific routes
*/
export const refreshCacheMiddleware = async (
c: Context<HonoEnv>,
next: Next,
) => {
// Continue with the request
await next();
// Only clear cache on successful responses (2xx status codes)
if (c.res.status < 200 || c.res.status >= 300) {
return;
}
const ctx = c.get("ctx");
const { logger, db, org, env } = ctx;
const pathname = new URL(c.req.url).pathname.replace("/v1", "");
const method = c.req.method;
// Check if URL matches customer-prefixed patterns
const pathMatch = cusPrefixedUrls.find((pattern) =>
matchRoute({ url: pathname, method, pattern }),
);
if (pathMatch) {
const customerId = c.req.param("customer_id");
if (customerId) {
logger.info(
`Clearing cache for customer ${customerId}, url: ${pathname}`,
);
await deleteCusCache({
db,
customerId,
org,
env,
});
}
return;
}
// Check if URL matches core patterns (attach, cancel)
const coreMatch = coreUrls.find((pattern) =>
matchRoute({ url: pathname, method, pattern }),
);
if (coreMatch) {
// For core URLs, check body for customer_id
const body = await c.req.json().catch(() => null);
if (body?.customer_id) {
logger.info(`Clearing cache for core url ${pathname}`);
await deleteCusCache({
db,
customerId: body.customer_id,
org,
env,
});
}
}
};

View File

@@ -5,9 +5,11 @@ import { apiVersionMiddleware } from "./honoMiddlewares/apiVersionMiddleware.js"
import { baseMiddleware } from "./honoMiddlewares/baseMiddleware.js";
import { errorMiddleware } from "./honoMiddlewares/errorMiddleware.js";
import { orgConfigMiddleware } from "./honoMiddlewares/orgConfigMiddleware.js";
import { refreshCacheMiddleware } from "./honoMiddlewares/refreshCacheMiddleware.js";
import { secretKeyMiddleware } from "./honoMiddlewares/secretKeyMiddleware.js";
import { traceMiddleware } from "./honoMiddlewares/traceMiddleware.js";
import type { HonoEnv } from "./honoUtils/HonoEnv.js";
import { cusRouter } from "./internal/customers/cusRouter.js";
import { honoProductRouter } from "./internal/products/productRouter.js";
import { auth } from "./utils/auth.js";
@@ -75,7 +77,11 @@ export const createHonoApp = () => {
// Step 5: Org config middleware - allows config overrides via header
app.use("/v1/*", orgConfigMiddleware);
// Step 6: Add pricing middleware, analytics middleware, etc.
// Step 6: Refresh cache middleware - clears customer cache after successful mutations
app.use("/v1/*", refreshCacheMiddleware);
// Step 7: Add pricing middleware, analytics middleware, etc.
app.route("v1/customers", cusRouter);
app.route("v1/products", honoProductRouter);
// Error handler - must be defined after all routes and middleware

View File

@@ -7,7 +7,7 @@ import { analyticsRouter } from "../analytics/analyticsRouter.js";
import { attachRouter } from "../customers/attach/attachRouter.js";
import { handleSetupPayment } from "../customers/attach/handleSetupPayment.js";
import cancelRouter from "../customers/cancel/cancelRouter.js";
import { cusRouter } from "../customers/cusRouter.js";
import { expressCusRouter } from "../customers/cusRouter.js";
import { handleCreateBillingPortal } from "../customers/handlers/handleCreateBillingPortal.js";
import { featureRouter } from "../features/featureRouter.js";
import { internalFeatureRouter } from "../features/internalFeatureRouter.js";
@@ -34,7 +34,7 @@ apiRouter.use(pricingMiddleware);
apiRouter.use(analyticsMiddleware);
apiRouter.use(refreshCacheMiddleware);
apiRouter.use("/customers", cusRouter);
apiRouter.use("/customers", expressCusRouter);
apiRouter.use("/invoices", invoiceRouter);
apiRouter.use("/products", productRouter);
apiRouter.use("/products_beta", productBetaRouter);

View File

@@ -77,7 +77,7 @@ export class CusService {
const result = await db.execute(query);
if (!result || result.length == 0) {
if (!result || result.length === 0) {
if (allowNotFound) {
// @ts-expect-error
return null as FullCustomer;

View File

@@ -6,6 +6,7 @@ import {
type EntitlementWithFeature,
type FeatureOptions,
type FullCustomerEntitlement,
getStartingBalance,
type Organization,
type Price,
} from "@autumn/shared";
@@ -17,7 +18,6 @@ import { nullish } from "@/utils/genUtils.js";
import type { InsertCusProductParams } from "../cusProducts/AttachParams.js";
import { CusProductService } from "../cusProducts/CusProductService.js";
import { CusEntService } from "../cusProducts/cusEnts/CusEntitlementService.js";
import { getResetBalance } from "../cusProducts/cusEnts/cusEntUtils.js";
import { initCusEntitlement } from "./initCusEnt.js";
const updateOneOffExistingEntitlement = async ({
@@ -51,7 +51,7 @@ const updateOneOffExistingEntitlement = async ({
env: env,
});
const resetBalance = getResetBalance({
const resetBalance = getStartingBalance({
entitlement,
options,
relatedPrice,

View File

@@ -1,27 +1,25 @@
import {
AllowanceType,
AttachReplaceable,
type AttachReplaceable,
BillingType,
Customer,
Entity,
EntityBalance,
type Customer,
type EntitlementWithFeature,
type Entity,
type EntityBalance,
type FeatureOptions,
FeatureType,
FreeTrial,
FullCusProduct,
FullCustomerEntitlement,
Price,
ProductOptions,
type FreeTrial,
type FullCusProduct,
type FullCustomerEntitlement,
getStartingBalance,
type Price,
type ProductOptions,
} from "@autumn/shared";
import { FeatureOptions } from "@autumn/shared";
import { EntitlementWithFeature } from "@autumn/shared";
import { getResetBalance } from "../cusProducts/cusEnts/cusEntUtils.js";
import { generateId, notNullish } from "@/utils/genUtils.js";
import { getBillingType } from "@/internal/products/prices/priceUtils.js";
import { entitlementLinkedToEntity } from "@/internal/api/entities/entityUtils.js";
import { initNextResetAt } from "../cusProducts/insertCusProduct/initCusEnt/initNextResetAt.js";
import { Decimal } from "decimal.js";
import { entitlementLinkedToEntity } from "@/internal/api/entities/entityUtils.js";
import { getBillingType } from "@/internal/products/prices/priceUtils.js";
import { generateId, notNullish } from "@/utils/genUtils.js";
import { initNextResetAt } from "../cusProducts/insertCusProduct/initCusEnt/initNextResetAt.js";
export const initCusEntEntities = ({
entitlement,
@@ -90,13 +88,13 @@ const initCusEntBalance = ({
return { newBalance: null, newEntities: null };
}
const resetBalance = getResetBalance({
const resetBalance = getStartingBalance({
entitlement,
options,
relatedPrice,
});
let newEntities: Record<string, EntityBalance> | null = initCusEntEntities({
const newEntities: Record<string, EntityBalance> | null = initCusEntEntities({
entitlement,
entities,
resetBalance,
@@ -157,7 +155,7 @@ export const initCusEntitlement = ({
(newBalance || 0) -
replaceables.filter((r) => r.ent.id === entitlement.id).length;
let nextResetAtValue = initNextResetAt({
const nextResetAtValue = initNextResetAt({
entitlement,
nextResetAt,
// keepResetIntervals,
@@ -169,7 +167,7 @@ export const initCusEntitlement = ({
});
// 3. Define expires at (TODO next time...)
let isBooleanFeature = entitlement.feature.type === FeatureType.Boolean;
const isBooleanFeature = entitlement.feature.type === FeatureType.Boolean;
let usageAllowed = false;
if (

View File

@@ -8,6 +8,7 @@ import {
cusProductToPrices,
ErrCode,
type FullCusProduct,
getStartingBalance,
type UsagePriceConfig,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
@@ -22,7 +23,6 @@ import {
import RecaseError from "@/utils/errorUtils.js";
import { notNullish, nullOrUndefined } from "@/utils/genUtils.js";
import type { AttachParams } from "../../cusProducts/AttachParams.js";
import { getResetBalance } from "../../cusProducts/cusEnts/cusEntUtils.js";
import type { AttachFlags } from "../models/AttachFlags.js";
import { attachParamToCusProducts } from "./convertAttachParams.js";
import { handleMultiAttachErrors } from "./handleAttachErrors/handleMultiAttachErrors.js";
@@ -177,7 +177,7 @@ const handleUpdateQuantityErrors = async ({
if (
curr.entitlement.internal_feature_id === option.internal_feature_id
) {
const allowance = getResetBalance({
const allowance = getStartingBalance({
entitlement: curr.entitlement,
options: cusProduct.options.find(
(o) => o.internal_feature_id === option.internal_feature_id,

View File

@@ -1,38 +1,27 @@
import {
AllowanceType,
AppEnv,
BillingType,
type AppEnv,
CusProductStatus,
Customer,
EntInterval,
entIntervalsDifferent,
entIntervalToValue,
Entitlement,
EntitlementWithFeature,
Entity,
Feature,
FeatureOptions,
type Customer,
type EntitlementWithFeature,
type Entity,
type Feature,
FeatureType,
FullCusProduct,
FullCustomerEntitlement,
FullCustomerPrice,
Organization,
Price,
UsagePriceConfig,
type FullCusProduct,
type FullCustomerEntitlement,
type FullCustomerPrice,
getStartingBalance,
type Organization,
} from "@autumn/shared";
import {
getBillingType,
getEntOptions,
} from "@/internal/products/prices/priceUtils.js";
import { logger } from "better-auth";
import { Decimal } from "decimal.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import { getEntOptions } from "@/internal/products/prices/priceUtils.js";
import { notNullish, nullish } from "@/utils/genUtils.js";
import {
getEntityBalance,
getSummedEntityBalances,
} from "./entBalanceUtils.js";
import { Decimal } from "decimal.js";
import { logger } from "better-auth";
export const getCusEntMasterBalance = ({
cusEnt,
@@ -41,18 +30,18 @@ export const getCusEntMasterBalance = ({
cusEnt: FullCustomerEntitlement;
entities: Entity[];
}) => {
let ent = cusEnt.entitlement;
let feature = ent.feature;
const ent = cusEnt.entitlement;
const feature = ent.feature;
if (notNullish(ent.entity_feature_id)) {
let totalBalance = Object.values(cusEnt.entities || {}).reduce(
const totalBalance = Object.values(cusEnt.entities || {}).reduce(
(acc, curr) => {
return acc + curr.balance;
},
0,
);
let totalAdjustment = Object.values(cusEnt.entities || {}).reduce(
const totalAdjustment = Object.values(cusEnt.entities || {}).reduce(
(acc, curr) => {
return acc + curr.adjustment;
},
@@ -68,7 +57,7 @@ export const getCusEntMasterBalance = ({
// Get unused count
let unusedCount =
const unusedCount =
entities &&
entities.filter(
(entity) =>
@@ -90,9 +79,9 @@ export const getCusEntBalance = ({
cusEnt: FullCustomerEntitlement;
entityId?: string | null;
}) => {
let entitlement = cusEnt.entitlement;
let ent = cusEnt.entitlement;
let feature = ent.feature;
const entitlement = cusEnt.entitlement;
const ent = cusEnt.entitlement;
const feature = ent.feature;
if (notNullish(entitlement.entity_feature_id)) {
if (nullish(entityId)) {
@@ -125,10 +114,10 @@ export const getRelatedCusPrice = (
cusPrices: FullCustomerPrice[],
) => {
return cusPrices.find((cusPrice) => {
let productMatch =
const productMatch =
cusPrice.customer_product_id == cusEnt.customer_product_id;
let entMatch = cusPrice.price.entitlement_id == cusEnt.entitlement.id;
const entMatch = cusPrice.price.entitlement_id == cusEnt.entitlement.id;
return productMatch && entMatch;
});
@@ -176,44 +165,44 @@ export const updateCusEntInStripe = async ({
};
// Get balance
export const getResetBalance = ({
entitlement,
options,
relatedPrice,
productQuantity,
}: {
entitlement: Entitlement;
options: FeatureOptions | undefined | null;
relatedPrice?: Price | null;
productQuantity?: number;
}) => {
// 1. No related price
if (!relatedPrice) {
return (entitlement.allowance || 0) * (productQuantity || 1);
}
// export const getResetBalance = ({
// entitlement,
// options,
// relatedPrice,
// productQuantity,
// }: {
// entitlement: Entitlement;
// options: FeatureOptions | undefined | null;
// relatedPrice?: Price | null;
// productQuantity?: number;
// }) => {
// // 1. No related price
// if (!relatedPrice) {
// return (entitlement.allowance || 0) * (productQuantity || 1);
// }
let config = relatedPrice.config as UsagePriceConfig;
// let config = relatedPrice.config as UsagePriceConfig;
let billingType = getBillingType(config);
if (billingType != BillingType.UsageInAdvance) {
return entitlement.allowance || 0;
}
// let billingType = getBillingType(config);
// if (billingType != BillingType.UsageInAdvance) {
// return entitlement.allowance || 0;
// }
let quantity = options?.quantity;
let billingUnits = (relatedPrice.config as UsagePriceConfig).billing_units;
if (nullish(quantity) || nullish(billingUnits)) {
return entitlement.allowance || 0;
}
// let quantity = options?.quantity;
// let billingUnits = (relatedPrice.config as UsagePriceConfig).billing_units;
// if (nullish(quantity) || nullish(billingUnits)) {
// return entitlement.allowance || 0;
// }
try {
return (entitlement.allowance || 0) + quantity! * billingUnits!;
} catch (error) {
console.log(
"WARNING: Failed to return quantity * billing units, returning allowance...",
);
return entitlement.allowance || 0;
}
};
// try {
// return (entitlement.allowance || 0) + quantity! * billingUnits!;
// } catch (error) {
// console.log(
// "WARNING: Failed to return quantity * billing units, returning allowance...",
// );
// return entitlement.allowance || 0;
// }
// };
export const getUnlimitedAndUsageAllowed = ({
cusEnts,
@@ -276,17 +265,17 @@ export const getFeatureBalance = ({
let cusEntBalance = cusEnt.balance!;
// If entity feature id exists, then it is grouped...
let entityFeatureId = cusEnt.entitlement.entity_feature_id;
const entityFeatureId = cusEnt.entitlement.entity_feature_id;
if (notNullish(entityFeatureId)) {
if (notNullish(entityId)) {
let { balance: entityBalance } = getEntityBalance({
const { balance: entityBalance } = getEntityBalance({
cusEnt,
entityId: entityId!,
});
cusEntBalance = entityBalance!;
} else {
let summed = getSummedEntityBalances({
const summed = getSummedEntityBalances({
cusEnt,
});
cusEntBalance = summed.balance;
@@ -350,7 +339,7 @@ export const getTotalNegativeBalance = ({
entities: Record<string, { balance: number; adjustment: number }>;
billingUnits?: number;
}) => {
let entityFeatureId = cusEnt.entitlement.entity_feature_id;
const entityFeatureId = cusEnt.entitlement.entity_feature_id;
if (nullish(entityFeatureId)) {
return balance;
@@ -373,7 +362,7 @@ export const getTotalNegativeBalance = ({
if (totalNegative == 0) {
if (Object.values(entities).length > 0) {
let entityBalances = Object.values(entities).map((e) => e.balance || 0);
const entityBalances = Object.values(entities).map((e) => e.balance || 0);
return Math.min(...entityBalances);
} else {
return cusEnt.entitlement.allowance || 0;
@@ -402,7 +391,7 @@ export const getExistingUsageFromCusProducts = ({
}
// Existing usage should also include entities
let entityUsage = entities.reduce((acc, entity) => {
const entityUsage = entities.reduce((acc, entity) => {
if (entity.internal_feature_id !== entitlement.internal_feature_id) {
return acc;
}
@@ -417,7 +406,7 @@ export const getExistingUsageFromCusProducts = ({
let existingUsage = 0;
// NOTE: Assuming that feature entitlements are unique to each main product...
let existingCusEnt = cusProducts
const existingCusEnt = cusProducts
?.filter(
(cp) =>
(cp.status === CusProductStatus.Active ||
@@ -445,24 +434,24 @@ export const getExistingUsageFromCusProducts = ({
}
// Get options
let cusProduct = cusProducts?.find(
const cusProduct = cusProducts?.find(
(cp) => cp.id === existingCusEnt.customer_product_id,
);
let options = getEntOptions(
const options = getEntOptions(
cusProduct?.options || [],
existingCusEnt.entitlement,
);
let price = getRelatedCusPrice(
const price = getRelatedCusPrice(
existingCusEnt,
cusProduct?.customer_prices || [],
);
let existingAllowance = getResetBalance({
const existingAllowance = getStartingBalance({
entitlement: existingCusEnt.entitlement,
options: options,
options: options || undefined,
relatedPrice: price?.price,
});
let { balance, adjustment, count, unused } = getCusEntMasterBalance({
const { balance, adjustment, count, unused } = getCusEntMasterBalance({
cusEnt: existingCusEnt as any,
entities: entities,
});

View File

@@ -1,25 +1,24 @@
import {
CustomerEntitlement,
type CustomerEntitlement,
CustomerEntitlementSchema,
EntInterval,
EntitlementWithFeature,
Entity,
Feature,
type EntitlementWithFeature,
type Entity,
type Feature,
FeatureType,
FullCusEntWithFullCusProduct,
FullCusProduct,
Price,
type FullCusEntWithFullCusProduct,
type FullCusProduct,
getStartingBalance,
type Price,
sortCusEntsForDeduction,
} from "@autumn/shared";
import { getEntOptions } from "@/internal/products/prices/priceUtils.js";
import { performDeductionOnCusEnt } from "@/trigger/updateBalanceTask.js";
import { notNullish, nullish } from "@/utils/genUtils.js";
import {
getRelatedCusPrice,
getResetBalance,
getUnlimitedAndUsageAllowed,
} from "../cusEntUtils.js";
import { getEntOptions } from "@/internal/products/prices/priceUtils.js";
import { notNullish, nullish } from "@/utils/genUtils.js";
import { performDeductionOnCusEnt } from "@/trigger/updateBalanceTask.js";
import { sortCusEntsForDeduction } from "@autumn/shared";
export const getExistingCusEntAndUsage = async ({
curCusProduct,
@@ -35,7 +34,7 @@ export const getExistingCusEntAndUsage = async ({
}
// 1. If there is only one cus ent, return it and usage
let similarCusEnts = curCusProduct.customer_entitlements.filter(
const similarCusEnts = curCusProduct.customer_entitlements.filter(
(ce) => ce.internal_feature_id === entitlement.internal_feature_id,
// &&
// ce.entitlement.interval === entitlement.interval
@@ -64,7 +63,7 @@ export const getExistingUsages = ({
entities: Entity[];
features: Feature[];
}) => {
let usages: Record<
const usages: Record<
string,
{
feature_id: string;
@@ -76,14 +75,14 @@ export const getExistingUsages = ({
}
> = {};
let cusPrices = curCusProduct?.customer_prices || [];
const cusPrices = curCusProduct?.customer_prices || [];
// Get entityUsage
for (const entity of entities) {
let feature = features.find(
const feature = features.find(
(f) => f.internal_id === entity.internal_feature_id,
);
let key = `${feature?.id}-${EntInterval.Lifetime}-1`;
const key = `${feature?.id}-${EntInterval.Lifetime}-1`;
if (!usages[key]) {
usages[key] = {
@@ -100,12 +99,12 @@ export const getExistingUsages = ({
}
for (const cusEnt of curCusProduct?.customer_entitlements || []) {
let ent = cusEnt.entitlement;
let key = `${ent.feature_id}-${ent.interval}-${ent.interval_count || 1}`;
let feature = ent.feature;
const ent = cusEnt.entitlement;
const key = `${ent.feature_id}-${ent.interval}-${ent.interval_count || 1}`;
const feature = ent.feature;
if (feature.type == FeatureType.Boolean) continue;
let { unlimited, usageAllowed } = getUnlimitedAndUsageAllowed({
const { unlimited, usageAllowed } = getUnlimitedAndUsageAllowed({
cusEnts: curCusProduct.customer_entitlements,
internalFeatureId: ent.internal_feature_id!,
});
@@ -128,12 +127,12 @@ export const getExistingUsages = ({
}
// 1. To check, does ent options work with multiple features?
let relatedCusPrice = getRelatedCusPrice(cusEnt, cusPrices);
let options = getEntOptions(curCusProduct.options, ent);
const relatedCusPrice = getRelatedCusPrice(cusEnt, cusPrices);
const options = getEntOptions(curCusProduct.options, ent);
let resetBalance = getResetBalance({
const resetBalance = getStartingBalance({
entitlement: ent,
options,
options: options || undefined,
relatedPrice: relatedCusPrice?.price,
// productQuantity: curCusProduct.quantity,
});
@@ -182,14 +181,14 @@ export const addExistingUsagesToCusEnts = ({
return cusEnts;
}
let existingUsages = getExistingUsages({
const existingUsages = getExistingUsages({
curCusProduct,
entities,
features,
});
let fullCusEnts = cusEnts.map((ce) => {
let entitlement = entitlements.find((e) => e.id === ce.entitlement_id!);
const fullCusEnts = cusEnts.map((ce) => {
const entitlement = entitlements.find((e) => e.id === ce.entitlement_id!);
return { ...ce, entitlement, customer_product: curCusProduct };
}) as FullCusEntWithFullCusProduct[];
@@ -211,7 +210,7 @@ export const addExistingUsagesToCusEnts = ({
for (const key in existingUsages) {
let usage = existingUsages[key].usage;
let entityUsages = existingUsages[key].entityUsages;
const entityUsages = existingUsages[key].entityUsages;
const {
feature_id = "",
@@ -220,22 +219,22 @@ export const addExistingUsagesToCusEnts = ({
} = existingUsages[key] || {};
for (const cusEnt of fullCusEnts) {
let ent = cusEnt.entitlement;
let fromEntities = existingUsages[key].fromEntities;
const ent = cusEnt.entitlement;
const fromEntities = existingUsages[key].fromEntities;
// if (cusEntKey !== key) continue;
const isSameFeature = cusEnt.feature_id == feature_id;
if (!isSameFeature) continue;
let shouldCarry =
const shouldCarry =
ent.carry_from_previous || carryExistingUsages || fromEntities;
if (!shouldCarry) continue;
if (notNullish(entityUsages)) {
for (const entityId in entityUsages) {
let { toDeduct, newEntities } = performDeductionOnCusEnt({
const { toDeduct, newEntities } = performDeductionOnCusEnt({
cusEnt,
toDeduct: entityUsages[entityId],
allowNegativeBalance: cusEnt.usage_allowed ?? false,
@@ -255,7 +254,7 @@ export const addExistingUsagesToCusEnts = ({
cusEnt.entities![entityId]!.balance = newEntities![entityId]!.balance;
}
} else {
let { newBalance, toDeduct } = performDeductionOnCusEnt({
const { newBalance, toDeduct } = performDeductionOnCusEnt({
cusEnt,
toDeduct: usage,
allowNegativeBalance: cusEnt.usage_allowed ?? false,

View File

@@ -1,8 +1,8 @@
import { RolloverDeductParams } from "@/trigger/updateBalanceTask.js";
import { FullCusEntWithFullCusProduct, Rollover } from "@autumn/shared";
import type { FullCusEntWithFullCusProduct, Rollover } from "@autumn/shared";
import type { RolloverDeductParams } from "@/trigger/updateBalanceTask.js";
import { RolloverService } from "./RolloverService.js";
export const deductFromCusRollovers = async ({
export const deductFromApiCusRollovers = async ({
toDeduct,
deductParams,
cusEnt,
@@ -15,19 +15,19 @@ export const deductFromCusRollovers = async ({
return toDeduct;
}
let updates = {
const updates = {
toInsert: [] as Rollover[],
toUpdate: [] as Rollover[],
};
let rollovers = getSortedRollovers({
const rollovers = getSortedRollovers({
cusEnts: [cusEnt],
featureId: deductParams.feature.id,
entityId: deductParams.entity?.id,
});
if (deductParams.entity) {
for (let rollover of rollovers) {
let entityRollover = rollover.entities[deductParams.entity.id];
for (const rollover of rollovers) {
const entityRollover = rollover.entities[deductParams.entity.id];
if (entityRollover) {
if (entityRollover.balance >= toDeduct) {
entityRollover.balance -= toDeduct;
@@ -38,7 +38,7 @@ export const deductFromCusRollovers = async ({
break;
} else {
if (entityRollover.balance > 0) {
let deductedAmount = entityRollover.balance;
const deductedAmount = entityRollover.balance;
toDeduct -= entityRollover.balance;
entityRollover.balance = 0;
entityRollover.usage += deductedAmount;

View File

@@ -1,4 +1,4 @@
import { FullCustomerEntitlement } from "@autumn/shared";
import type { FullCustomerEntitlement } from "@autumn/shared";
import { notNullish } from "@/utils/genUtils.js";
export const getResetBalancesUpdate = ({
@@ -9,14 +9,14 @@ export const getResetBalancesUpdate = ({
allowance?: number;
}) => {
let update = {};
let newBalance = notNullish(allowance)
const newBalance = notNullish(allowance)
? allowance!
: cusEnt.entitlement.allowance || 0;
let entitlement = cusEnt.entitlement;
const entitlement = cusEnt.entitlement;
if (notNullish(entitlement.entity_feature_id)) {
let newEntities = { ...cusEnt.entities };
const newEntities = { ...cusEnt.entities };
for (const entityId in newEntities) {
newEntities[entityId].balance = newBalance;
newEntities[entityId].adjustment = 0;

View File

@@ -1,8 +1,10 @@
import { ErrCode, LegacyVersion } from "@autumn/shared";
import { Router } from "express";
import { Hono } from "hono";
import { StatusCodes } from "http-status-codes";
import { createStripeCusIfNotExists } from "@/external/stripe/stripeCusUtils.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { CusSearchService } from "@/internal/customers/CusSearchService.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import RecaseError, { handleRequestError } from "@/utils/errorUtils.js";
@@ -13,18 +15,18 @@ import { CusService } from "./CusService.js";
import { handleAddCouponToCus } from "./handlers/handleAddCouponToCus.js";
import { handleCreateBillingPortal } from "./handlers/handleCreateBillingPortal.js";
import { handleDeleteCustomer } from "./handlers/handleDeleteCustomer.js";
import { handleGetCustomer } from "./handlers/handleGetCustomer.js";
import { handleGetCustomerV2 } from "./handlers/handleGetCustomerV2.js";
import { handlePostCustomerRequest } from "./handlers/handlePostCustomer.js";
import { handleTransferProduct } from "./handlers/handleTransferProduct.js";
import { handleUpdateBalances } from "./handlers/handleUpdateBalances.js";
import { handleUpdateCustomer } from "./handlers/handleUpdateCustomer.js";
import { handleUpdateEntitlement } from "./handlers/handleUpdateEntitlement.js";
export const cusRouter: Router = Router();
export const expressCusRouter: Router = Router();
cusRouter.get("", handleBatchCustomers);
expressCusRouter.get("", handleBatchCustomers);
cusRouter.post("/all/search", async (req: any, res: any) => {
expressCusRouter.post("/all/search", async (req: any, res: any) => {
try {
const { search, page_size = 50, page = 1, last_item, filters } = req.body;
@@ -45,120 +47,130 @@ cusRouter.post("/all/search", async (req: any, res: any) => {
}
});
cusRouter.post("", handlePostCustomerRequest);
expressCusRouter.post("", handlePostCustomerRequest);
cusRouter.get("/:customer_id", handleGetCustomer);
// cusRouter.get("/:customer_id", handleGetCustomer);
cusRouter.delete("/:customer_id", handleDeleteCustomer);
expressCusRouter.delete("/:customer_id", handleDeleteCustomer);
cusRouter.post("/:customer_id", handleUpdateCustomer);
expressCusRouter.post("/:customer_id", handleUpdateCustomer);
// Update customer entitlement directly
cusRouter.post(
expressCusRouter.post(
"/:customer_id/entitlements/:customer_entitlement_id",
handleUpdateEntitlement,
);
cusRouter.post("/:customer_id/balances", handleUpdateBalances);
expressCusRouter.post("/:customer_id/balances", handleUpdateBalances);
// cusRouter.post(
// "/customer_products/:customer_product_id",
// handleCusProductExpired
// );
cusRouter.get("/:customer_id/billing_portal", async (req: any, res: any) => {
try {
const returnUrl = req.query.return_url;
expressCusRouter.get(
"/:customer_id/billing_portal",
async (req: any, res: any) => {
try {
const returnUrl = req.query.return_url;
const customerId = req.params.customer_id;
const customerId = req.params.customer_id;
const [org, customer] = await Promise.all([
OrgService.getFromReq(req),
CusService.get({
db: req.db,
idOrInternalId: customerId,
orgId: req.orgId,
env: req.env,
}),
]);
if (!customer) {
throw new RecaseError({
message: `Customer ${customerId} not found`,
code: ErrCode.CustomerNotFound,
statusCode: StatusCodes.NOT_FOUND,
});
}
const stripeCli = createStripeCli({ org, env: req.env });
if (!customer.processor?.id) {
let newCus;
try {
newCus = await createStripeCusIfNotExists({
const [org, customer] = await Promise.all([
OrgService.getFromReq(req),
CusService.get({
db: req.db,
org,
idOrInternalId: customerId,
orgId: req.orgId,
env: req.env,
customer,
logger: req.logtail,
});
} catch (error: any) {
}),
]);
if (!customer) {
throw new RecaseError({
message: `Failed to create Stripe customer`,
code: ErrCode.StripeError,
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
message: `Customer ${customerId} not found`,
code: ErrCode.CustomerNotFound,
statusCode: StatusCodes.NOT_FOUND,
});
} finally {
if (!newCus) {
}
const stripeCli = createStripeCli({ org, env: req.env });
if (!customer.processor?.id) {
let newCus;
try {
newCus = await createStripeCusIfNotExists({
db: req.db,
org,
env: req.env,
customer,
logger: req.logtail,
});
} catch (error: any) {
throw new RecaseError({
message: `Failed to create Stripe customer`,
code: ErrCode.StripeError,
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
});
}
} finally {
if (!newCus) {
throw new RecaseError({
message: `Failed to create Stripe customer`,
code: ErrCode.StripeError,
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
});
}
const portal = await stripeCli.billingPortal.sessions.create({
customer: newCus.id,
return_url: returnUrl || toSuccessUrl({ org, env: req.env }),
});
const portal = await stripeCli.billingPortal.sessions.create({
customer: newCus.id,
return_url: returnUrl || toSuccessUrl({ org, env: req.env }),
});
if (org.api_version >= LegacyVersion.v1_1) {
return res.status(200).json({
customer_id: customer.id,
url: portal.url,
});
} else {
return res.status(200).json({
url: portal.url,
});
if (org.api_version >= LegacyVersion.v1_1) {
return res.status(200).json({
customer_id: customer.id,
url: portal.url,
});
} else {
return res.status(200).json({
url: portal.url,
});
}
}
}
}
const portal = await stripeCli.billingPortal.sessions.create({
customer: customer.processor.id,
return_url: returnUrl || toSuccessUrl({ org, env: req.env }),
});
if (org.api_version >= LegacyVersion.v1_1) {
res.status(200).json({
customer_id: customer.id,
url: portal.url,
});
} else {
res.status(200).json({
url: portal.url,
const portal = await stripeCli.billingPortal.sessions.create({
customer: customer.processor.id,
return_url: returnUrl || toSuccessUrl({ org, env: req.env }),
});
if (org.api_version >= LegacyVersion.v1_1) {
res.status(200).json({
customer_id: customer.id,
url: portal.url,
});
} else {
res.status(200).json({
url: portal.url,
});
}
} catch (error) {
handleRequestError({ req, error, res, action: "get billing portal" });
}
} catch (error) {
handleRequestError({ req, error, res, action: "get billing portal" });
}
});
},
);
cusRouter.post("/:customer_id/billing_portal", handleCreateBillingPortal);
expressCusRouter.post(
"/:customer_id/billing_portal",
handleCreateBillingPortal,
);
cusRouter.post("/:customer_id/coupons/:coupon_id", handleAddCouponToCus);
expressCusRouter.post("/:customer_id/coupons/:coupon_id", handleAddCouponToCus);
cusRouter.use("/:customer_id/entities", entityRouter);
expressCusRouter.use("/:customer_id/entities", entityRouter);
cusRouter.post("/:customer_id/transfer", handleTransferProduct);
expressCusRouter.post("/:customer_id/transfer", handleTransferProduct);
export const cusRouter = new Hono<HonoEnv>();
cusRouter.get("/:customer_id", ...handleGetCustomerV2);

View File

@@ -1,68 +0,0 @@
import {
AffectedResource,
type ApiVersion,
ApiVersionClass,
applyResponseVersionChanges,
CusEntResponseSchema,
type Feature,
FeatureType,
LATEST_VERSION,
} from "@autumn/shared";
/**
* Transforms feature balances to API format for requested version
*
* Latest format (V1_2):
* - Object keyed by feature_id
* - Has usage/included_usage fields (not used/allowance)
*
* V1_1:
* - Array format
* - Has usage/included_usage fields
*
* V1_0:
* - Array format (split response, not in customer object)
* - Has used/allowance fields
*/
export const getApiCusFeature = ({
balances,
features,
apiVersion,
}: {
balances: any[]; // Raw balances from getCusBalances
features: Feature[]; // Feature definitions
apiVersion: ApiVersion;
}): any => {
// Transform balances to latest format (V1_1+: usage/included_usage)
const transformedBalances = balances.map((b) => {
const isBoolean =
features.find((f: Feature) => f.id === b.feature_id)?.type ===
FeatureType.Boolean;
if (b.unlimited || isBoolean) {
return b;
}
return CusEntResponseSchema.parse({
...b,
usage: b.used,
included_usage: b.allowance,
});
});
// Build in latest format (V1_2: object keyed by feature_id)
const featuresObject: Record<string, any> = {};
for (const balance of transformedBalances) {
featuresObject[balance.feature_id] = balance;
}
// Apply version changes to transform to requested version
// V1_2 stays as object
// V1_1 → array (via V1_2_FeaturesArrayToObject transform)
return applyResponseVersionChanges({
input: featuresObject,
currentVersion: new ApiVersionClass(LATEST_VERSION),
targetVersion: new ApiVersionClass(apiVersion),
resource: AffectedResource.CusFeature,
});
};

View File

@@ -0,0 +1,49 @@
import {
type ApiCusFeature,
ApiFeatureType,
type FullCusEntWithFullCusProduct,
} from "@autumn/shared";
import { getCusFeatureType } from "@/internal/features/featureUtils.js";
export const getBooleanApiCusFeature = ({
cusEnts,
}: {
cusEnts: FullCusEntWithFullCusProduct[];
}): ApiCusFeature => {
const feature = cusEnts[0].entitlement.feature;
return {
id: feature.id,
type: ApiFeatureType.Static,
name: feature.name,
balance: 0,
usage: 0,
included_usage: 0,
next_reset_at: null,
unlimited: false,
overage_allowed: false,
};
};
export const getUnlimitedApiCusFeature = ({
cusEnts,
unlimited,
usageAllowed,
}: {
cusEnts: FullCusEntWithFullCusProduct[];
unlimited: boolean;
usageAllowed: boolean;
}): ApiCusFeature => {
const feature = cusEnts[0].entitlement.feature;
return {
id: feature.id,
type: getCusFeatureType({ feature }),
name: feature.name,
balance: 0,
usage: 0,
included_usage: 0,
next_reset_at: null,
unlimited,
overage_allowed: usageAllowed,
};
};

View File

@@ -0,0 +1,259 @@
import {
AffectedResource,
type ApiCusFeatureBreakdown,
ApiCusFeatureBreakdownSchema,
ApiCusFeatureSchema,
type ApiCusRollover,
applyResponseVersionChanges,
type EntInterval,
type Feature,
FeatureType,
getCusEntBalance,
} from "@autumn/shared";
import type { FullCustomer } from "@shared/models/cusModels/fullCusModel.js";
import type { FullCusEntWithFullCusProduct } from "@shared/models/cusProductModels/cusEntModels/cusEntWithProduct.js";
import {
cusEntToBalance,
cusEntToIncludedUsage,
cusEntToKey,
cusEntToUsageLimit,
} from "@shared/utils/cusEntUtils/convertCusEntUtils.js";
import { toApiFeature } from "@shared/utils/featureUtils.js";
import { notNullish, sumValues } from "@shared/utils/utils.js";
import { Decimal } from "decimal.js";
import type { RequestContext } from "@/honoUtils/HonoEnv.js";
import { getUnlimitedAndUsageAllowed } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js";
import { getCusFeatureType } from "@/internal/features/featureUtils.js";
import { getRolloverFields } from "../../cusFeatureResponseUtils/getCusBalances.js";
import {
getBooleanApiCusFeature,
getUnlimitedApiCusFeature,
} from "./apiCusFeatureUtils.js";
export const cusEntsToInterval = ({
cusEnts,
}: {
cusEnts: FullCusEntWithFullCusProduct[];
}): {
interval: EntInterval | "multiple" | null;
interval_count: number;
} => {
const cusEntKeys = cusEnts.map((cusEnt) => cusEntToKey({ cusEnt }));
const uniqueCusEntKeys = [...new Set(cusEntKeys)];
if (uniqueCusEntKeys.length === 1) {
return {
interval: cusEnts[0].entitlement.interval || null,
interval_count: cusEnts[0].entitlement.interval_count,
};
}
return { interval: "multiple", interval_count: 0 };
};
const cusEntsToNextResetAt = ({
cusEnts,
}: {
cusEnts: FullCusEntWithFullCusProduct[];
}) => {
const result = cusEnts.reduce((acc, curr) => {
if (curr.next_reset_at && curr.next_reset_at < acc) {
return curr.next_reset_at;
}
return acc;
}, Infinity);
if (result === Infinity) return null;
return result;
};
const cusEntsToRollovers = ({
cusEnts,
entityId,
}: {
cusEnts: FullCusEntWithFullCusProduct[];
entityId?: string;
}): ApiCusRollover[] | undefined => {
// If all cus ents no rollover, return undefined
if (cusEnts.every((cusEnt) => !cusEnt.entitlement.rollover)) {
return undefined;
}
return cusEnts
.map((cusEnt) => {
const rolloverFields = getRolloverFields({ cusEnt, entityId });
if (rolloverFields)
return rolloverFields.rollovers.map((rollover) => ({
balance: rollover.balance,
expires_at: rollover.expires_at || 0,
}));
return [];
})
.filter(notNullish)
.flat();
};
const cusEntsToBreakdown = ({
ctx,
fullCus,
cusEnts,
}: {
ctx: RequestContext;
cusEnts: FullCusEntWithFullCusProduct[];
fullCus: FullCustomer;
}): ApiCusFeatureBreakdown[] | undefined => {
const keyToCusEnts: Record<string, FullCusEntWithFullCusProduct[]> = {};
for (const cusEnt of cusEnts) {
const key = cusEntToKey({ cusEnt });
keyToCusEnts[key] = [...(keyToCusEnts[key] || []), cusEnt];
}
if (Object.keys(keyToCusEnts).length === 1) return undefined;
const breakdown: ApiCusFeatureBreakdown[] = [];
for (const key in keyToCusEnts) {
const cusEnts = keyToCusEnts[key];
const feature = cusEnts[0].entitlement.feature;
const { interval, interval_count } = cusEntsToInterval({ cusEnts });
const breakdownItem = getApiCusFeature({
ctx,
fullCus,
cusEnts,
feature,
});
breakdown.push(
ApiCusFeatureBreakdownSchema.parse({
...breakdownItem,
interval,
interval_count,
}),
);
}
return breakdown;
};
export const getApiCusFeature = ({
ctx,
fullCus,
cusEnts,
feature,
}: {
ctx: RequestContext;
fullCus: FullCustomer;
cusEnts: FullCusEntWithFullCusProduct[];
feature: Feature;
}) => {
const entityId = fullCus.entity?.id;
// 1. If feature is boolean
if (feature.type === FeatureType.Boolean) {
return getBooleanApiCusFeature({
cusEnts,
});
}
const { unlimited, usageAllowed } = getUnlimitedAndUsageAllowed({
cusEnts: cusEnts,
internalFeatureId: feature.internal_id,
});
// 2. If feature is unlimited
if (unlimited || usageAllowed) {
return getUnlimitedApiCusFeature({
cusEnts: cusEnts,
unlimited,
usageAllowed,
});
}
const totalBalanceWithRollovers = sumValues(
cusEnts
.map((cusEnt) =>
cusEntToBalance({
cusEnt,
entityId,
withRollovers: true,
}),
)
.filter(notNullish),
);
const totalAdjustment = sumValues(
cusEnts.map((cusEnt) => {
const { adjustment } = getCusEntBalance({ cusEnt, entityId });
return adjustment;
}),
);
const totalUnused = sumValues(
cusEnts.map((cusEnt) => {
const { unused } = getCusEntBalance({ cusEnt, entityId });
return unused;
}),
);
const nextResetAt = cusEntsToNextResetAt({ cusEnts });
const totalUsageLimit = sumValues(
cusEnts.map((cusEnt) => cusEntToUsageLimit({ cusEnt })),
);
const totalIncludedUsage = sumValues(
cusEnts.map((cusEnt) => cusEntToIncludedUsage({ cusEnt, entityId })),
);
const totalIncludedUsageWithRollovers = sumValues(
cusEnts.map((cusEnt) =>
cusEntToIncludedUsage({ cusEnt, entityId, withRollovers: true }),
),
);
const totalUsage = new Decimal(totalIncludedUsageWithRollovers)
.add(totalAdjustment)
.sub(totalBalanceWithRollovers)
.sub(totalUnused)
.toNumber();
const { interval, interval_count } = cusEntsToInterval({ cusEnts });
const rollovers = cusEntsToRollovers({ cusEnts, entityId });
const apiFeature = toApiFeature({ feature });
const { data: apiCusFeature, error } = ApiCusFeatureSchema.safeParse({
id: feature.id,
name: feature.name,
type: getCusFeatureType({ feature }),
balance: totalBalanceWithRollovers,
usage: totalUsage,
included_usage: totalIncludedUsage,
usage_limit:
totalUsageLimit === totalIncludedUsage ? undefined : totalUsageLimit,
next_reset_at: nextResetAt,
unlimited: false,
overage_allowed: false,
interval,
interval_count,
rollovers,
credit_schema:
apiFeature.credit_schema?.map((credit) => ({
feature_id: credit.metered_feature_id,
credit_amount: credit.credit_cost,
})) || undefined,
breakdown: cusEntsToBreakdown({ ctx, fullCus, cusEnts }),
});
if (error) throw error;
return applyResponseVersionChanges({
input: apiCusFeature,
targetVersion: ctx.apiVersion,
resource: AffectedResource.CusFeature,
});
};

View File

@@ -0,0 +1,116 @@
import {
type ApiCusFeature,
CusProductStatus,
cusEntToKey,
cusProductsToCusEnts,
type FullCusEntWithFullCusProduct,
type FullCustomer,
} from "@autumn/shared";
import { V1_2_FeaturesArrayToObject } from "@shared/api/customers/cusFeatures/changes/V1_2_FeaturesArrayToObject.js";
import { isBeforeChange } from "@shared/api/versionUtils/versionChangeUtils/applyVersionChanges.js";
import type { RequestContext } from "@/honoUtils/HonoEnv.js";
import { getApiCusFeature } from "./getApiCusFeature.js";
// V0 of customer, get list
export const getApiCusFeaturesList = async ({
ctx,
fullCus,
}: {
ctx: RequestContext;
fullCus: FullCustomer;
}) => {
const { org } = ctx;
const cusEntsWithCusProduct = cusProductsToCusEnts({
cusProducts: fullCus.customer_products,
inStatuses: org.config.include_past_due
? [CusProductStatus.Active, CusProductStatus.PastDue]
: [CusProductStatus.Active],
});
const keyToCusEnts: Record<string, FullCusEntWithFullCusProduct[]> = {};
for (const cusEnt of cusEntsWithCusProduct) {
const key = cusEntToKey({ cusEnt });
keyToCusEnts[key] = [...(keyToCusEnts[key] || []), cusEnt];
}
const apiCusFeatures: ApiCusFeature[] = [];
for (const key in keyToCusEnts) {
const feature = keyToCusEnts[key][0].entitlement.feature;
const cusEnts = keyToCusEnts[key];
const apiCusFeature = getApiCusFeature({
ctx,
fullCus,
cusEnts,
feature,
});
apiCusFeatures.push(apiCusFeature);
}
return apiCusFeatures;
};
export const getApiCusFeaturesObject = async ({
ctx,
fullCus,
}: {
ctx: RequestContext;
fullCus: FullCustomer;
}) => {
const { org } = ctx;
const cusEntsWithCusProduct = cusProductsToCusEnts({
cusProducts: fullCus.customer_products,
inStatuses: org.config.include_past_due
? [CusProductStatus.Active, CusProductStatus.PastDue]
: [CusProductStatus.Active],
});
const featureToCusEnt: Record<string, FullCusEntWithFullCusProduct[]> = {};
for (const cusEnt of cusEntsWithCusProduct) {
const featureId = cusEnt.entitlement.feature.id;
featureToCusEnt[featureId] = [
...(featureToCusEnt[featureId] || []),
cusEnt,
];
}
const apiCusFeatures: Record<string, ApiCusFeature> = {};
for (const key in featureToCusEnt) {
const feature = featureToCusEnt[key][0].entitlement.feature;
const cusEnts = featureToCusEnt[key];
// 1. Get cus feature for each breakdown
const apiCusFeature = getApiCusFeature({
ctx,
fullCus,
cusEnts,
feature,
});
// Otherwise...
apiCusFeatures[feature.id] = apiCusFeature;
}
return apiCusFeatures;
};
export const getApiCusFeatures = async ({
ctx,
fullCus,
}: {
ctx: RequestContext;
fullCus: FullCustomer;
}) => {
console.log("MADE IT HERE!!");
if (
isBeforeChange({
targetVersion: ctx.apiVersion,
versionChange: V1_2_FeaturesArrayToObject,
})
) {
return getApiCusFeaturesList({ ctx, fullCus });
}
return getApiCusFeaturesObject({ ctx, fullCus });
};

View File

@@ -1,153 +1,20 @@
import {
ACTIVE_STATUSES,
AffectedResource,
type APICusProduct,
APICustomerSchema,
type ApiVersion,
ApiVersionClass,
applyResponseVersionChanges,
type CusProductStatus,
type Feature,
type FullCusProduct,
type FullCustomer,
LATEST_VERSION,
} from "@autumn/shared";
import { getApiCusFeature } from "./getApiCusFeature.js";
import { getApiCusProduct } from "./getApiCusProduct.js";
/**
* Merges customer products by id and status
* This is how V1_1+ handles multiple subscriptions to the same product
*/
const mergeApiCusProducts = ({
cusProductResponses,
}: {
cusProductResponses: APICusProduct[];
}) => {
const getProductKey = (product: APICusProduct) => {
const status = ACTIVE_STATUSES.includes(product.status as CusProductStatus)
? "active"
: product.status;
return `${product.id}:${status}`;
};
const record: Record<string, any> = {};
for (const curr of cusProductResponses) {
const key = getProductKey(curr);
const latest = record[key];
const currStartedAt = curr.started_at;
record[key] = {
...(latest || curr),
version: Math.max(latest?.version || 1, curr?.version || 1),
canceled_at: curr.canceled_at
? curr.canceled_at
: latest?.canceled_at || null,
started_at: latest?.started_at
? Math.min(latest?.started_at, currStartedAt)
: currStartedAt,
quantity: (latest?.quantity || 0) + (curr?.quantity || 0),
};
}
return Object.values(record);
};
import { type FullCustomer } from "@autumn/shared";
import type { RequestContext } from "@/honoUtils/HonoEnv.js";
import { getApiCusFeatures } from "./getApiCusFeature/getApiCusFeatures.js";
export const getApiCustomer = async ({
customer,
cusProducts,
balances,
features,
apiVersion,
invoices,
trialsUsed,
rewards,
entities,
referrals,
upcomingInvoice,
paymentMethod,
withAutumnId = false,
ctx,
fullCus,
}: {
customer: FullCustomer;
cusProducts: FullCusProduct[];
balances: any[];
features: Feature[];
apiVersion: ApiVersion;
invoices?: any[];
trialsUsed?: any[];
rewards?: any;
entities?: any[];
referrals?: any[];
upcomingInvoice?: any;
paymentMethod?: any;
withAutumnId?: boolean;
}): Promise<any> => {
const subs = customer.subscriptions || [];
// Process each product using getApiCusProduct (builds latest format + applies transforms)
let main: APICusProduct[] = [];
let addOns: APICusProduct[] = [];
for (const cusProduct of cusProducts) {
const processed = await getApiCusProduct({
cusProduct,
subs,
features,
apiVersion,
});
const isAddOn = cusProduct.product.is_add_on;
if (isAddOn) {
addOns.push(processed);
} else {
main.push(processed);
}
}
// Merge products (V1_1+ behavior, always do this in latest format)
main = mergeApiCusProducts({ cusProductResponses: main });
addOns = mergeApiCusProducts({ cusProductResponses: addOns });
// Merge main and addOns into single products array (V1_1+ behavior)
const allProducts = [...main, ...addOns];
// Get versioned features (handles field mapping + object vs array format)
const apiFeatures = getApiCusFeature({
balances,
features,
apiVersion,
});
// Build customer in latest format (V1_1+: merged response with features/products)
const latestCustomer = APICustomerSchema.parse({
autumn_id: withAutumnId ? customer.internal_id : undefined,
id: customer.id,
email: customer.email,
name: customer.name,
fingerprint: customer.fingerprint,
stripe_id: customer.processor?.id,
env: customer.env,
created_at: customer.created_at,
features: apiFeatures, // Already versioned (object for V1_2, array for V1_1)
products: allProducts, // Merged products (V1_1+ format)
invoices,
trials_used: trialsUsed,
rewards,
metadata: customer.metadata || {},
entities,
referrals,
upcoming_invoice: upcomingInvoice,
payment_method: paymentMethod,
});
// Apply customer-level version changes (e.g., V1_1_MergedResponse)
// This will split the merged response for V1_0 users
return applyResponseVersionChanges({
input: latestCustomer,
currentVersion: new ApiVersionClass(LATEST_VERSION),
targetVersion: new ApiVersionClass(apiVersion),
resource: AffectedResource.Customer,
ctx: RequestContext;
fullCus: FullCustomer;
}) => {
console.log(`[getApiCustomer] Getting features for customer ${fullCus.id}`);
const apiCusFeatures = await getApiCusFeatures({
ctx,
fullCus,
});
return {
features: apiCusFeatures,
};
};

View File

@@ -1,9 +1,9 @@
import {
type ApiCusFeature,
type ApiCusRollover,
type CreditSchemaItem,
type CusEntResponse,
CusEntResponseSchema,
type CusEntResponseV2,
type CusRollover,
type Feature,
FeatureType,
type FullCustomerEntitlement,
@@ -37,7 +37,7 @@ export const getEarliestNextResetAt = (entList: CusEntResponse[]) => {
return acc;
}, Infinity);
return earliest == Infinity ? null : earliest;
return earliest === Infinity ? null : earliest;
};
export const featuresToObject = ({
@@ -47,15 +47,15 @@ export const featuresToObject = ({
features: Feature[];
entList: CusEntResponse[];
}) => {
const featureObject: Record<string, CusEntResponseV2> = {};
const featureObject: Record<string, ApiCusFeature> = {};
for (const entRes of entList) {
const feature = features.find((f) => f.id == entRes.feature_id)!;
const feature = features.find((f) => f.id === entRes.feature_id)!;
const featureType = getCusFeatureType({ feature });
const featureId = feature.id;
const unlimited = entRes.unlimited;
const relatedEnts = entList.filter((e) => e.feature_id == featureId);
const relatedEnts = entList.filter((e) => e.feature_id === featureId);
if (featureObject[featureId]) {
continue;
@@ -73,9 +73,9 @@ export const featuresToObject = ({
const rollovers = hasRollovers
? (relatedEnts
.flatMap((e) => e.rollovers)
.filter(notNullish) as CusRollover[])
.filter(notNullish) as ApiCusRollover[])
: undefined;
const cusFeature: CusEntResponseV2 = {
const cusFeature: ApiCusFeature = {
id: featureId,
name: feature.name,
type: featureType,
@@ -86,9 +86,9 @@ export const featuresToObject = ({
usage_limit: usageLimit,
next_reset_at: getEarliestNextResetAt(relatedEnts),
interval: relatedEnts.length == 1 ? relatedEnts[0].interval : "multiple",
interval: relatedEnts.length === 1 ? relatedEnts[0].interval : "multiple",
interval_count:
relatedEnts.length == 1 ? relatedEnts[0].interval_count : null,
relatedEnts.length === 1 ? relatedEnts[0].interval_count : null,
overage_allowed: relatedEnts.some((e) => e.overage_allowed),
breakdown:
!unlimited && relatedEnts.length > 1

View File

@@ -7,6 +7,7 @@ import {
type FullCustomerEntitlement,
type FullCustomerPrice,
getCusEntBalance,
getStartingBalance,
LegacyVersion,
type Organization,
} from "@autumn/shared";
@@ -16,7 +17,6 @@ import { BREAK_API_VERSION } from "@/utils/constants.js";
import { notNullish, notNullOrUndefined } from "@/utils/genUtils.js";
import {
getRelatedCusPrice,
getResetBalance,
getUnlimitedAndUsageAllowed,
} from "../../cusProducts/cusEnts/cusEntUtils.js";
@@ -274,9 +274,9 @@ export const getCusBalances = async ({
data[key].adjustment += adjustment || 0;
const total =
(getResetBalance({
(getStartingBalance({
entitlement: ent,
options: getEntOptions(cusProduct.options, ent),
options: getEntOptions(cusProduct.options, ent) || undefined,
relatedPrice: getRelatedCusPrice(cusEnt, cusPrices)?.price,
productQuantity: cusProduct.quantity || 1,
}) || 0) * count;
@@ -303,9 +303,9 @@ export const getCusBalances = async ({
data[key].next_reset_at = cusEnt.next_reset_at;
}
const resetBalance = getResetBalance({
const resetBalance = getStartingBalance({
entitlement: ent,
options: getEntOptions(cusProduct.options, ent),
options: getEntOptions(cusProduct.options, ent) || undefined,
relatedPrice: getRelatedCusPrice(cusEnt, cusPrices)?.price,
productQuantity: cusProduct.quantity || 1,
});
@@ -342,19 +342,19 @@ export const getCusBalances = async ({
}
// Sort balances
if (org.api_version == LegacyVersion.v1) {
if (org.api_version === LegacyVersion.v1) {
balances.sort((a: any, b: any) => {
const featureA = features.find((f) => f.id == a.feature_id);
const featureB = features.find((f) => f.id == b.feature_id);
const featureA = features.find((f) => f.id === a.feature_id);
const featureB = features.find((f) => f.id === b.feature_id);
if (
featureA?.type == FeatureType.Boolean &&
featureB?.type != FeatureType.Boolean
featureA?.type === FeatureType.Boolean &&
featureB?.type !== FeatureType.Boolean
) {
return -1;
} else if (
featureA?.type != FeatureType.Boolean &&
featureB?.type == FeatureType.Boolean
featureA?.type !== FeatureType.Boolean &&
featureB?.type === FeatureType.Boolean
) {
return 1;
}

View File

@@ -1,5 +1,5 @@
import {
APICustomerSchema,
ApiCustomerSchema,
type AppEnv,
CusEntResponseSchema,
CusExpand,
@@ -142,7 +142,7 @@ export const getCustomerDetails = async ({
});
const cusResponse = {
...APICustomerSchema.parse({
...ApiCustomerSchema.parse({
...customer,
stripe_id: customer.processor?.id,
features: entList,

View File

@@ -0,0 +1,150 @@
import { ApiVersion, CusExpand, CusProductStatus } from "@autumn/shared";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { getCusWithCache } from "../cusCache/getCusWithCache.js";
import { getApiCustomer } from "../cusUtils/apiCusUtils/getApiCustomer.js";
import { parseCusExpand } from "../cusUtils/cusUtils.js";
/**
* GET /customers/:customer_id (V2 with versioning system)
*
* This is the NEW implementation using the versioning system.
* DO NOT touch the old handleGetCustomer.ts until this is validated.
*
* Key differences:
* 1. Each resource (customer/product/feature) handles its own versioning
* 2. getApiCustomer/getApiCusProduct/getApiCusFeature apply version changes
* 3. No version branching in handler logic
* 4. Side effects handled explicitly (expand invoices for V1_0)
*/
export const handleGetCustomerV2 = createRoute({
handler: async (c) => {
const ctx = c.get("ctx");
const customerId = c.req.param("customer_id");
const { env, db, logger, org, features } = ctx;
const { expand } = c.req.query();
const expandArray = parseCusExpand(expand);
// Side effect: V1_0 always expands invoices
if (ctx.apiVersion.lt(ApiVersion.V1_1)) {
expandArray.push(CusExpand.Invoices);
}
logger.info(`[V2] Getting customer ${customerId} for org ${org.slug}`);
const startTime = Date.now();
const fullCus = await getCusWithCache({
db,
idOrInternalId: customerId,
org,
env,
expand: expandArray,
logger,
allowNotFound: false,
});
logger.info(`[V2] Get customer took ${Date.now() - startTime}ms`);
// Get feature balances
const inStatuses = org.config.include_past_due
? [CusProductStatus.Active, CusProductStatus.PastDue]
: [CusProductStatus.Active];
const customer = await getApiCustomer({
ctx,
fullCus: fullCus,
});
return c.json(customer);
// // Use getApiCustomer - it handles all versioning internally!
// // - Calls getApiCusProduct for each product (builds latest + applies transforms)
// // - Calls getApiCusFeature for features (field mapping + object↔array)
// // - Applies customer-level changes (splits response for V1_0)
// const customerResponse = await getApiCustomer({
// customer,
// cusProducts: customer.customer_products,
// balances,
// features,
// apiVersion: ctx.apiVersion.semver,
// invoices,
// trialsUsed,
// rewards,
// entities,
// referrals,
// upcomingInvoice,
// paymentMethod,
// withAutumnId: with_autumn_id === "true",
// });
return c.json(customerResponse);
},
});
// const balances = await getCusBalances({
// cusEntsWithCusProduct: cusEnts,
// cusPrices: cusProductsToCusPrices({
// cusProducts: customer.customer_products,
// inStatuses,
// }),
// org,
// apiVersion: ctx.apiVersion.semver as any, // TODO: fix type
// });
// // Fetch optional expanded fields
// const subIds = customer.customer_products.flatMap(
// (cp: any) => cp.subscription_ids || [],
// );
// const rewards = await getCusRewards({
// org,
// env,
// fullCus: customer,
// subIds,
// expand: expandArray,
// });
// const upcomingInvoice = await getCusUpcomingInvoice({
// db,
// org,
// env,
// fullCus: customer,
// expand: expandArray,
// });
// const referrals = await getCusReferrals({
// db,
// fullCus: customer,
// expand: expandArray,
// });
// const paymentMethod = await getCusPaymentMethodRes({
// org,
// env,
// fullCus: customer,
// expand: expandArray,
// });
// const invoices = expandArray.includes(CusExpand.Invoices)
// ? invoicesToResponse({
// invoices: customer.invoices || [],
// logger,
// })
// : undefined;
// const entities = expandArray.includes(CusExpand.Entities)
// ? customer.entities.map((e: any) => ({
// id: e.id,
// name: e.name,
// customer_id: customer.id,
// feature_id: e.feature_id,
// created_at: e.created_at,
// env: customer.env,
// }))
// : undefined;
// const trialsUsed = expandArray.includes(CusExpand.TrialsUsed)
// ? customer.trials_used
// : undefined;
// const { with_autumn_id } = c.req.query();

View File

@@ -1,24 +1,20 @@
import {
AttachScenario,
Entity,
ErrCode,
cusProductToProduct,
type Entity,
FeatureType,
FullCusProduct,
FullCustomer,
type FullCusProduct,
type FullCustomer,
getStartingBalance,
} from "@autumn/shared";
import { createFullCusProduct } from "../../add-product/createFullCusProduct.js";
import { attachToInsertParams } from "@/internal/products/productUtils.js";
import { cusProductToProduct } from "@autumn/shared";
import { ExtendedRequest } from "@/utils/models/Request.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import RecaseError from "@/utils/errorUtils.js";
import {
getRelatedCusPrice,
getResetBalance,
} from "../../cusProducts/cusEnts/cusEntUtils.js";
import { getEntOptions } from "@/internal/products/prices/priceUtils.js";
import { CusEntService } from "../../cusProducts/cusEnts/CusEntitlementService.js";
import { attachToInsertParams } from "@/internal/products/productUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import { createFullCusProduct } from "../../add-product/createFullCusProduct.js";
import { CusProductService } from "../../cusProducts/CusProductService.js";
import { CusEntService } from "../../cusProducts/cusEnts/CusEntitlementService.js";
import { getRelatedCusPrice } from "../../cusProducts/cusEnts/cusEntUtils.js";
export const handleDecreaseAndTransfer = async ({
req,
@@ -41,14 +37,14 @@ export const handleDecreaseAndTransfer = async ({
const batchDecrement = [];
for (const cusEnt of cusProduct.customer_entitlements) {
const feature = cusEnt.entitlement.feature;
if (feature.type == FeatureType.Boolean) continue;
if (feature.type === FeatureType.Boolean) continue;
const cusPrice = getRelatedCusPrice(cusEnt, cusProduct.customer_prices);
const options = getEntOptions(cusProduct.options, cusEnt.entitlement);
const resetBalance = getResetBalance({
const resetBalance = getStartingBalance({
entitlement: cusEnt.entitlement,
options: options,
options: options || undefined,
relatedPrice: cusPrice?.price,
});

View File

@@ -1,172 +0,0 @@
import {
ApiVersion,
CusExpand,
CusProductStatus,
cusProductsToCusEnts,
cusProductsToCusPrices,
ErrCode,
} from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { invoicesToResponse } from "@/internal/invoices/invoiceUtils.js";
import { getCusWithCache } from "../../cusCache/getCusWithCache.js";
import { getApiCustomer } from "../../cusUtils/apiCusUtils/getApiCustomer.js";
import { getCusBalances } from "../../cusUtils/cusFeatureResponseUtils/getCusBalances.js";
import { getCusPaymentMethodRes } from "../../cusUtils/cusResponseUtils/getCusPaymentMethodRes.js";
import { getCusReferrals } from "../../cusUtils/cusResponseUtils/getCusReferrals.js";
import { getCusRewards } from "../../cusUtils/cusResponseUtils/getCusRewards.js";
import { getCusUpcomingInvoice } from "../../cusUtils/cusResponseUtils/getCusUpcomingInvoice.js";
import { parseCusExpand } from "../../cusUtils/cusUtils.js";
/**
* GET /customers/:customer_id (V2 with versioning system)
*
* This is the NEW implementation using the versioning system.
* DO NOT touch the old handleGetCustomer.ts until this is validated.
*
* Key differences:
* 1. Each resource (customer/product/feature) handles its own versioning
* 2. getApiCustomer/getApiCusProduct/getApiCusFeature apply version changes
* 3. No version branching in handler logic
* 4. Side effects handled explicitly (expand invoices for V1_0)
*/
export const handleGetCustomerV2 = createRoute({
handler: async (c) => {
const ctx = c.get("ctx");
const customerId = c.req.param("customer_id");
const { env, db, logger, org, features } = ctx;
const { expand } = c.req.query();
const expandArray = parseCusExpand(expand);
// Side effect: V1_0 always expands invoices
if (ctx.apiVersion.lt(ApiVersion.V1_1)) {
expandArray.push(CusExpand.Invoices);
}
logger.info(`[V2] Getting customer ${customerId} for org ${org.slug}`);
const startTime = Date.now();
const customer = await getCusWithCache({
db,
idOrInternalId: customerId,
org,
env,
expand: expandArray,
allowNotFound: true,
logger,
});
logger.info(`[V2] Get customer took ${Date.now() - startTime}ms`);
if (!customer) {
logger.warn(`[V2] Customer ${customerId} not found | Org: ${org.slug}`);
return c.json(
{
message: `Customer ${customerId} not found`,
code: ErrCode.CustomerNotFound,
},
StatusCodes.NOT_FOUND,
);
}
// Get feature balances
const inStatuses = org.config.include_past_due
? [CusProductStatus.Active, CusProductStatus.PastDue]
: [CusProductStatus.Active];
const cusEnts = cusProductsToCusEnts({
cusProducts: customer.customer_products,
inStatuses,
});
const balances = await getCusBalances({
cusEntsWithCusProduct: cusEnts,
cusPrices: cusProductsToCusPrices({
cusProducts: customer.customer_products,
inStatuses,
}),
org,
apiVersion: ctx.apiVersion.semver as any, // TODO: fix type
});
// Fetch optional expanded fields
const subIds = customer.customer_products.flatMap(
(cp: any) => cp.subscription_ids || [],
);
const rewards = await getCusRewards({
org,
env,
fullCus: customer,
subIds,
expand: expandArray,
});
const upcomingInvoice = await getCusUpcomingInvoice({
db,
org,
env,
fullCus: customer,
expand: expandArray,
});
const referrals = await getCusReferrals({
db,
fullCus: customer,
expand: expandArray,
});
const paymentMethod = await getCusPaymentMethodRes({
org,
env,
fullCus: customer,
expand: expandArray,
});
const invoices = expandArray.includes(CusExpand.Invoices)
? invoicesToResponse({
invoices: customer.invoices || [],
logger,
})
: undefined;
const entities = expandArray.includes(CusExpand.Entities)
? customer.entities.map((e: any) => ({
id: e.id,
name: e.name,
customer_id: customer.id,
feature_id: e.feature_id,
created_at: e.created_at,
env: customer.env,
}))
: undefined;
const trialsUsed = expandArray.includes(CusExpand.TrialsUsed)
? customer.trials_used
: undefined;
const { with_autumn_id } = c.req.query();
// Use getApiCustomer - it handles all versioning internally!
// - Calls getApiCusProduct for each product (builds latest + applies transforms)
// - Calls getApiCusFeature for features (field mapping + object↔array)
// - Applies customer-level changes (splits response for V1_0)
const customerResponse = await getApiCustomer({
customer,
cusProducts: customer.customer_products,
balances,
features,
apiVersion: ctx.apiVersion.semver,
invoices,
trialsUsed,
rewards,
entities,
referrals,
upcomingInvoice,
paymentMethod,
withAutumnId: with_autumn_id === "true",
});
return c.json(customerResponse);
},
});

View File

@@ -1,27 +1,23 @@
import { DrizzleCli } from "@/db/initDrizzle.js";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
import {
getRelatedCusPrice,
getResetBalance,
} from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js";
type CreateEntity,
ErrCode,
type FullCusProduct,
type FullCustomer,
type FullCustomerEntitlement,
type Replaceable,
} from "@autumn/shared";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
import {
findLinkedCusEnts,
findMainCusEntForFeature,
} from "@/internal/customers/cusProducts/cusEnts/cusEntUtils/findCusEntUtils.js";
import { getRelatedCusPrice } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js";
import { adjustAllowance } from "@/trigger/adjustAllowance.js";
import { getReps } from "@/trigger/arrearProratedUsage/handleProratedUpgrade.js";
import RecaseError from "@/utils/errorUtils.js";
import { notNullish } from "@/utils/genUtils.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import {
CreateEntity,
ErrCode,
Feature,
FullCusProduct,
FullCustomer,
FullCustomerEntitlement,
Replaceable,
} from "@autumn/shared";
import type { ExtendedRequest } from "@/utils/models/Request.js";
export const updateLinkedCusEnt = async ({
db,
@@ -34,10 +30,10 @@ export const updateLinkedCusEnt = async ({
inputEntities: CreateEntity[];
entityToReplacement: Record<string, string>;
}) => {
let newEntities = structuredClone(linkedCusEnt.entities) || {};
const newEntities = structuredClone(linkedCusEnt.entities) || {};
for (const entity of inputEntities) {
let replaceableId = entityToReplacement[entity.id];
let replaceableInEntities = replaceableId
const replaceableId = entityToReplacement[entity.id];
const replaceableInEntities = replaceableId
? newEntities[replaceableId]
: null;
@@ -48,7 +44,7 @@ export const updateLinkedCusEnt = async ({
};
delete newEntities[replaceableId];
} else {
let balance = linkedCusEnt.entitlement.allowance!;
const balance = linkedCusEnt.entitlement.allowance!;
newEntities[entity.id] = {
id: entity.id,
balance,
@@ -120,7 +116,7 @@ export const createEntityForCusProduct = async ({
const originalBalance = mainCusEnt.balance || 0;
const newBalance = originalBalance - inputEntities.length;
let repsLength = getReps({
const repsLength = getReps({
cusEnt: mainCusEnt as any,
prevBalance: originalBalance,
newBalance,

View File

@@ -1,6 +1,6 @@
import {
APIFeatureSchema,
APIFeatureType,
ApiFeatureSchema,
ApiFeatureType,
ErrCode,
type Feature,
FeatureType,
@@ -19,7 +19,7 @@ import { validateFeatureId } from "./featureUtils.js";
import { handleDeleteFeature } from "./handlers/handleDeleteFeature.js";
import { handleGetFeatureDeletionInfo } from "./handlers/handleGetFeatureDeletionInfo.js";
import { handleUpdateFeature } from "./handlers/handleUpdateFeature.js";
import { fromAPIFeature, toAPIFeature } from "./utils/mapFeatureUtils.js";
import { fromApiFeature, toApiFeature } from "./utils/mapFeatureUtils.js";
export const featureRouter: Router = express.Router();
@@ -41,7 +41,7 @@ featureRouter.get("", async (req: any, res: any) =>
res
.status(200)
.json({ list: features.map((feature) => toAPIFeature({ feature })) });
.json({ list: features.map((feature) => toApiFeature({ feature })) });
},
}),
);
@@ -64,7 +64,7 @@ featureRouter.get("/:featureId", async (req: any, res: any) =>
});
}
res.status(200).json(toAPIFeature({ feature }));
res.status(200).json(toApiFeature({ feature }));
},
}),
);
@@ -75,14 +75,14 @@ featureRouter.post("", async (req: any, res: any) =>
res,
action: "Create feature",
handler: async () => {
const apiFeature = APIFeatureSchema.parse(req.body);
const apiFeature = ApiFeatureSchema.parse(req.body);
if (!apiFeature.name) {
apiFeature.name = keyToTitle(apiFeature.id);
}
validateFeatureId(apiFeature.id);
const feature = fromAPIFeature({
const feature = fromApiFeature({
apiFeature,
orgId: req.orgId,
env: req.env,
@@ -138,8 +138,8 @@ featureRouter.post("/:feature_id", async (req: any, res: any) =>
let featureType = apiFeature.type as unknown as FeatureType;
let usageType: FeatureUsageType | undefined;
if (
apiFeature.type === APIFeatureType.SingleUsage ||
apiFeature.type === APIFeatureType.ContinuousUse
apiFeature.type === ApiFeatureType.SingleUsage ||
apiFeature.type === ApiFeatureType.ContinuousUse
) {
featureType = FeatureType.Metered;
usageType = apiFeature.type as unknown as FeatureUsageType;

View File

@@ -1,5 +1,6 @@
import {
AggregateType,
ApiFeatureType,
type CreditSystemConfig,
cusProductsToCusPrices,
ErrCode,
@@ -8,7 +9,6 @@ import {
FeatureUsageType,
type FullCustomer,
type MeteredConfig,
ProductItemFeatureType,
type UsagePriceConfig,
} from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
@@ -195,15 +195,15 @@ export const runSaveFeatureDisplayTask = async ({
export const getCusFeatureType = ({ feature }: { feature: Feature }) => {
if (feature.type === FeatureType.Boolean) {
return ProductItemFeatureType.Static;
return ApiFeatureType.Static;
} else if (feature.type === FeatureType.Metered) {
if (feature.config.usage_type === FeatureUsageType.Single) {
return ProductItemFeatureType.SingleUse;
return ApiFeatureType.SingleUsage;
} else {
return ProductItemFeatureType.ContinuousUse;
return ApiFeatureType.ContinuousUse;
}
} else {
return ProductItemFeatureType.SingleUse;
return ApiFeatureType.SingleUsage;
}
};

View File

@@ -26,7 +26,7 @@ import { addTaskToQueue } from "@/queue/queueUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import { keyToTitle } from "@/utils/genUtils.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { toAPIFeature } from "../utils/mapFeatureUtils.js";
import { toApiFeature } from "../utils/mapFeatureUtils.js";
const handleFeatureIdChanged = async ({
db,
@@ -291,7 +291,7 @@ export const handleUpdateFeature = async (
.status(200)
.json(
updatedFeature
? toAPIFeature({ feature: updatedFeature })
? toApiFeature({ feature: updatedFeature })
: undefined,
);
}
@@ -403,7 +403,7 @@ export const handleUpdateFeature = async (
.status(200)
.json(
updatedFeature
? toAPIFeature({ feature: updatedFeature })
? toApiFeature({ feature: updatedFeature })
: undefined,
);
},

View File

@@ -1,21 +1,21 @@
import {
APIFeature,
APIFeatureType,
AppEnv,
CreditSchemaItem,
Feature,
type ApiFeature,
ApiFeatureSchema,
ApiFeatureType,
type AppEnv,
type CreditSchemaItem,
type Feature,
FeatureType,
FeatureUsageType,
type FeatureUsageType,
} from "@autumn/shared";
import { APIFeatureSchema } from "@autumn/shared";
import RecaseError from "@/utils/errorUtils.js";
import {
constructBooleanFeature,
constructCreditSystem,
constructMeteredFeature,
} from "./constructFeatureUtils.js";
import RecaseError from "@/utils/errorUtils.js";
export const toAPIFeature = ({ feature }: { feature: Feature }) => {
export const toApiFeature = ({ feature }: { feature: Feature }) => {
// return FeatureResponseSchema.parse(feature);
// 1. Get feature type
let featureType = feature.type;
@@ -23,7 +23,7 @@ export const toAPIFeature = ({ feature }: { feature: Feature }) => {
featureType = feature.config.usage_type;
}
let creditSchema = undefined;
let creditSchema;
if (feature.type == FeatureType.CreditSystem) {
creditSchema = feature.config.schema.map((s: CreditSchemaItem) => ({
metered_feature_id: s.metered_feature_id,
@@ -31,7 +31,7 @@ export const toAPIFeature = ({ feature }: { feature: Feature }) => {
}));
}
return APIFeatureSchema.parse({
return ApiFeatureSchema.parse({
id: feature.id,
name: feature.name,
type: featureType,
@@ -44,20 +44,20 @@ export const toAPIFeature = ({ feature }: { feature: Feature }) => {
});
};
export const fromAPIFeature = ({
export const fromApiFeature = ({
apiFeature,
orgId,
env,
}: {
apiFeature: APIFeature;
apiFeature: ApiFeature;
orgId: string;
env: AppEnv;
}) => {
let isMetered =
apiFeature.type == APIFeatureType.SingleUsage ||
apiFeature.type == APIFeatureType.ContinuousUse;
const isMetered =
apiFeature.type == ApiFeatureType.SingleUsage ||
apiFeature.type == ApiFeatureType.ContinuousUse;
let featureType: FeatureType = isMetered
const featureType: FeatureType = isMetered
? FeatureType.Metered
: (apiFeature.type as unknown as FeatureType);

View File

@@ -1,9 +1,9 @@
import {
type APIFreeTrial,
APIFreeTrialSchema,
APIProductItemSchema,
APIProductPropertiesSchema,
APIProductSchema,
ApiProductItemSchema,
ApiProductPropertiesSchema,
ApiProductSchema,
AttachScenario,
BillingInterval,
type Feature,
@@ -15,7 +15,7 @@ import {
UsageModel,
} from "@autumn/shared";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { toAPIFeature } from "@/internal/features/utils/mapFeatureUtils.js";
import { toApiFeature } from "@/internal/features/utils/mapFeatureUtils.js";
import { notNullish } from "@/utils/genUtils.js";
import { getFreeTrialAfterFingerprint } from "../../free-trials/freeTrialUtils.js";
import { sortProductItems } from "../../pricecn/pricecnUtils.js";
@@ -71,10 +71,10 @@ export const getProductItemResponse = ({
}
const feature = features.find((f) => f.id === item.feature_id);
return APIProductItemSchema.parse({
return ApiProductItemSchema.parse({
type,
...item,
feature: feature ? toAPIFeature({ feature }) : null,
feature: feature ? toApiFeature({ feature }) : null,
display: withDisplay ? display : undefined,
...priceData,
quantity,
@@ -142,7 +142,7 @@ export const getProductProperties = ({
const hasFreeTrial =
notNullish(freeTrial) && freeTrial?.trial_available !== false;
return APIProductPropertiesSchema.parse({
return ApiProductPropertiesSchema.parse({
is_free: isFreeProduct(product.prices) || false,
is_one_off: isOneOff(product.prices) || false,
interval_group: largestInterval?.interval,
@@ -207,7 +207,7 @@ export const getProductResponse = async ({
attachScenario,
})) as APIFreeTrial;
return APIProductSchema.parse({
return ApiProductSchema.parse({
...product,
name: product.name || null,
group: product.group || null,

View File

@@ -13,7 +13,7 @@ import { sendSvixEvent } from "@/external/svix/svixHelpers.js";
import { getSingleEntityResponse } from "@/internal/api/entities/getEntityUtils.js";
import { getV2CheckResponse } from "@/internal/api/entitled/checkUtils/getV2CheckResponse.js";
import { getCustomerDetails } from "@/internal/customers/cusUtils/getCustomerDetails.js";
import { toAPIFeature } from "@/internal/features/utils/mapFeatureUtils.js";
import { toApiFeature } from "@/internal/features/utils/mapFeatureUtils.js";
export const mergeNewCusEntsIntoCusProducts = ({
cusProducts,
@@ -86,7 +86,7 @@ export const sendSvixThresholdReachedEvent = async ({
data: {
threshold_type: thresholdType,
customer: cusDetails,
feature: toAPIFeature({ feature }),
feature: toApiFeature({ feature }),
},
});
@@ -252,7 +252,7 @@ export const handleThresholdReached = async ({
data: {
threshold_type: "limit_reached",
customer: cusDetails,
feature: toAPIFeature({ feature }),
feature: toApiFeature({ feature }),
},
});

View File

@@ -1,49 +1,49 @@
import {
AllowanceType,
AppEnv,
CusProductStatus,
Entity,
Event,
Feature,
FullCustomerEntitlement,
FullCustomerPrice,
Organization,
FullCusEntWithFullCusProduct,
type AppEnv,
BillingType,
CusProductStatus,
type Customer,
type Entity,
type Event,
type Feature,
FeatureType,
FeatureUsageType,
type FullCusEntWithFullCusProduct,
type FullCustomerEntitlement,
type FullCustomerPrice,
getStartingBalance,
type Organization,
} from "@autumn/shared";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
import { Customer, FeatureType } from "@autumn/shared";
import { getCusEntsInFeatures } from "@/internal/customers/cusUtils/cusUtils.js";
import { Decimal } from "decimal.js";
import { adjustAllowance } from "./adjustAllowance.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { entityFeatureIdExists } from "@/internal/api/entities/entityUtils.js";
import { CusService } from "@/internal/customers/CusService.js";
import { refreshCusCache } from "@/internal/customers/cusCache/updateCachedCus.js";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
import { findCusEnt } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils/findCusEntUtils.js";
import {
getMeteredDeduction,
getCreditSystemDeduction,
performDeduction,
} from "./deductUtils.js";
import { notNullish, nullish } from "@/utils/genUtils.js";
getCusEntMasterBalance,
getRelatedCusPrice,
getTotalNegativeBalance,
} from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js";
import { deductFromApiCusRollovers } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverDeductionUtils.js";
import { getCusEntsInFeatures } from "@/internal/customers/cusUtils/cusUtils.js";
import {
creditSystemContainsFeature,
featureToCreditSystem,
} from "@/internal/features/creditSystemUtils.js";
import {
getCusEntMasterBalance,
getRelatedCusPrice,
getResetBalance,
getTotalNegativeBalance,
} from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js";
import { entityFeatureIdExists } from "@/internal/api/entities/entityUtils.js";
import { CusService } from "@/internal/customers/CusService.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { findCusEnt } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils/findCusEntUtils.js";
import {
getBillingType,
getEntOptions,
} from "@/internal/products/prices/priceUtils.js";
import { deductFromCusRollovers } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverDeductionUtils.js";
import { refreshCusCache } from "@/internal/customers/cusCache/updateCachedCus.js";
import { notNullish, nullish } from "@/utils/genUtils.js";
import { adjustAllowance } from "./adjustAllowance.js";
import {
getCreditSystemDeduction,
getMeteredDeduction,
performDeduction,
} from "./deductUtils.js";
import { handleThresholdReached } from "./handleThresholdReached.js";
// Decimal.set({ precision: 12 }); // 12 DP precision
@@ -93,7 +93,7 @@ const getFeatureDeductions = ({
}
// Check if unlimited exists
let unlimitedExists = cusEnts.some(
const unlimitedExists = cusEnts.some(
(cusEnt) =>
cusEnt.entitlement.allowance_type === AllowanceType.Unlimited &&
cusEnt.entitlement.internal_feature_id == feature.internal_id,
@@ -215,16 +215,16 @@ export const performDeductionOnCusEnt = ({
let newAdjustment = structuredClone(cusEnt.adjustment);
let deducted = 0;
let cusProduct = cusEnt.customer_product;
let options = notNullish(cusProduct)
const cusProduct = cusEnt.customer_product;
const options = notNullish(cusProduct)
? getEntOptions(cusProduct.options, cusEnt.entitlement)
: undefined;
let cusPrice = notNullish(cusProduct)
const cusPrice = notNullish(cusProduct)
? getRelatedCusPrice(cusEnt, cusProduct.customer_prices)
: undefined;
let resetBalance = notNullish(cusProduct)
? getResetBalance({
options,
const resetBalance = notNullish(cusProduct)
? getStartingBalance({
options: options || undefined,
relatedPrice: cusPrice?.price,
entitlement: cusEnt.entitlement,
})
@@ -243,9 +243,9 @@ export const performDeductionOnCusEnt = ({
break;
}
let entityBalance = cusEnt.entities[entityId].balance;
const entityBalance = cusEnt.entities[entityId].balance;
let {
const {
newBalance: newEntityBalance,
deducted: newDeducted,
toDeduct: newToDeduct,
@@ -261,7 +261,7 @@ export const performDeductionOnCusEnt = ({
newEntities[entityId].balance = newEntityBalance!;
if (addAdjustment) {
let adjustment = newEntities![entityId!]!.adjustment || 0;
const adjustment = newEntities![entityId!]!.adjustment || 0;
newEntities![entityId!]!.adjustment = adjustment - newDeducted!;
}
@@ -276,9 +276,9 @@ export const performDeductionOnCusEnt = ({
toDeduct = toDeductCursor;
} else {
// 2. If entity ID, deduct from that entity
let currentEntityBalance = cusEnt.entities?.[entityId!]?.balance;
const currentEntityBalance = cusEnt.entities?.[entityId!]?.balance;
let {
const {
newBalance: newEntityBalance,
deducted: newDeducted,
toDeduct: newToDeduct,
@@ -294,7 +294,7 @@ export const performDeductionOnCusEnt = ({
newEntities![entityId!]!.balance = newEntityBalance!;
if (addAdjustment) {
let adjustment = newEntities![entityId!]!.adjustment || 0;
const adjustment = newEntities![entityId!]!.adjustment || 0;
newEntities![entityId!]!.adjustment = adjustment - newDeducted!;
}
@@ -306,7 +306,7 @@ export const performDeductionOnCusEnt = ({
deducted += newDeducted!;
}
} else {
let {
const {
newBalance: newBalance_,
deducted: deducted_,
toDeduct: newToDeduct_,
@@ -324,7 +324,7 @@ export const performDeductionOnCusEnt = ({
toDeduct = newToDeduct_;
if (addAdjustment) {
let adjustment = cusEnt.adjustment || 0;
const adjustment = cusEnt.adjustment || 0;
newAdjustment = adjustment - deducted!;
}
}
@@ -358,7 +358,7 @@ export const deductAllowanceFromCusEnt = async ({
)
return toDeduct;
let {
const {
newBalance,
newEntities,
deducted,
@@ -371,19 +371,19 @@ export const deductAllowanceFromCusEnt = async ({
setZeroAdjustment,
});
let originalGrpBalance = getTotalNegativeBalance({
const originalGrpBalance = getTotalNegativeBalance({
cusEnt,
balance: cusEnt.balance!,
entities: cusEnt.entities!,
});
let newGrpBalance = getTotalNegativeBalance({
const newGrpBalance = getTotalNegativeBalance({
cusEnt,
balance: newBalance!,
entities: newEntities!,
});
let updates: any = {
const updates: any = {
balance: newBalance,
entities: newEntities,
};
@@ -419,7 +419,7 @@ export const deductAllowanceFromCusEnt = async ({
// Deduct credit amounts too
if (feature.type === FeatureType.Metered && willDeductCredits) {
for (let i = 0; i < featureDeductions.length; i++) {
let { feature: creditSystem, deduction } = featureDeductions[i];
const { feature: creditSystem, deduction } = featureDeductions[i];
if (
creditSystem.type === FeatureType.CreditSystem &&
@@ -429,12 +429,12 @@ export const deductAllowanceFromCusEnt = async ({
})
) {
// toDeduct -= deduction;
let creditAmount = featureToCreditSystem({
const creditAmount = featureToCreditSystem({
featureId: feature.id!,
creditSystem: creditSystem,
amount: deducted,
});
let newDeduction = new Decimal(deduction)
const newDeduction = new Decimal(deduction)
.minus(creditAmount)
.toNumber();
@@ -497,14 +497,14 @@ export const deductFromUsageBasedCusEnt = async ({
return;
}
let cusPrice = getRelatedCusPrice(usageBasedEnt, cusPrices);
let billingType = cusPrice?.price
const cusPrice = getRelatedCusPrice(usageBasedEnt, cusPrices);
const billingType = cusPrice?.price
? getBillingType(cusPrice?.price.config!)
: undefined;
let blockUsageLimit =
const blockUsageLimit =
billingType === BillingType.InArrearProrated ? false : true;
let { newBalance, newEntities, deducted } = performDeductionOnCusEnt({
const { newBalance, newEntities, deducted } = performDeductionOnCusEnt({
cusEnt: usageBasedEnt,
toDeduct,
allowNegativeBalance: true,
@@ -513,13 +513,13 @@ export const deductFromUsageBasedCusEnt = async ({
blockUsageLimit,
});
let oldGrpBalance = getTotalNegativeBalance({
const oldGrpBalance = getTotalNegativeBalance({
cusEnt: usageBasedEnt,
balance: usageBasedEnt.balance!,
entities: usageBasedEnt.entities!,
});
let newGrpBalance = getTotalNegativeBalance({
const newGrpBalance = getTotalNegativeBalance({
cusEnt: usageBasedEnt,
balance: newBalance!,
entities: newEntities!,
@@ -532,7 +532,7 @@ export const deductFromUsageBasedCusEnt = async ({
usageBasedEnt.adjustment = 0;
}
let updates: any = {
const updates: any = {
balance: newBalance,
entities: newEntities,
};
@@ -650,7 +650,7 @@ export const updateCustomerBalance = async ({
continue;
}
toDeduct = await deductFromCusRollovers({
toDeduct = await deductFromApiCusRollovers({
toDeduct,
cusEnt,
deductParams: {

View File

@@ -1,28 +1,26 @@
import {
AllowanceType,
AppEnv,
type AppEnv,
CusProductStatus,
Customer,
Feature,
type Customer,
type Feature,
FeatureType,
FullCustomerEntitlement,
Organization,
type FullCustomerEntitlement,
type Organization,
} from "@autumn/shared";
import { getCusEntsInFeatures } from "@/internal/customers/cusUtils/cusUtils.js";
import { featureToCreditSystem } from "@/internal/features/creditSystemUtils.js";
import { getFeatureBalance } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js";
import { Decimal } from "decimal.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { CusService } from "@/internal/customers/CusService.js";
import { refreshCusCache } from "@/internal/customers/cusCache/updateCachedCus.js";
import { getFeatureBalance } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js";
import { deductFromApiCusRollovers } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverDeductionUtils.js";
import { getCusEntsInFeatures } from "@/internal/customers/cusUtils/cusUtils.js";
import { featureToCreditSystem } from "@/internal/features/creditSystemUtils.js";
import { handleThresholdReached } from "./handleThresholdReached.js";
import {
deductAllowanceFromCusEnt,
deductFromUsageBasedCusEnt,
} from "./updateBalanceTask.js";
import { CusService } from "@/internal/customers/CusService.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { deductFromCusRollovers } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverDeductionUtils.js";
import { refreshCusCache } from "@/internal/customers/cusCache/updateCachedCus.js";
import { handleThresholdReached } from "./handleThresholdReached.js";
// 2. Get deductions for each feature
const getFeatureDeductions = ({
@@ -36,13 +34,13 @@ const getFeatureDeductions = ({
features: Feature[];
shouldSet: boolean;
}) => {
let meteredFeature =
const meteredFeature =
features.find((f) => f.type === FeatureType.Metered) || features[0];
const featureDeductions = [];
for (const feature of features) {
let newValue = value;
let unlimitedExists = cusEnts.some(
const unlimitedExists = cusEnts.some(
(cusEnt) =>
cusEnt.entitlement.allowance_type === AllowanceType.Unlimited &&
cusEnt.entitlement.internal_feature_id == feature.internal_id,
@@ -64,13 +62,13 @@ const getFeatureDeductions = ({
let deduction = newValue;
if (shouldSet) {
let totalAllowance = cusEnts.reduce((acc, curr) => {
const totalAllowance = cusEnts.reduce((acc, curr) => {
return acc + (curr.entitlement.allowance || 0);
}, 0);
let targetBalance = new Decimal(totalAllowance).sub(value).toNumber();
const targetBalance = new Decimal(totalAllowance).sub(value).toNumber();
let totalBalance = getFeatureBalance({
const totalBalance = getFeatureBalance({
cusEnts,
internalFeatureId: feature.internal_id!,
})!;
@@ -236,7 +234,7 @@ export const updateUsage = async ({
continue;
}
toDeduct = await deductFromCusRollovers({
toDeduct = await deductFromApiCusRollovers({
toDeduct,
cusEnt,
deductParams: {

View File

@@ -139,3 +139,7 @@ export const slugify = (
export const getUnique = (vals: string[]) => {
return Array.from(new Set(vals));
};
export const sumValues = (vals: number[]) => {
return vals.reduce((acc, curr) => acc + curr, 0);
};

View File

@@ -1,9 +1,29 @@
import { z } from "zod/v4";
export const SuccessResponseSchema = z
.object({
success: z.boolean(),
})
.meta({
id: "SuccessResponse",
// Base schema without .meta() to avoid side effects during imports
export const SuccessResponseSchema = z.object({
success: z.boolean(),
});
export const getListResponseSchema = ({
schema,
id,
description,
}: {
schema: z.ZodType;
id?: string;
description?: string;
}) => {
const listResponse = z.object({
list: z.array(schema),
});
if (id || description) {
listResponse.meta({
id,
description,
});
}
return listResponse;
};

View File

@@ -1,35 +1,32 @@
import { z } from "zod/v4";
export const CustomerDataSchema = z
.object({
name: z.string().nullish().meta({
description: "Customer's name",
example: "John Doe",
// Base schema without top-level .meta() to avoid side effects during imports
// Individual field descriptions are kept as they don't cause registry conflicts
export const CustomerDataSchema = z.object({
name: z.string().nullish().meta({
description: "Customer's name",
example: "John Doe",
}),
email: z.string().nullish().meta({
description: "Customer's email address",
example: "john@example.com",
}),
fingerprint: z.string().nullish().meta({
description:
"Unique identifier (eg, serial number) to detect duplicate customers and prevent free trial abuse",
example: "fp_123abc",
}),
metadata: z
.record(z.any(), z.any())
.nullish()
.meta({
description: "Additional metadata for the customer",
example: { company: "Acme Inc" },
}),
email: z.string().nullish().meta({
description: "Customer's email address",
example: "john@example.com",
}),
fingerprint: z.string().nullish().meta({
description:
"Unique identifier (eg, serial number) to detect duplicate customers and prevent free trial abuse",
example: "fp_123abc",
}),
metadata: z
.record(z.any(), z.any())
.nullish()
.meta({
description: "Additional metadata for the customer",
example: { company: "Acme Inc" },
}),
stripe_id: z.string().nullish().meta({
description: "Stripe customer ID if you already have one",
example: "cus_stripe123",
}),
})
.meta({
id: "CustomerData",
description: "Customer data for creating or updating a customer",
});
stripe_id: z.string().nullish().meta({
description: "Stripe customer ID if you already have one",
example: "cus_stripe123",
}),
});
export type CustomerData = z.infer<typeof CustomerDataSchema>;

View File

@@ -1,19 +1,15 @@
import { z } from "zod/v4";
export const EntityDataSchema = z
.object({
feature_id: z.string().meta({
description: "The feature ID that this entity is associated with",
example: "seats",
}),
name: z.string().optional().meta({
description: "Name of the entity",
example: "Team Alpha",
}),
})
.meta({
id: "EntityData",
description: "Entity data for creating an entity",
});
// Base schema without top-level .meta() to avoid side effects during imports
export const EntityDataSchema = z.object({
feature_id: z.string().meta({
description: "The feature ID that this entity is associated with",
example: "seats",
}),
name: z.string().optional().meta({
description: "Name of the entity",
example: "Team Alpha",
}),
});
export type EntityData = z.infer<typeof EntityDataSchema>;

View File

@@ -1,107 +1,77 @@
import { EntityDataSchema } from "@api/common/entityData.js";
import { APIProductSchema } from "@api/products/apiProduct.js";
import { ApiProductSchema } from "@api/products/apiProduct.js";
import { z } from "zod/v4";
import { CustomerDataSchema } from "../common/customerData.js";
import { CoreCusFeatureSchema } from "../customers/cusFeatures/apiCusFeature.js";
// Check Feature Enums
export const CheckFeatureScenarioSchema = z
.enum(["usage_limit", "feature_flag"])
.meta({
id: "CheckFeatureScenario",
description: "Scenario type for feature check",
});
export const ProductScenarioSchema = z
.enum([
"scheduled",
"active",
"new",
"renew",
"upgrade",
"downgrade",
"cancel",
])
.meta({
id: "ProductScenario",
description: "Scenario type for product attachment",
});
export const CheckFeatureScenarioSchema = z.enum([
"usage_limit",
"feature_flag",
]);
// Check Feature Schemas
export const CheckParamsSchema = z
.object({
customer_id: z.string().meta({
description: "The ID of the customer to check",
example: "cus_123",
}),
feature_id: z.string().optional().meta({
description: "The ID of the feature to check access for",
example: "api_calls",
}),
product_id: z.string().optional().meta({
description: "The ID of the product to check",
example: "pro_plan",
}),
entity_id: z.string().optional().meta({
description: "The ID of the entity (optional)",
example: "entity_123",
}),
customer_data: CustomerDataSchema.optional().meta({
description:
"Customer data to create or update the customer if they don't exist",
}),
required_balance: z.number().optional().meta({
description: "The required balance for the check",
example: 1,
}),
send_event: z.boolean().optional().meta({
description: "Whether to send a usage event if allowed",
example: true,
}),
with_preview: z.boolean().optional().meta({
description: "Whether to include preview information in the response",
example: true,
}),
entity_data: EntityDataSchema.optional().meta({
description: "Entity data to create the entity if it doesn't exist",
}),
})
.meta({
id: "CheckParams",
description: "Parameters for checking feature or product access",
});
export const CheckParamsSchema = z.object({
customer_id: z.string().meta({
description: "The ID of the customer to check",
example: "cus_123",
}),
feature_id: z.string().optional().meta({
description: "The ID of the feature to check access for",
example: "api_calls",
}),
product_id: z.string().optional().meta({
description: "The ID of the product to check",
example: "pro_plan",
}),
entity_id: z.string().optional().meta({
description: "The ID of the entity (optional)",
example: "entity_123",
}),
customer_data: CustomerDataSchema.optional().meta({
description:
"Customer data to create or update the customer if they don't exist",
}),
required_balance: z.number().optional().meta({
description: "The required balance for the check",
example: 1,
}),
send_event: z.boolean().optional().meta({
description: "Whether to send a usage event if allowed",
example: true,
}),
with_preview: z.boolean().optional().meta({
description: "Whether to include preview information in the response",
example: true,
}),
entity_data: EntityDataSchema.optional().meta({
description: "Entity data to create the entity if it doesn't exist",
}),
});
// Check Feature Preview Schemas
export const CheckFeaturePreviewSchema = z
.object({
scenario: CheckFeatureScenarioSchema.meta({
description: "The scenario type for this feature preview",
example: "usage_limit",
}),
title: z.string().meta({
description: "Title for the preview message",
example: "Usage Limit Reached",
}),
message: z.string().meta({
description: "Detailed message explaining the check result",
example: "You've reached your usage limit. Upgrade to continue.",
}),
feature_id: z.string().meta({
description: "The ID of the feature",
example: "api_calls",
}),
feature_name: z.string().meta({
description: "The name of the feature",
example: "API Calls",
}),
products: z.array(APIProductSchema).meta({
description: "Available products that include this feature",
}),
})
.meta({
id: "CheckFeaturePreview",
description: "Preview information for a feature check",
});
export const CheckFeaturePreviewSchema = z.object({
scenario: CheckFeatureScenarioSchema,
title: z.string().meta({
description: "Title for the preview message",
example: "Usage Limit Reached",
}),
message: z.string().meta({
description: "Detailed message explaining the check result",
example: "You've reached your usage limit. Upgrade to continue.",
}),
feature_id: z.string().meta({
description: "The ID of the feature",
example: "api_calls",
}),
feature_name: z.string().meta({
description: "The name of the feature",
example: "API Calls",
}),
products: z.array(ApiProductSchema).meta({
description: "Available products that include this feature",
}),
});
export const CheckResultSchema = z
.object({
@@ -133,15 +103,9 @@ export const CheckResultSchema = z
description: "Preview information if with_preview was true",
}),
})
.extend(CoreCusFeatureSchema.shape)
.meta({
id: "CheckResult",
description: "Result of a feature check",
});
.extend(CoreCusFeatureSchema.shape);
// Export Types
export type CheckParams = z.infer<typeof CheckParamsSchema>;
export type CheckResponse = z.infer<typeof CheckResultSchema>;
export type CheckFeatureScenario = z.infer<typeof CheckFeatureScenarioSchema>;
// export type CheckFeaturePreview = z.infer<typeof CheckFeaturePreviewSchema>;
export type ProductScenario = z.infer<typeof ProductScenarioSchema>;

View File

@@ -1,7 +1,16 @@
import { z } from "zod/v4";
import { CustomerDataSchema } from "../common/customerData.js";
import { EntityDataSchema } from "../common/entityData.js";
import { ProductScenarioSchema } from "./checkModels.js";
export const ProductScenarioSchema = z.enum([
"scheduled",
"active",
"new",
"renew",
"upgrade",
"downgrade",
"cancel",
]);
// Check Product Schemas
export const CheckProductParamsSchema = z
@@ -98,10 +107,7 @@ export const CheckProductPreviewOptionSchema = z
export const CheckProductPreviewSchema = z
.object({
scenario: ProductScenarioSchema.meta({
description: "The scenario type for this product preview",
example: "upgrade",
}),
scenario: ProductScenarioSchema,
product_id: z.string().meta({
description: "The ID of the product",
example: "pro_plan",

View File

@@ -1,5 +1,5 @@
import { APIProductSchema } from "@api/products/apiProduct.js";
import { APIProductItemSchema } from "@api/products/apiProductItem.js";
import { ApiProductSchema } from "@api/products/apiProduct.js";
import { ApiProductItemSchema } from "@api/products/apiProductItem.js";
import { FeatureOptionsSchema } from "@models/cusProductModels/cusProductModels.js";
import { z } from "zod/v4";
import { AttachBodySchema, ExtAttachBodySchema } from "./attachModels.js";
@@ -17,15 +17,15 @@ export const CheckoutParamsSchema = AttachBodySchema.extend({
export const CheckoutLineSchema = z.object({
description: z.string(),
amount: z.number(),
item: APIProductItemSchema.nullish(),
item: ApiProductItemSchema.nullish(),
});
export const CheckoutResponseSchema = z.object({
url: z.string().nullish(),
customer_id: z.string(),
lines: z.array(CheckoutLineSchema),
product: APIProductSchema.nullish(),
current_product: APIProductSchema.nullish(),
product: ApiProductSchema.nullish(),
current_product: ApiProductSchema.nullish(),
options: z.array(FeatureOptionsSchema).nullish(),
total: z.number().nullish(),
currency: z.string().nullish(),

View File

@@ -3,248 +3,197 @@ import { CustomerDataSchema } from "../common/customerData.js";
import { EntityDataSchema } from "../common/entityData.js";
// Cancel Schemas
export const CancelBodySchema = z
.object({
customer_id: z.string().meta({
description: "The ID of the customer",
example: "cus_123",
}),
product_id: z.string().meta({
description: "The ID of the product to cancel",
example: "pro_plan",
}),
entity_id: z.string().nullish().meta({
description: "The ID of the entity (optional)",
example: "entity_123",
}),
cancel_immediately: z.boolean().optional().meta({
description: "Whether to cancel the product immediately or at period end",
example: false,
}),
prorate: z.boolean().nullish().meta({
description:
"Whether to prorate the cancellation (defaults to true if not specified)",
example: true,
}),
})
.meta({
id: "CancelBody",
description: "Parameters for canceling a customer's product",
});
export const CancelBodySchema = z.object({
customer_id: z.string().meta({
description: "The ID of the customer",
example: "cus_123",
}),
product_id: z.string().meta({
description: "The ID of the product to cancel",
example: "pro_plan",
}),
entity_id: z.string().nullish().meta({
description: "The ID of the entity (optional)",
example: "entity_123",
}),
cancel_immediately: z.boolean().optional().meta({
description: "Whether to cancel the product immediately or at period end",
example: false,
}),
prorate: z.boolean().nullish().meta({
description:
"Whether to prorate the cancellation (defaults to true if not specified)",
example: true,
}),
});
export const CancelResultSchema = z
.object({
success: z.boolean().meta({
description: "Whether the cancellation was successful",
example: true,
}),
customer_id: z.string().meta({
description: "The ID of the customer",
example: "cus_123",
}),
product_id: z.string().meta({
description: "The ID of the canceled product",
example: "pro_plan",
}),
})
.meta({
id: "CancelResult",
description: "Result of a product cancellation",
});
export const CancelResultSchema = z.object({
success: z.boolean().meta({
description: "Whether the cancellation was successful",
example: true,
}),
customer_id: z.string().meta({
description: "The ID of the customer",
example: "cus_123",
}),
product_id: z.string().meta({
description: "The ID of the canceled product",
example: "pro_plan",
}),
});
// Track Schemas
export const TrackParamsSchema = z
.object({
customer_id: z.string().nonempty().meta({
description: "The ID of the customer",
example: "cus_123",
export const TrackParamsSchema = z.object({
customer_id: z.string().nonempty().meta({
description: "The ID of the customer",
example: "cus_123",
}),
customer_data: CustomerDataSchema.nullish().meta({
description:
"Customer data to create or update the customer if they don't exist",
}),
event_name: z.string().nonempty().optional().meta({
description: "The name of the event to track",
example: "api_call",
}),
feature_id: z.string().optional().meta({
description:
"The ID of the feature (alternative to event_name for usage events)",
example: "api_calls",
}),
properties: z
.record(z.string(), z.any())
.nullish()
.meta({
description: "Additional properties for the event",
example: { endpoint: "/api/users" },
}),
customer_data: CustomerDataSchema.nullish().meta({
description:
"Customer data to create or update the customer if they don't exist",
}),
event_name: z.string().nonempty().optional().meta({
description: "The name of the event to track",
example: "api_call",
}),
feature_id: z.string().optional().meta({
description:
"The ID of the feature (alternative to event_name for usage events)",
example: "api_calls",
}),
properties: z
.record(z.string(), z.any())
.nullish()
.meta({
description: "Additional properties for the event",
example: { endpoint: "/api/users" },
}),
timestamp: z.number().nullish().meta({
description: "Unix timestamp in milliseconds when the event occurred",
example: 1717000000000,
}),
idempotency_key: z.string().nullish().meta({
description: "Idempotency key to prevent duplicate events",
example: "evt_abc123",
}),
value: z.number().nullish().meta({
description: "The value/count of the event",
example: 1,
}),
set_usage: z.boolean().nullish().meta({
description:
"Whether to set the usage to this value instead of increment",
example: false,
}),
entity_id: z.string().nullish().meta({
description: "The ID of the entity this event is associated with",
example: "entity_123",
}),
entity_data: EntityDataSchema.nullish().meta({
description: "Data for creating the entity if it doesn't exist",
}),
})
.meta({
id: "TrackParams",
description: "Parameters for tracking an event",
});
timestamp: z.number().nullish().meta({
description: "Unix timestamp in milliseconds when the event occurred",
example: 1717000000000,
}),
idempotency_key: z.string().nullish().meta({
description: "Idempotency key to prevent duplicate events",
example: "evt_abc123",
}),
value: z.number().nullish().meta({
description: "The value/count of the event",
example: 1,
}),
set_usage: z.boolean().nullish().meta({
description: "Whether to set the usage to this value instead of increment",
example: false,
}),
entity_id: z.string().nullish().meta({
description: "The ID of the entity this event is associated with",
example: "entity_123",
}),
entity_data: EntityDataSchema.nullish().meta({
description: "Data for creating the entity if it doesn't exist",
}),
});
export const TrackResultSchema = z
.object({
id: z.string().meta({
description: "The ID of the created event",
example: "evt_123",
}),
code: z.string().meta({
description: "Response code",
example: "event_received",
}),
customer_id: z.string().meta({
description: "The ID of the customer",
example: "cus_123",
}),
entity_id: z.string().optional().meta({
description: "The ID of the entity (if provided)",
example: "entity_123",
}),
event_name: z.string().optional().meta({
description: "The name of the event",
example: "api_call",
}),
feature_id: z.string().optional().meta({
description: "The ID of the feature (if provided)",
example: "api_calls",
}),
})
.meta({
id: "TrackResult",
description: "Result of tracking an event",
});
export const TrackResultSchema = z.object({
id: z.string().meta({
description: "The ID of the created event",
example: "evt_123",
}),
code: z.string().meta({
description: "Response code",
example: "event_received",
}),
customer_id: z.string().meta({
description: "The ID of the customer",
example: "cus_123",
}),
entity_id: z.string().optional().meta({
description: "The ID of the entity (if provided)",
example: "entity_123",
}),
event_name: z.string().optional().meta({
description: "The name of the event",
example: "api_call",
}),
feature_id: z.string().optional().meta({
description: "The ID of the feature (if provided)",
example: "api_calls",
}),
});
// Query Schemas
export const QueryParamsSchema = z
.object({
customer_id: z.string().meta({
description: "The ID of the customer to query analytics for",
example: "cus_123",
}),
feature_id: z.union([z.string(), z.array(z.string())]).meta({
description: "The feature ID(s) to query",
example: "api_calls",
}),
range: z
.enum(["24h", "7d", "30d", "90d", "last_cycle"] as const)
.nullish()
.meta({
description:
"Time range for the query (defaults to last_cycle if not provided)",
example: "7d",
}),
})
.meta({
id: "QueryParams",
description: "Parameters for querying analytics data",
});
export const QueryResultSchema = z
.object({
list: z.array(z.any()).meta({
description: "List of usage data points",
example: [{ period: 1717000000000, count: 100 }],
}),
})
.meta({
id: "QueryResult",
description: "Result of an analytics query",
});
export const SetupPaymentParamsSchema = z
.object({
customer_id: z.string().meta({
description: "The ID of the customer",
example: "cus_123",
}),
success_url: z.string().optional().meta({
description: "URL to redirect to after successful payment setup",
example: "https://example.com/success",
}),
checkout_session_params: z.record(z.any(), z.any()).optional().meta({
description: "Additional parameters for the checkout session",
}),
})
.meta({
id: "SetupPaymentParams",
description: "Parameters for setting up a payment method",
});
export const SetupPaymentResultSchema = z
.object({
customer_id: z.string().meta({
description: "The ID of the customer",
example: "cus_123",
}),
url: z.string().meta({
description: "URL to the payment setup page",
example: "https://checkout.stripe.com/...",
}),
})
.meta({
id: "SetupPaymentResult",
description: "Result of setting up a payment method",
});
export const BillingPortalParamsSchema = z
.object({
customer_id: z.string().meta({
description: "The ID of the customer",
example: "cus_123",
}),
return_url: z.string().optional().meta({
export const QueryParamsSchema = z.object({
customer_id: z.string().meta({
description: "The ID of the customer to query analytics for",
example: "cus_123",
}),
feature_id: z.union([z.string(), z.array(z.string())]).meta({
description: "The feature ID(s) to query",
example: "api_calls",
}),
range: z
.enum(["24h", "7d", "30d", "90d", "last_cycle"] as const)
.nullish()
.meta({
description:
"URL to return to after exiting the billing portal. Must include http:// or https://",
example: "https://example.com/dashboard",
"Time range for the query (defaults to last_cycle if not provided)",
example: "7d",
}),
})
.meta({
id: "BillingPortalParams",
description: "Parameters for accessing the billing portal",
});
});
export const BillingPortalResultSchema = z
.object({
customer_id: z.string().meta({
description: "The ID of the customer",
example: "cus_123",
}),
url: z.string().meta({
description: "URL to the billing portal",
example: "https://billing.stripe.com/...",
}),
})
.meta({
id: "BillingPortalResult",
description: "Result of creating a billing portal session",
});
export const QueryResultSchema = z.object({
list: z.array(z.any()).meta({
description: "List of usage data points",
example: [{ period: 1717000000000, count: 100 }],
}),
});
export const SetupPaymentParamsSchema = z.object({
customer_id: z.string().meta({
description: "The ID of the customer",
example: "cus_123",
}),
success_url: z.string().optional().meta({
description: "URL to redirect to after successful payment setup",
example: "https://example.com/success",
}),
checkout_session_params: z.record(z.any(), z.any()).optional().meta({
description: "Additional parameters for the checkout session",
}),
});
export const SetupPaymentResultSchema = z.object({
customer_id: z.string().meta({
description: "The ID of the customer",
example: "cus_123",
}),
url: z.string().meta({
description: "URL to the payment setup page",
example: "https://checkout.stripe.com/...",
}),
});
export const BillingPortalParamsSchema = z.object({
customer_id: z.string().meta({
description: "The ID of the customer",
example: "cus_123",
}),
return_url: z.string().optional().meta({
description:
"URL to return to after exiting the billing portal. Must include http:// or https://",
example: "https://example.com/dashboard",
}),
});
export const BillingPortalResultSchema = z.object({
customer_id: z.string().meta({
description: "The ID of the customer",
example: "cus_123",
}),
url: z.string().meta({
description: "URL to the billing portal",
example: "https://billing.stripe.com/...",
}),
});
export type CancelBody = z.infer<typeof CancelBodySchema>;
export type CancelResult = z.infer<typeof CancelResultSchema>;

View File

@@ -1,6 +1,6 @@
import { APICusProductSchema } from "@api/customers/components/apiCusProduct.js";
import { APICusReferralSchema } from "@api/customers/components/apiCusReferral.js";
import { APICusFeatureSchema } from "@api/customers/cusFeatures/apiCusFeature.js";
import { ApiCusFeatureSchema } from "@api/customers/cusFeatures/apiCusFeature.js";
import { APICusRewardsSchema } from "@api/models.js";
import { APIInvoiceSchema } from "@api/others/apiInvoice.js";
import { EntityResponseSchema } from "@models/cusModels/entityModels/entityResModels.js";
@@ -14,43 +14,38 @@ export const APITrialsUsedSchema = z.object({
fingerprint: z.string().nullish(),
});
export const APICustomerSchema = z
.object({
// Internal fields
autumn_id: z.string().nullish(),
export const ApiCustomerSchema = z.object({
// Internal fields
autumn_id: z.string().nullish(),
id: z.string().nullable().meta({
description: "Your internal ID for the customer",
example: "cus_123",
}),
id: z.string().nullable().meta({
description: "Your internal ID for the customer",
example: "cus_123",
}),
created_at: z.number().meta({
description:
"The date and time the customer was created in milliseconds since epoch",
example: 1717000000,
}),
created_at: z.number().meta({
description:
"The date and time the customer was created in milliseconds since epoch",
example: 1717000000,
}),
name: z.string().nullable(),
email: z.string().nullable(),
fingerprint: z.string().nullable(),
stripe_id: z.string().nullable().default(null),
env: z.enum(AppEnv),
name: z.string().nullable(),
email: z.string().nullable(),
fingerprint: z.string().nullable(),
stripe_id: z.string().nullable().default(null),
env: z.enum(AppEnv),
products: z.array(APICusProductSchema),
features: z.record(z.string(), APICusFeatureSchema),
invoices: z.array(APIInvoiceSchema).optional(),
trials_used: z.array(APITrialsUsedSchema).optional(),
products: z.array(APICusProductSchema),
features: z.record(z.string(), ApiCusFeatureSchema),
invoices: z.array(APIInvoiceSchema).optional(),
trials_used: z.array(APITrialsUsedSchema).optional(),
rewards: APICusRewardsSchema.nullish(),
metadata: z.record(z.any(), z.any()).default({}),
entities: z.array(EntityResponseSchema).optional(),
referrals: z.array(APICusReferralSchema).optional(),
upcoming_invoice: APICusUpcomingInvoiceSchema.nullish(),
payment_method: z.any().nullish(),
})
.meta({
id: "Customer",
description: "Customer object returned by the API",
});
rewards: APICusRewardsSchema.nullish(),
metadata: z.record(z.any(), z.any()).default({}),
entities: z.array(EntityResponseSchema).optional(),
referrals: z.array(APICusReferralSchema).optional(),
upcoming_invoice: APICusUpcomingInvoiceSchema.nullish(),
payment_method: z.any().nullish(),
});
export type APICustomer = z.infer<typeof APICustomerSchema>;
export type ApiCustomer = z.infer<typeof ApiCustomerSchema>;

View File

@@ -1,6 +1,6 @@
import { APICustomerSchema } from "@api/customers/apiCustomer.js";
import { ApiCustomerSchema } from "@api/customers/apiCustomer.js";
import { APICusProductSchema } from "@api/customers/components/apiCusProduct.js";
import { APICusFeatureSchema } from "@api/customers/cusFeatures/apiCusFeature.js";
import { ApiCusFeatureSchema } from "@api/customers/cusFeatures/apiCusFeature.js";
import { ApiVersion } from "@api/versionUtils/ApiVersion.js";
import {
AffectedResource,
@@ -16,11 +16,11 @@ import { z } from "zod/v4";
*/
// V1_1+ merged response schema
const V1_1_CustomerResponseSchema = APICustomerSchema;
const V1_1_CustomerResponseSchema = ApiCustomerSchema;
// V1_0 split response schema
const V1_0_CustomerResponseSchema = z.object({
customer: APICustomerSchema.omit({
customer: ApiCustomerSchema.omit({
features: true,
products: true,
invoices: true,
@@ -28,7 +28,7 @@ const V1_0_CustomerResponseSchema = z.object({
}),
products: z.array(APICusProductSchema),
add_ons: z.array(APICusProductSchema),
entitlements: z.array(APICusFeatureSchema),
entitlements: z.array(ApiCusFeatureSchema),
invoices: z.array(z.any()),
trials_used: z.array(z.any()).optional(),
});

View File

@@ -1,4 +1,4 @@
import { APIProductItemSchema } from "@api/products/apiProductItem.js";
import { ApiProductItemSchema } from "@api/products/apiProductItem.js";
import { z } from "zod/v4";
export const APICusProductSchema = z.object({
@@ -19,7 +19,7 @@ export const APICusProductSchema = z.object({
entity_id: z.string().nullish(),
items: z.array(APIProductItemSchema).nullish(),
items: z.array(ApiProductItemSchema).nullish(),
quantity: z.number().optional(),
});

View File

@@ -1,13 +1,15 @@
import { ApiFeatureType } from "@api/features/apiFeature.js";
import { EntInterval } from "@models/productModels/entModels/entEnums.js";
import { ProductItemFeatureType } from "@models/productV2Models/productItemModels/productItemModels.js";
import { z } from "zod/v4";
export const CusRolloverSchema = z.object({
export const ApiCusRolloverSchema = z.object({
balance: z.number(),
expires_at: z.number(),
});
// OLD CUS FEATURE RESPONSE
// Version 2 of cus feature response
export const CusEntResponseSchema = z.object({
feature_id: z.string(),
interval: z.enum(EntInterval).nullish(),
@@ -19,10 +21,19 @@ export const CusEntResponseSchema = z.object({
next_reset_at: z.number().nullish(),
overage_allowed: z.boolean().nullish(),
usage_limit: z.number().nullish(),
rollovers: z.array(CusRolloverSchema).nullish(),
rollovers: z.array(ApiCusRolloverSchema).nullish(),
});
// Version 3 of cus feature response
export const ApiCusFeatureBreakdownSchema = z.object({
interval: z.enum(EntInterval),
interval_count: z.number().nullish(),
balance: z.number().nullish(),
usage: z.number().nullish(),
included_usage: z.number().nullish(),
next_reset_at: z.number().nullish(),
});
// NEW CUS FEATURE RESPONSE
export const CoreCusFeatureSchema = z.object({
interval: z.enum(EntInterval).or(z.literal("multiple")).nullish(),
interval_count: z.number().nullish(),
@@ -55,17 +66,20 @@ export const CoreCusFeatureSchema = z.object({
.nullish(),
usage_limit: z.number().nullish(),
rollovers: z.array(CusRolloverSchema).nullish(),
rollovers: z.array(ApiCusRolloverSchema).nullish(),
});
export const APICusFeatureSchema = z
export const ApiCusFeatureSchema = z
.object({
id: z.string(),
type: z.enum(ProductItemFeatureType),
type: z.enum(ApiFeatureType),
name: z.string().nullish(),
})
.extend(CoreCusFeatureSchema.shape);
export type CusEntResponse = z.infer<typeof CusEntResponseSchema>;
export type CusEntResponseV2 = z.infer<typeof APICusFeatureSchema>;
export type CusRollover = z.infer<typeof CusRolloverSchema>;
export type ApiCusFeature = z.infer<typeof ApiCusFeatureSchema>;
export type ApiCusRollover = z.infer<typeof ApiCusRolloverSchema>;
export type ApiCusFeatureBreakdown = z.infer<
typeof ApiCusFeatureBreakdownSchema
>;

View File

@@ -1,4 +1,4 @@
import { APICusFeatureSchema } from "@api/customers/cusFeatures/apiCusFeature.js";
import { ApiCusFeatureSchema } from "@api/customers/cusFeatures/apiCusFeature.js";
import { ApiVersion } from "@api/versionUtils/ApiVersion.js";
import {
AffectedResource,
@@ -14,10 +14,10 @@ import { z } from "zod/v4";
*/
// V1_2+ features schema (object format)
const V1_2_FeaturesSchema = z.record(z.string(), APICusFeatureSchema);
const V1_2_FeaturesSchema = z.record(z.string(), ApiCusFeatureSchema);
// V1_1 features schema (array format)
const V1_1_FeaturesSchema = z.array(APICusFeatureSchema);
const V1_1_FeaturesSchema = z.array(ApiCusFeatureSchema);
export class V1_2_FeaturesArrayToObject extends VersionChange<
typeof V1_2_FeaturesSchema,

View File

@@ -1,5 +1,4 @@
import { EntInterval } from "@models/productModels/entModels/entEnums.js";
import { ProductItemFeatureType } from "@models/productV2Models/productItemModels/productItemModels.js";
import { z } from "zod/v4";
// const res: any = {
@@ -18,71 +17,110 @@ import { z } from "zod/v4";
*/
export const ApiCusFeatureV0Schema = z.object({
feature_id: z.string(),
interval: z.enum(EntInterval).nullish(),
interval_count: z.number().nullish(),
unlimited: z.boolean().nullish(),
interval: z.enum(EntInterval).nullish(),
balance: z.number().nullish(),
used: z.number().nullish(),
});
// OLD CUS FEATURE RESPONSE
export const CusEntResponseSchema = z.object({
// Example
/**
Boolean response:
{
"feature_id": "figma-integration",
"interval": null
},
Unlimited response:
{
"feature_id": "deepseek-messages",
"unlimited": true,
"interval": null,
"balance": null,
"used": null
},
Regular response:
{
"feature_id": "chat-messages",
"unlimited": false,
"interval": "month",
"balance": 1000,
"used": 0
},
*/
/**
* ApiCusFeatureV1Schema - Second version of customer feature API model, includes additional fields...
*/
export const ApiCusFeatureV1Schema = z.object({
feature_id: z.string(),
unlimited: z.boolean().nullish(),
interval: z.enum(EntInterval).nullish(),
interval_count: z.number().nullish(),
unlimited: z.boolean().nullish(),
balance: z.number().nullish(), //
usage: z.number().nullish(),
included_usage: z.number().nullish(),
next_reset_at: z.number().nullish(),
overage_allowed: z.boolean().nullish(),
usage_limit: z.number().nullish(),
// rollovers: z.array(CusRolloverSchema).nullish(),
});
// NEW CUS FEATURE RESPONSE
export const CoreCusFeatureSchema = z.object({
interval: z.enum(EntInterval).or(z.literal("multiple")).nullish(),
interval_count: z.number().nullish(),
unlimited: z.boolean().nullish(),
balance: z.number().nullish(),
usage: z.number().nullish(),
included_usage: z.number().nullish(),
next_reset_at: z.number().nullish(),
overage_allowed: z.boolean().nullish(),
breakdown: z
.array(
z.object({
interval: z.enum(EntInterval),
interval_count: z.number().nullish(),
balance: z.number().nullish(),
usage: z.number().nullish(),
included_usage: z.number().nullish(),
next_reset_at: z.number().nullish(),
}),
)
.nullish(),
credit_schema: z
.array(
z.object({
feature_id: z.string(),
credit_amount: z.number(),
}),
)
.nullish(),
usage_limit: z.number().nullish(),
rollovers: z.array(CusRolloverSchema).nullish(),
used: z.number().nullish(),
});
export const APICusFeatureSchema = z
.object({
id: z.string(),
type: z.enum(ProductItemFeatureType),
name: z.string().nullish(),
})
.extend(CoreCusFeatureSchema.shape);
// // OLD CUS FEATURE RESPONSE
// export const CusEntResponseSchema = z.object({
// feature_id: z.string(),
// interval: z.enum(EntInterval).nullish(),
// interval_count: z.number().nullish(),
// unlimited: z.boolean().nullish(),
// balance: z.number().nullish(), //
// usage: z.number().nullish(),
// included_usage: z.number().nullish(),
// next_reset_at: z.number().nullish(),
// overage_allowed: z.boolean().nullish(),
// usage_limit: z.number().nullish(),
// // rollovers: z.array(ApiCusRolloverSchema).nullish(),
// });
export type CusEntResponse = z.infer<typeof CusEntResponseSchema>;
export type CusEntResponseV2 = z.infer<typeof APICusFeatureSchema>;
export type CusRollover = z.infer<typeof CusRolloverSchema>;
// // NEW CUS FEATURE RESPONSE
// export const CoreCusFeatureSchema = z.object({
// interval: z.enum(EntInterval).or(z.literal("multiple")).nullish(),
// interval_count: z.number().nullish(),
// unlimited: z.boolean().nullish(),
// balance: z.number().nullish(),
// usage: z.number().nullish(),
// included_usage: z.number().nullish(),
// next_reset_at: z.number().nullish(),
// overage_allowed: z.boolean().nullish(),
// breakdown: z
// .array(
// z.object({
// interval: z.enum(EntInterval),
// interval_count: z.number().nullish(),
// balance: z.number().nullish(),
// usage: z.number().nullish(),
// included_usage: z.number().nullish(),
// next_reset_at: z.number().nullish(),
// }),
// )
// .nullish(),
// credit_schema: z
// .array(
// z.object({
// feature_id: z.string(),
// credit_amount: z.number(),
// }),
// )
// .nullish(),
// usage_limit: z.number().nullish(),
// rollovers: z.array(ApiCusRolloverSchema).nullish(),
// });
// export const ApiCusFeatureSchema = z
// .object({
// id: z.string(),
// type: z.enum(ProductItemFeatureType),
// name: z.string().nullish(),
// })
// .extend(CoreCusFeatureSchema.shape);
// export type CusEntResponse = z.infer<typeof CusEntResponseSchema>;
// export type CusEntResponseV2 = z.infer<typeof ApiCusFeatureSchema>;
// export type ApiCusRollover = z.infer<typeof ApiCusRolloverSchema>;

View File

@@ -2,170 +2,150 @@ import { z } from "zod/v4";
import { EntityDataSchema } from "../common/entityData.js";
// Create Customer Params (based on handlePostCustomer logic)
export const CreateCustomerParamsSchema = z
.object({
id: z
.string()
.refine(
(val) => {
if (val === "") return false;
if (val.includes("@")) return false;
if (val.includes(" ")) return false;
if (val.includes(".")) return false;
return /^[a-zA-Z0-9_-]+$/.test(val);
},
{
message:
"ID can only contain letters, numbers, underscores, and hyphens",
},
)
.nullish()
.meta({
description: "Your unique identifier for the customer",
example: "cus_123",
}),
name: z.string().nullish().meta({
description: "Customer's name",
example: "John Doe",
export const CreateCustomerParamsSchema = z.object({
id: z
.string()
.refine(
(val) => {
if (val === "") return false;
if (val.includes("@")) return false;
if (val.includes(" ")) return false;
if (val.includes(".")) return false;
return /^[a-zA-Z0-9_-]+$/.test(val);
},
{
message:
"ID can only contain letters, numbers, underscores, and hyphens",
},
)
.nullish()
.meta({
description: "Your unique identifier for the customer",
example: "cus_123",
}),
email: z
.email({ message: "not a valid email address" })
.or(z.literal(""))
.nullish()
.meta({
description: "Customer's email address",
example: "john@example.com",
}),
fingerprint: z.string().nullish().meta({
description:
"Unique identifier (eg, serial number) to detect duplicate customers and prevent free trial abuse",
example: "fp_123abc",
name: z.string().nullish().meta({
description: "Customer's name",
example: "John Doe",
}),
email: z
.email({ message: "not a valid email address" })
.or(z.literal(""))
.nullish()
.meta({
description: "Customer's email address",
example: "john@example.com",
}),
metadata: z
.record(z.any(), z.any())
.default({})
.nullish()
.meta({
description: "Additional metadata for the customer",
example: { company: "Acme Inc" },
}),
stripe_id: z.string().nullish().meta({
description: "Stripe customer ID if you already have one",
example: "cus_stripe123",
fingerprint: z.string().nullish().meta({
description:
"Unique identifier (eg, serial number) to detect duplicate customers and prevent free trial abuse",
example: "fp_123abc",
}),
metadata: z
.record(z.any(), z.any())
.default({})
.nullish()
.meta({
description: "Additional metadata for the customer",
example: { company: "Acme Inc" },
}),
entity_id: z.string().nullish().meta({
description: "Entity ID to associate with the customer",
example: "entity_123",
}),
entity_data: EntityDataSchema.nullish().meta({
description: "Data for creating an entity",
}),
})
.meta({
id: "CreateCustomerParams",
description: "Parameters for creating a customer",
});
stripe_id: z.string().nullish().meta({
description: "Stripe customer ID if you already have one",
example: "cus_stripe123",
}),
entity_id: z.string().nullish().meta({
description: "Entity ID to associate with the customer",
example: "entity_123",
}),
entity_data: EntityDataSchema.nullish().meta({
description: "Data for creating an entity",
}),
});
// Update Customer Params (based on handleUpdateCustomer logic)
export const UpdateCustomerParamsSchema = z
.object({
id: z
.string()
.refine(
(val) => {
if (val === "") return false;
if (val.includes("@")) return false;
if (val.includes(" ")) return false;
if (val.includes(".")) return false;
return /^[a-zA-Z0-9_-]+$/.test(val);
},
{
message:
"ID can only contain letters, numbers, underscores, and hyphens",
},
)
.nullish()
.meta({
description:
"New unique identifier for the customer (cannot be changed to null)",
example: "cus_123",
}),
name: z.string().nullish().meta({
description: "Customer's name",
example: "John Doe",
}),
email: z
.string()
.email({ message: "not a valid email address" })
.or(z.literal(""))
.nullish()
.meta({
description: "Customer's email address",
example: "john@example.com",
}),
fingerprint: z.string().nullish().meta({
export const UpdateCustomerParamsSchema = z.object({
id: z
.string()
.refine(
(val) => {
if (val === "") return false;
if (val.includes("@")) return false;
if (val.includes(" ")) return false;
if (val.includes(".")) return false;
return /^[a-zA-Z0-9_-]+$/.test(val);
},
{
message:
"ID can only contain letters, numbers, underscores, and hyphens",
},
)
.nullish()
.meta({
description:
"Unique identifier (eg, serial number) to detect duplicate customers",
example: "fp_123abc",
"New unique identifier for the customer (cannot be changed to null)",
example: "cus_123",
}),
metadata: z
.record(z.any(), z.any())
.nullish()
.meta({
description:
"Additional metadata for the customer (set individual keys to null to delete them)",
example: { company: "Acme Inc" },
}),
stripe_id: z.string().nullish().meta({
description: "Stripe customer ID",
example: "cus_stripe123",
name: z.string().nullish().meta({
description: "Customer's name",
example: "John Doe",
}),
email: z
.string()
.email({ message: "not a valid email address" })
.or(z.literal(""))
.nullish()
.meta({
description: "Customer's email address",
example: "john@example.com",
}),
})
.meta({
id: "UpdateCustomerParams",
description: "Parameters for updating a customer",
});
fingerprint: z.string().nullish().meta({
description:
"Unique identifier (eg, serial number) to detect duplicate customers",
example: "fp_123abc",
}),
metadata: z
.record(z.any(), z.any())
.nullish()
.meta({
description:
"Additional metadata for the customer (set individual keys to null to delete them)",
example: { company: "Acme Inc" },
}),
stripe_id: z.string().nullish().meta({
description: "Stripe customer ID",
example: "cus_stripe123",
}),
});
// List Customers Query (based on the docs)
export const ListCustomersQuerySchema = z
.object({
limit: z.number().int().min(10).max(100).default(10).optional().meta({
description: "Maximum number of customers to return",
example: 10,
}),
offset: z.number().int().min(0).default(0).optional().meta({
description: "Number of customers to skip before returning results",
example: 0,
}),
})
.meta({
id: "ListCustomersQuery",
description: "Query parameters for listing customers",
});
export const ListCustomersQuerySchema = z.object({
limit: z.number().int().min(10).max(100).default(10).optional().meta({
description: "Maximum number of customers to return",
example: 10,
}),
offset: z.number().int().min(0).default(0).optional().meta({
description: "Number of customers to skip before returning results",
example: 0,
}),
});
// List Customers Response
export const ListCustomersResponseSchema = z
.object({
list: z.array(z.any()).meta({
description: "List of customers",
}),
total: z.number().int().meta({
description: "Total number of customers available",
example: 100,
}),
limit: z.number().int().meta({
description: "Maximum number of customers returned",
example: 10,
}),
offset: z.number().int().meta({
description: "Number of customers skipped before returning results",
example: 0,
}),
})
.meta({
id: "ListCustomersResponse",
description: "Response for listing customers",
});
export const ListCustomersResponseSchema = z.object({
list: z.array(z.any()).meta({
description: "List of customers",
}),
total: z.number().int().meta({
description: "Total number of customers available",
example: 100,
}),
limit: z.number().int().meta({
description: "Maximum number of customers returned",
example: 10,
}),
offset: z.number().int().meta({
description: "Number of customers skipped before returning results",
example: 0,
}),
});
export type CreateCustomerParams = z.infer<typeof CreateCustomerParamsSchema>;
export type UpdateCustomerParams = z.infer<typeof UpdateCustomerParamsSchema>;

View File

@@ -1,13 +1,19 @@
import { CusExpand } from "@models/cusModels/cusExpand.js";
import { z } from "zod/v4";
import { SuccessResponseSchema } from "../common/commonResponses.js";
import { APICustomerSchema } from "./apiCustomer.js";
import { ApiCustomerSchema } from "./apiCustomer.js";
import {
CreateCustomerParamsSchema,
ListCustomersResponseSchema,
UpdateCustomerParamsSchema,
} from "./customerOpModels.js";
// Register schema with .meta() for OpenAPI spec generation
const ApiCustomerWithMeta = ApiCustomerSchema.meta({
id: "Customer",
description: "Customer object returned by the API",
});
export const customerOps = {
"/customers": {
get: {
@@ -44,7 +50,7 @@ export const customerOps = {
responses: {
"200": {
description: "200 OK",
content: { "application/json": { schema: APICustomerSchema } },
content: { "application/json": { schema: ApiCustomerWithMeta } },
},
},
},
@@ -64,7 +70,7 @@ export const customerOps = {
responses: {
"200": {
description: "200 OK",
content: { "application/json": { schema: APICustomerSchema } },
content: { "application/json": { schema: ApiCustomerWithMeta } },
},
},
},
@@ -87,7 +93,7 @@ export const customerOps = {
responses: {
"200": {
description: "200 OK",
content: { "application/json": { schema: APICustomerSchema } },
content: { "application/json": { schema: ApiCustomerWithMeta } },
},
},
},

View File

@@ -1,8 +1,6 @@
import { EntityResponseSchema } from "@models/cusModels/entityModels/entityResModels.js";
import type { z } from "zod/v4";
export const APIEntitySchema = EntityResponseSchema.meta({
id: "Entity",
description: "Entity object returned by the API",
});
// Base schema without .meta() to avoid side effects during imports
export const APIEntitySchema = EntityResponseSchema;
export type APIEntity = z.infer<typeof APIEntitySchema>;

View File

@@ -4,13 +4,11 @@ import { SuccessResponseSchema } from "../common/commonResponses.js";
import { APIEntitySchema } from "./apiEntity.js";
import { CreateEntityParamsSchema } from "./entityOpModels.js";
const EntityListResponseSchema = z
.object({
data: z.array(APIEntitySchema),
})
.meta({
id: "EntityListResponse",
});
// Register schema with .meta() for OpenAPI spec generation
const APIEntityWithMeta = APIEntitySchema.meta({
id: "Entity",
description: "Entity object returned by the API",
});
export const entityOps = {
"/customers/{customer_id}/entities": {
@@ -30,7 +28,7 @@ export const entityOps = {
responses: {
"200": {
description: "200 OK",
content: { "application/json": { schema: APIEntitySchema } },
content: { "application/json": { schema: APIEntityWithMeta } },
},
},
},
@@ -51,7 +49,7 @@ export const entityOps = {
responses: {
"200": {
description: "200 OK",
content: { "application/json": { schema: APIEntitySchema } },
content: { "application/json": { schema: APIEntityWithMeta } },
},
},
},

View File

@@ -1,39 +1,28 @@
import { z } from "zod/v4";
// Create Entity Params (based on CreateEntitySchema from shared/models)
export const CreateEntityParamsSchema = z
.object({
id: z.string().meta({
description: "The ID of the entity",
example: "entity_123",
}),
name: z.string().nullish().meta({
description: "The name of the entity",
example: "Team Alpha",
}),
feature_id: z.string().meta({
description: "The ID of the feature this entity is associated with",
example: "seats",
}),
})
.meta({
id: "CreateEntityParams",
description: "Parameters for creating an entity",
});
export const CreateEntityParamsSchema = z.object({
id: z.string().meta({
description: "The ID of the entity",
example: "entity_123",
}),
name: z.string().nullish().meta({
description: "The name of the entity",
example: "Team Alpha",
}),
feature_id: z.string().meta({
description: "The ID of the feature this entity is associated with",
example: "seats",
}),
});
// Get Entity Query Params
export const GetEntityQuerySchema = z
.object({
expand: z.string().optional().meta({
description:
"Comma-separated list of fields to expand (e.g., 'invoices')",
example: "invoices",
}),
})
.meta({
id: "GetEntityQuery",
description: "Query parameters for getting an entity",
});
export const GetEntityQuerySchema = z.object({
expand: z.string().optional().meta({
description: "Comma-separated list of fields to expand (e.g., 'invoices')",
example: "invoices",
}),
});
export type CreateEntityParams = z.infer<typeof CreateEntityParamsSchema>;
export type GetEntityQuery = z.infer<typeof GetEntityQuerySchema>;

View File

@@ -1,38 +1,35 @@
import { z } from "zod/v4";
export enum APIFeatureType {
export enum ApiFeatureType {
Static = "static", // legacy (will deprecate)
Boolean = "boolean",
SingleUsage = "single_use",
ContinuousUse = "continuous_use",
CreditSystem = "credit_system",
}
export const APIFeatureSchema = z
.object({
id: z.string(),
name: z.string().nullish(),
type: z.nativeEnum(APIFeatureType),
display: z
.object({
singular: z.string(),
plural: z.string(),
})
.nullish(),
// Base schema without .meta() to avoid side effects during imports
export const ApiFeatureSchema = z.object({
id: z.string(),
name: z.string().nullish(),
type: z.enum(ApiFeatureType),
display: z
.object({
singular: z.string(),
plural: z.string(),
})
.nullish(),
credit_schema: z
.array(
z.object({
metered_feature_id: z.string(),
credit_cost: z.number(),
}),
)
.nullish(),
credit_schema: z
.array(
z.object({
metered_feature_id: z.string(),
credit_cost: z.number(),
}),
)
.nullish(),
archived: z.boolean().nullish(),
})
.meta({
id: "Feature",
description: "Feature object returned by the API",
});
archived: z.boolean().nullish(),
});
export type APIFeature = z.infer<typeof APIFeatureSchema>;
export type ApiFeature = z.infer<typeof ApiFeatureSchema>;

View File

@@ -1,97 +1,87 @@
import { z } from "zod/v4";
import { APIFeatureType } from "./apiFeature.js";
import { ApiFeatureType } from "./apiFeature.js";
// Create Feature Params
export const CreateFeatureParamsSchema = z
.object({
id: z.string().meta({
description: "The ID of the feature",
example: "feature_123",
export const CreateFeatureParamsSchema = z.object({
id: z.string().meta({
description: "The ID of the feature",
example: "feature_123",
}),
name: z.string().nullish().meta({
description: "The name of the feature",
example: "API Calls",
}),
type: z.enum(ApiFeatureType).meta({
description: "The type of the feature",
example: "single_use",
}),
display: z
.object({
singular: z.string(),
plural: z.string(),
})
.nullish()
.meta({
description: "Display names for the feature",
example: { singular: "API Call", plural: "API Calls" },
}),
name: z.string().nullish().meta({
description: "The name of the feature",
example: "API Calls",
}),
type: z.nativeEnum(APIFeatureType).meta({
description: "The type of the feature",
example: "single_use",
}),
display: z
.object({
singular: z.string(),
plural: z.string(),
})
.nullish()
.meta({
description: "Display names for the feature",
example: { singular: "API Call", plural: "API Calls" },
credit_schema: z
.array(
z.object({
metered_feature_id: z.string(),
credit_cost: z.number(),
}),
credit_schema: z
.array(
z.object({
metered_feature_id: z.string(),
credit_cost: z.number(),
}),
)
.nullish()
.meta({
description:
"Credit schema for credit system features (only applicable when type is credit_system)",
example: [{ metered_feature_id: "api_calls", credit_cost: 10 }],
}),
})
.meta({
id: "CreateFeatureParams",
description: "Parameters for creating a feature",
});
)
.nullish()
.meta({
description:
"Credit schema for credit system features (only applicable when type is credit_system)",
example: [{ metered_feature_id: "api_calls", credit_cost: 10 }],
}),
});
// Update Feature Params
export const UpdateFeatureParamsSchema = z
.object({
id: z.string().optional().meta({
description: "The ID of the feature",
example: "feature_123",
export const UpdateFeatureParamsSchema = z.object({
id: z.string().optional().meta({
description: "The ID of the feature",
example: "feature_123",
}),
name: z.string().optional().meta({
description: "The name of the feature",
example: "API Calls",
}),
type: z.enum(ApiFeatureType).optional().meta({
description: "The type of the feature",
example: "single_use",
}),
display: z
.object({
singular: z.string(),
plural: z.string(),
})
.optional()
.meta({
description: "Display names for the feature",
example: { singular: "API Call", plural: "API Calls" },
}),
name: z.string().optional().meta({
description: "The name of the feature",
example: "API Calls",
}),
type: z.enum(APIFeatureType).optional().meta({
description: "The type of the feature",
example: "single_use",
}),
display: z
.object({
singular: z.string(),
plural: z.string(),
})
.optional()
.meta({
description: "Display names for the feature",
example: { singular: "API Call", plural: "API Calls" },
credit_schema: z
.array(
z.object({
metered_feature_id: z.string(),
credit_cost: z.number(),
}),
credit_schema: z
.array(
z.object({
metered_feature_id: z.string(),
credit_cost: z.number(),
}),
)
.optional()
.meta({
description:
"Credit schema for credit system features (only applicable when type is credit_system)",
example: [{ metered_feature_id: "api_calls", credit_cost: 10 }],
}),
archived: z.boolean().optional().meta({
description: "Whether the feature is archived",
example: false,
)
.optional()
.meta({
description:
"Credit schema for credit system features (only applicable when type is credit_system)",
example: [{ metered_feature_id: "api_calls", credit_cost: 10 }],
}),
})
.meta({
id: "UpdateFeatureParams",
description: "Parameters for updating a feature",
});
archived: z.boolean().optional().meta({
description: "Whether the feature is archived",
example: false,
}),
});
export type CreateFeatureParams = z.infer<typeof CreateFeatureParamsSchema>;
export type UpdateFeatureParams = z.infer<typeof UpdateFeatureParamsSchema>;

View File

@@ -1,18 +1,19 @@
import { APIFeatureSchema } from "./apiFeature.js";
import { z } from "zod/v4";
import {
getListResponseSchema,
SuccessResponseSchema,
} from "../common/commonResponses.js";
import { ApiFeatureSchema } from "./apiFeature.js";
import {
CreateFeatureParamsSchema,
UpdateFeatureParamsSchema,
} from "./featureOpModels.js";
import { z } from "zod/v4";
import { SuccessResponseSchema } from "../common/commonResponses.js";
const FeatureListResponseSchema = z
.object({
list: z.array(APIFeatureSchema),
})
.meta({
id: "FeatureListResponse",
});
// Register the schema with .meta() for OpenAPI spec generation
const ApiFeatureWithMeta = ApiFeatureSchema.meta({
id: "Feature",
description: "Feature object returned by the API",
});
export const featureOps = {
"/features": {
@@ -27,7 +28,11 @@ export const featureOps = {
responses: {
"200": {
description: "200 OK",
content: { "application/json": { schema: FeatureListResponseSchema } },
content: {
"application/json": {
schema: getListResponseSchema({ schema: ApiFeatureWithMeta }),
},
},
},
},
},
@@ -42,7 +47,7 @@ export const featureOps = {
responses: {
"200": {
description: "200 OK",
content: { "application/json": { schema: APIFeatureSchema } },
content: { "application/json": { schema: ApiFeatureWithMeta } },
},
},
},
@@ -59,7 +64,7 @@ export const featureOps = {
responses: {
"200": {
description: "200 OK",
content: { "application/json": { schema: APIFeatureSchema } },
content: { "application/json": { schema: ApiFeatureWithMeta } },
},
},
},
@@ -79,7 +84,7 @@ export const featureOps = {
responses: {
"200": {
description: "200 OK",
content: { "application/json": { schema: APIFeatureSchema } },
content: { "application/json": { schema: ApiFeatureWithMeta } },
},
},
},

View File

@@ -1,6 +1,6 @@
import { APIFeatureSchema } from "@api/features/apiFeature.js";
import { ApiFeatureSchema } from "@api/features/apiFeature.js";
import type { z } from "zod/v4";
export const UpdateFeatureParamsSchema = APIFeatureSchema.partial();
export const UpdateFeatureParamsSchema = ApiFeatureSchema.partial();
export type UpdateFeatureParams = z.infer<typeof UpdateFeatureParamsSchema>;

View File

@@ -2,7 +2,7 @@
export * from "./core/attachModels.js";
export * from "./core/checkModels.js";
export * from "./core/checkoutModels.js";
export * from "./core/coreOpenApi.js";
// NOTE: coreOpenApi.js is NOT exported here - it's only imported by openapi.ts for spec generation
export * from "./core/coreOpModels.js";
// Customers
@@ -12,17 +12,17 @@ export * from "./customers/components/apiCusProduct.js";
export * from "./customers/components/apiCusReferral.js";
export * from "./customers/cusFeatures/apiCusFeature.js";
export * from "./customers/customerOpModels.js";
export * from "./customers/customersOpenApi.js";
// NOTE: customersOpenApi.js is NOT exported here - it's only imported by openapi.ts for spec generation
// Entities
export * from "./entities/apiEntity.js";
export * from "./entities/entitiesOpenApi.js";
// NOTE: entitiesOpenApi.js is NOT exported here - it's only imported by openapi.ts for spec generation
export * from "./entities/entityOpModels.js";
// Features
export * from "./features/apiFeature.js";
export * from "./features/featureOpModels.js";
export * from "./features/featuresOpenApi.js";
// NOTE: featuresOpenApi.js is NOT exported here - it's only imported by openapi.ts for spec generation
// Others
export * from "./others/apiDiscount.js";
@@ -33,7 +33,7 @@ export * from "./products/apiFreeTrial.js";
export * from "./products/apiProduct.js";
export * from "./products/apiProductItem.js";
export * from "./products/productOpModels.js";
export * from "./products/productsOpenApi.js";
// NOTE: productsOpenApi.js is NOT exported here - it's only imported by openapi.ts for spec generation
// export * from "./products/apiFreeTrial.js";
// export * from "./products/apiProduct.js";

View File

@@ -45,8 +45,14 @@ const document = createDocument({
id: "AutumnError",
description: "An error that occurred in the API",
}),
customerData: CustomerDataSchema,
entityData: EntityDataSchema,
customerData: CustomerDataSchema.meta({
id: "CustomerData",
description: "Customer data for creating or updating a customer",
}),
entityData: EntityDataSchema.meta({
id: "EntityData",
description: "Entity data for creating an entity",
}),
},
securitySchemes: {
secretKey: {

View File

@@ -2,9 +2,9 @@ import { AttachScenario } from "@models/checkModels/checkPreviewModels.js";
import { AppEnv } from "@models/genModels/genEnums.js";
import { z } from "zod/v4";
import { APIFreeTrialSchema } from "./apiFreeTrial.js";
import { APIProductItemSchema } from "./apiProductItem.js";
import { ApiProductItemSchema } from "./apiProductItem.js";
export const APIProductPropertiesSchema = z.object({
export const ApiProductPropertiesSchema = z.object({
is_free: z.boolean().meta({
description: "True if the product has no base price or usage prices",
example: false,
@@ -29,115 +29,109 @@ export const APIProductPropertiesSchema = z.object({
}),
});
export const APIProductSchema = z
.object({
id: z.string().meta({
description: "The ID of the product you set when creating the product",
example: "pro_plan",
}),
export const ApiProductSchema = z.object({
id: z.string().meta({
description: "The ID of the product you set when creating the product",
example: "pro_plan",
}),
name: z.string().meta({
description: "The name of the product",
example: "Pro Plan",
}),
name: z.string().meta({
description: "The name of the product",
example: "Pro Plan",
}),
group: z.string().nullable().meta({
description: "The group of the product",
example: "product_set_1",
}),
group: z.string().nullable().meta({
description: "The group of the product",
example: "product_set_1",
}),
env: z.enum(AppEnv).meta({
description: "The environment of the product",
example: "production",
}),
env: z.enum(AppEnv).meta({
description: "The environment of the product",
example: "production",
}),
is_add_on: z.boolean().meta({
description:
"Whether the product is an add-on and can be purchased alongside other products",
example: true,
}),
is_add_on: z.boolean().meta({
description:
"Whether the product is an add-on and can be purchased alongside other products",
example: true,
}),
is_default: z.boolean().meta({
description: "Whether the product is the default product",
example: true,
}),
is_default: z.boolean().meta({
description: "Whether the product is the default product",
example: true,
}),
archived: z.boolean({ message: "archived should be a boolean" }).meta({
description:
"Whether this product has been archived and is no longer available",
example: false,
}),
archived: z.boolean({ message: "archived should be a boolean" }).meta({
description:
"Whether this product has been archived and is no longer available",
example: false,
}),
version: z.number().meta({
description: "The version of the product",
example: 1,
}),
version: z.number().meta({
description: "The version of the product",
example: 1,
}),
created_at: z.number().meta({
description:
"The timestamp of when the product was created in milliseconds since epoch",
example: 1759247877000,
}),
created_at: z.number().meta({
description:
"The timestamp of when the product was created in milliseconds since epoch",
example: 1759247877000,
}),
items: z.array(APIProductItemSchema).meta({
description:
"Array of product items that define the features and pricing",
example: [
{
feature_id: "<string>",
feature_type: "single_use",
included_usage: 123,
interval: "<string>",
usage_model: "prepaid",
price: 123,
billing_units: 123,
entity_feature_id: "<string>",
reset_usage_when_enabled: true,
tiers: [
{
to: 123,
amount: 123,
},
],
},
],
}),
free_trial: APIFreeTrialSchema.nullable().meta({
description: "Free trial configuration for this product, if available",
example: {
duration: "<string>",
length: 123,
unique_fingerprint: true,
items: z.array(ApiProductItemSchema).meta({
description: "Array of product items that define the features and pricing",
example: [
{
feature_id: "<string>",
feature_type: "single_use",
included_usage: 123,
interval: "<string>",
usage_model: "prepaid",
price: 123,
billing_units: 123,
entity_feature_id: "<string>",
reset_usage_when_enabled: true,
tiers: [
{
to: 123,
amount: 123,
},
],
},
}),
],
}),
base_variant_id: z.string().nullable().meta({
description: "ID of the base variant this product is derived from",
example: "var_1234567890abcdef",
}),
free_trial: APIFreeTrialSchema.nullable().meta({
description: "Free trial configuration for this product, if available",
example: {
duration: "<string>",
length: 123,
unique_fingerprint: true,
},
}),
scenario: z.enum(AttachScenario).optional().meta({
description:
"Scenario context for when this product is used in attach flows",
example: "upgrade",
}),
base_variant_id: z.string().nullable().meta({
description: "ID of the base variant this product is derived from",
example: "var_1234567890abcdef",
}),
properties: APIProductPropertiesSchema.optional().meta({
description: "Additional properties and metadata for the product",
example: {
is_free: false,
is_one_off: false,
interval_group: "monthly",
has_trial: true,
updateable: true,
},
}),
})
.meta({
description: "A product",
id: "Product",
});
scenario: z.enum(AttachScenario).optional().meta({
description:
"Scenario context for when this product is used in attach flows",
example: "upgrade",
}),
export type APIProduct = z.infer<typeof APIProductSchema>;
export type APIProductProperties = z.infer<typeof APIProductPropertiesSchema>;
properties: ApiProductPropertiesSchema.optional().meta({
description: "Additional properties and metadata for the product",
example: {
is_free: false,
is_one_off: false,
interval_group: "monthly",
has_trial: true,
updateable: true,
},
}),
});
export type ApiProduct = z.infer<typeof ApiProductSchema>;
export type ApiProductProperties = z.infer<typeof ApiProductPropertiesSchema>;

View File

@@ -1,4 +1,4 @@
import { APIFeatureSchema } from "@api/features/apiFeature.js";
import { ApiFeatureSchema } from "@api/features/apiFeature.js";
import { Infinite } from "@models/productModels/productEnums.js";
import {
PriceTierSchema,
@@ -9,47 +9,35 @@ import {
} from "@models/productV2Models/productItemModels/productItemModels.js";
import { z } from "zod/v4";
export const APIProductItemSchema = z
.object({
// Feature stuff
type: z.enum(ProductItemType).nullish(),
feature_id: z.string().nullish(),
feature_type: z.enum(ProductItemFeatureType).nullish(),
export const ApiProductItemSchema = z.object({
// Feature stuff
type: z.enum(ProductItemType).nullish(),
feature_id: z.string().nullish(),
feature_type: z.enum(ProductItemFeatureType).nullish(),
// Feature response
feature: APIFeatureSchema.nullish(),
// Feature response
feature: ApiFeatureSchema.nullish(),
included_usage: z.number().or(z.literal(Infinite)).nullish(),
interval: z.enum(ProductItemInterval).nullish(),
interval_count: z.number().nullish(),
included_usage: z.number().or(z.literal(Infinite)).nullish(),
interval: z.enum(ProductItemInterval).nullish(),
interval_count: z.number().nullish(),
// Price config
price: z.number().nullish(),
tiers: z.array(PriceTierSchema).nullish(),
usage_model: z.enum(UsageModel).nullish(),
billing_units: z.number().nullish(), // amount per billing unit (eg. $9 / 250 units)
reset_usage_when_enabled: z.boolean().nullish(),
quantity: z.number().nullish(),
next_cycle_quantity: z.number().nullish(),
entity_feature_id: z.string().nullish(),
// Price config
price: z.number().nullish(),
tiers: z.array(PriceTierSchema).nullish(),
usage_model: z.enum(UsageModel).nullish(),
billing_units: z.number().nullish(), // amount per billing unit (eg. $9 / 250 units)
reset_usage_when_enabled: z.boolean().nullish(),
quantity: z.number().nullish(),
next_cycle_quantity: z.number().nullish(),
entity_feature_id: z.string().nullish(),
display: z
.object({
primary_text: z.string(),
secondary_text: z.string().nullish(),
})
.nullish(),
})
.meta({
id: "ProductItem",
description: "A product item that defines a feature",
example: {
feature_id: "feature_1",
feature_type: "single_use",
included_usage: 123,
interval: "monthly",
usage_model: "prepaid",
},
});
display: z
.object({
primary_text: z.string(),
secondary_text: z.string().nullish(),
})
.nullish(),
});
export type APIProductItem = z.infer<typeof APIProductItemSchema>;
export type ApiProductItem = z.infer<typeof ApiProductItemSchema>;

View File

@@ -7,50 +7,40 @@ export const CreateProductItemParamsSchema = ProductItemSchema;
// Base product params
export const CreateProductV2ParamsSchema = z
.object({
id: z.string().nonempty().regex(idRegex),
export const CreateProductV2ParamsSchema = z.object({
id: z.string().nonempty().regex(idRegex),
name: z.string().refine((val) => val.length > 0, {
name: z.string().refine((val) => val.length > 0, {
message: "name must be a non-empty string",
}),
is_add_on: z.boolean().default(false),
is_default: z.boolean().default(false),
version: z.number().optional(),
group: z.string().default(""),
items: z.array(CreateProductItemParamsSchema).optional(),
free_trial: CreateFreeTrialSchema.nullish().default(null),
});
export const UpdateProductV2ParamsSchema = z.object({
id: z.string().nonempty().regex(idRegex).optional(),
name: z
.string()
.refine((val) => val.length > 0, {
message: "name must be a non-empty string",
}),
})
.optional(),
is_add_on: z.boolean().default(false),
is_default: z.boolean().default(false),
version: z.number().optional(),
group: z.string().default(""),
is_add_on: z.boolean().optional(),
is_default: z.boolean().optional(),
version: z.number().optional(),
group: z.string().optional(),
archived: z.boolean().optional(),
items: z.array(CreateProductItemParamsSchema).optional(),
free_trial: CreateFreeTrialSchema.nullish().default(null),
})
.meta({
id: "CreateProductParams",
description: "Create Product",
});
export const UpdateProductV2ParamsSchema = z
.object({
id: z.string().nonempty().regex(idRegex).optional(),
name: z
.string()
.refine((val) => val.length > 0, {
message: "name must be a non-empty string",
})
.optional(),
is_add_on: z.boolean().optional(),
is_default: z.boolean().optional(),
version: z.number().optional(),
group: z.string().optional(),
archived: z.boolean().optional(),
items: z.array(CreateProductItemParamsSchema).optional(),
free_trial: CreateFreeTrialSchema.nullish(),
})
.meta({
id: "UpdateProductParams",
description: "Update Product",
});
items: z.array(CreateProductItemParamsSchema).optional(),
free_trial: CreateFreeTrialSchema.nullish(),
});
export const UpdateProductQuerySchema = z.object({
version: z.string().optional(),

View File

@@ -2,7 +2,13 @@ import {
CreateProductV2ParamsSchema,
UpdateProductV2ParamsSchema,
} from "@api/models.js";
import { APIProductSchema } from "./apiProduct.js";
import { ApiProductSchema } from "./apiProduct.js";
// Register schema with .meta() for OpenAPI spec generation
const ApiProductWithMeta = ApiProductSchema.meta({
id: "Product",
description: "A product",
});
export const productOps = {
"/products": {
@@ -17,7 +23,7 @@ export const productOps = {
responses: {
"200": {
description: "200 OK",
content: { "application/json": { schema: APIProductSchema } },
content: { "application/json": { schema: ApiProductWithMeta } },
},
},
},
@@ -32,7 +38,7 @@ export const productOps = {
responses: {
"200": {
description: "200 OK",
content: { "application/json": { schema: APIProductSchema } },
content: { "application/json": { schema: ApiProductWithMeta } },
},
},
},

View File

@@ -21,8 +21,8 @@ export enum AffectedResource {
*
* @example
* // Features changed from array to object in V1_2
* const V1_2_FeaturesSchema = z.record(z.string(), APICusFeatureSchema);
* const V1_1_FeaturesSchema = z.array(APICusFeatureSchema);
* const V1_2_FeaturesSchema = z.record(z.string(), ApiCusFeatureSchema);
* const V1_1_FeaturesSchema = z.array(ApiCusFeatureSchema);
*
* class V1_2_FeaturesArrayToObject extends VersionChange {
* version = ApiVersion.V1_2;

View File

@@ -1,5 +1,5 @@
import type { ApiVersion } from "../ApiVersion.js";
import type { ApiVersionClass } from "../ApiVersionClass.js";
import { type ApiVersion, LATEST_VERSION } from "../ApiVersion.js";
import { ApiVersionClass } from "../ApiVersionClass.js";
import { getVersionsBetween } from "../versionRegistryUtils.js";
import type { AffectedResource } from "./VersionChange.js";
import { VersionChangeRegistryClass } from "./VersionChangeRegistryClass.js";
@@ -12,12 +12,20 @@ import { VersionChangeRegistryClass } from "./VersionChangeRegistryClass.js";
*
* @param input - Data in the newest version format
* @param data - Additional context data for transformations (optional)
* @param currentVersion - Version of the input data
* @param currentVersion - Version of the input data (defaults to LATEST_VERSION)
* @param targetVersion - Version to transform to (older)
* @param resource - Resource being transformed
*
* @example
* // Data is in V1_2 format, transform to V1_1
* // Data is in latest format, transform to V1_1 (currentVersion defaults to latest)
* const v1_1_data = applyResponseVersionChanges({
* input: latestCustomer,
* targetVersion: new ApiVersionClass(ApiVersion.V1_1),
* resource: AffectedResource.Customer
* });
*
* @example
* // Explicitly specify currentVersion
* const v1_1_data = applyResponseVersionChanges({
* input: v1_2_customer,
* currentVersion: new ApiVersionClass(ApiVersion.V1_2),
@@ -35,19 +43,21 @@ export function applyResponseVersionChanges<T = any, TData = any>({
}: {
input: T;
data?: TData;
currentVersion: ApiVersionClass;
currentVersion?: ApiVersionClass;
targetVersion: ApiVersionClass;
resource: AffectedResource;
}): T {
// Default currentVersion to latest if not provided
const _currentVersion = currentVersion || new ApiVersionClass(LATEST_VERSION);
// If versions are equal, no transformation needed
if (currentVersion.eq(targetVersion)) {
if (_currentVersion.eq(targetVersion)) {
return input;
}
// If target is newer than current, throw error (can't transform forward)
if (targetVersion.gt(currentVersion)) {
if (targetVersion.gt(_currentVersion)) {
throw new Error(
`Cannot transform forward from ${currentVersion} to ${targetVersion}. ` +
`Cannot transform forward from ${_currentVersion} to ${targetVersion}. ` +
"Transforms only work backwards to older versions.",
);
}
@@ -55,7 +65,7 @@ export function applyResponseVersionChanges<T = any, TData = any>({
// Get all versions between current and target (exclusive of target, inclusive of current)
const versionsToApply = getVersionsBetween({
from: targetVersion.value,
to: currentVersion.value,
to: _currentVersion.value,
}).filter((v) => v !== targetVersion.value); // Exclude target itself
// Sort versions from newest to oldest (we apply backwards)
@@ -204,12 +214,14 @@ export function applyRequestVersionChanges<T = any, TData = any>({
* expandArray.push(CusExpand.Invoices);
* }
*/
export function isChangeActive(
targetVersion: ApiVersionClass,
// biome-ignore lint/suspicious/noExplicitAny: Generic version change class constructor
changeClass: new () => any,
): boolean {
const change = new changeClass();
export function isBeforeChange({
targetVersion,
versionChange,
}: {
targetVersion: ApiVersionClass;
versionChange: new () => any;
}): boolean {
const change = new versionChange();
return targetVersion.lt(change.version);
}
@@ -226,7 +238,7 @@ export function applyResponseVersionChangesToArray<T = any, TData = any>({
}: {
inputArray: T[];
data?: TData;
currentVersion: ApiVersionClass;
currentVersion?: ApiVersionClass;
targetVersion: ApiVersionClass;
resource: AffectedResource;
}): T[] {

View File

@@ -6,11 +6,45 @@
export type { ApiVersionString } from "./ApiVersion.js";
export { API_VERSIONS, ApiVersion, LATEST_VERSION } from "./ApiVersion.js";
export { ApiVersionClass } from "./ApiVersionClass.js";
// Conversion utilities
export {
calVerToSemVer,
legacyToSemVer,
parseVersion,
semVerToCalVer,
semVerToLegacy,
} from "./convertVersionUtils.js";
// Org-specific utilities (deprecated)
export { getOrgApiVersion, toLegacyVersion } from "./orgVersionUtils.js";
// Branching utilities
export {
ifVersion,
requireVersion,
versionRange,
versionSwitch,
versionTernary,
} from "./versionBranchUtils.js";
export {
applyRequestVersionChanges,
applyRequestVersionChangesToArray,
applyResponseVersionChanges,
// Deprecated aliases (for backward compatibility)
applyResponseVersionChanges as applyVersionChanges,
applyResponseVersionChangesToArray,
applyResponseVersionChangesToArray as applyVersionChangesToArray,
getChangesForResource,
isBeforeChange,
} from "./versionChangeUtils/applyVersionChanges.js";
// Version changes
export {
AffectedResource,
VersionChange,
type VersionChangeConstructor,
} from "./versionChangeUtils/VersionChange.js";
export { VersionChangeRegistryClass } from "./versionChangeUtils/VersionChangeRegistryClass.js";
// Version registry
export type { VersionMetadata } from "./versionRegistry.js";
export { VERSION_REGISTRY } from "./versionRegistry.js";
// Version registry utilities
export {
CALVER_TO_SEMVER_MAP,
@@ -20,45 +54,5 @@ export {
isValidVersion,
} from "./versionRegistryUtils.js";
// Conversion utilities
export {
calVerToSemVer,
legacyToSemVer,
parseVersion,
semVerToCalVer,
semVerToLegacy,
} from "./convertVersionUtils.js";
// Org-specific utilities (deprecated)
export { getOrgApiVersion, toLegacyVersion } from "./orgVersionUtils.js";
// Branching utilities
export {
ifVersion,
requireVersion,
versionRange,
versionSwitch,
versionTernary,
} from "./versionBranchUtils.js";
// Version changes
export {
AffectedResource,
VersionChange,
type VersionChangeConstructor,
} from "./versionChangeUtils/VersionChange.js";
export { VersionChangeRegistryClass } from "./versionChangeUtils/VersionChangeRegistryClass.js";
export {
applyResponseVersionChanges,
applyResponseVersionChangesToArray,
applyRequestVersionChanges,
applyRequestVersionChangesToArray,
getChangesForResource,
isChangeActive,
// Deprecated aliases (for backward compatibility)
applyResponseVersionChanges as applyVersionChanges,
applyResponseVersionChangesToArray as applyVersionChangesToArray,
} from "./versionChangeUtils/applyVersionChanges.js";
// Auto-register all version changes
import "./versionChangeUtils/versionChangeRegistry.js";

View File

@@ -1,4 +1,4 @@
import type { APIProduct } from "@api/products/apiProduct.js";
import type { ApiProduct } from "@api/products/apiProduct.js";
import type { Infinite } from "../productModels/productEnums.js";
import type { UsageModel } from "../productV2Models/productItemModels/productItemModels.js";
@@ -59,7 +59,7 @@ export interface CheckProductPreview {
currency: string;
};
product?: APIProduct;
product?: ApiProduct;
payment_method?: any;
}
@@ -76,5 +76,5 @@ export interface CheckFeaturePreview {
feature_id: string;
feature_name: string;
products: APIProduct[];
products: ApiProduct[];
}

View File

@@ -1,5 +1,5 @@
import { APICusProductSchema } from "@api/customers/components/apiCusProduct.js";
import { APICusFeatureSchema } from "@api/customers/cusFeatures/apiCusFeature.js";
import { ApiCusFeatureSchema } from "@api/customers/cusFeatures/apiCusFeature.js";
import { APIInvoiceSchema } from "@api/others/apiInvoice.js";
import { z } from "zod/v4";
import { AppEnv } from "../../genModels/genEnums.js";
@@ -13,7 +13,7 @@ export const EntityResponseSchema = z.object({
created_at: z.number(),
env: z.enum(AppEnv),
products: z.array(APICusProductSchema).optional(),
features: z.record(z.string(), APICusFeatureSchema).optional(),
features: z.record(z.string(), ApiCusFeatureSchema).optional(),
invoices: z.array(APIInvoiceSchema).optional(),
});

View File

@@ -1,4 +1,4 @@
import { FullCustomerEntitlement } from "../../models/cusProductModels/cusEntModels/cusEntModels.js";
import type { FullCustomerEntitlement } from "../../models/cusProductModels/cusEntModels/cusEntModels.js";
import { notNullish, nullish } from "../utils.js";
export const getSummedEntityBalances = ({
@@ -16,16 +16,16 @@ export const getSummedEntityBalances = ({
}
return {
balance: Object.values(cusEnt.entities!).reduce(
balance: Object.values(cusEnt.entities).reduce(
(acc, curr) => acc + curr.balance,
0,
),
adjustment: Object.values(cusEnt.entities!).reduce(
adjustment: Object.values(cusEnt.entities).reduce(
(acc, curr) => acc + curr.adjustment,
0,
),
unused: 0,
count: Object.values(cusEnt.entities!).length,
count: Object.values(cusEnt.entities).length,
};
};
@@ -36,9 +36,7 @@ export const getCusEntBalance = ({
cusEnt: FullCustomerEntitlement;
entityId?: string | null;
}) => {
let entitlement = cusEnt.entitlement;
let ent = cusEnt.entitlement;
let feature = ent.feature;
const entitlement = cusEnt.entitlement;
if (notNullish(entitlement.entity_feature_id)) {
if (nullish(entityId)) {
@@ -46,8 +44,8 @@ export const getCusEntBalance = ({
cusEnt,
});
} else {
let entityBalance = cusEnt.entities?.[entityId!]?.balance;
let adjustment = cusEnt.entities?.[entityId!]?.adjustment || 0;
const entityBalance = cusEnt.entities?.[entityId]?.balance;
const adjustment = cusEnt.entities?.[entityId]?.adjustment || 0;
if (nullish(entityBalance)) {
return { balance: 0, adjustment: 0, unused: 0, count: 1 };
@@ -63,8 +61,8 @@ export const getCusEntBalance = ({
}
return {
balance: cusEnt.balance,
adjustment: cusEnt.adjustment,
balance: cusEnt.balance || 0,
adjustment: cusEnt.adjustment || 0,
unused: cusEnt.replaceables?.length || 0,
count: 1,
};

View File

@@ -0,0 +1,107 @@
import { Decimal } from "decimal.js";
import {
entToOptions,
type FullCusEntWithFullCusProduct,
type FullCustomerEntitlement,
getCusEntBalance,
getStartingBalance,
} from "../../index.js";
import { cusEntToCusPrice } from "../productUtils/convertUtils.js";
import { getRolloverFields } from "./getRolloverFields.js";
export const cusEntToKey = ({
cusEnt,
}: {
cusEnt: FullCustomerEntitlement;
}) => {
const ent = cusEnt.entitlement;
return `${ent.interval || "null"}-${ent.interval_count || 1}-${ent.feature.id}`;
};
export const cusEntToBalance = ({
cusEnt,
entityId,
withRollovers = false,
}: {
cusEnt: FullCustomerEntitlement;
entityId?: string;
withRollovers?: boolean;
}) => {
const { balance } = getCusEntBalance({
cusEnt,
entityId,
});
const rollover = getRolloverFields({
cusEnt,
entityId,
});
if (withRollovers && rollover) {
return balance + rollover.balance;
}
return balance;
};
export const cusEntToIncludedUsage = ({
cusEnt,
entityId,
withRollovers = false,
}: {
cusEnt: FullCusEntWithFullCusProduct;
entityId?: string;
withRollovers?: boolean;
}) => {
const rollover = getRolloverFields({
cusEnt,
entityId,
});
const { count: entityCount } = getCusEntBalance({
cusEnt,
entityId,
});
const cusProduct = cusEnt.customer_product;
const options = entToOptions({
ent: cusEnt.entitlement,
options: cusProduct.options,
});
const cusPrice = cusEntToCusPrice({ cusEnt });
const startingBalance = getStartingBalance({
entitlement: cusEnt.entitlement,
options: options || undefined,
relatedPrice: cusPrice?.price,
productQuantity: cusProduct.quantity || 1,
});
const total = new Decimal(startingBalance).mul(entityCount).toNumber();
if (withRollovers && rollover) {
return total + rollover.balance + rollover.usage;
}
return total;
// if (rollover) {
// total = new Decimal(total)
// .add(rollover.balance)
// .add(rollover.usage)
// .toNumber();
// }
};
export const cusEntToUsageLimit = ({
cusEnt,
}: {
cusEnt: FullCusEntWithFullCusProduct;
}) => {
const startingBalance = cusEntToIncludedUsage({
cusEnt,
});
if (cusEnt.entitlement.usage_limit) return cusEnt.entitlement.usage_limit;
return startingBalance;
};

View File

@@ -0,0 +1,9 @@
import type { FullCustomerEntitlement } from "@models/cusProductModels/cusEntModels/cusEntModels.js";
export const formatCusEnt = ({
cusEnt,
}: {
cusEnt: FullCustomerEntitlement;
}) => {
return `${cusEnt.entitlement.feature_id} (${cusEnt.entitlement.interval}) (${cusEnt.balance})`;
};

View File

@@ -0,0 +1,106 @@
import type { FullCustomerEntitlement } from "@models/cusProductModels/cusEntModels/cusEntModels.js";
import { notNullish } from "@utils/utils.js";
type CusRolloverInfo = {
balance: number;
usage: number;
expires_at: number | null;
};
interface RolloverFields {
balance: number;
usage: number;
rollovers: CusRolloverInfo[];
}
export const getRolloverFields = ({
cusEnt,
entityId,
}: {
cusEnt: FullCustomerEntitlement;
entityId?: string;
}): RolloverFields | undefined => {
const hasRollover = notNullish(cusEnt.entitlement.rollover);
if (!hasRollover) return;
const rollovers = cusEnt.rollovers || [];
if (cusEnt.entitlement.entity_feature_id) {
if (entityId) {
return rollovers.reduce(
(acc, rollover) => {
if (rollover.entities[entityId]) {
return {
balance: acc.balance + rollover.entities[entityId].balance,
usage: acc.usage + rollover.entities[entityId].usage,
rollovers: [
...acc.rollovers,
{
balance: rollover.entities[entityId].balance,
usage: rollover.entities[entityId].usage,
expires_at: rollover.expires_at,
},
],
};
}
return acc;
},
{
balance: 0,
usage: 0,
rollovers: [] as CusRolloverInfo[],
},
);
} else {
return rollovers.reduce(
(acc, rollover) => {
let newBalance = 0;
let newUsage = 0;
for (const entityId in rollover.entities) {
newBalance += rollover.entities[entityId].balance;
newUsage += rollover.entities[entityId].usage;
}
return {
balance: acc.balance + newBalance,
usage: acc.usage + newUsage,
rollovers: [
...acc.rollovers,
{
balance: newBalance,
usage: newUsage,
expires_at: rollover.expires_at,
},
],
};
},
{
balance: 0,
usage: 0,
rollovers: [] as CusRolloverInfo[],
},
);
}
} else {
return rollovers.reduce(
(acc, rollover) => {
return {
balance: acc.balance + rollover.balance,
usage: acc.usage + rollover.usage,
rollovers: [
...acc.rollovers,
{
balance: rollover.balance,
usage: rollover.usage,
expires_at: rollover.expires_at,
},
],
};
},
{
balance: 0,
usage: 0,
rollovers: [] as CusRolloverInfo[],
},
);
}
};

View File

@@ -0,0 +1,45 @@
import type { FeatureOptions } from "@models/cusProductModels/cusProductModels.js";
import type { Entitlement } from "@models/productModels/entModels/entModels.js";
import { BillingType } from "@models/productModels/priceModels/priceEnums.js";
import type { Price } from "@models/productModels/priceModels/priceModels.js";
import { getBillingType } from "@utils/productUtils/priceUtils.js";
import { nullish } from "@utils/utils.js";
export const getStartingBalance = ({
entitlement,
options,
relatedPrice,
productQuantity,
}: {
entitlement: Entitlement;
options?: FeatureOptions;
relatedPrice?: Price;
productQuantity?: number;
}) => {
// 1. No related price
if (!relatedPrice) {
return (entitlement.allowance || 0) * (productQuantity || 1);
}
const config = relatedPrice.config;
const billingType = getBillingType(config);
if (billingType !== BillingType.UsageInAdvance) {
return entitlement.allowance || 0;
}
const quantity = options?.quantity;
const billingUnits = relatedPrice.config.billing_units;
if (nullish(quantity) || nullish(billingUnits)) {
return entitlement.allowance || 0;
}
try {
return (entitlement.allowance || 0) + quantity * billingUnits;
} catch (_error) {
console.log(
"WARNING: Failed to return quantity * billing units, returning allowance...",
);
return entitlement.allowance || 0;
}
};

View File

@@ -1,9 +1,9 @@
import { APIFeatureSchema } from "@api/features/apiFeature.js";
import { ApiFeatureSchema } from "@api/features/apiFeature.js";
import type { CreditSchemaItem } from "../models/featureModels/featureConfig/creditConfig.js";
import { FeatureType } from "../models/featureModels/featureEnums.js";
import type { Feature } from "../models/featureModels/featureModels.js";
export const toAPIFeature = ({ feature }: { feature: Feature }) => {
export const toApiFeature = ({ feature }: { feature: Feature }) => {
// return FeatureResponseSchema.parse(feature);
// 1. Get feature type
let featureType = feature.type;
@@ -19,7 +19,7 @@ export const toAPIFeature = ({ feature }: { feature: Feature }) => {
}));
}
return APIFeatureSchema.parse({
return ApiFeatureSchema.parse({
id: feature.id,
name: feature.name,
type: featureType,

View File

@@ -2,6 +2,9 @@
// Cus ent utils
export * from "./cusEntUtils/balanceUtils.js";
export * from "./cusEntUtils/convertCusEntUtils.js";
export * from "./cusEntUtils/cusEntUtils.js";
export * from "./cusEntUtils/getStartingBalance.js";
export * from "./cusEntUtils/sortCusEntsForDeduction.js";
// Cus product utils
export * from "./cusProductUtils/classifyCusProduct.js";

View File

@@ -1,4 +1,4 @@
import { APIProductItemSchema } from "@api/products/apiProductItem.js";
import { ApiProductItemSchema } from "@api/products/apiProductItem.js";
import { Decimal } from "decimal.js";
import type { FeatureOptions } from "../../models/cusProductModels/cusProductModels.js";
import type { Feature } from "../../models/featureModels/featureModels.js";
@@ -6,7 +6,7 @@ import {
type ProductItem,
UsageModel,
} from "../../models/productV2Models/productItemModels/productItemModels.js";
import { toAPIFeature } from "../featureUtils.js";
import { toApiFeature } from "../featureUtils.js";
import { getProductItemDisplay } from "../productDisplayUtils.js";
import { notNullish } from "../utils.js";
import { getItemType } from "./getItemType.js";
@@ -135,10 +135,10 @@ export const getProductItemResponse = ({
}
const feature = features.find((f) => f.id === item.feature_id);
return APIProductItemSchema.parse({
return ApiProductItemSchema.parse({
type,
...item,
feature: feature ? toAPIFeature({ feature }) : null,
feature: feature ? toApiFeature({ feature }) : null,
display: withDisplay ? display : undefined,
...priceData,
quantity,

View File

@@ -1,3 +1,6 @@
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 {
Entitlement,
EntitlementWithFeature,
@@ -54,3 +57,32 @@ export const priceToEnt = ({
ent.internal_product_id === price.internal_product_id,
);
};
export const entToOptions = ({
ent,
options,
}: {
ent: Entitlement;
options: FeatureOptions[];
}) => {
return options.find(
(option) => option.internal_feature_id === ent.internal_feature_id,
);
};
export const cusEntToCusPrice = ({
cusEnt,
}: {
cusEnt: FullCusEntWithFullCusProduct;
}) => {
const cusProduct = cusEnt.customer_product;
const cusPrices = cusProduct.customer_prices;
return cusPrices.find((cusPrice: FullCustomerPrice) => {
const productMatch =
cusPrice.customer_product_id === cusEnt.customer_product_id;
const entMatch = cusPrice.price.entitlement_id === cusEnt.entitlement.id;
return productMatch && entMatch;
});
};

View File

@@ -8,3 +8,7 @@ export const notNullish = <T>(value: T | null | undefined): value is T =>
value !== null && value !== undefined;
export const idRegex = /^[a-zA-Z0-9_-]+$/;
export const sumValues = (vals: number[]) => {
return vals.reduce((acc, curr) => acc + curr, 0);
};

View File

@@ -1,5 +1,5 @@
import {
APIFeatureType,
ApiFeatureType,
type CreateFeature,
FeatureType,
} from "@autumn/shared";
@@ -18,12 +18,12 @@ export const SelectFeatureType = ({
const featureType = feature.type;
const usageType = feature.config?.usage_type;
const setFeatureType = (type: APIFeatureType) => {
const setFeatureType = (type: ApiFeatureType) => {
// 1. If type is boolean
if (type === APIFeatureType.Boolean) {
if (type === ApiFeatureType.Boolean) {
setFeature({
...feature,
type: APIFeatureType.Boolean,
type: ApiFeatureType.Boolean,
config: undefined,
});
} else {
@@ -73,7 +73,7 @@ export const SelectFeatureType = ({
description="Features that are either enabled or disabled, like 'premium models'"
icon={<ToggleLeft className="h-3 w-3 text-t3" />}
isSelected={featureType === FeatureType.Boolean}
onClick={() => setFeatureType(APIFeatureType.Boolean)}
onClick={() => setFeatureType(ApiFeatureType.Boolean)}
/> */}
</div>
</div>

View File

@@ -1,5 +1,5 @@
import {
APIFeatureType,
ApiFeatureType,
type CreateFeature,
FeatureType,
FeatureUsageType,
@@ -19,12 +19,12 @@ export const SelectFeatureUsageType = ({
const featureType = feature.type;
const usageType = feature.config?.usage_type;
const setFeatureType = (type: APIFeatureType) => {
const setFeatureType = (type: ApiFeatureType) => {
// 1. If type is boolean
if (type === APIFeatureType.Boolean) {
if (type === ApiFeatureType.Boolean) {
setFeature({
...feature,
type: APIFeatureType.Boolean,
type: ApiFeatureType.Boolean,
config: undefined,
});
} else {
@@ -47,7 +47,7 @@ export const SelectFeatureUsageType = ({
featureType === FeatureType.Metered &&
usageType === FeatureUsageType.Single
}
onClick={() => setFeatureType(APIFeatureType.SingleUsage)}
onClick={() => setFeatureType(ApiFeatureType.SingleUsage)}
/>
<SelectType
title="Continuous"
@@ -57,14 +57,14 @@ export const SelectFeatureUsageType = ({
featureType === FeatureType.Metered &&
usageType === FeatureUsageType.Continuous
}
onClick={() => setFeatureType(APIFeatureType.ContinuousUse)}
onClick={() => setFeatureType(ApiFeatureType.ContinuousUse)}
/>
{/* <SelectType
title="Boolean"
description="Features that are either enabled or disabled, like 'premium models'"
icon={<ToggleLeft className="h-3 w-3 text-t3" />}
isSelected={featureType === FeatureType.Boolean}
onClick={() => setFeatureType(APIFeatureType.Boolean)}
onClick={() => setFeatureType(ApiFeatureType.Boolean)}
/> */}
</div>
</div>