From 4fd25cc1611ebff07d8befec98f3dbaca6639f28 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Mon, 22 Sep 2025 12:43:06 +0100 Subject: [PATCH] fix: ent rollover null / undefined difference --- .../createStripePrice/createStripePrepaid.ts | 1 + .../migrations/runRewardMigrationTask.ts | 130 +++---- .../products/entitlements/entitlementUtils.ts | 44 ++- .../handleUpdateProduct.ts | 355 +++++++++--------- .../productItemUtils/handleNewProductItems.ts | 1 + .../productItemUtils/itemToPriceAndEnt.ts | 4 +- server/src/queue/queueUtils.ts | 57 +-- 7 files changed, 295 insertions(+), 297 deletions(-) diff --git a/server/src/external/stripe/createStripePrice/createStripePrepaid.ts b/server/src/external/stripe/createStripePrice/createStripePrepaid.ts index 83ad3ee51..4736ca35b 100644 --- a/server/src/external/stripe/createStripePrice/createStripePrepaid.ts +++ b/server/src/external/stripe/createStripePrice/createStripePrepaid.ts @@ -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"; diff --git a/server/src/internal/migrations/runRewardMigrationTask.ts b/server/src/internal/migrations/runRewardMigrationTask.ts index d0e45e241..d57c8fed5 100644 --- a/server/src/internal/migrations/runRewardMigrationTask.ts +++ b/server/src/internal/migrations/runRewardMigrationTask.ts @@ -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; } } diff --git a/server/src/internal/products/entitlements/entitlementUtils.ts b/server/src/internal/products/entitlements/entitlementUtils.ts index f621b7974..30e96d07f 100644 --- a/server/src/internal/products/entitlements/entitlementUtils.ts +++ b/server/src/internal/products/entitlements/entitlementUtils.ts @@ -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; }; diff --git a/server/src/internal/products/handlers/handleUpdateProduct/handleUpdateProduct.ts b/server/src/internal/products/handlers/handleUpdateProduct/handleUpdateProduct.ts index 233a2024e..d75744f94 100644 --- a/server/src/internal/products/handlers/handleUpdateProduct/handleUpdateProduct.ts +++ b/server/src/internal/products/handlers/handleUpdateProduct/handleUpdateProduct.ts @@ -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; + }, + }); diff --git a/server/src/internal/products/product-items/productItemUtils/handleNewProductItems.ts b/server/src/internal/products/product-items/productItemUtils/handleNewProductItems.ts index 271829697..8d3ca4e7b 100644 --- a/server/src/internal/products/product-items/productItemUtils/handleNewProductItems.ts +++ b/server/src/internal/products/product-items/productItemUtils/handleNewProductItems.ts @@ -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, diff --git a/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts b/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts index 733f0fe09..84cbc93f9 100644 --- a/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts +++ b/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts @@ -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, diff --git a/server/src/queue/queueUtils.ts b/server/src/queue/queueUtils.ts index 62aeb7073..0438010a2 100644 --- a/server/src/queue/queueUtils.ts +++ b/server/src/queue/queueUtils.ts @@ -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 ({ - 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, + }, + }); + } + } };