From c690b749d12f6dd7ad0429c6463101e28033cc8a Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Mon, 21 Jul 2025 17:46:26 +0100 Subject: [PATCH 01/45] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20ui=20for=20rollove?= =?UTF-8?q?rs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../products/product/hooks/useProductData.tsx | 1 + .../product-item/ProductItemConfig.tsx | 18 +- .../product-item/UpdateProductItem.tsx | 4 +- .../advanced-config/AdvancedItemConfig.tsx | 247 ++++++++++++------ 4 files changed, 180 insertions(+), 90 deletions(-) diff --git a/vite/src/views/products/product/hooks/useProductData.tsx b/vite/src/views/products/product/hooks/useProductData.tsx index eea9ffc28..ff0b4887e 100644 --- a/vite/src/views/products/product/hooks/useProductData.tsx +++ b/vite/src/views/products/product/hooks/useProductData.tsx @@ -27,6 +27,7 @@ export const useProductData = ({ data }: { data: any }) => { ...data.product, items: sortProductItems(data.product.items), }; + initialProductRef.current = structuredClone(sortedProduct); setEntityFeatureIds(initEntityFeatureIds(sortedProduct)); setProduct(sortedProduct); diff --git a/vite/src/views/products/product/product-item/ProductItemConfig.tsx b/vite/src/views/products/product/product-item/ProductItemConfig.tsx index 4c88deeeb..c86894644 100644 --- a/vite/src/views/products/product/product-item/ProductItemConfig.tsx +++ b/vite/src/views/products/product/product-item/ProductItemConfig.tsx @@ -63,13 +63,21 @@ export const ProductItemConfig = () => { reset_usage_when_enabled: resetUsageWhenEnabled, }; - const newConfig = !showProration ? undefined : item.config; + // Only manage proration config if this item should show proration + if (showProration) { + // Preserve existing config and manage proration parts + const existingConfig = item.config || {}; + const newConfig = { + ...existingConfig, + on_increase: existingConfig.on_increase, + on_decrease: existingConfig.on_decrease, + }; - if (newConfig) { - newItem.config = newConfig; - } else { - delete newItem.config; + if (Object.keys(newConfig).length > 0) { + newItem.config = newConfig; + } } + // If showProration is false, don't touch the config at all - preserve whatever is there setItem(newItem); } diff --git a/vite/src/views/products/product/product-item/UpdateProductItem.tsx b/vite/src/views/products/product/product-item/UpdateProductItem.tsx index 5a13461b0..ebbc02854 100644 --- a/vite/src/views/products/product/product-item/UpdateProductItem.tsx +++ b/vite/src/views/products/product/product-item/UpdateProductItem.tsx @@ -27,6 +27,8 @@ export default function UpdateProductItem({ const { product, setProduct, features } = useProductContext(); const [showCreateFeature, setShowCreateFeature] = useState(false); + + const handleUpdateProductItem = () => { console.log("Selected Item: ", selectedItem); const validatedItem = validateProductItem({ @@ -83,4 +85,4 @@ export default function UpdateProductItem({ ); -} +}; 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 ce0643331..bc8d8c302 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,99 +7,178 @@ import { OnDecreaseSelect } from "./proration-config/OnDecreaseSelect"; import { OnIncreaseSelect } from "./proration-config/OnIncreaseSelect"; import { shouldShowProrationConfig } from "@/utils/product/productItemUtils"; import { - getFeature, - getFeatureUsageType, + getFeature, + getFeatureUsageType, } from "@/utils/product/entitlementUtils"; -import { FeatureUsageType } from "@autumn/shared"; +import { FeatureUsageType, ProductItemInterval } from "@autumn/shared"; import { Input } from "@/components/ui/input"; export const AdvancedItemConfig = () => { - const { features } = useProductContext(); - const { item, setItem } = useProductItemContext(); - const [isOpen, setIsOpen] = useState(item.usage_limit != null); + const { features } = useProductContext(); + const { item, setItem } = useProductItemContext(); + console.log("item", item); + const [isOpen, setIsOpen] = useState(item.usage_limit != null); - const showProrationConfig = shouldShowProrationConfig({ item, features }); - const usageType = getFeatureUsageType({ item, features }); + const showProrationConfig = shouldShowProrationConfig({ item, features }); + const usageType = getFeatureUsageType({ item, features }); - return ( -
- + return ( +
+ -
-
- { - 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} - /> +
+
+ { + 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} + /> -
- { - 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" - /> +
+ { + 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" - /> - )} -
+ {item.usage_limit != null && ( + { + setItem({ + ...item, + usage_limit: parseInt(e.target.value), + }); + }} + placeholder="eg. 100" + /> + )} +
- {showProrationConfig && ( - <> - - - - )} - {/*
+ {showProrationConfig && ( + <> + + + + )} + {/*
*/} -
-
-
- ); + +
+ { + if (item.config?.rollover != null) { + setItem({ + ...item, + config: { + ...item.config, + rollover: null, + }, + }); + } else { + setItem({ + ...item, + config: { + ...item.config, + 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" + /> + + {item.config?.rollover != null && ( +
+ { + setItem({ + ...item, + config: { + ...item.config, + rollover: { + ...item.config!.rollover!, + max: parseInt(e.target.value) + }, + }, + }); + }} + /> + + + { + setItem({ + ...item, + config: { + ...item.config, + rollover: { + ...item.config!.rollover!, + length: parseInt(e.target.value) + }, + }, + }); + }} + className="ml-0 w-full" + endContent={ + <> +

month(s)

+ + } + /> +
+ )} +
+
+
+
+ ); }; From bbe031b1f109299f875cba6a2e1516fb7d3d8dd7 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Mon, 21 Jul 2025 17:46:56 +0100 Subject: [PATCH 02/45] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20add=20item.config?= =?UTF-8?q?=20.rollover=20to=20db=20models?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../productModels/entModels/entModels.ts | 4 + .../productModels/entModels/entTable.ts | 6 +- .../productItemModels/featureItem.ts | 1 + .../productItemModels/featurePriceItem.ts | 1 + .../productItemModels/productItemModels.ts | 116 ++++++++++-------- 5 files changed, 76 insertions(+), 52 deletions(-) diff --git a/shared/models/productModels/entModels/entModels.ts b/shared/models/productModels/entModels/entModels.ts index 36c10d3b1..c8570bcd6 100644 --- a/shared/models/productModels/entModels/entModels.ts +++ b/shared/models/productModels/entModels/entModels.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { FeatureSchema } from "../../featureModels/featureModels.js"; import { EntInterval } from "./entEnums.js"; +import { RolloverSchema } from "../../productV2Models/productItemModels/productItemModels.js"; export enum AllowanceType { Fixed = "fixed", @@ -27,6 +28,8 @@ export const EntitlementSchema = z.object({ org_id: z.string().optional(), feature_id: z.string().optional(), usage_limit: z.number().nullable().optional().default(null), + + rollover: RolloverSchema.nullish() }); export const CreateEntitlementSchema = z.object({ @@ -39,6 +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(), }); export type CreateEntitlement = z.infer; diff --git a/shared/models/productModels/entModels/entTable.ts b/shared/models/productModels/entModels/entTable.ts index 5ad8dd2fc..2b9bf9f52 100644 --- a/shared/models/productModels/entModels/entTable.ts +++ b/shared/models/productModels/entModels/entTable.ts @@ -6,6 +6,7 @@ import { unique, text, index, + jsonb, } from "drizzle-orm/pg-core"; import { features } from "../../featureModels/featureTable.js"; @@ -13,6 +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"; export const entitlements = pgTable( "entitlements", @@ -34,8 +36,10 @@ export const entitlements = pgTable( org_id: text("org_id"), feature_id: text("feature_id"), usage_limit: numeric({ mode: "number" }), + + rollover: jsonb().$type(), }, - (table) => [ + (table) => [ foreignKey({ columns: [table.internal_feature_id], foreignColumns: [features.internal_id], diff --git a/shared/models/productV2Models/productItemModels/featureItem.ts b/shared/models/productV2Models/productItemModels/featureItem.ts index 8b0de74e7..e1e1ce63a 100644 --- a/shared/models/productV2Models/productItemModels/featureItem.ts +++ b/shared/models/productV2Models/productItemModels/featureItem.ts @@ -13,6 +13,7 @@ export const FeatureItemSchema = ProductItemSchema.pick({ interval: true, entity_feature_id: true, reset_usage_when_enabled: true, + config: true, }).extend({ feature_id: z.string().nonempty(), included_usage: z diff --git a/shared/models/productV2Models/productItemModels/featurePriceItem.ts b/shared/models/productV2Models/productItemModels/featurePriceItem.ts index 9cd67014d..9cc17e273 100644 --- a/shared/models/productV2Models/productItemModels/featurePriceItem.ts +++ b/shared/models/productV2Models/productItemModels/featurePriceItem.ts @@ -18,6 +18,7 @@ export const FeaturePriceItemSchema = ProductItemSchema.pick({ reset_usage_when_enabled: true, usage_limit: true, + config: true, }).extend({ feature_id: z.string().nonempty(), included_usage: z.number().nonnegative().nullish(), diff --git a/shared/models/productV2Models/productItemModels/productItemModels.ts b/shared/models/productV2Models/productItemModels/productItemModels.ts index efd9aa01d..04274f0be 100644 --- a/shared/models/productV2Models/productItemModels/productItemModels.ts +++ b/shared/models/productV2Models/productItemModels/productItemModels.ts @@ -6,87 +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() + 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) + .optional() + .default(OnIncrease.BillImmediately), + on_decrease: z + .nativeEnum(OnDecrease) + .optional() + .default(OnDecrease.ProrateImmediately), + + rollover: RolloverSchema, }); 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(), - // Stored in backend - created_at: z.number().nullish(), - entitlement_id: z.string().nullish(), - price_id: z.string().nullish(), - price_config: z.any().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(), }); 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 From ec48df666d3c1761805215099e6939b7cfc11f6a Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Mon, 21 Jul 2025 17:47:23 +0100 Subject: [PATCH 03/45] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20handle=20updating,?= =?UTF-8?q?=20creating=20and=20inserting=20rollovers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entitlements/EntitlementService.ts | 1 - .../products/entitlements/entitlementUtils.ts | 4 + .../updateProductDetails.ts | 353 +++++++++--------- .../product-items/compareItemUtils.ts | 27 +- .../products/product-items/mapToItem.ts | 20 +- .../productItemUtils/itemToPriceAndEnt.ts | 5 + 6 files changed, 222 insertions(+), 188 deletions(-) diff --git a/server/src/internal/products/entitlements/EntitlementService.ts b/server/src/internal/products/entitlements/EntitlementService.ts index 5b3bce4a7..c95ff0401 100644 --- a/server/src/internal/products/entitlements/EntitlementService.ts +++ b/server/src/internal/products/entitlements/EntitlementService.ts @@ -81,7 +81,6 @@ export class EntitlementService { if (Array.isArray(data) && data.length == 0) return; const updateColumns = buildConflictUpdateColumns(entitlements, ["id"]); - await db .insert(entitlements) .values(data as any) diff --git a/server/src/internal/products/entitlements/entitlementUtils.ts b/server/src/internal/products/entitlements/entitlementUtils.ts index 33e89fe10..d7caa27c8 100644 --- a/server/src/internal/products/entitlements/entitlementUtils.ts +++ b/server/src/internal/products/entitlements/entitlementUtils.ts @@ -112,6 +112,10 @@ export const entsAreSame = (ent1: Entitlement, ent2: Entitlement) => { condition: ent1.usage_limit !== ent2.usage_limit, message: `Usage limit different: ${ent1.usage_limit} !== ${ent2.usage_limit}`, }, + rollover: { + condition: JSON.stringify(ent1.rollover) !== JSON.stringify(ent2.rollover), + message: `Rollover different: ${ent1.rollover} !== ${ent2.rollover}`, + }, }; let entsAreDiff = Object.values(diffs).some((d) => d.condition); diff --git a/server/src/internal/products/handlers/handleUpdateProduct/updateProductDetails.ts b/server/src/internal/products/handlers/handleUpdateProduct/updateProductDetails.ts index 33c433d93..066f4c2f8 100644 --- a/server/src/internal/products/handlers/handleUpdateProduct/updateProductDetails.ts +++ b/server/src/internal/products/handlers/handleUpdateProduct/updateProductDetails.ts @@ -3,214 +3,223 @@ import { CusProductService } from "@/internal/customers/cusProducts/CusProductSe import RecaseError from "@/utils/errorUtils.js"; import { notNullish } from "@/utils/genUtils.js"; import { - AppEnv, - ErrCode, - FullProduct, - Organization, - Product, - ProductItem, - RewardProgram, - UpdateProduct, + AppEnv, + ErrCode, + FullProduct, + Organization, + Product, + ProductItem, + RewardProgram, + UpdateProduct, } from "@autumn/shared"; import { ProductService } from "../../ProductService.js"; import { createStripeCli } from "@/external/stripe/utils.js"; import { priceToFeature } from "../../prices/priceUtils/convertPrice.js"; import { usagePriceToProductName } from "../../prices/priceUtils/usagePriceUtils/convertUsagePrice.js"; import { - isFeaturePriceItem, - isPriceItem, + isFeaturePriceItem, + isPriceItem, } from "../../product-items/productItemUtils/getItemType.js"; import { isFreeProduct } from "../../productUtils.js"; const productDetailsSame = (prod1: Product, prod2: UpdateProduct) => { - if (notNullish(prod2.id) && prod1.id != prod2.id) { - return false; - } + if (notNullish(prod2.id) && prod1.id != prod2.id) { + return false; + } - if (notNullish(prod2.name) && prod1.name != prod2.name) { - return false; - } + if (notNullish(prod2.name) && prod1.name != prod2.name) { + return false; + } - if (notNullish(prod2.group) && prod1.group != prod2.group) { - return false; - } + if (notNullish(prod2.group) && prod1.group != prod2.group) { + return false; + } - if (notNullish(prod2.is_add_on) && prod1.is_add_on != prod2.is_add_on) { - return false; - } + if (notNullish(prod2.is_add_on) && prod1.is_add_on != prod2.is_add_on) { + return false; + } - if (notNullish(prod2.is_default) && prod1.is_default != prod2.is_default) { - return false; - } + if (notNullish(prod2.is_default) && prod1.is_default != prod2.is_default) { + return false; + } - return true; + return true; }; const updateStripeProductNames = async ({ - db, - org, - curProduct, - newName, - logger, + db, + org, + curProduct, + newName, + logger, }: { - db: DrizzleCli; - org: Organization; - curProduct: FullProduct; - newName: string; - logger: any; + db: DrizzleCli; + org: Organization; + curProduct: FullProduct; + newName: string; + logger: any; }) => { - if (!org.stripe_connected) return; - const stripeCli = createStripeCli({ - org, - env: curProduct.env as AppEnv, - }); - let stripeProdId = curProduct.processor?.id; + if (!org.stripe_connected) return; + const stripeCli = createStripeCli({ + org, + env: curProduct.env as AppEnv, + }); + let stripeProdId = curProduct.processor?.id; - if (!stripeProdId) { - return; - } + if (!stripeProdId) { + return; + } - try { - await stripeCli.products.update(stripeProdId, { - name: newName, - }); - } catch (error: any) { - logger.error( - `Error updating product ${curProduct.id} name in Stripe: ${error.message}`, - { - error, - stripeProdId, - newName, - }, - ); - } + try { + await stripeCli.products.update(stripeProdId, { + name: newName, + }); + } catch (error: any) { + logger.error( + `Error updating product ${curProduct.id} name in Stripe: ${error.message}`, + { + error, + stripeProdId, + newName, + } + ); + } - for (const price of curProduct.prices) { - let stripeProdId = price.config?.stripe_product_id; + for (const price of curProduct.prices) { + let stripeProdId = price.config?.stripe_product_id; - if (stripeProdId) { - let name = usagePriceToProductName({ - price, - fullProduct: { - ...curProduct, - name: newName, - }, - }); + if (stripeProdId) { + let name = usagePriceToProductName({ + price, + fullProduct: { + ...curProduct, + name: newName, + }, + }); - try { - await stripeCli.products.update(stripeProdId, { - name, - }); - } catch (error: any) { - logger.error( - `Error updating price ${price.id} name in Stripe: ${error.message}`, - ); - } - } - } + try { + await stripeCli.products.update(stripeProdId, { + name, + }); + } catch (error: any) { + logger.error( + `Error updating price ${price.id} name in Stripe: ${error.message}` + ); + } + } + } }; export const handleUpdateProductDetails = async ({ - db, - newProduct, - curProduct, - items, - org, - rewardPrograms, - logger, + db, + newProduct, + curProduct, + items, + org, + rewardPrograms, + logger, }: { - db: DrizzleCli; - curProduct: FullProduct; - newProduct: UpdateProduct; - items: ProductItem[]; - org: Organization; - rewardPrograms: RewardProgram[]; - logger: any; + db: DrizzleCli; + curProduct: FullProduct; + newProduct: UpdateProduct; + items: ProductItem[]; + org: Organization; + rewardPrograms: RewardProgram[]; + logger: any; }) => { - const customersOnAllVersions = await CusProductService.getByProductId({ - db, - productId: curProduct.id, - orgId: org.id, - env: curProduct.env as AppEnv, - }); + const customersOnAllVersions = await CusProductService.getByProductId({ + db, + productId: curProduct.id, + orgId: org.id, + env: curProduct.env as AppEnv, + }); - if (newProduct.is_default && !org.config.allow_paid_default) { - // 1. Check if there are items - if (items) { - if (items.some((item) => isFeaturePriceItem(item) || isPriceItem(item))) { - throw new RecaseError({ - message: - "Cannot make a product default if it has fixed prices or paid features", - code: ErrCode.InvalidProduct, - statusCode: 400, - }); - } - } else { - if (!isFreeProduct(curProduct.prices)) { - throw new RecaseError({ - message: - "Cannot make a product default if it has fixed prices or paid features", - code: ErrCode.InvalidProduct, - statusCode: 400, - }); - } - } + if (newProduct.is_default && !org.config.allow_paid_default) { + // 1. Check if there are items + if (items) { + if ( + items.some( + (item) => isFeaturePriceItem(item) || isPriceItem(item) + ) + ) { + throw new RecaseError({ + message: + "Cannot make a product default if it has fixed prices or paid features", + code: ErrCode.InvalidProduct, + statusCode: 400, + }); + } + } else { + if (!isFreeProduct(curProduct.prices)) { + throw new RecaseError({ + message: + "Cannot make a product default if it has fixed prices or paid features", + code: ErrCode.InvalidProduct, + statusCode: 400, + }); + } + } - // 2. Check if current product is default - } + // 2. Check if current product is default + } - if (productDetailsSame(curProduct, newProduct)) { - return; - } + if (productDetailsSame(curProduct, newProduct)) { + return; + } - if (newProduct.id !== curProduct.id) { - if (customersOnAllVersions.length > 0) { - throw new RecaseError({ - message: "Cannot change product ID because it has existing customers", - code: ErrCode.ProductHasCustomers, - statusCode: 400, - }); - } + if (newProduct.id !== curProduct.id) { + if (customersOnAllVersions.length > 0) { + throw new RecaseError({ + message: + "Cannot change product ID because it has existing customers", + code: ErrCode.ProductHasCustomers, + statusCode: 400, + }); + } - if (rewardPrograms.length > 0) { - throw new RecaseError({ - message: - "Cannot change product ID because existing reward programs are linked to it", - code: ErrCode.ProductHasRewardPrograms, - statusCode: 400, - }); - } - } + if (rewardPrograms.length > 0) { + throw new RecaseError({ + message: + "Cannot change product ID because existing reward programs are linked to it", + code: ErrCode.ProductHasRewardPrograms, + statusCode: 400, + }); + } + } - // 2. Update product - await ProductService.updateByInternalId({ - db, - internalId: curProduct.internal_id, - update: { - id: newProduct.id, - name: newProduct.name, - group: newProduct.group, - is_add_on: newProduct.is_add_on, - is_default: newProduct.is_default, - }, - }); + // 2. Update product + await ProductService.updateByInternalId({ + db, + internalId: curProduct.internal_id, + update: { + id: newProduct.id, + name: newProduct.name, + group: newProduct.group, + is_add_on: newProduct.is_add_on, + is_default: newProduct.is_default, + config: { + ...curProduct.config, + ...newProduct.config, + }, + }, + }); - // Update product name in Stripe - if (curProduct.name !== newProduct.name && notNullish(newProduct.name)) { - logger.info( - `Updating product (${curProduct.id}) name in Stripe to ${newProduct.name}`, - ); - await updateStripeProductNames({ - db, - org, - curProduct, - newName: newProduct.name!, - logger, - }); - } + // Update product name in Stripe + if (curProduct.name !== newProduct.name && notNullish(newProduct.name)) { + logger.info( + `Updating product (${curProduct.id}) name in Stripe to ${newProduct.name}` + ); + await updateStripeProductNames({ + db, + org, + curProduct, + newName: newProduct.name!, + logger, + }); + } - curProduct.name = newProduct.name || curProduct.name; - curProduct.group = newProduct.group || curProduct.group; - curProduct.is_add_on = newProduct.is_add_on || curProduct.is_add_on; - curProduct.is_default = newProduct.is_default || curProduct.is_default; + curProduct.name = newProduct.name || curProduct.name; + curProduct.group = newProduct.group || curProduct.group; + curProduct.is_add_on = newProduct.is_add_on || curProduct.is_add_on; + curProduct.is_default = newProduct.is_default || curProduct.is_default; }; diff --git a/server/src/internal/products/product-items/compareItemUtils.ts b/server/src/internal/products/product-items/compareItemUtils.ts index 39b7e4a1a..468fcce52 100644 --- a/server/src/internal/products/product-items/compareItemUtils.ts +++ b/server/src/internal/products/product-items/compareItemUtils.ts @@ -67,13 +67,19 @@ export const featureItemsAreSame = ({ item1: FeatureItem; item2: FeatureItem; }) => { - return ( + // Compare config objects (including rollover) + const configsAreSame = JSON.stringify(item1.config) === JSON.stringify(item2.config); + + const same = ( item1.feature_id === item2.feature_id && item1.included_usage == item2.included_usage && item1.interval == item2.interval && item1.entity_feature_id == item2.entity_feature_id && - item1.reset_usage_when_enabled == item2.reset_usage_when_enabled + item1.reset_usage_when_enabled == item2.reset_usage_when_enabled && + configsAreSame ); + + return same; }; export const priceItemsAreSame = ({ @@ -107,6 +113,10 @@ export const featurePriceItemsAreSame = ({ item1.reset_usage_when_enabled == item2.reset_usage_when_enabled, message: `Reset usage when enabled different: ${item1.reset_usage_when_enabled} !== ${item2.reset_usage_when_enabled}`, }, + config: { + condition: JSON.stringify(item1.config) === JSON.stringify(item2.config), + message: `Config different: ${JSON.stringify(item1.config)} !== ${JSON.stringify(item2.config)}`, + }, }; const pricesSame = { @@ -147,17 +157,6 @@ export const featurePriceItemsAreSame = ({ const pricesChanged = Object.values(pricesSame).some((d) => !d.condition); - if (!same) { - console.log( - "Feature price items different:", - Object.values(entsSame) - .filter((d) => !d.condition) - .map((d) => d.message), - Object.values(pricesSame) - .filter((d) => !d.condition) - .map((d) => d.message), - ); - } return { same, @@ -178,6 +177,8 @@ export const itemsAreSame = ({ let same = false; let pricesChanged = false; + + if (isFeatureItem(item1)) { if (!isFeatureItem(item2)) { return { diff --git a/server/src/internal/products/product-items/mapToItem.ts b/server/src/internal/products/product-items/mapToItem.ts index 95bcca4d3..9f439e8b7 100644 --- a/server/src/internal/products/product-items/mapToItem.ts +++ b/server/src/internal/products/product-items/mapToItem.ts @@ -41,7 +41,9 @@ export const toFeatureItem = ({ ent }: { ent: EntitlementWithFeature }) => { }; } - return { + const itemConfig = ent.rollover ? { rollover: ent.rollover } : undefined; + + const item = { feature_id: ent.feature.id, included_usage: ent.allowance_type == AllowanceType.Unlimited ? Infinite : ent.allowance, @@ -50,10 +52,15 @@ export const toFeatureItem = ({ ent }: { ent: EntitlementWithFeature }) => { entity_feature_id: ent.entity_feature_id, reset_usage_when_enabled: !ent.carry_from_previous, + // Include rollover config + config: itemConfig, + // Stored in backend entitlement_id: ent.id, created_at: ent.created_at, }; + + return item; }; export const toFeaturePriceItem = ({ @@ -71,6 +78,15 @@ export const toFeaturePriceItem = ({ }; }); + // Build the item config from both price proration config and entitlement rollover + let itemConfig: any = {}; + if (price.proration_config) { + itemConfig = { ...price.proration_config }; + } + if (ent.rollover) { + itemConfig.rollover = ent.rollover; + } + let item: ProductItem = { feature_id: ent.feature.id, feature_type: @@ -98,7 +114,7 @@ export const toFeaturePriceItem = ({ price_id: price.id, price_config: price.config, - config: price.proration_config || undefined, + config: Object.keys(itemConfig).length > 0 ? itemConfig : undefined, usage_limit: ent.usage_limit, }; diff --git a/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts b/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts index f945657af..4142da45b 100644 --- a/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts +++ b/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts @@ -90,6 +90,7 @@ export const toFeature = ({ newVersion?: boolean; feature?: Feature; }) => { + console.log("item toFeature", item); let isBoolean = feature?.type == FeatureType.Boolean; let resetUsage = item.reset_usage_when_enabled || false; @@ -116,6 +117,8 @@ export const toFeature = ({ carry_from_previous: !resetUsage, entity_feature_id: item.entity_feature_id, usage_limit: null, + + rollover: item.config?.rollover, }; if (isCustom || newVersion) { @@ -168,6 +171,8 @@ export const toFeatureAndPrice = ({ carry_from_previous: !resetUsage, entity_feature_id: item.entity_feature_id, usage_limit: item.usage_limit || null, + + rollover: item.config?.rollover, }; // Will only create new ent id if From 644ccb86930c653eb3266345bc1c4e94422e522f Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Mon, 21 Jul 2025 18:14:46 +0100 Subject: [PATCH 04/45] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20rollovers=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/db/schema.ts | 4 ++- .../rolloverModels/rolloverTable.ts | 28 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 shared/models/productModels/rolloverModels/rolloverTable.ts diff --git a/shared/db/schema.ts b/shared/db/schema.ts index b5fb36227..a765beb29 100644 --- a/shared/db/schema.ts +++ b/shared/db/schema.ts @@ -12,6 +12,7 @@ import { products } from "../models/productModels/productTable.js"; import { prices } from "../models/productModels/priceModels/priceTable.js"; import { entitlements } from "../models/productModels/entModels/entTable.js"; import { freeTrials } from "../models/productModels/freeTrialModels/freeTrialTable.js"; +import { rollovers } from "../models/productModels/rolloverModels/rolloverTable.js"; // CusProduct Tables import { customerProducts } from "../models/cusProductModels/cusProductTable.js"; @@ -106,7 +107,8 @@ export { actions, events, replaceables, - + rollovers, + // Auth user, session, diff --git a/shared/models/productModels/rolloverModels/rolloverTable.ts b/shared/models/productModels/rolloverModels/rolloverTable.ts new file mode 100644 index 000000000..14cfabbcd --- /dev/null +++ b/shared/models/productModels/rolloverModels/rolloverTable.ts @@ -0,0 +1,28 @@ +import { + foreignKey, + pgTable, + numeric, + jsonb, + text, + integer, +} from "drizzle-orm/pg-core"; + +import { entitlements } from "../entModels/entTable.js"; +import { EntityBalance } from "../../cusProductModels/cusEntModels/cusEntModels.js"; + +export const rollovers = pgTable( + "rollovers", + { + cus_ent_id: text("cus_ent_id").notNull(), + balance: numeric({ mode: "number" }).notNull(), + expires_at: integer("timestamp").notNull(), + entities: jsonb("entities").$type(), + }, + (table) => [ + foreignKey({ + columns: [table.cus_ent_id], + foreignColumns: [entitlements.id], + name: "rollover_cus_ent_id_fkey", + }), + ] +).enableRLS(); From 5685b47c8ddad14e8a81fd377b9fd673488956fc Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Mon, 21 Jul 2025 17:46:26 +0100 Subject: [PATCH 05/45] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20ui=20for=20rollove?= =?UTF-8?q?rs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../products/product/hooks/useProductData.tsx | 1 + .../product-item/ProductItemConfig.tsx | 18 +- .../product-item/UpdateProductItem.tsx | 4 +- .../advanced-config/AdvancedItemConfig.tsx | 247 ++++++++++++------ 4 files changed, 180 insertions(+), 90 deletions(-) diff --git a/vite/src/views/products/product/hooks/useProductData.tsx b/vite/src/views/products/product/hooks/useProductData.tsx index eea9ffc28..ff0b4887e 100644 --- a/vite/src/views/products/product/hooks/useProductData.tsx +++ b/vite/src/views/products/product/hooks/useProductData.tsx @@ -27,6 +27,7 @@ export const useProductData = ({ data }: { data: any }) => { ...data.product, items: sortProductItems(data.product.items), }; + initialProductRef.current = structuredClone(sortedProduct); setEntityFeatureIds(initEntityFeatureIds(sortedProduct)); setProduct(sortedProduct); diff --git a/vite/src/views/products/product/product-item/ProductItemConfig.tsx b/vite/src/views/products/product/product-item/ProductItemConfig.tsx index 4c88deeeb..c86894644 100644 --- a/vite/src/views/products/product/product-item/ProductItemConfig.tsx +++ b/vite/src/views/products/product/product-item/ProductItemConfig.tsx @@ -63,13 +63,21 @@ export const ProductItemConfig = () => { reset_usage_when_enabled: resetUsageWhenEnabled, }; - const newConfig = !showProration ? undefined : item.config; + // Only manage proration config if this item should show proration + if (showProration) { + // Preserve existing config and manage proration parts + const existingConfig = item.config || {}; + const newConfig = { + ...existingConfig, + on_increase: existingConfig.on_increase, + on_decrease: existingConfig.on_decrease, + }; - if (newConfig) { - newItem.config = newConfig; - } else { - delete newItem.config; + if (Object.keys(newConfig).length > 0) { + newItem.config = newConfig; + } } + // If showProration is false, don't touch the config at all - preserve whatever is there setItem(newItem); } diff --git a/vite/src/views/products/product/product-item/UpdateProductItem.tsx b/vite/src/views/products/product/product-item/UpdateProductItem.tsx index 5a13461b0..ebbc02854 100644 --- a/vite/src/views/products/product/product-item/UpdateProductItem.tsx +++ b/vite/src/views/products/product/product-item/UpdateProductItem.tsx @@ -27,6 +27,8 @@ export default function UpdateProductItem({ const { product, setProduct, features } = useProductContext(); const [showCreateFeature, setShowCreateFeature] = useState(false); + + const handleUpdateProductItem = () => { console.log("Selected Item: ", selectedItem); const validatedItem = validateProductItem({ @@ -83,4 +85,4 @@ export default function UpdateProductItem({ ); -} +}; 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 ce0643331..bc8d8c302 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,99 +7,178 @@ import { OnDecreaseSelect } from "./proration-config/OnDecreaseSelect"; import { OnIncreaseSelect } from "./proration-config/OnIncreaseSelect"; import { shouldShowProrationConfig } from "@/utils/product/productItemUtils"; import { - getFeature, - getFeatureUsageType, + getFeature, + getFeatureUsageType, } from "@/utils/product/entitlementUtils"; -import { FeatureUsageType } from "@autumn/shared"; +import { FeatureUsageType, ProductItemInterval } from "@autumn/shared"; import { Input } from "@/components/ui/input"; export const AdvancedItemConfig = () => { - const { features } = useProductContext(); - const { item, setItem } = useProductItemContext(); - const [isOpen, setIsOpen] = useState(item.usage_limit != null); + const { features } = useProductContext(); + const { item, setItem } = useProductItemContext(); + console.log("item", item); + const [isOpen, setIsOpen] = useState(item.usage_limit != null); - const showProrationConfig = shouldShowProrationConfig({ item, features }); - const usageType = getFeatureUsageType({ item, features }); + const showProrationConfig = shouldShowProrationConfig({ item, features }); + const usageType = getFeatureUsageType({ item, features }); - return ( -
- + return ( +
+ -
-
- { - 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} - /> +
+
+ { + 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} + /> -
- { - 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" - /> +
+ { + 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" - /> - )} -
+ {item.usage_limit != null && ( + { + setItem({ + ...item, + usage_limit: parseInt(e.target.value), + }); + }} + placeholder="eg. 100" + /> + )} +
- {showProrationConfig && ( - <> - - - - )} - {/*
+ {showProrationConfig && ( + <> + + + + )} + {/*
*/} -
-
-
- ); + +
+ { + if (item.config?.rollover != null) { + setItem({ + ...item, + config: { + ...item.config, + rollover: null, + }, + }); + } else { + setItem({ + ...item, + config: { + ...item.config, + 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" + /> + + {item.config?.rollover != null && ( +
+ { + setItem({ + ...item, + config: { + ...item.config, + rollover: { + ...item.config!.rollover!, + max: parseInt(e.target.value) + }, + }, + }); + }} + /> + + + { + setItem({ + ...item, + config: { + ...item.config, + rollover: { + ...item.config!.rollover!, + length: parseInt(e.target.value) + }, + }, + }); + }} + className="ml-0 w-full" + endContent={ + <> +

month(s)

+ + } + /> +
+ )} +
+
+
+
+ ); }; From 68a93aa8c5dc4811faf4ff49f6cef249b2095c17 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Mon, 21 Jul 2025 17:46:56 +0100 Subject: [PATCH 06/45] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20add=20item.config?= =?UTF-8?q?=20.rollover=20to=20db=20models?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../productModels/entModels/entModels.ts | 4 + .../productModels/entModels/entTable.ts | 6 +- .../productItemModels/featureItem.ts | 1 + .../productItemModels/featurePriceItem.ts | 1 + .../productItemModels/productItemModels.ts | 116 ++++++++++-------- 5 files changed, 76 insertions(+), 52 deletions(-) diff --git a/shared/models/productModels/entModels/entModels.ts b/shared/models/productModels/entModels/entModels.ts index 36c10d3b1..c8570bcd6 100644 --- a/shared/models/productModels/entModels/entModels.ts +++ b/shared/models/productModels/entModels/entModels.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { FeatureSchema } from "../../featureModels/featureModels.js"; import { EntInterval } from "./entEnums.js"; +import { RolloverSchema } from "../../productV2Models/productItemModels/productItemModels.js"; export enum AllowanceType { Fixed = "fixed", @@ -27,6 +28,8 @@ export const EntitlementSchema = z.object({ org_id: z.string().optional(), feature_id: z.string().optional(), usage_limit: z.number().nullable().optional().default(null), + + rollover: RolloverSchema.nullish() }); export const CreateEntitlementSchema = z.object({ @@ -39,6 +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(), }); export type CreateEntitlement = z.infer; diff --git a/shared/models/productModels/entModels/entTable.ts b/shared/models/productModels/entModels/entTable.ts index 5ad8dd2fc..2b9bf9f52 100644 --- a/shared/models/productModels/entModels/entTable.ts +++ b/shared/models/productModels/entModels/entTable.ts @@ -6,6 +6,7 @@ import { unique, text, index, + jsonb, } from "drizzle-orm/pg-core"; import { features } from "../../featureModels/featureTable.js"; @@ -13,6 +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"; export const entitlements = pgTable( "entitlements", @@ -34,8 +36,10 @@ export const entitlements = pgTable( org_id: text("org_id"), feature_id: text("feature_id"), usage_limit: numeric({ mode: "number" }), + + rollover: jsonb().$type(), }, - (table) => [ + (table) => [ foreignKey({ columns: [table.internal_feature_id], foreignColumns: [features.internal_id], diff --git a/shared/models/productV2Models/productItemModels/featureItem.ts b/shared/models/productV2Models/productItemModels/featureItem.ts index 82d40796f..3ce838cba 100644 --- a/shared/models/productV2Models/productItemModels/featureItem.ts +++ b/shared/models/productV2Models/productItemModels/featureItem.ts @@ -9,6 +9,7 @@ export const FeatureItemSchema = ProductItemSchema.pick({ interval: true, entity_feature_id: true, reset_usage_when_enabled: true, + config: true, }).extend({ feature_id: z.string().nonempty(), included_usage: z diff --git a/shared/models/productV2Models/productItemModels/featurePriceItem.ts b/shared/models/productV2Models/productItemModels/featurePriceItem.ts index a54b92035..63eb4a780 100644 --- a/shared/models/productV2Models/productItemModels/featurePriceItem.ts +++ b/shared/models/productV2Models/productItemModels/featurePriceItem.ts @@ -14,6 +14,7 @@ export const FeaturePriceItemSchema = ProductItemSchema.pick({ reset_usage_when_enabled: true, usage_limit: true, + config: true, }).extend({ feature_id: z.string().nonempty(), included_usage: z.number().nonnegative().nullish(), diff --git a/shared/models/productV2Models/productItemModels/productItemModels.ts b/shared/models/productV2Models/productItemModels/productItemModels.ts index efd9aa01d..04274f0be 100644 --- a/shared/models/productV2Models/productItemModels/productItemModels.ts +++ b/shared/models/productV2Models/productItemModels/productItemModels.ts @@ -6,87 +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() + 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) + .optional() + .default(OnIncrease.BillImmediately), + on_decrease: z + .nativeEnum(OnDecrease) + .optional() + .default(OnDecrease.ProrateImmediately), + + rollover: RolloverSchema, }); 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(), - // Stored in backend - created_at: z.number().nullish(), - entitlement_id: z.string().nullish(), - price_id: z.string().nullish(), - price_config: z.any().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(), }); 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 From 2eda605de707e31bdade2755e69b821aae2d72c6 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Mon, 21 Jul 2025 17:47:23 +0100 Subject: [PATCH 07/45] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20handle=20updating,?= =?UTF-8?q?=20creating=20and=20inserting=20rollovers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entitlements/EntitlementService.ts | 1 - .../products/entitlements/entitlementUtils.ts | 4 + .../updateProductDetails.ts | 353 +++++++++--------- .../product-items/compareItemUtils.ts | 19 +- .../products/product-items/mapToItem.ts | 20 +- .../productItemUtils/itemToPriceAndEnt.ts | 5 + 6 files changed, 221 insertions(+), 181 deletions(-) diff --git a/server/src/internal/products/entitlements/EntitlementService.ts b/server/src/internal/products/entitlements/EntitlementService.ts index 5b3bce4a7..c95ff0401 100644 --- a/server/src/internal/products/entitlements/EntitlementService.ts +++ b/server/src/internal/products/entitlements/EntitlementService.ts @@ -81,7 +81,6 @@ export class EntitlementService { if (Array.isArray(data) && data.length == 0) return; const updateColumns = buildConflictUpdateColumns(entitlements, ["id"]); - await db .insert(entitlements) .values(data as any) diff --git a/server/src/internal/products/entitlements/entitlementUtils.ts b/server/src/internal/products/entitlements/entitlementUtils.ts index 33e89fe10..d7caa27c8 100644 --- a/server/src/internal/products/entitlements/entitlementUtils.ts +++ b/server/src/internal/products/entitlements/entitlementUtils.ts @@ -112,6 +112,10 @@ export const entsAreSame = (ent1: Entitlement, ent2: Entitlement) => { condition: ent1.usage_limit !== ent2.usage_limit, message: `Usage limit different: ${ent1.usage_limit} !== ${ent2.usage_limit}`, }, + rollover: { + condition: JSON.stringify(ent1.rollover) !== JSON.stringify(ent2.rollover), + message: `Rollover different: ${ent1.rollover} !== ${ent2.rollover}`, + }, }; let entsAreDiff = Object.values(diffs).some((d) => d.condition); diff --git a/server/src/internal/products/handlers/handleUpdateProduct/updateProductDetails.ts b/server/src/internal/products/handlers/handleUpdateProduct/updateProductDetails.ts index 33c433d93..066f4c2f8 100644 --- a/server/src/internal/products/handlers/handleUpdateProduct/updateProductDetails.ts +++ b/server/src/internal/products/handlers/handleUpdateProduct/updateProductDetails.ts @@ -3,214 +3,223 @@ import { CusProductService } from "@/internal/customers/cusProducts/CusProductSe import RecaseError from "@/utils/errorUtils.js"; import { notNullish } from "@/utils/genUtils.js"; import { - AppEnv, - ErrCode, - FullProduct, - Organization, - Product, - ProductItem, - RewardProgram, - UpdateProduct, + AppEnv, + ErrCode, + FullProduct, + Organization, + Product, + ProductItem, + RewardProgram, + UpdateProduct, } from "@autumn/shared"; import { ProductService } from "../../ProductService.js"; import { createStripeCli } from "@/external/stripe/utils.js"; import { priceToFeature } from "../../prices/priceUtils/convertPrice.js"; import { usagePriceToProductName } from "../../prices/priceUtils/usagePriceUtils/convertUsagePrice.js"; import { - isFeaturePriceItem, - isPriceItem, + isFeaturePriceItem, + isPriceItem, } from "../../product-items/productItemUtils/getItemType.js"; import { isFreeProduct } from "../../productUtils.js"; const productDetailsSame = (prod1: Product, prod2: UpdateProduct) => { - if (notNullish(prod2.id) && prod1.id != prod2.id) { - return false; - } + if (notNullish(prod2.id) && prod1.id != prod2.id) { + return false; + } - if (notNullish(prod2.name) && prod1.name != prod2.name) { - return false; - } + if (notNullish(prod2.name) && prod1.name != prod2.name) { + return false; + } - if (notNullish(prod2.group) && prod1.group != prod2.group) { - return false; - } + if (notNullish(prod2.group) && prod1.group != prod2.group) { + return false; + } - if (notNullish(prod2.is_add_on) && prod1.is_add_on != prod2.is_add_on) { - return false; - } + if (notNullish(prod2.is_add_on) && prod1.is_add_on != prod2.is_add_on) { + return false; + } - if (notNullish(prod2.is_default) && prod1.is_default != prod2.is_default) { - return false; - } + if (notNullish(prod2.is_default) && prod1.is_default != prod2.is_default) { + return false; + } - return true; + return true; }; const updateStripeProductNames = async ({ - db, - org, - curProduct, - newName, - logger, + db, + org, + curProduct, + newName, + logger, }: { - db: DrizzleCli; - org: Organization; - curProduct: FullProduct; - newName: string; - logger: any; + db: DrizzleCli; + org: Organization; + curProduct: FullProduct; + newName: string; + logger: any; }) => { - if (!org.stripe_connected) return; - const stripeCli = createStripeCli({ - org, - env: curProduct.env as AppEnv, - }); - let stripeProdId = curProduct.processor?.id; + if (!org.stripe_connected) return; + const stripeCli = createStripeCli({ + org, + env: curProduct.env as AppEnv, + }); + let stripeProdId = curProduct.processor?.id; - if (!stripeProdId) { - return; - } + if (!stripeProdId) { + return; + } - try { - await stripeCli.products.update(stripeProdId, { - name: newName, - }); - } catch (error: any) { - logger.error( - `Error updating product ${curProduct.id} name in Stripe: ${error.message}`, - { - error, - stripeProdId, - newName, - }, - ); - } + try { + await stripeCli.products.update(stripeProdId, { + name: newName, + }); + } catch (error: any) { + logger.error( + `Error updating product ${curProduct.id} name in Stripe: ${error.message}`, + { + error, + stripeProdId, + newName, + } + ); + } - for (const price of curProduct.prices) { - let stripeProdId = price.config?.stripe_product_id; + for (const price of curProduct.prices) { + let stripeProdId = price.config?.stripe_product_id; - if (stripeProdId) { - let name = usagePriceToProductName({ - price, - fullProduct: { - ...curProduct, - name: newName, - }, - }); + if (stripeProdId) { + let name = usagePriceToProductName({ + price, + fullProduct: { + ...curProduct, + name: newName, + }, + }); - try { - await stripeCli.products.update(stripeProdId, { - name, - }); - } catch (error: any) { - logger.error( - `Error updating price ${price.id} name in Stripe: ${error.message}`, - ); - } - } - } + try { + await stripeCli.products.update(stripeProdId, { + name, + }); + } catch (error: any) { + logger.error( + `Error updating price ${price.id} name in Stripe: ${error.message}` + ); + } + } + } }; export const handleUpdateProductDetails = async ({ - db, - newProduct, - curProduct, - items, - org, - rewardPrograms, - logger, + db, + newProduct, + curProduct, + items, + org, + rewardPrograms, + logger, }: { - db: DrizzleCli; - curProduct: FullProduct; - newProduct: UpdateProduct; - items: ProductItem[]; - org: Organization; - rewardPrograms: RewardProgram[]; - logger: any; + db: DrizzleCli; + curProduct: FullProduct; + newProduct: UpdateProduct; + items: ProductItem[]; + org: Organization; + rewardPrograms: RewardProgram[]; + logger: any; }) => { - const customersOnAllVersions = await CusProductService.getByProductId({ - db, - productId: curProduct.id, - orgId: org.id, - env: curProduct.env as AppEnv, - }); + const customersOnAllVersions = await CusProductService.getByProductId({ + db, + productId: curProduct.id, + orgId: org.id, + env: curProduct.env as AppEnv, + }); - if (newProduct.is_default && !org.config.allow_paid_default) { - // 1. Check if there are items - if (items) { - if (items.some((item) => isFeaturePriceItem(item) || isPriceItem(item))) { - throw new RecaseError({ - message: - "Cannot make a product default if it has fixed prices or paid features", - code: ErrCode.InvalidProduct, - statusCode: 400, - }); - } - } else { - if (!isFreeProduct(curProduct.prices)) { - throw new RecaseError({ - message: - "Cannot make a product default if it has fixed prices or paid features", - code: ErrCode.InvalidProduct, - statusCode: 400, - }); - } - } + if (newProduct.is_default && !org.config.allow_paid_default) { + // 1. Check if there are items + if (items) { + if ( + items.some( + (item) => isFeaturePriceItem(item) || isPriceItem(item) + ) + ) { + throw new RecaseError({ + message: + "Cannot make a product default if it has fixed prices or paid features", + code: ErrCode.InvalidProduct, + statusCode: 400, + }); + } + } else { + if (!isFreeProduct(curProduct.prices)) { + throw new RecaseError({ + message: + "Cannot make a product default if it has fixed prices or paid features", + code: ErrCode.InvalidProduct, + statusCode: 400, + }); + } + } - // 2. Check if current product is default - } + // 2. Check if current product is default + } - if (productDetailsSame(curProduct, newProduct)) { - return; - } + if (productDetailsSame(curProduct, newProduct)) { + return; + } - if (newProduct.id !== curProduct.id) { - if (customersOnAllVersions.length > 0) { - throw new RecaseError({ - message: "Cannot change product ID because it has existing customers", - code: ErrCode.ProductHasCustomers, - statusCode: 400, - }); - } + if (newProduct.id !== curProduct.id) { + if (customersOnAllVersions.length > 0) { + throw new RecaseError({ + message: + "Cannot change product ID because it has existing customers", + code: ErrCode.ProductHasCustomers, + statusCode: 400, + }); + } - if (rewardPrograms.length > 0) { - throw new RecaseError({ - message: - "Cannot change product ID because existing reward programs are linked to it", - code: ErrCode.ProductHasRewardPrograms, - statusCode: 400, - }); - } - } + if (rewardPrograms.length > 0) { + throw new RecaseError({ + message: + "Cannot change product ID because existing reward programs are linked to it", + code: ErrCode.ProductHasRewardPrograms, + statusCode: 400, + }); + } + } - // 2. Update product - await ProductService.updateByInternalId({ - db, - internalId: curProduct.internal_id, - update: { - id: newProduct.id, - name: newProduct.name, - group: newProduct.group, - is_add_on: newProduct.is_add_on, - is_default: newProduct.is_default, - }, - }); + // 2. Update product + await ProductService.updateByInternalId({ + db, + internalId: curProduct.internal_id, + update: { + id: newProduct.id, + name: newProduct.name, + group: newProduct.group, + is_add_on: newProduct.is_add_on, + is_default: newProduct.is_default, + config: { + ...curProduct.config, + ...newProduct.config, + }, + }, + }); - // Update product name in Stripe - if (curProduct.name !== newProduct.name && notNullish(newProduct.name)) { - logger.info( - `Updating product (${curProduct.id}) name in Stripe to ${newProduct.name}`, - ); - await updateStripeProductNames({ - db, - org, - curProduct, - newName: newProduct.name!, - logger, - }); - } + // Update product name in Stripe + if (curProduct.name !== newProduct.name && notNullish(newProduct.name)) { + logger.info( + `Updating product (${curProduct.id}) name in Stripe to ${newProduct.name}` + ); + await updateStripeProductNames({ + db, + org, + curProduct, + newName: newProduct.name!, + logger, + }); + } - curProduct.name = newProduct.name || curProduct.name; - curProduct.group = newProduct.group || curProduct.group; - curProduct.is_add_on = newProduct.is_add_on || curProduct.is_add_on; - curProduct.is_default = newProduct.is_default || curProduct.is_default; + curProduct.name = newProduct.name || curProduct.name; + curProduct.group = newProduct.group || curProduct.group; + curProduct.is_add_on = newProduct.is_add_on || curProduct.is_add_on; + curProduct.is_default = newProduct.is_default || curProduct.is_default; }; diff --git a/server/src/internal/products/product-items/compareItemUtils.ts b/server/src/internal/products/product-items/compareItemUtils.ts index 0ec162bcf..c2c8129f3 100644 --- a/server/src/internal/products/product-items/compareItemUtils.ts +++ b/server/src/internal/products/product-items/compareItemUtils.ts @@ -67,16 +67,17 @@ export const featureItemsAreSame = ({ item1: FeatureItem; item2: FeatureItem; }) => { - const same = + // Compare config objects (including rollover) + const configsAreSame = JSON.stringify(item1.config) === JSON.stringify(item2.config); + + const same = ( item1.feature_id === item2.feature_id && item1.included_usage == item2.included_usage && item1.interval == item2.interval && item1.entity_feature_id == item2.entity_feature_id && - item1.reset_usage_when_enabled == item2.reset_usage_when_enabled; - - if (!same) { - console.log(`Feature items different: ${item1.feature_id}`); - } + item1.reset_usage_when_enabled == item2.reset_usage_when_enabled && + configsAreSame + ); return same; }; @@ -118,6 +119,10 @@ export const featurePriceItemsAreSame = ({ item1.reset_usage_when_enabled == item2.reset_usage_when_enabled, message: `Reset usage when enabled different: ${item1.reset_usage_when_enabled} !== ${item2.reset_usage_when_enabled}`, }, + config: { + condition: JSON.stringify(item1.config) === JSON.stringify(item2.config), + message: `Config different: ${JSON.stringify(item1.config)} !== ${JSON.stringify(item2.config)}`, + }, }; const pricesSame = { @@ -189,6 +194,8 @@ export const itemsAreSame = ({ let same = false; let pricesChanged = false; + + if (isFeatureItem(item1)) { if (!isFeatureItem(item2)) { return { diff --git a/server/src/internal/products/product-items/mapToItem.ts b/server/src/internal/products/product-items/mapToItem.ts index 95bcca4d3..9f439e8b7 100644 --- a/server/src/internal/products/product-items/mapToItem.ts +++ b/server/src/internal/products/product-items/mapToItem.ts @@ -41,7 +41,9 @@ export const toFeatureItem = ({ ent }: { ent: EntitlementWithFeature }) => { }; } - return { + const itemConfig = ent.rollover ? { rollover: ent.rollover } : undefined; + + const item = { feature_id: ent.feature.id, included_usage: ent.allowance_type == AllowanceType.Unlimited ? Infinite : ent.allowance, @@ -50,10 +52,15 @@ export const toFeatureItem = ({ ent }: { ent: EntitlementWithFeature }) => { entity_feature_id: ent.entity_feature_id, reset_usage_when_enabled: !ent.carry_from_previous, + // Include rollover config + config: itemConfig, + // Stored in backend entitlement_id: ent.id, created_at: ent.created_at, }; + + return item; }; export const toFeaturePriceItem = ({ @@ -71,6 +78,15 @@ export const toFeaturePriceItem = ({ }; }); + // Build the item config from both price proration config and entitlement rollover + let itemConfig: any = {}; + if (price.proration_config) { + itemConfig = { ...price.proration_config }; + } + if (ent.rollover) { + itemConfig.rollover = ent.rollover; + } + let item: ProductItem = { feature_id: ent.feature.id, feature_type: @@ -98,7 +114,7 @@ export const toFeaturePriceItem = ({ price_id: price.id, price_config: price.config, - config: price.proration_config || undefined, + config: Object.keys(itemConfig).length > 0 ? itemConfig : undefined, usage_limit: ent.usage_limit, }; diff --git a/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts b/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts index aa78e5e3f..dde40c9fd 100644 --- a/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts +++ b/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts @@ -115,6 +115,7 @@ export const toFeature = ({ newVersion?: boolean; feature?: Feature; }) => { + console.log("item toFeature", item); let isBoolean = feature?.type == FeatureType.Boolean; let resetUsage = getResetUsage({ item, feature }); @@ -141,6 +142,8 @@ export const toFeature = ({ carry_from_previous: !resetUsage, entity_feature_id: item.entity_feature_id, usage_limit: null, + + rollover: item.config?.rollover, }; if (isCustom || newVersion) { @@ -196,6 +199,8 @@ export const toFeatureAndPrice = ({ carry_from_previous: !resetUsage, entity_feature_id: item.entity_feature_id, usage_limit: item.usage_limit || null, + + rollover: item.config?.rollover, }; // Will only create new ent id if From cc912d79eb16e97038a8185c7e70feedb19bbd2b Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Mon, 21 Jul 2025 18:14:46 +0100 Subject: [PATCH 08/45] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20rollovers=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/db/schema.ts | 4 ++- .../rolloverModels/rolloverTable.ts | 28 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 shared/models/productModels/rolloverModels/rolloverTable.ts diff --git a/shared/db/schema.ts b/shared/db/schema.ts index b5fb36227..a765beb29 100644 --- a/shared/db/schema.ts +++ b/shared/db/schema.ts @@ -12,6 +12,7 @@ import { products } from "../models/productModels/productTable.js"; import { prices } from "../models/productModels/priceModels/priceTable.js"; import { entitlements } from "../models/productModels/entModels/entTable.js"; import { freeTrials } from "../models/productModels/freeTrialModels/freeTrialTable.js"; +import { rollovers } from "../models/productModels/rolloverModels/rolloverTable.js"; // CusProduct Tables import { customerProducts } from "../models/cusProductModels/cusProductTable.js"; @@ -106,7 +107,8 @@ export { actions, events, replaceables, - + rollovers, + // Auth user, session, diff --git a/shared/models/productModels/rolloverModels/rolloverTable.ts b/shared/models/productModels/rolloverModels/rolloverTable.ts new file mode 100644 index 000000000..14cfabbcd --- /dev/null +++ b/shared/models/productModels/rolloverModels/rolloverTable.ts @@ -0,0 +1,28 @@ +import { + foreignKey, + pgTable, + numeric, + jsonb, + text, + integer, +} from "drizzle-orm/pg-core"; + +import { entitlements } from "../entModels/entTable.js"; +import { EntityBalance } from "../../cusProductModels/cusEntModels/cusEntModels.js"; + +export const rollovers = pgTable( + "rollovers", + { + cus_ent_id: text("cus_ent_id").notNull(), + balance: numeric({ mode: "number" }).notNull(), + expires_at: integer("timestamp").notNull(), + entities: jsonb("entities").$type(), + }, + (table) => [ + foreignKey({ + columns: [table.cus_ent_id], + foreignColumns: [entitlements.id], + name: "rollover_cus_ent_id_fkey", + }), + ] +).enableRLS(); From b67d4851a72a5330d2d53941a80783a3fe311e3a Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Tue, 22 Jul 2025 12:19:43 +0100 Subject: [PATCH 09/45] =?UTF-8?q?fix:=20=F0=9F=90=9B=20weird=20bun=20not?= =?UTF-8?q?=20found=20errors=20on=20windows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/package.json b/shared/package.json index 2ff3ce839..3b537384c 100644 --- a/shared/package.json +++ b/shared/package.json @@ -14,7 +14,7 @@ "license": "Apache-2.0", "scripts": { "build": "bun build ./index.ts --outdir dist --target bun", - "dev": "bunx nodemon --ext ts --ignore dist --exec \"bun run build\"", + "dev": "bunx nodemon --ext ts --ignore dist --exec bun run build", "db:push": "cross-env NODE_OPTIONS=\"--import tsx\" pnpm exec drizzle-kit push --config drizzle.config.ts", "db:generate": "cross-env NODE_OPTIONS=\"--import tsx\" pnpm exec drizzle-kit generate --config drizzle.config.ts", From 6179bc32374f5a0536053e09761738c19e1b4fcf Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Tue, 22 Jul 2025 12:19:52 +0100 Subject: [PATCH 10/45] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20warning=20toast=20?= =?UTF-8?q?styles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vite/src/components/general/CustomToaster.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/vite/src/components/general/CustomToaster.tsx b/vite/src/components/general/CustomToaster.tsx index 0f88bc060..d266a3abc 100644 --- a/vite/src/components/general/CustomToaster.tsx +++ b/vite/src/components/general/CustomToaster.tsx @@ -13,6 +13,8 @@ export const CustomToaster = () => { gap-2 bg-white/70 backdrop-blur-sm border border-red-400 rounded-sm p-2 text-sm shadow-md`, success: `w-[350px] text-green-600 flex items-start gap-2 bg-white/90 backdrop-blur-sm border border-green-500 rounded-sm p-2 text-sm shadow-md`, + warning: `w-[350px] text-yellow-600 flex items-start + gap-2 bg-white/90 backdrop-blur-sm border border-yellow-500 rounded-sm p-2 text-sm shadow-md`, }, }} /> From 45ac5d3c63d803ad9e9b9419fbadecb0c766ff1c Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Tue, 22 Jul 2025 12:20:17 +0100 Subject: [PATCH 11/45] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20validate=20item=20?= =?UTF-8?q?is=20able=20to=20be=20configured=20for=20rollovers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vite/src/utils/product/entitlementUtils.ts | 41 ++-- .../product-item/validateProductItem.ts | 44 ++++- .../advanced-config/AdvancedItemConfig.tsx | 182 ++++++++++-------- 3 files changed, 172 insertions(+), 95 deletions(-) diff --git a/vite/src/utils/product/entitlementUtils.ts b/vite/src/utils/product/entitlementUtils.ts index 29dcb0523..21d9d5bb1 100644 --- a/vite/src/utils/product/entitlementUtils.ts +++ b/vite/src/utils/product/entitlementUtils.ts @@ -1,24 +1,37 @@ -import { Feature, ProductItem } from "@autumn/shared"; +import { Feature, FeatureType, ProductItem } from "@autumn/shared"; export const getFeature = ( - featureId: string | undefined, - features: Feature[], + featureId: string | undefined, + features: Feature[] ) => { - const foundFeature = features?.find( - (feature: Feature) => feature.id === featureId, - ); - return foundFeature || null; + const foundFeature = features?.find( + (feature: Feature) => feature.id === featureId + ); + return foundFeature || null; }; export const getFeatureUsageType = ({ - item, - features, + item, + features, }: { - item: ProductItem; - features: Feature[]; + item: ProductItem; + features: Feature[]; }) => { - if (!item.feature_id) return null; - const feature = getFeature(item.feature_id, features); + if (!item.feature_id) return null; + const feature = getFeature(item.feature_id, features); - return feature?.config?.usage_type; + return feature?.config?.usage_type; +}; + +export const getFeatureCreditSystem = ({ + item, + features +}: { + item: ProductItem; + features: Feature[]; +}) => { + if (!item.feature_id) return null; + const feature = getFeature(item.feature_id, features); + + return feature?.type === FeatureType.CreditSystem; }; diff --git a/vite/src/utils/product/product-item/validateProductItem.ts b/vite/src/utils/product/product-item/validateProductItem.ts index 05bac1b65..47be76192 100644 --- a/vite/src/utils/product/product-item/validateProductItem.ts +++ b/vite/src/utils/product/product-item/validateProductItem.ts @@ -1,7 +1,8 @@ import { invalidNumber, notNullish } from "@/utils/genUtils"; -import { Feature, FeatureUsageType, ProductItem } from "@autumn/shared"; +import { Feature, FeatureUsageType, ProductItem, ProductItemInterval } from "@autumn/shared"; import { toast } from "sonner"; import { isFeatureItem, isFeaturePriceItem } from "../getItemType"; +import { isOneOffProduct } from "../priceUtils"; export const validateProductItem = ({ item, @@ -100,5 +101,46 @@ 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 (invalidNumber(item.config.rollover.max)) { + toast.error("Please enter a valid maximum rollover amount"); + item.config.rollover = undefined; + return null; + } + + if (invalidNumber(item.config.rollover.length)) { + toast.error("Please enter a valid rollover duration"); + item.config.rollover = undefined; + return null; + } + + if(item.config.rollover.duration != ProductItemInterval.Month) { + toast.error("Rollovers currently only support monthly cycles."); + item.config.rollover = undefined; + return null; + } + + 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 (item.config.rollover.length < 0 || !item.config.rollover.length) { + toast.error("Please enter a positive rollover length"); + item.config.rollover = undefined; + return null; + } + } + } + return item; }; 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 bc8d8c302..82ef5822f 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 @@ -8,19 +8,23 @@ import { OnIncreaseSelect } from "./proration-config/OnIncreaseSelect"; import { shouldShowProrationConfig } from "@/utils/product/productItemUtils"; import { getFeature, + getFeatureCreditSystem, getFeatureUsageType, } from "@/utils/product/entitlementUtils"; -import { FeatureUsageType, ProductItemInterval } from "@autumn/shared"; +import { FeatureUsageType, ProductItem, ProductItemInterval } from "@autumn/shared"; import { Input } from "@/components/ui/input"; export const AdvancedItemConfig = () => { const { features } = useProductContext(); const { item, setItem } = useProductItemContext(); - console.log("item", item); + console.log("item", item); const [isOpen, setIsOpen] = useState(item.usage_limit != null); const showProrationConfig = shouldShowProrationConfig({ item, features }); const usageType = getFeatureUsageType({ item, features }); + const hasCreditSystem = getFeatureCreditSystem({ item, features }); + const showRolloverConfig = + (hasCreditSystem || usageType === FeatureUsageType.Single) && item.interval !== null; return (
@@ -41,7 +45,7 @@ export const AdvancedItemConfig = () => { isOpen ? "max-h-72 opacity-100 mt-2" : "max-h-0 opacity-0" }`} > -
+
{ @@ -57,7 +61,7 @@ export const AdvancedItemConfig = () => { disabled={usageType === FeatureUsageType.Continuous} /> -
+
{ @@ -101,84 +105,102 @@ export const AdvancedItemConfig = () => { {/*
*/} -
- { - if (item.config?.rollover != null) { - setItem({ - ...item, - config: { - ...item.config, - rollover: null, - }, - }); - } else { - setItem({ - ...item, - config: { - ...item.config, - 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" - /> - - {item.config?.rollover != null && ( -
- { - setItem({ - ...item, - config: { - ...item.config, - rollover: { - ...item.config!.rollover!, - max: parseInt(e.target.value) - }, - }, - }); - }} - /> - - - { - setItem({ - ...item, - config: { - ...item.config, - rollover: { - ...item.config!.rollover!, - length: parseInt(e.target.value) - }, - }, - }); - }} - className="ml-0 w-full" - endContent={ - <> -

month(s)

- - } - /> -
- )} -
+ {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)

+ + } + /> +
+ )} +
+ ); +}; From f975f3561fea1c287a701bcfc6859fd80a58d4c6 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Tue, 22 Jul 2025 12:22:47 +0100 Subject: [PATCH 12/45] =?UTF-8?q?fix:=20=F0=9F=90=9B=20top=20level=20dev?= =?UTF-8?q?=20command=20not=20up=20to=20date=20w/=20server=20cmds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1059177c8..6e30adc47 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:bun\" \"cd vite && bun run dev:bun\"", + "dev:bun": "concurrently \"cd server && bun run dev\" \"cd vite && bun run 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": { From d30e1ef3a76664053548591fdaa012e6f78b848e Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Tue, 22 Jul 2025 17:52:09 +0100 Subject: [PATCH 13/45] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20rollover=20db=20st?= =?UTF-8?q?uff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/index.ts | 3 +++ .../cusProductModels/cusEntModels/cusEntModels.ts | 6 ++++++ .../productModels/rolloverModels/rolloverModels.ts | 14 ++++++++++++++ .../productModels/rolloverModels/rolloverTable.ts | 8 +++++--- 4 files changed, 28 insertions(+), 3 deletions(-) create mode 100644 shared/models/productModels/rolloverModels/rolloverModels.ts diff --git a/shared/index.ts b/shared/index.ts index 9b78e0d8f..72e76cf21 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -86,6 +86,9 @@ export * from "./models/cusModels/entityModels/entityTable.js"; export * from "./models/cusModels/entityModels/entityExpand.js"; export * from "./models/cusModels/entityModels/entityResModels.js"; +export * from "./models/productModels/rolloverModels/rolloverModels.js"; + + // 4. Chat Result Models export * from "./models/chatResultModels/chatResultTable.js"; export * from "./models/chatResultModels/chatResultFeature.js"; diff --git a/shared/models/cusProductModels/cusEntModels/cusEntModels.ts b/shared/models/cusProductModels/cusEntModels/cusEntModels.ts index d675122f8..60edfe857 100644 --- a/shared/models/cusProductModels/cusEntModels/cusEntModels.ts +++ b/shared/models/cusProductModels/cusEntModels/cusEntModels.ts @@ -9,6 +9,11 @@ export const EntityBalanceSchema = z.object({ adjustment: z.number(), }); +export const EntityRolloverBalanceSchema = EntityBalanceSchema.pick({ + id: true, + balance: true, +}); + export const CustomerEntitlementSchema = z.object({ // Foreign keys id: z.string(), @@ -39,6 +44,7 @@ export const FullCustomerEntitlementSchema = CustomerEntitlementSchema.extend({ }); export type EntityBalance = z.infer; +export type EntityRolloverBalance = z.infer; export type CustomerEntitlement = z.infer; export type FullCustomerEntitlement = z.infer< typeof FullCustomerEntitlementSchema diff --git a/shared/models/productModels/rolloverModels/rolloverModels.ts b/shared/models/productModels/rolloverModels/rolloverModels.ts new file mode 100644 index 000000000..8f3db9bde --- /dev/null +++ b/shared/models/productModels/rolloverModels/rolloverModels.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +export const RolloverModelSchema = z.object({ + id: z.string(), + cus_ent_id: z.string(), + balance: z.number(), + expires_at: z.number(), + entities: z.array(z.object({ + id: z.string(), + balance: z.number(), + })), +}); + +export type RolloverModel = z.infer; \ No newline at end of file diff --git a/shared/models/productModels/rolloverModels/rolloverTable.ts b/shared/models/productModels/rolloverModels/rolloverTable.ts index 14cfabbcd..98b22424a 100644 --- a/shared/models/productModels/rolloverModels/rolloverTable.ts +++ b/shared/models/productModels/rolloverModels/rolloverTable.ts @@ -5,23 +5,25 @@ import { jsonb, text, integer, + uuid, } from "drizzle-orm/pg-core"; -import { entitlements } from "../entModels/entTable.js"; import { EntityBalance } 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: integer("timestamp").notNull(), + expires_at: numeric({ mode: "number" }).notNull(), entities: jsonb("entities").$type(), }, (table) => [ foreignKey({ columns: [table.cus_ent_id], - foreignColumns: [entitlements.id], + foreignColumns: [customerEntitlements.id], name: "rollover_cus_ent_id_fkey", }), ] From 289011f8765268b1633f050de6d40fafe145650e Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Tue, 22 Jul 2025 17:52:27 +0100 Subject: [PATCH 14/45] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20rollover=20service?= =?UTF-8?q?=20and=20cronjob?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/cron.ts | 22 ++++ .../cusEnts/cusRollovers/RolloverService.ts | 45 ++++++++ .../cusEnts/cusRollovers/rolloverUtils.ts | 100 ++++++++++++++++++ .../cusProducts/cusEnts/groupByUtils.ts | 46 ++++---- 4 files changed, 191 insertions(+), 22 deletions(-) create mode 100644 server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts create mode 100644 server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.ts diff --git a/server/src/cron.ts b/server/src/cron.ts index fb15e40e6..dc7ef8d8d 100644 --- a/server/src/cron.ts +++ b/server/src/cron.ts @@ -19,12 +19,14 @@ import { getResetBalance, } from "./internal/customers/cusProducts/cusEnts/cusEntUtils.js"; import { getResetBalancesUpdate } from "./internal/customers/cusProducts/cusEnts/groupByUtils.js"; +import { getRolloverUpdates } from "./internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.js"; import { CusProductService } from "./internal/customers/cusProducts/CusProductService.js"; import { createStripeCli } from "./external/stripe/utils.js"; import { UTCDate } from "@date-fns/utc"; import { type DrizzleCli, initDrizzle } from "./db/initDrizzle.js"; import { CusPriceService } from "./internal/customers/cusProducts/cusPrices/CusPriceService.js"; +import { RolloverService } from "./internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.js"; dotenv.config(); @@ -171,11 +173,21 @@ const resetCustomerEntitlement = async ({ cusEnt.entitlement.interval as EntInterval ); + let rolloverUpdate = getRolloverUpdates({ + cusEnt, + allowance: resetBalance || undefined, + nextResetAt, + }); + let resetBalanceUpdate = getResetBalancesUpdate({ cusEnt, allowance: resetBalance || undefined, }); + + console.log("Rollover update", rolloverUpdate); + + try { nextResetAt = await checkSubAnchor({ db, @@ -197,6 +209,16 @@ const resetCustomerEntitlement = async ({ }, }); + let rolloverRows: any[] = []; + if (rolloverUpdate?.toInsert && rolloverUpdate.toInsert.length > 0) { + rolloverRows = await RolloverService.insert({ + db, + rows: rolloverUpdate.toInsert, + }); + } + + console.log("Rollover rows", rolloverRows); + console.log( `Reset ${cusEnt.id} | customer: ${chalk.yellow( cusEnt.customer_id diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts new file mode 100644 index 000000000..ffd223575 --- /dev/null +++ b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts @@ -0,0 +1,45 @@ +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { RolloverModel } from "@autumn/shared"; +import { rollovers } from "@shared/db/schema.js"; +import { eq, inArray } from "drizzle-orm"; + +export class RolloverService { + static async update({ + db, + id, + updates, + }: { + db: DrizzleCli; + id: string; + updates: Partial; + }) { + const data = await db + .update(rollovers) + .set(updates as any) + .where(eq(rollovers.id, id)) + .returning(); + + return data; + } + + static async insert({ + db, + rows, + }: { + db: DrizzleCli; + rows: RolloverModel[]; + }) { + const data = await db.insert(rollovers).values(rows as any).returning(); + return data; + } + + static async delete({ + db, + ids, + }: { + db: DrizzleCli; + ids: string[]; + }) { + const data = await db.delete(rollovers).where(inArray(rollovers.id, ids)); + } +} \ No newline at end of file diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.ts b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.ts new file mode 100644 index 000000000..a2c2be864 --- /dev/null +++ b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.ts @@ -0,0 +1,100 @@ +import { + FullCustomerEntitlement, + ProductItemInterval, + Rollover, + RolloverModel, + EntityBalance, + EntityRolloverBalance, +} from "@autumn/shared"; +import { notNullish, nullish } from "@/utils/genUtils.js"; +import { randomUUID } from "crypto"; + +export const getRolloverUpdates = ({ + cusEnt, + allowance, + nextResetAt, +}: { + cusEnt: FullCustomerEntitlement; + allowance?: number; + nextResetAt: number; +}) => { + let update: { + toDelete: string[]; + toInsert: RolloverModel[]; + toUpdate: RolloverModel[]; + } = { + toDelete: [], + toInsert: [], + toUpdate: [], + }; + + if (nullish(cusEnt.entitlement.rollover) || !cusEnt.entitlement.rollover) { + return update; + } + + let nextExpiry = calculateNextExpiry( + nextResetAt, + cusEnt.entitlement.rollover + ); + if (nullish(nextExpiry) || !nextExpiry) { + return update; + } + + let entitlement = cusEnt.entitlement.allowance; + + if (nullish(entitlement) || !entitlement) { + return update; + } + + let balance = cusEnt.balance || 0; + + let rollover = entitlement! - balance; + console.log( + `Rollover: ${rollover} | Entitlement: ${entitlement} | Balance: ${balance}` + ); + + let newEntitlement = { + cus_ent_id: cusEnt.id, + balance: rollover, + expires_at: nextExpiry, + entities: [] as EntityRolloverBalance[], + id: randomUUID() as string, + }; + + console.log("🏢 entities:", cusEnt.entities); + console.log("📋 entitlement:", cusEnt.entitlement); + console.log("🆔 entity feature id:", cusEnt.entitlement.entity_feature_id); + + if (notNullish(cusEnt.entitlement.entity_feature_id)) { + console.log("🔍 newEntities:", cusEnt.entities); + for (const entityId in cusEnt.entities) { + let entRollover = entitlement! - cusEnt.entities[entityId].balance; + if (entRollover > 0) { + newEntitlement.entities.push({ + id: entityId, + balance: entRollover, + }); + } + } + update.toInsert.push(newEntitlement); + } else { + update.toInsert.push(newEntitlement); + } + + console.log("Rollover update", update); + + return update; +}; + +export const calculateNextExpiry = (nextResetAt: number, config: Rollover) => { + if (nullish(config)) { + return null; + } + + let nextExpiry = new Date(nextResetAt); + if (config!.duration === ProductItemInterval.Month) { + nextExpiry.setMonth(nextExpiry.getMonth() + config!.length); + } + + return nextExpiry.getTime(); +}; diff --git a/server/src/internal/customers/cusProducts/cusEnts/groupByUtils.ts b/server/src/internal/customers/cusProducts/cusEnts/groupByUtils.ts index 0d672433a..6c04a904a 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/groupByUtils.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/groupByUtils.ts @@ -1,30 +1,32 @@ -import { FullCustomerEntitlement } from "@autumn/shared"; +import { + FullCustomerEntitlement, +} from "@autumn/shared"; import { notNullish } from "@/utils/genUtils.js"; export const getResetBalancesUpdate = ({ - cusEnt, - allowance, + cusEnt, + allowance, }: { - cusEnt: FullCustomerEntitlement; - allowance?: number; + cusEnt: FullCustomerEntitlement; + allowance?: number; }) => { - let update = {}; - let newBalance = notNullish(allowance) - ? allowance! - : cusEnt.entitlement.allowance || 0; + let update = {}; + let newBalance = notNullish(allowance) + ? allowance! + : cusEnt.entitlement.allowance || 0; - let entitlement = cusEnt.entitlement; + let entitlement = cusEnt.entitlement; - if (notNullish(entitlement.entity_feature_id)) { - let newEntities = { ...cusEnt.entities }; - for (const entityId in newEntities) { - newEntities[entityId].balance = newBalance; - newEntities[entityId].adjustment = 0; - } - update = { entities: newEntities }; - } else { - update = { balance: newBalance }; - } + if (notNullish(entitlement.entity_feature_id)) { + let newEntities = { ...cusEnt.entities }; + for (const entityId in newEntities) { + newEntities[entityId].balance = newBalance; + newEntities[entityId].adjustment = 0; + } + update = { entities: newEntities }; + } else { + update = { balance: newBalance }; + } - return update; -}; + return update; +}; \ No newline at end of file From a3c17eb36e1e89b098c73011222ba632201fa7f0 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Wed, 23 Jul 2025 15:23:21 +0100 Subject: [PATCH 15/45] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20cascade=20for=20ro?= =?UTF-8?q?llover?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/index.ts | 2 +- shared/models/productModels/rolloverModels/rolloverTable.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/shared/index.ts b/shared/index.ts index 72e76cf21..c514e54ce 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -86,9 +86,9 @@ export * from "./models/cusModels/entityModels/entityTable.js"; export * from "./models/cusModels/entityModels/entityExpand.js"; export * from "./models/cusModels/entityModels/entityResModels.js"; +export * from "./models/productModels/rolloverModels/rolloverTable.js"; export * from "./models/productModels/rolloverModels/rolloverModels.js"; - // 4. Chat Result Models export * from "./models/chatResultModels/chatResultTable.js"; export * from "./models/chatResultModels/chatResultFeature.js"; diff --git a/shared/models/productModels/rolloverModels/rolloverTable.ts b/shared/models/productModels/rolloverModels/rolloverTable.ts index 98b22424a..049aeb11a 100644 --- a/shared/models/productModels/rolloverModels/rolloverTable.ts +++ b/shared/models/productModels/rolloverModels/rolloverTable.ts @@ -25,6 +25,8 @@ export const rollovers = pgTable( columns: [table.cus_ent_id], foreignColumns: [customerEntitlements.id], name: "rollover_cus_ent_id_fkey", - }), + }) + .onUpdate("cascade") + .onDelete("cascade"), ] ).enableRLS(); From 7510ff68659c48d237ce0d4d8beabbca408e5461 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Wed, 23 Jul 2025 15:23:52 +0100 Subject: [PATCH 16/45] =?UTF-8?q?style:=20=F0=9F=92=84=20clean=20up=20weir?= =?UTF-8?q?d=20json=20dumps=20in=20console?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../webhookHandlers/handleSubUpdated.ts | 9 +-------- .../attach/attachUtils/getAttachFunction.ts | 19 +++++++++++-------- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/server/src/external/stripe/webhookHandlers/handleSubUpdated.ts b/server/src/external/stripe/webhookHandlers/handleSubUpdated.ts index 173ec87f4..20df69153 100644 --- a/server/src/external/stripe/webhookHandlers/handleSubUpdated.ts +++ b/server/src/external/stripe/webhookHandlers/handleSubUpdated.ts @@ -74,14 +74,7 @@ export const handleSubscriptionUpdated = async ({ if (updatedCusProducts.length > 0) { logger.info( - `subscription.updated: updated ${updatedCusProducts.length} customer products`, - { - data: { - ids: updatedCusProducts.map((cp) => cp.id), - status: updatedCusProducts[0].status, - canceled_at: updatedCusProducts[0].canceled_at, - }, - }, + `✅ Updated ${updatedCusProducts.length} customer product${updatedCusProducts.length === 1 ? '' : 's'} (${updatedCusProducts.map(cp => cp.id).join(', ')}) - Status: ${updatedCusProducts[0].status}${updatedCusProducts[0].canceled_at ? `, Canceled: ${new Date(updatedCusProducts[0].canceled_at).toISOString()}` : ''}` ); } diff --git a/server/src/internal/customers/attach/attachUtils/getAttachFunction.ts b/server/src/internal/customers/attach/attachUtils/getAttachFunction.ts index 1272054b0..015c58f81 100644 --- a/server/src/internal/customers/attach/attachUtils/getAttachFunction.ts +++ b/server/src/internal/customers/attach/attachUtils/getAttachFunction.ts @@ -142,15 +142,18 @@ export const runAttachFunction = async ({ logger.info(`Entity: ${customer.entity.name} (${customer.entity.id})`); } logger.info( - `Branch: ${chalk.yellow(branch)}, Function: ${chalk.yellow(attachFunction)}`, - { - data: { - curMainProduct: curMainProduct?.product.id, - curSameProduct: curSameProduct?.product.id, - curScheduledProduct: curScheduledProduct?.product.id, - }, - } + `Branch: ${chalk.yellow(branch)}, Function: ${chalk.yellow(attachFunction)}` ); + + if (curMainProduct) { + logger.info(`→ Current Main Product: ${curMainProduct.product.id}`); + } + if (curSameProduct) { + logger.info(`→ Current Same Product: ${curSameProduct.product.id}`); + } + if (curScheduledProduct) { + logger.info(`→ Current Scheduled Product: ${curScheduledProduct.product.id}`); + } // config.proration = ProrationBehavior.None; // attachParams.billingAnchor = 1781702400000; From 3188a8a63d1d31ef476ab598f4dd911d78783c09 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Wed, 23 Jul 2025 15:24:29 +0100 Subject: [PATCH 17/45] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20rolloverUtils=20pr?= =?UTF-8?q?ioritises=20entities=20and=20then=20balances?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cusEnts/cusRollovers/RolloverService.ts | 3 +- .../cusEnts/cusRollovers/rolloverUtils.ts | 55 +++++++++++++------ 2 files changed, 38 insertions(+), 20 deletions(-) diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts index ffd223575..7df083d92 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts @@ -1,6 +1,5 @@ import { DrizzleCli } from "@/db/initDrizzle.js"; -import { RolloverModel } from "@autumn/shared"; -import { rollovers } from "@shared/db/schema.js"; +import { RolloverModel, rollovers } from "@autumn/shared"; import { eq, inArray } from "drizzle-orm"; export class RolloverService { diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.ts b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.ts index a2c2be864..ba03487f1 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.ts @@ -11,11 +11,9 @@ import { randomUUID } from "crypto"; export const getRolloverUpdates = ({ cusEnt, - allowance, - nextResetAt, + nextResetAt }: { cusEnt: FullCustomerEntitlement; - allowance?: number; nextResetAt: number; }) => { let update: { @@ -27,7 +25,6 @@ export const getRolloverUpdates = ({ toInsert: [], toUpdate: [], }; - if (nullish(cusEnt.entitlement.rollover) || !cusEnt.entitlement.rollover) { return update; } @@ -36,52 +33,74 @@ export const getRolloverUpdates = ({ nextResetAt, cusEnt.entitlement.rollover ); + if (nullish(nextExpiry) || !nextExpiry) { return update; } - let entitlement = cusEnt.entitlement.allowance; + let entitlement = cusEnt.entitlement.allowance ?? 0; - if (nullish(entitlement) || !entitlement) { + if (entitlement < 0) { return update; } - let balance = cusEnt.balance || 0; - - let rollover = entitlement! - balance; + let rollover = cusEnt.balance || 0; console.log( - `Rollover: ${rollover} | Entitlement: ${entitlement} | Balance: ${balance}` + `🔥 Unused balance (rollover): ${rollover} | Entitlement: ${entitlement}` ); let newEntitlement = { cus_ent_id: cusEnt.id, - balance: rollover, + balance: 0, expires_at: nextExpiry, entities: [] as EntityRolloverBalance[], id: randomUUID() as string, }; - console.log("🏢 entities:", cusEnt.entities); - console.log("📋 entitlement:", cusEnt.entitlement); - console.log("🆔 entity feature id:", cusEnt.entitlement.entity_feature_id); + if (cusEnt.entities != null) + console.log( + "🏢 entities:", + Object.values(cusEnt.entities).map((x: any) => `${x.id}: ${x.balance}`) + ); + else console.log("🏢 entities: none"); + console.log( + "📋 entitlement:", + cusEnt.entitlement.feature_id, + "| 🆔 entity_feature_id:", + cusEnt.entitlement.entity_feature_id, + "| allowance:", + cusEnt.entitlement.allowance + ); if (notNullish(cusEnt.entitlement.entity_feature_id)) { console.log("🔍 newEntities:", cusEnt.entities); for (const entityId in cusEnt.entities) { - let entRollover = entitlement! - cusEnt.entities[entityId].balance; + let entRollover = cusEnt.entities[entityId].balance; if (entRollover > 0) { newEntitlement.entities.push({ id: entityId, balance: entRollover, }); - } + console.log("🔍 entityId:", entityId, "entRollover:", entRollover); + } else console.log("🔍 no rollover for entityId:", entityId, " | entitlement:", entitlement, " | balance:", cusEnt.entities[entityId].balance); } update.toInsert.push(newEntitlement); } else { - update.toInsert.push(newEntitlement); + if (rollover > 0) { + newEntitlement.balance = rollover; + update.toInsert.push(newEntitlement); + } else console.log("🔍 no rollover for entitlement: ", cusEnt.id, " | rollable balance:", rollover); } - console.log("Rollover update", update); + console.log( + "Rollover update sending from rolloverUtils:", + update.toInsert.map((rollover) => ({ + id: rollover.id, + balance: rollover.balance, + entities: rollover.entities.map((entity) => `${entity.id}: ${entity.balance}`).join(", "), + expires_at: rollover.expires_at ? new Date(rollover.expires_at).toISOString() : null, + })) + ); return update; }; From 3af722aa433d1986d39f1899a26c05d8205b37d3 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Wed, 23 Jul 2025 15:25:20 +0100 Subject: [PATCH 18/45] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20get=20and=20insert?= =?UTF-8?q?=20rollover=20updates=20across=203=20reset=20triggers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/cron.ts | 20 ++++++++--- .../handlePrepaidPrices.ts | 34 +++++++++++++++++++ .../handleInvoiceCreated/handleUsagePrices.ts | 34 +++++++++++++++++++ 3 files changed, 83 insertions(+), 5 deletions(-) diff --git a/server/src/cron.ts b/server/src/cron.ts index dc7ef8d8d..ec91fb6f5 100644 --- a/server/src/cron.ts +++ b/server/src/cron.ts @@ -175,8 +175,7 @@ const resetCustomerEntitlement = async ({ let rolloverUpdate = getRolloverUpdates({ cusEnt, - allowance: resetBalance || undefined, - nextResetAt, + nextResetAt: cusEnt.next_reset_at! as number, }); let resetBalanceUpdate = getResetBalancesUpdate({ @@ -185,7 +184,15 @@ const resetCustomerEntitlement = async ({ }); - console.log("Rollover update", rolloverUpdate); + console.log( + "Rollover update received in cron.ts/resetCustomerEntitlement:", + rolloverUpdate.toInsert.map((rollover) => ({ + id: rollover.id, + balance: rollover.balance, + entities: rollover.entities.map((entity) => `${entity.id}: ${entity.balance}`).join(", "), + expires_at: rollover.expires_at ? new Date(rollover.expires_at).toISOString() : null, + })) + ); try { @@ -217,8 +224,11 @@ const resetCustomerEntitlement = async ({ }); } - console.log("Rollover rows", rolloverRows); - + console.log( + "Rollover rows", + Object.values(rolloverRows).map((x) => `${x.id}: ${x.balance} | entities: ${x.entities.map((y: any) => `${y.id}: ${y.balance}`).join(", ")}`) + ); + console.log( `Reset ${cusEnt.id} | customer: ${chalk.yellow( cusEnt.customer_id diff --git a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handlePrepaidPrices.ts b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handlePrepaidPrices.ts index f24412676..47ca3215c 100644 --- a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handlePrepaidPrices.ts +++ b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handlePrepaidPrices.ts @@ -1,6 +1,8 @@ import { DrizzleCli } from "@/db/initDrizzle.js"; import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js"; import { getResetBalance } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js"; +import { RolloverService } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.js"; +import { getRolloverUpdates } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.js"; import { getRelatedCusEnt } from "@/internal/customers/cusProducts/cusPrices/cusPriceUtils.js"; import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; import { getEntOptions } from "@/internal/products/prices/priceUtils.js"; @@ -65,6 +67,22 @@ export const handlePrepaidPrices = async ({ const ent = cusEnt.entitlement; + let rolloverUpdate = getRolloverUpdates({ + cusEnt, + nextResetAt: usageSub.current_period_end * 1000, + }); + console.log("🔍 rolloverUpdate", rolloverUpdate); + + console.log( + "Rollover update received in handlePrepaidPrices:", + rolloverUpdate.toInsert.map((rollover) => ({ + id: rollover.id, + balance: rollover.balance, + entities: rollover.entities.map((entity) => `${entity.id}: ${entity.balance}`).join(", "), + expires_at: rollover.expires_at ? new Date(rollover.expires_at).toISOString() : null, + })) + ); + if (notNullish(options?.upcoming_quantity)) { const newOptions = cusProduct.options.map((o) => { if (o.feature_id == ent.feature_id) { @@ -104,6 +122,22 @@ export const handlePrepaidPrices = async ({ `🔥 Resetting balance for ${ent.feature.id}, customer: ${customer.id} (name: ${customer.name})`, ); + let rolloverRows: any[] = []; + if (rolloverUpdate?.toInsert && rolloverUpdate.toInsert.length > 0) { + logger.info( + `🔥 Rolling over balance for ${ent.feature.id}, customer: ${customer.id} (name: ${customer.name})`, + ); + rolloverRows = await RolloverService.insert({ + db, + rows: rolloverUpdate.toInsert, + }); + } + + console.log( + "Rollover rows", + Object.values(rolloverRows).map((x) => `${x.id}: ${x.balance} | entities: ${x.entities.map((y: any) => `${y.id}: ${y.balance}`).join(", ")}`) + ); + await CusEntService.update({ db, id: cusEnt.id, diff --git a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleUsagePrices.ts b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleUsagePrices.ts index 559e09544..5991ec824 100644 --- a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleUsagePrices.ts +++ b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleUsagePrices.ts @@ -16,6 +16,8 @@ import { submitUsageToStripe } from "../../stripeMeterUtils.js"; import { getInvoiceItemForUsage } from "../../stripePriceUtils.js"; import { getCusPriceUsage } from "@/internal/customers/cusProducts/cusPrices/cusPriceUtils.js"; import { findStripeItemForPrice } from "../../stripeSubUtils/stripeSubItemUtils.js"; +import { getRolloverUpdates } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.js"; +import { RolloverService } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.js"; export const handleUsagePrices = async ({ db, @@ -124,6 +126,22 @@ export const handleUsagePrices = async ({ } let ent = relatedCusEnt.entitlement; + + let rolloverUpdate = getRolloverUpdates({ + cusEnt: relatedCusEnt, + nextResetAt: usageSub.current_period_end * 1000, + }); + + console.log( + "Rollover update received in handleUsagePrices:", + rolloverUpdate.toInsert.map((rollover) => ({ + id: rollover.id, + balance: rollover.balance, + entities: rollover.entities.map((entity) => `${entity.id}: ${entity.balance}`).join(", "), + expires_at: rollover.expires_at ? new Date(rollover.expires_at).toISOString() : null, + })) + ); + let resetBalancesUpdate = getResetBalancesUpdate({ cusEnt: relatedCusEnt, allowance: ent.interval == EntInterval.Lifetime ? 0 : ent.allowance!, @@ -141,5 +159,21 @@ export const handleUsagePrices = async ({ }, }); + let rolloverRows: any[] = []; + if (rolloverUpdate?.toInsert && rolloverUpdate.toInsert.length > 0) { + logger.info( + `🔥 Rolling over balance for ${ent.feature.id}, customer: ${customer.id} (name: ${customer.name})`, + ); + rolloverRows = await RolloverService.insert({ + db, + rows: rolloverUpdate.toInsert, + }); + } + + console.log( + "Rollover rows", + Object.values(rolloverRows).map((x) => `${x.id}: ${x.balance} | entities: ${x.entities.map((y: any) => `${y.id}: ${y.balance}`).join(", ")}`) + ); + logger.info("✅ Successfully reset balance"); }; From 3cfcc2abd0953e44dc10a3e9bf21972c8b0ee684 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Tue, 22 Jul 2025 12:19:43 +0100 Subject: [PATCH 19/45] =?UTF-8?q?fix:=20=F0=9F=90=9B=20weird=20bun=20not?= =?UTF-8?q?=20found=20errors=20on=20windows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/shared/package.json b/shared/package.json index 396cc0ac6..db7f0e850 100644 --- a/shared/package.json +++ b/shared/package.json @@ -15,6 +15,7 @@ "scripts": { "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", "db:push": "cross-env NODE_OPTIONS=\"--import tsx\" pnpm exec drizzle-kit push --config drizzle.config.ts", "db:generate": "cross-env NODE_OPTIONS=\"--import tsx\" pnpm exec drizzle-kit generate --config drizzle.config.ts", From e0a0dd48a7b754f4ffc8904c55e78f8f0228de6f Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Tue, 22 Jul 2025 12:19:52 +0100 Subject: [PATCH 20/45] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20warning=20toast=20?= =?UTF-8?q?styles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vite/src/components/general/CustomToaster.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/vite/src/components/general/CustomToaster.tsx b/vite/src/components/general/CustomToaster.tsx index 0f88bc060..d266a3abc 100644 --- a/vite/src/components/general/CustomToaster.tsx +++ b/vite/src/components/general/CustomToaster.tsx @@ -13,6 +13,8 @@ export const CustomToaster = () => { gap-2 bg-white/70 backdrop-blur-sm border border-red-400 rounded-sm p-2 text-sm shadow-md`, success: `w-[350px] text-green-600 flex items-start gap-2 bg-white/90 backdrop-blur-sm border border-green-500 rounded-sm p-2 text-sm shadow-md`, + warning: `w-[350px] text-yellow-600 flex items-start + gap-2 bg-white/90 backdrop-blur-sm border border-yellow-500 rounded-sm p-2 text-sm shadow-md`, }, }} /> From 709fc765673965cdffd6aba4cdef5d32f0fe3281 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Tue, 22 Jul 2025 12:20:17 +0100 Subject: [PATCH 21/45] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20validate=20item=20?= =?UTF-8?q?is=20able=20to=20be=20configured=20for=20rollovers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vite/src/utils/product/entitlementUtils.ts | 41 ++-- .../product-item/validateProductItem.ts | 44 ++++- .../advanced-config/AdvancedItemConfig.tsx | 182 ++++++++++-------- 3 files changed, 172 insertions(+), 95 deletions(-) diff --git a/vite/src/utils/product/entitlementUtils.ts b/vite/src/utils/product/entitlementUtils.ts index 29dcb0523..21d9d5bb1 100644 --- a/vite/src/utils/product/entitlementUtils.ts +++ b/vite/src/utils/product/entitlementUtils.ts @@ -1,24 +1,37 @@ -import { Feature, ProductItem } from "@autumn/shared"; +import { Feature, FeatureType, ProductItem } from "@autumn/shared"; export const getFeature = ( - featureId: string | undefined, - features: Feature[], + featureId: string | undefined, + features: Feature[] ) => { - const foundFeature = features?.find( - (feature: Feature) => feature.id === featureId, - ); - return foundFeature || null; + const foundFeature = features?.find( + (feature: Feature) => feature.id === featureId + ); + return foundFeature || null; }; export const getFeatureUsageType = ({ - item, - features, + item, + features, }: { - item: ProductItem; - features: Feature[]; + item: ProductItem; + features: Feature[]; }) => { - if (!item.feature_id) return null; - const feature = getFeature(item.feature_id, features); + if (!item.feature_id) return null; + const feature = getFeature(item.feature_id, features); - return feature?.config?.usage_type; + return feature?.config?.usage_type; +}; + +export const getFeatureCreditSystem = ({ + item, + features +}: { + item: ProductItem; + features: Feature[]; +}) => { + if (!item.feature_id) return null; + const feature = getFeature(item.feature_id, features); + + return feature?.type === FeatureType.CreditSystem; }; diff --git a/vite/src/utils/product/product-item/validateProductItem.ts b/vite/src/utils/product/product-item/validateProductItem.ts index 05bac1b65..47be76192 100644 --- a/vite/src/utils/product/product-item/validateProductItem.ts +++ b/vite/src/utils/product/product-item/validateProductItem.ts @@ -1,7 +1,8 @@ import { invalidNumber, notNullish } from "@/utils/genUtils"; -import { Feature, FeatureUsageType, ProductItem } from "@autumn/shared"; +import { Feature, FeatureUsageType, ProductItem, ProductItemInterval } from "@autumn/shared"; import { toast } from "sonner"; import { isFeatureItem, isFeaturePriceItem } from "../getItemType"; +import { isOneOffProduct } from "../priceUtils"; export const validateProductItem = ({ item, @@ -100,5 +101,46 @@ 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 (invalidNumber(item.config.rollover.max)) { + toast.error("Please enter a valid maximum rollover amount"); + item.config.rollover = undefined; + return null; + } + + if (invalidNumber(item.config.rollover.length)) { + toast.error("Please enter a valid rollover duration"); + item.config.rollover = undefined; + return null; + } + + if(item.config.rollover.duration != ProductItemInterval.Month) { + toast.error("Rollovers currently only support monthly cycles."); + item.config.rollover = undefined; + return null; + } + + 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 (item.config.rollover.length < 0 || !item.config.rollover.length) { + toast.error("Please enter a positive rollover length"); + item.config.rollover = undefined; + return null; + } + } + } + return item; }; 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 bc8d8c302..82ef5822f 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 @@ -8,19 +8,23 @@ import { OnIncreaseSelect } from "./proration-config/OnIncreaseSelect"; import { shouldShowProrationConfig } from "@/utils/product/productItemUtils"; import { getFeature, + getFeatureCreditSystem, getFeatureUsageType, } from "@/utils/product/entitlementUtils"; -import { FeatureUsageType, ProductItemInterval } from "@autumn/shared"; +import { FeatureUsageType, ProductItem, ProductItemInterval } from "@autumn/shared"; import { Input } from "@/components/ui/input"; export const AdvancedItemConfig = () => { const { features } = useProductContext(); const { item, setItem } = useProductItemContext(); - console.log("item", item); + console.log("item", item); const [isOpen, setIsOpen] = useState(item.usage_limit != null); const showProrationConfig = shouldShowProrationConfig({ item, features }); const usageType = getFeatureUsageType({ item, features }); + const hasCreditSystem = getFeatureCreditSystem({ item, features }); + const showRolloverConfig = + (hasCreditSystem || usageType === FeatureUsageType.Single) && item.interval !== null; return (
@@ -41,7 +45,7 @@ export const AdvancedItemConfig = () => { isOpen ? "max-h-72 opacity-100 mt-2" : "max-h-0 opacity-0" }`} > -
+
{ @@ -57,7 +61,7 @@ export const AdvancedItemConfig = () => { disabled={usageType === FeatureUsageType.Continuous} /> -
+
{ @@ -101,84 +105,102 @@ export const AdvancedItemConfig = () => { {/*
*/} -
- { - if (item.config?.rollover != null) { - setItem({ - ...item, - config: { - ...item.config, - rollover: null, - }, - }); - } else { - setItem({ - ...item, - config: { - ...item.config, - 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" - /> - - {item.config?.rollover != null && ( -
- { - setItem({ - ...item, - config: { - ...item.config, - rollover: { - ...item.config!.rollover!, - max: parseInt(e.target.value) - }, - }, - }); - }} - /> - - - { - setItem({ - ...item, - config: { - ...item.config, - rollover: { - ...item.config!.rollover!, - length: parseInt(e.target.value) - }, - }, - }); - }} - className="ml-0 w-full" - endContent={ - <> -

month(s)

- - } - /> -
- )} -
+ {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)

+ + } + /> +
+ )} +
+ ); +}; From 92279e22d96e6f5ae76b4426c760efe0953c8270 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Tue, 22 Jul 2025 12:22:47 +0100 Subject: [PATCH 22/45] =?UTF-8?q?fix:=20=F0=9F=90=9B=20top=20level=20dev?= =?UTF-8?q?=20command=20not=20up=20to=20date=20w/=20server=20cmds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1059177c8..6e30adc47 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:bun\" \"cd vite && bun run dev:bun\"", + "dev:bun": "concurrently \"cd server && bun run dev\" \"cd vite && bun run 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": { From 6a4691648fa17e8050ac0c9a77c104e23785b416 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Tue, 22 Jul 2025 17:52:09 +0100 Subject: [PATCH 23/45] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20rollover=20db=20st?= =?UTF-8?q?uff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/index.ts | 3 +++ .../cusProductModels/cusEntModels/cusEntModels.ts | 6 ++++++ .../productModels/rolloverModels/rolloverModels.ts | 14 ++++++++++++++ .../productModels/rolloverModels/rolloverTable.ts | 8 +++++--- 4 files changed, 28 insertions(+), 3 deletions(-) create mode 100644 shared/models/productModels/rolloverModels/rolloverModels.ts diff --git a/shared/index.ts b/shared/index.ts index 9b78e0d8f..72e76cf21 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -86,6 +86,9 @@ export * from "./models/cusModels/entityModels/entityTable.js"; export * from "./models/cusModels/entityModels/entityExpand.js"; export * from "./models/cusModels/entityModels/entityResModels.js"; +export * from "./models/productModels/rolloverModels/rolloverModels.js"; + + // 4. Chat Result Models export * from "./models/chatResultModels/chatResultTable.js"; export * from "./models/chatResultModels/chatResultFeature.js"; diff --git a/shared/models/cusProductModels/cusEntModels/cusEntModels.ts b/shared/models/cusProductModels/cusEntModels/cusEntModels.ts index d675122f8..60edfe857 100644 --- a/shared/models/cusProductModels/cusEntModels/cusEntModels.ts +++ b/shared/models/cusProductModels/cusEntModels/cusEntModels.ts @@ -9,6 +9,11 @@ export const EntityBalanceSchema = z.object({ adjustment: z.number(), }); +export const EntityRolloverBalanceSchema = EntityBalanceSchema.pick({ + id: true, + balance: true, +}); + export const CustomerEntitlementSchema = z.object({ // Foreign keys id: z.string(), @@ -39,6 +44,7 @@ export const FullCustomerEntitlementSchema = CustomerEntitlementSchema.extend({ }); export type EntityBalance = z.infer; +export type EntityRolloverBalance = z.infer; export type CustomerEntitlement = z.infer; export type FullCustomerEntitlement = z.infer< typeof FullCustomerEntitlementSchema diff --git a/shared/models/productModels/rolloverModels/rolloverModels.ts b/shared/models/productModels/rolloverModels/rolloverModels.ts new file mode 100644 index 000000000..8f3db9bde --- /dev/null +++ b/shared/models/productModels/rolloverModels/rolloverModels.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +export const RolloverModelSchema = z.object({ + id: z.string(), + cus_ent_id: z.string(), + balance: z.number(), + expires_at: z.number(), + entities: z.array(z.object({ + id: z.string(), + balance: z.number(), + })), +}); + +export type RolloverModel = z.infer; \ No newline at end of file diff --git a/shared/models/productModels/rolloverModels/rolloverTable.ts b/shared/models/productModels/rolloverModels/rolloverTable.ts index 14cfabbcd..98b22424a 100644 --- a/shared/models/productModels/rolloverModels/rolloverTable.ts +++ b/shared/models/productModels/rolloverModels/rolloverTable.ts @@ -5,23 +5,25 @@ import { jsonb, text, integer, + uuid, } from "drizzle-orm/pg-core"; -import { entitlements } from "../entModels/entTable.js"; import { EntityBalance } 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: integer("timestamp").notNull(), + expires_at: numeric({ mode: "number" }).notNull(), entities: jsonb("entities").$type(), }, (table) => [ foreignKey({ columns: [table.cus_ent_id], - foreignColumns: [entitlements.id], + foreignColumns: [customerEntitlements.id], name: "rollover_cus_ent_id_fkey", }), ] From 6e7dfc0a290976407d971e78a13f166d0eaee4b4 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Tue, 22 Jul 2025 17:52:27 +0100 Subject: [PATCH 24/45] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20rollover=20service?= =?UTF-8?q?=20and=20cronjob?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/cron.ts | 22 ++++ .../cusEnts/cusRollovers/RolloverService.ts | 45 ++++++++ .../cusEnts/cusRollovers/rolloverUtils.ts | 100 ++++++++++++++++++ .../cusProducts/cusEnts/groupByUtils.ts | 46 ++++---- 4 files changed, 191 insertions(+), 22 deletions(-) create mode 100644 server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts create mode 100644 server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.ts diff --git a/server/src/cron.ts b/server/src/cron.ts index fb15e40e6..dc7ef8d8d 100644 --- a/server/src/cron.ts +++ b/server/src/cron.ts @@ -19,12 +19,14 @@ import { getResetBalance, } from "./internal/customers/cusProducts/cusEnts/cusEntUtils.js"; import { getResetBalancesUpdate } from "./internal/customers/cusProducts/cusEnts/groupByUtils.js"; +import { getRolloverUpdates } from "./internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.js"; import { CusProductService } from "./internal/customers/cusProducts/CusProductService.js"; import { createStripeCli } from "./external/stripe/utils.js"; import { UTCDate } from "@date-fns/utc"; import { type DrizzleCli, initDrizzle } from "./db/initDrizzle.js"; import { CusPriceService } from "./internal/customers/cusProducts/cusPrices/CusPriceService.js"; +import { RolloverService } from "./internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.js"; dotenv.config(); @@ -171,11 +173,21 @@ const resetCustomerEntitlement = async ({ cusEnt.entitlement.interval as EntInterval ); + let rolloverUpdate = getRolloverUpdates({ + cusEnt, + allowance: resetBalance || undefined, + nextResetAt, + }); + let resetBalanceUpdate = getResetBalancesUpdate({ cusEnt, allowance: resetBalance || undefined, }); + + console.log("Rollover update", rolloverUpdate); + + try { nextResetAt = await checkSubAnchor({ db, @@ -197,6 +209,16 @@ const resetCustomerEntitlement = async ({ }, }); + let rolloverRows: any[] = []; + if (rolloverUpdate?.toInsert && rolloverUpdate.toInsert.length > 0) { + rolloverRows = await RolloverService.insert({ + db, + rows: rolloverUpdate.toInsert, + }); + } + + console.log("Rollover rows", rolloverRows); + console.log( `Reset ${cusEnt.id} | customer: ${chalk.yellow( cusEnt.customer_id diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts new file mode 100644 index 000000000..ffd223575 --- /dev/null +++ b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts @@ -0,0 +1,45 @@ +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { RolloverModel } from "@autumn/shared"; +import { rollovers } from "@shared/db/schema.js"; +import { eq, inArray } from "drizzle-orm"; + +export class RolloverService { + static async update({ + db, + id, + updates, + }: { + db: DrizzleCli; + id: string; + updates: Partial; + }) { + const data = await db + .update(rollovers) + .set(updates as any) + .where(eq(rollovers.id, id)) + .returning(); + + return data; + } + + static async insert({ + db, + rows, + }: { + db: DrizzleCli; + rows: RolloverModel[]; + }) { + const data = await db.insert(rollovers).values(rows as any).returning(); + return data; + } + + static async delete({ + db, + ids, + }: { + db: DrizzleCli; + ids: string[]; + }) { + const data = await db.delete(rollovers).where(inArray(rollovers.id, ids)); + } +} \ No newline at end of file diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.ts b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.ts new file mode 100644 index 000000000..a2c2be864 --- /dev/null +++ b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.ts @@ -0,0 +1,100 @@ +import { + FullCustomerEntitlement, + ProductItemInterval, + Rollover, + RolloverModel, + EntityBalance, + EntityRolloverBalance, +} from "@autumn/shared"; +import { notNullish, nullish } from "@/utils/genUtils.js"; +import { randomUUID } from "crypto"; + +export const getRolloverUpdates = ({ + cusEnt, + allowance, + nextResetAt, +}: { + cusEnt: FullCustomerEntitlement; + allowance?: number; + nextResetAt: number; +}) => { + let update: { + toDelete: string[]; + toInsert: RolloverModel[]; + toUpdate: RolloverModel[]; + } = { + toDelete: [], + toInsert: [], + toUpdate: [], + }; + + if (nullish(cusEnt.entitlement.rollover) || !cusEnt.entitlement.rollover) { + return update; + } + + let nextExpiry = calculateNextExpiry( + nextResetAt, + cusEnt.entitlement.rollover + ); + if (nullish(nextExpiry) || !nextExpiry) { + return update; + } + + let entitlement = cusEnt.entitlement.allowance; + + if (nullish(entitlement) || !entitlement) { + return update; + } + + let balance = cusEnt.balance || 0; + + let rollover = entitlement! - balance; + console.log( + `Rollover: ${rollover} | Entitlement: ${entitlement} | Balance: ${balance}` + ); + + let newEntitlement = { + cus_ent_id: cusEnt.id, + balance: rollover, + expires_at: nextExpiry, + entities: [] as EntityRolloverBalance[], + id: randomUUID() as string, + }; + + console.log("🏢 entities:", cusEnt.entities); + console.log("📋 entitlement:", cusEnt.entitlement); + console.log("🆔 entity feature id:", cusEnt.entitlement.entity_feature_id); + + if (notNullish(cusEnt.entitlement.entity_feature_id)) { + console.log("🔍 newEntities:", cusEnt.entities); + for (const entityId in cusEnt.entities) { + let entRollover = entitlement! - cusEnt.entities[entityId].balance; + if (entRollover > 0) { + newEntitlement.entities.push({ + id: entityId, + balance: entRollover, + }); + } + } + update.toInsert.push(newEntitlement); + } else { + update.toInsert.push(newEntitlement); + } + + console.log("Rollover update", update); + + return update; +}; + +export const calculateNextExpiry = (nextResetAt: number, config: Rollover) => { + if (nullish(config)) { + return null; + } + + let nextExpiry = new Date(nextResetAt); + if (config!.duration === ProductItemInterval.Month) { + nextExpiry.setMonth(nextExpiry.getMonth() + config!.length); + } + + return nextExpiry.getTime(); +}; diff --git a/server/src/internal/customers/cusProducts/cusEnts/groupByUtils.ts b/server/src/internal/customers/cusProducts/cusEnts/groupByUtils.ts index 0d672433a..6c04a904a 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/groupByUtils.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/groupByUtils.ts @@ -1,30 +1,32 @@ -import { FullCustomerEntitlement } from "@autumn/shared"; +import { + FullCustomerEntitlement, +} from "@autumn/shared"; import { notNullish } from "@/utils/genUtils.js"; export const getResetBalancesUpdate = ({ - cusEnt, - allowance, + cusEnt, + allowance, }: { - cusEnt: FullCustomerEntitlement; - allowance?: number; + cusEnt: FullCustomerEntitlement; + allowance?: number; }) => { - let update = {}; - let newBalance = notNullish(allowance) - ? allowance! - : cusEnt.entitlement.allowance || 0; + let update = {}; + let newBalance = notNullish(allowance) + ? allowance! + : cusEnt.entitlement.allowance || 0; - let entitlement = cusEnt.entitlement; + let entitlement = cusEnt.entitlement; - if (notNullish(entitlement.entity_feature_id)) { - let newEntities = { ...cusEnt.entities }; - for (const entityId in newEntities) { - newEntities[entityId].balance = newBalance; - newEntities[entityId].adjustment = 0; - } - update = { entities: newEntities }; - } else { - update = { balance: newBalance }; - } + if (notNullish(entitlement.entity_feature_id)) { + let newEntities = { ...cusEnt.entities }; + for (const entityId in newEntities) { + newEntities[entityId].balance = newBalance; + newEntities[entityId].adjustment = 0; + } + update = { entities: newEntities }; + } else { + update = { balance: newBalance }; + } - return update; -}; + return update; +}; \ No newline at end of file From 293b6434b51a176d2a8839ab3e81ccdff18bceaf Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Wed, 23 Jul 2025 15:23:21 +0100 Subject: [PATCH 25/45] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20cascade=20for=20ro?= =?UTF-8?q?llover?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/index.ts | 2 +- shared/models/productModels/rolloverModels/rolloverTable.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/shared/index.ts b/shared/index.ts index 72e76cf21..c514e54ce 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -86,9 +86,9 @@ export * from "./models/cusModels/entityModels/entityTable.js"; export * from "./models/cusModels/entityModels/entityExpand.js"; export * from "./models/cusModels/entityModels/entityResModels.js"; +export * from "./models/productModels/rolloverModels/rolloverTable.js"; export * from "./models/productModels/rolloverModels/rolloverModels.js"; - // 4. Chat Result Models export * from "./models/chatResultModels/chatResultTable.js"; export * from "./models/chatResultModels/chatResultFeature.js"; diff --git a/shared/models/productModels/rolloverModels/rolloverTable.ts b/shared/models/productModels/rolloverModels/rolloverTable.ts index 98b22424a..049aeb11a 100644 --- a/shared/models/productModels/rolloverModels/rolloverTable.ts +++ b/shared/models/productModels/rolloverModels/rolloverTable.ts @@ -25,6 +25,8 @@ export const rollovers = pgTable( columns: [table.cus_ent_id], foreignColumns: [customerEntitlements.id], name: "rollover_cus_ent_id_fkey", - }), + }) + .onUpdate("cascade") + .onDelete("cascade"), ] ).enableRLS(); From fc4ee52a12b4d21100647bdd73e0d6082cc541c4 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Wed, 23 Jul 2025 15:23:52 +0100 Subject: [PATCH 26/45] =?UTF-8?q?style:=20=F0=9F=92=84=20clean=20up=20weir?= =?UTF-8?q?d=20json=20dumps=20in=20console?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../webhookHandlers/handleSubUpdated.ts | 9 +-------- .../attach/attachUtils/getAttachFunction.ts | 19 +++++++++++-------- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/server/src/external/stripe/webhookHandlers/handleSubUpdated.ts b/server/src/external/stripe/webhookHandlers/handleSubUpdated.ts index 173ec87f4..20df69153 100644 --- a/server/src/external/stripe/webhookHandlers/handleSubUpdated.ts +++ b/server/src/external/stripe/webhookHandlers/handleSubUpdated.ts @@ -74,14 +74,7 @@ export const handleSubscriptionUpdated = async ({ if (updatedCusProducts.length > 0) { logger.info( - `subscription.updated: updated ${updatedCusProducts.length} customer products`, - { - data: { - ids: updatedCusProducts.map((cp) => cp.id), - status: updatedCusProducts[0].status, - canceled_at: updatedCusProducts[0].canceled_at, - }, - }, + `✅ Updated ${updatedCusProducts.length} customer product${updatedCusProducts.length === 1 ? '' : 's'} (${updatedCusProducts.map(cp => cp.id).join(', ')}) - Status: ${updatedCusProducts[0].status}${updatedCusProducts[0].canceled_at ? `, Canceled: ${new Date(updatedCusProducts[0].canceled_at).toISOString()}` : ''}` ); } diff --git a/server/src/internal/customers/attach/attachUtils/getAttachFunction.ts b/server/src/internal/customers/attach/attachUtils/getAttachFunction.ts index 1272054b0..015c58f81 100644 --- a/server/src/internal/customers/attach/attachUtils/getAttachFunction.ts +++ b/server/src/internal/customers/attach/attachUtils/getAttachFunction.ts @@ -142,15 +142,18 @@ export const runAttachFunction = async ({ logger.info(`Entity: ${customer.entity.name} (${customer.entity.id})`); } logger.info( - `Branch: ${chalk.yellow(branch)}, Function: ${chalk.yellow(attachFunction)}`, - { - data: { - curMainProduct: curMainProduct?.product.id, - curSameProduct: curSameProduct?.product.id, - curScheduledProduct: curScheduledProduct?.product.id, - }, - } + `Branch: ${chalk.yellow(branch)}, Function: ${chalk.yellow(attachFunction)}` ); + + if (curMainProduct) { + logger.info(`→ Current Main Product: ${curMainProduct.product.id}`); + } + if (curSameProduct) { + logger.info(`→ Current Same Product: ${curSameProduct.product.id}`); + } + if (curScheduledProduct) { + logger.info(`→ Current Scheduled Product: ${curScheduledProduct.product.id}`); + } // config.proration = ProrationBehavior.None; // attachParams.billingAnchor = 1781702400000; From 31ade93ca1459f6c287981e09cfb2d6d46801909 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Wed, 23 Jul 2025 15:24:29 +0100 Subject: [PATCH 27/45] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20rolloverUtils=20pr?= =?UTF-8?q?ioritises=20entities=20and=20then=20balances?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cusEnts/cusRollovers/RolloverService.ts | 3 +- .../cusEnts/cusRollovers/rolloverUtils.ts | 55 +++++++++++++------ 2 files changed, 38 insertions(+), 20 deletions(-) diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts index ffd223575..7df083d92 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts @@ -1,6 +1,5 @@ import { DrizzleCli } from "@/db/initDrizzle.js"; -import { RolloverModel } from "@autumn/shared"; -import { rollovers } from "@shared/db/schema.js"; +import { RolloverModel, rollovers } from "@autumn/shared"; import { eq, inArray } from "drizzle-orm"; export class RolloverService { diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.ts b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.ts index a2c2be864..ba03487f1 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.ts @@ -11,11 +11,9 @@ import { randomUUID } from "crypto"; export const getRolloverUpdates = ({ cusEnt, - allowance, - nextResetAt, + nextResetAt }: { cusEnt: FullCustomerEntitlement; - allowance?: number; nextResetAt: number; }) => { let update: { @@ -27,7 +25,6 @@ export const getRolloverUpdates = ({ toInsert: [], toUpdate: [], }; - if (nullish(cusEnt.entitlement.rollover) || !cusEnt.entitlement.rollover) { return update; } @@ -36,52 +33,74 @@ export const getRolloverUpdates = ({ nextResetAt, cusEnt.entitlement.rollover ); + if (nullish(nextExpiry) || !nextExpiry) { return update; } - let entitlement = cusEnt.entitlement.allowance; + let entitlement = cusEnt.entitlement.allowance ?? 0; - if (nullish(entitlement) || !entitlement) { + if (entitlement < 0) { return update; } - let balance = cusEnt.balance || 0; - - let rollover = entitlement! - balance; + let rollover = cusEnt.balance || 0; console.log( - `Rollover: ${rollover} | Entitlement: ${entitlement} | Balance: ${balance}` + `🔥 Unused balance (rollover): ${rollover} | Entitlement: ${entitlement}` ); let newEntitlement = { cus_ent_id: cusEnt.id, - balance: rollover, + balance: 0, expires_at: nextExpiry, entities: [] as EntityRolloverBalance[], id: randomUUID() as string, }; - console.log("🏢 entities:", cusEnt.entities); - console.log("📋 entitlement:", cusEnt.entitlement); - console.log("🆔 entity feature id:", cusEnt.entitlement.entity_feature_id); + if (cusEnt.entities != null) + console.log( + "🏢 entities:", + Object.values(cusEnt.entities).map((x: any) => `${x.id}: ${x.balance}`) + ); + else console.log("🏢 entities: none"); + console.log( + "📋 entitlement:", + cusEnt.entitlement.feature_id, + "| 🆔 entity_feature_id:", + cusEnt.entitlement.entity_feature_id, + "| allowance:", + cusEnt.entitlement.allowance + ); if (notNullish(cusEnt.entitlement.entity_feature_id)) { console.log("🔍 newEntities:", cusEnt.entities); for (const entityId in cusEnt.entities) { - let entRollover = entitlement! - cusEnt.entities[entityId].balance; + let entRollover = cusEnt.entities[entityId].balance; if (entRollover > 0) { newEntitlement.entities.push({ id: entityId, balance: entRollover, }); - } + console.log("🔍 entityId:", entityId, "entRollover:", entRollover); + } else console.log("🔍 no rollover for entityId:", entityId, " | entitlement:", entitlement, " | balance:", cusEnt.entities[entityId].balance); } update.toInsert.push(newEntitlement); } else { - update.toInsert.push(newEntitlement); + if (rollover > 0) { + newEntitlement.balance = rollover; + update.toInsert.push(newEntitlement); + } else console.log("🔍 no rollover for entitlement: ", cusEnt.id, " | rollable balance:", rollover); } - console.log("Rollover update", update); + console.log( + "Rollover update sending from rolloverUtils:", + update.toInsert.map((rollover) => ({ + id: rollover.id, + balance: rollover.balance, + entities: rollover.entities.map((entity) => `${entity.id}: ${entity.balance}`).join(", "), + expires_at: rollover.expires_at ? new Date(rollover.expires_at).toISOString() : null, + })) + ); return update; }; From e3615f8e88810477920f7a1d1cc2c12f44c38e6d Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Wed, 23 Jul 2025 15:25:20 +0100 Subject: [PATCH 28/45] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20get=20and=20insert?= =?UTF-8?q?=20rollover=20updates=20across=203=20reset=20triggers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/cron.ts | 20 ++++++++--- .../handlePrepaidPrices.ts | 34 +++++++++++++++++++ .../handleInvoiceCreated/handleUsagePrices.ts | 34 +++++++++++++++++++ 3 files changed, 83 insertions(+), 5 deletions(-) diff --git a/server/src/cron.ts b/server/src/cron.ts index dc7ef8d8d..ec91fb6f5 100644 --- a/server/src/cron.ts +++ b/server/src/cron.ts @@ -175,8 +175,7 @@ const resetCustomerEntitlement = async ({ let rolloverUpdate = getRolloverUpdates({ cusEnt, - allowance: resetBalance || undefined, - nextResetAt, + nextResetAt: cusEnt.next_reset_at! as number, }); let resetBalanceUpdate = getResetBalancesUpdate({ @@ -185,7 +184,15 @@ const resetCustomerEntitlement = async ({ }); - console.log("Rollover update", rolloverUpdate); + console.log( + "Rollover update received in cron.ts/resetCustomerEntitlement:", + rolloverUpdate.toInsert.map((rollover) => ({ + id: rollover.id, + balance: rollover.balance, + entities: rollover.entities.map((entity) => `${entity.id}: ${entity.balance}`).join(", "), + expires_at: rollover.expires_at ? new Date(rollover.expires_at).toISOString() : null, + })) + ); try { @@ -217,8 +224,11 @@ const resetCustomerEntitlement = async ({ }); } - console.log("Rollover rows", rolloverRows); - + console.log( + "Rollover rows", + Object.values(rolloverRows).map((x) => `${x.id}: ${x.balance} | entities: ${x.entities.map((y: any) => `${y.id}: ${y.balance}`).join(", ")}`) + ); + console.log( `Reset ${cusEnt.id} | customer: ${chalk.yellow( cusEnt.customer_id diff --git a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handlePrepaidPrices.ts b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handlePrepaidPrices.ts index f24412676..47ca3215c 100644 --- a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handlePrepaidPrices.ts +++ b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handlePrepaidPrices.ts @@ -1,6 +1,8 @@ import { DrizzleCli } from "@/db/initDrizzle.js"; import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js"; import { getResetBalance } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js"; +import { RolloverService } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.js"; +import { getRolloverUpdates } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.js"; import { getRelatedCusEnt } from "@/internal/customers/cusProducts/cusPrices/cusPriceUtils.js"; import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; import { getEntOptions } from "@/internal/products/prices/priceUtils.js"; @@ -65,6 +67,22 @@ export const handlePrepaidPrices = async ({ const ent = cusEnt.entitlement; + let rolloverUpdate = getRolloverUpdates({ + cusEnt, + nextResetAt: usageSub.current_period_end * 1000, + }); + console.log("🔍 rolloverUpdate", rolloverUpdate); + + console.log( + "Rollover update received in handlePrepaidPrices:", + rolloverUpdate.toInsert.map((rollover) => ({ + id: rollover.id, + balance: rollover.balance, + entities: rollover.entities.map((entity) => `${entity.id}: ${entity.balance}`).join(", "), + expires_at: rollover.expires_at ? new Date(rollover.expires_at).toISOString() : null, + })) + ); + if (notNullish(options?.upcoming_quantity)) { const newOptions = cusProduct.options.map((o) => { if (o.feature_id == ent.feature_id) { @@ -104,6 +122,22 @@ export const handlePrepaidPrices = async ({ `🔥 Resetting balance for ${ent.feature.id}, customer: ${customer.id} (name: ${customer.name})`, ); + let rolloverRows: any[] = []; + if (rolloverUpdate?.toInsert && rolloverUpdate.toInsert.length > 0) { + logger.info( + `🔥 Rolling over balance for ${ent.feature.id}, customer: ${customer.id} (name: ${customer.name})`, + ); + rolloverRows = await RolloverService.insert({ + db, + rows: rolloverUpdate.toInsert, + }); + } + + console.log( + "Rollover rows", + Object.values(rolloverRows).map((x) => `${x.id}: ${x.balance} | entities: ${x.entities.map((y: any) => `${y.id}: ${y.balance}`).join(", ")}`) + ); + await CusEntService.update({ db, id: cusEnt.id, diff --git a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleUsagePrices.ts b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleUsagePrices.ts index 559e09544..5991ec824 100644 --- a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleUsagePrices.ts +++ b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleUsagePrices.ts @@ -16,6 +16,8 @@ import { submitUsageToStripe } from "../../stripeMeterUtils.js"; import { getInvoiceItemForUsage } from "../../stripePriceUtils.js"; import { getCusPriceUsage } from "@/internal/customers/cusProducts/cusPrices/cusPriceUtils.js"; import { findStripeItemForPrice } from "../../stripeSubUtils/stripeSubItemUtils.js"; +import { getRolloverUpdates } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.js"; +import { RolloverService } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.js"; export const handleUsagePrices = async ({ db, @@ -124,6 +126,22 @@ export const handleUsagePrices = async ({ } let ent = relatedCusEnt.entitlement; + + let rolloverUpdate = getRolloverUpdates({ + cusEnt: relatedCusEnt, + nextResetAt: usageSub.current_period_end * 1000, + }); + + console.log( + "Rollover update received in handleUsagePrices:", + rolloverUpdate.toInsert.map((rollover) => ({ + id: rollover.id, + balance: rollover.balance, + entities: rollover.entities.map((entity) => `${entity.id}: ${entity.balance}`).join(", "), + expires_at: rollover.expires_at ? new Date(rollover.expires_at).toISOString() : null, + })) + ); + let resetBalancesUpdate = getResetBalancesUpdate({ cusEnt: relatedCusEnt, allowance: ent.interval == EntInterval.Lifetime ? 0 : ent.allowance!, @@ -141,5 +159,21 @@ export const handleUsagePrices = async ({ }, }); + let rolloverRows: any[] = []; + if (rolloverUpdate?.toInsert && rolloverUpdate.toInsert.length > 0) { + logger.info( + `🔥 Rolling over balance for ${ent.feature.id}, customer: ${customer.id} (name: ${customer.name})`, + ); + rolloverRows = await RolloverService.insert({ + db, + rows: rolloverUpdate.toInsert, + }); + } + + console.log( + "Rollover rows", + Object.values(rolloverRows).map((x) => `${x.id}: ${x.balance} | entities: ${x.entities.map((y: any) => `${y.id}: ${y.balance}`).join(", ")}`) + ); + logger.info("✅ Successfully reset balance"); }; From 70d9c292156cf8aec2b334ca6b8531ab3b4f7dcc Mon Sep 17 00:00:00 2001 From: John Yeo Date: Thu, 24 Jul 2025 12:30:51 +0100 Subject: [PATCH 29/45] feat: rollover max can be unlimited and duration forever --- .../productItemUtils/itemToPriceAndEnt.ts | 1 - .../product-items/validateProductItems.ts | 10 +- .../productModels/entModels/entModels.ts | 6 +- .../productModels/entModels/entTable.ts | 8 +- .../rolloverModels/rolloverTable.ts | 48 +-- .../productItemModels/productItemModels.ts | 126 ++++---- shared/package.json | 1 + .../product-item/validateProductItem.ts | 89 ++++-- .../advanced-config/AdvancedItemConfig.tsx | 298 +++++++----------- .../advanced-config/RolloverConfig.tsx | 130 ++++++++ .../components/feature-price/FeaturePrice.tsx | 18 +- 11 files changed, 412 insertions(+), 323 deletions(-) create mode 100644 vite/src/views/products/product/product-item/product-item-config/advanced-config/RolloverConfig.tsx 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 049aeb11a..aafe3c6d7 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 } 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"), - ] + "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().array(), + }, + (table) => [ + foreignKey({ + columns: [table.cus_ent_id], + foreignColumns: [customerEntitlements.id], + name: "rollover_cus_ent_id_fkey", + }) + .onUpdate("cascade") + .onDelete("cascade"), + ] ).enableRLS(); 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 47be76192..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 } 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,44 +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 (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() {
Date: Thu, 24 Jul 2025 13:20:29 +0100 Subject: [PATCH 30/45] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20lots=20of=20stuff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/cron.ts | 458 +++++++++--------- .../handlePrepaidPrices.ts | 3 + .../handleInvoiceCreated/handleUsagePrices.ts | 4 + .../cusEnts/cusRollovers/RolloverService.ts | 136 +++++- .../cusEnts/cusRollovers/rolloverUtils.ts | 200 +++++++- .../src/internal/customers/getFullCusQuery.ts | 10 +- .../rolloverModels/rolloverTable.ts | 4 +- .../product-item/validateProductItem.ts | 9 +- 8 files changed, 580 insertions(+), 244 deletions(-) diff --git a/server/src/cron.ts b/server/src/cron.ts index ec91fb6f5..3c94f1d68 100644 --- a/server/src/cron.ts +++ b/server/src/cron.ts @@ -1,9 +1,9 @@ import { - AllowanceType, - AppEnv, - EntInterval, - FullCusEntWithProduct, - Organization, + AllowanceType, + AppEnv, + EntInterval, + FullCusEntWithProduct, + Organization, } from "@autumn/shared"; import { CusEntService } from "./internal/customers/cusProducts/cusEnts/CusEntitlementService.js"; @@ -15,8 +15,8 @@ import chalk from "chalk"; import { format, getDate, getMonth, setDate } from "date-fns"; import { CronJob } from "cron"; import { - getRelatedCusPrice, - getResetBalance, + getRelatedCusPrice, + getResetBalance, } from "./internal/customers/cusProducts/cusEnts/cusEntUtils.js"; import { getResetBalancesUpdate } from "./internal/customers/cusProducts/cusEnts/groupByUtils.js"; import { getRolloverUpdates } from "./internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.js"; @@ -24,6 +24,7 @@ import { CusProductService } from "./internal/customers/cusProducts/CusProductSe import { createStripeCli } from "./external/stripe/utils.js"; import { UTCDate } from "@date-fns/utc"; import { type DrizzleCli, initDrizzle } from "./db/initDrizzle.js"; +import { notNullish } from "./utils/genUtils.js"; import { CusPriceService } from "./internal/customers/cusProducts/cusPrices/CusPriceService.js"; import { RolloverService } from "./internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.js"; @@ -31,271 +32,274 @@ import { RolloverService } from "./internal/customers/cusProducts/cusEnts/cusRol dotenv.config(); const checkSubAnchor = async ({ - db, - cusEnt, - nextResetAt, + db, + cusEnt, + nextResetAt, }: { - db: DrizzleCli; - cusEnt: FullCusEntWithProduct; - nextResetAt: number; + db: DrizzleCli; + cusEnt: FullCusEntWithProduct; + nextResetAt: number; }) => { - let nextResetAtDate = new UTCDate(nextResetAt); + let nextResetAtDate = new UTCDate(nextResetAt); - // If nextResetAt is on the 28th of March, or Day 30, then do this check. - const nextResetAtDay = getDate(nextResetAtDate); - const nextResetAtMonth = getMonth(nextResetAtDate); + // If nextResetAt is on the 28th of March, or Day 30, then do this check. + const nextResetAtDay = getDate(nextResetAtDate); + const nextResetAtMonth = getMonth(nextResetAtDate); - const shouldCheck = - nextResetAtDay === 30 || (nextResetAtDay === 28 && nextResetAtMonth === 2); + const shouldCheck = + nextResetAtDay === 30 || + (nextResetAtDay === 28 && nextResetAtMonth === 2); - if (!shouldCheck) { - return nextResetAt; - } + if (!shouldCheck) { + return nextResetAt; + } - // 1. Get the customer product - const cusProduct = await CusProductService.getByIdForReset({ - db, - id: cusEnt.customer_product_id, - }); + // 1. Get the customer product + const cusProduct = await CusProductService.getByIdForReset({ + db, + id: cusEnt.customer_product_id, + }); - // Get org and env - const env = cusProduct.product.env as AppEnv; - const org = cusProduct.product.org as Organization; + // Get org and env + const env = cusProduct.product.env as AppEnv; + const org = cusProduct.product.org as Organization; - const stripeCli = createStripeCli({ org, env }); - if (!cusProduct.subscription_ids || cusProduct.subscription_ids.length == 0) { - return nextResetAt; - } + const stripeCli = createStripeCli({ org, env }); + if ( + !cusProduct.subscription_ids || + cusProduct.subscription_ids.length == 0 + ) { + return nextResetAt; + } - const subId = cusProduct.subscription_ids[0]; - const sub = await stripeCli.subscriptions.retrieve(subId); + const subId = cusProduct.subscription_ids[0]; + const sub = await stripeCli.subscriptions.retrieve(subId); - const billingCycleAnchor = sub.billing_cycle_anchor * 1000; - console.log("Checking billing cycle anchor"); - console.log( - "Next reset at ", - format(new UTCDate(nextResetAt), "dd MMM yyyy HH:mm:ss") - ); - console.log( - "Billing cycle anchor", - format(new UTCDate(billingCycleAnchor), "dd MMM yyyy HH:mm:ss") - ); + const billingCycleAnchor = sub.billing_cycle_anchor * 1000; + console.log("Checking billing cycle anchor"); + console.log( + "Next reset at ", + format(new UTCDate(nextResetAt), "dd MMM yyyy HH:mm:ss") + ); + console.log( + "Billing cycle anchor", + format(new UTCDate(billingCycleAnchor), "dd MMM yyyy HH:mm:ss") + ); - const billingCycleDay = getDate(new UTCDate(billingCycleAnchor)); - const nextResetDay = getDate(nextResetAtDate); + const billingCycleDay = getDate(new UTCDate(billingCycleAnchor)); + const nextResetDay = getDate(nextResetAtDate); - if (billingCycleDay > nextResetDay) { - nextResetAtDate = setDate(nextResetAtDate, billingCycleDay); - return nextResetAtDate.getTime(); - } else { - return nextResetAt; - } + if (billingCycleDay > nextResetDay) { + nextResetAtDate = setDate(nextResetAtDate, billingCycleDay); + return nextResetAtDate.getTime(); + } else { + return nextResetAt; + } }; const resetCustomerEntitlement = async ({ - db, - cusEnt, + db, + cusEnt, }: { - db: DrizzleCli; - cusEnt: FullCusEntWithProduct; + db: DrizzleCli; + cusEnt: FullCusEntWithProduct; }) => { - try { - if (cusEnt.usage_allowed) { - return; - } + try { + if (cusEnt.usage_allowed) { + return; + } - // Fetch related price - const cusPrices = await CusPriceService.getByCustomerProductId({ - db, - customerProductId: cusEnt.customer_product_id, - }); + // Fetch related price + const cusPrices = await CusPriceService.getByCustomerProductId({ + db, + customerProductId: cusEnt.customer_product_id, + }); - // 2. Quantity is from prices... - const relatedCusPrice = getRelatedCusPrice(cusEnt, cusPrices); - if (relatedCusPrice) { - return; - } + // 2. Quantity is from prices... + const relatedCusPrice = getRelatedCusPrice(cusEnt, cusPrices); + if (relatedCusPrice) { + return; + } - const entOptions = getEntOptions( - cusEnt.customer_product.options, - cusEnt.entitlement - ); + const entOptions = getEntOptions( + cusEnt.customer_product.options, + cusEnt.entitlement + ); - const resetBalance = getResetBalance({ - entitlement: cusEnt.entitlement, - options: entOptions, - relatedPrice: undefined, - // relatedPrice: relatedCusPrice, - }); + const resetBalance = getResetBalance({ + entitlement: cusEnt.entitlement, + options: entOptions, + relatedPrice: undefined, + // relatedPrice: relatedCusPrice, + }); - // Handle if entitlement changed to unlimited... - let entitlement = cusEnt.entitlement; - if (entitlement.allowance_type === AllowanceType.Unlimited) { - await CusEntService.update({ - db, - id: cusEnt.id, - updates: { - unlimited: true, - next_reset_at: null, - }, - }); + // Handle if entitlement changed to unlimited... + let entitlement = cusEnt.entitlement; + if (entitlement.allowance_type === AllowanceType.Unlimited) { + await CusEntService.update({ + db, + id: cusEnt.id, + updates: { + unlimited: true, + next_reset_at: null, + }, + }); - console.log( - `Reset ${cusEnt.id} | customer: ${chalk.yellow( - cusEnt.customer_id - )} | feature: ${chalk.yellow( - cusEnt.feature_id - )} | new balance: unlimited` - ); - return; - } + console.log( + `Reset ${cusEnt.id} | customer: ${chalk.yellow( + cusEnt.customer_id + )} | feature: ${chalk.yellow( + cusEnt.feature_id + )} | new balance: unlimited` + ); + return; + } - if (entitlement.interval === EntInterval.Lifetime) { - await CusEntService.update({ - db, - id: cusEnt.id, - updates: { - next_reset_at: null, - }, - }); + if (entitlement.interval === EntInterval.Lifetime) { + await CusEntService.update({ + db, + id: cusEnt.id, + updates: { + next_reset_at: null, + }, + }); - console.log( - `Reset ${cusEnt.id} | customer: ${chalk.yellow( - cusEnt.customer_id - )} | feature: ${chalk.yellow( - cusEnt.feature_id - )} | reset to lifetime (next_reset_at: null)` - ); - return; - } - let nextResetAt = getNextResetAt( - new UTCDate(cusEnt.next_reset_at!), - cusEnt.entitlement.interval as EntInterval - ); + console.log( + `Reset ${cusEnt.id} | customer: ${chalk.yellow( + cusEnt.customer_id + )} | feature: ${chalk.yellow( + cusEnt.feature_id + )} | reset to lifetime (next_reset_at: null)` + ); + return; + } + let nextResetAt = getNextResetAt( + new UTCDate(cusEnt.next_reset_at!), + cusEnt.entitlement.interval as EntInterval + ); - let rolloverUpdate = getRolloverUpdates({ - cusEnt, - nextResetAt: cusEnt.next_reset_at! as number, - }); + let rolloverUpdate = getRolloverUpdates({ + cusEnt, + nextResetAt: cusEnt.next_reset_at! as number, + }); - let resetBalanceUpdate = getResetBalancesUpdate({ - cusEnt, - allowance: resetBalance || undefined, - }); + let resetBalanceUpdate = getResetBalancesUpdate({ + cusEnt, + allowance: resetBalance || undefined, + }); + console.log( + "Rollover update recieved in cron.ts/resetCustomerEntitlement", + rolloverUpdate + ); - console.log( - "Rollover update received in cron.ts/resetCustomerEntitlement:", - rolloverUpdate.toInsert.map((rollover) => ({ - id: rollover.id, - balance: rollover.balance, - entities: rollover.entities.map((entity) => `${entity.id}: ${entity.balance}`).join(", "), - expires_at: rollover.expires_at ? new Date(rollover.expires_at).toISOString() : null, - })) - ); + try { + nextResetAt = await checkSubAnchor({ + db, + cusEnt, + nextResetAt, + }); + } catch (error) { + console.log("WARNING: Failed to check sub anchor"); + console.log(error); + } + await CusEntService.update({ + db, + id: cusEnt.id, + updates: { + ...resetBalanceUpdate, + next_reset_at: nextResetAt, + adjustment: 0, + }, + }); - try { - nextResetAt = await checkSubAnchor({ - db, - cusEnt, - nextResetAt, - }); - } catch (error) { - console.log("WARNING: Failed to check sub anchor"); - console.log(error); - } + let rolloverRows: any[] = []; + if (rolloverUpdate?.toInsert && rolloverUpdate.toInsert.length > 0) { + rolloverRows = await RolloverService.insert({ + db, + rows: rolloverUpdate.toInsert, + rolloverConfig: cusEnt.entitlement.rollover, + cusEntID: cusEnt.id, + entityMode: notNullish(cusEnt.entitlement.entity_feature_id), + }); + } - await CusEntService.update({ - db, - id: cusEnt.id, - updates: { - ...resetBalanceUpdate, - next_reset_at: nextResetAt, - adjustment: 0, - }, - }); + console.log( + "Rollover rows", + Object.values(rolloverRows).map( + (x) => + `${x.id}: ${x.balance} | entities: ${x.entities.map((y: any) => `${y.id}: ${y.balance}`).join(", ")}` + ) + ); - let rolloverRows: any[] = []; - if (rolloverUpdate?.toInsert && rolloverUpdate.toInsert.length > 0) { - rolloverRows = await RolloverService.insert({ - db, - rows: rolloverUpdate.toInsert, - }); - } - - console.log( - "Rollover rows", - Object.values(rolloverRows).map((x) => `${x.id}: ${x.balance} | entities: ${x.entities.map((y: any) => `${y.id}: ${y.balance}`).join(", ")}`) - ); - - console.log( - `Reset ${cusEnt.id} | customer: ${chalk.yellow( - cusEnt.customer_id - )} | feature: ${chalk.yellow( - cusEnt.feature_id - )} | new balance: ${chalk.green( - resetBalance - )} | new next_reset_at: ${chalk.green( - format(new UTCDate(nextResetAt), "dd MMM yyyy HH:mm:ss") - )}` - ); - } catch (error: any) { - console.log( - `Failed to reset ${cusEnt.id} | ${cusEnt.customer_id} | ${cusEnt.feature_id}, error: ${error}` - ); - } + console.log( + `Reset ${cusEnt.id} | customer: ${chalk.yellow( + cusEnt.customer_id + )} | feature: ${chalk.yellow( + cusEnt.feature_id + )} | new balance: ${chalk.green( + resetBalance + )} | new next_reset_at: ${chalk.green( + format(new UTCDate(nextResetAt), "dd MMM yyyy HH:mm:ss") + )}` + ); + } catch (error: any) { + console.log( + `Failed to reset ${cusEnt.id} | ${cusEnt.customer_id} | ${cusEnt.feature_id}, error: ${error}` + ); + } }; export const cronTask = async () => { - console.log( - "\n----------------------------------\nRUNNING RESET CRON:", - format(new UTCDate(), "yyyy-MM-dd HH:mm:ss") - ); + console.log( + "\n----------------------------------\nRUNNING RESET CRON:", + format(new UTCDate(), "yyyy-MM-dd HH:mm:ss") + ); - const { db, client } = initDrizzle(); + const { db, client } = initDrizzle(); - try { - let cusEnts: FullCusEntWithProduct[] = - await CusEntService.getActiveResetPassed({ db, batchSize: 500 }); + try { + let cusEnts: FullCusEntWithProduct[] = + await CusEntService.getActiveResetPassed({ db, batchSize: 500 }); - const batchSize = 50; - for (let i = 0; i < cusEnts.length; i += batchSize) { - const batch = cusEnts.slice(i, i + batchSize); - const batchResets = []; - for (const cusEnt of batch) { - batchResets.push( - resetCustomerEntitlement({ - db, - cusEnt: cusEnt as FullCusEntWithProduct, - }) - ); - } + const batchSize = 50; + for (let i = 0; i < cusEnts.length; i += batchSize) { + const batch = cusEnts.slice(i, i + batchSize); + const batchResets = []; + for (const cusEnt of batch) { + batchResets.push( + resetCustomerEntitlement({ + db, + cusEnt: cusEnt as FullCusEntWithProduct, + }) + ); + } - await Promise.all(batchResets); - } + await Promise.all(batchResets); + } - console.log( - "FINISHED RESET CRON:", - format(new UTCDate(), "yyyy-MM-dd HH:mm:ss") - ); - console.log("----------------------------------\n"); - } catch (error) { - console.error("Error getting entitlements for reset:", error); - return; - } + console.log( + "FINISHED RESET CRON:", + format(new UTCDate(), "yyyy-MM-dd HH:mm:ss") + ); + console.log("----------------------------------\n"); + } catch (error) { + console.error("Error getting entitlements for reset:", error); + return; + } - await client.end(); + await client.end(); }; const job = new CronJob( - "* * * * *", // Run every minute - function () { - cronTask(); - }, - null, // onComplete - true, // start immediately - "UTC" // timezone (adjust as needed) + "* * * * *", // Run every minute + function () { + cronTask(); + }, + null, // onComplete + true, // start immediately + "UTC" // timezone (adjust as needed) ); // job.start(); diff --git a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handlePrepaidPrices.ts b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handlePrepaidPrices.ts index 47ca3215c..364af8762 100644 --- a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handlePrepaidPrices.ts +++ b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handlePrepaidPrices.ts @@ -130,6 +130,9 @@ export const handlePrepaidPrices = async ({ rolloverRows = await RolloverService.insert({ db, rows: rolloverUpdate.toInsert, + rolloverConfig: ent.rollover, + 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 5991ec824..9eb1e1083 100644 --- a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleUsagePrices.ts +++ b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleUsagePrices.ts @@ -18,6 +18,7 @@ import { getCusPriceUsage } from "@/internal/customers/cusProducts/cusPrices/cus import { findStripeItemForPrice } from "../../stripeSubUtils/stripeSubItemUtils.js"; import { getRolloverUpdates } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.js"; import { RolloverService } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.js"; +import { notNullish } from "@/utils/genUtils.js"; export const handleUsagePrices = async ({ db, @@ -167,6 +168,9 @@ export const handleUsagePrices = async ({ rolloverRows = await RolloverService.insert({ db, rows: rolloverUpdate.toInsert, + rolloverConfig: ent.rollover, + 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 7df083d92..1e388293e 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts @@ -1,6 +1,11 @@ import { DrizzleCli } from "@/db/initDrizzle.js"; -import { RolloverModel, rollovers } from "@autumn/shared"; -import { eq, inArray } from "drizzle-orm"; +import { + Rollover as RolloverConfig, + RolloverModel, + rollovers, +} from "@autumn/shared"; +import { and, eq, gte, inArray } from "drizzle-orm"; +import { performMaximumClearing } from "./rolloverUtils.js"; export class RolloverService { static async update({ @@ -21,24 +26,135 @@ export class RolloverService { return data; } - static async insert({ + static async bulkUpdate({ db, rows, }: { db: DrizzleCli; rows: RolloverModel[]; }) { - const data = await db.insert(rollovers).values(rows as any).returning(); - return data; + return await db.transaction(async (tx) => { + const results = []; + for (const row of rows) { + const result = await tx + .update(rollovers) + .set(row as any) + .where(eq(rollovers.id, row.id)) + .returning(); + results.push(...result); + } + return results; + }); } - static async delete({ + static async insert({ db, - ids, + rows, + rolloverConfig, + cusEntID, + entityMode, }: { db: DrizzleCli; - ids: string[]; + rows: RolloverModel[]; + rolloverConfig: RolloverConfig; + cusEntID: string; + entityMode: boolean; }) { - const data = await db.delete(rollovers).where(inArray(rollovers.id, ids)); + const data = await db + .insert(rollovers) + .values(rows as any) + .returning(); + + console.log("🔍 cusEntID", cusEntID, data[0].cus_ent_id); + + const currentRolloverRows = await db + .select() + .from(rollovers) + .where( + and( + eq(rollovers.cus_ent_id, cusEntID), + gte(rollovers.expires_at, new Date().getTime()) + ) + ); + + console.log("🔍 rolloverCusEnt:"); + currentRolloverRows.forEach((rollover, index) => { + console.log(` [${index}] ID: ${rollover.id}`); + console.log(` Customer Entity ID: ${rollover.cus_ent_id}`); + console.log(` Balance: ${rollover.balance}`); + console.log( + ` Expires At: ${new Date(rollover.expires_at).toISOString()}` + ); + if (rollover.entities && Array.isArray(rollover.entities)) { + console.log( + ` Entities: ${rollover.entities.length} items` + ); + rollover.entities.forEach( + (entity: any, entityIndex: number) => { + console.log( + ` [${entityIndex}] ID: ${entity.id}, Balance: ${entity.balance}, Adjustment: ${entity.adjustment}` + ); + } + ); + } + console.log(""); + }); + + console.log("🔍 rolloverConfig:"); + console.log(` Max: ${rolloverConfig?.max}`); + console.log(` Length: ${rolloverConfig?.length}`); + console.log(` Duration: ${rolloverConfig?.duration}`); + + let { toDelete, toUpdate } = await performMaximumClearing({ + rows: currentRolloverRows as RolloverModel[], + rolloverConfig, + cusEntID, + entityMode, + }); + + if (toDelete.length > 0) { + await RolloverService.delete({ db, ids: toDelete }); + } + + if (toUpdate.length > 0) { + await RolloverService.bulkUpdate({ db, rows: toUpdate }); + } + + // Update data in memory to reflect the changes made by performMaximumClearing + let updatedData = [...data]; + + // Remove deleted items from the data + if (toDelete.length > 0) { + updatedData = updatedData.filter(item => !toDelete.includes(item.id)); + } + + // Update modified items in the data + if (toUpdate.length > 0) { + updatedData = updatedData.map(item => { + const updateItem = toUpdate.find(update => update.id === item.id); + if (updateItem) { + // Ensure entities have the correct structure with adjustment property + const updatedEntities = updateItem.entities?.map(entity => ({ + id: entity.id, + balance: entity.balance, + })) ?? null; + + return { + ...item, + ...updateItem, + entities: updatedEntities + }; + } + return item; + }); + } + + return updatedData; } -} \ No newline at end of file + + static async delete({ db, ids }: { db: DrizzleCli; ids: string[] }) { + const data = await db + .delete(rollovers) + .where(inArray(rollovers.id, ids)); + } +} diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.ts b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.ts index ba03487f1..f42b62fd9 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.ts @@ -84,11 +84,11 @@ export const getRolloverUpdates = ({ console.log("🔍 entityId:", entityId, "entRollover:", entRollover); } else console.log("🔍 no rollover for entityId:", entityId, " | entitlement:", entitlement, " | balance:", cusEnt.entities[entityId].balance); } - update.toInsert.push(newEntitlement); + if(newEntitlement.entities.length > 0) update.toInsert.push(newEntitlement); } else { if (rollover > 0) { newEntitlement.balance = rollover; - update.toInsert.push(newEntitlement); + if(newEntitlement.balance > 0) update.toInsert.push(newEntitlement); } else console.log("🔍 no rollover for entitlement: ", cusEnt.id, " | rollable balance:", rollover); } @@ -117,3 +117,199 @@ export const calculateNextExpiry = (nextResetAt: number, config: Rollover) => { return nextExpiry.getTime(); }; + +export async function performMaximumClearing({ + rows, + rolloverConfig, + cusEntID, + entityMode, +}: { + rows: RolloverModel[]; + rolloverConfig: Rollover; + cusEntID: string; + entityMode: boolean; +}) { + if (!rolloverConfig) { + throw new Error("Rollover config is required"); + } + + let total = 0; + let toDelete: string[] = []; + let toUpdate: RolloverModel[] = []; + + // look through each row + // if entityMode is true, then look through each entity + // otherwise look at balance + + // sort by the oldest first + // add the balance of the oldest to the total + // if the total is greater than or equal to the max, then: + // subtract the max from the total, if theres a difference then instantiate the updated row object and push to toUpdate + // if theres no difference, then push to toDelete + // move to the next row + // if the total is less than the max, then + // move to the next row + + if (!entityMode) { + console.log(`🔄 Starting maximum clearing for cusEnt ${cusEntID} in non-entity mode`); + console.log(`📊 Initial rows count: ${rows.length}`); + console.log(`🎯 Maximum rollover allowed: ${rolloverConfig.max}`); + + rows.sort((a, b) => a.expires_at - b.expires_at); + console.log(`📅 Sorted rows by expiry date (oldest first)`); + + for (let i = 0; i < rows.length; i++) { + let row = rows[i]; + console.log(`\n🔍 Processing row ${i + 1}/${rows.length}:`); + console.log(` - Row ID: ${row.id}`); + console.log(` - Row balance: ${row.balance}`); + console.log(` - Expires at: ${new Date(row.expires_at).toISOString()}`); + console.log(` - Total before adding this row: ${total}`); + + total += row.balance; + console.log(` - Total after adding this row: ${total}`); + + if (total > rolloverConfig.max) { + let diff = total - rolloverConfig.max; + console.log(` - ⚠️ Total exceeds maximum (${rolloverConfig.max})`); + console.log(` - Difference to remove: ${diff}`); + + let newBalance = row.balance - diff; + if (newBalance > 0) { + console.log(` - ✏️ Updating row balance from ${row.balance} to ${newBalance}`); + toUpdate.push({ + ...row, + balance: newBalance, + }); + } else { + console.log(` - 🗑️ Marking row for deletion (no remaining balance)`); + toDelete.push(row.id); + } + } else { + console.log(` - ✅ Total still under maximum, continuing to next row`); + continue; + } + } + + console.log(`\n📋 Maximum clearing summary for cusEnt ${cusEntID}:`); + console.log(` - Final total: ${total}`); + console.log(` - Rows to update: ${toUpdate.length}`); + console.log(` - Rows to delete: ${toDelete.length}`); + if (toUpdate.length > 0) { + console.log(` - Updated balances: ${toUpdate.map(r => `${r.id}: ${r.balance}`).join(', ')}`); + } + if (toDelete.length > 0) { + console.log(` - Deleted row IDs: ${toDelete.join(', ')}`); + } + } else { + console.log(`🔄 Starting maximum clearing for cusEnt ${cusEntID} in entity mode`); + console.log(`📊 Initial rows count: ${rows.length}`); + console.log(`🎯 Maximum rollover allowed: ${rolloverConfig.max}`); + + // Collect all unique entity IDs across all rows + const allEntityIds = new Set(); + rows.forEach(row => { + if (row.entities && Array.isArray(row.entities)) { + row.entities.forEach((entity: any) => { + if (entity.id) { + allEntityIds.add(entity.id); + } + }); + } + }); + + console.log(`🔍 Found ${allEntityIds.size} unique entity IDs: ${Array.from(allEntityIds).join(', ')}`); + + // Sort rows by expiry date (oldest first) + rows.sort((a, b) => a.expires_at - b.expires_at); + console.log(`📅 Sorted rows by expiry date (oldest first)`); + + // Track totals per entity ID + const entityTotals = new Map(); + allEntityIds.forEach(id => entityTotals.set(id, 0)); + + for (let i = 0; i < rows.length; i++) { + let row = rows[i]; + console.log(`\n🔍 Processing row ${i + 1}/${rows.length}:`); + console.log(` - Row ID: ${row.id}`); + console.log(` - Expires at: ${new Date(row.expires_at).toISOString()}`); + + if (!row.entities || !Array.isArray(row.entities)) { + console.log(` - ⚠️ Row has no entities array, skipping`); + continue; + } + + let rowNeedsUpdate = false; + let updatedEntities = [...row.entities]; + + // Process each entity in this row + for (let j = 0; j < updatedEntities.length; j++) { + const entity = updatedEntities[j]; + if (!entity.id || !entity.balance) { + console.log(` - ⚠️ Entity missing id or balance, skipping`); + continue; + } + + const currentTotal = entityTotals.get(entity.id) || 0; + const newTotal = currentTotal + entity.balance; + + console.log(` - Entity ${entity.id}: balance=${entity.balance}, currentTotal=${currentTotal}, newTotal=${newTotal}`); + + if (newTotal > rolloverConfig.max) { + const excess = newTotal - rolloverConfig.max; + const newBalance = entity.balance - excess; + + console.log(` - ⚠️ Total exceeds maximum (${rolloverConfig.max})`); + console.log(` - Excess to remove: ${excess}`); + console.log(` - Updating entity balance from ${entity.balance} to ${newBalance}`); + + if (newBalance > 0) { + updatedEntities[j] = { ...entity, balance: newBalance }; + entityTotals.set(entity.id, rolloverConfig.max); + rowNeedsUpdate = true; + } else { + console.log(` - 🗑️ Removing entity (no remaining balance)`); + updatedEntities.splice(j, 1); + j--; // Adjust index after removal + entityTotals.set(entity.id, rolloverConfig.max); + rowNeedsUpdate = true; + } + } else { + entityTotals.set(entity.id, newTotal); + console.log(` - ✅ Total still under maximum, continuing`); + } + } + + // Determine what to do with this row + if (updatedEntities.length === 0) { + console.log(` - 🗑️ Marking row for deletion (no entities remaining)`); + toDelete.push(row.id); + } else if (rowNeedsUpdate) { + console.log(` - ✏️ Marking row for update (entities modified)`); + toUpdate.push({ + ...row, + entities: updatedEntities, + }); + } else { + console.log(` - ✅ Row unchanged`); + } + } + + console.log(`\n📋 Maximum clearing summary for cusEnt ${cusEntID} (entity mode):`); + console.log(` - Rows to update: ${toUpdate.length}`); + console.log(` - Rows to delete: ${toDelete.length}`); + console.log(` - Final entity totals:`); + entityTotals.forEach((total, entityId) => { + console.log(` - ${entityId}: ${total}`); + }); + if (toUpdate.length > 0) { + console.log(` - Updated row IDs: ${toUpdate.map(r => r.id).join(', ')}`); + } + if (toDelete.length > 0) { + console.log(` - Deleted row IDs: ${toDelete.join(', ')}`); + } + } + + // return the rows that were cleared + return { toDelete, toUpdate }; +} \ No newline at end of file diff --git a/server/src/internal/customers/getFullCusQuery.ts b/server/src/internal/customers/getFullCusQuery.ts index 41fe031bf..fb2ca3e83 100644 --- a/server/src/internal/customers/getFullCusQuery.ts +++ b/server/src/internal/customers/getFullCusQuery.ts @@ -26,7 +26,7 @@ const buildOptimizedCusProductsCTE = (inStatuses?: CusProductStatus[]) => { '[]'::json ) AS customer_prices, - -- Spread customer_entitlements fields + add entitlement and replaceables + -- Spread customer_entitlements fields + add entitlement, replaceables, and rollovers COALESCE( json_agg(DISTINCT ( to_jsonb(ce.*) || jsonb_build_object( @@ -46,6 +46,14 @@ const buildOptimizedCusProductsCTE = (inStatuses?: CusProductStatus[]) => { ) FROM replaceables r WHERE r.cus_ent_id = ce.id + ), + 'rollover', ( + SELECT COALESCE( + json_agg(row_to_json(ro)) FILTER (WHERE ro.expires_at > EXTRACT(EPOCH FROM now()) * 1000), + '[]'::json + ) + FROM rollovers ro + WHERE ro.cus_ent_id = ce.id ) ) )) FILTER (WHERE ce.id IS NOT NULL), diff --git a/shared/models/productModels/rolloverModels/rolloverTable.ts b/shared/models/productModels/rolloverModels/rolloverTable.ts index 049aeb11a..6cd95cd70 100644 --- a/shared/models/productModels/rolloverModels/rolloverTable.ts +++ b/shared/models/productModels/rolloverModels/rolloverTable.ts @@ -8,7 +8,7 @@ import { uuid, } from "drizzle-orm/pg-core"; -import { EntityBalance } from "../../cusProductModels/cusEntModels/cusEntModels.js"; +import { EntityBalance, EntityRolloverBalance } from "../../cusProductModels/cusEntModels/cusEntModels.js"; import { customerEntitlements } from "../../cusProductModels/cusEntModels/cusEntTable.js"; export const rollovers = pgTable( @@ -18,7 +18,7 @@ export const rollovers = pgTable( cus_ent_id: text("cus_ent_id").notNull(), balance: numeric({ mode: "number" }).notNull(), expires_at: numeric({ mode: "number" }).notNull(), - entities: jsonb("entities").$type(), + entities: jsonb("entities").$type(), }, (table) => [ foreignKey({ diff --git a/vite/src/utils/product/product-item/validateProductItem.ts b/vite/src/utils/product/product-item/validateProductItem.ts index 47be76192..61b4b9f79 100644 --- a/vite/src/utils/product/product-item/validateProductItem.ts +++ b/vite/src/utils/product/product-item/validateProductItem.ts @@ -1,5 +1,5 @@ import { invalidNumber, notNullish } from "@/utils/genUtils"; -import { Feature, FeatureUsageType, ProductItem, ProductItemInterval } from "@autumn/shared"; +import { Feature, FeatureUsageType, ProductItem, ProductItemInterval, UsageModel } from "@autumn/shared"; import { toast } from "sonner"; import { isFeatureItem, isFeaturePriceItem } from "../getItemType"; import { isOneOffProduct } from "../priceUtils"; @@ -109,7 +109,6 @@ export const validateProductItem = ({ return item; } - if (invalidNumber(item.config.rollover.max)) { toast.error("Please enter a valid maximum rollover amount"); item.config.rollover = undefined; @@ -139,6 +138,12 @@ export const validateProductItem = ({ 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; + } } } From f1622b32c3a46f22033bced2c6abec33fd8851e2 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Thu, 24 Jul 2025 13:29:27 +0100 Subject: [PATCH 31/45] reviewing rollovers --- server/src/cron.ts | 20 +- .../handlePrepaidPrices.ts | 91 +++++---- .../cusEnts/cusRollovers/rolloverUtils.ts | 178 ++++++++---------- server/tests/advanced/rollovers/rollover1.ts | 151 +++++++++++++++ .../rolloverModels/rolloverModels.ts | 17 +- 5 files changed, 295 insertions(+), 162 deletions(-) create mode 100644 server/tests/advanced/rollovers/rollover1.ts diff --git a/server/src/cron.ts b/server/src/cron.ts index ec91fb6f5..28ae8bcdd 100644 --- a/server/src/cron.ts +++ b/server/src/cron.ts @@ -183,18 +183,20 @@ const resetCustomerEntitlement = async ({ allowance: resetBalance || undefined, }); - console.log( "Rollover update received in cron.ts/resetCustomerEntitlement:", rolloverUpdate.toInsert.map((rollover) => ({ id: rollover.id, balance: rollover.balance, - entities: rollover.entities.map((entity) => `${entity.id}: ${entity.balance}`).join(", "), - expires_at: rollover.expires_at ? new Date(rollover.expires_at).toISOString() : null, + entities: rollover.entities + .map((entity) => `${entity.id}: ${entity.balance}`) + .join(", "), + expires_at: rollover.expires_at + ? new Date(rollover.expires_at).toISOString() + : null, })) ); - try { nextResetAt = await checkSubAnchor({ db, @@ -216,19 +218,15 @@ const resetCustomerEntitlement = async ({ }, }); - let rolloverRows: any[] = []; + // let rolloverRows: any[] = []; if (rolloverUpdate?.toInsert && rolloverUpdate.toInsert.length > 0) { - rolloverRows = await RolloverService.insert({ + // rolloverRows = + await RolloverService.insert({ db, rows: rolloverUpdate.toInsert, }); } - console.log( - "Rollover rows", - Object.values(rolloverRows).map((x) => `${x.id}: ${x.balance} | entities: ${x.entities.map((y: any) => `${y.id}: ${y.balance}`).join(", ")}`) - ); - console.log( `Reset ${cusEnt.id} | customer: ${chalk.yellow( cusEnt.customer_id diff --git a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handlePrepaidPrices.ts b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handlePrepaidPrices.ts index 47ca3215c..743ced120 100644 --- a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handlePrepaidPrices.ts +++ b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handlePrepaidPrices.ts @@ -3,6 +3,7 @@ import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntit import { getResetBalance } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js"; import { RolloverService } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.js"; import { getRolloverUpdates } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.js"; +import { getResetBalancesUpdate } from "@/internal/customers/cusProducts/cusEnts/groupByUtils.js"; import { getRelatedCusEnt } from "@/internal/customers/cusProducts/cusPrices/cusPriceUtils.js"; import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; import { getEntOptions } from "@/internal/products/prices/priceUtils.js"; @@ -13,6 +14,7 @@ import { FeatureOptions, FullCusProduct, FullCustomerPrice, + UsagePriceConfig, } from "@autumn/shared"; import Stripe from "stripe"; @@ -47,22 +49,31 @@ export const handlePrepaidPrices = async ({ if (!cusEnt) { logger.error( - `Tried to handle prepaid price for ${cusPrice.id} (${cusPrice.price.id}) but no cus ent found`, + `Tried to handle prepaid price for ${cusPrice.id} (${cusPrice.price.id}) but no cus ent found` ); return; } const options = getEntOptions(cusProduct.options, cusEnt.entitlement); - const resetBalance = getResetBalance({ - entitlement: cusEnt.entitlement, - options: notNullish(options?.upcoming_quantity) - ? { - feature_id: options?.feature_id!, - quantity: options?.upcoming_quantity!, - } - : options, - relatedPrice: cusPrice.price, + // const resetBalance = getResetBalance({ + // entitlement: cusEnt.entitlement, + // options: notNullish(options?.upcoming_quantity) + // ? { + // feature_id: options?.feature_id!, + // quantity: options?.upcoming_quantity!, + // } + // : options, + // relatedPrice: cusPrice.price, + // }); + let resetQuantity = options?.upcoming_quantity || options?.quantity!; + let config = cusPrice.price.config as UsagePriceConfig; + let billingUnits = config.billing_units || 1; + let newAllowance = resetQuantity * billingUnits; + + const resetUpdate = getResetBalancesUpdate({ + cusEnt, + allowance: newAllowance, }); const ent = cusEnt.entitlement; @@ -71,17 +82,17 @@ export const handlePrepaidPrices = async ({ cusEnt, nextResetAt: usageSub.current_period_end * 1000, }); - console.log("🔍 rolloverUpdate", rolloverUpdate); + // console.log("🔍 rolloverUpdate", rolloverUpdate); - console.log( - "Rollover update received in handlePrepaidPrices:", - rolloverUpdate.toInsert.map((rollover) => ({ - id: rollover.id, - balance: rollover.balance, - entities: rollover.entities.map((entity) => `${entity.id}: ${entity.balance}`).join(", "), - expires_at: rollover.expires_at ? new Date(rollover.expires_at).toISOString() : null, - })) - ); + // console.log( + // "Rollover update received in handlePrepaidPrices:", + // rolloverUpdate.toInsert.map((rollover) => ({ + // id: rollover.id, + // balance: rollover.balance, + // entities: rollover.entities.map((entity) => `${entity.id}: ${entity.balance}`).join(", "), + // expires_at: rollover.expires_at ? new Date(rollover.expires_at).toISOString() : null, + // })) + // ); if (notNullish(options?.upcoming_quantity)) { const newOptions = cusProduct.options.map((o) => { @@ -118,32 +129,34 @@ export const handlePrepaidPrices = async ({ return; } - logger.info( - `🔥 Resetting balance for ${ent.feature.id}, customer: ${customer.id} (name: ${customer.name})`, - ); + // logger.info( + // `🔥 Resetting balance for ${ent.feature.id}, customer: ${customer.id} (name: ${customer.name})` + // ); - let rolloverRows: any[] = []; - if (rolloverUpdate?.toInsert && rolloverUpdate.toInsert.length > 0) { - logger.info( - `🔥 Rolling over balance for ${ent.feature.id}, customer: ${customer.id} (name: ${customer.name})`, - ); - rolloverRows = await RolloverService.insert({ - db, - rows: rolloverUpdate.toInsert, - }); - } + // let rolloverRows: any[] = []; + // if (rolloverUpdate?.toInsert && rolloverUpdate.toInsert.length > 0) { + // logger.info( + // `🔥 Rolling over balance for ${ent.feature.id}, customer: ${customer.id} (name: ${customer.name})` + // ); + // rolloverRows = await RolloverService.insert({ + // db, + // rows: rolloverUpdate.toInsert, + // }); + // } - console.log( - "Rollover rows", - Object.values(rolloverRows).map((x) => `${x.id}: ${x.balance} | entities: ${x.entities.map((y: any) => `${y.id}: ${y.balance}`).join(", ")}`) - ); + // console.log( + // "Rollover rows", + // Object.values(rolloverRows).map( + // (x) => + // `${x.id}: ${x.balance} | entities: ${x.entities.map((y: any) => `${y.id}: ${y.balance}`).join(", ")}` + // ) + // ); await CusEntService.update({ db, id: cusEnt.id, updates: { - balance: resetBalance, - adjustment: 0, + ...resetUpdate, next_reset_at: usageSub.current_period_end * 1000, }, }); diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.ts b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.ts index ba03487f1..ca64bd77b 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.ts @@ -1,119 +1,91 @@ import { - FullCustomerEntitlement, - ProductItemInterval, - Rollover, - RolloverModel, - EntityBalance, - EntityRolloverBalance, + FullCustomerEntitlement, + RolloverConfig, + RolloverModel, + RolloverDuration, } from "@autumn/shared"; -import { notNullish, nullish } from "@/utils/genUtils.js"; -import { randomUUID } from "crypto"; +import { generateId, notNullish, nullish } from "@/utils/genUtils.js"; +import { addMonths } from "date-fns"; export const getRolloverUpdates = ({ - cusEnt, - nextResetAt + cusEnt, + nextResetAt, }: { - cusEnt: FullCustomerEntitlement; - nextResetAt: number; + cusEnt: FullCustomerEntitlement; + nextResetAt: number; }) => { - let update: { - toDelete: string[]; - toInsert: RolloverModel[]; - toUpdate: RolloverModel[]; - } = { - toDelete: [], - toInsert: [], - toUpdate: [], - }; - if (nullish(cusEnt.entitlement.rollover) || !cusEnt.entitlement.rollover) { - return update; - } + let update: { + toDelete: string[]; + toInsert: RolloverModel[]; + toUpdate: RolloverModel[]; + } = { + toDelete: [], + toInsert: [], + toUpdate: [], + }; + let ent = cusEnt.entitlement; + let shouldRollover = + cusEnt.balance && cusEnt.balance > 0 && notNullish(ent.rollover); - let nextExpiry = calculateNextExpiry( - nextResetAt, - cusEnt.entitlement.rollover - ); + if (!shouldRollover) return update; - if (nullish(nextExpiry) || !nextExpiry) { - return update; - } + let nextExpiry = calculateNextExpiry(nextResetAt, ent.rollover!); - let entitlement = cusEnt.entitlement.allowance ?? 0; + let newEntitlement: RolloverModel = { + id: generateId("roll"), + entities: {}, + cus_ent_id: cusEnt.id, + balance: 0, + expires_at: nextExpiry, + }; - if (entitlement < 0) { - return update; - } + if (notNullish(ent.entity_feature_id)) { + for (const entityId in cusEnt.entities) { + let entRollover = cusEnt.entities[entityId].balance; + if (entRollover > 0) { + newEntitlement.entities[entityId] = { + id: entityId, + balance: entRollover, + adjustment: 0, + }; + } + } + update.toInsert.push(newEntitlement); + } else { + let balance = cusEnt.balance!; + if (balance > 0) { + newEntitlement.balance = balance; + update.toInsert.push(newEntitlement); + } + } - let rollover = cusEnt.balance || 0; - console.log( - `🔥 Unused balance (rollover): ${rollover} | Entitlement: ${entitlement}` - ); - - let newEntitlement = { - cus_ent_id: cusEnt.id, - balance: 0, - expires_at: nextExpiry, - entities: [] as EntityRolloverBalance[], - id: randomUUID() as string, - }; - - if (cusEnt.entities != null) - console.log( - "🏢 entities:", - Object.values(cusEnt.entities).map((x: any) => `${x.id}: ${x.balance}`) - ); - else console.log("🏢 entities: none"); - console.log( - "📋 entitlement:", - cusEnt.entitlement.feature_id, - "| 🆔 entity_feature_id:", - cusEnt.entitlement.entity_feature_id, - "| allowance:", - cusEnt.entitlement.allowance - ); - - if (notNullish(cusEnt.entitlement.entity_feature_id)) { - console.log("🔍 newEntities:", cusEnt.entities); - for (const entityId in cusEnt.entities) { - let entRollover = cusEnt.entities[entityId].balance; - if (entRollover > 0) { - newEntitlement.entities.push({ - id: entityId, - balance: entRollover, - }); - console.log("🔍 entityId:", entityId, "entRollover:", entRollover); - } else console.log("🔍 no rollover for entityId:", entityId, " | entitlement:", entitlement, " | balance:", cusEnt.entities[entityId].balance); - } - update.toInsert.push(newEntitlement); - } else { - if (rollover > 0) { - newEntitlement.balance = rollover; - update.toInsert.push(newEntitlement); - } else console.log("🔍 no rollover for entitlement: ", cusEnt.id, " | rollable balance:", rollover); - } - - console.log( - "Rollover update sending from rolloverUtils:", - update.toInsert.map((rollover) => ({ - id: rollover.id, - balance: rollover.balance, - entities: rollover.entities.map((entity) => `${entity.id}: ${entity.balance}`).join(", "), - expires_at: rollover.expires_at ? new Date(rollover.expires_at).toISOString() : null, - })) - ); - - return update; + return update; }; -export const calculateNextExpiry = (nextResetAt: number, config: Rollover) => { - if (nullish(config)) { - return null; - } +export const calculateNextExpiry = ( + nextResetAt: number, + config: RolloverConfig +) => { + if (nullish(config)) { + return null; + } - let nextExpiry = new Date(nextResetAt); - if (config!.duration === ProductItemInterval.Month) { - nextExpiry.setMonth(nextExpiry.getMonth() + config!.length); - } + if (config.duration == RolloverDuration.Forever) return null; - return nextExpiry.getTime(); + return addMonths(nextResetAt, config.length).getTime(); }; + +// if (nullish(nextExpiry) || !nextExpiry) { +// return update; +// } + +// let entitlement = cusEnt.entitlement.allowance ?? 0; + +// if (entitlement < 0) { +// return update; +// } + +// let rollover = cusEnt.balance || 0; +// console.log( +// `🔥 Unused balance (rollover): ${rollover} | Entitlement: ${entitlement}` +// ); diff --git a/server/tests/advanced/rollovers/rollover1.ts b/server/tests/advanced/rollovers/rollover1.ts new file mode 100644 index 000000000..2846ac47e --- /dev/null +++ b/server/tests/advanced/rollovers/rollover1.ts @@ -0,0 +1,151 @@ +import chalk from "chalk"; +import Stripe from "stripe"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { + APIVersion, + AppEnv, + ErrCode, + Organization, + ProductItemInterval, +} from "@autumn/shared"; + +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { setupBefore } from "tests/before.js"; +import { createProducts } from "tests/utils/productUtils.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { addPrefixToProducts, runAttachTest } from "tests/attach/utils.js"; +import { expectAutumnError } from "tests/utils/expectUtils/expectErrUtils.js"; +import { expect } from "chai"; + +const userItem = constructFeatureItem({ + featureId: TestFeature.Users, + includedUsage: 0, + interval: ProductItemInterval.Month, +}); + +export let pro = constructProduct({ + items: [userItem], + type: "pro", +}); + +const testCase = "usageLimit1"; + +describe(`${chalk.yellowBright(`${testCase}: Testing usage limits for entities`)}`, () => { + let customerId = testCase; + let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); + let testClockId: string; + let db: DrizzleCli, org: Organization, env: AppEnv; + let stripeCli: Stripe; + + let curUnix = new Date().getTime(); + + before(async function () { + await setupBefore(this); + const { autumnJs } = this; + db = this.db; + org = this.org; + env = this.env; + + stripeCli = this.stripeCli; + + addPrefixToProducts({ + products: [pro], + prefix: testCase, + }); + + await createProducts({ + autumn, + products: [pro], + customerId, + db, + orgId: org.id, + env, + }); + + const { testClockId: testClockId1 } = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + testClockId = testClockId1!; + }); + + const entities = [ + { + id: "1", + name: "Entity 1", + feature_id: TestFeature.Users, + }, + { + id: "2", + name: "Entity 2", + feature_id: TestFeature.Users, + }, + { + id: "3", + name: "Entity 3", + feature_id: TestFeature.Users, + }, + { + id: "4", + name: "Entity 4", + feature_id: TestFeature.Users, + }, + ]; + + it("should attach pro product", async function () { + await runAttachTest({ + autumn, + customerId, + product: pro, + stripeCli, + db, + org, + env, + }); + }); + it("should create more entities than the limit and hit error", async function () { + await expectAutumnError({ + errCode: ErrCode.FeatureLimitReached, + func: async () => { + await autumn.entities.create(customerId, entities); + }, + }); + }); + + it("should create entities one by one, then hit usage limit", async function () { + await autumn.entities.create(customerId, entities[0]); + await autumn.entities.create(customerId, entities[1]); + + await expectAutumnError({ + errCode: ErrCode.FeatureLimitReached, + func: async () => { + await autumn.entities.create(customerId, entities[2]); + }, + }); + }); + + it("should have correct check and get customer value", async function () { + const check = await autumn.check({ + customer_id: customerId, + feature_id: TestFeature.Users, + }); + const customer = await autumn.customers.get(customerId); + + expect(check.balance).to.equal(-2); + // @ts-ignore + expect(check.usage_limit).to.equal(userItem.usage_limit); + + // @ts-ignore + expect(customer.features[TestFeature.Users].usage_limit).to.equal( + userItem.usage_limit + ); + }); +}); diff --git a/shared/models/productModels/rolloverModels/rolloverModels.ts b/shared/models/productModels/rolloverModels/rolloverModels.ts index 8f3db9bde..b02559142 100644 --- a/shared/models/productModels/rolloverModels/rolloverModels.ts +++ b/shared/models/productModels/rolloverModels/rolloverModels.ts @@ -1,14 +1,13 @@ import { z } from "zod"; +import { EntityBalance, EntityBalanceSchema } from "../../../index.js"; +import { jsonb } from "drizzle-orm/pg-core"; export const RolloverModelSchema = z.object({ - id: z.string(), - cus_ent_id: z.string(), - balance: z.number(), - expires_at: z.number(), - entities: z.array(z.object({ - id: z.string(), - balance: z.number(), - })), + id: z.string(), + cus_ent_id: z.string(), + balance: z.number(), + entities: z.record(z.string(), EntityBalanceSchema), + expires_at: z.number().nullable(), }); -export type RolloverModel = z.infer; \ No newline at end of file +export type RolloverModel = z.infer; From 94d01361ced77b407bb9f95a95e6db68aa473e0f Mon Sep 17 00:00:00 2001 From: John Yeo Date: Thu, 24 Jul 2025 16:37:27 +0100 Subject: [PATCH 32/45] fix: added usage field to rollover schema --- .../balancesToFeatureResponse.ts | 3 ++- .../cusFeatureResponseUtils/getCusBalances.ts | 7 ++++++- server/src/trigger/updateBalanceTask.ts | 14 +++++++------- .../cusEntModels/rolloverModels/rolloverTable.ts | 2 ++ 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/server/src/internal/customers/cusUtils/cusFeatureResponseUtils/balancesToFeatureResponse.ts b/server/src/internal/customers/cusUtils/cusFeatureResponseUtils/balancesToFeatureResponse.ts index 3e66e2360..e3c0eff95 100644 --- a/server/src/internal/customers/cusUtils/cusFeatureResponseUtils/balancesToFeatureResponse.ts +++ b/server/src/internal/customers/cusUtils/cusFeatureResponseUtils/balancesToFeatureResponse.ts @@ -16,7 +16,7 @@ import { notNullish } from "@/utils/genUtils.js"; export const sumValues = ( entList: CusEntResponse[], - key: keyof CusEntResponse, + key: keyof CusEntResponse ) => { return entList.reduce((acc, curr) => { if (curr[key]) { @@ -79,6 +79,7 @@ export const featuresToObject = ({ next_reset_at: getEarliestNextResetAt(relatedEnts), interval: relatedEnts.length == 1 ? relatedEnts[0].interval : "multiple", overage_allowed: relatedEnts.some((e) => e.overage_allowed), + // rollovers: relatedEnts.flatMap((e) => e.rollovers), breakdown: !unlimited && relatedEnts.length > 1 ? relatedEnts.map((e) => ({ diff --git a/server/src/internal/customers/cusUtils/cusFeatureResponseUtils/getCusBalances.ts b/server/src/internal/customers/cusUtils/cusFeatureResponseUtils/getCusBalances.ts index f6b9075f2..26aa94996 100644 --- a/server/src/internal/customers/cusUtils/cusFeatureResponseUtils/getCusBalances.ts +++ b/server/src/internal/customers/cusUtils/cusFeatureResponseUtils/getCusBalances.ts @@ -87,7 +87,7 @@ export const getCusBalances = async ({ }) => { const data: Record = {}; const features = cusEntsWithCusProduct.map( - (cusEnt) => cusEnt.entitlement.feature, + (cusEnt) => cusEnt.entitlement.feature ); const cusEntsFiltered = cusEntsWithCusProduct.filter((cusEnt) => { const ent: EntitlementWithFeature = cusEnt.entitlement; @@ -170,6 +170,11 @@ export const getCusBalances = async ({ }); data[key].balance += balance || 0; + // let totalRolloverBalance = cusEnt.rollovers.reduce((acc, rollover) => { + // return acc + (rollover.balance || 0); + // }, 0); + + // data[key].balance += totalRolloverBalance; data[key].adjustment += adjustment || 0; let total = diff --git a/server/src/trigger/updateBalanceTask.ts b/server/src/trigger/updateBalanceTask.ts index 56f03c9b6..9adb355ad 100644 --- a/server/src/trigger/updateBalanceTask.ts +++ b/server/src/trigger/updateBalanceTask.ts @@ -67,7 +67,7 @@ const getFeatureDeductions = ({ features: Feature[]; }) => { const meteredFeatures = features.filter( - (feature) => feature.type === FeatureType.Metered, + (feature) => feature.type === FeatureType.Metered ); const featureDeductions = []; for (const feature of features) { @@ -86,7 +86,7 @@ const getFeatureDeductions = ({ let unlimitedExists = cusEnts.some( (cusEnt) => cusEnt.entitlement.allowance_type === AllowanceType.Unlimited && - cusEnt.entitlement.internal_feature_id == feature.internal_id, + cusEnt.entitlement.internal_feature_id == feature.internal_id ); if (unlimitedExists || !deduction) { @@ -142,7 +142,7 @@ export const logBalanceUpdate = ({ console.log( ` - Customer: ${customer.id} (${customer.env}) | Org: ${ org.slug - } | Features: ${features.map((f) => f.id).join(", ")}`, + } | Features: ${features.map((f) => f.id).join(", ")}` ); console.log(" - Properties:", properties); console.log( @@ -152,7 +152,7 @@ export const logBalanceUpdate = ({ if (notNullish(cusEnt.entitlement.entity_feature_id)) { console.log( - ` - Entity feature ID found for feature: ${cusEnt.feature_id}`, + ` - Entity feature ID found for feature: ${cusEnt.feature_id}` ); if (notNullish(entityId)) { @@ -179,7 +179,7 @@ export const logBalanceUpdate = ({ })`; }), "| Deductions:", - featureDeductions.map((f: any) => `${f.feature.id}: ${f.deduction}`), + featureDeductions.map((f: any) => `${f.feature.id}: ${f.deduction}`) ); }; @@ -462,7 +462,7 @@ export const deductFromUsageBasedCusEnt = async ({ if (!usageBasedEnt) { console.log( - ` - Feature ${feature.id}, To deduct: ${toDeduct} -> no usage-based entitlement found`, + ` - Feature ${feature.id}, To deduct: ${toDeduct} -> no usage-based entitlement found` ); return; } @@ -659,7 +659,7 @@ export const runUpdateBalanceTask = async ({ console.log("--------------------------------"); console.log( - `UPDATING BALANCE FOR CUSTOMER (${customerId}), ORG: ${org.slug}`, + `UPDATING BALANCE FOR CUSTOMER (${customerId}), ORG: ${org.slug}` ); const cusEnts: any = await updateCustomerBalance({ diff --git a/shared/models/cusProductModels/cusEntModels/rolloverModels/rolloverTable.ts b/shared/models/cusProductModels/cusEntModels/rolloverModels/rolloverTable.ts index 31e6c305c..75119b577 100644 --- a/shared/models/cusProductModels/cusEntModels/rolloverModels/rolloverTable.ts +++ b/shared/models/cusProductModels/cusEntModels/rolloverModels/rolloverTable.ts @@ -12,12 +12,14 @@ import { export const EntityRolloverBalanceSchema = z.object({ id: z.string(), balance: z.number(), + usage: z.number(), }); export const RolloverSchema = z.object({ id: z.string(), cus_ent_id: z.string(), balance: z.number(), + usage: z.number(), expires_at: z.number().nullable(), entities: z.record(z.string(), EntityRolloverBalanceSchema), }); From 2ac37ba00e851e6c2243720673710b50fbb67084 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Thu, 24 Jul 2025 16:38:44 +0100 Subject: [PATCH 33/45] fix: usage field in rollover --- .../cusProducts/cusEnts/cusRollovers/rolloverUtils.ts | 4 ++++ .../cusEntModels/rolloverModels/rolloverTable.ts | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.ts b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.ts index b64768fbc..dd8f97566 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.ts @@ -37,6 +37,7 @@ export const getRolloverUpdates = ({ id: generateId("roll"), cus_ent_id: cusEnt.id, balance: 0, + usage: 0, expires_at: nextExpiry, entities: {}, }; @@ -49,6 +50,7 @@ export const getRolloverUpdates = ({ newRollover.entities[entityId] = { id: entityId, balance: entRollover, + usage: 0, }; } } @@ -197,6 +199,7 @@ export async function performMaximumClearing({ update.entities[entityId] = { id: entityId, balance: newBalance.toNumber(), + usage: 0, }; } else { newBalance = new Decimal(0); @@ -205,6 +208,7 @@ export async function performMaximumClearing({ update.entities[entityId] = { id: entityId, balance: 0, + usage: 0, }; } } diff --git a/shared/models/cusProductModels/cusEntModels/rolloverModels/rolloverTable.ts b/shared/models/cusProductModels/cusEntModels/rolloverModels/rolloverTable.ts index 75119b577..3da143aba 100644 --- a/shared/models/cusProductModels/cusEntModels/rolloverModels/rolloverTable.ts +++ b/shared/models/cusProductModels/cusEntModels/rolloverModels/rolloverTable.ts @@ -19,7 +19,7 @@ export const RolloverSchema = z.object({ id: z.string(), cus_ent_id: z.string(), balance: z.number(), - usage: z.number(), + usage: z.number().default(0), expires_at: z.number().nullable(), entities: z.record(z.string(), EntityRolloverBalanceSchema), }); @@ -31,6 +31,7 @@ export const rollovers = pgTable( cus_ent_id: text("cus_ent_id").notNull(), balance: numeric({ mode: "number" }).notNull(), expires_at: numeric({ mode: "number" }), + usage: numeric({ mode: "number" }).default(0).notNull(), entities: jsonb("entities") .$type>() .notNull() From 6f2a747d42508fd54e7356dfef8e7f5c4a60479b Mon Sep 17 00:00:00 2001 From: John Yeo Date: Thu, 24 Jul 2025 17:11:54 +0100 Subject: [PATCH 34/45] feat: added rollover response to get customer --- .../handleInvoiceCreated/handleUsagePrices.ts | 20 +-- .../balancesToFeatureResponse.ts | 12 +- .../cusFeatureResponseUtils/getCusBalances.ts | 114 +++++++++++++++++- .../cusResModels/cusFeatureResponse.ts | 12 +- 4 files changed, 134 insertions(+), 24 deletions(-) diff --git a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleUsagePrices.ts b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleUsagePrices.ts index 1bc3d8c9e..2bbc8cfaf 100644 --- a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleUsagePrices.ts +++ b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleUsagePrices.ts @@ -129,21 +129,6 @@ export const handleUsagePrices = async ({ let ent = relatedCusEnt.entitlement; - let rolloverUpdate = getRolloverUpdates({ - cusEnt: relatedCusEnt, - nextResetAt: usageSub.current_period_end * 1000, - }); - - // console.log( - // "Rollover update received in handleUsagePrices:", - // rolloverUpdate.toInsert.map((rollover) => ({ - // id: rollover.id, - // balance: rollover.balance, - // entities: rollover.entities.map((entity) => `${entity.id}: ${entity.balance}`).join(", "), - // expires_at: rollover.expires_at ? new Date(rollover.expires_at).toISOString() : null, - // })) - // ); - let resetBalancesUpdate = getResetBalancesUpdate({ cusEnt: relatedCusEnt, allowance: ent.interval == EntInterval.Lifetime ? 0 : ent.allowance!, @@ -161,6 +146,11 @@ export const handleUsagePrices = async ({ }, }); + let rolloverUpdate = getRolloverUpdates({ + cusEnt: relatedCusEnt, + nextResetAt: usageSub.current_period_end * 1000, + }); + if (rolloverUpdate?.toInsert && rolloverUpdate.toInsert.length > 0) { await RolloverService.insert({ db, diff --git a/server/src/internal/customers/cusUtils/cusFeatureResponseUtils/balancesToFeatureResponse.ts b/server/src/internal/customers/cusUtils/cusFeatureResponseUtils/balancesToFeatureResponse.ts index e3c0eff95..be62a6a41 100644 --- a/server/src/internal/customers/cusUtils/cusFeatureResponseUtils/balancesToFeatureResponse.ts +++ b/server/src/internal/customers/cusUtils/cusFeatureResponseUtils/balancesToFeatureResponse.ts @@ -3,6 +3,7 @@ import { CusEntResponse, CusEntResponseSchema, CusEntResponseV2, + CusRollover, Feature, FeatureType, FullCustomerEntitlement, @@ -66,7 +67,9 @@ export const featuresToObject = ({ usageLimit = undefined; } - featureObject[featureId] = { + // console.log(`Feature ${featureId} list:`, relatedEnts); + + let cusFeature: CusEntResponseV2 = { id: featureId, name: feature.name, type: featureType, @@ -79,7 +82,6 @@ export const featuresToObject = ({ next_reset_at: getEarliestNextResetAt(relatedEnts), interval: relatedEnts.length == 1 ? relatedEnts[0].interval : "multiple", overage_allowed: relatedEnts.some((e) => e.overage_allowed), - // rollovers: relatedEnts.flatMap((e) => e.rollovers), breakdown: !unlimited && relatedEnts.length > 1 ? relatedEnts.map((e) => ({ @@ -96,7 +98,13 @@ export const featuresToObject = ({ credit_amount: s.credit_amount, })) : undefined, + + rollovers: relatedEnts + .flatMap((e) => e.rollovers) + .filter(notNullish) as CusRollover[], }; + + featureObject[featureId] = cusFeature; } return featureObject; diff --git a/server/src/internal/customers/cusUtils/cusFeatureResponseUtils/getCusBalances.ts b/server/src/internal/customers/cusUtils/cusFeatureResponseUtils/getCusBalances.ts index 26aa94996..4e7c94645 100644 --- a/server/src/internal/customers/cusUtils/cusFeatureResponseUtils/getCusBalances.ts +++ b/server/src/internal/customers/cusUtils/cusFeatureResponseUtils/getCusBalances.ts @@ -69,6 +69,100 @@ export const getV1EntitlementsRes = ({ return res; }; +export const getRolloverFields = ({ + cusEnt, + entityId, +}: { + cusEnt: FullCustomerEntitlement; + entityId?: string; +}) => { + let hasRollover = notNullish(cusEnt.entitlement.rollover); + if (!hasRollover) { + return null; + } + + if (cusEnt.entitlement.entity_feature_id) { + if (entityId) { + return cusEnt.rollovers.reduce( + (acc, rollover) => { + if (rollover.entities[entityId]) { + return { + balance: acc.balance + rollover.entities[entityId].balance, + usage: acc.usage + rollover.entities[entityId].usage, + rollovers: [ + ...acc.rollovers, + { + balance: rollover.entities[entityId].balance, + usage: rollover.entities[entityId].usage, + expires_at: rollover.expires_at, + }, + ], + }; + } + return acc; + }, + { + balance: 0, + usage: 0, + rollovers: [] as any[], + } + ); + } else { + return cusEnt.rollovers.reduce( + (acc, rollover) => { + let newBalance = 0; + let newUsage = 0; + + for (const entityId in rollover.entities) { + newBalance += rollover.entities[entityId].balance; + newUsage += rollover.entities[entityId].usage; + } + + return { + balance: acc.balance + newBalance, + usage: acc.usage + newUsage, + rollovers: [ + ...acc.rollovers, + { + balance: newBalance, + usage: newUsage, + expires_at: rollover.expires_at, + }, + ], + }; + }, + { + balance: 0, + usage: 0, + rollovers: [] as any[], + } + ); + } + } else { + return cusEnt.rollovers.reduce( + (acc, rollover) => { + return { + balance: acc.balance + rollover.balance, + usage: acc.usage + rollover.usage, + rollovers: [ + ...acc.rollovers, + { + balance: rollover.balance, + usage: rollover.usage, + expires_at: rollover.expires_at, + }, + ], + }; + }, + { + balance: 0, + usage: 0, + rollovers: [] as any[], + } + ); + } +}; + // IMPORTANT FUNCTION export const getCusBalances = async ({ cusEntsWithCusProduct, @@ -170,11 +264,6 @@ export const getCusBalances = async ({ }); data[key].balance += balance || 0; - // let totalRolloverBalance = cusEnt.rollovers.reduce((acc, rollover) => { - // return acc + (rollover.balance || 0); - // }, 0); - - // data[key].balance += totalRolloverBalance; data[key].adjustment += adjustment || 0; let total = @@ -188,6 +277,17 @@ export const getCusBalances = async ({ data[key].total += total; data[key].unused += unused || 0; + let rollover = getRolloverFields({ + cusEnt, + entityId: entity?.id, + }); + + if (rollover) { + data[key].balance += rollover.balance; + data[key].total += rollover.balance + rollover.usage; + data[key].rollovers = rollover.rollovers; + } + if (org.config.api_version >= BREAK_API_VERSION) { if ( !data[key].next_reset_at || @@ -261,5 +361,9 @@ export const getCusBalances = async ({ }); } + // if (org.api_version == APIVersion.v1) { + + // } + return balances as CusFeatureBalance[]; }; diff --git a/shared/models/cusModels/cusResModels/cusFeatureResponse.ts b/shared/models/cusModels/cusResModels/cusFeatureResponse.ts index eeaaaea08..1caae1a8c 100644 --- a/shared/models/cusModels/cusResModels/cusFeatureResponse.ts +++ b/shared/models/cusModels/cusResModels/cusFeatureResponse.ts @@ -2,6 +2,11 @@ import { z } from "zod"; import { EntInterval } from "../../productModels/entModels/entEnums.js"; import { ProductItemFeatureType } from "../../productV2Models/productItemModels/productItemModels.js"; +export const CusRolloverSchema = z.object({ + balance: z.number(), + expires_at: z.number(), +}); + export const CusEntResponseSchema = z.object({ feature_id: z.string(), interval: z.nativeEnum(EntInterval).nullish(), @@ -12,6 +17,7 @@ export const CusEntResponseSchema = z.object({ next_reset_at: z.number().nullish(), overage_allowed: z.boolean().nullish(), usage_limit: z.number().nullish(), + rollovers: z.array(CusRolloverSchema).nullish(), }); export const CoreCusFeatureResponseSchema = z.object({ @@ -31,7 +37,7 @@ export const CoreCusFeatureResponseSchema = z.object({ usage: z.number().nullish(), included_usage: z.number().nullish(), next_reset_at: z.number().nullish(), - }), + }) ) .nullish(), credit_schema: z @@ -39,11 +45,12 @@ export const CoreCusFeatureResponseSchema = z.object({ z.object({ feature_id: z.string(), credit_amount: z.number(), - }), + }) ) .nullish(), usage_limit: z.number().nullish(), + rollovers: z.array(CusRolloverSchema).nullish(), }); export const CusEntResponseV2Schema = z @@ -68,3 +75,4 @@ export const CheckResponseSchema = z export type CusEntResponse = z.infer; export type CusEntResponseV2 = z.infer; export type CheckResponse = z.infer; +export type CusRollover = z.infer; From 691bb96fef6c83c51bb83da9e4892421565ce5a5 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Thu, 24 Jul 2025 17:45:04 +0100 Subject: [PATCH 35/45] chore: added psuedocode for getNewProductRollovers --- .../add-product/createFullCusProduct.ts | 9 +++ .../cusRollovers/getNewProductRollovers.ts | 57 ++++++++++++------- 2 files changed, 46 insertions(+), 20 deletions(-) diff --git a/server/src/internal/customers/add-product/createFullCusProduct.ts b/server/src/internal/customers/add-product/createFullCusProduct.ts index 08ea4d29e..5b72f6af8 100644 --- a/server/src/internal/customers/add-product/createFullCusProduct.ts +++ b/server/src/internal/customers/add-product/createFullCusProduct.ts @@ -33,6 +33,7 @@ import { CusEntService } from "../cusProducts/cusEnts/CusEntitlementService.js"; import { CusPriceService } from "../cusProducts/cusPrices/CusPriceService.js"; import { addExistingUsagesToCusEnts } from "../cusProducts/cusEnts/cusEntUtils/getExistingUsage.js"; import { RepService } from "../cusProducts/cusEnts/RepService.js"; +import { getNewProductRollovers } from "../cusProducts/cusEnts/cusRollovers/getNewProductRollovers.js"; export const initCusPrice = ({ price, @@ -403,6 +404,14 @@ export const createFullCusProduct = async ({ features: attachParams.features, }); + // 4. Get new rollovers + await getNewProductRollovers({ + curCusProduct: curCusProduct as FullCusProduct, + cusEnts, + entitlements, + logger, + }); + // 4. create customer prices const cusPrices: CustomerPrice[] = []; diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/getNewProductRollovers.ts b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/getNewProductRollovers.ts index 83e661ba8..eb1bdde55 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/getNewProductRollovers.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/getNewProductRollovers.ts @@ -1,23 +1,40 @@ -// import { -// CustomerEntitlement, -// EntitlementWithFeature, -// FullCusProduct, -// } from "@autumn/shared"; +import { + CustomerEntitlement, + EntitlementWithFeature, + FullCusProduct, +} from "@autumn/shared"; -// export const getNewProductRollovers = ({ -// curCusProduct, -// cusEnts, -// entitlements, -// }: { -// curCusProduct: FullCusProduct; -// cusEnts: CustomerEntitlement[]; -// entitlements: EntitlementWithFeature[]; -// }) => { -// let newRollovers = []; +export const getNewProductRollovers = async ({ + curCusProduct, + cusEnts, + entitlements, + logger, +}: { + curCusProduct: FullCusProduct; + cusEnts: CustomerEntitlement[]; + entitlements: EntitlementWithFeature[]; + logger: any; +}) => { + try { + let newRollovers = []; -// for (const cusEnt of cusEnts) { -// let ent = entitlements.find((e) => e.id === cusEnt.entitlement_id); -// if (!ent?.rollover) continue; + for (const cusEnt of cusEnts) { + let ent = entitlements.find((e) => e.id === cusEnt.entitlement_id); + if (!ent?.rollover) continue; -// } -// }; + // 1. Get rollovers from current cus product (Look at feature ID) + + // 2. Cases + // - Bring over current balance (if greater > 0), and any existing rollover + // - Perform max clearing according to new entitlement's rollover config (so cusEnt.entitlement.rollover) + // - To test: entity mode and non-entity mode, upgrade and downgrade + // - Don't need to handle no entity -> entity or entity -> no entity + + // 3. Perform db operations AFTER insertFullCusProduct later on + } + } catch (error) { + logger.error(`Failed to handle new product rollovers:`, { + error, + }); + } +}; From 1e88d0aa661f955ffa0c8ab33da1845f7b832849 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Fri, 25 Jul 2025 11:38:39 +0100 Subject: [PATCH 36/45] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20show=20rolledover?= =?UTF-8?q?=20balance=20on=20frontend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entitlements/CustomerEntitlementsList.tsx | 616 ++++++++++-------- 1 file changed, 350 insertions(+), 266 deletions(-) diff --git a/vite/src/views/customers/customer/entitlements/CustomerEntitlementsList.tsx b/vite/src/views/customers/customer/entitlements/CustomerEntitlementsList.tsx index 79ac9b5ed..5b1869cef 100644 --- a/vite/src/views/customers/customer/entitlements/CustomerEntitlementsList.tsx +++ b/vite/src/views/customers/customer/entitlements/CustomerEntitlementsList.tsx @@ -1,14 +1,14 @@ import { - AllowanceType, - FeatureType, - FullCusEntWithFullCusProduct, - FullCustomerEntitlement, + AllowanceType, + FeatureType, + FullCusEntWithFullCusProduct, + FullCustomerEntitlement, } from "@autumn/shared"; import { useCustomerContext } from "../CustomerContext"; import { - formatUnixToDate, - formatUnixToDateTime, + formatUnixToDate, + formatUnixToDateTime, } from "@/utils/formatUtils/formatDateUtils"; import { useState } from "react"; @@ -23,287 +23,371 @@ import { cn } from "@/lib/utils"; import { CusProductEntityItem } from "../components/CusProductEntityItem"; export const CustomerEntitlementsList = () => { - const [featureType, setFeatureType] = useState( - FeatureType.Metered - ); - const [showExpired, setShowExpired] = useState(false); + const [featureType, setFeatureType] = useState( + FeatureType.Metered + ); + const [showExpired, setShowExpired] = useState(false); - const { products, customer, entities, entityId, showEntityView } = - useCustomerContext(); + const { products, customer, entities, entityId, showEntityView } = + useCustomerContext(); - const [selectedCusEntitlement, setSelectedCusEntitlement] = - useState(null); + const [selectedCusEntitlement, setSelectedCusEntitlement] = + useState(null); - const cusEnts: FullCusEntWithFullCusProduct[] = - customer.customer_products.flatMap((cp: any) => { - return cp.customer_entitlements.map((e: any) => ({ - ...e, - customer_product: cp, - })); - }); + const cusEnts: FullCusEntWithFullCusProduct[] = + customer.customer_products.flatMap((cp: any) => { + return cp.customer_entitlements.map((e: any) => ({ + ...e, + customer_product: cp, + })); + }); - const filteredEntitlements = cusEnts.filter( - (cusEnt: FullCusEntWithFullCusProduct) => { - const entFeatureType = cusEnt.entitlement.feature.type; - const cusProduct = cusEnt.customer_product; + const filteredEntitlements = cusEnts.filter( + (cusEnt: FullCusEntWithFullCusProduct) => { + const entFeatureType = cusEnt.entitlement.feature.type; + const cusProduct = cusEnt.customer_product; - const isExpired = cusProduct?.status === "expired"; - const isScheduled = cusProduct?.status === "scheduled"; + const isExpired = cusProduct?.status === "expired"; + const isScheduled = cusProduct?.status === "scheduled"; - // Filter by feature type - const featureTypeMatches = - featureType === FeatureType.Boolean - ? entFeatureType === FeatureType.Boolean - : entFeatureType === FeatureType.Metered || - entFeatureType === FeatureType.CreditSystem; + // Filter by feature type + const featureTypeMatches = + featureType === FeatureType.Boolean + ? entFeatureType === FeatureType.Boolean + : entFeatureType === FeatureType.Metered || + entFeatureType === FeatureType.CreditSystem; - // Filter by expired status - const expiredStatusMatches = showExpired ? true : !isExpired; + // Filter by expired status + const expiredStatusMatches = showExpired ? true : !isExpired; - // Filter by entity - const entity = entities.find((e: any) => e.id === entityId); - let entityMatch = true; - if (entityId) { - entityMatch = false; + // Filter by entity + const entity = entities.find((e: any) => e.id === entityId); + let entityMatch = true; + if (entityId) { + entityMatch = false; - const cusProduct = customer.products.find( - (p: any) => p.id === cusEnt.customer_product_id - ); + const cusProduct = customer.products.find( + (p: any) => p.id === cusEnt.customer_product_id + ); - // 1. Product match - const productAttachedToEntity = - cusProduct?.internal_entity_id === entity?.internal_id; + // 1. Product match + const productAttachedToEntity = + cusProduct?.internal_entity_id === entity?.internal_id; - const cusEntContainsEntity = - Object.keys(cusEnt.entities || {}).includes(entity?.id) || - cusEnt.entitlement.entity_feature_id === entity?.feature_id; + const cusEntContainsEntity = + Object.keys(cusEnt.entities || {}).includes(entity?.id) || + cusEnt.entitlement.entity_feature_id === entity?.feature_id; - entityMatch = productAttachedToEntity || cusEntContainsEntity; - } + entityMatch = productAttachedToEntity || cusEntContainsEntity; + } - return ( - featureTypeMatches && - expiredStatusMatches && - !isScheduled && - entityMatch - ); - } - ); + return ( + featureTypeMatches && + expiredStatusMatches && + !isScheduled && + entityMatch + ); + } + ); - const handleSelectCusEntitlement = (cusEnt: FullCustomerEntitlement) => { - setSelectedCusEntitlement(cusEnt); - }; + const handleSelectCusEntitlement = (cusEnt: FullCustomerEntitlement) => { + setSelectedCusEntitlement(cusEnt); + }; - const getAdminHoverTexts = (cusEnt: FullCustomerEntitlement) => { - const entitlement = cusEnt.entitlement; - const featureEntities = entities.filter( - (e: any) => e.feature_id === entitlement.feature.id - ); + const getAdminHoverTexts = (cusEnt: FullCustomerEntitlement) => { + const entitlement = cusEnt.entitlement; + const featureEntities = entities.filter( + (e: any) => e.feature_id === entitlement.feature.id + ); - const hoverTexts = [ - { - key: "Cus Ent ID", - value: cusEnt.id, - }, - ]; + const hoverTexts = [ + { + key: "Cus Ent ID", + value: cusEnt.id, + }, + ]; - if (featureEntities.length > 0) { - hoverTexts.push({ - key: "Entities", - value: featureEntities - .map((e: any) => `${e.id} (${e.name})${e.deleted ? " Deleted" : ""}`) - .join("\n"), - }); - } else if (cusEnt.entities && Object.keys(cusEnt.entities).length > 0) { - const mappedEntities = Object.keys(cusEnt.entities) - .map((e: any) => { - const entity = entities.find((ee: any) => ee.id === e); - const balance = cusEnt.entities![e].balance; - return `${entity?.id} (${entity?.name}): ${balance}`; - }) - .join("\n"); - hoverTexts.push({ - key: "Entities", - value: mappedEntities, - }); - } + if (featureEntities.length > 0) { + hoverTexts.push({ + key: "Entities", + value: featureEntities + .map( + (e: any) => + `${e.id} (${e.name})${e.deleted ? " Deleted" : ""}` + ) + .join("\n"), + }); + } else if (cusEnt.entities && Object.keys(cusEnt.entities).length > 0) { + const mappedEntities = Object.keys(cusEnt.entities) + .map((e: any) => { + const entity = entities.find((ee: any) => ee.id === e); + const balance = cusEnt.entities![e].balance; + return `${entity?.id} (${entity?.name}): ${balance}`; + }) + .join("\n"); + hoverTexts.push({ + key: "Entities", + value: mappedEntities, + }); + } - if (cusEnt.rollovers.length > 0) { - hoverTexts.push({ - key: "Rollovers", - value: cusEnt.rollovers - .map((r: any) => { - if (Object.values(r.entities).length > 0) { - return Object.values(r.entities) - .map((e: any) => `${e.balance} (${e.id})`) - .join(", "); - } else { - return `${r.balance} (ex: ${r.expires_at ? formatUnixToDate(r.expires_at) : "N/A"})`; - } - }) - .join("\n"), - }); - } + if (cusEnt.rollovers.length > 0) { + hoverTexts.push({ + key: "Rollovers", + value: cusEnt.rollovers + .map((r: any) => { + if (Object.values(r.entities).length > 0) { + return Object.values(r.entities) + .map((e: any) => `${e.balance} (${e.id})`) + .join(", "); + } else { + return `${r.balance} (ex: ${r.expires_at ? formatUnixToDate(r.expires_at) : "N/A"})`; + } + }) + .join("\n"), + }); + } - return hoverTexts; - }; + return hoverTexts; + }; - return ( -
-
-

- Available Features -

-
-
-
- - - -
- setFeatureType(FeatureType.Metered)} - > - Metered - - setFeatureType(FeatureType.Boolean)} - > - Boolean - -
-
-
-
-
-
-
- - {filteredEntitlements.length === 0 ? ( -
-

- Attach a product to grant access to features -

-
- ) : ( - <> - - Feature - {showEntityView && Entity} - - {featureType === FeatureType.Metered && "Balance"} - - Product - - {featureType === FeatureType.Metered && "Next Reset"} - - - - - )} + return ( +
+
+

+ Available Features +

+
+
+
+ + + +
+ + setFeatureType( + FeatureType.Metered + ) + } + > + Metered + + + setFeatureType( + FeatureType.Boolean + ) + } + > + Boolean + +
+
+
+
+
+
+
+ + {filteredEntitlements.length === 0 ? ( +
+

+ Attach a product to grant access to features +

+
+ ) : ( + <> + + Feature + {showEntityView && ( + Entity + )} + + {featureType === FeatureType.Metered && "Balance"} + + Product + + {featureType === FeatureType.Metered && + "Next Reset"} + + + + + )} - {filteredEntitlements.map((cusEnt: FullCusEntWithFullCusProduct) => { - const entitlement = cusEnt.entitlement; - const allowanceType = entitlement.allowance_type; + {filteredEntitlements.map( + (cusEnt: FullCusEntWithFullCusProduct) => { + const entitlement = cusEnt.entitlement; + const allowanceType = entitlement.allowance_type; - return ( - - featureType === FeatureType.Metered && - handleSelectCusEntitlement(cusEnt) - } - > - - - {entitlement.feature.name} - - - {showEntityView && ( - - - - )} - -
- {entitlement.feature.type == FeatureType.Boolean ? ( - <> - ) : allowanceType == AllowanceType.Unlimited ? ( - "Unlimited" - ) : entityId && cusEnt.entities?.[entityId] ? ( -
- {cusEnt.entities?.[entityId]?.balance}{" "} -
- ) : cusEnt.entities ? ( -
- {Object.values(cusEnt.entities).reduce( - (sum, entity) => sum + (entity.balance || 0), - 0 - )} -
- ) : ( - <> - {cusEnt.balance}{" "} - - {cusEnt.replaceables.length > 0 - ? ` (${cusEnt.replaceables.length} free)` - : ""} - - - )} -
-
+ return ( + + featureType === FeatureType.Metered && + handleSelectCusEntitlement(cusEnt) + } + > + + + {entitlement.feature.name} + + + {showEntityView && ( + + + + )} + +
+ {entitlement.feature.type == + FeatureType.Boolean ? ( + <> + ) : allowanceType == + AllowanceType.Unlimited ? ( + "Unlimited" + ) : entityId && + cusEnt.entities?.[entityId] ? ( +
+ { + cusEnt.entities?.[entityId] + ?.balance + } + {(() => { + const rolloverAmount = cusEnt.rollovers + .filter( + (x) => + x.entities && + x.entities[entityId] + ) + .reduce( + (sum, rollover) => + sum + + (rollover + .entities?.[ + entityId + ]?.balance || 0), + 0 + ); + return rolloverAmount > 0 ? ( + <> + + {" + "} + {rolloverAmount} + {" "} + (rolled over) + + + ) : null; + })()} +
+ ) : cusEnt.entities ? ( +
+ {Object.values( + cusEnt.entities + ).reduce( + (sum, entity) => + sum + (entity.balance || 0), + 0 + )} + {(() => { + const rolloverAmount = cusEnt.rollovers.reduce( + (x, y) => { + return x + y.balance; + }, + 0 + ); + return rolloverAmount > 0 ? ( + + {" + "} + {rolloverAmount} + {" "} + (rolled over) + + ) : null; + })()} +
+ ) : ( + <> + {cusEnt.balance}{" "} + + {cusEnt.replaceables.length > 0 + ? ` (${cusEnt.replaceables.length} free)` + : ""} + + + )} +
+
- -
- {/* {getProductName(cusEnt)} */} - {cusEnt.customer_product.product.name} - {customer.products.find( - (p: any) => p.id === cusEnt.customer_product_id - )?.status === "expired" && ( - - expired - - )} -
-
- - {formatUnixToDateTime(cusEnt.next_reset_at).date}{" "} - {formatUnixToDateTime(cusEnt.next_reset_at).time} - - -
- ); - })} -
- ); + +
+ {/* {getProductName(cusEnt)} */} + {cusEnt.customer_product.product.name} + {customer.products.find( + (p: any) => + p.id === cusEnt.customer_product_id + )?.status === "expired" && ( + + expired + + )} +
+
+ + { + formatUnixToDateTime(cusEnt.next_reset_at) + .date + }{" "} + { + formatUnixToDateTime(cusEnt.next_reset_at) + .time + } + + + + ); + } + )} +
+ ); }; From db14e8e742d1e5ad93d6f277e5c1df7a1505bb73 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Fri, 25 Jul 2025 11:38:57 +0100 Subject: [PATCH 37/45] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20/track=20deducts?= =?UTF-8?q?=20from=20rollovers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cusEnts/cusRollovers/RolloverService.ts | 32 +- .../cusRollovers/rolloverDeductionUtils.ts | 197 +++ server/src/trigger/updateBalanceTask.ts | 1169 +++++++++-------- server/src/trigger/updateUsageTask.ts | 557 ++++---- 4 files changed, 1109 insertions(+), 846 deletions(-) create mode 100644 server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverDeductionUtils.ts diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts index 7d25cd087..88f642ec9 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts @@ -23,18 +23,25 @@ export class RolloverService { } static async bulkUpdate({ db, rows }: { db: DrizzleCli; rows: Rollover[] }) { - return await db.transaction(async (tx) => { - const results = []; - for (const row of rows) { - const result = await tx - .update(rollovers) - .set(row as any) - .where(eq(rollovers.id, row.id)) - .returning(); - results.push(...result); - } - return results; - }); + if (rows.length === 0) return []; + + const results = []; + for (const row of rows) { + const result = await this.update({ + db, + id: row.id, + updates: row, + }); + results.push(...result); + } + return results; + } + + static async getCurrentRollovers({ db, cusEntID }: { db: DrizzleCli; cusEntID: string }) { + return await db + .select() + .from(rollovers) + .where(and(eq(rollovers.cus_ent_id, cusEntID), gte(rollovers.expires_at, new Date().getTime()))); } static async insert({ @@ -50,7 +57,6 @@ export class RolloverService { cusEntID: string; entityMode: boolean; }) { - console.log("Inserting rollovers:", JSON.stringify(rows, null, 2)); await db .insert(rollovers) .values(rows as any) diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverDeductionUtils.ts b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverDeductionUtils.ts new file mode 100644 index 000000000..a7649ca12 --- /dev/null +++ b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverDeductionUtils.ts @@ -0,0 +1,197 @@ +import { logger } from "@/external/logtail/logtailUtils.js"; +import { + DeductParams, + RolloverDeductParams, +} from "@/trigger/updateBalanceTask.js"; +import { FullCusEntWithFullCusProduct, Rollover } from "@autumn/shared"; +import { RolloverService } from "./RolloverService.js"; + +export const deductFromCusRollovers = async ({ + toDeduct, + deductParams, + cusEnt, +}: { + toDeduct: number; + deductParams: RolloverDeductParams; + cusEnt: FullCusEntWithFullCusProduct; +}) => { + if (toDeduct == 0) { + return toDeduct; + } + let start = performance.now(); + let updates = { + toInsert: [] as Rollover[], + toUpdate: [] as Rollover[], + }; + let rollovers = getSortedRollovers({ + cusEnts: [cusEnt], + featureId: deductParams.feature.id, + entityId: deductParams.entity?.id, + }); + + console.log(`rollovers: ${JSON.stringify(rollovers)}`); + + if (deductParams.entity) { + console.log( + `Processing entity-specific rollover deduction for entity ${deductParams.entity.id}, toDeduct: ${toDeduct}` + ); + for(let rollover of rollovers) { + console.log( + `Processing rollover ${rollover.id} for entity ${deductParams.entity.id} with balance ${rollover.balance}, usage ${rollover.usage}, expires_at ${rollover.expires_at}, toDeduct remaining: ${toDeduct}` + ); + let entityRollover = rollover.entities[deductParams.entity.id]; + if(entityRollover) { + console.log( + `Found entity rollover for entity ${deductParams.entity.id}: balance ${entityRollover.balance}, usage ${entityRollover.usage}` + ); + if(entityRollover.balance >= toDeduct) { + console.log( + `Entity rollover has sufficient balance (${entityRollover.balance}) to cover remaining deduction (${toDeduct})` + ); + entityRollover.balance -= toDeduct; + entityRollover.usage += toDeduct; + console.log( + `Updated entity rollover: new balance ${entityRollover.balance}, new usage ${entityRollover.usage}` + ); + console.log( + `Updated rollover ${rollover.id}: new balance ${rollover.balance}, new usage ${rollover.usage}` + ); + updates.toUpdate.push(rollover); + toDeduct = 0; + console.log( + `Entity deduction complete. Remaining toDeduct: ${toDeduct}` + ); + break; + } else { + if(entityRollover.balance > 0) { + console.log( + `Entity rollover has insufficient balance (${entityRollover.balance}) for full deduction (${toDeduct}). Using all available balance.` + ); + let deductedAmount = entityRollover.balance; + toDeduct -= entityRollover.balance; + entityRollover.balance = 0; + entityRollover.usage += deductedAmount; + console.log( + `Updated entity rollover: new balance ${entityRollover.balance}, new usage ${entityRollover.usage}` + ); + console.log( + `Updated rollover ${rollover.id}: new balance ${rollover.balance}, new usage ${rollover.usage}. Remaining toDeduct: ${toDeduct}` + ); + updates.toUpdate.push(rollover); + } else { + console.log( + `Entity rollover has zero balance, skipping` + ); + } + } + } else { + console.log( + `No entity rollover found for entity ${deductParams.entity.id} in rollover ${rollover.id}` + ); + } + } + } else { + for (let rollover of rollovers) { + console.log( + `Processing rollover ${rollover.id} with balance ${rollover.balance}, usage ${rollover.usage}, expires_at ${rollover.expires_at}, toDeduct remaining: ${toDeduct}` + ); + + if (rollover.balance >= toDeduct) { + console.log( + `Rollover ${rollover.id} has sufficient balance (${rollover.balance}) to cover remaining deduction (${toDeduct})` + ); + rollover = { + ...rollover, + balance: rollover.balance - toDeduct, + usage: rollover.usage + toDeduct, + }; + console.log( + `Updated rollover ${rollover.id}: new balance ${rollover.balance}, new usage ${rollover.usage}` + ); + updates.toUpdate.push(rollover); + toDeduct = 0; + console.log( + `Deduction complete. Remaining toDeduct: ${toDeduct}` + ); + break; + } else { + if (rollover.balance > 0) { + console.log( + `Rollover ${rollover.id} has insufficient balance (${rollover.balance}) for full deduction (${toDeduct}). Using all available balance.` + ); + toDeduct -= rollover.balance; + rollover = { + ...rollover, + usage: rollover.usage + rollover.balance, + balance: 0, + }; + console.log( + `Updated rollover ${rollover.id}: new balance ${rollover.balance}, new usage ${rollover.usage}. Remaining toDeduct: ${toDeduct}` + ); + updates.toUpdate.push(rollover); + } else { + console.log( + `Rollover ${rollover.id} has zero balance, skipping` + ); + } + } + } + } + + let dbResp = await RolloverService.bulkUpdate({ + db: deductParams.db, + rows: updates.toUpdate, + }); + console.log(`dbResp: ${JSON.stringify(dbResp)}`); + + let end = performance.now(); + console.log( + `deductFromCusRollovers took ${end - start}ms for ${toDeduct} toDeduct out of ${rollovers.length} rollovers` + ); + + return toDeduct; +}; + +export const getSortedRollovers = ({ + cusEnts, + featureId, + entityId, +}: { + cusEnts: FullCusEntWithFullCusProduct[]; + featureId: string; + entityId?: string; +}) => { + if (!entityId) + return cusEnts + .filter((cusEnt) => { + return cusEnt.feature_id === featureId; + }) + .flatMap((cusEnt) => { + return cusEnt.rollovers; + }) + .sort((a, b) => { + if (a.expires_at && b.expires_at) + return a.expires_at - b.expires_at; + if (a.expires_at && !b.expires_at) return -1; + if (!a.expires_at && b.expires_at) return 1; + return 0; + }); + else { + return cusEnts + .filter((cusEnt) => { + return cusEnt.feature_id === featureId && cusEnt.entities && cusEnt.entities[entityId]; + }) + .flatMap((cusEnt) => { + return cusEnt.rollovers.filter(x => { + return x.entities[entityId] + }); + }) + .sort((a, b) => { + if (a.expires_at && b.expires_at) + return a.expires_at - b.expires_at; + if (a.expires_at && !b.expires_at) return -1; + if (!a.expires_at && b.expires_at) return 1; + return 0; + }); + } +}; \ No newline at end of file diff --git a/server/src/trigger/updateBalanceTask.ts b/server/src/trigger/updateBalanceTask.ts index 9adb355ad..6f330bda4 100644 --- a/server/src/trigger/updateBalanceTask.ts +++ b/server/src/trigger/updateBalanceTask.ts @@ -1,16 +1,16 @@ import { - AllowanceType, - AppEnv, - FullCusProduct, - CusProductStatus, - Entity, - Event, - Feature, - FullCustomerEntitlement, - FullCustomerPrice, - Organization, - FullCusEntWithFullCusProduct, - BillingType, + AllowanceType, + AppEnv, + FullCusProduct, + CusProductStatus, + Entity, + Event, + Feature, + FullCustomerEntitlement, + FullCustomerPrice, + Organization, + FullCusEntWithFullCusProduct, + BillingType, } from "@autumn/shared"; import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js"; import { Customer, FeatureType } from "@autumn/shared"; @@ -18,678 +18,711 @@ import { getCusEntsInFeatures } from "@/internal/customers/cusUtils/cusUtils.js" import { Decimal } from "decimal.js"; import { adjustAllowance } from "./adjustAllowance.js"; import { - getMeteredDeduction, - getCreditSystemDeduction, - performDeduction, + getMeteredDeduction, + getCreditSystemDeduction, + performDeduction, } from "./deductUtils.js"; import { notNullish, nullish } from "@/utils/genUtils.js"; import { - creditSystemContainsFeature, - featureToCreditSystem, + creditSystemContainsFeature, + featureToCreditSystem, } from "@/internal/features/creditSystemUtils.js"; import { - getCusEntMasterBalance, - getRelatedCusPrice, - getResetBalance, - getTotalNegativeBalance, + getCusEntMasterBalance, + getRelatedCusPrice, + getResetBalance, + getTotalNegativeBalance, } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js"; import { entityFeatureIdExists } from "@/internal/api/entities/entityUtils.js"; import { CusService } from "@/internal/customers/CusService.js"; import { DrizzleCli } from "@/db/initDrizzle.js"; import { findCusEnt } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils/findCusEntUtils.js"; import { - getBillingType, - getEntOptions, + getBillingType, + getEntOptions, } from "@/internal/products/prices/priceUtils.js"; +import { deductFromCusRollovers } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverDeductionUtils.js"; // Decimal.set({ precision: 12 }); // 12 DP precision -type DeductParams = { - db: DrizzleCli; - env: AppEnv; - org: Organization; - cusPrices: FullCustomerPrice[]; - customer: Customer; - properties: any; - feature: Feature; - entity?: Entity; +export type DeductParams = { + db: DrizzleCli; + env: AppEnv; + org: Organization; + cusPrices: FullCustomerPrice[]; + customer: Customer; + properties: any; + feature: Feature; + entity?: Entity; +}; + +export type RolloverDeductParams = { + db: DrizzleCli; + env: AppEnv; + feature: Feature; + entity?: Entity; }; // 2. Get deductions for each feature const getFeatureDeductions = ({ - cusEnts, - event, - features, + cusEnts, + event, + features, }: { - cusEnts: FullCustomerEntitlement[]; - event: Event; - features: Feature[]; + cusEnts: FullCustomerEntitlement[]; + event: Event; + features: Feature[]; }) => { - const meteredFeatures = features.filter( - (feature) => feature.type === FeatureType.Metered - ); - const featureDeductions = []; - for (const feature of features) { - let deduction; - if (feature.type === FeatureType.Metered) { - deduction = getMeteredDeduction(feature, event); - } else if (feature.type === FeatureType.CreditSystem) { - deduction = getCreditSystemDeduction({ - meteredFeatures: meteredFeatures, - creditSystem: feature, - event, - }); - } + const meteredFeatures = features.filter( + (feature) => feature.type === FeatureType.Metered + ); + const featureDeductions = []; + for (const feature of features) { + let deduction; + if (feature.type === FeatureType.Metered) { + deduction = getMeteredDeduction(feature, event); + } else if (feature.type === FeatureType.CreditSystem) { + deduction = getCreditSystemDeduction({ + meteredFeatures: meteredFeatures, + creditSystem: feature, + event, + }); + } - // Check if unlimited exists - let unlimitedExists = cusEnts.some( - (cusEnt) => - cusEnt.entitlement.allowance_type === AllowanceType.Unlimited && - cusEnt.entitlement.internal_feature_id == feature.internal_id - ); + // Check if unlimited exists + let unlimitedExists = cusEnts.some( + (cusEnt) => + cusEnt.entitlement.allowance_type === AllowanceType.Unlimited && + cusEnt.entitlement.internal_feature_id == feature.internal_id + ); - if (unlimitedExists || !deduction) { - continue; - } + if (unlimitedExists || !deduction) { + continue; + } - featureDeductions.push({ - feature, - deduction, - }); - } + featureDeductions.push({ + feature, + deduction, + }); + } - featureDeductions.sort((a, b) => { - if ( - a.feature.type === FeatureType.CreditSystem && - b.feature.type !== FeatureType.CreditSystem - ) { - return 1; - } + featureDeductions.sort((a, b) => { + if ( + a.feature.type === FeatureType.CreditSystem && + b.feature.type !== FeatureType.CreditSystem + ) { + return 1; + } - if ( - a.feature.type !== FeatureType.CreditSystem && - b.feature.type === FeatureType.CreditSystem - ) { - return -1; - } + if ( + a.feature.type !== FeatureType.CreditSystem && + b.feature.type === FeatureType.CreditSystem + ) { + return -1; + } - return a.feature.id.localeCompare(b.feature.id); - }); + return a.feature.id.localeCompare(b.feature.id); + }); - return featureDeductions; + return featureDeductions; }; export const logBalanceUpdate = ({ - timeTaken, - customer, - features, - cusEnts, - featureDeductions, - properties, - entityId, - org, + timeTaken, + customer, + features, + cusEnts, + featureDeductions, + properties, + entityId, + org, }: { - timeTaken: string; - customer: Customer; - features: Feature[]; - cusEnts: FullCustomerEntitlement[]; - featureDeductions: any; - properties: any; - entityId?: string | null; - org: Organization; + timeTaken: string; + customer: Customer; + features: Feature[]; + cusEnts: FullCustomerEntitlement[]; + featureDeductions: any; + properties: any; + entityId?: string | null; + org: Organization; }) => { - console.log( - ` - Customer: ${customer.id} (${customer.env}) | Org: ${ - org.slug - } | Features: ${features.map((f) => f.id).join(", ")}` - ); - console.log(" - Properties:", properties); - console.log( - " - CusEnts:", - cusEnts.map((cusEnt: any) => { - let balanceStr = cusEnt.balance; + console.log( + ` - Customer: ${customer.id} (${customer.env}) | Org: ${ + org.slug + } | Features: ${features.map((f) => f.id).join(", ")}` + ); + console.log(" - Properties:", properties); + console.log( + " - CusEnts:", + cusEnts.map((cusEnt: any) => { + let balanceStr = cusEnt.balance; - if (notNullish(cusEnt.entitlement.entity_feature_id)) { - console.log( - ` - Entity feature ID found for feature: ${cusEnt.feature_id}` - ); + if (notNullish(cusEnt.entitlement.entity_feature_id)) { + console.log( + ` - Entity feature ID found for feature: ${cusEnt.feature_id}` + ); - if (notNullish(entityId)) { - balanceStr = `${cusEnt.entities?.[entityId!]?.balance} [${entityId}]`; - } else { - balanceStr = `${ - getCusEntMasterBalance({ - cusEnt, - entities: cusEnt.customer_product?.entities, - }).balance - } [Master]`; - } - } - try { - if (cusEnt.entitlement.allowance_type === AllowanceType.Unlimited) { - balanceStr = "Unlimited"; - } - } catch (error) { - balanceStr = "failed_to_get_balance"; - } + if (notNullish(entityId)) { + balanceStr = `${cusEnt.entities?.[entityId!]?.balance} [${entityId}]`; + } else { + balanceStr = `${ + getCusEntMasterBalance({ + cusEnt, + entities: cusEnt.customer_product?.entities, + }).balance + } [Master]`; + } + } + try { + if ( + cusEnt.entitlement.allowance_type === + AllowanceType.Unlimited + ) { + balanceStr = "Unlimited"; + } + } catch (error) { + balanceStr = "failed_to_get_balance"; + } - return `${cusEnt.feature_id} - ${balanceStr} (${ - cusEnt.customer_product ? cusEnt.customer_product.product_id : "" - })`; - }), - "| Deductions:", - featureDeductions.map((f: any) => `${f.feature.id}: ${f.deduction}`) - ); + return `${cusEnt.feature_id} - ${balanceStr} (${ + cusEnt.customer_product + ? cusEnt.customer_product.product_id + : "" + })`; + }), + "| Deductions:", + featureDeductions.map((f: any) => `${f.feature.id}: ${f.deduction}`) + ); }; export const performDeductionOnCusEnt = ({ - cusEnt, - toDeduct, - entityId, - allowNegativeBalance = false, - addAdjustment = false, - setZeroAdjustment = false, - blockUsageLimit = true, + cusEnt, + toDeduct, + entityId, + allowNegativeBalance = false, + addAdjustment = false, + setZeroAdjustment = false, + blockUsageLimit = true, }: { - cusEnt: FullCusEntWithFullCusProduct; - toDeduct: number; - entityId?: string | null; - allowNegativeBalance?: boolean; - addAdjustment?: boolean; - setZeroAdjustment?: boolean; - blockUsageLimit?: boolean; + cusEnt: FullCusEntWithFullCusProduct; + toDeduct: number; + entityId?: string | null; + allowNegativeBalance?: boolean; + addAdjustment?: boolean; + setZeroAdjustment?: boolean; + blockUsageLimit?: boolean; }) => { - let newEntities = structuredClone(cusEnt.entities); - let newBalance = structuredClone(cusEnt.balance); - let newAdjustment = structuredClone(cusEnt.adjustment); - let deducted = 0; + let newEntities = structuredClone(cusEnt.entities); + let newBalance = structuredClone(cusEnt.balance); + let newAdjustment = structuredClone(cusEnt.adjustment); + let deducted = 0; - let cusProduct = cusEnt.customer_product; - let options = notNullish(cusProduct) - ? getEntOptions(cusProduct.options, cusEnt.entitlement) - : undefined; - let cusPrice = notNullish(cusProduct) - ? getRelatedCusPrice(cusEnt, cusProduct.customer_prices) - : undefined; - let resetBalance = notNullish(cusProduct) - ? getResetBalance({ - options, - relatedPrice: cusPrice?.price, - entitlement: cusEnt.entitlement, - }) - : cusEnt.entitlement.allowance || 0; + let cusProduct = cusEnt.customer_product; + let options = notNullish(cusProduct) + ? getEntOptions(cusProduct.options, cusEnt.entitlement) + : undefined; + let cusPrice = notNullish(cusProduct) + ? getRelatedCusPrice(cusEnt, cusProduct.customer_prices) + : undefined; + let resetBalance = notNullish(cusProduct) + ? getResetBalance({ + options, + relatedPrice: cusPrice?.price, + entitlement: cusEnt.entitlement, + }) + : cusEnt.entitlement.allowance || 0; - if (entityFeatureIdExists({ cusEnt })) { - if (nullish(entityId)) { - // 1. If no entity ID, deduct from all - newEntities = structuredClone(cusEnt.entities); - if (!newEntities) { - newEntities = {}; - } - let toDeductCursor = toDeduct; - for (const entityId in cusEnt.entities) { - if (toDeductCursor == 0) { - break; - } + if (entityFeatureIdExists({ cusEnt })) { + if (nullish(entityId)) { + // 1. If no entity ID, deduct from all + newEntities = structuredClone(cusEnt.entities); + if (!newEntities) { + newEntities = {}; + } + let toDeductCursor = toDeduct; + for (const entityId in cusEnt.entities) { + if (toDeductCursor == 0) { + break; + } - let entityBalance = cusEnt.entities[entityId].balance; + let entityBalance = cusEnt.entities[entityId].balance; - let { - newBalance: newEntityBalance, - deducted: newDeducted, - toDeduct: newToDeduct, - } = performDeduction({ - cusEntBalance: new Decimal(entityBalance), - toDeduct: toDeductCursor, - allowNegativeBalance, - ent: cusEnt.entitlement, - resetBalance, - blockUsageLimit, - }); + let { + newBalance: newEntityBalance, + deducted: newDeducted, + toDeduct: newToDeduct, + } = performDeduction({ + cusEntBalance: new Decimal(entityBalance), + toDeduct: toDeductCursor, + allowNegativeBalance, + ent: cusEnt.entitlement, + resetBalance, + blockUsageLimit, + }); - newEntities[entityId].balance = newEntityBalance!; + newEntities[entityId].balance = newEntityBalance!; - if (addAdjustment) { - let adjustment = newEntities![entityId!]!.adjustment || 0; - newEntities![entityId!]!.adjustment = adjustment - newDeducted!; - } + if (addAdjustment) { + let adjustment = newEntities![entityId!]!.adjustment || 0; + newEntities![entityId!]!.adjustment = + adjustment - newDeducted!; + } - if (setZeroAdjustment) { - newEntities![entityId!]!.adjustment = 0; - } + if (setZeroAdjustment) { + newEntities![entityId!]!.adjustment = 0; + } - toDeductCursor = newToDeduct!; - deducted += newDeducted!; - } + toDeductCursor = newToDeduct!; + deducted += newDeducted!; + } - toDeduct = toDeductCursor; - } else { - // 2. If entity ID, deduct from that entity - let currentEntityBalance = cusEnt.entities?.[entityId!]?.balance; + toDeduct = toDeductCursor; + } else { + // 2. If entity ID, deduct from that entity + let currentEntityBalance = cusEnt.entities?.[entityId!]?.balance; - let { - newBalance: newEntityBalance, - deducted: newDeducted, - toDeduct: newToDeduct, - } = performDeduction({ - cusEntBalance: new Decimal(currentEntityBalance!), - toDeduct, - allowNegativeBalance, - ent: cusEnt.entitlement, - resetBalance, - blockUsageLimit, - }); + let { + newBalance: newEntityBalance, + deducted: newDeducted, + toDeduct: newToDeduct, + } = performDeduction({ + cusEntBalance: new Decimal(currentEntityBalance!), + toDeduct, + allowNegativeBalance, + ent: cusEnt.entitlement, + resetBalance, + blockUsageLimit, + }); - newEntities![entityId!]!.balance = newEntityBalance!; + newEntities![entityId!]!.balance = newEntityBalance!; - if (addAdjustment) { - let adjustment = newEntities![entityId!]!.adjustment || 0; - newEntities![entityId!]!.adjustment = adjustment - newDeducted!; - } + if (addAdjustment) { + let adjustment = newEntities![entityId!]!.adjustment || 0; + newEntities![entityId!]!.adjustment = adjustment - newDeducted!; + } - if (setZeroAdjustment) { - newEntities![entityId!]!.adjustment = 0; - } + if (setZeroAdjustment) { + newEntities![entityId!]!.adjustment = 0; + } - toDeduct = newToDeduct!; - deducted += newDeducted!; - } - } else { - let { - newBalance: newBalance_, - deducted: deducted_, - toDeduct: newToDeduct_, - } = performDeduction({ - cusEntBalance: new Decimal(cusEnt.balance!), - toDeduct, - allowNegativeBalance, - ent: cusEnt.entitlement, - resetBalance, - blockUsageLimit, - }); + toDeduct = newToDeduct!; + deducted += newDeducted!; + } + } else { + let { + newBalance: newBalance_, + deducted: deducted_, + toDeduct: newToDeduct_, + } = performDeduction({ + cusEntBalance: new Decimal(cusEnt.balance!), + toDeduct, + allowNegativeBalance, + ent: cusEnt.entitlement, + resetBalance, + blockUsageLimit, + }); - newBalance = newBalance_; - deducted = deducted_; - toDeduct = newToDeduct_; + newBalance = newBalance_; + deducted = deducted_; + toDeduct = newToDeduct_; - if (addAdjustment) { - let adjustment = cusEnt.adjustment || 0; - newAdjustment = adjustment - deducted!; - } - } - return { newBalance, newEntities, deducted, toDeduct, newAdjustment }; + if (addAdjustment) { + let adjustment = cusEnt.adjustment || 0; + newAdjustment = adjustment - deducted!; + } + } + return { newBalance, newEntities, deducted, toDeduct, newAdjustment }; }; export const deductAllowanceFromCusEnt = async ({ - toDeduct, - deductParams, - cusEnt, - featureDeductions, - willDeductCredits = false, - setZeroAdjustment = false, + toDeduct, + deductParams, + cusEnt, + featureDeductions, + willDeductCredits = false, + setZeroAdjustment = false, }: { - toDeduct: number; - deductParams: DeductParams; - cusEnt: FullCusEntWithFullCusProduct; - featureDeductions: any; - willDeductCredits?: boolean; - setZeroAdjustment?: boolean; + toDeduct: number; + deductParams: DeductParams; + cusEnt: FullCusEntWithFullCusProduct; + featureDeductions: any; + willDeductCredits?: boolean; + setZeroAdjustment?: boolean; }) => { - const { db, feature, env, org, cusPrices, customer, entity } = deductParams; + const { db, feature, env, org, cusPrices, customer, entity } = deductParams; - if (toDeduct == 0) { - return 0; - } + if (toDeduct == 0) { + return 0; + } - if ( - entity && - entityFeatureIdExists({ cusEnt }) && - cusEnt.entitlement.entity_feature_id !== entity.feature_id - ) - return toDeduct; + if ( + entity && + entityFeatureIdExists({ cusEnt }) && + cusEnt.entitlement.entity_feature_id !== entity.feature_id + ) + return toDeduct; - let { - newBalance, - newEntities, - deducted, - toDeduct: newToDeduct, - } = performDeductionOnCusEnt({ - cusEnt, - toDeduct, - entityId: entity?.id, - allowNegativeBalance: false, - setZeroAdjustment, - }); + let { + newBalance, + newEntities, + deducted, + toDeduct: newToDeduct, + } = performDeductionOnCusEnt({ + cusEnt, + toDeduct, + entityId: entity?.id, + allowNegativeBalance: false, + setZeroAdjustment, + }); - let originalGrpBalance = getTotalNegativeBalance({ - cusEnt, - balance: cusEnt.balance!, - entities: cusEnt.entities!, - }); + let originalGrpBalance = getTotalNegativeBalance({ + cusEnt, + balance: cusEnt.balance!, + entities: cusEnt.entities!, + }); - let newGrpBalance = getTotalNegativeBalance({ - cusEnt, - balance: newBalance!, - entities: newEntities!, - }); + let newGrpBalance = getTotalNegativeBalance({ + cusEnt, + balance: newBalance!, + entities: newEntities!, + }); - let updates: any = { - balance: newBalance, - entities: newEntities, - }; - if (setZeroAdjustment) { - updates.adjustment = 0; - } + let updates: any = { + balance: newBalance, + entities: newEntities, + }; + if (setZeroAdjustment) { + updates.adjustment = 0; + } - const { newReplaceables, deletedReplaceables } = await adjustAllowance({ - db, - env, - org, - cusPrices: cusPrices as any, - customer, - affectedFeature: feature, - cusEnt: cusEnt as any, - originalBalance: originalGrpBalance, - newBalance: newGrpBalance, - logger: console, - }); + const { newReplaceables, deletedReplaceables } = await adjustAllowance({ + db, + env, + org, + cusPrices: cusPrices as any, + customer, + affectedFeature: feature, + cusEnt: cusEnt as any, + originalBalance: originalGrpBalance, + newBalance: newGrpBalance, + logger: console, + }); - if (newReplaceables && newReplaceables.length > 0) { - updates.balance = newBalance! - newReplaceables.length; - } else if (deletedReplaceables && deletedReplaceables.length > 0) { - updates.balance = newBalance! + deletedReplaceables.length; - } + if (newReplaceables && newReplaceables.length > 0) { + updates.balance = newBalance! - newReplaceables.length; + } else if (deletedReplaceables && deletedReplaceables.length > 0) { + updates.balance = newBalance! + deletedReplaceables.length; + } - await CusEntService.update({ - db, - id: cusEnt.id, - updates, - }); + await CusEntService.update({ + db, + id: cusEnt.id, + updates, + }); - // Deduct credit amounts too - if (feature.type === FeatureType.Metered && willDeductCredits) { - for (let i = 0; i < featureDeductions.length; i++) { - let { feature: creditSystem, deduction } = featureDeductions[i]; + // Deduct credit amounts too + if (feature.type === FeatureType.Metered && willDeductCredits) { + for (let i = 0; i < featureDeductions.length; i++) { + let { feature: creditSystem, deduction } = featureDeductions[i]; - if ( - creditSystem.type === FeatureType.CreditSystem && - creditSystemContainsFeature({ - creditSystem: creditSystem, - meteredFeatureId: feature.id!, - }) - ) { - // toDeduct -= deduction; - let creditAmount = featureToCreditSystem({ - featureId: feature.id!, - creditSystem: creditSystem, - amount: deducted, - }); - let newDeduction = new Decimal(deduction) - .minus(creditAmount) - .toNumber(); + if ( + creditSystem.type === FeatureType.CreditSystem && + creditSystemContainsFeature({ + creditSystem: creditSystem, + meteredFeatureId: feature.id!, + }) + ) { + // toDeduct -= deduction; + let creditAmount = featureToCreditSystem({ + featureId: feature.id!, + creditSystem: creditSystem, + amount: deducted, + }); + let newDeduction = new Decimal(deduction) + .minus(creditAmount) + .toNumber(); - featureDeductions[i].deduction = newDeduction; - } - } - } + featureDeductions[i].deduction = newDeduction; + } + } + } - cusEnt.balance = newBalance; - cusEnt.entities = newEntities; - return newToDeduct; + cusEnt.balance = newBalance; + cusEnt.entities = newEntities; + return newToDeduct; }; export const deductFromUsageBasedCusEnt = async ({ - toDeduct, - deductParams, - cusEnts, - setZeroAdjustment = false, + toDeduct, + deductParams, + cusEnts, + setZeroAdjustment = false, }: { - toDeduct: number; - deductParams: DeductParams; - cusEnts: FullCusEntWithFullCusProduct[]; - setZeroAdjustment?: boolean; + toDeduct: number; + deductParams: DeductParams; + cusEnts: FullCusEntWithFullCusProduct[]; + setZeroAdjustment?: boolean; }) => { - const { db, feature, env, org, cusPrices, customer, entity } = deductParams; + const { db, feature, env, org, cusPrices, customer, entity } = deductParams; - // Deduct from usage-based price - const usageBasedEnt = findCusEnt({ - cusEnts, - feature, - entity, - onlyUsageAllowed: true, - }) as FullCusEntWithFullCusProduct; + // Deduct from usage-based price + const usageBasedEnt = findCusEnt({ + cusEnts, + feature, + entity, + onlyUsageAllowed: true, + }) as FullCusEntWithFullCusProduct; - if (!usageBasedEnt) { - console.log( - ` - Feature ${feature.id}, To deduct: ${toDeduct} -> no usage-based entitlement found` - ); - return; - } + if (!usageBasedEnt) { + console.log( + ` - Feature ${feature.id}, To deduct: ${toDeduct} -> no usage-based entitlement found` + ); + return; + } - let cusPrice = getRelatedCusPrice(usageBasedEnt, cusPrices); - let billingType = cusPrice?.price - ? getBillingType(cusPrice?.price.config!) - : undefined; - let blockUsageLimit = - billingType === BillingType.InArrearProrated ? false : true; + let cusPrice = getRelatedCusPrice(usageBasedEnt, cusPrices); + let billingType = cusPrice?.price + ? getBillingType(cusPrice?.price.config!) + : undefined; + let blockUsageLimit = + billingType === BillingType.InArrearProrated ? false : true; - let { newBalance, newEntities, deducted } = performDeductionOnCusEnt({ - cusEnt: usageBasedEnt, - toDeduct, - allowNegativeBalance: true, - setZeroAdjustment, - entityId: entity?.id, - blockUsageLimit, - }); + let { newBalance, newEntities, deducted } = performDeductionOnCusEnt({ + cusEnt: usageBasedEnt, + toDeduct, + allowNegativeBalance: true, + setZeroAdjustment, + entityId: entity?.id, + blockUsageLimit, + }); - let oldGrpBalance = getTotalNegativeBalance({ - cusEnt: usageBasedEnt, - balance: usageBasedEnt.balance!, - entities: usageBasedEnt.entities!, - }); + let oldGrpBalance = getTotalNegativeBalance({ + cusEnt: usageBasedEnt, + balance: usageBasedEnt.balance!, + entities: usageBasedEnt.entities!, + }); - let newGrpBalance = getTotalNegativeBalance({ - cusEnt: usageBasedEnt, - balance: newBalance!, - entities: newEntities!, - }); + let newGrpBalance = getTotalNegativeBalance({ + cusEnt: usageBasedEnt, + balance: newBalance!, + entities: newEntities!, + }); - let updates: any = { - balance: newBalance, - entities: newEntities, - }; - if (setZeroAdjustment) { - updates.adjustment = 0; - } + let updates: any = { + balance: newBalance, + entities: newEntities, + }; + if (setZeroAdjustment) { + updates.adjustment = 0; + } - const { newReplaceables, deletedReplaceables } = await adjustAllowance({ - db, - env, - affectedFeature: feature, - org, - cusEnt: usageBasedEnt as any, - cusPrices: cusPrices as any, - customer, - originalBalance: oldGrpBalance, - newBalance: newGrpBalance, - logger: console, - }); + const { newReplaceables, deletedReplaceables } = await adjustAllowance({ + db, + env, + affectedFeature: feature, + org, + cusEnt: usageBasedEnt as any, + cusPrices: cusPrices as any, + customer, + originalBalance: oldGrpBalance, + newBalance: newGrpBalance, + logger: console, + }); - if (newReplaceables && newReplaceables.length > 0) { - updates.balance = newBalance! - newReplaceables.length; - } else if (deletedReplaceables && deletedReplaceables.length > 0) { - updates.balance = newBalance! + deletedReplaceables.length; - } + if (newReplaceables && newReplaceables.length > 0) { + updates.balance = newBalance! - newReplaceables.length; + } else if (deletedReplaceables && deletedReplaceables.length > 0) { + updates.balance = newBalance! + deletedReplaceables.length; + } - await CusEntService.update({ - db, - id: usageBasedEnt!.id, - updates, - }); + await CusEntService.update({ + db, + id: usageBasedEnt!.id, + updates, + }); }; // Main function to update customer balance export const updateCustomerBalance = async ({ - db, - customerId, - entityId, - event, - features, - org, - env, - logger, + db, + customerId, + entityId, + event, + features, + org, + env, + logger, }: { - db: DrizzleCli; - customerId: string; - entityId: string; - event: Event; - features: Feature[]; - org: Organization; - env: AppEnv; - logger: any; + db: DrizzleCli; + customerId: string; + entityId: string; + event: Event; + features: Feature[]; + org: Organization; + env: AppEnv; + logger: any; }) => { - const startTime = performance.now(); - console.log("REVERSE DEDUCTION ORDER", org.config.reverse_deduction_order); - const customer = await CusService.getFull({ - db, - idOrInternalId: customerId, - orgId: org.id, - env, - inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue], - entityId, - }); + const startTime = performance.now(); + console.log("REVERSE DEDUCTION ORDER", org.config.reverse_deduction_order); + const customer = await CusService.getFull({ + db, + idOrInternalId: customerId, + orgId: org.id, + env, + inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue], + entityId, + }); - const { cusEnts, cusPrices } = await getCusEntsInFeatures({ - customer, - internalFeatureIds: features.map((f) => f.internal_id!), - logger, - reverseOrder: org.config.reverse_deduction_order, - }); + const { cusEnts, cusPrices } = await getCusEntsInFeatures({ + customer, + internalFeatureIds: features.map((f) => f.internal_id!), + logger, + reverseOrder: org.config.reverse_deduction_order, + }); - const endTime = performance.now(); + const endTime = performance.now(); - // 1. Get deductions for each feature - const featureDeductions = getFeatureDeductions({ - cusEnts, - event, - features, - }); + // 1. Get deductions for each feature + const featureDeductions = getFeatureDeductions({ + cusEnts, + event, + features, + }); - logBalanceUpdate({ - timeTaken: (endTime - startTime).toFixed(2), - customer, - features, - cusEnts, - featureDeductions, - properties: event.properties, - org, - entityId: event.entity_id, - }); + logBalanceUpdate({ + timeTaken: (endTime - startTime).toFixed(2), + customer, + features, + cusEnts, + featureDeductions, + properties: event.properties, + org, + entityId: event.entity_id, + }); - // 3. Return if no customer entitlements or features found - if (cusEnts.length === 0 || features.length === 0) { - console.log(" - No customer entitlements or features found"); - return; - } + // 3. Return if no customer entitlements or features found + if (cusEnts.length === 0 || features.length === 0) { + console.log(" - No customer entitlements or features found"); + return; + } - // 4. Perform deductions and update customer balance - for (const obj of featureDeductions) { - let { feature, deduction: toDeduct } = obj; + // 4. Perform deductions and update customer balance + for (const obj of featureDeductions) { + let { feature, deduction: toDeduct } = obj; - for (const cusEnt of cusEnts) { - if (cusEnt.entitlement.internal_feature_id != feature.internal_id) { - continue; - } + for (const cusEnt of cusEnts) { + if (cusEnt.entitlement.internal_feature_id != feature.internal_id) { + continue; + } - toDeduct = await deductAllowanceFromCusEnt({ - toDeduct, - cusEnt, - deductParams: { - db, - feature, - env, - org, - cusPrices: cusPrices as any[], - customer, - properties: event.properties, - entity: customer.entity, - }, - featureDeductions, - willDeductCredits: true, - }); - } + console.log(`toDeduct: ${toDeduct}`); - if (toDeduct == 0) { - continue; - } + toDeduct = await deductFromCusRollovers({ + toDeduct, + cusEnt, + deductParams: { + db, + feature, + env, + entity: customer.entity ? customer.entity : undefined, + }, + }); - await deductFromUsageBasedCusEnt({ - toDeduct, - cusEnts, - deductParams: { - db, - feature, - env, - org, - cusPrices: cusPrices as any[], - customer, - properties: event.properties, - entity: customer.entity, - }, - }); - } + console.log(`toDeduct after rollovers: ${toDeduct}`); - return cusEnts; + if (toDeduct == 0) { + continue; + } + + toDeduct = await deductAllowanceFromCusEnt({ + toDeduct, + cusEnt, + deductParams: { + db, + feature, + env, + org, + cusPrices: cusPrices as any[], + customer, + properties: event.properties, + entity: customer.entity, + }, + featureDeductions, + willDeductCredits: true, + }); + } + + if (toDeduct == 0) { + continue; + } + + await deductFromUsageBasedCusEnt({ + toDeduct, + cusEnts, + deductParams: { + db, + feature, + env, + org, + cusPrices: cusPrices as any[], + customer, + properties: event.properties, + entity: customer.entity, + }, + }); + } + + return cusEnts; }; // MAIN FUNCTION export const runUpdateBalanceTask = async ({ - payload, - logger, - db, + payload, + logger, + db, }: { - payload: any; - logger: any; - db: DrizzleCli; + payload: any; + logger: any; + db: DrizzleCli; }) => { - try { - // 1. Update customer balance - const { customerId, features, event, org, env, entityId } = payload; + try { + // 1. Update customer balance + const { customerId, features, event, org, env, entityId } = payload; - console.log("--------------------------------"); - console.log( - `UPDATING BALANCE FOR CUSTOMER (${customerId}), ORG: ${org.slug}` - ); + console.log("--------------------------------"); + console.log( + `UPDATING BALANCE FOR CUSTOMER (${customerId}), ORG: ${org.slug}` + ); - const cusEnts: any = await updateCustomerBalance({ - db, - customerId, - features, - event, - org, - env, - logger, - entityId, - }); + const cusEnts: any = await updateCustomerBalance({ + db, + customerId, + features, + event, + org, + env, + logger, + entityId, + }); - if (!cusEnts || cusEnts.length === 0) { - return; - } - console.log(" ✅ Customer balance updated"); - } catch (error) { - if (logger) { - logger.use((log: any) => { - return { - ...log, - data: payload, - }; - }); + if (!cusEnts || cusEnts.length === 0) { + return; + } + console.log(" ✅ Customer balance updated"); + } catch (error) { + if (logger) { + logger.use((log: any) => { + return { + ...log, + data: payload, + }; + }); - logger.error(`ERROR UPDATING BALANCE`); - logger.error(error); - } else { - console.log(error); - } - } + logger.error(`ERROR UPDATING BALANCE`); + logger.error(error); + } else { + console.log(error); + } + } }; diff --git a/server/src/trigger/updateUsageTask.ts b/server/src/trigger/updateUsageTask.ts index 0bbe634f3..ea7727db0 100644 --- a/server/src/trigger/updateUsageTask.ts +++ b/server/src/trigger/updateUsageTask.ts @@ -1,12 +1,12 @@ import { - AllowanceType, - AppEnv, - CusProductStatus, - Customer, - Feature, - FeatureType, - FullCustomerEntitlement, - Organization, + AllowanceType, + AppEnv, + CusProductStatus, + Customer, + Feature, + FeatureType, + FullCustomerEntitlement, + Organization, } from "@autumn/shared"; import { getCusEntsInFeatures } from "@/internal/customers/cusUtils/cusUtils.js"; @@ -15,317 +15,344 @@ import { getFeatureBalance } from "@/internal/customers/cusProducts/cusEnts/cusE import { Decimal } from "decimal.js"; import { - deductAllowanceFromCusEnt, - deductFromUsageBasedCusEnt, + deductAllowanceFromCusEnt, + deductFromUsageBasedCusEnt, } from "./updateBalanceTask.js"; import { CusService } from "@/internal/customers/CusService.js"; import { DrizzleCli } from "@/db/initDrizzle.js"; +import { deductFromCusRollovers } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverDeductionUtils.js"; // 2. Get deductions for each feature const getFeatureDeductions = ({ - cusEnts, - value, - features, - shouldSet, + cusEnts, + value, + features, + shouldSet, }: { - cusEnts: FullCustomerEntitlement[]; - value: number; - features: Feature[]; - shouldSet: boolean; + cusEnts: FullCustomerEntitlement[]; + value: number; + features: Feature[]; + shouldSet: boolean; }) => { - let meteredFeature = - features.find((f) => f.type === FeatureType.Metered) || features[0]; + let meteredFeature = + features.find((f) => f.type === FeatureType.Metered) || features[0]; - const featureDeductions = []; - for (const feature of features) { - let newValue = value; - let unlimitedExists = cusEnts.some( - (cusEnt) => - cusEnt.entitlement.allowance_type === AllowanceType.Unlimited && - cusEnt.entitlement.internal_feature_id == feature.internal_id, - ); + const featureDeductions = []; + for (const feature of features) { + let newValue = value; + let unlimitedExists = cusEnts.some( + (cusEnt) => + cusEnt.entitlement.allowance_type === AllowanceType.Unlimited && + cusEnt.entitlement.internal_feature_id == feature.internal_id + ); - if (unlimitedExists) { - continue; - } + if (unlimitedExists) { + continue; + } - if (feature.type === FeatureType.CreditSystem) { - newValue = featureToCreditSystem({ - featureId: meteredFeature.id, - creditSystem: feature, - amount: value, - }); - } + if (feature.type === FeatureType.CreditSystem) { + newValue = featureToCreditSystem({ + featureId: meteredFeature.id, + creditSystem: feature, + amount: value, + }); + } - // If it's set - let deduction = newValue; + // If it's set + let deduction = newValue; - if (shouldSet) { - let totalAllowance = cusEnts.reduce((acc, curr) => { - return acc + (curr.entitlement.allowance || 0); - }, 0); + if (shouldSet) { + let totalAllowance = cusEnts.reduce((acc, curr) => { + return acc + (curr.entitlement.allowance || 0); + }, 0); - let targetBalance = new Decimal(totalAllowance).sub(value).toNumber(); + let targetBalance = new Decimal(totalAllowance) + .sub(value) + .toNumber(); - let totalBalance = getFeatureBalance({ - cusEnts, - internalFeatureId: feature.internal_id!, - })!; + let totalBalance = getFeatureBalance({ + cusEnts, + internalFeatureId: feature.internal_id!, + })!; - deduction = new Decimal(totalBalance).sub(targetBalance).toNumber(); - } + deduction = new Decimal(totalBalance).sub(targetBalance).toNumber(); + } - if (deduction == 0) { - console.log(` - Skipping feature ${feature.id} -- deduction is 0`); - continue; - } + if (deduction == 0) { + console.log( + ` - Skipping feature ${feature.id} -- deduction is 0` + ); + continue; + } - featureDeductions.push({ - feature, - deduction, - }); - } + featureDeductions.push({ + feature, + deduction, + }); + } - featureDeductions.sort((a, b) => { - if ( - a.feature.type === FeatureType.CreditSystem && - b.feature.type !== FeatureType.CreditSystem - ) { - return 1; - } + featureDeductions.sort((a, b) => { + if ( + a.feature.type === FeatureType.CreditSystem && + b.feature.type !== FeatureType.CreditSystem + ) { + return 1; + } - if ( - a.feature.type !== FeatureType.CreditSystem && - b.feature.type === FeatureType.CreditSystem - ) { - return -1; - } + if ( + a.feature.type !== FeatureType.CreditSystem && + b.feature.type === FeatureType.CreditSystem + ) { + return -1; + } - return a.feature.id.localeCompare(b.feature.id); - }); + return a.feature.id.localeCompare(b.feature.id); + }); - return featureDeductions; + return featureDeductions; }; const logUsageUpdate = ({ - customer, - features, - cusEnts, - featureDeductions, - org, - setUsage, - entityId, + customer, + features, + cusEnts, + featureDeductions, + org, + setUsage, + entityId, }: { - customer: Customer; - features: Feature[]; - cusEnts: FullCustomerEntitlement[]; - featureDeductions: any; - org: Organization; - setUsage: boolean; - entityId?: string; + customer: Customer; + features: Feature[]; + cusEnts: FullCustomerEntitlement[]; + featureDeductions: any; + org: Organization; + setUsage: boolean; + entityId?: string; }) => { - console.log( - ` - Customer: ${customer.id} (${customer.env}) | Org: ${ - org.slug - } | Features: ${features.map((f) => f.id).join(", ")} | Set Usage: ${ - setUsage ? "true" : "false" - }`, - ); + console.log( + ` - Customer: ${customer.id} (${customer.env}) | Org: ${ + org.slug + } | Features: ${features.map((f) => f.id).join(", ")} | Set Usage: ${ + setUsage ? "true" : "false" + }` + ); - console.log( - " - CusEnts:", - cusEnts.map((cusEnt: any) => { - let balanceStr = cusEnt.balance; - try { - if (cusEnt.entitlement.allowance_type === AllowanceType.Unlimited) { - balanceStr = "Unlimited"; - } - } catch (error) { - balanceStr = "failed_to_get_balance"; - } + console.log( + " - CusEnts:", + cusEnts.map((cusEnt: any) => { + let balanceStr = cusEnt.balance; + try { + if ( + cusEnt.entitlement.allowance_type === + AllowanceType.Unlimited + ) { + balanceStr = "Unlimited"; + } + } catch (error) { + balanceStr = "failed_to_get_balance"; + } - if (entityId && cusEnt.entities) { - balanceStr = `${cusEnt.entities?.[entityId!]?.balance} [${entityId}]`; - } + if (entityId && cusEnt.entities) { + balanceStr = `${cusEnt.entities?.[entityId!]?.balance} [${entityId}]`; + } - return `${cusEnt.feature_id} - ${balanceStr} (${ - cusEnt.customer_product ? cusEnt.customer_product.product_id : "" - })`; - }), - "| Deductions:", - featureDeductions.map((f: any) => `${f.feature.id}: ${f.deduction}`), - ); + return `${cusEnt.feature_id} - ${balanceStr} (${ + cusEnt.customer_product + ? cusEnt.customer_product.product_id + : "" + })`; + }), + "| Deductions:", + featureDeductions.map((f: any) => `${f.feature.id}: ${f.deduction}`) + ); }; // Main function to update customer balance export const updateUsage = async ({ - db, - customerId, - features, - org, - env, - value, - properties, - setUsage, - logger, - entityId, + db, + customerId, + features, + org, + env, + value, + properties, + setUsage, + logger, + entityId, }: { - db: DrizzleCli; - customerId: string; - features: Feature[]; - org: Organization; - env: AppEnv; - value: number; - properties: any; - setUsage: boolean; - logger: any; - entityId?: string; + db: DrizzleCli; + customerId: string; + features: Feature[]; + org: Organization; + env: AppEnv; + value: number; + properties: any; + setUsage: boolean; + logger: any; + entityId?: string; }) => { - const customer = await CusService.getFull({ - db, - idOrInternalId: customerId, - orgId: org.id, - env, - inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue], - entityId, - }); + const customer = await CusService.getFull({ + db, + idOrInternalId: customerId, + orgId: org.id, + env, + inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue], + entityId, + }); - const { cusEnts, cusPrices } = await getCusEntsInFeatures({ - customer, - internalFeatureIds: features.map((f) => f.internal_id!), - logger, - reverseOrder: org.config?.reverse_deduction_order, - }); + const { cusEnts, cusPrices } = await getCusEntsInFeatures({ + customer, + internalFeatureIds: features.map((f) => f.internal_id!), + logger, + reverseOrder: org.config?.reverse_deduction_order, + }); - // 1. Get deductions for each feature - const featureDeductions = getFeatureDeductions({ - cusEnts, - value, - shouldSet: setUsage, - features, - }); + // 1. Get deductions for each feature + const featureDeductions = getFeatureDeductions({ + cusEnts, + value, + shouldSet: setUsage, + features, + }); - logUsageUpdate({ - customer, - features, - cusEnts, - featureDeductions, - org, - setUsage, - entityId, - }); + logUsageUpdate({ + customer, + features, + cusEnts, + featureDeductions, + org, + setUsage, + entityId, + }); - // 3. Return if no customer entitlements or features found - if (cusEnts.length === 0 || features.length === 0) { - console.log(" - No customer entitlements or features found"); - return; - } + // 3. Return if no customer entitlements or features found + if (cusEnts.length === 0 || features.length === 0) { + console.log(" - No customer entitlements or features found"); + return; + } - for (const obj of featureDeductions) { - let { feature, deduction: toDeduct } = obj; + for (const obj of featureDeductions) { + let { feature, deduction: toDeduct } = obj; - for (const cusEnt of cusEnts) { - if (cusEnt.entitlement.internal_feature_id != feature.internal_id) { - continue; - } + for (const cusEnt of cusEnts) { + if (cusEnt.entitlement.internal_feature_id != feature.internal_id) { + continue; + } + console.log(`toDeduct: ${toDeduct}`); - toDeduct = await deductAllowanceFromCusEnt({ - toDeduct, - cusEnt, - deductParams: { - db, - feature, - env, - org, - cusPrices: cusPrices as any[], - customer, - properties, - entity: customer.entity, - }, - featureDeductions, - willDeductCredits: true, - setZeroAdjustment: true, - }); - } + toDeduct = await deductFromCusRollovers({ + toDeduct, + cusEnt, + deductParams: { + db, + feature, + env, + entity: customer.entity ? customer.entity : undefined, + }, + }); - if (toDeduct == 0) { - continue; - } + console.log(`toDeduct after rollovers: ${toDeduct}`); - await deductFromUsageBasedCusEnt({ - toDeduct, - cusEnts, - deductParams: { - db, - feature, - env, - org, - cusPrices: cusPrices as any[], - customer, - properties, - entity: customer.entity, - }, - setZeroAdjustment: true, - }); - } + if (toDeduct == 0) { + continue; + } + toDeduct = await deductAllowanceFromCusEnt({ + toDeduct, + cusEnt, + deductParams: { + db, + feature, + env, + org, + cusPrices: cusPrices as any[], + customer, + properties, + entity: customer.entity, + }, + featureDeductions, + willDeductCredits: true, + setZeroAdjustment: true, + }); + } - return cusEnts; + if (toDeduct == 0) { + continue; + } + + await deductFromUsageBasedCusEnt({ + toDeduct, + cusEnts, + deductParams: { + db, + feature, + env, + org, + cusPrices: cusPrices as any[], + customer, + properties, + entity: customer.entity, + }, + setZeroAdjustment: true, + }); + } + + return cusEnts; }; // MAIN FUNCTION export const runUpdateUsageTask = async ({ - payload, - logger, - db, - throwError = false, + payload, + logger, + db, + throwError = false, }: { - payload: any; - logger: any; - db: DrizzleCli; - throwError?: boolean; + payload: any; + logger: any; + db: DrizzleCli; + throwError?: boolean; }) => { - try { - // 1. Update customer balance - const { - internalCustomerId, - customerId, - features, - value, - set_usage, - properties, - org, - env, - entityId, - } = payload; + try { + // 1. Update customer balance + const { + internalCustomerId, + customerId, + features, + value, + set_usage, + properties, + org, + env, + entityId, + } = payload; - console.log("--------------------------------"); - console.log( - `HANDLING USAGE TASK FOR CUSTOMER (${customerId}), ORG: ${org.slug}`, - ); + console.log("--------------------------------"); + console.log( + `HANDLING USAGE TASK FOR CUSTOMER (${customerId}), ORG: ${org.slug}` + ); - const cusEnts: any = await updateUsage({ - db, - customerId, - features, - value, - properties, - org, - env, - setUsage: set_usage, - logger, - entityId, - }); + const cusEnts: any = await updateUsage({ + db, + customerId, + features, + value, + properties, + org, + env, + setUsage: set_usage, + logger, + entityId, + }); - if (!cusEnts || cusEnts.length === 0) { - return; - } - console.log(" ✅ Customer balance updated"); - } catch (error) { - logger.error(`ERROR UPDATING USAGE`); - logger.error(error); + if (!cusEnts || cusEnts.length === 0) { + return; + } + console.log(" ✅ Customer balance updated"); + } catch (error) { + logger.error(`ERROR UPDATING USAGE`); + logger.error(error); - if (throwError) { - throw error; - } - } + if (throwError) { + throw error; + } + } }; From fe3f8b518600d38cd642ff1da05db5e4a7faf24f Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Fri, 25 Jul 2025 13:43:29 +0100 Subject: [PATCH 38/45] =?UTF-8?q?fix:=20=F0=9F=90=9B=20weird=20edge=20case?= =?UTF-8?q?s=20with=20filters=20and=20replacables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entitlements/CustomerEntitlementsList.tsx | 99 ++++++++++++------- 1 file changed, 65 insertions(+), 34 deletions(-) diff --git a/vite/src/views/customers/customer/entitlements/CustomerEntitlementsList.tsx b/vite/src/views/customers/customer/entitlements/CustomerEntitlementsList.tsx index 5b1869cef..57131002f 100644 --- a/vite/src/views/customers/customer/entitlements/CustomerEntitlementsList.tsx +++ b/vite/src/views/customers/customer/entitlements/CustomerEntitlementsList.tsx @@ -290,27 +290,32 @@ export const CustomerEntitlementsList = () => { ?.balance } {(() => { - const rolloverAmount = cusEnt.rollovers - .filter( - (x) => - x.entities && - x.entities[entityId] - ) - .reduce( - (sum, rollover) => - sum + - (rollover - .entities?.[ - entityId - ]?.balance || 0), - 0 - ); + const rolloverAmount = + cusEnt.rollovers + .filter( + (x) => + x.entities && + x.entities[ + entityId + ] + ) + .reduce( + (sum, rollover) => + sum + + (rollover + .entities?.[ + entityId + ]?.balance || + 0), + 0 + ); return rolloverAmount > 0 ? ( <> {" + "} - {rolloverAmount} - {" "} + { + rolloverAmount + }{" "} (rolled over) @@ -327,31 +332,57 @@ export const CustomerEntitlementsList = () => { 0 )} {(() => { - const rolloverAmount = cusEnt.rollovers.reduce( - (x, y) => { - return x + y.balance; - }, - 0 - ); + const rolloverAmount = + cusEnt.rollovers.reduce( + (sum, rollover) => { + // Add global rollover balance + let total = sum + (rollover.balance || 0); + + // Add entity-specific rollover balances + if (rollover.entities) { + total += Object.values(rollover.entities).reduce( + (entitySum: number, entity: any) => + entitySum + (entity.balance || 0), + 0 + ); + } + + return total; + }, + 0 + ); return rolloverAmount > 0 ? ( {" + "} - {rolloverAmount} - {" "} - (rolled over) + {rolloverAmount} (rolled + over) ) : null; })()}
) : ( - <> - {cusEnt.balance}{" "} - - {cusEnt.replaceables.length > 0 - ? ` (${cusEnt.replaceables.length} free)` - : ""} - - +
+ {cusEnt.balance} + {(() => { + const rolloverAmount = + cusEnt.rollovers.reduce( + (sum, rollover) => + sum + rollover.balance, + 0 + ); + return rolloverAmount > 0 ? ( + + {" + "} + {rolloverAmount} (rolled over) + + ) : null; + })()} + {cusEnt.replaceables.length > 0 && ( + + {` (${cusEnt.replaceables.length} free)`} + + )} +
)}
From f1a2df50145753725b347b60ab140edc2f36c538 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Fri, 25 Jul 2025 13:43:44 +0100 Subject: [PATCH 39/45] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20rollover=20when=20?= =?UTF-8?q?upgrading=20and=20downgrading?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../add-product/createFullCusProduct.ts | 42 ++++- .../cusEnts/cusRollovers/RolloverService.ts | 5 + .../cusRollovers/getNewProductRollovers.ts | 175 +++++++++++++++--- 3 files changed, 186 insertions(+), 36 deletions(-) diff --git a/server/src/internal/customers/add-product/createFullCusProduct.ts b/server/src/internal/customers/add-product/createFullCusProduct.ts index 5b72f6af8..cf41038ed 100644 --- a/server/src/internal/customers/add-product/createFullCusProduct.ts +++ b/server/src/internal/customers/add-product/createFullCusProduct.ts @@ -34,6 +34,7 @@ import { CusPriceService } from "../cusProducts/cusPrices/CusPriceService.js"; import { addExistingUsagesToCusEnts } from "../cusProducts/cusEnts/cusEntUtils/getExistingUsage.js"; import { RepService } from "../cusProducts/cusEnts/RepService.js"; import { getNewProductRollovers } from "../cusProducts/cusEnts/cusRollovers/getNewProductRollovers.js"; +import { RolloverService } from "../cusProducts/cusEnts/cusRollovers/RolloverService.js"; export const initCusPrice = ({ price, @@ -405,7 +406,8 @@ export const createFullCusProduct = async ({ }); // 4. Get new rollovers - await getNewProductRollovers({ + let rolloverOperations = await getNewProductRollovers({ + db, curCusProduct: curCusProduct as FullCusProduct, cusEnts, entitlements, @@ -467,10 +469,27 @@ export const createFullCusProduct = async ({ replaceables: newReplaceables, }); - let fullCusProduct = { - ...cusProd, - product, - customer_entitlements: cusEnts.map((ce) => ({ + // Insert rollovers for each entitlement + console.log('Inserting rollovers for', rolloverOperations.length, 'entitlements'); + for (const operation of rolloverOperations) { + // Update before insert to ensure performMaximumClearing is called in the right order + if(operation.toUpdate.length > 0) await RolloverService.bulkUpdate({ + db, + rows: operation.toUpdate, + }) + + if(operation.toInsert.length > 0) await RolloverService.insert({ + db, + rows: operation.toInsert, + rolloverConfig: operation.rolloverConfig, + cusEntID: operation.cusEntId, + entityMode: operation.entityMode, + }); + } + + // Get rollovers for each entitlement + const cusEntsWithRollovers = await Promise.all( + cusEnts.map(async (ce) => ({ ...ce, entitlement: entitlements.find((e) => e.id === ce.entitlement_id)!, replaceables: newReplaceables @@ -479,8 +498,17 @@ export const createFullCusProduct = async ({ ...r, delete_next_cycle: r.delete_next_cycle || false, })), - rollovers: [], - })), + rollovers: await RolloverService.getCurrentRollovers({ + db, + cusEntID: ce.id, + }), + })) + ); + + let fullCusProduct = { + ...cusProd, + product, + customer_entitlements: cusEntsWithRollovers, customer_prices: cusPrices.map((cp) => ({ ...cp, price: prices.find((p) => p.id === cp.price_id)!, diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts index 88f642ec9..061ed8ed6 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts @@ -13,6 +13,8 @@ export class RolloverService { id: string; updates: Partial; }) { + if(!updates.balance && !updates.entities) return []; + const data = await db .update(rollovers) .set(updates as any) @@ -57,6 +59,8 @@ export class RolloverService { cusEntID: string; entityMode: boolean; }) { + if(rows.length === 0) return {}; + await db .insert(rollovers) .values(rows as any) @@ -89,6 +93,7 @@ export class RolloverService { } static async delete({ db, ids }: { db: DrizzleCli; ids: string[] }) { + if(ids.length === 0) return; const data = await db.delete(rollovers).where(inArray(rollovers.id, ids)); } } diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/getNewProductRollovers.ts b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/getNewProductRollovers.ts index eb1bdde55..5a485a087 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/getNewProductRollovers.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/getNewProductRollovers.ts @@ -1,40 +1,157 @@ +import { generateId } from "@/utils/genUtils.js"; import { - CustomerEntitlement, - EntitlementWithFeature, - FullCusProduct, + CustomerEntitlement, + EntitlementWithFeature, + FullCusProduct, + Rollover, + RolloverConfig, } from "@autumn/shared"; +import { RolloverService } from "./RolloverService.js"; +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { calculateNextExpiry } from "./rolloverUtils.js"; export const getNewProductRollovers = async ({ - curCusProduct, - cusEnts, - entitlements, - logger, + curCusProduct, + cusEnts: newCusEnts, + entitlements, + db, + logger, }: { - curCusProduct: FullCusProduct; - cusEnts: CustomerEntitlement[]; - entitlements: EntitlementWithFeature[]; - logger: any; + curCusProduct: FullCusProduct; + cusEnts: CustomerEntitlement[]; + entitlements: EntitlementWithFeature[]; + db: DrizzleCli; + logger: any; }) => { - try { - let newRollovers = []; + if (!curCusProduct) return []; + if (!curCusProduct.id) return []; + try { + let rolloverOperations: { + cusEntId: string; + rolloverConfig: RolloverConfig; + toInsert: Rollover[]; + toUpdate: Rollover[]; + entityMode: boolean; + }[] = []; - for (const cusEnt of cusEnts) { - let ent = entitlements.find((e) => e.id === cusEnt.entitlement_id); - if (!ent?.rollover) continue; + let oldEnts = curCusProduct.customer_entitlements; - // 1. Get rollovers from current cus product (Look at feature ID) + for (const newCusEnt of newCusEnts) { + let newEnt = entitlements.find( + (e) => e.id === newCusEnt.entitlement_id + ); + let oldCusEnt = oldEnts.find( + (e) => e.internal_feature_id === newEnt?.internal_feature_id + ); - // 2. Cases - // - Bring over current balance (if greater > 0), and any existing rollover - // - Perform max clearing according to new entitlement's rollover config (so cusEnt.entitlement.rollover) - // - To test: entity mode and non-entity mode, upgrade and downgrade - // - Don't need to handle no entity -> entity or entity -> no entity + if (!oldCusEnt) { + continue; + } + if (!newEnt?.rollover) { + continue; + } - // 3. Perform db operations AFTER insertFullCusProduct later on - } - } catch (error) { - logger.error(`Failed to handle new product rollovers:`, { - error, - }); - } + // Do not handle case where user is upgrading from non-entity to entity or vice versa + if ( + newEnt?.entity_feature_id && + !oldCusEnt.entitlement.entity_feature_id + ) { + continue; + } + if ( + !newEnt?.entity_feature_id && + oldCusEnt.entitlement.entity_feature_id + ) { + continue; + } + + let rollover = newEnt.rollover; + let entityMode = !!newEnt.entity_feature_id; + let toInsert: Rollover[] = []; + let toUpdate: Rollover[] = []; + + // Bring over current balance (if greater > 0), and any existing rollover + if ( + oldCusEnt.balance && + oldCusEnt.balance > 0 && + !oldCusEnt.entitlement.entity_feature_id && + rollover + ) { + toInsert.push({ + id: generateId("roll"), + cus_ent_id: newCusEnt.id, + balance: oldCusEnt.balance, + entities: {}, + usage: 0, + expires_at: calculateNextExpiry(Date.now(), rollover), + }); + } else if (oldCusEnt.entitlement.entity_feature_id) { + if (oldCusEnt.entities) { + const entityRollovers = Object.keys( + oldCusEnt.entities || {} + ).reduce( + (acc, entityId) => { + const entityBalance = + oldCusEnt.entities?.[entityId]; + if (entityBalance && entityBalance.balance > 0) { + acc[entityId] = { + id: entityId, + balance: entityBalance.balance || 0, + usage: 0, + }; + } + return acc; + }, + {} as Record< + string, + { id: string; balance: number; usage: number } + > + ); + + toInsert.push({ + id: generateId("roll"), + cus_ent_id: newCusEnt.id, + balance: 0, + entities: entityRollovers, + usage: 0, + expires_at: calculateNextExpiry(Date.now(), rollover), + }); + } + } + + // Get rollovers from the OLD customer entitlement, not the new one + let curRollovers = await RolloverService.getCurrentRollovers({ + db, + cusEntID: oldCusEnt.id, + }); + + for (const curRollover of curRollovers) { + if ( + curRollover.expires_at && + curRollover.expires_at > Date.now() + ) { + toUpdate.push({ + ...curRollover, + cus_ent_id: newCusEnt.id, // Reassign to the new customer entitlement + }); + } + } + + // Add this entitlement's rollover operations + rolloverOperations.push({ + cusEntId: newCusEnt.id, + rolloverConfig: rollover, + toInsert, + toUpdate, + entityMode, + }); + } + + return rolloverOperations; + } catch (error) { + logger.error(`Failed to handle new product rollovers:`, { + error, + }); + return []; + } }; From 87ebf55d958b27fc236cb4a65f157ac32d6caf37 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Fri, 25 Jul 2025 15:38:30 +0100 Subject: [PATCH 40/45] fix: trigger checkout reward --- .../external/stripe/webhookHandlers/handleCheckoutCompleted.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts b/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts index 77b5c44c0..52040bdbc 100644 --- a/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts +++ b/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts @@ -154,7 +154,7 @@ export const handleCheckoutSessionCompleted = async ({ product, org, env: attachParams.customer.env, - subId: checkoutSession.subscription as string, + subId: checkoutSub?.id as string, }, }); } From cd9f26c2838e0a0a4eb6da7dac8455de8a8bbb77 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Fri, 25 Jul 2025 17:19:38 +0100 Subject: [PATCH 41/45] working on rollovers --- server/src/cron.ts | 32 +-- .../cusEnts/cusRollovers/RolloverService.ts | 49 ++-- server/tests/advanced/rollovers/rollover1.ts | 151 ++++-------- server/tests/advanced/rollovers/rollover2.ts | 229 ++++++++++++++++++ .../advanced/rollovers/rolloverTestUtils.ts | 48 ++++ 5 files changed, 358 insertions(+), 151 deletions(-) create mode 100644 server/tests/advanced/rollovers/rollover2.ts create mode 100644 server/tests/advanced/rollovers/rolloverTestUtils.ts diff --git a/server/src/cron.ts b/server/src/cron.ts index 9b71ac9c7..71be81d6f 100644 --- a/server/src/cron.ts +++ b/server/src/cron.ts @@ -1,34 +1,10 @@ -import { - AllowanceType, - AppEnv, - EntInterval, - FullCusEntWithProduct, - Organization, - RolloverConfig, -} from "@autumn/shared"; -import { CusEntService } from "./internal/customers/cusProducts/cusEnts/CusEntitlementService.js"; - import dotenv from "dotenv"; -import { getEntOptions } from "./internal/products/prices/priceUtils.js"; -import { getNextResetAt } from "./utils/timeUtils.js"; -import chalk from "chalk"; - -import { format, getDate, getMonth, setDate } from "date-fns"; +import { FullCusEntWithProduct } from "@autumn/shared"; +import { CusEntService } from "./internal/customers/cusProducts/cusEnts/CusEntitlementService.js"; +import { format } from "date-fns"; import { CronJob } from "cron"; -import { - getRelatedCusPrice, - getResetBalance, -} from "./internal/customers/cusProducts/cusEnts/cusEntUtils.js"; -import { getResetBalancesUpdate } from "./internal/customers/cusProducts/cusEnts/groupByUtils.js"; -import { getRolloverUpdates } from "./internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.js"; -import { CusProductService } from "./internal/customers/cusProducts/CusProductService.js"; -import { createStripeCli } from "./external/stripe/utils.js"; import { UTCDate } from "@date-fns/utc"; -import { type DrizzleCli, initDrizzle } from "./db/initDrizzle.js"; -import { notNullish } from "./utils/genUtils.js"; - -import { CusPriceService } from "./internal/customers/cusProducts/cusPrices/CusPriceService.js"; -import { RolloverService } from "./internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.js"; +import { initDrizzle } from "./db/initDrizzle.js"; import { resetCustomerEntitlement } from "./cron/cronUtils.js"; dotenv.config(); diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts index 061ed8ed6..4c559a693 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts @@ -13,7 +13,7 @@ export class RolloverService { id: string; updates: Partial; }) { - if(!updates.balance && !updates.entities) return []; + if (!updates.balance && !updates.entities) return []; const data = await db .update(rollovers) @@ -26,7 +26,7 @@ export class RolloverService { static async bulkUpdate({ db, rows }: { db: DrizzleCli; rows: Rollover[] }) { if (rows.length === 0) return []; - + const results = []; for (const row of rows) { const result = await this.update({ @@ -39,11 +39,22 @@ export class RolloverService { return results; } - static async getCurrentRollovers({ db, cusEntID }: { db: DrizzleCli; cusEntID: string }) { + static async getCurrentRollovers({ + db, + cusEntID, + }: { + db: DrizzleCli; + cusEntID: string; + }) { return await db .select() .from(rollovers) - .where(and(eq(rollovers.cus_ent_id, cusEntID), gte(rollovers.expires_at, new Date().getTime()))); + .where( + and( + eq(rollovers.cus_ent_id, cusEntID), + gte(rollovers.expires_at, new Date().getTime()) + ) + ); } static async insert({ @@ -59,7 +70,9 @@ export class RolloverService { cusEntID: string; entityMode: boolean; }) { - if(rows.length === 0) return {}; + if (rows.length === 0) return {}; + + console.log("inserting rollovers", rows); await db .insert(rollovers) @@ -76,24 +89,24 @@ export class RolloverService { ) ); - let { toDelete, toUpdate } = await performMaximumClearing({ - rows: currentRolloverRows as Rollover[], - rolloverConfig, - cusEntID, - entityMode, - }); + // let { toDelete, toUpdate } = await performMaximumClearing({ + // rows: currentRolloverRows as Rollover[], + // rolloverConfig, + // cusEntID, + // entityMode, + // }); - if (toDelete.length > 0) { - await RolloverService.delete({ db, ids: toDelete }); - } + // if (toDelete.length > 0) { + // await RolloverService.delete({ db, ids: toDelete }); + // } - if (toUpdate.length > 0) { - await RolloverService.bulkUpdate({ db, rows: toUpdate }); - } + // if (toUpdate.length > 0) { + // await RolloverService.bulkUpdate({ db, rows: toUpdate }); + // } } static async delete({ db, ids }: { db: DrizzleCli; ids: string[] }) { - if(ids.length === 0) return; + if (ids.length === 0) return; const data = await db.delete(rollovers).where(inArray(rollovers.id, ids)); } } diff --git a/server/tests/advanced/rollovers/rollover1.ts b/server/tests/advanced/rollovers/rollover1.ts index b516ebf02..16d076064 100644 --- a/server/tests/advanced/rollovers/rollover1.ts +++ b/server/tests/advanced/rollovers/rollover1.ts @@ -26,8 +26,9 @@ import { getMainCusProduct } from "@/internal/customers/cusProducts/cusProductUt import { cusProductToCusEnt } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js"; import { timeout } from "@/utils/genUtils.js"; import { resetCustomerEntitlement } from "@/cron/cronUtils.js"; +import { resetAndGetCusEnt } from "./rolloverTestUtils.js"; -let rolloverConfig = { max: 100, length: 1, duration: RolloverDuration.Month }; +let rolloverConfig = { max: 500, length: 1, duration: RolloverDuration.Month }; const messagesItem = constructFeatureItem({ featureId: TestFeature.Messages, includedUsage: 400, @@ -35,22 +36,15 @@ const messagesItem = constructFeatureItem({ rolloverConfig, }) as LimitedItem; -const perUserItem = constructFeatureItem({ - featureId: TestFeature.Credits, - includedUsage: 400, - interval: ProductItemInterval.Month, - rolloverConfig, - entityFeatureId: TestFeature.Users, -}); - export let pro = constructProduct({ - items: [messagesItem, perUserItem], + items: [messagesItem], type: "pro", }); const testCase = "rollover1"; +// , per entity and regular -describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item, per entity and regular`)}`, () => { +describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item`)}`, () => { let customerId = testCase; let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); let testClockId: string; @@ -96,29 +90,15 @@ describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item, customer = res.customer; }); - const entities = [ - { - id: "1", - name: "Entity 1", - feature_id: TestFeature.Users, - }, - { - id: "2", - name: "Entity 2", - feature_id: TestFeature.Users, - }, - ]; - it("should attach pro product", async function () { await autumn.attach({ customer_id: customerId, product_id: pro.id, }); - - await autumn.entities.create(customerId, entities); }); let messageUsage = 250; + let curBalance = messagesItem.included_usage; it("should create track messages, reset, and have correct rollover", async function () { await autumn.track({ @@ -129,100 +109,61 @@ describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item, await timeout(3000); - // Run reset cusEnt on ... - let mainCusProduct = await getMainCusProduct({ + let msgesCusEnt = await resetAndGetCusEnt({ db, - internalCustomerId: customer.internal_id, + customer, productGroup: pro.group, - }); - - let msgesCusEnt = cusProductToCusEnt({ - cusProduct: mainCusProduct!, featureId: TestFeature.Messages, }); - await resetCustomerEntitlement({ - db, - cusEnt: msgesCusEnt!, - }); + let cus = await autumn.customers.get(customerId); + let msgesFeature = cus.features[TestFeature.Messages]; - mainCusProduct = await getMainCusProduct({ - db, - internalCustomerId: customer.internal_id, - productGroup: pro.group, - }); - - msgesCusEnt = cusProductToCusEnt({ - cusProduct: mainCusProduct!, - featureId: TestFeature.Messages, - }); - - let rollover = messagesItem.included_usage - messageUsage; - - expect(msgesCusEnt?.rollovers.length).to.equal(1); - expect(msgesCusEnt?.rollovers[0].balance).to.equal( - Math.min(rollover, rolloverConfig.max) + let expectedRollover = Math.min( + messagesItem.included_usage - messageUsage, + rolloverConfig.max ); + + let expectedBalance = messagesItem.included_usage + expectedRollover; + + expect(msgesFeature).to.exist; + expect(msgesFeature?.balance).to.equal(expectedBalance); + // @ts-ignore + expect(msgesFeature?.rollovers[0].balance).to.equal(expectedRollover); + curBalance = expectedBalance; + + // let rollover = messagesItem.included_usage - messageUsage; + + // expect(msgesCusEnt?.rollovers.length).to.equal(1); + // expect(msgesCusEnt?.rollovers[0].balance).to.equal( + // Math.min(rollover, rolloverConfig.max) + // ); }); - let perUserUsage = { - [entities[0].id]: 350, - [entities[1].id]: 200, - }; - - it("should track per user credits, reset, and have correct rollover", async function () { - for (let entityId in perUserUsage) { - await autumn.track({ - customer_id: customerId, - feature_id: TestFeature.Credits, - value: perUserUsage[entityId], - entity_id: entityId, - }); - } - - await timeout(2000); - - let mainCusProduct = await getMainCusProduct({ + // let usage2 = 50; + it("should track messages, reset again and have correct rollover", async function () { + await resetAndGetCusEnt({ db, - internalCustomerId: customer.internal_id, + customer, productGroup: pro.group, + featureId: TestFeature.Messages, }); - let perUserCusEnt = cusProductToCusEnt({ - cusProduct: mainCusProduct!, - featureId: TestFeature.Credits, - }); + console.log("Current balance", curBalance); + console.log("Max rollover", rolloverConfig.max); + let expectedRollover = Math.min(curBalance, rolloverConfig.max); - await resetCustomerEntitlement({ - db, - cusEnt: perUserCusEnt!, - }); + let expectedBalance = curBalance + expectedRollover; + console.log("Expected rollover", expectedRollover); + console.log("Expected balance", expectedBalance); - mainCusProduct = await getMainCusProduct({ - db, - internalCustomerId: customer.internal_id, - productGroup: pro.group, - }); + let cus = await autumn.customers.get(customerId); + let msgesFeature = cus.features[TestFeature.Messages]; - perUserCusEnt = cusProductToCusEnt({ - cusProduct: mainCusProduct!, - featureId: TestFeature.Credits, - }); - - let perUserRollover = perUserCusEnt?.rollovers[0]; - expect(perUserRollover).to.exist; - for (let entityId in perUserUsage) { - let entityRollover = perUserRollover?.entities[entityId]; - - let expectedRollover = Math.min( - entityRollover!.balance, - rolloverConfig.max - ); - - expect(entityRollover).to.exist; - expect(entityRollover?.balance).to.equal(expectedRollover); - } - - await timeout(3000); + expect(msgesFeature).to.exist; + expect(msgesFeature?.balance).to.equal(expectedBalance); + // // @ts-ignore + // expect(msgesFeature?.rollovers[0].balance).to.equal(expectedRollover); + // curBalance = expectedBalance; }); }); diff --git a/server/tests/advanced/rollovers/rollover2.ts b/server/tests/advanced/rollovers/rollover2.ts new file mode 100644 index 000000000..1d58a2d15 --- /dev/null +++ b/server/tests/advanced/rollovers/rollover2.ts @@ -0,0 +1,229 @@ +import chalk from "chalk"; +import Stripe from "stripe"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; + +import { + APIVersion, + AppEnv, + Customer, + LimitedItem, + Organization, + ProductItemInterval, + RolloverDuration, +} from "@autumn/shared"; + +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { setupBefore } from "tests/before.js"; +import { createProducts } from "tests/utils/productUtils.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { addPrefixToProducts } from "tests/attach/utils.js"; + +import { expect } from "chai"; +import { getMainCusProduct } from "@/internal/customers/cusProducts/cusProductUtils.js"; +import { cusProductToCusEnt } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js"; +import { timeout } from "@/utils/genUtils.js"; +import { resetCustomerEntitlement } from "@/cron/cronUtils.js"; + +let rolloverConfig = { max: 100, length: 1, duration: RolloverDuration.Month }; +const messagesItem = constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 400, + interval: ProductItemInterval.Month, + rolloverConfig, +}) as LimitedItem; + +const perUserItem = constructFeatureItem({ + featureId: TestFeature.Credits, + includedUsage: 400, + interval: ProductItemInterval.Month, + rolloverConfig, + entityFeatureId: TestFeature.Users, +}); + +export let pro = constructProduct({ + items: [messagesItem, perUserItem], + type: "pro", +}); + +const testCase = "rollover1"; +// , per entity and regular + +describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item`)}`, () => { + let customerId = testCase; + let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); + let testClockId: string; + let db: DrizzleCli, org: Organization, env: AppEnv; + let customer: Customer; + let stripeCli: Stripe; + + let curUnix = new Date().getTime(); + + before(async function () { + await setupBefore(this); + const { autumnJs } = this; + db = this.db; + org = this.org; + env = this.env; + + stripeCli = this.stripeCli; + + addPrefixToProducts({ + products: [pro], + prefix: testCase, + }); + + await createProducts({ + autumn, + products: [pro], + customerId, + db, + orgId: org.id, + env, + }); + + const res = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + testClockId = res.testClockId!; + customer = res.customer; + }); + + const entities = [ + { + id: "1", + name: "Entity 1", + feature_id: TestFeature.Users, + }, + { + id: "2", + name: "Entity 2", + feature_id: TestFeature.Users, + }, + ]; + + it("should attach pro product", async function () { + await autumn.attach({ + customer_id: customerId, + product_id: pro.id, + }); + + await autumn.entities.create(customerId, entities); + }); + + let messageUsage = 250; + + it("should create track messages, reset, and have correct rollover", async function () { + await autumn.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: messageUsage, + }); + + await timeout(3000); + + // Run reset cusEnt on ... + let mainCusProduct = await getMainCusProduct({ + db, + internalCustomerId: customer.internal_id, + productGroup: pro.group, + }); + + let msgesCusEnt = cusProductToCusEnt({ + cusProduct: mainCusProduct!, + featureId: TestFeature.Messages, + }); + + await resetCustomerEntitlement({ + db, + cusEnt: msgesCusEnt!, + }); + + mainCusProduct = await getMainCusProduct({ + db, + internalCustomerId: customer.internal_id, + productGroup: pro.group, + }); + + msgesCusEnt = cusProductToCusEnt({ + cusProduct: mainCusProduct!, + featureId: TestFeature.Messages, + }); + + let rollover = messagesItem.included_usage - messageUsage; + + expect(msgesCusEnt?.rollovers.length).to.equal(1); + expect(msgesCusEnt?.rollovers[0].balance).to.equal( + Math.min(rollover, rolloverConfig.max) + ); + }); + + let perUserUsage = { + [entities[0].id]: 350, + [entities[1].id]: 200, + }; + + it("should track per user credits, reset, and have correct rollover", async function () { + for (let entityId in perUserUsage) { + await autumn.track({ + customer_id: customerId, + feature_id: TestFeature.Credits, + value: perUserUsage[entityId], + entity_id: entityId, + }); + } + + await timeout(2000); + + let mainCusProduct = await getMainCusProduct({ + db, + internalCustomerId: customer.internal_id, + productGroup: pro.group, + }); + + let perUserCusEnt = cusProductToCusEnt({ + cusProduct: mainCusProduct!, + featureId: TestFeature.Credits, + }); + + await resetCustomerEntitlement({ + db, + cusEnt: perUserCusEnt!, + }); + + mainCusProduct = await getMainCusProduct({ + db, + internalCustomerId: customer.internal_id, + productGroup: pro.group, + }); + + perUserCusEnt = cusProductToCusEnt({ + cusProduct: mainCusProduct!, + featureId: TestFeature.Credits, + }); + + let perUserRollover = perUserCusEnt?.rollovers[0]; + expect(perUserRollover).to.exist; + for (let entityId in perUserUsage) { + let entityRollover = perUserRollover?.entities[entityId]; + + let expectedRollover = Math.min( + entityRollover!.balance, + rolloverConfig.max + ); + + expect(entityRollover).to.exist; + expect(entityRollover?.balance).to.equal(expectedRollover); + } + + await timeout(3000); + }); +}); diff --git a/server/tests/advanced/rollovers/rolloverTestUtils.ts b/server/tests/advanced/rollovers/rolloverTestUtils.ts new file mode 100644 index 000000000..f038160f4 --- /dev/null +++ b/server/tests/advanced/rollovers/rolloverTestUtils.ts @@ -0,0 +1,48 @@ +import { resetCustomerEntitlement } from "@/cron/cronUtils.js"; +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { getMainCusProduct } from "@/internal/customers/cusProducts/cusProductUtils.js"; +import { cusProductToCusEnt } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js"; +import { Customer } from "@autumn/shared"; +import { TestFeature } from "tests/setup/v2Features.js"; + +export const resetAndGetCusEnt = async ({ + db, + customer, + productGroup, + featureId, +}: { + db: DrizzleCli; + customer: Customer; + productGroup: string; + featureId: string; +}) => { + // Run reset cusEnt on ... + let mainCusProduct = await getMainCusProduct({ + db, + internalCustomerId: customer.internal_id, + productGroup, + }); + + let cusEnt = cusProductToCusEnt({ + cusProduct: mainCusProduct!, + featureId, + }); + + await resetCustomerEntitlement({ + db, + cusEnt: cusEnt!, + }); + + mainCusProduct = await getMainCusProduct({ + db, + internalCustomerId: customer.internal_id, + productGroup, + }); + + cusEnt = cusProductToCusEnt({ + cusProduct: mainCusProduct!, + featureId, + }); + + return cusEnt; +}; From ab8ddc30de9b26a6e3f731afcb86b779d5871cbc Mon Sep 17 00:00:00 2001 From: John Yeo Date: Fri, 25 Jul 2025 17:19:51 +0100 Subject: [PATCH 42/45] cleaning rollovers --- .../customer/entitlements/CusEntBalance.tsx | 109 +++ .../entitlements/CustomerEntitlementsList.tsx | 622 +++++++----------- 2 files changed, 350 insertions(+), 381 deletions(-) create mode 100644 vite/src/views/customers/customer/entitlements/CusEntBalance.tsx diff --git a/vite/src/views/customers/customer/entitlements/CusEntBalance.tsx b/vite/src/views/customers/customer/entitlements/CusEntBalance.tsx new file mode 100644 index 000000000..93001873c --- /dev/null +++ b/vite/src/views/customers/customer/entitlements/CusEntBalance.tsx @@ -0,0 +1,109 @@ +import { + AllowanceType, + FeatureType, + FullCusEntWithFullCusProduct, +} from "@autumn/shared"; +import { useCustomerContext } from "../CustomerContext"; + +const BalanceWrapper = ({ children }: { children: React.ReactNode }) => { + return ( +
+ {children} +
+ ); +}; + +export const CusEntBalance = ({ + cusEnt, +}: { + cusEnt: FullCusEntWithFullCusProduct; +}) => { + const { entityId } = useCustomerContext(); + const ent = cusEnt.entitlement; + const feature = ent.feature; + const rollovers = cusEnt.rollovers; + + if (feature.type == FeatureType.Boolean) { + return <>; + } + + if (ent.allowance_type == AllowanceType.Unlimited) { + return Unlimited; + } + + if (entityId && cusEnt.entities?.[entityId]) { + const entityBalance = cusEnt.entities?.[entityId]?.balance; + const rolloverAmount = rollovers + .filter((x) => x.entities && x.entities[entityId]) + .reduce( + (sum, rollover) => sum + (rollover.entities?.[entityId]?.balance || 0), + 0 + ); + + return ( + +

+ {entityBalance} + {rolloverAmount > 0 && ( + + {" + "} + {rolloverAmount} (rolled over) + + )} +

+
+ ); + } + + if (cusEnt.entities) { + const totalBalance = Object.values(cusEnt.entities).reduce( + (sum, entity) => sum + (entity.balance || 0), + 0 + ); + + const rolloverAmount = cusEnt.rollovers.reduce((sum, rollover) => { + // Add global rollover balance + return ( + sum + + Object.values(rollover.entities).reduce( + (entitySum: number, entity: any) => entitySum + (entity.balance || 0), + 0 + ) + ); + }, 0); + + return ( + +

+ {totalBalance} + {rolloverAmount > 0 && ( + + {" + "} + {rolloverAmount} (rolled over) + + )} +

+
+ ); + } + + const rolloverAmount = cusEnt.rollovers.reduce((sum, rollover) => { + return sum + (rollover.balance || 0); + }, 0); + + return ( + +

+ {cusEnt.balance} + {rolloverAmount > 0 && ( + + {rolloverAmount} (rolled over) + )} + {cusEnt.replaceables.length > 0 && ( + + {` (${cusEnt.replaceables.length} free)`} + + )} +

+
+ ); +}; diff --git a/vite/src/views/customers/customer/entitlements/CustomerEntitlementsList.tsx b/vite/src/views/customers/customer/entitlements/CustomerEntitlementsList.tsx index 57131002f..1274d3637 100644 --- a/vite/src/views/customers/customer/entitlements/CustomerEntitlementsList.tsx +++ b/vite/src/views/customers/customer/entitlements/CustomerEntitlementsList.tsx @@ -1,14 +1,14 @@ import { - AllowanceType, - FeatureType, - FullCusEntWithFullCusProduct, - FullCustomerEntitlement, + AllowanceType, + FeatureType, + FullCusEntWithFullCusProduct, + FullCustomerEntitlement, } from "@autumn/shared"; import { useCustomerContext } from "../CustomerContext"; import { - formatUnixToDate, - formatUnixToDateTime, + formatUnixToDate, + formatUnixToDateTime, } from "@/utils/formatUtils/formatDateUtils"; import { useState } from "react"; @@ -21,404 +21,264 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { CusProductEntityItem } from "../components/CusProductEntityItem"; +import { CusEntBalance } from "./CusEntBalance"; export const CustomerEntitlementsList = () => { - const [featureType, setFeatureType] = useState( - FeatureType.Metered - ); - const [showExpired, setShowExpired] = useState(false); + const [featureType, setFeatureType] = useState( + FeatureType.Metered + ); + const [showExpired, setShowExpired] = useState(false); - const { products, customer, entities, entityId, showEntityView } = - useCustomerContext(); + const { products, customer, entities, entityId, showEntityView } = + useCustomerContext(); - const [selectedCusEntitlement, setSelectedCusEntitlement] = - useState(null); + const [selectedCusEntitlement, setSelectedCusEntitlement] = + useState(null); - const cusEnts: FullCusEntWithFullCusProduct[] = - customer.customer_products.flatMap((cp: any) => { - return cp.customer_entitlements.map((e: any) => ({ - ...e, - customer_product: cp, - })); - }); + const cusEnts: FullCusEntWithFullCusProduct[] = + customer.customer_products.flatMap((cp: any) => { + return cp.customer_entitlements.map((e: any) => ({ + ...e, + customer_product: cp, + })); + }); - const filteredEntitlements = cusEnts.filter( - (cusEnt: FullCusEntWithFullCusProduct) => { - const entFeatureType = cusEnt.entitlement.feature.type; - const cusProduct = cusEnt.customer_product; + const filteredEntitlements = cusEnts.filter( + (cusEnt: FullCusEntWithFullCusProduct) => { + const entFeatureType = cusEnt.entitlement.feature.type; + const cusProduct = cusEnt.customer_product; - const isExpired = cusProduct?.status === "expired"; - const isScheduled = cusProduct?.status === "scheduled"; + const isExpired = cusProduct?.status === "expired"; + const isScheduled = cusProduct?.status === "scheduled"; - // Filter by feature type - const featureTypeMatches = - featureType === FeatureType.Boolean - ? entFeatureType === FeatureType.Boolean - : entFeatureType === FeatureType.Metered || - entFeatureType === FeatureType.CreditSystem; + // Filter by feature type + const featureTypeMatches = + featureType === FeatureType.Boolean + ? entFeatureType === FeatureType.Boolean + : entFeatureType === FeatureType.Metered || + entFeatureType === FeatureType.CreditSystem; - // Filter by expired status - const expiredStatusMatches = showExpired ? true : !isExpired; + // Filter by expired status + const expiredStatusMatches = showExpired ? true : !isExpired; - // Filter by entity - const entity = entities.find((e: any) => e.id === entityId); - let entityMatch = true; - if (entityId) { - entityMatch = false; + // Filter by entity + const entity = entities.find((e: any) => e.id === entityId); + let entityMatch = true; + if (entityId) { + entityMatch = false; - const cusProduct = customer.products.find( - (p: any) => p.id === cusEnt.customer_product_id - ); + const cusProduct = customer.products.find( + (p: any) => p.id === cusEnt.customer_product_id + ); - // 1. Product match - const productAttachedToEntity = - cusProduct?.internal_entity_id === entity?.internal_id; + // 1. Product match + const productAttachedToEntity = + cusProduct?.internal_entity_id === entity?.internal_id; - const cusEntContainsEntity = - Object.keys(cusEnt.entities || {}).includes(entity?.id) || - cusEnt.entitlement.entity_feature_id === entity?.feature_id; + const cusEntContainsEntity = + Object.keys(cusEnt.entities || {}).includes(entity?.id) || + cusEnt.entitlement.entity_feature_id === entity?.feature_id; - entityMatch = productAttachedToEntity || cusEntContainsEntity; - } + entityMatch = productAttachedToEntity || cusEntContainsEntity; + } - return ( - featureTypeMatches && - expiredStatusMatches && - !isScheduled && - entityMatch - ); - } - ); + return ( + featureTypeMatches && + expiredStatusMatches && + !isScheduled && + entityMatch + ); + } + ); - const handleSelectCusEntitlement = (cusEnt: FullCustomerEntitlement) => { - setSelectedCusEntitlement(cusEnt); - }; + const handleSelectCusEntitlement = (cusEnt: FullCustomerEntitlement) => { + setSelectedCusEntitlement(cusEnt); + }; - const getAdminHoverTexts = (cusEnt: FullCustomerEntitlement) => { - const entitlement = cusEnt.entitlement; - const featureEntities = entities.filter( - (e: any) => e.feature_id === entitlement.feature.id - ); + const getAdminHoverTexts = (cusEnt: FullCustomerEntitlement) => { + const entitlement = cusEnt.entitlement; + const featureEntities = entities.filter( + (e: any) => e.feature_id === entitlement.feature.id + ); - const hoverTexts = [ - { - key: "Cus Ent ID", - value: cusEnt.id, - }, - ]; + const hoverTexts = [ + { + key: "Cus Ent ID", + value: cusEnt.id, + }, + ]; - if (featureEntities.length > 0) { - hoverTexts.push({ - key: "Entities", - value: featureEntities - .map( - (e: any) => - `${e.id} (${e.name})${e.deleted ? " Deleted" : ""}` - ) - .join("\n"), - }); - } else if (cusEnt.entities && Object.keys(cusEnt.entities).length > 0) { - const mappedEntities = Object.keys(cusEnt.entities) - .map((e: any) => { - const entity = entities.find((ee: any) => ee.id === e); - const balance = cusEnt.entities![e].balance; - return `${entity?.id} (${entity?.name}): ${balance}`; - }) - .join("\n"); - hoverTexts.push({ - key: "Entities", - value: mappedEntities, - }); - } + if (featureEntities.length > 0) { + hoverTexts.push({ + key: "Entities", + value: featureEntities + .map((e: any) => `${e.id} (${e.name})${e.deleted ? " Deleted" : ""}`) + .join("\n"), + }); + } else if (cusEnt.entities && Object.keys(cusEnt.entities).length > 0) { + const mappedEntities = Object.keys(cusEnt.entities) + .map((e: any) => { + const entity = entities.find((ee: any) => ee.id === e); + const balance = cusEnt.entities![e].balance; + return `${entity?.id} (${entity?.name}): ${balance}`; + }) + .join("\n"); + hoverTexts.push({ + key: "Entities", + value: mappedEntities, + }); + } - if (cusEnt.rollovers.length > 0) { - hoverTexts.push({ - key: "Rollovers", - value: cusEnt.rollovers - .map((r: any) => { - if (Object.values(r.entities).length > 0) { - return Object.values(r.entities) - .map((e: any) => `${e.balance} (${e.id})`) - .join(", "); - } else { - return `${r.balance} (ex: ${r.expires_at ? formatUnixToDate(r.expires_at) : "N/A"})`; - } - }) - .join("\n"), - }); - } + if (cusEnt.rollovers.length > 0) { + hoverTexts.push({ + key: "Rollovers", + value: cusEnt.rollovers + .map((r: any) => { + if (Object.values(r.entities).length > 0) { + return Object.values(r.entities) + .map((e: any) => `${e.balance} (${e.id})`) + .join(", "); + } else { + return `${r.balance} (ex: ${r.expires_at ? formatUnixToDate(r.expires_at) : "N/A"})`; + } + }) + .join("\n"), + }); + } - return hoverTexts; - }; + return hoverTexts; + }; - return ( -
-
-

- Available Features -

-
-
-
- - - -
- - setFeatureType( - FeatureType.Metered - ) - } - > - Metered - - - setFeatureType( - FeatureType.Boolean - ) - } - > - Boolean - -
-
-
-
-
-
-
- - {filteredEntitlements.length === 0 ? ( -
-

- Attach a product to grant access to features -

-
- ) : ( - <> - - Feature - {showEntityView && ( - Entity - )} - - {featureType === FeatureType.Metered && "Balance"} - - Product - - {featureType === FeatureType.Metered && - "Next Reset"} - - - - - )} + return ( +
+
+

+ Available Features +

+
+
+
+ + + +
+ setFeatureType(FeatureType.Metered)} + > + Metered + + setFeatureType(FeatureType.Boolean)} + > + Boolean + +
+
+
+
+
+
+
+ + {filteredEntitlements.length === 0 ? ( +
+

+ Attach a product to grant access to features +

+
+ ) : ( + <> + + Feature + {showEntityView && Entity} + + {featureType === FeatureType.Metered && "Balance"} + + Product + + {featureType === FeatureType.Metered && "Next Reset"} + + + + + )} - {filteredEntitlements.map( - (cusEnt: FullCusEntWithFullCusProduct) => { - const entitlement = cusEnt.entitlement; - const allowanceType = entitlement.allowance_type; + {filteredEntitlements.map((cusEnt: FullCusEntWithFullCusProduct) => { + const entitlement = cusEnt.entitlement; + const allowanceType = entitlement.allowance_type; - return ( - - featureType === FeatureType.Metered && - handleSelectCusEntitlement(cusEnt) - } - > - - - {entitlement.feature.name} - - - {showEntityView && ( - - - - )} - -
- {entitlement.feature.type == - FeatureType.Boolean ? ( - <> - ) : allowanceType == - AllowanceType.Unlimited ? ( - "Unlimited" - ) : entityId && - cusEnt.entities?.[entityId] ? ( -
- { - cusEnt.entities?.[entityId] - ?.balance - } - {(() => { - const rolloverAmount = - cusEnt.rollovers - .filter( - (x) => - x.entities && - x.entities[ - entityId - ] - ) - .reduce( - (sum, rollover) => - sum + - (rollover - .entities?.[ - entityId - ]?.balance || - 0), - 0 - ); - return rolloverAmount > 0 ? ( - <> - - {" + "} - { - rolloverAmount - }{" "} - (rolled over) - - - ) : null; - })()} -
- ) : cusEnt.entities ? ( -
- {Object.values( - cusEnt.entities - ).reduce( - (sum, entity) => - sum + (entity.balance || 0), - 0 - )} - {(() => { - const rolloverAmount = - cusEnt.rollovers.reduce( - (sum, rollover) => { - // Add global rollover balance - let total = sum + (rollover.balance || 0); - - // Add entity-specific rollover balances - if (rollover.entities) { - total += Object.values(rollover.entities).reduce( - (entitySum: number, entity: any) => - entitySum + (entity.balance || 0), - 0 - ); - } - - return total; - }, - 0 - ); - return rolloverAmount > 0 ? ( - - {" + "} - {rolloverAmount} (rolled - over) - - ) : null; - })()} -
- ) : ( -
- {cusEnt.balance} - {(() => { - const rolloverAmount = - cusEnt.rollovers.reduce( - (sum, rollover) => - sum + rollover.balance, - 0 - ); - return rolloverAmount > 0 ? ( - - {" + "} - {rolloverAmount} (rolled over) - - ) : null; - })()} - {cusEnt.replaceables.length > 0 && ( - - {` (${cusEnt.replaceables.length} free)`} - - )} -
- )} -
-
+ return ( + + featureType === FeatureType.Metered && + handleSelectCusEntitlement(cusEnt) + } + > + + + {entitlement.feature.name} + + + {showEntityView && ( + + + + )} + + + - -
- {/* {getProductName(cusEnt)} */} - {cusEnt.customer_product.product.name} - {customer.products.find( - (p: any) => - p.id === cusEnt.customer_product_id - )?.status === "expired" && ( - - expired - - )} -
-
- - { - formatUnixToDateTime(cusEnt.next_reset_at) - .date - }{" "} - { - formatUnixToDateTime(cusEnt.next_reset_at) - .time - } - - -
- ); - } - )} -
- ); + +
+ {/* {getProductName(cusEnt)} */} + {cusEnt.customer_product.product.name} + {customer.products.find( + (p: any) => p.id === cusEnt.customer_product_id + )?.status === "expired" && ( + + expired + + )} +
+
+ + {formatUnixToDateTime(cusEnt.next_reset_at).date}{" "} + {formatUnixToDateTime(cusEnt.next_reset_at).time} + + + + ); + })} +
+ ); }; From f6929e2ccafeec6618c97419c4495b4ad5dff5e6 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Fri, 25 Jul 2025 20:01:07 +0100 Subject: [PATCH 43/45] fix: reducing queries for rollover insert --- server/src/cron/cronUtils.ts | 7 +- .../handlePrepaidPrices.ts | 10 +- .../handleInvoiceCreated/handleUsagePrices.ts | 7 +- .../add-product/createFullCusProduct.ts | 37 +- .../cusEnts/cusRollovers/RolloverService.ts | 118 +- .../cusRollovers/getNewProductRollovers.ts | 253 ++-- .../cusRollovers/rolloverDeductionUtils.ts | 293 ++-- .../cusEnts/cusRollovers/rolloverUtils.ts | 32 +- .../src/internal/customers/getFullCusQuery.ts | 2 +- server/src/trigger/updateBalanceTask.ts | 1178 ++++++++--------- server/src/trigger/updateUsageTask.ts | 569 ++++---- server/src/utils/scriptUtils/constructItem.ts | 14 +- server/tests/advanced/rollovers/rollover1.ts | 86 +- server/tests/advanced/rollovers/rollover2.ts | 206 ++- server/tests/advanced/rollovers/rollover3.ts | 126 ++ server/tests/advanced/rollovers/rollover4.ts | 157 +++ server/tests/advanced/rollovers/rollover5.ts | 140 ++ server/tests/advanced/rollovers/rollover6.ts | 140 ++ .../entitlements/CustomerEntitlementsList.tsx | 9 +- .../entitlements/UpdateCusEntitlement.tsx | 2 +- .../product-item/ProductItemConfig.tsx | 2 +- .../product-item/UpdateProductItem.tsx | 4 +- .../advanced-config/AdvancedItemConfig.tsx | 19 +- 23 files changed, 1971 insertions(+), 1440 deletions(-) create mode 100644 server/tests/advanced/rollovers/rollover3.ts create mode 100644 server/tests/advanced/rollovers/rollover4.ts create mode 100644 server/tests/advanced/rollovers/rollover5.ts create mode 100644 server/tests/advanced/rollovers/rollover6.ts diff --git a/server/src/cron/cronUtils.ts b/server/src/cron/cronUtils.ts index dc3a33185..b8a6a2962 100644 --- a/server/src/cron/cronUtils.ts +++ b/server/src/cron/cronUtils.ts @@ -218,9 +218,10 @@ export const resetCustomerEntitlement = async ({ await RolloverService.insert({ db, rows: rolloverUpdate.toInsert, - rolloverConfig: cusEnt.entitlement.rollover as RolloverConfig, - cusEntID: cusEnt.id, - entityMode: notNullish(cusEnt.entitlement.entity_feature_id), + fullCusEnt: cusEnt, + // 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 6f2c3d6bd..10c71deca 100644 --- a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handlePrepaidPrices.ts +++ b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handlePrepaidPrices.ts @@ -70,7 +70,8 @@ export const handlePrepaidPrices = async ({ let resetQuantity = options?.upcoming_quantity || options?.quantity!; let config = cusPrice.price.config as UsagePriceConfig; let billingUnits = config.billing_units || 1; - let newAllowance = resetQuantity * billingUnits; + let newAllowance = + resetQuantity * billingUnits + (cusEnt.entitlement.allowance || 0); const resetUpdate = getResetBalancesUpdate({ cusEnt, @@ -138,9 +139,10 @@ export const handlePrepaidPrices = async ({ await RolloverService.insert({ db, rows: rolloverUpdate.toInsert, - rolloverConfig: ent.rollover as RolloverConfig, - cusEntID: cusEnt.id, - entityMode: notNullish(ent.entity_feature_id), + fullCusEnt: cusEnt, + // 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 2bbc8cfaf..40701d969 100644 --- a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleUsagePrices.ts +++ b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleUsagePrices.ts @@ -155,9 +155,10 @@ export const handleUsagePrices = async ({ await RolloverService.insert({ db, rows: rolloverUpdate.toInsert, - rolloverConfig: ent.rollover as RolloverConfig, - cusEntID: ent.id, - entityMode: notNullish(ent.entity_feature_id), + fullCusEnt: relatedCusEnt, + // rolloverConfig: ent.rollover as RolloverConfig, + // cusEntID: ent.id, + // entityMode: notNullish(ent.entity_feature_id), }); } diff --git a/server/src/internal/customers/add-product/createFullCusProduct.ts b/server/src/internal/customers/add-product/createFullCusProduct.ts index cf41038ed..40f2cd434 100644 --- a/server/src/internal/customers/add-product/createFullCusProduct.ts +++ b/server/src/internal/customers/add-product/createFullCusProduct.ts @@ -406,7 +406,7 @@ export const createFullCusProduct = async ({ }); // 4. Get new rollovers - let rolloverOperations = await getNewProductRollovers({ + let rolloverOps = await getNewProductRollovers({ db, curCusProduct: curCusProduct as FullCusProduct, cusEnts, @@ -469,24 +469,20 @@ export const createFullCusProduct = async ({ replaceables: newReplaceables, }); - // Insert rollovers for each entitlement - console.log('Inserting rollovers for', rolloverOperations.length, 'entitlements'); - for (const operation of rolloverOperations) { - // Update before insert to ensure performMaximumClearing is called in the right order - if(operation.toUpdate.length > 0) await RolloverService.bulkUpdate({ - db, - rows: operation.toUpdate, - }) + let rolloverInserts: any = []; - if(operation.toInsert.length > 0) await RolloverService.insert({ - db, - rows: operation.toInsert, - rolloverConfig: operation.rolloverConfig, - cusEntID: operation.cusEntId, - entityMode: operation.entityMode, - }); + for (const operation of rolloverOps) { + rolloverInserts.push( + RolloverService.insert({ + db, + rows: operation.toInsert, + fullCusEnt: operation.cusEnt, + }) + ); } + let finalRollovers = (await Promise.all(rolloverInserts)).flatMap((r) => r); + // Get rollovers for each entitlement const cusEntsWithRollovers = await Promise.all( cusEnts.map(async (ce) => ({ @@ -498,10 +494,11 @@ export const createFullCusProduct = async ({ ...r, delete_next_cycle: r.delete_next_cycle || false, })), - rollovers: await RolloverService.getCurrentRollovers({ - db, - cusEntID: ce.id, - }), + rollovers: finalRollovers.filter((r) => r.cus_ent_id === ce.id), + // await RolloverService.getCurrentRollovers({ + // db, + // cusEntID: ce.id, + // }), })) ); diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts index 4c559a693..704b4d15a 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts @@ -1,7 +1,13 @@ import { DrizzleCli } from "@/db/initDrizzle.js"; -import { Rollover, RolloverConfig, rollovers } from "@autumn/shared"; +import { + FullCustomerEntitlement, + Rollover, + RolloverConfig, + rollovers, +} from "@autumn/shared"; import { and, eq, gte, inArray } from "drizzle-orm"; import { performMaximumClearing } from "./rolloverUtils.js"; +import { buildConflictUpdateColumns } from "@/db/dbUtils.js"; export class RolloverService { static async update({ @@ -24,21 +30,34 @@ export class RolloverService { return data; } - static async bulkUpdate({ db, rows }: { db: DrizzleCli; rows: Rollover[] }) { - if (rows.length === 0) return []; + static async upsert({ db, rows }: { db: DrizzleCli; rows: Rollover[] }) { + if (Array.isArray(rows) && rows.length == 0) return; - const results = []; - for (const row of rows) { - const result = await this.update({ - db, - id: row.id, - updates: row, + const updateColumns = buildConflictUpdateColumns(rollovers, ["id"]); + await db + .insert(rollovers) + .values(rows as any) + .onConflictDoUpdate({ + target: rollovers.id, + set: updateColumns, }); - results.push(...result); - } - return results; } + // static async bulkUpdate({ db, rows }: { db: DrizzleCli; rows: Rollover[] }) { + // if (rows.length === 0) return []; + + // const results = []; + // for (const row of rows) { + // const result = await this.update({ + // db, + // id: row.id, + // updates: row, + // }); + // results.push(...result); + // } + // return results; + // } + static async getCurrentRollovers({ db, cusEntID, @@ -60,49 +79,68 @@ export class RolloverService { static async insert({ db, rows, - rolloverConfig, - cusEntID, - entityMode, + // rolloverConfig, + fullCusEnt, + // cusEntID, + // entityMode, }: { db: DrizzleCli; rows: Rollover[]; - rolloverConfig: RolloverConfig; - cusEntID: string; - entityMode: boolean; + // rolloverConfig: RolloverConfig; + fullCusEnt: FullCustomerEntitlement; + // cusEntID: string; + // entityMode: boolean; }) { if (rows.length === 0) return {}; - console.log("inserting rollovers", rows); + // console.log("inserting rollovers", rows); await db .insert(rollovers) .values(rows as any) .returning(); - const currentRolloverRows = await db - .select() - .from(rollovers) - .where( - and( - eq(rollovers.cus_ent_id, cusEntID), - gte(rollovers.expires_at, new Date().getTime()) - ) - ); + // const currentRolloverRows = await db + // .select() + // .from(rollovers) + // .where( + // and( + // eq(rollovers.cus_ent_id, cusEntID), + // gte(rollovers.expires_at, new Date().getTime()) + // ) + // ); + let curRollovers = [...fullCusEnt.rollovers, ...rows]; + console.log(`Cur rollovers:`, curRollovers); - // let { toDelete, toUpdate } = await performMaximumClearing({ - // rows: currentRolloverRows as Rollover[], - // rolloverConfig, - // cusEntID, - // entityMode, - // }); + let { toDelete, toUpdate } = performMaximumClearing({ + rows: curRollovers as Rollover[], + // rolloverConfig, + cusEnt: fullCusEnt, + // cusEntID, + // entityMode, + }); + // console.log(`To update:`, toUpdate); + // console.log(`To delete:`, toDelete); - // if (toDelete.length > 0) { - // await RolloverService.delete({ db, ids: toDelete }); - // } + if (toDelete.length > 0) { + await RolloverService.delete({ db, ids: toDelete }); + } - // if (toUpdate.length > 0) { - // await RolloverService.bulkUpdate({ db, rows: toUpdate }); - // } + if (toUpdate.length > 0) { + await RolloverService.upsert({ db, rows: toUpdate }); + } + + // Return latest rollovers...? + curRollovers = curRollovers.filter((r) => toDelete.includes(r.id)); + curRollovers = curRollovers.map((r) => { + let updatedRow = toUpdate.find((u) => u.id === r.id); + if (updatedRow) { + return updatedRow; + } + return r; + }); + + return curRollovers; } static async delete({ db, ids }: { db: DrizzleCli; ids: string[] }) { diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/getNewProductRollovers.ts b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/getNewProductRollovers.ts index 5a485a087..92fce5265 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/getNewProductRollovers.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/getNewProductRollovers.ts @@ -1,157 +1,138 @@ -import { generateId } from "@/utils/genUtils.js"; +import { generateId, nullish } from "@/utils/genUtils.js"; import { - CustomerEntitlement, - EntitlementWithFeature, - FullCusProduct, - Rollover, - RolloverConfig, + CustomerEntitlement, + EntitlementWithFeature, + FullCusProduct, + FullCustomerEntitlement, + Rollover, + RolloverConfig, } from "@autumn/shared"; import { RolloverService } from "./RolloverService.js"; import { DrizzleCli } from "@/db/initDrizzle.js"; import { calculateNextExpiry } from "./rolloverUtils.js"; export const getNewProductRollovers = async ({ - curCusProduct, - cusEnts: newCusEnts, - entitlements, - db, - logger, + curCusProduct, + cusEnts: newCusEnts, + entitlements, + db, + logger, }: { - curCusProduct: FullCusProduct; - cusEnts: CustomerEntitlement[]; - entitlements: EntitlementWithFeature[]; - db: DrizzleCli; - logger: any; + curCusProduct: FullCusProduct; + cusEnts: CustomerEntitlement[]; + entitlements: EntitlementWithFeature[]; + db: DrizzleCli; + logger: any; }) => { - if (!curCusProduct) return []; - if (!curCusProduct.id) return []; - try { - let rolloverOperations: { - cusEntId: string; - rolloverConfig: RolloverConfig; - toInsert: Rollover[]; - toUpdate: Rollover[]; - entityMode: boolean; - }[] = []; + if (!curCusProduct) return []; + if (!curCusProduct.id) return []; + try { + let rolloverOperations: { + // rolloverConfig: RolloverConfig; + toInsert: Rollover[]; + cusEnt: FullCustomerEntitlement; + // cusEntId: string; + // toUpdate: Rollover[]; + // entityMode: boolean; + }[] = []; - let oldEnts = curCusProduct.customer_entitlements; + // let newRollovers: Rollover[] = []; - for (const newCusEnt of newCusEnts) { - let newEnt = entitlements.find( - (e) => e.id === newCusEnt.entitlement_id - ); - let oldCusEnt = oldEnts.find( - (e) => e.internal_feature_id === newEnt?.internal_feature_id - ); + let oldCusEnts = curCusProduct.customer_entitlements; - if (!oldCusEnt) { - continue; - } - if (!newEnt?.rollover) { - continue; - } + for (const newCusEnt of newCusEnts) { + let newRollovers: Rollover[] = []; + let newEnt = entitlements.find((e) => e.id === newCusEnt.entitlement_id); + let oldCusEnt = oldCusEnts.find( + (e) => e.entitlement.internal_feature_id === newEnt?.internal_feature_id + ); + let oldEnt = oldCusEnt?.entitlement; - // Do not handle case where user is upgrading from non-entity to entity or vice versa - if ( - newEnt?.entity_feature_id && - !oldCusEnt.entitlement.entity_feature_id - ) { - continue; - } - if ( - !newEnt?.entity_feature_id && - oldCusEnt.entitlement.entity_feature_id - ) { - continue; - } + if (!oldCusEnt || !newEnt?.rollover) continue; - let rollover = newEnt.rollover; - let entityMode = !!newEnt.entity_feature_id; - let toInsert: Rollover[] = []; - let toUpdate: Rollover[] = []; + // Do not handle case where user is upgrading from non-entity to entity or vice versa + if (newEnt?.entity_feature_id && !oldEnt?.entity_feature_id) { + continue; + } + if (!newEnt?.entity_feature_id && oldEnt?.entity_feature_id) { + continue; + } - // Bring over current balance (if greater > 0), and any existing rollover - if ( - oldCusEnt.balance && - oldCusEnt.balance > 0 && - !oldCusEnt.entitlement.entity_feature_id && - rollover - ) { - toInsert.push({ - id: generateId("roll"), - cus_ent_id: newCusEnt.id, - balance: oldCusEnt.balance, - entities: {}, - usage: 0, - expires_at: calculateNextExpiry(Date.now(), rollover), - }); - } else if (oldCusEnt.entitlement.entity_feature_id) { - if (oldCusEnt.entities) { - const entityRollovers = Object.keys( - oldCusEnt.entities || {} - ).reduce( - (acc, entityId) => { - const entityBalance = - oldCusEnt.entities?.[entityId]; - if (entityBalance && entityBalance.balance > 0) { - acc[entityId] = { - id: entityId, - balance: entityBalance.balance || 0, - usage: 0, - }; - } - return acc; - }, - {} as Record< - string, - { id: string; balance: number; usage: number } - > - ); + // Bring over current balance (if greater > 0), and any existing rollover + // if ( + // oldCusEnt.balance && + // oldCusEnt.balance > 0 && + // !oldCusEnt.entitlement.entity_feature_id && + // rollover + // ) { + // newRollovers.push({ + // id: generateId("roll"), + // cus_ent_id: newCusEnt.id, + // balance: oldCusEnt.balance, + // entities: {}, + // usage: 0, + // expires_at: calculateNextExpiry(Date.now(), rollover), + // }); + // } else if ( + // oldCusEnt.entitlement.entity_feature_id && + // oldCusEnt.entities + // ) { + // const entityRollovers = Object.keys(oldCusEnt.entities || {}).reduce( + // (acc, entityId) => { + // const entityBalance = oldCusEnt.entities?.[entityId]; + // if (entityBalance && entityBalance.balance > 0) { + // acc[entityId] = { + // id: entityId, + // balance: entityBalance.balance || 0, + // usage: 0, + // }; + // } + // return acc; + // }, + // {} as Record + // ); - toInsert.push({ - id: generateId("roll"), - cus_ent_id: newCusEnt.id, - balance: 0, - entities: entityRollovers, - usage: 0, - expires_at: calculateNextExpiry(Date.now(), rollover), - }); - } - } + // if (Object.keys(entityRollovers).length > 0) { + // newRollovers.push({ + // id: generateId("roll"), + // cus_ent_id: newCusEnt.id, + // balance: 0, + // entities: entityRollovers, + // usage: 0, + // expires_at: calculateNextExpiry(Date.now(), rollover), + // }); + // } + // } - // Get rollovers from the OLD customer entitlement, not the new one - let curRollovers = await RolloverService.getCurrentRollovers({ - db, - cusEntID: oldCusEnt.id, - }); + let curRollovers = oldCusEnt.rollovers; - for (const curRollover of curRollovers) { - if ( - curRollover.expires_at && - curRollover.expires_at > Date.now() - ) { - toUpdate.push({ - ...curRollover, - cus_ent_id: newCusEnt.id, // Reassign to the new customer entitlement - }); - } - } + for (const curRollover of curRollovers) { + newRollovers.push({ + ...curRollover, + id: generateId("roll"), + cus_ent_id: newCusEnt.id, + }); + } - // Add this entitlement's rollover operations - rolloverOperations.push({ - cusEntId: newCusEnt.id, - rolloverConfig: rollover, - toInsert, - toUpdate, - entityMode, - }); - } + console.log(`Feature ${newEnt?.feature_id} rollovers:`, newRollovers); - return rolloverOperations; - } catch (error) { - logger.error(`Failed to handle new product rollovers:`, { - error, - }); - return []; - } + // // Add this entitlement's rollover operations + rolloverOperations.push({ + toInsert: newRollovers, + cusEnt: { + ...newCusEnt, + entitlement: newEnt, + rollovers: [], + replaceables: [], + }, + }); + } + + return rolloverOperations; + } catch (error) { + logger.error(`Failed to handle new product rollovers:`, { + error, + }); + return []; + } }; diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverDeductionUtils.ts b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverDeductionUtils.ts index a7649ca12..a7c923b8f 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverDeductionUtils.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverDeductionUtils.ts @@ -1,197 +1,130 @@ -import { logger } from "@/external/logtail/logtailUtils.js"; -import { - DeductParams, - RolloverDeductParams, -} from "@/trigger/updateBalanceTask.js"; +import { RolloverDeductParams } from "@/trigger/updateBalanceTask.js"; import { FullCusEntWithFullCusProduct, Rollover } from "@autumn/shared"; import { RolloverService } from "./RolloverService.js"; export const deductFromCusRollovers = async ({ - toDeduct, - deductParams, - cusEnt, + toDeduct, + deductParams, + cusEnt, }: { - toDeduct: number; - deductParams: RolloverDeductParams; - cusEnt: FullCusEntWithFullCusProduct; + toDeduct: number; + deductParams: RolloverDeductParams; + cusEnt: FullCusEntWithFullCusProduct; }) => { - if (toDeduct == 0) { - return toDeduct; - } - let start = performance.now(); - let updates = { - toInsert: [] as Rollover[], - toUpdate: [] as Rollover[], - }; - let rollovers = getSortedRollovers({ - cusEnts: [cusEnt], - featureId: deductParams.feature.id, - entityId: deductParams.entity?.id, - }); + if (toDeduct == 0) { + return toDeduct; + } - console.log(`rollovers: ${JSON.stringify(rollovers)}`); + let updates = { + toInsert: [] as Rollover[], + toUpdate: [] as Rollover[], + }; + let rollovers = getSortedRollovers({ + cusEnts: [cusEnt], + featureId: deductParams.feature.id, + entityId: deductParams.entity?.id, + }); - if (deductParams.entity) { - console.log( - `Processing entity-specific rollover deduction for entity ${deductParams.entity.id}, toDeduct: ${toDeduct}` - ); - for(let rollover of rollovers) { - console.log( - `Processing rollover ${rollover.id} for entity ${deductParams.entity.id} with balance ${rollover.balance}, usage ${rollover.usage}, expires_at ${rollover.expires_at}, toDeduct remaining: ${toDeduct}` - ); - let entityRollover = rollover.entities[deductParams.entity.id]; - if(entityRollover) { - console.log( - `Found entity rollover for entity ${deductParams.entity.id}: balance ${entityRollover.balance}, usage ${entityRollover.usage}` - ); - if(entityRollover.balance >= toDeduct) { - console.log( - `Entity rollover has sufficient balance (${entityRollover.balance}) to cover remaining deduction (${toDeduct})` - ); - entityRollover.balance -= toDeduct; - entityRollover.usage += toDeduct; - console.log( - `Updated entity rollover: new balance ${entityRollover.balance}, new usage ${entityRollover.usage}` - ); - console.log( - `Updated rollover ${rollover.id}: new balance ${rollover.balance}, new usage ${rollover.usage}` - ); - updates.toUpdate.push(rollover); - toDeduct = 0; - console.log( - `Entity deduction complete. Remaining toDeduct: ${toDeduct}` - ); - break; - } else { - if(entityRollover.balance > 0) { - console.log( - `Entity rollover has insufficient balance (${entityRollover.balance}) for full deduction (${toDeduct}). Using all available balance.` - ); - let deductedAmount = entityRollover.balance; - toDeduct -= entityRollover.balance; - entityRollover.balance = 0; - entityRollover.usage += deductedAmount; - console.log( - `Updated entity rollover: new balance ${entityRollover.balance}, new usage ${entityRollover.usage}` - ); - console.log( - `Updated rollover ${rollover.id}: new balance ${rollover.balance}, new usage ${rollover.usage}. Remaining toDeduct: ${toDeduct}` - ); - updates.toUpdate.push(rollover); - } else { - console.log( - `Entity rollover has zero balance, skipping` - ); - } - } - } else { - console.log( - `No entity rollover found for entity ${deductParams.entity.id} in rollover ${rollover.id}` - ); - } - } - } else { - for (let rollover of rollovers) { - console.log( - `Processing rollover ${rollover.id} with balance ${rollover.balance}, usage ${rollover.usage}, expires_at ${rollover.expires_at}, toDeduct remaining: ${toDeduct}` - ); + if (deductParams.entity) { + for (let rollover of rollovers) { + let entityRollover = rollover.entities[deductParams.entity.id]; + if (entityRollover) { + if (entityRollover.balance >= toDeduct) { + entityRollover.balance -= toDeduct; + entityRollover.usage += toDeduct; - if (rollover.balance >= toDeduct) { - console.log( - `Rollover ${rollover.id} has sufficient balance (${rollover.balance}) to cover remaining deduction (${toDeduct})` - ); - rollover = { - ...rollover, - balance: rollover.balance - toDeduct, - usage: rollover.usage + toDeduct, - }; - console.log( - `Updated rollover ${rollover.id}: new balance ${rollover.balance}, new usage ${rollover.usage}` - ); - updates.toUpdate.push(rollover); - toDeduct = 0; - console.log( - `Deduction complete. Remaining toDeduct: ${toDeduct}` - ); - break; - } else { - if (rollover.balance > 0) { - console.log( - `Rollover ${rollover.id} has insufficient balance (${rollover.balance}) for full deduction (${toDeduct}). Using all available balance.` - ); - toDeduct -= rollover.balance; - rollover = { - ...rollover, - usage: rollover.usage + rollover.balance, - balance: 0, - }; - console.log( - `Updated rollover ${rollover.id}: new balance ${rollover.balance}, new usage ${rollover.usage}. Remaining toDeduct: ${toDeduct}` - ); - updates.toUpdate.push(rollover); - } else { - console.log( - `Rollover ${rollover.id} has zero balance, skipping` - ); - } - } - } - } + updates.toUpdate.push(rollover); + toDeduct = 0; + break; + } else { + if (entityRollover.balance > 0) { + let deductedAmount = entityRollover.balance; + toDeduct -= entityRollover.balance; + entityRollover.balance = 0; + entityRollover.usage += deductedAmount; + updates.toUpdate.push(rollover); + } + } + } + } + } else { + for (let rollover of rollovers) { + if (rollover.balance >= toDeduct) { + rollover = { + ...rollover, + balance: rollover.balance - toDeduct, + usage: rollover.usage + toDeduct, + }; - let dbResp = await RolloverService.bulkUpdate({ - db: deductParams.db, - rows: updates.toUpdate, - }); - console.log(`dbResp: ${JSON.stringify(dbResp)}`); + updates.toUpdate.push(rollover); + toDeduct = 0; - let end = performance.now(); - console.log( - `deductFromCusRollovers took ${end - start}ms for ${toDeduct} toDeduct out of ${rollovers.length} rollovers` - ); + break; + } else { + if (rollover.balance > 0) { + toDeduct -= rollover.balance; + rollover = { + ...rollover, + usage: rollover.usage + rollover.balance, + balance: 0, + }; - return toDeduct; + updates.toUpdate.push(rollover); + } + } + } + } + + await RolloverService.upsert({ + db: deductParams.db, + rows: updates.toUpdate, + }); + + return toDeduct; }; export const getSortedRollovers = ({ - cusEnts, - featureId, - entityId, + cusEnts, + featureId, + entityId, }: { - cusEnts: FullCusEntWithFullCusProduct[]; - featureId: string; - entityId?: string; + cusEnts: FullCusEntWithFullCusProduct[]; + featureId: string; + entityId?: string; }) => { - if (!entityId) - return cusEnts - .filter((cusEnt) => { - return cusEnt.feature_id === featureId; - }) - .flatMap((cusEnt) => { - return cusEnt.rollovers; - }) - .sort((a, b) => { - if (a.expires_at && b.expires_at) - return a.expires_at - b.expires_at; - if (a.expires_at && !b.expires_at) return -1; - if (!a.expires_at && b.expires_at) return 1; - return 0; - }); - else { - return cusEnts - .filter((cusEnt) => { - return cusEnt.feature_id === featureId && cusEnt.entities && cusEnt.entities[entityId]; - }) - .flatMap((cusEnt) => { - return cusEnt.rollovers.filter(x => { - return x.entities[entityId] - }); - }) - .sort((a, b) => { - if (a.expires_at && b.expires_at) - return a.expires_at - b.expires_at; - if (a.expires_at && !b.expires_at) return -1; - if (!a.expires_at && b.expires_at) return 1; - return 0; - }); - } -}; \ No newline at end of file + if (!entityId) + return cusEnts + .filter((cusEnt) => { + return cusEnt.feature_id === featureId; + }) + .flatMap((cusEnt) => { + return cusEnt.rollovers; + }) + .sort((a, b) => { + if (a.expires_at && b.expires_at) return a.expires_at - b.expires_at; + if (a.expires_at && !b.expires_at) return -1; + if (!a.expires_at && b.expires_at) return 1; + return 0; + }); + else { + return cusEnts + .filter((cusEnt) => { + return ( + cusEnt.feature_id === featureId && + cusEnt.entities && + cusEnt.entities[entityId] + ); + }) + .flatMap((cusEnt) => { + return cusEnt.rollovers.filter((x) => { + return x.entities[entityId]; + }); + }) + .sort((a, b) => { + if (a.expires_at && b.expires_at) return a.expires_at - b.expires_at; + if (a.expires_at && !b.expires_at) return -1; + if (!a.expires_at && b.expires_at) return 1; + return 0; + }); + } +}; diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.ts b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.ts index dd8f97566..01b326b62 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.ts @@ -80,20 +80,22 @@ export const calculateNextExpiry = ( return addMonths(nextResetAt, config.length).getTime(); }; -export async function performMaximumClearing({ +export function performMaximumClearing({ rows, - rolloverConfig, - cusEntID, - entityMode, + // rolloverConfig, + cusEnt, + // cusEntID, + // entityMode, }: { rows: Rollover[]; - rolloverConfig: RolloverConfig; - cusEntID: string; - entityMode: boolean; + // rolloverConfig: RolloverConfig; + cusEnt: FullCustomerEntitlement; + // cusEntID: string; + // entityMode: boolean; }) { + let rolloverConfig = cusEnt.entitlement.rollover; + if (!rolloverConfig) { - // throw new Error("Rollover config is required"); - // Throw warning return { toDelete: [], toUpdate: [] }; } @@ -125,6 +127,9 @@ export async function performMaximumClearing({ return 0; }); + let ent = cusEnt.entitlement; + let entityMode = !!ent.entity_feature_id; + if (!entityMode) { let totalRolloverBalance = rows.reduce((acc, row) => acc + row.balance, 0); let toDeduct = new Decimal(totalRolloverBalance).sub(rolloverConfig.max); @@ -175,6 +180,8 @@ export async function performMaximumClearing({ } }); + // console.log(`id to total:`, entityIdToTotal); + let toUpdate: Rollover[] = []; let toDelete: string[] = []; @@ -188,13 +195,14 @@ export async function performMaximumClearing({ let toDeduct = new Decimal(entityTotal).sub(rolloverConfig.max); if (toDeduct.lte(0) || !row.entities[entityId]) continue; + // console.log(`Entity ${entityId}, deducting ${toDeduct.toNumber()}`); let curBalance = new Decimal(row.entities[entityId].balance); let newBalance = curBalance; if (curBalance.gte(toDeduct)) { newBalance = newBalance.sub(toDeduct); - toDeduct = new Decimal(0); + entityIdToTotal[entityId] = 0; shouldUpdate = true; update.entities[entityId] = { id: entityId, @@ -203,7 +211,7 @@ export async function performMaximumClearing({ }; } else { newBalance = new Decimal(0); - toDeduct = toDeduct.sub(curBalance); + entityIdToTotal[entityId] = toDeduct.sub(curBalance).toNumber(); shouldUpdate = true; update.entities[entityId] = { id: entityId, @@ -212,6 +220,8 @@ export async function performMaximumClearing({ }; } } + // console.log(`Max clearing for row ${row.id}`); + // console.log(`Update:`, update.entities); // If all keys are 0, then delete the row if ( diff --git a/server/src/internal/customers/getFullCusQuery.ts b/server/src/internal/customers/getFullCusQuery.ts index 2322c96e1..a00650e54 100644 --- a/server/src/internal/customers/getFullCusQuery.ts +++ b/server/src/internal/customers/getFullCusQuery.ts @@ -49,7 +49,7 @@ const buildOptimizedCusProductsCTE = (inStatuses?: CusProductStatus[]) => { ), 'rollovers', ( SELECT COALESCE( - json_agg(row_to_json(ro)) FILTER (WHERE ro.expires_at > EXTRACT(EPOCH FROM now()) * 1000), + json_agg(row_to_json(ro) ORDER BY ro.expires_at ASC) FILTER (WHERE ro.expires_at > EXTRACT(EPOCH FROM now()) * 1000), '[]'::json ) FROM rollovers ro diff --git a/server/src/trigger/updateBalanceTask.ts b/server/src/trigger/updateBalanceTask.ts index 6f330bda4..d941ad1e3 100644 --- a/server/src/trigger/updateBalanceTask.ts +++ b/server/src/trigger/updateBalanceTask.ts @@ -1,16 +1,16 @@ import { - AllowanceType, - AppEnv, - FullCusProduct, - CusProductStatus, - Entity, - Event, - Feature, - FullCustomerEntitlement, - FullCustomerPrice, - Organization, - FullCusEntWithFullCusProduct, - BillingType, + AllowanceType, + AppEnv, + FullCusProduct, + CusProductStatus, + Entity, + Event, + Feature, + FullCustomerEntitlement, + FullCustomerPrice, + Organization, + FullCusEntWithFullCusProduct, + BillingType, } from "@autumn/shared"; import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js"; import { Customer, FeatureType } from "@autumn/shared"; @@ -18,711 +18,701 @@ import { getCusEntsInFeatures } from "@/internal/customers/cusUtils/cusUtils.js" import { Decimal } from "decimal.js"; import { adjustAllowance } from "./adjustAllowance.js"; import { - getMeteredDeduction, - getCreditSystemDeduction, - performDeduction, + getMeteredDeduction, + getCreditSystemDeduction, + performDeduction, } from "./deductUtils.js"; import { notNullish, nullish } from "@/utils/genUtils.js"; import { - creditSystemContainsFeature, - featureToCreditSystem, + creditSystemContainsFeature, + featureToCreditSystem, } from "@/internal/features/creditSystemUtils.js"; import { - getCusEntMasterBalance, - getRelatedCusPrice, - getResetBalance, - getTotalNegativeBalance, + getCusEntMasterBalance, + getRelatedCusPrice, + getResetBalance, + getTotalNegativeBalance, } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js"; import { entityFeatureIdExists } from "@/internal/api/entities/entityUtils.js"; import { CusService } from "@/internal/customers/CusService.js"; import { DrizzleCli } from "@/db/initDrizzle.js"; import { findCusEnt } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils/findCusEntUtils.js"; import { - getBillingType, - getEntOptions, + getBillingType, + getEntOptions, } from "@/internal/products/prices/priceUtils.js"; import { deductFromCusRollovers } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverDeductionUtils.js"; // Decimal.set({ precision: 12 }); // 12 DP precision export type DeductParams = { - db: DrizzleCli; - env: AppEnv; - org: Organization; - cusPrices: FullCustomerPrice[]; - customer: Customer; - properties: any; - feature: Feature; - entity?: Entity; + db: DrizzleCli; + env: AppEnv; + org: Organization; + cusPrices: FullCustomerPrice[]; + customer: Customer; + properties: any; + feature: Feature; + entity?: Entity; }; export type RolloverDeductParams = { - db: DrizzleCli; - env: AppEnv; - feature: Feature; - entity?: Entity; + db: DrizzleCli; + env: AppEnv; + feature: Feature; + entity?: Entity; }; // 2. Get deductions for each feature const getFeatureDeductions = ({ - cusEnts, - event, - features, + cusEnts, + event, + features, }: { - cusEnts: FullCustomerEntitlement[]; - event: Event; - features: Feature[]; + cusEnts: FullCustomerEntitlement[]; + event: Event; + features: Feature[]; }) => { - const meteredFeatures = features.filter( - (feature) => feature.type === FeatureType.Metered - ); - const featureDeductions = []; - for (const feature of features) { - let deduction; - if (feature.type === FeatureType.Metered) { - deduction = getMeteredDeduction(feature, event); - } else if (feature.type === FeatureType.CreditSystem) { - deduction = getCreditSystemDeduction({ - meteredFeatures: meteredFeatures, - creditSystem: feature, - event, - }); - } + const meteredFeatures = features.filter( + (feature) => feature.type === FeatureType.Metered + ); + const featureDeductions = []; + for (const feature of features) { + let deduction; + if (feature.type === FeatureType.Metered) { + deduction = getMeteredDeduction(feature, event); + } else if (feature.type === FeatureType.CreditSystem) { + deduction = getCreditSystemDeduction({ + meteredFeatures: meteredFeatures, + creditSystem: feature, + event, + }); + } - // Check if unlimited exists - let unlimitedExists = cusEnts.some( - (cusEnt) => - cusEnt.entitlement.allowance_type === AllowanceType.Unlimited && - cusEnt.entitlement.internal_feature_id == feature.internal_id - ); + // Check if unlimited exists + let unlimitedExists = cusEnts.some( + (cusEnt) => + cusEnt.entitlement.allowance_type === AllowanceType.Unlimited && + cusEnt.entitlement.internal_feature_id == feature.internal_id + ); - if (unlimitedExists || !deduction) { - continue; - } + if (unlimitedExists || !deduction) { + continue; + } - featureDeductions.push({ - feature, - deduction, - }); - } + featureDeductions.push({ + feature, + deduction, + }); + } - featureDeductions.sort((a, b) => { - if ( - a.feature.type === FeatureType.CreditSystem && - b.feature.type !== FeatureType.CreditSystem - ) { - return 1; - } + featureDeductions.sort((a, b) => { + if ( + a.feature.type === FeatureType.CreditSystem && + b.feature.type !== FeatureType.CreditSystem + ) { + return 1; + } - if ( - a.feature.type !== FeatureType.CreditSystem && - b.feature.type === FeatureType.CreditSystem - ) { - return -1; - } + if ( + a.feature.type !== FeatureType.CreditSystem && + b.feature.type === FeatureType.CreditSystem + ) { + return -1; + } - return a.feature.id.localeCompare(b.feature.id); - }); + return a.feature.id.localeCompare(b.feature.id); + }); - return featureDeductions; + return featureDeductions; }; export const logBalanceUpdate = ({ - timeTaken, - customer, - features, - cusEnts, - featureDeductions, - properties, - entityId, - org, + timeTaken, + customer, + features, + cusEnts, + featureDeductions, + properties, + entityId, + org, }: { - timeTaken: string; - customer: Customer; - features: Feature[]; - cusEnts: FullCustomerEntitlement[]; - featureDeductions: any; - properties: any; - entityId?: string | null; - org: Organization; + timeTaken: string; + customer: Customer; + features: Feature[]; + cusEnts: FullCustomerEntitlement[]; + featureDeductions: any; + properties: any; + entityId?: string | null; + org: Organization; }) => { - console.log( - ` - Customer: ${customer.id} (${customer.env}) | Org: ${ - org.slug - } | Features: ${features.map((f) => f.id).join(", ")}` - ); - console.log(" - Properties:", properties); - console.log( - " - CusEnts:", - cusEnts.map((cusEnt: any) => { - let balanceStr = cusEnt.balance; + console.log( + ` - Customer: ${customer.id} (${customer.env}) | Org: ${ + org.slug + } | Features: ${features.map((f) => f.id).join(", ")}` + ); + console.log(" - Properties:", properties); + console.log( + " - CusEnts:", + cusEnts.map((cusEnt: any) => { + let balanceStr = cusEnt.balance; - if (notNullish(cusEnt.entitlement.entity_feature_id)) { - console.log( - ` - Entity feature ID found for feature: ${cusEnt.feature_id}` - ); + if (notNullish(cusEnt.entitlement.entity_feature_id)) { + console.log( + ` - Entity feature ID found for feature: ${cusEnt.feature_id}` + ); - if (notNullish(entityId)) { - balanceStr = `${cusEnt.entities?.[entityId!]?.balance} [${entityId}]`; - } else { - balanceStr = `${ - getCusEntMasterBalance({ - cusEnt, - entities: cusEnt.customer_product?.entities, - }).balance - } [Master]`; - } - } - try { - if ( - cusEnt.entitlement.allowance_type === - AllowanceType.Unlimited - ) { - balanceStr = "Unlimited"; - } - } catch (error) { - balanceStr = "failed_to_get_balance"; - } + if (notNullish(entityId)) { + balanceStr = `${cusEnt.entities?.[entityId!]?.balance} [${entityId}]`; + } else { + balanceStr = `${ + getCusEntMasterBalance({ + cusEnt, + entities: cusEnt.customer_product?.entities, + }).balance + } [Master]`; + } + } + try { + if (cusEnt.entitlement.allowance_type === AllowanceType.Unlimited) { + balanceStr = "Unlimited"; + } + } catch (error) { + balanceStr = "failed_to_get_balance"; + } - return `${cusEnt.feature_id} - ${balanceStr} (${ - cusEnt.customer_product - ? cusEnt.customer_product.product_id - : "" - })`; - }), - "| Deductions:", - featureDeductions.map((f: any) => `${f.feature.id}: ${f.deduction}`) - ); + return `${cusEnt.feature_id} - ${balanceStr} (${ + cusEnt.customer_product ? cusEnt.customer_product.product_id : "" + })`; + }), + "| Deductions:", + featureDeductions.map((f: any) => `${f.feature.id}: ${f.deduction}`) + ); }; export const performDeductionOnCusEnt = ({ - cusEnt, - toDeduct, - entityId, - allowNegativeBalance = false, - addAdjustment = false, - setZeroAdjustment = false, - blockUsageLimit = true, + cusEnt, + toDeduct, + entityId, + allowNegativeBalance = false, + addAdjustment = false, + setZeroAdjustment = false, + blockUsageLimit = true, }: { - cusEnt: FullCusEntWithFullCusProduct; - toDeduct: number; - entityId?: string | null; - allowNegativeBalance?: boolean; - addAdjustment?: boolean; - setZeroAdjustment?: boolean; - blockUsageLimit?: boolean; + cusEnt: FullCusEntWithFullCusProduct; + toDeduct: number; + entityId?: string | null; + allowNegativeBalance?: boolean; + addAdjustment?: boolean; + setZeroAdjustment?: boolean; + blockUsageLimit?: boolean; }) => { - let newEntities = structuredClone(cusEnt.entities); - let newBalance = structuredClone(cusEnt.balance); - let newAdjustment = structuredClone(cusEnt.adjustment); - let deducted = 0; + let newEntities = structuredClone(cusEnt.entities); + let newBalance = structuredClone(cusEnt.balance); + let newAdjustment = structuredClone(cusEnt.adjustment); + let deducted = 0; - let cusProduct = cusEnt.customer_product; - let options = notNullish(cusProduct) - ? getEntOptions(cusProduct.options, cusEnt.entitlement) - : undefined; - let cusPrice = notNullish(cusProduct) - ? getRelatedCusPrice(cusEnt, cusProduct.customer_prices) - : undefined; - let resetBalance = notNullish(cusProduct) - ? getResetBalance({ - options, - relatedPrice: cusPrice?.price, - entitlement: cusEnt.entitlement, - }) - : cusEnt.entitlement.allowance || 0; + let cusProduct = cusEnt.customer_product; + let options = notNullish(cusProduct) + ? getEntOptions(cusProduct.options, cusEnt.entitlement) + : undefined; + let cusPrice = notNullish(cusProduct) + ? getRelatedCusPrice(cusEnt, cusProduct.customer_prices) + : undefined; + let resetBalance = notNullish(cusProduct) + ? getResetBalance({ + options, + relatedPrice: cusPrice?.price, + entitlement: cusEnt.entitlement, + }) + : cusEnt.entitlement.allowance || 0; - if (entityFeatureIdExists({ cusEnt })) { - if (nullish(entityId)) { - // 1. If no entity ID, deduct from all - newEntities = structuredClone(cusEnt.entities); - if (!newEntities) { - newEntities = {}; - } - let toDeductCursor = toDeduct; - for (const entityId in cusEnt.entities) { - if (toDeductCursor == 0) { - break; - } + if (entityFeatureIdExists({ cusEnt })) { + if (nullish(entityId)) { + // 1. If no entity ID, deduct from all + newEntities = structuredClone(cusEnt.entities); + if (!newEntities) { + newEntities = {}; + } + let toDeductCursor = toDeduct; + for (const entityId in cusEnt.entities) { + if (toDeductCursor == 0) { + break; + } - let entityBalance = cusEnt.entities[entityId].balance; + let entityBalance = cusEnt.entities[entityId].balance; - let { - newBalance: newEntityBalance, - deducted: newDeducted, - toDeduct: newToDeduct, - } = performDeduction({ - cusEntBalance: new Decimal(entityBalance), - toDeduct: toDeductCursor, - allowNegativeBalance, - ent: cusEnt.entitlement, - resetBalance, - blockUsageLimit, - }); + let { + newBalance: newEntityBalance, + deducted: newDeducted, + toDeduct: newToDeduct, + } = performDeduction({ + cusEntBalance: new Decimal(entityBalance), + toDeduct: toDeductCursor, + allowNegativeBalance, + ent: cusEnt.entitlement, + resetBalance, + blockUsageLimit, + }); - newEntities[entityId].balance = newEntityBalance!; + newEntities[entityId].balance = newEntityBalance!; - if (addAdjustment) { - let adjustment = newEntities![entityId!]!.adjustment || 0; - newEntities![entityId!]!.adjustment = - adjustment - newDeducted!; - } + if (addAdjustment) { + let adjustment = newEntities![entityId!]!.adjustment || 0; + newEntities![entityId!]!.adjustment = adjustment - newDeducted!; + } - if (setZeroAdjustment) { - newEntities![entityId!]!.adjustment = 0; - } + if (setZeroAdjustment) { + newEntities![entityId!]!.adjustment = 0; + } - toDeductCursor = newToDeduct!; - deducted += newDeducted!; - } + toDeductCursor = newToDeduct!; + deducted += newDeducted!; + } - toDeduct = toDeductCursor; - } else { - // 2. If entity ID, deduct from that entity - let currentEntityBalance = cusEnt.entities?.[entityId!]?.balance; + toDeduct = toDeductCursor; + } else { + // 2. If entity ID, deduct from that entity + let currentEntityBalance = cusEnt.entities?.[entityId!]?.balance; - let { - newBalance: newEntityBalance, - deducted: newDeducted, - toDeduct: newToDeduct, - } = performDeduction({ - cusEntBalance: new Decimal(currentEntityBalance!), - toDeduct, - allowNegativeBalance, - ent: cusEnt.entitlement, - resetBalance, - blockUsageLimit, - }); + let { + newBalance: newEntityBalance, + deducted: newDeducted, + toDeduct: newToDeduct, + } = performDeduction({ + cusEntBalance: new Decimal(currentEntityBalance!), + toDeduct, + allowNegativeBalance, + ent: cusEnt.entitlement, + resetBalance, + blockUsageLimit, + }); - newEntities![entityId!]!.balance = newEntityBalance!; + newEntities![entityId!]!.balance = newEntityBalance!; - if (addAdjustment) { - let adjustment = newEntities![entityId!]!.adjustment || 0; - newEntities![entityId!]!.adjustment = adjustment - newDeducted!; - } + if (addAdjustment) { + let adjustment = newEntities![entityId!]!.adjustment || 0; + newEntities![entityId!]!.adjustment = adjustment - newDeducted!; + } - if (setZeroAdjustment) { - newEntities![entityId!]!.adjustment = 0; - } + if (setZeroAdjustment) { + newEntities![entityId!]!.adjustment = 0; + } - toDeduct = newToDeduct!; - deducted += newDeducted!; - } - } else { - let { - newBalance: newBalance_, - deducted: deducted_, - toDeduct: newToDeduct_, - } = performDeduction({ - cusEntBalance: new Decimal(cusEnt.balance!), - toDeduct, - allowNegativeBalance, - ent: cusEnt.entitlement, - resetBalance, - blockUsageLimit, - }); + toDeduct = newToDeduct!; + deducted += newDeducted!; + } + } else { + let { + newBalance: newBalance_, + deducted: deducted_, + toDeduct: newToDeduct_, + } = performDeduction({ + cusEntBalance: new Decimal(cusEnt.balance!), + toDeduct, + allowNegativeBalance, + ent: cusEnt.entitlement, + resetBalance, + blockUsageLimit, + }); - newBalance = newBalance_; - deducted = deducted_; - toDeduct = newToDeduct_; + newBalance = newBalance_; + deducted = deducted_; + toDeduct = newToDeduct_; - if (addAdjustment) { - let adjustment = cusEnt.adjustment || 0; - newAdjustment = adjustment - deducted!; - } - } - return { newBalance, newEntities, deducted, toDeduct, newAdjustment }; + if (addAdjustment) { + let adjustment = cusEnt.adjustment || 0; + newAdjustment = adjustment - deducted!; + } + } + return { newBalance, newEntities, deducted, toDeduct, newAdjustment }; }; export const deductAllowanceFromCusEnt = async ({ - toDeduct, - deductParams, - cusEnt, - featureDeductions, - willDeductCredits = false, - setZeroAdjustment = false, + toDeduct, + deductParams, + cusEnt, + featureDeductions, + willDeductCredits = false, + setZeroAdjustment = false, }: { - toDeduct: number; - deductParams: DeductParams; - cusEnt: FullCusEntWithFullCusProduct; - featureDeductions: any; - willDeductCredits?: boolean; - setZeroAdjustment?: boolean; + toDeduct: number; + deductParams: DeductParams; + cusEnt: FullCusEntWithFullCusProduct; + featureDeductions: any; + willDeductCredits?: boolean; + setZeroAdjustment?: boolean; }) => { - const { db, feature, env, org, cusPrices, customer, entity } = deductParams; + const { db, feature, env, org, cusPrices, customer, entity } = deductParams; - if (toDeduct == 0) { - return 0; - } + if (toDeduct == 0) { + return 0; + } - if ( - entity && - entityFeatureIdExists({ cusEnt }) && - cusEnt.entitlement.entity_feature_id !== entity.feature_id - ) - return toDeduct; + if ( + entity && + entityFeatureIdExists({ cusEnt }) && + cusEnt.entitlement.entity_feature_id !== entity.feature_id + ) + return toDeduct; - let { - newBalance, - newEntities, - deducted, - toDeduct: newToDeduct, - } = performDeductionOnCusEnt({ - cusEnt, - toDeduct, - entityId: entity?.id, - allowNegativeBalance: false, - setZeroAdjustment, - }); + let { + newBalance, + newEntities, + deducted, + toDeduct: newToDeduct, + } = performDeductionOnCusEnt({ + cusEnt, + toDeduct, + entityId: entity?.id, + allowNegativeBalance: false, + setZeroAdjustment, + }); - let originalGrpBalance = getTotalNegativeBalance({ - cusEnt, - balance: cusEnt.balance!, - entities: cusEnt.entities!, - }); + let originalGrpBalance = getTotalNegativeBalance({ + cusEnt, + balance: cusEnt.balance!, + entities: cusEnt.entities!, + }); - let newGrpBalance = getTotalNegativeBalance({ - cusEnt, - balance: newBalance!, - entities: newEntities!, - }); + let newGrpBalance = getTotalNegativeBalance({ + cusEnt, + balance: newBalance!, + entities: newEntities!, + }); - let updates: any = { - balance: newBalance, - entities: newEntities, - }; - if (setZeroAdjustment) { - updates.adjustment = 0; - } + let updates: any = { + balance: newBalance, + entities: newEntities, + }; + if (setZeroAdjustment) { + updates.adjustment = 0; + } - const { newReplaceables, deletedReplaceables } = await adjustAllowance({ - db, - env, - org, - cusPrices: cusPrices as any, - customer, - affectedFeature: feature, - cusEnt: cusEnt as any, - originalBalance: originalGrpBalance, - newBalance: newGrpBalance, - logger: console, - }); + const { newReplaceables, deletedReplaceables } = await adjustAllowance({ + db, + env, + org, + cusPrices: cusPrices as any, + customer, + affectedFeature: feature, + cusEnt: cusEnt as any, + originalBalance: originalGrpBalance, + newBalance: newGrpBalance, + logger: console, + }); - if (newReplaceables && newReplaceables.length > 0) { - updates.balance = newBalance! - newReplaceables.length; - } else if (deletedReplaceables && deletedReplaceables.length > 0) { - updates.balance = newBalance! + deletedReplaceables.length; - } + if (newReplaceables && newReplaceables.length > 0) { + updates.balance = newBalance! - newReplaceables.length; + } else if (deletedReplaceables && deletedReplaceables.length > 0) { + updates.balance = newBalance! + deletedReplaceables.length; + } - await CusEntService.update({ - db, - id: cusEnt.id, - updates, - }); + await CusEntService.update({ + db, + id: cusEnt.id, + updates, + }); - // Deduct credit amounts too - if (feature.type === FeatureType.Metered && willDeductCredits) { - for (let i = 0; i < featureDeductions.length; i++) { - let { feature: creditSystem, deduction } = featureDeductions[i]; + // Deduct credit amounts too + if (feature.type === FeatureType.Metered && willDeductCredits) { + for (let i = 0; i < featureDeductions.length; i++) { + let { feature: creditSystem, deduction } = featureDeductions[i]; - if ( - creditSystem.type === FeatureType.CreditSystem && - creditSystemContainsFeature({ - creditSystem: creditSystem, - meteredFeatureId: feature.id!, - }) - ) { - // toDeduct -= deduction; - let creditAmount = featureToCreditSystem({ - featureId: feature.id!, - creditSystem: creditSystem, - amount: deducted, - }); - let newDeduction = new Decimal(deduction) - .minus(creditAmount) - .toNumber(); + if ( + creditSystem.type === FeatureType.CreditSystem && + creditSystemContainsFeature({ + creditSystem: creditSystem, + meteredFeatureId: feature.id!, + }) + ) { + // toDeduct -= deduction; + let creditAmount = featureToCreditSystem({ + featureId: feature.id!, + creditSystem: creditSystem, + amount: deducted, + }); + let newDeduction = new Decimal(deduction) + .minus(creditAmount) + .toNumber(); - featureDeductions[i].deduction = newDeduction; - } - } - } + featureDeductions[i].deduction = newDeduction; + } + } + } - cusEnt.balance = newBalance; - cusEnt.entities = newEntities; - return newToDeduct; + cusEnt.balance = newBalance; + cusEnt.entities = newEntities; + return newToDeduct; }; export const deductFromUsageBasedCusEnt = async ({ - toDeduct, - deductParams, - cusEnts, - setZeroAdjustment = false, + toDeduct, + deductParams, + cusEnts, + setZeroAdjustment = false, }: { - toDeduct: number; - deductParams: DeductParams; - cusEnts: FullCusEntWithFullCusProduct[]; - setZeroAdjustment?: boolean; + toDeduct: number; + deductParams: DeductParams; + cusEnts: FullCusEntWithFullCusProduct[]; + setZeroAdjustment?: boolean; }) => { - const { db, feature, env, org, cusPrices, customer, entity } = deductParams; + const { db, feature, env, org, cusPrices, customer, entity } = deductParams; - // Deduct from usage-based price - const usageBasedEnt = findCusEnt({ - cusEnts, - feature, - entity, - onlyUsageAllowed: true, - }) as FullCusEntWithFullCusProduct; + // Deduct from usage-based price + const usageBasedEnt = findCusEnt({ + cusEnts, + feature, + entity, + onlyUsageAllowed: true, + }) as FullCusEntWithFullCusProduct; - if (!usageBasedEnt) { - console.log( - ` - Feature ${feature.id}, To deduct: ${toDeduct} -> no usage-based entitlement found` - ); - return; - } + if (!usageBasedEnt) { + console.log( + ` - Feature ${feature.id}, To deduct: ${toDeduct} -> no usage-based entitlement found` + ); + return; + } - let cusPrice = getRelatedCusPrice(usageBasedEnt, cusPrices); - let billingType = cusPrice?.price - ? getBillingType(cusPrice?.price.config!) - : undefined; - let blockUsageLimit = - billingType === BillingType.InArrearProrated ? false : true; + let cusPrice = getRelatedCusPrice(usageBasedEnt, cusPrices); + let billingType = cusPrice?.price + ? getBillingType(cusPrice?.price.config!) + : undefined; + let blockUsageLimit = + billingType === BillingType.InArrearProrated ? false : true; - let { newBalance, newEntities, deducted } = performDeductionOnCusEnt({ - cusEnt: usageBasedEnt, - toDeduct, - allowNegativeBalance: true, - setZeroAdjustment, - entityId: entity?.id, - blockUsageLimit, - }); + let { newBalance, newEntities, deducted } = performDeductionOnCusEnt({ + cusEnt: usageBasedEnt, + toDeduct, + allowNegativeBalance: true, + setZeroAdjustment, + entityId: entity?.id, + blockUsageLimit, + }); - let oldGrpBalance = getTotalNegativeBalance({ - cusEnt: usageBasedEnt, - balance: usageBasedEnt.balance!, - entities: usageBasedEnt.entities!, - }); + let oldGrpBalance = getTotalNegativeBalance({ + cusEnt: usageBasedEnt, + balance: usageBasedEnt.balance!, + entities: usageBasedEnt.entities!, + }); - let newGrpBalance = getTotalNegativeBalance({ - cusEnt: usageBasedEnt, - balance: newBalance!, - entities: newEntities!, - }); + let newGrpBalance = getTotalNegativeBalance({ + cusEnt: usageBasedEnt, + balance: newBalance!, + entities: newEntities!, + }); - let updates: any = { - balance: newBalance, - entities: newEntities, - }; - if (setZeroAdjustment) { - updates.adjustment = 0; - } + let updates: any = { + balance: newBalance, + entities: newEntities, + }; + if (setZeroAdjustment) { + updates.adjustment = 0; + } - const { newReplaceables, deletedReplaceables } = await adjustAllowance({ - db, - env, - affectedFeature: feature, - org, - cusEnt: usageBasedEnt as any, - cusPrices: cusPrices as any, - customer, - originalBalance: oldGrpBalance, - newBalance: newGrpBalance, - logger: console, - }); + const { newReplaceables, deletedReplaceables } = await adjustAllowance({ + db, + env, + affectedFeature: feature, + org, + cusEnt: usageBasedEnt as any, + cusPrices: cusPrices as any, + customer, + originalBalance: oldGrpBalance, + newBalance: newGrpBalance, + logger: console, + }); - if (newReplaceables && newReplaceables.length > 0) { - updates.balance = newBalance! - newReplaceables.length; - } else if (deletedReplaceables && deletedReplaceables.length > 0) { - updates.balance = newBalance! + deletedReplaceables.length; - } + if (newReplaceables && newReplaceables.length > 0) { + updates.balance = newBalance! - newReplaceables.length; + } else if (deletedReplaceables && deletedReplaceables.length > 0) { + updates.balance = newBalance! + deletedReplaceables.length; + } - await CusEntService.update({ - db, - id: usageBasedEnt!.id, - updates, - }); + await CusEntService.update({ + db, + id: usageBasedEnt!.id, + updates, + }); }; // Main function to update customer balance export const updateCustomerBalance = async ({ - db, - customerId, - entityId, - event, - features, - org, - env, - logger, + db, + customerId, + entityId, + event, + features, + org, + env, + logger, }: { - db: DrizzleCli; - customerId: string; - entityId: string; - event: Event; - features: Feature[]; - org: Organization; - env: AppEnv; - logger: any; + db: DrizzleCli; + customerId: string; + entityId: string; + event: Event; + features: Feature[]; + org: Organization; + env: AppEnv; + logger: any; }) => { - const startTime = performance.now(); - console.log("REVERSE DEDUCTION ORDER", org.config.reverse_deduction_order); - const customer = await CusService.getFull({ - db, - idOrInternalId: customerId, - orgId: org.id, - env, - inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue], - entityId, - }); + const startTime = performance.now(); + console.log("REVERSE DEDUCTION ORDER", org.config.reverse_deduction_order); + const customer = await CusService.getFull({ + db, + idOrInternalId: customerId, + orgId: org.id, + env, + inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue], + entityId, + }); - const { cusEnts, cusPrices } = await getCusEntsInFeatures({ - customer, - internalFeatureIds: features.map((f) => f.internal_id!), - logger, - reverseOrder: org.config.reverse_deduction_order, - }); + const { cusEnts, cusPrices } = await getCusEntsInFeatures({ + customer, + internalFeatureIds: features.map((f) => f.internal_id!), + logger, + reverseOrder: org.config.reverse_deduction_order, + }); - const endTime = performance.now(); + const endTime = performance.now(); - // 1. Get deductions for each feature - const featureDeductions = getFeatureDeductions({ - cusEnts, - event, - features, - }); + // 1. Get deductions for each feature + const featureDeductions = getFeatureDeductions({ + cusEnts, + event, + features, + }); - logBalanceUpdate({ - timeTaken: (endTime - startTime).toFixed(2), - customer, - features, - cusEnts, - featureDeductions, - properties: event.properties, - org, - entityId: event.entity_id, - }); + logBalanceUpdate({ + timeTaken: (endTime - startTime).toFixed(2), + customer, + features, + cusEnts, + featureDeductions, + properties: event.properties, + org, + entityId: event.entity_id, + }); - // 3. Return if no customer entitlements or features found - if (cusEnts.length === 0 || features.length === 0) { - console.log(" - No customer entitlements or features found"); - return; - } + // 3. Return if no customer entitlements or features found + if (cusEnts.length === 0 || features.length === 0) { + console.log(" - No customer entitlements or features found"); + return; + } - // 4. Perform deductions and update customer balance - for (const obj of featureDeductions) { - let { feature, deduction: toDeduct } = obj; + // 4. Perform deductions and update customer balance + for (const obj of featureDeductions) { + let { feature, deduction: toDeduct } = obj; - for (const cusEnt of cusEnts) { - if (cusEnt.entitlement.internal_feature_id != feature.internal_id) { - continue; - } + for (const cusEnt of cusEnts) { + if (cusEnt.entitlement.internal_feature_id != feature.internal_id) { + continue; + } - console.log(`toDeduct: ${toDeduct}`); + toDeduct = await deductFromCusRollovers({ + toDeduct, + cusEnt, + deductParams: { + db, + feature, + env, + entity: customer.entity ? customer.entity : undefined, + }, + }); - toDeduct = await deductFromCusRollovers({ - toDeduct, - cusEnt, - deductParams: { - db, - feature, - env, - entity: customer.entity ? customer.entity : undefined, - }, - }); + if (toDeduct == 0) { + continue; + } - console.log(`toDeduct after rollovers: ${toDeduct}`); + toDeduct = await deductAllowanceFromCusEnt({ + toDeduct, + cusEnt, + deductParams: { + db, + feature, + env, + org, + cusPrices: cusPrices as any[], + customer, + properties: event.properties, + entity: customer.entity, + }, + featureDeductions, + willDeductCredits: true, + }); + } - if (toDeduct == 0) { - continue; - } + if (toDeduct == 0) { + continue; + } - toDeduct = await deductAllowanceFromCusEnt({ - toDeduct, - cusEnt, - deductParams: { - db, - feature, - env, - org, - cusPrices: cusPrices as any[], - customer, - properties: event.properties, - entity: customer.entity, - }, - featureDeductions, - willDeductCredits: true, - }); - } + await deductFromUsageBasedCusEnt({ + toDeduct, + cusEnts, + deductParams: { + db, + feature, + env, + org, + cusPrices: cusPrices as any[], + customer, + properties: event.properties, + entity: customer.entity, + }, + }); + } - if (toDeduct == 0) { - continue; - } - - await deductFromUsageBasedCusEnt({ - toDeduct, - cusEnts, - deductParams: { - db, - feature, - env, - org, - cusPrices: cusPrices as any[], - customer, - properties: event.properties, - entity: customer.entity, - }, - }); - } - - return cusEnts; + return cusEnts; }; // MAIN FUNCTION export const runUpdateBalanceTask = async ({ - payload, - logger, - db, + payload, + logger, + db, }: { - payload: any; - logger: any; - db: DrizzleCli; + payload: any; + logger: any; + db: DrizzleCli; }) => { - try { - // 1. Update customer balance - const { customerId, features, event, org, env, entityId } = payload; + try { + // 1. Update customer balance + const { customerId, features, event, org, env, entityId } = payload; - console.log("--------------------------------"); - console.log( - `UPDATING BALANCE FOR CUSTOMER (${customerId}), ORG: ${org.slug}` - ); + console.log("--------------------------------"); + console.log( + `UPDATING BALANCE FOR CUSTOMER (${customerId}), ORG: ${org.slug}` + ); - const cusEnts: any = await updateCustomerBalance({ - db, - customerId, - features, - event, - org, - env, - logger, - entityId, - }); + const cusEnts: any = await updateCustomerBalance({ + db, + customerId, + features, + event, + org, + env, + logger, + entityId, + }); - if (!cusEnts || cusEnts.length === 0) { - return; - } - console.log(" ✅ Customer balance updated"); - } catch (error) { - if (logger) { - logger.use((log: any) => { - return { - ...log, - data: payload, - }; - }); + if (!cusEnts || cusEnts.length === 0) { + return; + } + console.log(" ✅ Customer balance updated"); + } catch (error) { + if (logger) { + logger.use((log: any) => { + return { + ...log, + data: payload, + }; + }); - logger.error(`ERROR UPDATING BALANCE`); - logger.error(error); - } else { - console.log(error); - } - } + logger.error(`ERROR UPDATING BALANCE`); + logger.error(error); + } else { + console.log(error); + } + } }; diff --git a/server/src/trigger/updateUsageTask.ts b/server/src/trigger/updateUsageTask.ts index ea7727db0..9d4e5df7a 100644 --- a/server/src/trigger/updateUsageTask.ts +++ b/server/src/trigger/updateUsageTask.ts @@ -1,12 +1,12 @@ import { - AllowanceType, - AppEnv, - CusProductStatus, - Customer, - Feature, - FeatureType, - FullCustomerEntitlement, - Organization, + AllowanceType, + AppEnv, + CusProductStatus, + Customer, + Feature, + FeatureType, + FullCustomerEntitlement, + Organization, } from "@autumn/shared"; import { getCusEntsInFeatures } from "@/internal/customers/cusUtils/cusUtils.js"; @@ -15,8 +15,8 @@ import { getFeatureBalance } from "@/internal/customers/cusProducts/cusEnts/cusE import { Decimal } from "decimal.js"; import { - deductAllowanceFromCusEnt, - deductFromUsageBasedCusEnt, + deductAllowanceFromCusEnt, + deductFromUsageBasedCusEnt, } from "./updateBalanceTask.js"; import { CusService } from "@/internal/customers/CusService.js"; import { DrizzleCli } from "@/db/initDrizzle.js"; @@ -24,335 +24,326 @@ import { deductFromCusRollovers } from "@/internal/customers/cusProducts/cusEnts // 2. Get deductions for each feature const getFeatureDeductions = ({ - cusEnts, - value, - features, - shouldSet, + cusEnts, + value, + features, + shouldSet, }: { - cusEnts: FullCustomerEntitlement[]; - value: number; - features: Feature[]; - shouldSet: boolean; + cusEnts: FullCustomerEntitlement[]; + value: number; + features: Feature[]; + shouldSet: boolean; }) => { - let meteredFeature = - features.find((f) => f.type === FeatureType.Metered) || features[0]; + let meteredFeature = + features.find((f) => f.type === FeatureType.Metered) || features[0]; - const featureDeductions = []; - for (const feature of features) { - let newValue = value; - let unlimitedExists = cusEnts.some( - (cusEnt) => - cusEnt.entitlement.allowance_type === AllowanceType.Unlimited && - cusEnt.entitlement.internal_feature_id == feature.internal_id - ); + const featureDeductions = []; + for (const feature of features) { + let newValue = value; + let unlimitedExists = cusEnts.some( + (cusEnt) => + cusEnt.entitlement.allowance_type === AllowanceType.Unlimited && + cusEnt.entitlement.internal_feature_id == feature.internal_id + ); - if (unlimitedExists) { - continue; - } + if (unlimitedExists) { + continue; + } - if (feature.type === FeatureType.CreditSystem) { - newValue = featureToCreditSystem({ - featureId: meteredFeature.id, - creditSystem: feature, - amount: value, - }); - } + if (feature.type === FeatureType.CreditSystem) { + newValue = featureToCreditSystem({ + featureId: meteredFeature.id, + creditSystem: feature, + amount: value, + }); + } - // If it's set - let deduction = newValue; + // If it's set + let deduction = newValue; - if (shouldSet) { - let totalAllowance = cusEnts.reduce((acc, curr) => { - return acc + (curr.entitlement.allowance || 0); - }, 0); + if (shouldSet) { + let totalAllowance = cusEnts.reduce((acc, curr) => { + return acc + (curr.entitlement.allowance || 0); + }, 0); - let targetBalance = new Decimal(totalAllowance) - .sub(value) - .toNumber(); + let targetBalance = new Decimal(totalAllowance).sub(value).toNumber(); - let totalBalance = getFeatureBalance({ - cusEnts, - internalFeatureId: feature.internal_id!, - })!; + let totalBalance = getFeatureBalance({ + cusEnts, + internalFeatureId: feature.internal_id!, + })!; - deduction = new Decimal(totalBalance).sub(targetBalance).toNumber(); - } + deduction = new Decimal(totalBalance).sub(targetBalance).toNumber(); + } - if (deduction == 0) { - console.log( - ` - Skipping feature ${feature.id} -- deduction is 0` - ); - continue; - } + if (deduction == 0) { + console.log(` - Skipping feature ${feature.id} -- deduction is 0`); + continue; + } - featureDeductions.push({ - feature, - deduction, - }); - } + featureDeductions.push({ + feature, + deduction, + }); + } - featureDeductions.sort((a, b) => { - if ( - a.feature.type === FeatureType.CreditSystem && - b.feature.type !== FeatureType.CreditSystem - ) { - return 1; - } + featureDeductions.sort((a, b) => { + if ( + a.feature.type === FeatureType.CreditSystem && + b.feature.type !== FeatureType.CreditSystem + ) { + return 1; + } - if ( - a.feature.type !== FeatureType.CreditSystem && - b.feature.type === FeatureType.CreditSystem - ) { - return -1; - } + if ( + a.feature.type !== FeatureType.CreditSystem && + b.feature.type === FeatureType.CreditSystem + ) { + return -1; + } - return a.feature.id.localeCompare(b.feature.id); - }); + return a.feature.id.localeCompare(b.feature.id); + }); - return featureDeductions; + return featureDeductions; }; const logUsageUpdate = ({ - customer, - features, - cusEnts, - featureDeductions, - org, - setUsage, - entityId, + customer, + features, + cusEnts, + featureDeductions, + org, + setUsage, + entityId, }: { - customer: Customer; - features: Feature[]; - cusEnts: FullCustomerEntitlement[]; - featureDeductions: any; - org: Organization; - setUsage: boolean; - entityId?: string; + customer: Customer; + features: Feature[]; + cusEnts: FullCustomerEntitlement[]; + featureDeductions: any; + org: Organization; + setUsage: boolean; + entityId?: string; }) => { - console.log( - ` - Customer: ${customer.id} (${customer.env}) | Org: ${ - org.slug - } | Features: ${features.map((f) => f.id).join(", ")} | Set Usage: ${ - setUsage ? "true" : "false" - }` - ); + console.log( + ` - Customer: ${customer.id} (${customer.env}) | Org: ${ + org.slug + } | Features: ${features.map((f) => f.id).join(", ")} | Set Usage: ${ + setUsage ? "true" : "false" + }` + ); - console.log( - " - CusEnts:", - cusEnts.map((cusEnt: any) => { - let balanceStr = cusEnt.balance; - try { - if ( - cusEnt.entitlement.allowance_type === - AllowanceType.Unlimited - ) { - balanceStr = "Unlimited"; - } - } catch (error) { - balanceStr = "failed_to_get_balance"; - } + console.log( + " - CusEnts:", + cusEnts.map((cusEnt: any) => { + let balanceStr = cusEnt.balance; + try { + if (cusEnt.entitlement.allowance_type === AllowanceType.Unlimited) { + balanceStr = "Unlimited"; + } + } catch (error) { + balanceStr = "failed_to_get_balance"; + } - if (entityId && cusEnt.entities) { - balanceStr = `${cusEnt.entities?.[entityId!]?.balance} [${entityId}]`; - } + if (entityId && cusEnt.entities) { + balanceStr = `${cusEnt.entities?.[entityId!]?.balance} [${entityId}]`; + } - return `${cusEnt.feature_id} - ${balanceStr} (${ - cusEnt.customer_product - ? cusEnt.customer_product.product_id - : "" - })`; - }), - "| Deductions:", - featureDeductions.map((f: any) => `${f.feature.id}: ${f.deduction}`) - ); + return `${cusEnt.feature_id} - ${balanceStr} (${ + cusEnt.customer_product ? cusEnt.customer_product.product_id : "" + })`; + }), + "| Deductions:", + featureDeductions.map((f: any) => `${f.feature.id}: ${f.deduction}`) + ); }; // Main function to update customer balance export const updateUsage = async ({ - db, - customerId, - features, - org, - env, - value, - properties, - setUsage, - logger, - entityId, + db, + customerId, + features, + org, + env, + value, + properties, + setUsage, + logger, + entityId, }: { - db: DrizzleCli; - customerId: string; - features: Feature[]; - org: Organization; - env: AppEnv; - value: number; - properties: any; - setUsage: boolean; - logger: any; - entityId?: string; + db: DrizzleCli; + customerId: string; + features: Feature[]; + org: Organization; + env: AppEnv; + value: number; + properties: any; + setUsage: boolean; + logger: any; + entityId?: string; }) => { - const customer = await CusService.getFull({ - db, - idOrInternalId: customerId, - orgId: org.id, - env, - inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue], - entityId, - }); + const customer = await CusService.getFull({ + db, + idOrInternalId: customerId, + orgId: org.id, + env, + inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue], + entityId, + }); - const { cusEnts, cusPrices } = await getCusEntsInFeatures({ - customer, - internalFeatureIds: features.map((f) => f.internal_id!), - logger, - reverseOrder: org.config?.reverse_deduction_order, - }); + const { cusEnts, cusPrices } = await getCusEntsInFeatures({ + customer, + internalFeatureIds: features.map((f) => f.internal_id!), + logger, + reverseOrder: org.config?.reverse_deduction_order, + }); - // 1. Get deductions for each feature - const featureDeductions = getFeatureDeductions({ - cusEnts, - value, - shouldSet: setUsage, - features, - }); + // 1. Get deductions for each feature + const featureDeductions = getFeatureDeductions({ + cusEnts, + value, + shouldSet: setUsage, + features, + }); - logUsageUpdate({ - customer, - features, - cusEnts, - featureDeductions, - org, - setUsage, - entityId, - }); + logUsageUpdate({ + customer, + features, + cusEnts, + featureDeductions, + org, + setUsage, + entityId, + }); - // 3. Return if no customer entitlements or features found - if (cusEnts.length === 0 || features.length === 0) { - console.log(" - No customer entitlements or features found"); - return; - } + // 3. Return if no customer entitlements or features found + if (cusEnts.length === 0 || features.length === 0) { + console.log(" - No customer entitlements or features found"); + return; + } - for (const obj of featureDeductions) { - let { feature, deduction: toDeduct } = obj; + for (const obj of featureDeductions) { + let { feature, deduction: toDeduct } = obj; - for (const cusEnt of cusEnts) { - if (cusEnt.entitlement.internal_feature_id != feature.internal_id) { - continue; - } - console.log(`toDeduct: ${toDeduct}`); + for (const cusEnt of cusEnts) { + if (cusEnt.entitlement.internal_feature_id != feature.internal_id) { + continue; + } + // console.log(`toDeduct: ${toDeduct}`); - toDeduct = await deductFromCusRollovers({ - toDeduct, - cusEnt, - deductParams: { - db, - feature, - env, - entity: customer.entity ? customer.entity : undefined, - }, - }); + toDeduct = await deductFromCusRollovers({ + toDeduct, + cusEnt, + deductParams: { + db, + feature, + env, + entity: customer.entity ? customer.entity : undefined, + }, + }); - console.log(`toDeduct after rollovers: ${toDeduct}`); + // console.log(`toDeduct after rollovers: ${toDeduct}`); - if (toDeduct == 0) { - continue; - } - toDeduct = await deductAllowanceFromCusEnt({ - toDeduct, - cusEnt, - deductParams: { - db, - feature, - env, - org, - cusPrices: cusPrices as any[], - customer, - properties, - entity: customer.entity, - }, - featureDeductions, - willDeductCredits: true, - setZeroAdjustment: true, - }); - } + if (toDeduct == 0) { + continue; + } + toDeduct = await deductAllowanceFromCusEnt({ + toDeduct, + cusEnt, + deductParams: { + db, + feature, + env, + org, + cusPrices: cusPrices as any[], + customer, + properties, + entity: customer.entity, + }, + featureDeductions, + willDeductCredits: true, + setZeroAdjustment: true, + }); + } - if (toDeduct == 0) { - continue; - } + if (toDeduct == 0) { + continue; + } - await deductFromUsageBasedCusEnt({ - toDeduct, - cusEnts, - deductParams: { - db, - feature, - env, - org, - cusPrices: cusPrices as any[], - customer, - properties, - entity: customer.entity, - }, - setZeroAdjustment: true, - }); - } + await deductFromUsageBasedCusEnt({ + toDeduct, + cusEnts, + deductParams: { + db, + feature, + env, + org, + cusPrices: cusPrices as any[], + customer, + properties, + entity: customer.entity, + }, + setZeroAdjustment: true, + }); + } - return cusEnts; + return cusEnts; }; // MAIN FUNCTION export const runUpdateUsageTask = async ({ - payload, - logger, - db, - throwError = false, + payload, + logger, + db, + throwError = false, }: { - payload: any; - logger: any; - db: DrizzleCli; - throwError?: boolean; + payload: any; + logger: any; + db: DrizzleCli; + throwError?: boolean; }) => { - try { - // 1. Update customer balance - const { - internalCustomerId, - customerId, - features, - value, - set_usage, - properties, - org, - env, - entityId, - } = payload; + try { + // 1. Update customer balance + const { + internalCustomerId, + customerId, + features, + value, + set_usage, + properties, + org, + env, + entityId, + } = payload; - console.log("--------------------------------"); - console.log( - `HANDLING USAGE TASK FOR CUSTOMER (${customerId}), ORG: ${org.slug}` - ); + console.log("--------------------------------"); + console.log( + `HANDLING USAGE TASK FOR CUSTOMER (${customerId}), ORG: ${org.slug}` + ); - const cusEnts: any = await updateUsage({ - db, - customerId, - features, - value, - properties, - org, - env, - setUsage: set_usage, - logger, - entityId, - }); + const cusEnts: any = await updateUsage({ + db, + customerId, + features, + value, + properties, + org, + env, + setUsage: set_usage, + logger, + entityId, + }); - if (!cusEnts || cusEnts.length === 0) { - return; - } - console.log(" ✅ Customer balance updated"); - } catch (error) { - logger.error(`ERROR UPDATING USAGE`); - logger.error(error); + if (!cusEnts || cusEnts.length === 0) { + return; + } + console.log(" ✅ Customer balance updated"); + } catch (error) { + logger.error(`ERROR UPDATING USAGE`); + logger.error(error); - if (throwError) { - throw error; - } - } + if (throwError) { + throw error; + } + } }; diff --git a/server/src/utils/scriptUtils/constructItem.ts b/server/src/utils/scriptUtils/constructItem.ts index ef8aee9a6..199fad396 100644 --- a/server/src/utils/scriptUtils/constructItem.ts +++ b/server/src/utils/scriptUtils/constructItem.ts @@ -56,6 +56,7 @@ export const constructPrepaidItem = ({ on_increase: OnIncrease.ProrateImmediately, on_decrease: OnDecrease.ProrateImmediately, }, + rolloverConfig, usageLimit, }: { featureId: string; @@ -64,6 +65,7 @@ export const constructPrepaidItem = ({ includedUsage?: number; isOneOff?: boolean; config?: ProductItemConfig; + rolloverConfig?: RolloverConfig; usageLimit?: number; }) => { let item: ProductItem = { @@ -76,7 +78,10 @@ export const constructPrepaidItem = ({ interval: isOneOff ? null : ProductItemInterval.Month, included_usage: includedUsage, - config, + config: { + ...config, + ...(rolloverConfig ? { rollover: rolloverConfig } : {}), + }, usage_limit: usageLimit, }; @@ -128,12 +133,14 @@ export const constructArrearProratedItem = ({ on_decrease: OnDecrease.None, }, usageLimit, + rolloverConfig, }: { featureId: string; pricePerUnit?: number; includedUsage?: number; config?: ProductItemConfig; usageLimit?: number; + rolloverConfig?: RolloverConfig; }) => { let item: ProductItem = { feature_id: featureId, @@ -142,7 +149,10 @@ export const constructArrearProratedItem = ({ price: pricePerUnit, billing_units: 1, interval: ProductItemInterval.Month, - config, + config: { + ...config, + ...(rolloverConfig ? { rollover: rolloverConfig } : {}), + }, usage_limit: usageLimit, }; diff --git a/server/tests/advanced/rollovers/rollover1.ts b/server/tests/advanced/rollovers/rollover1.ts index 16d076064..09cfaaece 100644 --- a/server/tests/advanced/rollovers/rollover1.ts +++ b/server/tests/advanced/rollovers/rollover1.ts @@ -22,10 +22,7 @@ import { TestFeature } from "tests/setup/v2Features.js"; import { addPrefixToProducts } from "tests/attach/utils.js"; import { expect } from "chai"; -import { getMainCusProduct } from "@/internal/customers/cusProducts/cusProductUtils.js"; -import { cusProductToCusEnt } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js"; import { timeout } from "@/utils/genUtils.js"; -import { resetCustomerEntitlement } from "@/cron/cronUtils.js"; import { resetAndGetCusEnt } from "./rolloverTestUtils.js"; let rolloverConfig = { max: 500, length: 1, duration: RolloverDuration.Month }; @@ -36,9 +33,10 @@ const messagesItem = constructFeatureItem({ rolloverConfig, }) as LimitedItem; -export let pro = constructProduct({ +export let free = constructProduct({ items: [messagesItem], - type: "pro", + type: "free", + isDefault: false, }); const testCase = "rollover1"; @@ -64,13 +62,13 @@ describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item` stripeCli = this.stripeCli; addPrefixToProducts({ - products: [pro], + products: [free], prefix: testCase, }); await createProducts({ autumn, - products: [pro], + products: [free], customerId, db, orgId: org.id, @@ -90,10 +88,10 @@ describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item` customer = res.customer; }); - it("should attach pro product", async function () { + it("should attach free product", async function () { await autumn.attach({ customer_id: customerId, - product_id: pro.id, + product_id: free.id, }); }); @@ -109,10 +107,10 @@ describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item` await timeout(3000); - let msgesCusEnt = await resetAndGetCusEnt({ + await resetAndGetCusEnt({ db, customer, - productGroup: pro.group, + productGroup: free.group, featureId: TestFeature.Messages, }); @@ -131,39 +129,69 @@ describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item` // @ts-ignore expect(msgesFeature?.rollovers[0].balance).to.equal(expectedRollover); curBalance = expectedBalance; - - // let rollover = messagesItem.included_usage - messageUsage; - - // expect(msgesCusEnt?.rollovers.length).to.equal(1); - // expect(msgesCusEnt?.rollovers[0].balance).to.equal( - // Math.min(rollover, rolloverConfig.max) - // ); }); // let usage2 = 50; - it("should track messages, reset again and have correct rollover", async function () { + it("should reset again and have correct rollover", async function () { await resetAndGetCusEnt({ db, customer, - productGroup: pro.group, + productGroup: free.group, featureId: TestFeature.Messages, }); - console.log("Current balance", curBalance); - console.log("Max rollover", rolloverConfig.max); let expectedRollover = Math.min(curBalance, rolloverConfig.max); - - let expectedBalance = curBalance + expectedRollover; - console.log("Expected rollover", expectedRollover); - console.log("Expected balance", expectedBalance); + let expectedBalance = messagesItem.included_usage + expectedRollover; let cus = await autumn.customers.get(customerId); let msgesFeature = cus.features[TestFeature.Messages]; expect(msgesFeature).to.exist; expect(msgesFeature?.balance).to.equal(expectedBalance); - // // @ts-ignore - // expect(msgesFeature?.rollovers[0].balance).to.equal(expectedRollover); - // curBalance = expectedBalance; + + // @ts-ignore (oldest rollover should be 100 (150 - 50)) + expect(msgesFeature?.rollovers[0].balance).to.equal(100); + // @ts-ignore (newest rollover should be 400 (msges.included_usage)) + expect(msgesFeature?.rollovers[1].balance).to.equal(400); + }); + + it("should track messages and deduct from rollovers first", async function () { + await autumn.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 150, + }); + + await timeout(3000); + + let cus = await autumn.customers.get(customerId); + let msgesFeature = cus.features[TestFeature.Messages]; + + // @ts-ignore + let rollover1 = msgesFeature?.rollovers[0]; + // @ts-ignore + let rollover2 = msgesFeature?.rollovers[1]; + + expect(rollover1.balance).to.equal(0); + expect(rollover2.balance).to.equal(350); + }); + + it("should track and deduct from rollover + original balance", async function () { + await autumn.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 400, + }); + + await timeout(3000); + + let cus = await autumn.customers.get(customerId); + let msgesFeature = cus.features[TestFeature.Messages]; + + // @ts-ignore + let rollovers = msgesFeature.rollovers; + expect(rollovers[0].balance).to.equal(0); + expect(rollovers[1].balance).to.equal(0); + expect(msgesFeature.balance).to.equal(messagesItem.included_usage - 50); }); }); diff --git a/server/tests/advanced/rollovers/rollover2.ts b/server/tests/advanced/rollovers/rollover2.ts index 1d58a2d15..f02cd2100 100644 --- a/server/tests/advanced/rollovers/rollover2.ts +++ b/server/tests/advanced/rollovers/rollover2.ts @@ -26,32 +26,28 @@ import { getMainCusProduct } from "@/internal/customers/cusProducts/cusProductUt import { cusProductToCusEnt } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js"; import { timeout } from "@/utils/genUtils.js"; import { resetCustomerEntitlement } from "@/cron/cronUtils.js"; +import { resetAndGetCusEnt } from "./rolloverTestUtils.js"; -let rolloverConfig = { max: 100, length: 1, duration: RolloverDuration.Month }; -const messagesItem = constructFeatureItem({ +let rolloverConfig = { max: 500, length: 1, duration: RolloverDuration.Month }; + +const msgesItem = constructFeatureItem({ featureId: TestFeature.Messages, includedUsage: 400, interval: ProductItemInterval.Month, rolloverConfig, + entityFeatureId: TestFeature.Users, }) as LimitedItem; -const perUserItem = constructFeatureItem({ - featureId: TestFeature.Credits, - includedUsage: 400, - interval: ProductItemInterval.Month, - rolloverConfig, - entityFeatureId: TestFeature.Users, +export let free = constructProduct({ + items: [msgesItem], + type: "free", + isDefault: false, }); -export let pro = constructProduct({ - items: [messagesItem, perUserItem], - type: "pro", -}); - -const testCase = "rollover1"; +const testCase = "rollover2"; // , per entity and regular -describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item`)}`, () => { +describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item (per entity)`)}`, () => { let customerId = testCase; let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); let testClockId: string; @@ -71,13 +67,13 @@ describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item` stripeCli = this.stripeCli; addPrefixToProducts({ - products: [pro], + products: [free], prefix: testCase, }); await createProducts({ autumn, - products: [pro], + products: [free], customerId, db, orgId: org.id, @@ -97,7 +93,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item` customer = res.customer; }); - const entities = [ + const entities: any[] = [ { id: "1", name: "Entity 1", @@ -113,117 +109,119 @@ describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item` it("should attach pro product", async function () { await autumn.attach({ customer_id: customerId, - product_id: pro.id, + product_id: free.id, }); await autumn.entities.create(customerId, entities); }); - let messageUsage = 250; + let entity1Id = entities[0].id; + let entity2Id = entities[1].id; + let newEntity1Balance = 300; + let newEntity2Balance = 200; + let includedUsage = msgesItem.included_usage; + let usages = [ + { + entityId: entity1Id, + usage: includedUsage - newEntity1Balance, + rollover: newEntity1Balance, + }, + { + entityId: entity2Id, + usage: includedUsage - newEntity2Balance, + rollover: newEntity2Balance, + }, + ]; it("should create track messages, reset, and have correct rollover", async function () { - await autumn.track({ - customer_id: customerId, - feature_id: TestFeature.Messages, - value: messageUsage, - }); + for (const usage of usages) { + await autumn.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: usage.usage, + entity_id: usage.entityId, + }); + } await timeout(3000); // Run reset cusEnt on ... - let mainCusProduct = await getMainCusProduct({ + await resetAndGetCusEnt({ db, - internalCustomerId: customer.internal_id, - productGroup: pro.group, - }); - - let msgesCusEnt = cusProductToCusEnt({ - cusProduct: mainCusProduct!, + customer, + productGroup: free.group, featureId: TestFeature.Messages, }); - await resetCustomerEntitlement({ - db, - cusEnt: msgesCusEnt!, - }); + for (const usage of usages) { + let entity = await autumn.entities.get(customerId, usage.entityId); + let msgesFeature = entity.features[TestFeature.Messages]; + let expectedRollover = Math.min(usage.rollover, rolloverConfig.max); - mainCusProduct = await getMainCusProduct({ - db, - internalCustomerId: customer.internal_id, - productGroup: pro.group, - }); - - msgesCusEnt = cusProductToCusEnt({ - cusProduct: mainCusProduct!, - featureId: TestFeature.Messages, - }); - - let rollover = messagesItem.included_usage - messageUsage; - - expect(msgesCusEnt?.rollovers.length).to.equal(1); - expect(msgesCusEnt?.rollovers[0].balance).to.equal( - Math.min(rollover, rolloverConfig.max) - ); + expect(msgesFeature.rollovers.length).to.equal(1); + expect(msgesFeature.balance).to.equal(includedUsage + expectedRollover); + expect(msgesFeature.rollovers[0].balance).to.equal(expectedRollover); + } }); - let perUserUsage = { - [entities[0].id]: 350, - [entities[1].id]: 200, - }; + it("should reset again and have correct rollovers", async function () { + await resetAndGetCusEnt({ + db, + customer, + productGroup: free.group, + featureId: TestFeature.Messages, + }); - it("should track per user credits, reset, and have correct rollover", async function () { - for (let entityId in perUserUsage) { + let entity1 = await autumn.entities.get(customerId, entity1Id); + let entity1Msges = entity1.features[TestFeature.Messages]; + // 400, 300 -> 400, 100 (max is 500) + let rollovers = entity1Msges.rollovers; + expect(rollovers[0].balance).to.equal(100); + expect(rollovers[1].balance).to.equal(400); + + let entity2 = await autumn.entities.get(customerId, entity2Id); + let entity2Msges = entity2.features[TestFeature.Messages]; + // 400, 200 -> 400, 0 (max is 500) + let rollovers2 = entity2Msges.rollovers; + expect(rollovers2[0].balance).to.equal(100); + expect(rollovers2[1].balance).to.equal(400); + }); + + it("should track and deduct from oldest rollovers first", async function () { + for (const entity of entities) { await autumn.track({ customer_id: customerId, - feature_id: TestFeature.Credits, - value: perUserUsage[entityId], - entity_id: entityId, + feature_id: TestFeature.Messages, + value: 150, + entity_id: entity.id, }); + + await timeout(2000); + let entRes = await autumn.entities.get(customerId, entity.id); + let msgesFeature = entRes.features[TestFeature.Messages]; + let rollovers = msgesFeature.rollovers; + expect(rollovers[0].balance).to.equal(0); + expect(rollovers[1].balance).to.equal(350); + expect(msgesFeature.balance).to.equal(includedUsage + 350); } + }); - await timeout(2000); + it("should track past rollovers and deduct from original balance", async function () { + for (const entity of entities) { + await autumn.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 400, + entity_id: entity.id, + }); + await timeout(2000); - let mainCusProduct = await getMainCusProduct({ - db, - internalCustomerId: customer.internal_id, - productGroup: pro.group, - }); - - let perUserCusEnt = cusProductToCusEnt({ - cusProduct: mainCusProduct!, - featureId: TestFeature.Credits, - }); - - await resetCustomerEntitlement({ - db, - cusEnt: perUserCusEnt!, - }); - - mainCusProduct = await getMainCusProduct({ - db, - internalCustomerId: customer.internal_id, - productGroup: pro.group, - }); - - perUserCusEnt = cusProductToCusEnt({ - cusProduct: mainCusProduct!, - featureId: TestFeature.Credits, - }); - - let perUserRollover = perUserCusEnt?.rollovers[0]; - expect(perUserRollover).to.exist; - for (let entityId in perUserUsage) { - let entityRollover = perUserRollover?.entities[entityId]; - - let expectedRollover = Math.min( - entityRollover!.balance, - rolloverConfig.max - ); - - expect(entityRollover).to.exist; - expect(entityRollover?.balance).to.equal(expectedRollover); + let entRes = await autumn.entities.get(customerId, entity.id); + let msgesFeature = entRes.features[TestFeature.Messages]; + let rollovers = msgesFeature.rollovers; + expect(rollovers[0].balance).to.equal(0); + expect(rollovers[1].balance).to.equal(0); + expect(msgesFeature.balance).to.equal(includedUsage - 50); } - - await timeout(3000); }); }); diff --git a/server/tests/advanced/rollovers/rollover3.ts b/server/tests/advanced/rollovers/rollover3.ts new file mode 100644 index 000000000..b4a34c996 --- /dev/null +++ b/server/tests/advanced/rollovers/rollover3.ts @@ -0,0 +1,126 @@ +import chalk from "chalk"; +import Stripe from "stripe"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; + +import { + APIVersion, + AppEnv, + Customer, + LimitedItem, + Organization, + RolloverDuration, +} from "@autumn/shared"; + +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { setupBefore } from "tests/before.js"; +import { createProducts } from "tests/utils/productUtils.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { addPrefixToProducts } from "tests/attach/utils.js"; + +import { expect } from "chai"; +import { timeout } from "@/utils/genUtils.js"; +import { advanceTestClock } from "@/utils/scriptUtils/testClockUtils.js"; +import { addMonths } from "date-fns"; + +let rolloverConfig = { max: 500, length: 1, duration: RolloverDuration.Month }; +const messagesItem = constructArrearProratedItem({ + featureId: TestFeature.Messages, + includedUsage: 400, + rolloverConfig, +}) as LimitedItem; + +export let pro = constructProduct({ + items: [messagesItem], + type: "pro", + isDefault: false, +}); + +const testCase = "rollover3"; + +describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for usage price feature`)}`, () => { + let customerId = testCase; + let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); + let testClockId: string; + let db: DrizzleCli, org: Organization, env: AppEnv; + let customer: Customer; + let stripeCli: Stripe; + + let curUnix = new Date().getTime(); + + before(async function () { + await setupBefore(this); + const { autumnJs } = this; + db = this.db; + org = this.org; + env = this.env; + + stripeCli = this.stripeCli; + + addPrefixToProducts({ + products: [pro], + prefix: testCase, + }); + + await createProducts({ + autumn, + products: [pro], + customerId, + db, + orgId: org.id, + env, + }); + + const res = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + testClockId = res.testClockId!; + customer = res.customer; + }); + + it("should attach pro product", async function () { + await autumn.attach({ + customer_id: customerId, + product_id: pro.id, + }); + }); + + let rollover = 250; + let curBalance = messagesItem.included_usage; + + it("should create track messages, reset, and have correct rollover", async function () { + await autumn.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: messagesItem.included_usage - rollover, + }); + + await timeout(3000); + + await advanceTestClock({ + stripeCli, + testClockId, + advanceTo: addMonths(new Date(), 1).getTime(), + waitForSeconds: 20, + }); + + let cus = await autumn.customers.get(customerId); + let msgesFeature = cus.features[TestFeature.Messages]; + + let expectedBalance = messagesItem.included_usage + rollover; + + expect(msgesFeature).to.exist; + expect(msgesFeature?.balance).to.equal(expectedBalance); + // @ts-ignore + expect(msgesFeature?.rollovers[0].balance).to.equal(rollover); + curBalance = expectedBalance; + }); +}); diff --git a/server/tests/advanced/rollovers/rollover4.ts b/server/tests/advanced/rollovers/rollover4.ts new file mode 100644 index 000000000..7e191a047 --- /dev/null +++ b/server/tests/advanced/rollovers/rollover4.ts @@ -0,0 +1,157 @@ +import chalk from "chalk"; +import Stripe from "stripe"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; + +import { + APIVersion, + AppEnv, + Customer, + LimitedItem, + Organization, + RolloverDuration, +} from "@autumn/shared"; + +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { setupBefore } from "tests/before.js"; +import { createProducts } from "tests/utils/productUtils.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { constructPrepaidItem } from "@/utils/scriptUtils/constructItem.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { addPrefixToProducts } from "tests/attach/utils.js"; + +import { expect } from "chai"; +import { timeout } from "@/utils/genUtils.js"; +import { advanceTestClock } from "@/utils/scriptUtils/testClockUtils.js"; +import { addMonths } from "date-fns"; +import { resetAndGetCusEnt } from "./rolloverTestUtils.js"; + +let rolloverConfig = { max: 400, length: 1, duration: RolloverDuration.Month }; +const messagesItem = constructPrepaidItem({ + featureId: TestFeature.Messages, + includedUsage: 100, + billingUnits: 300, + price: 10, + rolloverConfig, +}) as LimitedItem; + +export let pro = constructProduct({ + items: [messagesItem], + type: "pro", + isDefault: false, +}); + +const testCase = "rollover4"; + +describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for usage price feature`)}`, () => { + let customerId = testCase; + let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); + let testClockId: string; + let db: DrizzleCli, org: Organization, env: AppEnv; + let customer: Customer; + let stripeCli: Stripe; + + let curUnix = new Date().getTime(); + + before(async function () { + await setupBefore(this); + const { autumnJs } = this; + db = this.db; + org = this.org; + env = this.env; + + stripeCli = this.stripeCli; + + addPrefixToProducts({ + products: [pro], + prefix: testCase, + }); + + await createProducts({ + autumn, + products: [pro], + customerId, + db, + orgId: org.id, + env, + }); + + const res = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + testClockId = res.testClockId!; + customer = res.customer; + }); + + let paidQuantity = 300; + let balance = paidQuantity + messagesItem.included_usage; + const options = [ + { + feature_id: TestFeature.Messages, + quantity: paidQuantity, + }, + ]; + + it("should attach pro product", async function () { + await autumn.attach({ + customer_id: customerId, + product_id: pro.id, + options, + }); + }); + + let rollover = 50; + it("should create track messages, reset, and have correct rollover", async function () { + await autumn.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: balance - rollover, + }); + + await timeout(3000); + + curUnix = await advanceTestClock({ + stripeCli, + testClockId, + advanceTo: addMonths(new Date(), 1).getTime(), + waitForSeconds: 20, + }); + + let cus = await autumn.customers.get(customerId); + let msgesFeature = cus.features[TestFeature.Messages]; + + // @ts-ignore + let rollovers = msgesFeature?.rollovers; + + expect(msgesFeature).to.exist; + expect(msgesFeature?.balance).to.equal(balance + rollover); + expect(rollovers[0].balance).to.equal(rollover); + }); + + // let usage2 = 50; + it("should reset again and have correct rollover", async function () { + await advanceTestClock({ + stripeCli, + testClockId, + advanceTo: addMonths(curUnix, 1).getTime(), + waitForSeconds: 20, + }); + + let newRollover = Math.min(balance + rollover, rolloverConfig.max); + let cus = await autumn.customers.get(customerId); + let msgesFeature = cus.features[TestFeature.Messages]; + // @ts-ignore + let rollovers = msgesFeature?.rollovers; + + expect(msgesFeature).to.exist; + expect(msgesFeature?.balance).to.equal(balance + newRollover); + expect(rollovers[0].balance).to.equal(0); + expect(rollovers[1].balance).to.equal(400); + }); +}); diff --git a/server/tests/advanced/rollovers/rollover5.ts b/server/tests/advanced/rollovers/rollover5.ts new file mode 100644 index 000000000..54162e863 --- /dev/null +++ b/server/tests/advanced/rollovers/rollover5.ts @@ -0,0 +1,140 @@ +import chalk from "chalk"; +import Stripe from "stripe"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; + +import { + APIVersion, + AppEnv, + Customer, + LimitedItem, + Organization, + RolloverDuration, +} from "@autumn/shared"; + +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { setupBefore } from "tests/before.js"; +import { createProducts } from "tests/utils/productUtils.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { addPrefixToProducts } from "tests/attach/utils.js"; + +import { expect } from "chai"; +import { resetAndGetCusEnt } from "./rolloverTestUtils.js"; + +let freeRollover = { max: 1000, length: 1, duration: RolloverDuration.Month }; +let proRollover = { max: 600, length: 1, duration: RolloverDuration.Month }; + +const freeMsges = constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 500, + rolloverConfig: freeRollover, +}) as LimitedItem; + +const proMsges = constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 500, + rolloverConfig: proRollover, +}) as LimitedItem; + +const free = constructProduct({ + items: [freeMsges], + type: "free", + isDefault: false, +}); + +const pro = constructProduct({ + items: [proMsges], + type: "pro", +}); + +const testCase = "rollover5"; + +describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for upgrade`)}`, () => { + let customerId = testCase; + let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); + let testClockId: string; + let db: DrizzleCli, org: Organization, env: AppEnv; + let customer: Customer; + let stripeCli: Stripe; + let curUnix = new Date().getTime(); + + before(async function () { + await setupBefore(this); + const { autumnJs } = this; + db = this.db; + org = this.org; + env = this.env; + + stripeCli = this.stripeCli; + + addPrefixToProducts({ + products: [free, pro], + prefix: testCase, + }); + + await createProducts({ + autumn, + products: [free, pro], + customerId, + db, + orgId: org.id, + env, + }); + + const res = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + testClockId = res.testClockId!; + customer = res.customer; + }); + + it("should attach free product", async function () { + await autumn.attach({ + customer_id: customerId, + product_id: free.id, + }); + }); + + it("should create rollovers", async function () { + await resetAndGetCusEnt({ + customer, + db, + productGroup: testCase, + featureId: TestFeature.Messages, + }); + await resetAndGetCusEnt({ + customer, + db, + productGroup: testCase, + featureId: TestFeature.Messages, + }); + + // Attach pro + await autumn.attach({ + customer_id: customerId, + product_id: pro.id, + }); + + let cus = await autumn.customers.get(customerId); + let msgesFeature = cus.features[TestFeature.Messages]; + let freeRolloverBalance = freeMsges.included_usage * 2; + let proRolloverBalance = Math.min(proRollover.max, freeRolloverBalance); + + expect(msgesFeature).to.exist; + expect(msgesFeature?.balance).to.equal( + proMsges.included_usage + proRolloverBalance + ); + // @ts-ignore + let rollovers = msgesFeature?.rollovers; + expect(rollovers[0].balance).to.equal(100); + expect(rollovers[1].balance).to.equal(500); + }); +}); diff --git a/server/tests/advanced/rollovers/rollover6.ts b/server/tests/advanced/rollovers/rollover6.ts new file mode 100644 index 000000000..e06038b2c --- /dev/null +++ b/server/tests/advanced/rollovers/rollover6.ts @@ -0,0 +1,140 @@ +import chalk from "chalk"; +import Stripe from "stripe"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; + +import { + APIVersion, + AppEnv, + Customer, + LimitedItem, + Organization, + RolloverDuration, +} from "@autumn/shared"; + +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { setupBefore } from "tests/before.js"; +import { createProducts } from "tests/utils/productUtils.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { addPrefixToProducts } from "tests/attach/utils.js"; + +import { expect } from "chai"; +import { resetAndGetCusEnt } from "./rolloverTestUtils.js"; + +let freeRollover = { max: 1000, length: 1, duration: RolloverDuration.Month }; +let proRollover = { max: 600, length: 1, duration: RolloverDuration.Month }; + +const freeMsges = constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 500, + rolloverConfig: freeRollover, +}) as LimitedItem; + +const proMsges = constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 500, + rolloverConfig: proRollover, +}) as LimitedItem; + +const free = constructProduct({ + items: [freeMsges], + type: "free", + isDefault: false, +}); + +const pro = constructProduct({ + items: [proMsges], + type: "pro", +}); + +const testCase = "rollover6"; + +describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for upgrade`)}`, () => { + let customerId = testCase; + let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); + let testClockId: string; + let db: DrizzleCli, org: Organization, env: AppEnv; + let customer: Customer; + let stripeCli: Stripe; + let curUnix = new Date().getTime(); + + before(async function () { + await setupBefore(this); + const { autumnJs } = this; + db = this.db; + org = this.org; + env = this.env; + + stripeCli = this.stripeCli; + + addPrefixToProducts({ + products: [free, pro], + prefix: testCase, + }); + + await createProducts({ + autumn, + products: [free, pro], + customerId, + db, + orgId: org.id, + env, + }); + + const res = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + testClockId = res.testClockId!; + customer = res.customer; + }); + + it("should attach free product", async function () { + await autumn.attach({ + customer_id: customerId, + product_id: pro.id, + }); + }); + + it("should create rollovers", async function () { + await resetAndGetCusEnt({ + customer, + db, + productGroup: testCase, + featureId: TestFeature.Messages, + }); + await resetAndGetCusEnt({ + customer, + db, + productGroup: testCase, + featureId: TestFeature.Messages, + }); + + // Attach pro + await autumn.attach({ + customer_id: customerId, + product_id: pro.id, + }); + + let cus = await autumn.customers.get(customerId); + let msgesFeature = cus.features[TestFeature.Messages]; + let freeRolloverBalance = freeMsges.included_usage * 2; + let proRolloverBalance = Math.min(proRollover.max, freeRolloverBalance); + + expect(msgesFeature).to.exist; + expect(msgesFeature?.balance).to.equal( + proMsges.included_usage + proRolloverBalance + ); + // @ts-ignore + let rollovers = msgesFeature?.rollovers; + expect(rollovers[0].balance).to.equal(100); + expect(rollovers[1].balance).to.equal(500); + }); +}); diff --git a/vite/src/views/customers/customer/entitlements/CustomerEntitlementsList.tsx b/vite/src/views/customers/customer/entitlements/CustomerEntitlementsList.tsx index 1274d3637..fbd337d66 100644 --- a/vite/src/views/customers/customer/entitlements/CustomerEntitlementsList.tsx +++ b/vite/src/views/customers/customer/entitlements/CustomerEntitlementsList.tsx @@ -135,9 +135,12 @@ export const CustomerEntitlementsList = () => { value: cusEnt.rollovers .map((r: any) => { if (Object.values(r.entities).length > 0) { - return Object.values(r.entities) - .map((e: any) => `${e.balance} (${e.id})`) - .join(", "); + return ( + Object.values(r.entities) + .map((e: any) => `${e.balance} (${e.id})`) + .join(", ") + + ` (expires: ${r.expires_at ? formatUnixToDate(r.expires_at) : "N/A"})` + ); } else { return `${r.balance} (ex: ${r.expires_at ? formatUnixToDate(r.expires_at) : "N/A"})`; } diff --git a/vite/src/views/customers/customer/entitlements/UpdateCusEntitlement.tsx b/vite/src/views/customers/customer/entitlements/UpdateCusEntitlement.tsx index 6edc3b63c..7aafb5f7c 100644 --- a/vite/src/views/customers/customer/entitlements/UpdateCusEntitlement.tsx +++ b/vite/src/views/customers/customer/entitlements/UpdateCusEntitlement.tsx @@ -34,7 +34,7 @@ function UpdateCusEntitlement({ const [updateLoading, setUpdateLoading] = useState(false); - let cusEnt = selectedCusEntitlement; + const cusEnt = selectedCusEntitlement; const [updateFields, setUpdateFields] = useState({ balance: diff --git a/vite/src/views/products/product/product-item/ProductItemConfig.tsx b/vite/src/views/products/product/product-item/ProductItemConfig.tsx index c86894644..27b4d8ceb 100644 --- a/vite/src/views/products/product/product-item/ProductItemConfig.tsx +++ b/vite/src/views/products/product/product-item/ProductItemConfig.tsx @@ -92,7 +92,7 @@ export const ProductItemConfig = () => { "flex flex-col gap-6 w-md transition-all ease-in-out duration-300 !overflow-visible", //modal animations isPriceItem(item) && "w-xs", isFeaturePriceItem(item) && "w-md", - isFeaturePriceItem(item) && item.tiers?.length > 1 && "w-md", + isFeaturePriceItem(item) && item.tiers?.length > 1 && "w-md" )} > {isPrice ? ( diff --git a/vite/src/views/products/product/product-item/UpdateProductItem.tsx b/vite/src/views/products/product/product-item/UpdateProductItem.tsx index ebbc02854..5a13461b0 100644 --- a/vite/src/views/products/product/product-item/UpdateProductItem.tsx +++ b/vite/src/views/products/product/product-item/UpdateProductItem.tsx @@ -27,8 +27,6 @@ export default function UpdateProductItem({ const { product, setProduct, features } = useProductContext(); const [showCreateFeature, setShowCreateFeature] = useState(false); - - const handleUpdateProductItem = () => { console.log("Selected Item: ", selectedItem); const validatedItem = validateProductItem({ @@ -85,4 +83,4 @@ export default function UpdateProductItem({ ); -}; +} 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 d99dc761a..2d306a126 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 @@ -10,22 +10,9 @@ import { getFeatureCreditSystem, getFeatureUsageType, } from "@/utils/product/entitlementUtils"; -import { - FeatureUsageType, - Infinite, - ProductItem, - RolloverDuration, - RolloverConfig, -} from "@autumn/shared"; +import { FeatureUsageType } 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"; import { notNullish } from "@/utils/genUtils"; @@ -61,7 +48,7 @@ export const AdvancedItemConfig = () => {
From 8ca041cc701617ba6a52b7dcfed1e29eda8b55cd Mon Sep 17 00:00:00 2001 From: John Yeo Date: Fri, 25 Jul 2025 20:01:15 +0100 Subject: [PATCH 44/45] fix: update product_id in rollover test to use free product --- server/tests/advanced/rollovers/rollover6.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/tests/advanced/rollovers/rollover6.ts b/server/tests/advanced/rollovers/rollover6.ts index e06038b2c..7b054f623 100644 --- a/server/tests/advanced/rollovers/rollover6.ts +++ b/server/tests/advanced/rollovers/rollover6.ts @@ -120,7 +120,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for upgrade`)}`, // Attach pro await autumn.attach({ customer_id: customerId, - product_id: pro.id, + product_id: free.id, }); let cus = await autumn.customers.get(customerId); From 7f430c531c6fd75de091d372d3b443713674af02 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Fri, 25 Jul 2025 20:23:15 +0100 Subject: [PATCH 45/45] testing rollovers --- server/shell/g4.sh | 3 ++- server/src/cron/cronUtils.ts | 16 +++++++++++++ .../cusEnts/cusRollovers/RolloverService.ts | 17 ------------- server/tests/advanced/rollovers/rollover6.ts | 24 +++++++++++++++---- 4 files changed, 37 insertions(+), 23 deletions(-) diff --git a/server/shell/g4.sh b/server/shell/g4.sh index b5a362206..647b4702e 100755 --- a/server/shell/g4.sh +++ b/server/shell/g4.sh @@ -11,7 +11,8 @@ fi $MOCHA_CMD 'tests/advanced/multiFeature/*.ts' \ 'tests/advanced/coupons/*.ts' \ 'tests/attach/updateQuantity/*.ts' \ - 'tests/advanced/referrals/*.ts' + 'tests/advanced/referrals/*.ts' \ + 'tests/advanced/rollovers/*.ts' $MOCHA_CMD 'tests/attach/multiProduct/*.ts' \ 'tests/advanced/usageLimit/*.ts' diff --git a/server/src/cron/cronUtils.ts b/server/src/cron/cronUtils.ts index b8a6a2962..b2e00dcc1 100644 --- a/server/src/cron/cronUtils.ts +++ b/server/src/cron/cronUtils.ts @@ -27,6 +27,8 @@ import { notNullish } from "../utils/genUtils.js"; import { CusPriceService } from "../internal/customers/cusProducts/cusPrices/CusPriceService.js"; import { RolloverService } from "../internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.js"; +import { CusService } from "@/internal/customers/CusService.js"; +import { refreshCusCache } from "@/internal/customers/cusCache/updateCachedCus.js"; const checkSubAnchor = async ({ db, @@ -236,6 +238,20 @@ export const resetCustomerEntitlement = async ({ format(new UTCDate(nextResetAt), "dd MMM yyyy HH:mm:ss") )}` ); + + let customer = await CusService.getByInternalId({ + db, + internalId: cusEnt.internal_customer_id, + }); + + if (customer) { + await refreshCusCache({ + db, + customerId: customer.id!, + orgId: customer.org_id, + env: customer.env, + }); + } } catch (error: any) { console.log( `Failed to reset ${cusEnt.id} | ${cusEnt.customer_id} | ${cusEnt.feature_id}, error: ${error}` diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts index 704b4d15a..573dc1eb6 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts @@ -93,34 +93,17 @@ export class RolloverService { }) { if (rows.length === 0) return {}; - // console.log("inserting rollovers", rows); - await db .insert(rollovers) .values(rows as any) .returning(); - // const currentRolloverRows = await db - // .select() - // .from(rollovers) - // .where( - // and( - // eq(rollovers.cus_ent_id, cusEntID), - // gte(rollovers.expires_at, new Date().getTime()) - // ) - // ); let curRollovers = [...fullCusEnt.rollovers, ...rows]; - console.log(`Cur rollovers:`, curRollovers); let { toDelete, toUpdate } = performMaximumClearing({ rows: curRollovers as Rollover[], - // rolloverConfig, cusEnt: fullCusEnt, - // cusEntID, - // entityMode, }); - // console.log(`To update:`, toUpdate); - // console.log(`To delete:`, toDelete); if (toDelete.length > 0) { await RolloverService.delete({ db, ids: toDelete }); diff --git a/server/tests/advanced/rollovers/rollover6.ts b/server/tests/advanced/rollovers/rollover6.ts index 7b054f623..c9dc16ffa 100644 --- a/server/tests/advanced/rollovers/rollover6.ts +++ b/server/tests/advanced/rollovers/rollover6.ts @@ -22,9 +22,12 @@ import { addPrefixToProducts } from "tests/attach/utils.js"; import { expect } from "chai"; import { resetAndGetCusEnt } from "./rolloverTestUtils.js"; +import { advanceTestClock } from "@/utils/scriptUtils/testClockUtils.js"; +import { addHours, addMonths } from "date-fns"; +import { hoursToFinalizeInvoice } from "tests/utils/constants.js"; -let freeRollover = { max: 1000, length: 1, duration: RolloverDuration.Month }; -let proRollover = { max: 600, length: 1, duration: RolloverDuration.Month }; +let freeRollover = { max: 600, length: 1, duration: RolloverDuration.Month }; +let proRollover = { max: 1000, length: 1, duration: RolloverDuration.Month }; const freeMsges = constructFeatureItem({ featureId: TestFeature.Messages, @@ -123,15 +126,26 @@ describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for upgrade`)}`, product_id: free.id, }); + await advanceTestClock({ + stripeCli, + testClockId, + advanceTo: addHours( + addMonths(curUnix, 1), + hoursToFinalizeInvoice + ).getTime(), + waitForSeconds: 20, + }); + let cus = await autumn.customers.get(customerId); let msgesFeature = cus.features[TestFeature.Messages]; - let freeRolloverBalance = freeMsges.included_usage * 2; - let proRolloverBalance = Math.min(proRollover.max, freeRolloverBalance); + let proRolloverBalance = proMsges.included_usage * 2; + let freeRolloverBalance = Math.min(freeRollover.max, proRolloverBalance); expect(msgesFeature).to.exist; expect(msgesFeature?.balance).to.equal( - proMsges.included_usage + proRolloverBalance + freeMsges.included_usage + freeRolloverBalance ); + // @ts-ignore let rollovers = msgesFeature?.rollovers; expect(rollovers[0].balance).to.equal(100);