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 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" - 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. - 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. - 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 `.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 ## Error Handling in API Routes
- NEVER use `c.json({ message: "...", code: "..." }, statusCode)` pattern for input validation or expected errors in Hono 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. - 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; return data;
}; };
initStripe = async () => {
await this.post(`/products/all/init_stripe`, {});
};
balances = { balances = {
update: async (params: BalancesUpdateParams) => { update: async (params: BalancesUpdateParams) => {
const data = await this.post(`/balances/update`, params); const data = await this.post(`/balances/update`, params);

View File

@@ -496,8 +496,4 @@ export class AutumnCliV2 {
}) => { }) => {
return await this.post(`/migrations`, params); 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 { ZodType, z } from "zod/v4";
import type { DrizzleCli } from "@/db/initDrizzle.js"; import type { DrizzleCli } from "@/db/initDrizzle.js";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { expandMiddleware } from "./expandMiddleware.js";
import { validator } from "./validatorMiddleware.js"; import { validator } from "./validatorMiddleware.js";
import { versionedValidator } from "./versionedValidator.js"; import { versionedValidator } from "./versionedValidator.js";
import { expandMiddleware } from "./expandMiddleware.js";
/** /**
* Extended context type that includes validated input * Extended context type that includes validated input
@@ -56,7 +56,7 @@ type VersionedSchemas<T extends ZodType> = Partial<
* handler: async (c) => { * handler: async (c) => {
* const body = c.req.valid("json"); // ✅ Fully typed! * const body = c.req.valid("json"); // ✅ Fully typed!
* const query = c.req.valid("query"); // ✅ 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 }); * return c.json({ success: true });
* } * }
* }); * });

View File

@@ -100,6 +100,7 @@ export const versionedValidator = ({
c.req.addValidatedData(target, validatedData); c.req.addValidatedData(target, validatedData);
} else { } else {
// For other targets, use zValidator // For other targets, use zValidator
const validatorMiddleware = zValidator(target, schema, (result, _c) => { const validatorMiddleware = zValidator(target, schema, (result, _c) => {
if (!result.success) { if (!result.success) {
// Validation errors reference fields from user's version ✅ // 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 { cusRouter } from "./internal/customers/cusRouter.js";
import { internalCusRouter } from "./internal/customers/internalCusRouter.js"; import { internalCusRouter } from "./internal/customers/internalCusRouter.js";
import { entityRouter } from "./internal/entities/entityRouter.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 { handleOAuthCallback } from "./internal/orgs/handlers/stripeHandlers/handleOAuthCallback.js";
import { honoOrgRouter } from "./internal/orgs/orgRouter.js"; import { honoOrgRouter } from "./internal/orgs/orgRouter.js";
import { platformBetaRouter } from "./internal/platform/platformBeta/platformBetaRouter.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_beta", honoProductBetaRouter);
app.route("v1/products", honoProductRouter); app.route("v1/products", honoProductRouter);
app.route("v1/plans", honoProductRouter); app.route("v1/plans", honoProductRouter);
app.route("v1/features", featureRouter);
app.route("v1", balancesRouter); app.route("v1", balancesRouter);
app.route("v1/platform", platformBetaRouter); app.route("v1/platform", platformBetaRouter);

View File

@@ -7,17 +7,10 @@ import { refreshCacheMiddleware } from "@/middleware/refreshCacheMiddleware.js";
import { analyticsRouter } from "../analytics/analyticsRouter.js"; import { analyticsRouter } from "../analytics/analyticsRouter.js";
import { attachRouter } from "../customers/attach/attachRouter.js"; import { attachRouter } from "../customers/attach/attachRouter.js";
import cancelRouter from "../customers/cancel/cancelRouter.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 { handleGetOrg } from "../orgs/handlers/handleGetOrg.js";
import { platformRouter } from "../platform/platformLegacy/platformRouter.js"; import { platformRouter } from "../platform/platformLegacy/platformRouter.js";
import { productRouter } from "../products/productRouter.js";
import { componentRouter } from "./components/componentRouter.js"; import { componentRouter } from "./components/componentRouter.js";
import { usageRouter } from "./events/usageRouter.js";
import { invoiceRouter } from "./invoiceRouter.js"; import { invoiceRouter } from "./invoiceRouter.js";
import { redemptionRouter, referralRouter } from "./rewards/referralRouter.js"; import { redemptionRouter, referralRouter } from "./rewards/referralRouter.js";
import { rewardProgramRouter } from "./rewards/rewardProgramRouter.js"; import { rewardProgramRouter } from "./rewards/rewardProgramRouter.js";
@@ -31,15 +24,9 @@ apiRouter.use(analyticsMiddleware);
apiRouter.use(expressApiVersionMiddleware as any); apiRouter.use(expressApiVersionMiddleware as any);
apiRouter.use(refreshCacheMiddleware); apiRouter.use(refreshCacheMiddleware);
apiRouter.use("/customers", expressCusRouter);
apiRouter.use("/invoices", invoiceRouter); apiRouter.use("/invoices", invoiceRouter);
apiRouter.use("/products", productRouter);
apiRouter.use("/components", componentRouter); apiRouter.use("/components", componentRouter);
apiRouter.use("/rewards", rewardRouter); apiRouter.use("/rewards", rewardRouter);
apiRouter.use("/features", featureRouter);
apiRouter.use("/internal_features", internalFeatureRouter);
apiRouter.use("/usage", usageRouter);
// REWARDS // REWARDS
apiRouter.use("/reward_programs", rewardProgramRouter); apiRouter.use("/reward_programs", rewardProgramRouter);
@@ -50,14 +37,6 @@ apiRouter.use("/redemptions", redemptionRouter);
apiRouter.use("", attachRouter); apiRouter.use("", attachRouter);
apiRouter.use("/cancel", cancelRouter); 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 // Analytics
apiRouter.use("/query", analyticsRouter); apiRouter.use("/query", analyticsRouter);
apiRouter.use("/platform", platformRouter); apiRouter.use("/platform", platformRouter);
@@ -68,3 +47,7 @@ apiRouter.use("/platform", platformRouter);
apiRouter.get("/organization", handleGetOrg); apiRouter.get("/organization", handleGetOrg);
export { apiRouter }; 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 { CusService } from "@/internal/customers/CusService.js";
import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.js";
import { FeatureService } from "@/internal/features/FeatureService.js"; import { FeatureService } from "@/internal/features/FeatureService.js";
import { OrgService } from "@/internal/orgs/OrgService.js"; import { OrgService } from "@/internal/orgs/OrgService.js";
import { toPricecnProduct } from "@/internal/products/pricecn/pricecnUtils.js";
import { ProductService } from "@/internal/products/ProductService.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 { getProductResponse } from "@/internal/products/productUtils/productResponseUtils/getProductResponse.js";
import { isProductUpgrade } from "@/internal/products/productUtils.js";
import { routeHandler } from "@/utils/routerUtils.js"; import { routeHandler } from "@/utils/routerUtils.js";
import { ProductV2 } from "@autumn/shared";
import { Router } from "express";
export const componentRouter: Router = Router(); export const componentRouter: Router = Router();
@@ -19,7 +19,7 @@ componentRouter.get("/pricing_table", async (req: any, res) =>
action: "get pricing table", action: "get pricing table",
handler: async () => { handler: async () => {
const { orgId, env, db } = req; 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([ const [org, features, products, customer] = await Promise.all([
OrgService.getFromReq(req), OrgService.getFromReq(req),
@@ -45,7 +45,7 @@ componentRouter.get("/pricing_table", async (req: any, res) =>
// 1. Sort products by price // 1. Sort products by price
products.sort((a, b) => { products.sort((a, b) => {
let isUpgradeA = isProductUpgrade({ const isUpgradeA = isProductUpgrade({
prices1: a.prices, prices1: a.prices,
prices2: b.prices, prices2: b.prices,
usageAlwaysUpgrade: false, usageAlwaysUpgrade: false,
@@ -58,13 +58,13 @@ componentRouter.get("/pricing_table", async (req: any, res) =>
} }
}); });
let batchResponse = []; const batchResponse = [];
for (let p of products) { for (const p of products) {
let prod = await getProductResponse({ product: p, features }); const prod = await getProductResponse({ product: p, features });
let curMainProduct, curScheduledProduct; let curMainProduct, curScheduledProduct;
if (customer) { if (customer) {
let res = getExistingCusProducts({ const res = getExistingCusProducts({
product: p, product: p,
cusProducts: customer.customer_products, cusProducts: customer.customer_products,
}); });
@@ -82,13 +82,13 @@ componentRouter.get("/pricing_table", async (req: any, res) =>
features, features,
curMainProduct, curMainProduct,
curScheduledProduct, curScheduledProduct,
otherProducts: products.filter((other) => other.id != p.id), otherProducts: products.filter((other) => other.id !== p.id),
fullCus: customer, fullCus: customer,
}), }),
); );
} }
let pricecnProds = await Promise.all(batchResponse); const pricecnProds = await Promise.all(batchResponse);
// let pricecnProds = await Promise.all( // let pricecnProds = await Promise.all(
// products // products

View File

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

View File

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

View File

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

View File

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

View File

@@ -30,7 +30,7 @@ export const getPricesAndEnts = async ({
customer: FullCustomer; customer: FullCustomer;
products: FullProduct[]; 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 { features, db, org, logger } = req;
const { curMainProduct, curSameProduct } = getExistingCusProducts({ 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 { import {
createStripeCusIfNotExists, createStripeCusIfNotExists,
listCusPaymentMethods, listCusPaymentMethods,
} from "@/external/stripe/stripeCusUtils.js"; } 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 ({ export const getStripeCusData = async ({
stripeCli, stripeCli,
@@ -28,7 +27,7 @@ export const getStripeCusData = async ({
return { stripeCus: undefined, paymentMethod: null, now: undefined }; return { stripeCus: undefined, paymentMethod: null, now: undefined };
} }
let stripeCus = (await createStripeCusIfNotExists({ const stripeCus = (await createStripeCusIfNotExists({
db, db,
org, org,
env, env,
@@ -36,16 +35,16 @@ export const getStripeCusData = async ({
logger, logger,
})) as Stripe.Customer; })) 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 : Date.now();
let now = testClock ? testClock.frozen_time * 1000 : undefined; const now = testClock ? testClock.frozen_time * 1000 : undefined;
let paymentMethod = stripeCus.invoice_settings let paymentMethod = stripeCus.invoice_settings
?.default_payment_method as Stripe.PaymentMethod | null; ?.default_payment_method as Stripe.PaymentMethod | null;
if (!paymentMethod) { if (!paymentMethod) {
let paymentMethods = await listCusPaymentMethods({ const paymentMethods = await listCusPaymentMethods({
stripeCli, stripeCli,
stripeId: stripeCus.id, stripeId: stripeCus.id,
}); });

View File

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

View File

@@ -1,122 +1,30 @@
import { ErrCode } from "@autumn/shared";
import { Router } from "express";
import { Hono } from "hono"; 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 type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { OrgService } from "@/internal/orgs/OrgService.js"; import { handleAddCouponToCusV2 } from "./handlers/handleAddCouponToCusV2.js";
import RecaseError, { handleRequestError } from "@/utils/errorUtils.js"; import { handleCreateBillingPortal } from "./handlers/handleBillingPortal/handleCreateBillingPortal.js";
import { toSuccessUrl } from "../orgs/orgUtils/convertOrgUtils.js"; import { handleDeleteCustomerV2 } from "./handlers/handleDeleteCustomerV2.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 { handleGetCustomerV2 } from "./handlers/handleGetCustomerV2.js"; import { handleGetCustomerV2 } from "./handlers/handleGetCustomerV2.js";
import { handleListCustomers } from "./handlers/handleListCustomers.js"; import { handleListCustomers } from "./handlers/handleListCustomers.js";
import { handlePostCustomer } from "./handlers/handlePostCustomerV2.js"; import { handlePostCustomer } from "./handlers/handlePostCustomerV2.js";
import { handleTransferProduct } from "./handlers/handleTransferProduct.js"; import { handleTransferProductV2 } from "./handlers/handleTransferProductV2.js";
import { handleUpdateBalances } from "./handlers/handleUpdateBalances.js"; import { handleUpdateBalancesV2 } from "./handlers/handleUpdateBalancesV2.js";
import { handleUpdateCustomer } from "./handlers/handleUpdateCustomer.js";
import { handleUpdateCustomerV2 } from "./handlers/handleUpdateCustomerV2.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>(); export const cusRouter = new Hono<HonoEnv>();
cusRouter.get("", ...handleListCustomers); cusRouter.get("", ...handleListCustomers);
cusRouter.get("/:customer_id", ...handleGetCustomerV2);
cusRouter.post("", ...handlePostCustomer); cusRouter.post("", ...handlePostCustomer);
cusRouter.get("/:customer_id", ...handleGetCustomerV2);
cusRouter.post("/:customer_id", ...handleUpdateCustomerV2); 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 ApiBalance,
type ApiBalanceReset, type ApiBalanceReset,
type ApiBalanceRollover, type ApiBalanceRollover,
type ApiFeature, type ApiFeatureV1,
entIntvToResetIntv, entIntvToResetIntv,
type Feature, type Feature,
type FullCusEntWithFullCusProduct, type FullCusEntWithFullCusProduct,
@@ -94,7 +94,7 @@ export const getBooleanApiBalance = ({
apiFeature, apiFeature,
}: { }: {
cusEnts: FullCusEntWithFullCusProduct[]; cusEnts: FullCusEntWithFullCusProduct[];
apiFeature?: ApiFeature; apiFeature?: ApiFeatureV1;
}): ApiBalance => { }): ApiBalance => {
const feature = cusEnts[0].entitlement.feature; const feature = cusEnts[0].entitlement.feature;
return { return {
@@ -121,7 +121,7 @@ export const getUnlimitedApiBalance = ({
apiFeature, apiFeature,
cusEnts, cusEnts,
}: { }: {
apiFeature?: ApiFeature; apiFeature?: ApiFeatureV1;
cusEnts: FullCusEntWithFullCusProduct[]; cusEnts: FullCusEntWithFullCusProduct[];
}): ApiBalance => { }): ApiBalance => {
const feature = cusEnts[0].entitlement.feature; const feature = cusEnts[0].entitlement.feature;
@@ -150,7 +150,7 @@ export const getNoCusEntsApiBalance = ({
apiFeature, apiFeature,
featureId, featureId,
}: { }: {
apiFeature?: ApiFeature; apiFeature?: ApiFeatureV1;
featureId: string; featureId: string;
}): ApiBalance => { }): ApiBalance => {
return { return {

View File

@@ -16,6 +16,7 @@ import {
cusEntToGrantedBalance, cusEntToGrantedBalance,
cusEntToKey, cusEntToKey,
cusEntToPurchasedBalance, cusEntToPurchasedBalance,
dbToApiFeatureV1,
expandIncludes, expandIncludes,
type Feature, type Feature,
FeatureType, FeatureType,
@@ -23,7 +24,6 @@ import {
isPrepaidPrice, isPrepaidPrice,
notNullish, notNullish,
sumValues, sumValues,
toApiFeature,
} from "@autumn/shared"; } from "@autumn/shared";
import { Decimal } from "decimal.js"; import { Decimal } from "decimal.js";
import type { RequestContext } from "@/honoUtils/HonoEnv.js"; import type { RequestContext } from "@/honoUtils/HonoEnv.js";
@@ -139,7 +139,7 @@ export const getApiBalance = ({
expand: ctx.expand, expand: ctx.expand,
includes: [CheckExpand.BalanceFeature, CusExpand.BalancesFeature], includes: [CheckExpand.BalanceFeature, CusExpand.BalancesFeature],
}) })
? toApiFeature({ feature }) ? dbToApiFeatureV1({ dbFeature: feature })
: undefined; : undefined;
// 1. If feature is boolean // 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); const newCus = CreateCustomerSchema.parse(cusData);
console.log("Create default products:", createDefaultProducts);
// 1. If no ID and email is not NULL // 1. If no ID and email is not NULL
let createdCustomer: Customer; 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, getStartingBalance,
} from "@autumn/shared"; } from "@autumn/shared";
import { createStripeCli } from "@/external/connect/createStripeCli.js"; import { createStripeCli } from "@/external/connect/createStripeCli.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { getEntOptions } from "@/internal/products/prices/priceUtils.js"; import { getEntOptions } from "@/internal/products/prices/priceUtils.js";
import { attachToInsertParams } from "@/internal/products/productUtils.js"; import { attachToInsertParams } from "@/internal/products/productUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import { createFullCusProduct } from "../../add-product/createFullCusProduct.js"; import { createFullCusProduct } from "../../add-product/createFullCusProduct.js";
import { CusProductService } from "../../cusProducts/CusProductService.js"; import { CusProductService } from "../../cusProducts/CusProductService.js";
import { CusEntService } from "../../cusProducts/cusEnts/CusEntitlementService.js"; import { CusEntService } from "../../cusProducts/cusEnts/CusEntitlementService.js";
import { getRelatedCusPrice } from "../../cusProducts/cusEnts/cusEntUtils.js"; import { getRelatedCusPrice } from "../../cusProducts/cusEnts/cusEntUtils.js";
export const handleDecreaseAndTransfer = async ({ export const handleDecreaseAndTransfer = async ({
req, ctx,
fullCus, fullCus,
cusProduct, cusProduct,
toEntity, toEntity,
}: { }: {
req: ExtendedRequest; ctx: AutumnContext;
fullCus: FullCustomer; fullCus: FullCustomer;
cusProduct: FullCusProduct; cusProduct: FullCusProduct;
toEntity: Entity; toEntity: Entity;
}) => { }) => {
// 1. Create new cus product for 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 stripeCli = createStripeCli({ org, env });
const product = cusProductToProduct({ cusProduct }); const product = cusProductToProduct({ cusProduct });
@@ -50,7 +50,7 @@ export const handleDecreaseAndTransfer = async ({
batchDecrement.push( batchDecrement.push(
CusEntService.decrement({ CusEntService.decrement({
db: req.db, db,
id: cusEnt.id, id: cusEnt.id,
amount: resetBalance, amount: resetBalance,
}), }),
@@ -60,37 +60,36 @@ export const handleDecreaseAndTransfer = async ({
await Promise.all(batchDecrement); await Promise.all(batchDecrement);
await CusProductService.update({ await CusProductService.update({
db: req.db, db,
cusProductId: cusProduct.id, cusProductId: cusProduct.id,
updates: { updates: {
quantity: cusProduct.quantity - 1, quantity: cusProduct.quantity - 1,
}, },
}); });
const newCusProduct = await createFullCusProduct({ await createFullCusProduct({
db: req.db, db,
logger: req.logger, logger,
trialEndsAt: cusProduct.trial_ends_at || undefined, trialEndsAt: cusProduct.trial_ends_at || undefined,
subscriptionIds: cusProduct.subscription_ids || [], subscriptionIds: cusProduct.subscription_ids || [],
attachParams: attachToInsertParams( attachParams: attachToInsertParams(
{ {
req, req: ctx as any, // Pass ctx as req for now (AttachParams still uses req)
customer: fullCus, customer: fullCus,
products: [product], products: [product],
prices: product.prices, prices: product.prices,
entitlements: product.entitlements, entitlements: product.entitlements,
org: req.org, org,
stripeCli: stripeCli, stripeCli: stripeCli,
paymentMethod: null, paymentMethod: null,
freeTrial: cusProduct.free_trial || null, freeTrial: cusProduct.free_trial || null,
optionsList: cusProduct.options, optionsList: cusProduct.options,
scenario: AttachScenario.New, scenario: AttachScenario.New,
// scenario: AttachScenario.New,
cusProducts: fullCus.customer_products, cusProducts: fullCus.customer_products,
replaceables: [], replaceables: [],
entities: fullCus.entities, entities: fullCus.entities,
features: req.features, features,
internalEntityId: toEntity.internal_id, internalEntityId: toEntity.internal_id,
entityId: toEntity.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 { ErrCode, getCusEntBalance } from "@autumn/shared";
import { Decimal } from "decimal.js"; // import { Decimal } from "decimal.js";
import { StatusCodes } from "http-status-codes"; // import { StatusCodes } from "http-status-codes";
import { CusService } from "@/internal/customers/CusService.js"; // import { CusService } from "@/internal/customers/CusService.js";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js"; // import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
import { getUnlimitedAndUsageAllowed } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js"; // import { getUnlimitedAndUsageAllowed } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js";
import { FeatureService } from "@/internal/features/FeatureService.js"; // import { FeatureService } from "@/internal/features/FeatureService.js";
import { OrgService } from "@/internal/orgs/OrgService.js"; // import { OrgService } from "@/internal/orgs/OrgService.js";
import { // import {
deductAllowanceFromCusEnt, // deductAllowanceFromCusEnt,
deductFromUsageBasedCusEnt, // deductFromUsageBasedCusEnt,
} from "@/trigger/updateBalanceTask.js"; // } from "@/trigger/updateBalanceTask.js";
import RecaseError, { handleRequestError } from "@/utils/errorUtils.js"; // import RecaseError, { handleRequestError } from "@/utils/errorUtils.js";
import { notNullish } from "@/utils/genUtils.js"; // import { notNullish } from "@/utils/genUtils.js";
import { getCusEntsInFeatures } from "../cusUtils/cusUtils.js"; // import { getCusEntsInFeatures } from "../cusUtils/cusUtils.js";
const getCusFeaturesAndOrg = async (req: any, customerId: string) => { // const getCusFeaturesAndOrg = async (req: any, customerId: string) => {
// 1. Get customer // // 1. Get customer
const [customer, features, org] = await Promise.all([ // const [customer, features, org] = await Promise.all([
CusService.getFull({ // CusService.getFull({
db: req.db, // db: req.db,
idOrInternalId: customerId, // idOrInternalId: customerId,
orgId: req.orgId, // orgId: req.orgId,
env: req.env, // env: req.env,
entityId: req.params.entity_id, // entityId: req.params.entity_id,
}), // }),
FeatureService.getFromReq(req), // FeatureService.getFromReq(req),
OrgService.getFromReq(req), // OrgService.getFromReq(req),
]); // ]);
if (!customer) { // if (!customer) {
throw new RecaseError({ // throw new RecaseError({
message: `Customer ${customerId} not found`, // message: `Customer ${customerId} not found`,
code: ErrCode.CustomerNotFound, // code: ErrCode.CustomerNotFound,
statusCode: StatusCodes.NOT_FOUND, // statusCode: StatusCodes.NOT_FOUND,
}); // });
} // }
return { customer, features, org }; // return { customer, features, org };
}; // };
export const handleUpdateBalances = async (req: any, res: any) => { // export const handleUpdateBalances = async (req: any, res: any) => {
try { // try {
const logger = req.logger; // const logger = req.logger;
const cusId = req.params.customer_id; // const cusId = req.params.customer_id;
const { env, db, features } = req; // const { env, db, features } = req;
const { balances } = req.body; // const { balances } = req.body;
if (!Array.isArray(balances)) { // if (!Array.isArray(balances)) {
throw new RecaseError({ // throw new RecaseError({
message: "Balances must be an array", // message: "Balances must be an array",
code: ErrCode.InvalidRequest, // code: ErrCode.InvalidRequest,
statusCode: StatusCodes.BAD_REQUEST, // statusCode: StatusCodes.BAD_REQUEST,
}); // });
} // }
const { customer, org } = await getCusFeaturesAndOrg(req, cusId); // const { customer, org } = await getCusFeaturesAndOrg(req, cusId);
const featuresToUpdate = features.filter((f: any) => // const featuresToUpdate = features.filter((f: any) =>
balances.map((b: any) => b.feature_id).includes(f.id), // balances.map((b: any) => b.feature_id).includes(f.id),
); // );
if (featuresToUpdate.length === 0) { // if (featuresToUpdate.length === 0) {
throw new RecaseError({ // throw new RecaseError({
message: "No valid features found to update", // message: "No valid features found to update",
code: ErrCode.InvalidRequest, // code: ErrCode.InvalidRequest,
statusCode: StatusCodes.BAD_REQUEST, // statusCode: StatusCodes.BAD_REQUEST,
}); // });
} // }
const { cusEnts, cusPrices } = await getCusEntsInFeatures({ // const { cusEnts, cusPrices } = await getCusEntsInFeatures({
customer, // customer,
internalFeatureIds: featuresToUpdate.map((f: any) => f.internal_id!), // internalFeatureIds: featuresToUpdate.map((f: any) => f.internal_id!),
logger: req.logger, // logger: req.logger,
}); // });
logger.info("--------------------------------"); // logger.info("--------------------------------");
logger.info( // logger.info(
`REQUEST: UPDATE BALANCES FOR CUSTOMER ${customer.id}, ORG: ${org.slug}`, // `REQUEST: UPDATE BALANCES FOR CUSTOMER ${customer.id}, ORG: ${org.slug}`,
); // );
logger.info( // logger.info(
`Features to update: ${balances.map( // `Features to update: ${balances.map(
(b: any) => // (b: any) =>
`${b.feature_id} - ${b.unlimited ? "unlimited" : b.balance}`, // `${b.feature_id} - ${b.unlimited ? "unlimited" : b.balance}`,
)}`, // )}`,
); // );
// Get deductions for each feature // // Get deductions for each feature
const featureDeductions = []; // const featureDeductions = [];
for (const balance of balances) { // for (const balance of balances) {
if (!balance.feature_id) { // if (!balance.feature_id) {
throw new RecaseError({ // throw new RecaseError({
message: "Feature ID is required", // message: "Feature ID is required",
code: ErrCode.InvalidRequest, // code: ErrCode.InvalidRequest,
statusCode: StatusCodes.BAD_REQUEST, // statusCode: StatusCodes.BAD_REQUEST,
}); // });
} // }
if (typeof balance.balance !== "number" && balance.unlimited !== true) { // if (typeof balance.balance !== "number" && balance.unlimited !== true) {
throw new RecaseError({ // throw new RecaseError({
message: "Balance must be a number", // message: "Balance must be a number",
code: ErrCode.InvalidRequest, // code: ErrCode.InvalidRequest,
statusCode: StatusCodes.BAD_REQUEST, // statusCode: StatusCodes.BAD_REQUEST,
}); // });
} // }
const feature = featuresToUpdate.find( // const feature = featuresToUpdate.find(
(f: any) => f.id === balance.feature_id, // (f: any) => f.id === balance.feature_id,
); // );
if (balance.unlimited === true) { // if (balance.unlimited === true) {
featureDeductions.push({ // featureDeductions.push({
feature, // feature,
unlimited: true, // unlimited: true,
toDeduct: 0, // toDeduct: 0,
}); // });
continue; // continue;
} // }
const { unlimited } = getUnlimitedAndUsageAllowed({ // const { unlimited } = getUnlimitedAndUsageAllowed({
cusEnts, // cusEnts,
internalFeatureId: feature!.internal_id!, // internalFeatureId: feature!.internal_id!,
}); // });
if (unlimited) { // if (unlimited) {
throw new RecaseError({ // throw new RecaseError({
message: `Can't set balance for unlimited feature: ${feature!.id}`, // message: `Can't set balance for unlimited feature: ${feature!.id}`,
code: ErrCode.InvalidRequest, // code: ErrCode.InvalidRequest,
statusCode: StatusCodes.BAD_REQUEST, // statusCode: StatusCodes.BAD_REQUEST,
}); // });
} // }
// Get deductions // // Get deductions
const newBalance = balance.balance; // const newBalance = balance.balance;
let curBalance = new Decimal(0); // let curBalance = new Decimal(0);
const properties = structuredClone(balance); // const properties = structuredClone(balance);
delete properties.feature_id; // delete properties.feature_id;
delete properties.balance; // delete properties.balance;
for (const cusEnt of cusEnts) { // for (const cusEnt of cusEnts) {
const cusEntIntCount = cusEnt.entitlement.interval_count || 1; // const cusEntIntCount = cusEnt.entitlement.interval_count || 1;
const deductionIntCount = balance.interval_count || 1; // const deductionIntCount = balance.interval_count || 1;
const intCountMatch = notNullish(balance.interval_count) // const intCountMatch = notNullish(balance.interval_count)
? cusEntIntCount === deductionIntCount // ? cusEntIntCount === deductionIntCount
: true; // : true;
const intMatch = notNullish(balance.interval) // const intMatch = notNullish(balance.interval)
? balance.interval === cusEnt.entitlement.interval // ? balance.interval === cusEnt.entitlement.interval
: true; // : true;
if ( // if (
cusEnt.internal_feature_id !== feature!.internal_id! || // cusEnt.internal_feature_id !== feature!.internal_id! ||
!intMatch || // !intMatch ||
!intCountMatch // !intCountMatch
) { // ) {
continue; // continue;
} // }
const { balance: cusEntBalance } = getCusEntBalance({ // const { balance: cusEntBalance } = getCusEntBalance({
cusEnt, // cusEnt,
entityId: balance.entity_id, // 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) { // if (toDeduct === 0) {
logger.info(`Skipping ${feature!.id} -- no change`); // logger.info(`Skipping ${feature!.id} -- no change`);
} // }
featureDeductions.push({ // featureDeductions.push({
feature, // feature,
toDeduct, // toDeduct,
properties, // properties,
interval: balance.interval, // interval: balance.interval,
intervalCount: balance.interval_count, // intervalCount: balance.interval_count,
}); // });
} // }
const batchDeduct = []; // const batchDeduct = [];
for (const featureDeduction of featureDeductions) { // for (const featureDeduction of featureDeductions) {
// 1. Deduct from allowance // // 1. Deduct from allowance
const performDeduction = async () => { // const performDeduction = async () => {
let { toDeduct, feature, properties, interval } = featureDeduction; // let { toDeduct, feature, properties, interval } = featureDeduction;
// Handle unlimited // // Handle unlimited
if (featureDeduction.unlimited) { // if (featureDeduction.unlimited) {
// Get one active cusEnt and set unlimited to true // // Get one active cusEnt and set unlimited to true
const cusEnt = notNullish(interval) // const cusEnt = notNullish(interval)
? cusEnts.find((cusEnt) => { // ? cusEnts.find((cusEnt) => {
const cusEntIntCount = cusEnt.entitlement.interval_count || 1; // const cusEntIntCount = cusEnt.entitlement.interval_count || 1;
const deductionIntCount = featureDeduction.intervalCount || 1; // const deductionIntCount = featureDeduction.intervalCount || 1;
return ( // return (
cusEnt.internal_feature_id === feature!.internal_id! && // cusEnt.internal_feature_id === feature!.internal_id! &&
cusEnt.entitlement.interval === interval && // cusEnt.entitlement.interval === interval &&
cusEntIntCount === deductionIntCount // cusEntIntCount === deductionIntCount
); // );
}) // })
: cusEnts.find( // : cusEnts.find(
(cusEnt) => // (cusEnt) =>
cusEnt.internal_feature_id === feature!.internal_id!, // cusEnt.internal_feature_id === feature!.internal_id!,
); // );
if (!cusEnt) { // if (!cusEnt) {
logger.warn( // logger.warn(
`No active cus ent to set unlimited balance for feature: ${ // `No active cus ent to set unlimited balance for feature: ${
feature!.id // feature!.id
}`, // }`,
); // );
return; // return;
} // }
await CusEntService.update({ // await CusEntService.update({
db, // db,
id: cusEnt.id, // id: cusEnt.id,
updates: { // updates: {
unlimited: true, // unlimited: true,
next_reset_at: null, // next_reset_at: null,
}, // },
}); // });
return; // return;
} // }
for (const cusEnt of cusEnts) { // for (const cusEnt of cusEnts) {
const cusEntIntCount = cusEnt.entitlement.interval_count || 1; // const cusEntIntCount = cusEnt.entitlement.interval_count || 1;
const deductionIntCount = featureDeduction.intervalCount || 1; // const deductionIntCount = featureDeduction.intervalCount || 1;
const intCountMatch = notNullish(featureDeduction.intervalCount) // const intCountMatch = notNullish(featureDeduction.intervalCount)
? cusEntIntCount === deductionIntCount // ? cusEntIntCount === deductionIntCount
: true; // : true;
const intMatch = notNullish(featureDeduction.interval) // const intMatch = notNullish(featureDeduction.interval)
? featureDeduction.interval === cusEnt.entitlement.interval // ? featureDeduction.interval === cusEnt.entitlement.interval
: true; // : true;
if ( // if (
cusEnt.internal_feature_id !== // cusEnt.internal_feature_id !==
featureDeduction.feature!.internal_id! || // featureDeduction.feature!.internal_id! ||
!intMatch || // !intMatch ||
!intCountMatch // !intCountMatch
) { // ) {
continue; // continue;
} // }
toDeduct = await deductAllowanceFromCusEnt({ // toDeduct = await deductAllowanceFromCusEnt({
toDeduct, // toDeduct,
deductParams: { // deductParams: {
db, // db,
feature: featureDeduction.feature!, // feature: featureDeduction.feature!,
env: req.env, // env: req.env,
org, // org,
cusPrices: cusPrices as any[], // cusPrices: cusPrices as any[],
customer, // customer,
}, // },
cusEnt, // cusEnt,
featureDeductions: [], // not important because not deducting credits // featureDeductions: [], // not important because not deducting credits
willDeductCredits: false, // willDeductCredits: false,
}); // });
} // }
if (toDeduct === 0) { // if (toDeduct === 0) {
return; // return;
} // }
await deductFromUsageBasedCusEnt({ // await deductFromUsageBasedCusEnt({
toDeduct, // toDeduct,
cusEnts, // cusEnts,
deductParams: { // deductParams: {
db, // db,
feature: featureDeduction.feature!, // feature: featureDeduction.feature!,
env, // env,
org, // org,
cusPrices: cusPrices as any[], // cusPrices: cusPrices as any[],
customer, // customer,
}, // },
}); // });
}; // };
batchDeduct.push(performDeduction()); // batchDeduct.push(performDeduction());
} // }
await Promise.all(batchDeduct); // await Promise.all(batchDeduct);
logger.info(" ✅ Successfully updated balances"); // logger.info(" ✅ Successfully updated balances");
res.status(200).json({ success: true }); // res.status(200).json({ success: true });
} catch (error) { // } catch (error) {
handleRequestError({ req, error, res, action: "update customer balances" }); // 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 { import {
AffectedResource, AffectedResource,
ApiVersion, ApiVersion,
ErrCode, CustomerAlreadyExistsError,
CustomerNotFoundError,
GetCustomerQuerySchema, GetCustomerQuerySchema,
ProcessorType, ProcessorType,
RecaseError,
UpdateCustomerParamsSchema, UpdateCustomerParamsSchema,
} from "@autumn/shared"; } from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import { z } from "zod/v4";
import { createStripeCli } from "@/external/connect/createStripeCli.js"; import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { createRoute } from "@/honoMiddlewares/routeHandler.js"; import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import RecaseError from "@/utils/errorUtils.js";
import { notNullish } from "@/utils/genUtils.js"; import { notNullish } from "@/utils/genUtils.js";
import { CusService } from "../CusService.js"; import { CusService } from "../CusService.js";
import { getApiCustomer } from "../cusUtils/apiCusUtils/getApiCustomer.js"; import { getApiCustomer } from "../cusUtils/apiCusUtils/getApiCustomer.js";
export const handleUpdateCustomerV2 = createRoute({ export const handleUpdateCustomerV2 = createRoute({
params: z.object({
customer_id: z.string(),
}),
body: UpdateCustomerParamsSchema, body: UpdateCustomerParamsSchema,
versionedQuery: { versionedQuery: {
latest: GetCustomerQuerySchema, latest: GetCustomerQuerySchema,
@@ -28,9 +25,8 @@ export const handleUpdateCustomerV2 = createRoute({
handler: async (c) => { handler: async (c) => {
const ctx = c.get("ctx"); const ctx = c.get("ctx");
const { db, org, env, logger } = 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 newCusData = c.req.valid("json");
const { expand } = ctx;
const originalCustomer = await CusService.get({ const originalCustomer = await CusService.get({
db, db,
@@ -40,18 +36,12 @@ export const handleUpdateCustomerV2 = createRoute({
}); });
if (!originalCustomer) { if (!originalCustomer) {
throw new RecaseError({ throw new CustomerNotFoundError({ customerId: customer_id });
message: `Update customer: Customer ${customer_id} not found`,
code: ErrCode.CustomerNotFound,
statusCode: StatusCodes.NOT_FOUND,
});
} }
if (newCusData.id === null) { if (newCusData.id === null) {
throw new RecaseError({ throw new RecaseError({
message: `Update customer: Can't change customer ID to null`, 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) { if (existingCustomer) {
throw new RecaseError({ throw new CustomerAlreadyExistsError({
message: `Update customer: Customer ${newCusData.id} already exists, can't change to this ID`, message: `Customer with ID ${newCusData.id} already exists, can't change to this ID`,
code: ErrCode.DuplicateCustomerId, customerId: newCusData.id,
statusCode: StatusCodes.CONFLICT,
}); });
} }
} }
@@ -138,20 +127,12 @@ export const handleUpdateCustomerV2 = createRoute({
update: updateData, update: updateData,
}); });
const finalCustomer = await CusService.getFull({ ctx.skipCache = true;
db,
idOrInternalId: originalCustomer.internal_id,
orgId: org.id,
env,
withEntities: true,
});
const customerDetails = await getApiCustomer({ const customerDetails = await getApiCustomer({
ctx, ctx,
fullCus: finalCustomer, customerId: customer_id,
}); });
return c.json(customerDetails); return c.json(customerDetails);
}, },
}); });

View File

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

View File

@@ -10,7 +10,7 @@ export const handleGetCusReferrals = createRoute({
params: z.object({ customer_id: z.string() }), params: z.object({ customer_id: z.string() }),
handler: async (c) => { handler: async (c) => {
const { env, db, org } = c.get("ctx"); 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({ const internalCustomer = await CusService.get({
db, 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 type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { JobName } from "@/queue/JobName.js"; import { JobName } from "@/queue/JobName.js";
import { addTaskToQueue } from "@/queue/queueUtils.js"; import { addTaskToQueue } from "@/queue/queueUtils.js";
import { generateId } from "@/utils/genUtils.js"; import { generateId } from "@/utils/genUtils.js";
import { FeatureService } from "../FeatureService.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 { interface CreateFeatureParams {
ctx: AutumnContext; ctx: AutumnContext;

View File

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

View File

@@ -1,177 +1,198 @@
import { // import {
ApiFeatureSchema, // ApiFeatureType,
ApiFeatureType, // ApiFeatureV0Schema,
ErrCode, // ErrCode,
type Feature, // type Feature,
FeatureType, // FeatureType,
type FeatureUsageType, // type FeatureUsageType,
UpdateFeatureParamsSchema, // UpdateFeatureParamsSchema,
} from "@autumn/shared"; // } from "@autumn/shared";
import express, { type Router } from "express"; import { Hono } from "hono";
import { JobName } from "@/queue/JobName.js"; import type { HonoEnv } from "../../honoUtils/HonoEnv";
import { addTaskToQueue } from "@/queue/queueUtils.js"; import { handleCreateFeature } from "./handlers/handleCreateFeature";
import RecaseError from "@/utils/errorUtils.js"; import { handleDeleteFeature } from "./handlers/handleDeleteFeature";
import { keyToTitle } from "@/utils/genUtils.js"; import { handleGetFeature } from "./handlers/handleGetFeature";
import { routeHandler } from "@/utils/routerUtils.js"; import { handleListFeatures } from "./handlers/handleListFeatures";
import { FeatureService } from "./FeatureService.js"; import { handleUpdateFeature } from "./handlers/handleUpdateFeature";
import { validateFeatureId } from "./featureUtils.js"; import { handleGetFeatureDeletionInfo } from "./internalHandlers/handleGetFeatureDeletionInfo";
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";
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... // export const featureRouter: Router = express.Router();
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 // // 1. Get features...
.status(200) // featureRouter.get("", async (req: any, res: any) =>
.json({ list: features.map((feature) => toApiFeature({ feature })) }); // 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 }; const newConfig = { ...config, usage_type: FeatureUsageType.Single };
for (let i = 0; i < newConfig.schema.length; i++) { for (let i = 0; i < newConfig.schema.length; i++) {
newConfig.schema[i].feature_amount = 1;
const creditAmount = parseFloat( const creditAmount = parseFloat(
newConfig.schema[i].credit_amount.toString(), newConfig.schema[i].credit_amount.toString(),
); );
if (Number.isNaN(creditAmount)) { if (Number.isNaN(creditAmount)) {
throw new RecaseError({ throw new RecaseError({
message: `Credit amount should be a number`, message: `Credit amount should be a number`,
@@ -120,9 +119,10 @@ export const runSaveFeatureDisplayTask = async ({
}, },
}); });
} catch (error) { } catch (error) {
logger.error("failed to generate feature display", { logger.error(`failed to generate feature display, ${error}`, {
error, data: {
feature, feature,
},
}); });
} }
}; };

View File

@@ -1,18 +1,43 @@
import { handleFrontendReqError } from "@/utils/errorUtils.js"; import {
import { createFeature } from "../featureActions/createFeature.js"; 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) => { export const handleCreateFeature = createRoute({
try { versionedBody: {
console.log("Trying to create feature"); latest: CreateFeatureV1ParamsSchema,
const data = req.body; [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({ // Get backend feature
ctx: req, const feature = featureV1ToDbFeature({
data, apiFeature: body,
originalFeature: undefined,
}); });
res.status(200).json(insertedFeature); // Body is now always in the latest V1 format, regardless of API version
} catch (error) { const dbFeature = await createFeature({
handleFrontendReqError({ req, error, res, action: "Create feature" }); 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 { FeatureNotFoundError, RecaseError } from "@autumn/shared";
import { getCreditSystemsFromFeature } from "@/internal/features/creditSystemUtils.js"; import { createRoute } from "../../../honoMiddlewares/routeHandler";
import { FeatureService } from "@/internal/features/FeatureService.js"; import { EntitlementService } from "../../products/entitlements/EntitlementService";
import { EntitlementService } from "@/internal/products/entitlements/EntitlementService.js"; import { getCreditSystemsFromFeature } from "../creditSystemUtils";
import RecaseError from "@/utils/errorUtils.js"; import { FeatureService } from "../FeatureService";
import type {
ExtendedRequest,
ExtendedResponse,
} from "@/utils/models/Request.js";
import { routeHandler } from "@/utils/routerUtils.js";
export const handleDeleteFeature = async (req: any, res: any) => export const handleDeleteFeature = createRoute({
routeHandler({ handler: async (c) => {
req, const ctx = c.get("ctx");
res, const { db, org, features } = ctx;
action: "Delete feature", const { feature_id } = c.req.param();
handler: async (req: ExtendedRequest, res: ExtendedResponse) => {
const { db, orgId } = req;
const { featureId } = req.params; const feature = features.find((f) => f.id === feature_id);
const features = await FeatureService.getFromReq(req); if (!feature) {
const feature = features.find((f) => f.id === featureId); throw new FeatureNotFoundError({ featureId: feature_id });
const creditSystems = getCreditSystemsFromFeature({ }
featureId,
features, 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) { // Get prices that use this feature
throw new RecaseError({ const ent = await EntitlementService.getByFeature({
message: `Feature ${featureId} not found`, db,
code: ErrCode.FeatureNotFound, internalFeatureId: feature.internal_id!,
statusCode: 404, });
});
}
if (creditSystems.length > 0) { if (ent) {
throw new RecaseError({ throw new RecaseError({
message: `Feature ${featureId} is used by credit system ${creditSystems[0].id}`, message: `Feature ${feature_id} is used in a product. You must delete the product first, or archive it instead.`,
code: ErrCode.InvalidFeature,
statusCode: 400,
});
}
// Get prices that use this feature
const ent = await EntitlementService.getByFeature({
db,
internalFeatureId: feature.internal_id!,
}); });
}
if (ent) { await FeatureService.delete({
throw new RecaseError({ db,
message: `Feature ${featureId} is used in a product. You must delete the product first, or archive it instead.`, orgId: org.id,
code: ErrCode.InvalidFeature, featureId: feature_id,
statusCode: 400, env: ctx.env,
}); });
}
await FeatureService.delete({ return c.json({ success: true });
db: req.db, },
orgId, });
featureId,
env: req.env,
});
res.status(200).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 {
import { updateFeature } from "../featureActions/updateFeature.js"; AffectedResource,
import { toApiFeature } from "../utils/mapFeatureUtils.js"; ApiVersion,
dbToApiFeatureV1,
FeatureNotFoundError,
FeatureType,
featureV1ToDbFeatureConfig,
InternalError,
nullish,
RecaseError,
UpdateFeatureV0ParamsSchema,
UpdateFeatureV1ParamsSchema,
} from "@autumn/shared";
export const handleUpdateFeature = async ( import { createRoute } from "../../../honoMiddlewares/routeHandler";
req: any, import { updateFeature } from "../featureActions/updateFeature";
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 export const handleUpdateFeature = createRoute({
const updatedFeature = await updateFeature({ versionedBody: {
ctx: req, latest: UpdateFeatureV1ParamsSchema,
featureId, [ApiVersion.V1_Beta]: UpdateFeatureV0ParamsSchema,
updates: data, },
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 const newConfig = featureV1ToDbFeatureConfig({
.status(200) apiFeature: body,
.json( originalFeature,
updatedFeature });
? toApiFeature({ feature: updatedFeature })
: undefined, 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 { import {
type ApiFeature,
ApiFeatureSchema,
ApiFeatureType, ApiFeatureType,
type ApiFeatureV0,
type AppEnv, type AppEnv,
type CreditSchemaItem,
type Feature,
FeatureType, FeatureType,
type FeatureUsageType, type FeatureUsageType,
} from "@autumn/shared"; } from "@autumn/shared";
@@ -15,41 +12,12 @@ import {
constructMeteredFeature, constructMeteredFeature,
} from "./constructFeatureUtils.js"; } 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 = ({ export const fromApiFeature = ({
apiFeature, apiFeature,
orgId, orgId,
env, env,
}: { }: {
apiFeature: ApiFeature; apiFeature: ApiFeatureV0;
orgId: string; orgId: string;
env: AppEnv; env: AppEnv;
}) => { }) => {

View File

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

View File

@@ -9,6 +9,7 @@ import {
type Feature, type Feature,
FeatureType, FeatureType,
type FreeTrial, type FreeTrial,
FreeTrialDuration,
type FullEntitlement, type FullEntitlement,
type FullProduct, type FullProduct,
type Price, 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 = ( export const applyTrialToEntitlement = (
entitlement: EntitlementWithFeature, entitlement: EntitlementWithFeature,
freeTrial: FreeTrial | null, freeTrial: FreeTrial | null,
@@ -55,13 +72,13 @@ export const applyTrialToEntitlement = (
return false; return false;
if (entitlement.allowance_type === AllowanceType.Unlimited) return false; if (entitlement.allowance_type === AllowanceType.Unlimited) return false;
const trialDays = freeTrial.length; const trialDays = trialToDays(freeTrial);
const entDays = entIntervalToTrialDuration({ const entDays = entIntervalToTrialDuration({
interval: entitlement.interval!, interval: entitlement.interval!,
intervalCount: entitlement.interval_count || 1, intervalCount: entitlement.interval_count || 1,
}); });
if (entDays && entDays > trialDays) { if (entDays && trialDays < entDays) {
return true; return true;
} }

View File

@@ -6,10 +6,29 @@ import {
} from "@autumn/shared"; } from "@autumn/shared";
import { createRoute } from "@/honoMiddlewares/routeHandler.js"; import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { FeatureService } from "@/internal/features/FeatureService.js"; import { FeatureService } from "@/internal/features/FeatureService.js";
import { initNewFeature } from "@/internal/features/internalFeatureRouter.js";
import { ProductService } from "@/internal/products/ProductService.js"; import { ProductService } from "@/internal/products/ProductService.js";
import { copyProduct } from "@/internal/products/productUtils.js"; import { copyProduct } from "@/internal/products/productUtils.js";
import RecaseError from "@/utils/errorUtils.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 * Route: POST /v1/products/:productId/copy - Copy a product

View File

@@ -13,10 +13,7 @@ const DeleteProductParamsSchema = z.object({
}); });
const DeleteProductQuerySchema = z.object({ const DeleteProductQuerySchema = z.object({
all_versions: z all_versions: z.boolean(),
.string()
.optional()
.transform((val) => val === "true"),
}); });
export const handleDeleteProduct = createRoute({ export const handleDeleteProduct = createRoute({
@@ -24,7 +21,7 @@ export const handleDeleteProduct = createRoute({
query: DeleteProductQuerySchema, query: DeleteProductQuerySchema,
resource: AffectedResource.Product, resource: AffectedResource.Product,
handler: async (c) => { handler: async (c) => {
const { product_id } = c.req.valid("param"); const { product_id } = c.req.param();
const { all_versions } = c.req.valid("query"); const { all_versions } = c.req.valid("query");
const { db, org, env } = c.get("ctx"); 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 // 2. Update product
console.log("New group:", newProduct.group);
await ProductService.updateByInternalId({ await ProductService.updateByInternalId({
db, db,

View File

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

View File

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

View File

@@ -1,4 +1,6 @@
import type { IntervalConfig } from "@autumn/shared"; import type { IntervalConfig } from "@autumn/shared";
import { UTCDate } from "@date-fns/utc";
import { toMilliseconds } from "../../../utils/timeUtils.js";
import { import {
addIntervalForProration, addIntervalForProration,
subtractIntervalForProration, 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 = ({ export const subtractIntervalFromAnchor = ({
anchor, anchor,
intervalConfig, intervalConfig,
@@ -45,13 +56,14 @@ export const subtractIntervalFromAnchor = ({
for (let i = 0; i < 50; i++) { for (let i = 0; i < 50; i++) {
const newAnchor = subtractIntervalForProration({ const newAnchor = subtractIntervalForProration({
unixTimestamp: anchor, unixTimestamp: curAnchor,
interval: intervalConfig.interval, interval: intervalConfig.interval,
intervalCount: intervalConfig.intervalCount ?? 1, intervalCount: intervalConfig.intervalCount ?? 1,
}); });
// Return anchor before it goes below now // 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; curAnchor = newAnchor;
} }

View File

@@ -6,11 +6,12 @@ import {
entToPrice, entToPrice,
getBillingType, getBillingType,
isFixedPrice, isFixedPrice,
itemToEntInterval,
type Price, type Price,
type ProductItem, type ProductItem,
UsageModel, UsageModel,
} from "@autumn/shared"; } from "@autumn/shared";
import { itemToEntInterval } from "../itemIntervalUtils.js";
import { isFeatureItem, isFeaturePriceItem } from "./getItemType.js"; import { isFeatureItem, isFeaturePriceItem } from "./getItemType.js";
export const addIdsToProductItems = ({ export const addIdsToProductItems = ({
@@ -56,10 +57,10 @@ export const addIdsToProductItems = ({
// Sort by interval // Sort by interval
const aIntervalValue = entIntervalToValue( const aIntervalValue = entIntervalToValue(
itemToEntInterval(a) as EntInterval, itemToEntInterval({ item: a }) as EntInterval,
); );
const bIntervalValue = entIntervalToValue( const bIntervalValue = entIntervalToValue(
itemToEntInterval(b) as EntInterval, itemToEntInterval({ item: b }) as EntInterval,
); );
if (!aIntervalValue.eq(bIntervalValue)) { if (!aIntervalValue.eq(bIntervalValue)) {
return aIntervalValue.sub(bIntervalValue).toNumber(); 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 { Hono } from "hono";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { handleCopyProductV2 } from "./handlers/handleCopyProduct/handleCopyProductV2.js"; import { handleCopyProductV2 } from "./handlers/handleCopyProduct/handleCopyProductV2.js";
import { handleCreatePlan } from "./handlers/handleCreatePlan.js"; import { handleCreatePlan } from "./handlers/handleCreatePlan.js";
import { handleDeleteProduct as handleDeleteProductHono } from "./handlers/handleDeleteProduct.js"; import { handleDeleteProduct as handleDeleteProductHono } from "./handlers/handleDeleteProduct.js";
import { handleGetPlan } from "./handlers/handleGetPlan.js"; import { handleGetPlan } from "./handlers/handleGetPlan.js";
import { handleGetPlanDeleteInfo } from "./handlers/handleGetPlanDeleteInfo.js";
import { handleListPlans } from "./handlers/handleListPlans.js"; import { handleListPlans } from "./handlers/handleListPlans.js";
import { handlePlanHasCustomers } from "./handlers/handlePlanHasCustomers.js";
import { handleUpdatePlan } from "./handlers/handleUpdateProduct/handleUpdatePlan.js"; import { handleUpdatePlan } from "./handlers/handleUpdateProduct/handleUpdatePlan.js";
export const honoProductBetaRouter = new Hono<HonoEnv>(); export const honoProductBetaRouter = new Hono<HonoEnv>();
@@ -144,11 +16,85 @@ honoProductBetaRouter.get("", ...handleListPlans);
export const honoProductRouter = new Hono<HonoEnv>(); export const honoProductRouter = new Hono<HonoEnv>();
export const migrationRouter = new Hono<HonoEnv>(); export const migrationRouter = new Hono<HonoEnv>();
// POST /products - Create a product // CRUD
honoProductRouter.get("", ...handleListPlans); honoProductRouter.get("", ...handleListPlans);
honoProductRouter.post("", ...handleCreatePlan); honoProductRouter.post("", ...handleCreatePlan);
honoProductRouter.get("/:product_id", ...handleGetPlan); honoProductRouter.get("/:product_id", ...handleGetPlan);
honoProductRouter.post("/:product_id", ...handleUpdatePlan); // will be deprecated honoProductRouter.post("/:product_id", ...handleUpdatePlan); // will be deprecated
honoProductRouter.delete("/:product_id", ...handleDeleteProductHono); honoProductRouter.delete("/:product_id", ...handleDeleteProductHono);
// Others
honoProductRouter.post("/:product_id/copy", ...handleCopyProductV2); 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 FullProduct,
type Price, type Price,
type ProductItem, type ProductItem,
toApiFeature,
UsageModel, UsageModel,
} from "@autumn/shared"; } from "@autumn/shared";
import type { DrizzleCli } from "@/db/initDrizzle.js"; import type { DrizzleCli } from "@/db/initDrizzle.js";
import { toApiFeature } from "@/internal/features/utils/mapFeatureUtils.js";
import { notNullish } from "@/utils/genUtils.js"; import { notNullish } from "@/utils/genUtils.js";
import { getFreeTrialAfterFingerprint } from "../../free-trials/freeTrialUtils.js"; import { getFreeTrialAfterFingerprint } from "../../free-trials/freeTrialUtils.js";
import { sortProductItems } from "../../pricecn/pricecnUtils.js"; import { sortProductItems } from "../../pricecn/pricecnUtils.js";

View File

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

View File

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

View File

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

View File

@@ -44,7 +44,6 @@ import {
getMeteredDeduction, getMeteredDeduction,
performDeduction, performDeduction,
} from "./deductUtils.js"; } from "./deductUtils.js";
import { handleThresholdReached } from "./handleThresholdReached.js";
export type DeductParams = { export type DeductParams = {
db: DrizzleCli; db: DrizzleCli;
@@ -265,7 +264,7 @@ export const performDeductionOnCusEnt = ({
deducted: newDeducted, deducted: newDeducted,
toDeduct: newToDeduct, toDeduct: newToDeduct,
} = performDeduction({ } = performDeduction({
cusEntBalance: new Decimal(entityBalance), cusEntBalance: new Decimal(entityBalance ?? 0),
toDeduct: toDeductCursor, toDeduct: toDeductCursor,
allowNegativeBalance, allowNegativeBalance,
ent: cusEnt.entitlement, ent: cusEnt.entitlement,
@@ -527,7 +526,7 @@ export const deductFromUsageBasedCusEnt = async ({
const cusPrice = getRelatedCusPrice(usageBasedEnt, cusPrices); const cusPrice = getRelatedCusPrice(usageBasedEnt, cusPrices);
const billingType = cusPrice?.price const billingType = cusPrice?.price
? getBillingType(cusPrice?.price.config!) ? getBillingType(cusPrice?.price.config ?? undefined)
: undefined; : undefined;
const blockUsageLimit = const blockUsageLimit =
billingType === BillingType.InArrearProrated ? false : true; 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; return cusEnts;

View File

@@ -31,7 +31,6 @@ import RecaseError from "@/utils/errorUtils.js";
import { deductFromAdditionalBalance } from "../internal/balances/deductUtils/deductFromAdditionalBalance.js"; import { deductFromAdditionalBalance } from "../internal/balances/deductUtils/deductFromAdditionalBalance.js";
import { generateId } from "../utils/genUtils.js"; import { generateId } from "../utils/genUtils.js";
import { handleThresholdReached } from "./handleThresholdReached.js";
import { import {
deductAllowanceFromCusEnt, deductAllowanceFromCusEnt,
deductFromUsageBasedCusEnt, deductFromUsageBasedCusEnt,
@@ -475,6 +474,7 @@ export const updateUsage = async ({
apiVersion: new ApiVersionClass(LATEST_VERSION), apiVersion: new ApiVersionClass(LATEST_VERSION),
timestamp: Date.now(), timestamp: Date.now(),
expand: [], expand: [],
skipCache: true,
}, },
}); });
@@ -520,18 +520,6 @@ export const updateUsage = async ({
}; };
await performFeatureDeduction(); await performFeatureDeduction();
handleThresholdReached({
org,
env,
features: allFeatures,
db,
feature,
cusEnts: originalCusEnts,
newCusEnts: cusEnts,
fullCus: customer,
logger,
});
} }
return cusEnts; 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; return balance;
}; };
@@ -127,64 +133,39 @@ export const normalizeCachedData = <T extends ApiCustomer | ApiEntityV1>(
// Fix missing credit_schema -> null // Fix missing credit_schema -> null
if (data.balances) { if (data.balances) {
for (const featureId in data.balances) { for (const featureId in data.balances) {
const feature = data.balances[featureId]; const balance = data.balances[featureId];
// if (!feature.reset) { // if (!feature.reset) {
// feature.reset = null; // feature.reset = null;
// } // }
if ( if (
!Array.isArray(feature.breakdown) && !Array.isArray(balance.breakdown) &&
typeof feature.breakdown === "object" typeof balance.breakdown === "object"
) { ) {
feature.breakdown = undefined; balance.breakdown = undefined;
} }
if ( if (
!Array.isArray(feature.rollovers) && !Array.isArray(balance.rollovers) &&
typeof feature.rollovers === "object" typeof balance.rollovers === "object"
) { ) {
feature.rollovers = undefined; balance.rollovers = undefined;
} }
if (feature.breakdown) { if (balance.feature?.event_names) {
for (const breakdown of feature.breakdown) { 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) { // if (!breakdown.reset) {
// breakdown.reset = null; // 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 FullCustomer,
type Organization, type Organization,
} from "@autumn/shared"; } from "@autumn/shared";
import { defaultApiVersion } from "@tests/constants.js";
import { cusProductToSubIds } from "@tests/merged/mergeUtils.test.js";
import type Stripe from "stripe"; 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 type { DrizzleCli } from "@/db/initDrizzle.js";
import { priceToStripeItem } from "@/external/stripe/priceToStripeItem/priceToStripeItem.js"; import { priceToStripeItem } from "@/external/stripe/priceToStripeItem/priceToStripeItem.js";
import { subIsCanceled } from "@/external/stripe/stripeSubUtils.js"; import { subIsCanceled } from "@/external/stripe/stripeSubUtils.js";

View File

@@ -6,7 +6,6 @@ import {
CreateFreeTrialSchema, CreateFreeTrialSchema,
type CreateReward, type CreateReward,
FeatureUsageType, FeatureUsageType,
type FreeTrial,
FreeTrialDuration, FreeTrialDuration,
type ProductItem, type ProductItem,
type ProductV2, type ProductV2,
@@ -144,15 +143,7 @@ export const constructProduct = ({
id || id ||
(isAnnual ? `${type}-annual` : interval ? `${type}-${interval}` : type); (isAnnual ? `${type}-annual` : interval ? `${type}-${interval}` : type);
let free_trial: CreateFreeTrial | null = null; const freeTrialLength = freeTrial?.length || 7;
if (freeTrial) {
free_trial = freeTrial as FreeTrial;
} else if (trial) {
free_trial = CreateFreeTrialSchema.parse({
length: 7,
duration: FreeTrialDuration.Day,
});
}
const product: ProductV2 = { const product: ProductV2 = {
id: id_, id: id_,
@@ -169,7 +160,15 @@ export const constructProduct = ({
is_default: (type === "free" && isDefault) || forcePaidDefault, is_default: (type === "free" && isDefault) || forcePaidDefault,
version: 1, version: 1,
group: group || "", 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(), 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 type { TestContext } from "@tests/utils/testInitUtils/createTestContext.js";
import { and, eq, inArray } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { deleteCachedApiCustomer } from "../../../internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer";
export const createSharedProducts = async ({ export const createSharedProducts = async ({
products, products,
@@ -33,16 +34,30 @@ export const createSharedProducts = async ({
throw new Error("Too many customers under shared default free product"); throw new Error("Too many customers under shared default free product");
} }
await ctx.db.delete(customers).where( const deletedCustomers = await ctx.db
and( .delete(customers)
inArray( .where(
customers.internal_id, and(
cusProducts.map((cp) => cp.internal_customer_id), 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({ const autumn = new AutumnInt({
secretKey: ctx.orgSecretKey, secretKey: ctx.orgSecretKey,

View File

@@ -60,5 +60,6 @@ export const createWorkerAutumnContext = async ({
authType: AuthType.Unknown, authType: AuthType.Unknown,
apiVersion, apiVersion,
timestamp: Date.now(), timestamp: Date.now(),
skipCache: false,
} satisfies AutumnContext; } 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 { beforeAll, describe, test } from "bun:test";
import { import { FreeTrialDuration, LegacyVersion } from "@autumn/shared";
type AppEnv,
LegacyVersion,
OnDecrease,
OnIncrease,
type Organization,
} from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js"; import { TestFeature } from "@tests/setup/v2Features.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk"; import chalk from "chalk";
import type { Stripe } from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
constructArrearProratedItem,
constructFeatureItem,
} from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
@@ -25,53 +14,23 @@ const pro = constructProduct({
type: "pro", type: "pro",
items: [ items: [
// constructFeatureItem({ featureId: TestFeature.Words, includedUsage: 100 }), 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,
},
}),
], ],
freeTrial: {
length: 7,
duration: FreeTrialDuration.Day,
unique_fingerprint: false,
card_required: true,
},
}); });
describe(`${chalk.yellowBright("temp: Testing add ons")}`, () => { describe(`${chalk.yellowBright("temp: Testing add ons")}`, () => {
const customerId = "temp"; const customerId = "temp";
const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 });
let stripeCli: Stripe;
let testClockId: string; let testClockId: string;
let curUnix: number;
let db: DrizzleCli;
let org: Organization;
let env: AppEnv;
beforeAll(async () => { beforeAll(async () => {
db = ctx.db; const result = await initCustomerV3({
org = ctx.org;
env = ctx.env;
stripeCli = ctx.stripeCli;
const { testClockId: testClockId1 } = await initCustomerV3({
ctx, ctx,
customerId, customerId,
customerData: {}, customerData: {},
@@ -81,11 +40,11 @@ describe(`${chalk.yellowBright("temp: Testing add ons")}`, () => {
await initProductsV0({ await initProductsV0({
ctx, ctx,
products: [pro, premium], products: [pro],
prefix: customerId, prefix: customerId,
}); });
testClockId = testClockId1!; testClockId = result.testClockId!;
}); });
test("should attach pro product", async () => { test("should attach pro product", async () => {

View File

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

View File

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

View File

@@ -1,14 +1,21 @@
import { beforeAll, describe, expect, test } from "bun:test"; import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion, type CheckResponseV1, SuccessCode } from "@autumn/shared"; import {
import chalk from "chalk"; ApiVersion,
AppEnv,
type CheckResponseV1,
SuccessCode,
} from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js"; import { TestFeature } from "@tests/setup/v2Features.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js"; 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 { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
import { timeout } from "../../../utils/genUtils.js";
const messagesFeature = constructFeatureItem({ const messagesFeature = constructFeatureItem({
featureId: TestFeature.Messages, featureId: TestFeature.Messages,
@@ -46,10 +53,30 @@ describe(`${chalk.yellowBright("check8: test public key & send_event")}`, () =>
product_id: freeProd.id, 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 // Initialize Autumn client with public key
autumnPublic = new AutumnInt({ autumnPublic = new AutumnInt({
version: ApiVersion.V1_2, 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 { beforeAll, describe, expect, test } from "bun:test";
import { type ApiCustomer, ApiVersion, type LimitedItem } from "@autumn/shared"; import { type ApiCustomer, ApiVersion, type LimitedItem } from "@autumn/shared";
import chalk from "chalk";
import { TestFeature } from "@tests/setup/v2Features.js"; import { TestFeature } from "@tests/setup/v2Features.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { CusService } from "@/internal/customers/CusService.js"; import { CusService } from "@/internal/customers/CusService.js";
import { constructArrearItem } from "@/utils/scriptUtils/constructItem.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_balance).toBe(10); // Added
expect(cusEnt?.additional_granted_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]; const balance = customer.balances[TestFeature.Users];
// current = 0 + 0 + 10 = 10 // current = 0 + 0 + 10 = 10
expect(balance.current_balance).toBe(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_balance).toBe(5); // 10 - 5
expect(cusEnt?.additional_granted_balance).toBe(5); // 10 - 5 expect(cusEnt?.additional_granted_balance).toBe(5); // 10 - 5
const customer = await autumnV2.customers.get(customerId); const customer = await autumnV2.customers.get<ApiCustomer>(customerId);
const feature = customer.features[TestFeature.Users]; const balance = customer.balances[TestFeature.Users];
// current = 0 + 0 + 5 = 5
expect(feature.current_balance).toBe(5); expect(balance.current_balance).toBe(5);
}); });
test("CASE C: balances.update REMOVE with insufficient additional_balance", async () => { 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_balance).toBe(0); // Floored
expect(cusEnt?.additional_granted_balance).toBe(0); // 5 - 5 expect(cusEnt?.additional_granted_balance).toBe(0); // 5 - 5
const customer = await autumnV2.customers.get(customerId); const customer = await autumnV2.customers.get<ApiCustomer>(customerId);
const feature = customer.features[TestFeature.Users]; const balance = customer.balances[TestFeature.Users];
expect(feature.current_balance).toBe(0); expect(balance.current_balance).toBe(0);
}); });
test("Track +5 then update REMOVE to trigger main balance deduction", async () => { 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_balance).toBe(0); // Unchanged
expect(cusEnt?.additional_granted_balance).toBe(0); // Unchanged expect(cusEnt?.additional_granted_balance).toBe(0); // Unchanged
const customer = await autumnV2.customers.get(customerId); const customer = await autumnV2.customers.get<ApiCustomer>(customerId);
const feature = customer.features[TestFeature.Users]; const balance = customer.balances[TestFeature.Users];
// current = Math.max(0, 0) + 0 = 0
// purchased = 0 expect(balance.current_balance).toBe(0);
expect(feature.current_balance).toBe(0); expect(balance.purchased_balance).toBe(0);
expect(feature.purchased_balance).toBe(0); expect(balance.granted_balance).toBe(0);
expect(feature.granted_balance).toBe(0); expect(balance.usage).toBe(0);
expect(feature.usage).toBe(0);
}); });
}); });

View File

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

View File

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

View File

@@ -167,10 +167,10 @@ export const calcProrationAndExpectInvoice = async ({
expect(invoices.length).to.equal( expect(invoices.length).to.equal(
numInvoices, numInvoices,
`Should have ${numInvoices} invoices`, `Should have ${numInvoices} invoices; got ${invoices.length}`,
); );
expect(invoices[0].total).to.equal( expect(invoices[0].total).to.equal(
proratedAmount, 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, SuccessResponseSchema,
} from "../../../common/commonResponses.js"; } from "../../../common/commonResponses.js";
import { import {
ApiFeatureSchema, ApiFeatureV0Schema,
FEATURE_EXAMPLE, FEATURE_EXAMPLE,
} from "../../../features/apiFeature.js"; } from "../../../features/prevVersions/apiFeatureV0.js";
import { import {
CreateFeatureParamsSchema, CreateFeatureParamsSchema,
UpdateFeatureParamsSchema, 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"]), type: z.enum(["boolean", "single_use", "continuous_use", "credit_system"]),
}).meta({ }).meta({
id: "Feature", id: "Feature",

View File

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

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 { ApiVersion } from "@api/versionUtils/ApiVersion.js";
import { import {
AffectedResource, AffectedResource,

View File

@@ -1,6 +1,6 @@
import { ApiFeatureSchema } from "@api/features/apiFeature.js";
import { ResetInterval } from "@models/productModels/intervals/resetInterval.js"; import { ResetInterval } from "@models/productModels/intervals/resetInterval.js";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { ApiFeatureV1Schema } from "../../features/apiFeatureV1.js";
export const ApiBalanceResetSchema = z.object({ export const ApiBalanceResetSchema = z.object({
interval: z.enum(ResetInterval).or(z.literal("multiple")), interval: z.enum(ResetInterval).or(z.literal("multiple")),
@@ -26,7 +26,7 @@ export const ApiBalanceBreakdownSchema = z.object({
export const ApiBalanceSchema = z.object({ export const ApiBalanceSchema = z.object({
feature_id: z.string(), feature_id: z.string(),
feature: ApiFeatureSchema.optional(), feature: ApiFeatureV1Schema.optional(),
unlimited: z.boolean(), unlimited: z.boolean(),
granted_balance: z.number(), 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 { ApiVersion } from "@api/versionUtils/ApiVersion.js";
import { import {
AffectedResource, AffectedResource,
@@ -7,7 +7,9 @@ import {
import { EntInterval } from "@models/productModels/intervals/entitlementInterval.js"; import { EntInterval } from "@models/productModels/intervals/entitlementInterval.js";
import { Decimal } from "decimal.js"; import { Decimal } from "decimal.js";
import type { z } from "zod/v4"; import type { z } from "zod/v4";
import { FeatureType } from "../../../../models/featureModels/featureEnums.js";
import { resetIntvToEntIntv } from "../../../../utils/planFeatureUtils/planFeatureIntervals.js"; import { resetIntvToEntIntv } from "../../../../utils/planFeatureUtils/planFeatureIntervals.js";
import type { ApiFeatureV1 } from "../../../features/apiFeatureV1.js";
import { import {
type ApiBalance, type ApiBalance,
type ApiBalanceBreakdown, type ApiBalanceBreakdown,
@@ -38,14 +40,14 @@ const resetToV3IntervalParams = ({
unlimited, unlimited,
}: { }: {
input: ApiBalance | ApiBalanceBreakdown; input: ApiBalance | ApiBalanceBreakdown;
feature?: ApiFeature; feature?: ApiFeatureV1;
unlimited: boolean; unlimited: boolean;
}): { }): {
interval: EntInterval | "multiple" | null; interval: EntInterval | "multiple" | null;
interval_count: number | null; interval_count: number | null;
next_reset_at: number | null; next_reset_at: number | null;
} => { } => {
const isBoolean = feature?.type === ApiFeatureType.Boolean; const isBoolean = feature?.type === FeatureType.Boolean;
// 1. No reset // 1. No reset
if (!input.reset) if (!input.reset)
@@ -72,10 +74,20 @@ const resetToV3IntervalParams = ({
}; };
}; };
const toV3Type = ({ feature }: { feature?: ApiFeature }) => { const toV3Type = ({ feature }: { feature?: ApiFeatureV1 }) => {
if (feature?.type === ApiFeatureType.Boolean) { if (feature?.type === FeatureType.Boolean) {
return ApiFeatureType.Static; 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 = ({ const toV3BalanceParams = ({
@@ -85,11 +97,11 @@ const toV3BalanceParams = ({
legacyData, legacyData,
}: { }: {
input: ApiBalance | ApiBalanceBreakdown; input: ApiBalance | ApiBalanceBreakdown;
feature?: ApiFeature; feature?: ApiFeatureV1;
unlimited: boolean; unlimited: boolean;
legacyData?: CusFeatureLegacyData; legacyData?: CusFeatureLegacyData;
}) => { }) => {
const isBoolean = feature?.type === ApiFeatureType.Boolean; const isBoolean = feature?.type === FeatureType.Boolean;
if (isBoolean || unlimited) { if (isBoolean || unlimited) {
return { 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 { EntInterval } from "@models/productModels/intervals/entitlementInterval.js";
import { z } from "zod/v4"; import { z } from "zod/v4";

View File

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