Merge branch 'feat/rollovers' of https://github.com/SirTenzin/autumn into feat/rollovers
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import {
|
||||
Rollover as RolloverConfig,
|
||||
RolloverConfig,
|
||||
RolloverModel,
|
||||
rollovers,
|
||||
} from "@autumn/shared";
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
@@ -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>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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, 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;
|
||||
|
||||
<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, 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 && (
|
||||
<>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user