feat: created new features API version

This commit is contained in:
John Yeo
2025-11-14 15:59:39 +00:00
parent 2818c1687c
commit 4e68c3dc9a
138 changed files with 2561 additions and 2967 deletions

View File

@@ -25,6 +25,8 @@
- Always prefer foo({ bar }) over foo(bar) method signatures - no matter if we are using only one argument or not, object as param are always better, as in the future when wanting to change the order of parameters, or add new ones - its easier.
- **ALWAYS use `c.req.param()` to get route parameters in Hono handlers**, NOT `c.req.valid("param")`. Example: `const { customer_id } = c.req.param();`
- When creating "hooks" folders, don't nest them under "components"
- Functions (unless there's a very good reason) should always take in objects as arguments. Object params are named and easy to understand.

View File

@@ -31,6 +31,8 @@
- Do not run "npx tsc" - run "tsc" instead as it is installed globally.
- **ALWAYS use `.meta()` for zod-openapi schema registration**, NOT `.openapi()`. Example: `ApiProductSchema.meta({ id: "Product" })`
- **ALWAYS use `c.req.param()` to get route parameters in Hono handlers**, NOT `c.req.valid("param")`. Example: `const { customer_id } = c.req.param();`
## Error Handling in API Routes
- NEVER use `c.json({ message: "...", code: "..." }, statusCode)` pattern for input validation or expected errors in Hono routes
- ALWAYS throw `RecaseError` from `@autumn/shared` for all validation errors, not found errors, forbidden errors, etc.

View File

@@ -589,10 +589,6 @@ export class AutumnInt {
return data;
};
initStripe = async () => {
await this.post(`/products/all/init_stripe`, {});
};
balances = {
update: async (params: BalancesUpdateParams) => {
const data = await this.post(`/balances/update`, params);

View File

@@ -496,8 +496,4 @@ export class AutumnCliV2 {
}) => {
return await this.post(`/migrations`, params);
};
initStripe = async () => {
await this.post(`/products/all/init_stripe`, {});
};
}

View File

@@ -3,9 +3,9 @@ import type { Context, Env, Handler, MiddlewareHandler } from "hono";
import type { ZodType, z } from "zod/v4";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { expandMiddleware } from "./expandMiddleware.js";
import { validator } from "./validatorMiddleware.js";
import { versionedValidator } from "./versionedValidator.js";
import { expandMiddleware } from "./expandMiddleware.js";
/**
* Extended context type that includes validated input
@@ -56,7 +56,7 @@ type VersionedSchemas<T extends ZodType> = Partial<
* handler: async (c) => {
* const body = c.req.valid("json"); // ✅ Fully typed!
* const query = c.req.valid("query"); // ✅ Fully typed!
* const params = c.req.valid("param"); // ✅ Fully typed!
* const params = c.req.param(); // ✅ Fully typed!
* return c.json({ success: true });
* }
* });

View File

@@ -100,6 +100,7 @@ export const versionedValidator = ({
c.req.addValidatedData(target, validatedData);
} else {
// For other targets, use zValidator
const validatorMiddleware = zValidator(target, schema, (result, _c) => {
if (!result.success) {
// Validation errors reference fields from user's version ✅

View File

@@ -18,6 +18,7 @@ import { billingRouter } from "./internal/billing/billingRouter.js";
import { cusRouter } from "./internal/customers/cusRouter.js";
import { internalCusRouter } from "./internal/customers/internalCusRouter.js";
import { entityRouter } from "./internal/entities/entityRouter.js";
import { featureRouter } from "./internal/features/featureRouter.js";
import { handleOAuthCallback } from "./internal/orgs/handlers/stripeHandlers/handleOAuthCallback.js";
import { honoOrgRouter } from "./internal/orgs/orgRouter.js";
import { platformBetaRouter } from "./internal/platform/platformBeta/platformBetaRouter.js";
@@ -120,6 +121,7 @@ export const createHonoApp = () => {
app.route("v1/products_beta", honoProductBetaRouter);
app.route("v1/products", honoProductRouter);
app.route("v1/plans", honoProductRouter);
app.route("v1/features", featureRouter);
app.route("v1", balancesRouter);
app.route("v1/platform", platformBetaRouter);

View File

@@ -7,17 +7,10 @@ import { refreshCacheMiddleware } from "@/middleware/refreshCacheMiddleware.js";
import { analyticsRouter } from "../analytics/analyticsRouter.js";
import { attachRouter } from "../customers/attach/attachRouter.js";
import cancelRouter from "../customers/cancel/cancelRouter.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";
import { handleGetOrg } from "../orgs/handlers/handleGetOrg.js";
import { platformRouter } from "../platform/platformLegacy/platformRouter.js";
import { productRouter } from "../products/productRouter.js";
import { componentRouter } from "./components/componentRouter.js";
import { usageRouter } from "./events/usageRouter.js";
import { invoiceRouter } from "./invoiceRouter.js";
import { redemptionRouter, referralRouter } from "./rewards/referralRouter.js";
import { rewardProgramRouter } from "./rewards/rewardProgramRouter.js";
@@ -31,15 +24,9 @@ apiRouter.use(analyticsMiddleware);
apiRouter.use(expressApiVersionMiddleware as any);
apiRouter.use(refreshCacheMiddleware);
apiRouter.use("/customers", expressCusRouter);
apiRouter.use("/invoices", invoiceRouter);
apiRouter.use("/products", productRouter);
apiRouter.use("/components", componentRouter);
apiRouter.use("/rewards", rewardRouter);
apiRouter.use("/features", featureRouter);
apiRouter.use("/internal_features", internalFeatureRouter);
apiRouter.use("/usage", usageRouter);
// REWARDS
apiRouter.use("/reward_programs", rewardProgramRouter);
@@ -50,14 +37,6 @@ apiRouter.use("/redemptions", redemptionRouter);
apiRouter.use("", attachRouter);
apiRouter.use("/cancel", cancelRouter);
// apiRouter.use("/entitled", checkRouter);
// apiRouter.use("/check", checkRouter);
// apiRouter.use("/usage", usageRouter);
// apiRouter.use("/events", eventsRouter);
// apiRouter.use("/track", eventsRouter);
apiRouter.post("/billing_portal", handleCreateBillingPortal);
// Analytics
apiRouter.use("/query", analyticsRouter);
apiRouter.use("/platform", platformRouter);
@@ -68,3 +47,7 @@ apiRouter.use("/platform", platformRouter);
apiRouter.get("/organization", handleGetOrg);
export { apiRouter };
// Features
// type: boolean, metered or credit system
// resets_periodically: true / false

View File

@@ -1,14 +1,14 @@
import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.js";
import type { ProductV2 } from "@autumn/shared";
import { Router } from "express";
import { CusService } from "@/internal/customers/CusService.js";
import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.js";
import { FeatureService } from "@/internal/features/FeatureService.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { toPricecnProduct } from "@/internal/products/pricecn/pricecnUtils.js";
import { ProductService } from "@/internal/products/ProductService.js";
import { isProductUpgrade } from "@/internal/products/productUtils.js";
import { toPricecnProduct } from "@/internal/products/pricecn/pricecnUtils.js";
import { getProductResponse } from "@/internal/products/productUtils/productResponseUtils/getProductResponse.js";
import { isProductUpgrade } from "@/internal/products/productUtils.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { ProductV2 } from "@autumn/shared";
import { Router } from "express";
export const componentRouter: Router = Router();
@@ -19,7 +19,7 @@ componentRouter.get("/pricing_table", async (req: any, res) =>
action: "get pricing table",
handler: async () => {
const { orgId, env, db } = req;
let customerId = req.query.customer_id;
const customerId = req.query.customer_id;
const [org, features, products, customer] = await Promise.all([
OrgService.getFromReq(req),
@@ -45,7 +45,7 @@ componentRouter.get("/pricing_table", async (req: any, res) =>
// 1. Sort products by price
products.sort((a, b) => {
let isUpgradeA = isProductUpgrade({
const isUpgradeA = isProductUpgrade({
prices1: a.prices,
prices2: b.prices,
usageAlwaysUpgrade: false,
@@ -58,13 +58,13 @@ componentRouter.get("/pricing_table", async (req: any, res) =>
}
});
let batchResponse = [];
for (let p of products) {
let prod = await getProductResponse({ product: p, features });
const batchResponse = [];
for (const p of products) {
const prod = await getProductResponse({ product: p, features });
let curMainProduct, curScheduledProduct;
if (customer) {
let res = getExistingCusProducts({
const res = getExistingCusProducts({
product: p,
cusProducts: customer.customer_products,
});
@@ -82,13 +82,13 @@ componentRouter.get("/pricing_table", async (req: any, res) =>
features,
curMainProduct,
curScheduledProduct,
otherProducts: products.filter((other) => other.id != p.id),
otherProducts: products.filter((other) => other.id !== p.id),
fullCus: customer,
}),
);
}
let pricecnProds = await Promise.all(batchResponse);
const pricecnProds = await Promise.all(batchResponse);
// let pricecnProds = await Promise.all(
// products

View File

@@ -80,6 +80,7 @@ export const syncItem = async ({
// For sync, we need the raw balance for that specific scope (not merged)
let redisEntity: ApiCustomer | ApiEntityV1;
ctx.skipCache = false;
if (entityId) {
const { apiEntity } = await getCachedApiEntity({
ctx,
@@ -97,6 +98,8 @@ export const syncItem = async ({
redisEntity = apiCustomer;
}
console.log("Redis entity: ", redisEntity);
// Get fresh customer from DB (no locking - let deduction handle it)
const fullCus = await CusService.getFull({
db,

View File

@@ -180,7 +180,7 @@ export const initCusEntitlement = ({
if (notNullish(productOptions?.quantity) && notNullish(newBalance)) {
newBalance = new Decimal(newBalance!)
.mul(productOptions?.quantity!)
.mul(productOptions?.quantity || 1)
.toNumber();
}

View File

@@ -1,13 +1,13 @@
import { DrizzleCli } from "@/db/initDrizzle.js";
import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { FullCusProduct } from "@autumn/shared";
import Stripe from "stripe";
import type { FullCusProduct } from "@autumn/shared";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { payForInvoice } from "@/external/stripe/stripeInvoiceUtils.js";
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { insertInvoiceFromAttach } from "@/internal/invoices/invoiceUtils.js";
import {
createUsageInvoiceItems,
resetUsageBalances,
} from "./createUsageInvoiceItems.js";
import { payForInvoice } from "@/external/stripe/stripeInvoiceUtils.js";
import { insertInvoiceFromAttach } from "@/internal/invoices/invoiceUtils.js";
export const createUsageInvoice = async ({
db,

View File

@@ -25,11 +25,7 @@ import {
isFreeProduct,
} from "@/internal/products/productUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import {
notNullish,
notNullOrUndefined,
nullOrUndefined,
} from "@/utils/genUtils.js";
import { notNullish, nullOrUndefined } from "@/utils/genUtils.js";
import { handleCheckout } from "./checkout/handleCheckout.js";
import { handleAttach } from "./handleAttach.js";
import { handleAttachPreview } from "./handleAttachPreview/handleAttachPreview.js";
@@ -79,7 +75,7 @@ export const handlePrepaidErrors = async ({
}
// 3. Quantity cannot be negative
if (notNullish(options?.quantity) && options?.quantity! < 0) {
if (notNullish(options?.quantity) && options?.quantity < 0) {
throw new RecaseError({
message: `Quantity cannot be negative`,
code: ErrCode.InvalidOptions,
@@ -249,7 +245,7 @@ export const customerHasPm = async ({
stripeId: attachParams.customer.processor?.id,
});
return notNullOrUndefined(paymentMethod) ? true : false;
return notNullish(paymentMethod);
};
attachRouter.post("/attach", handleAttach);

View File

@@ -30,7 +30,7 @@ export const getPricesAndEnts = async ({
customer: FullCustomer;
products: FullProduct[];
}) => {
const { options: optionsInput, is_custom, items, free_trial } = attachBody;
const { options: optionsInput, is_custom, free_trial } = attachBody;
const { features, db, org, logger } = req;
const { curMainProduct, curSameProduct } = getExistingCusProducts({

View File

@@ -1,11 +1,10 @@
import { DrizzleCli } from "@/db/initDrizzle.js";
import type { AppEnv, Customer, Organization } from "@autumn/shared";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import {
createStripeCusIfNotExists,
listCusPaymentMethods,
} from "@/external/stripe/stripeCusUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import { AppEnv, Customer, ErrCode, Organization } from "@autumn/shared";
import Stripe from "stripe";
export const getStripeCusData = async ({
stripeCli,
@@ -28,7 +27,7 @@ export const getStripeCusData = async ({
return { stripeCus: undefined, paymentMethod: null, now: undefined };
}
let stripeCus = (await createStripeCusIfNotExists({
const stripeCus = (await createStripeCusIfNotExists({
db,
org,
env,
@@ -36,16 +35,16 @@ export const getStripeCusData = async ({
logger,
})) as Stripe.Customer;
let testClock = stripeCus.test_clock as Stripe.TestHelpers.TestClock | null;
const testClock = stripeCus.test_clock as Stripe.TestHelpers.TestClock | null;
// let now = testClock ? testClock.frozen_time * 1000 : Date.now();
let now = testClock ? testClock.frozen_time * 1000 : undefined;
const now = testClock ? testClock.frozen_time * 1000 : undefined;
let paymentMethod = stripeCus.invoice_settings
?.default_payment_method as Stripe.PaymentMethod | null;
if (!paymentMethod) {
let paymentMethods = await listCusPaymentMethods({
const paymentMethods = await listCusPaymentMethods({
stripeCli,
stripeId: stripeCus.id,
});

View File

@@ -39,7 +39,7 @@ export const initNextResetAt = ({
// 2. If nextResetAt is provided, return it...
if (nextResetAt) return nextResetAt;
// 3. Calculate next reset at...
// 3. Get next reset at:
let nextResetAtCalculated = null;
const trialEndTimestamp = trialEndsAt
? Math.round(trialEndsAt / 1000)
@@ -47,29 +47,41 @@ export const initNextResetAt = ({
? freeTrialToStripeTimestamp({ freeTrial, now })
: null;
if (
freeTrial &&
applyTrialToEntitlement(entitlement, freeTrial) &&
trialEndTimestamp
) {
const shouldApplyTrial = applyTrialToEntitlement(entitlement, freeTrial);
// console.log(
// "Trial end timestamp: ",
// formatUnixToDateTime(trialEndTimestamp! * 1000),
// );
// console.log("Should apply trial: ", shouldApplyTrial);
// console.log("Anchor to unix: ", formatUnixToDateTime(anchorToUnix!));
if (freeTrial && shouldApplyTrial && trialEndTimestamp) {
nextResetAtCalculated = new UTCDate(trialEndTimestamp! * 1000);
}
const resetInterval = entitlement.interval as EntInterval;
const startDate = nextResetAtCalculated || new UTCDate(now);
nextResetAtCalculated = getNextEntitlementReset(
nextResetAtCalculated || new UTCDate(now),
startDate,
resetInterval,
entitlement.interval_count || 1,
).getTime();
// console.log(
// "Next reset at calculated: ",
// formatUnixToDateTime(nextResetAtCalculated),
// );
// If anchorToUnix, align next reset at to anchorToUnix...
if (
anchorToUnix &&
nextResetAtCalculated &&
Object.values(BillingInterval).includes(
entitlement.interval as unknown as BillingInterval,
)
) &&
!shouldApplyTrial
) {
nextResetAtCalculated = getAlignedUnix({
anchor: anchorToUnix,

View File

@@ -1,122 +1,30 @@
import { ErrCode } from "@autumn/shared";
import { Router } from "express";
import { Hono } from "hono";
import { StatusCodes } from "http-status-codes";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { createStripeCusIfNotExists } from "@/external/stripe/stripeCusUtils.js";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import RecaseError, { handleRequestError } from "@/utils/errorUtils.js";
import { toSuccessUrl } from "../orgs/orgUtils/convertOrgUtils.js";
import { CusService } from "./CusService.js";
import { handleAddCouponToCus } from "./handlers/handleAddCouponToCus.js";
import { handleCreateBillingPortal } from "./handlers/handleCreateBillingPortal.js";
import { handleDeleteCustomer } from "./handlers/handleDeleteCustomer.js";
import { handleAddCouponToCusV2 } from "./handlers/handleAddCouponToCusV2.js";
import { handleCreateBillingPortal } from "./handlers/handleBillingPortal/handleCreateBillingPortal.js";
import { handleDeleteCustomerV2 } from "./handlers/handleDeleteCustomerV2.js";
import { handleGetCustomerV2 } from "./handlers/handleGetCustomerV2.js";
import { handleListCustomers } from "./handlers/handleListCustomers.js";
import { handlePostCustomer } from "./handlers/handlePostCustomerV2.js";
import { handleTransferProduct } from "./handlers/handleTransferProduct.js";
import { handleUpdateBalances } from "./handlers/handleUpdateBalances.js";
import { handleUpdateCustomer } from "./handlers/handleUpdateCustomer.js";
import { handleTransferProductV2 } from "./handlers/handleTransferProductV2.js";
import { handleUpdateBalancesV2 } from "./handlers/handleUpdateBalancesV2.js";
import { handleUpdateCustomerV2 } from "./handlers/handleUpdateCustomerV2.js";
import { handleUpdateEntitlement } from "./handlers/handleUpdateEntitlement.js";
export const expressCusRouter: Router = Router();
// expressCusRouter.post("", handlePostCustomerRequest);
// cusRouter.get("/:customer_id", handleGetCustomer);
expressCusRouter.delete("/:customer_id", handleDeleteCustomer);
// Update customer entitlement directly
expressCusRouter.post(
"/:customer_id/entitlements/:customer_entitlement_id",
handleUpdateEntitlement,
);
expressCusRouter.post("/:customer_id/balances", handleUpdateBalances);
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 [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 });
let stripeCusId: string = customer.processor?.id;
if (!customer.processor?.id) {
const newCus = await createStripeCusIfNotExists({
db: req.db,
org,
env: req.env,
customer,
logger: req.logger,
});
if (!newCus) {
throw new RecaseError({
message: `Failed to create Stripe customer`,
code: ErrCode.StripeError,
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
});
}
stripeCusId = newCus.id;
}
const portal = await stripeCli.billingPortal.sessions.create({
customer: stripeCusId,
return_url: returnUrl || toSuccessUrl({ org, env: req.env }),
});
res.status(200).json({
customer_id: customer.id || null,
url: portal.url,
});
// if (org.api_version >= LegacyVersion.v1_1) {
// } else {
// res.status(200).json({
// url: portal.url,
// });
// }
} catch (error) {
handleRequestError({ req, error, res, action: "get billing portal" });
}
},
);
expressCusRouter.post(
"/:customer_id/billing_portal",
handleCreateBillingPortal,
);
expressCusRouter.post("/:customer_id/coupons/:coupon_id", handleAddCouponToCus);
expressCusRouter.post("/:customer_id/transfer", handleTransferProduct);
export const cusRouter = new Hono<HonoEnv>();
cusRouter.get("", ...handleListCustomers);
cusRouter.get("/:customer_id", ...handleGetCustomerV2);
cusRouter.post("", ...handlePostCustomer);
cusRouter.get("/:customer_id", ...handleGetCustomerV2);
cusRouter.post("/:customer_id", ...handleUpdateCustomerV2);
cusRouter.patch("/:customer_id", ...handleUpdateCustomerV2);
cusRouter.delete("/:customer_id", ...handleDeleteCustomerV2);
cusRouter.post("/:customer_id/coupons/:coupon_id", ...handleAddCouponToCusV2);
cusRouter.post("/:customer_id/transfer", ...handleTransferProductV2);
// Billing portal
cusRouter.post("/:customer_id/billing_portal", ...handleCreateBillingPortal);
cusRouter.get("/:customer_id/billing_portal", ...handleCreateBillingPortal);
// Legacy...
cusRouter.post("/:customer_id/balances", ...handleUpdateBalancesV2);

View File

@@ -2,7 +2,7 @@ import {
type ApiBalance,
type ApiBalanceReset,
type ApiBalanceRollover,
type ApiFeature,
type ApiFeatureV1,
entIntvToResetIntv,
type Feature,
type FullCusEntWithFullCusProduct,
@@ -94,7 +94,7 @@ export const getBooleanApiBalance = ({
apiFeature,
}: {
cusEnts: FullCusEntWithFullCusProduct[];
apiFeature?: ApiFeature;
apiFeature?: ApiFeatureV1;
}): ApiBalance => {
const feature = cusEnts[0].entitlement.feature;
return {
@@ -121,7 +121,7 @@ export const getUnlimitedApiBalance = ({
apiFeature,
cusEnts,
}: {
apiFeature?: ApiFeature;
apiFeature?: ApiFeatureV1;
cusEnts: FullCusEntWithFullCusProduct[];
}): ApiBalance => {
const feature = cusEnts[0].entitlement.feature;
@@ -150,7 +150,7 @@ export const getNoCusEntsApiBalance = ({
apiFeature,
featureId,
}: {
apiFeature?: ApiFeature;
apiFeature?: ApiFeatureV1;
featureId: string;
}): ApiBalance => {
return {

View File

@@ -16,6 +16,7 @@ import {
cusEntToGrantedBalance,
cusEntToKey,
cusEntToPurchasedBalance,
dbToApiFeatureV1,
expandIncludes,
type Feature,
FeatureType,
@@ -23,7 +24,6 @@ import {
isPrepaidPrice,
notNullish,
sumValues,
toApiFeature,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import type { RequestContext } from "@/honoUtils/HonoEnv.js";
@@ -139,7 +139,7 @@ export const getApiBalance = ({
expand: ctx.expand,
includes: [CheckExpand.BalanceFeature, CusExpand.BalancesFeature],
})
? toApiFeature({ feature })
? dbToApiFeatureV1({ dbFeature: feature })
: undefined;
// 1. If feature is boolean

View File

@@ -1,84 +0,0 @@
import { ErrCode } from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { createStripeCusIfNotExists } from "@/external/stripe/stripeCusUtils.js";
import { CusService } from "@/internal/customers/CusService.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { RewardService } from "@/internal/rewards/RewardService.js";
import RecaseError, { handleRequestError } from "@/utils/errorUtils.js";
export const handleAddCouponToCus = async (req: any, res: any) => {
try {
const { customer_id, coupon_id } = req.params;
const { db, orgId, env, logger } = req;
const [org, customer, coupon] = await Promise.all([
OrgService.getFromReq(req),
CusService.get({
db,
idOrInternalId: customer_id,
orgId,
env,
}),
RewardService.get({
db,
idOrInternalId: coupon_id,
orgId: req.orgId,
env: req.env,
}),
]);
if (!customer) {
throw new RecaseError({
message: `Customer ${customer_id} not found`,
code: ErrCode.CustomerNotFound,
statusCode: StatusCodes.NOT_FOUND,
});
}
if (!coupon) {
throw new RecaseError({
message: `Coupon ${coupon_id} not found`,
code: ErrCode.RewardNotFound,
statusCode: StatusCodes.NOT_FOUND,
});
}
const stripeCli = createStripeCli({
org,
env,
// apiVersion: "2025-02-24.acacia",
legacyVersion: true,
});
await createStripeCusIfNotExists({
db,
org,
env,
customer,
logger,
});
// Attach coupon to customer
// curl https://api.stripe.com/v1/customers/cus_123456/discounts \
// -u sk_test_your_key: \
// -d coupon=COUPON_ID
await stripeCli.rawRequest(
"POST",
`/v1/customers/${customer.processor.id}`,
{
coupon: coupon.id,
},
);
// await stripeCli.customers.update(customer.processor.id, {
// coupon: coupon.id,
// discounts: [{ coupon: coupon.id }],
// });
res.status(200).json({ customer, coupon });
} catch (error) {
handleRequestError({ req, error, res, action: "add coupon to customer" });
}
};

View File

@@ -0,0 +1,69 @@
import {
AffectedResource,
CustomerNotFoundError,
RecaseError,
} from "@autumn/shared";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { createStripeCusIfNotExists } from "@/external/stripe/stripeCusUtils.js";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { RewardService } from "../../rewards/RewardService.js";
import { CusService } from "../CusService.js";
export const handleAddCouponToCusV2 = createRoute({
resource: AffectedResource.Customer,
handler: async (c) => {
const ctx = c.get("ctx");
const { db, org, env, logger } = ctx;
const { customer_id, coupon_id } = c.req.param();
const [customer, coupon] = await Promise.all([
CusService.get({
db,
idOrInternalId: customer_id,
orgId: org.id,
env,
}),
RewardService.get({
db,
idOrInternalId: coupon_id,
orgId: org.id,
env,
}),
]);
if (!customer) {
throw new CustomerNotFoundError({ customerId: customer_id });
}
if (!coupon) {
throw new RecaseError({
message: `Coupon ${coupon_id} not found`,
});
}
const stripeCli = createStripeCli({
org,
env,
legacyVersion: true,
});
await createStripeCusIfNotExists({
db,
org,
env,
customer,
logger,
});
// Attach coupon to customer
await stripeCli.rawRequest(
"POST",
`/v1/customers/${customer.processor.id}`,
{
coupon: coupon.id,
},
);
return c.json({ customer, coupon });
},
});

View File

@@ -0,0 +1,86 @@
import { type Customer, InternalError } from "@autumn/shared";
import { createStripeCli } from "../../../../external/connect/createStripeCli";
import { createStripeCusIfNotExists } from "../../../../external/stripe/stripeCusUtils";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv";
import { toSuccessUrl } from "../../../orgs/orgUtils/convertOrgUtils";
import { createDefaultPortalConfig } from "./createDefaultPortalConfig";
export const createBillingPortalSession = async ({
ctx,
customer,
returnUrl,
}: {
ctx: AutumnContext;
customer: Customer;
returnUrl?: string;
}) => {
const { db, org, env, logger } = ctx;
const stripeCli = createStripeCli({ org, env });
// Determine the Stripe customer ID to use
let stripeCustomerId: string;
if (!customer.processor?.id) {
const newCus = await createStripeCusIfNotExists({
db,
org,
env,
customer,
logger,
});
if (!newCus) {
throw new InternalError({
message: `Failed to create Stripe customer`,
});
}
stripeCustomerId = newCus.id;
} else {
stripeCustomerId = customer.processor.id;
}
// 1. Try to create billing portal session
try {
return await stripeCli.billingPortal.sessions.create({
customer: stripeCustomerId,
return_url: returnUrl || toSuccessUrl({ org, env }),
});
} catch (error: any) {
// If not a missing configuration error, rethrow
if (
!error.message?.includes("default configuration has not been created")
) {
throw error;
}
// Handle missing configuration by creating default and retrying
logger.info(
`Creating default billing portal configuration for customer ${customer.id}`,
);
const configuration = await createDefaultPortalConfig(stripeCli).catch(
(configError: any) => {
logger.error("Failed to create billing portal configuration", {
error: configError.message,
orgId: org.id,
});
throw new InternalError({
message: `Failed to create billing portal configuration: ${configError.message}`,
});
},
);
logger.info("Successfully created billing portal configuration", {
configurationId: configuration.id,
orgId: org.id,
});
// Retry with new configuration
return await stripeCli.billingPortal.sessions.create({
customer: stripeCustomerId,
return_url: returnUrl || toSuccessUrl({ org, env }),
configuration: configuration.id,
});
}
};

View File

@@ -0,0 +1,36 @@
import { ErrCode, RecaseError } from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import type Stripe from "stripe";
export const createDefaultPortalConfig = async (stripeCli: Stripe) => {
try {
const configuration = await stripeCli.billingPortal.configurations.create({
features: {
customer_update: {
allowed_updates: ["email", "address"],
enabled: true,
},
invoice_history: {
enabled: true,
},
payment_method_update: {
enabled: true,
},
subscription_cancel: {
enabled: true,
},
},
// business_profile: {
// privacy_policy_url: "https://example.com/privacy",
// terms_of_service_url: "https://example.com/terms",
// },
});
return configuration;
} catch (error: any) {
throw new RecaseError({
message: `Failed to create billing portal configuration: ${error.message}`,
code: ErrCode.StripeError,
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
});
}
};

View File

@@ -0,0 +1,45 @@
import { CustomerNotFoundError } from "@autumn/shared";
import { z } from "zod/v4";
import { createRoute } from "../../../../honoMiddlewares/routeHandler";
import { CusService } from "../../CusService";
import { createBillingPortalSession } from "./createBillingPortalSession";
export const handleCreateBillingPortal = createRoute({
query: z.object({
return_url: z.string().optional(),
}),
body: z.object({
return_url: z.string().optional(),
}),
handler: async (c) => {
const ctx = c.get("ctx");
const { return_url: queryReturnUrl } = c.req.valid("query");
const { return_url: bodyReturnUrl } = c.req.valid("json");
const returnUrl = queryReturnUrl ?? bodyReturnUrl;
const customerId = c.req.param("customer_id");
const customer = await CusService.get({
db: ctx.db,
idOrInternalId: customerId,
orgId: ctx.org.id,
env: ctx.env,
});
if (!customer) {
throw new CustomerNotFoundError({ customerId });
}
const session = await createBillingPortalSession({
ctx,
customer,
returnUrl,
});
return c.json({
customer_id: customer.id,
url: session.url,
});
},
});

View File

@@ -1,165 +0,0 @@
import { ErrCode } from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import type Stripe from "stripe";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { createStripeCusIfNotExists } from "@/external/stripe/stripeCusUtils.js";
import { CusService } from "@/internal/customers/CusService.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { toSuccessUrl } from "@/internal/orgs/orgUtils/convertOrgUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import { routeHandler } from "@/utils/routerUtils.js";
const createDefaultBillingPortalConfiguration = async (stripeCli: Stripe) => {
try {
const configuration = await stripeCli.billingPortal.configurations.create({
features: {
customer_update: {
allowed_updates: ["email", "address"],
enabled: true,
},
invoice_history: {
enabled: true,
},
payment_method_update: {
enabled: true,
},
subscription_cancel: {
enabled: true,
},
},
// business_profile: {
// privacy_policy_url: "https://example.com/privacy",
// terms_of_service_url: "https://example.com/terms",
// },
});
return configuration;
} catch (error: any) {
throw new RecaseError({
message: `Failed to create billing portal configuration: ${error.message}`,
code: ErrCode.StripeError,
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
});
}
};
export const handleCreateBillingPortal = async (req: any, res: any) =>
routeHandler({
req,
res,
action: "create_billing_portal",
handler: async (req: any, res: any) => {
const customerId = req.params.customer_id || req.body.customer_id;
const returnUrl = req.body.return_url;
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 });
// Determine the Stripe customer ID to use
let stripeCustomerId: string;
if (!customer.processor?.id) {
try {
const newCus = await createStripeCusIfNotExists({
db: req.db,
org,
env: req.env,
customer,
logger: req.logger,
});
if (!newCus) {
throw new RecaseError({
message: `Failed to create Stripe customer`,
code: ErrCode.StripeError,
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
});
}
stripeCustomerId = newCus.id;
} catch (error: any) {
throw new RecaseError({
message: `Failed to create Stripe customer`,
code: ErrCode.StripeError,
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
});
}
} else {
stripeCustomerId = customer.processor.id;
}
// Create billing portal session
let portal;
try {
portal = await stripeCli.billingPortal.sessions.create({
customer: stripeCustomerId,
return_url: returnUrl || toSuccessUrl({ org, env: req.env }),
});
} catch (error: any) {
console.log(`Code: ${error.code}, Message: ${error.message}`);
// Check if the error is due to missing default configuration
if (
error.message?.includes("default configuration has not been created")
) {
try {
// Create a default billing portal configuration
req.logger?.info(
`Creating default billing portal configuration for customer ${customer.id}`,
);
const configuration =
await createDefaultBillingPortalConfiguration(stripeCli);
req.logger?.info(
"Successfully created billing portal configuration",
{
configurationId: configuration.id,
orgId: org.id,
},
);
// Retry creating the portal session with the new configuration
portal = await stripeCli.billingPortal.sessions.create({
customer: stripeCustomerId,
return_url: returnUrl || toSuccessUrl({ org, env: req.env }),
configuration: configuration.id,
});
} catch (configError: any) {
req.logger?.error("Failed to create billing portal configuration", {
error: configError.message,
orgId: org.id,
});
throw new RecaseError({
message: `Failed to create billing portal configuration: ${configError.message}`,
code: ErrCode.StripeError,
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
});
}
} else {
throw error;
}
}
res.status(200).json({
customer_id: customer.id,
url: portal.url,
});
},
});

View File

@@ -189,8 +189,6 @@ export const handleCreateCustomer = async ({
}) => {
const newCus = CreateCustomerSchema.parse(cusData);
console.log("Create default products:", createDefaultProducts);
// 1. If no ID and email is not NULL
let createdCustomer: Customer;

View File

@@ -1,108 +0,0 @@
import { type AppEnv, ErrCode, type Organization } from "@autumn/shared";
import chalk from "chalk";
import { StatusCodes } from "http-status-codes";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { deleteStripeCustomer } from "@/external/stripe/stripeCusUtils.js";
import { CusService } from "@/internal/customers/CusService.js";
import { deleteCachedApiCustomer } from "@/internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer.js";
import RecaseError from "@/utils/errorUtils.js";
import type {
ExtendedRequest,
ExtendedResponse,
} from "@/utils/models/Request.js";
import { routeHandler } from "@/utils/routerUtils.js";
export const deleteCusById = async ({
db,
org,
customerId,
env,
logger,
deleteInStripe = false,
}: {
db: DrizzleCli;
org: Organization;
customerId: string;
env: AppEnv;
logger: any;
deleteInStripe?: boolean;
}) => {
const orgId = org.id;
const customer = await CusService.get({
db,
idOrInternalId: customerId,
orgId,
env,
});
if (!customer) {
throw new RecaseError({
message: `Customer ${customerId} not found`,
code: ErrCode.CustomerNotFound,
statusCode: StatusCodes.NOT_FOUND,
});
}
const response = {
customer,
success: true,
};
try {
if (customer.processor?.id && deleteInStripe) {
await deleteStripeCustomer({
org,
env: env,
stripeId: customer.processor.id,
});
}
} catch (error: any) {
console.log(
`Couldn't delete ${chalk.yellow("stripe customer")} ${
customer.processor.id
}`,
error?.message || error,
);
response.success = false;
}
await CusService.deleteByInternalId({
db,
internalId: customer.internal_id,
orgId,
env: env,
});
// Delete customer and all entity caches atomically
await deleteCachedApiCustomer({
customerId: customer.id ?? "",
orgId,
env,
});
return response;
};
export const handleDeleteCustomer = async (req: any, res: any) =>
routeHandler({
req,
res,
action: "delete customer",
handler: async (req: ExtendedRequest, res: ExtendedResponse) => {
const { env, logger, db, org } = req;
const { delete_in_stripe } = req.query;
const data = await deleteCusById({
db,
org,
customerId: req.params.customer_id,
env,
logger,
deleteInStripe: delete_in_stripe === "true",
});
res.status(200).json(data);
},
});

View File

@@ -0,0 +1,73 @@
import { AffectedResource, CustomerNotFoundError } from "@autumn/shared";
import chalk from "chalk";
import { z } from "zod/v4";
import { deleteStripeCustomer } from "@/external/stripe/stripeCusUtils.js";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { CusService } from "../CusService.js";
import { deleteCachedApiCustomer } from "../cusUtils/apiCusCacheUtils/deleteCachedApiCustomer.js";
const DeleteCustomerQuerySchema = z.object({
delete_in_stripe: z.boolean().optional().default(false),
});
export const handleDeleteCustomerV2 = createRoute({
query: DeleteCustomerQuerySchema,
resource: AffectedResource.Customer,
handler: async (c) => {
const ctx = c.get("ctx");
const { db, org, env } = ctx;
const { customer_id } = c.req.param();
const { delete_in_stripe } = c.req.valid("query");
const customer = await CusService.get({
db,
idOrInternalId: customer_id,
orgId: org.id,
env,
});
if (!customer) {
throw new CustomerNotFoundError({ customerId: customer_id });
}
const response = {
customer,
success: true,
};
try {
if (customer.processor?.id && delete_in_stripe) {
await deleteStripeCustomer({
org,
env,
stripeId: customer.processor.id,
});
}
} catch (error: any) {
console.log(
`Couldn't delete ${chalk.yellow("stripe customer")} ${
customer.processor.id
}`,
error?.message || error,
);
response.success = false;
}
await CusService.deleteByInternalId({
db,
internalId: customer.internal_id,
orgId: org.id,
env,
});
// Delete customer and all entity caches atomically
await deleteCachedApiCustomer({
customerId: customer.id ?? "",
orgId: org.id,
env,
});
return c.json(response);
},
});

View File

@@ -1,39 +0,0 @@
import {
AffectedResource,
ApiVersion,
ListCustomersQuerySchema,
} from "@autumn/shared";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { CusBatchService } from "../CusBatchService.js";
export const handleListCustomers = createRoute({
versionedQuery: {
latest: ListCustomersQuerySchema,
[ApiVersion.V1_2]: ListCustomersQuerySchema,
},
query: ListCustomersQuerySchema,
resource: AffectedResource.Customer,
handler: async (c) => {
const ctx = c.get("ctx");
const query = c.req.valid("query");
const { limit = 10, offset = 0 } = query;
// Note: expand and statuses are not exposed in the query params for list endpoint
const statuses: any[] = [];
const customers = await CusBatchService.getPage({
ctx,
limit,
offset,
statuses,
});
return c.json({
list: customers,
total: customers.length,
limit,
offset,
});
},
});

View File

@@ -1,151 +0,0 @@
import {
AttachScenario,
CusProductAlreadyExistsError,
CusProductNotFoundError,
ErrCode,
} from "@autumn/shared";
import { z } from "zod";
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js";
import { ProductService } from "@/internal/products/ProductService.js";
import RecaseError from "@/utils/errorUtils.js";
import { nullish } from "@/utils/genUtils.js";
import type {
ExtendedRequest,
ExtendedResponse,
} from "@/utils/models/Request.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { CusService } from "../CusService.js";
import { CusProductService } from "../cusProducts/CusProductService.js";
import { handleDecreaseAndTransfer } from "./handleTransferProduct/handleDecreaseAndTransfer.js";
const TransferProductSchema = z.object({
from_entity_id: z.string().nullish(),
to_entity_id: z.string(),
product_id: z.string(),
});
export const handleTransferProduct = async (req: any, res: any) =>
routeHandler({
req,
res,
action: "transfer product",
handler: async (req: ExtendedRequest, res: ExtendedResponse) => {
const { customer_id } = req.params;
const { from_entity_id, to_entity_id, product_id } =
TransferProductSchema.parse(req.body);
const customer = await CusService.getFull({
idOrInternalId: customer_id,
orgId: req.orgId,
env: req.env,
db: req.db,
withEntities: true,
// entityId: from_entity_id,
});
const product = await ProductService.get({
id: product_id,
orgId: req.orgId,
env: req.env,
db: req.db,
});
if (!product) {
throw new CusProductNotFoundError({
customerId: customer_id,
productId: product_id,
});
}
const fromEntity = customer.entities.find(
(e: any) => e.id === from_entity_id,
);
const toEntity = customer.entities.find(
(e: any) => e.id === to_entity_id,
);
// if (!fromEntity) {
// throw new RecaseError({
// code: ErrCode.EntityNotFound,
// message: `Entity ${from_entity_id} not found`,
// statusCode: 404,
// });
// }
if (!toEntity) {
throw new RecaseError({
code: ErrCode.EntityNotFound,
message: `Entity ${to_entity_id} not found`,
statusCode: 404,
});
}
const cusProduct = customer.customer_products.find(
(cp: any) =>
(fromEntity
? cp.internal_entity_id === fromEntity.internal_id
: nullish(cp.internal_entity_id)) && cp.product.id === product_id,
);
const toCusProduct = customer.customer_products.find(
(cp: any) =>
cp.internal_entity_id === toEntity.internal_id &&
cp.product.group === product.group,
);
if (toCusProduct) {
throw new CusProductAlreadyExistsError({
productId: product_id,
entityId: toEntity.id,
});
}
if (!cusProduct) {
throw new CusProductNotFoundError({
customerId: customer_id,
productId: product_id,
entityId: from_entity_id || undefined,
});
}
// 1. If cus product has quantity > 1, only transfer 1...
if (cusProduct.quantity > 1) {
await handleDecreaseAndTransfer({
req: req,
fullCus: customer,
cusProduct: cusProduct,
toEntity: toEntity,
});
} else {
await CusProductService.update({
db: req.db,
cusProductId: cusProduct.id,
updates: {
entity_id: toEntity.id,
internal_entity_id: toEntity.internal_id,
},
});
await addProductsUpdatedWebhookTask({
req,
internalCustomerId: customer.internal_id,
org: req.org,
env: req.env,
customerId: customer.id || customer.internal_id,
scenario: AttachScenario.New,
cusProduct: {
...cusProduct,
entity_id: toEntity.id,
internal_entity_id: toEntity.internal_id,
},
logger: req.logger,
});
}
res.status(200).json({
// message: "Product transferred successfully",
success: true,
});
},
});

View File

@@ -8,27 +8,27 @@ import {
getStartingBalance,
} from "@autumn/shared";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { getEntOptions } from "@/internal/products/prices/priceUtils.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,
ctx,
fullCus,
cusProduct,
toEntity,
}: {
req: ExtendedRequest;
ctx: AutumnContext;
fullCus: FullCustomer;
cusProduct: FullCusProduct;
toEntity: Entity;
}) => {
// 1. Create new cus product for entity...
const { org, env } = req;
const { org, env, db, logger, features } = ctx;
const stripeCli = createStripeCli({ org, env });
const product = cusProductToProduct({ cusProduct });
@@ -50,7 +50,7 @@ export const handleDecreaseAndTransfer = async ({
batchDecrement.push(
CusEntService.decrement({
db: req.db,
db,
id: cusEnt.id,
amount: resetBalance,
}),
@@ -60,37 +60,36 @@ export const handleDecreaseAndTransfer = async ({
await Promise.all(batchDecrement);
await CusProductService.update({
db: req.db,
db,
cusProductId: cusProduct.id,
updates: {
quantity: cusProduct.quantity - 1,
},
});
const newCusProduct = await createFullCusProduct({
db: req.db,
logger: req.logger,
await createFullCusProduct({
db,
logger,
trialEndsAt: cusProduct.trial_ends_at || undefined,
subscriptionIds: cusProduct.subscription_ids || [],
attachParams: attachToInsertParams(
{
req,
req: ctx as any, // Pass ctx as req for now (AttachParams still uses req)
customer: fullCus,
products: [product],
prices: product.prices,
entitlements: product.entitlements,
org: req.org,
org,
stripeCli: stripeCli,
paymentMethod: null,
freeTrial: cusProduct.free_trial || null,
optionsList: cusProduct.options,
scenario: AttachScenario.New,
// scenario: AttachScenario.New,
cusProducts: fullCus.customer_products,
replaceables: [],
entities: fullCus.entities,
features: req.features,
features,
internalEntityId: toEntity.internal_id,
entityId: toEntity.id,
},

View File

@@ -0,0 +1,133 @@
import {
AffectedResource,
AttachScenario,
CusProductAlreadyExistsError,
CusProductNotFoundError,
RecaseError,
} from "@autumn/shared";
import { z } from "zod/v4";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js";
import { ProductService } from "@/internal/products/ProductService.js";
import { nullish } from "@/utils/genUtils.js";
import type { ExtendedRequest } from "../../../utils/models/Request.js";
import { CusService } from "../CusService.js";
import { CusProductService } from "../cusProducts/CusProductService.js";
import { handleDecreaseAndTransfer } from "./handleTransferProduct/handleDecreaseAndTransfer.js";
const TransferProductSchema = z.object({
from_entity_id: z.string().nullish(),
to_entity_id: z.string(),
product_id: z.string(),
});
export const handleTransferProductV2 = createRoute({
body: TransferProductSchema,
resource: AffectedResource.Customer,
handler: async (c) => {
const ctx = c.get("ctx");
const { db, org, env } = ctx;
const { customer_id } = c.req.param();
const { from_entity_id, to_entity_id, product_id } = c.req.valid("json");
const customer = await CusService.getFull({
idOrInternalId: customer_id,
orgId: org.id,
env,
db,
withEntities: true,
});
const product = await ProductService.get({
id: product_id,
orgId: org.id,
env,
db,
});
if (!product) {
throw new CusProductNotFoundError({
customerId: customer_id,
productId: product_id,
});
}
const fromEntity = customer.entities.find(
(e: any) => e.id === from_entity_id,
);
const toEntity = customer.entities.find((e: any) => e.id === to_entity_id);
if (!toEntity) {
throw new RecaseError({
message: `Entity ${to_entity_id} not found`,
});
}
const cusProduct = customer.customer_products.find(
(cp: any) =>
(fromEntity
? cp.internal_entity_id === fromEntity.internal_id
: nullish(cp.internal_entity_id)) && cp.product.id === product_id,
);
const toCusProduct = customer.customer_products.find(
(cp: any) =>
cp.internal_entity_id === toEntity.internal_id &&
cp.product.group === product.group,
);
if (toCusProduct) {
throw new CusProductAlreadyExistsError({
productId: product_id,
entityId: toEntity.id,
});
}
if (!cusProduct) {
throw new CusProductNotFoundError({
customerId: customer_id,
productId: product_id,
entityId: from_entity_id || undefined,
});
}
// 1. If cus product has quantity > 1, only transfer 1...
if (cusProduct.quantity > 1) {
await handleDecreaseAndTransfer({
ctx,
fullCus: customer,
cusProduct: cusProduct,
toEntity: toEntity,
});
} else {
await CusProductService.update({
db,
cusProductId: cusProduct.id,
updates: {
entity_id: toEntity.id,
internal_entity_id: toEntity.internal_id,
},
});
await addProductsUpdatedWebhookTask({
req: ctx as ExtendedRequest,
internalCustomerId: customer.internal_id,
org: ctx.org,
env: ctx.env,
customerId: customer.id || customer.internal_id,
scenario: AttachScenario.New,
cusProduct: {
...cusProduct,
entity_id: toEntity.id,
internal_entity_id: toEntity.internal_id,
},
logger: ctx.logger,
});
}
return c.json({
success: true,
});
},
});

View File

@@ -1,295 +1,295 @@
import { ErrCode, getCusEntBalance } from "@autumn/shared";
import { Decimal } from "decimal.js";
import { StatusCodes } from "http-status-codes";
import { CusService } from "@/internal/customers/CusService.js";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
import { getUnlimitedAndUsageAllowed } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js";
import { FeatureService } from "@/internal/features/FeatureService.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import {
deductAllowanceFromCusEnt,
deductFromUsageBasedCusEnt,
} from "@/trigger/updateBalanceTask.js";
import RecaseError, { handleRequestError } from "@/utils/errorUtils.js";
import { notNullish } from "@/utils/genUtils.js";
import { getCusEntsInFeatures } from "../cusUtils/cusUtils.js";
// import { ErrCode, getCusEntBalance } from "@autumn/shared";
// import { Decimal } from "decimal.js";
// import { StatusCodes } from "http-status-codes";
// import { CusService } from "@/internal/customers/CusService.js";
// import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
// import { getUnlimitedAndUsageAllowed } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js";
// import { FeatureService } from "@/internal/features/FeatureService.js";
// import { OrgService } from "@/internal/orgs/OrgService.js";
// import {
// deductAllowanceFromCusEnt,
// deductFromUsageBasedCusEnt,
// } from "@/trigger/updateBalanceTask.js";
// import RecaseError, { handleRequestError } from "@/utils/errorUtils.js";
// import { notNullish } from "@/utils/genUtils.js";
// import { getCusEntsInFeatures } from "../cusUtils/cusUtils.js";
const getCusFeaturesAndOrg = async (req: any, customerId: string) => {
// 1. Get customer
const [customer, features, org] = await Promise.all([
CusService.getFull({
db: req.db,
idOrInternalId: customerId,
orgId: req.orgId,
env: req.env,
entityId: req.params.entity_id,
}),
FeatureService.getFromReq(req),
OrgService.getFromReq(req),
]);
// const getCusFeaturesAndOrg = async (req: any, customerId: string) => {
// // 1. Get customer
// const [customer, features, org] = await Promise.all([
// CusService.getFull({
// db: req.db,
// idOrInternalId: customerId,
// orgId: req.orgId,
// env: req.env,
// entityId: req.params.entity_id,
// }),
// FeatureService.getFromReq(req),
// OrgService.getFromReq(req),
// ]);
if (!customer) {
throw new RecaseError({
message: `Customer ${customerId} not found`,
code: ErrCode.CustomerNotFound,
statusCode: StatusCodes.NOT_FOUND,
});
}
// if (!customer) {
// throw new RecaseError({
// message: `Customer ${customerId} not found`,
// code: ErrCode.CustomerNotFound,
// statusCode: StatusCodes.NOT_FOUND,
// });
// }
return { customer, features, org };
};
// return { customer, features, org };
// };
export const handleUpdateBalances = async (req: any, res: any) => {
try {
const logger = req.logger;
const cusId = req.params.customer_id;
const { env, db, features } = req;
const { balances } = req.body;
// export const handleUpdateBalances = async (req: any, res: any) => {
// try {
// const logger = req.logger;
// const cusId = req.params.customer_id;
// const { env, db, features } = req;
// const { balances } = req.body;
if (!Array.isArray(balances)) {
throw new RecaseError({
message: "Balances must be an array",
code: ErrCode.InvalidRequest,
statusCode: StatusCodes.BAD_REQUEST,
});
}
// if (!Array.isArray(balances)) {
// throw new RecaseError({
// message: "Balances must be an array",
// code: ErrCode.InvalidRequest,
// statusCode: StatusCodes.BAD_REQUEST,
// });
// }
const { customer, org } = await getCusFeaturesAndOrg(req, cusId);
// const { customer, org } = await getCusFeaturesAndOrg(req, cusId);
const featuresToUpdate = features.filter((f: any) =>
balances.map((b: any) => b.feature_id).includes(f.id),
);
// const featuresToUpdate = features.filter((f: any) =>
// balances.map((b: any) => b.feature_id).includes(f.id),
// );
if (featuresToUpdate.length === 0) {
throw new RecaseError({
message: "No valid features found to update",
code: ErrCode.InvalidRequest,
statusCode: StatusCodes.BAD_REQUEST,
});
}
// if (featuresToUpdate.length === 0) {
// throw new RecaseError({
// message: "No valid features found to update",
// code: ErrCode.InvalidRequest,
// statusCode: StatusCodes.BAD_REQUEST,
// });
// }
const { cusEnts, cusPrices } = await getCusEntsInFeatures({
customer,
internalFeatureIds: featuresToUpdate.map((f: any) => f.internal_id!),
logger: req.logger,
});
// const { cusEnts, cusPrices } = await getCusEntsInFeatures({
// customer,
// internalFeatureIds: featuresToUpdate.map((f: any) => f.internal_id!),
// logger: req.logger,
// });
logger.info("--------------------------------");
logger.info(
`REQUEST: UPDATE BALANCES FOR CUSTOMER ${customer.id}, ORG: ${org.slug}`,
);
logger.info(
`Features to update: ${balances.map(
(b: any) =>
`${b.feature_id} - ${b.unlimited ? "unlimited" : b.balance}`,
)}`,
);
// logger.info("--------------------------------");
// logger.info(
// `REQUEST: UPDATE BALANCES FOR CUSTOMER ${customer.id}, ORG: ${org.slug}`,
// );
// logger.info(
// `Features to update: ${balances.map(
// (b: any) =>
// `${b.feature_id} - ${b.unlimited ? "unlimited" : b.balance}`,
// )}`,
// );
// Get deductions for each feature
const featureDeductions = [];
for (const balance of balances) {
if (!balance.feature_id) {
throw new RecaseError({
message: "Feature ID is required",
code: ErrCode.InvalidRequest,
statusCode: StatusCodes.BAD_REQUEST,
});
}
// // Get deductions for each feature
// const featureDeductions = [];
// for (const balance of balances) {
// if (!balance.feature_id) {
// throw new RecaseError({
// message: "Feature ID is required",
// code: ErrCode.InvalidRequest,
// statusCode: StatusCodes.BAD_REQUEST,
// });
// }
if (typeof balance.balance !== "number" && balance.unlimited !== true) {
throw new RecaseError({
message: "Balance must be a number",
code: ErrCode.InvalidRequest,
statusCode: StatusCodes.BAD_REQUEST,
});
}
// if (typeof balance.balance !== "number" && balance.unlimited !== true) {
// throw new RecaseError({
// message: "Balance must be a number",
// code: ErrCode.InvalidRequest,
// statusCode: StatusCodes.BAD_REQUEST,
// });
// }
const feature = featuresToUpdate.find(
(f: any) => f.id === balance.feature_id,
);
// const feature = featuresToUpdate.find(
// (f: any) => f.id === balance.feature_id,
// );
if (balance.unlimited === true) {
featureDeductions.push({
feature,
unlimited: true,
toDeduct: 0,
});
continue;
}
// if (balance.unlimited === true) {
// featureDeductions.push({
// feature,
// unlimited: true,
// toDeduct: 0,
// });
// continue;
// }
const { unlimited } = getUnlimitedAndUsageAllowed({
cusEnts,
internalFeatureId: feature!.internal_id!,
});
// const { unlimited } = getUnlimitedAndUsageAllowed({
// cusEnts,
// internalFeatureId: feature!.internal_id!,
// });
if (unlimited) {
throw new RecaseError({
message: `Can't set balance for unlimited feature: ${feature!.id}`,
code: ErrCode.InvalidRequest,
statusCode: StatusCodes.BAD_REQUEST,
});
}
// if (unlimited) {
// throw new RecaseError({
// message: `Can't set balance for unlimited feature: ${feature!.id}`,
// code: ErrCode.InvalidRequest,
// statusCode: StatusCodes.BAD_REQUEST,
// });
// }
// Get deductions
const newBalance = balance.balance;
let curBalance = new Decimal(0);
const properties = structuredClone(balance);
delete properties.feature_id;
delete properties.balance;
// // Get deductions
// const newBalance = balance.balance;
// let curBalance = new Decimal(0);
// const properties = structuredClone(balance);
// delete properties.feature_id;
// delete properties.balance;
for (const cusEnt of cusEnts) {
const cusEntIntCount = cusEnt.entitlement.interval_count || 1;
const deductionIntCount = balance.interval_count || 1;
// for (const cusEnt of cusEnts) {
// const cusEntIntCount = cusEnt.entitlement.interval_count || 1;
// const deductionIntCount = balance.interval_count || 1;
const intCountMatch = notNullish(balance.interval_count)
? cusEntIntCount === deductionIntCount
: true;
// const intCountMatch = notNullish(balance.interval_count)
// ? cusEntIntCount === deductionIntCount
// : true;
const intMatch = notNullish(balance.interval)
? balance.interval === cusEnt.entitlement.interval
: true;
// const intMatch = notNullish(balance.interval)
// ? balance.interval === cusEnt.entitlement.interval
// : true;
if (
cusEnt.internal_feature_id !== feature!.internal_id! ||
!intMatch ||
!intCountMatch
) {
continue;
}
// if (
// cusEnt.internal_feature_id !== feature!.internal_id! ||
// !intMatch ||
// !intCountMatch
// ) {
// continue;
// }
const { balance: cusEntBalance } = getCusEntBalance({
cusEnt,
entityId: balance.entity_id,
});
// const { balance: cusEntBalance } = getCusEntBalance({
// cusEnt,
// entityId: balance.entity_id,
// });
curBalance = curBalance.add(new Decimal(cusEntBalance!));
}
// curBalance = curBalance.add(new Decimal(cusEntBalance!));
// }
const toDeduct = curBalance.sub(newBalance).toNumber();
// const toDeduct = curBalance.sub(newBalance).toNumber();
if (toDeduct === 0) {
logger.info(`Skipping ${feature!.id} -- no change`);
}
// if (toDeduct === 0) {
// logger.info(`Skipping ${feature!.id} -- no change`);
// }
featureDeductions.push({
feature,
toDeduct,
properties,
interval: balance.interval,
intervalCount: balance.interval_count,
});
}
// featureDeductions.push({
// feature,
// toDeduct,
// properties,
// interval: balance.interval,
// intervalCount: balance.interval_count,
// });
// }
const batchDeduct = [];
// const batchDeduct = [];
for (const featureDeduction of featureDeductions) {
// 1. Deduct from allowance
const performDeduction = async () => {
let { toDeduct, feature, properties, interval } = featureDeduction;
// for (const featureDeduction of featureDeductions) {
// // 1. Deduct from allowance
// const performDeduction = async () => {
// let { toDeduct, feature, properties, interval } = featureDeduction;
// Handle unlimited
if (featureDeduction.unlimited) {
// Get one active cusEnt and set unlimited to true
// // Handle unlimited
// if (featureDeduction.unlimited) {
// // Get one active cusEnt and set unlimited to true
const cusEnt = notNullish(interval)
? cusEnts.find((cusEnt) => {
const cusEntIntCount = cusEnt.entitlement.interval_count || 1;
const deductionIntCount = featureDeduction.intervalCount || 1;
// const cusEnt = notNullish(interval)
// ? cusEnts.find((cusEnt) => {
// const cusEntIntCount = cusEnt.entitlement.interval_count || 1;
// const deductionIntCount = featureDeduction.intervalCount || 1;
return (
cusEnt.internal_feature_id === feature!.internal_id! &&
cusEnt.entitlement.interval === interval &&
cusEntIntCount === deductionIntCount
);
})
: cusEnts.find(
(cusEnt) =>
cusEnt.internal_feature_id === feature!.internal_id!,
);
// return (
// cusEnt.internal_feature_id === feature!.internal_id! &&
// cusEnt.entitlement.interval === interval &&
// cusEntIntCount === deductionIntCount
// );
// })
// : cusEnts.find(
// (cusEnt) =>
// cusEnt.internal_feature_id === feature!.internal_id!,
// );
if (!cusEnt) {
logger.warn(
`No active cus ent to set unlimited balance for feature: ${
feature!.id
}`,
);
return;
}
// if (!cusEnt) {
// logger.warn(
// `No active cus ent to set unlimited balance for feature: ${
// feature!.id
// }`,
// );
// return;
// }
await CusEntService.update({
db,
id: cusEnt.id,
updates: {
unlimited: true,
next_reset_at: null,
},
});
// await CusEntService.update({
// db,
// id: cusEnt.id,
// updates: {
// unlimited: true,
// next_reset_at: null,
// },
// });
return;
}
// return;
// }
for (const cusEnt of cusEnts) {
const cusEntIntCount = cusEnt.entitlement.interval_count || 1;
const deductionIntCount = featureDeduction.intervalCount || 1;
// for (const cusEnt of cusEnts) {
// const cusEntIntCount = cusEnt.entitlement.interval_count || 1;
// const deductionIntCount = featureDeduction.intervalCount || 1;
const intCountMatch = notNullish(featureDeduction.intervalCount)
? cusEntIntCount === deductionIntCount
: true;
// const intCountMatch = notNullish(featureDeduction.intervalCount)
// ? cusEntIntCount === deductionIntCount
// : true;
const intMatch = notNullish(featureDeduction.interval)
? featureDeduction.interval === cusEnt.entitlement.interval
: true;
// const intMatch = notNullish(featureDeduction.interval)
// ? featureDeduction.interval === cusEnt.entitlement.interval
// : true;
if (
cusEnt.internal_feature_id !==
featureDeduction.feature!.internal_id! ||
!intMatch ||
!intCountMatch
) {
continue;
}
// if (
// cusEnt.internal_feature_id !==
// featureDeduction.feature!.internal_id! ||
// !intMatch ||
// !intCountMatch
// ) {
// continue;
// }
toDeduct = await deductAllowanceFromCusEnt({
toDeduct,
deductParams: {
db,
feature: featureDeduction.feature!,
env: req.env,
org,
cusPrices: cusPrices as any[],
customer,
},
cusEnt,
featureDeductions: [], // not important because not deducting credits
willDeductCredits: false,
});
}
// toDeduct = await deductAllowanceFromCusEnt({
// toDeduct,
// deductParams: {
// db,
// feature: featureDeduction.feature!,
// env: req.env,
// org,
// cusPrices: cusPrices as any[],
// customer,
// },
// cusEnt,
// featureDeductions: [], // not important because not deducting credits
// willDeductCredits: false,
// });
// }
if (toDeduct === 0) {
return;
}
// if (toDeduct === 0) {
// return;
// }
await deductFromUsageBasedCusEnt({
toDeduct,
cusEnts,
deductParams: {
db,
feature: featureDeduction.feature!,
env,
org,
cusPrices: cusPrices as any[],
customer,
},
});
};
batchDeduct.push(performDeduction());
}
await Promise.all(batchDeduct);
// await deductFromUsageBasedCusEnt({
// toDeduct,
// cusEnts,
// deductParams: {
// db,
// feature: featureDeduction.feature!,
// env,
// org,
// cusPrices: cusPrices as any[],
// customer,
// },
// });
// };
// batchDeduct.push(performDeduction());
// }
// await Promise.all(batchDeduct);
logger.info(" ✅ Successfully updated balances");
// logger.info(" ✅ Successfully updated balances");
res.status(200).json({ success: true });
} catch (error) {
handleRequestError({ req, error, res, action: "update customer balances" });
}
};
// res.status(200).json({ success: true });
// } catch (error) {
// handleRequestError({ req, error, res, action: "update customer balances" });
// }
// };

View File

@@ -0,0 +1,51 @@
import {
FeatureNotFoundError,
UpdateBalancesParamsSchema,
} from "@autumn/shared";
import { createRoute } from "../../../honoMiddlewares/routeHandler";
import type { FeatureDeduction } from "../../balances/track/trackUtils/getFeatureDeductions";
import { runDeductionTx } from "../../balances/track/trackUtils/runDeductionTx";
import { CusService } from "../CusService";
export const handleUpdateBalancesV2 = createRoute({
body: UpdateBalancesParamsSchema,
handler: async (c) => {
const ctx = c.get("ctx");
const { org, env, db, features } = ctx;
const { customer_id } = c.req.param();
const { balances } = c.req.valid("json");
const fullCus = await CusService.getFull({
db,
idOrInternalId: customer_id,
orgId: org.id,
env,
});
for (const balance of balances) {
const feature = features.find((f) => f.id === balance.feature_id);
if (!feature) {
throw new FeatureNotFoundError({ featureId: balance.feature_id });
}
}
const featureDeductions = balances.map((b) => ({
feature: features.find((f) => f.id === b.feature_id)!,
deduction: 0,
targetBalance: b.balance,
})) satisfies FeatureDeduction[];
await runDeductionTx({
ctx,
customerId: customer_id,
deductions: featureDeductions,
entityId: fullCus.entity?.id,
skipAdditionalBalance: true,
alterGrantedBalance: true,
refreshCache: true,
});
return c.json({ success: true });
},
});

View File

@@ -1,148 +0,0 @@
import { CreateCustomerSchema, ErrCode, ProcessorType } from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { CusService } from "@/internal/customers/CusService.js";
import { FeatureService } from "@/internal/features/FeatureService.js";
import RecaseError from "@/utils/errorUtils.js";
import { notNullish } from "@/utils/genUtils.js";
import type {
ExtendedRequest,
ExtendedResponse,
} from "@/utils/models/Request.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { getApiCustomer } from "../cusUtils/apiCusUtils/getApiCustomer.js";
import { parseCusExpand } from "../cusUtils/cusUtils.js";
export const handleUpdateCustomer = async (req: any, res: any) =>
routeHandler({
req,
res,
action: "POST/customers/:customer_id",
handler: async (req: ExtendedRequest, res: ExtendedResponse) => {
const { orgId, env, db, org } = req;
const customerId = req.params.customer_id;
const [originalCustomer, features] = await Promise.all([
CusService.get({
db,
idOrInternalId: customerId,
orgId,
env,
}),
FeatureService.getFromReq(req),
]);
if (!originalCustomer) {
throw new RecaseError({
message: `Update customer: Customer ${customerId} not found`,
code: ErrCode.CustomerNotFound,
statusCode: StatusCodes.NOT_FOUND,
});
}
const newCusData: any = CreateCustomerSchema.parse(req.body);
if (req.body.id === null) {
throw new RecaseError({
message: `Update customer: Can't change customer ID to null`,
code: ErrCode.InvalidUpdateCustomerParams,
statusCode: StatusCodes.BAD_REQUEST,
});
}
if (notNullish(newCusData.id) && originalCustomer.id !== newCusData.id) {
// Fetch for existing customer
const existingCustomer = await CusService.get({
db: req.db,
idOrInternalId: newCusData.id,
orgId: req.orgId,
env: req.env,
});
if (existingCustomer) {
throw new RecaseError({
message: `Update customer: Customer ${newCusData.id} already exists, can't change to this ID`,
code: ErrCode.DuplicateCustomerId,
statusCode: StatusCodes.CONFLICT,
});
}
} else {
delete newCusData.id;
}
// Try to update stripe ID
let stripeId = originalCustomer.processor?.id;
const newStripeId = newCusData.stripe_id;
if (notNullish(newStripeId) && stripeId !== newStripeId) {
const stripeCli = createStripeCli({ org, env: req.env });
await stripeCli.customers.retrieve(newStripeId);
stripeId = newCusData.stripe_id;
req.logger.info(
`Updating customer's Stripe ID from ${originalCustomer.processor?.id} to ${stripeId}`,
);
}
// 2. Check if customer email is being changed
const oldMetadata = originalCustomer.metadata || {};
const newMetadata = newCusData.metadata || {};
for (const key in newMetadata) {
if (newMetadata[key] === null) {
delete newMetadata[key];
delete oldMetadata[key];
}
}
const stripeUpdate = {
email:
originalCustomer.email !== newCusData.email
? newCusData.email
: undefined,
name:
originalCustomer.name !== newCusData.name
? newCusData.name
: undefined,
};
if (Object.keys(stripeUpdate).length > 0 && stripeId) {
const stripeCli = createStripeCli({ org, env: req.env });
await stripeCli.customers.update(stripeId, stripeUpdate as any);
}
await CusService.update({
db: req.db,
idOrInternalId: originalCustomer.internal_id,
orgId: req.orgId,
env: req.env,
update: {
...newCusData,
processor: newStripeId
? { id: newStripeId, type: ProcessorType.Stripe }
: undefined,
metadata: {
...oldMetadata,
...newMetadata,
},
},
});
const finalCustomer = await CusService.getFull({
db,
idOrInternalId: originalCustomer.internal_id,
orgId: req.orgId,
env: req.env,
withEntities: true,
});
// res.status(200).json({ customer: updatedCustomer });
const customerDetails = await getApiCustomer({
ctx: req as AutumnContext,
fullCus: finalCustomer,
expand: parseCusExpand(req.query.expand as string),
});
res.status(200).json(customerDetails);
},
});

View File

@@ -1,24 +1,21 @@
import {
AffectedResource,
ApiVersion,
ErrCode,
CustomerAlreadyExistsError,
CustomerNotFoundError,
GetCustomerQuerySchema,
ProcessorType,
RecaseError,
UpdateCustomerParamsSchema,
} from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import { z } from "zod/v4";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import RecaseError from "@/utils/errorUtils.js";
import { notNullish } from "@/utils/genUtils.js";
import { CusService } from "../CusService.js";
import { getApiCustomer } from "../cusUtils/apiCusUtils/getApiCustomer.js";
export const handleUpdateCustomerV2 = createRoute({
params: z.object({
customer_id: z.string(),
}),
body: UpdateCustomerParamsSchema,
versionedQuery: {
latest: GetCustomerQuerySchema,
@@ -28,9 +25,8 @@ export const handleUpdateCustomerV2 = createRoute({
handler: async (c) => {
const ctx = c.get("ctx");
const { db, org, env, logger } = ctx;
const { customer_id } = c.req.valid("param");
const { customer_id } = c.req.param();
const newCusData = c.req.valid("json");
const { expand } = ctx;
const originalCustomer = await CusService.get({
db,
@@ -40,18 +36,12 @@ export const handleUpdateCustomerV2 = createRoute({
});
if (!originalCustomer) {
throw new RecaseError({
message: `Update customer: Customer ${customer_id} not found`,
code: ErrCode.CustomerNotFound,
statusCode: StatusCodes.NOT_FOUND,
});
throw new CustomerNotFoundError({ customerId: customer_id });
}
if (newCusData.id === null) {
throw new RecaseError({
message: `Update customer: Can't change customer ID to null`,
code: ErrCode.InvalidUpdateCustomerParams,
statusCode: StatusCodes.BAD_REQUEST,
});
}
@@ -65,10 +55,9 @@ export const handleUpdateCustomerV2 = createRoute({
});
if (existingCustomer) {
throw new RecaseError({
message: `Update customer: Customer ${newCusData.id} already exists, can't change to this ID`,
code: ErrCode.DuplicateCustomerId,
statusCode: StatusCodes.CONFLICT,
throw new CustomerAlreadyExistsError({
message: `Customer with ID ${newCusData.id} already exists, can't change to this ID`,
customerId: newCusData.id,
});
}
}
@@ -138,20 +127,12 @@ export const handleUpdateCustomerV2 = createRoute({
update: updateData,
});
const finalCustomer = await CusService.getFull({
db,
idOrInternalId: originalCustomer.internal_id,
orgId: org.id,
env,
withEntities: true,
});
ctx.skipCache = true;
const customerDetails = await getApiCustomer({
ctx,
fullCus: finalCustomer,
customerId: customer_id,
});
return c.json(customerDetails);
},
});

View File

@@ -8,7 +8,6 @@ import {
import { Router } from "express";
import { Hono } from "hono";
import { StatusCodes } from "http-status-codes";
import { z } from "zod/v4";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import RecaseError, { handleFrontendReqError } from "@/utils/errorUtils.js";
@@ -192,10 +191,9 @@ cusRouter.get(
export const internalCusRouter = new Hono<HonoEnv>();
export const handleGetCustomerInternal = createRoute({
params: z.object({ customer_id: z.string() }),
handler: async (c) => {
const { db, org, env } = c.get("ctx");
const { customer_id } = c.req.valid("param");
const { customer_id } = c.req.param();
const fullCus = await CusService.getFull({
db,

View File

@@ -10,7 +10,7 @@ export const handleGetCusReferrals = createRoute({
params: z.object({ customer_id: z.string() }),
handler: async (c) => {
const { env, db, org } = c.get("ctx");
const { customer_id } = c.req.valid("param");
const { customer_id } = c.req.param();
const internalCustomer = await CusService.get({
db,

View File

@@ -0,0 +1,18 @@
import { handleFrontendReqError } from "@/utils/errorUtils.js";
import { createFeature } from "../featureActions/createFeature.js";
export const handleCreateFeature = async (req: any, res: any) => {
try {
console.log("Trying to create feature");
const data = req.body;
const insertedFeature = await createFeature({
ctx: req,
data,
});
res.status(200).json(insertedFeature);
} catch (error) {
handleFrontendReqError({ req, error, res, action: "Create feature" });
}
};

View File

@@ -0,0 +1,67 @@
// import { ErrCode } from "@autumn/shared";
// import { getCreditSystemsFromFeature } from "@/internal/features/creditSystemUtils.js";
// import { FeatureService } from "@/internal/features/FeatureService.js";
// import { EntitlementService } from "@/internal/products/entitlements/EntitlementService.js";
// import RecaseError from "@/utils/errorUtils.js";
// import type {
// ExtendedRequest,
// ExtendedResponse,
// } from "@/utils/models/Request.js";
// import { routeHandler } from "@/utils/routerUtils.js";
// export const handleDeleteFeature = async (req: any, res: any) =>
// routeHandler({
// req,
// res,
// action: "Delete feature",
// handler: async (req: ExtendedRequest, res: ExtendedResponse) => {
// const { db, orgId } = req;
// const { featureId } = req.params;
// const features = await FeatureService.getFromReq(req);
// const feature = features.find((f) => f.id === featureId);
// const creditSystems = getCreditSystemsFromFeature({
// featureId,
// features,
// });
// if (!feature) {
// throw new RecaseError({
// message: `Feature ${featureId} not found`,
// code: ErrCode.FeatureNotFound,
// statusCode: 404,
// });
// }
// if (creditSystems.length > 0) {
// throw new RecaseError({
// message: `Feature ${featureId} is used by credit system ${creditSystems[0].id}`,
// code: ErrCode.InvalidFeature,
// statusCode: 400,
// });
// }
// // Get prices that use this feature
// const ent = await EntitlementService.getByFeature({
// db,
// internalFeatureId: feature.internal_id!,
// });
// if (ent) {
// throw new RecaseError({
// message: `Feature ${featureId} is used in a product. You must delete the product first, or archive it instead.`,
// code: ErrCode.InvalidFeature,
// statusCode: 400,
// });
// }
// await FeatureService.delete({
// db: req.db,
// orgId,
// featureId,
// env: req.env,
// });
// res.status(200).json({ success: true });
// },
// });

View File

@@ -0,0 +1,32 @@
// import { routeHandler } from "@/utils/routerUtils.js";
// import { updateFeature } from "../featureActions/updateFeature.js";
// export const handleUpdateFeature = async (
// req: any,
// res: any,
// _fromApi: boolean = false,
// ) =>
// routeHandler({
// req,
// res,
// action: "Update feature",
// handler: async (req: any, res: any) => {
// const featureId = req.params.feature_id;
// const data = req.body;
// // Use the abstracted updateFeature function
// const updatedFeature = await updateFeature({
// ctx: req,
// featureId,
// updates: data,
// });
// res
// .status(200)
// .json(
// updatedFeature
// : undefined,
// );
// },
// });

View File

@@ -1,10 +1,30 @@
import type { Feature } from "@autumn/shared";
import { CreateFeatureSchema, type Feature, FeatureType } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { JobName } from "@/queue/JobName.js";
import { addTaskToQueue } from "@/queue/queueUtils.js";
import { generateId } from "@/utils/genUtils.js";
import { FeatureService } from "../FeatureService.js";
import { validateFeature } from "../internalFeatureRouter.js";
import {
validateCreditSystem,
validateFeatureId,
validateMeteredConfig,
} from "../featureUtils.js";
export const validateFeature = (data: any) => {
const featureType = data.type;
validateFeatureId(data.id);
let config = data.config;
if (featureType === FeatureType.Metered) {
config = validateMeteredConfig(config);
} else if (featureType === FeatureType.CreditSystem) {
config = validateCreditSystem(config);
}
const parsedFeature = CreateFeatureSchema.parse({ ...data, config });
return parsedFeature;
};
interface CreateFeatureParams {
ctx: AutumnContext;

View File

@@ -8,10 +8,10 @@ import {
validateCreditSystem,
validateMeteredConfig,
} from "../featureUtils.js";
import { getObjectsUsingFeature } from "../handlers/handleUpdateFeature/getObjectsUsingFeature.js";
import { handleFeatureIdChanged } from "../handlers/handleUpdateFeature/handleFeatureIdChanged.js";
import { handleFeatureTypeChanged } from "../handlers/handleUpdateFeature/handleFeatureTypeChanged.js";
import { handleFeatureUsageTypeChanged } from "../handlers/handleUpdateFeature/handleFeatureUsageTypeChanged.js";
import { getObjectsUsingFeature } from "../utils/updateFeatureUtils/getObjectsUsingFeature.js";
import { handleFeatureIdChanged } from "../utils/updateFeatureUtils/handleFeatureIdChanged.js";
import { handleFeatureTypeChanged } from "../utils/updateFeatureUtils/handleFeatureTypeChanged.js";
import { handleFeatureUsageTypeChanged } from "../utils/updateFeatureUtils/handleFeatureUsageTypeChanged.js";
interface UpdateFeatureParams {
ctx: AutumnContext;

View File

@@ -1,177 +1,198 @@
import {
ApiFeatureSchema,
ApiFeatureType,
ErrCode,
type Feature,
FeatureType,
type FeatureUsageType,
UpdateFeatureParamsSchema,
} from "@autumn/shared";
// import {
// ApiFeatureType,
// ApiFeatureV0Schema,
// ErrCode,
// type Feature,
// FeatureType,
// type FeatureUsageType,
// UpdateFeatureParamsSchema,
// } from "@autumn/shared";
import express, { type Router } from "express";
import { JobName } from "@/queue/JobName.js";
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 { FeatureService } from "./FeatureService.js";
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 { Hono } from "hono";
import type { HonoEnv } from "../../honoUtils/HonoEnv";
import { handleCreateFeature } from "./handlers/handleCreateFeature";
import { handleDeleteFeature } from "./handlers/handleDeleteFeature";
import { handleGetFeature } from "./handlers/handleGetFeature";
import { handleListFeatures } from "./handlers/handleListFeatures";
import { handleUpdateFeature } from "./handlers/handleUpdateFeature";
import { handleGetFeatureDeletionInfo } from "./internalHandlers/handleGetFeatureDeletionInfo";
export const featureRouter: Router = express.Router();
// import express, { type Router } from "express";
// import { JobName } from "@/queue/JobName.js";
// 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 { FeatureService } from "./FeatureService.js";
// 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";
// 1. Get features...
featureRouter.get("", async (req: any, res: any) =>
routeHandler({
req,
res,
action: "list features",
handler: async () => {
const includeArchived = req.query.include_archived === "true";
const features = await FeatureService.list({
db: req.db,
orgId: req.orgId,
env: req.env,
archived: includeArchived ? undefined : false,
// showOnlyArchived: includeArchived ? undefined : false,
});
// export const featureRouter: Router = express.Router();
res
.status(200)
.json({ list: features.map((feature) => toApiFeature({ feature })) });
},
}),
// // 1. Get features...
// featureRouter.get("", async (req: any, res: any) =>
// routeHandler({
// req,
// res,
// action: "list features",
// handler: async () => {
// const includeArchived = req.query.include_archived === "true";
// const features = await FeatureService.list({
// db: req.db,
// orgId: req.orgId,
// env: req.env,
// archived: includeArchived ? undefined : false,
// // showOnlyArchived: includeArchived ? undefined : false,
// });
// res
// .status(200)
// .json({ list: features.map((feature) => toApiFeature({ feature })) });
// },
// }),
// );
// featureRouter.get("/:featureId", async (req: any, res: any) =>
// routeHandler({
// req,
// res,
// action: "Get feature",
// handler: async () => {
// const feature = req.features.find(
// (f: Feature) => f.id === req.params.featureId,
// );
// if (!feature) {
// throw new RecaseError({
// message: `Feature with id ${req.params.featureId} not found`,
// code: ErrCode.FeatureNotFound,
// statusCode: 404,
// });
// }
// res.status(200).json(toApiFeature({ feature }));
// },
// }),
// );
// featureRouter.post("", async (req: any, res: any) =>
// routeHandler({
// req,
// res,
// action: "Create feature",
// handler: async () => {
// const apiFeature = ApiFeatureV0Schema.parse(req.body);
// if (!apiFeature.name) {
// apiFeature.name = keyToTitle(apiFeature.id);
// }
// validateFeatureId(apiFeature.id);
// const feature = fromApiFeature({
// apiFeature,
// orgId: req.orgId,
// env: req.env,
// });
// const { db, logger, features: curFeatures } = req;
// const curFeature = curFeatures.find((f: Feature) => f.id === feature.id);
// if (curFeature) {
// throw new RecaseError({
// message: `Feature with id ${feature.id} already exists`,
// code: ErrCode.DuplicateFeatureId,
// statusCode: 400,
// });
// }
// await FeatureService.insert({ db, data: [feature], logger });
// await addTaskToQueue({
// jobName: JobName.GenerateFeatureDisplay,
// payload: { feature },
// });
// res.status(200).json(apiFeature);
// },
// }),
// );
// featureRouter.post("/:feature_id", async (req: any, res: any) =>
// routeHandler({
// req,
// res,
// action: "Update feature",
// handler: async (req: any, res: any) => {
// const { feature_id: featureId } = req.params;
// const { features: curFeatures } = req;
// const apiFeature = UpdateFeatureParamsSchema.parse(req.body);
// const originalFeature = curFeatures.find(
// (f: Feature) => f.id === featureId,
// );
// if (!originalFeature) {
// throw new RecaseError({
// message: `Feature with id ${featureId} not found`,
// code: ErrCode.FeatureNotFound,
// statusCode: 404,
// });
// }
// // Replace body...
// let featureType = apiFeature.type as unknown as FeatureType;
// let usageType: FeatureUsageType | undefined;
// if (
// apiFeature.type === ApiFeatureType.SingleUsage ||
// apiFeature.type === ApiFeatureType.ContinuousUse
// ) {
// featureType = FeatureType.Metered;
// usageType = apiFeature.type as unknown as FeatureUsageType;
// }
// const newConfig = originalFeature.config;
// if (usageType) {
// newConfig.usage_type = usageType;
// }
// if (apiFeature.credit_schema) {
// newConfig.schema = apiFeature.credit_schema.map((credit) => ({
// metered_feature_id: credit.metered_feature_id,
// credit_amount: credit.credit_cost,
// }));
// }
// const newBody = {
// id: req.body.id || undefined,
// name: req.body.name || undefined,
// type: featureType,
// config: newConfig,
// archived: req.body.archived ?? undefined,
// };
// req.body = newBody;
// await handleUpdateFeature(req, res, true);
// },
// }),
// );
// featureRouter.delete("/:featureId", handleDeleteFeature);
// featureRouter.get("/:feature_id/deletion_info", handleGetFeatureDeletionInfo);
export const featureRouter = new Hono<HonoEnv>();
featureRouter.get("", ...handleListFeatures);
featureRouter.post("", ...handleCreateFeature);
featureRouter.get("/:feature_id", ...handleGetFeature);
featureRouter.post("/:feature_id", ...handleUpdateFeature);
featureRouter.delete("/:feature_id", ...handleDeleteFeature);
featureRouter.get(
"/:feature_id/deletion_info",
...handleGetFeatureDeletionInfo,
);
featureRouter.get("/:featureId", async (req: any, res: any) =>
routeHandler({
req,
res,
action: "Get feature",
handler: async () => {
const feature = req.features.find(
(f: Feature) => f.id === req.params.featureId,
);
if (!feature) {
throw new RecaseError({
message: `Feature with id ${req.params.featureId} not found`,
code: ErrCode.FeatureNotFound,
statusCode: 404,
});
}
res.status(200).json(toApiFeature({ feature }));
},
}),
);
featureRouter.post("", async (req: any, res: any) =>
routeHandler({
req,
res,
action: "Create feature",
handler: async () => {
const apiFeature = ApiFeatureSchema.parse(req.body);
if (!apiFeature.name) {
apiFeature.name = keyToTitle(apiFeature.id);
}
validateFeatureId(apiFeature.id);
const feature = fromApiFeature({
apiFeature,
orgId: req.orgId,
env: req.env,
});
const { db, logger, features: curFeatures } = req;
const curFeature = curFeatures.find((f: Feature) => f.id === feature.id);
if (curFeature) {
throw new RecaseError({
message: `Feature with id ${feature.id} already exists`,
code: ErrCode.DuplicateFeatureId,
statusCode: 400,
});
}
await FeatureService.insert({ db, data: [feature], logger });
await addTaskToQueue({
jobName: JobName.GenerateFeatureDisplay,
payload: { feature },
});
res.status(200).json(apiFeature);
},
}),
);
featureRouter.post("/:feature_id", async (req: any, res: any) =>
routeHandler({
req,
res,
action: "Update feature",
handler: async (req: any, res: any) => {
const { feature_id: featureId } = req.params;
const { features: curFeatures } = req;
const apiFeature = UpdateFeatureParamsSchema.parse(req.body);
const originalFeature = curFeatures.find(
(f: Feature) => f.id === featureId,
);
if (!originalFeature) {
throw new RecaseError({
message: `Feature with id ${featureId} not found`,
code: ErrCode.FeatureNotFound,
statusCode: 404,
});
}
// Replace body...
let featureType = apiFeature.type as unknown as FeatureType;
let usageType: FeatureUsageType | undefined;
if (
apiFeature.type === ApiFeatureType.SingleUsage ||
apiFeature.type === ApiFeatureType.ContinuousUse
) {
featureType = FeatureType.Metered;
usageType = apiFeature.type as unknown as FeatureUsageType;
}
const newConfig = originalFeature.config;
if (usageType) {
newConfig.usage_type = usageType;
}
if (apiFeature.credit_schema) {
newConfig.schema = apiFeature.credit_schema.map((credit) => ({
metered_feature_id: credit.metered_feature_id,
credit_amount: credit.credit_cost,
}));
}
const newBody = {
id: req.body.id || undefined,
name: req.body.name || undefined,
type: featureType,
config: newConfig,
archived: req.body.archived ?? undefined,
};
req.body = newBody;
await handleUpdateFeature(req, res, true);
},
}),
);
featureRouter.delete("/:featureId", handleDeleteFeature);
featureRouter.get("/:feature_id/deletion_info", handleGetFeatureDeletionInfo);

View File

@@ -69,11 +69,10 @@ export const validateCreditSystem = (config: CreditSystemConfig) => {
const newConfig = { ...config, usage_type: FeatureUsageType.Single };
for (let i = 0; i < newConfig.schema.length; i++) {
newConfig.schema[i].feature_amount = 1;
const creditAmount = parseFloat(
newConfig.schema[i].credit_amount.toString(),
);
if (Number.isNaN(creditAmount)) {
throw new RecaseError({
message: `Credit amount should be a number`,
@@ -120,9 +119,10 @@ export const runSaveFeatureDisplayTask = async ({
},
});
} catch (error) {
logger.error("failed to generate feature display", {
error,
feature,
logger.error(`failed to generate feature display, ${error}`, {
data: {
feature,
},
});
}
};

View File

@@ -1,18 +1,43 @@
import { handleFrontendReqError } from "@/utils/errorUtils.js";
import { createFeature } from "../featureActions/createFeature.js";
import {
AffectedResource,
ApiVersion,
CreateFeatureV0ParamsSchema,
CreateFeatureV1ParamsSchema,
dbToApiFeatureV1,
featureV1ToDbFeature,
InternalError,
} from "@autumn/shared";
import { createRoute } from "../../../honoMiddlewares/routeHandler";
import { createFeature } from "../featureActions/createFeature";
export const handleCreateFeature = async (req: any, res: any) => {
try {
console.log("Trying to create feature");
const data = req.body;
export const handleCreateFeature = createRoute({
versionedBody: {
latest: CreateFeatureV1ParamsSchema,
[ApiVersion.V1_Beta]: CreateFeatureV0ParamsSchema,
},
resource: AffectedResource.Feature,
handler: async (c) => {
const body = c.req.valid("json");
const ctx = c.get("ctx");
const insertedFeature = await createFeature({
ctx: req,
data,
// Get backend feature
const feature = featureV1ToDbFeature({
apiFeature: body,
originalFeature: undefined,
});
res.status(200).json(insertedFeature);
} catch (error) {
handleFrontendReqError({ req, error, res, action: "Create feature" });
}
};
// Body is now always in the latest V1 format, regardless of API version
const dbFeature = await createFeature({
ctx,
data: feature,
});
if (!dbFeature) {
throw new InternalError({ message: "Insert feature returned null" });
}
return c.json(
dbToApiFeatureV1({ dbFeature, targetVersion: ctx.apiVersion }),
);
},
});

View File

@@ -1,67 +1,50 @@
import { ErrCode } from "@autumn/shared";
import { getCreditSystemsFromFeature } from "@/internal/features/creditSystemUtils.js";
import { FeatureService } from "@/internal/features/FeatureService.js";
import { EntitlementService } from "@/internal/products/entitlements/EntitlementService.js";
import RecaseError from "@/utils/errorUtils.js";
import type {
ExtendedRequest,
ExtendedResponse,
} from "@/utils/models/Request.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { FeatureNotFoundError, RecaseError } from "@autumn/shared";
import { createRoute } from "../../../honoMiddlewares/routeHandler";
import { EntitlementService } from "../../products/entitlements/EntitlementService";
import { getCreditSystemsFromFeature } from "../creditSystemUtils";
import { FeatureService } from "../FeatureService";
export const handleDeleteFeature = async (req: any, res: any) =>
routeHandler({
req,
res,
action: "Delete feature",
handler: async (req: ExtendedRequest, res: ExtendedResponse) => {
const { db, orgId } = req;
export const handleDeleteFeature = createRoute({
handler: async (c) => {
const ctx = c.get("ctx");
const { db, org, features } = ctx;
const { feature_id } = c.req.param();
const { featureId } = req.params;
const features = await FeatureService.getFromReq(req);
const feature = features.find((f) => f.id === featureId);
const creditSystems = getCreditSystemsFromFeature({
featureId,
features,
const feature = features.find((f) => f.id === feature_id);
if (!feature) {
throw new FeatureNotFoundError({ featureId: feature_id });
}
const creditSystems = getCreditSystemsFromFeature({
featureId: feature_id,
features,
});
if (creditSystems.length > 0) {
throw new RecaseError({
message: `Feature ${feature_id} is used by credit system ${creditSystems[0].id}`,
});
}
if (!feature) {
throw new RecaseError({
message: `Feature ${featureId} not found`,
code: ErrCode.FeatureNotFound,
statusCode: 404,
});
}
// Get prices that use this feature
const ent = await EntitlementService.getByFeature({
db,
internalFeatureId: feature.internal_id!,
});
if (creditSystems.length > 0) {
throw new RecaseError({
message: `Feature ${featureId} is used by credit system ${creditSystems[0].id}`,
code: ErrCode.InvalidFeature,
statusCode: 400,
});
}
// Get prices that use this feature
const ent = await EntitlementService.getByFeature({
db,
internalFeatureId: feature.internal_id!,
if (ent) {
throw new RecaseError({
message: `Feature ${feature_id} is used in a product. You must delete the product first, or archive it instead.`,
});
}
if (ent) {
throw new RecaseError({
message: `Feature ${featureId} is used in a product. You must delete the product first, or archive it instead.`,
code: ErrCode.InvalidFeature,
statusCode: 400,
});
}
await FeatureService.delete({
db,
orgId: org.id,
featureId: feature_id,
env: ctx.env,
});
await FeatureService.delete({
db: req.db,
orgId,
featureId,
env: req.env,
});
res.status(200).json({ success: true });
},
});
return c.json({ success: true });
},
});

View File

@@ -0,0 +1,42 @@
import {
AffectedResource,
dbToApiFeatureV1,
ErrCode,
RecaseError,
} from "@autumn/shared";
import { z } from "zod/v4";
import { createRoute } from "../../../honoMiddlewares/routeHandler";
import { FeatureService } from "../FeatureService";
export const handleGetFeature = createRoute({
resource: AffectedResource.Feature,
params: z.object({
feature_id: z.string(),
}),
handler: async (c) => {
const ctx = c.get("ctx");
const { feature_id } = c.req.param();
const feature = await FeatureService.get({
db: ctx.db,
id: feature_id,
orgId: ctx.org.id,
env: ctx.env,
});
if (!feature) {
throw new RecaseError({
message: `Feature with id ${feature_id} not found`,
code: ErrCode.FeatureNotFound,
statusCode: 404,
});
}
const apiFeature = dbToApiFeatureV1({
dbFeature: feature,
targetVersion: ctx.apiVersion,
});
return c.json(apiFeature);
},
});

View File

@@ -1,64 +0,0 @@
import { ExtendedRequest, ExtendedResponse } from "@/utils/models/Request.js";
import { and, eq, sql } from "drizzle-orm";
import { FeatureService } from "../FeatureService.js";
import { entitlements, products } from "@autumn/shared";
import { routeHandler } from "@/utils/routerUtils.js";
export const handleGetFeatureDeletionInfo = async (req: any, res: any) =>
routeHandler({
req,
res,
action: "Get feature deletion info",
handler: async (req: any, res: any) => {
let { db } = req;
let { feature_id } = req.params;
let feature = await FeatureService.get({
db,
id: feature_id,
orgId: req.orgId,
env: req.env,
});
if (!feature) {
return res.status(404).json({ error: "Feature not found" });
}
// Use Drizzle query similar to ProductService.getDeletionText
let res_data = await db
.select({
productName: sql<string>`CASE WHEN ROW_NUMBER() OVER (ORDER BY ${products.created_at}) = 1 THEN ${products.name ?? "Product name not found"} ELSE NULL END`,
totalCount: sql<number>`COUNT(*) OVER ()`,
})
.from(products)
.innerJoin(
entitlements,
eq(products.internal_id, entitlements.internal_product_id),
)
.where(
and(
eq(entitlements.internal_feature_id, feature.internal_id!),
eq(products.env, req.env),
eq(products.org_id, req.orgId),
),
)
.limit(1);
console.log(
`Feature ${feature_id} has ${res_data.length} products. First product name: ${res_data[0]?.productName}`,
);
// If no products found, return explicit zero count
if (!res_data || res_data.length === 0) {
res.status(200).json({
productName: null,
totalCount: 0,
});
} else {
res.status(200).json({
productName: res_data[0]?.productName || null,
totalCount: Number(res_data[0]?.totalCount) || 0,
});
}
},
});

View File

@@ -0,0 +1,15 @@
import { AffectedResource, dbToApiFeatureV1 } from "@autumn/shared";
import { createRoute } from "../../../honoMiddlewares/routeHandler";
export const handleListFeatures = createRoute({
resource: AffectedResource.Feature,
handler: async (c) => {
const ctx = c.get("ctx");
const apiFeatures = ctx.features.map((feature) =>
dbToApiFeatureV1({ dbFeature: feature, targetVersion: ctx.apiVersion }),
);
return c.json({ list: apiFeatures });
},
});

View File

@@ -1,33 +1,73 @@
import { routeHandler } from "@/utils/routerUtils.js";
import { updateFeature } from "../featureActions/updateFeature.js";
import { toApiFeature } from "../utils/mapFeatureUtils.js";
import {
AffectedResource,
ApiVersion,
dbToApiFeatureV1,
FeatureNotFoundError,
FeatureType,
featureV1ToDbFeatureConfig,
InternalError,
nullish,
RecaseError,
UpdateFeatureV0ParamsSchema,
UpdateFeatureV1ParamsSchema,
} from "@autumn/shared";
export const handleUpdateFeature = async (
req: any,
res: any,
_fromApi: boolean = false,
) =>
routeHandler({
req,
res,
action: "Update feature",
handler: async (req: any, res: any) => {
const featureId = req.params.feature_id;
const data = req.body;
import { createRoute } from "../../../honoMiddlewares/routeHandler";
import { updateFeature } from "../featureActions/updateFeature";
// Use the abstracted updateFeature function
const updatedFeature = await updateFeature({
ctx: req,
featureId,
updates: data,
export const handleUpdateFeature = createRoute({
versionedBody: {
latest: UpdateFeatureV1ParamsSchema,
[ApiVersion.V1_Beta]: UpdateFeatureV0ParamsSchema,
},
resource: AffectedResource.Feature,
handler: async (c) => {
const body = c.req.valid("json");
const ctx = c.get("ctx");
const { feature_id } = c.req.param();
const originalFeature = ctx.features.find((f) => f.id === feature_id);
if (!originalFeature) {
throw new FeatureNotFoundError({ featureId: feature_id });
}
// If changing type and consumable not provided, throw error
if (body.type === FeatureType.Metered && nullish(body.consumable)) {
throw new RecaseError({
message: "Consumable is required when changing type to metered",
});
}
res
.status(200)
.json(
updatedFeature
? toApiFeature({ feature: updatedFeature })
: undefined,
);
},
});
const newConfig = featureV1ToDbFeatureConfig({
apiFeature: body,
originalFeature,
});
const updatedFeature = await updateFeature({
ctx,
featureId: feature_id,
updates: {
id: body.id,
name: body.name ?? undefined,
type: body.type,
config: newConfig,
archived: body.archived,
event_names: body.event_names,
display: body.display,
},
});
if (!updatedFeature) {
throw new InternalError({ message: "Update feature returned null" });
}
return c.json(
dbToApiFeatureV1({
dbFeature: updatedFeature,
targetVersion: ctx.apiVersion,
}),
);
},
});

View File

@@ -1,93 +0,0 @@
import { CreateFeatureSchema, ErrCode, FeatureType } from "@autumn/shared";
import express, { type Router } from "express";
import { formatZodError } from "@/errors/formatZodError.js";
import { handleDeleteFeature } from "@/internal/features/handlers/handleDeleteFeature.js";
import { handleUpdateFeature } from "@/internal/features/handlers/handleUpdateFeature.js";
import RecaseError from "@/utils/errorUtils.js";
import { generateId } from "@/utils/genUtils.js";
import { FeatureService } from "./FeatureService.js";
import {
validateCreditSystem,
validateFeatureId,
validateMeteredConfig,
} from "./featureUtils.js";
import { handleCreateFeature } from "./handlers/handleCreateFeature.js";
import { handleGetFeatureDeletionInfo } from "./handlers/handleGetFeatureDeletionInfo.js";
export const internalFeatureRouter: Router = express.Router();
internalFeatureRouter.get("", async (req: any, res: any) => {
try {
const { showArchived } = req.query;
if (showArchived !== undefined) {
// If showArchived is specified, use FeatureService.list with the parameter
const features = await FeatureService.list({
db: req.db,
orgId: req.orgId,
env: req.env,
archived: showArchived === "true",
});
res.status(200).json({ features });
} else {
// If no showArchived parameter, use the original getFromReq method
const features = await FeatureService.getFromReq(req);
res.status(200).json({ features });
}
} catch (error: any) {
console.log("Error fetching features:", error);
res.status(500).json({ error: error.message });
}
});
export const validateFeature = (data: any) => {
const featureType = data.type;
validateFeatureId(data.id);
let config = data.config;
if (featureType === FeatureType.Metered) {
config = validateMeteredConfig(config);
} else if (featureType === FeatureType.CreditSystem) {
config = validateCreditSystem(config);
}
try {
const parsedFeature = CreateFeatureSchema.parse({ ...data, config });
return parsedFeature;
} catch (error: any) {
throw new RecaseError({
message: `Invalid feature: ${formatZodError(error)}`,
code: ErrCode.InvalidFeature,
statusCode: 400,
});
}
};
export const initNewFeature = ({
data,
orgId,
env,
}: {
data: any;
orgId: string;
env: any;
}) => {
return {
...data,
org_id: orgId,
env,
created_at: Date.now(),
internal_id: generateId("fe"),
};
};
internalFeatureRouter.post("", handleCreateFeature);
internalFeatureRouter.get(
"/data/deletion_text/:feature_id",
handleGetFeatureDeletionInfo,
);
internalFeatureRouter.post("/:feature_id", handleUpdateFeature as any);
internalFeatureRouter.delete("/:featureId", handleDeleteFeature);

View File

@@ -0,0 +1,70 @@
import {
AffectedResource,
ErrCode,
entitlements,
products,
RecaseError,
} from "@autumn/shared";
import { and, eq, sql } from "drizzle-orm";
import { createRoute } from "../../../honoMiddlewares/routeHandler";
import { FeatureService } from "../FeatureService.js";
export const handleGetFeatureDeletionInfo = createRoute({
resource: AffectedResource.Feature,
handler: async (c) => {
const ctx = c.get("ctx");
const { feature_id } = c.req.param();
const feature = await FeatureService.get({
db: ctx.db,
id: feature_id,
orgId: ctx.org.id,
env: ctx.env,
});
if (!feature) {
throw new RecaseError({
message: "Feature not found",
code: ErrCode.FeatureNotFound,
statusCode: 404,
});
}
// Use Drizzle query similar to ProductService.getDeletionText
const res_data = await ctx.db
.select({
productName: sql<string>`CASE WHEN ROW_NUMBER() OVER (ORDER BY ${products.created_at}) = 1 THEN ${products.name ?? "Product name not found"} ELSE NULL END`,
totalCount: sql<number>`COUNT(*) OVER ()`,
})
.from(products)
.innerJoin(
entitlements,
eq(products.internal_id, entitlements.internal_product_id),
)
.where(
and(
eq(entitlements.internal_feature_id, feature.internal_id!),
eq(products.env, ctx.env),
eq(products.org_id, ctx.org.id),
),
)
.limit(1);
console.log(
`Feature ${feature_id} has ${res_data.length} products. First product name: ${res_data[0]?.productName}`,
);
// If no products found, return explicit zero count
if (!res_data || res_data.length === 0) {
return c.json({
productName: null,
totalCount: 0,
});
}
return c.json({
productName: res_data[0]?.productName || null,
totalCount: Number(res_data[0]?.totalCount) || 0,
});
},
});

View File

@@ -1,10 +1,7 @@
import {
type ApiFeature,
ApiFeatureSchema,
ApiFeatureType,
type ApiFeatureV0,
type AppEnv,
type CreditSchemaItem,
type Feature,
FeatureType,
type FeatureUsageType,
} from "@autumn/shared";
@@ -15,41 +12,12 @@ import {
constructMeteredFeature,
} from "./constructFeatureUtils.js";
export const toApiFeature = ({ feature }: { feature: Feature }) => {
// return FeatureResponseSchema.parse(feature);
// 1. Get feature type
let featureType = feature.type;
if (feature.type === FeatureType.Metered) {
featureType = feature.config.usage_type;
}
let creditSchema;
if (feature.type === FeatureType.CreditSystem) {
creditSchema = feature.config.schema.map((s: CreditSchemaItem) => ({
metered_feature_id: s.metered_feature_id,
credit_cost: s.credit_amount,
}));
}
return ApiFeatureSchema.parse({
id: feature.id,
name: feature.name,
type: featureType,
display: {
singular: feature.display?.singular || feature.name,
plural: feature.display?.plural || feature.name,
},
credit_schema: creditSchema,
archived: feature.archived,
});
};
export const fromApiFeature = ({
apiFeature,
orgId,
env,
}: {
apiFeature: ApiFeature;
apiFeature: ApiFeatureV0;
orgId: string;
env: AppEnv;
}) => {

View File

@@ -12,7 +12,6 @@ import { analyticsRouter } from "./analytics/internalAnalyticsRouter.js";
import { trmnlRouter } from "./api/trmnl/trmnlRouter.js";
import { cusRouter } from "./customers/internalCusRouter.js";
import { devRouter } from "./dev/devRouter.js";
import { internalFeatureRouter } from "./features/internalFeatureRouter.js";
import { InvoiceService } from "./invoices/InvoiceService.js";
import { handlePostOrg } from "./orgs/handlers/handlePostOrg.js";
import { onboardingRouter } from "./orgs/onboarding/onboardingRouter.js";
@@ -32,7 +31,6 @@ mainRouter.use("/admin", withAdminAuth, adminRouter);
mainRouter.use("/users", withAuth, userRouter);
mainRouter.use("/onboarding", withOrgAuth, onboardingRouter);
mainRouter.use("/organization", withOrgAuth, orgRouter);
mainRouter.use("/features", withOrgAuth, internalFeatureRouter);
mainRouter.use("/products", withOrgAuth, expressProductRouter);
mainRouter.use("/dev", devRouter);
mainRouter.use("/customers", withOrgAuth, cusRouter);

View File

@@ -9,6 +9,7 @@ import {
type Feature,
FeatureType,
type FreeTrial,
FreeTrialDuration,
type FullEntitlement,
type FullProduct,
type Price,
@@ -44,6 +45,22 @@ export const entIntervalToTrialDuration = ({
}
};
export const trialToDays = (freeTrial: FreeTrial) => {
let days: number;
switch (freeTrial.duration) {
case FreeTrialDuration.Day:
days = freeTrial.length;
break;
case FreeTrialDuration.Month:
days = freeTrial.length * 30;
break;
case FreeTrialDuration.Year:
days = freeTrial.length * 365;
break;
}
return days;
};
export const applyTrialToEntitlement = (
entitlement: EntitlementWithFeature,
freeTrial: FreeTrial | null,
@@ -55,13 +72,13 @@ export const applyTrialToEntitlement = (
return false;
if (entitlement.allowance_type === AllowanceType.Unlimited) return false;
const trialDays = freeTrial.length;
const trialDays = trialToDays(freeTrial);
const entDays = entIntervalToTrialDuration({
interval: entitlement.interval!,
intervalCount: entitlement.interval_count || 1,
});
if (entDays && entDays > trialDays) {
if (entDays && trialDays < entDays) {
return true;
}

View File

@@ -6,10 +6,29 @@ import {
} from "@autumn/shared";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { FeatureService } from "@/internal/features/FeatureService.js";
import { initNewFeature } from "@/internal/features/internalFeatureRouter.js";
import { ProductService } from "@/internal/products/ProductService.js";
import { copyProduct } from "@/internal/products/productUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import { generateId } from "../../../../utils/genUtils";
export const initNewFeature = ({
data,
orgId,
env,
}: {
data: any;
orgId: string;
env: any;
}) => {
return {
...data,
org_id: orgId,
env,
created_at: Date.now(),
internal_id: generateId("fe"),
};
};
/**
* Route: POST /v1/products/:productId/copy - Copy a product

View File

@@ -13,10 +13,7 @@ const DeleteProductParamsSchema = z.object({
});
const DeleteProductQuerySchema = z.object({
all_versions: z
.string()
.optional()
.transform((val) => val === "true"),
all_versions: z.boolean(),
});
export const handleDeleteProduct = createRoute({
@@ -24,7 +21,7 @@ export const handleDeleteProduct = createRoute({
query: DeleteProductQuerySchema,
resource: AffectedResource.Product,
handler: async (c) => {
const { product_id } = c.req.valid("param");
const { product_id } = c.req.param();
const { all_versions } = c.req.valid("query");
const { db, org, env } = c.get("ctx");

View File

@@ -0,0 +1,50 @@
import { AffectedResource, ProductNotFoundError } from "@autumn/shared";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { CusProdReadService } from "../../customers/cusProducts/CusProdReadService.js";
import { ProductService } from "../ProductService.js";
export const handleGetPlanDeleteInfo = createRoute({
resource: AffectedResource.Product,
handler: async (c) => {
const ctx = c.get("ctx");
const { db, org, env } = ctx;
const { product_id } = c.req.param();
const product = await ProductService.get({
db,
id: product_id,
orgId: org.id,
env,
});
if (!product) {
throw new ProductNotFoundError({ productId: product_id });
}
const [allVersions, latestVersion, deletionText] = await Promise.all([
CusProdReadService.existsForProduct({
db,
productId: product_id,
}),
CusProdReadService.existsForProduct({
db,
internalProductId: product.internal_id,
}),
ProductService.getDeletionText({
db,
productId: product_id,
orgId: org.id,
env,
}),
]);
return c.json({
numVersion: product.version,
hasCusProducts: allVersions,
hasCusProductsLatest: latestVersion,
customerName:
deletionText[0]?.name || deletionText[0]?.email || deletionText[0]?.id,
totalCount: deletionText[0]?.totalCount,
});
},
});

View File

@@ -0,0 +1,58 @@
import {
AffectedResource,
ProductNotFoundError,
type ProductV2,
productsAreSame,
} from "@autumn/shared";
import { z } from "zod/v4";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { CusProductService } from "../../customers/cusProducts/CusProductService.js";
import { ProductService } from "../ProductService.js";
const HasCustomersBodySchema = z.object({
id: z.string().optional(),
items: z.array(z.any()).optional(),
free_trial: z.any().optional(),
});
export const handlePlanHasCustomers = createRoute({
body: HasCustomersBodySchema,
resource: AffectedResource.Product,
handler: async (c) => {
const ctx = c.get("ctx");
const { db, features, org, env } = ctx;
const { product_id } = c.req.param();
const body = c.req.valid("json");
const product = await ProductService.getFull({
db,
idOrInternalId: product_id,
orgId: org.id,
env,
});
if (!product) {
throw new ProductNotFoundError({ productId: product_id });
}
const cusProductsCurVersion =
await CusProductService.getByInternalProductId({
db,
internalProductId: product.internal_id,
});
const { itemsSame, freeTrialsSame } = productsAreSame({
newProductV2: body as ProductV2,
curProductV1: product,
features,
});
const productSame = itemsSame && freeTrialsSame;
return c.json({
current_version: product.version,
will_version: !productSame && cusProductsCurVersion.length > 0,
archived: product.archived,
});
},
});

View File

@@ -223,7 +223,6 @@ export const handleUpdateProductDetails = async ({
}
// 2. Update product
console.log("New group:", newProduct.group);
await ProductService.updateByInternalId({
db,

View File

@@ -99,12 +99,12 @@ export const handleVersionProductV2 = async ({
data: customPrices,
});
// Handle new free trial
// Handle new free trial (create new)
if (newProductV2.free_trial || latestProduct.free_trial) {
await handleNewFreeTrial({
db,
newFreeTrial: newProductV2.free_trial || null,
curFreeTrial: latestProduct.free_trial,
curFreeTrial: null,
internalProductId: newProduct.internal_id,
isCustom: false,
newVersion: true, // This is a new product version

View File

@@ -81,15 +81,15 @@ export const updateProduct = async ({
const newFreeTrial =
"free_trial" in updates
? (updates.free_trial as FreeTrial | undefined)
: curProductV2.free_trial;
? ((updates.free_trial as FreeTrial | undefined) ?? undefined)
: (curProductV2.free_trial ?? undefined);
const newProductV2: ProductV2 = {
...curProductV2,
...updates,
group: updates.group || curProductV2.group || "",
items: updates.items || [],
free_trial:
"free_trial" in updates ? newFreeTrial : curProductV2.free_trial,
free_trial: newFreeTrial,
};
await disableCurrentDefault({
@@ -101,8 +101,7 @@ export const updateProduct = async ({
db,
curProduct: fullProduct,
newProduct: UpdateProductSchema.parse(updates),
newFreeTrial:
"free_trial" in updates ? updates.free_trial : curProductV2.free_trial,
newFreeTrial: newFreeTrial,
items: updates.items || curProductV2.items,
org,
rewardPrograms,

View File

@@ -1,4 +1,6 @@
import type { IntervalConfig } from "@autumn/shared";
import { UTCDate } from "@date-fns/utc";
import { toMilliseconds } from "../../../utils/timeUtils.js";
import {
addIntervalForProration,
subtractIntervalForProration,
@@ -31,6 +33,15 @@ export const addIntervalToAnchor = ({
});
};
const isLessThanEquals = ({ a, b }: { a: UTCDate; b: UTCDate }) => {
// Check if a is <= now. return true if a is ~ same as b (maybe by a couple of hours?)
const aUnix = a.getTime();
const bUnix = b.getTime();
if (aUnix < bUnix + toMilliseconds.hours(1)) return true;
return false;
};
export const subtractIntervalFromAnchor = ({
anchor,
intervalConfig,
@@ -45,13 +56,14 @@ export const subtractIntervalFromAnchor = ({
for (let i = 0; i < 50; i++) {
const newAnchor = subtractIntervalForProration({
unixTimestamp: anchor,
unixTimestamp: curAnchor,
interval: intervalConfig.interval,
intervalCount: intervalConfig.intervalCount ?? 1,
});
// Return anchor before it goes below now
if (newAnchor <= now) return curAnchor;
if (isLessThanEquals({ a: new UTCDate(newAnchor), b: new UTCDate(now) }))
return curAnchor;
curAnchor = newAnchor;
}

View File

@@ -6,11 +6,12 @@ import {
entToPrice,
getBillingType,
isFixedPrice,
itemToEntInterval,
type Price,
type ProductItem,
UsageModel,
} from "@autumn/shared";
import { itemToEntInterval } from "../itemIntervalUtils.js";
import { isFeatureItem, isFeaturePriceItem } from "./getItemType.js";
export const addIdsToProductItems = ({
@@ -56,10 +57,10 @@ export const addIdsToProductItems = ({
// Sort by interval
const aIntervalValue = entIntervalToValue(
itemToEntInterval(a) as EntInterval,
itemToEntInterval({ item: a }) as EntInterval,
);
const bIntervalValue = entIntervalToValue(
itemToEntInterval(b) as EntInterval,
itemToEntInterval({ item: b }) as EntInterval,
);
if (!aIntervalValue.eq(bIntervalValue)) {
return aIntervalValue.sub(bIntervalValue).toNumber();

View File

@@ -1,140 +1,12 @@
import { ProductNotFoundError, productsAreSame } from "@autumn/shared";
import { Router } from "express";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { createStripePriceIFNotExist } from "@/external/stripe/createStripePrice/createStripePrice.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { ProductService } from "@/internal/products/ProductService.js";
import { checkStripeProductExists } from "@/internal/products/productUtils.js";
import { handleRequestError } from "@/utils/errorUtils.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { CusProductService } from "../customers/cusProducts/CusProductService.js";
import { handleGetProductDeleteInfo } from "./handlers/handleGetProductDeleteInfo.js";
export const productRouter: Router = Router();
productRouter.post("/all/init_stripe", async (req: any, res) => {
try {
const { orgId, env, logger, db } = req;
const [fullProducts, org] = await Promise.all([
ProductService.listFull({
db,
orgId,
env,
}),
OrgService.getFromReq(req),
]);
console.log(
"fullProducts",
fullProducts.map((p) => p.id),
);
const stripeCli = createStripeCli({
org,
env,
});
const productBatchSize = 5;
for (let i = 0; i < fullProducts.length; i += productBatchSize) {
const batch = fullProducts.slice(i, i + productBatchSize);
const batchPromises = batch.map((product) =>
checkStripeProductExists({
db,
org,
env,
product,
logger,
}),
);
await Promise.all(batchPromises);
}
const entitlements = fullProducts.flatMap((p) => p.entitlements);
const prices = fullProducts.flatMap((p) => p.prices);
const batchSize = 3;
for (let i = 0; i < prices.length; i += batchSize) {
const batch = prices.slice(i, i + batchSize);
const batchPriceUpdate = [];
for (const price of batch) {
batchPriceUpdate.push(
createStripePriceIFNotExist({
db,
org,
stripeCli: stripeCli,
price,
entitlements,
product: fullProducts.find(
(p) => p.internal_id === price.internal_product_id,
)!,
logger,
}),
);
}
await Promise.all(batchPriceUpdate);
}
res.status(200).json({ message: "Stripe products initialized" });
} catch (error) {
handleRequestError({ req, error, res, action: "Init stripe products" });
}
});
productRouter.get("/:productId/has_customers", async (req: any, res: any) =>
routeHandler({
req,
res,
action: "Get product has customers",
handler: async () => {
const { productId } = req.params;
const { db, features } = req;
const { id, items, free_trial } = req.body;
const product = await ProductService.getFull({
db,
idOrInternalId: productId,
orgId: req.orgId,
env: req.env,
});
if (!product) {
throw new ProductNotFoundError({ productId });
}
const cusProductsCurVersion =
await CusProductService.getByInternalProductId({
db,
internalProductId: product.internal_id,
});
const { itemsSame, freeTrialsSame } = productsAreSame({
newProductV2: req.body,
curProductV1: product,
features,
});
const productSame = itemsSame && freeTrialsSame;
res.status(200).json({
current_version: product.version,
will_version: !productSame && cusProductsCurVersion.length > 0,
archived: product.archived,
});
},
}),
);
productRouter.get("/:productId/deletion_info", handleGetProductDeleteInfo);
import { Hono } from "hono";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { handleCopyProductV2 } from "./handlers/handleCopyProduct/handleCopyProductV2.js";
import { handleCreatePlan } from "./handlers/handleCreatePlan.js";
import { handleDeleteProduct as handleDeleteProductHono } from "./handlers/handleDeleteProduct.js";
import { handleGetPlan } from "./handlers/handleGetPlan.js";
import { handleGetPlanDeleteInfo } from "./handlers/handleGetPlanDeleteInfo.js";
import { handleListPlans } from "./handlers/handleListPlans.js";
import { handlePlanHasCustomers } from "./handlers/handlePlanHasCustomers.js";
import { handleUpdatePlan } from "./handlers/handleUpdateProduct/handleUpdatePlan.js";
export const honoProductBetaRouter = new Hono<HonoEnv>();
@@ -144,11 +16,85 @@ honoProductBetaRouter.get("", ...handleListPlans);
export const honoProductRouter = new Hono<HonoEnv>();
export const migrationRouter = new Hono<HonoEnv>();
// POST /products - Create a product
// CRUD
honoProductRouter.get("", ...handleListPlans);
honoProductRouter.post("", ...handleCreatePlan);
honoProductRouter.get("/:product_id", ...handleGetPlan);
honoProductRouter.post("/:product_id", ...handleUpdatePlan); // will be deprecated
honoProductRouter.delete("/:product_id", ...handleDeleteProductHono);
// Others
honoProductRouter.post("/:product_id/copy", ...handleCopyProductV2);
// Info before deleting plan
honoProductRouter.get("/:product_id/has_customers", ...handlePlanHasCustomers);
honoProductRouter.get("/:product_id/deletion_info", ...handleGetPlanDeleteInfo);
// productRouter.post("/all/init_stripe", async (req: any, res) => {
// try {
// const { orgId, env, logger, db } = req;
// const [fullProducts, org] = await Promise.all([
// ProductService.listFull({
// db,
// orgId,
// env,
// }),
// OrgService.getFromReq(req),
// ]);
// console.log(
// "fullProducts",
// fullProducts.map((p) => p.id),
// );
// const stripeCli = createStripeCli({
// org,
// env,
// });
// const productBatchSize = 5;
// for (let i = 0; i < fullProducts.length; i += productBatchSize) {
// const batch = fullProducts.slice(i, i + productBatchSize);
// const batchPromises = batch.map((product) =>
// checkStripeProductExists({
// db,
// org,
// env,
// product,
// logger,
// }),
// );
// await Promise.all(batchPromises);
// }
// const entitlements = fullProducts.flatMap((p) => p.entitlements);
// const prices = fullProducts.flatMap((p) => p.prices);
// const batchSize = 3;
// for (let i = 0; i < prices.length; i += batchSize) {
// const batch = prices.slice(i, i + batchSize);
// const batchPriceUpdate = [];
// for (const price of batch) {
// batchPriceUpdate.push(
// createStripePriceIFNotExist({
// db,
// org,
// stripeCli: stripeCli,
// price,
// entitlements,
// product: fullProducts.find(
// (p) => p.internal_id === price.internal_product_id,
// )!,
// logger,
// }),
// );
// }
// await Promise.all(batchPriceUpdate);
// }
// res.status(200).json({ message: "Stripe products initialized" });
// } catch (error) {
// handleRequestError({ req, error, res, action: "Init stripe products" });
// }
// });

View File

@@ -12,10 +12,10 @@ import {
type FullProduct,
type Price,
type ProductItem,
toApiFeature,
UsageModel,
} from "@autumn/shared";
import type { DrizzleCli } from "@/db/initDrizzle.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";

View File

@@ -84,7 +84,6 @@ export const mapToProductV2 = ({
const productV2: ProductV2 = {
internal_id: product.internal_id,
env: product.env,
id: product.id,
name: product.name,
is_add_on: product.is_add_on,

View File

@@ -58,6 +58,7 @@ export const createWorkerContext = async ({
apiVersion: createdAtToVersion({ createdAt: org.created_at! }),
clickhouseClient: null as any,
expand: [],
skipCache: true,
};
return ctx;

View File

@@ -1,10 +1,11 @@
import {
dbToApiFeatureV1,
type Feature,
type FullCustomer,
toApiFeature,
WebhookEventType,
} from "@autumn/shared";
import { sendSvixEvent } from "@/external/svix/svixHelpers.js";
import { toApiFeature } from "@/internal/features/utils/mapFeatureUtils.js";
import type { AutumnContext } from "../honoUtils/HonoEnv.js";
import { apiBalanceToAllowed } from "../internal/api/check/checkUtils/apiBalanceToAllowed.js";
import { getApiCustomerBase } from "../internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.js";
@@ -59,7 +60,10 @@ export const handleAllowanceUsed = async ({
data: {
threshold_type: "allowance_used",
customer: newApiCustomer,
feature: toApiFeature({ feature }),
feature: dbToApiFeatureV1({
dbFeature: feature,
targetVersion: ctx.apiVersion,
}),
},
});
}

View File

@@ -44,7 +44,6 @@ import {
getMeteredDeduction,
performDeduction,
} from "./deductUtils.js";
import { handleThresholdReached } from "./handleThresholdReached.js";
export type DeductParams = {
db: DrizzleCli;
@@ -265,7 +264,7 @@ export const performDeductionOnCusEnt = ({
deducted: newDeducted,
toDeduct: newToDeduct,
} = performDeduction({
cusEntBalance: new Decimal(entityBalance),
cusEntBalance: new Decimal(entityBalance ?? 0),
toDeduct: toDeductCursor,
allowNegativeBalance,
ent: cusEnt.entitlement,
@@ -527,7 +526,7 @@ export const deductFromUsageBasedCusEnt = async ({
const cusPrice = getRelatedCusPrice(usageBasedEnt, cusPrices);
const billingType = cusPrice?.price
? getBillingType(cusPrice?.price.config!)
? getBillingType(cusPrice?.price.config ?? undefined)
: undefined;
const blockUsageLimit =
billingType === BillingType.InArrearProrated ? false : true;
@@ -723,18 +722,6 @@ export const updateCustomerBalance = async ({
},
});
}
handleThresholdReached({
org,
env,
features: allFeatures,
db,
feature,
cusEnts: originalCusEnts,
newCusEnts: cusEnts,
fullCus: customer,
logger,
});
}
return cusEnts;

View File

@@ -31,7 +31,6 @@ import RecaseError from "@/utils/errorUtils.js";
import { deductFromAdditionalBalance } from "../internal/balances/deductUtils/deductFromAdditionalBalance.js";
import { generateId } from "../utils/genUtils.js";
import { handleThresholdReached } from "./handleThresholdReached.js";
import {
deductAllowanceFromCusEnt,
deductFromUsageBasedCusEnt,
@@ -475,6 +474,7 @@ export const updateUsage = async ({
apiVersion: new ApiVersionClass(LATEST_VERSION),
timestamp: Date.now(),
expand: [],
skipCache: true,
},
});
@@ -520,18 +520,6 @@ export const updateUsage = async ({
};
await performFeatureDeduction();
handleThresholdReached({
org,
env,
features: allFeatures,
db,
feature,
cusEnts: originalCusEnts,
newCusEnts: cusEnts,
fullCus: customer,
logger,
});
}
return cusEnts;

View File

@@ -90,6 +90,12 @@ export const normalizeCachedBalance = (balance: any): any => {
}
}
if (balance.feature?.event_names) {
balance.feature.event_names = normalizeArray(
balance.feature.event_names,
) as typeof balance.feature.event_names;
}
return balance;
};
@@ -127,64 +133,39 @@ export const normalizeCachedData = <T extends ApiCustomer | ApiEntityV1>(
// Fix missing credit_schema -> null
if (data.balances) {
for (const featureId in data.balances) {
const feature = data.balances[featureId];
const balance = data.balances[featureId];
// if (!feature.reset) {
// feature.reset = null;
// }
if (
!Array.isArray(feature.breakdown) &&
typeof feature.breakdown === "object"
!Array.isArray(balance.breakdown) &&
typeof balance.breakdown === "object"
) {
feature.breakdown = undefined;
balance.breakdown = undefined;
}
if (
!Array.isArray(feature.rollovers) &&
typeof feature.rollovers === "object"
!Array.isArray(balance.rollovers) &&
typeof balance.rollovers === "object"
) {
feature.rollovers = undefined;
balance.rollovers = undefined;
}
if (feature.breakdown) {
for (const breakdown of feature.breakdown) {
if (balance.feature?.event_names) {
balance.feature.event_names = normalizeArray(
balance.feature.event_names,
) as typeof balance.feature.event_names;
}
if (balance.breakdown) {
for (const breakdown of balance.breakdown) {
// if (!breakdown.reset) {
// breakdown.reset = null;
// }
}
}
// if (feature.usage_limit === 0 || feature.usage_limit === null) {
// feature.usage_limit = undefined;
// }
// // Ensure credit_schema is null if undefined (for consistent schema)
// if (feature.credit_schema === null) {
// feature.credit_schema = undefined;
// }
// if (feature.interval_count === undefined) {
// feature.interval_count = null;
// }
// // interval should be null if undefined
// if (feature.interval === undefined) {
// feature.interval = null;
// }
// Fix breakdown usage_limit
// if (feature.breakdown) {
// for (const breakdown of feature.breakdown) {
// if (breakdown.usage_limit === 0) {
// breakdown.usage_limit = undefined;
// }
// // if (breakdown.next_reset_at === undefined) {
// // breakdown.next_reset_at = null;
// // }
// }
// }
}
}

View File

@@ -9,9 +9,9 @@ import {
type FullCustomer,
type Organization,
} from "@autumn/shared";
import { defaultApiVersion } from "@tests/constants.js";
import { cusProductToSubIds } from "@tests/merged/mergeUtils.test.js";
import type Stripe from "stripe";
import { defaultApiVersion } from "tests/constants.js";
import { cusProductToSubIds } from "tests/merged/mergeUtils.test.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { priceToStripeItem } from "@/external/stripe/priceToStripeItem/priceToStripeItem.js";
import { subIsCanceled } from "@/external/stripe/stripeSubUtils.js";

View File

@@ -6,7 +6,6 @@ import {
CreateFreeTrialSchema,
type CreateReward,
FeatureUsageType,
type FreeTrial,
FreeTrialDuration,
type ProductItem,
type ProductV2,
@@ -144,15 +143,7 @@ export const constructProduct = ({
id ||
(isAnnual ? `${type}-annual` : interval ? `${type}-${interval}` : type);
let free_trial: CreateFreeTrial | null = null;
if (freeTrial) {
free_trial = freeTrial as FreeTrial;
} else if (trial) {
free_trial = CreateFreeTrialSchema.parse({
length: 7,
duration: FreeTrialDuration.Day,
});
}
const freeTrialLength = freeTrial?.length || 7;
const product: ProductV2 = {
id: id_,
@@ -169,7 +160,15 @@ export const constructProduct = ({
is_default: (type === "free" && isDefault) || forcePaidDefault,
version: 1,
group: group || "",
free_trial: free_trial as FreeTrial,
free_trial:
freeTrial || trial
? (CreateFreeTrialSchema.parse({
length: freeTrialLength,
duration: FreeTrialDuration.Day,
unique_fingerprint: true,
card_required: true,
}) as any)
: null,
created_at: Date.now(),
};

View File

@@ -8,6 +8,7 @@ import { createProducts } from "@tests/utils/productUtils.js";
import type { TestContext } from "@tests/utils/testInitUtils/createTestContext.js";
import { and, eq, inArray } from "drizzle-orm";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { deleteCachedApiCustomer } from "../../../internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer";
export const createSharedProducts = async ({
products,
@@ -33,16 +34,30 @@ export const createSharedProducts = async ({
throw new Error("Too many customers under shared default free product");
}
await ctx.db.delete(customers).where(
and(
inArray(
customers.internal_id,
cusProducts.map((cp) => cp.internal_customer_id),
const deletedCustomers = await ctx.db
.delete(customers)
.where(
and(
inArray(
customers.internal_id,
cusProducts.map((cp) => cp.internal_customer_id),
),
eq(customers.env, ctx.env),
eq(customers.org_id, ctx.org.id),
),
eq(customers.env, ctx.env),
eq(customers.org_id, ctx.org.id),
),
);
)
.returning();
const clearCache = [];
for (const customer of deletedCustomers) {
clearCache.push(
deleteCachedApiCustomer({
customerId: customer.id ?? "",
orgId: ctx.org.id,
env: ctx.env,
}),
);
}
await Promise.all(clearCache);
const autumn = new AutumnInt({
secretKey: ctx.orgSecretKey,

View File

@@ -60,5 +60,6 @@ export const createWorkerAutumnContext = async ({
authType: AuthType.Unknown,
apiVersion,
timestamp: Date.now(),
skipCache: false,
} satisfies AutumnContext;
};

View File

@@ -1,18 +0,0 @@
export class WebsocketManager {
public channels: Map<string, Set<WebSocket>>;
public subscriptions: WeakMap<WebSocket, Set<string>>;
constructor() {
this.channels = new Map();
this.subscriptions = new WeakMap();
}
// Initialize a client's subscription tracking
initializeClient(ws: WebSocket) {
// this.subscriptions.set(ws, new Set());
// this.sendToClient(ws, {
// type: "welcome",
// channels: Array.from(this.channels.keys()),
// });
}
}

View File

@@ -1,127 +0,0 @@
import http from "http";
import { AppEnv, ErrCode } from "@autumn/shared";
import { WebSocketServer, WebSocket } from "ws";
export enum SbChannelEvent {
BalanceUpdated = "balance_updated",
}
interface RouteInfo {
pattern: RegExp;
paramNames: string[];
callback: (
ws: WebSocket,
req: http.IncomingMessage,
params: Record<string, string>,
) => Promise<void>;
}
const getPkey = async (req: any) => {
const query = req.url.split("?")[1];
const queryParams = new URLSearchParams(query);
const pkey = req.headers["x-publishable-key"] || queryParams.get("pkey");
if (!pkey) {
throw new Error("No publishable key found");
}
if (typeof pkey !== "string") {
throw new Error("Invalid publishable key");
}
if (!pkey.startsWith("am_pk_test_") && !pkey.startsWith("am_pk_live_")) {
throw new Error("Invalid publishable key");
}
const env = pkey.startsWith("am_pk_test_") ? AppEnv.Sandbox : AppEnv.Live;
return {
error: ErrCode.OrgNotFound,
fallback: false,
statusCode: 401,
};
};
class WebSocketRouter {
private wss: WebSocketServer;
private routes: RouteInfo[] = [];
public on({
route,
callback,
}: {
route: string;
callback: (
ws: WebSocket,
req: any,
params: Record<string, string>,
) => Promise<void>;
}) {
const paramNames: string[] = [];
const pattern = route.replace(/:([^/]+)/g, (_, paramName) => {
paramNames.push(paramName);
return "([^/]+)";
});
this.routes.push({
pattern: new RegExp(`^${pattern}$`),
paramNames,
callback,
});
}
constructor(server: http.Server) {
this.wss = new WebSocketServer({ server });
this.wss.on("connection", (ws: WebSocket, req: http.IncomingMessage) =>
this.handleConnection(ws as any, req as any),
);
}
private async handleConnection(ws: WebSocket, req: any) {
const path = req.url;
try {
} catch (error) {
console.log("Failed to get org from pkey");
ws.close(1000, "Invalid publishable key");
return;
}
for (const route of this.routes) {
const match = path.match(route.pattern);
if (match) {
// Extract params from match groups
const params: Record<string, string> = {};
route.paramNames.forEach((name, index) => {
params[name] = match[index + 1];
});
route.callback(ws, req, params);
return;
}
}
if (!path) {
ws.close(1000, "No path found");
return;
}
ws.close(1000, "Route not found");
}
}
export const initWs = (server: http.Server) => {
const wsRouter = new WebSocketRouter(server);
wsRouter.on({
route: "/:customer_id/entitlements",
callback: async (ws, req, params) => {
console.log("entitlements", params);
},
});
wsRouter.on({
route: "/:customer_id/entitlements/:feature_id",
callback: async (ws, req, params) => {
console.log("entitlement", params);
},
});
};

View File

@@ -1,21 +1,10 @@
import { beforeAll, describe, test } from "bun:test";
import {
type AppEnv,
LegacyVersion,
OnDecrease,
OnIncrease,
type Organization,
} from "@autumn/shared";
import { FreeTrialDuration, LegacyVersion } from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import type { Stripe } from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import {
constructArrearProratedItem,
constructFeatureItem,
} from "@/utils/scriptUtils/constructItem.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
@@ -25,53 +14,23 @@ const pro = constructProduct({
type: "pro",
items: [
// constructFeatureItem({ featureId: TestFeature.Words, includedUsage: 100 }),
constructArrearProratedItem({
featureId: TestFeature.Users,
includedUsage: 1,
pricePerUnit: 10,
config: {
on_increase: OnIncrease.BillImmediately,
on_decrease: OnDecrease.Prorate,
},
}),
],
});
const premium = constructProduct({
type: "premium",
items: [
// constructFeatureItem({ featureId: TestFeature.Words, includedUsage: 100 }),
constructArrearProratedItem({
featureId: TestFeature.Users,
includedUsage: 1,
pricePerUnit: 10,
config: {
on_increase: OnIncrease.BillImmediately,
on_decrease: OnDecrease.Prorate,
},
}),
constructFeatureItem({ featureId: TestFeature.Words, includedUsage: 100 }),
],
freeTrial: {
length: 7,
duration: FreeTrialDuration.Day,
unique_fingerprint: false,
card_required: true,
},
});
describe(`${chalk.yellowBright("temp: Testing add ons")}`, () => {
const customerId = "temp";
const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 });
let stripeCli: Stripe;
let testClockId: string;
let curUnix: number;
let db: DrizzleCli;
let org: Organization;
let env: AppEnv;
beforeAll(async () => {
db = ctx.db;
org = ctx.org;
env = ctx.env;
stripeCli = ctx.stripeCli;
const { testClockId: testClockId1 } = await initCustomerV3({
const result = await initCustomerV3({
ctx,
customerId,
customerData: {},
@@ -81,11 +40,11 @@ describe(`${chalk.yellowBright("temp: Testing add ons")}`, () => {
await initProductsV0({
ctx,
products: [pro, premium],
products: [pro],
prefix: customerId,
});
testClockId = testClockId1!;
testClockId = result.testClockId!;
});
test("should attach pro product", async () => {

View File

@@ -10,8 +10,8 @@ import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
import { initCustomerV3 } from "../../../src/utils/scriptUtils/testUtils/initCustomerV3.js";
import { sharedDefaultFree } from "./sharedProducts.js";
const free2 = constructProduct({

View File

@@ -108,6 +108,7 @@ describe(`${chalk.yellowBright("basic8: Testing trial duplicates (same fingerpri
});
const customer = await AutumnCli.getCustomer(customerId2);
console.log(JSON.stringify(customer, null, 2));
await expectCustomerV0Correct({
sent: proWithTrial,

View File

@@ -1,14 +1,21 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion, type CheckResponseV1, SuccessCode } from "@autumn/shared";
import chalk from "chalk";
import {
ApiVersion,
AppEnv,
type CheckResponseV1,
SuccessCode,
} from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { timeout } from "@/utils/genUtils.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { generatePublishableKey } from "@/utils/encryptUtils.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
import { timeout } from "../../../utils/genUtils.js";
const messagesFeature = constructFeatureItem({
featureId: TestFeature.Messages,
@@ -46,10 +53,30 @@ describe(`${chalk.yellowBright("check8: test public key & send_event")}`, () =>
product_id: freeProd.id,
});
// Ensure test_pkey is set on the org (needed for public key tests)
if (!ctx.org.test_pkey) {
const testPkey = generatePublishableKey(AppEnv.Sandbox);
await OrgService.update({
db: ctx.db,
orgId: ctx.org.id,
updates: {
test_pkey: testPkey,
},
});
// Update the context org object
ctx.org.test_pkey = testPkey;
}
if (!ctx.org.test_pkey.startsWith("am_pk")) {
throw new Error(
`test_pkey "${ctx.org.test_pkey}" does not start with "am_pk". Expected format: am_pk_test_...`,
);
}
// Initialize Autumn client with public key
autumnPublic = new AutumnInt({
version: ApiVersion.V1_2,
secretKey: ctx.org.test_pkey!,
secretKey: ctx.org.test_pkey,
});
});

View File

@@ -1,8 +1,8 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { type ApiCustomer, ApiVersion, type LimitedItem } from "@autumn/shared";
import chalk from "chalk";
import { TestFeature } from "@tests/setup/v2Features.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { CusService } from "@/internal/customers/CusService.js";
import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js";
@@ -113,7 +113,7 @@ describe(`${chalk.yellowBright("balances-update3: Balance decoupling tests")}`,
expect(cusEnt?.additional_balance).toBe(10); // Added
expect(cusEnt?.additional_granted_balance).toBe(10); // Added
const customer = await autumnV2.customers.get(customerId);
const customer = await autumnV2.customers.get<ApiCustomer>(customerId);
const balance = customer.balances[TestFeature.Users];
// current = 0 + 0 + 10 = 10
expect(balance.current_balance).toBe(10);
@@ -141,10 +141,10 @@ describe(`${chalk.yellowBright("balances-update3: Balance decoupling tests")}`,
expect(cusEnt?.additional_balance).toBe(5); // 10 - 5
expect(cusEnt?.additional_granted_balance).toBe(5); // 10 - 5
const customer = await autumnV2.customers.get(customerId);
const feature = customer.features[TestFeature.Users];
// current = 0 + 0 + 5 = 5
expect(feature.current_balance).toBe(5);
const customer = await autumnV2.customers.get<ApiCustomer>(customerId);
const balance = customer.balances[TestFeature.Users];
expect(balance.current_balance).toBe(5);
});
test("CASE C: balances.update REMOVE with insufficient additional_balance", async () => {
@@ -168,9 +168,9 @@ describe(`${chalk.yellowBright("balances-update3: Balance decoupling tests")}`,
expect(cusEnt?.additional_balance).toBe(0); // Floored
expect(cusEnt?.additional_granted_balance).toBe(0); // 5 - 5
const customer = await autumnV2.customers.get(customerId);
const feature = customer.features[TestFeature.Users];
expect(feature.current_balance).toBe(0);
const customer = await autumnV2.customers.get<ApiCustomer>(customerId);
const balance = customer.balances[TestFeature.Users];
expect(balance.current_balance).toBe(0);
});
test("Track +5 then update REMOVE to trigger main balance deduction", async () => {
@@ -269,13 +269,12 @@ describe(`${chalk.yellowBright("balances-update3: Balance decoupling tests")}`,
expect(cusEnt?.additional_balance).toBe(0); // Unchanged
expect(cusEnt?.additional_granted_balance).toBe(0); // Unchanged
const customer = await autumnV2.customers.get(customerId);
const feature = customer.features[TestFeature.Users];
// current = Math.max(0, 0) + 0 = 0
// purchased = 0
expect(feature.current_balance).toBe(0);
expect(feature.purchased_balance).toBe(0);
expect(feature.granted_balance).toBe(0);
expect(feature.usage).toBe(0);
const customer = await autumnV2.customers.get<ApiCustomer>(customerId);
const balance = customer.balances[TestFeature.Users];
expect(balance.current_balance).toBe(0);
expect(balance.purchased_balance).toBe(0);
expect(balance.granted_balance).toBe(0);
expect(balance.usage).toBe(0);
});
});

View File

@@ -8,11 +8,11 @@ import {
OnDecrease,
OnIncrease,
} from "@autumn/shared";
import chalk from "chalk";
import { TestFeature } from "@tests/setup/v2Features.js";
import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js";
import { useEntityBalanceAndExpect } from "@tests/utils/expectUtils/expectContUse/expectEntityUtils.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { timeout } from "@/utils/genUtils.js";
import {
@@ -130,7 +130,7 @@ describe(`${chalk.yellowBright(`contUse/${testCase}: Testing per entity features
const entRes = await autumn.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
entity_id: entity.id,
entity_id: entity.id ?? "",
});
expect(entRes.balance).toBe(perEntityItem.included_usage);

View File

@@ -7,7 +7,6 @@ import {
type CreatePlanParams,
FreeTrialDuration,
Infinite,
ProductItemInterval,
ResetInterval,
TierInfinite,
UsageModel,
@@ -115,7 +114,7 @@ describe(chalk.yellowBright("Plan V2 - Cross-Version Consistency"), () => {
const v1_2 = (await autumnV1_2.products.get(
"trial_transform",
)) as ApiProduct;
expect(v1_2.free_trial!.duration).toBe(ProductItemInterval.Day);
expect(v1_2.free_trial!.duration).toBe(FreeTrialDuration.Day);
expect(v1_2.free_trial!.length).toBe(7);
expect(v1_2.free_trial!.unique_fingerprint).toBe(true); // Always true in V1.2
});

View File

@@ -65,7 +65,7 @@ export function parseTestOutput(output: string): TestSummary {
}
}
passedFiles = totalFiles - failedFiles;
let passedFiles = totalFiles - failedFiles;
// Extract failure details
let currentTestFile = "";

View File

@@ -167,10 +167,10 @@ export const calcProrationAndExpectInvoice = async ({
expect(invoices.length).to.equal(
numInvoices,
`Should have ${numInvoices} invoices`,
`Should have ${numInvoices} invoices; got ${invoices.length}`,
);
expect(invoices[0].total).to.equal(
proratedAmount,
"Latest invoice should be equals to calculated prorated amount",
`Latest invoice should be equals to calculated prorated amount; got ${invoices[0].total}`,
);
};

View File

@@ -4,15 +4,15 @@ import {
SuccessResponseSchema,
} from "../../../common/commonResponses.js";
import {
ApiFeatureSchema,
ApiFeatureV0Schema,
FEATURE_EXAMPLE,
} from "../../../features/apiFeature.js";
} from "../../../features/prevVersions/apiFeatureV0.js";
import {
CreateFeatureParamsSchema,
UpdateFeatureParamsSchema,
} from "../../../features/featureOpModels.js";
} from "../../../features/prevVersions/featureV0OpModels.js";
export const ApiFeatureWithMeta = ApiFeatureSchema.extend({
export const ApiFeatureWithMeta = ApiFeatureV0Schema.extend({
type: z.enum(["boolean", "single_use", "continuous_use", "credit_system"]),
}).meta({
id: "Feature",

View File

@@ -1,25 +1,14 @@
import { z } from "zod/v4";
export const UpdateBalancesParamsSchema = z
.object({
balances: z.array(
z.object({
feature_id: z.string().meta({
description: "The ID of the feature to update balance for.",
}),
balance: z.number().meta({
description: "The new balance value.",
}),
export const UpdateBalancesParamsSchema = z.object({
balances: z.array(
z.object({
feature_id: z.string().meta({
description: "The ID of the feature to update balance for.",
}),
),
})
.meta({
example: {
balances: [
{
feature_id: "tokens",
balance: 1000,
},
],
},
});
balance: z.number().meta({
description: "The new balance value.",
}),
}),
),
});

View File

@@ -1,130 +0,0 @@
// Check Feature Schemas
// export const ExtCheckParamsSchema = 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",
// }),
// properties: z.record(z.string(), z.any()).optional().meta({
// description: "Properties to pass to the check",
// internal: true,
// }),
// });
// export const CheckParamsSchema = ExtCheckParamsSchema.extend({
// required_quantity: z.number().optional(),
// }).refine(
// (data) => {
// if (data.product_id && data.feature_id) {
// return false;
// }
// if (!data.product_id && !data.feature_id) {
// return false;
// }
// return true;
// },
// {
// message: "Must provide either product_id or feature_id",
// path: [],
// },
// );
// export const CheckResultSchema = z
// .object({
// allowed: z.boolean().meta({
// description: "Whether the customer is allowed to use the feature",
// example: true,
// }),
// customer_id: z.string().meta({
// description: "The ID of the customer",
// example: "cus_123",
// }),
// feature_id: z.string().meta({
// description: "The ID of the feature checked",
// example: "api_calls",
// }),
// entity_id: z.string().nullish().meta({
// description: "The ID of the entity (if provided)",
// example: "entity_123",
// }),
// required_balance: z
// .number()
// .meta({
// description: "The required balance for this check",
// example: 1,
// })
// .optional(), // not present for boolean features
// code: z.string().meta({
// description: "Response code indicating the result",
// example: "allowed",
// }),
// // preview: CheckFeaturePreviewSchema.optional().meta({
// // description: "Preview information if with_preview was true",
// // }),
// })
// .extend(CoreCusFeatureSchema.shape);
// Export Types
// export type ExtCheckParams = z.infer<typeof ExtCheckParamsSchema>;
// export type CheckParams = z.infer<typeof CheckParamsSchema>;
// export type CheckResponse = z.infer<typeof CheckResultSchema>;
// export type CheckFeatureScenario = z.infer<typeof CheckFeatureScenarioSchema>;
// export type CheckResult = z.infer<typeof CheckResultSchema>;
// // Check Feature Preview Schemas
// 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",
// }),
// });

View File

@@ -1,4 +1,4 @@
import { ApiFeatureType } from "@api/features/apiFeature.js";
import { ApiFeatureType } from "@api/features/prevVersions/apiFeatureV0.js";
import { ApiVersion } from "@api/versionUtils/ApiVersion.js";
import {
AffectedResource,

View File

@@ -1,6 +1,6 @@
import { ApiFeatureSchema } from "@api/features/apiFeature.js";
import { ResetInterval } from "@models/productModels/intervals/resetInterval.js";
import { z } from "zod/v4";
import { ApiFeatureV1Schema } from "../../features/apiFeatureV1.js";
export const ApiBalanceResetSchema = z.object({
interval: z.enum(ResetInterval).or(z.literal("multiple")),
@@ -26,7 +26,7 @@ export const ApiBalanceBreakdownSchema = z.object({
export const ApiBalanceSchema = z.object({
feature_id: z.string(),
feature: ApiFeatureSchema.optional(),
feature: ApiFeatureV1Schema.optional(),
unlimited: z.boolean(),
granted_balance: z.number(),

View File

@@ -1,4 +1,4 @@
import { type ApiFeature, ApiFeatureType } from "@api/features/apiFeature.js";
import { ApiFeatureType } from "@api/features/prevVersions/apiFeatureV0.js";
import { ApiVersion } from "@api/versionUtils/ApiVersion.js";
import {
AffectedResource,
@@ -7,7 +7,9 @@ import {
import { EntInterval } from "@models/productModels/intervals/entitlementInterval.js";
import { Decimal } from "decimal.js";
import type { z } from "zod/v4";
import { FeatureType } from "../../../../models/featureModels/featureEnums.js";
import { resetIntvToEntIntv } from "../../../../utils/planFeatureUtils/planFeatureIntervals.js";
import type { ApiFeatureV1 } from "../../../features/apiFeatureV1.js";
import {
type ApiBalance,
type ApiBalanceBreakdown,
@@ -38,14 +40,14 @@ const resetToV3IntervalParams = ({
unlimited,
}: {
input: ApiBalance | ApiBalanceBreakdown;
feature?: ApiFeature;
feature?: ApiFeatureV1;
unlimited: boolean;
}): {
interval: EntInterval | "multiple" | null;
interval_count: number | null;
next_reset_at: number | null;
} => {
const isBoolean = feature?.type === ApiFeatureType.Boolean;
const isBoolean = feature?.type === FeatureType.Boolean;
// 1. No reset
if (!input.reset)
@@ -72,10 +74,20 @@ const resetToV3IntervalParams = ({
};
};
const toV3Type = ({ feature }: { feature?: ApiFeature }) => {
if (feature?.type === ApiFeatureType.Boolean) {
const toV3Type = ({ feature }: { feature?: ApiFeatureV1 }) => {
if (feature?.type === FeatureType.Boolean) {
return ApiFeatureType.Static;
} else return feature?.type ?? ApiFeatureType.SingleUsage;
} else if (feature?.type === FeatureType.Metered) {
if (feature.consumable) {
return ApiFeatureType.SingleUsage;
} else {
return ApiFeatureType.ContinuousUse;
}
} else if (feature?.type === FeatureType.CreditSystem) {
return ApiFeatureType.CreditSystem;
} else {
return ApiFeatureType.Static;
}
};
const toV3BalanceParams = ({
@@ -85,11 +97,11 @@ const toV3BalanceParams = ({
legacyData,
}: {
input: ApiBalance | ApiBalanceBreakdown;
feature?: ApiFeature;
feature?: ApiFeatureV1;
unlimited: boolean;
legacyData?: CusFeatureLegacyData;
}) => {
const isBoolean = feature?.type === ApiFeatureType.Boolean;
const isBoolean = feature?.type === FeatureType.Boolean;
if (isBoolean || unlimited) {
return {

View File

@@ -1,4 +1,4 @@
import { ApiFeatureType } from "@api/features/apiFeature.js";
import { ApiFeatureType } from "@api/features/prevVersions/apiFeatureV0.js";
import { EntInterval } from "@models/productModels/intervals/entitlementInterval.js";
import { z } from "zod/v4";

View File

@@ -102,14 +102,9 @@ export const UpdateCustomerParamsSchema = z.object({
name: z.string().nullish().meta({
description: "The customer's name.",
}),
email: z
.string()
.email({ message: "not a valid email address" })
.or(z.literal(""))
.nullish()
.meta({
description: "The customer's email address.",
}),
email: z.email({ message: "not a valid email address" }).nullish().meta({
description: "Customer's email address",
}),
fingerprint: z.string().nullish().meta({
description:
"Unique identifier (eg, serial number) to detect duplicate customers.",

Some files were not shown because too many files have changed in this diff Show More