fix: ent rollover null / undefined difference

This commit is contained in:
John Yeo
2025-09-22 12:43:06 +01:00
parent e40e5a06f9
commit 4fd25cc161
7 changed files with 295 additions and 297 deletions

View File

@@ -13,6 +13,7 @@ import { SupabaseClient } from "@supabase/supabase-js";
import Stripe from "stripe";
import { billingIntervalToStripe } from "../stripePriceUtils.js";
import {
formatPrice,
getBillingType,
getPriceEntitlement,
} from "@/internal/products/prices/priceUtils.js";

View File

@@ -22,6 +22,7 @@ import { createStripeCoupon } from "@/external/stripe/stripeCouponUtils/stripeCo
import { PriceService } from "../products/prices/PriceService.js";
import { OrgService } from "../orgs/OrgService.js";
import { formatPrice } from "../products/prices/priceUtils.js";
import { ProductService } from "../products/ProductService.js";
// Helper function to check if tier structures match
const tiersMatch = (oldTiers: any[], newTiers: any[]): boolean => {
@@ -83,6 +84,8 @@ const findBestMatch = (oldPrice: Price, newPrices: Price[]): Price | null => {
// First, filter by basic characteristics
const candidates = newPrices.filter((newPrice) => {
if (newPrice.id === oldPrice.id) return true;
const oldConfig = oldPrice.config as UsagePriceConfig;
const newConfig = newPrice.config as UsagePriceConfig;
@@ -96,12 +99,6 @@ const findBestMatch = (oldPrice: Price, newPrices: Price[]): Price | null => {
);
});
console.log(
"Candidates: ",
candidates.map((p) => formatPrice({ price: p }))
);
console.log("--------------------------------");
if (candidates.length === 0) return null;
if (candidates.length === 1) return candidates[0];
@@ -128,17 +125,27 @@ export async function runRewardMigrationTask({
try {
const {
oldPrices,
newPrices,
productId,
// newPrices,
orgId,
env,
}: {
oldPrices: Price[];
newPrices: Price[];
product: FullProduct;
// newPrices: Price[];
productId: string;
orgId: string;
env: AppEnv;
} = payload;
const fullProduct = await ProductService.getFull({
db,
idOrInternalId: productId,
orgId,
env,
});
const newPrices = fullProduct.prices;
// Get organization for Stripe operations
const org = await OrgService.get({
db,
@@ -167,10 +174,8 @@ export async function runRewardMigrationTask({
)
);
// console.log(
// "New price IDs: ",
// newPrices.map((p) => formatPrice({ price: p }))
// );
let shouldUpdateReward = false;
for (const reward of filteredRewards) {
const newPriceIds: string[] = [];
const unmatchedPrices: string[] = [];
@@ -178,8 +183,10 @@ export async function runRewardMigrationTask({
if (reward.discount_config?.price_ids) {
for (const priceId of reward.discount_config.price_ids) {
const oldPrice = oldPrices.find((p) => p.id === priceId);
// From other product
if (!oldPrice) {
logger.warn(`Old price ${priceId} not found in oldPrices array`);
newPriceIds.push(priceId);
continue;
}
@@ -187,23 +194,28 @@ export async function runRewardMigrationTask({
if (matchingNewPrice) {
newPriceIds.push(matchingNewPrice.id);
const shouldUpdate =
matchingNewPrice.config.stripe_price_id !==
oldPrice.config.stripe_price_id ||
matchingNewPrice.config.stripe_product_id !==
oldPrice.config.stripe_product_id;
if (shouldUpdate) {
shouldUpdateReward = true;
}
} else {
unmatchedPrices.push(oldPrice.id);
}
}
}
console.log("New price IDs: ", newPriceIds);
throw new Error("test");
// Update the reward with new price IDs
if (newPriceIds.length > 0) {
if (shouldUpdateReward) {
try {
// Check if price IDs have actually changed
const originalPriceIds = reward.discount_config?.price_ids || [];
const priceIdsChanged =
originalPriceIds.length !== newPriceIds.length ||
!originalPriceIds.every((id) => newPriceIds.includes(id));
// Update Stripe coupon and reward if price IDs have changed
console.log(
`Updating ${reward.id}, updating reward and Stripe coupon...`
);
// Update the reward in the database
const updatedReward = await RewardService.update({
@@ -219,74 +231,38 @@ export async function runRewardMigrationTask({
},
});
// Update Stripe coupon if price IDs have changed
if (priceIdsChanged && org) {
try {
logger.info(
`Price IDs changed for reward ${reward.id}, updating Stripe coupon...`
);
// Get the price objects for the new price IDs
const prices = await PriceService.getInIds({
db,
ids: newPriceIds,
});
// Get the price objects for the new price IDs
const prices = await PriceService.getInIds({
db,
ids: newPriceIds,
});
// Recreate the Stripe coupon with new product restrictions
await createStripeCoupon({
reward: updatedReward,
org,
env,
prices,
logger,
});
// Recreate the Stripe coupon with new product restrictions
await createStripeCoupon({
reward: updatedReward,
org,
env,
prices,
logger,
});
logger.info(
`Successfully updated Stripe coupon for reward ${reward.id} with new product restrictions`
);
} catch (stripeError) {
logger.error(
`Failed to update Stripe coupon for reward ${reward.id}:`,
stripeError
);
// Don't throw here - we want to continue with other rewards
}
}
logger.info(
`Updated reward "${reward.name}" (${reward.id}) with ${newPriceIds.length} prices`
console.log(
`Successfully updated Stripe coupon for reward ${reward.id} with new product restrictions`
);
} catch (error) {
logger.error(`Failed to update reward ${reward.id}:`, error);
console.error(`Failed to update reward ${reward.id}:`, error);
}
}
if (unmatchedPrices.length > 0) {
logger.warn(
console.warn(
`Unmatched prices for reward ${reward.id}:`,
unmatchedPrices
);
}
}
// Migration summary
const totalRewards = filteredRewards.length;
const updatedRewards = filteredRewards.filter((r) =>
r.discount_config?.price_ids?.some(
(priceId) =>
oldPrices.find((p) => p.id === priceId) &&
findBestMatch(oldPrices.find((p) => p.id === priceId)!, newPrices)
)
).length;
logger.info("================================");
logger.info("REWARD MIGRATION SUMMARY FOR ORG: ", orgId);
logger.info("================================");
logger.info(`Total rewards processed: ${totalRewards}`);
logger.info(`Rewards with successful matches: ${updatedRewards}`);
logger.info(`Rewards with no matches: ${totalRewards - updatedRewards}`);
logger.info("================================");
} catch (error) {
logger.error("Error running reward migration task", { error });
console.error("Error running reward migration task", { error });
throw error;
}
}

View File

@@ -14,6 +14,8 @@ import {
Price,
FullProduct,
FullEntitlement,
Rollover,
RolloverConfig,
} from "@autumn/shared";
import { addDays } from "date-fns";
@@ -76,6 +78,24 @@ export const addTrialToNextResetAt = (
return addDays(new Date(nextResetAt), freeTrial.length).getTime();
};
export const rolloversAreSame = ({
rollover1,
rollover2,
}: {
rollover1?: RolloverConfig | null;
rollover2?: RolloverConfig | null;
}) => {
if (!rollover1 && !rollover2) return true;
if (!rollover1 && rollover2) return false;
if (rollover1 && !rollover2) return false;
return (
rollover1!.max == rollover2!.max &&
rollover1!.duration == rollover2!.duration &&
rollover1!.length == rollover2!.length
);
};
export const entsAreSame = (ent1: Entitlement, ent2: Entitlement) => {
// 1. Check if they have same internal_feature_id
if (ent1.internal_feature_id !== ent2.internal_feature_id) {
@@ -121,23 +141,25 @@ export const entsAreSame = (ent1: Entitlement, ent2: Entitlement) => {
message: `Usage limit different: ${ent1.usage_limit} !== ${ent2.usage_limit}`,
},
rollover: {
condition:
JSON.stringify(ent1.rollover) !== JSON.stringify(ent2.rollover),
condition: !rolloversAreSame({
rollover1: ent1.rollover,
rollover2: ent2.rollover,
}),
message: `Rollover different: ${ent1.rollover} !== ${ent2.rollover}`,
},
};
let entsAreDiff = Object.values(diffs).some((d) => d.condition);
// if (entsAreDiff) {
// console.log("Entitlements different");
// console.log(
// "Differences:",
// Object.values(diffs)
// .filter((d) => d.condition)
// .map((d) => d.message),
// );
// }
if (entsAreDiff) {
console.log("Entitlements different");
console.log(
"Differences:",
Object.values(diffs)
.filter((d) => d.condition)
.map((d) => d.message)
);
}
return !entsAreDiff;
};

View File

@@ -22,202 +22,199 @@ import {
} from "../handleCreateProduct.js";
import { handleVersionProductV2 } from "../handleVersionProduct.js";
import { handleUpdateProductDetails } from "./updateProductDetails.js";
import { formatPrice } from "../../prices/priceUtils.js";
export const handleUpdateProductV2 = async (req: any, res: any) =>
routeHandler({
req,
res,
action: "Update product",
handler: async () => {
const { productId } = req.params;
const { version, upsert, disable_version } = req.query;
const { orgId, env, logger, db } = req;
routeHandler({
req,
res,
action: "Update product",
handler: async () => {
const { productId } = req.params;
const { version, upsert, disable_version } = req.query;
const { orgId, env, logger, db } = req;
const [features, org, fullProduct, rewardPrograms, _defaultProds] =
await Promise.all([
FeatureService.getFromReq(req),
OrgService.getFromReq(req),
ProductService.getFull({
db,
idOrInternalId: productId,
orgId,
env,
version: version ? parseInt(version) : undefined,
allowNotFound: upsert === "true",
}),
RewardProgramService.getByProductId({
db,
productIds: [productId],
orgId,
env,
}),
ProductService.listDefault({
db,
orgId,
env,
}),
]);
const [features, org, fullProduct, rewardPrograms, _defaultProds] =
await Promise.all([
FeatureService.getFromReq(req),
OrgService.getFromReq(req),
ProductService.getFull({
db,
idOrInternalId: productId,
orgId,
env,
version: version ? parseInt(version) : undefined,
allowNotFound: upsert === "true",
}),
RewardProgramService.getByProductId({
db,
productIds: [productId],
orgId,
env,
}),
ProductService.listDefault({
db,
orgId,
env,
}),
]);
if (!fullProduct) {
if (upsert === "true") {
await handleCreateProduct(req, res);
return;
}
if (!fullProduct) {
if (upsert === "true") {
await handleCreateProduct(req, res);
return;
}
throw new RecaseError({
message: "Product not found",
code: ErrCode.ProductNotFound,
statusCode: 404,
});
}
throw new RecaseError({
message: "Product not found",
code: ErrCode.ProductNotFound,
statusCode: 404,
});
}
const cusProductsCurVersion =
await CusProductService.getByInternalProductId({
db,
internalProductId: fullProduct.internal_id,
});
const cusProductsCurVersion =
await CusProductService.getByInternalProductId({
db,
internalProductId: fullProduct.internal_id,
});
const cusProductExists = cusProductsCurVersion.length > 0;
const cusProductExists = cusProductsCurVersion.length > 0;
// console.log("Updating product", {
// id: fullProduct.id,
// body: req.body,
// });
await disableCurrentDefault({
req,
newProduct: {
...fullProduct,
...req.body,
},
items:
req.body.items ||
mapToProductItems({
prices: fullProduct.prices,
entitlements: fullProduct.entitlements,
features,
}),
freeTrial: req.body.free_trial || fullProduct.free_trial || null,
});
// console.log("Updating product", {
// id: fullProduct.id,
// body: req.body,
// });
await disableCurrentDefault({
req,
newProduct: {
...fullProduct,
...req.body,
},
items:
req.body.items ||
mapToProductItems({
prices: fullProduct.prices,
entitlements: fullProduct.entitlements,
features,
}),
freeTrial: req.body.free_trial || fullProduct.free_trial || null,
});
await handleUpdateProductDetails({
db,
curProduct: fullProduct,
newProduct: UpdateProductSchema.parse(req.body),
newFreeTrial: req.body.free_trial,
items: req.body.items,
org,
rewardPrograms,
logger,
});
await handleUpdateProductDetails({
db,
curProduct: fullProduct,
newProduct: UpdateProductSchema.parse(req.body),
newFreeTrial: req.body.free_trial,
items: req.body.items,
org,
rewardPrograms,
logger,
});
const itemsExist = notNullish(req.body.items);
if (cusProductExists && itemsExist) {
if (disable_version === "true") {
throw new RecaseError({
message: "Cannot auto save product as there are existing customers",
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
const itemsExist = notNullish(req.body.items);
if (cusProductExists && itemsExist) {
if (disable_version === "true") {
throw new RecaseError({
message: "Cannot auto save product as there are existing customers",
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
const { itemsSame, freeTrialsSame } = productsAreSame({
newProductV2: req.body,
curProductV1: fullProduct,
features,
});
const productSame = itemsSame && freeTrialsSame;
const { itemsSame, freeTrialsSame } = productsAreSame({
newProductV2: req.body,
curProductV1: fullProduct,
features,
});
const productSame = itemsSame && freeTrialsSame;
if (!productSame) {
await handleVersionProductV2({
req,
res,
latestProduct: fullProduct,
org,
env,
items: req.body.items,
freeTrial: req.body.free_trial,
});
return;
}
res.status(200).send(fullProduct);
return;
}
if (!productSame) {
await handleVersionProductV2({
req,
res,
latestProduct: fullProduct,
org,
env,
items: req.body.items,
freeTrial: req.body.free_trial,
});
return;
}
res.status(200).send(fullProduct);
return;
}
const { items, free_trial } = req.body;
const { items, free_trial } = req.body;
if (free_trial !== undefined) {
await validateOneOffTrial({
prices: fullProduct.prices,
freeTrial: free_trial,
});
}
if (free_trial !== undefined) {
await validateOneOffTrial({
prices: fullProduct.prices,
freeTrial: free_trial,
});
}
const { prices, entitlements } = await handleNewProductItems({
db,
curPrices: fullProduct.prices,
curEnts: fullProduct.entitlements,
newItems: items,
features,
product: fullProduct,
logger,
isCustom: false,
});
await handleNewProductItems({
db,
curPrices: fullProduct.prices,
curEnts: fullProduct.entitlements,
newItems: items,
features,
product: fullProduct,
logger,
isCustom: false,
});
if (free_trial !== undefined) {
await validateOneOffTrial({
prices,
freeTrial: free_trial,
});
// New full product
const newFullProduct = await ProductService.getFull({
db,
idOrInternalId: fullProduct.id,
orgId,
env,
});
await handleNewFreeTrial({
db,
curFreeTrial: fullProduct.free_trial,
newFreeTrial: free_trial,
internalProductId: fullProduct.internal_id,
isCustom: false,
product: fullProduct,
});
}
if (free_trial !== undefined) {
await validateOneOffTrial({
prices: newFullProduct.prices,
freeTrial: free_trial,
});
await initProductInStripe({
db,
product: {
...fullProduct,
prices,
entitlements,
} as FullProduct,
org,
env,
logger,
});
await handleNewFreeTrial({
db,
curFreeTrial: fullProduct.free_trial,
newFreeTrial: free_trial,
internalProductId: fullProduct.internal_id,
isCustom: false,
product: fullProduct,
});
}
logger.info("Adding task to queue to detect base variant");
await addTaskToQueue({
jobName: JobName.DetectBaseVariant,
payload: {
curProduct: {
...fullProduct,
prices: prices.length > 0 ? prices : fullProduct.prices,
entitlements,
},
},
});
// New full product
await initProductInStripe({
db,
product: newFullProduct,
org,
env,
logger,
});
await addTaskToQueue({
jobName: JobName.RewardMigration,
payload: {
oldPrices: fullProduct.prices,
newPrices: prices,
product: {
...fullProduct,
prices,
entitlements: getEntsWithFeature({ ents: entitlements, features }),
},
orgId: org.id,
env,
},
});
res.status(200).send({ message: "Product updated" });
return;
},
});
logger.info("Adding task to queue to detect base variant");
await addTaskToQueue({
jobName: JobName.DetectBaseVariant,
payload: {
curProduct: newFullProduct,
},
});
await addTaskToQueue({
jobName: JobName.RewardMigration,
payload: {
oldPrices: fullProduct.prices,
productId: fullProduct.id,
orgId: org.id,
env,
},
});
res.status(200).send({ message: "Product updated" });
return;
},
});

View File

@@ -13,6 +13,7 @@ import { validateProductItems } from "../validateProductItems.js";
import { FeatureService } from "@/internal/features/FeatureService.js";
import { isFeatureItem } from "./getItemType.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { formatPrice } from "../../prices/priceUtils.js";
const updateDbPricesAndEnts = async ({
db,

View File

@@ -1,3 +1,4 @@
import RecaseError from "@/utils/errorUtils.js";
import {
AllowanceType,
BillingInterval,
@@ -19,13 +20,12 @@ import {
OnIncrease,
OnDecrease,
FeatureUsageType,
features,
} from "@autumn/shared";
import { generateId, notNullish, nullish } from "@/utils/genUtils.js";
import { pricesAreSame } from "@/internal/products/prices/priceInitUtils.js";
import { entsAreSame } from "../../entitlements/entitlementUtils.js";
import { getBillingType } from "@/internal/products/prices/priceUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import {
isFeatureItem,
isFeaturePriceItem,

View File

@@ -4,40 +4,41 @@ import { JobName } from "./JobName.js";
import { QueueManager } from "./QueueManager.js";
export interface Payloads {
[JobName.RewardMigration]: {
oldPrices: Price[];
newPrices: Price[];
product: FullProduct;
[JobName.RewardMigration]: {
oldPrices: Price[];
productId: string;
// newPrices: Price[];
// product: FullProduct;
orgId: string;
env: AppEnv;
};
};
[key: string]: any;
}
export const addTaskToQueue = async <T extends keyof Payloads>({
jobName,
payload,
jobName,
payload,
}: {
jobName: T;
payload: Payloads[T];
jobName: T;
payload: Payloads[T];
}) => {
try {
const queue = await QueueManager.getQueue({ useBackup: false });
await queue.add(jobName as string, payload);
} catch (error: any) {
try {
console.log(`Adding ${jobName} to backup queue`);
const backupQueue = await QueueManager.getQueue({ useBackup: true });
await backupQueue.add(jobName as string, payload);
} catch (error: any) {
throw new RecaseError({
message: `Failed to add ${jobName} to queue (backup)`,
code: "EVENT_QUEUE_ERROR",
statusCode: 500,
data: {
message: error.message,
},
});
}
}
try {
const queue = await QueueManager.getQueue({ useBackup: false });
await queue.add(jobName as string, payload);
} catch (error: any) {
try {
console.log(`Adding ${jobName} to backup queue`);
const backupQueue = await QueueManager.getQueue({ useBackup: true });
await backupQueue.add(jobName as string, payload);
} catch (error: any) {
throw new RecaseError({
message: `Failed to add ${jobName} to queue (backup)`,
code: "EVENT_QUEUE_ERROR",
statusCode: 500,
data: {
message: error.message,
},
});
}
}
};