From db8fab3429bc8de85d736aa1b679487e1bc93d40 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Fri, 26 Sep 2025 16:33:19 +0100 Subject: [PATCH] fix: cleaning up update product section --- .../createStripePrice/createStripeInArrear.ts | 86 +++--- .../src/external/stripe/stripePriceUtils.ts | 27 +- .../internal/invoices/invoiceFormatUtils.ts | 96 ++----- .../handleUpdateProduct.ts | 15 +- .../products/internalProductRouter.ts | 64 ++--- .../product-items/itemIntervalUtils.ts | 48 ---- .../product-items/productItemUtils.ts | 33 +-- .../productItemUtils/itemToPriceAndEnt.ts | 88 +++--- .../product-items/validateProductItems.ts | 47 ++-- .../freeTrialModels/freeTrialModels.ts | 5 +- .../priceConfig/fixedPriceConfig.ts | 2 + .../productItemModels/productItemModels.ts | 1 - shared/utils/index.ts | 1 + shared/utils/intervalUtils.ts | 6 +- shared/utils/productDisplayUtils.ts | 15 +- shared/utils/productUtils/freeTrialUtils.ts | 38 ++- .../compareItemUtils.ts | 40 ++- .../compareProductUtils.ts | 48 ++-- .../productItemUtils/itemIntervalUtils.ts | 61 ++++ .../productItemUtils/mapToItem.ts | 43 +-- .../productItemUtils/productItemUtils.ts | 48 ++-- .../productItemUtils/productV3ItemUtils.ts | 11 +- vite/src/components/v2/icons/AutumnIcons.tsx | 10 +- vite/src/utils/product/itemIntervalUtils.ts | 43 --- .../product-item/validateProductItem.ts | 34 +-- .../views/products/plan/PlanEditorView.tsx | 48 ++-- .../EditPlanFeatureSheet/BillingType.tsx | 34 ++- .../EditPlanFeatureSheet/BillingUnits.tsx | 50 +++- .../EditPlanFeatureSheet.tsx | 8 +- .../EditPlanFeatureSheet/IncludedUsage.tsx | 265 ++++++++---------- .../EditPlanFeatureSheet/PriceTiers.tsx | 2 +- .../PricedFeatureSettings.tsx | 11 +- .../EditPlanFeatureSheet/UsageReset.tsx | 119 ++------ .../advanced-settings/RolloverConfig.tsx | 4 +- .../plan/components/EditPlanHeader.tsx | 16 +- .../products/plan/components/ManagePlan.tsx | 6 +- .../PlanCard/DeleteFeatureRowDialog.tsx | 49 ++++ .../plan/components/PlanCard/PlanCard.tsx | 4 +- .../components/PlanCard/PlanFeatureIcon.tsx | 3 + .../components/PlanCard/PlanFeatureList.tsx | 35 +-- .../components/PlanCard/PlanFeatureRow.tsx | 30 +- .../plan/components/SaveChangesBar.tsx | 4 +- .../edit-plan-details/AdditionalOptions.tsx | 2 + .../edit-plan-details/BasePriceSection.tsx | 2 +- .../edit-plan-details/FreeTrialSection.tsx | 4 +- .../edit-plan-details/SelectBillingCycle.tsx | 13 - .../views/products/plan/hooks/usePlanData.tsx | 26 +- .../tierUtils.ts | 0 .../views/products/product/ProductContext.tsx | 6 +- .../components/SelectResetCycle.tsx | 47 ++-- .../products/product/utils/updateProduct.ts | 1 - 51 files changed, 859 insertions(+), 840 deletions(-) delete mode 100644 server/src/internal/products/product-items/itemIntervalUtils.ts create mode 100644 shared/utils/productV2Utils/productItemUtils/itemIntervalUtils.ts create mode 100644 vite/src/views/products/plan/components/PlanCard/DeleteFeatureRowDialog.tsx rename vite/src/views/products/plan/{components/EditPlanFeatureSheet => utils}/tierUtils.ts (100%) 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 }) => { Included Usage { /> { - if (billingInterval == BillingInterval.OneOff) { - return null; - } - - return billingInterval as unknown as ProductItemInterval; -}; - -export const entToItemInterval = (entInterval: EntInterval) => { - 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 as unknown as EntInterval; -}; diff --git a/vite/src/utils/product/product-item/validateProductItem.ts b/vite/src/utils/product/product-item/validateProductItem.ts index 73581ef48..49af3a78f 100644 --- a/vite/src/utils/product/product-item/validateProductItem.ts +++ b/vite/src/utils/product/product-item/validateProductItem.ts @@ -1,12 +1,12 @@ -import { invalidNumber, notNullish, nullish } from "@/utils/genUtils"; import { - Feature, + type Feature, FeatureUsageType, - FrontendProductItem, - RolloverConfig, + type FrontendProductItem, + type RolloverConfig, RolloverDuration, } from "@autumn/shared"; import { toast } from "sonner"; +import { invalidNumber, notNullish, nullish } from "@/utils/genUtils"; import { isFeatureItem, isFeaturePriceItem } from "../getItemType"; export const validateProductItem = ({ @@ -16,11 +16,12 @@ export const validateProductItem = ({ item: FrontendProductItem; features: Feature[]; }) => { - const feature = features.find((f) => f.id == item.feature_id); + const feature = features.find((f) => f.id === item.feature_id); + // Sanitize product item if ( feature && - feature.config?.usage_type == FeatureUsageType.Continuous && + feature.config?.usage_type === FeatureUsageType.Continuous && isFeatureItem(item) ) { item.interval = null; @@ -42,12 +43,10 @@ export const validateProductItem = ({ return null; } - item.price = parseFloat(item.price!.toString()); + item.price = parseFloat(item.price.toString()); } - if ((item.included_usage as any) === "") { - item.included_usage = null; - } else if (!invalidNumber(item.included_usage)) { + if (!invalidNumber(item.included_usage)) { item.included_usage = Number(item.included_usage); } @@ -56,20 +55,15 @@ export const validateProductItem = ({ return null; } - //if both item.tiers and item.price are set, set item.price to null - if (item.tiers && item.price) { - item.price = null; - } - - // Usage/Feature item validation (when tiers are set) + if (item.tiers && item.price) item.price = null; if (item.tiers) { let previousTo = 0; - const allFree = item.tiers.every((tier) => tier.amount == 0); + const allFree = item.tiers.every((tier) => tier.amount === 0); if (allFree) { - if (item.tiers.length == 1) { + if (item.tiers.length === 1) { toast.error("Price should be greater than 0"); } else { toast.error("Should have at least one tier with price greater than 0"); @@ -78,7 +72,9 @@ export const validateProductItem = ({ } const freeTier = - item.tiers.length > 0 && item.tiers[0].amount == 0 ? item.tiers[0] : null; + item.tiers.length > 0 && item.tiers[0].amount === 0 + ? item.tiers[0] + : null; // const includedUsage = parseFloat(item.included_usage?.toString() || "0"); diff --git a/vite/src/views/products/plan/PlanEditorView.tsx b/vite/src/views/products/plan/PlanEditorView.tsx index 84e746d6e..e48b129e4 100644 --- a/vite/src/views/products/plan/PlanEditorView.tsx +++ b/vite/src/views/products/plan/PlanEditorView.tsx @@ -1,6 +1,7 @@ -import type { ProductItem } from "@autumn/shared"; +import { type ProductItem, productV2ToFeatureItems } from "@autumn/shared"; import { useState } from "react"; import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; +import { getItemId } from "@/utils/product/productItemUtils"; import LoadingScreen from "@/views/general/LoadingScreen"; import { useProductChangedAlert } from "../product/hooks/useProductChangedAlert"; import { useProductQuery } from "../product/hooks/useProductQuery"; @@ -20,7 +21,9 @@ export default function PlanEditorView() { const { product: originalProduct } = useProductQuery(); const { isLoading: featuresLoading } = useFeaturesQuery(); - const { product, setProduct, hasChanges } = usePlanData({ originalProduct }); + const { product, setProduct, hasChanges, willVersion } = usePlanData({ + originalProduct, + }); const [entityFeatureIds, setEntityFeatureIds] = useState([]); const { modal } = useProductChangedAlert({ hasChanges }); @@ -54,6 +57,7 @@ export default function PlanEditorView() { entityFeatureIds, setEntityFeatureIds, hasChanges, + willVersion, setSheet: setSheetWithTransition, editingState, setEditingState, @@ -69,7 +73,7 @@ export default function PlanEditorView() { }} />
-
+
@@ -85,28 +89,30 @@ export default function PlanEditorView() { export const PlanSheets = ({ sheet }: { sheet: Sheets }) => { const { product, setProduct, editingState } = useProductContext(); - // Find the item being edited - const currentItem = - product?.items?.find((item: ProductItem, index: number) => { - const itemId = item.entitlement_id || item.price_id || `item-${index}`; - return editingState.id === itemId; - }) || null; + const featureItems = productV2ToFeatureItems({ items: product?.items }); + + const isCurrentItem = (item: ProductItem, index: number) => { + const itemId = getItemId({ item, itemIndex: index }); + return editingState.id === itemId; + }; + + const currentItem = featureItems.find(isCurrentItem); - // Create a proper setItem function that updates the product const setCurrentItem = (updatedItem: ProductItem) => { if (!product || !product.items) return; - const updatedItems = product.items.map( - (item: ProductItem, index: number) => { - const itemId = item.entitlement_id || item.price_id || `item-${index}`; - return editingState.id === itemId ? updatedItem : item; - }, - ); - - setProduct({ - ...product, - items: updatedItems, + const filteredItems = productV2ToFeatureItems({ + items: product.items, + withBasePrice: true, }); + + const currentItemIndex = filteredItems.findIndex(isCurrentItem); + + if (currentItemIndex === -1) return; + + const updatedItems = [...filteredItems]; + updatedItems[currentItemIndex] = updatedItem; + setProduct({ ...product, items: updatedItems }); }; // Don't render on small screens @@ -118,7 +124,7 @@ export const PlanSheets = ({ sheet }: { sheet: Sheets }) => { return ( { + const getPricedInterval = () => { + if ( + !Object.values(BillingInterval).includes( + item.interval as unknown as BillingInterval, + ) + ) { + return ProductItemInterval.Month; + } + return item.interval; + }; + if (type === "included") { // Remove tiers to switch to included - setItem({ ...item, tiers: null, billing_units: undefined }); + setItem({ + ...item, + tiers: null, + billing_units: undefined, + // interval: , + }); } else { // Add initial tier to switch to priced - setItem({ ...item, tiers: [{ to: 0, amount: 0 }], billing_units: 1 }); + setItem({ + ...item, + tiers: [{ to: Infinite, amount: 0 }], + billing_units: 1, + included_usage: + item.included_usage === Infinite ? 0 : item.included_usage || 0, + interval: getPricedInterval(), + }); } }; diff --git a/vite/src/views/products/plan/components/EditPlanFeatureSheet/BillingUnits.tsx b/vite/src/views/products/plan/components/EditPlanFeatureSheet/BillingUnits.tsx index 7439c00f6..b69917ada 100644 --- a/vite/src/views/products/plan/components/EditPlanFeatureSheet/BillingUnits.tsx +++ b/vite/src/views/products/plan/components/EditPlanFeatureSheet/BillingUnits.tsx @@ -1,29 +1,45 @@ -import { useRef, useState } from "react"; +import { type Feature, getFeatureName } from "@autumn/shared"; +import { useEffect, useRef, useState } from "react"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Button } from "@/components/v2/buttons/Button"; -import { Input } from "@/components/v2/inputs/Input"; +import { LabelInput } from "@/components/v2/inputs/LabelInput"; import { useOrg } from "@/hooks/common/useOrg"; +import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; import { cn } from "@/lib/utils"; import { useProductItemContext } from "@/views/products/product/product-item/ProductItemContext"; export function BillingUnits() { const { org } = useOrg(); + const { features } = useFeaturesQuery(); const [popoverOpen, setPopoverOpen] = useState(false); const { item, setItem } = useProductItemContext(); + const [billingUnits, setBillingUnits] = useState(item?.billing_units); const triggerRef = useRef(null); + useEffect(() => { + setBillingUnits(item?.billing_units ?? 1); + }, [item?.billing_units]); + if (!item) return null; const handleEnterClick = () => { - setItem({ ...item, billing_units: Number(item.billing_units) }); + setItem({ + ...item, + billing_units: billingUnits === 0 || "" ? 1 : Number(billingUnits), + }); setPopoverOpen(false); }; const currency = org?.default_currency?.toUpperCase() ?? "USD"; + const unitName = getFeatureName({ + feature: features.find((f: Feature) => f.id === item.feature_id), + plural: Boolean(item.billing_units && item.billing_units > 1), + capitalize: false, + }); return (
@@ -31,25 +47,29 @@ export function BillingUnits() { - - + - setItem({ ...item, billing_units: Number(e.target.value) }) - } + value={billingUnits === 0 ? "" : (billingUnits ?? "")} + onChange={(e) => setBillingUnits(Number(e.target.value))} placeholder="e.g. 100 units" onKeyDown={(e) => { if (e.key === "Enter") { diff --git a/vite/src/views/products/plan/components/EditPlanFeatureSheet/EditPlanFeatureSheet.tsx b/vite/src/views/products/plan/components/EditPlanFeatureSheet/EditPlanFeatureSheet.tsx index c8a15a82a..338876efa 100644 --- a/vite/src/views/products/plan/components/EditPlanFeatureSheet/EditPlanFeatureSheet.tsx +++ b/vite/src/views/products/plan/components/EditPlanFeatureSheet/EditPlanFeatureSheet.tsx @@ -1,4 +1,4 @@ -import { ProductItemFeatureType } from "@autumn/shared"; +import { FeatureType } from "@autumn/shared"; import { SheetHeader, SheetSection } from "@/components/v2/sheets/InlineSheet"; import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; import { getFeature } from "@/utils/product/entitlementUtils"; @@ -15,12 +15,10 @@ export function EditPlanFeatureSheet() { const { item } = useProductItemContext(); const { features } = useFeaturesQuery(); - // Early return if no item + // console.log("Item", item); if (!item) return null; const feature = getFeature(item?.feature_id ?? "", features); - - // Derive billing type from item state - no local state needed const isFeaturePrice = isFeaturePriceItem(item); return ( @@ -30,7 +28,7 @@ export function EditPlanFeatureSheet() { description="Configure how this feature is used in your app" /> - {item.feature_type !== ProductItemFeatureType.Static && ( + {feature?.type !== FeatureType.Boolean && ( <> diff --git a/vite/src/views/products/plan/components/EditPlanFeatureSheet/IncludedUsage.tsx b/vite/src/views/products/plan/components/EditPlanFeatureSheet/IncludedUsage.tsx index 3184b7e3d..946b75f8f 100644 --- a/vite/src/views/products/plan/components/EditPlanFeatureSheet/IncludedUsage.tsx +++ b/vite/src/views/products/plan/components/EditPlanFeatureSheet/IncludedUsage.tsx @@ -1,63 +1,37 @@ -import { - BillingInterval, - EntInterval, - Infinite, - isContUseItem, - type ProductItemInterval, -} from "@autumn/shared"; +import { Infinite, isContUseItem } from "@autumn/shared"; import { InfinityIcon } from "@phosphor-icons/react"; -import { useState } from "react"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { Button } from "@/components/v2/buttons/Button"; import { IconCheckbox } from "@/components/v2/checkboxes/IconCheckbox"; -import { FormLabel } from "@/components/v2/form/FormLabel"; import { Input } from "@/components/v2/inputs/Input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/v2/selects/Select"; import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; -import { formatIntervalText } from "@/utils/formatUtils/formatTextUtils"; import { isFeaturePriceItem } from "@/utils/product/getItemType"; import { useProductItemContext } from "@/views/products/product/product-item/ProductItemContext"; +import { UsageReset } from "./UsageReset"; export function IncludedUsage() { const { features } = useFeaturesQuery(); const { item, setItem } = useProductItemContext(); - const [open, setOpen] = useState(false); if (!item) return null; const includedUsage = item.included_usage; - const intervalCount = item.interval_count || 1; - - const handleBillingIntervalSelected = ( - value: BillingInterval | EntInterval | ProductItemInterval, - ) => { - setItem({ - ...item, - interval: - value === EntInterval.Lifetime ? null : (value as ProductItemInterval), - }); - }; - - const handleSaveCustomInterval = (newIntervalCount: number) => { - setItem({ - ...item, - interval_count: newIntervalCount, - }); - setOpen(false); - }; const isFeaturePrice = isFeaturePriceItem(item); + // Helper function to get the display value for the input + const getInputValue = () => { + if (includedUsage === Infinite) { + return "Unlimited"; + } + if ( + includedUsage === null || + includedUsage === undefined || + includedUsage === 0 + ) { + return ""; + } + return includedUsage.toString(); + }; + return (
@@ -67,20 +41,23 @@ export function IncludedUsage() {
{ - const value = e.target.value; - const numValue = value === "" ? 0 : parseInt(value) || 0; - setItem({ ...item, included_usage: numValue }); + const value = e.target.value.trim(); + + if (value === "" || value === "0") { + setItem({ ...item, included_usage: 0 }); + } else { + const numValue = parseInt(value); + if (!Number.isNaN(numValue) && numValue > 0) { + setItem({ ...item, included_usage: numValue }); + } + } }} disabled={includedUsage === Infinite} + type="text" /> {/* Only show Usage Reset dropdown for included billing type */} - {!isFeaturePrice && !isContUseItem({ item, features }) && ( -
-
Usage Reset
- { - const value = parseInt(e.target.value) || 1; - setItem({ - ...item, - interval_count: value, - }); - }} - onKeyDown={(e) => { - if (e.key === "Enter") { - handleSaveCustomInterval(intervalCount as number); - } - if (e.key === "Escape") { - setOpen(false); - } - }} - /> - -
- - - - -
- )} + {!isFeaturePrice && !isContUseItem({ item, features }) && }
); } + +//
+//
Usage Reset
+// { +// const value = parseInt(e.target.value) || 1; +// setItem({ +// ...item, +// interval_count: value, +// }); +// }} +// onKeyDown={(e) => { +// if (e.key === "Enter") { +// handleSaveCustomInterval(intervalCount as number); +// } +// if (e.key === "Escape") { +// setOpen(false); +// } +// }} +// /> +// +//
+//
+// +// +// +//
diff --git a/vite/src/views/products/plan/components/EditPlanFeatureSheet/PriceTiers.tsx b/vite/src/views/products/plan/components/EditPlanFeatureSheet/PriceTiers.tsx index ff996cf33..dfb5a1f50 100644 --- a/vite/src/views/products/plan/components/EditPlanFeatureSheet/PriceTiers.tsx +++ b/vite/src/views/products/plan/components/EditPlanFeatureSheet/PriceTiers.tsx @@ -4,8 +4,8 @@ import { Plus } from "lucide-react"; import { IconButton } from "@/components/v2/buttons/IconButton"; import { Input } from "@/components/v2/inputs/Input"; import { useProductItemContext } from "@/views/products/product/product-item/ProductItemContext"; +import { addTier, removeTier, updateTier } from "../../utils/tierUtils"; import { BillingUnits } from "./BillingUnits"; -import { addTier, removeTier, updateTier } from "./tierUtils"; export function PriceTiers() { const { item, setItem } = useProductItemContext(); diff --git a/vite/src/views/products/plan/components/EditPlanFeatureSheet/PricedFeatureSettings.tsx b/vite/src/views/products/plan/components/EditPlanFeatureSheet/PricedFeatureSettings.tsx index 9cd033b0a..57304990e 100644 --- a/vite/src/views/products/plan/components/EditPlanFeatureSheet/PricedFeatureSettings.tsx +++ b/vite/src/views/products/plan/components/EditPlanFeatureSheet/PricedFeatureSettings.tsx @@ -1,4 +1,4 @@ -import { UsageModel } from "@autumn/shared"; +import { nullish, ProductItemInterval, UsageModel } from "@autumn/shared"; import { AreaCheckbox } from "@/components/v2/checkboxes/AreaCheckbox"; import { useProductItemContext } from "@/views/products/product/product-item/ProductItemContext"; @@ -19,7 +19,14 @@ export function PricedFeatureSettings() { const newUsageModel = checked ? UsageModel.Prepaid : UsageModel.PayPerUse; - setItem({ ...item, usage_model: newUsageModel }); + setItem({ + ...item, + usage_model: newUsageModel, + interval: + !checked && nullish(item.interval) + ? ProductItemInterval.Month + : item.interval, + }); }} />
diff --git a/vite/src/views/products/plan/components/EditPlanFeatureSheet/UsageReset.tsx b/vite/src/views/products/plan/components/EditPlanFeatureSheet/UsageReset.tsx index 3fb99b212..f7f2f4fb9 100644 --- a/vite/src/views/products/plan/components/EditPlanFeatureSheet/UsageReset.tsx +++ b/vite/src/views/products/plan/components/EditPlanFeatureSheet/UsageReset.tsx @@ -1,17 +1,13 @@ import { BillingInterval, + billingToItemInterval, EntInterval, - type ProductItemInterval, + entToItemInterval, + isFeaturePriceItem, + itemToBillingInterval, + itemToEntInterval, + UsageModel, } from "@autumn/shared"; -import { useState } from "react"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { Button } from "@/components/v2/buttons/Button"; -import { FormLabel } from "@/components/v2/form/FormLabel"; -import { Input } from "@/components/v2/inputs/Input"; import { Select, SelectContent, @@ -21,6 +17,7 @@ import { } from "@/components/v2/selects/Select"; import { formatIntervalText } from "@/utils/formatUtils/formatTextUtils"; import { useProductItemContext } from "@/views/products/product/product-item/ProductItemContext"; +import { CustomiseIntervalPopover } from "../CustomiseIntervalPopover"; interface UsageResetProps { showBillingLabel?: boolean; @@ -28,33 +25,26 @@ interface UsageResetProps { export function UsageReset({ showBillingLabel = false }: UsageResetProps) { const { item, setItem } = useProductItemContext(); - const [open, setOpen] = useState(false); if (!item) return null; - const intervalCount = item.interval_count || 1; + const isFeaturePrice = isFeaturePriceItem(item); const handleBillingIntervalSelected = ( - value: BillingInterval | EntInterval | ProductItemInterval, + value: BillingInterval | EntInterval, ) => { setItem({ ...item, - interval: - value === EntInterval.Lifetime ? null : (value as ProductItemInterval), + interval: isFeaturePrice + ? billingToItemInterval({ + billingInterval: value as BillingInterval, + }) + : entToItemInterval({ + entInterval: value as EntInterval, + }), }); }; - const handleSaveCustomInterval = (newIntervalCount: number) => { - setItem({ - ...item, - interval_count: newIntervalCount, - }); - setOpen(false); - }; - - // Get current interval for display - use Lifetime for null intervals - const currentInterval = item.interval ?? EntInterval.Lifetime; - const label = showBillingLabel ? "Usage Reset & Billing Interval" : "Usage Reset"; @@ -63,24 +53,24 @@ export function UsageReset({ showBillingLabel = false }: UsageResetProps) {
{label}
{ - const value = parseInt(e.target.value) || 1; - setItem({ - ...item, - interval_count: value, - }); - }} - onKeyDown={(e) => { - if (e.key === "Enter") { - handleSaveCustomInterval(intervalCount as number); - } - if (e.key === "Escape") { - setOpen(false); - } - }} - /> - -
- - +
diff --git a/vite/src/views/products/plan/components/EditPlanFeatureSheet/advanced-settings/RolloverConfig.tsx b/vite/src/views/products/plan/components/EditPlanFeatureSheet/advanced-settings/RolloverConfig.tsx index 8a433268d..690d038da 100644 --- a/vite/src/views/products/plan/components/EditPlanFeatureSheet/advanced-settings/RolloverConfig.tsx +++ b/vite/src/views/products/plan/components/EditPlanFeatureSheet/advanced-settings/RolloverConfig.tsx @@ -78,7 +78,7 @@ export function RolloverConfig() { return ( <> - {showRolloverConfig && ( + {showRolloverConfig ? (
- )} + ) : null} ); } diff --git a/vite/src/views/products/plan/components/EditPlanHeader.tsx b/vite/src/views/products/plan/components/EditPlanHeader.tsx index 6c29a7bf5..6ee3ec70e 100644 --- a/vite/src/views/products/plan/components/EditPlanHeader.tsx +++ b/vite/src/views/products/plan/components/EditPlanHeader.tsx @@ -3,23 +3,12 @@ import { AdminHover } from "@/components/general/AdminHover"; import { Badge } from "@/components/v2/badges/Badge"; import { IconBadge } from "@/components/v2/badges/IconBadge"; import V2Breadcrumb from "@/components/v2/breadcrumb"; -import { PlanTypeBadge } from "../../components/PlanTypeBadge"; import { useProductCountsQuery } from "../../product/hooks/queries/useProductCountsQuery"; import { useProductQuery } from "../../product/hooks/useProductQuery"; export const EditPlanHeader = () => { const { product } = useProductQuery(); const { counts } = useProductCountsQuery(); - const badgeType = - product.is_default && - product.free_trial && - !product.free_trial.card_required - ? "Default Trial" - : product.is_default - ? "Default" - : product.is_add_on - ? "Add-on" - : ""; const getProductAdminHover = () => { return [ @@ -64,11 +53,12 @@ export const EditPlanHeader = () => {
- {badgeType && {badgeType}} + {product.is_default && Default} + {product.is_add_on && Add-on} }> {counts?.active || 0} - + {/* */}
); diff --git a/vite/src/views/products/plan/components/ManagePlan.tsx b/vite/src/views/products/plan/components/ManagePlan.tsx index d62481f8f..29e275ce4 100644 --- a/vite/src/views/products/plan/components/ManagePlan.tsx +++ b/vite/src/views/products/plan/components/ManagePlan.tsx @@ -2,10 +2,8 @@ import PlanCard from "./PlanCard/PlanCard"; export const ManagePlan = () => { return ( -
-
- -
+
+
); }; diff --git a/vite/src/views/products/plan/components/PlanCard/DeleteFeatureRowDialog.tsx b/vite/src/views/products/plan/components/PlanCard/DeleteFeatureRowDialog.tsx new file mode 100644 index 000000000..1a5a50cf1 --- /dev/null +++ b/vite/src/views/products/plan/components/PlanCard/DeleteFeatureRowDialog.tsx @@ -0,0 +1,49 @@ +import type { ProductItem } from "@autumn/shared"; +import { Button } from "@/components/v2/buttons/Button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/v2/dialogs/Dialog"; +import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; +import { useProductContext } from "@/views/products/product/ProductContext"; + +export const DeleteFeatureRowDialog = ({ + open, + setOpen, + item, + onDelete, +}: { + open: boolean; + setOpen: (open: boolean) => void; + item: ProductItem; + onDelete: (item: ProductItem) => void; +}) => { + const { features } = useFeaturesQuery(); + const { product } = useProductContext(); + const featureName = features.find((f) => f.id === item.feature_id)?.name; + + return ( + + + + Delete "{featureName}" + + Please confirm that you want to delete this feature. + + + + + + + + + ); +}; diff --git a/vite/src/views/products/plan/components/PlanCard/PlanCard.tsx b/vite/src/views/products/plan/components/PlanCard/PlanCard.tsx index 8279804eb..4602998c7 100644 --- a/vite/src/views/products/plan/components/PlanCard/PlanCard.tsx +++ b/vite/src/views/products/plan/components/PlanCard/PlanCard.tsx @@ -14,9 +14,9 @@ export default function PlanCard() { }); return ( - + - + diff --git a/vite/src/views/products/plan/components/PlanCard/PlanFeatureIcon.tsx b/vite/src/views/products/plan/components/PlanCard/PlanFeatureIcon.tsx index f329821d7..6c78bcd2a 100644 --- a/vite/src/views/products/plan/components/PlanCard/PlanFeatureIcon.tsx +++ b/vite/src/views/products/plan/components/PlanCard/PlanFeatureIcon.tsx @@ -64,6 +64,9 @@ const getLeftIcon = ( return { icon: ArrowsCounterClockwiseIcon, color: "text-primary" }; // Persistent Usage - pink case ProductItemFeatureType.Static: return { icon: PowerIcon, color: "text-primary", size: 5 }; // Static - pink + case null: + case undefined: + return { icon: UsageBasedIcon, color: "text-primary" }; // Default for null/undefined - pink default: console.warn(`Unknown feature type: ${featureType}`); return { icon: UsageBasedIcon, color: "text-primary" }; // Default - pink diff --git a/vite/src/views/products/plan/components/PlanCard/PlanFeatureList.tsx b/vite/src/views/products/plan/components/PlanCard/PlanFeatureList.tsx index 03b58f9ea..04d3c9b94 100644 --- a/vite/src/views/products/plan/components/PlanCard/PlanFeatureList.tsx +++ b/vite/src/views/products/plan/components/PlanCard/PlanFeatureList.tsx @@ -1,4 +1,5 @@ import { type ProductItem, productV2ToFeatureItems } from "@autumn/shared"; +import { getItemId } from "@/utils/product/productItemUtils"; import { useProductContext } from "@/views/products/product/ProductContext"; import { AddFeatureRow } from "./AddFeatureRow"; import { PlanFeatureRow } from "./PlanFeatureRow"; @@ -9,22 +10,23 @@ export const PlanFeatureList = () => { const filteredItems = productV2ToFeatureItems({ items: product?.items }); - const handleFeatureClick = (item: ProductItem) => { - console.log("Feature clicked:", item); - }; + // const handleFeatureClick = (item: ProductItem) => { + // console.log("Feature clicked:", item); + // }; - const handleEdit = (item: ProductItem) => { - // Use array index as stable ID - won't change during editing - const itemIndex = - product?.items?.findIndex((i: ProductItem) => i === item) || 0; - const itemId = item.entitlement_id || item.price_id || `item-${itemIndex}`; + // const handleEdit = (item: ProductItem) => { + // // Use array index as stable ID - won't change during editing + // const itemIndex = + // product?.items?.findIndex((i: ProductItem) => i === item) || 0; + // const itemId = item.entitlement_id || item.price_id || `item-${itemIndex}`; - setEditingState({ type: "feature", id: itemId }); - setSheet("edit-feature"); - }; + // console.log(`Clicking on index ${itemIndex} with id ${itemId}`); + + // setEditingState({ type: "feature", id: itemId }); + // setSheet("edit-feature"); + // }; const handleDelete = (item: ProductItem) => { - console.log("Delete feature:", item); if (!product?.items) return; // Remove the item from the product @@ -34,7 +36,7 @@ export const PlanFeatureList = () => { // Close editing sidebar if this item was being edited const itemIndex = product.items.findIndex((i: ProductItem) => i === item); - const itemId = item.entitlement_id || item.price_id || `item-${itemIndex}`; + const itemId = getItemId({ item, itemIndex }); if (editingState.id === itemId) { setEditingState({ type: null, id: null }); setSheet(null); @@ -66,19 +68,12 @@ export const PlanFeatureList = () => {

Features

{filteredItems.map((item: ProductItem, index: number) => { - const itemId = item.entitlement_id || item.price_id || `item-${index}`; - const isBeingEdited = - editingState.type === "feature" && editingState.id === itemId; - return ( ); })} diff --git a/vite/src/views/products/plan/components/PlanCard/PlanFeatureRow.tsx b/vite/src/views/products/plan/components/PlanCard/PlanFeatureRow.tsx index d23d5674d..b6fe2b373 100644 --- a/vite/src/views/products/plan/components/PlanCard/PlanFeatureRow.tsx +++ b/vite/src/views/products/plan/components/PlanCard/PlanFeatureRow.tsx @@ -11,6 +11,7 @@ import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; import { cn } from "@/lib/utils"; import { getItemId } from "@/utils/product/productItemUtils"; import { useProductContext } from "@/views/products/product/ProductContext"; +import { useProductItemContext } from "@/views/products/product/product-item/ProductItemContext"; import { PlanFeatureIcon } from "./PlanFeatureIcon"; // Custom dot component with bigger height but smaller width @@ -20,22 +21,20 @@ const CustomDotIcon = () => { interface PlanFeatureRowProps { item: ProductItem; - onRowClick?: (item: ProductItem) => void; - onEdit?: (item: ProductItem) => void; onDelete?: (item: ProductItem) => void; - editDisabled?: boolean; index?: number; } export const PlanFeatureRow = ({ item, - onEdit, onDelete, index, }: PlanFeatureRowProps) => { const { org } = useOrg(); const { features } = useFeaturesQuery(); - const { editingState } = useProductContext(); + const { setItem } = useProductItemContext(); + const { editingState, setEditingState, setSheet } = useProductContext(); + const [isPressed, setIsPressed] = useState(false); const display = getProductItemDisplay({ @@ -48,6 +47,17 @@ export const PlanFeatureRow = ({ const isSelected = getItemId({ item, itemIndex: index }) === editingState.id; + // useEffect(() => { + // console.log("isSelected", isSelected); + // }, [isSelected]); + + const handleRowClicked = () => { + const itemId = getItemId({ item, itemIndex: index }); + setItem(item); + setEditingState({ type: "feature", id: itemId }); + setSheet("edit-feature"); + }; + return (
setIsPressed(false)} onMouseLeave={() => setIsPressed(false)} - onClick={() => onEdit?.(item)} + onClick={handleRowClicked} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); - onEdit?.(item); + handleRowClicked(); } }} > @@ -90,8 +100,8 @@ export const PlanFeatureRow = ({
-
-

+

+

{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 (

Billing Interval diff --git a/vite/src/views/products/plan/hooks/usePlanData.tsx b/vite/src/views/products/plan/hooks/usePlanData.tsx index 9c22b732f..ff7f05288 100644 --- a/vite/src/views/products/plan/hooks/usePlanData.tsx +++ b/vite/src/views/products/plan/hooks/usePlanData.tsx @@ -27,8 +27,12 @@ export function usePlanData({ originalProduct }: UsePlanDataProps) { } }, [originalProduct]); - const hasChanges = useMemo(() => { - if (!originalProductRef.current || !product) return false; + const diff = useMemo(() => { + if (!originalProductRef.current || !product) + return { + hasChanges: false, + willVersion: false, + }; const comparison = productsAreSame({ newProductV2: product as unknown as ProductV2, @@ -38,17 +42,23 @@ export function usePlanData({ originalProduct }: UsePlanDataProps) { // console.log("Comparison:", comparison); - return ( - !comparison.itemsSame || - !comparison.freeTrialsSame || - !comparison.detailsSame - ); + return { + hasChanges: + !comparison.itemsSame || + !comparison.detailsSame || + !comparison.freeTrialsSame, + willVersion: + !comparison.optionsSame || + !comparison.itemsSame || + !comparison.freeTrialsSame, + }; }, [product, features]); return { product, setProduct, - hasChanges, + hasChanges: diff.hasChanges, + willVersion: diff.willVersion, originalProduct: originalProductRef.current, }; } diff --git a/vite/src/views/products/plan/components/EditPlanFeatureSheet/tierUtils.ts b/vite/src/views/products/plan/utils/tierUtils.ts similarity index 100% rename from vite/src/views/products/plan/components/EditPlanFeatureSheet/tierUtils.ts rename to vite/src/views/products/plan/utils/tierUtils.ts diff --git a/vite/src/views/products/product/ProductContext.tsx b/vite/src/views/products/product/ProductContext.tsx index 196e8b274..3904f2941 100644 --- a/vite/src/views/products/product/ProductContext.tsx +++ b/vite/src/views/products/product/ProductContext.tsx @@ -11,10 +11,10 @@ interface ProductContextType { setShowNewVersionDialog: (show: boolean) => void; product: any; setProduct: (product: any) => void; - actionState: any; - entityFeatureIds: any; - setEntityFeatureIds: (ids: any) => void; + entityFeatureIds: string[]; + setEntityFeatureIds: (ids: string[]) => void; hasChanges: boolean; + willVersion: boolean; setSheet: (sheet: Sheets) => void; editingState: EditingState; setEditingState: (state: EditingState) => void; diff --git a/vite/src/views/products/product/product-item/product-item-config/components/SelectResetCycle.tsx b/vite/src/views/products/product/product-item/product-item-config/components/SelectResetCycle.tsx index 00640277d..8837ceddd 100644 --- a/vite/src/views/products/product/product-item/product-item-config/components/SelectResetCycle.tsx +++ b/vite/src/views/products/product/product-item/product-item-config/components/SelectResetCycle.tsx @@ -1,27 +1,26 @@ -import { - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; - -import { Select } from "@/components/ui/select"; -import { useProductItemContext } from "../../ProductItemContext"; -import { isFeaturePriceItem } from "@/utils/product/getItemType"; import { BillingInterval, EntInterval, FeatureUsageType, Infinite, + itemToEntInterval, } from "@autumn/shared"; -import { itemToEntInterval } from "@/utils/product/itemIntervalUtils"; -import FieldLabel from "@/components/general/modal-components/FieldLabel"; -import { cn } from "@/lib/utils"; -import { InfoTooltip } from "@/components/general/modal-components/InfoTooltip"; -import { getFeatureUsageType } from "@/utils/product/entitlementUtils"; import { useState } from "react"; -import { CustomiseIntervalPopover } from "./CusomiseIntervalPopover"; +import FieldLabel from "@/components/general/modal-components/FieldLabel"; +import { InfoTooltip } from "@/components/general/modal-components/InfoTooltip"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; +import { cn } from "@/lib/utils"; +import { getFeatureUsageType } from "@/utils/product/entitlementUtils"; +import { isFeaturePriceItem } from "@/utils/product/getItemType"; +import { useProductItemContext } from "../../ProductItemContext"; +import { CustomiseIntervalPopover } from "./CusomiseIntervalPopover"; const getIntervalText = ({ interval, @@ -54,18 +53,18 @@ export const SelectResetCycle = () => { const handleChange = (value: EntInterval) => { setItem({ ...item, - interval: value == EntInterval.Lifetime ? null : (value as EntInterval), + interval: value === EntInterval.Lifetime ? null : (value as EntInterval), }); }; - const isFeaturePrice = isFeaturePriceItem(item); - const usageType = getFeatureUsageType({ item, features }); + const isFeaturePrice = isFeaturePriceItem(item!); + const usageType = getFeatureUsageType({ item: item!, features }); if (usageType === FeatureUsageType.Continuous) { return null; } - const interval = itemToEntInterval(item); + const interval = itemToEntInterval({ item: item! }); return (
{