fix: components/pricing_table exports item data in field

This commit is contained in:
John Yeo
2025-06-25 17:44:38 +01:00
parent 928de4743a
commit 29ab4b23a2
27 changed files with 491 additions and 125 deletions

View File

@@ -23,7 +23,7 @@ elif [[ $filename == *"/tests/"* ]]; then
elif [[ $filename == *".sh"* ]]; then
$filename
else
npx tsx $filename
NODE_ENV=development npx tsx $filename
fi

View File

@@ -2,34 +2,9 @@ import pino from "pino";
export const initLogger = () => {
// Create separate streams for console and HyperDX
const streams: pino.StreamEntry[] = [
// Pretty console output stream
// {
// level: process.env.NODE_ENV === "development" ? "debug" : "info",
// stream: pino.transport({
// target: "pino-pretty",
// options: {
// colorize: true,
// translateTime: "UTC:yyyy-mm-dd HH:MM:ss",
// ignore: "pid,hostname,res,context,req,statusCode,worker",
// customColors: {
// default: "white",
// 60: "bgRed",
// 50: "red",
// 40: "yellow",
// 30: "green",
// 20: "blue",
// 10: "gray",
// message: "reset",
// greyMessage: "gray",
// time: "darkGray",
// },
// },
// }),
// },
];
const streams: pino.StreamEntry[] = [];
if (process.env.NODE_ENV == "development") {
if (process.env.NODE_ENV === "development") {
streams.push({
level: process.env.NODE_ENV === "development" ? "debug" : "info",
stream: pino.transport({

View File

@@ -88,8 +88,10 @@ stripeWebhookRouter.post(
request.logtail = request.logtail.child({
context: {
context: {
body: request.body,
// body: request.body,
event_type: event.type,
event_id: event.id,
object_id: `${event.data?.object?.id}` || "N/A",
authType: AuthType.Stripe,
org_id: orgId,
org_slug: org.slug,

View File

@@ -20,6 +20,7 @@ import { ExtendedRequest } from "@/utils/models/Request.js";
import { handleCheckoutSub } from "./handleCheckoutCompleted/handleCheckoutSub.js";
import { handleRemainingSets } from "./handleCheckoutCompleted/handleRemainingSets.js";
import { getOptionsFromCheckoutSession } from "./handleCheckoutCompleted/getOptionsFromCheckout.js";
import { getEntityInvoiceDescription } from "@/internal/entities/entityUtils/entityInvoiceUtils.js";
export const handleCheckoutSessionCompleted = async ({
req,
@@ -130,6 +131,7 @@ export const handleCheckoutSessionCompleted = async ({
console.log("✅ checkout.completed: successfully created cus product");
const batchInsertInvoice: any = [];
for (const invoiceId of invoiceIds) {
batchInsertInvoice.push(
insertInvoiceFromAttach({

View File

@@ -329,8 +329,6 @@ export const handleInvoiceCreated = async ({
let entity = await EntityService.getByInternalId({
db,
internalId: internalEntityId,
orgId: org.id,
env,
});
let feature = features.find(
@@ -352,7 +350,7 @@ export const handleInvoiceCreated = async ({
feature,
plural: false,
capitalize: true,
})}: ${entity?.name} (ID: ${entity?.id})`,
})}: ${entDetails}`,
});
}
} catch (error: any) {

View File

@@ -45,21 +45,13 @@ export class EntityService {
static async getByInternalId({
db,
internalId,
orgId,
env,
}: {
db: DrizzleCli;
internalId: string;
orgId: string;
env: string;
}) {
let entity = await db.query.entities.findFirst({
where: (entities, { eq, and }) =>
and(
eq(entities.internal_id, internalId),
eq(entities.org_id, orgId),
eq(entities.env, env),
),
and(eq(entities.internal_id, internalId)),
});
if (!entity) {
throw new RecaseError({

View File

@@ -13,6 +13,8 @@ import { getNextStartOfMonthUnix } from "@/internal/products/prices/billingInter
import { APIVersion } from "@autumn/shared";
import { SuccessCode } from "@autumn/shared";
import { notNullish } from "@/utils/genUtils.js";
import { getEntityInvoiceDescription } from "@/internal/entities/entityUtils/entityInvoiceUtils.js";
import Stripe from "stripe";
export const handleCreateCheckout = async ({
req,
@@ -67,13 +69,14 @@ export const handleCreateCheckout = async ({
);
}
const subscriptionData = isRecurring
const subscriptionData:
| Stripe.Checkout.SessionCreateParams.SubscriptionData
| undefined = isRecurring
? {
trial_end:
freeTrial && !attachParams.disableFreeTrial
? freeTrialToStripeTimestamp({ freeTrial })
: undefined,
// metadata: subMeta,
billing_cycle_anchor: billingCycleAnchorUnixSeconds,
}
: undefined;

View File

@@ -49,12 +49,14 @@ export const cusProductsToCusEnts = ({
cusProducts,
inStatuses = [CusProductStatus.Active],
reverseOrder = false,
featureId,
}: {
cusProducts: FullCusProduct[];
inStatuses?: CusProductStatus[];
reverseOrder?: boolean;
featureId?: string;
}) => {
const cusEnts: FullCustomerEntitlement[] = [];
let cusEnts: FullCustomerEntitlement[] = [];
for (const cusProduct of cusProducts) {
if (!inStatuses.includes(cusProduct.status)) {
@@ -69,6 +71,12 @@ export const cusProductsToCusEnts = ({
);
}
if (featureId) {
cusEnts = cusEnts.filter(
(cusEnt) => cusEnt.entitlement.feature_id === featureId,
);
}
sortCusEntsForDeduction(cusEnts, reverseOrder);
return cusEnts;

View File

@@ -29,25 +29,17 @@ import {
export const createNewCustomer = async ({
req,
db,
org,
env,
customer,
nextResetAt,
processor,
logger,
createDefaultProducts = true,
}: {
req: ExtendedRequest;
db: DrizzleCli;
org: Organization;
env: AppEnv;
customer: CreateCustomer;
nextResetAt?: number;
processor?: any;
logger: any;
createDefaultProducts?: boolean;
}) => {
const { db, org, env, logger } = req;
logger.info(
`Creating customer: ${customer.email || customer.id}, org: ${org.slug}`,
);
@@ -65,17 +57,24 @@ export const createNewCustomer = async ({
const customerData: Customer = {
...parsedCustomer,
name: parsedCustomer.name || "",
email:
nonFreeProds.length > 0 && !parsedCustomer.email
? `${parsedCustomer.id}@invoices.useautumn.com`
: parsedCustomer.email || "",
metadata: parsedCustomer.metadata || {},
internal_id: generateId("cus"),
org_id: org.id,
created_at: Date.now(),
env,
processor,
processor: parsedCustomer.stripe_id
? {
id: parsedCustomer.stripe_id,
type: "stripe",
}
: undefined,
};
// Check if stripeCli exists

View File

@@ -74,7 +74,6 @@ export const getOrCreateCustomer = async ({
try {
customer = await handleCreateCustomer({
req,
db,
cusData: {
id: customerId,
name: customerData?.name,
@@ -82,9 +81,6 @@ export const getOrCreateCustomer = async ({
fingerprint: customerData?.fingerprint,
metadata: customerData?.metadata || {},
},
org,
env,
logger,
});
customer = await CusService.getFull({

View File

@@ -59,23 +59,15 @@ export const initStripeCusAndProducts = async ({
const handleIdIsNull = async ({
req,
db,
org,
env,
newCus,
logger,
processor,
createDefaultProducts,
}: {
req: ExtendedRequest;
db: DrizzleCli;
org: Organization;
env: AppEnv;
newCus: CreateCustomer;
logger: any;
processor?: any;
createDefaultProducts?: boolean;
}) => {
const { db, org, env, logger } = req;
// 1. ID is null
if (!newCus.email) {
throw new RecaseError({
@@ -113,12 +105,7 @@ const handleIdIsNull = async ({
const createdCustomer = await createNewCustomer({
req,
db,
org,
env,
customer: newCus,
logger,
processor,
createDefaultProducts,
});
@@ -128,23 +115,15 @@ const handleIdIsNull = async ({
// CAN ALSO USE DURING MIGRATION...
export const handleCreateCustomerWithId = async ({
req,
db,
org,
env,
logger,
newCus,
processor,
createDefaultProducts = true,
}: {
req: ExtendedRequest;
db: DrizzleCli;
org: Organization;
env: AppEnv;
logger: any;
newCus: CreateCustomer;
processor?: any;
createDefaultProducts?: boolean;
}) => {
const { db, org, env, logger } = req;
// 1. Get by ID
let existingCustomer = await CusService.get({
db,
@@ -155,7 +134,7 @@ export const handleCreateCustomerWithId = async ({
if (existingCustomer) {
logger.info(
`POST /customers, existing customer found: ${existingCustomer.id} (org: ${org.slug})`,
`Customer already exists, skipping creation: ${existingCustomer.id}`,
);
return existingCustomer;
}
@@ -191,33 +170,18 @@ export const handleCreateCustomerWithId = async ({
// 2. Handle email step...
return await createNewCustomer({
req,
db,
org,
env,
customer: newCus,
logger,
processor,
createDefaultProducts,
});
};
export const handleCreateCustomer = async ({
req,
db,
cusData,
org,
env,
logger,
processor,
createDefaultProducts = true,
}: {
req: ExtendedRequest;
db: DrizzleCli;
cusData: CreateCustomer;
org: Organization;
env: AppEnv;
logger: any;
processor?: any;
createDefaultProducts?: boolean;
}) => {
const newCus = CreateCustomerSchema.parse(cusData);
@@ -227,23 +191,13 @@ export const handleCreateCustomer = async ({
if (newCus.id === null) {
createdCustomer = await handleIdIsNull({
req,
db,
org,
env,
newCus,
logger,
processor,
createDefaultProducts,
});
} else {
createdCustomer = await handleCreateCustomerWithId({
req,
db,
org,
env,
logger,
newCus,
processor,
createDefaultProducts,
});
}

View File

@@ -0,0 +1,48 @@
import { DrizzleCli } from "@/db/initDrizzle.js";
import { EntityService } from "@/internal/api/entities/EntityService.js";
import { Feature, getFeatureName } from "@autumn/shared";
import { AppEnv, Entity } from "autumn-js";
export const getEntityInvoiceDescription = async ({
db,
internalEntityId,
features,
logger,
}: {
db: DrizzleCli;
internalEntityId: string;
features: Feature[];
logger: any;
}) => {
try {
let entity = await EntityService.getByInternalId({
db,
internalId: internalEntityId,
});
let feature = features.find(
(f) => f.internal_id == entity?.internal_feature_id,
);
let entDetails = "";
if (entity.name) {
entDetails = `${entity.name}${entity.id ? ` (ID: ${entity.id})` : ""}`;
} else if (entity.id) {
entDetails = `${entity.id}`;
}
if (feature && entDetails) {
let featureName = getFeatureName({
feature,
plural: false,
capitalize: true,
});
return `${featureName}: ${entDetails}`;
}
return "";
} catch (error) {
logger.error(`Failed to get entity invoice description`, { error });
return "";
}
};

View File

@@ -134,17 +134,23 @@ export const getPricecnPrice = ({
if (isPriceItem(priceItem)) {
return {
...priceItem,
primaryText: getPriceText({ item: priceItem, org }),
secondaryText: priceItem.interval ? `per ${priceItem.interval}` : " ",
};
} else {
let feature = features.find((f) => f.id == priceItem.feature_id);
return featurePricetoPricecnItem({
let texts = featurePricetoPricecnItem({
feature,
item: priceItem,
org,
isMainPrice,
});
return {
...priceItem,
primaryText: texts.primaryText,
secondaryText: texts.secondaryText,
};
}
};

View File

@@ -0,0 +1,29 @@
import Stripe from "stripe";
import { constructPrice } from "../priceUtils.js";
import { FullProduct, PriceType } from "@autumn/shared";
import { generateId } from "@/utils/genUtils.js";
import { subItemToAutumnInterval } from "tests/utils/stripeUtils.js";
export const subItemToFixedPrice = ({
subItem,
product,
basePrice,
}: {
subItem: Stripe.SubscriptionItem;
product: FullProduct;
basePrice?: number;
}) => {
const { price } = subItem;
return constructPrice({
internalProductId: product.internal_id,
isCustom: true,
orgId: product.org_id,
fixedConfig: {
type: PriceType.Fixed,
amount: basePrice || (price.unit_amount || 0) / 100,
interval: subItemToAutumnInterval(subItem)!,
stripe_price_id: price.id,
},
});
};

View File

@@ -116,3 +116,13 @@ export const priceToProduct = ({
(p: Product) => p.internal_id == price.internal_product_id,
);
};
export const filterByBillingType = ({
prices,
billingType,
}: {
prices: Price[];
billingType: BillingType;
}) => {
return prices.filter((p) => getBillingType(p.config) == billingType);
};

View File

@@ -0,0 +1,168 @@
import Stripe from "stripe";
import { stripeToAutumnSubStatus } from "@/external/stripe/stripeSubUtils.js";
import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js";
import { isFreeProduct } from "@/internal/products/productUtils.js";
import { SubService } from "@/internal/subscriptions/SubService.js";
import { constructSub } from "@/internal/subscriptions/subUtils.js";
import {
FullCustomer,
FullProduct,
Price,
EntitlementWithFeature,
CusProductStatus,
UsagePriceConfig,
BillingInterval,
} from "@autumn/shared";
import { notNullish } from "../genUtils.js";
import { ExtendedRequest } from "../models/Request.js";
import { isUsagePrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import { subToAutumnInterval } from "@/external/stripe/utils.js";
import { prices as priceTable } from "@autumn/shared";
import { PriceService } from "@/internal/products/prices/PriceService.js";
export const addProductFromSubs = async ({
req,
autumnCus,
autumnProduct,
stripeSubs,
prices,
entitlements,
force = false,
isCustom = false,
anchorToUnix,
}: {
req: ExtendedRequest;
autumnCus: FullCustomer;
autumnProduct: FullProduct;
stripeSubs: Stripe.Subscription[];
prices?: Price[];
entitlements?: EntitlementWithFeature[];
force?: boolean;
isCustom?: boolean;
anchorToUnix?: number;
}) => {
const { db, logger, org, env } = req;
const cusProducts = autumnCus.customer_products;
const entity = autumnCus.entity;
let mainCusProduct = cusProducts.find(
(cp) =>
!cp.product.is_add_on &&
cp.product_id == autumnProduct.id &&
(notNullish(entity)
? cp.internal_entity_id == entity!.internal_id
: true),
);
if (mainCusProduct && !force) {
let prices = mainCusProduct.customer_prices.map((cp) => cp.price);
let isFree = isFreeProduct(prices);
if (!isFree) {
logger.info(
`Customer ${
autumnCus.id || autumnCus.email
} already has non-free free product: ${
mainCusProduct.product.name
}, skipping...`,
);
return mainCusProduct;
}
}
// Handle if trialing
let trialEndsAt = stripeSubs[0].trial_end
? stripeSubs[0].trial_end * 1000
: null;
// 1. Insert custom prices...
let customPrices = prices?.filter((p) => p.is_custom);
if (customPrices && customPrices.length > 0) {
await PriceService.upsert({
db,
data: customPrices,
});
}
let newCusProduct = await createFullCusProduct({
db,
attachParams: {
replaceables: [],
customer: autumnCus,
product: autumnProduct,
org,
prices: notNullish(prices) ? prices! : autumnProduct.prices,
entitlements: notNullish(entitlements)
? entitlements!
: autumnProduct.entitlements,
freeTrial: autumnProduct.free_trial || null,
optionsList: [],
entities: [],
cusProducts: cusProducts,
features: [],
internalEntityId: entity?.internal_id,
entityId: entity?.id,
isCustom: isCustom,
},
logger,
trialEndsAt: trialEndsAt || undefined,
subscriptionIds: stripeSubs.map((s) => s.id),
anchorToUnix: anchorToUnix || stripeSubs[0].current_period_end * 1000,
subscriptionStatus: stripeToAutumnSubStatus(
stripeSubs[0].status,
) as CusProductStatus,
canceledAt: stripeSubs[0].canceled_at
? stripeSubs[0].canceled_at * 1000
: null,
createdAt: stripeSubs[0].created * 1000,
sendWebhook: false,
});
logger.info(
`Added product ${autumnProduct.name} to customer ${autumnCus.name}`,
);
// Create sub
let usageFeatures = autumnProduct.prices
.filter((p) => isUsagePrice({ price: p }))
.map((p) => (p.config as UsagePriceConfig).internal_feature_id);
for (const sub of stripeSubs) {
let subFromDb = await SubService.getInStripeIds({
db,
ids: [sub.id],
});
let subInterval = subToAutumnInterval(sub);
if (subFromDb.length === 0) {
await SubService.createSub({
db,
sub: constructSub({
stripeId: sub.id,
usageFeatures:
subInterval == BillingInterval.Month ? usageFeatures : [],
orgId: org.id,
env,
currentPeriodStart: sub.current_period_start,
currentPeriodEnd: sub.current_period_end,
}),
});
logger.info(`Created sub ${sub.id} in DB`);
} else {
logger.info(`Sub ${sub.id} already exists in DB`);
}
}
autumnCus.customer_products = [
...(autumnCus.customer_products || []),
newCusProduct!,
];
return newCusProduct;
};

View File

@@ -0,0 +1,52 @@
import { findStripeItemForPrice } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js";
import { filterByBillingType } from "@/internal/products/prices/priceUtils/findPriceUtils.js";
import { BillingType, FullProduct, UsagePriceConfig } from "@autumn/shared";
import Stripe from "stripe";
export const addContUsePricesToSub = async ({
stripe,
sub,
autumnProduct,
quantity,
logger,
}: {
stripe: Stripe;
sub: Stripe.Subscription;
autumnProduct: FullProduct;
quantity: number;
logger: any;
}) => {
const usagePrices = filterByBillingType({
prices: autumnProduct.prices,
billingType: BillingType.InArrearProrated,
});
logger.info(`Adding ${usagePrices.length} cont use prices to sub`);
for (const usagePrice of usagePrices) {
const config = usagePrice.config as UsagePriceConfig;
const latestSub = await stripe.subscriptions.retrieve(sub.id);
let subItem = findStripeItemForPrice({
price: usagePrice,
stripeItems: latestSub.items.data,
});
if (subItem) {
logger.info(`Sub already has price for ${config.feature_id}`);
continue;
}
const newSubItem = await stripe.subscriptionItems.create({
subscription: sub.id,
price: usagePrice.config.stripe_price_id!,
proration_behavior: "none",
quantity,
});
// logger.info(`New sub item:`, {
// newSubItem,
// });
logger.info(`Successfully added ${config.feature_id} to sub`);
}
};

View File

@@ -0,0 +1,27 @@
import Stripe from "stripe";
import { subItemToFixedPrice } from "@/internal/products/prices/priceUtils/constructPriceUtils.js";
import { isFixedPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import { FullProduct } from "@autumn/shared";
// Scenario 1: Replace base price with new base price
export const replaceBasePrice = async ({
subItems,
autumnProduct,
basePrice,
}: {
subItems: Stripe.SubscriptionItem[];
autumnProduct: FullProduct;
basePrice?: number;
}) => {
let prices = autumnProduct.prices.filter((p) => !isFixedPrice({ price: p }));
// Get first sub item
const subItem = subItems[0];
const customPrice = subItemToFixedPrice({
subItem,
product: autumnProduct,
basePrice,
});
return [customPrice, ...prices];
};

View File

@@ -0,0 +1,43 @@
import { FeatureOptions } from "@autumn/shared";
import csvParser from "csv-parser";
import fs from "fs";
export interface ImportCustomer {
id: string;
name: string;
email?: string;
stripe_id: string;
product_id: string;
base_price?: number;
options?: FeatureOptions[];
// business_id;name;email;active_pass_count;Stripe id;Base price;free_trial_end
}
export const parseCsv = (slug: string): Promise<any[]> => {
const path = `scripts/customers/${slug}/data.csv`;
const results: ImportCustomer[] = [];
return new Promise((resolve, reject) => {
const stream = fs.createReadStream(path);
stream
.pipe(csvParser({ separator: ";" }))
.on("data", (data) =>
results.push({
id: data.business_id,
name: data.name,
email: data.email,
stripe_id: data["Stripe id"],
// base_price: data["Base price"],
product_id: "standard_subscription",
base_price: data["Base price"],
options: [
{
feature_id: "active_passes",
quantity: data["active_pass_count"],
},
],
}),
)
.on("end", () => resolve(results))
.on("error", (error) => reject(error));
});
};

View File

@@ -0,0 +1,37 @@
import { DrizzleCli } from "@/db/initDrizzle.js";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
import { ACTIVE_STATUSES } from "@/internal/customers/cusProducts/CusProductService.js";
import { cusProductsToCusEnts } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js";
import { CusProductStatus, FullCustomer } from "@autumn/shared";
export const updateUsages = async ({
featureId,
usage,
fullCus,
db,
}: {
featureId: string;
usage: number;
fullCus: FullCustomer;
db: DrizzleCli;
}) => {
let cusEnts = cusProductsToCusEnts({
cusProducts: fullCus.customer_products,
inStatuses: ACTIVE_STATUSES,
featureId,
});
if (cusEnts.length === 0) {
throw new Error(`No cus ent for ${featureId}`);
}
let cusEnt = cusEnts[0];
let newBalance = cusEnt.balance! - usage;
await CusEntService.update({
db,
id: cusEnt.id,
updates: {
balance: newBalance,
},
});
};

View File

@@ -24,7 +24,8 @@ export const CreateCustomerSchema = z.object({
name: z.string().nullish(),
email: z.string().nullish(),
fingerprint: z.string().nullish(),
metadata: z.record(z.any()).nullish().default({}),
metadata: z.record(z.any()).default({}).nullish(),
stripe_id: z.string().nullish(),
});
export const CustomerDataSchema = z.object({

View File

@@ -1,7 +1,7 @@
import { invalidNumber, notNullish } from "@/utils/genUtils";
import { Feature, FeatureUsageType, ProductItem } from "@autumn/shared";
import { toast } from "sonner";
import { isFeatureItem } from "../getItemType";
import { isFeatureItem, isFeaturePriceItem } from "../getItemType";
export const validateProductItem = ({
item,
@@ -93,7 +93,11 @@ export const validateProductItem = ({
toast.error("Please enter valid billing units");
return null;
} else {
item.billing_units = Number(item.billing_units);
if (isFeaturePriceItem(item)) {
item.billing_units = Number(item.billing_units);
} else {
item.billing_units = undefined;
}
}
return item;

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useState } from "react";
import LoadingScreen from "@/views/general/LoadingScreen";
import { useAxiosSWR } from "@/services/useAxiosSwr";
@@ -19,7 +19,6 @@ import ProductSidebar from "./ProductSidebar";
import { FeaturesContext } from "@/views/features/FeaturesContext";
import ProductViewBreadcrumbs from "./components/ProductViewBreadcrumbs";
import ConfirmNewVersionDialog from "./versioning/ConfirmNewVersionDialog";
import { sortProductItems } from "@/utils/productUtils";
import { useProductData } from "./hooks/useProductData";
import { useProductChangedAlert } from "./hooks/useProductChangedAlert";

View File

@@ -63,18 +63,26 @@ export const ProductItemConfig = () => {
});
} else {
const showProration = shouldShowProrationConfig({ item, features });
const newConfig = !showProration ? null : item.config;
const resetUsageWhenEnabled =
feature.config?.usage_type == FeatureUsageType.Continuous
? false
: true;
setItem({
const newItem = {
...item,
feature_type: feature.config?.usage_type,
reset_usage_when_enabled: resetUsageWhenEnabled,
config: newConfig,
});
};
const newConfig = !showProration ? undefined : item.config;
if (newConfig) {
newItem.config = newConfig;
} else {
delete newItem.config;
}
setItem(newItem);
}
}
}, [item.feature_id, item.usage_model]);

View File

@@ -1,5 +1,5 @@
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { useState } from "react";
import { useEffect, useState } from "react";
import { ProductItemConfig } from "./ProductItemConfig";
import { ProductItem } from "@autumn/shared";
import { ProductItemContext } from "./ProductItemContext";
@@ -27,11 +27,12 @@ export default function UpdateProductItem({
const { product, setProduct, features } = useProductContext();
const [showCreateFeature, setShowCreateFeature] = useState(false);
const handleUpdateProductItem = (show: any) => {
const handleUpdateProductItem = () => {
const validatedItem = validateProductItem({
item: selectedItem!,
features,
});
if (!validatedItem) return;
if (notNullish(selectedIndex)) {
const newProduct = { ...product };

View File

@@ -34,10 +34,12 @@ export const IncludedUsage = () => {
}
type={item.included_usage === Infinite ? "text" : "number"}
onChange={(e) => {
setItem({
const newItem = {
...item,
included_usage: e.target.value,
});
};
setItem(newItem);
}}
/>
<ToggleDisplayButton

View File

@@ -66,7 +66,9 @@ export const BillingUnits = ({ disabled }: { disabled: boolean }) => {
placeholder={`eg. 100 ${featureName}`}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleEnterClick();
if (popoverOpen) {
handleEnterClick();
}
}
}}
onBlur={handleEnterClick}