Merge pull request #120 from SirTenzin/feat/rollovers
feat: 🎸 credit rollovers
This commit is contained in:
@@ -37,7 +37,7 @@
|
||||
"vite:start:bun": "bun -F @autumn/shared build && bun -F @autumn/vite start:bun",
|
||||
|
||||
|
||||
"dev:bun": "concurrently \"cd server && bun run dev: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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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:",
|
||||
|
||||
260
server/src/cron/cronUtils.ts
Normal file
260
server/src/cron/cronUtils.ts
Normal 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}`
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -154,7 +154,7 @@ export const handleCheckoutSessionCompleted = async ({
|
||||
product,
|
||||
org,
|
||||
env: attachParams.customer.env,
|
||||
subId: checkoutSession.subscription as string,
|
||||
subId: checkoutSub?.id as string,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
@@ -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()}` : ''}`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)!,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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(", ")}`);
|
||||
// }
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
197
server/tests/advanced/rollovers/rollover1.ts
Normal file
197
server/tests/advanced/rollovers/rollover1.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
227
server/tests/advanced/rollovers/rollover2.ts
Normal file
227
server/tests/advanced/rollovers/rollover2.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
126
server/tests/advanced/rollovers/rollover3.ts
Normal file
126
server/tests/advanced/rollovers/rollover3.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
157
server/tests/advanced/rollovers/rollover4.ts
Normal file
157
server/tests/advanced/rollovers/rollover4.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
140
server/tests/advanced/rollovers/rollover5.ts
Normal file
140
server/tests/advanced/rollovers/rollover5.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
154
server/tests/advanced/rollovers/rollover6.ts
Normal file
154
server/tests/advanced/rollovers/rollover6.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
48
server/tests/advanced/rollovers/rolloverTestUtils.ts
Normal file
48
server/tests/advanced/rollovers/rolloverTestUtils.ts
Normal 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;
|
||||
};
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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>;
|
||||
@@ -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],
|
||||
}),
|
||||
}));
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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`,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
109
vite/src/views/customers/customer/entitlements/CusEntBalance.tsx
Normal file
109
vite/src/views/customers/customer/entitlements/CusEntBalance.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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, they’ll get 500 credits on upgrade. If false, they’ll have 480."
|
||||
buttonText="Reset existing usage when product is enabled"
|
||||
className="text-t3 h-fit"
|
||||
disabled={usageType === FeatureUsageType.Continuous}
|
||||
disabled={
|
||||
usageType === FeatureUsageType.Continuous ||
|
||||
notNullish(item.config?.rollover)
|
||||
}
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -38,12 +38,14 @@ export default function FeaturePrice() {
|
||||
};
|
||||
|
||||
const handlePriceRemoved = () => {
|
||||
setItem(
|
||||
FeatureItemSchema.parse({
|
||||
...item,
|
||||
included_usage: item.included_usage || 0,
|
||||
}),
|
||||
);
|
||||
setItem({
|
||||
feature_id: item.feature_id,
|
||||
included_usage: item.included_usage || 0,
|
||||
interval: item.interval,
|
||||
entity_feature_id: item.entity_feature_id,
|
||||
reset_usage_when_enabled: item.reset_usage_when_enabled,
|
||||
config: item.config,
|
||||
});
|
||||
};
|
||||
const handleRemoveTier = (index: number) => {
|
||||
const newTiers = [...item.tiers];
|
||||
@@ -88,7 +90,7 @@ export default function FeaturePrice() {
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full text-sm",
|
||||
tier.to == -1 && "bg-transparent",
|
||||
tier.to == -1 && "bg-transparent"
|
||||
)}
|
||||
>
|
||||
<UsageTierInput
|
||||
@@ -104,7 +106,7 @@ export default function FeaturePrice() {
|
||||
<div
|
||||
className={cn(
|
||||
"flex text-sm",
|
||||
item.tiers?.length == 1 ? "w-full" : "w-32",
|
||||
item.tiers?.length == 1 ? "w-full" : "w-32"
|
||||
)}
|
||||
>
|
||||
<UsageTierInput
|
||||
|
||||
Reference in New Issue
Block a user