feat: created new features API version
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
4
server/src/external/autumn/autumnCli.ts
vendored
4
server/src/external/autumn/autumnCli.ts
vendored
@@ -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);
|
||||||
|
|||||||
4
server/src/external/autumn/autumnCliV2.ts
vendored
4
server/src/external/autumn/autumnCliV2.ts
vendored
@@ -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`, {});
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
* }
|
* }
|
||||||
* });
|
* });
|
||||||
|
|||||||
@@ -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 ✅
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -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 });
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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" });
|
||||||
}
|
// }
|
||||||
};
|
// };
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
18
server/src/internal/features/archives/handleCreateFeature.ts
Normal file
18
server/src/internal/features/archives/handleCreateFeature.ts
Normal 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" });
|
||||||
|
}
|
||||||
|
};
|
||||||
67
server/src/internal/features/archives/handleDeleteFeature.ts
Normal file
67
server/src/internal/features/archives/handleDeleteFeature.ts
Normal 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 });
|
||||||
|
// },
|
||||||
|
// });
|
||||||
32
server/src/internal/features/archives/handleUpdateFeature.ts
Normal file
32
server/src/internal/features/archives/handleUpdateFeature.ts
Normal 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,
|
||||||
|
// );
|
||||||
|
// },
|
||||||
|
// });
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|||||||
42
server/src/internal/features/handlers/handleGetFeature.ts
Normal file
42
server/src/internal/features/handlers/handleGetFeature.ts
Normal 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);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
15
server/src/internal/features/handlers/handleListFeatures.ts
Normal file
15
server/src/internal/features/handlers/handleListFeatures.ts
Normal 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 });
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
|
||||||
// // }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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()),
|
|
||||||
// });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 = "";
|
||||||
|
|||||||
@@ -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}`,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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",
|
|
||||||
// }),
|
|
||||||
// });
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user