diff --git a/package.json b/package.json index 1059177c8..7d7b18216 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "vite:start:bun": "bun -F @autumn/shared build && bun -F @autumn/vite start:bun", - "dev:bun": "concurrently \"cd server && bun run dev:bun\" \"cd vite && bun run dev:bun\"", + "dev:bun": "concurrently \"cd server && bun run dev\" \"cd vite && bun run dev:bun\" \"bun -F @autumn/shared dev:bun\"", "build:all:bun": "bun run -F @autumn/shared build:bun && bun run -F @autumn/server prod:build:bun && bun run -F @autumn/vite build:bun" }, "dependencies": { diff --git a/server/package.json b/server/package.json index 6d9c81a9b..f9564755b 100644 --- a/server/package.json +++ b/server/package.json @@ -7,7 +7,7 @@ "scripts": { "email": "email dev -p 3001", "start": "bun src/index.ts", - "dev": "NODE_ENV=development bun --watch src/index.ts --ignore scripts --ignore tests", + "dev": "NODE_ENV=development bunx --bun nodemon --exec bun src/index.ts --ignore scripts --ignore tests", "workers": "bun src/workers.ts", "workers:dev": "bun --watch src/workers.ts", "cron": "bun src/cron.ts", diff --git a/server/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.ts b/server/src/cron.ts index 5b386713b..71be81d6f 100644 --- a/server/src/cron.ts +++ b/server/src/cron.ts @@ -1,236 +1,14 @@ -import { - AllowanceType, - AppEnv, - EntInterval, - FullCusEntWithProduct, - Organization, -} 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 { 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 { CusService } from "./internal/customers/CusService.js"; -import { refreshCusCache } from "./internal/customers/cusCache/updateCachedCus.js"; +import { initDrizzle } from "./db/initDrizzle.js"; +import { resetCustomerEntitlement } from "./cron/cronUtils.js"; dotenv.config(); -const checkSubAnchor = async ({ - db, - cusEnt, - nextResetAt, -}: { - db: DrizzleCli; - cusEnt: FullCusEntWithProduct; - nextResetAt: number; -}) => { - 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); - - const shouldCheck = - nextResetAtDay === 30 || (nextResetAtDay === 28 && nextResetAtMonth === 2); - - if (!shouldCheck) { - return nextResetAt; - } - - // 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; - - 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 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); - - if (billingCycleDay > nextResetDay) { - nextResetAtDate = setDate(nextResetAtDate, billingCycleDay); - return nextResetAtDate.getTime(); - } else { - return nextResetAt; - } -}; - -const resetCustomerEntitlement = async ({ - db, - cusEnt, -}: { - db: DrizzleCli; - cusEnt: FullCusEntWithProduct; -}) => { - try { - if (cusEnt.usage_allowed) { - return; - } - - // 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; - } - - const entOptions = getEntOptions( - cusEnt.customer_product.options, - cusEnt.entitlement - ); - - 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, - }, - }); - - 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, - }, - }); - - 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 resetBalanceUpdate = getResetBalancesUpdate({ - cusEnt, - allowance: resetBalance || undefined, - }); - - 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, - }, - }); - - 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") - )}` - ); - - 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}` - ); - } -}; - export const cronTask = async () => { console.log( "\n----------------------------------\nRUNNING RESET CRON:", diff --git a/server/src/cron/cronUtils.ts b/server/src/cron/cronUtils.ts new file mode 100644 index 000000000..b2e00dcc1 --- /dev/null +++ b/server/src/cron/cronUtils.ts @@ -0,0 +1,260 @@ +import { + AllowanceType, + AppEnv, + EntInterval, + FullCusEntWithProduct, + Organization, + RolloverConfig, +} from "@autumn/shared"; +import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js"; + +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 { + 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 { CusService } from "@/internal/customers/CusService.js"; +import { refreshCusCache } from "@/internal/customers/cusCache/updateCachedCus.js"; + +const checkSubAnchor = async ({ + db, + cusEnt, + nextResetAt, +}: { + db: DrizzleCli; + cusEnt: FullCusEntWithProduct; + nextResetAt: number; +}) => { + 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); + + const shouldCheck = + nextResetAtDay === 30 || (nextResetAtDay === 28 && nextResetAtMonth === 2); + + if (!shouldCheck) { + return nextResetAt; + } + + // 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; + + 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 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); + + if (billingCycleDay > nextResetDay) { + nextResetAtDate = setDate(nextResetAtDate, billingCycleDay); + return nextResetAtDate.getTime(); + } else { + return nextResetAt; + } +}; + +export const resetCustomerEntitlement = async ({ + db, + cusEnt, +}: { + db: DrizzleCli; + cusEnt: FullCusEntWithProduct; +}) => { + try { + if (cusEnt.usage_allowed) { + return; + } + + // 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; + } + + const entOptions = getEntOptions( + cusEnt.customer_product.options, + cusEnt.entitlement + ); + + 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, + }, + }); + + 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, + }, + }); + + 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 resetBalanceUpdate = getResetBalancesUpdate({ + cusEnt, + allowance: resetBalance || undefined, + }); + + try { + nextResetAt = await checkSubAnchor({ + db, + cusEnt, + nextResetAt, + }); + } catch (error) { + console.log("WARNING: Failed to check sub anchor"); + console.log(error); + } + + 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, + }, + }); + + if (rolloverUpdate?.toInsert && rolloverUpdate.toInsert.length > 0) { + await RolloverService.insert({ + db, + rows: rolloverUpdate.toInsert, + fullCusEnt: cusEnt, + // rolloverConfig: cusEnt.entitlement.rollover as RolloverConfig, + // cusEntID: cusEnt.id, + // entityMode: notNullish(cusEnt.entitlement.entity_feature_id), + }); + } + + 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") + )}` + ); + + 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/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, }, }); } diff --git a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handlePrepaidPrices.ts b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handlePrepaidPrices.ts index f24412676..10c71deca 100644 --- a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handlePrepaidPrices.ts +++ b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handlePrepaidPrices.ts @@ -1,6 +1,9 @@ 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 { 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"; @@ -11,6 +14,8 @@ import { FeatureOptions, FullCusProduct, FullCustomerPrice, + UsagePriceConfig, + RolloverConfig, } from "@autumn/shared"; import Stripe from "stripe"; @@ -45,26 +50,52 @@ 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 + (cusEnt.entitlement.allowance || 0); + + const resetUpdate = getResetBalancesUpdate({ + cusEnt, + allowance: newAllowance, }); 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) { @@ -100,16 +131,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})` + // ); + + if (rolloverUpdate?.toInsert && rolloverUpdate.toInsert.length > 0) { + await RolloverService.insert({ + db, + rows: rolloverUpdate.toInsert, + fullCusEnt: cusEnt, + // rolloverConfig: ent.rollover as RolloverConfig, + // cusEntID: cusEnt.id, + // entityMode: notNullish(ent.entity_feature_id), + }); + } + + // 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/external/stripe/webhookHandlers/handleInvoiceCreated/handleUsagePrices.ts b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleUsagePrices.ts index 559e09544..40701d969 100644 --- a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleUsagePrices.ts +++ b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleUsagePrices.ts @@ -10,12 +10,16 @@ import { EntInterval, Customer, APIVersion, + RolloverConfig, } from "@autumn/shared"; import { differenceInMinutes, subDays } from "date-fns"; 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"; +import { notNullish } from "@/utils/genUtils.js"; export const handleUsagePrices = async ({ db, @@ -42,8 +46,8 @@ export const handleUsagePrices = async ({ Math.abs( differenceInMinutes( new Date(activeProduct.created_at), - new Date(invoice.created * 1000), - ), + new Date(invoice.created * 1000) + ) ) < 10; let invoiceFromUpgrade = invoice.billing_reason == "subscription_update"; @@ -93,7 +97,7 @@ export const handleUsagePrices = async ({ } else { if (!config.stripe_meter_id) { logger.warn( - `Price ${price.id} has no stripe meter id, skipping invoice.created for usage in arrear`, + `Price ${price.id} has no stripe meter id, skipping invoice.created for usage in arrear` ); return; } @@ -105,7 +109,7 @@ export const handleUsagePrices = async ({ }); const usageTimestamp = Math.round( - subDays(new Date(invoice.created * 1000), 1).getTime() / 1000, + subDays(new Date(invoice.created * 1000), 1).getTime() / 1000 ); await submitUsageToStripe({ @@ -124,6 +128,7 @@ export const handleUsagePrices = async ({ } let ent = relatedCusEnt.entitlement; + let resetBalancesUpdate = getResetBalancesUpdate({ cusEnt: relatedCusEnt, allowance: ent.interval == EntInterval.Lifetime ? 0 : ent.allowance!, @@ -141,5 +146,21 @@ 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, + rows: rolloverUpdate.toInsert, + fullCusEnt: relatedCusEnt, + // rolloverConfig: ent.rollover as RolloverConfig, + // cusEntID: ent.id, + // entityMode: notNullish(ent.entity_feature_id), + }); + } + logger.info("βœ… Successfully reset balance"); }; 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/add-product/createFullCusProduct.ts b/server/src/internal/customers/add-product/createFullCusProduct.ts index 6790a8f3f..40f2cd434 100644 --- a/server/src/internal/customers/add-product/createFullCusProduct.ts +++ b/server/src/internal/customers/add-product/createFullCusProduct.ts @@ -33,6 +33,8 @@ 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"; +import { RolloverService } from "../cusProducts/cusEnts/cusRollovers/RolloverService.js"; export const initCusPrice = ({ price, @@ -197,7 +199,7 @@ export const expireOrDeleteCusProduct = async ({ cp.status === CusProductStatus.Scheduled && (internalEntityId ? cp.internal_entity_id === internalEntityId - : nullish(cp.internal_entity_id)), + : nullish(cp.internal_entity_id)) ); if (curScheduledProduct) { @@ -350,7 +352,7 @@ export const createFullCusProduct = async ({ const cusProdId = generateId("cus_prod"); logger.info( - `Inserting cus product ${product.id} for ${customer.name}, cus product ID: ${cusProdId}`, + `Inserting cus product ${product.id} for ${customer.name}, cus product ID: ${cusProdId}` ); // 1. create customer entitlements @@ -403,6 +405,15 @@ export const createFullCusProduct = async ({ features: attachParams.features, }); + // 4. Get new rollovers + let rolloverOps = await getNewProductRollovers({ + db, + curCusProduct: curCusProduct as FullCusProduct, + cusEnts, + entitlements, + logger, + }); + // 4. create customer prices const cusPrices: CustomerPrice[] = []; @@ -458,10 +469,23 @@ export const createFullCusProduct = async ({ replaceables: newReplaceables, }); - let fullCusProduct = { - ...cusProd, - product, - customer_entitlements: cusEnts.map((ce) => ({ + let rolloverInserts: any = []; + + 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) => ({ ...ce, entitlement: entitlements.find((e) => e.id === ce.entitlement_id)!, replaceables: newReplaceables @@ -470,7 +494,18 @@ export const createFullCusProduct = async ({ ...r, delete_next_cycle: r.delete_next_cycle || false, })), - })), + rollovers: finalRollovers.filter((r) => r.cus_ent_id === ce.id), + // 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/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; diff --git a/server/src/internal/customers/cusProducts/CusProductService.ts b/server/src/internal/customers/cusProducts/CusProductService.ts index 61ff6a7e2..fa5660d50 100644 --- a/server/src/internal/customers/cusProducts/CusProductService.ts +++ b/server/src/internal/customers/cusProducts/CusProductService.ts @@ -85,6 +85,7 @@ const getFullCusProdRelations = () => { }, }, replaceables: true, + rollovers: true, }, }, customer_prices: { @@ -147,6 +148,7 @@ export class CusProductService { }, }, replaceables: true, + rollovers: true, }, }, customer_prices: { @@ -197,7 +199,7 @@ export class CusProductService { let cusProducts = await db.query.customerProducts.findMany({ where: and( eq(customerProducts.internal_customer_id, internalCustomerId), - inStatuses ? inArray(customerProducts.status, inStatuses) : undefined, + inStatuses ? inArray(customerProducts.status, inStatuses) : undefined ), with: { customer: withCustomer ? true : undefined, @@ -210,6 +212,7 @@ export class CusProductService { }, }, replaceables: true, + rollovers: true, }, }, customer_prices: { @@ -259,14 +262,14 @@ export class CusProductService { .from(customerProducts) .innerJoin( products, - eq(customerProducts.internal_product_id, products.internal_id), + eq(customerProducts.internal_product_id, products.internal_id) ) .where( and( eq(products.id, productId), eq(products.org_id, orgId), - eq(products.env, env), - ), + eq(products.env, env) + ) ) .limit(1); @@ -294,7 +297,7 @@ export class CusProductService { where: (table, { and, or, inArray }) => and( or(arrayContains(customerProducts.subscription_ids, [stripeSubId])), - inStatuses ? inArray(customerProducts.status, inStatuses) : undefined, + inStatuses ? inArray(customerProducts.status, inStatuses) : undefined ), with: { @@ -308,6 +311,7 @@ export class CusProductService { }, }, replaceables: true, + rollovers: true, }, }, customer_prices: { @@ -345,10 +349,10 @@ export class CusProductService { or( eq( sql`${customerProducts.processor}->>'subscription_schedule_id'`, - stripeScheduledId, + stripeScheduledId ), - sql`${customerProducts.scheduled_ids} @> ${sql`ARRAY[${stripeScheduledId}]`}`, - ), + sql`${customerProducts.scheduled_ids} @> ${sql`ARRAY[${stripeScheduledId}]`}` + ) ), with: { @@ -362,6 +366,7 @@ export class CusProductService { }, }, replaceables: true, + rollovers: true, }, }, customer_prices: { @@ -402,6 +407,7 @@ export class CusProductService { }, }, replaceables: true, + rollovers: true, }, }, customer_prices: { @@ -462,9 +468,9 @@ export class CusProductService { or( eq(customerProducts.status, CusProductStatus.Active), eq(customerProducts.status, CusProductStatus.PastDue), - eq(customerProducts.status, CusProductStatus.Scheduled), - ), - ), + eq(customerProducts.status, CusProductStatus.Scheduled) + ) + ) ) .returning({ id: customerProducts.id, @@ -473,7 +479,7 @@ export class CusProductService { let fullUpdated = (await db.query.customerProducts.findMany({ where: inArray( customerProducts.id, - updated.map((u) => u.id), + updated.map((u) => u.id) ), with: { product: true, @@ -514,21 +520,21 @@ export class CusProductService { .from(customerProducts) .innerJoin( customers, - eq(customerProducts.internal_customer_id, customers.internal_id), + eq(customerProducts.internal_customer_id, customers.internal_id) ) .innerJoin( products, - eq(customerProducts.internal_product_id, products.internal_id), + eq(customerProducts.internal_product_id, products.internal_id) ) .where( and( or( fingerprint ? eq(customers.fingerprint, fingerprint) : undefined, - eq(customers.internal_id, internalCustomerId), + eq(customers.internal_id, internalCustomerId) ), eq(products.id, productId), - isNotNull(customerProducts.free_trial_id), - ), + isNotNull(customerProducts.free_trial_id) + ) ); return data; @@ -546,7 +552,7 @@ export class CusProductService { let data = await db.query.customerProducts.findMany({ where: and( eq(customerProducts.free_trial_id, freeTrialId), - eq(customerProducts.internal_customer_id, internalCustomerId), + eq(customerProducts.internal_customer_id, internalCustomerId) ), with: { customer: true, 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..573dc1eb6 --- /dev/null +++ b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts @@ -0,0 +1,133 @@ +import { DrizzleCli } from "@/db/initDrizzle.js"; +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({ + db, + id, + updates, + }: { + db: DrizzleCli; + id: string; + updates: Partial; + }) { + if (!updates.balance && !updates.entities) return []; + + const data = await db + .update(rollovers) + .set(updates as any) + .where(eq(rollovers.id, id)) + .returning(); + + return data; + } + + static async upsert({ db, rows }: { db: DrizzleCli; rows: Rollover[] }) { + if (Array.isArray(rows) && rows.length == 0) return; + + const updateColumns = buildConflictUpdateColumns(rollovers, ["id"]); + await db + .insert(rollovers) + .values(rows as any) + .onConflictDoUpdate({ + target: rollovers.id, + set: updateColumns, + }); + } + + // 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, + }: { + 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({ + db, + rows, + // rolloverConfig, + fullCusEnt, + // cusEntID, + // entityMode, + }: { + db: DrizzleCli; + rows: Rollover[]; + // rolloverConfig: RolloverConfig; + fullCusEnt: FullCustomerEntitlement; + // cusEntID: string; + // entityMode: boolean; + }) { + if (rows.length === 0) return {}; + + await db + .insert(rollovers) + .values(rows as any) + .returning(); + + let curRollovers = [...fullCusEnt.rollovers, ...rows]; + + let { toDelete, toUpdate } = performMaximumClearing({ + rows: curRollovers as Rollover[], + cusEnt: fullCusEnt, + }); + + if (toDelete.length > 0) { + await RolloverService.delete({ db, ids: toDelete }); + } + + 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[] }) { + 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 new file mode 100644 index 000000000..92fce5265 --- /dev/null +++ b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/getNewProductRollovers.ts @@ -0,0 +1,138 @@ +import { generateId, nullish } from "@/utils/genUtils.js"; +import { + 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: FullCusProduct; + cusEnts: CustomerEntitlement[]; + entitlements: EntitlementWithFeature[]; + db: DrizzleCli; + logger: any; +}) => { + if (!curCusProduct) return []; + if (!curCusProduct.id) return []; + try { + let rolloverOperations: { + // rolloverConfig: RolloverConfig; + toInsert: Rollover[]; + cusEnt: FullCustomerEntitlement; + // cusEntId: string; + // toUpdate: Rollover[]; + // entityMode: boolean; + }[] = []; + + // let newRollovers: Rollover[] = []; + + let oldCusEnts = curCusProduct.customer_entitlements; + + 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; + + if (!oldCusEnt || !newEnt?.rollover) continue; + + // 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 + // ) { + // 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 + // ); + + // 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), + // }); + // } + // } + + let curRollovers = oldCusEnt.rollovers; + + for (const curRollover of curRollovers) { + newRollovers.push({ + ...curRollover, + id: generateId("roll"), + cus_ent_id: newCusEnt.id, + }); + } + + console.log(`Feature ${newEnt?.feature_id} rollovers:`, newRollovers); + + // // 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 new file mode 100644 index 000000000..a7c923b8f --- /dev/null +++ b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverDeductionUtils.ts @@ -0,0 +1,130 @@ +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: number; + deductParams: RolloverDeductParams; + cusEnt: FullCusEntWithFullCusProduct; +}) => { + if (toDeduct == 0) { + return toDeduct; + } + + let updates = { + toInsert: [] as Rollover[], + toUpdate: [] as Rollover[], + }; + let rollovers = getSortedRollovers({ + cusEnts: [cusEnt], + featureId: deductParams.feature.id, + entityId: deductParams.entity?.id, + }); + + 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; + + 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, + }; + + updates.toUpdate.push(rollover); + toDeduct = 0; + + break; + } else { + if (rollover.balance > 0) { + toDeduct -= rollover.balance; + rollover = { + ...rollover, + usage: rollover.usage + rollover.balance, + balance: 0, + }; + + updates.toUpdate.push(rollover); + } + } + } + } + + await RolloverService.upsert({ + db: deductParams.db, + rows: updates.toUpdate, + }); + + 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; + }); + } +}; 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..01b326b62 --- /dev/null +++ b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.ts @@ -0,0 +1,394 @@ +import { + FullCustomerEntitlement, + RolloverConfig, + EntityRolloverBalance, + RolloverDuration, + Rollover, +} from "@autumn/shared"; +import { generateId, notNullish, nullish } from "@/utils/genUtils.js"; +import { addMonths } from "date-fns"; +import { Decimal } from "decimal.js"; + +export const getRolloverUpdates = ({ + cusEnt, + nextResetAt, +}: { + cusEnt: FullCustomerEntitlement; + nextResetAt: number; +}) => { + let update: { + toDelete: string[]; + toInsert: Rollover[]; + toUpdate: Rollover[]; + } = { + toDelete: [], + toInsert: [], + toUpdate: [], + }; + let ent = cusEnt.entitlement; + let shouldRollover = + cusEnt.balance && cusEnt.balance > 0 && notNullish(ent.rollover); + + if (!shouldRollover) return update; + + let nextExpiry = calculateNextExpiry(nextResetAt, ent.rollover!); + + let newRollover: Rollover = { + id: generateId("roll"), + cus_ent_id: cusEnt.id, + balance: 0, + usage: 0, + expires_at: nextExpiry, + entities: {}, + }; + + if (notNullish(ent.entity_feature_id)) { + for (const entityId in cusEnt.entities) { + let entRollover = cusEnt.entities[entityId].balance; + + if (entRollover > 0) { + newRollover.entities[entityId] = { + id: entityId, + balance: entRollover, + usage: 0, + }; + } + } + + update.toInsert.push(newRollover); + } else { + let balance = cusEnt.balance!; + if (balance > 0) { + newRollover.balance = balance; + update.toInsert.push(newRollover); + } + } + + return update; +}; + +export const calculateNextExpiry = ( + nextResetAt: number, + config: RolloverConfig +) => { + if (nullish(config)) { + return null; + } + + if (config.duration == RolloverDuration.Forever) return null; + + return addMonths(nextResetAt, config.length).getTime(); +}; + +export function performMaximumClearing({ + rows, + // rolloverConfig, + cusEnt, + // cusEntID, + // entityMode, +}: { + rows: Rollover[]; + // rolloverConfig: RolloverConfig; + cusEnt: FullCustomerEntitlement; + // cusEntID: string; + // entityMode: boolean; +}) { + let rolloverConfig = cusEnt.entitlement.rollover; + + if (!rolloverConfig) { + return { toDelete: [], toUpdate: [] }; + } + + if (rolloverConfig.max == null) { + return { toDelete: [], toUpdate: [] }; + } + + let total = 0; + let toDelete: string[] = []; + let toUpdate: Rollover[] = []; + + // 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 + + rows.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; + }); + + 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); + + if (toDeduct.lt(0)) return { toDelete: [], toUpdate: [] }; + + let toUpdate: Rollover[] = []; + let toDelete: string[] = []; + + for (const row of rows) { + let curBalance = new Decimal(row.balance); + let newBalance = curBalance; + if (curBalance.gte(toDeduct)) { + newBalance = newBalance.sub(toDeduct); + toDeduct = new Decimal(0); + + toUpdate.push({ ...row, balance: newBalance.toNumber() }); + } else { + newBalance = new Decimal(0); + toDeduct = toDeduct.sub(curBalance); + + toDelete.push(row.id); + } + + if (toDeduct.lte(0)) break; + } + + return { toDelete, toUpdate }; + } else { + 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); + } + }); + } + }); + + const entityTotals = new Map(); + allEntityIds.forEach((id) => entityTotals.set(id, 0)); + let entityIdToTotal: Record = {}; + rows.forEach((row) => { + for (const entityId in row.entities) { + entityIdToTotal[entityId] = + (entityIdToTotal[entityId] || 0) + row.entities[entityId].balance; + } + }); + + // console.log(`id to total:`, entityIdToTotal); + + let toUpdate: Rollover[] = []; + let toDelete: string[] = []; + + // Non entity mode + for (const row of rows) { + let update = structuredClone(row); + let shouldUpdate = false; + + for (const entityId in entityIdToTotal) { + let entityTotal = entityIdToTotal[entityId]; + 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); + entityIdToTotal[entityId] = 0; + shouldUpdate = true; + update.entities[entityId] = { + id: entityId, + balance: newBalance.toNumber(), + usage: 0, + }; + } else { + newBalance = new Decimal(0); + entityIdToTotal[entityId] = toDeduct.sub(curBalance).toNumber(); + shouldUpdate = true; + update.entities[entityId] = { + id: entityId, + balance: 0, + usage: 0, + }; + } + } + // console.log(`Max clearing for row ${row.id}`); + // console.log(`Update:`, update.entities); + + // If all keys are 0, then delete the row + if ( + Object.values(update.entities).every( + (entity: EntityRolloverBalance) => entity.balance === 0 + ) + ) { + toDelete.push(row.id); + } else if (shouldUpdate) { + toUpdate.push(update); + } + } + + return { toDelete, toUpdate }; + } +} + +// For each entity ID, perform maximum clearing... + +// 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 + +// 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 + +// 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(", ")}`); +// } 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 diff --git a/server/src/internal/customers/cusProducts/cusProductUtils.ts b/server/src/internal/customers/cusProducts/cusProductUtils.ts index 68dcd1edf..166d708e8 100644 --- a/server/src/internal/customers/cusProducts/cusProductUtils.ts +++ b/server/src/internal/customers/cusProducts/cusProductUtils.ts @@ -20,7 +20,7 @@ import { getUsageTier, } from "@/internal/products/prices/priceUtils.js"; import { ProductService } from "@/internal/products/ProductService.js"; -import { CusProductService } from "./CusProductService.js"; +import { CusProductService, RELEVANT_STATUSES } from "./CusProductService.js"; import { createStripeCli } from "@/external/stripe/utils.js"; import { createFullCusProduct } from "../add-product/createFullCusProduct.js"; import Stripe from "stripe"; @@ -514,11 +514,7 @@ export const getMainCusProduct = async ({ let cusProducts = await CusProductService.list({ db, internalCustomerId, - inStatuses: [ - CusProductStatus.Active, - CusProductStatus.PastDue, - CusProductStatus.Scheduled, - ], + inStatuses: RELEVANT_STATUSES, }); let mainCusProduct = cusProducts.find( @@ -527,7 +523,7 @@ export const getMainCusProduct = async ({ (productGroup ? cusProduct.product.group === productGroup : true) ); - return mainCusProduct; + return mainCusProduct as FullCusProduct; }; export const getCusProductsWithStripeSubId = ({ diff --git a/server/src/internal/customers/cusProducts/cusProductUtils/convertCusProduct.ts b/server/src/internal/customers/cusProducts/cusProductUtils/convertCusProduct.ts index 56c3b603b..8117bc74f 100644 --- a/server/src/internal/customers/cusProducts/cusProductUtils/convertCusProduct.ts +++ b/server/src/internal/customers/cusProducts/cusProductUtils/convertCusProduct.ts @@ -36,7 +36,7 @@ export const cusProductsToCusPrices = ({ let prices = cusProduct.customer_prices; if (billingType) { prices = prices.filter( - (cp) => getBillingType(cp.price.config) === billingType, + (cp) => getBillingType(cp.price.config) === billingType ); } @@ -68,13 +68,13 @@ export const cusProductsToCusEnts = ({ ...cusProduct.customer_entitlements.map((cusEnt) => ({ ...cusEnt, customer_product: cusProduct, - })), + })) ); } if (featureId) { cusEnts = cusEnts.filter( - (cusEnt) => cusEnt.entitlement.feature_id === featureId, + (cusEnt) => cusEnt.entitlement.feature_id === featureId ); } @@ -153,3 +153,26 @@ export const cusProductsToStripeSubs = ({ subIds: cusProducts.flatMap((p: any) => p.subscription_ids || []), }); }; + +export const cusProductToCusEnt = ({ + cusProduct, + featureId, +}: { + cusProduct: FullCusProduct; + featureId: string; +}) => { + let cusEnts = cusProduct.customer_entitlements; + + let fullCusEnt = cusEnts.find( + (ce) => ce.entitlement.feature_id === featureId + ); + + if (fullCusEnt) { + return { + ...fullCusEnt, + customer_product: cusProduct, + } as FullCusEntWithFullCusProduct; + } + + return undefined; +}; diff --git a/server/src/internal/customers/cusUtils/cusFeatureResponseUtils/balancesToFeatureResponse.ts b/server/src/internal/customers/cusUtils/cusFeatureResponseUtils/balancesToFeatureResponse.ts index 3e66e2360..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, @@ -16,7 +17,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]) { @@ -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, @@ -95,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 f6b9075f2..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, @@ -87,7 +181,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; @@ -183,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 || @@ -256,5 +361,9 @@ export const getCusBalances = async ({ }); } + // if (org.api_version == APIVersion.v1) { + + // } + return balances as CusFeatureBalance[]; }; diff --git a/server/src/internal/customers/getFullCusQuery.ts b/server/src/internal/customers/getFullCusQuery.ts index 41fe031bf..a00650e54 100644 --- a/server/src/internal/customers/getFullCusQuery.ts +++ b/server/src/internal/customers/getFullCusQuery.ts @@ -7,7 +7,7 @@ const buildOptimizedCusProductsCTE = (inStatuses?: CusProductStatus[]) => { return inStatuses ? sql`AND cp.status = ANY(ARRAY[${sql.join( inStatuses.map((status) => sql`${status}`), - sql`, `, + sql`, ` )}])` : sql``; }; @@ -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 + ), + 'rollovers', ( + SELECT COALESCE( + 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 + WHERE ro.cus_ent_id = ce.id ) ) )) FILTER (WHERE ce.id IS NOT NULL), @@ -110,7 +118,7 @@ const buildEntityCTE = (entityId?: string) => { const buildTrialsUsedCTE = ( withTrialsUsed: boolean, orgId: string, - env: AppEnv, + env: AppEnv ) => { if (!withTrialsUsed) { return sql``; @@ -140,7 +148,7 @@ const buildTrialsUsedCTE = ( const buildSubscriptionsCTE = ( withSubs: boolean, - inStatuses?: CusProductStatus[], + inStatuses?: CusProductStatus[] ) => { if (!withSubs) { return sql``; @@ -194,7 +202,7 @@ export const getFullCusQuery = ( withEntities: boolean, withTrialsUsed: boolean, withSubs: boolean, - entityId?: string, + entityId?: string ) => { const sqlChunks: SQL[] = []; 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 66eb86c63..9f60a95ef 100644 --- a/server/src/internal/products/handlers/handleUpdateProduct/updateProductDetails.ts +++ b/server/src/internal/products/handlers/handleUpdateProduct/updateProductDetails.ts @@ -14,7 +14,6 @@ import { } 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, 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..07ab6b936 100644 --- a/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts +++ b/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts @@ -141,6 +141,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 +198,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 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/server/src/trigger/updateBalanceTask.ts b/server/src/trigger/updateBalanceTask.ts index 87cf80d2c..989c36b54 100644 --- a/server/src/trigger/updateBalanceTask.ts +++ b/server/src/trigger/updateBalanceTask.ts @@ -42,11 +42,12 @@ import { getBillingType, getEntOptions, } from "@/internal/products/prices/priceUtils.js"; +import { deductFromCusRollovers } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverDeductionUtils.js"; import { refreshCusCache } from "@/internal/customers/cusCache/updateCachedCus.js"; // Decimal.set({ precision: 12 }); // 12 DP precision -type DeductParams = { +export type DeductParams = { db: DrizzleCli; env: AppEnv; org: Organization; @@ -57,6 +58,13 @@ type DeductParams = { entity?: Entity; }; +export type RolloverDeductParams = { + db: DrizzleCli; + env: AppEnv; + feature: Feature; + entity?: Entity; +}; + // 2. Get deductions for each feature const getFeatureDeductions = ({ cusEnts, @@ -603,6 +611,21 @@ export const updateCustomerBalance = async ({ continue; } + toDeduct = await deductFromCusRollovers({ + toDeduct, + cusEnt, + deductParams: { + db, + feature, + env, + entity: customer.entity ? customer.entity : undefined, + }, + }); + + if (toDeduct == 0) { + continue; + } + toDeduct = await deductAllowanceFromCusEnt({ toDeduct, cusEnt, diff --git a/server/src/trigger/updateUsageTask.ts b/server/src/trigger/updateUsageTask.ts index 8eea77faf..ac86c9e66 100644 --- a/server/src/trigger/updateUsageTask.ts +++ b/server/src/trigger/updateUsageTask.ts @@ -20,6 +20,7 @@ import { } 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"; import { refreshCusCache } from "@/internal/customers/cusCache/updateCachedCus.js"; // 2. Get deductions for each feature @@ -230,6 +231,20 @@ export const updateUsage = async ({ continue; } + toDeduct = await deductFromCusRollovers({ + toDeduct, + cusEnt, + deductParams: { + db, + feature, + env, + entity: customer.entity ? customer.entity : undefined, + }, + }); + + if (toDeduct == 0) { + continue; + } toDeduct = await deductAllowanceFromCusEnt({ toDeduct, cusEnt, diff --git a/server/src/utils/scriptUtils/constructItem.ts b/server/src/utils/scriptUtils/constructItem.ts index 81809f55e..199fad396 100644 --- a/server/src/utils/scriptUtils/constructItem.ts +++ b/server/src/utils/scriptUtils/constructItem.ts @@ -5,6 +5,7 @@ import { ProductItem, ProductItemConfig, ProductItemInterval, + RolloverConfig, UsageModel, } from "@autumn/shared"; @@ -14,12 +15,14 @@ export const constructFeatureItem = ({ interval = ProductItemInterval.Month, entityFeatureId, isBoolean = false, + rolloverConfig, }: { featureId: string; includedUsage?: number; interval?: ProductItemInterval | null; entityFeatureId?: string; isBoolean?: boolean; + rolloverConfig?: RolloverConfig; }) => { if (isBoolean) { return { @@ -34,6 +37,12 @@ export const constructFeatureItem = ({ interval: interval, }; + if (rolloverConfig) { + item.config = { + rollover: rolloverConfig, + }; + } + return item; }; @@ -47,6 +56,7 @@ export const constructPrepaidItem = ({ on_increase: OnIncrease.ProrateImmediately, on_decrease: OnDecrease.ProrateImmediately, }, + rolloverConfig, usageLimit, }: { featureId: string; @@ -55,6 +65,7 @@ export const constructPrepaidItem = ({ includedUsage?: number; isOneOff?: boolean; config?: ProductItemConfig; + rolloverConfig?: RolloverConfig; usageLimit?: number; }) => { let item: ProductItem = { @@ -67,7 +78,10 @@ export const constructPrepaidItem = ({ interval: isOneOff ? null : ProductItemInterval.Month, included_usage: includedUsage, - config, + config: { + ...config, + ...(rolloverConfig ? { rollover: rolloverConfig } : {}), + }, usage_limit: usageLimit, }; @@ -119,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, @@ -133,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 new file mode 100644 index 000000000..09cfaaece --- /dev/null +++ b/server/tests/advanced/rollovers/rollover1.ts @@ -0,0 +1,197 @@ +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 { timeout } from "@/utils/genUtils.js"; +import { resetAndGetCusEnt } from "./rolloverTestUtils.js"; + +let rolloverConfig = { max: 500, length: 1, duration: RolloverDuration.Month }; +const messagesItem = constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 400, + interval: ProductItemInterval.Month, + rolloverConfig, +}) as LimitedItem; + +export let free = constructProduct({ + items: [messagesItem], + type: "free", + isDefault: false, +}); + +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: [free], + prefix: testCase, + }); + + await createProducts({ + autumn, + products: [free], + 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, + }); + }); + + let messageUsage = 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: messageUsage, + }); + + await timeout(3000); + + await resetAndGetCusEnt({ + db, + customer, + productGroup: free.group, + featureId: TestFeature.Messages, + }); + + let cus = await autumn.customers.get(customerId); + let msgesFeature = cus.features[TestFeature.Messages]; + + 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 usage2 = 50; + it("should reset again and have correct rollover", async function () { + await resetAndGetCusEnt({ + db, + customer, + productGroup: free.group, + featureId: TestFeature.Messages, + }); + + let expectedRollover = Math.min(curBalance, rolloverConfig.max); + 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 (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 new file mode 100644 index 000000000..f02cd2100 --- /dev/null +++ b/server/tests/advanced/rollovers/rollover2.ts @@ -0,0 +1,227 @@ +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"; +import { resetAndGetCusEnt } from "./rolloverTestUtils.js"; + +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; + +export let free = constructProduct({ + items: [msgesItem], + type: "free", + isDefault: false, +}); + +const testCase = "rollover2"; +// , per entity and regular + +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; + 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], + prefix: testCase, + }); + + await createProducts({ + autumn, + products: [free], + 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: any[] = [ + { + 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: free.id, + }); + + await autumn.entities.create(customerId, entities); + }); + + 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 () { + 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 ... + await resetAndGetCusEnt({ + db, + customer, + productGroup: free.group, + featureId: TestFeature.Messages, + }); + + 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); + + expect(msgesFeature.rollovers.length).to.equal(1); + expect(msgesFeature.balance).to.equal(includedUsage + expectedRollover); + expect(msgesFeature.rollovers[0].balance).to.equal(expectedRollover); + } + }); + + it("should reset again and have correct rollovers", async function () { + await resetAndGetCusEnt({ + db, + customer, + productGroup: free.group, + featureId: TestFeature.Messages, + }); + + 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.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); + } + }); + + 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 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); + } + }); +}); 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..c9dc16ffa --- /dev/null +++ b/server/tests/advanced/rollovers/rollover6.ts @@ -0,0 +1,154 @@ +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"; +import { advanceTestClock } from "@/utils/scriptUtils/testClockUtils.js"; +import { addHours, addMonths } from "date-fns"; +import { hoursToFinalizeInvoice } from "tests/utils/constants.js"; + +let freeRollover = { max: 600, length: 1, duration: RolloverDuration.Month }; +let proRollover = { max: 1000, 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: 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 proRolloverBalance = proMsges.included_usage * 2; + let freeRolloverBalance = Math.min(freeRollover.max, proRolloverBalance); + + expect(msgesFeature).to.exist; + expect(msgesFeature?.balance).to.equal( + freeMsges.included_usage + freeRolloverBalance + ); + + // @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/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; +}; diff --git a/server/tests/attach/utils.ts b/server/tests/attach/utils.ts index ae36b286d..1a1238c2d 100644 --- a/server/tests/attach/utils.ts +++ b/server/tests/attach/utils.ts @@ -91,7 +91,7 @@ export const runAttachTest = async ({ expect( productCount, - `customer should only have 1 product (from this group: ${product.group})`, + `customer should only have 1 product (from this group: ${product.group})` ).to.equal(1); expectProductAttached({ @@ -101,7 +101,7 @@ export const runAttachTest = async ({ }); let intervals = Array.from( - new Set(product.items.map((item) => item.interval)), + new Set(product.items.map((item) => item.interval)) ).filter(notNullish); const multiInterval = intervals.length > 1; @@ -150,7 +150,7 @@ export const runAttachTest = async ({ } else { expect(stripeSubs.data.length).to.equal( 1, - "should only have 1 subscription", + "should only have 1 subscription" ); } }; @@ -191,7 +191,7 @@ export const replaceItems = ({ if (interval) { index = newItems.findIndex( - (item) => item.interval == (interval as any) && nullish(item.feature_id), + (item) => item.interval == (interval as any) && nullish(item.feature_id) ); } diff --git a/shared/db/schema.ts b/shared/db/schema.ts index b5fb36227..6ce6d4e4e 100644 --- a/shared/db/schema.ts +++ b/shared/db/schema.ts @@ -12,6 +12,8 @@ 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/cusProductModels/cusEntModels/rolloverModels/rolloverTable.js"; +import { rolloverRelations } from "../models/cusProductModels/cusEntModels/rolloverModels/rolloverRelations.js"; // CusProduct Tables import { customerProducts } from "../models/cusProductModels/cusProductTable.js"; @@ -106,6 +108,7 @@ export { actions, events, replaceables, + rollovers, // Auth user, @@ -134,7 +137,7 @@ export { migrationErrorRelations, replaceableRelations, invoiceRelations, - + rolloverRelations, // Auth Relations userRelations, }; diff --git a/shared/index.ts b/shared/index.ts index 9b78e0d8f..623798664 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -67,6 +67,7 @@ export * from "./models/cusProductModels/cusEntModels/cusEntWithProduct.js"; export * from "./models/cusProductModels/cusEntModels/cusEntTable.js"; export * from "./models/cusProductModels/cusEntModels/replaceableTable.js"; export * from "./models/cusProductModels/cusEntModels/replaceableSchema.js"; +export * from "./models/cusProductModels/cusEntModels/rolloverModels/rolloverTable.js"; // 8. Customer Models export * from "./models/cusModels/cusModels.js"; 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; diff --git a/shared/models/cusProductModels/cusEntModels/cusEntModels.ts b/shared/models/cusProductModels/cusEntModels/cusEntModels.ts index d675122f8..3b8665d30 100644 --- a/shared/models/cusProductModels/cusEntModels/cusEntModels.ts +++ b/shared/models/cusProductModels/cusEntModels/cusEntModels.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { EntitlementWithFeatureSchema } from "../../productModels/entModels/entModels.js"; import { Replaceable } from "./replaceableTable.js"; import { ReplaceableSchema } from "./replaceableSchema.js"; +import { RolloverSchema } from "./rolloverModels/rolloverTable.js"; export const EntityBalanceSchema = z.object({ id: z.string(), @@ -36,9 +37,11 @@ export const CustomerEntitlementSchema = z.object({ export const FullCustomerEntitlementSchema = CustomerEntitlementSchema.extend({ entitlement: EntitlementWithFeatureSchema, replaceables: z.array(ReplaceableSchema), + rollovers: z.array(RolloverSchema), }); export type EntityBalance = z.infer; + export type CustomerEntitlement = z.infer; export type FullCustomerEntitlement = z.infer< typeof FullCustomerEntitlementSchema diff --git a/shared/models/cusProductModels/cusEntModels/cusEntRelations.ts b/shared/models/cusProductModels/cusEntModels/cusEntRelations.ts index 8dd3d61ba..8453f23c9 100644 --- a/shared/models/cusProductModels/cusEntModels/cusEntRelations.ts +++ b/shared/models/cusProductModels/cusEntModels/cusEntRelations.ts @@ -6,6 +6,7 @@ import { features } from "../../featureModels/featureTable.js"; import { customerEntitlements } from "./cusEntTable.js"; import { customerProducts } from "../cusProductTable.js"; import { replaceables } from "./replaceableTable.js"; +import { rollovers } from "./rolloverModels/rolloverTable.js"; export const customerEntitlementsRelations = relations( customerEntitlements, @@ -27,5 +28,6 @@ export const customerEntitlementsRelations = relations( references: [features.internal_id], }), replaceables: many(replaceables), - }), + rollovers: many(rollovers), + }) ); diff --git a/shared/models/cusProductModels/cusEntModels/rolloverModels/rolloverModels.ts b/shared/models/cusProductModels/cusEntModels/rolloverModels/rolloverModels.ts new file mode 100644 index 000000000..b4428dbf6 --- /dev/null +++ b/shared/models/cusProductModels/cusEntModels/rolloverModels/rolloverModels.ts @@ -0,0 +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(), +// entities: z.record(z.string(), EntityBalanceSchema), +// expires_at: z.number().nullable(), +// }); + +// export type RolloverModel = z.infer; diff --git a/shared/models/cusProductModels/cusEntModels/rolloverModels/rolloverRelations.ts b/shared/models/cusProductModels/cusEntModels/rolloverModels/rolloverRelations.ts new file mode 100644 index 000000000..35cf54c76 --- /dev/null +++ b/shared/models/cusProductModels/cusEntModels/rolloverModels/rolloverRelations.ts @@ -0,0 +1,10 @@ +import { relations } from "drizzle-orm"; +import { rollovers } from "./rolloverTable.js"; +import { customerEntitlements } from "../cusEntTable.js"; + +export const rolloverRelations = relations(rollovers, ({ one }) => ({ + customer_entitlement: one(customerEntitlements, { + fields: [rollovers.cus_ent_id], + references: [customerEntitlements.id], + }), +})); diff --git a/shared/models/cusProductModels/cusEntModels/rolloverModels/rolloverTable.ts b/shared/models/cusProductModels/cusEntModels/rolloverModels/rolloverTable.ts new file mode 100644 index 000000000..3da143aba --- /dev/null +++ b/shared/models/cusProductModels/cusEntModels/rolloverModels/rolloverTable.ts @@ -0,0 +1,55 @@ +import { z } from "zod"; +import { customerEntitlements } from "../cusEntTable.js"; +import { + foreignKey, + pgTable, + numeric, + jsonb, + text, + index, +} from "drizzle-orm/pg-core"; + +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().default(0), + expires_at: z.number().nullable(), + entities: z.record(z.string(), EntityRolloverBalanceSchema), +}); + +export const rollovers = pgTable( + "rollovers", + { + id: text("id").primaryKey().notNull(), + 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() + .default({}), + }, + (table) => [ + foreignKey({ + columns: [table.cus_ent_id], + foreignColumns: [customerEntitlements.id], + name: "rollover_cus_ent_id_fkey", + }) + .onUpdate("cascade") + .onDelete("cascade"), + + index("idx_rollovers_cus_ent_id").on(table.cus_ent_id), + ] +).enableRLS(); + +export type Rollover = z.infer; +export type InsertRollover = typeof rollovers.$inferInsert; +export type EntityRolloverBalance = z.infer; diff --git a/shared/models/productModels/entModels/entModels.ts b/shared/models/productModels/entModels/entModels.ts index 36c10d3b1..383e85fd1 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 { RolloverConfigSchema } 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: RolloverConfigSchema.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: RolloverConfigSchema.nullish(), }); export type CreateEntitlement = z.infer; diff --git a/shared/models/productModels/entModels/entTable.ts b/shared/models/productModels/entModels/entTable.ts index 5ad8dd2fc..26718ba01 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 { RolloverConfig } from "../../../index.js"; export const entitlements = pgTable( "entitlements", @@ -34,6 +36,8 @@ export const entitlements = pgTable( org_id: text("org_id"), feature_id: text("feature_id"), usage_limit: numeric({ mode: "number" }), + + rollover: jsonb().$type(), }, (table) => [ foreignKey({ @@ -50,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/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..910cfe787 100644 --- a/shared/models/productV2Models/productItemModels/productItemModels.ts +++ b/shared/models/productV2Models/productItemModels/productItemModels.ts @@ -43,15 +43,21 @@ export enum ProductItemFeatureType { Static = "static", } +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(), + on_decrease: z.nativeEnum(OnDecrease).nullish(), + rollover: RolloverConfigSchema.nullish(), }); export const ProductItemSchema = z.object({ @@ -90,3 +96,4 @@ export type ProductItem = z.infer; export type LimitedItem = z.infer; export type ProductItemConfig = z.infer; export type PriceTier = z.infer; +export type RolloverConfig = z.infer; diff --git a/shared/package.json b/shared/package.json index 396cc0ac6..d4a4fd5a6 100644 --- a/shared/package.json +++ b/shared/package.json @@ -13,8 +13,10 @@ "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", "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", 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`, }, }} /> 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..5dffaf5a6 100644 --- a/vite/src/utils/product/product-item/validateProductItem.ts +++ b/vite/src/utils/product/product-item/validateProductItem.ts @@ -1,7 +1,16 @@ -import { invalidNumber, notNullish } from "@/utils/genUtils"; -import { Feature, FeatureUsageType, ProductItem } 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"; export const validateProductItem = ({ item, @@ -100,5 +109,57 @@ export const validateProductItem = ({ } } + if (item.config?.rollover) { + const rollover = item.config?.rollover as RolloverConfig; + + if (rollover.max && rollover.max !== null) { + rollover.max = parseFloat(rollover.max.toString()); + } + + if (rollover.duration !== RolloverDuration.Forever) { + rollover.length = parseFloat(rollover.length.toString()); + } else { + rollover.length = 0; + } + + if ( + item.interval === null || + nullish(item.included_usage) || + item.included_usage === 0 + ) { + item.config!.rollover = null; + return item; + } + + if (rollover.max !== null && invalidNumber(rollover.max)) { + toast.error("Please enter a valid maximum rollover amount"); + 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; + } + } + return item; }; 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 74e1a3f9c..fbd337d66 100644 --- a/vite/src/views/customers/customer/entitlements/CustomerEntitlementsList.tsx +++ b/vite/src/views/customers/customer/entitlements/CustomerEntitlementsList.tsx @@ -6,7 +6,10 @@ import { } from "@autumn/shared"; import { useCustomerContext } from "../CustomerContext"; -import { formatUnixToDateTime } from "@/utils/formatUtils/formatDateUtils"; +import { + formatUnixToDate, + formatUnixToDateTime, +} from "@/utils/formatUtils/formatDateUtils"; import { useState } from "react"; @@ -18,6 +21,7 @@ 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( @@ -125,6 +129,26 @@ export const CustomerEntitlementsList = () => { }); } + 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(", ") + + ` (expires: ${r.expires_at ? formatUnixToDate(r.expires_at) : "N/A"})` + ); + } else { + return `${r.balance} (ex: ${r.expires_at ? formatUnixToDate(r.expires_at) : "N/A"})`; + } + }) + .join("\n"), + }); + } + return hoverTexts; }; @@ -234,26 +258,7 @@ export const CustomerEntitlementsList = () => { )} -
- {entitlement.feature.type == FeatureType.Boolean ? ( - <> - ) : allowanceType == AllowanceType.Unlimited ? ( - "Unlimited" - ) : entityId && cusEnt.entities?.[entityId] ? ( -
- {cusEnt.entities?.[entityId]?.balance}{" "} -
- ) : ( - <> - {cusEnt.balance}{" "} - - {cusEnt.replaceables.length > 0 - ? ` (${cusEnt.replaceables.length} free)` - : ""} - - - )} -
+
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..27b4d8ceb 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); } @@ -84,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/product-item-config/advanced-config/AdvancedItemConfig.tsx b/vite/src/views/products/product/product-item/product-item-config/advanced-config/AdvancedItemConfig.tsx index ce0643331..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 @@ -7,19 +7,30 @@ import { OnDecreaseSelect } from "./proration-config/OnDecreaseSelect"; import { OnIncreaseSelect } from "./proration-config/OnIncreaseSelect"; import { shouldShowProrationConfig } from "@/utils/product/productItemUtils"; import { - getFeature, + getFeatureCreditSystem, getFeatureUsageType, } from "@/utils/product/entitlementUtils"; import { FeatureUsageType } from "@autumn/shared"; import { Input } from "@/components/ui/input"; +import { RolloverConfigView } from "./RolloverConfig"; +import { notNullish } from "@/utils/genUtils"; + export const AdvancedItemConfig = () => { const { features } = useProductContext(); const { item, setItem } = useProductItemContext(); + 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 && + item.included_usage && + item.included_usage > 0; return (
@@ -37,10 +48,10 @@ export const AdvancedItemConfig = () => {
-
+
{ @@ -52,10 +63,13 @@ export const AdvancedItemConfig = () => { 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} + disabled={ + usageType === FeatureUsageType.Continuous || + notNullish(item.config?.rollover) + } /> -
+
{ @@ -98,6 +112,14 @@ export const AdvancedItemConfig = () => { )} {/*
*/} + + {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..0da2d1e19 --- /dev/null +++ b/vite/src/views/products/product/product-item/product-item-config/advanced-config/RolloverConfig.tsx @@ -0,0 +1,137 @@ +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 { + setItem({ + ...item, + reset_usage_when_enabled: true, + config: { + ...(item.config || {}), + rollover: 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() {