feat: 🎸 lots of stuff

This commit is contained in:
amianthus
2025-07-24 13:20:29 +01:00
parent 4373e8a0c9
commit 3de3c5f151
8 changed files with 580 additions and 244 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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