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 use `c.req.param()` to get route parameters in Hono handlers**, NOT `c.req.valid("param")`. Example: `const { customer_id } = c.req.param();`
|
||||
|
||||
- When creating "hooks" folders, don't nest them under "components"
|
||||
|
||||
- Functions (unless there's a very good reason) should always take in objects as arguments. Object params are named and easy to understand.
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
- Do not run "npx tsc" - run "tsc" instead as it is installed globally.
|
||||
|
||||
- **ALWAYS use `.meta()` for zod-openapi schema registration**, NOT `.openapi()`. Example: `ApiProductSchema.meta({ id: "Product" })`
|
||||
|
||||
- **ALWAYS use `c.req.param()` to get route parameters in Hono handlers**, NOT `c.req.valid("param")`. Example: `const { customer_id } = c.req.param();`
|
||||
## Error Handling in API Routes
|
||||
- NEVER use `c.json({ message: "...", code: "..." }, statusCode)` pattern for input validation or expected errors in Hono routes
|
||||
- ALWAYS throw `RecaseError` from `@autumn/shared` for all validation errors, not found errors, forbidden errors, etc.
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
initStripe = async () => {
|
||||
await this.post(`/products/all/init_stripe`, {});
|
||||
};
|
||||
|
||||
balances = {
|
||||
update: async (params: BalancesUpdateParams) => {
|
||||
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);
|
||||
};
|
||||
|
||||
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 { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
|
||||
import { expandMiddleware } from "./expandMiddleware.js";
|
||||
import { validator } from "./validatorMiddleware.js";
|
||||
import { versionedValidator } from "./versionedValidator.js";
|
||||
import { expandMiddleware } from "./expandMiddleware.js";
|
||||
|
||||
/**
|
||||
* Extended context type that includes validated input
|
||||
@@ -56,7 +56,7 @@ type VersionedSchemas<T extends ZodType> = Partial<
|
||||
* handler: async (c) => {
|
||||
* const body = c.req.valid("json"); // ✅ Fully typed!
|
||||
* const query = c.req.valid("query"); // ✅ Fully typed!
|
||||
* const params = c.req.valid("param"); // ✅ Fully typed!
|
||||
* const params = c.req.param(); // ✅ Fully typed!
|
||||
* return c.json({ success: true });
|
||||
* }
|
||||
* });
|
||||
|
||||
@@ -100,6 +100,7 @@ export const versionedValidator = ({
|
||||
c.req.addValidatedData(target, validatedData);
|
||||
} else {
|
||||
// For other targets, use zValidator
|
||||
|
||||
const validatorMiddleware = zValidator(target, schema, (result, _c) => {
|
||||
if (!result.success) {
|
||||
// Validation errors reference fields from user's version ✅
|
||||
|
||||
@@ -18,6 +18,7 @@ import { billingRouter } from "./internal/billing/billingRouter.js";
|
||||
import { cusRouter } from "./internal/customers/cusRouter.js";
|
||||
import { internalCusRouter } from "./internal/customers/internalCusRouter.js";
|
||||
import { entityRouter } from "./internal/entities/entityRouter.js";
|
||||
import { featureRouter } from "./internal/features/featureRouter.js";
|
||||
import { handleOAuthCallback } from "./internal/orgs/handlers/stripeHandlers/handleOAuthCallback.js";
|
||||
import { honoOrgRouter } from "./internal/orgs/orgRouter.js";
|
||||
import { platformBetaRouter } from "./internal/platform/platformBeta/platformBetaRouter.js";
|
||||
@@ -120,6 +121,7 @@ export const createHonoApp = () => {
|
||||
app.route("v1/products_beta", honoProductBetaRouter);
|
||||
app.route("v1/products", honoProductRouter);
|
||||
app.route("v1/plans", honoProductRouter);
|
||||
app.route("v1/features", featureRouter);
|
||||
|
||||
app.route("v1", balancesRouter);
|
||||
app.route("v1/platform", platformBetaRouter);
|
||||
|
||||
@@ -7,17 +7,10 @@ import { refreshCacheMiddleware } from "@/middleware/refreshCacheMiddleware.js";
|
||||
import { analyticsRouter } from "../analytics/analyticsRouter.js";
|
||||
import { attachRouter } from "../customers/attach/attachRouter.js";
|
||||
import cancelRouter from "../customers/cancel/cancelRouter.js";
|
||||
import { expressCusRouter } from "../customers/cusRouter.js";
|
||||
import { handleCreateBillingPortal } from "../customers/handlers/handleCreateBillingPortal.js";
|
||||
import { featureRouter } from "../features/featureRouter.js";
|
||||
import { internalFeatureRouter } from "../features/internalFeatureRouter.js";
|
||||
import { handleGetOrg } from "../orgs/handlers/handleGetOrg.js";
|
||||
import { platformRouter } from "../platform/platformLegacy/platformRouter.js";
|
||||
import { productRouter } from "../products/productRouter.js";
|
||||
import { componentRouter } from "./components/componentRouter.js";
|
||||
|
||||
import { usageRouter } from "./events/usageRouter.js";
|
||||
|
||||
import { invoiceRouter } from "./invoiceRouter.js";
|
||||
import { redemptionRouter, referralRouter } from "./rewards/referralRouter.js";
|
||||
import { rewardProgramRouter } from "./rewards/rewardProgramRouter.js";
|
||||
@@ -31,15 +24,9 @@ apiRouter.use(analyticsMiddleware);
|
||||
apiRouter.use(expressApiVersionMiddleware as any);
|
||||
apiRouter.use(refreshCacheMiddleware);
|
||||
|
||||
apiRouter.use("/customers", expressCusRouter);
|
||||
apiRouter.use("/invoices", invoiceRouter);
|
||||
apiRouter.use("/products", productRouter);
|
||||
apiRouter.use("/components", componentRouter);
|
||||
apiRouter.use("/rewards", rewardRouter);
|
||||
apiRouter.use("/features", featureRouter);
|
||||
apiRouter.use("/internal_features", internalFeatureRouter);
|
||||
|
||||
apiRouter.use("/usage", usageRouter);
|
||||
|
||||
// REWARDS
|
||||
apiRouter.use("/reward_programs", rewardProgramRouter);
|
||||
@@ -50,14 +37,6 @@ apiRouter.use("/redemptions", redemptionRouter);
|
||||
apiRouter.use("", attachRouter);
|
||||
apiRouter.use("/cancel", cancelRouter);
|
||||
|
||||
// apiRouter.use("/entitled", checkRouter);
|
||||
// apiRouter.use("/check", checkRouter);
|
||||
// apiRouter.use("/usage", usageRouter);
|
||||
|
||||
// apiRouter.use("/events", eventsRouter);
|
||||
// apiRouter.use("/track", eventsRouter);
|
||||
apiRouter.post("/billing_portal", handleCreateBillingPortal);
|
||||
|
||||
// Analytics
|
||||
apiRouter.use("/query", analyticsRouter);
|
||||
apiRouter.use("/platform", platformRouter);
|
||||
@@ -68,3 +47,7 @@ apiRouter.use("/platform", platformRouter);
|
||||
apiRouter.get("/organization", handleGetOrg);
|
||||
|
||||
export { apiRouter };
|
||||
|
||||
// Features
|
||||
// type: boolean, metered or credit system
|
||||
// resets_periodically: true / false
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.js";
|
||||
import type { ProductV2 } from "@autumn/shared";
|
||||
import { Router } from "express";
|
||||
import { CusService } from "@/internal/customers/CusService.js";
|
||||
import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.js";
|
||||
import { FeatureService } from "@/internal/features/FeatureService.js";
|
||||
import { OrgService } from "@/internal/orgs/OrgService.js";
|
||||
import { toPricecnProduct } from "@/internal/products/pricecn/pricecnUtils.js";
|
||||
import { ProductService } from "@/internal/products/ProductService.js";
|
||||
import { isProductUpgrade } from "@/internal/products/productUtils.js";
|
||||
import { toPricecnProduct } from "@/internal/products/pricecn/pricecnUtils.js";
|
||||
import { getProductResponse } from "@/internal/products/productUtils/productResponseUtils/getProductResponse.js";
|
||||
import { isProductUpgrade } from "@/internal/products/productUtils.js";
|
||||
import { routeHandler } from "@/utils/routerUtils.js";
|
||||
import { ProductV2 } from "@autumn/shared";
|
||||
import { Router } from "express";
|
||||
|
||||
export const componentRouter: Router = Router();
|
||||
|
||||
@@ -19,7 +19,7 @@ componentRouter.get("/pricing_table", async (req: any, res) =>
|
||||
action: "get pricing table",
|
||||
handler: async () => {
|
||||
const { orgId, env, db } = req;
|
||||
let customerId = req.query.customer_id;
|
||||
const customerId = req.query.customer_id;
|
||||
|
||||
const [org, features, products, customer] = await Promise.all([
|
||||
OrgService.getFromReq(req),
|
||||
@@ -45,7 +45,7 @@ componentRouter.get("/pricing_table", async (req: any, res) =>
|
||||
|
||||
// 1. Sort products by price
|
||||
products.sort((a, b) => {
|
||||
let isUpgradeA = isProductUpgrade({
|
||||
const isUpgradeA = isProductUpgrade({
|
||||
prices1: a.prices,
|
||||
prices2: b.prices,
|
||||
usageAlwaysUpgrade: false,
|
||||
@@ -58,13 +58,13 @@ componentRouter.get("/pricing_table", async (req: any, res) =>
|
||||
}
|
||||
});
|
||||
|
||||
let batchResponse = [];
|
||||
for (let p of products) {
|
||||
let prod = await getProductResponse({ product: p, features });
|
||||
const batchResponse = [];
|
||||
for (const p of products) {
|
||||
const prod = await getProductResponse({ product: p, features });
|
||||
let curMainProduct, curScheduledProduct;
|
||||
|
||||
if (customer) {
|
||||
let res = getExistingCusProducts({
|
||||
const res = getExistingCusProducts({
|
||||
product: p,
|
||||
cusProducts: customer.customer_products,
|
||||
});
|
||||
@@ -82,13 +82,13 @@ componentRouter.get("/pricing_table", async (req: any, res) =>
|
||||
features,
|
||||
curMainProduct,
|
||||
curScheduledProduct,
|
||||
otherProducts: products.filter((other) => other.id != p.id),
|
||||
otherProducts: products.filter((other) => other.id !== p.id),
|
||||
fullCus: customer,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
let pricecnProds = await Promise.all(batchResponse);
|
||||
const pricecnProds = await Promise.all(batchResponse);
|
||||
|
||||
// let pricecnProds = await Promise.all(
|
||||
// products
|
||||
|
||||
@@ -80,6 +80,7 @@ export const syncItem = async ({
|
||||
// For sync, we need the raw balance for that specific scope (not merged)
|
||||
let redisEntity: ApiCustomer | ApiEntityV1;
|
||||
|
||||
ctx.skipCache = false;
|
||||
if (entityId) {
|
||||
const { apiEntity } = await getCachedApiEntity({
|
||||
ctx,
|
||||
@@ -97,6 +98,8 @@ export const syncItem = async ({
|
||||
redisEntity = apiCustomer;
|
||||
}
|
||||
|
||||
console.log("Redis entity: ", redisEntity);
|
||||
|
||||
// Get fresh customer from DB (no locking - let deduction handle it)
|
||||
const fullCus = await CusService.getFull({
|
||||
db,
|
||||
|
||||
@@ -180,7 +180,7 @@ export const initCusEntitlement = ({
|
||||
|
||||
if (notNullish(productOptions?.quantity) && notNullish(newBalance)) {
|
||||
newBalance = new Decimal(newBalance!)
|
||||
.mul(productOptions?.quantity!)
|
||||
.mul(productOptions?.quantity || 1)
|
||||
.toNumber();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
|
||||
import { FullCusProduct } from "@autumn/shared";
|
||||
import Stripe from "stripe";
|
||||
import type { FullCusProduct } from "@autumn/shared";
|
||||
import type Stripe from "stripe";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { payForInvoice } from "@/external/stripe/stripeInvoiceUtils.js";
|
||||
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
|
||||
import { insertInvoiceFromAttach } from "@/internal/invoices/invoiceUtils.js";
|
||||
import {
|
||||
createUsageInvoiceItems,
|
||||
resetUsageBalances,
|
||||
} from "./createUsageInvoiceItems.js";
|
||||
import { payForInvoice } from "@/external/stripe/stripeInvoiceUtils.js";
|
||||
import { insertInvoiceFromAttach } from "@/internal/invoices/invoiceUtils.js";
|
||||
|
||||
export const createUsageInvoice = async ({
|
||||
db,
|
||||
|
||||
@@ -25,11 +25,7 @@ import {
|
||||
isFreeProduct,
|
||||
} from "@/internal/products/productUtils.js";
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import {
|
||||
notNullish,
|
||||
notNullOrUndefined,
|
||||
nullOrUndefined,
|
||||
} from "@/utils/genUtils.js";
|
||||
import { notNullish, nullOrUndefined } from "@/utils/genUtils.js";
|
||||
import { handleCheckout } from "./checkout/handleCheckout.js";
|
||||
import { handleAttach } from "./handleAttach.js";
|
||||
import { handleAttachPreview } from "./handleAttachPreview/handleAttachPreview.js";
|
||||
@@ -79,7 +75,7 @@ export const handlePrepaidErrors = async ({
|
||||
}
|
||||
|
||||
// 3. Quantity cannot be negative
|
||||
if (notNullish(options?.quantity) && options?.quantity! < 0) {
|
||||
if (notNullish(options?.quantity) && options?.quantity < 0) {
|
||||
throw new RecaseError({
|
||||
message: `Quantity cannot be negative`,
|
||||
code: ErrCode.InvalidOptions,
|
||||
@@ -249,7 +245,7 @@ export const customerHasPm = async ({
|
||||
stripeId: attachParams.customer.processor?.id,
|
||||
});
|
||||
|
||||
return notNullOrUndefined(paymentMethod) ? true : false;
|
||||
return notNullish(paymentMethod);
|
||||
};
|
||||
|
||||
attachRouter.post("/attach", handleAttach);
|
||||
|
||||
@@ -30,7 +30,7 @@ export const getPricesAndEnts = async ({
|
||||
customer: FullCustomer;
|
||||
products: FullProduct[];
|
||||
}) => {
|
||||
const { options: optionsInput, is_custom, items, free_trial } = attachBody;
|
||||
const { options: optionsInput, is_custom, free_trial } = attachBody;
|
||||
const { features, db, org, logger } = req;
|
||||
|
||||
const { curMainProduct, curSameProduct } = getExistingCusProducts({
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import type { AppEnv, Customer, Organization } from "@autumn/shared";
|
||||
import type Stripe from "stripe";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import {
|
||||
createStripeCusIfNotExists,
|
||||
listCusPaymentMethods,
|
||||
} from "@/external/stripe/stripeCusUtils.js";
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import { AppEnv, Customer, ErrCode, Organization } from "@autumn/shared";
|
||||
import Stripe from "stripe";
|
||||
|
||||
export const getStripeCusData = async ({
|
||||
stripeCli,
|
||||
@@ -28,7 +27,7 @@ export const getStripeCusData = async ({
|
||||
return { stripeCus: undefined, paymentMethod: null, now: undefined };
|
||||
}
|
||||
|
||||
let stripeCus = (await createStripeCusIfNotExists({
|
||||
const stripeCus = (await createStripeCusIfNotExists({
|
||||
db,
|
||||
org,
|
||||
env,
|
||||
@@ -36,16 +35,16 @@ export const getStripeCusData = async ({
|
||||
logger,
|
||||
})) as Stripe.Customer;
|
||||
|
||||
let testClock = stripeCus.test_clock as Stripe.TestHelpers.TestClock | null;
|
||||
const testClock = stripeCus.test_clock as Stripe.TestHelpers.TestClock | null;
|
||||
|
||||
// let now = testClock ? testClock.frozen_time * 1000 : Date.now();
|
||||
let now = testClock ? testClock.frozen_time * 1000 : undefined;
|
||||
const now = testClock ? testClock.frozen_time * 1000 : undefined;
|
||||
|
||||
let paymentMethod = stripeCus.invoice_settings
|
||||
?.default_payment_method as Stripe.PaymentMethod | null;
|
||||
|
||||
if (!paymentMethod) {
|
||||
let paymentMethods = await listCusPaymentMethods({
|
||||
const paymentMethods = await listCusPaymentMethods({
|
||||
stripeCli,
|
||||
stripeId: stripeCus.id,
|
||||
});
|
||||
|
||||
@@ -39,7 +39,7 @@ export const initNextResetAt = ({
|
||||
// 2. If nextResetAt is provided, return it...
|
||||
if (nextResetAt) return nextResetAt;
|
||||
|
||||
// 3. Calculate next reset at...
|
||||
// 3. Get next reset at:
|
||||
let nextResetAtCalculated = null;
|
||||
const trialEndTimestamp = trialEndsAt
|
||||
? Math.round(trialEndsAt / 1000)
|
||||
@@ -47,29 +47,41 @@ export const initNextResetAt = ({
|
||||
? freeTrialToStripeTimestamp({ freeTrial, now })
|
||||
: null;
|
||||
|
||||
if (
|
||||
freeTrial &&
|
||||
applyTrialToEntitlement(entitlement, freeTrial) &&
|
||||
trialEndTimestamp
|
||||
) {
|
||||
const shouldApplyTrial = applyTrialToEntitlement(entitlement, freeTrial);
|
||||
|
||||
// console.log(
|
||||
// "Trial end timestamp: ",
|
||||
// formatUnixToDateTime(trialEndTimestamp! * 1000),
|
||||
// );
|
||||
// console.log("Should apply trial: ", shouldApplyTrial);
|
||||
// console.log("Anchor to unix: ", formatUnixToDateTime(anchorToUnix!));
|
||||
|
||||
if (freeTrial && shouldApplyTrial && trialEndTimestamp) {
|
||||
nextResetAtCalculated = new UTCDate(trialEndTimestamp! * 1000);
|
||||
}
|
||||
|
||||
const resetInterval = entitlement.interval as EntInterval;
|
||||
|
||||
const startDate = nextResetAtCalculated || new UTCDate(now);
|
||||
nextResetAtCalculated = getNextEntitlementReset(
|
||||
nextResetAtCalculated || new UTCDate(now),
|
||||
startDate,
|
||||
resetInterval,
|
||||
entitlement.interval_count || 1,
|
||||
).getTime();
|
||||
|
||||
// console.log(
|
||||
// "Next reset at calculated: ",
|
||||
// formatUnixToDateTime(nextResetAtCalculated),
|
||||
// );
|
||||
|
||||
// If anchorToUnix, align next reset at to anchorToUnix...
|
||||
if (
|
||||
anchorToUnix &&
|
||||
nextResetAtCalculated &&
|
||||
Object.values(BillingInterval).includes(
|
||||
entitlement.interval as unknown as BillingInterval,
|
||||
)
|
||||
) &&
|
||||
!shouldApplyTrial
|
||||
) {
|
||||
nextResetAtCalculated = getAlignedUnix({
|
||||
anchor: anchorToUnix,
|
||||
|
||||
@@ -1,122 +1,30 @@
|
||||
import { ErrCode } from "@autumn/shared";
|
||||
import { Router } from "express";
|
||||
import { Hono } from "hono";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
import { createStripeCli } from "@/external/connect/createStripeCli.js";
|
||||
import { createStripeCusIfNotExists } from "@/external/stripe/stripeCusUtils.js";
|
||||
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
|
||||
import { OrgService } from "@/internal/orgs/OrgService.js";
|
||||
import RecaseError, { handleRequestError } from "@/utils/errorUtils.js";
|
||||
import { toSuccessUrl } from "../orgs/orgUtils/convertOrgUtils.js";
|
||||
import { CusService } from "./CusService.js";
|
||||
import { handleAddCouponToCus } from "./handlers/handleAddCouponToCus.js";
|
||||
import { handleCreateBillingPortal } from "./handlers/handleCreateBillingPortal.js";
|
||||
import { handleDeleteCustomer } from "./handlers/handleDeleteCustomer.js";
|
||||
import { handleAddCouponToCusV2 } from "./handlers/handleAddCouponToCusV2.js";
|
||||
import { handleCreateBillingPortal } from "./handlers/handleBillingPortal/handleCreateBillingPortal.js";
|
||||
import { handleDeleteCustomerV2 } from "./handlers/handleDeleteCustomerV2.js";
|
||||
import { handleGetCustomerV2 } from "./handlers/handleGetCustomerV2.js";
|
||||
import { handleListCustomers } from "./handlers/handleListCustomers.js";
|
||||
import { handlePostCustomer } from "./handlers/handlePostCustomerV2.js";
|
||||
import { handleTransferProduct } from "./handlers/handleTransferProduct.js";
|
||||
import { handleUpdateBalances } from "./handlers/handleUpdateBalances.js";
|
||||
import { handleUpdateCustomer } from "./handlers/handleUpdateCustomer.js";
|
||||
import { handleTransferProductV2 } from "./handlers/handleTransferProductV2.js";
|
||||
import { handleUpdateBalancesV2 } from "./handlers/handleUpdateBalancesV2.js";
|
||||
import { handleUpdateCustomerV2 } from "./handlers/handleUpdateCustomerV2.js";
|
||||
import { handleUpdateEntitlement } from "./handlers/handleUpdateEntitlement.js";
|
||||
|
||||
export const expressCusRouter: Router = Router();
|
||||
|
||||
// expressCusRouter.post("", handlePostCustomerRequest);
|
||||
|
||||
// cusRouter.get("/:customer_id", handleGetCustomer);
|
||||
|
||||
expressCusRouter.delete("/:customer_id", handleDeleteCustomer);
|
||||
|
||||
// Update customer entitlement directly
|
||||
expressCusRouter.post(
|
||||
"/:customer_id/entitlements/:customer_entitlement_id",
|
||||
handleUpdateEntitlement,
|
||||
);
|
||||
|
||||
expressCusRouter.post("/:customer_id/balances", handleUpdateBalances);
|
||||
|
||||
expressCusRouter.get(
|
||||
"/:customer_id/billing_portal",
|
||||
async (req: any, res: any) => {
|
||||
try {
|
||||
const returnUrl = req.query.return_url;
|
||||
const customerId = req.params.customer_id;
|
||||
const [org, customer] = await Promise.all([
|
||||
OrgService.getFromReq(req),
|
||||
CusService.get({
|
||||
db: req.db,
|
||||
idOrInternalId: customerId,
|
||||
orgId: req.orgId,
|
||||
env: req.env,
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!customer) {
|
||||
throw new RecaseError({
|
||||
message: `Customer ${customerId} not found`,
|
||||
code: ErrCode.CustomerNotFound,
|
||||
statusCode: StatusCodes.NOT_FOUND,
|
||||
});
|
||||
}
|
||||
|
||||
const stripeCli = createStripeCli({ org, env: req.env });
|
||||
|
||||
let stripeCusId: string = customer.processor?.id;
|
||||
if (!customer.processor?.id) {
|
||||
const newCus = await createStripeCusIfNotExists({
|
||||
db: req.db,
|
||||
org,
|
||||
env: req.env,
|
||||
customer,
|
||||
logger: req.logger,
|
||||
});
|
||||
|
||||
if (!newCus) {
|
||||
throw new RecaseError({
|
||||
message: `Failed to create Stripe customer`,
|
||||
code: ErrCode.StripeError,
|
||||
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
|
||||
});
|
||||
}
|
||||
|
||||
stripeCusId = newCus.id;
|
||||
}
|
||||
|
||||
const portal = await stripeCli.billingPortal.sessions.create({
|
||||
customer: stripeCusId,
|
||||
return_url: returnUrl || toSuccessUrl({ org, env: req.env }),
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
customer_id: customer.id || null,
|
||||
url: portal.url,
|
||||
});
|
||||
// if (org.api_version >= LegacyVersion.v1_1) {
|
||||
// } else {
|
||||
// res.status(200).json({
|
||||
// url: portal.url,
|
||||
// });
|
||||
// }
|
||||
} catch (error) {
|
||||
handleRequestError({ req, error, res, action: "get billing portal" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
expressCusRouter.post(
|
||||
"/:customer_id/billing_portal",
|
||||
handleCreateBillingPortal,
|
||||
);
|
||||
|
||||
expressCusRouter.post("/:customer_id/coupons/:coupon_id", handleAddCouponToCus);
|
||||
|
||||
expressCusRouter.post("/:customer_id/transfer", handleTransferProduct);
|
||||
|
||||
export const cusRouter = new Hono<HonoEnv>();
|
||||
|
||||
cusRouter.get("", ...handleListCustomers);
|
||||
cusRouter.get("/:customer_id", ...handleGetCustomerV2);
|
||||
cusRouter.post("", ...handlePostCustomer);
|
||||
cusRouter.get("/:customer_id", ...handleGetCustomerV2);
|
||||
cusRouter.post("/:customer_id", ...handleUpdateCustomerV2);
|
||||
cusRouter.patch("/:customer_id", ...handleUpdateCustomerV2);
|
||||
cusRouter.delete("/:customer_id", ...handleDeleteCustomerV2);
|
||||
|
||||
cusRouter.post("/:customer_id/coupons/:coupon_id", ...handleAddCouponToCusV2);
|
||||
cusRouter.post("/:customer_id/transfer", ...handleTransferProductV2);
|
||||
|
||||
// Billing portal
|
||||
cusRouter.post("/:customer_id/billing_portal", ...handleCreateBillingPortal);
|
||||
cusRouter.get("/:customer_id/billing_portal", ...handleCreateBillingPortal);
|
||||
|
||||
// Legacy...
|
||||
cusRouter.post("/:customer_id/balances", ...handleUpdateBalancesV2);
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
type ApiBalance,
|
||||
type ApiBalanceReset,
|
||||
type ApiBalanceRollover,
|
||||
type ApiFeature,
|
||||
type ApiFeatureV1,
|
||||
entIntvToResetIntv,
|
||||
type Feature,
|
||||
type FullCusEntWithFullCusProduct,
|
||||
@@ -94,7 +94,7 @@ export const getBooleanApiBalance = ({
|
||||
apiFeature,
|
||||
}: {
|
||||
cusEnts: FullCusEntWithFullCusProduct[];
|
||||
apiFeature?: ApiFeature;
|
||||
apiFeature?: ApiFeatureV1;
|
||||
}): ApiBalance => {
|
||||
const feature = cusEnts[0].entitlement.feature;
|
||||
return {
|
||||
@@ -121,7 +121,7 @@ export const getUnlimitedApiBalance = ({
|
||||
apiFeature,
|
||||
cusEnts,
|
||||
}: {
|
||||
apiFeature?: ApiFeature;
|
||||
apiFeature?: ApiFeatureV1;
|
||||
cusEnts: FullCusEntWithFullCusProduct[];
|
||||
}): ApiBalance => {
|
||||
const feature = cusEnts[0].entitlement.feature;
|
||||
@@ -150,7 +150,7 @@ export const getNoCusEntsApiBalance = ({
|
||||
apiFeature,
|
||||
featureId,
|
||||
}: {
|
||||
apiFeature?: ApiFeature;
|
||||
apiFeature?: ApiFeatureV1;
|
||||
featureId: string;
|
||||
}): ApiBalance => {
|
||||
return {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
cusEntToGrantedBalance,
|
||||
cusEntToKey,
|
||||
cusEntToPurchasedBalance,
|
||||
dbToApiFeatureV1,
|
||||
expandIncludes,
|
||||
type Feature,
|
||||
FeatureType,
|
||||
@@ -23,7 +24,6 @@ import {
|
||||
isPrepaidPrice,
|
||||
notNullish,
|
||||
sumValues,
|
||||
toApiFeature,
|
||||
} from "@autumn/shared";
|
||||
import { Decimal } from "decimal.js";
|
||||
import type { RequestContext } from "@/honoUtils/HonoEnv.js";
|
||||
@@ -139,7 +139,7 @@ export const getApiBalance = ({
|
||||
expand: ctx.expand,
|
||||
includes: [CheckExpand.BalanceFeature, CusExpand.BalancesFeature],
|
||||
})
|
||||
? toApiFeature({ feature })
|
||||
? dbToApiFeatureV1({ dbFeature: feature })
|
||||
: undefined;
|
||||
|
||||
// 1. If feature is boolean
|
||||
|
||||
@@ -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);
|
||||
|
||||
console.log("Create default products:", createDefaultProducts);
|
||||
|
||||
// 1. If no ID and email is not NULL
|
||||
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,
|
||||
} from "@autumn/shared";
|
||||
import { createStripeCli } from "@/external/connect/createStripeCli.js";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { getEntOptions } from "@/internal/products/prices/priceUtils.js";
|
||||
import { attachToInsertParams } from "@/internal/products/productUtils.js";
|
||||
import type { ExtendedRequest } from "@/utils/models/Request.js";
|
||||
import { createFullCusProduct } from "../../add-product/createFullCusProduct.js";
|
||||
import { CusProductService } from "../../cusProducts/CusProductService.js";
|
||||
import { CusEntService } from "../../cusProducts/cusEnts/CusEntitlementService.js";
|
||||
import { getRelatedCusPrice } from "../../cusProducts/cusEnts/cusEntUtils.js";
|
||||
|
||||
export const handleDecreaseAndTransfer = async ({
|
||||
req,
|
||||
ctx,
|
||||
fullCus,
|
||||
cusProduct,
|
||||
toEntity,
|
||||
}: {
|
||||
req: ExtendedRequest;
|
||||
ctx: AutumnContext;
|
||||
fullCus: FullCustomer;
|
||||
cusProduct: FullCusProduct;
|
||||
toEntity: Entity;
|
||||
}) => {
|
||||
// 1. Create new cus product for entity...
|
||||
const { org, env } = req;
|
||||
const { org, env, db, logger, features } = ctx;
|
||||
const stripeCli = createStripeCli({ org, env });
|
||||
const product = cusProductToProduct({ cusProduct });
|
||||
|
||||
@@ -50,7 +50,7 @@ export const handleDecreaseAndTransfer = async ({
|
||||
|
||||
batchDecrement.push(
|
||||
CusEntService.decrement({
|
||||
db: req.db,
|
||||
db,
|
||||
id: cusEnt.id,
|
||||
amount: resetBalance,
|
||||
}),
|
||||
@@ -60,37 +60,36 @@ export const handleDecreaseAndTransfer = async ({
|
||||
await Promise.all(batchDecrement);
|
||||
|
||||
await CusProductService.update({
|
||||
db: req.db,
|
||||
db,
|
||||
cusProductId: cusProduct.id,
|
||||
updates: {
|
||||
quantity: cusProduct.quantity - 1,
|
||||
},
|
||||
});
|
||||
|
||||
const newCusProduct = await createFullCusProduct({
|
||||
db: req.db,
|
||||
logger: req.logger,
|
||||
await createFullCusProduct({
|
||||
db,
|
||||
logger,
|
||||
trialEndsAt: cusProduct.trial_ends_at || undefined,
|
||||
subscriptionIds: cusProduct.subscription_ids || [],
|
||||
attachParams: attachToInsertParams(
|
||||
{
|
||||
req,
|
||||
req: ctx as any, // Pass ctx as req for now (AttachParams still uses req)
|
||||
customer: fullCus,
|
||||
products: [product],
|
||||
prices: product.prices,
|
||||
entitlements: product.entitlements,
|
||||
org: req.org,
|
||||
org,
|
||||
stripeCli: stripeCli,
|
||||
paymentMethod: null,
|
||||
freeTrial: cusProduct.free_trial || null,
|
||||
optionsList: cusProduct.options,
|
||||
scenario: AttachScenario.New,
|
||||
// scenario: AttachScenario.New,
|
||||
|
||||
cusProducts: fullCus.customer_products,
|
||||
replaceables: [],
|
||||
entities: fullCus.entities,
|
||||
features: req.features,
|
||||
features,
|
||||
internalEntityId: toEntity.internal_id,
|
||||
entityId: toEntity.id,
|
||||
},
|
||||
|
||||
@@ -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 { Decimal } from "decimal.js";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
import { CusService } from "@/internal/customers/CusService.js";
|
||||
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
|
||||
import { getUnlimitedAndUsageAllowed } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js";
|
||||
import { FeatureService } from "@/internal/features/FeatureService.js";
|
||||
import { OrgService } from "@/internal/orgs/OrgService.js";
|
||||
import {
|
||||
deductAllowanceFromCusEnt,
|
||||
deductFromUsageBasedCusEnt,
|
||||
} from "@/trigger/updateBalanceTask.js";
|
||||
import RecaseError, { handleRequestError } from "@/utils/errorUtils.js";
|
||||
import { notNullish } from "@/utils/genUtils.js";
|
||||
import { getCusEntsInFeatures } from "../cusUtils/cusUtils.js";
|
||||
// import { ErrCode, getCusEntBalance } from "@autumn/shared";
|
||||
// import { Decimal } from "decimal.js";
|
||||
// import { StatusCodes } from "http-status-codes";
|
||||
// import { CusService } from "@/internal/customers/CusService.js";
|
||||
// import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
|
||||
// import { getUnlimitedAndUsageAllowed } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js";
|
||||
// import { FeatureService } from "@/internal/features/FeatureService.js";
|
||||
// import { OrgService } from "@/internal/orgs/OrgService.js";
|
||||
// import {
|
||||
// deductAllowanceFromCusEnt,
|
||||
// deductFromUsageBasedCusEnt,
|
||||
// } from "@/trigger/updateBalanceTask.js";
|
||||
// import RecaseError, { handleRequestError } from "@/utils/errorUtils.js";
|
||||
// import { notNullish } from "@/utils/genUtils.js";
|
||||
// import { getCusEntsInFeatures } from "../cusUtils/cusUtils.js";
|
||||
|
||||
const getCusFeaturesAndOrg = async (req: any, customerId: string) => {
|
||||
// 1. Get customer
|
||||
const [customer, features, org] = await Promise.all([
|
||||
CusService.getFull({
|
||||
db: req.db,
|
||||
idOrInternalId: customerId,
|
||||
orgId: req.orgId,
|
||||
env: req.env,
|
||||
entityId: req.params.entity_id,
|
||||
}),
|
||||
FeatureService.getFromReq(req),
|
||||
OrgService.getFromReq(req),
|
||||
]);
|
||||
// const getCusFeaturesAndOrg = async (req: any, customerId: string) => {
|
||||
// // 1. Get customer
|
||||
// const [customer, features, org] = await Promise.all([
|
||||
// CusService.getFull({
|
||||
// db: req.db,
|
||||
// idOrInternalId: customerId,
|
||||
// orgId: req.orgId,
|
||||
// env: req.env,
|
||||
// entityId: req.params.entity_id,
|
||||
// }),
|
||||
// FeatureService.getFromReq(req),
|
||||
// OrgService.getFromReq(req),
|
||||
// ]);
|
||||
|
||||
if (!customer) {
|
||||
throw new RecaseError({
|
||||
message: `Customer ${customerId} not found`,
|
||||
code: ErrCode.CustomerNotFound,
|
||||
statusCode: StatusCodes.NOT_FOUND,
|
||||
});
|
||||
}
|
||||
// if (!customer) {
|
||||
// throw new RecaseError({
|
||||
// message: `Customer ${customerId} not found`,
|
||||
// code: ErrCode.CustomerNotFound,
|
||||
// statusCode: StatusCodes.NOT_FOUND,
|
||||
// });
|
||||
// }
|
||||
|
||||
return { customer, features, org };
|
||||
};
|
||||
// return { customer, features, org };
|
||||
// };
|
||||
|
||||
export const handleUpdateBalances = async (req: any, res: any) => {
|
||||
try {
|
||||
const logger = req.logger;
|
||||
const cusId = req.params.customer_id;
|
||||
const { env, db, features } = req;
|
||||
const { balances } = req.body;
|
||||
// export const handleUpdateBalances = async (req: any, res: any) => {
|
||||
// try {
|
||||
// const logger = req.logger;
|
||||
// const cusId = req.params.customer_id;
|
||||
// const { env, db, features } = req;
|
||||
// const { balances } = req.body;
|
||||
|
||||
if (!Array.isArray(balances)) {
|
||||
throw new RecaseError({
|
||||
message: "Balances must be an array",
|
||||
code: ErrCode.InvalidRequest,
|
||||
statusCode: StatusCodes.BAD_REQUEST,
|
||||
});
|
||||
}
|
||||
// if (!Array.isArray(balances)) {
|
||||
// throw new RecaseError({
|
||||
// message: "Balances must be an array",
|
||||
// code: ErrCode.InvalidRequest,
|
||||
// statusCode: StatusCodes.BAD_REQUEST,
|
||||
// });
|
||||
// }
|
||||
|
||||
const { customer, org } = await getCusFeaturesAndOrg(req, cusId);
|
||||
// const { customer, org } = await getCusFeaturesAndOrg(req, cusId);
|
||||
|
||||
const featuresToUpdate = features.filter((f: any) =>
|
||||
balances.map((b: any) => b.feature_id).includes(f.id),
|
||||
);
|
||||
// const featuresToUpdate = features.filter((f: any) =>
|
||||
// balances.map((b: any) => b.feature_id).includes(f.id),
|
||||
// );
|
||||
|
||||
if (featuresToUpdate.length === 0) {
|
||||
throw new RecaseError({
|
||||
message: "No valid features found to update",
|
||||
code: ErrCode.InvalidRequest,
|
||||
statusCode: StatusCodes.BAD_REQUEST,
|
||||
});
|
||||
}
|
||||
// if (featuresToUpdate.length === 0) {
|
||||
// throw new RecaseError({
|
||||
// message: "No valid features found to update",
|
||||
// code: ErrCode.InvalidRequest,
|
||||
// statusCode: StatusCodes.BAD_REQUEST,
|
||||
// });
|
||||
// }
|
||||
|
||||
const { cusEnts, cusPrices } = await getCusEntsInFeatures({
|
||||
customer,
|
||||
internalFeatureIds: featuresToUpdate.map((f: any) => f.internal_id!),
|
||||
logger: req.logger,
|
||||
});
|
||||
// const { cusEnts, cusPrices } = await getCusEntsInFeatures({
|
||||
// customer,
|
||||
// internalFeatureIds: featuresToUpdate.map((f: any) => f.internal_id!),
|
||||
// logger: req.logger,
|
||||
// });
|
||||
|
||||
logger.info("--------------------------------");
|
||||
logger.info(
|
||||
`REQUEST: UPDATE BALANCES FOR CUSTOMER ${customer.id}, ORG: ${org.slug}`,
|
||||
);
|
||||
logger.info(
|
||||
`Features to update: ${balances.map(
|
||||
(b: any) =>
|
||||
`${b.feature_id} - ${b.unlimited ? "unlimited" : b.balance}`,
|
||||
)}`,
|
||||
);
|
||||
// logger.info("--------------------------------");
|
||||
// logger.info(
|
||||
// `REQUEST: UPDATE BALANCES FOR CUSTOMER ${customer.id}, ORG: ${org.slug}`,
|
||||
// );
|
||||
// logger.info(
|
||||
// `Features to update: ${balances.map(
|
||||
// (b: any) =>
|
||||
// `${b.feature_id} - ${b.unlimited ? "unlimited" : b.balance}`,
|
||||
// )}`,
|
||||
// );
|
||||
|
||||
// Get deductions for each feature
|
||||
const featureDeductions = [];
|
||||
for (const balance of balances) {
|
||||
if (!balance.feature_id) {
|
||||
throw new RecaseError({
|
||||
message: "Feature ID is required",
|
||||
code: ErrCode.InvalidRequest,
|
||||
statusCode: StatusCodes.BAD_REQUEST,
|
||||
});
|
||||
}
|
||||
// // Get deductions for each feature
|
||||
// const featureDeductions = [];
|
||||
// for (const balance of balances) {
|
||||
// if (!balance.feature_id) {
|
||||
// throw new RecaseError({
|
||||
// message: "Feature ID is required",
|
||||
// code: ErrCode.InvalidRequest,
|
||||
// statusCode: StatusCodes.BAD_REQUEST,
|
||||
// });
|
||||
// }
|
||||
|
||||
if (typeof balance.balance !== "number" && balance.unlimited !== true) {
|
||||
throw new RecaseError({
|
||||
message: "Balance must be a number",
|
||||
code: ErrCode.InvalidRequest,
|
||||
statusCode: StatusCodes.BAD_REQUEST,
|
||||
});
|
||||
}
|
||||
// if (typeof balance.balance !== "number" && balance.unlimited !== true) {
|
||||
// throw new RecaseError({
|
||||
// message: "Balance must be a number",
|
||||
// code: ErrCode.InvalidRequest,
|
||||
// statusCode: StatusCodes.BAD_REQUEST,
|
||||
// });
|
||||
// }
|
||||
|
||||
const feature = featuresToUpdate.find(
|
||||
(f: any) => f.id === balance.feature_id,
|
||||
);
|
||||
// const feature = featuresToUpdate.find(
|
||||
// (f: any) => f.id === balance.feature_id,
|
||||
// );
|
||||
|
||||
if (balance.unlimited === true) {
|
||||
featureDeductions.push({
|
||||
feature,
|
||||
unlimited: true,
|
||||
toDeduct: 0,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// if (balance.unlimited === true) {
|
||||
// featureDeductions.push({
|
||||
// feature,
|
||||
// unlimited: true,
|
||||
// toDeduct: 0,
|
||||
// });
|
||||
// continue;
|
||||
// }
|
||||
|
||||
const { unlimited } = getUnlimitedAndUsageAllowed({
|
||||
cusEnts,
|
||||
internalFeatureId: feature!.internal_id!,
|
||||
});
|
||||
// const { unlimited } = getUnlimitedAndUsageAllowed({
|
||||
// cusEnts,
|
||||
// internalFeatureId: feature!.internal_id!,
|
||||
// });
|
||||
|
||||
if (unlimited) {
|
||||
throw new RecaseError({
|
||||
message: `Can't set balance for unlimited feature: ${feature!.id}`,
|
||||
code: ErrCode.InvalidRequest,
|
||||
statusCode: StatusCodes.BAD_REQUEST,
|
||||
});
|
||||
}
|
||||
// if (unlimited) {
|
||||
// throw new RecaseError({
|
||||
// message: `Can't set balance for unlimited feature: ${feature!.id}`,
|
||||
// code: ErrCode.InvalidRequest,
|
||||
// statusCode: StatusCodes.BAD_REQUEST,
|
||||
// });
|
||||
// }
|
||||
|
||||
// Get deductions
|
||||
const newBalance = balance.balance;
|
||||
let curBalance = new Decimal(0);
|
||||
const properties = structuredClone(balance);
|
||||
delete properties.feature_id;
|
||||
delete properties.balance;
|
||||
// // Get deductions
|
||||
// const newBalance = balance.balance;
|
||||
// let curBalance = new Decimal(0);
|
||||
// const properties = structuredClone(balance);
|
||||
// delete properties.feature_id;
|
||||
// delete properties.balance;
|
||||
|
||||
for (const cusEnt of cusEnts) {
|
||||
const cusEntIntCount = cusEnt.entitlement.interval_count || 1;
|
||||
const deductionIntCount = balance.interval_count || 1;
|
||||
// for (const cusEnt of cusEnts) {
|
||||
// const cusEntIntCount = cusEnt.entitlement.interval_count || 1;
|
||||
// const deductionIntCount = balance.interval_count || 1;
|
||||
|
||||
const intCountMatch = notNullish(balance.interval_count)
|
||||
? cusEntIntCount === deductionIntCount
|
||||
: true;
|
||||
// const intCountMatch = notNullish(balance.interval_count)
|
||||
// ? cusEntIntCount === deductionIntCount
|
||||
// : true;
|
||||
|
||||
const intMatch = notNullish(balance.interval)
|
||||
? balance.interval === cusEnt.entitlement.interval
|
||||
: true;
|
||||
// const intMatch = notNullish(balance.interval)
|
||||
// ? balance.interval === cusEnt.entitlement.interval
|
||||
// : true;
|
||||
|
||||
if (
|
||||
cusEnt.internal_feature_id !== feature!.internal_id! ||
|
||||
!intMatch ||
|
||||
!intCountMatch
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
// if (
|
||||
// cusEnt.internal_feature_id !== feature!.internal_id! ||
|
||||
// !intMatch ||
|
||||
// !intCountMatch
|
||||
// ) {
|
||||
// continue;
|
||||
// }
|
||||
|
||||
const { balance: cusEntBalance } = getCusEntBalance({
|
||||
cusEnt,
|
||||
entityId: balance.entity_id,
|
||||
});
|
||||
// const { balance: cusEntBalance } = getCusEntBalance({
|
||||
// cusEnt,
|
||||
// entityId: balance.entity_id,
|
||||
// });
|
||||
|
||||
curBalance = curBalance.add(new Decimal(cusEntBalance!));
|
||||
}
|
||||
// curBalance = curBalance.add(new Decimal(cusEntBalance!));
|
||||
// }
|
||||
|
||||
const toDeduct = curBalance.sub(newBalance).toNumber();
|
||||
// const toDeduct = curBalance.sub(newBalance).toNumber();
|
||||
|
||||
if (toDeduct === 0) {
|
||||
logger.info(`Skipping ${feature!.id} -- no change`);
|
||||
}
|
||||
// if (toDeduct === 0) {
|
||||
// logger.info(`Skipping ${feature!.id} -- no change`);
|
||||
// }
|
||||
|
||||
featureDeductions.push({
|
||||
feature,
|
||||
toDeduct,
|
||||
properties,
|
||||
interval: balance.interval,
|
||||
intervalCount: balance.interval_count,
|
||||
});
|
||||
}
|
||||
// featureDeductions.push({
|
||||
// feature,
|
||||
// toDeduct,
|
||||
// properties,
|
||||
// interval: balance.interval,
|
||||
// intervalCount: balance.interval_count,
|
||||
// });
|
||||
// }
|
||||
|
||||
const batchDeduct = [];
|
||||
// const batchDeduct = [];
|
||||
|
||||
for (const featureDeduction of featureDeductions) {
|
||||
// 1. Deduct from allowance
|
||||
const performDeduction = async () => {
|
||||
let { toDeduct, feature, properties, interval } = featureDeduction;
|
||||
// for (const featureDeduction of featureDeductions) {
|
||||
// // 1. Deduct from allowance
|
||||
// const performDeduction = async () => {
|
||||
// let { toDeduct, feature, properties, interval } = featureDeduction;
|
||||
|
||||
// Handle unlimited
|
||||
if (featureDeduction.unlimited) {
|
||||
// Get one active cusEnt and set unlimited to true
|
||||
// // Handle unlimited
|
||||
// if (featureDeduction.unlimited) {
|
||||
// // Get one active cusEnt and set unlimited to true
|
||||
|
||||
const cusEnt = notNullish(interval)
|
||||
? cusEnts.find((cusEnt) => {
|
||||
const cusEntIntCount = cusEnt.entitlement.interval_count || 1;
|
||||
const deductionIntCount = featureDeduction.intervalCount || 1;
|
||||
// const cusEnt = notNullish(interval)
|
||||
// ? cusEnts.find((cusEnt) => {
|
||||
// const cusEntIntCount = cusEnt.entitlement.interval_count || 1;
|
||||
// const deductionIntCount = featureDeduction.intervalCount || 1;
|
||||
|
||||
return (
|
||||
cusEnt.internal_feature_id === feature!.internal_id! &&
|
||||
cusEnt.entitlement.interval === interval &&
|
||||
cusEntIntCount === deductionIntCount
|
||||
);
|
||||
})
|
||||
: cusEnts.find(
|
||||
(cusEnt) =>
|
||||
cusEnt.internal_feature_id === feature!.internal_id!,
|
||||
);
|
||||
// return (
|
||||
// cusEnt.internal_feature_id === feature!.internal_id! &&
|
||||
// cusEnt.entitlement.interval === interval &&
|
||||
// cusEntIntCount === deductionIntCount
|
||||
// );
|
||||
// })
|
||||
// : cusEnts.find(
|
||||
// (cusEnt) =>
|
||||
// cusEnt.internal_feature_id === feature!.internal_id!,
|
||||
// );
|
||||
|
||||
if (!cusEnt) {
|
||||
logger.warn(
|
||||
`No active cus ent to set unlimited balance for feature: ${
|
||||
feature!.id
|
||||
}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// if (!cusEnt) {
|
||||
// logger.warn(
|
||||
// `No active cus ent to set unlimited balance for feature: ${
|
||||
// feature!.id
|
||||
// }`,
|
||||
// );
|
||||
// return;
|
||||
// }
|
||||
|
||||
await CusEntService.update({
|
||||
db,
|
||||
id: cusEnt.id,
|
||||
updates: {
|
||||
unlimited: true,
|
||||
next_reset_at: null,
|
||||
},
|
||||
});
|
||||
// await CusEntService.update({
|
||||
// db,
|
||||
// id: cusEnt.id,
|
||||
// updates: {
|
||||
// unlimited: true,
|
||||
// next_reset_at: null,
|
||||
// },
|
||||
// });
|
||||
|
||||
return;
|
||||
}
|
||||
// return;
|
||||
// }
|
||||
|
||||
for (const cusEnt of cusEnts) {
|
||||
const cusEntIntCount = cusEnt.entitlement.interval_count || 1;
|
||||
const deductionIntCount = featureDeduction.intervalCount || 1;
|
||||
// for (const cusEnt of cusEnts) {
|
||||
// const cusEntIntCount = cusEnt.entitlement.interval_count || 1;
|
||||
// const deductionIntCount = featureDeduction.intervalCount || 1;
|
||||
|
||||
const intCountMatch = notNullish(featureDeduction.intervalCount)
|
||||
? cusEntIntCount === deductionIntCount
|
||||
: true;
|
||||
// const intCountMatch = notNullish(featureDeduction.intervalCount)
|
||||
// ? cusEntIntCount === deductionIntCount
|
||||
// : true;
|
||||
|
||||
const intMatch = notNullish(featureDeduction.interval)
|
||||
? featureDeduction.interval === cusEnt.entitlement.interval
|
||||
: true;
|
||||
// const intMatch = notNullish(featureDeduction.interval)
|
||||
// ? featureDeduction.interval === cusEnt.entitlement.interval
|
||||
// : true;
|
||||
|
||||
if (
|
||||
cusEnt.internal_feature_id !==
|
||||
featureDeduction.feature!.internal_id! ||
|
||||
!intMatch ||
|
||||
!intCountMatch
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
// if (
|
||||
// cusEnt.internal_feature_id !==
|
||||
// featureDeduction.feature!.internal_id! ||
|
||||
// !intMatch ||
|
||||
// !intCountMatch
|
||||
// ) {
|
||||
// continue;
|
||||
// }
|
||||
|
||||
toDeduct = await deductAllowanceFromCusEnt({
|
||||
toDeduct,
|
||||
deductParams: {
|
||||
db,
|
||||
feature: featureDeduction.feature!,
|
||||
env: req.env,
|
||||
org,
|
||||
cusPrices: cusPrices as any[],
|
||||
customer,
|
||||
},
|
||||
cusEnt,
|
||||
featureDeductions: [], // not important because not deducting credits
|
||||
willDeductCredits: false,
|
||||
});
|
||||
}
|
||||
// toDeduct = await deductAllowanceFromCusEnt({
|
||||
// toDeduct,
|
||||
// deductParams: {
|
||||
// db,
|
||||
// feature: featureDeduction.feature!,
|
||||
// env: req.env,
|
||||
// org,
|
||||
// cusPrices: cusPrices as any[],
|
||||
// customer,
|
||||
// },
|
||||
// cusEnt,
|
||||
// featureDeductions: [], // not important because not deducting credits
|
||||
// willDeductCredits: false,
|
||||
// });
|
||||
// }
|
||||
|
||||
if (toDeduct === 0) {
|
||||
return;
|
||||
}
|
||||
// if (toDeduct === 0) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
await deductFromUsageBasedCusEnt({
|
||||
toDeduct,
|
||||
cusEnts,
|
||||
deductParams: {
|
||||
db,
|
||||
feature: featureDeduction.feature!,
|
||||
env,
|
||||
org,
|
||||
cusPrices: cusPrices as any[],
|
||||
customer,
|
||||
},
|
||||
});
|
||||
};
|
||||
batchDeduct.push(performDeduction());
|
||||
}
|
||||
await Promise.all(batchDeduct);
|
||||
// await deductFromUsageBasedCusEnt({
|
||||
// toDeduct,
|
||||
// cusEnts,
|
||||
// deductParams: {
|
||||
// db,
|
||||
// feature: featureDeduction.feature!,
|
||||
// env,
|
||||
// org,
|
||||
// cusPrices: cusPrices as any[],
|
||||
// customer,
|
||||
// },
|
||||
// });
|
||||
// };
|
||||
// batchDeduct.push(performDeduction());
|
||||
// }
|
||||
// await Promise.all(batchDeduct);
|
||||
|
||||
logger.info(" ✅ Successfully updated balances");
|
||||
// logger.info(" ✅ Successfully updated balances");
|
||||
|
||||
res.status(200).json({ success: true });
|
||||
} catch (error) {
|
||||
handleRequestError({ req, error, res, action: "update customer balances" });
|
||||
}
|
||||
};
|
||||
// res.status(200).json({ success: true });
|
||||
// } catch (error) {
|
||||
// handleRequestError({ req, error, res, action: "update customer balances" });
|
||||
// }
|
||||
// };
|
||||
|
||||
@@ -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 {
|
||||
AffectedResource,
|
||||
ApiVersion,
|
||||
ErrCode,
|
||||
CustomerAlreadyExistsError,
|
||||
CustomerNotFoundError,
|
||||
GetCustomerQuerySchema,
|
||||
ProcessorType,
|
||||
RecaseError,
|
||||
UpdateCustomerParamsSchema,
|
||||
} from "@autumn/shared";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
import { z } from "zod/v4";
|
||||
import { createStripeCli } from "@/external/connect/createStripeCli.js";
|
||||
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
|
||||
import { notNullish } from "@/utils/genUtils.js";
|
||||
import { CusService } from "../CusService.js";
|
||||
import { getApiCustomer } from "../cusUtils/apiCusUtils/getApiCustomer.js";
|
||||
|
||||
export const handleUpdateCustomerV2 = createRoute({
|
||||
params: z.object({
|
||||
customer_id: z.string(),
|
||||
}),
|
||||
body: UpdateCustomerParamsSchema,
|
||||
versionedQuery: {
|
||||
latest: GetCustomerQuerySchema,
|
||||
@@ -28,9 +25,8 @@ export const handleUpdateCustomerV2 = createRoute({
|
||||
handler: async (c) => {
|
||||
const ctx = c.get("ctx");
|
||||
const { db, org, env, logger } = ctx;
|
||||
const { customer_id } = c.req.valid("param");
|
||||
const { customer_id } = c.req.param();
|
||||
const newCusData = c.req.valid("json");
|
||||
const { expand } = ctx;
|
||||
|
||||
const originalCustomer = await CusService.get({
|
||||
db,
|
||||
@@ -40,18 +36,12 @@ export const handleUpdateCustomerV2 = createRoute({
|
||||
});
|
||||
|
||||
if (!originalCustomer) {
|
||||
throw new RecaseError({
|
||||
message: `Update customer: Customer ${customer_id} not found`,
|
||||
code: ErrCode.CustomerNotFound,
|
||||
statusCode: StatusCodes.NOT_FOUND,
|
||||
});
|
||||
throw new CustomerNotFoundError({ customerId: customer_id });
|
||||
}
|
||||
|
||||
if (newCusData.id === null) {
|
||||
throw new RecaseError({
|
||||
message: `Update customer: Can't change customer ID to null`,
|
||||
code: ErrCode.InvalidUpdateCustomerParams,
|
||||
statusCode: StatusCodes.BAD_REQUEST,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -65,10 +55,9 @@ export const handleUpdateCustomerV2 = createRoute({
|
||||
});
|
||||
|
||||
if (existingCustomer) {
|
||||
throw new RecaseError({
|
||||
message: `Update customer: Customer ${newCusData.id} already exists, can't change to this ID`,
|
||||
code: ErrCode.DuplicateCustomerId,
|
||||
statusCode: StatusCodes.CONFLICT,
|
||||
throw new CustomerAlreadyExistsError({
|
||||
message: `Customer with ID ${newCusData.id} already exists, can't change to this ID`,
|
||||
customerId: newCusData.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -138,20 +127,12 @@ export const handleUpdateCustomerV2 = createRoute({
|
||||
update: updateData,
|
||||
});
|
||||
|
||||
const finalCustomer = await CusService.getFull({
|
||||
db,
|
||||
idOrInternalId: originalCustomer.internal_id,
|
||||
orgId: org.id,
|
||||
env,
|
||||
withEntities: true,
|
||||
});
|
||||
|
||||
ctx.skipCache = true;
|
||||
const customerDetails = await getApiCustomer({
|
||||
ctx,
|
||||
fullCus: finalCustomer,
|
||||
customerId: customer_id,
|
||||
});
|
||||
|
||||
return c.json(customerDetails);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
import { Router } from "express";
|
||||
import { Hono } from "hono";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
import { z } from "zod/v4";
|
||||
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
|
||||
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
|
||||
import RecaseError, { handleFrontendReqError } from "@/utils/errorUtils.js";
|
||||
@@ -192,10 +191,9 @@ cusRouter.get(
|
||||
export const internalCusRouter = new Hono<HonoEnv>();
|
||||
|
||||
export const handleGetCustomerInternal = createRoute({
|
||||
params: z.object({ customer_id: z.string() }),
|
||||
handler: async (c) => {
|
||||
const { db, org, env } = c.get("ctx");
|
||||
const { customer_id } = c.req.valid("param");
|
||||
const { customer_id } = c.req.param();
|
||||
|
||||
const fullCus = await CusService.getFull({
|
||||
db,
|
||||
|
||||
@@ -10,7 +10,7 @@ export const handleGetCusReferrals = createRoute({
|
||||
params: z.object({ customer_id: z.string() }),
|
||||
handler: async (c) => {
|
||||
const { env, db, org } = c.get("ctx");
|
||||
const { customer_id } = c.req.valid("param");
|
||||
const { customer_id } = c.req.param();
|
||||
|
||||
const internalCustomer = await CusService.get({
|
||||
db,
|
||||
|
||||
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 { JobName } from "@/queue/JobName.js";
|
||||
import { addTaskToQueue } from "@/queue/queueUtils.js";
|
||||
import { generateId } from "@/utils/genUtils.js";
|
||||
import { FeatureService } from "../FeatureService.js";
|
||||
import { validateFeature } from "../internalFeatureRouter.js";
|
||||
import {
|
||||
validateCreditSystem,
|
||||
validateFeatureId,
|
||||
validateMeteredConfig,
|
||||
} from "../featureUtils.js";
|
||||
|
||||
export const validateFeature = (data: any) => {
|
||||
const featureType = data.type;
|
||||
|
||||
validateFeatureId(data.id);
|
||||
|
||||
let config = data.config;
|
||||
if (featureType === FeatureType.Metered) {
|
||||
config = validateMeteredConfig(config);
|
||||
} else if (featureType === FeatureType.CreditSystem) {
|
||||
config = validateCreditSystem(config);
|
||||
}
|
||||
|
||||
const parsedFeature = CreateFeatureSchema.parse({ ...data, config });
|
||||
return parsedFeature;
|
||||
};
|
||||
|
||||
interface CreateFeatureParams {
|
||||
ctx: AutumnContext;
|
||||
|
||||
@@ -8,10 +8,10 @@ import {
|
||||
validateCreditSystem,
|
||||
validateMeteredConfig,
|
||||
} from "../featureUtils.js";
|
||||
import { getObjectsUsingFeature } from "../handlers/handleUpdateFeature/getObjectsUsingFeature.js";
|
||||
import { handleFeatureIdChanged } from "../handlers/handleUpdateFeature/handleFeatureIdChanged.js";
|
||||
import { handleFeatureTypeChanged } from "../handlers/handleUpdateFeature/handleFeatureTypeChanged.js";
|
||||
import { handleFeatureUsageTypeChanged } from "../handlers/handleUpdateFeature/handleFeatureUsageTypeChanged.js";
|
||||
import { getObjectsUsingFeature } from "../utils/updateFeatureUtils/getObjectsUsingFeature.js";
|
||||
import { handleFeatureIdChanged } from "../utils/updateFeatureUtils/handleFeatureIdChanged.js";
|
||||
import { handleFeatureTypeChanged } from "../utils/updateFeatureUtils/handleFeatureTypeChanged.js";
|
||||
import { handleFeatureUsageTypeChanged } from "../utils/updateFeatureUtils/handleFeatureUsageTypeChanged.js";
|
||||
|
||||
interface UpdateFeatureParams {
|
||||
ctx: AutumnContext;
|
||||
|
||||
@@ -1,177 +1,198 @@
|
||||
import {
|
||||
ApiFeatureSchema,
|
||||
ApiFeatureType,
|
||||
ErrCode,
|
||||
type Feature,
|
||||
FeatureType,
|
||||
type FeatureUsageType,
|
||||
UpdateFeatureParamsSchema,
|
||||
} from "@autumn/shared";
|
||||
// import {
|
||||
// ApiFeatureType,
|
||||
// ApiFeatureV0Schema,
|
||||
// ErrCode,
|
||||
// type Feature,
|
||||
// FeatureType,
|
||||
// type FeatureUsageType,
|
||||
// UpdateFeatureParamsSchema,
|
||||
// } from "@autumn/shared";
|
||||
|
||||
import express, { type Router } from "express";
|
||||
import { JobName } from "@/queue/JobName.js";
|
||||
import { addTaskToQueue } from "@/queue/queueUtils.js";
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import { keyToTitle } from "@/utils/genUtils.js";
|
||||
import { routeHandler } from "@/utils/routerUtils.js";
|
||||
import { FeatureService } from "./FeatureService.js";
|
||||
import { validateFeatureId } from "./featureUtils.js";
|
||||
import { handleDeleteFeature } from "./handlers/handleDeleteFeature.js";
|
||||
import { handleGetFeatureDeletionInfo } from "./handlers/handleGetFeatureDeletionInfo.js";
|
||||
import { handleUpdateFeature } from "./handlers/handleUpdateFeature.js";
|
||||
import { fromApiFeature, toApiFeature } from "./utils/mapFeatureUtils.js";
|
||||
import { Hono } from "hono";
|
||||
import type { HonoEnv } from "../../honoUtils/HonoEnv";
|
||||
import { handleCreateFeature } from "./handlers/handleCreateFeature";
|
||||
import { handleDeleteFeature } from "./handlers/handleDeleteFeature";
|
||||
import { handleGetFeature } from "./handlers/handleGetFeature";
|
||||
import { handleListFeatures } from "./handlers/handleListFeatures";
|
||||
import { handleUpdateFeature } from "./handlers/handleUpdateFeature";
|
||||
import { handleGetFeatureDeletionInfo } from "./internalHandlers/handleGetFeatureDeletionInfo";
|
||||
|
||||
export const featureRouter: Router = express.Router();
|
||||
// import express, { type Router } from "express";
|
||||
// import { JobName } from "@/queue/JobName.js";
|
||||
// import { addTaskToQueue } from "@/queue/queueUtils.js";
|
||||
// import RecaseError from "@/utils/errorUtils.js";
|
||||
// import { keyToTitle } from "@/utils/genUtils.js";
|
||||
// import { routeHandler } from "@/utils/routerUtils.js";
|
||||
// import { FeatureService } from "./FeatureService.js";
|
||||
// import { validateFeatureId } from "./featureUtils.js";
|
||||
// import { handleDeleteFeature } from "./handlers/handleDeleteFeature.js";
|
||||
// import { handleGetFeatureDeletionInfo } from "./handlers/handleGetFeatureDeletionInfo.js";
|
||||
// import { handleUpdateFeature } from "./handlers/handleUpdateFeature.js";
|
||||
// import { fromApiFeature, toApiFeature } from "./utils/mapFeatureUtils.js";
|
||||
|
||||
// 1. Get features...
|
||||
featureRouter.get("", async (req: any, res: any) =>
|
||||
routeHandler({
|
||||
req,
|
||||
res,
|
||||
action: "list features",
|
||||
handler: async () => {
|
||||
const includeArchived = req.query.include_archived === "true";
|
||||
const features = await FeatureService.list({
|
||||
db: req.db,
|
||||
orgId: req.orgId,
|
||||
env: req.env,
|
||||
archived: includeArchived ? undefined : false,
|
||||
// showOnlyArchived: includeArchived ? undefined : false,
|
||||
});
|
||||
// export const featureRouter: Router = express.Router();
|
||||
|
||||
res
|
||||
.status(200)
|
||||
.json({ list: features.map((feature) => toApiFeature({ feature })) });
|
||||
},
|
||||
}),
|
||||
// // 1. Get features...
|
||||
// featureRouter.get("", async (req: any, res: any) =>
|
||||
// routeHandler({
|
||||
// req,
|
||||
// res,
|
||||
// action: "list features",
|
||||
// handler: async () => {
|
||||
// const includeArchived = req.query.include_archived === "true";
|
||||
// const features = await FeatureService.list({
|
||||
// db: req.db,
|
||||
// orgId: req.orgId,
|
||||
// env: req.env,
|
||||
// archived: includeArchived ? undefined : false,
|
||||
// // showOnlyArchived: includeArchived ? undefined : false,
|
||||
// });
|
||||
|
||||
// res
|
||||
// .status(200)
|
||||
// .json({ list: features.map((feature) => toApiFeature({ feature })) });
|
||||
// },
|
||||
// }),
|
||||
// );
|
||||
|
||||
// featureRouter.get("/:featureId", async (req: any, res: any) =>
|
||||
// routeHandler({
|
||||
// req,
|
||||
// res,
|
||||
// action: "Get feature",
|
||||
// handler: async () => {
|
||||
// const feature = req.features.find(
|
||||
// (f: Feature) => f.id === req.params.featureId,
|
||||
// );
|
||||
|
||||
// if (!feature) {
|
||||
// throw new RecaseError({
|
||||
// message: `Feature with id ${req.params.featureId} not found`,
|
||||
// code: ErrCode.FeatureNotFound,
|
||||
// statusCode: 404,
|
||||
// });
|
||||
// }
|
||||
|
||||
// res.status(200).json(toApiFeature({ feature }));
|
||||
// },
|
||||
// }),
|
||||
// );
|
||||
|
||||
// featureRouter.post("", async (req: any, res: any) =>
|
||||
// routeHandler({
|
||||
// req,
|
||||
// res,
|
||||
// action: "Create feature",
|
||||
// handler: async () => {
|
||||
// const apiFeature = ApiFeatureV0Schema.parse(req.body);
|
||||
// if (!apiFeature.name) {
|
||||
// apiFeature.name = keyToTitle(apiFeature.id);
|
||||
// }
|
||||
|
||||
// validateFeatureId(apiFeature.id);
|
||||
|
||||
// const feature = fromApiFeature({
|
||||
// apiFeature,
|
||||
// orgId: req.orgId,
|
||||
// env: req.env,
|
||||
// });
|
||||
|
||||
// const { db, logger, features: curFeatures } = req;
|
||||
|
||||
// const curFeature = curFeatures.find((f: Feature) => f.id === feature.id);
|
||||
|
||||
// if (curFeature) {
|
||||
// throw new RecaseError({
|
||||
// message: `Feature with id ${feature.id} already exists`,
|
||||
// code: ErrCode.DuplicateFeatureId,
|
||||
// statusCode: 400,
|
||||
// });
|
||||
// }
|
||||
|
||||
// await FeatureService.insert({ db, data: [feature], logger });
|
||||
|
||||
// await addTaskToQueue({
|
||||
// jobName: JobName.GenerateFeatureDisplay,
|
||||
// payload: { feature },
|
||||
// });
|
||||
|
||||
// res.status(200).json(apiFeature);
|
||||
// },
|
||||
// }),
|
||||
// );
|
||||
|
||||
// featureRouter.post("/:feature_id", async (req: any, res: any) =>
|
||||
// routeHandler({
|
||||
// req,
|
||||
// res,
|
||||
// action: "Update feature",
|
||||
// handler: async (req: any, res: any) => {
|
||||
// const { feature_id: featureId } = req.params;
|
||||
// const { features: curFeatures } = req;
|
||||
// const apiFeature = UpdateFeatureParamsSchema.parse(req.body);
|
||||
|
||||
// const originalFeature = curFeatures.find(
|
||||
// (f: Feature) => f.id === featureId,
|
||||
// );
|
||||
|
||||
// if (!originalFeature) {
|
||||
// throw new RecaseError({
|
||||
// message: `Feature with id ${featureId} not found`,
|
||||
// code: ErrCode.FeatureNotFound,
|
||||
// statusCode: 404,
|
||||
// });
|
||||
// }
|
||||
|
||||
// // Replace body...
|
||||
// let featureType = apiFeature.type as unknown as FeatureType;
|
||||
// let usageType: FeatureUsageType | undefined;
|
||||
// if (
|
||||
// apiFeature.type === ApiFeatureType.SingleUsage ||
|
||||
// apiFeature.type === ApiFeatureType.ContinuousUse
|
||||
// ) {
|
||||
// featureType = FeatureType.Metered;
|
||||
// usageType = apiFeature.type as unknown as FeatureUsageType;
|
||||
// }
|
||||
|
||||
// const newConfig = originalFeature.config;
|
||||
// if (usageType) {
|
||||
// newConfig.usage_type = usageType;
|
||||
// }
|
||||
|
||||
// if (apiFeature.credit_schema) {
|
||||
// newConfig.schema = apiFeature.credit_schema.map((credit) => ({
|
||||
// metered_feature_id: credit.metered_feature_id,
|
||||
// credit_amount: credit.credit_cost,
|
||||
// }));
|
||||
// }
|
||||
|
||||
// const newBody = {
|
||||
// id: req.body.id || undefined,
|
||||
// name: req.body.name || undefined,
|
||||
// type: featureType,
|
||||
// config: newConfig,
|
||||
// archived: req.body.archived ?? undefined,
|
||||
// };
|
||||
|
||||
// req.body = newBody;
|
||||
|
||||
// await handleUpdateFeature(req, res, true);
|
||||
// },
|
||||
// }),
|
||||
// );
|
||||
|
||||
// featureRouter.delete("/:featureId", handleDeleteFeature);
|
||||
|
||||
// featureRouter.get("/:feature_id/deletion_info", handleGetFeatureDeletionInfo);
|
||||
|
||||
export const featureRouter = new Hono<HonoEnv>();
|
||||
featureRouter.get("", ...handleListFeatures);
|
||||
featureRouter.post("", ...handleCreateFeature);
|
||||
featureRouter.get("/:feature_id", ...handleGetFeature);
|
||||
featureRouter.post("/:feature_id", ...handleUpdateFeature);
|
||||
featureRouter.delete("/:feature_id", ...handleDeleteFeature);
|
||||
|
||||
featureRouter.get(
|
||||
"/:feature_id/deletion_info",
|
||||
...handleGetFeatureDeletionInfo,
|
||||
);
|
||||
|
||||
featureRouter.get("/:featureId", async (req: any, res: any) =>
|
||||
routeHandler({
|
||||
req,
|
||||
res,
|
||||
action: "Get feature",
|
||||
handler: async () => {
|
||||
const feature = req.features.find(
|
||||
(f: Feature) => f.id === req.params.featureId,
|
||||
);
|
||||
|
||||
if (!feature) {
|
||||
throw new RecaseError({
|
||||
message: `Feature with id ${req.params.featureId} not found`,
|
||||
code: ErrCode.FeatureNotFound,
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
res.status(200).json(toApiFeature({ feature }));
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
featureRouter.post("", async (req: any, res: any) =>
|
||||
routeHandler({
|
||||
req,
|
||||
res,
|
||||
action: "Create feature",
|
||||
handler: async () => {
|
||||
const apiFeature = ApiFeatureSchema.parse(req.body);
|
||||
if (!apiFeature.name) {
|
||||
apiFeature.name = keyToTitle(apiFeature.id);
|
||||
}
|
||||
|
||||
validateFeatureId(apiFeature.id);
|
||||
|
||||
const feature = fromApiFeature({
|
||||
apiFeature,
|
||||
orgId: req.orgId,
|
||||
env: req.env,
|
||||
});
|
||||
|
||||
const { db, logger, features: curFeatures } = req;
|
||||
|
||||
const curFeature = curFeatures.find((f: Feature) => f.id === feature.id);
|
||||
|
||||
if (curFeature) {
|
||||
throw new RecaseError({
|
||||
message: `Feature with id ${feature.id} already exists`,
|
||||
code: ErrCode.DuplicateFeatureId,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
await FeatureService.insert({ db, data: [feature], logger });
|
||||
|
||||
await addTaskToQueue({
|
||||
jobName: JobName.GenerateFeatureDisplay,
|
||||
payload: { feature },
|
||||
});
|
||||
|
||||
res.status(200).json(apiFeature);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
featureRouter.post("/:feature_id", async (req: any, res: any) =>
|
||||
routeHandler({
|
||||
req,
|
||||
res,
|
||||
action: "Update feature",
|
||||
handler: async (req: any, res: any) => {
|
||||
const { feature_id: featureId } = req.params;
|
||||
const { features: curFeatures } = req;
|
||||
const apiFeature = UpdateFeatureParamsSchema.parse(req.body);
|
||||
|
||||
const originalFeature = curFeatures.find(
|
||||
(f: Feature) => f.id === featureId,
|
||||
);
|
||||
|
||||
if (!originalFeature) {
|
||||
throw new RecaseError({
|
||||
message: `Feature with id ${featureId} not found`,
|
||||
code: ErrCode.FeatureNotFound,
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
// Replace body...
|
||||
let featureType = apiFeature.type as unknown as FeatureType;
|
||||
let usageType: FeatureUsageType | undefined;
|
||||
if (
|
||||
apiFeature.type === ApiFeatureType.SingleUsage ||
|
||||
apiFeature.type === ApiFeatureType.ContinuousUse
|
||||
) {
|
||||
featureType = FeatureType.Metered;
|
||||
usageType = apiFeature.type as unknown as FeatureUsageType;
|
||||
}
|
||||
|
||||
const newConfig = originalFeature.config;
|
||||
if (usageType) {
|
||||
newConfig.usage_type = usageType;
|
||||
}
|
||||
|
||||
if (apiFeature.credit_schema) {
|
||||
newConfig.schema = apiFeature.credit_schema.map((credit) => ({
|
||||
metered_feature_id: credit.metered_feature_id,
|
||||
credit_amount: credit.credit_cost,
|
||||
}));
|
||||
}
|
||||
|
||||
const newBody = {
|
||||
id: req.body.id || undefined,
|
||||
name: req.body.name || undefined,
|
||||
type: featureType,
|
||||
config: newConfig,
|
||||
archived: req.body.archived ?? undefined,
|
||||
};
|
||||
|
||||
req.body = newBody;
|
||||
|
||||
await handleUpdateFeature(req, res, true);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
featureRouter.delete("/:featureId", handleDeleteFeature);
|
||||
|
||||
featureRouter.get("/:feature_id/deletion_info", handleGetFeatureDeletionInfo);
|
||||
|
||||
@@ -69,11 +69,10 @@ export const validateCreditSystem = (config: CreditSystemConfig) => {
|
||||
|
||||
const newConfig = { ...config, usage_type: FeatureUsageType.Single };
|
||||
for (let i = 0; i < newConfig.schema.length; i++) {
|
||||
newConfig.schema[i].feature_amount = 1;
|
||||
|
||||
const creditAmount = parseFloat(
|
||||
newConfig.schema[i].credit_amount.toString(),
|
||||
);
|
||||
|
||||
if (Number.isNaN(creditAmount)) {
|
||||
throw new RecaseError({
|
||||
message: `Credit amount should be a number`,
|
||||
@@ -120,9 +119,10 @@ export const runSaveFeatureDisplayTask = async ({
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("failed to generate feature display", {
|
||||
error,
|
||||
feature,
|
||||
logger.error(`failed to generate feature display, ${error}`, {
|
||||
data: {
|
||||
feature,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,18 +1,43 @@
|
||||
import { handleFrontendReqError } from "@/utils/errorUtils.js";
|
||||
import { createFeature } from "../featureActions/createFeature.js";
|
||||
import {
|
||||
AffectedResource,
|
||||
ApiVersion,
|
||||
CreateFeatureV0ParamsSchema,
|
||||
CreateFeatureV1ParamsSchema,
|
||||
dbToApiFeatureV1,
|
||||
featureV1ToDbFeature,
|
||||
InternalError,
|
||||
} from "@autumn/shared";
|
||||
import { createRoute } from "../../../honoMiddlewares/routeHandler";
|
||||
import { createFeature } from "../featureActions/createFeature";
|
||||
|
||||
export const handleCreateFeature = async (req: any, res: any) => {
|
||||
try {
|
||||
console.log("Trying to create feature");
|
||||
const data = req.body;
|
||||
export const handleCreateFeature = createRoute({
|
||||
versionedBody: {
|
||||
latest: CreateFeatureV1ParamsSchema,
|
||||
[ApiVersion.V1_Beta]: CreateFeatureV0ParamsSchema,
|
||||
},
|
||||
resource: AffectedResource.Feature,
|
||||
handler: async (c) => {
|
||||
const body = c.req.valid("json");
|
||||
const ctx = c.get("ctx");
|
||||
|
||||
const insertedFeature = await createFeature({
|
||||
ctx: req,
|
||||
data,
|
||||
// Get backend feature
|
||||
const feature = featureV1ToDbFeature({
|
||||
apiFeature: body,
|
||||
originalFeature: undefined,
|
||||
});
|
||||
|
||||
res.status(200).json(insertedFeature);
|
||||
} catch (error) {
|
||||
handleFrontendReqError({ req, error, res, action: "Create feature" });
|
||||
}
|
||||
};
|
||||
// Body is now always in the latest V1 format, regardless of API version
|
||||
const dbFeature = await createFeature({
|
||||
ctx,
|
||||
data: feature,
|
||||
});
|
||||
|
||||
if (!dbFeature) {
|
||||
throw new InternalError({ message: "Insert feature returned null" });
|
||||
}
|
||||
|
||||
return c.json(
|
||||
dbToApiFeatureV1({ dbFeature, targetVersion: ctx.apiVersion }),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,67 +1,50 @@
|
||||
import { ErrCode } from "@autumn/shared";
|
||||
import { getCreditSystemsFromFeature } from "@/internal/features/creditSystemUtils.js";
|
||||
import { FeatureService } from "@/internal/features/FeatureService.js";
|
||||
import { EntitlementService } from "@/internal/products/entitlements/EntitlementService.js";
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import type {
|
||||
ExtendedRequest,
|
||||
ExtendedResponse,
|
||||
} from "@/utils/models/Request.js";
|
||||
import { routeHandler } from "@/utils/routerUtils.js";
|
||||
import { FeatureNotFoundError, RecaseError } from "@autumn/shared";
|
||||
import { createRoute } from "../../../honoMiddlewares/routeHandler";
|
||||
import { EntitlementService } from "../../products/entitlements/EntitlementService";
|
||||
import { getCreditSystemsFromFeature } from "../creditSystemUtils";
|
||||
import { FeatureService } from "../FeatureService";
|
||||
|
||||
export const handleDeleteFeature = async (req: any, res: any) =>
|
||||
routeHandler({
|
||||
req,
|
||||
res,
|
||||
action: "Delete feature",
|
||||
handler: async (req: ExtendedRequest, res: ExtendedResponse) => {
|
||||
const { db, orgId } = req;
|
||||
export const handleDeleteFeature = createRoute({
|
||||
handler: async (c) => {
|
||||
const ctx = c.get("ctx");
|
||||
const { db, org, features } = ctx;
|
||||
const { feature_id } = c.req.param();
|
||||
|
||||
const { featureId } = req.params;
|
||||
const features = await FeatureService.getFromReq(req);
|
||||
const feature = features.find((f) => f.id === featureId);
|
||||
const creditSystems = getCreditSystemsFromFeature({
|
||||
featureId,
|
||||
features,
|
||||
const feature = features.find((f) => f.id === feature_id);
|
||||
if (!feature) {
|
||||
throw new FeatureNotFoundError({ featureId: feature_id });
|
||||
}
|
||||
|
||||
const creditSystems = getCreditSystemsFromFeature({
|
||||
featureId: feature_id,
|
||||
features,
|
||||
});
|
||||
|
||||
if (creditSystems.length > 0) {
|
||||
throw new RecaseError({
|
||||
message: `Feature ${feature_id} is used by credit system ${creditSystems[0].id}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!feature) {
|
||||
throw new RecaseError({
|
||||
message: `Feature ${featureId} not found`,
|
||||
code: ErrCode.FeatureNotFound,
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
// Get prices that use this feature
|
||||
const ent = await EntitlementService.getByFeature({
|
||||
db,
|
||||
internalFeatureId: feature.internal_id!,
|
||||
});
|
||||
|
||||
if (creditSystems.length > 0) {
|
||||
throw new RecaseError({
|
||||
message: `Feature ${featureId} is used by credit system ${creditSystems[0].id}`,
|
||||
code: ErrCode.InvalidFeature,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// Get prices that use this feature
|
||||
const ent = await EntitlementService.getByFeature({
|
||||
db,
|
||||
internalFeatureId: feature.internal_id!,
|
||||
if (ent) {
|
||||
throw new RecaseError({
|
||||
message: `Feature ${feature_id} is used in a product. You must delete the product first, or archive it instead.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (ent) {
|
||||
throw new RecaseError({
|
||||
message: `Feature ${featureId} is used in a product. You must delete the product first, or archive it instead.`,
|
||||
code: ErrCode.InvalidFeature,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
await FeatureService.delete({
|
||||
db,
|
||||
orgId: org.id,
|
||||
featureId: feature_id,
|
||||
env: ctx.env,
|
||||
});
|
||||
|
||||
await FeatureService.delete({
|
||||
db: req.db,
|
||||
orgId,
|
||||
featureId,
|
||||
env: req.env,
|
||||
});
|
||||
|
||||
res.status(200).json({ success: true });
|
||||
},
|
||||
});
|
||||
return c.json({ success: true });
|
||||
},
|
||||
});
|
||||
|
||||
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 { updateFeature } from "../featureActions/updateFeature.js";
|
||||
import { toApiFeature } from "../utils/mapFeatureUtils.js";
|
||||
import {
|
||||
AffectedResource,
|
||||
ApiVersion,
|
||||
dbToApiFeatureV1,
|
||||
FeatureNotFoundError,
|
||||
FeatureType,
|
||||
featureV1ToDbFeatureConfig,
|
||||
InternalError,
|
||||
nullish,
|
||||
RecaseError,
|
||||
UpdateFeatureV0ParamsSchema,
|
||||
UpdateFeatureV1ParamsSchema,
|
||||
} from "@autumn/shared";
|
||||
|
||||
export const handleUpdateFeature = async (
|
||||
req: any,
|
||||
res: any,
|
||||
_fromApi: boolean = false,
|
||||
) =>
|
||||
routeHandler({
|
||||
req,
|
||||
res,
|
||||
action: "Update feature",
|
||||
handler: async (req: any, res: any) => {
|
||||
const featureId = req.params.feature_id;
|
||||
const data = req.body;
|
||||
import { createRoute } from "../../../honoMiddlewares/routeHandler";
|
||||
import { updateFeature } from "../featureActions/updateFeature";
|
||||
|
||||
// Use the abstracted updateFeature function
|
||||
const updatedFeature = await updateFeature({
|
||||
ctx: req,
|
||||
featureId,
|
||||
updates: data,
|
||||
export const handleUpdateFeature = createRoute({
|
||||
versionedBody: {
|
||||
latest: UpdateFeatureV1ParamsSchema,
|
||||
[ApiVersion.V1_Beta]: UpdateFeatureV0ParamsSchema,
|
||||
},
|
||||
resource: AffectedResource.Feature,
|
||||
handler: async (c) => {
|
||||
const body = c.req.valid("json");
|
||||
const ctx = c.get("ctx");
|
||||
|
||||
const { feature_id } = c.req.param();
|
||||
const originalFeature = ctx.features.find((f) => f.id === feature_id);
|
||||
if (!originalFeature) {
|
||||
throw new FeatureNotFoundError({ featureId: feature_id });
|
||||
}
|
||||
|
||||
// If changing type and consumable not provided, throw error
|
||||
if (body.type === FeatureType.Metered && nullish(body.consumable)) {
|
||||
throw new RecaseError({
|
||||
message: "Consumable is required when changing type to metered",
|
||||
});
|
||||
}
|
||||
|
||||
res
|
||||
.status(200)
|
||||
.json(
|
||||
updatedFeature
|
||||
? toApiFeature({ feature: updatedFeature })
|
||||
: undefined,
|
||||
);
|
||||
},
|
||||
});
|
||||
const newConfig = featureV1ToDbFeatureConfig({
|
||||
apiFeature: body,
|
||||
originalFeature,
|
||||
});
|
||||
|
||||
const updatedFeature = await updateFeature({
|
||||
ctx,
|
||||
featureId: feature_id,
|
||||
updates: {
|
||||
id: body.id,
|
||||
name: body.name ?? undefined,
|
||||
type: body.type,
|
||||
|
||||
config: newConfig,
|
||||
|
||||
archived: body.archived,
|
||||
event_names: body.event_names,
|
||||
display: body.display,
|
||||
},
|
||||
});
|
||||
|
||||
if (!updatedFeature) {
|
||||
throw new InternalError({ message: "Update feature returned null" });
|
||||
}
|
||||
|
||||
return c.json(
|
||||
dbToApiFeatureV1({
|
||||
dbFeature: updatedFeature,
|
||||
targetVersion: ctx.apiVersion,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
type ApiFeature,
|
||||
ApiFeatureSchema,
|
||||
ApiFeatureType,
|
||||
type ApiFeatureV0,
|
||||
type AppEnv,
|
||||
type CreditSchemaItem,
|
||||
type Feature,
|
||||
FeatureType,
|
||||
type FeatureUsageType,
|
||||
} from "@autumn/shared";
|
||||
@@ -15,41 +12,12 @@ import {
|
||||
constructMeteredFeature,
|
||||
} from "./constructFeatureUtils.js";
|
||||
|
||||
export const toApiFeature = ({ feature }: { feature: Feature }) => {
|
||||
// return FeatureResponseSchema.parse(feature);
|
||||
// 1. Get feature type
|
||||
let featureType = feature.type;
|
||||
if (feature.type === FeatureType.Metered) {
|
||||
featureType = feature.config.usage_type;
|
||||
}
|
||||
|
||||
let creditSchema;
|
||||
if (feature.type === FeatureType.CreditSystem) {
|
||||
creditSchema = feature.config.schema.map((s: CreditSchemaItem) => ({
|
||||
metered_feature_id: s.metered_feature_id,
|
||||
credit_cost: s.credit_amount,
|
||||
}));
|
||||
}
|
||||
|
||||
return ApiFeatureSchema.parse({
|
||||
id: feature.id,
|
||||
name: feature.name,
|
||||
type: featureType,
|
||||
display: {
|
||||
singular: feature.display?.singular || feature.name,
|
||||
plural: feature.display?.plural || feature.name,
|
||||
},
|
||||
credit_schema: creditSchema,
|
||||
archived: feature.archived,
|
||||
});
|
||||
};
|
||||
|
||||
export const fromApiFeature = ({
|
||||
apiFeature,
|
||||
orgId,
|
||||
env,
|
||||
}: {
|
||||
apiFeature: ApiFeature;
|
||||
apiFeature: ApiFeatureV0;
|
||||
orgId: string;
|
||||
env: AppEnv;
|
||||
}) => {
|
||||
|
||||
@@ -12,7 +12,6 @@ import { analyticsRouter } from "./analytics/internalAnalyticsRouter.js";
|
||||
import { trmnlRouter } from "./api/trmnl/trmnlRouter.js";
|
||||
import { cusRouter } from "./customers/internalCusRouter.js";
|
||||
import { devRouter } from "./dev/devRouter.js";
|
||||
import { internalFeatureRouter } from "./features/internalFeatureRouter.js";
|
||||
import { InvoiceService } from "./invoices/InvoiceService.js";
|
||||
import { handlePostOrg } from "./orgs/handlers/handlePostOrg.js";
|
||||
import { onboardingRouter } from "./orgs/onboarding/onboardingRouter.js";
|
||||
@@ -32,7 +31,6 @@ mainRouter.use("/admin", withAdminAuth, adminRouter);
|
||||
mainRouter.use("/users", withAuth, userRouter);
|
||||
mainRouter.use("/onboarding", withOrgAuth, onboardingRouter);
|
||||
mainRouter.use("/organization", withOrgAuth, orgRouter);
|
||||
mainRouter.use("/features", withOrgAuth, internalFeatureRouter);
|
||||
mainRouter.use("/products", withOrgAuth, expressProductRouter);
|
||||
mainRouter.use("/dev", devRouter);
|
||||
mainRouter.use("/customers", withOrgAuth, cusRouter);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type Feature,
|
||||
FeatureType,
|
||||
type FreeTrial,
|
||||
FreeTrialDuration,
|
||||
type FullEntitlement,
|
||||
type FullProduct,
|
||||
type Price,
|
||||
@@ -44,6 +45,22 @@ export const entIntervalToTrialDuration = ({
|
||||
}
|
||||
};
|
||||
|
||||
export const trialToDays = (freeTrial: FreeTrial) => {
|
||||
let days: number;
|
||||
switch (freeTrial.duration) {
|
||||
case FreeTrialDuration.Day:
|
||||
days = freeTrial.length;
|
||||
break;
|
||||
case FreeTrialDuration.Month:
|
||||
days = freeTrial.length * 30;
|
||||
break;
|
||||
case FreeTrialDuration.Year:
|
||||
days = freeTrial.length * 365;
|
||||
break;
|
||||
}
|
||||
return days;
|
||||
};
|
||||
|
||||
export const applyTrialToEntitlement = (
|
||||
entitlement: EntitlementWithFeature,
|
||||
freeTrial: FreeTrial | null,
|
||||
@@ -55,13 +72,13 @@ export const applyTrialToEntitlement = (
|
||||
return false;
|
||||
if (entitlement.allowance_type === AllowanceType.Unlimited) return false;
|
||||
|
||||
const trialDays = freeTrial.length;
|
||||
const trialDays = trialToDays(freeTrial);
|
||||
const entDays = entIntervalToTrialDuration({
|
||||
interval: entitlement.interval!,
|
||||
intervalCount: entitlement.interval_count || 1,
|
||||
});
|
||||
|
||||
if (entDays && entDays > trialDays) {
|
||||
if (entDays && trialDays < entDays) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,29 @@ import {
|
||||
} from "@autumn/shared";
|
||||
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
|
||||
import { FeatureService } from "@/internal/features/FeatureService.js";
|
||||
import { initNewFeature } from "@/internal/features/internalFeatureRouter.js";
|
||||
|
||||
import { ProductService } from "@/internal/products/ProductService.js";
|
||||
import { copyProduct } from "@/internal/products/productUtils.js";
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import { generateId } from "../../../../utils/genUtils";
|
||||
|
||||
export const initNewFeature = ({
|
||||
data,
|
||||
orgId,
|
||||
env,
|
||||
}: {
|
||||
data: any;
|
||||
orgId: string;
|
||||
env: any;
|
||||
}) => {
|
||||
return {
|
||||
...data,
|
||||
org_id: orgId,
|
||||
env,
|
||||
created_at: Date.now(),
|
||||
internal_id: generateId("fe"),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Route: POST /v1/products/:productId/copy - Copy a product
|
||||
|
||||
@@ -13,10 +13,7 @@ const DeleteProductParamsSchema = z.object({
|
||||
});
|
||||
|
||||
const DeleteProductQuerySchema = z.object({
|
||||
all_versions: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => val === "true"),
|
||||
all_versions: z.boolean(),
|
||||
});
|
||||
|
||||
export const handleDeleteProduct = createRoute({
|
||||
@@ -24,7 +21,7 @@ export const handleDeleteProduct = createRoute({
|
||||
query: DeleteProductQuerySchema,
|
||||
resource: AffectedResource.Product,
|
||||
handler: async (c) => {
|
||||
const { product_id } = c.req.valid("param");
|
||||
const { product_id } = c.req.param();
|
||||
const { all_versions } = c.req.valid("query");
|
||||
const { db, org, env } = c.get("ctx");
|
||||
|
||||
|
||||
@@ -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
|
||||
console.log("New group:", newProduct.group);
|
||||
|
||||
await ProductService.updateByInternalId({
|
||||
db,
|
||||
|
||||
@@ -99,12 +99,12 @@ export const handleVersionProductV2 = async ({
|
||||
data: customPrices,
|
||||
});
|
||||
|
||||
// Handle new free trial
|
||||
// Handle new free trial (create new)
|
||||
if (newProductV2.free_trial || latestProduct.free_trial) {
|
||||
await handleNewFreeTrial({
|
||||
db,
|
||||
newFreeTrial: newProductV2.free_trial || null,
|
||||
curFreeTrial: latestProduct.free_trial,
|
||||
curFreeTrial: null,
|
||||
internalProductId: newProduct.internal_id,
|
||||
isCustom: false,
|
||||
newVersion: true, // This is a new product version
|
||||
|
||||
@@ -81,15 +81,15 @@ export const updateProduct = async ({
|
||||
|
||||
const newFreeTrial =
|
||||
"free_trial" in updates
|
||||
? (updates.free_trial as FreeTrial | undefined)
|
||||
: curProductV2.free_trial;
|
||||
? ((updates.free_trial as FreeTrial | undefined) ?? undefined)
|
||||
: (curProductV2.free_trial ?? undefined);
|
||||
|
||||
const newProductV2: ProductV2 = {
|
||||
...curProductV2,
|
||||
...updates,
|
||||
group: updates.group || curProductV2.group || "",
|
||||
items: updates.items || [],
|
||||
free_trial:
|
||||
"free_trial" in updates ? newFreeTrial : curProductV2.free_trial,
|
||||
free_trial: newFreeTrial,
|
||||
};
|
||||
|
||||
await disableCurrentDefault({
|
||||
@@ -101,8 +101,7 @@ export const updateProduct = async ({
|
||||
db,
|
||||
curProduct: fullProduct,
|
||||
newProduct: UpdateProductSchema.parse(updates),
|
||||
newFreeTrial:
|
||||
"free_trial" in updates ? updates.free_trial : curProductV2.free_trial,
|
||||
newFreeTrial: newFreeTrial,
|
||||
items: updates.items || curProductV2.items,
|
||||
org,
|
||||
rewardPrograms,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { IntervalConfig } from "@autumn/shared";
|
||||
import { UTCDate } from "@date-fns/utc";
|
||||
import { toMilliseconds } from "../../../utils/timeUtils.js";
|
||||
import {
|
||||
addIntervalForProration,
|
||||
subtractIntervalForProration,
|
||||
@@ -31,6 +33,15 @@ export const addIntervalToAnchor = ({
|
||||
});
|
||||
};
|
||||
|
||||
const isLessThanEquals = ({ a, b }: { a: UTCDate; b: UTCDate }) => {
|
||||
// Check if a is <= now. return true if a is ~ same as b (maybe by a couple of hours?)
|
||||
const aUnix = a.getTime();
|
||||
const bUnix = b.getTime();
|
||||
if (aUnix < bUnix + toMilliseconds.hours(1)) return true;
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const subtractIntervalFromAnchor = ({
|
||||
anchor,
|
||||
intervalConfig,
|
||||
@@ -45,13 +56,14 @@ export const subtractIntervalFromAnchor = ({
|
||||
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const newAnchor = subtractIntervalForProration({
|
||||
unixTimestamp: anchor,
|
||||
unixTimestamp: curAnchor,
|
||||
interval: intervalConfig.interval,
|
||||
intervalCount: intervalConfig.intervalCount ?? 1,
|
||||
});
|
||||
|
||||
// Return anchor before it goes below now
|
||||
if (newAnchor <= now) return curAnchor;
|
||||
if (isLessThanEquals({ a: new UTCDate(newAnchor), b: new UTCDate(now) }))
|
||||
return curAnchor;
|
||||
|
||||
curAnchor = newAnchor;
|
||||
}
|
||||
|
||||
@@ -6,11 +6,12 @@ import {
|
||||
entToPrice,
|
||||
getBillingType,
|
||||
isFixedPrice,
|
||||
itemToEntInterval,
|
||||
type Price,
|
||||
type ProductItem,
|
||||
UsageModel,
|
||||
} from "@autumn/shared";
|
||||
import { itemToEntInterval } from "../itemIntervalUtils.js";
|
||||
|
||||
import { isFeatureItem, isFeaturePriceItem } from "./getItemType.js";
|
||||
|
||||
export const addIdsToProductItems = ({
|
||||
@@ -56,10 +57,10 @@ export const addIdsToProductItems = ({
|
||||
|
||||
// Sort by interval
|
||||
const aIntervalValue = entIntervalToValue(
|
||||
itemToEntInterval(a) as EntInterval,
|
||||
itemToEntInterval({ item: a }) as EntInterval,
|
||||
);
|
||||
const bIntervalValue = entIntervalToValue(
|
||||
itemToEntInterval(b) as EntInterval,
|
||||
itemToEntInterval({ item: b }) as EntInterval,
|
||||
);
|
||||
if (!aIntervalValue.eq(bIntervalValue)) {
|
||||
return aIntervalValue.sub(bIntervalValue).toNumber();
|
||||
|
||||
@@ -1,140 +1,12 @@
|
||||
import { ProductNotFoundError, productsAreSame } from "@autumn/shared";
|
||||
import { Router } from "express";
|
||||
import { createStripeCli } from "@/external/connect/createStripeCli.js";
|
||||
import { createStripePriceIFNotExist } from "@/external/stripe/createStripePrice/createStripePrice.js";
|
||||
import { OrgService } from "@/internal/orgs/OrgService.js";
|
||||
import { ProductService } from "@/internal/products/ProductService.js";
|
||||
import { checkStripeProductExists } from "@/internal/products/productUtils.js";
|
||||
import { handleRequestError } from "@/utils/errorUtils.js";
|
||||
import { routeHandler } from "@/utils/routerUtils.js";
|
||||
import { CusProductService } from "../customers/cusProducts/CusProductService.js";
|
||||
|
||||
import { handleGetProductDeleteInfo } from "./handlers/handleGetProductDeleteInfo.js";
|
||||
|
||||
export const productRouter: Router = Router();
|
||||
|
||||
productRouter.post("/all/init_stripe", async (req: any, res) => {
|
||||
try {
|
||||
const { orgId, env, logger, db } = req;
|
||||
|
||||
const [fullProducts, org] = await Promise.all([
|
||||
ProductService.listFull({
|
||||
db,
|
||||
orgId,
|
||||
env,
|
||||
}),
|
||||
OrgService.getFromReq(req),
|
||||
]);
|
||||
|
||||
console.log(
|
||||
"fullProducts",
|
||||
fullProducts.map((p) => p.id),
|
||||
);
|
||||
|
||||
const stripeCli = createStripeCli({
|
||||
org,
|
||||
env,
|
||||
});
|
||||
|
||||
const productBatchSize = 5;
|
||||
for (let i = 0; i < fullProducts.length; i += productBatchSize) {
|
||||
const batch = fullProducts.slice(i, i + productBatchSize);
|
||||
const batchPromises = batch.map((product) =>
|
||||
checkStripeProductExists({
|
||||
db,
|
||||
org,
|
||||
env,
|
||||
product,
|
||||
logger,
|
||||
}),
|
||||
);
|
||||
await Promise.all(batchPromises);
|
||||
}
|
||||
|
||||
const entitlements = fullProducts.flatMap((p) => p.entitlements);
|
||||
const prices = fullProducts.flatMap((p) => p.prices);
|
||||
|
||||
const batchSize = 3;
|
||||
for (let i = 0; i < prices.length; i += batchSize) {
|
||||
const batch = prices.slice(i, i + batchSize);
|
||||
const batchPriceUpdate = [];
|
||||
for (const price of batch) {
|
||||
batchPriceUpdate.push(
|
||||
createStripePriceIFNotExist({
|
||||
db,
|
||||
org,
|
||||
stripeCli: stripeCli,
|
||||
price,
|
||||
entitlements,
|
||||
product: fullProducts.find(
|
||||
(p) => p.internal_id === price.internal_product_id,
|
||||
)!,
|
||||
logger,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(batchPriceUpdate);
|
||||
}
|
||||
res.status(200).json({ message: "Stripe products initialized" });
|
||||
} catch (error) {
|
||||
handleRequestError({ req, error, res, action: "Init stripe products" });
|
||||
}
|
||||
});
|
||||
|
||||
productRouter.get("/:productId/has_customers", async (req: any, res: any) =>
|
||||
routeHandler({
|
||||
req,
|
||||
res,
|
||||
action: "Get product has customers",
|
||||
handler: async () => {
|
||||
const { productId } = req.params;
|
||||
const { db, features } = req;
|
||||
const { id, items, free_trial } = req.body;
|
||||
|
||||
const product = await ProductService.getFull({
|
||||
db,
|
||||
idOrInternalId: productId,
|
||||
orgId: req.orgId,
|
||||
env: req.env,
|
||||
});
|
||||
|
||||
if (!product) {
|
||||
throw new ProductNotFoundError({ productId });
|
||||
}
|
||||
|
||||
const cusProductsCurVersion =
|
||||
await CusProductService.getByInternalProductId({
|
||||
db,
|
||||
internalProductId: product.internal_id,
|
||||
});
|
||||
|
||||
const { itemsSame, freeTrialsSame } = productsAreSame({
|
||||
newProductV2: req.body,
|
||||
curProductV1: product,
|
||||
features,
|
||||
});
|
||||
|
||||
const productSame = itemsSame && freeTrialsSame;
|
||||
|
||||
res.status(200).json({
|
||||
current_version: product.version,
|
||||
will_version: !productSame && cusProductsCurVersion.length > 0,
|
||||
archived: product.archived,
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
productRouter.get("/:productId/deletion_info", handleGetProductDeleteInfo);
|
||||
|
||||
import { Hono } from "hono";
|
||||
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
|
||||
import { handleCopyProductV2 } from "./handlers/handleCopyProduct/handleCopyProductV2.js";
|
||||
import { handleCreatePlan } from "./handlers/handleCreatePlan.js";
|
||||
import { handleDeleteProduct as handleDeleteProductHono } from "./handlers/handleDeleteProduct.js";
|
||||
import { handleGetPlan } from "./handlers/handleGetPlan.js";
|
||||
import { handleGetPlanDeleteInfo } from "./handlers/handleGetPlanDeleteInfo.js";
|
||||
import { handleListPlans } from "./handlers/handleListPlans.js";
|
||||
import { handlePlanHasCustomers } from "./handlers/handlePlanHasCustomers.js";
|
||||
import { handleUpdatePlan } from "./handlers/handleUpdateProduct/handleUpdatePlan.js";
|
||||
|
||||
export const honoProductBetaRouter = new Hono<HonoEnv>();
|
||||
@@ -144,11 +16,85 @@ honoProductBetaRouter.get("", ...handleListPlans);
|
||||
export const honoProductRouter = new Hono<HonoEnv>();
|
||||
export const migrationRouter = new Hono<HonoEnv>();
|
||||
|
||||
// POST /products - Create a product
|
||||
// CRUD
|
||||
honoProductRouter.get("", ...handleListPlans);
|
||||
honoProductRouter.post("", ...handleCreatePlan);
|
||||
|
||||
honoProductRouter.get("/:product_id", ...handleGetPlan);
|
||||
honoProductRouter.post("/:product_id", ...handleUpdatePlan); // will be deprecated
|
||||
honoProductRouter.delete("/:product_id", ...handleDeleteProductHono);
|
||||
|
||||
// Others
|
||||
honoProductRouter.post("/:product_id/copy", ...handleCopyProductV2);
|
||||
|
||||
// Info before deleting plan
|
||||
honoProductRouter.get("/:product_id/has_customers", ...handlePlanHasCustomers);
|
||||
honoProductRouter.get("/:product_id/deletion_info", ...handleGetPlanDeleteInfo);
|
||||
|
||||
// productRouter.post("/all/init_stripe", async (req: any, res) => {
|
||||
// try {
|
||||
// const { orgId, env, logger, db } = req;
|
||||
|
||||
// const [fullProducts, org] = await Promise.all([
|
||||
// ProductService.listFull({
|
||||
// db,
|
||||
// orgId,
|
||||
// env,
|
||||
// }),
|
||||
// OrgService.getFromReq(req),
|
||||
// ]);
|
||||
|
||||
// console.log(
|
||||
// "fullProducts",
|
||||
// fullProducts.map((p) => p.id),
|
||||
// );
|
||||
|
||||
// const stripeCli = createStripeCli({
|
||||
// org,
|
||||
// env,
|
||||
// });
|
||||
|
||||
// const productBatchSize = 5;
|
||||
// for (let i = 0; i < fullProducts.length; i += productBatchSize) {
|
||||
// const batch = fullProducts.slice(i, i + productBatchSize);
|
||||
// const batchPromises = batch.map((product) =>
|
||||
// checkStripeProductExists({
|
||||
// db,
|
||||
// org,
|
||||
// env,
|
||||
// product,
|
||||
// logger,
|
||||
// }),
|
||||
// );
|
||||
// await Promise.all(batchPromises);
|
||||
// }
|
||||
|
||||
// const entitlements = fullProducts.flatMap((p) => p.entitlements);
|
||||
// const prices = fullProducts.flatMap((p) => p.prices);
|
||||
|
||||
// const batchSize = 3;
|
||||
// for (let i = 0; i < prices.length; i += batchSize) {
|
||||
// const batch = prices.slice(i, i + batchSize);
|
||||
// const batchPriceUpdate = [];
|
||||
// for (const price of batch) {
|
||||
// batchPriceUpdate.push(
|
||||
// createStripePriceIFNotExist({
|
||||
// db,
|
||||
// org,
|
||||
// stripeCli: stripeCli,
|
||||
// price,
|
||||
// entitlements,
|
||||
// product: fullProducts.find(
|
||||
// (p) => p.internal_id === price.internal_product_id,
|
||||
// )!,
|
||||
// logger,
|
||||
// }),
|
||||
// );
|
||||
// }
|
||||
|
||||
// await Promise.all(batchPriceUpdate);
|
||||
// }
|
||||
// res.status(200).json({ message: "Stripe products initialized" });
|
||||
// } catch (error) {
|
||||
// handleRequestError({ req, error, res, action: "Init stripe products" });
|
||||
// }
|
||||
// });
|
||||
|
||||
@@ -12,10 +12,10 @@ import {
|
||||
type FullProduct,
|
||||
type Price,
|
||||
type ProductItem,
|
||||
toApiFeature,
|
||||
UsageModel,
|
||||
} from "@autumn/shared";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { toApiFeature } from "@/internal/features/utils/mapFeatureUtils.js";
|
||||
import { notNullish } from "@/utils/genUtils.js";
|
||||
import { getFreeTrialAfterFingerprint } from "../../free-trials/freeTrialUtils.js";
|
||||
import { sortProductItems } from "../../pricecn/pricecnUtils.js";
|
||||
|
||||
@@ -84,7 +84,6 @@ export const mapToProductV2 = ({
|
||||
|
||||
const productV2: ProductV2 = {
|
||||
internal_id: product.internal_id,
|
||||
env: product.env,
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
is_add_on: product.is_add_on,
|
||||
|
||||
@@ -58,6 +58,7 @@ export const createWorkerContext = async ({
|
||||
apiVersion: createdAtToVersion({ createdAt: org.created_at! }),
|
||||
clickhouseClient: null as any,
|
||||
expand: [],
|
||||
skipCache: true,
|
||||
};
|
||||
|
||||
return ctx;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import {
|
||||
dbToApiFeatureV1,
|
||||
type Feature,
|
||||
type FullCustomer,
|
||||
toApiFeature,
|
||||
WebhookEventType,
|
||||
} from "@autumn/shared";
|
||||
import { sendSvixEvent } from "@/external/svix/svixHelpers.js";
|
||||
import { toApiFeature } from "@/internal/features/utils/mapFeatureUtils.js";
|
||||
import type { AutumnContext } from "../honoUtils/HonoEnv.js";
|
||||
import { apiBalanceToAllowed } from "../internal/api/check/checkUtils/apiBalanceToAllowed.js";
|
||||
import { getApiCustomerBase } from "../internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.js";
|
||||
@@ -59,7 +60,10 @@ export const handleAllowanceUsed = async ({
|
||||
data: {
|
||||
threshold_type: "allowance_used",
|
||||
customer: newApiCustomer,
|
||||
feature: toApiFeature({ feature }),
|
||||
feature: dbToApiFeatureV1({
|
||||
dbFeature: feature,
|
||||
targetVersion: ctx.apiVersion,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -44,7 +44,6 @@ import {
|
||||
getMeteredDeduction,
|
||||
performDeduction,
|
||||
} from "./deductUtils.js";
|
||||
import { handleThresholdReached } from "./handleThresholdReached.js";
|
||||
|
||||
export type DeductParams = {
|
||||
db: DrizzleCli;
|
||||
@@ -265,7 +264,7 @@ export const performDeductionOnCusEnt = ({
|
||||
deducted: newDeducted,
|
||||
toDeduct: newToDeduct,
|
||||
} = performDeduction({
|
||||
cusEntBalance: new Decimal(entityBalance),
|
||||
cusEntBalance: new Decimal(entityBalance ?? 0),
|
||||
toDeduct: toDeductCursor,
|
||||
allowNegativeBalance,
|
||||
ent: cusEnt.entitlement,
|
||||
@@ -527,7 +526,7 @@ export const deductFromUsageBasedCusEnt = async ({
|
||||
|
||||
const cusPrice = getRelatedCusPrice(usageBasedEnt, cusPrices);
|
||||
const billingType = cusPrice?.price
|
||||
? getBillingType(cusPrice?.price.config!)
|
||||
? getBillingType(cusPrice?.price.config ?? undefined)
|
||||
: undefined;
|
||||
const blockUsageLimit =
|
||||
billingType === BillingType.InArrearProrated ? false : true;
|
||||
@@ -723,18 +722,6 @@ export const updateCustomerBalance = async ({
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
handleThresholdReached({
|
||||
org,
|
||||
env,
|
||||
features: allFeatures,
|
||||
db,
|
||||
feature,
|
||||
cusEnts: originalCusEnts,
|
||||
newCusEnts: cusEnts,
|
||||
fullCus: customer,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
return cusEnts;
|
||||
|
||||
@@ -31,7 +31,6 @@ import RecaseError from "@/utils/errorUtils.js";
|
||||
import { deductFromAdditionalBalance } from "../internal/balances/deductUtils/deductFromAdditionalBalance.js";
|
||||
import { generateId } from "../utils/genUtils.js";
|
||||
|
||||
import { handleThresholdReached } from "./handleThresholdReached.js";
|
||||
import {
|
||||
deductAllowanceFromCusEnt,
|
||||
deductFromUsageBasedCusEnt,
|
||||
@@ -475,6 +474,7 @@ export const updateUsage = async ({
|
||||
apiVersion: new ApiVersionClass(LATEST_VERSION),
|
||||
timestamp: Date.now(),
|
||||
expand: [],
|
||||
skipCache: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -520,18 +520,6 @@ export const updateUsage = async ({
|
||||
};
|
||||
|
||||
await performFeatureDeduction();
|
||||
|
||||
handleThresholdReached({
|
||||
org,
|
||||
env,
|
||||
features: allFeatures,
|
||||
db,
|
||||
feature,
|
||||
cusEnts: originalCusEnts,
|
||||
newCusEnts: cusEnts,
|
||||
fullCus: customer,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
return cusEnts;
|
||||
|
||||
@@ -90,6 +90,12 @@ export const normalizeCachedBalance = (balance: any): any => {
|
||||
}
|
||||
}
|
||||
|
||||
if (balance.feature?.event_names) {
|
||||
balance.feature.event_names = normalizeArray(
|
||||
balance.feature.event_names,
|
||||
) as typeof balance.feature.event_names;
|
||||
}
|
||||
|
||||
return balance;
|
||||
};
|
||||
|
||||
@@ -127,64 +133,39 @@ export const normalizeCachedData = <T extends ApiCustomer | ApiEntityV1>(
|
||||
// Fix missing credit_schema -> null
|
||||
if (data.balances) {
|
||||
for (const featureId in data.balances) {
|
||||
const feature = data.balances[featureId];
|
||||
const balance = data.balances[featureId];
|
||||
|
||||
// if (!feature.reset) {
|
||||
// feature.reset = null;
|
||||
// }
|
||||
|
||||
if (
|
||||
!Array.isArray(feature.breakdown) &&
|
||||
typeof feature.breakdown === "object"
|
||||
!Array.isArray(balance.breakdown) &&
|
||||
typeof balance.breakdown === "object"
|
||||
) {
|
||||
feature.breakdown = undefined;
|
||||
balance.breakdown = undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
!Array.isArray(feature.rollovers) &&
|
||||
typeof feature.rollovers === "object"
|
||||
!Array.isArray(balance.rollovers) &&
|
||||
typeof balance.rollovers === "object"
|
||||
) {
|
||||
feature.rollovers = undefined;
|
||||
balance.rollovers = undefined;
|
||||
}
|
||||
|
||||
if (feature.breakdown) {
|
||||
for (const breakdown of feature.breakdown) {
|
||||
if (balance.feature?.event_names) {
|
||||
balance.feature.event_names = normalizeArray(
|
||||
balance.feature.event_names,
|
||||
) as typeof balance.feature.event_names;
|
||||
}
|
||||
|
||||
if (balance.breakdown) {
|
||||
for (const breakdown of balance.breakdown) {
|
||||
// if (!breakdown.reset) {
|
||||
// breakdown.reset = null;
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
// if (feature.usage_limit === 0 || feature.usage_limit === null) {
|
||||
// feature.usage_limit = undefined;
|
||||
// }
|
||||
|
||||
// // Ensure credit_schema is null if undefined (for consistent schema)
|
||||
// if (feature.credit_schema === null) {
|
||||
// feature.credit_schema = undefined;
|
||||
// }
|
||||
|
||||
// if (feature.interval_count === undefined) {
|
||||
// feature.interval_count = null;
|
||||
// }
|
||||
|
||||
// // interval should be null if undefined
|
||||
// if (feature.interval === undefined) {
|
||||
// feature.interval = null;
|
||||
// }
|
||||
|
||||
// Fix breakdown usage_limit
|
||||
// if (feature.breakdown) {
|
||||
// for (const breakdown of feature.breakdown) {
|
||||
// if (breakdown.usage_limit === 0) {
|
||||
// breakdown.usage_limit = undefined;
|
||||
// }
|
||||
|
||||
// // if (breakdown.next_reset_at === undefined) {
|
||||
// // breakdown.next_reset_at = null;
|
||||
// // }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
type FullCustomer,
|
||||
type Organization,
|
||||
} from "@autumn/shared";
|
||||
import { defaultApiVersion } from "@tests/constants.js";
|
||||
import { cusProductToSubIds } from "@tests/merged/mergeUtils.test.js";
|
||||
import type Stripe from "stripe";
|
||||
import { defaultApiVersion } from "tests/constants.js";
|
||||
import { cusProductToSubIds } from "tests/merged/mergeUtils.test.js";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { priceToStripeItem } from "@/external/stripe/priceToStripeItem/priceToStripeItem.js";
|
||||
import { subIsCanceled } from "@/external/stripe/stripeSubUtils.js";
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
CreateFreeTrialSchema,
|
||||
type CreateReward,
|
||||
FeatureUsageType,
|
||||
type FreeTrial,
|
||||
FreeTrialDuration,
|
||||
type ProductItem,
|
||||
type ProductV2,
|
||||
@@ -144,15 +143,7 @@ export const constructProduct = ({
|
||||
id ||
|
||||
(isAnnual ? `${type}-annual` : interval ? `${type}-${interval}` : type);
|
||||
|
||||
let free_trial: CreateFreeTrial | null = null;
|
||||
if (freeTrial) {
|
||||
free_trial = freeTrial as FreeTrial;
|
||||
} else if (trial) {
|
||||
free_trial = CreateFreeTrialSchema.parse({
|
||||
length: 7,
|
||||
duration: FreeTrialDuration.Day,
|
||||
});
|
||||
}
|
||||
const freeTrialLength = freeTrial?.length || 7;
|
||||
|
||||
const product: ProductV2 = {
|
||||
id: id_,
|
||||
@@ -169,7 +160,15 @@ export const constructProduct = ({
|
||||
is_default: (type === "free" && isDefault) || forcePaidDefault,
|
||||
version: 1,
|
||||
group: group || "",
|
||||
free_trial: free_trial as FreeTrial,
|
||||
free_trial:
|
||||
freeTrial || trial
|
||||
? (CreateFreeTrialSchema.parse({
|
||||
length: freeTrialLength,
|
||||
duration: FreeTrialDuration.Day,
|
||||
unique_fingerprint: true,
|
||||
card_required: true,
|
||||
}) as any)
|
||||
: null,
|
||||
created_at: Date.now(),
|
||||
};
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createProducts } from "@tests/utils/productUtils.js";
|
||||
import type { TestContext } from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { deleteCachedApiCustomer } from "../../../internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer";
|
||||
|
||||
export const createSharedProducts = async ({
|
||||
products,
|
||||
@@ -33,16 +34,30 @@ export const createSharedProducts = async ({
|
||||
throw new Error("Too many customers under shared default free product");
|
||||
}
|
||||
|
||||
await ctx.db.delete(customers).where(
|
||||
and(
|
||||
inArray(
|
||||
customers.internal_id,
|
||||
cusProducts.map((cp) => cp.internal_customer_id),
|
||||
const deletedCustomers = await ctx.db
|
||||
.delete(customers)
|
||||
.where(
|
||||
and(
|
||||
inArray(
|
||||
customers.internal_id,
|
||||
cusProducts.map((cp) => cp.internal_customer_id),
|
||||
),
|
||||
eq(customers.env, ctx.env),
|
||||
eq(customers.org_id, ctx.org.id),
|
||||
),
|
||||
eq(customers.env, ctx.env),
|
||||
eq(customers.org_id, ctx.org.id),
|
||||
),
|
||||
);
|
||||
)
|
||||
.returning();
|
||||
const clearCache = [];
|
||||
for (const customer of deletedCustomers) {
|
||||
clearCache.push(
|
||||
deleteCachedApiCustomer({
|
||||
customerId: customer.id ?? "",
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await Promise.all(clearCache);
|
||||
|
||||
const autumn = new AutumnInt({
|
||||
secretKey: ctx.orgSecretKey,
|
||||
|
||||
@@ -60,5 +60,6 @@ export const createWorkerAutumnContext = async ({
|
||||
authType: AuthType.Unknown,
|
||||
apiVersion,
|
||||
timestamp: Date.now(),
|
||||
skipCache: false,
|
||||
} 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 {
|
||||
type AppEnv,
|
||||
LegacyVersion,
|
||||
OnDecrease,
|
||||
OnIncrease,
|
||||
type Organization,
|
||||
} from "@autumn/shared";
|
||||
import { FreeTrialDuration, LegacyVersion } from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import chalk from "chalk";
|
||||
import type { Stripe } from "stripe";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import {
|
||||
constructArrearProratedItem,
|
||||
constructFeatureItem,
|
||||
} from "@/utils/scriptUtils/constructItem.js";
|
||||
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
|
||||
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||
@@ -25,53 +14,23 @@ const pro = constructProduct({
|
||||
type: "pro",
|
||||
|
||||
items: [
|
||||
// constructFeatureItem({ featureId: TestFeature.Words, includedUsage: 100 }),
|
||||
constructArrearProratedItem({
|
||||
featureId: TestFeature.Users,
|
||||
includedUsage: 1,
|
||||
pricePerUnit: 10,
|
||||
config: {
|
||||
on_increase: OnIncrease.BillImmediately,
|
||||
on_decrease: OnDecrease.Prorate,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
const premium = constructProduct({
|
||||
type: "premium",
|
||||
|
||||
items: [
|
||||
// constructFeatureItem({ featureId: TestFeature.Words, includedUsage: 100 }),
|
||||
constructArrearProratedItem({
|
||||
featureId: TestFeature.Users,
|
||||
includedUsage: 1,
|
||||
pricePerUnit: 10,
|
||||
config: {
|
||||
on_increase: OnIncrease.BillImmediately,
|
||||
on_decrease: OnDecrease.Prorate,
|
||||
},
|
||||
}),
|
||||
constructFeatureItem({ featureId: TestFeature.Words, includedUsage: 100 }),
|
||||
],
|
||||
freeTrial: {
|
||||
length: 7,
|
||||
duration: FreeTrialDuration.Day,
|
||||
unique_fingerprint: false,
|
||||
card_required: true,
|
||||
},
|
||||
});
|
||||
|
||||
describe(`${chalk.yellowBright("temp: Testing add ons")}`, () => {
|
||||
const customerId = "temp";
|
||||
const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 });
|
||||
|
||||
let stripeCli: Stripe;
|
||||
let testClockId: string;
|
||||
let curUnix: number;
|
||||
let db: DrizzleCli;
|
||||
let org: Organization;
|
||||
let env: AppEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
db = ctx.db;
|
||||
org = ctx.org;
|
||||
env = ctx.env;
|
||||
stripeCli = ctx.stripeCli;
|
||||
|
||||
const { testClockId: testClockId1 } = await initCustomerV3({
|
||||
const result = await initCustomerV3({
|
||||
ctx,
|
||||
customerId,
|
||||
customerData: {},
|
||||
@@ -81,11 +40,11 @@ describe(`${chalk.yellowBright("temp: Testing add ons")}`, () => {
|
||||
|
||||
await initProductsV0({
|
||||
ctx,
|
||||
products: [pro, premium],
|
||||
products: [pro],
|
||||
prefix: customerId,
|
||||
});
|
||||
|
||||
testClockId = testClockId1!;
|
||||
testClockId = result.testClockId!;
|
||||
});
|
||||
|
||||
test("should attach pro product", async () => {
|
||||
|
||||
@@ -10,8 +10,8 @@ import chalk from "chalk";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
|
||||
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||
import { initCustomerV3 } from "../../../src/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||
import { sharedDefaultFree } from "./sharedProducts.js";
|
||||
|
||||
const free2 = constructProduct({
|
||||
|
||||
@@ -108,6 +108,7 @@ describe(`${chalk.yellowBright("basic8: Testing trial duplicates (same fingerpri
|
||||
});
|
||||
|
||||
const customer = await AutumnCli.getCustomer(customerId2);
|
||||
console.log(JSON.stringify(customer, null, 2));
|
||||
|
||||
await expectCustomerV0Correct({
|
||||
sent: proWithTrial,
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { beforeAll, describe, expect, test } from "bun:test";
|
||||
import { ApiVersion, type CheckResponseV1, SuccessCode } from "@autumn/shared";
|
||||
import chalk from "chalk";
|
||||
import {
|
||||
ApiVersion,
|
||||
AppEnv,
|
||||
type CheckResponseV1,
|
||||
SuccessCode,
|
||||
} from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import chalk from "chalk";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { timeout } from "@/utils/genUtils.js";
|
||||
import { OrgService } from "@/internal/orgs/OrgService.js";
|
||||
import { generatePublishableKey } from "@/utils/encryptUtils.js";
|
||||
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
|
||||
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||
import { timeout } from "../../../utils/genUtils.js";
|
||||
|
||||
const messagesFeature = constructFeatureItem({
|
||||
featureId: TestFeature.Messages,
|
||||
@@ -46,10 +53,30 @@ describe(`${chalk.yellowBright("check8: test public key & send_event")}`, () =>
|
||||
product_id: freeProd.id,
|
||||
});
|
||||
|
||||
// Ensure test_pkey is set on the org (needed for public key tests)
|
||||
if (!ctx.org.test_pkey) {
|
||||
const testPkey = generatePublishableKey(AppEnv.Sandbox);
|
||||
await OrgService.update({
|
||||
db: ctx.db,
|
||||
orgId: ctx.org.id,
|
||||
updates: {
|
||||
test_pkey: testPkey,
|
||||
},
|
||||
});
|
||||
// Update the context org object
|
||||
ctx.org.test_pkey = testPkey;
|
||||
}
|
||||
|
||||
if (!ctx.org.test_pkey.startsWith("am_pk")) {
|
||||
throw new Error(
|
||||
`test_pkey "${ctx.org.test_pkey}" does not start with "am_pk". Expected format: am_pk_test_...`,
|
||||
);
|
||||
}
|
||||
|
||||
// Initialize Autumn client with public key
|
||||
autumnPublic = new AutumnInt({
|
||||
version: ApiVersion.V1_2,
|
||||
secretKey: ctx.org.test_pkey!,
|
||||
secretKey: ctx.org.test_pkey,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { beforeAll, describe, expect, test } from "bun:test";
|
||||
import { type ApiCustomer, ApiVersion, type LimitedItem } from "@autumn/shared";
|
||||
import chalk from "chalk";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import chalk from "chalk";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { CusService } from "@/internal/customers/CusService.js";
|
||||
import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js";
|
||||
@@ -113,7 +113,7 @@ describe(`${chalk.yellowBright("balances-update3: Balance decoupling tests")}`,
|
||||
expect(cusEnt?.additional_balance).toBe(10); // Added
|
||||
expect(cusEnt?.additional_granted_balance).toBe(10); // Added
|
||||
|
||||
const customer = await autumnV2.customers.get(customerId);
|
||||
const customer = await autumnV2.customers.get<ApiCustomer>(customerId);
|
||||
const balance = customer.balances[TestFeature.Users];
|
||||
// current = 0 + 0 + 10 = 10
|
||||
expect(balance.current_balance).toBe(10);
|
||||
@@ -141,10 +141,10 @@ describe(`${chalk.yellowBright("balances-update3: Balance decoupling tests")}`,
|
||||
expect(cusEnt?.additional_balance).toBe(5); // 10 - 5
|
||||
expect(cusEnt?.additional_granted_balance).toBe(5); // 10 - 5
|
||||
|
||||
const customer = await autumnV2.customers.get(customerId);
|
||||
const feature = customer.features[TestFeature.Users];
|
||||
// current = 0 + 0 + 5 = 5
|
||||
expect(feature.current_balance).toBe(5);
|
||||
const customer = await autumnV2.customers.get<ApiCustomer>(customerId);
|
||||
const balance = customer.balances[TestFeature.Users];
|
||||
|
||||
expect(balance.current_balance).toBe(5);
|
||||
});
|
||||
|
||||
test("CASE C: balances.update REMOVE with insufficient additional_balance", async () => {
|
||||
@@ -168,9 +168,9 @@ describe(`${chalk.yellowBright("balances-update3: Balance decoupling tests")}`,
|
||||
expect(cusEnt?.additional_balance).toBe(0); // Floored
|
||||
expect(cusEnt?.additional_granted_balance).toBe(0); // 5 - 5
|
||||
|
||||
const customer = await autumnV2.customers.get(customerId);
|
||||
const feature = customer.features[TestFeature.Users];
|
||||
expect(feature.current_balance).toBe(0);
|
||||
const customer = await autumnV2.customers.get<ApiCustomer>(customerId);
|
||||
const balance = customer.balances[TestFeature.Users];
|
||||
expect(balance.current_balance).toBe(0);
|
||||
});
|
||||
|
||||
test("Track +5 then update REMOVE to trigger main balance deduction", async () => {
|
||||
@@ -269,13 +269,12 @@ describe(`${chalk.yellowBright("balances-update3: Balance decoupling tests")}`,
|
||||
expect(cusEnt?.additional_balance).toBe(0); // Unchanged
|
||||
expect(cusEnt?.additional_granted_balance).toBe(0); // Unchanged
|
||||
|
||||
const customer = await autumnV2.customers.get(customerId);
|
||||
const feature = customer.features[TestFeature.Users];
|
||||
// current = Math.max(0, 0) + 0 = 0
|
||||
// purchased = 0
|
||||
expect(feature.current_balance).toBe(0);
|
||||
expect(feature.purchased_balance).toBe(0);
|
||||
expect(feature.granted_balance).toBe(0);
|
||||
expect(feature.usage).toBe(0);
|
||||
const customer = await autumnV2.customers.get<ApiCustomer>(customerId);
|
||||
const balance = customer.balances[TestFeature.Users];
|
||||
|
||||
expect(balance.current_balance).toBe(0);
|
||||
expect(balance.purchased_balance).toBe(0);
|
||||
expect(balance.granted_balance).toBe(0);
|
||||
expect(balance.usage).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,11 +8,11 @@ import {
|
||||
OnDecrease,
|
||||
OnIncrease,
|
||||
} from "@autumn/shared";
|
||||
import chalk from "chalk";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js";
|
||||
import { useEntityBalanceAndExpect } from "@tests/utils/expectUtils/expectContUse/expectEntityUtils.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import chalk from "chalk";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { timeout } from "@/utils/genUtils.js";
|
||||
import {
|
||||
@@ -130,7 +130,7 @@ describe(`${chalk.yellowBright(`contUse/${testCase}: Testing per entity features
|
||||
const entRes = await autumn.check({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
entity_id: entity.id,
|
||||
entity_id: entity.id ?? "",
|
||||
});
|
||||
|
||||
expect(entRes.balance).toBe(perEntityItem.included_usage);
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
type CreatePlanParams,
|
||||
FreeTrialDuration,
|
||||
Infinite,
|
||||
ProductItemInterval,
|
||||
ResetInterval,
|
||||
TierInfinite,
|
||||
UsageModel,
|
||||
@@ -115,7 +114,7 @@ describe(chalk.yellowBright("Plan V2 - Cross-Version Consistency"), () => {
|
||||
const v1_2 = (await autumnV1_2.products.get(
|
||||
"trial_transform",
|
||||
)) as ApiProduct;
|
||||
expect(v1_2.free_trial!.duration).toBe(ProductItemInterval.Day);
|
||||
expect(v1_2.free_trial!.duration).toBe(FreeTrialDuration.Day);
|
||||
expect(v1_2.free_trial!.length).toBe(7);
|
||||
expect(v1_2.free_trial!.unique_fingerprint).toBe(true); // Always true in V1.2
|
||||
});
|
||||
|
||||
@@ -65,7 +65,7 @@ export function parseTestOutput(output: string): TestSummary {
|
||||
}
|
||||
}
|
||||
|
||||
passedFiles = totalFiles - failedFiles;
|
||||
let passedFiles = totalFiles - failedFiles;
|
||||
|
||||
// Extract failure details
|
||||
let currentTestFile = "";
|
||||
|
||||
@@ -167,10 +167,10 @@ export const calcProrationAndExpectInvoice = async ({
|
||||
|
||||
expect(invoices.length).to.equal(
|
||||
numInvoices,
|
||||
`Should have ${numInvoices} invoices`,
|
||||
`Should have ${numInvoices} invoices; got ${invoices.length}`,
|
||||
);
|
||||
expect(invoices[0].total).to.equal(
|
||||
proratedAmount,
|
||||
"Latest invoice should be equals to calculated prorated amount",
|
||||
`Latest invoice should be equals to calculated prorated amount; got ${invoices[0].total}`,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,15 +4,15 @@ import {
|
||||
SuccessResponseSchema,
|
||||
} from "../../../common/commonResponses.js";
|
||||
import {
|
||||
ApiFeatureSchema,
|
||||
ApiFeatureV0Schema,
|
||||
FEATURE_EXAMPLE,
|
||||
} from "../../../features/apiFeature.js";
|
||||
} from "../../../features/prevVersions/apiFeatureV0.js";
|
||||
import {
|
||||
CreateFeatureParamsSchema,
|
||||
UpdateFeatureParamsSchema,
|
||||
} from "../../../features/featureOpModels.js";
|
||||
} from "../../../features/prevVersions/featureV0OpModels.js";
|
||||
|
||||
export const ApiFeatureWithMeta = ApiFeatureSchema.extend({
|
||||
export const ApiFeatureWithMeta = ApiFeatureV0Schema.extend({
|
||||
type: z.enum(["boolean", "single_use", "continuous_use", "credit_system"]),
|
||||
}).meta({
|
||||
id: "Feature",
|
||||
|
||||
@@ -1,25 +1,14 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const UpdateBalancesParamsSchema = z
|
||||
.object({
|
||||
balances: z.array(
|
||||
z.object({
|
||||
feature_id: z.string().meta({
|
||||
description: "The ID of the feature to update balance for.",
|
||||
}),
|
||||
balance: z.number().meta({
|
||||
description: "The new balance value.",
|
||||
}),
|
||||
export const UpdateBalancesParamsSchema = z.object({
|
||||
balances: z.array(
|
||||
z.object({
|
||||
feature_id: z.string().meta({
|
||||
description: "The ID of the feature to update balance for.",
|
||||
}),
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
example: {
|
||||
balances: [
|
||||
{
|
||||
feature_id: "tokens",
|
||||
balance: 1000,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
balance: z.number().meta({
|
||||
description: "The new balance value.",
|
||||
}),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
AffectedResource,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ApiFeatureSchema } from "@api/features/apiFeature.js";
|
||||
import { ResetInterval } from "@models/productModels/intervals/resetInterval.js";
|
||||
import { z } from "zod/v4";
|
||||
import { ApiFeatureV1Schema } from "../../features/apiFeatureV1.js";
|
||||
|
||||
export const ApiBalanceResetSchema = z.object({
|
||||
interval: z.enum(ResetInterval).or(z.literal("multiple")),
|
||||
@@ -26,7 +26,7 @@ export const ApiBalanceBreakdownSchema = z.object({
|
||||
|
||||
export const ApiBalanceSchema = z.object({
|
||||
feature_id: z.string(),
|
||||
feature: ApiFeatureSchema.optional(),
|
||||
feature: ApiFeatureV1Schema.optional(),
|
||||
unlimited: z.boolean(),
|
||||
|
||||
granted_balance: z.number(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ApiFeature, ApiFeatureType } from "@api/features/apiFeature.js";
|
||||
import { ApiFeatureType } from "@api/features/prevVersions/apiFeatureV0.js";
|
||||
import { ApiVersion } from "@api/versionUtils/ApiVersion.js";
|
||||
import {
|
||||
AffectedResource,
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
import { EntInterval } from "@models/productModels/intervals/entitlementInterval.js";
|
||||
import { Decimal } from "decimal.js";
|
||||
import type { z } from "zod/v4";
|
||||
import { FeatureType } from "../../../../models/featureModels/featureEnums.js";
|
||||
import { resetIntvToEntIntv } from "../../../../utils/planFeatureUtils/planFeatureIntervals.js";
|
||||
import type { ApiFeatureV1 } from "../../../features/apiFeatureV1.js";
|
||||
import {
|
||||
type ApiBalance,
|
||||
type ApiBalanceBreakdown,
|
||||
@@ -38,14 +40,14 @@ const resetToV3IntervalParams = ({
|
||||
unlimited,
|
||||
}: {
|
||||
input: ApiBalance | ApiBalanceBreakdown;
|
||||
feature?: ApiFeature;
|
||||
feature?: ApiFeatureV1;
|
||||
unlimited: boolean;
|
||||
}): {
|
||||
interval: EntInterval | "multiple" | null;
|
||||
interval_count: number | null;
|
||||
next_reset_at: number | null;
|
||||
} => {
|
||||
const isBoolean = feature?.type === ApiFeatureType.Boolean;
|
||||
const isBoolean = feature?.type === FeatureType.Boolean;
|
||||
|
||||
// 1. No reset
|
||||
if (!input.reset)
|
||||
@@ -72,10 +74,20 @@ const resetToV3IntervalParams = ({
|
||||
};
|
||||
};
|
||||
|
||||
const toV3Type = ({ feature }: { feature?: ApiFeature }) => {
|
||||
if (feature?.type === ApiFeatureType.Boolean) {
|
||||
const toV3Type = ({ feature }: { feature?: ApiFeatureV1 }) => {
|
||||
if (feature?.type === FeatureType.Boolean) {
|
||||
return ApiFeatureType.Static;
|
||||
} else return feature?.type ?? ApiFeatureType.SingleUsage;
|
||||
} else if (feature?.type === FeatureType.Metered) {
|
||||
if (feature.consumable) {
|
||||
return ApiFeatureType.SingleUsage;
|
||||
} else {
|
||||
return ApiFeatureType.ContinuousUse;
|
||||
}
|
||||
} else if (feature?.type === FeatureType.CreditSystem) {
|
||||
return ApiFeatureType.CreditSystem;
|
||||
} else {
|
||||
return ApiFeatureType.Static;
|
||||
}
|
||||
};
|
||||
|
||||
const toV3BalanceParams = ({
|
||||
@@ -85,11 +97,11 @@ const toV3BalanceParams = ({
|
||||
legacyData,
|
||||
}: {
|
||||
input: ApiBalance | ApiBalanceBreakdown;
|
||||
feature?: ApiFeature;
|
||||
feature?: ApiFeatureV1;
|
||||
unlimited: boolean;
|
||||
legacyData?: CusFeatureLegacyData;
|
||||
}) => {
|
||||
const isBoolean = feature?.type === ApiFeatureType.Boolean;
|
||||
const isBoolean = feature?.type === FeatureType.Boolean;
|
||||
|
||||
if (isBoolean || unlimited) {
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiFeatureType } from "@api/features/apiFeature.js";
|
||||
import { ApiFeatureType } from "@api/features/prevVersions/apiFeatureV0.js";
|
||||
import { EntInterval } from "@models/productModels/intervals/entitlementInterval.js";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
|
||||
@@ -102,14 +102,9 @@ export const UpdateCustomerParamsSchema = z.object({
|
||||
name: z.string().nullish().meta({
|
||||
description: "The customer's name.",
|
||||
}),
|
||||
email: z
|
||||
.string()
|
||||
.email({ message: "not a valid email address" })
|
||||
.or(z.literal(""))
|
||||
.nullish()
|
||||
.meta({
|
||||
description: "The customer's email address.",
|
||||
}),
|
||||
email: z.email({ message: "not a valid email address" }).nullish().meta({
|
||||
description: "Customer's email address",
|
||||
}),
|
||||
fingerprint: z.string().nullish().meta({
|
||||
description:
|
||||
"Unique identifier (eg, serial number) to detect duplicate customers.",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user