diff --git a/server/src/external/stripe/createStripePrice/createStripeInArrear.ts b/server/src/external/stripe/createStripePrice/createStripeInArrear.ts index 0f47d0ba1..2886db5a1 100644 --- a/server/src/external/stripe/createStripePrice/createStripeInArrear.ts +++ b/server/src/external/stripe/createStripePrice/createStripeInArrear.ts @@ -1,23 +1,22 @@ +import { + type Entitlement, + type EntitlementWithFeature, + ErrCode, + type Feature, + type Organization, + type Price, + type Product, + TierInfinite, + type UsagePriceConfig, +} from "@autumn/shared"; +import { Decimal } from "decimal.js"; +import { StatusCodes } from "http-status-codes"; +import type Stripe from "stripe"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; import { PriceService } from "@/internal/products/prices/PriceService.js"; import { getPriceEntitlement } from "@/internal/products/prices/priceUtils.js"; -import { - Product, - Price, - Organization, - EntitlementWithFeature, - UsagePriceConfig, - Feature, - TierInfinite, - Entitlement, - ErrCode, -} from "@autumn/shared"; -import { SupabaseClient } from "@supabase/supabase-js"; -import Stripe from "stripe"; -import { billingIntervalToStripe } from "../stripePriceUtils.js"; -import { Decimal } from "decimal.js"; import RecaseError from "@/utils/errorUtils.js"; -import { StatusCodes } from "http-status-codes"; -import { DrizzleCli } from "@/db/initDrizzle.js"; +import { billingIntervalToStripe } from "../stripePriceUtils.js"; export const searchStripeMeter = async ({ stripeCli, @@ -30,7 +29,7 @@ export const searchStripeMeter = async ({ meterId?: string; logger: any; }) => { - let allStripeMeters = []; + const allStripeMeters = []; let hasMore = true; let startingAfter; @@ -52,7 +51,7 @@ export const searchStripeMeter = async ({ const end = performance.now(); logger.info(`Stripe meter list took ${end - start}ms`); - let stripeMeter = allStripeMeters.find( + const stripeMeter = allStripeMeters.find( (m) => m.event_name == eventName || m.id == meterId, ); @@ -72,11 +71,11 @@ export const getStripeMeter = async ({ price: Price; logger: any; }) => { - let config = price.config as UsagePriceConfig; + const config = price.config as UsagePriceConfig; let createNew = false; try { - let stripeMeter = await searchStripeMeter({ + const stripeMeter = await searchStripeMeter({ stripeCli, eventName: price.id!, meterId: config.stripe_meter_id!, @@ -94,7 +93,7 @@ export const getStripeMeter = async ({ } catch (error) { createNew = true; } - let meter = await stripeCli.billing.meters.create({ + const meter = await stripeCli.billing.meters.create({ display_name: `${product.name} - ${feature!.name}`, event_name: price.id!, default_aggregation: { @@ -109,7 +108,7 @@ export const priceToInArrearTiers = ( price: Price, entitlement: Entitlement, ) => { - let usageConfig = structuredClone(price.config) as UsagePriceConfig; + const usageConfig = structuredClone(price.config) as UsagePriceConfig; const tiers: any[] = []; if (entitlement.allowance) { tiers.push({ @@ -118,7 +117,7 @@ export const priceToInArrearTiers = ( }); for (let i = 0; i < usageConfig.usage_tiers.length; i++) { - let tier = usageConfig.usage_tiers[i]; + const tier = usageConfig.usage_tiers[i]; if (tier.to != -1 && tier.to != TierInfinite) { usageConfig.usage_tiers[i].to = (tier.to || 0) + entitlement.allowance; } @@ -127,7 +126,7 @@ export const priceToInArrearTiers = ( for (let i = 0; i < usageConfig.usage_tiers.length; i++) { const tier = usageConfig.usage_tiers[i]; - let amount = new Decimal(tier.amount) + const amount = new Decimal(tier.amount) .div(usageConfig.billing_units ?? 1) .mul(100) .toDecimalPlaces(10) @@ -167,11 +166,13 @@ export const createStripeInArrearPrice = async ({ internalEntityId?: string; useCheckout?: boolean; }) => { - let config = price.config as UsagePriceConfig; + const config = price.config as UsagePriceConfig; // 1. Create meter - let relatedEnt = getPriceEntitlement(price, entitlements); - let feature = relatedEnt?.feature; + const relatedEnt = getPriceEntitlement(price, entitlements); + const feature = relatedEnt?.feature; + + console.log("Price usage tiers:", price.config.usage_tiers); // 1. If internal entity ID and not curStripe product, create product if (internalEntityId && !useCheckout) { @@ -179,14 +180,14 @@ export const createStripeInArrearPrice = async ({ logger.info( `Creating stripe in arrear product for ${relatedEnt.feature.name} (internal entity ID exists!)`, ); - let stripeProduct = await stripeCli.products.create({ - name: `${product.name} - ${feature!.name}`, + const stripeProduct = await stripeCli.products.create({ + name: `${product.name} - ${feature?.name}`, }); config.stripe_product_id = stripeProduct.id; await PriceService.update({ db, - id: price.id!, + id: price.id, update: { config }, }); } @@ -211,7 +212,7 @@ export const createStripeInArrearPrice = async ({ } // 1. Get meter by event_name - let meter = await getStripeMeter({ + const meter = await getStripeMeter({ product, feature, stripeCli, @@ -227,7 +228,7 @@ export const createStripeInArrearPrice = async ({ ); let priceAmountData = {}; - if (tiers.length == 1) { + if (tiers.length === 1) { priceAmountData = { unit_amount_decimal: tiers[0].unit_amount_decimal, }; @@ -247,7 +248,7 @@ export const createStripeInArrearPrice = async ({ } else { productData = { product_data: { - name: `${product.name} - ${feature!.name}`, + name: `${product.name} - ${feature.name}`, }, }; } @@ -255,13 +256,13 @@ export const createStripeInArrearPrice = async ({ const stripePrice = await stripeCli.prices.create({ ...productData, ...priceAmountData, - currency: org.default_currency!, + currency: org.default_currency || "usd", recurring: { - ...(billingIntervalToStripe({ - interval: price.config!.interval, - intervalCount: price.config!.interval_count, - }) as any), - meter: meter!.id, + ...billingIntervalToStripe({ + interval: price.config.interval, + intervalCount: price.config.interval_count, + }), + meter: meter.id, usage_type: "metered", }, nickname: `Autumn Price (${relatedEnt.feature.name})`, @@ -269,10 +270,11 @@ export const createStripeInArrearPrice = async ({ config.stripe_price_id = stripePrice.id; config.stripe_product_id = stripePrice.product as string; - config.stripe_meter_id = meter!.id; + config.stripe_meter_id = meter.id; + await PriceService.update({ db, - id: price.id!, + id: price.id, update: { config }, }); }; diff --git a/server/src/external/stripe/stripePriceUtils.ts b/server/src/external/stripe/stripePriceUtils.ts index 4cde57493..5516cfd9b 100644 --- a/server/src/external/stripe/stripePriceUtils.ts +++ b/server/src/external/stripe/stripePriceUtils.ts @@ -1,18 +1,15 @@ import { BillingInterval, - Price, - Feature, - Customer, - FullCusProduct, - UsagePriceConfig, - FullProduct, - Organization, + type Customer, + type Feature, + type FullCusProduct, + type FullProduct, + type Organization, + type Price, + type UsagePriceConfig, } from "@autumn/shared"; -import Stripe from "stripe"; -import { getPriceForOverage } from "@/internal/products/prices/priceUtils.js"; - -import { getFeatureName } from "@/internal/features/utils/displayUtils.js"; +import type Stripe from "stripe"; import { getCusPriceUsage } from "@/internal/customers/cusProducts/cusPrices/cusPriceUtils.js"; export const createSubMeta = ({ features }: { features: Feature[] }) => { @@ -29,7 +26,7 @@ export const billingIntervalToStripe = ({ }: { interval: BillingInterval; intervalCount?: number | null; -}) => { +}): Stripe.PriceCreateParams.Recurring => { const finalCount = intervalCount ?? 1; switch (interval) { case BillingInterval.Week: @@ -58,7 +55,7 @@ export const billingIntervalToStripe = ({ interval_count: finalCount, }; default: - break; + throw new Error(`billingIntervalToStripe: invalid interval ${interval}`); } }; @@ -88,9 +85,9 @@ export const getInvoiceItemForUsage = ({ withProdPrefix: true, }); - let config = price.config! as UsagePriceConfig; + const config = price.config! as UsagePriceConfig; - let invoiceItem: Stripe.InvoiceItemCreateParams = { + const invoiceItem: Stripe.InvoiceItemCreateParams = { invoice: stripeInvoiceId, customer: customer.processor.id, currency, diff --git a/server/src/internal/invoices/invoiceFormatUtils.ts b/server/src/internal/invoices/invoiceFormatUtils.ts index 236aec227..e306b83b7 100644 --- a/server/src/internal/invoices/invoiceFormatUtils.ts +++ b/server/src/internal/invoices/invoiceFormatUtils.ts @@ -1,29 +1,27 @@ import { BillingInterval, BillingType, - EntitlementWithFeature, - Feature, - FixedPriceConfig, - FullCusProduct, - FullCustomerPrice, - FullProduct, + billingToItemInterval, + cusProductToEnts, + type EntitlementWithFeature, + type Feature, + type FixedPriceConfig, + type FullCusProduct, + type FullProduct, getFeatureName, getFeatureNameWithCapital, - Organization, - Price, - ProductItemInterval, - UsagePriceConfig, + type Organization, + type Price, + type ProductItemInterval, + type UsagePriceConfig, } from "@autumn/shared"; +import { formatAmount } from "@/utils/formatUtils.js"; +import { getFeatureQuantity } from "../customers/cusProducts/cusProductUtils.js"; import { getBillingType, getPriceEntitlement, } from "../products/prices/priceUtils.js"; -import { cusProductToEnts } from "@autumn/shared"; - -import { getFeatureQuantity } from "../customers/cusProducts/cusProductUtils.js"; -import { formatAmount } from "@/utils/formatUtils.js"; import { getIntervalString } from "../products/productUtils/productResponseUtils/getProductItemDisplay.js"; -import { billingToItemInterval } from "../products/product-items/itemIntervalUtils.js"; const getSingularAndPlural = (feature: Feature) => { const singular = getFeatureName({ @@ -53,8 +51,8 @@ export const formatPrepaidPrice = ({ const config = price.config as UsagePriceConfig; const billingUnits = config.billing_units || 1; - if (billingUnits == 1) { - if (quantity == 1) + if (billingUnits === 1) { + if (quantity === 1) return `${quantity} ${singular}`; // eg. 1 credit else return `${quantity} ${plural}`; // eg. 4 credits } else { @@ -75,52 +73,20 @@ export const formatFixedPrice = ({ const amount = formatAmount({ org, amount: config.amount }); const intervalStr = getIntervalString({ - interval: billingToItemInterval(config.interval) as ProductItemInterval, + interval: billingToItemInterval({ + billingInterval: config.interval, + }) as ProductItemInterval, intervalCount: config.interval_count || 1, prefix: "", }); - if (config.interval == BillingInterval.OneOff) { + if (config.interval === BillingInterval.OneOff) { return `${amount}`; } else { return `${amount} / ${intervalStr}`; } }; -// export const formatUsageInArrear = ({ -// price, -// cusProduct, -// logger, -// }: { -// price: Price; -// feature: Feature; -// cusProduct: FullCusProduct; -// logger: any; -// }) => { -// // const cusPrice = cusProduct.customer_prices.find( -// // (cp) => cp.price.id == price.id, -// // ); - -// // const { usage, overage, roundedUsage } = getCusPriceUsage({ -// // cusPrice: cusPrice!, -// // cusProduct, -// // logger, -// // }); - -// // const cusEnt = getRelatedCusEnt({ -// // cusPrice: cusPrice!, -// // cusEnts: cusProduct.customer_entitlements, -// // })!; - -// const { singular, plural } = getSingularAndPlural(cusEnt.entitlement.feature); - -// if (usage == 1) { -// return `${usage} x ${singular}`; -// } else { -// return `${usage} x ${plural}`; -// } -// }; - export const formatInArrearProrated = ({ price, ents, @@ -134,7 +100,7 @@ export const formatInArrearProrated = ({ const { singular, plural } = getSingularAndPlural(ent.feature); - if (quantity == 1) { + if (quantity === 1) { return `${quantity} x ${singular}`; } else { return `${quantity} x ${plural}`; @@ -154,12 +120,12 @@ export const priceToInvoiceDescription = ({ quantity?: number; logger: any; }) => { - let billingType = getBillingType(price.config); - let productName = cusProduct.product.name; + const billingType = getBillingType(price.config); + const productName = cusProduct.product.name; const ents = cusProductToEnts({ cusProduct }); let description = ""; - if (billingType == BillingType.UsageInAdvance) { + if (billingType === BillingType.UsageInAdvance) { const ent = getPriceEntitlement(price, ents); const quantity = getFeatureQuantity({ cusProduct, @@ -170,8 +136,8 @@ export const priceToInvoiceDescription = ({ } if ( - billingType == BillingType.FixedCycle || - billingType == BillingType.OneOff + billingType === BillingType.FixedCycle || + billingType === BillingType.OneOff ) { description = formatFixedPrice({ org: org!, @@ -180,7 +146,7 @@ export const priceToInvoiceDescription = ({ }); } - if (billingType == BillingType.InArrearProrated) { + if (billingType === BillingType.InArrearProrated) { description = formatInArrearProrated({ price, ents, quantity }); } @@ -210,22 +176,22 @@ export const newPriceToInvoiceDescription = ({ let description = ""; if ( - billingType == BillingType.FixedCycle || - billingType == BillingType.OneOff + billingType === BillingType.FixedCycle || + billingType === BillingType.OneOff ) { description = formatFixedPrice({ org, price }); } - if (billingType == BillingType.InArrearProrated) { + if (billingType === BillingType.InArrearProrated) { description = formatInArrearProrated({ price, ents, quantity }); } - if (billingType == BillingType.UsageInArrear) { + if (billingType === BillingType.UsageInArrear) { const ent = getPriceEntitlement(price, ents); description = getFeatureNameWithCapital({ feature: ent.feature }); } - if (billingType == BillingType.UsageInAdvance) { + if (billingType === BillingType.UsageInAdvance) { description = formatPrepaidPrice({ price, ents, quantity: quantity! }); } diff --git a/server/src/internal/products/handlers/handleUpdateProduct/handleUpdateProduct.ts b/server/src/internal/products/handlers/handleUpdateProduct/handleUpdateProduct.ts index 54a7518fd..4b8938047 100644 --- a/server/src/internal/products/handlers/handleUpdateProduct/handleUpdateProduct.ts +++ b/server/src/internal/products/handlers/handleUpdateProduct/handleUpdateProduct.ts @@ -1,4 +1,5 @@ import { + CreateFreeTrialSchema, ErrCode, mapToProductItems, productsAreSame, @@ -73,6 +74,11 @@ export const handleUpdateProductV2 = async (req: any, res: any) => }); } + if (req.body.free_trial) { + req.body.free_trial = CreateFreeTrialSchema.parse(req.body.free_trial); + console.log("Free trial after parsing", req.body.free_trial); + } + const cusProductsCurVersion = await CusProductService.getByInternalProductId({ db, @@ -81,10 +87,6 @@ export const handleUpdateProductV2 = async (req: any, res: any) => const cusProductExists = cusProductsCurVersion.length > 0; - // console.log("Updating product", { - // id: fullProduct.id, - // body: req.body, - // }); await disableCurrentDefault({ req, newProduct: { @@ -122,12 +124,13 @@ export const handleUpdateProductV2 = async (req: any, res: any) => }); } - const { itemsSame, freeTrialsSame } = productsAreSame({ + const { itemsSame, freeTrialsSame, optionsSame } = productsAreSame({ newProductV2: req.body, curProductV1: fullProduct, features, }); - const productSame = itemsSame && freeTrialsSame; + + const productSame = itemsSame && freeTrialsSame && optionsSame; if (!productSame) { await handleVersionProductV2({ diff --git a/server/src/internal/products/internalProductRouter.ts b/server/src/internal/products/internalProductRouter.ts index 6be0d048c..619601304 100644 --- a/server/src/internal/products/internalProductRouter.ts +++ b/server/src/internal/products/internalProductRouter.ts @@ -1,27 +1,24 @@ +import { ErrCode, type FeatureOptions, UsageModel } from "@autumn/shared"; import { Router } from "express"; -import { FeatureService } from "../features/FeatureService.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 { getGroupToDefaults, getProductVersionCounts } from "./productUtils.js"; -import { getLatestProducts } from "./productUtils.js"; -import { CusProdReadService } from "../customers/cusProducts/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/getItemType.js"; - import RecaseError, { handleFrontendReqError } from "@/utils/errorUtils.js"; - +import { CusProdReadService } from "../customers/cusProducts/CusProdReadService.js"; +import { FeatureService } from "../features/FeatureService.js"; +import { MigrationService } from "../migrations/MigrationService.js"; +import { OrgService } from "../orgs/OrgService.js"; import { createOrgResponse } from "../orgs/orgUtils.js"; -import { - sortFullProducts, - sortProductsByPrice, -} from "./productUtils/sortProductUtils.js"; +import { RewardProgramService } from "../rewards/RewardProgramService.js"; +import { RewardService } from "../rewards/RewardService.js"; import { handleGetProductDeleteInfo } from "./handlers/handleGetProductDeleteInfo.js"; +import { ProductService } from "./ProductService.js"; +import { isFeaturePriceItem } from "./product-items/productItemUtils/getItemType.js"; +import { sortFullProducts } from "./productUtils/sortProductUtils.js"; +import { + getGroupToDefaults, + getLatestProducts, + getProductVersionCounts, +} from "./productUtils.js"; +import { mapToProductV2 } from "./productV2Utils.js"; export const productRouter: Router = Router({ mergeParams: true }); @@ -56,14 +53,14 @@ productRouter.get("/products", async (req: any, res) => { // Get counts for all products productRouter.get("/product_counts", async (req: any, res) => { try { - let { db } = req; - let products = await ProductService.listFull({ + const { db } = req; + const products = await ProductService.listFull({ db, orgId: req.orgId, env: req.env, }); - let counts = await Promise.all( + const counts = await Promise.all( products.map(async (product) => { // if (latestVersion) { // return CusProdReadService.getCounts({ @@ -81,7 +78,7 @@ productRouter.get("/product_counts", async (req: any, res) => { }), ); - let result: { [key: string]: any } = {}; + const result: { [key: string]: any } = {}; for (let i = 0; i < products.length; i++) { if (!result[products[i].id]) { result[products[i].id] = counts[i]; @@ -159,7 +156,7 @@ productRouter.get("/:productId/data2", async (req: any, res) => { }); } - let productV2 = mapToProductV2({ + const productV2 = mapToProductV2({ product: product, features: req.features, }); @@ -199,6 +196,7 @@ productRouter.get("/:productId/count", async (req: any, res) => { } // Get counts from postgres + const counts = await CusProdReadService.getCounts({ db, internalProductId: product.internal_id, @@ -237,7 +235,7 @@ productRouter.get("/migrations", async (req: any, res) => { productRouter.get("/data", async (req: any, res) => { try { - let { db } = req; + const { db } = req; const allVersions = req.query.all_versions === "true"; @@ -292,8 +290,8 @@ productRouter.get("/data", async (req: any, res) => { productRouter.post("/data", async (req: any, res) => { try { - let { db } = req; - let { showArchived } = req.body; + const { db } = req; + const { showArchived } = req.body; const [products, defaultProds, features, org, coupons, rewardPrograms] = await Promise.all([ @@ -343,8 +341,8 @@ productRouter.post("/data", async (req: any, res) => { productRouter.get("/counts", async (req: any, res) => { try { - let { db } = req; - let products = await ProductService.listFull({ + const { db } = req; + const products = await ProductService.listFull({ db, orgId: req.orgId, env: req.env, @@ -353,7 +351,7 @@ productRouter.get("/counts", async (req: any, res) => { const latestVersion = req.query.latest_version === "true"; - let counts = await Promise.all( + const counts = await Promise.all( products.map(async (product) => { if (latestVersion) { return CusProdReadService.getCounts({ @@ -371,7 +369,7 @@ productRouter.get("/counts", async (req: any, res) => { }), ); - let result: { [key: string]: any } = {}; + const result: { [key: string]: any } = {}; for (let i = 0; i < products.length; i++) { if (!result[products[i].id]) { result[products[i].id] = counts[i]; @@ -447,7 +445,7 @@ productRouter.get("/:productId/data", async (req: any, res) => { return b.id.localeCompare(a.id); }); - let productV2 = mapToProductV2({ product, features }); + const productV2 = mapToProductV2({ product, features }); res.status(200).send({ product: productV2, @@ -483,7 +481,7 @@ productRouter.post("/product_options", async (req: any, res: any) => { const featureToOptions: { [key: string]: FeatureOptions } = {}; for (const item of items) { - if (isFeaturePriceItem(item) && item.usage_model == UsageModel.Prepaid) { + if (isFeaturePriceItem(item) && item.usage_model === UsageModel.Prepaid) { featureToOptions[item.feature_id] = { feature_id: item.feature_id, quantity: 0, diff --git a/server/src/internal/products/product-items/itemIntervalUtils.ts b/server/src/internal/products/product-items/itemIntervalUtils.ts deleted file mode 100644 index 2f3d4e3c0..000000000 --- a/server/src/internal/products/product-items/itemIntervalUtils.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { nullish } from "@/utils/genUtils.js"; -import { - BillingInterval, - EntInterval, - ProductItem, - ProductItemFeatureType, - ProductItemInterval, -} from "@autumn/shared"; - -export const billingToItemInterval = (billingInterval: BillingInterval) => { - if (billingInterval == BillingInterval.OneOff) { - return null; - } - - return billingInterval as unknown as ProductItemInterval; -}; - -export const entToItemInterval = (entInterval?: EntInterval) => { - if (nullish(entInterval)) { - return null; - } - - if (entInterval == EntInterval.Lifetime) { - return null; - } - - return entInterval as unknown as ProductItemInterval; -}; - -export const itemToBillingInterval = (item: ProductItem) => { - if (nullish(item.interval)) { - return BillingInterval.OneOff; - } - - return item.interval; -}; - -export const itemToEntInterval = (item: ProductItem) => { - if (nullish(item.interval)) { - return EntInterval.Lifetime; - } - - if (item.feature_type == ProductItemFeatureType.ContinuousUse) { - return EntInterval.Lifetime; - } - - return item.interval; -}; diff --git a/server/src/internal/products/product-items/productItemUtils.ts b/server/src/internal/products/product-items/productItemUtils.ts index 2169b98ee..66fd8f402 100644 --- a/server/src/internal/products/product-items/productItemUtils.ts +++ b/server/src/internal/products/product-items/productItemUtils.ts @@ -1,24 +1,15 @@ -import { notNullish, nullish } from "@/utils/genUtils.js"; -import { - EntInterval, - ProductItemInterval, - BillingInterval, - ProductItem, - ProductItemType, - UsageModel, - Infinite, - ProductItemFeatureType, - Feature, - FeatureType, -} from "@autumn/shared"; -import { isFeatureItem } from "./productItemUtils/getItemType.js"; import { + type BillingInterval, billingToItemInterval, + EntInterval, entToItemInterval, -} from "./itemIntervalUtils.js"; + type ProductItem, + type ProductItemFeatureType, + type UsageModel, +} from "@autumn/shared"; import { calculateProrationAmount, - Proration, + type Proration, } from "@/internal/invoices/prorationUtils.js"; export const itemToPriceOrTiers = ({ @@ -89,10 +80,10 @@ export const constructFeatureItem = ({ entitlement_id?: string; entity_feature_id?: string; }) => { - let item: ProductItem = { + const item: ProductItem = { feature_id, included_usage: included_usage as number, - interval: entToItemInterval(interval), + interval: entToItemInterval({ entInterval: interval }), entitlement_id, entity_feature_id, }; @@ -109,7 +100,7 @@ export const constructPriceItem = ({ interval: BillingInterval | null; intervalCount?: number; }) => { - let item: ProductItem = { + const item: ProductItem = { price: price, interval: interval as any, interval_count: intervalCount || 1, @@ -140,14 +131,14 @@ export const constructFeaturePriceItem = ({ reset_usage_when_enabled?: boolean; entity_feature_id?: string; }) => { - let item: ProductItem & { + const item: ProductItem & { included_usage: number; } = { feature_id, feature_type, included_usage: included_usage as number, price, - interval: billingToItemInterval(interval), + interval: billingToItemInterval({ billingInterval: interval }), usage_model, billing_units, reset_usage_when_enabled, diff --git a/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts b/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts index 1ecb0fc27..ab59498f6 100644 --- a/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts +++ b/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts @@ -1,42 +1,39 @@ -import RecaseError from "@/utils/errorUtils.js"; import { AllowanceType, BillingInterval, BillingType, BillWhen, EntInterval, - Entitlement, + type Entitlement, ErrCode, - Feature, + type Feature, FeatureType, - FixedPriceConfig, - Infinite, - Price, - PriceType, - ProductItem, - UsageModel, - TierInfinite, - UsagePriceConfig, - OnIncrease, - OnDecrease, FeatureUsageType, + type FixedPriceConfig, + Infinite, + itemToBillingInterval, + itemToEntInterval, + OnDecrease, + OnIncrease, + type Price, + PriceType, + type ProductItem, + TierInfinite, + UsageModel, + type UsagePriceConfig, } from "@autumn/shared"; -import { generateId, notNullish, nullish } from "@/utils/genUtils.js"; import { pricesAreSame } from "@/internal/products/prices/priceInitUtils.js"; -import { entsAreSame } from "../../entitlements/entitlementUtils.js"; import { getBillingType } from "@/internal/products/prices/priceUtils.js"; - +import RecaseError from "@/utils/errorUtils.js"; +import { generateId, notNullish, nullish } from "@/utils/genUtils.js"; +import { entsAreSame } from "../../entitlements/entitlementUtils.js"; +import { shouldProrate } from "../../prices/priceUtils/prorationConfigUtils.js"; +import { itemCanBeProrated } from "./classifyItem.js"; import { isFeatureItem, isFeaturePriceItem, isPriceItem, } from "./getItemType.js"; -import { - itemToBillingInterval, - itemToEntInterval, -} from "../itemIntervalUtils.js"; -import { itemCanBeProrated } from "./classifyItem.js"; -import { shouldProrate } from "../../prices/priceUtils/prorationConfigUtils.js"; export const getResetUsage = ({ item, @@ -53,7 +50,7 @@ export const getResetUsage = ({ (isFeatureItem(item) || isFeaturePriceItem(item)) && feature ) { - return feature?.config?.usage_type == FeatureUsageType.Single; + return feature?.config?.usage_type === FeatureUsageType.Single; } return item.reset_usage_when_enabled; }; @@ -71,10 +68,10 @@ export const toPrice = ({ isCustom: boolean; newVersion?: boolean; }) => { - let config: FixedPriceConfig = { + const config: FixedPriceConfig = { type: PriceType.Fixed, - amount: notNullish(item.price) ? item.price! : item.tiers![0].amount!, - interval: itemToBillingInterval(item) as BillingInterval, + amount: notNullish(item.price) ? item.price : item.tiers![0].amount, + interval: itemToBillingInterval({ item }) as BillingInterval, interval_count: item.interval_count || 1, }; @@ -116,9 +113,9 @@ export const toFeature = ({ newVersion?: boolean; feature?: Feature; }) => { - let isBoolean = feature?.type == FeatureType.Boolean; + const isBoolean = feature?.type == FeatureType.Boolean; - let resetUsage = getResetUsage({ item, feature }); + const resetUsage = getResetUsage({ item, feature }); let ent: Entitlement = { id: item.entitlement_id || generateId("ent"), @@ -137,7 +134,7 @@ export const toFeature = ({ ? AllowanceType.Unlimited : AllowanceType.Fixed, - interval: isBoolean ? null : (itemToEntInterval(item) as EntInterval), + interval: isBoolean ? null : (itemToEntInterval({ item }) as EntInterval), interval_count: item.interval_count || 1, carry_from_previous: !resetUsage, @@ -178,7 +175,7 @@ export const toFeatureAndPrice = ({ newVersion?: boolean; features: Feature[]; }) => { - let resetUsage = getResetUsage({ + const resetUsage = getResetUsage({ item, feature: features.find((f) => f.id == item.feature_id), }); @@ -195,7 +192,7 @@ export const toFeatureAndPrice = ({ allowance: (item.included_usage as number) || 0, allowance_type: AllowanceType.Fixed, - interval: itemToEntInterval(item) as EntInterval, + interval: itemToEntInterval({ item }) as EntInterval, interval_count: item.interval_count || 1, carry_from_previous: !resetUsage, @@ -206,7 +203,7 @@ export const toFeatureAndPrice = ({ }; // Will only create new ent id if - let newEnt = !curEnt || (isCustom && !entsAreSame(curEnt, ent)); + const newEnt = !curEnt || (isCustom && !entsAreSame(curEnt, ent)); if (newEnt || newVersion) { ent = { ...ent, @@ -215,9 +212,9 @@ export const toFeatureAndPrice = ({ }; } - let entInterval = itemToEntInterval(item); + const entInterval = itemToEntInterval({ item }); - let config: UsagePriceConfig = { + const config: UsagePriceConfig = { type: PriceType.Usage, bill_when: @@ -238,13 +235,14 @@ export const toFeatureAndPrice = ({ }, ] : (item.tiers as any), - interval: itemToBillingInterval(item) as BillingInterval, + interval: itemToBillingInterval({ item }) as BillingInterval, interval_count: item.interval_count || 1, }; let prorationConfig = null; if (itemCanBeProrated({ item, features })) { - let onIncrease = item.config?.on_increase || OnIncrease.ProrateImmediately; + const onIncrease = + item.config?.on_increase || OnIncrease.ProrateImmediately; let onDecrease = item.config?.on_decrease || OnDecrease.Prorate; // console.log("Item config:", item.config); @@ -274,7 +272,7 @@ export const toFeatureAndPrice = ({ proration_config: prorationConfig, }; - let billingType = getBillingType(price.config!); + const billingType = getBillingType(price.config!); if ( (billingType == BillingType.UsageInArrear || billingType == BillingType.InArrearProrated) && @@ -287,13 +285,13 @@ export const toFeatureAndPrice = ({ }); } - let priceOrEntDifferent = + const priceOrEntDifferent = (curPrice && !pricesAreSame(curPrice, price, true)) || (curEnt && !entsAreSame(curEnt, ent)); if (curPrice && (priceOrEntDifferent || newVersion)) { - let newConfig = price.config as UsagePriceConfig; - let curConfig = curPrice.config as UsagePriceConfig; + const newConfig = price.config as UsagePriceConfig; + const curConfig = curPrice.config as UsagePriceConfig; newConfig.stripe_meter_id = curConfig.stripe_meter_id; newConfig.stripe_product_id = curConfig.stripe_product_id; price.config = newConfig; @@ -341,7 +339,7 @@ export const itemToPriceAndEnt = ({ let sameEnt: Entitlement | null = null; if (isPriceItem(item)) { - let { price } = toPrice({ + const { price } = toPrice({ item, orgId, internalProductId, @@ -363,9 +361,9 @@ export const itemToPriceAndEnt = ({ code: ErrCode.InvalidRequest, }); } - let isBoolean = feature?.type == FeatureType.Boolean; + const isBoolean = feature?.type == FeatureType.Boolean; - let { ent } = toFeature({ + const { ent } = toFeature({ item, orgId, internalFeatureId: feature!.internal_id!, @@ -393,7 +391,7 @@ export const itemToPriceAndEnt = ({ }); } - let { price, ent } = toFeatureAndPrice({ + const { price, ent } = toFeatureAndPrice({ item, orgId, internalFeatureId: feature!.internal_id!, @@ -405,7 +403,7 @@ export const itemToPriceAndEnt = ({ features, }); - let entSame = curEnt && entsAreSame(curEnt, ent); + const entSame = curEnt && entsAreSame(curEnt, ent); // 1. If no curPrice, price is new if (!curPrice || newVersion) { diff --git a/server/src/internal/products/product-items/validateProductItems.ts b/server/src/internal/products/product-items/validateProductItems.ts index 14dbffcd8..2fcded8b6 100644 --- a/server/src/internal/products/product-items/validateProductItems.ts +++ b/server/src/internal/products/product-items/validateProductItems.ts @@ -5,6 +5,7 @@ import { type Feature, FeatureType, Infinite, + itemToEntInterval, OnIncrease, type ProductItem, ProductItemInterval, @@ -17,7 +18,6 @@ import { StatusCodes } from "http-status-codes"; import RecaseError from "@/utils/errorUtils.js"; import { notNullish, nullish } from "@/utils/genUtils.js"; import { createFeaturesFromItems } from "./createFeaturesFromItems.js"; -import { itemToEntInterval } from "./itemIntervalUtils.js"; import { isBooleanFeatureItem, isFeatureItem, @@ -27,11 +27,13 @@ import { const validateProductItem = ({ item, + features, }: { item: ProductItem; features: Feature[]; }) => { item = ProductItemSchema.parse(item); + const feature = features.find((f) => f.id === item.feature_id); if (nullish(item.feature_id) && nullish(item.price) && nullish(item.tiers)) { throw new RecaseError({ @@ -70,30 +72,17 @@ const validateProductItem = ({ statusCode: StatusCodes.BAD_REQUEST, }); } - - // if (item.tiers) { - // item.tiers.forEach((tier) => { - // if (tier.amount.toString().split(".")[1]?.length > 2) { - // throw new RecaseError({ - // message: `One off prices can have at most 2 decimal places`, - // code: ErrCode.InvalidInputs, - // statusCode: StatusCodes.BAD_REQUEST, - // }); - // } - // }); - // } } // 4. If it's a feature item, it should have included usage as number or inf if (isFeaturePriceItem(item) || isFeatureItem(item)) { if ( - (typeof item.included_usage !== "number" && - item.included_usage !== Infinite && - notNullish(item.included_usage)) || - item.included_usage === 0 + typeof item.included_usage !== "number" && + item.included_usage !== Infinite && + notNullish(item.included_usage) ) { throw new RecaseError({ - message: `Included usage must be a number or '${Infinite}'`, + message: `Included usage for feature ${item.feature_id} must be a number or '${Infinite}'`, code: ErrCode.InvalidInputs, statusCode: StatusCodes.BAD_REQUEST, }); @@ -104,6 +93,16 @@ const validateProductItem = ({ } } + if (isFeatureItem(item)) { + if (item.included_usage === 0 && feature?.type !== FeatureType.Boolean) { + throw new RecaseError({ + message: `Included usage for feature ${item.feature_id} must be greater than 0`, + code: ErrCode.InvalidInputs, + statusCode: StatusCodes.BAD_REQUEST, + }); + } + } + // 5. If it's a price, can't have day, minute or hour interval if (isFeaturePriceItem(item) || isPriceItem(item)) { if ( @@ -134,7 +133,7 @@ const validateProductItem = ({ }) ) { throw new RecaseError({ - message: `Tiered prices must be greater than 0`, + message: `Price must be a number and greater than 0 for feature ${item.feature_id}`, code: ErrCode.InvalidInputs, statusCode: StatusCodes.BAD_REQUEST, }); @@ -179,7 +178,8 @@ const validateProductItem = ({ item.included_usage === 0 ) { throw new RecaseError({ - message: "Rollover is only allowed for items with intervals and included usage", + message: + "Rollover is only allowed for items with intervals and included usage", code: ErrCode.InvalidInputs, statusCode: StatusCodes.BAD_REQUEST, }); @@ -200,7 +200,8 @@ const validateProductItem = ({ if (rollover.duration === RolloverDuration.Month) { if (typeof rollover.length !== "number" || rollover.length < 0) { throw new RecaseError({ - message: "Rollover length must be a positive number for monthly durations", + message: + "Rollover length must be a positive number for monthly durations", code: ErrCode.InvalidInputs, statusCode: StatusCodes.BAD_REQUEST, }); @@ -253,7 +254,7 @@ export const validateProductItems = ({ for (let index = 0; index < newItems.length; index++) { const item = newItems[index]; - const entInterval = itemToEntInterval(item); + const entInterval = itemToEntInterval({ item }); const intervalCount = item.interval_count || 1; if (isFeaturePriceItem(item) && entInterval === EntInterval.Lifetime) { @@ -293,7 +294,7 @@ export const validateProductItems = ({ return ( i.feature_id === item.feature_id && index2 !== index && - itemToEntInterval(i) === entInterval && + itemToEntInterval({ item: i }) === entInterval && (i.interval_count || 1) === intervalCount && i.entity_feature_id === item.entity_feature_id ); diff --git a/shared/models/productModels/freeTrialModels/freeTrialModels.ts b/shared/models/productModels/freeTrialModels/freeTrialModels.ts index 8d4b1d593..efde4a544 100644 --- a/shared/models/productModels/freeTrialModels/freeTrialModels.ts +++ b/shared/models/productModels/freeTrialModels/freeTrialModels.ts @@ -17,7 +17,10 @@ export const CreateFreeTrialSchema = z.object({ length: z .string() .or(z.number()) - .transform((val) => Number(val)), + .transform((val) => Number(val)) + .refine((val) => val > 0, { + message: "Free trial length must be greater than 0", + }), unique_fingerprint: z.boolean().default(false), duration: z.nativeEnum(FreeTrialDuration).default(FreeTrialDuration.Day), card_required: z.boolean().default(true), diff --git a/shared/models/productModels/priceModels/priceConfig/fixedPriceConfig.ts b/shared/models/productModels/priceModels/priceConfig/fixedPriceConfig.ts index 6494fbc82..0274ae8db 100644 --- a/shared/models/productModels/priceModels/priceConfig/fixedPriceConfig.ts +++ b/shared/models/productModels/priceModels/priceConfig/fixedPriceConfig.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { BillingInterval } from "../priceEnums.js"; +import { UsageTierSchema } from "./usagePriceConfig.js"; export const FixedPriceConfigSchema = z.object({ type: z.string(), @@ -9,6 +10,7 @@ export const FixedPriceConfigSchema = z.object({ // Usage price fields billing_units: z.number().nullish(), + usage_tiers: z.array(UsageTierSchema).nullish(), stripe_price_id: z.string().nullish(), stripe_empty_price_id: z.string().nullish(), stripe_product_id: z.null().or(z.undefined()), diff --git a/shared/models/productV2Models/productItemModels/productItemModels.ts b/shared/models/productV2Models/productItemModels/productItemModels.ts index b52d337bc..228c43083 100644 --- a/shared/models/productV2Models/productItemModels/productItemModels.ts +++ b/shared/models/productV2Models/productItemModels/productItemModels.ts @@ -94,7 +94,6 @@ export const LimitedItemSchema = ProductItemSchema.extend({ }); export const FrontendProductItem = ProductItemSchema.extend({ - price: z.union([z.number(), z.string()]).nullish(), isPrice: z.boolean(), isVariable: z.boolean().nullish(), isBasePrice: z.boolean().nullish(), diff --git a/shared/utils/index.ts b/shared/utils/index.ts index 1b09d39d2..5e1a2ddb3 100644 --- a/shared/utils/index.ts +++ b/shared/utils/index.ts @@ -20,6 +20,7 @@ export * from "./productV2Utils/mapToProductV2.js"; export * from "./productV2Utils/productItemUtils/classifyItemUtils.js"; export * from "./productV2Utils/productItemUtils/convertItemUtils.js"; export * from "./productV2Utils/productItemUtils/getItemType.js"; +export * from "./productV2Utils/productItemUtils/itemIntervalUtils.js"; // Item utils export * from "./productV2Utils/productItemUtils/mapToItem.js"; export * from "./productV2Utils/productItemUtils/productItemUtils.js"; diff --git a/shared/utils/intervalUtils.ts b/shared/utils/intervalUtils.ts index 9a3450b23..5a3583d6d 100644 --- a/shared/utils/intervalUtils.ts +++ b/shared/utils/intervalUtils.ts @@ -30,9 +30,9 @@ export const intervalsDifferent = ({ intervalA: IntervalConfig; intervalB: IntervalConfig; }) => { - let valA = intervalToValue(intervalA.interval, intervalA.intervalCount); - let valB = intervalToValue(intervalB.interval, intervalB.intervalCount); - return valA != valB; + const valA = intervalToValue(intervalA.interval, intervalA.intervalCount); + const valB = intervalToValue(intervalB.interval, intervalB.intervalCount); + return valA !== valB; }; export const intervalsSame = ({ diff --git a/shared/utils/productDisplayUtils.ts b/shared/utils/productDisplayUtils.ts index 634746868..53128734a 100644 --- a/shared/utils/productDisplayUtils.ts +++ b/shared/utils/productDisplayUtils.ts @@ -1,9 +1,9 @@ +import { FeatureType } from "../models/featureModels/featureEnums.js"; import type { Feature } from "../models/featureModels/featureModels.js"; import { Infinite } from "../models/productModels/productEnums.js"; -import { - type ProductItem, - ProductItemFeatureType, - type ProductItemInterval, +import type { + ProductItem, + ProductItemInterval, } from "../models/productV2Models/productItemModels/productItemModels.js"; import { formatAmount, @@ -76,7 +76,7 @@ export const getFeatureItemDisplay = ({ }) => { if (!feature) throw new Error(`Feature ${item.feature_id} not found`); - if (item.feature_type === ProductItemFeatureType.Static) { + if (feature.type === FeatureType.Boolean) { return { primary_text: feature.name }; } @@ -97,10 +97,6 @@ export const getFeatureItemDisplay = ({ intervalCount: item.interval_count, }); - console.log( - `feature ${feature.id}, interval ${item.interval}, interval count ${item.interval_count}, interval string ${intervalStr}`, - ); - return { primary_text: `${includedUsageTxt}${featureName}`, secondary_text: fullDisplay && intervalStr ? intervalStr : undefined, @@ -166,6 +162,7 @@ export const getFeaturePriceItemDisplay = ({ } const priceStr = formatTiers({ item, currency, amountFormatOptions }); + const billingFeatureName = getFeatureName({ feature, units: item.billing_units, diff --git a/shared/utils/productUtils/freeTrialUtils.ts b/shared/utils/productUtils/freeTrialUtils.ts index ceecfe341..53aade237 100644 --- a/shared/utils/productUtils/freeTrialUtils.ts +++ b/shared/utils/productUtils/freeTrialUtils.ts @@ -12,10 +12,36 @@ export const freeTrialsAreSame = ({ }) => { if (!ft1 && !ft2) return true; if (!ft1 || !ft2) return false; - return ( - ft1.length === ft2.length && - ft1.unique_fingerprint === ft2.unique_fingerprint && - ft1.duration === ft2.duration && - ft1.card_required === ft2.card_required - ); + + const diffs = { + length: { + condition: ft1.length !== ft2.length, + message: `Length different: ${ft1.length} !== ${ft2.length}`, + }, + unique_fingerprint: { + condition: ft1.unique_fingerprint !== ft2.unique_fingerprint, + message: `Unique fingerprint different: ${ft1.unique_fingerprint} !== ${ft2.unique_fingerprint}`, + }, + duration: { + condition: ft1.duration !== ft2.duration, + message: `Duration different: ${ft1.duration} !== ${ft2.duration}`, + }, + card_required: { + condition: ft1.card_required !== ft2.card_required, + message: `Card required different: ${ft1.card_required} !== ${ft2.card_required}`, + }, + }; + + const freeTrialsAreDiff = Object.values(diffs).some((d) => d.condition); + + if (freeTrialsAreDiff) { + console.log("Free trials different"); + console.log( + "Differences:", + Object.values(diffs) + .filter((d) => d.condition) + .map((d) => d.message), + ); + } + return !freeTrialsAreDiff; }; diff --git a/shared/utils/productV2Utils/compareProductUtils.ts/compareItemUtils.ts b/shared/utils/productV2Utils/compareProductUtils.ts/compareItemUtils.ts index 587262a50..4b225b516 100644 --- a/shared/utils/productV2Utils/compareProductUtils.ts/compareItemUtils.ts +++ b/shared/utils/productV2Utils/compareProductUtils.ts/compareItemUtils.ts @@ -1,9 +1,13 @@ /** biome-ignore-all lint/suspicious/noDoubleEquals: need to compare null / undefined for different fields */ import { + entIntervalsSame, type Feature, type FeatureItem, type FeaturePriceItem, FeatureUsageType, + intervalsSame, + itemToBillingInterval, + itemToEntInterval, type PriceItem, type ProductItem, type UsageTier, @@ -22,9 +26,39 @@ export const findSimilarItem = ({ item: ProductItem; items: ProductItem[]; }) => { - // 1. If feature item - if (isFeatureItem(item) || isFeaturePriceItem(item)) { - return items.find((i) => i.feature_id === item.feature_id); + if (isFeatureItem(item)) { + return items.find( + (i) => + i.feature_id === item.feature_id && + entIntervalsSame({ + intervalA: { + interval: itemToEntInterval({ item: i }), + intervalCount: i.interval_count, + }, + intervalB: { + interval: itemToEntInterval({ item }), + intervalCount: item.interval_count, + }, + }), + ); + } + + if (isFeaturePriceItem(item)) { + return items.find( + (i) => + i.feature_id === item.feature_id && + intervalsSame({ + intervalA: { + interval: itemToBillingInterval({ item: i }), + intervalCount: i.interval_count, + }, + intervalB: { + interval: itemToBillingInterval({ item }), + intervalCount: item.interval_count, + }, + }) && + item.usage_model == i.usage_model, + ); } // 2. If price item diff --git a/shared/utils/productV2Utils/compareProductUtils.ts/compareProductUtils.ts b/shared/utils/productV2Utils/compareProductUtils.ts/compareProductUtils.ts index 4f95b4f31..92f78c131 100644 --- a/shared/utils/productV2Utils/compareProductUtils.ts/compareProductUtils.ts +++ b/shared/utils/productV2Utils/compareProductUtils.ts/compareProductUtils.ts @@ -77,6 +77,19 @@ export const compareDetails = ({ return detailsSame; }; +export const prodOptionsAreSame = ({ + curProduct, + newProduct, +}: { + curProduct: ProductV2 | FullProduct; + newProduct: ProductV2 | FullProduct; +}) => { + return ( + curProduct.is_default === newProduct.is_default && + curProduct.is_add_on === newProduct.is_add_on + ); +}; + export const productsAreSame = ({ newProductV1, newProductV2, @@ -117,14 +130,11 @@ export const productsAreSame = ({ items1 = sanitizeItems({ items: items1, features }); items2 = sanitizeItems({ items: items2, features }); - // console.log("Items 1:", items1); - // console.log("Items 2:", items2); - let itemsSame = true; let pricesChanged = false; + let detailsSame = true; const newItems: ProductItem[] = []; const removedItems: ProductItem[] = []; - let detailsSame = true; detailsSame = compareDetails({ newProductV2, @@ -135,30 +145,17 @@ export const productsAreSame = ({ itemsSame = false; } - // // Check if any feature's usage limits have changed - // items1.some((item1: ProductItem) => { - // const matchingItem2 = items2?.find( - // (item2: ProductItem) => item2.feature_id === item1.feature_id, - // ); - // if (!matchingItem2) return false; - - // const feature = features.find((f) => f.id === item1.feature_id); - // if (!feature) return false; - - // return false; - // }); - if (items1.length !== items2.length) itemsSame = false; for (const item of items1) { - // console.log("Item:", item); + // console.log(`Base ${formatItem({ item, features })}`); const similarItem = findSimilarItem({ item, items: items2, }); - // console.log("Similar item:", similarItem); + // console.log(`Similar ${formatItem({ item, features })}`); if (!similarItem) { if (isFeaturePriceItem(item) || isPriceItem(item)) { @@ -212,12 +209,12 @@ export const productsAreSame = ({ ft2: freeTrial2, }); - if (!freeTrialsSame) { - console.log("Free trials different"); - console.log("Free trial 1:", freeTrial1); - console.log("Free trial 2:", freeTrial2); - console.log("--------------------------------"); - } + const optionsSame = prodOptionsAreSame({ + // biome-ignore lint/style/noNonNullAssertion: either one is provided + curProduct: curProductV2 || curProductV1!, + // biome-ignore lint/style/noNonNullAssertion: either one is provided + newProduct: newProductV2 || newProductV1!, + }); // Compare name return { @@ -227,5 +224,6 @@ export const productsAreSame = ({ newItems, removedItems, detailsSame, + optionsSame, }; }; diff --git a/shared/utils/productV2Utils/productItemUtils/itemIntervalUtils.ts b/shared/utils/productV2Utils/productItemUtils/itemIntervalUtils.ts new file mode 100644 index 000000000..e39c291a7 --- /dev/null +++ b/shared/utils/productV2Utils/productItemUtils/itemIntervalUtils.ts @@ -0,0 +1,61 @@ +import { + BillingInterval, + EntInterval, + isFeatureItem, + type ProductItem, + ProductItemFeatureType, + type ProductItemInterval, +} from "@autumn/shared"; + +import { nullish } from "../../utils.js"; + +export const billingToItemInterval = ({ + billingInterval, +}: { + billingInterval: BillingInterval; +}) => { + if (billingInterval === BillingInterval.OneOff) { + return null; + } + + return billingInterval as unknown as ProductItemInterval; +}; + +export const entToItemInterval = ({ + entInterval, +}: { + entInterval: EntInterval | null | undefined; +}) => { + if (!entInterval || entInterval === EntInterval.Lifetime) { + return null; + } + return entInterval as unknown as ProductItemInterval; +}; + +export const itemToBillingOrEntInterval = ({ item }: { item: ProductItem }) => { + if (isFeatureItem(item)) { + return itemToEntInterval({ item }); + } + + return itemToBillingInterval({ item }); +}; + +export const itemToBillingInterval = ({ item }: { item: ProductItem }) => { + if (nullish(item.interval)) { + return BillingInterval.OneOff as unknown as BillingInterval; + } + + return item.interval as unknown as BillingInterval; +}; + +export const itemToEntInterval = ({ item }: { item: ProductItem }) => { + if (nullish(item.interval)) { + return EntInterval.Lifetime; + } + + if (item.feature_type === ProductItemFeatureType.ContinuousUse) { + return EntInterval.Lifetime; + } + + return item.interval as unknown as EntInterval; +}; diff --git a/shared/utils/productV2Utils/productItemUtils/mapToItem.ts b/shared/utils/productV2Utils/productItemUtils/mapToItem.ts index 650017e98..eecc4afb7 100644 --- a/shared/utils/productV2Utils/productItemUtils/mapToItem.ts +++ b/shared/utils/productV2Utils/productItemUtils/mapToItem.ts @@ -1,17 +1,18 @@ import { FeatureType } from "../../../models/featureModels/featureEnums.js"; import { AllowanceType, - EntitlementWithFeature, + type EntitlementWithFeature, } from "../../../models/productModels/entModels/entModels.js"; -import { FixedPriceConfig } from "../../../models/productModels/priceModels/priceConfig/fixedPriceConfig.js"; +import type { FixedPriceConfig } from "../../../models/productModels/priceModels/priceConfig/fixedPriceConfig.js"; import { BillWhen, - UsagePriceConfig, + type UsagePriceConfig, } from "../../../models/productModels/priceModels/priceConfig/usagePriceConfig.js"; -import { Price } from "../../../models/productModels/priceModels/priceModels.js"; +import type { Price } from "../../../models/productModels/priceModels/priceModels.js"; import { Infinite } from "../../../models/productModels/productEnums.js"; import { - ProductItem, + type ProductItem, + type ProductItemConfig, ProductItemFeatureType, TierInfinite, UsageModel, @@ -20,7 +21,7 @@ import { nullish } from "../../utils.js"; import { billingToItemInterval, entToItemInterval, -} from "./productItemUtils.js"; +} from "./itemIntervalUtils.js"; export const toProductItem = ({ ent, @@ -30,13 +31,13 @@ export const toProductItem = ({ price?: Price; }) => { if (nullish(price)) return toFeatureItem({ ent: ent! }) as ProductItem; - if (nullish(ent)) return toPriceItem({ price: price! }) as ProductItem; + if (nullish(ent)) return toPriceItem({ price: price }) as ProductItem; - return toFeaturePriceItem({ ent: ent!, price: price! }) as ProductItem; + return toFeaturePriceItem({ ent: ent, price: price }) as ProductItem; }; export const toFeatureItem = ({ ent }: { ent: EntitlementWithFeature }) => { - if (ent.feature.type == FeatureType.Boolean) { + if (ent.feature.type === FeatureType.Boolean) { return { feature_id: ent.feature.id, entitlement_id: ent.id, @@ -49,8 +50,8 @@ export const toFeatureItem = ({ ent }: { ent: EntitlementWithFeature }) => { const item = { feature_id: ent.feature.id, included_usage: - ent.allowance_type == AllowanceType.Unlimited ? Infinite : ent.allowance, - interval: entToItemInterval(ent.interval!), + ent.allowance_type === AllowanceType.Unlimited ? Infinite : ent.allowance, + interval: entToItemInterval({ entInterval: ent.interval }), interval_count: ent.interval_count ?? 1, entity_feature_id: ent.entity_feature_id, @@ -74,16 +75,16 @@ export const toFeaturePriceItem = ({ ent: EntitlementWithFeature; price: Price; }) => { - let config = price.config as UsagePriceConfig; - let tiers = config.usage_tiers.map((tier) => { + const config = price.config as UsagePriceConfig; + const tiers = config.usage_tiers.map((tier) => { return { amount: tier.amount, - to: tier.to == -1 ? TierInfinite : tier.to, + to: tier.to === -1 ? TierInfinite : tier.to, }; }); // Build the item config from both price proration config and entitlement rollover - let itemConfig: any = {}; + let itemConfig: ProductItemConfig = {}; if (price.proration_config) { itemConfig = { ...price.proration_config }; } @@ -91,14 +92,14 @@ export const toFeaturePriceItem = ({ itemConfig.rollover = ent.rollover; } - let item: ProductItem = { + const item: ProductItem = { feature_id: ent.feature.id, feature_type: ent.feature.config?.usage_type || ProductItemFeatureType.SingleUse, included_usage: ent.allowance, - interval: billingToItemInterval(config.interval!), + interval: billingToItemInterval({ billingInterval: config.interval }), interval_count: config.interval_count ?? 1, price: null, @@ -108,8 +109,8 @@ export const toFeaturePriceItem = ({ entity_feature_id: ent.entity_feature_id, reset_usage_when_enabled: !ent.carry_from_previous, usage_model: - config.bill_when == BillWhen.StartOfPeriod || - config.bill_when == BillWhen.InAdvance + config.bill_when === BillWhen.StartOfPeriod || + config.bill_when === BillWhen.InAdvance ? UsageModel.Prepaid : UsageModel.PayPerUse, @@ -127,11 +128,11 @@ export const toFeaturePriceItem = ({ }; export const toPriceItem = ({ price }: { price: Price }) => { - let config = price.config as FixedPriceConfig; + const config = price.config as FixedPriceConfig; return { feature_id: null, - interval: billingToItemInterval(config.interval!), + interval: billingToItemInterval({ billingInterval: config.interval }), interval_count: config.interval_count ?? 1, price: config.amount, diff --git a/shared/utils/productV2Utils/productItemUtils/productItemUtils.ts b/shared/utils/productV2Utils/productItemUtils/productItemUtils.ts index 94ff471e9..9dff796ea 100644 --- a/shared/utils/productV2Utils/productItemUtils/productItemUtils.ts +++ b/shared/utils/productV2Utils/productItemUtils/productItemUtils.ts @@ -3,30 +3,17 @@ import { FeatureUsageType, } from "../../../models/featureModels/featureEnums.js"; import type { Feature } from "../../../models/featureModels/featureModels.js"; -import { EntInterval } from "../../../models/productModels/entModels/entEnums.js"; -import { BillingInterval } from "../../../models/productModels/priceModels/priceEnums.js"; import { type ProductItem, ProductItemFeatureType, - type ProductItemInterval, } from "../../../models/productV2Models/productItemModels/productItemModels.js"; +import { getProductItemDisplay } from "../../productDisplayUtils.js"; import { nullish } from "../../utils.js"; -import { isFeatureItem, isFeaturePriceItem } from "./getItemType.js"; - -export const entToItemInterval = (entInterval: EntInterval) => { - if (entInterval === EntInterval.Lifetime) { - return null; - } - return entInterval as unknown as ProductItemInterval; -}; - -export const billingToItemInterval = (billingInterval: BillingInterval) => { - if (billingInterval === BillingInterval.OneOff) { - return null; - } - - return billingInterval as unknown as ProductItemInterval; -}; +import { + getItemType, + isFeatureItem, + isFeaturePriceItem, +} from "./getItemType.js"; export const getItemFeatureType = ({ item, @@ -69,3 +56,26 @@ export const getResetUsage = ({ } return item.reset_usage_when_enabled; }; + +export const formatItem = ({ + item, + features, +}: { + item?: ProductItem; + features: Feature[]; +}) => { + if (!item || features.length === 0) return "N / A"; + const display = getProductItemDisplay({ + item, + features, + currency: "usd", + // fullDisplay: true, + amountFormatOptions: { + currencyDisplay: "narrowSymbol", + }, + }); + + const itemType = getItemType(item); + + return `(${itemType}) ${display.primary_text} ${display.secondary_text || ""}`; +}; diff --git a/shared/utils/productV3Utils/productItemUtils/productV3ItemUtils.ts b/shared/utils/productV3Utils/productItemUtils/productV3ItemUtils.ts index 64afe6cfb..a8a559599 100644 --- a/shared/utils/productV3Utils/productItemUtils/productV3ItemUtils.ts +++ b/shared/utils/productV3Utils/productItemUtils/productV3ItemUtils.ts @@ -31,10 +31,19 @@ export function productV2ToBasePrice({ product }: { product: ProductV2 }): { export const productV2ToFeatureItems = ({ items, + withBasePrice = false, }: { items: ProductItem[]; + withBasePrice?: boolean; }) => { - return items.filter( + const filteredItems = items.filter( (item) => isFeatureItem(item) || isFeaturePriceItem(item), ); + + const priceItem = items.find((item) => isPriceItem(item)); + if (withBasePrice && priceItem) { + return [...filteredItems, priceItem]; + } + + return filteredItems; }; diff --git a/vite/src/components/v2/icons/AutumnIcons.tsx b/vite/src/components/v2/icons/AutumnIcons.tsx index 795753dfb..840e0f805 100644 --- a/vite/src/components/v2/icons/AutumnIcons.tsx +++ b/vite/src/components/v2/icons/AutumnIcons.tsx @@ -27,28 +27,28 @@ export const IncludedUsageIcon = ({ size = 16 }: { size?: number }) => {
+
{display.primary_text}
{" "}
diff --git a/vite/src/views/products/plan/components/SaveChangesBar.tsx b/vite/src/views/products/plan/components/SaveChangesBar.tsx
index 5d88698c1..44d21068e 100644
--- a/vite/src/views/products/plan/components/SaveChangesBar.tsx
+++ b/vite/src/views/products/plan/components/SaveChangesBar.tsx
@@ -11,7 +11,7 @@ import { updateProduct } from "../../product/utils/updateProduct";
export const SaveChangesBar = () => {
const axiosInstance = useAxiosInstance();
- const { hasChanges, setProduct } = useProductContext();
+ const { hasChanges, willVersion, setProduct } = useProductContext();
const [saving, setSaving] = useState(false);
const { product, setShowNewVersionDialog } = useProductContext();
const { counts, isLoading } = useProductCountsQuery();
@@ -21,7 +21,7 @@ export const SaveChangesBar = () => {
const handleSaveClicked = async () => {
if (isLoading) toast.error("Product counts are loading");
- if (counts?.all > 0) {
+ if (counts?.all > 0 && willVersion) {
setShowNewVersionDialog(true);
return;
}
diff --git a/vite/src/views/products/plan/components/edit-plan-details/AdditionalOptions.tsx b/vite/src/views/products/plan/components/edit-plan-details/AdditionalOptions.tsx
index aeddaa1ad..884030d6e 100644
--- a/vite/src/views/products/plan/components/edit-plan-details/AdditionalOptions.tsx
+++ b/vite/src/views/products/plan/components/edit-plan-details/AdditionalOptions.tsx
@@ -15,6 +15,7 @@ export const AdditionalOptions = () => {
description="This product will be enabled by default for all new users,
typically used for your free plan"
checked={product.is_default}
+ disabled={product.is_add_on}
onCheckedChange={(checked) =>
setProduct({ ...product, is_default: checked })
}
@@ -24,6 +25,7 @@ export const AdditionalOptions = () => {
description="This product is an add-on that can be bought together with your
base products (eg, top ups)"
checked={product.is_add_on}
+ disabled={product.is_default}
onCheckedChange={(checked) =>
setProduct({ ...product, is_add_on: checked })
}
diff --git a/vite/src/views/products/plan/components/edit-plan-details/BasePriceSection.tsx b/vite/src/views/products/plan/components/edit-plan-details/BasePriceSection.tsx
index 7a0aa4735..c7f24fa63 100644
--- a/vite/src/views/products/plan/components/edit-plan-details/BasePriceSection.tsx
+++ b/vite/src/views/products/plan/components/edit-plan-details/BasePriceSection.tsx
@@ -67,7 +67,7 @@ export const BasePriceSection = () => {
...newItems[basePriceIndex],
price: newAmount,
interval: interval
- ? billingToItemInterval(interval)
+ ? billingToItemInterval({ billingInterval: interval })
: basePrice?.interval,
interval_count: interval ? intervalCount : basePrice?.intervalCount,
isBasePrice: true,
diff --git a/vite/src/views/products/plan/components/edit-plan-details/FreeTrialSection.tsx b/vite/src/views/products/plan/components/edit-plan-details/FreeTrialSection.tsx
index 4b326822b..edf4709c2 100644
--- a/vite/src/views/products/plan/components/edit-plan-details/FreeTrialSection.tsx
+++ b/vite/src/views/products/plan/components/edit-plan-details/FreeTrialSection.tsx
@@ -26,7 +26,6 @@ export const FreeTrialSection = () => {
if (checked) {
setProduct({ ...product, free_trial: getDefaultFreeTrial() });
} else {
- console.log("setting free trial to null");
setProduct({ ...product, free_trial: null });
}
}}
@@ -41,11 +40,12 @@ export const FreeTrialSection = () => {
value={product.free_trial?.length || ""}
disabled={!product.free_trial}
onChange={(e) => {
+ const val = e.target.value;
setProduct({
...product,
free_trial: {
...product.free_trial,
- length: e.target.value as unknown as number,
+ length: val === "" ? 0 : parseInt(val),
},
});
}}
diff --git a/vite/src/views/products/plan/components/edit-plan-details/SelectBillingCycle.tsx b/vite/src/views/products/plan/components/edit-plan-details/SelectBillingCycle.tsx
index b6ba1c60c..57db465d3 100644
--- a/vite/src/views/products/plan/components/edit-plan-details/SelectBillingCycle.tsx
+++ b/vite/src/views/products/plan/components/edit-plan-details/SelectBillingCycle.tsx
@@ -19,19 +19,6 @@ export const SelectBillingCycle = ({
setItem: (item: ProductItem) => void;
disabled: boolean;
}) => {
- // const handleBillingIntervalSelected = (value: BillingInterval) => {
- // let usageModel = item.usage_model;
- // if (value == BillingInterval.OneOff) {
- // usageModel = UsageModel.Prepaid;
- // }
-
- // setItem({
- // ...item,
- // interval: value == BillingInterval.OneOff ? null : value,
- // usage_model: usageModel,
- // });
- // };s
-
return (