diff --git a/package.json b/package.json index 6e30adc47..7d7b18216 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "vite:start:bun": "bun -F @autumn/shared build && bun -F @autumn/vite start:bun", - "dev:bun": "concurrently \"cd server && bun run dev\" \"cd vite && bun run dev:bun\"", + "dev:bun": "concurrently \"cd server && bun run dev\" \"cd vite && bun run dev:bun\" \"bun -F @autumn/shared dev:bun\"", "build:all:bun": "bun run -F @autumn/shared build:bun && bun run -F @autumn/server prod:build:bun && bun run -F @autumn/vite build:bun" }, "dependencies": { diff --git a/server/package.json b/server/package.json index 01ccc293a..5db1b346f 100644 --- a/server/package.json +++ b/server/package.json @@ -7,7 +7,7 @@ "scripts": { "email": "email dev -p 3001", "start": "bun src/index.ts", - "dev": "NODE_ENV=development bun --watch src/index.ts --ignore scripts --ignore tests", + "dev": "NODE_ENV=development bunx --bun nodemon --exec bun src/index.ts --ignore scripts --ignore tests", "workers": "bun src/workers.ts", "workers:dev": "bun --watch src/workers.ts", "cron": "bun src/cron.ts", diff --git a/server/src/cron.ts b/server/src/cron.ts index 3c94f1d68..3c539fd55 100644 --- a/server/src/cron.ts +++ b/server/src/cron.ts @@ -4,6 +4,7 @@ import { EntInterval, FullCusEntWithProduct, Organization, + RolloverConfig, } from "@autumn/shared"; import { CusEntService } from "./internal/customers/cusProducts/cusEnts/CusEntitlementService.js"; @@ -219,7 +220,7 @@ const resetCustomerEntitlement = async ({ rolloverRows = await RolloverService.insert({ db, rows: rolloverUpdate.toInsert, - rolloverConfig: cusEnt.entitlement.rollover, + rolloverConfig: cusEnt.entitlement.rollover as RolloverConfig, cusEntID: cusEnt.id, entityMode: notNullish(cusEnt.entitlement.entity_feature_id), }); diff --git a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handlePrepaidPrices.ts b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handlePrepaidPrices.ts index 364af8762..8e31ed0e4 100644 --- a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handlePrepaidPrices.ts +++ b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handlePrepaidPrices.ts @@ -13,6 +13,7 @@ import { FeatureOptions, FullCusProduct, FullCustomerPrice, + RolloverConfig, } from "@autumn/shared"; import Stripe from "stripe"; @@ -130,7 +131,7 @@ export const handlePrepaidPrices = async ({ rolloverRows = await RolloverService.insert({ db, rows: rolloverUpdate.toInsert, - rolloverConfig: ent.rollover, + rolloverConfig: ent.rollover as RolloverConfig, cusEntID: cusEnt.id, entityMode: notNullish(ent.entity_feature_id), }); diff --git a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleUsagePrices.ts b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleUsagePrices.ts index 9eb1e1083..b272d7c7f 100644 --- a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleUsagePrices.ts +++ b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleUsagePrices.ts @@ -10,6 +10,7 @@ import { EntInterval, Customer, APIVersion, + RolloverConfig, } from "@autumn/shared"; import { differenceInMinutes, subDays } from "date-fns"; import { submitUsageToStripe } from "../../stripeMeterUtils.js"; @@ -168,7 +169,7 @@ export const handleUsagePrices = async ({ rolloverRows = await RolloverService.insert({ db, rows: rolloverUpdate.toInsert, - rolloverConfig: ent.rollover, + rolloverConfig: ent.rollover as RolloverConfig, cusEntID: ent.id, entityMode: notNullish(ent.entity_feature_id), }); diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts index 1e388293e..8e1cdfab1 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts @@ -1,6 +1,6 @@ import { DrizzleCli } from "@/db/initDrizzle.js"; import { - Rollover as RolloverConfig, + RolloverConfig, RolloverModel, rollovers, } from "@autumn/shared"; diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.ts b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.ts index f42b62fd9..d3f9aac44 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.ts @@ -1,10 +1,11 @@ import { FullCustomerEntitlement, ProductItemInterval, - Rollover, + RolloverConfig as Rollover, RolloverModel, EntityBalance, EntityRolloverBalance, + RolloverDuration, } from "@autumn/shared"; import { notNullish, nullish } from "@/utils/genUtils.js"; import { randomUUID } from "crypto"; @@ -111,7 +112,7 @@ export const calculateNextExpiry = (nextResetAt: number, config: Rollover) => { } let nextExpiry = new Date(nextResetAt); - if (config!.duration === ProductItemInterval.Month) { + if (config!.duration === RolloverDuration.Month) { nextExpiry.setMonth(nextExpiry.getMonth() + config!.length); } @@ -133,6 +134,10 @@ export async function performMaximumClearing({ throw new Error("Rollover config is required"); } + if(rolloverConfig.max == null) { + throw new Error("Rollover config max is required"); + } + let total = 0; let toDelete: string[] = []; let toUpdate: RolloverModel[] = []; diff --git a/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts b/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts index dde40c9fd..07ab6b936 100644 --- a/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts +++ b/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts @@ -115,7 +115,6 @@ export const toFeature = ({ newVersion?: boolean; feature?: Feature; }) => { - console.log("item toFeature", item); let isBoolean = feature?.type == FeatureType.Boolean; let resetUsage = getResetUsage({ item, feature }); diff --git a/server/src/internal/products/product-items/validateProductItems.ts b/server/src/internal/products/product-items/validateProductItems.ts index 0677bd3c0..f60e08685 100644 --- a/server/src/internal/products/product-items/validateProductItems.ts +++ b/server/src/internal/products/product-items/validateProductItems.ts @@ -12,7 +12,7 @@ import { AppEnv, OnIncrease, UsageModel, - FeatureUsageType, + RolloverDuration, } from "@autumn/shared"; import { StatusCodes } from "http-status-codes"; import { notNullish, nullish } from "@/utils/genUtils.js"; @@ -160,6 +160,14 @@ const validateProductItem = ({ statusCode: StatusCodes.BAD_REQUEST, }); } + + // Rollover + // if (item.config?.rollover) { + // let rollover = item.config.rollover; + + // if (rollover.duration == RolloverDuration.Month) { + // } + // } }; export const validateProductItems = ({ newItems, diff --git a/shared/models/productModels/entModels/entModels.ts b/shared/models/productModels/entModels/entModels.ts index c8570bcd6..383e85fd1 100644 --- a/shared/models/productModels/entModels/entModels.ts +++ b/shared/models/productModels/entModels/entModels.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { FeatureSchema } from "../../featureModels/featureModels.js"; import { EntInterval } from "./entEnums.js"; -import { RolloverSchema } from "../../productV2Models/productItemModels/productItemModels.js"; +import { RolloverConfigSchema } from "../../productV2Models/productItemModels/productItemModels.js"; export enum AllowanceType { Fixed = "fixed", @@ -29,7 +29,7 @@ export const EntitlementSchema = z.object({ feature_id: z.string().optional(), usage_limit: z.number().nullable().optional().default(null), - rollover: RolloverSchema.nullish() + rollover: RolloverConfigSchema.nullish(), }); export const CreateEntitlementSchema = z.object({ @@ -42,7 +42,7 @@ export const CreateEntitlementSchema = z.object({ carry_from_previous: z.boolean().default(false), entity_feature_id: z.string().nullish(), usage_limit: z.number().nullish().default(null), - rollover: RolloverSchema.nullish(), + rollover: RolloverConfigSchema.nullish(), }); export type CreateEntitlement = z.infer; diff --git a/shared/models/productModels/entModels/entTable.ts b/shared/models/productModels/entModels/entTable.ts index 2b9bf9f52..26718ba01 100644 --- a/shared/models/productModels/entModels/entTable.ts +++ b/shared/models/productModels/entModels/entTable.ts @@ -14,7 +14,7 @@ import { products } from "../productTable.js"; import { createInsertSchema } from "drizzle-zod"; import { sql } from "drizzle-orm"; import { collatePgColumn } from "../../../db/utils.js"; -import { Rollover } from "../../../index.js"; +import { RolloverConfig } from "../../../index.js"; export const entitlements = pgTable( "entitlements", @@ -37,9 +37,9 @@ export const entitlements = pgTable( feature_id: text("feature_id"), usage_limit: numeric({ mode: "number" }), - rollover: jsonb().$type(), + rollover: jsonb().$type(), }, - (table) => [ + (table) => [ foreignKey({ columns: [table.internal_feature_id], foreignColumns: [features.internal_id], @@ -54,7 +54,7 @@ export const entitlements = pgTable( .onDelete("cascade"), unique("entitlements_id_key").on(table.id), index("idx_entitlements_internal_product_id").on(table.internal_product_id), - ], + ] ); export const EntInsertSchema = createInsertSchema(entitlements); diff --git a/shared/models/productModels/rolloverModels/rolloverTable.ts b/shared/models/productModels/rolloverModels/rolloverTable.ts index 6cd95cd70..68df5db76 100644 --- a/shared/models/productModels/rolloverModels/rolloverTable.ts +++ b/shared/models/productModels/rolloverModels/rolloverTable.ts @@ -1,32 +1,32 @@ import { - foreignKey, - pgTable, - numeric, - jsonb, - text, - integer, - uuid, + foreignKey, + pgTable, + numeric, + jsonb, + text, + integer, + uuid, } from "drizzle-orm/pg-core"; import { EntityBalance, EntityRolloverBalance } from "../../cusProductModels/cusEntModels/cusEntModels.js"; import { customerEntitlements } from "../../cusProductModels/cusEntModels/cusEntTable.js"; export const rollovers = pgTable( - "rollovers", - { - id: uuid("id").primaryKey().defaultRandom(), - cus_ent_id: text("cus_ent_id").notNull(), - balance: numeric({ mode: "number" }).notNull(), - expires_at: numeric({ mode: "number" }).notNull(), - entities: jsonb("entities").$type(), - }, - (table) => [ - foreignKey({ - columns: [table.cus_ent_id], - foreignColumns: [customerEntitlements.id], - name: "rollover_cus_ent_id_fkey", - }) - .onUpdate("cascade") - .onDelete("cascade"), - ] -).enableRLS(); + "rollovers", + { + id: text("id").primaryKey().notNull(), + cus_ent_id: text("cus_ent_id").notNull(), + balance: numeric({ mode: "number" }).notNull(), + expires_at: numeric({ mode: "number" }).notNull(), + entities: jsonb("entities").$type(), + }, + (table) => [ + foreignKey({ + columns: [table.cus_ent_id], + foreignColumns: [customerEntitlements.id], + name: "rollover_cus_ent_id_fkey", + }) + .onUpdate("cascade") + .onDelete("cascade"), + ] +).enableRLS(); \ No newline at end of file diff --git a/shared/models/productV2Models/productItemModels/productItemModels.ts b/shared/models/productV2Models/productItemModels/productItemModels.ts index 04274f0be..5982145bd 100644 --- a/shared/models/productV2Models/productItemModels/productItemModels.ts +++ b/shared/models/productV2Models/productItemModels/productItemModels.ts @@ -6,101 +6,101 @@ import { OnDecrease } from "./productItemEnums.js"; export const TierInfinite = "inf"; export enum ProductItemInterval { - // None = "none", + // None = "none", - // Reset interval - Minute = "minute", - Hour = "hour", - Day = "day", - Week = "week", + // Reset interval + Minute = "minute", + Hour = "hour", + Day = "day", + Week = "week", - // Billing interval - Month = "month", - Quarter = "quarter", - SemiAnnual = "semi_annual", - Year = "year", + // Billing interval + Month = "month", + Quarter = "quarter", + SemiAnnual = "semi_annual", + Year = "year", } export enum ProductItemType { - Feature = "feature", - FeaturePrice = "priced_feature", - Price = "price", + Feature = "feature", + FeaturePrice = "priced_feature", + Price = "price", } export const PriceTierSchema = z.object({ - to: z.number().or(z.literal(TierInfinite)), - amount: z.number(), + to: z.number().or(z.literal(TierInfinite)), + amount: z.number(), }); export enum UsageModel { - Prepaid = "prepaid", - PayPerUse = "pay_per_use", + Prepaid = "prepaid", + PayPerUse = "pay_per_use", } export enum ProductItemFeatureType { - SingleUse = "single_use", - ContinuousUse = "continuous_use", - Static = "static", + SingleUse = "single_use", + ContinuousUse = "continuous_use", + Static = "static", } -export const RolloverSchema = z.object({ - max: z.number(), - duration: z - .nativeEnum(ProductItemInterval) - .default(ProductItemInterval.Month), - length: z.number(), -}) -.nullish() +export enum RolloverDuration { + Month = "month", + Forever = "forever", +} + +export const RolloverConfigSchema = z.object({ + max: z.number().nullable(), + duration: z.nativeEnum(RolloverDuration).default(RolloverDuration.Month), + length: z.number(), +}); const ProductItemConfigSchema = z.object({ - on_increase: z - .nativeEnum(OnIncrease) - .optional() - .default(OnIncrease.BillImmediately), - on_decrease: z - .nativeEnum(OnDecrease) - .optional() - .default(OnDecrease.ProrateImmediately), + on_increase: z + .nativeEnum(OnIncrease) + .nullish() + .default(OnIncrease.BillImmediately), + on_decrease: z + .nativeEnum(OnDecrease) + .nullish() + .default(OnDecrease.ProrateImmediately), - rollover: RolloverSchema, + rollover: RolloverConfigSchema.nullish(), }); export const ProductItemSchema = z.object({ - // Feature stuff - feature_id: z.string().nullish(), - feature_type: z.nativeEnum(ProductItemFeatureType).nullish(), - included_usage: z.union([z.number(), z.literal(Infinite)]).nullish(), - interval: z.nativeEnum(ProductItemInterval).nullish(), - entity_feature_id: z.string().nullish(), + // Feature stuff + feature_id: z.string().nullish(), + feature_type: z.nativeEnum(ProductItemFeatureType).nullish(), + included_usage: z.union([z.number(), z.literal(Infinite)]).nullish(), + interval: z.nativeEnum(ProductItemInterval).nullish(), + entity_feature_id: z.string().nullish(), - // Price config - usage_model: z.nativeEnum(UsageModel).nullish(), - price: z.number().nullish(), - tiers: z.array(PriceTierSchema).nullish(), - billing_units: z.number().nullish(), // amount per billing unit (eg. $9 / 250 units) - usage_limit: z.number().nullish(), + // Price config + usage_model: z.nativeEnum(UsageModel).nullish(), + price: z.number().nullish(), + tiers: z.array(PriceTierSchema).nullish(), + billing_units: z.number().nullish(), // amount per billing unit (eg. $9 / 250 units) + usage_limit: z.number().nullish(), - // Others - // carry_over_usage: z.boolean().nullish(), - reset_usage_when_enabled: z.boolean().nullish(), + // Others + // carry_over_usage: z.boolean().nullish(), + reset_usage_when_enabled: z.boolean().nullish(), - config: ProductItemConfigSchema.nullish(), + config: ProductItemConfigSchema.nullish(), - rollover: RolloverSchema.nullish(), - - // Stored in backend - created_at: z.number().nullish(), - entitlement_id: z.string().nullish(), - price_id: z.string().nullish(), - price_config: z.any().nullish(), + // Stored in backend + created_at: z.number().nullish(), + entitlement_id: z.string().nullish(), + price_id: z.string().nullish(), + price_config: z.any().nullish(), }); export const LimitedItemSchema = ProductItemSchema.extend({ - included_usage: z.number(), + included_usage: z.number(), }); export type ProductItem = z.infer; export type LimitedItem = z.infer; export type ProductItemConfig = z.infer; export type PriceTier = z.infer; -export type Rollover = z.infer; \ No newline at end of file +export type RolloverConfig = z.infer; diff --git a/shared/package.json b/shared/package.json index db7f0e850..d4a4fd5a6 100644 --- a/shared/package.json +++ b/shared/package.json @@ -13,6 +13,7 @@ "author": "Recase Inc.", "license": "Apache-2.0", "scripts": { + "build:tsc": "tsc", "build": "bun build ./index.ts --outdir dist --target bun --external zod", "dev": "bunx nodemon --ext ts --ignore dist --exec \"bun run build\"", "dev:bun": "bun ./index.ts --outdir dist --target bun --external zod --watch", diff --git a/vite/src/utils/product/product-item/validateProductItem.ts b/vite/src/utils/product/product-item/validateProductItem.ts index 61b4b9f79..5dffaf5a6 100644 --- a/vite/src/utils/product/product-item/validateProductItem.ts +++ b/vite/src/utils/product/product-item/validateProductItem.ts @@ -1,5 +1,13 @@ -import { invalidNumber, notNullish } from "@/utils/genUtils"; -import { Feature, FeatureUsageType, ProductItem, ProductItemInterval, UsageModel } from "@autumn/shared"; +import { invalidNumber, notNullish, nullish } from "@/utils/genUtils"; +import { + Feature, + FeatureUsageType, + Infinite, + ProductItem, + ProductItemInterval, + RolloverConfig, + RolloverDuration, +} from "@autumn/shared"; import { toast } from "sonner"; import { isFeatureItem, isFeaturePriceItem } from "../getItemType"; import { isOneOffProduct } from "../priceUtils"; @@ -101,49 +109,55 @@ export const validateProductItem = ({ } } - if (item.config) { - if (item.config.rollover) { - if(item.interval === null) { - toast.warning("Cannot create rollover config for a one off product - disabling rollovers"); - item.config.rollover = undefined; - return item; - } + if (item.config?.rollover) { + const rollover = item.config?.rollover as RolloverConfig; - if (invalidNumber(item.config.rollover.max)) { - toast.error("Please enter a valid maximum rollover amount"); - item.config.rollover = undefined; - return null; - } + if (rollover.max && rollover.max !== null) { + rollover.max = parseFloat(rollover.max.toString()); + } - if (invalidNumber(item.config.rollover.length)) { - toast.error("Please enter a valid rollover duration"); - item.config.rollover = undefined; - return null; - } + if (rollover.duration !== RolloverDuration.Forever) { + rollover.length = parseFloat(rollover.length.toString()); + } else { + rollover.length = 0; + } - if(item.config.rollover.duration != ProductItemInterval.Month) { - toast.error("Rollovers currently only support monthly cycles."); - item.config.rollover = undefined; - return null; - } + if ( + item.interval === null || + nullish(item.included_usage) || + item.included_usage === 0 + ) { + item.config!.rollover = null; + return item; + } - if (item.config.rollover.max < 0 || !item.config.rollover.max) { - toast.error("Please enter a positive rollover max amount"); - item.config.rollover = undefined; - return null; - } + if (rollover.max !== null && invalidNumber(rollover.max)) { + toast.error("Please enter a valid maximum rollover amount"); + return null; + } - if (item.config.rollover.length < 0 || !item.config.rollover.length) { - toast.error("Please enter a positive rollover length"); - item.config.rollover = undefined; - return null; - } + if (invalidNumber(rollover.length)) { + toast.error("Please enter a valid rollover duration"); + item.config.rollover = undefined; + return null; + } - if(item.entity_feature_id && item.usage_model === UsageModel.Prepaid) { - toast.error("Prepaid products cannot have entity features"); - item.config.rollover = undefined; - return null; - } + // if (rollover.duration != RolloverDuration.Month) { + // toast.error("Rollovers currently only support monthly cycles."); + // item.config.rollover = undefined; + // return null; + // } + + if (typeof rollover.max == "number" && rollover.max < 0) { + toast.error("Please enter a positive rollover max amount"); + item.config.rollover = undefined; + return null; + } + + if (rollover.duration == RolloverDuration.Month && rollover.length < 0) { + toast.error("Please enter a positive rollover length"); + item.config.rollover = undefined; + return null; } } diff --git a/vite/src/views/products/product/product-item/product-item-config/advanced-config/AdvancedItemConfig.tsx b/vite/src/views/products/product/product-item/product-item-config/advanced-config/AdvancedItemConfig.tsx index 82ef5822f..d5bcc9798 100644 --- a/vite/src/views/products/product/product-item/product-item-config/advanced-config/AdvancedItemConfig.tsx +++ b/vite/src/views/products/product/product-item/product-item-config/advanced-config/AdvancedItemConfig.tsx @@ -7,200 +7,130 @@ import { OnDecreaseSelect } from "./proration-config/OnDecreaseSelect"; import { OnIncreaseSelect } from "./proration-config/OnIncreaseSelect"; import { shouldShowProrationConfig } from "@/utils/product/productItemUtils"; import { - getFeature, - getFeatureCreditSystem, - getFeatureUsageType, + getFeatureCreditSystem, + getFeatureUsageType, } from "@/utils/product/entitlementUtils"; -import { FeatureUsageType, ProductItem, ProductItemInterval } from "@autumn/shared"; +import { + FeatureUsageType, + Infinite, + ProductItem, + RolloverDuration, + RolloverConfig, +} from "@autumn/shared"; import { Input } from "@/components/ui/input"; +import { ToggleDisplayButton } from "@/components/general/ToggleDisplayButton"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { RolloverConfigView } from "./RolloverConfig"; export const AdvancedItemConfig = () => { - const { features } = useProductContext(); - const { item, setItem } = useProductItemContext(); - console.log("item", item); - const [isOpen, setIsOpen] = useState(item.usage_limit != null); + const { features } = useProductContext(); + const { item, setItem } = useProductItemContext(); - const showProrationConfig = shouldShowProrationConfig({ item, features }); - const usageType = getFeatureUsageType({ item, features }); - const hasCreditSystem = getFeatureCreditSystem({ item, features }); - const showRolloverConfig = - (hasCreditSystem || usageType === FeatureUsageType.Single) && item.interval !== null; + const [isOpen, setIsOpen] = useState(item.usage_limit != null); - return ( -
- + const showProrationConfig = shouldShowProrationConfig({ item, features }); -
-
- { - setItem({ - ...item, - reset_usage_when_enabled: - !item.reset_usage_when_enabled, - }); - }} - infoContent="A customer has used 20/100 credits on a free plan. Then they upgrade to a Pro plan with 500 credits. If this flag is enabled, they’ll get 500 credits on upgrade. If false, they’ll have 480." - buttonText="Reset existing usage when product is enabled" - className="text-t3 h-fit" - disabled={usageType === FeatureUsageType.Continuous} - /> + const usageType = getFeatureUsageType({ item, features }); + const hasCreditSystem = getFeatureCreditSystem({ item, features }); + const showRolloverConfig = + (hasCreditSystem || usageType === FeatureUsageType.Single) && + item.interval !== null && + item.included_usage && + item.included_usage > 0; -
- { - let usage_limit; - if (item.usage_limit) { - usage_limit = null; - } else { - usage_limit = Infinity; - } - setItem({ - ...item, - usage_limit: usage_limit, - }); - }} - buttonText="Enable usage limits" - className="text-t3 h-fit" - /> + return ( +
+ - {item.usage_limit != null && ( - { - setItem({ - ...item, - usage_limit: parseInt(e.target.value), - }); - }} - placeholder="eg. 100" - /> - )} -
+
+
+ { + setItem({ + ...item, + reset_usage_when_enabled: !item.reset_usage_when_enabled, + }); + }} + infoContent="A customer has used 20/100 credits on a free plan. Then they upgrade to a Pro plan with 500 credits. If this flag is enabled, they’ll get 500 credits on upgrade. If false, they’ll have 480." + buttonText="Reset existing usage when product is enabled" + className="text-t3 h-fit" + disabled={usageType === FeatureUsageType.Continuous} + /> - {showProrationConfig && ( - <> - - - - )} - {/*
+
+ { + let usage_limit; + if (item.usage_limit) { + usage_limit = null; + } else { + usage_limit = Infinity; + } + setItem({ + ...item, + usage_limit: usage_limit, + }); + }} + buttonText="Enable usage limits" + className="text-t3 h-fit" + /> + + {item.usage_limit != null && ( + { + setItem({ + ...item, + usage_limit: parseInt(e.target.value), + }); + }} + placeholder="eg. 100" + /> + )} +
+ + {showProrationConfig && ( + <> + + + + )} + {/*
*/} - {showRolloverConfig && } -
-
-
- ); -}; - -export const RolloverConfig = ({ - item, - setItem, - showRolloverConfig, -}: { - item: ProductItem; - setItem: (item: ProductItem) => void; - showRolloverConfig: boolean; -}) => { - - return ( -
- { - if (item.config?.rollover != null) { - setItem({ - ...item, - config: { - ...item.config, - rollover: null, - }, - }); - } else { - setItem({ - ...item, - config: { - ...item.config, - // @ts-expect-error - TODO: fix this - rollover: { - duration: ProductItemInterval.Month, - }, - }, - }); - } - }} - buttonText="Enable rollovers" - infoContent="Rollovers allow unused credits to carry forward to the next billing cycle. For example: if a customer uses 80 out of 100 credits, they'll start the next cycle with 120 credits (100 new + 20 unused). You can set a maximum rollover amount to cap how many credits can accumulate, and specify how many billing cycles the rollover continues before resetting to the base amount." - className="text-t3 h-fit" - disabled={!showRolloverConfig} - /> - - {item.config?.rollover != null && showRolloverConfig && ( -
- { - setItem({ - ...item, - // @ts-expect-error - TODO: fix this - config: { - ...item.config, - rollover: { - ...item.config!.rollover!, - max: parseInt(e.target.value), - }, - }, - }); - }} - /> - - { - setItem({ - ...item, - // @ts-expect-error - TODO: fix this - config: { - ...item.config, - rollover: { - ...item.config!.rollover!, - length: parseInt(e.target.value), - }, - }, - }); - }} - className="ml-0 w-full" - endContent={ - <> -

month(s)

- - } - /> -
- )} -
- ); + {showRolloverConfig && ( + + )} +
+
+
+ ); }; diff --git a/vite/src/views/products/product/product-item/product-item-config/advanced-config/RolloverConfig.tsx b/vite/src/views/products/product/product-item/product-item-config/advanced-config/RolloverConfig.tsx new file mode 100644 index 000000000..1b8c8cd5a --- /dev/null +++ b/vite/src/views/products/product/product-item/product-item-config/advanced-config/RolloverConfig.tsx @@ -0,0 +1,130 @@ +import { ToggleButton } from "@/components/general/ToggleButton"; +import { ToggleDisplayButton } from "@/components/general/ToggleDisplayButton"; +import { Input } from "@/components/ui/input"; +import { ProductItem, RolloverConfig, RolloverDuration } from "@autumn/shared"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +export const RolloverConfigView = ({ + item, + setItem, + showRolloverConfig, +}: { + item: ProductItem; + setItem: (item: any) => void; + showRolloverConfig: boolean; +}) => { + const defaultRollover: RolloverConfig = { + duration: RolloverDuration.Month, + length: 1, + max: null, + }; + + const setRolloverConfigKey = (key: keyof RolloverConfig, value: any) => { + setItem({ + ...item, + config: { + ...(item.config || {}), + rollover: { + ...(item.config?.rollover || {}), + [key]: value, + }, + }, + }); + }; + + const setRolloverConfig = (rollover: RolloverConfig | null) => { + setItem({ + ...item, + config: { + ...(item.config || {}), + rollover: rollover, + }, + }); + }; + + const rollover = item.config?.rollover as RolloverConfig; + + return ( +
+ { + if (item.config?.rollover != null) { + setRolloverConfig(null); + } else { + setRolloverConfig(defaultRollover); + } + }} + buttonText="Enable rollovers" + infoContent="Rollovers carry unused credits to the next billing cycle. Set a maximum rollover amount and specify how many cycles before resetting." + className="text-t3 h-fit" + disabled={!showRolloverConfig} + /> + + {item.config?.rollover && showRolloverConfig && ( +
+
+ { + setRolloverConfigKey("max", e.target.value); + }} + /> + { + if (rollover.max === null) { + setRolloverConfigKey("max", 0); + } else { + setRolloverConfigKey("max", null); + } + }} + > + ♾️ + +
+ +
+ {rollover.duration === RolloverDuration.Month && ( + { + setRolloverConfigKey("length", e.target.value); + }} + className="w-14" + /> + )} + +
+
+ )} +
+ ); +}; diff --git a/vite/src/views/products/product/product-item/product-item-config/components/feature-price/FeaturePrice.tsx b/vite/src/views/products/product/product-item/product-item-config/components/feature-price/FeaturePrice.tsx index 07c518804..c667e8c47 100644 --- a/vite/src/views/products/product/product-item/product-item-config/components/feature-price/FeaturePrice.tsx +++ b/vite/src/views/products/product/product-item/product-item-config/components/feature-price/FeaturePrice.tsx @@ -38,12 +38,14 @@ export default function FeaturePrice() { }; const handlePriceRemoved = () => { - setItem( - FeatureItemSchema.parse({ - ...item, - included_usage: item.included_usage || 0, - }), - ); + setItem({ + feature_id: item.feature_id, + included_usage: item.included_usage || 0, + interval: item.interval, + entity_feature_id: item.entity_feature_id, + reset_usage_when_enabled: item.reset_usage_when_enabled, + config: item.config, + }); }; const handleRemoveTier = (index: number) => { const newTiers = [...item.tiers]; @@ -88,7 +90,7 @@ export default function FeaturePrice() {