diff --git a/server/src/internal/api/customers/handlers/handleCreateCustomer.ts b/server/src/internal/api/customers/handlers/handleCreateCustomer.ts index 0f7993899..0acdc2bf6 100644 --- a/server/src/internal/api/customers/handlers/handleCreateCustomer.ts +++ b/server/src/internal/api/customers/handlers/handleCreateCustomer.ts @@ -425,6 +425,10 @@ export const handlePostCustomerRequest = async (req: any, res: any) => { CusProductStatus.Scheduled, ], expand, + + features, + entityId: data.entity_id, + entityData: data.entity_data, }); let cusDetails = await getCustomerDetails({ diff --git a/server/src/internal/api/customers/products/attachRouter.ts b/server/src/internal/api/customers/products/attachRouter.ts index 441fe25ad..33c2020a3 100644 --- a/server/src/internal/api/customers/products/attachRouter.ts +++ b/server/src/internal/api/customers/products/attachRouter.ts @@ -255,8 +255,9 @@ attachRouter.post("/attach", async (req: any, res) => { const { customer_id, product_id, - entity_id, customer_data, + entity_id, + entity_data, is_custom, // prices, @@ -315,7 +316,6 @@ attachRouter.post("/attach", async (req: any, res) => { entityId: entity_id, customerData: customer_data, org, - orgId: org.id, features, env, itemsInput, @@ -325,6 +325,7 @@ attachRouter.post("/attach", async (req: any, res) => { productIds: product_ids, logger, version, + entityData: entity_data, }); attachParams.apiVersion = diff --git a/server/src/internal/api/entities/EntityService.ts b/server/src/internal/api/entities/EntityService.ts index 84250cf62..9e5472258 100644 --- a/server/src/internal/api/entities/EntityService.ts +++ b/server/src/internal/api/entities/EntityService.ts @@ -119,14 +119,22 @@ export class EntityService { internalId: string; update: any; }) { - const { error } = await sb + const { data, error } = await sb .from("entities") .update(update) - .eq("internal_id", internalId); + .eq("internal_id", internalId) + .select() + .single(); if (error) { + if (error.code === "PGRST116") { + return null; + } + throw error; } + + return data; } static async getByInternalCustomerId({ diff --git a/server/src/internal/api/entities/entityRouter.ts b/server/src/internal/api/entities/entityRouter.ts index ac6b56941..85bed520c 100644 --- a/server/src/internal/api/entities/entityRouter.ts +++ b/server/src/internal/api/entities/entityRouter.ts @@ -1,5 +1,5 @@ import { Router } from "express"; -import { handleCreateEntity } from "./handleCreateEntity.js"; + import { handleDeleteEntity } from "./handleDeleteEntity.js"; import { routeHandler } from "@/utils/routerUtils.js"; import { EntityService } from "./EntityService.js"; @@ -7,6 +7,7 @@ import { CusService } from "@/internal/customers/CusService.js"; import RecaseError from "@/utils/errorUtils.js"; import { ErrCode } from "@autumn/shared"; import { handleGetEntity } from "./handleGetEntity.js"; +import { handlePostEntityRequest } from "./handleCreateEntity.js"; export const entityRouter = Router({ mergeParams: true }); @@ -49,10 +50,11 @@ entityRouter.get("", (req, res) => }) ); -entityRouter.get("/:entity_id", handleGetEntity); - // 1. Create entity -entityRouter.post("", handleCreateEntity); +entityRouter.post("", handlePostEntityRequest); // 2. Delete entity entityRouter.delete("/:entity_id", handleDeleteEntity); + +// 3. Get entity +entityRouter.get("/:entity_id", handleGetEntity); diff --git a/server/src/internal/api/entities/handleCreateEntity.ts b/server/src/internal/api/entities/handleCreateEntity.ts index f2561690b..c52d7008d 100644 --- a/server/src/internal/api/entities/handleCreateEntity.ts +++ b/server/src/internal/api/entities/handleCreateEntity.ts @@ -11,6 +11,8 @@ import { CusProductStatus, Entity, ErrCode, + Feature, + Organization, } from "@autumn/shared"; import { generateId, notNullish, nullish } from "@/utils/genUtils.js"; import { adjustAllowance } from "@/trigger/adjustAllowance.js"; @@ -19,18 +21,26 @@ import { getEntityResponse } from "./getEntityUtils.js"; import { StatusCodes } from "http-status-codes"; import { orgToVersion } from "@/utils/versionUtils.js"; +interface CreateEntityData { + id: string; + name?: string; + feature_id?: string; +} + export const constructEntity = ({ inputEntity, feature, internalCustomerId, orgId, env, + deleted = false, }: { inputEntity: any; feature: any; internalCustomerId: string; orgId: string; env: AppEnv; + deleted?: boolean; }) => { let entity: Entity = { internal_id: generateId("ety"), @@ -41,7 +51,7 @@ export const constructEntity = ({ internal_feature_id: feature.internal_id, org_id: orgId, env, - deleted: false, + deleted, created_at: Date.now(), }; @@ -148,46 +158,44 @@ export const logEntityToAction = ({ export const validateAndGetInputEntities = async ({ sb, - req, - customer_id, orgId, + features, + customerId, + createEntityData, env, logger, }: { sb: any; - req: any; - customer_id: string; orgId: string; + features: Feature[]; + customerId: string; env: AppEnv; + createEntityData: CreateEntityData[] | CreateEntityData; logger: any; }) => { // 1. Get customer, features and orgs - let [customer, features, org] = await Promise.all([ - CusService.getWithProducts({ - sb, - idOrInternalId: customer_id, - orgId, - env, - inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue], - withEntities: true, - }), - FeatureService.getFromReq(req), - OrgService.getFromReq(req), - ]); + let customer = await CusService.getWithProducts({ + sb, + idOrInternalId: customerId, + orgId, + env, + inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue], + withEntities: true, + }); if (!customer) { throw new RecaseError({ - message: `Customer ${customer_id} not found`, + message: `Customer ${customerId} not found`, code: ErrCode.CustomerNotFound, }); } // 2. Get input entities let inputEntities: any[] = []; - if (Array.isArray(req.body)) { - inputEntities = req.body; + if (Array.isArray(createEntityData)) { + inputEntities = createEntityData; } else { - inputEntities = [req.body]; + inputEntities = [createEntityData]; } let featureIds = [...new Set(inputEntities.map((e: any) => e.feature_id))]; @@ -204,7 +212,7 @@ export const validateAndGetInputEntities = async ({ if (!feature) { throw new RecaseError({ - message: `Feature ${feature_id} not found`, + message: `Create entity failed: feature ${feature_id} not found`, code: ErrCode.FeatureNotFound, statusCode: StatusCodes.NOT_FOUND, }); @@ -246,7 +254,6 @@ export const validateAndGetInputEntities = async ({ return { customer, features, - org, inputEntities, feature_id, feature, @@ -255,191 +262,249 @@ export const validateAndGetInputEntities = async ({ }; }; -export const handleCreateEntity = async (req: any, res: any) => { +export const createEntities = async ({ + sb, + env, + org, + features, + logger, + customerId, + createEntityData, + withAutumnId = false, + apiVersion, + fromAutoCreate = false, +}: { + sb: any; + org: Organization; + features: Feature[]; + env: AppEnv; + logger: any; + customerId: string; + createEntityData: CreateEntityData[] | CreateEntityData; + withAutumnId?: boolean; + apiVersion?: APIVersion; + fromAutoCreate?: boolean; +}) => { + const { + customer, + inputEntities, + feature_id, + feature, + cusProducts, + existingEntities, + } = await validateAndGetInputEntities({ + sb, + customerId, + orgId: org.id, + env, + logger, + createEntityData, + features, + }); + + const entityToAction = getEntityToAction({ + inputEntities, + existingEntities, + logger, + feature, + cusProducts, + }); + + logEntityToAction({ + entityToAction, + logger, + }); + + // 3. CREATE LINKED CUSTOMER ENTITLEMENTS + for (const cusProduct of cusProducts) { + let cusEnts = cusProduct.customer_entitlements; + let product = cusProduct.product; + + let mainCusEnt = cusEnts.find( + (e: any) => e.entitlement.feature.id === feature_id + ); + + // Get linked features + let linkedCusEnts = cusEnts.filter( + (e: any) => e.entitlement.entity_feature_id === feature.id + ); + + if (linkedCusEnts.length > 0 && inputEntities.some((e: any) => !e.id)) { + throw new RecaseError({ + message: "Entity ID is required", + code: ErrCode.EntityIdRequired, + statusCode: StatusCodes.BAD_REQUEST, + }); + } + + // 1. Pay for new seats + let replacedCount = Object.keys(entityToAction).filter( + (id) => entityToAction[id].action === "replace" + ).length; + + let newCount = Object.keys(entityToAction).filter( + (id) => entityToAction[id].action === "create" + ).length; + + if (mainCusEnt) { + if (fromAutoCreate) { + return []; + } + + let { unused } = getCusEntMasterBalance({ + cusEnt: mainCusEnt, + entities: existingEntities, + }); + + const originalBalance = mainCusEnt.balance + (unused || 0); + const newBalance = + mainCusEnt.balance - (newCount + replacedCount) + (unused || 0); + + await adjustAllowance({ + sb, + env, + org, + cusPrices: cusProducts.flatMap((p: any) => p.customer_prices), + customer, + affectedFeature: feature, + cusEnt: { ...mainCusEnt, customer_product: cusProduct }, + originalBalance, + newBalance, + deduction: newCount + replacedCount, + product, + replacedCount, + fromEntities: true, + }); + + await CustomerEntitlementService.update({ + sb, + id: mainCusEnt.id, + updates: { balance: mainCusEnt.balance - newCount }, + }); + + // await pg.query( + // `UPDATE customer_entitlements SET balance = balance - $1 WHERE id = $2`, + // [newCount, mainCusEnt.id] + // ); + } + + for (const linkedCusEnt of linkedCusEnts) { + let allowance = linkedCusEnt?.entitlement.allowance; + let newEntities = linkedCusEnt?.entities || {}; + + for (const entity of inputEntities) { + let entityAction = entityToAction[entity.id]; + + if (entityAction.action === "create") { + newEntities[entity.id] = { + id: entity.id, + balance: allowance, + adjustment: 0, + }; + } else if (entityAction.action === "replace") { + let tmp = newEntities[entityAction.replace.id]; + delete newEntities[entityAction.replace.id]; + newEntities[entity.id] = { + id: entity.id, + ...tmp, + }; + } + } + + await CustomerEntitlementService.update({ + sb, + id: linkedCusEnt.id, + updates: { entities: newEntities }, + }); + } + } + + // 4. CREATE ENTITIES + let newEntities: Entity[] = []; + for (const id in entityToAction) { + let { action, entity, replace } = entityToAction[id]; + + // Create and add to customer entitlement? + if (action === "create") { + let newEntity = await EntityService.insert({ + sb, + data: constructEntity({ + inputEntity: entity, + feature, + internalCustomerId: customer.internal_id, + orgId: org.id, + env, + }), + }); + + newEntities.push(newEntity); + } else if (action === "replace") { + let updatedEntity = await EntityService.update({ + sb, + internalId: replace.internal_id, + update: { + id: entity.id, + name: entity.name, + deleted: false, + }, + }); + + newEntities.push(updatedEntity); + } + } + + if (fromAutoCreate) { + return newEntities; + } + + let { entities } = await getEntityResponse({ + sb, + entityIds: inputEntities.map((e: any) => e.id), + org, + env, + customerId: customer.id || customer.internal_id, + withAutumnId, + apiVersion: apiVersion!, + }); + + return entities; +}; + +export const handlePostEntityRequest = async (req: any, res: any) => { try { // Create entity! + const { sb, pg, env, logtail: logger } = req; - const { sb, env, orgId, logtail: logger } = req; - const { customer_id } = req.params; - - const { - customer, - features, - org, - inputEntities, - feature_id, - feature, - cusProducts, - existingEntities, - } = await validateAndGetInputEntities({ - sb, - req, - customer_id, - orgId, - env, - logger, - }); - - const entityToAction = getEntityToAction({ - inputEntities, - existingEntities, - logger, - feature, - cusProducts, - }); - - logEntityToAction({ - entityToAction, - logger, - }); - - // 3. CREATE LINKED CUSTOMER ENTITLEMENTS - for (const cusProduct of cusProducts) { - let cusEnts = cusProduct.customer_entitlements; - let product = cusProduct.product; - - let mainCusEnt = cusEnts.find( - (e: any) => e.entitlement.feature.id === feature_id - ); - - // Get linked features - let linkedCusEnts = cusEnts.filter( - (e: any) => e.entitlement.entity_feature_id === feature.id - ); - - if (linkedCusEnts.length > 0 && inputEntities.some((e: any) => !e.id)) { - throw new RecaseError({ - message: "Entity ID is required", - code: ErrCode.EntityIdRequired, - statusCode: StatusCodes.BAD_REQUEST, - }); - } - - // 1. Pay for new seats - let replacedCount = Object.keys(entityToAction).filter( - (id) => entityToAction[id].action === "replace" - ).length; - - let newCount = Object.keys(entityToAction).filter( - (id) => entityToAction[id].action === "create" - ).length; - - if (mainCusEnt) { - let { unused } = getCusEntMasterBalance({ - cusEnt: mainCusEnt, - entities: existingEntities, - }); - - const originalBalance = mainCusEnt.balance + (unused || 0); - const newBalance = - mainCusEnt.balance - (newCount + replacedCount) + (unused || 0); - - await adjustAllowance({ - sb, - env, - org, - cusPrices: cusProducts.flatMap((p: any) => p.customer_prices), - customer, - affectedFeature: feature, - cusEnt: { ...mainCusEnt, customer_product: cusProduct }, - originalBalance, - newBalance, - deduction: newCount + replacedCount, - product, - replacedCount, - fromEntities: true, - }); - - await req.pg.query( - `UPDATE customer_entitlements SET balance = balance - $1 WHERE id = $2`, - [newCount, mainCusEnt.id] - ); - } - - for (const linkedCusEnt of linkedCusEnts) { - let allowance = linkedCusEnt?.entitlement.allowance; - let newEntities = linkedCusEnt?.entities || {}; - - for (const entity of inputEntities) { - let entityAction = entityToAction[entity.id]; - - if (entityAction.action === "create") { - newEntities[entity.id] = { - id: entity.id, - balance: allowance, - adjustment: 0, - }; - } else if (entityAction.action === "replace") { - let tmp = newEntities[entityAction.replace.id]; - delete newEntities[entityAction.replace.id]; - newEntities[entity.id] = { - id: entity.id, - ...tmp, - }; - } - } - - await CustomerEntitlementService.update({ - sb, - id: linkedCusEnt.id, - updates: { entities: newEntities }, - }); - } - } - - // 4. CREATE ENTITIES - for (const id in entityToAction) { - let { action, entity, replace } = entityToAction[id]; - - // Create and add to customer entitlement? - if (action === "create") { - await EntityService.insert({ - sb, - data: constructEntity({ - inputEntity: entity, - feature, - internalCustomerId: customer.internal_id, - orgId, - env, - }), - }); - } else if (action === "replace") { - await EntityService.update({ - sb, - internalId: replace.internal_id, - update: { - id: entity.id, - name: entity.name, - deleted: false, - }, - }); - } - } + const [org, features] = await Promise.all([ + OrgService.getFromReq(req), + FeatureService.getFromReq(req), + ]); let apiVersion = orgToVersion({ org, reqApiVersion: req.apiVersion, }); + const entities = await createEntities({ + sb, + org, + features, + logger, + env, + customerId: req.params.customer_id, + createEntityData: req.body, + withAutumnId: req.query.with_autumn_id === "true", + apiVersion, + }); + + logger.info(` Created / replaced entities!`); + if (apiVersion < APIVersion.v1_2) { res.status(200).json({ success: true, }); return; } - - let { entities } = await getEntityResponse({ - sb, - entityIds: inputEntities.map((e: any) => e.id), - org, - env, - customerId: customer.id || customer.internal_id, - withAutumnId: req.query.with_autumn_id === "true", - apiVersion, - }); - - logger.info(` Created / replaced entities!`); - if (Array.isArray(req.body)) { res.status(200).json({ list: entities, diff --git a/server/src/internal/api/entities/handleDeleteEntity.ts b/server/src/internal/api/entities/handleDeleteEntity.ts index ed3b17d5f..4b2c4a4e7 100644 --- a/server/src/internal/api/entities/handleDeleteEntity.ts +++ b/server/src/internal/api/entities/handleDeleteEntity.ts @@ -13,9 +13,9 @@ import { import { fullCusProductToCusEnts } from "@/internal/customers/products/cusProductUtils.js"; import { removeEntityFromCusEnt } from "./entityUtils.js"; import { CustomerEntitlementService } from "@/internal/customers/entitlements/CusEntitlementService.js"; -import { cancelFutureProductSchedule } from "@/internal/customers/change-product/scheduleUtils.js"; + import { createStripeCli } from "@/external/stripe/utils.js"; -import { CusProductService } from "@/internal/customers/products/CusProductService.js"; + import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js"; import { cancelCurSubs } from "@/internal/customers/change-product/handleDowngrade/cancelCurSubs.js"; import { removeScheduledProduct } from "../customers/handlers/handleCusProductExpired.js"; diff --git a/server/src/internal/api/entitled/entitledRouter.ts b/server/src/internal/api/entitled/entitledRouter.ts index 49defdd85..496c1a35d 100644 --- a/server/src/internal/api/entitled/entitledRouter.ts +++ b/server/src/internal/api/entitled/entitledRouter.ts @@ -9,17 +9,18 @@ import { Organization, } from "@autumn/shared"; +import { + cusEntsContainFeature, + getFeatureBalance, + getUnlimitedAndUsageAllowed, +} from "@/internal/customers/entitlements/cusEntUtils.js"; + import { Router } from "express"; import { StatusCodes } from "http-status-codes"; import { handleEventSent } from "../events/eventRouter.js"; import { FeatureService } from "@/internal/features/FeatureService.js"; import { SupabaseClient } from "@supabase/supabase-js"; -import { - cusEntsContainFeature, - getFeatureBalance, - getUnlimitedAndUsageAllowed, -} from "@/internal/customers/entitlements/cusEntUtils.js"; import { featureToCreditSystem } from "@/internal/features/creditSystemUtils.js"; import { notNullish, nullish } from "@/utils/genUtils.js"; import { OrgService } from "@/internal/orgs/OrgService.js"; @@ -27,7 +28,6 @@ import { SuccessCode } from "@autumn/shared"; import { handleProductCheck } from "./handlers/handleProductCheck.js"; import { getBooleanEntitledResult } from "./checkUtils.js"; import { getOrCreateCustomer } from "@/internal/customers/cusUtils/getOrCreateCustomer.js"; - import { getCheckPreview } from "./getCheckPreview.js"; import { orgToVersion } from "@/utils/versionUtils.js"; @@ -201,40 +201,35 @@ const getCusEntsAndFeatures = async ({ }) => { let { customer_id, feature_id, customer_data, entity_id } = req.body; - let { sb, orgId, env } = req; + let { sb, env } = req; // 1. Get org and features const startTime = Date.now(); // Fetch org, feature, and customer in parallel - const [org, featureRes, customer] = await Promise.all([ + const [org, featureRes] = await Promise.all([ OrgService.getFromReq(req), getFeatureAndCreditSystems({ req, featureId: feature_id, }), - getOrCreateCustomer({ - sb, - org: req.org, - env, - customerId: customer_id, - customerData: customer_data, - inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue], - logger, - entityId: entity_id, - }), ]); - if (entity_id && !customer.entity) { - throw new RecaseError({ - message: `Entity ${entity_id} not found for customer ${customer_id}`, - code: ErrCode.EntityNotFound, - statusCode: StatusCodes.BAD_REQUEST, - }); - } - const { feature, creditSystems, allFeatures } = featureRes; + const customer = await getOrCreateCustomer({ + sb, + org: req.org, + env, + customerId: customer_id, + customerData: customer_data, + inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue], + logger, + entityId: entity_id, + entityData: req.body.entity_data, + features: allFeatures, + }); + const duration = Date.now() - startTime; console.log(`/check: fetched org, features & customer in ${duration}ms`); diff --git a/server/src/internal/api/entitled/handlers/handleProductCheck.ts b/server/src/internal/api/entitled/handlers/handleProductCheck.ts index a5b5f10b3..cadd63888 100644 --- a/server/src/internal/api/entitled/handlers/handleProductCheck.ts +++ b/server/src/internal/api/entitled/handlers/handleProductCheck.ts @@ -14,6 +14,7 @@ import { getAttachPreview } from "./getAttachPreview.js"; import { getCusPaymentMethod } from "@/external/stripe/stripeCusUtils.js"; import { getOrCreateCustomer } from "@/internal/customers/cusUtils/getOrCreateCustomer.js"; import { FeatureService } from "@/internal/features/FeatureService.js"; +import { getOrgAndFeatures } from "@/internal/orgs/orgUtils.js"; export const handleProductCheck = async ({ req, @@ -22,34 +23,43 @@ export const handleProductCheck = async ({ req: any; res: any; }) => { - const { customer_id, product_id, entity_id, customer_data, with_preview } = - req.body; + const { + customer_id, + product_id, + entity_id, + customer_data, + with_preview, + entity_data, + } = req.body; const { orgId, sb, env, logtail: logger } = req; + let { org, features } = await getOrgAndFeatures({ req }); + // 1. Get customer and org - let [customer, org, product, features] = await Promise.all([ + let [customer, product] = await Promise.all([ getOrCreateCustomer({ sb, - org: req.org, + org, env, customerId: customer_id, customerData: customer_data, - entityId: entity_id, logger, inStatuses: [ CusProductStatus.Active, CusProductStatus.PastDue, CusProductStatus.Scheduled, ], + features, + + entityId: entity_id, + entityData: entity_data, }), - OrgService.getFromReq(req), ProductService.getFullProduct({ sb, orgId, env, productId: product_id, }), - FeatureService.getFromReq(req), ]); let cusProducts = customer.customer_products; diff --git a/server/src/internal/api/events/eventRouter.ts b/server/src/internal/api/events/eventRouter.ts index e82c8b4b9..532e5c258 100644 --- a/server/src/internal/api/events/eventRouter.ts +++ b/server/src/internal/api/events/eventRouter.ts @@ -4,6 +4,7 @@ import { AppEnv, CreateEventSchema, CusProductStatus, + EntityData, ErrCode, Event, Feature, @@ -36,20 +37,24 @@ const getEventAndCustomer = async ({ sb, org, env, + features, customer_id, customer_data, event_data, logger, entityId, + entityData, }: { sb: SupabaseClient; org: Organization; + features: Feature[]; env: AppEnv; customer_id: string; customer_data: any; event_data: any; entityId: string; logger: any; + entityData?: EntityData; }) => { if (!customer_id) { throw new RecaseError({ @@ -71,6 +76,8 @@ const getEventAndCustomer = async ({ logger, entityId, inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue], + entityData, + features, }); // 3. Insert event @@ -152,7 +159,7 @@ export const handleEventSent = async ({ const { sb, pg, orgId, env } = req; const org = await OrgService.getFromReq(req); - + const features = await FeatureService.getFromReq(req); const { customer, event } = await getEventAndCustomer({ sb, org, @@ -162,6 +169,8 @@ export const handleEventSent = async ({ event_data, logger: req.logtail, entityId: event_data.entity_id, + entityData: event_data.entity_data, + features, }); const affectedFeatures = await getAffectedFeatures({ diff --git a/server/src/internal/api/events/usageRouter.ts b/server/src/internal/api/events/usageRouter.ts index 623cbcbcc..a0971b6df 100644 --- a/server/src/internal/api/events/usageRouter.ts +++ b/server/src/internal/api/events/usageRouter.ts @@ -19,6 +19,7 @@ import { JobName } from "@/queue/JobName.js"; import { getOrCreateCustomer } from "@/internal/customers/cusUtils/getOrCreateCustomer.js"; import { creditSystemContainsFeature } from "@/internal/features/creditSystemUtils.js"; import { addTaskToQueue } from "@/queue/queueUtils.js"; +import { getOrgAndFeatures } from "@/internal/orgs/orgUtils.js"; export const eventsRouter = Router(); export const usageRouter = Router(); @@ -36,8 +37,8 @@ const getCusFeatureAndOrg = async ({ customerData: any; }) => { // 1. Get customer - let org = await OrgService.getFromReq(req); - let [customer, features] = await Promise.all([ + let { org, features } = await getOrgAndFeatures({ req }); + let [customer] = await Promise.all([ getOrCreateCustomer({ sb: req.sb, org, @@ -45,10 +46,12 @@ const getCusFeatureAndOrg = async ({ customerId, customerData, logger: req.logtail, - entityId, inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue], + + entityId, + entityData: req.body.entity_data, + features, }), - FeatureService.getFromReq(req), ]); let feature = features.find((f) => f.id == featureId); diff --git a/server/src/internal/customers/cusUtils/getOrCreateCustomer.ts b/server/src/internal/customers/cusUtils/getOrCreateCustomer.ts index 5946c820e..b7610b576 100644 --- a/server/src/internal/customers/cusUtils/getOrCreateCustomer.ts +++ b/server/src/internal/customers/cusUtils/getOrCreateCustomer.ts @@ -7,16 +7,21 @@ import { CusExpand, CusProductStatus, CustomerData, + EntityData, ErrCode, + Feature, FullCustomer, Organization, } from "@autumn/shared"; -import { StatusCodes } from "http-status-codes"; + +import { createEntities } from "@/internal/api/entities/handleCreateEntity.js"; import RecaseError from "@/utils/errorUtils.js"; +import { StatusCodes } from "http-status-codes"; export const getOrCreateCustomer = async ({ sb, org, + features, customerId, customerData, env, @@ -28,11 +33,15 @@ export const getOrCreateCustomer = async ({ ], skipGet = false, withEntities = false, - entityId, expand, + + // Entity stuff + entityId, + entityData, }: { sb: SupabaseClient; org: Organization; + features: Feature[]; env: AppEnv; customerId: string; customerData?: CustomerData; @@ -40,8 +49,9 @@ export const getOrCreateCustomer = async ({ inStatuses?: CusProductStatus[]; skipGet?: boolean; withEntities?: boolean; - entityId?: string; expand?: CusExpand[]; + entityId?: string; + entityData?: EntityData; }): Promise => { let customer; @@ -112,11 +122,34 @@ export const getOrCreateCustomer = async ({ }); if (entityId && !customer.entity) { - throw new RecaseError({ - message: `Entity ${entityId} not found for customer ${customerId}`, - code: ErrCode.EntityNotFound, - statusCode: StatusCodes.BAD_REQUEST, + logger.info(`Auto creating entity ${entityId} for customer ${customerId}`); + + let newEntities = await createEntities({ + sb, + org, + customerId, + createEntityData: { + id: entityId, + name: entityData?.name, + feature_id: entityData?.feature_id, + }, + features, + env, + logger, + fromAutoCreate: true, }); + + customer.entities = [...(customer.entities || []), ...newEntities]; + customer.entity = newEntities.length > 0 ? newEntities[0] : null; + + if (customer.entity === null) { + throw new RecaseError({ + message: `Entity ${entityId} not found for customer ${customerId}. This entity must be createdfirst as it has a price associated with it.`, + code: ErrCode.EntityNotFound, + statusCode: StatusCodes.BAD_REQUEST, + }); + } } + return customer as FullCustomer; }; diff --git a/server/src/internal/customers/products/attachUtils.ts b/server/src/internal/customers/products/attachUtils.ts index 93339a9c8..d3b2aa863 100644 --- a/server/src/internal/customers/products/attachUtils.ts +++ b/server/src/internal/customers/products/attachUtils.ts @@ -7,6 +7,7 @@ import { Entitlement, EntitlementWithFeature, Entity, + EntityData, Feature, FeatureOptions, FreeTrial, @@ -152,6 +153,7 @@ const getProducts = async ({ const getCustomerAndProducts = async ({ sb, org, + features, customerId, customerData, productId, @@ -161,9 +163,11 @@ const getCustomerAndProducts = async ({ logger, version, entityId, + entityData, }: { sb: SupabaseClient; org: Organization; + features: Feature[]; customerData?: CustomerData; customerId: string; productId?: string; @@ -172,11 +176,13 @@ const getCustomerAndProducts = async ({ logger: any; version?: number; entityId?: string; + entityData?: EntityData; }) => { const [customer, products] = await Promise.all([ getOrCreateCustomer({ sb, org, + features, env, customerId, customerData, @@ -186,8 +192,9 @@ const getCustomerAndProducts = async ({ CusProductStatus.Scheduled, CusProductStatus.PastDue, ], - entityId, withEntities: true, + entityId, + entityData, }), getProducts({ sb, productId, productIds, orgId: org.id, env, version }), ]); @@ -275,7 +282,6 @@ export const getFullCusProductData = async ({ productId, entityId, productIds, - orgId, itemsInput, env, optionsListInput, @@ -283,16 +289,15 @@ export const getFullCusProductData = async ({ isCustom = false, logger, version, + entityData, }: { org: Organization; features: Feature[]; sb: SupabaseClient; customerId: string; customerData?: Customer; - entityId?: string; productId?: string; productIds?: string[]; - orgId: string; itemsInput: ProductItem[]; env: AppEnv; optionsListInput: FeatureOptions[]; @@ -300,10 +305,13 @@ export const getFullCusProductData = async ({ isCustom?: boolean; logger: any; version?: number; + entityId?: string; + entityData?: EntityData; }) => { // 1. Get customer, product, org & features const { customer, products, cusProducts } = await getCustomerAndProducts({ org, + features, sb, customerId, customerData, @@ -312,7 +320,9 @@ export const getFullCusProductData = async ({ env, logger, version, + entityId, + entityData, }); if (!isCustom) { diff --git a/server/src/internal/orgs/orgUtils.ts b/server/src/internal/orgs/orgUtils.ts index dc5a41261..e2b35a3f5 100644 --- a/server/src/internal/orgs/orgUtils.ts +++ b/server/src/internal/orgs/orgUtils.ts @@ -3,6 +3,8 @@ import RecaseError from "@/utils/errorUtils.js"; import { AppEnv, ErrCode, Organization } from "@autumn/shared"; import { createSvixApp } from "@/external/svix/svixUtils.js"; import { createStripeCli } from "@/external/stripe/utils.js"; +import { OrgService } from "./OrgService.js"; +import { FeatureService } from "../features/FeatureService.js"; export const initOrgSvixApps = async ({ id, @@ -96,3 +98,14 @@ export const createOrgResponse = (org: Organization) => { live_pkey: org.live_pkey, }; }; + +export const getOrgAndFeatures = async ({ req }: { req: any }) => { + let { orgId, env } = req; + + let [org, features] = await Promise.all([ + OrgService.getFromReq(req), + FeatureService.getFromReq(req), + ]); + + return { org, features }; +}; diff --git a/server/src/internal/products/internalProductRouter.ts b/server/src/internal/products/internalProductRouter.ts index 6804b007d..be3228a02 100644 --- a/server/src/internal/products/internalProductRouter.ts +++ b/server/src/internal/products/internalProductRouter.ts @@ -1,26 +1,21 @@ import { Router } from "express"; import { FeatureService } from "../features/FeatureService.js"; import { entitlementRouter } from "./entitlementRouter.js"; - +import { StatusCodes } from "http-status-codes"; import { ProductService } from "./ProductService.js"; import { ErrCode, UsageModel } from "@autumn/shared"; - import { FeatureOptions } from "@autumn/shared"; - import { OrgService } from "../orgs/OrgService.js"; import { RewardService } from "../rewards/RewardService.js"; import { getProductVersionCounts } from "./productUtils.js"; import { getLatestProducts } from "./productUtils.js"; - import { CusProdReadService } from "../customers/products/CusProdReadService.js"; import { MigrationService } from "../migrations/MigrationService.js"; import { RewardProgramService } from "../rewards/RewardProgramService.js"; import { mapToProductV2 } from "./productV2Utils.js"; - import { isFeaturePriceItem } from "./product-items/productItemUtils.js"; import RecaseError, { handleFrontendReqError } from "@/utils/errorUtils.js"; -import { StatusCodes } from "http-status-codes"; export const productRouter = Router({ mergeParams: true }); @@ -63,7 +58,9 @@ productRouter.get("/data", async (req: any, res) => { ]); res.status(200).json({ - products: getLatestProducts(products), + products: getLatestProducts(products).map((product) => { + return mapToProductV2({ product, features }); + }), versionCounts: getProductVersionCounts(products), features, org: { diff --git a/shared/models/cusModels/entityModels/entityModels.ts b/shared/models/cusModels/entityModels/entityModels.ts index 684949176..2790a4313 100644 --- a/shared/models/cusModels/entityModels/entityModels.ts +++ b/shared/models/cusModels/entityModels/entityModels.ts @@ -20,5 +20,11 @@ export const CreateEntitySchema = z.object({ feature_id: z.string(), // Feature ID of entity }); +export const EntityDataSchema = z.object({ + name: z.string(), // Name of entity + feature_id: z.string(), // Feature ID of entity +}); + export type Entity = z.infer; export type CreateEntity = z.infer; +export type EntityData = z.infer; diff --git a/vite/src/utils/product/product-item/formatProductItem.ts b/vite/src/utils/product/product-item/formatProductItem.ts new file mode 100644 index 000000000..50e16b62c --- /dev/null +++ b/vite/src/utils/product/product-item/formatProductItem.ts @@ -0,0 +1,93 @@ +import { + Feature, + Organization, + ProductItem, + ProductItemType, +} from "@autumn/shared"; +import { formatAmount, getItemType, intervalIsNone } from "../productItemUtils"; + +const getPaidFeatureString = ({ + item, + org, + features, +}: { + item: ProductItem; + org: Organization; + features: Feature[]; +}) => { + let amountStr = ""; + + if (item.price) { + amountStr = formatAmount({ + defaultCurrency: org?.default_currency || "USD", + amount: item.price, + }); + } else if (item.tiers && item.tiers.length == 1) { + amountStr = formatAmount({ + defaultCurrency: org?.default_currency || "USD", + amount: item.tiers![0].amount, + }); + } else { + amountStr = `${formatAmount({ + defaultCurrency: org?.default_currency || "USD", + amount: item.tiers![0].amount, + })} - ${formatAmount({ + defaultCurrency: org?.default_currency || "USD", + amount: item.tiers![item.tiers!.length - 1].amount, + })}`; + } + + let feature = features.find((f: Feature) => f.id == item.feature_id); + + amountStr += ` per ${item.billing_units! > 1 ? item.billing_units : ""} ${ + feature?.name + }`; + + if (!intervalIsNone(item.interval)) { + amountStr += ` per ${item.interval}`; + } + + if (item.included_usage) { + return `${item.included_usage} ${feature?.name} free, then ${amountStr}`; + } else { + return amountStr; + } +}; + +const getFixedPriceString = ({ + item, + org, +}: { + item: ProductItem; + org: Organization; +}) => { + let currency = org?.default_currency || "USD"; + let formattedAmount = formatAmount({ + defaultCurrency: currency, + amount: item.price!, + }); + + if (!intervalIsNone(item.interval)) { + return `${formattedAmount} per ${item.interval}`; + } + + return `${formattedAmount}`; +}; + +export const formatProductItemText = ({ + item, + org, + features, +}: { + item: ProductItem; + org: Organization; + features: Feature[]; +}) => { + let itemType = getItemType(item); + + if (itemType == ProductItemType.FeaturePrice) { + return getPaidFeatureString({ item, org, features }); + } else if (itemType == ProductItemType.Price) { + return getFixedPriceString({ item, org }); + } +}; diff --git a/vite/src/views/products/product/product-item/ProductItemConfig.tsx b/vite/src/views/products/product/product-item/ProductItemConfig.tsx index b15fe287f..d7e9ddeaf 100644 --- a/vite/src/views/products/product/product-item/ProductItemConfig.tsx +++ b/vite/src/views/products/product/product-item/ProductItemConfig.tsx @@ -68,6 +68,10 @@ export const ProductItemConfig = () => { setItem({ ...item, feature_type: feature.config?.usage_type, + reset_usage_when_enabled: + feature.config?.usage_type == FeatureUsageType.Continuous + ? false + : true, }); } } diff --git a/vite/src/views/products/product/product-item/ProductItemTable.tsx b/vite/src/views/products/product/product-item/ProductItemTable.tsx index ba5d1cdff..8b903a755 100644 --- a/vite/src/views/products/product/product-item/ProductItemTable.tsx +++ b/vite/src/views/products/product/product-item/ProductItemTable.tsx @@ -1,3 +1,4 @@ +import UpdateProductItem from "./UpdateProductItem"; import { Feature, FeatureType, @@ -14,8 +15,8 @@ import { intervalIsNone, itemIsFixedPrice, } from "@/utils/product/productItemUtils"; -import UpdateProductItem from "./UpdateProductItem"; -import { useEffect, useState } from "react"; + +import { useState } from "react"; import { AdminHover } from "@/components/general/AdminHover"; import { getFeature } from "@/utils/product/entitlementUtils"; import { Badge } from "@/components/ui/badge"; diff --git a/vite/src/views/products/product/product-item/components/ConfigWithFeature.tsx b/vite/src/views/products/product/product-item/components/ConfigWithFeature.tsx index 3e81636d9..619b64393 100644 --- a/vite/src/views/products/product/product-item/components/ConfigWithFeature.tsx +++ b/vite/src/views/products/product/product-item/components/ConfigWithFeature.tsx @@ -1,3 +1,5 @@ +import PerEntityConfig from "./PerEntityConfig"; +import TieredPrice from "../TieredPrice"; import { ToggleDisplayButton } from "@/components/general/ToggleDisplayButton"; import { SelectItemFeature } from "./SelectItemFeature"; import { cn } from "@/lib/utils"; @@ -7,25 +9,16 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { InfoIcon, X } from "lucide-react"; +import { InfoIcon } from "lucide-react"; import { Input } from "@/components/ui/input"; import { useProductItemContext } from "../ProductItemContext"; -import { useState } from "react"; import { Button } from "@/components/ui/button"; -import TieredPrice from "../TieredPrice"; - import { useProductContext } from "../../ProductContext"; -import { - Feature, - FeatureType, - FeatureUsageType, - Infinite, - ProductItemFeatureType, -} from "@autumn/shared"; +import { FeatureType, Infinite, ProductItemFeatureType } from "@autumn/shared"; import { itemIsUnlimited } from "@/utils/product/productItemUtils"; import { SelectCycle } from "./SelectCycle"; import MoreMenuButton, { MoreMenuPriceButton } from "../MoreMenuButton"; -import PerEntityConfig from "./PerEntityConfig"; + import { getFeature } from "@/utils/product/entitlementUtils"; export const ConfigWithFeature = ({ diff --git a/vite/src/views/products/product/product-item/components/SelectItemFeature.tsx b/vite/src/views/products/product/product-item/components/SelectItemFeature.tsx index 05c8256f2..c000855b6 100644 --- a/vite/src/views/products/product/product-item/components/SelectItemFeature.tsx +++ b/vite/src/views/products/product/product-item/components/SelectItemFeature.tsx @@ -30,11 +30,6 @@ export const SelectItemFeature = ({ value={item.feature_id || ""} onValueChange={(value) => { setItem({ ...item, feature_id: value }); - // setSelectedFeature(getFeature(value, features)); - // setPriceConfig({ - // ...priceConfig, - // internal_feature_id: value, - // }); }} disabled={isUpdate} > @@ -69,7 +64,14 @@ export const SelectItemFeature = ({ variant="ghost" className="w-fit text-t3" onClick={() => { - setItem({ ...item, feature_id: null, included_usage: null }); + setItem({ + ...item, + feature_id: null, + included_usage: null, + feature_type: null, + tiers: null, + price: item.tiers?.[0]?.amount || 0, + }); setShow({ ...show, feature: false }); }} > diff --git a/vite/src/views/products/rewards/DiscountConfig.tsx b/vite/src/views/products/rewards/DiscountConfig.tsx index 68f44e25d..f35897605 100644 --- a/vite/src/views/products/rewards/DiscountConfig.tsx +++ b/vite/src/views/products/rewards/DiscountConfig.tsx @@ -10,6 +10,7 @@ import { CouponDurationType, DiscountType, RewardType, + ProductItem, } from "@autumn/shared"; import { useProductsContext } from "../ProductsContext"; import { @@ -29,6 +30,8 @@ import { Check, ChevronsUpDown, Trash2, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; +import { isFeatureItem } from "@/utils/product/getItemType"; +import { formatProductItemText } from "@/utils/product/product-item/formatProductItem"; export const DiscountConfig = ({ reward, @@ -48,7 +51,7 @@ export const DiscountConfig = ({ }; return ( -
+
Amount @@ -129,7 +132,7 @@ const ProductPriceSelector = ({ reward: Reward; setReward: (reward: Reward) => void; }) => { - const { products, features } = useProductsContext(); + const { products, features, org } = useProductsContext(); const [open, setOpen] = useState(false); let config = reward.discount_config!; @@ -172,7 +175,7 @@ const ProductPriceSelector = ({ variant="outline" role="combobox" aria-expanded={open} - className="w-full justify-between min-h-9 flex flex-wrap h-fit py-2 justify-start items-center gap-2 relative hover:bg-zinc-50" + className="w-full min-h-9 flex flex-wrap h-fit py-2 justify-start items-center gap-2 relative hover:bg-zinc-50 max-w-[400px]" > {config.apply_to_all ? ( "All Products" @@ -183,9 +186,19 @@ const ProductPriceSelector = ({ {config.price_ids.map((priceId) => (
-

{getPriceText(priceId)}

+

+ {formatProductItemText({ + item: products + .find((p: any) => + p.items.find((i: any) => i.price_id === priceId) + ) + .items.find((i: any) => i.price_id === priceId), + org, + features, + })} +