fix: cleaning up update product section

This commit is contained in:
John Yeo
2025-09-26 16:33:19 +01:00
parent 643c3185a2
commit db8fab3429
51 changed files with 859 additions and 840 deletions

View File

@@ -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 },
});
};

View File

@@ -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,

View File

@@ -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! });
}

View File

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

View File

@@ -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,

View File

@@ -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;
};

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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
);

View File

@@ -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),

View File

@@ -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()),

View File

@@ -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(),

View File

@@ -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";

View File

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

View File

@@ -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,

View File

@@ -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;
};

View File

@@ -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

View File

@@ -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,
};
};

View File

@@ -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;
};

View File

@@ -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,

View File

@@ -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 || ""}`;
};

View File

@@ -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;
};

View File

@@ -27,28 +27,28 @@ export const IncludedUsageIcon = ({ size = 16 }: { size?: number }) => {
<title>Included Usage</title>
<path
d="M6 7.5C8.76142 7.5 11 6.49264 11 5.25C11 4.00736 8.76142 3 6 3C3.23858 3 1 4.00736 1 5.25C1 6.49264 3.23858 7.5 6 7.5Z"
stroke="#666666"
stroke="currentColor"
stroke-width="1.1"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M1 5.25V7.75C1 8.9925 3.23875 10 6 10C8.76125 10 11 8.9925 11 7.75V5.25"
stroke="#666666"
stroke="currentColor"
stroke-width="1.1"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M11.0001 6.04492C13.2826 6.25367 15.0001 7.16117 15.0001 8.24992C15.0001 9.49242 12.7613 10.4999 10.0001 10.4999C8.77508 10.4999 7.65258 10.3018 6.7832 9.97242"
stroke="#666666"
stroke="currentColor"
stroke-width="1.1"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M5 9.955V10.75C5 11.9925 7.23875 13 10 13C12.7613 13 15 11.9925 15 10.75V8.25"
stroke="#666666"
stroke="currentColor"
stroke-width="1.1"
stroke-linecap="round"
stroke-linejoin="round"
@@ -62,7 +62,7 @@ export const IncludedUsageIcon = ({ size = 16 }: { size?: number }) => {
/>
<path
d="M2.85352 14.5469L14.6465 2.75391"
stroke="#666666"
stroke="currentColor"
stroke-width="1.1"
stroke-linecap="round"
stroke-linejoin="round"

View File

@@ -1,43 +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 (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;
};

View File

@@ -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");

View File

@@ -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<string[]>([]);
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() {
}}
/>
<div className="flex w-full h-full overflow-y-auto bg-[#eee]">
<div className="flex flex-col justify-between h-full flex-1">
<div className="flex flex-col justify-between h-full w-full overflow-x-hidden relative">
<EditPlanHeader />
<ManagePlan />
<SaveChangesBar />
@@ -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 (
<ProductItemContext.Provider
value={{
item: currentItem,
item: currentItem ?? null,
setItem: setCurrentItem,
selectedIndex: 0,
showCreateFeature: false,

View File

@@ -1,4 +1,9 @@
import { isFeaturePriceItem } from "@autumn/shared";
import {
BillingInterval,
Infinite,
isFeaturePriceItem,
ProductItemInterval,
} from "@autumn/shared";
import { CoinsIcon } from "@phosphor-icons/react";
import { PanelButton } from "@/components/v2/buttons/PanelButton";
import { IncludedUsageIcon } from "@/components/v2/icons/AutumnIcons";
@@ -13,12 +18,35 @@ export function BillingType() {
const isFeaturePrice = isFeaturePriceItem(item);
const setBillingType = (type: "included" | "priced") => {
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(),
});
}
};

View File

@@ -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<HTMLButtonElement>(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 (
<div className="flex max-w-28 min-w-28">
@@ -31,25 +47,29 @@ export function BillingUnits() {
<PopoverTrigger asChild>
<Button
ref={triggerRef}
size="default"
variant="skeleton"
className="w-fit max-w-32 text-body-secondary overflow-hidden hover:bg-transparent justify-start p-1 h-auto
[&:focus]:outline-none [&:focus-visible]:outline-none [&:focus]:ring-0 [&:focus-visible]:ring-0"
size="sm"
variant="muted"
className={cn(
item.tiers?.length && item.tiers.length > 1
? "max-w-28"
: "max-w-40",
// "w-fit max-w-32 text-body-secondary overflow-hidden hover:bg-transparent justify-start p-1 h-auto [&:focus]:outline-none [&:focus-visible]:outline-none [&:focus]:ring-0 [&:focus-visible]:ring-0",
// "underline hover:text-t3",
)}
>
<span className={cn("truncate text-xs")}>
{item.billing_units === 1
? `${currency} per unit`
: `${currency} per ${item.billing_units} units`}
? `${currency} per ${unitName}`
: `${currency} per ${item.billing_units} ${unitName}`}
</span>
</Button>
</PopoverTrigger>
<PopoverContent className="max-w-md p-1" align="start">
<Input
<PopoverContent className="max-w-[200px] p-3 pt-2" align="start">
<LabelInput
label={`Billing units (${unitName})`}
type="number"
value={item.billing_units === 0 ? "" : (item.billing_units ?? "")}
onChange={(e) =>
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") {

View File

@@ -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 && (
<>
<SheetSection title="Billing Type">
<BillingType />

View File

@@ -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 (
<div className="space-y-4">
<div className="w-full h-auto flex items-end gap-2">
@@ -67,20 +41,23 @@ export function IncludedUsage() {
</div>
<div className="flex items-center gap-2">
<Input
key={`included-usage-${item.feature_id || item.price_id || "default"}`}
placeholder="eg. 100 credits"
value={
includedUsage === 0
? ""
: includedUsage?.toString() === Infinite
? "Unlimited"
: includedUsage?.toString()
}
value={getInputValue()}
onChange={(e) => {
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"
/>
<IconCheckbox
hide={isFeaturePrice}
@@ -103,97 +80,97 @@ export function IncludedUsage() {
</div>
{/* Only show Usage Reset dropdown for included billing type */}
{!isFeaturePrice && !isContUseItem({ item, features }) && (
<div>
<div className="text-form-label block mb-2">Usage Reset</div>
<Select
value={item.interval ?? EntInterval.Lifetime}
onValueChange={handleBillingIntervalSelected}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select interval" />
</SelectTrigger>
<SelectContent>
{/* Add EntInterval.Lifetime for "no reset" */}
<SelectItem value={EntInterval.Lifetime}>
{formatIntervalText({
interval: EntInterval.Lifetime,
intervalCount: item.interval_count || undefined,
})}
</SelectItem>
{/* Add BillingInterval options except OneOff (since we have "no reset") */}
{Object.values(BillingInterval)
.filter((interval) => interval !== BillingInterval.OneOff)
.map((interval) => (
<SelectItem key={interval} value={interval}>
{formatIntervalText({
billingInterval: interval,
intervalCount: item.interval_count || undefined,
isBillingInterval: true,
})}
</SelectItem>
))}
{/* Custom interval option */}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
className="w-full justify-start px-2"
variant="skeleton"
disabled={
item.included_usage === Infinite || item.interval == null
}
>
<p className="text-t3">Customise Interval</p>
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="p-3 w-[200px]"
sideOffset={-1}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<div className="mb-2">
<FormLabel>Interval Count</FormLabel>
</div>
<div className="flex items-center gap-2">
<Input
className="flex-1"
value={intervalCount}
onChange={(e) => {
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);
}
}}
/>
<Button
variant="secondary"
className="px-4 h-7"
onClick={() =>
handleSaveCustomInterval(intervalCount as number)
}
>
Save
</Button>
</div>
</PopoverContent>
</Popover>
</SelectContent>
</Select>
</div>
)}
{!isFeaturePrice && !isContUseItem({ item, features }) && <UsageReset />}
</div>
);
}
// <div>
// <div className="text-form-label block mb-2">Usage Reset</div>
// <Select
// value={item.interval ?? EntInterval.Lifetime}
// onValueChange={handleBillingIntervalSelected}
// >
// <SelectTrigger className="w-full">
// <SelectValue placeholder="Select interval" />
// </SelectTrigger>
// <SelectContent>
// {/* Add EntInterval.Lifetime for "no reset" */}
// <SelectItem value={EntInterval.Lifetime}>
// {formatIntervalText({
// interval: EntInterval.Lifetime,
// intervalCount: item.interval_count || undefined,
// })}
// </SelectItem>
// {/* Add BillingInterval options except OneOff (since we have "no reset") */}
// {Object.values(BillingInterval)
// .filter((interval) => interval !== BillingInterval.OneOff)
// .map((interval) => (
// <SelectItem key={interval} value={interval}>
// {formatIntervalText({
// billingInterval: interval,
// intervalCount: item.interval_count || undefined,
// isBillingInterval: true,
// })}
// </SelectItem>
// ))}
// {/* Custom interval option */}
// <Popover open={open} onOpenChange={setOpen}>
// <PopoverTrigger asChild>
// <Button
// className="w-full justify-start px-2"
// variant="skeleton"
// disabled={
// item.included_usage === Infinite || item.interval == null
// }
// >
// <p className="text-t3">Customise Interval</p>
// </Button>
// </PopoverTrigger>
// <PopoverContent
// align="start"
// className="p-3 w-[200px]"
// sideOffset={-1}
// onOpenAutoFocus={(e) => e.preventDefault()}
// onCloseAutoFocus={(e) => e.preventDefault()}
// >
// <div className="mb-2">
// <FormLabel>Interval Count</FormLabel>
// </div>
// <div className="flex items-center gap-2">
// <Input
// className="flex-1"
// value={intervalCount}
// onChange={(e) => {
// 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);
// }
// }}
// />
// <Button
// variant="secondary"
// className="px-4 h-7"
// onClick={() =>
// handleSaveCustomInterval(intervalCount as number)
// }
// >
// Save
// </Button>
// </div>
// </PopoverContent>
// </Popover>
// </SelectContent>
// </Select>
// </div>

View File

@@ -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();

View File

@@ -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,
});
}}
/>
</div>

View File

@@ -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) {
<div className={showBillingLabel ? "mt-3" : ""}>
<div className="text-form-label block mb-2">{label}</div>
<Select
value={currentInterval}
value={
isFeaturePrice
? itemToBillingInterval({ item })
: itemToEntInterval({ item })
}
onValueChange={handleBillingIntervalSelected}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select interval" />
</SelectTrigger>
<SelectContent>
{/* Add EntInterval.Lifetime for "no reset" */}
<SelectItem value={EntInterval.Lifetime}>
{formatIntervalText({
interval: EntInterval.Lifetime,
intervalCount: item.interval_count || undefined,
})}
</SelectItem>
{/* Add BillingInterval options except OneOff (since we have "no reset") */}
{Object.values(BillingInterval)
.filter((interval) => interval !== BillingInterval.OneOff)
{Object.values(isFeaturePrice ? BillingInterval : EntInterval)
.filter((i) => {
if (isFeaturePrice && item.usage_model === UsageModel.PayPerUse) {
return i !== BillingInterval.OneOff;
}
return true;
})
.map((interval) => (
<SelectItem key={interval} value={interval}>
{formatIntervalText({
@@ -92,58 +82,7 @@ export function UsageReset({ showBillingLabel = false }: UsageResetProps) {
))}
{/* Custom interval option */}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
className="w-full justify-start px-2"
variant="skeleton"
disabled={item.included_usage === "∞" || item.interval == null}
>
<p className="text-t3">Customise Interval</p>
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="p-3 w-[200px]"
sideOffset={-1}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<div className="mb-2">
<FormLabel>Interval Count</FormLabel>
</div>
<div className="flex items-center gap-2">
<Input
className="flex-1"
value={intervalCount}
onChange={(e) => {
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);
}
}}
/>
<Button
variant="secondary"
className="px-4 h-7"
onClick={() =>
handleSaveCustomInterval(intervalCount as number)
}
>
Save
</Button>
</div>
</PopoverContent>
</Popover>
<CustomiseIntervalPopover item={item} setItem={setItem} />
</SelectContent>
</Select>
</div>

View File

@@ -78,7 +78,7 @@ export function RolloverConfig() {
return (
<>
{showRolloverConfig && (
{showRolloverConfig ? (
<AreaCheckbox
title="Rollovers"
tooltip="Rollovers carry unused credits to the next billing cycle. Set a maximum rollover amount and specify how many cycles before resetting."
@@ -181,7 +181,7 @@ export function RolloverConfig() {
</div>
</div>
</AreaCheckbox>
)}
) : null}
</>
);
}

View File

@@ -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 = () => {
</div>
</div>
<div className="flex flex-row gap-2">
{badgeType && <Badge variant="muted">{badgeType}</Badge>}
{product.is_default && <Badge variant="muted">Default</Badge>}
{product.is_add_on && <Badge variant="muted">Add-on</Badge>}
<IconBadge variant="muted" icon={<UserIcon />}>
{counts?.active || 0}
</IconBadge>
<PlanTypeBadge product={product} />
{/* <PlanTypeBadge product={product} /> */}
</div>
</div>
);

View File

@@ -2,10 +2,8 @@ import PlanCard from "./PlanCard/PlanCard";
export const ManagePlan = () => {
return (
<div className="flex flex-col gap-4 h-full overflow-hidden">
<div className="flex flex-col h-full bg-[#EEEEEE] items-center justify-start pt-20">
<PlanCard />
</div>
<div className="flex flex-col w-full h-full bg-[#EEEEEE] items-center justify-start pt-20">
<PlanCard />
</div>
);
};

View File

@@ -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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Delete "{featureName}"</DialogTitle>
<DialogDescription>
Please confirm that you want to delete this feature.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="secondary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={() => setOpen(false)}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -14,9 +14,9 @@ export default function PlanCard() {
});
return (
<Card className="min-w-sm w-[70%] max-w-xl mx-4 bg-card">
<Card className={`min-w-sm max-w-xl mx-4 bg-card w-[80%]`}>
<PlanCardHeader />
<CardContent>
<CardContent className="max-w-full">
<PlanFeatureList />
</CardContent>
</Card>

View File

@@ -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

View File

@@ -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 = () => {
<div className="space-y-2">
<h4 className="text-sm font-medium text-foreground mb-2">Features</h4>
{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 (
<PlanFeatureRow
key={item.entitlement_id || item.price_id || index}
item={item}
index={index}
onRowClick={handleFeatureClick}
onEdit={handleEdit}
onDelete={handleDelete}
editDisabled={isBeingEdited}
/>
);
})}

View File

@@ -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 (
<div
role="button"
@@ -55,7 +65,7 @@ export const PlanFeatureRow = ({
data-state={isSelected ? "open" : "closed"}
data-pressed={isPressed}
className={cn(
"flex w-full group !h-9 group/row input-base input-shadow-tiny select-bg",
"flex w-full group !h-9 group/row input-base input-shadow-tiny select-bg select-none",
// To prevent flickering when clicking inner buttons
!isSelected &&
@@ -74,11 +84,11 @@ export const PlanFeatureRow = ({
}}
onMouseUp={() => 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 = ({
<PlanFeatureIcon item={item} position="right" />
</div>
<div className="flex items-center gap-2 flex-1 max-w-[85%]">
<p className="whitespace-nowrap truncate">
<div className="flex items-center gap-2 flex-1 min-w-0 max-w-[90%] ">
<p className="whitespace-nowrap truncate max-w-full">
<span className="text-body">{display.primary_text}</span>
<span className="text-body-secondary">
{" "}

View File

@@ -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;
}

View File

@@ -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 })
}

View File

@@ -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,

View File

@@ -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),
},
});
}}

View File

@@ -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 (
<div className="w-full">
<FormLabel>Billing Interval</FormLabel>

View File

@@ -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,
};
}

View File

@@ -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;

View File

@@ -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 (
<div
@@ -85,8 +84,8 @@ export const SelectResetCycle = () => {
</FieldLabel>
<div className="flex items-center gap-2">
<Select
disabled={item.included_usage == Infinite}
value={itemToEntInterval(item) as string}
disabled={item!.included_usage === Infinite}
value={itemToEntInterval({ item }) as string}
onValueChange={(value) => {
handleChange(value as EntInterval);
}}
@@ -96,7 +95,7 @@ export const SelectResetCycle = () => {
<span className="block truncate overflow-hidden text-ellipsis max-w-full">
{getIntervalText({
interval,
intervalCount: item.interval_count,
intervalCount: item!.interval_count,
})}
</span>
</SelectValue>
@@ -198,7 +197,7 @@ const SelectIntervalItem = ({
>
<div className="flex items-center gap-2 w-full whitespace-nowrap truncate overflow-hidden">
<span className="truncate">
{getIntervalText({ interval, intervalCount: item?.interval_count })}
{getIntervalText({ interval, intervalCount: item!.interval_count })}
</span>
</div>
</SelectItem>

View File

@@ -22,7 +22,6 @@ export const updateProduct = async ({
product.items as FrontendProductItem[],
);
console.log("validated", validated);
if (!validated) {
return false;
}