Files
cfw-autumn/server/src/trigger/updateUsageTask.ts
2025-10-06 14:16:51 +01:00

372 lines
7.4 KiB
TypeScript

import {
AllowanceType,
type AppEnv,
CusProductStatus,
type Customer,
type Feature,
FeatureType,
type FullCustomerEntitlement,
type Organization,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { CusService } from "@/internal/customers/CusService.js";
import { refreshCusCache } from "@/internal/customers/cusCache/updateCachedCus.js";
import { getFeatureBalance } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js";
import { deductFromApiCusRollovers } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverDeductionUtils.js";
import { getCusEntsInFeatures } from "@/internal/customers/cusUtils/cusUtils.js";
import { featureToCreditSystem } from "@/internal/features/creditSystemUtils.js";
import { handleThresholdReached } from "./handleThresholdReached.js";
import {
deductAllowanceFromCusEnt,
deductFromUsageBasedCusEnt,
} from "./updateBalanceTask.js";
// 2. Get deductions for each feature
const getFeatureDeductions = ({
cusEnts,
value,
features,
shouldSet,
}: {
cusEnts: FullCustomerEntitlement[];
value: number;
features: Feature[];
shouldSet: boolean;
}) => {
const meteredFeature =
features.find((f) => f.type === FeatureType.Metered) || features[0];
const featureDeductions = [];
for (const feature of features) {
let newValue = value;
const unlimitedExists = cusEnts.some(
(cusEnt) =>
cusEnt.entitlement.allowance_type === AllowanceType.Unlimited &&
cusEnt.entitlement.internal_feature_id == feature.internal_id,
);
if (unlimitedExists) {
continue;
}
if (feature.type === FeatureType.CreditSystem) {
newValue = featureToCreditSystem({
featureId: meteredFeature.id,
creditSystem: feature,
amount: value,
});
}
// If it's set
let deduction = newValue;
if (shouldSet) {
const totalAllowance = cusEnts.reduce((acc, curr) => {
return acc + (curr.entitlement.allowance || 0);
}, 0);
const targetBalance = new Decimal(totalAllowance).sub(value).toNumber();
const totalBalance = getFeatureBalance({
cusEnts,
internalFeatureId: feature.internal_id!,
})!;
deduction = new Decimal(totalBalance).sub(targetBalance).toNumber();
}
if (deduction == 0) {
console.log(` - Skipping feature ${feature.id} -- deduction is 0`);
continue;
}
featureDeductions.push({
feature,
deduction,
});
}
featureDeductions.sort((a, b) => {
if (
a.feature.type === FeatureType.CreditSystem &&
b.feature.type !== FeatureType.CreditSystem
) {
return 1;
}
if (
a.feature.type !== FeatureType.CreditSystem &&
b.feature.type === FeatureType.CreditSystem
) {
return -1;
}
return a.feature.id.localeCompare(b.feature.id);
});
return featureDeductions;
};
const logUsageUpdate = ({
customer,
features,
cusEnts,
featureDeductions,
org,
setUsage,
entityId,
}: {
customer: Customer;
features: Feature[];
cusEnts: FullCustomerEntitlement[];
featureDeductions: any;
org: Organization;
setUsage: boolean;
entityId?: string;
}) => {
console.log(
` - Customer: ${customer.id} (${customer.env}) | Org: ${
org.slug
} | Features: ${features.map((f) => f.id).join(", ")} | Set Usage: ${
setUsage ? "true" : "false"
}`,
);
console.log(
" - CusEnts:",
cusEnts.map((cusEnt: any) => {
let balanceStr = cusEnt.balance;
try {
if (cusEnt.entitlement.allowance_type === AllowanceType.Unlimited) {
balanceStr = "Unlimited";
}
} catch (error) {
balanceStr = "failed_to_get_balance";
}
if (entityId && cusEnt.entities) {
balanceStr = `${cusEnt.entities?.[entityId!]?.balance} [${entityId}]`;
}
return `${cusEnt.feature_id} - ${balanceStr} (${
cusEnt.customer_product ? cusEnt.customer_product.product_id : ""
})`;
}),
"| Deductions:",
featureDeductions.map((f: any) => `${f.feature.id}: ${f.deduction}`),
);
};
// Main function to update customer balance
export const updateUsage = async ({
db,
customerId,
features,
org,
env,
value,
properties,
setUsage,
logger,
entityId,
allFeatures,
}: {
db: DrizzleCli;
customerId: string;
features: Feature[];
org: Organization;
env: AppEnv;
value: number;
properties: any;
setUsage: boolean;
logger: any;
entityId?: string;
allFeatures: Feature[];
}) => {
const customer = await CusService.getFull({
db,
idOrInternalId: customerId,
orgId: org.id,
env,
inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue],
entityId,
withSubs: true,
});
const { cusEnts, cusPrices } = await getCusEntsInFeatures({
customer,
internalFeatureIds: features.map((f) => f.internal_id!),
logger,
reverseOrder: org.config?.reverse_deduction_order,
});
// 1. Get deductions for each feature
const featureDeductions = getFeatureDeductions({
cusEnts,
value,
shouldSet: setUsage,
features,
});
logUsageUpdate({
customer,
features,
cusEnts,
featureDeductions,
org,
setUsage,
entityId,
});
// 3. Return if no customer entitlements or features found
if (cusEnts.length === 0 || features.length === 0) {
console.log(" - No customer entitlements or features found");
return;
}
const originalCusEnts = structuredClone(cusEnts);
for (const obj of featureDeductions) {
let { feature, deduction: toDeduct } = obj;
for (const cusEnt of cusEnts) {
if (cusEnt.entitlement.internal_feature_id != feature.internal_id) {
continue;
}
toDeduct = await deductFromApiCusRollovers({
toDeduct,
cusEnt,
deductParams: {
db,
feature,
env,
entity: customer.entity ? customer.entity : undefined,
},
});
if (toDeduct == 0) continue;
toDeduct = await deductAllowanceFromCusEnt({
toDeduct,
cusEnt,
deductParams: {
db,
feature,
env,
org,
cusPrices: cusPrices as any[],
customer,
properties,
entity: customer.entity,
},
featureDeductions,
willDeductCredits: true,
setZeroAdjustment: true,
});
}
if (toDeduct !== 0) {
await deductFromUsageBasedCusEnt({
toDeduct,
cusEnts,
deductParams: {
db,
feature,
env,
org,
cusPrices: cusPrices as any[],
customer,
properties,
entity: customer.entity,
},
setZeroAdjustment: true,
});
}
handleThresholdReached({
org,
env,
features: allFeatures,
db,
feature,
cusEnts: originalCusEnts,
newCusEnts: cusEnts,
fullCus: customer,
logger,
});
}
return cusEnts;
};
// MAIN FUNCTION
export const runUpdateUsageTask = async ({
payload,
logger,
db,
throwError = false,
}: {
payload: any;
logger: any;
db: DrizzleCli;
throwError?: boolean;
}) => {
try {
// 1. Update customer balance
const {
internalCustomerId,
customerId,
eventId,
features,
value,
set_usage,
properties,
org,
env,
entityId,
allFeatures,
} = payload;
console.log("--------------------------------");
console.log(
`HANDLING USAGE TASK FOR CUSTOMER (${customerId}), ORG: ${org.slug}, EVENT ID: ${eventId}`,
);
const cusEnts: any = await updateUsage({
db,
customerId,
features,
value,
properties,
org,
env,
setUsage: set_usage,
logger,
entityId,
allFeatures,
});
await refreshCusCache({
db,
customerId,
entityId,
org,
env,
});
if (!cusEnts || cusEnts.length === 0) {
return;
}
console.log(" ✅ Customer balance updated");
} catch (error) {
logger.error(`ERROR UPDATING USAGE`);
logger.error(error);
if (throwError) {
throw error;
}
}
};