Merge pull request #120 from SirTenzin/feat/rollovers

feat: 🎸 credit rollovers
This commit is contained in:
John Yeo
2025-07-25 20:33:43 +01:00
committed by GitHub
64 changed files with 3123 additions and 418 deletions

View File

@@ -37,7 +37,7 @@
"vite:start:bun": "bun -F @autumn/shared build && bun -F @autumn/vite start:bun",
"dev:bun": "concurrently \"cd server && bun run dev: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": {

View File

@@ -7,7 +7,7 @@
"scripts": {
"email": "email dev -p 3001",
"start": "bun src/index.ts",
"dev": "NODE_ENV=development bun --watch src/index.ts --ignore scripts --ignore tests",
"dev": "NODE_ENV=development bunx --bun nodemon --exec bun src/index.ts --ignore scripts --ignore tests",
"workers": "bun src/workers.ts",
"workers:dev": "bun --watch src/workers.ts",
"cron": "bun src/cron.ts",

View File

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

View File

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

View File

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

View File

@@ -154,7 +154,7 @@ export const handleCheckoutSessionCompleted = async ({
product,
org,
env: attachParams.customer.env,
subId: checkoutSession.subscription as string,
subId: checkoutSub?.id as string,
},
});
}

View File

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

View File

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

View File

@@ -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()}` : ''}`
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, { id: string; balance: number; usage: number }>
// );
// 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 [];
}
};

View File

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

View File

@@ -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<string>();
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<string, number>();
allEntityIds.forEach((id) => entityTotals.set(id, 0));
let entityIdToTotal: Record<string, number> = {};
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(", ")}`);
// }

View File

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

View File

@@ -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 = ({

View File

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

View File

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

View File

@@ -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<string, any> = {};
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[];
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,7 @@ import {
AppEnv,
OnIncrease,
UsageModel,
FeatureUsageType,
RolloverDuration,
} from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import { notNullish, nullish } from "@/utils/genUtils.js";
@@ -160,6 +160,14 @@ const validateProductItem = ({
statusCode: StatusCodes.BAD_REQUEST,
});
}
// Rollover
// if (item.config?.rollover) {
// let rollover = item.config.rollover;
// if (rollover.duration == RolloverDuration.Month) {
// }
// }
};
export const validateProductItems = ({
newItems,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<typeof CusEntResponseSchema>;
export type CusEntResponseV2 = z.infer<typeof CusEntResponseV2Schema>;
export type CheckResponse = z.infer<typeof CheckResponseSchema>;
export type CusRollover = z.infer<typeof CusRolloverSchema>;

View File

@@ -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<typeof EntityBalanceSchema>;
export type CustomerEntitlement = z.infer<typeof CustomerEntitlementSchema>;
export type FullCustomerEntitlement = z.infer<
typeof FullCustomerEntitlementSchema

View File

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

View File

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

View File

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

View File

@@ -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<Record<string, EntityRolloverBalance>>()
.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<typeof RolloverSchema>;
export type InsertRollover = typeof rollovers.$inferInsert;
export type EntityRolloverBalance = z.infer<typeof EntityRolloverBalanceSchema>;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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`,
},
}}
/>

View File

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

View File

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

View File

@@ -0,0 +1,109 @@
import {
AllowanceType,
FeatureType,
FullCusEntWithFullCusProduct,
} from "@autumn/shared";
import { useCustomerContext } from "../CustomerContext";
const BalanceWrapper = ({ children }: { children: React.ReactNode }) => {
return (
<div className="flex items-center font-mono font-medium rounded-md border-b border-stone-300 border-dashed ">
{children}
</div>
);
};
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 <BalanceWrapper>Unlimited</BalanceWrapper>;
}
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 (
<BalanceWrapper>
<p>
{entityBalance}
{rolloverAmount > 0 && (
<span className="text-t3">
{" + "}
{rolloverAmount} (rolled over)
</span>
)}
</p>
</BalanceWrapper>
);
}
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 (
<BalanceWrapper>
<p>
{totalBalance}
{rolloverAmount > 0 && (
<span className="text-t3">
{" + "}
{rolloverAmount} (rolled over)
</span>
)}
</p>
</BalanceWrapper>
);
}
const rolloverAmount = cusEnt.rollovers.reduce((sum, rollover) => {
return sum + (rollover.balance || 0);
}, 0);
return (
<BalanceWrapper>
<p>
{cusEnt.balance}
{rolloverAmount > 0 && (
<span className="text-t3"> + {rolloverAmount} (rolled over)</span>
)}
{cusEnt.replaceables.length > 0 && (
<span className="text-t3">
{` (${cusEnt.replaceables.length} free)`}
</span>
)}
</p>
</BalanceWrapper>
);
};

