Merge branch 'feat/rollovers' of https://github.com/SirTenzin/autumn into feat/rollovers

This commit is contained in:
amianthus
2025-07-24 13:28:37 +01:00
18 changed files with 428 additions and 336 deletions

View File

@@ -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": {

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { DrizzleCli } from "@/db/initDrizzle.js";
import {
Rollover as RolloverConfig,
RolloverConfig,
RolloverModel,
rollovers,
} from "@autumn/shared";

View File

@@ -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[] = [];

View File

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

View File

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

View File

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

View File

@@ -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>(),
rollover: jsonb().$type<RolloverConfig>(),
},
(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);

View File

@@ -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<EntityRolloverBalance[]>(),
},
(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<EntityRolloverBalance[]>(),
},
(table) => [
foreignKey({
columns: [table.cus_ent_id],
foreignColumns: [customerEntitlements.id],
name: "rollover_cus_ent_id_fkey",
})
.onUpdate("cascade")
.onDelete("cascade"),
]
).enableRLS();

View File

@@ -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<typeof ProductItemSchema>;
export type LimitedItem = z.infer<typeof LimitedItemSchema>;
export type ProductItemConfig = z.infer<typeof ProductItemConfigSchema>;
export type PriceTier = z.infer<typeof PriceTierSchema>;
export type Rollover = z.infer<typeof RolloverSchema>;
export type RolloverConfig = z.infer<typeof RolloverConfigSchema>;

View File

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

View File

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

View File

@@ -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 (
<div className="w-full h-fit">
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-1 w-fit rounded-md text-t3 hover:text-zinc-800 transition-all duration-150 ease-out mt-1"
>
<ChevronRight
className={`w-4 h-4 transition-transform duration-150 ease-out ${
isOpen ? "rotate-90" : "rotate-0"
}`}
/>
<span className="text-sm font-medium">Advanced</span>
</button>
const showProrationConfig = shouldShowProrationConfig({ item, features });
<div
className={`overflow-hidden transition-all duration-150 ease-out ${
isOpen ? "max-h-72 opacity-100 mt-2" : "max-h-0 opacity-0"
}`}
>
<div className="flex flex-col gap-4 p-4 bg-stone-100 ">
<ToggleButton
value={item.reset_usage_when_enabled}
setValue={() => {
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, theyll get 500 credits on upgrade. If false, theyll 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;
<div className="relative flex flex-row items-center gap-3">
<ToggleButton
value={item.usage_limit != null}
setValue={() => {
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 (
<div className="w-full h-fit">
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-1 w-fit rounded-md text-t3 hover:text-zinc-800 transition-all duration-150 ease-out mt-1"
>
<ChevronRight
className={`w-4 h-4 transition-transform duration-150 ease-out ${
isOpen ? "rotate-90" : "rotate-0"
}`}
/>
<span className="text-sm font-medium">Advanced</span>
</button>
{item.usage_limit != null && (
<Input
type="number"
value={item.usage_limit || ""}
className="ml-5 w-25"
onChange={(e) => {
setItem({
...item,
usage_limit: parseInt(e.target.value),
});
}}
placeholder="eg. 100"
/>
)}
</div>
<div
className={`overflow-hidden transition-all duration-150 ease-out ${
isOpen ? "max-h-72 opacity-100 mt-2" : "max-h-0 opacity-0"
}`}
>
<div className="flex flex-col gap-4 p-4 bg-stone-100 ">
<ToggleButton
value={item.reset_usage_when_enabled}
setValue={() => {
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, theyll get 500 credits on upgrade. If false, theyll have 480."
buttonText="Reset existing usage when product is enabled"
className="text-t3 h-fit"
disabled={usageType === FeatureUsageType.Continuous}
/>
{showProrationConfig && (
<>
<OnIncreaseSelect />
<OnDecreaseSelect />
</>
)}
{/* <div className="flex flex-col gap-2"></div>
<div className="relative flex flex-row items-center gap-3">
<ToggleButton
value={item.usage_limit != null}
setValue={() => {
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 && (
<Input
type="number"
value={item.usage_limit || ""}
className="ml-5 w-25"
onChange={(e) => {
setItem({
...item,
usage_limit: parseInt(e.target.value),
});
}}
placeholder="eg. 100"
/>
)}
</div>
{showProrationConfig && (
<>
<OnIncreaseSelect />
<OnDecreaseSelect />
</>
)}
{/* <div className="flex flex-col gap-2"></div>
<div className="flex gap-2"></div> */}
{showRolloverConfig && <RolloverConfig item={item} setItem={setItem} showRolloverConfig={showRolloverConfig} />}
</div>
</div>
</div>
);
};
export const RolloverConfig = ({
item,
setItem,
showRolloverConfig,
}: {
item: ProductItem;
setItem: (item: ProductItem) => void;
showRolloverConfig: boolean;
}) => {
return (
<div className="relative flex flex-row items-center gap-3">
<ToggleButton
value={item.config?.rollover != null}
setValue={() => {
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 && (
<div className="flex flex-row items-center gap-3 w-full">
<Input
type="number"
value={item.config.rollover.max || ""}
className="ml-5 w-full"
placeholder="Max amount"
onChange={(e) => {
setItem({
...item,
// @ts-expect-error - TODO: fix this
config: {
...item.config,
rollover: {
...item.config!.rollover!,
max: parseInt(e.target.value),
},
},
});
}}
/>
<Input
type="number"
value={item.config.rollover.length || ""}
onChange={(e) => {
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={
<>
<p className="text-sm">month(s)</p>
</>
}
/>
</div>
)}
</div>
);
{showRolloverConfig && (
<RolloverConfigView
item={item}
setItem={setItem}
showRolloverConfig={showRolloverConfig}
/>
)}
</div>
</div>
</div>
);
};

View File

@@ -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 (
<div className="relative flex flex-col gap-3">
<ToggleButton
value={item.config?.rollover != null}
setValue={() => {
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 && (
<div className="flex gap-3 w-full">
<div className="w-6/12 flex gap-1">
<Input
value={rollover.max === null ? "Unlimited" : rollover.max}
className="w-full"
placeholder="Max"
disabled={rollover.max === null}
onChange={(e) => {
setRolloverConfigKey("max", e.target.value);
}}
/>
<ToggleDisplayButton
label="Unlimited"
show={rollover.max === null}
className="h-8"
onClick={() => {
if (rollover.max === null) {
setRolloverConfigKey("max", 0);
} else {
setRolloverConfigKey("max", null);
}
}}
>
</ToggleDisplayButton>
</div>
<div className="w-6/12 flex gap-1">
{rollover.duration === RolloverDuration.Month && (
<Input
value={rollover.length || ""}
onChange={(e) => {
setRolloverConfigKey("length", e.target.value);
}}
className="w-14"
/>
)}
<Select
value={rollover.duration}
onValueChange={(value) => {
setRolloverConfigKey("duration", value as RolloverDuration);
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a duration" />
</SelectTrigger>
<SelectContent>
{Object.values(RolloverDuration).map((duration) => (
<SelectItem key={duration} value={duration}>
{duration}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
</div>
);
};

View File

@@ -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() {
<div
className={cn(
"flex w-full text-sm",
tier.to == -1 && "bg-transparent",
tier.to == -1 && "bg-transparent"
)}
>
<UsageTierInput
@@ -104,7 +106,7 @@ export default function FeaturePrice() {
<div
className={cn(
"flex text-sm",
item.tiers?.length == 1 ? "w-full" : "w-32",
item.tiers?.length == 1 ? "w-full" : "w-32"
)}
>
<UsageTierInput