fix: cleaning up update product section
This commit is contained in:
@@ -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 },
|
||||
});
|
||||
};
|
||||
|
||||
27
server/src/external/stripe/stripePriceUtils.ts
vendored
27
server/src/external/stripe/stripePriceUtils.ts
vendored
@@ -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,
|
||||
|
||||
@@ -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! });
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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 || ""}`;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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">
|
||||
{" "}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -22,7 +22,6 @@ export const updateProduct = async ({
|
||||
product.items as FrontendProductItem[],
|
||||
);
|
||||
|
||||
console.log("validated", validated);
|
||||
if (!validated) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user