feat: 🎸 lots of stuff
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
AllowanceType,
|
||||
AppEnv,
|
||||
EntInterval,
|
||||
FullCusEntWithProduct,
|
||||
Organization,
|
||||
AllowanceType,
|
||||
AppEnv,
|
||||
EntInterval,
|
||||
FullCusEntWithProduct,
|
||||
Organization,
|
||||
} from "@autumn/shared";
|
||||
import { CusEntService } from "./internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
|
||||
|
||||
@@ -15,8 +15,8 @@ import chalk from "chalk";
|
||||
import { format, getDate, getMonth, setDate } from "date-fns";
|
||||
import { CronJob } from "cron";
|
||||
import {
|
||||
getRelatedCusPrice,
|
||||
getResetBalance,
|
||||
getRelatedCusPrice,
|
||||
getResetBalance,
|
||||
} from "./internal/customers/cusProducts/cusEnts/cusEntUtils.js";
|
||||
import { getResetBalancesUpdate } from "./internal/customers/cusProducts/cusEnts/groupByUtils.js";
|
||||
import { getRolloverUpdates } from "./internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.js";
|
||||
@@ -24,6 +24,7 @@ import { CusProductService } from "./internal/customers/cusProducts/CusProductSe
|
||||
import { createStripeCli } from "./external/stripe/utils.js";
|
||||
import { UTCDate } from "@date-fns/utc";
|
||||
import { type DrizzleCli, initDrizzle } from "./db/initDrizzle.js";
|
||||
import { notNullish } from "./utils/genUtils.js";
|
||||
|
||||
import { CusPriceService } from "./internal/customers/cusProducts/cusPrices/CusPriceService.js";
|
||||
import { RolloverService } from "./internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.js";
|
||||
@@ -31,271 +32,274 @@ import { RolloverService } from "./internal/customers/cusProducts/cusEnts/cusRol
|
||||
dotenv.config();
|
||||
|
||||
const checkSubAnchor = async ({
|
||||
db,
|
||||
cusEnt,
|
||||
nextResetAt,
|
||||
db,
|
||||
cusEnt,
|
||||
nextResetAt,
|
||||
}: {
|
||||
db: DrizzleCli;
|
||||
cusEnt: FullCusEntWithProduct;
|
||||
nextResetAt: number;
|
||||
db: DrizzleCli;
|
||||
cusEnt: FullCusEntWithProduct;
|
||||
nextResetAt: number;
|
||||
}) => {
|
||||
let nextResetAtDate = new UTCDate(nextResetAt);
|
||||
let nextResetAtDate = new UTCDate(nextResetAt);
|
||||
|
||||
// If nextResetAt is on the 28th of March, or Day 30, then do this check.
|
||||
const nextResetAtDay = getDate(nextResetAtDate);
|
||||
const nextResetAtMonth = getMonth(nextResetAtDate);
|
||||
// If nextResetAt is on the 28th of March, or Day 30, then do this check.
|
||||
const nextResetAtDay = getDate(nextResetAtDate);
|
||||
const nextResetAtMonth = getMonth(nextResetAtDate);
|
||||
|
||||
const shouldCheck =
|
||||
nextResetAtDay === 30 || (nextResetAtDay === 28 && nextResetAtMonth === 2);
|
||||
const shouldCheck =
|
||||
nextResetAtDay === 30 ||
|
||||
(nextResetAtDay === 28 && nextResetAtMonth === 2);
|
||||
|
||||
if (!shouldCheck) {
|
||||
return nextResetAt;
|
||||
}
|
||||
if (!shouldCheck) {
|
||||
return nextResetAt;
|
||||
}
|
||||
|
||||
// 1. Get the customer product
|
||||
const cusProduct = await CusProductService.getByIdForReset({
|
||||
db,
|
||||
id: cusEnt.customer_product_id,
|
||||
});
|
||||
// 1. Get the customer product
|
||||
const cusProduct = await CusProductService.getByIdForReset({
|
||||
db,
|
||||
id: cusEnt.customer_product_id,
|
||||
});
|
||||
|
||||
// Get org and env
|
||||
const env = cusProduct.product.env as AppEnv;
|
||||
const org = cusProduct.product.org as Organization;
|
||||
// Get org and env
|
||||
const env = cusProduct.product.env as AppEnv;
|
||||
const org = cusProduct.product.org as Organization;
|
||||
|
||||
const stripeCli = createStripeCli({ org, env });
|
||||
if (!cusProduct.subscription_ids || cusProduct.subscription_ids.length == 0) {
|
||||
return nextResetAt;
|
||||
}
|
||||
const stripeCli = createStripeCli({ org, env });
|
||||
if (
|
||||
!cusProduct.subscription_ids ||
|
||||
cusProduct.subscription_ids.length == 0
|
||||
) {
|
||||
return nextResetAt;
|
||||
}
|
||||
|
||||
const subId = cusProduct.subscription_ids[0];
|
||||
const sub = await stripeCli.subscriptions.retrieve(subId);
|
||||
const subId = cusProduct.subscription_ids[0];
|
||||
const sub = await stripeCli.subscriptions.retrieve(subId);
|
||||
|
||||
const billingCycleAnchor = sub.billing_cycle_anchor * 1000;
|
||||
console.log("Checking billing cycle anchor");
|
||||
console.log(
|
||||
"Next reset at ",
|
||||
format(new UTCDate(nextResetAt), "dd MMM yyyy HH:mm:ss")
|
||||
);
|
||||
console.log(
|
||||
"Billing cycle anchor",
|
||||
format(new UTCDate(billingCycleAnchor), "dd MMM yyyy HH:mm:ss")
|
||||
);
|
||||
const billingCycleAnchor = sub.billing_cycle_anchor * 1000;
|
||||
console.log("Checking billing cycle anchor");
|
||||
console.log(
|
||||
"Next reset at ",
|
||||
format(new UTCDate(nextResetAt), "dd MMM yyyy HH:mm:ss")
|
||||
);
|
||||
console.log(
|
||||
"Billing cycle anchor",
|
||||
format(new UTCDate(billingCycleAnchor), "dd MMM yyyy HH:mm:ss")
|
||||
);
|
||||
|
||||
const billingCycleDay = getDate(new UTCDate(billingCycleAnchor));
|
||||
const nextResetDay = getDate(nextResetAtDate);
|
||||
const billingCycleDay = getDate(new UTCDate(billingCycleAnchor));
|
||||
const nextResetDay = getDate(nextResetAtDate);
|
||||
|
||||
if (billingCycleDay > nextResetDay) {
|
||||
nextResetAtDate = setDate(nextResetAtDate, billingCycleDay);
|
||||
return nextResetAtDate.getTime();
|
||||
} else {
|
||||
return nextResetAt;
|
||||
}
|
||||
if (billingCycleDay > nextResetDay) {
|
||||
nextResetAtDate = setDate(nextResetAtDate, billingCycleDay);
|
||||
return nextResetAtDate.getTime();
|
||||
} else {
|
||||
return nextResetAt;
|
||||
}
|
||||
};
|
||||
|
||||
const resetCustomerEntitlement = async ({
|
||||
db,
|
||||
cusEnt,
|
||||
db,
|
||||
cusEnt,
|
||||
}: {
|
||||
db: DrizzleCli;
|
||||
cusEnt: FullCusEntWithProduct;
|
||||
db: DrizzleCli;
|
||||
cusEnt: FullCusEntWithProduct;
|
||||
}) => {
|
||||
try {
|
||||
if (cusEnt.usage_allowed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (cusEnt.usage_allowed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch related price
|
||||
const cusPrices = await CusPriceService.getByCustomerProductId({
|
||||
db,
|
||||
customerProductId: cusEnt.customer_product_id,
|
||||
});
|
||||
// Fetch related price
|
||||
const cusPrices = await CusPriceService.getByCustomerProductId({
|
||||
db,
|
||||
customerProductId: cusEnt.customer_product_id,
|
||||
});
|
||||
|
||||
// 2. Quantity is from prices...
|
||||
const relatedCusPrice = getRelatedCusPrice(cusEnt, cusPrices);
|
||||
if (relatedCusPrice) {
|
||||
return;
|
||||
}
|
||||
// 2. Quantity is from prices...
|
||||
const relatedCusPrice = getRelatedCusPrice(cusEnt, cusPrices);
|
||||
if (relatedCusPrice) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entOptions = getEntOptions(
|
||||
cusEnt.customer_product.options,
|
||||
cusEnt.entitlement
|
||||
);
|
||||
const entOptions = getEntOptions(
|
||||
cusEnt.customer_product.options,
|
||||
cusEnt.entitlement
|
||||
);
|
||||
|
||||
const resetBalance = getResetBalance({
|
||||
entitlement: cusEnt.entitlement,
|
||||
options: entOptions,
|
||||
relatedPrice: undefined,
|
||||
// relatedPrice: relatedCusPrice,
|
||||
});
|
||||
const resetBalance = getResetBalance({
|
||||
entitlement: cusEnt.entitlement,
|
||||
options: entOptions,
|
||||
relatedPrice: undefined,
|
||||
// relatedPrice: relatedCusPrice,
|
||||
});
|
||||
|
||||
// Handle if entitlement changed to unlimited...
|
||||
let entitlement = cusEnt.entitlement;
|
||||
if (entitlement.allowance_type === AllowanceType.Unlimited) {
|
||||
await CusEntService.update({
|
||||
db,
|
||||
id: cusEnt.id,
|
||||
updates: {
|
||||
unlimited: true,
|
||||
next_reset_at: null,
|
||||
},
|
||||
});
|
||||
// Handle if entitlement changed to unlimited...
|
||||
let entitlement = cusEnt.entitlement;
|
||||
if (entitlement.allowance_type === AllowanceType.Unlimited) {
|
||||
await CusEntService.update({
|
||||
db,
|
||||
id: cusEnt.id,
|
||||
updates: {
|
||||
unlimited: true,
|
||||
next_reset_at: null,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Reset ${cusEnt.id} | customer: ${chalk.yellow(
|
||||
cusEnt.customer_id
|
||||
)} | feature: ${chalk.yellow(
|
||||
cusEnt.feature_id
|
||||
)} | new balance: unlimited`
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
`Reset ${cusEnt.id} | customer: ${chalk.yellow(
|
||||
cusEnt.customer_id
|
||||
)} | feature: ${chalk.yellow(
|
||||
cusEnt.feature_id
|
||||
)} | new balance: unlimited`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entitlement.interval === EntInterval.Lifetime) {
|
||||
await CusEntService.update({
|
||||
db,
|
||||
id: cusEnt.id,
|
||||
updates: {
|
||||
next_reset_at: null,
|
||||
},
|
||||
});
|
||||
if (entitlement.interval === EntInterval.Lifetime) {
|
||||
await CusEntService.update({
|
||||
db,
|
||||
id: cusEnt.id,
|
||||
updates: {
|
||||
next_reset_at: null,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Reset ${cusEnt.id} | customer: ${chalk.yellow(
|
||||
cusEnt.customer_id
|
||||
)} | feature: ${chalk.yellow(
|
||||
cusEnt.feature_id
|
||||
)} | reset to lifetime (next_reset_at: null)`
|
||||
);
|
||||
return;
|
||||
}
|
||||
let nextResetAt = getNextResetAt(
|
||||
new UTCDate(cusEnt.next_reset_at!),
|
||||
cusEnt.entitlement.interval as EntInterval
|
||||
);
|
||||
console.log(
|
||||
`Reset ${cusEnt.id} | customer: ${chalk.yellow(
|
||||
cusEnt.customer_id
|
||||
)} | feature: ${chalk.yellow(
|
||||
cusEnt.feature_id
|
||||
)} | reset to lifetime (next_reset_at: null)`
|
||||
);
|
||||
return;
|
||||
}
|
||||
let nextResetAt = getNextResetAt(
|
||||
new UTCDate(cusEnt.next_reset_at!),
|
||||
cusEnt.entitlement.interval as EntInterval
|
||||
);
|
||||
|
||||
let rolloverUpdate = getRolloverUpdates({
|
||||
cusEnt,
|
||||
nextResetAt: cusEnt.next_reset_at! as number,
|
||||
});
|
||||
let rolloverUpdate = getRolloverUpdates({
|
||||
cusEnt,
|
||||
nextResetAt: cusEnt.next_reset_at! as number,
|
||||
});
|
||||
|
||||
let resetBalanceUpdate = getResetBalancesUpdate({
|
||||
cusEnt,
|
||||
allowance: resetBalance || undefined,
|
||||
});
|
||||
let resetBalanceUpdate = getResetBalancesUpdate({
|
||||
cusEnt,
|
||||
allowance: resetBalance || undefined,
|
||||
});
|
||||
|
||||
console.log(
|
||||
"Rollover update recieved in cron.ts/resetCustomerEntitlement",
|
||||
rolloverUpdate
|
||||
);
|
||||
|
||||
console.log(
|
||||
"Rollover update received in cron.ts/resetCustomerEntitlement:",
|
||||
rolloverUpdate.toInsert.map((rollover) => ({
|
||||
id: rollover.id,
|
||||
balance: rollover.balance,
|
||||
entities: rollover.entities.map((entity) => `${entity.id}: ${entity.balance}`).join(", "),
|
||||
expires_at: rollover.expires_at ? new Date(rollover.expires_at).toISOString() : null,
|
||||
}))
|
||||
);
|
||||
try {
|
||||
nextResetAt = await checkSubAnchor({
|
||||
db,
|
||||
cusEnt,
|
||||
nextResetAt,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("WARNING: Failed to check sub anchor");
|
||||
console.log(error);
|
||||
}
|
||||
|
||||
await CusEntService.update({
|
||||
db,
|
||||
id: cusEnt.id,
|
||||
updates: {
|
||||
...resetBalanceUpdate,
|
||||
next_reset_at: nextResetAt,
|
||||
adjustment: 0,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
nextResetAt = await checkSubAnchor({
|
||||
db,
|
||||
cusEnt,
|
||||
nextResetAt,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("WARNING: Failed to check sub anchor");
|
||||
console.log(error);
|
||||
}
|
||||
let rolloverRows: any[] = [];
|
||||
if (rolloverUpdate?.toInsert && rolloverUpdate.toInsert.length > 0) {
|
||||
rolloverRows = await RolloverService.insert({
|
||||
db,
|
||||
rows: rolloverUpdate.toInsert,
|
||||
rolloverConfig: cusEnt.entitlement.rollover,
|
||||
cusEntID: cusEnt.id,
|
||||
entityMode: notNullish(cusEnt.entitlement.entity_feature_id),
|
||||
});
|
||||
}
|
||||
|
||||
await CusEntService.update({
|
||||
db,
|
||||
id: cusEnt.id,
|
||||
updates: {
|
||||
...resetBalanceUpdate,
|
||||
next_reset_at: nextResetAt,
|
||||
adjustment: 0,
|
||||
},
|
||||
});
|
||||
console.log(
|
||||
"Rollover rows",
|
||||
Object.values(rolloverRows).map(
|
||||
(x) =>
|
||||
`${x.id}: ${x.balance} | entities: ${x.entities.map((y: any) => `${y.id}: ${y.balance}`).join(", ")}`
|
||||
)
|
||||
);
|
||||
|
||||
let rolloverRows: any[] = [];
|
||||
if (rolloverUpdate?.toInsert && rolloverUpdate.toInsert.length > 0) {
|
||||
rolloverRows = await RolloverService.insert({
|
||||
db,
|
||||
rows: rolloverUpdate.toInsert,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(
|
||||
"Rollover rows",
|
||||
Object.values(rolloverRows).map((x) => `${x.id}: ${x.balance} | entities: ${x.entities.map((y: any) => `${y.id}: ${y.balance}`).join(", ")}`)
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Reset ${cusEnt.id} | customer: ${chalk.yellow(
|
||||
cusEnt.customer_id
|
||||
)} | feature: ${chalk.yellow(
|
||||
cusEnt.feature_id
|
||||
)} | new balance: ${chalk.green(
|
||||
resetBalance
|
||||
)} | new next_reset_at: ${chalk.green(
|
||||
format(new UTCDate(nextResetAt), "dd MMM yyyy HH:mm:ss")
|
||||
)}`
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.log(
|
||||
`Failed to reset ${cusEnt.id} | ${cusEnt.customer_id} | ${cusEnt.feature_id}, error: ${error}`
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`Reset ${cusEnt.id} | customer: ${chalk.yellow(
|
||||
cusEnt.customer_id
|
||||
)} | feature: ${chalk.yellow(
|
||||
cusEnt.feature_id
|
||||
)} | new balance: ${chalk.green(
|
||||
resetBalance
|
||||
)} | new next_reset_at: ${chalk.green(
|
||||
format(new UTCDate(nextResetAt), "dd MMM yyyy HH:mm:ss")
|
||||
)}`
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.log(
|
||||
`Failed to reset ${cusEnt.id} | ${cusEnt.customer_id} | ${cusEnt.feature_id}, error: ${error}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const cronTask = async () => {
|
||||
console.log(
|
||||
"\n----------------------------------\nRUNNING RESET CRON:",
|
||||
format(new UTCDate(), "yyyy-MM-dd HH:mm:ss")
|
||||
);
|
||||
console.log(
|
||||
"\n----------------------------------\nRUNNING RESET CRON:",
|
||||
format(new UTCDate(), "yyyy-MM-dd HH:mm:ss")
|
||||
);
|
||||
|
||||
const { db, client } = initDrizzle();
|
||||
const { db, client } = initDrizzle();
|
||||
|
||||
try {
|
||||
let cusEnts: FullCusEntWithProduct[] =
|
||||
await CusEntService.getActiveResetPassed({ db, batchSize: 500 });
|
||||
try {
|
||||
let cusEnts: FullCusEntWithProduct[] =
|
||||
await CusEntService.getActiveResetPassed({ db, batchSize: 500 });
|
||||
|
||||
const batchSize = 50;
|
||||
for (let i = 0; i < cusEnts.length; i += batchSize) {
|
||||
const batch = cusEnts.slice(i, i + batchSize);
|
||||
const batchResets = [];
|
||||
for (const cusEnt of batch) {
|
||||
batchResets.push(
|
||||
resetCustomerEntitlement({
|
||||
db,
|
||||
cusEnt: cusEnt as FullCusEntWithProduct,
|
||||
})
|
||||
);
|
||||
}
|
||||
const batchSize = 50;
|
||||
for (let i = 0; i < cusEnts.length; i += batchSize) {
|
||||
const batch = cusEnts.slice(i, i + batchSize);
|
||||
const batchResets = [];
|
||||
for (const cusEnt of batch) {
|
||||
batchResets.push(
|
||||
resetCustomerEntitlement({
|
||||
db,
|
||||
cusEnt: cusEnt as FullCusEntWithProduct,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(batchResets);
|
||||
}
|
||||
await Promise.all(batchResets);
|
||||
}
|
||||
|
||||
console.log(
|
||||
"FINISHED RESET CRON:",
|
||||
format(new UTCDate(), "yyyy-MM-dd HH:mm:ss")
|
||||
);
|
||||
console.log("----------------------------------\n");
|
||||
} catch (error) {
|
||||
console.error("Error getting entitlements for reset:", error);
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
"FINISHED RESET CRON:",
|
||||
format(new UTCDate(), "yyyy-MM-dd HH:mm:ss")
|
||||
);
|
||||
console.log("----------------------------------\n");
|
||||
} catch (error) {
|
||||
console.error("Error getting entitlements for reset:", error);
|
||||
return;
|
||||
}
|
||||
|
||||
await client.end();
|
||||
await client.end();
|
||||
};
|
||||
|
||||
const job = new CronJob(
|
||||
"* * * * *", // Run every minute
|
||||
function () {
|
||||
cronTask();
|
||||
},
|
||||
null, // onComplete
|
||||
true, // start immediately
|
||||
"UTC" // timezone (adjust as needed)
|
||||
"* * * * *", // Run every minute
|
||||
function () {
|
||||
cronTask();
|
||||
},
|
||||
null, // onComplete
|
||||
true, // start immediately
|
||||
"UTC" // timezone (adjust as needed)
|
||||
);
|
||||
|
||||
// job.start();
|
||||
|
||||
@@ -130,6 +130,9 @@ export const handlePrepaidPrices = async ({
|
||||
rolloverRows = await RolloverService.insert({
|
||||
db,
|
||||
rows: rolloverUpdate.toInsert,
|
||||
rolloverConfig: ent.rollover,
|
||||
cusEntID: cusEnt.id,
|
||||
entityMode: notNullish(ent.entity_feature_id),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { getCusPriceUsage } from "@/internal/customers/cusProducts/cusPrices/cus
|
||||
import { findStripeItemForPrice } from "../../stripeSubUtils/stripeSubItemUtils.js";
|
||||
import { getRolloverUpdates } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.js";
|
||||
import { RolloverService } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.js";
|
||||
import { notNullish } from "@/utils/genUtils.js";
|
||||
|
||||
export const handleUsagePrices = async ({
|
||||
db,
|
||||
@@ -167,6 +168,9 @@ export const handleUsagePrices = async ({
|
||||
rolloverRows = await RolloverService.insert({
|
||||
db,
|
||||
rows: rolloverUpdate.toInsert,
|
||||
rolloverConfig: ent.rollover,
|
||||
cusEntID: ent.id,
|
||||
entityMode: notNullish(ent.entity_feature_id),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { RolloverModel, rollovers } from "@autumn/shared";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import {
|
||||
Rollover as RolloverConfig,
|
||||
RolloverModel,
|
||||
rollovers,
|
||||
} from "@autumn/shared";
|
||||
import { and, eq, gte, inArray } from "drizzle-orm";
|
||||
import { performMaximumClearing } from "./rolloverUtils.js";
|
||||
|
||||
export class RolloverService {
|
||||
static async update({
|
||||
@@ -21,24 +26,135 @@ export class RolloverService {
|
||||
return data;
|
||||
}
|
||||
|
||||
static async insert({
|
||||
static async bulkUpdate({
|
||||
db,
|
||||
rows,
|
||||
}: {
|
||||
db: DrizzleCli;
|
||||
rows: RolloverModel[];
|
||||
}) {
|
||||
const data = await db.insert(rollovers).values(rows as any).returning();
|
||||
return data;
|
||||
return await db.transaction(async (tx) => {
|
||||
const results = [];
|
||||
for (const row of rows) {
|
||||
const result = await tx
|
||||
.update(rollovers)
|
||||
.set(row as any)
|
||||
.where(eq(rollovers.id, row.id))
|
||||
.returning();
|
||||
results.push(...result);
|
||||
}
|
||||
return results;
|
||||
});
|
||||
}
|
||||
|
||||
static async delete({
|
||||
static async insert({
|
||||
db,
|
||||
ids,
|
||||
rows,
|
||||
rolloverConfig,
|
||||
cusEntID,
|
||||
entityMode,
|
||||
}: {
|
||||
db: DrizzleCli;
|
||||
ids: string[];
|
||||
rows: RolloverModel[];
|
||||
rolloverConfig: RolloverConfig;
|
||||
cusEntID: string;
|
||||
entityMode: boolean;
|
||||
}) {
|
||||
const data = await db.delete(rollovers).where(inArray(rollovers.id, ids));
|
||||
const data = await db
|
||||
.insert(rollovers)
|
||||
.values(rows as any)
|
||||
.returning();
|
||||
|
||||
console.log("🔍 cusEntID", cusEntID, data[0].cus_ent_id);
|
||||
|
||||
const currentRolloverRows = await db
|
||||
.select()
|
||||
.from(rollovers)
|
||||
.where(
|
||||
and(
|
||||
eq(rollovers.cus_ent_id, cusEntID),
|
||||
gte(rollovers.expires_at, new Date().getTime())
|
||||
)
|
||||
);
|
||||
|
||||
console.log("🔍 rolloverCusEnt:");
|
||||
currentRolloverRows.forEach((rollover, index) => {
|
||||
console.log(` [${index}] ID: ${rollover.id}`);
|
||||
console.log(` Customer Entity ID: ${rollover.cus_ent_id}`);
|
||||
console.log(` Balance: ${rollover.balance}`);
|
||||
console.log(
|
||||
` Expires At: ${new Date(rollover.expires_at).toISOString()}`
|
||||
);
|
||||
if (rollover.entities && Array.isArray(rollover.entities)) {
|
||||
console.log(
|
||||
` Entities: ${rollover.entities.length} items`
|
||||
);
|
||||
rollover.entities.forEach(
|
||||
(entity: any, entityIndex: number) => {
|
||||
console.log(
|
||||
` [${entityIndex}] ID: ${entity.id}, Balance: ${entity.balance}, Adjustment: ${entity.adjustment}`
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
console.log("");
|
||||
});
|
||||
|
||||
console.log("🔍 rolloverConfig:");
|
||||
console.log(` Max: ${rolloverConfig?.max}`);
|
||||
console.log(` Length: ${rolloverConfig?.length}`);
|
||||
console.log(` Duration: ${rolloverConfig?.duration}`);
|
||||
|
||||
let { toDelete, toUpdate } = await performMaximumClearing({
|
||||
rows: currentRolloverRows as RolloverModel[],
|
||||
rolloverConfig,
|
||||
cusEntID,
|
||||
entityMode,
|
||||
});
|
||||
|
||||
if (toDelete.length > 0) {
|
||||
await RolloverService.delete({ db, ids: toDelete });
|
||||
}
|
||||
|
||||
if (toUpdate.length > 0) {
|
||||
await RolloverService.bulkUpdate({ db, rows: toUpdate });
|
||||
}
|
||||
|
||||
// Update data in memory to reflect the changes made by performMaximumClearing
|
||||
let updatedData = [...data];
|
||||
|
||||
// Remove deleted items from the data
|
||||
if (toDelete.length > 0) {
|
||||
updatedData = updatedData.filter(item => !toDelete.includes(item.id));
|
||||
}
|
||||
|
||||
// Update modified items in the data
|
||||
if (toUpdate.length > 0) {
|
||||
updatedData = updatedData.map(item => {
|
||||
const updateItem = toUpdate.find(update => update.id === item.id);
|
||||
if (updateItem) {
|
||||
// Ensure entities have the correct structure with adjustment property
|
||||
const updatedEntities = updateItem.entities?.map(entity => ({
|
||||
id: entity.id,
|
||||
balance: entity.balance,
|
||||
})) ?? null;
|
||||
|
||||
return {
|
||||
...item,
|
||||
...updateItem,
|
||||
entities: updatedEntities
|
||||
};
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
return updatedData;
|
||||
}
|
||||
}
|
||||
|
||||
static async delete({ db, ids }: { db: DrizzleCli; ids: string[] }) {
|
||||
const data = await db
|
||||
.delete(rollovers)
|
||||
.where(inArray(rollovers.id, ids));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,11 +84,11 @@ export const getRolloverUpdates = ({
|
||||
console.log("🔍 entityId:", entityId, "entRollover:", entRollover);
|
||||
} else console.log("🔍 no rollover for entityId:", entityId, " | entitlement:", entitlement, " | balance:", cusEnt.entities[entityId].balance);
|
||||
}
|
||||
update.toInsert.push(newEntitlement);
|
||||
if(newEntitlement.entities.length > 0) update.toInsert.push(newEntitlement);
|
||||
} else {
|
||||
if (rollover > 0) {
|
||||
newEntitlement.balance = rollover;
|
||||
update.toInsert.push(newEntitlement);
|
||||
if(newEntitlement.balance > 0) update.toInsert.push(newEntitlement);
|
||||
} else console.log("🔍 no rollover for entitlement: ", cusEnt.id, " | rollable balance:", rollover);
|
||||
}
|
||||
|
||||
@@ -117,3 +117,199 @@ export const calculateNextExpiry = (nextResetAt: number, config: Rollover) => {
|
||||
|
||||
return nextExpiry.getTime();
|
||||
};
|
||||
|
||||
export async function performMaximumClearing({
|
||||
rows,
|
||||
rolloverConfig,
|
||||
cusEntID,
|
||||
entityMode,
|
||||
}: {
|
||||
rows: RolloverModel[];
|
||||
rolloverConfig: Rollover;
|
||||
cusEntID: string;
|
||||
entityMode: boolean;
|
||||
}) {
|
||||
if (!rolloverConfig) {
|
||||
throw new Error("Rollover config is required");
|
||||
}
|
||||
|
||||
let total = 0;
|
||||
let toDelete: string[] = [];
|
||||
let toUpdate: RolloverModel[] = [];
|
||||
|
||||
// look through each row
|
||||
// if entityMode is true, then look through each entity
|
||||
// otherwise look at balance
|
||||
|
||||
// sort by the oldest first
|
||||
// add the balance of the oldest to the total
|
||||
// if the total is greater than or equal to the max, then:
|
||||
// subtract the max from the total, if theres a difference then instantiate the updated row object and push to toUpdate
|
||||
// if theres no difference, then push to toDelete
|
||||
// move to the next row
|
||||
// if the total is less than the max, then
|
||||
// move to the next row
|
||||
|
||||
if (!entityMode) {
|
||||
console.log(`🔄 Starting maximum clearing for cusEnt ${cusEntID} in non-entity mode`);
|
||||
console.log(`📊 Initial rows count: ${rows.length}`);
|
||||
console.log(`🎯 Maximum rollover allowed: ${rolloverConfig.max}`);
|
||||
|
||||
rows.sort((a, b) => a.expires_at - b.expires_at);
|
||||
console.log(`📅 Sorted rows by expiry date (oldest first)`);
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
let row = rows[i];
|
||||
console.log(`\n🔍 Processing row ${i + 1}/${rows.length}:`);
|
||||
console.log(` - Row ID: ${row.id}`);
|
||||
console.log(` - Row balance: ${row.balance}`);
|
||||
console.log(` - Expires at: ${new Date(row.expires_at).toISOString()}`);
|
||||
console.log(` - Total before adding this row: ${total}`);
|
||||
|
||||
total += row.balance;
|
||||
console.log(` - Total after adding this row: ${total}`);
|
||||
|
||||
if (total > rolloverConfig.max) {
|
||||
let diff = total - rolloverConfig.max;
|
||||
console.log(` - ⚠️ Total exceeds maximum (${rolloverConfig.max})`);
|
||||
console.log(` - Difference to remove: ${diff}`);
|
||||
|
||||
let newBalance = row.balance - diff;
|
||||
if (newBalance > 0) {
|
||||
console.log(` - ✏️ Updating row balance from ${row.balance} to ${newBalance}`);
|
||||
toUpdate.push({
|
||||
...row,
|
||||
balance: newBalance,
|
||||
});
|
||||
} else {
|
||||
console.log(` - 🗑️ Marking row for deletion (no remaining balance)`);
|
||||
toDelete.push(row.id);
|
||||
}
|
||||
} else {
|
||||
console.log(` - ✅ Total still under maximum, continuing to next row`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n📋 Maximum clearing summary for cusEnt ${cusEntID}:`);
|
||||
console.log(` - Final total: ${total}`);
|
||||
console.log(` - Rows to update: ${toUpdate.length}`);
|
||||
console.log(` - Rows to delete: ${toDelete.length}`);
|
||||
if (toUpdate.length > 0) {
|
||||
console.log(` - Updated balances: ${toUpdate.map(r => `${r.id}: ${r.balance}`).join(', ')}`);
|
||||
}
|
||||
if (toDelete.length > 0) {
|
||||
console.log(` - Deleted row IDs: ${toDelete.join(', ')}`);
|
||||
}
|
||||
} else {
|
||||
console.log(`🔄 Starting maximum clearing for cusEnt ${cusEntID} in entity mode`);
|
||||
console.log(`📊 Initial rows count: ${rows.length}`);
|
||||
console.log(`🎯 Maximum rollover allowed: ${rolloverConfig.max}`);
|
||||
|
||||
// Collect all unique entity IDs across all rows
|
||||
const allEntityIds = new Set<string>();
|
||||
rows.forEach(row => {
|
||||
if (row.entities && Array.isArray(row.entities)) {
|
||||
row.entities.forEach((entity: any) => {
|
||||
if (entity.id) {
|
||||
allEntityIds.add(entity.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`🔍 Found ${allEntityIds.size} unique entity IDs: ${Array.from(allEntityIds).join(', ')}`);
|
||||
|
||||
// Sort rows by expiry date (oldest first)
|
||||
rows.sort((a, b) => a.expires_at - b.expires_at);
|
||||
console.log(`📅 Sorted rows by expiry date (oldest first)`);
|
||||
|
||||
// Track totals per entity ID
|
||||
const entityTotals = new Map<string, number>();
|
||||
allEntityIds.forEach(id => entityTotals.set(id, 0));
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
let row = rows[i];
|
||||
console.log(`\n🔍 Processing row ${i + 1}/${rows.length}:`);
|
||||
console.log(` - Row ID: ${row.id}`);
|
||||
console.log(` - Expires at: ${new Date(row.expires_at).toISOString()}`);
|
||||
|
||||
if (!row.entities || !Array.isArray(row.entities)) {
|
||||
console.log(` - ⚠️ Row has no entities array, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let rowNeedsUpdate = false;
|
||||
let updatedEntities = [...row.entities];
|
||||
|
||||
// Process each entity in this row
|
||||
for (let j = 0; j < updatedEntities.length; j++) {
|
||||
const entity = updatedEntities[j];
|
||||
if (!entity.id || !entity.balance) {
|
||||
console.log(` - ⚠️ Entity missing id or balance, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentTotal = entityTotals.get(entity.id) || 0;
|
||||
const newTotal = currentTotal + entity.balance;
|
||||
|
||||
console.log(` - Entity ${entity.id}: balance=${entity.balance}, currentTotal=${currentTotal}, newTotal=${newTotal}`);
|
||||
|
||||
if (newTotal > rolloverConfig.max) {
|
||||
const excess = newTotal - rolloverConfig.max;
|
||||
const newBalance = entity.balance - excess;
|
||||
|
||||
console.log(` - ⚠️ Total exceeds maximum (${rolloverConfig.max})`);
|
||||
console.log(` - Excess to remove: ${excess}`);
|
||||
console.log(` - Updating entity balance from ${entity.balance} to ${newBalance}`);
|
||||
|
||||
if (newBalance > 0) {
|
||||
updatedEntities[j] = { ...entity, balance: newBalance };
|
||||
entityTotals.set(entity.id, rolloverConfig.max);
|
||||
rowNeedsUpdate = true;
|
||||
} else {
|
||||
console.log(` - 🗑️ Removing entity (no remaining balance)`);
|
||||
updatedEntities.splice(j, 1);
|
||||
j--; // Adjust index after removal
|
||||
entityTotals.set(entity.id, rolloverConfig.max);
|
||||
rowNeedsUpdate = true;
|
||||
}
|
||||
} else {
|
||||
entityTotals.set(entity.id, newTotal);
|
||||
console.log(` - ✅ Total still under maximum, continuing`);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine what to do with this row
|
||||
if (updatedEntities.length === 0) {
|
||||
console.log(` - 🗑️ Marking row for deletion (no entities remaining)`);
|
||||
toDelete.push(row.id);
|
||||
} else if (rowNeedsUpdate) {
|
||||
console.log(` - ✏️ Marking row for update (entities modified)`);
|
||||
toUpdate.push({
|
||||
...row,
|
||||
entities: updatedEntities,
|
||||
});
|
||||
} else {
|
||||
console.log(` - ✅ Row unchanged`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n📋 Maximum clearing summary for cusEnt ${cusEntID} (entity mode):`);
|
||||
console.log(` - Rows to update: ${toUpdate.length}`);
|
||||
console.log(` - Rows to delete: ${toDelete.length}`);
|
||||
console.log(` - Final entity totals:`);
|
||||
entityTotals.forEach((total, entityId) => {
|
||||
console.log(` - ${entityId}: ${total}`);
|
||||
});
|
||||
if (toUpdate.length > 0) {
|
||||
console.log(` - Updated row IDs: ${toUpdate.map(r => r.id).join(', ')}`);
|
||||
}
|
||||
if (toDelete.length > 0) {
|
||||
console.log(` - Deleted row IDs: ${toDelete.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// return the rows that were cleared
|
||||
return { toDelete, toUpdate };
|
||||
}
|
||||
@@ -26,7 +26,7 @@ const buildOptimizedCusProductsCTE = (inStatuses?: CusProductStatus[]) => {
|
||||
'[]'::json
|
||||
) AS customer_prices,
|
||||
|
||||
-- Spread customer_entitlements fields + add entitlement and replaceables
|
||||
-- Spread customer_entitlements fields + add entitlement, replaceables, and rollovers
|
||||
COALESCE(
|
||||
json_agg(DISTINCT (
|
||||
to_jsonb(ce.*) || jsonb_build_object(
|
||||
@@ -46,6 +46,14 @@ const buildOptimizedCusProductsCTE = (inStatuses?: CusProductStatus[]) => {
|
||||
)
|
||||
FROM replaceables r
|
||||
WHERE r.cus_ent_id = ce.id
|
||||
),
|
||||
'rollover', (
|
||||
SELECT COALESCE(
|
||||
json_agg(row_to_json(ro)) FILTER (WHERE ro.expires_at > EXTRACT(EPOCH FROM now()) * 1000),
|
||||
'[]'::json
|
||||
)
|
||||
FROM rollovers ro
|
||||
WHERE ro.cus_ent_id = ce.id
|
||||
)
|
||||
)
|
||||
)) FILTER (WHERE ce.id IS NOT NULL),
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
import { EntityBalance } from "../../cusProductModels/cusEntModels/cusEntModels.js";
|
||||
import { EntityBalance, EntityRolloverBalance } from "../../cusProductModels/cusEntModels/cusEntModels.js";
|
||||
import { customerEntitlements } from "../../cusProductModels/cusEntModels/cusEntTable.js";
|
||||
|
||||
export const rollovers = pgTable(
|
||||
@@ -18,7 +18,7 @@ export const rollovers = pgTable(
|
||||
cus_ent_id: text("cus_ent_id").notNull(),
|
||||
balance: numeric({ mode: "number" }).notNull(),
|
||||
expires_at: numeric({ mode: "number" }).notNull(),
|
||||
entities: jsonb("entities").$type<EntityBalance>(),
|
||||
entities: jsonb("entities").$type<EntityRolloverBalance[]>(),
|
||||
},
|
||||
(table) => [
|
||||
foreignKey({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { invalidNumber, notNullish } from "@/utils/genUtils";
|
||||
import { Feature, FeatureUsageType, ProductItem, ProductItemInterval } from "@autumn/shared";
|
||||
import { Feature, FeatureUsageType, ProductItem, ProductItemInterval, UsageModel } from "@autumn/shared";
|
||||
import { toast } from "sonner";
|
||||
import { isFeatureItem, isFeaturePriceItem } from "../getItemType";
|
||||
import { isOneOffProduct } from "../priceUtils";
|
||||
@@ -109,7 +109,6 @@ export const validateProductItem = ({
|
||||
return item;
|
||||
}
|
||||
|
||||
|
||||
if (invalidNumber(item.config.rollover.max)) {
|
||||
toast.error("Please enter a valid maximum rollover amount");
|
||||
item.config.rollover = undefined;
|
||||
@@ -139,6 +138,12 @@ export const validateProductItem = ({
|
||||
item.config.rollover = undefined;
|
||||
return null;
|
||||
}
|
||||
|
||||
if(item.entity_feature_id && item.usage_model === UsageModel.Prepaid) {
|
||||
toast.error("Prepaid products cannot have entity features");
|
||||
item.config.rollover = undefined;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user