View File

@@ -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<FeatureType>(
@@ -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 = () => {
</Item>
)}
<Item className="col-span-3">
<div className="flex items-center font-mono font-medium rounded-md px-1 border-b border-stone-300 border-dashed ">
{entitlement.feature.type == FeatureType.Boolean ? (
<></>
) : allowanceType == AllowanceType.Unlimited ? (
"Unlimited"
) : entityId && cusEnt.entities?.[entityId] ? (
<div className="flex items-center gap-2">
{cusEnt.entities?.[entityId]?.balance}{" "}
</div>
) : (
<>
{cusEnt.balance}{" "}
<span className="text-t3">
{cusEnt.replaceables.length > 0
? ` (${cusEnt.replaceables.length} free)`
: ""}
</span>
</>
)}
</div>
<CusEntBalance cusEnt={cusEnt} />
</Item>
<Item className="col-span-3">

View File

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

View File

@@ -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 ? (

View File

@@ -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 (
<div className="w-full h-fit">
@@ -37,10 +48,10 @@ export const AdvancedItemConfig = () => {
<div
className={`overflow-hidden transition-all duration-150 ease-out ${
isOpen ? "max-h-72 opacity-100 mt-2" : "max-h-0 opacity-0"
isOpen ? "max-h-80 opacity-100 mt-2" : "max-h-0 opacity-0"
}`}
>
<div className="flex flex-col gap-4 p-4 bg-stone-100">
<div className="flex flex-col gap-4 p-4 bg-stone-100 ">
<ToggleButton
value={item.reset_usage_when_enabled}
setValue={() => {
@@ -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, theyll get 500 credits on upgrade. If false, theyll have 480."
buttonText="Reset existing usage when product is enabled"
className="text-t3 h-fit"
disabled={usageType === FeatureUsageType.Continuous}
disabled={
usageType === FeatureUsageType.Continuous ||
notNullish(item.config?.rollover)
}
/>
<div className="relative flex flex-row items-center gap-3 min-h-[35px]">
<div className="relative flex flex-row items-center gap-3">
<ToggleButton
value={item.usage_limit != null}
setValue={() => {
@@ -98,6 +112,14 @@ export const AdvancedItemConfig = () => {
)}
{/* <div className="flex flex-col gap-2"></div>
<div className="flex gap-2"></div> */}
{showRolloverConfig && (
<RolloverConfigView
item={item}
setItem={setItem}
showRolloverConfig={showRolloverConfig}
/>
)}
</div>
</div>
</div>

View File

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

View File

@@ -38,12 +38,14 @@ export default function FeaturePrice() {
};
const handlePriceRemoved = () => {
setItem(
FeatureItemSchema.parse({
...item,
included_usage: item.included_usage || 0,
}),
);
setItem({
feature_id: item.feature_id,
included_usage: item.included_usage || 0,
interval: item.interval,
entity_feature_id: item.entity_feature_id,
reset_usage_when_enabled: item.reset_usage_when_enabled,
config: item.config,
});
};
const handleRemoveTier = (index: number) => {
const newTiers = [...item.tiers];
@@ -88,7 +90,7 @@ export default function FeaturePrice() {
<div
className={cn(
"flex w-full text-sm",
tier.to == -1 && "bg-transparent",
tier.to == -1 && "bg-transparent"
)}
>
<UsageTierInput
@@ -104,7 +106,7 @@ export default function FeaturePrice() {
<div
className={cn(
"flex text-sm",
item.tiers?.length == 1 ? "w-full" : "w-32",
item.tiers?.length == 1 ? "w-full" : "w-32"
)}
>
<UsageTierInput