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,13 +231,6 @@ 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,
@@ -241,52 +246,23 @@ export async function runRewardMigrationTask({
logger,
});
logger.info(
console.log(
`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`
);
} 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,6 +22,7 @@ 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({
@@ -152,7 +153,7 @@ export const handleUpdateProductV2 = async (req: any, res: any) =>
});
}
const { prices, entitlements } = await handleNewProductItems({
await handleNewProductItems({
db,
curPrices: fullProduct.prices,
curEnts: fullProduct.entitlements,
@@ -163,9 +164,17 @@ export const handleUpdateProductV2 = async (req: any, res: any) =>
isCustom: false,
});
// New full product
const newFullProduct = await ProductService.getFull({
db,
idOrInternalId: fullProduct.id,
orgId,
env,
});
if (free_trial !== undefined) {
await validateOneOffTrial({
prices,
prices: newFullProduct.prices,
freeTrial: free_trial,
});
@@ -179,13 +188,10 @@ export const handleUpdateProductV2 = async (req: any, res: any) =>
});
}
// New full product
await initProductInStripe({
db,
product: {
...fullProduct,
prices,
entitlements,
} as FullProduct,
product: newFullProduct,
org,
env,
logger,
@@ -195,11 +201,7 @@ export const handleUpdateProductV2 = async (req: any, res: any) =>
await addTaskToQueue({
jobName: JobName.DetectBaseVariant,
payload: {
curProduct: {
...fullProduct,
prices: prices.length > 0 ? prices : fullProduct.prices,
entitlements,
},
curProduct: newFullProduct,
},
});
@@ -207,12 +209,7 @@ export const handleUpdateProductV2 = async (req: any, res: any) =>
jobName: JobName.RewardMigration,
payload: {
oldPrices: fullProduct.prices,
newPrices: prices,
product: {
...fullProduct,
prices,
entitlements: getEntsWithFeature({ ents: entitlements, features }),
},
productId: fullProduct.id,
orgId: org.id,
env,
},

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

@@ -6,8 +6,9 @@ import { QueueManager } from "./QueueManager.js";
export interface Payloads {
[JobName.RewardMigration]: {
oldPrices: Price[];
newPrices: Price[];
product: FullProduct;
productId: string;
// newPrices: Price[];
// product: FullProduct;
orgId: string;
env: AppEnv;
};