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 new file mode 100644 index 000000000..d57c8fed5 --- /dev/null +++ b/server/src/internal/migrations/runRewardMigrationTask.ts @@ -0,0 +1,268 @@ +import { + type AppEnv, + type FixedPriceConfig, + type FullProduct, + type Price, + type UsagePriceConfig, + DiscountConfig, + PriceType, + RewardType, + getBillingType, + isFixedPrice, + isUsagePrice, +} from "@autumn/shared"; + +import type { DrizzleCli } from "@/db/initDrizzle.js"; +import type { logger as loggerType } from "@/external/logtail/logtailUtils.js"; +import type { JobName } from "@/queue/JobName.js"; +import type { Payloads } from "@/queue/queueUtils.js"; +import { RewardService } from "../rewards/RewardService.js"; +import { tiersAreSame } from "../products/prices/priceInitUtils.js"; +import { createStripeCoupon } from "@/external/stripe/stripeCouponUtils/stripeCouponUtils.js"; +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 => { + if (oldTiers.length !== newTiers.length) return false; + + return oldTiers.every((oldTier, index) => { + const newTier = newTiers[index]; + return oldTier.to === newTier.to && oldTier.amount === newTier.amount; + }); +}; + +// Match fixed prices by amount +const findMatchingFixedPrice = ( + oldPrice: Price, + candidates: Price[] +): Price | null => { + const oldConfig = oldPrice.config as FixedPriceConfig; + + const possibleCandidate = candidates.find((candidate) => { + const newConfig = candidate.config as FixedPriceConfig; + return newConfig.amount === oldConfig.amount; + }); + + return possibleCandidate || candidates?.[0]; +}; + +// Match usage prices by feature and billing characteristics +const findMatchingUsagePrice = ( + oldPrice: Price, + candidates: Price[] +): Price | null => { + const oldConfig = oldPrice.config as UsagePriceConfig; + + return ( + candidates.find((candidate) => { + const newConfig = candidate.config as UsagePriceConfig; + + // Match by feature + if (newConfig.internal_feature_id !== oldConfig.internal_feature_id) + return false; + + // Match by billing behavior + let newBillingType = getBillingType(newConfig); + let oldBillingType = getBillingType(oldConfig); + if (newBillingType !== oldBillingType) return false; + + // Optionally match by tier structure + // if (!tiersMatch(oldConfig.usage_tiers, newConfig.usage_tiers)) + if (!tiersAreSame(oldConfig.usage_tiers, newConfig.usage_tiers)) + return false; + + return true; + }) || null + ); +}; + +// Main matching function with type-specific logic +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; + + return ( + getBillingType(newPrice.config) === getBillingType(oldPrice.config) && + newPrice.config.interval === oldPrice.config.interval && + newPrice.config.interval_count === oldPrice.config.interval_count && + (oldConfig.type == PriceType.Usage + ? oldConfig.internal_feature_id === newConfig.internal_feature_id + : true) + ); + }); + + if (candidates.length === 0) return null; + if (candidates.length === 1) return candidates[0]; + + // If multiple candidates, use type-specific matching + if (isFixedPrice({ price: oldPrice })) { + return findMatchingFixedPrice(oldPrice, candidates); + } else if (isUsagePrice({ price: oldPrice })) { + return findMatchingUsagePrice(oldPrice, candidates); + } + + // Fallback to first candidate + return candidates[0]; +}; + +export async function runRewardMigrationTask({ + db, + payload, + logger, +}: { + db: DrizzleCli; + payload: Payloads[JobName.RewardMigration]; + logger: ReturnType; +}) { + try { + const { + oldPrices, + productId, + // newPrices, + orgId, + env, + }: { + oldPrices: Price[]; + // 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, + orgId, + }); + + const rewards = await RewardService.list({ + db, + orgId, + env, + inTypes: [ + RewardType.PercentageDiscount, + RewardType.FixedDiscount, + RewardType.InvoiceCredits, + ], + }); + + const filteredRewards = rewards.filter( + (x) => + x.org_id === orgId && + x.env === env && + x.type !== RewardType.FreeProduct && + x.discount_config && + x.discount_config.price_ids?.some((p) => + oldPrices.map((p) => p.id).includes(p) + ) + ); + + let shouldUpdateReward = false; + + for (const reward of filteredRewards) { + const newPriceIds: string[] = []; + const unmatchedPrices: string[] = []; + + 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) { + newPriceIds.push(priceId); + continue; + } + + const matchingNewPrice = findBestMatch(oldPrice, newPrices); + + 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); + } + } + } + + // Update the reward with new price IDs + if (shouldUpdateReward) { + try { + // 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({ + db, + internalId: reward.internal_id!, + env, + orgId, + update: { + discount_config: { + ...(reward.discount_config as DiscountConfig), + price_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, + }); + + console.log( + `Successfully updated Stripe coupon for reward ${reward.id} with new product restrictions` + ); + } catch (error) { + console.error(`Failed to update reward ${reward.id}:`, error); + } + } + + if (unmatchedPrices.length > 0) { + console.warn( + `Unmatched prices for reward ${reward.id}:`, + unmatchedPrices + ); + } + } + } catch (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 0c177f7fa..d75744f94 100644 --- a/server/src/internal/products/handlers/handleUpdateProduct/handleUpdateProduct.ts +++ b/server/src/internal/products/handlers/handleUpdateProduct/handleUpdateProduct.ts @@ -1,28 +1,28 @@ -import RecaseError from "@/utils/errorUtils.js"; -import { ErrCode, FullProduct, UpdateProductSchema } from "@autumn/shared"; - -import { ProductService } from "../../ProductService.js"; -import { notNullish } from "@/utils/genUtils.js"; +import { ErrCode, type FullProduct, UpdateProductSchema } from "@autumn/shared"; +import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; import { FeatureService } from "@/internal/features/FeatureService.js"; import { OrgService } from "@/internal/orgs/OrgService.js"; - import { handleNewFreeTrial } from "@/internal/products/free-trials/freeTrialUtils.js"; -import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; -import { handleVersionProductV2 } from "../handleVersionProduct.js"; -import { routeHandler } from "@/utils/routerUtils.js"; import { handleNewProductItems } from "@/internal/products/product-items/productItemUtils/handleNewProductItems.js"; import { RewardProgramService } from "@/internal/rewards/RewardProgramService.js"; -import { handleUpdateProductDetails } from "./updateProductDetails.js"; -import { addTaskToQueue } from "@/queue/queueUtils.js"; import { JobName } from "@/queue/JobName.js"; +import { addTaskToQueue } from "@/queue/queueUtils.js"; +import RecaseError from "@/utils/errorUtils.js"; +import { notNullish } from "@/utils/genUtils.js"; +import { routeHandler } from "@/utils/routerUtils.js"; +import { getEntsWithFeature } from "../../entitlements/entitlementUtils.js"; +import { validateOneOffTrial } from "../../free-trials/freeTrialUtils.js"; +import { ProductService } from "../../ProductService.js"; import { productsAreSame } from "../../productUtils/compareProductUtils.js"; import { initProductInStripe } from "../../productUtils.js"; +import { mapToProductItems } from "../../productV2Utils.js"; import { disableCurrentDefault, handleCreateProduct, } from "../handleCreateProduct.js"; -import { mapToProductItems } from "../../productV2Utils.js"; -import { validateOneOffTrial } from "../../free-trials/freeTrialUtils.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({ @@ -34,7 +34,7 @@ export const handleUpdateProductV2 = async (req: any, res: any) => const { version, upsert, disable_version } = req.query; const { orgId, env, logger, db } = req; - const [features, org, fullProduct, rewardPrograms, defaultProds] = + const [features, org, fullProduct, rewardPrograms, _defaultProds] = await Promise.all([ FeatureService.getFromReq(req), OrgService.getFromReq(req), @@ -44,7 +44,7 @@ export const handleUpdateProductV2 = async (req: any, res: any) => orgId, env, version: version ? parseInt(version) : undefined, - allowNotFound: upsert == "true", + allowNotFound: upsert === "true", }), RewardProgramService.getByProductId({ db, @@ -60,7 +60,7 @@ export const handleUpdateProductV2 = async (req: any, res: any) => ]); if (!fullProduct) { - if (upsert == "true") { + if (upsert === "true") { await handleCreateProduct(req, res); return; } @@ -78,7 +78,7 @@ export const handleUpdateProductV2 = async (req: any, res: any) => internalProductId: fullProduct.internal_id, }); - let cusProductExists = cusProductsCurVersion.length > 0; + const cusProductExists = cusProductsCurVersion.length > 0; // console.log("Updating product", { // id: fullProduct.id, @@ -111,15 +111,14 @@ export const handleUpdateProductV2 = async (req: any, res: any) => logger, }); - let itemsExist = notNullish(req.body.items); + const itemsExist = notNullish(req.body.items); if (cusProductExists && itemsExist) { - if (disable_version == "true") { + if (disable_version === "true") { throw new RecaseError({ message: "Cannot auto save product as there are existing customers", code: ErrCode.InvalidRequest, statusCode: 400, }); - return; } const { itemsSame, freeTrialsSame } = productsAreSame({ @@ -154,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, @@ -165,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, }); @@ -181,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, @@ -197,14 +201,19 @@ 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, }, }); + 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/handlers/handleVersionProduct.ts b/server/src/internal/products/handlers/handleVersionProduct.ts index 1747eded1..7cb6142c3 100644 --- a/server/src/internal/products/handlers/handleVersionProduct.ts +++ b/server/src/internal/products/handlers/handleVersionProduct.ts @@ -1,142 +1,156 @@ +import { + type AppEnv, + CreateProductSchema, + type FreeTrial, + type FullProduct, + type Organization, + type ProductItem, +} from "@autumn/shared"; import { FeatureService } from "@/internal/features/FeatureService.js"; +import { EntitlementService } from "@/internal/products/entitlements/EntitlementService.js"; import { handleNewFreeTrial } from "@/internal/products/free-trials/freeTrialUtils.js"; import { ProductService } from "@/internal/products/ProductService.js"; +import { PriceService } from "@/internal/products/prices/PriceService.js"; +import { handleNewProductItems } from "@/internal/products/product-items/productItemUtils/handleNewProductItems.js"; +import { validateProductItems } from "@/internal/products/product-items/validateProductItems.js"; import { constructProduct, initProductInStripe, } from "@/internal/products/productUtils.js"; -import { - AppEnv, - CreateProductSchema, - FreeTrial, - Organization, - ProductItem, -} from "@autumn/shared"; - -import { FullProduct } from "@autumn/shared"; -import { handleNewProductItems } from "@/internal/products/product-items/productItemUtils/handleNewProductItems.js"; -import { validateProductItems } from "@/internal/products/product-items/validateProductItems.js"; -import { EntitlementService } from "@/internal/products/entitlements/EntitlementService.js"; -import { PriceService } from "@/internal/products/prices/PriceService.js"; -import { addTaskToQueue } from "@/queue/queueUtils.js"; import { JobName } from "@/queue/JobName.js"; +import { addTaskToQueue } from "@/queue/queueUtils.js"; import { getEntsWithFeature } from "../entitlements/entitlementUtils.js"; export const handleVersionProductV2 = async ({ - req, - res, - latestProduct, - org, - env, - items, - freeTrial, + req, + res, + latestProduct, + org, + env, + items, + freeTrial, }: { - req: any; - res: any; - latestProduct: FullProduct; - org: Organization; - env: AppEnv; - items: ProductItem[]; - freeTrial: FreeTrial; + req: any; + res: any; + latestProduct: FullProduct; + org: Organization; + env: AppEnv; + items: ProductItem[]; + freeTrial: FreeTrial; }) => { - const { db } = req; + const { db } = req; - let curVersion = latestProduct.version; - let newVersion = curVersion + 1; + const curVersion = latestProduct.version; + const newVersion = curVersion + 1; - let features = await FeatureService.getFromReq(req); + const features = await FeatureService.getFromReq(req); - console.log( - `Updating product ${latestProduct.id} version from ${curVersion} to ${newVersion}` - ); + console.log( + `Updating product ${latestProduct.id} version from ${curVersion} to ${newVersion}`, + ); - const newProduct = constructProduct({ - productData: CreateProductSchema.parse({ - ...latestProduct, - ...req.body, - version: newVersion, - }), - orgId: org.id, - env: latestProduct.env as AppEnv, - processor: latestProduct.processor, - baseVariantId: latestProduct.base_variant_id, - }); + const newProduct = constructProduct({ + productData: CreateProductSchema.parse({ + ...latestProduct, + ...req.body, + version: newVersion, + }), + orgId: org.id, + env: latestProduct.env as AppEnv, + processor: latestProduct.processor, + baseVariantId: latestProduct.base_variant_id, + }); - // Validate product items... - validateProductItems({ - newItems: items, - features, - orgId: org.id, - env, - }); + // Validate product items... + validateProductItems({ + newItems: items, + features, + orgId: org.id, + env, + }); - if (latestProduct.is_default) { - await ProductService.updateByInternalId({ - db, - internalId: latestProduct.internal_id, - update: { - is_default: false, - }, - }); - } + if (latestProduct.is_default) { + await ProductService.updateByInternalId({ + db, + internalId: latestProduct.internal_id, + update: { + is_default: false, + }, + }); + } - await ProductService.insert({ db, product: newProduct }); + await ProductService.insert({ db, product: newProduct }); - const { customPrices, customEnts } = await handleNewProductItems({ - db, - curPrices: latestProduct.prices, - curEnts: latestProduct.entitlements, - newItems: items, - features, - product: newProduct, - logger: console, - isCustom: false, - newVersion: true, - }); + const { customPrices, customEnts } = await handleNewProductItems({ + db, + curPrices: latestProduct.prices, + curEnts: latestProduct.entitlements, + newItems: items, + features, + product: newProduct, + logger: console, + isCustom: false, + newVersion: true, + }); - await EntitlementService.insert({ - db, - data: customEnts, - }); + await EntitlementService.insert({ + db, + data: customEnts, + }); - await PriceService.insert({ - db, - data: customPrices, - }); + await PriceService.insert({ + db, + data: customPrices, + }); - // Handle new free trial - if (freeTrial) { - await handleNewFreeTrial({ - db, - newFreeTrial: freeTrial, - curFreeTrial: null, - internalProductId: newProduct.internal_id, - isCustom: false, - }); - } + // Handle new free trial + if (freeTrial) { + await handleNewFreeTrial({ + db, + newFreeTrial: freeTrial, + curFreeTrial: null, + internalProductId: newProduct.internal_id, + isCustom: false, + }); + } - // await addTaskToQueue({ - // jobName: JobName.DetectBaseVariant, - // payload: { - // curProduct: { - // ...newProduct, - // // prices: customPrices, - // // entitlements: getEntsWithFeature({ ents: customEnts, features }), - // }, - // }, - // }); + // await addTaskToQueue({ + // jobName: JobName.DetectBaseVariant, + // payload: { + // curProduct: { + // ...newProduct, + // // prices: customPrices, + // // entitlements: getEntsWithFeature({ ents: customEnts, features }), + // }, + // }, + // }); - await initProductInStripe({ - db, - product: { - ...newProduct, - prices: customPrices, - entitlements: getEntsWithFeature({ ents: customEnts, features }), - } as FullProduct, - org, - env, - logger: console, - }); + await initProductInStripe({ + db, + product: { + ...newProduct, + prices: customPrices, + entitlements: getEntsWithFeature({ ents: customEnts, features }), + } as FullProduct, + org, + env, + logger: console, + }); - res.status(200).send(newProduct); + await addTaskToQueue({ + jobName: JobName.RewardMigration, + payload: { + oldPrices: latestProduct.prices, + newPrices: customPrices, + product: { + ...newProduct, + prices: customPrices, + entitlements: getEntsWithFeature({ ents: customEnts, features }), + }, + orgId: org.id, + env, + }, + }); + + res.status(200).send(newProduct); }; diff --git a/server/src/internal/products/internalProductRouter.ts b/server/src/internal/products/internalProductRouter.ts index f326aa66a..32af65272 100644 --- a/server/src/internal/products/internalProductRouter.ts +++ b/server/src/internal/products/internalProductRouter.ts @@ -14,13 +14,13 @@ import { RewardProgramService } from "../rewards/RewardProgramService.js"; import { mapToProductV2 } from "./productV2Utils.js"; import { isFeaturePriceItem } from "./product-items/productItemUtils/getItemType.js"; -import RecaseError, { - handleFrontendReqError, - handleRequestError, -} from "@/utils/errorUtils.js"; +import RecaseError, { handleFrontendReqError } from "@/utils/errorUtils.js"; import { createOrgResponse } from "../orgs/orgUtils.js"; -import { sortFullProducts } from "./productUtils/sortProductUtils.js"; +import { + sortFullProducts, + sortProductsByPrice, +} from "./productUtils/sortProductUtils.js"; import { handleGetProductDeleteInfo } from "./handlers/handleGetProductDeleteInfo.js"; export const productRouter: Router = Router({ mergeParams: true }); @@ -35,6 +35,8 @@ productRouter.get("/products", async (req: any, res) => { env: req.env, }); + sortFullProducts({ products }); + const groupToDefaults = getGroupToDefaults({ defaultProds: products, }); 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/internal/rewards/RewardService.ts b/server/src/internal/rewards/RewardService.ts index 25be93f17..ff7e417b3 100644 --- a/server/src/internal/rewards/RewardService.ts +++ b/server/src/internal/rewards/RewardService.ts @@ -1,164 +1,176 @@ -import { type AppEnv, ErrCode, type Reward, rewards } from "@autumn/shared"; +import { + type AppEnv, + ErrCode, + type Reward, + rewards, + RewardType, +} from "@autumn/shared"; import { and, desc, eq, inArray, or, sql } from "drizzle-orm"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import RecaseError from "@/utils/errorUtils.js"; export class RewardService { - static async get({ - db, - idOrInternalId, - orgId, - env, - }: { - db: DrizzleCli; - idOrInternalId: string; - orgId: string; - env: AppEnv; - }) { - const result = await db.query.rewards.findFirst({ - where: and( - or( - eq(rewards.id, idOrInternalId), - eq(rewards.internal_id, idOrInternalId), - ), - eq(rewards.org_id, orgId), - eq(rewards.env, env), - ), - }); + static async get({ + db, + idOrInternalId, + orgId, + env, + }: { + db: DrizzleCli; + idOrInternalId: string; + orgId: string; + env: AppEnv; + }) { + const result = await db.query.rewards.findFirst({ + where: and( + or( + eq(rewards.id, idOrInternalId), + eq(rewards.internal_id, idOrInternalId) + ), + eq(rewards.org_id, orgId), + eq(rewards.env, env) + ), + }); - if (!result) { - return null; - } + if (!result) { + return null; + } - return result as Reward; - } + return result as Reward; + } - static async getByIdOrCode({ - db, - codes, - orgId, - env, - }: { - db: DrizzleCli; - codes: string[]; - orgId: string; - env: AppEnv; - }) { - const reward = await db.query.rewards.findMany({ - where: and( - eq(rewards.org_id, orgId), - eq(rewards.env, env), - or( - inArray(rewards.id, codes), - ...codes.map( - (code) => sql`EXISTS ( + static async getByIdOrCode({ + db, + codes, + orgId, + env, + }: { + db: DrizzleCli; + codes: string[]; + orgId: string; + env: AppEnv; + }) { + const reward = await db.query.rewards.findMany({ + where: and( + eq(rewards.org_id, orgId), + eq(rewards.env, env), + or( + inArray(rewards.id, codes), + ...codes.map( + (code) => sql`EXISTS ( SELECT 1 FROM unnest("promo_codes") AS elem WHERE elem->>'code' = ${code} - )`, - ), - ), - ), - }); + )` + ) + ) + ), + }); - return reward as Reward[]; - } + return reward as Reward[]; + } - static async insert({ - db, - data, - }: { - db: DrizzleCli; - data: Reward | Reward[]; - }) { - const results = await db.insert(rewards).values(data as Reward); - return results as Reward[]; - } + static async insert({ + db, + data, + }: { + db: DrizzleCli; + data: Reward | Reward[]; + }) { + const results = await db.insert(rewards).values(data as Reward); + return results as Reward[]; + } - static async list({ - db, - orgId, - env, - }: { - db: DrizzleCli; - orgId: string; - env: AppEnv; - }) { - const results = await db.query.rewards.findMany({ - where: and(eq(rewards.org_id, orgId), eq(rewards.env, env)), - orderBy: [desc(rewards.internal_id)], - }); + static async list({ + db, + orgId, + env, + inTypes, + }: { + db: DrizzleCli; + orgId: string; + env: AppEnv; + inTypes?: RewardType[]; + }) { + const results = await db.query.rewards.findMany({ + where: and( + eq(rewards.org_id, orgId), + eq(rewards.env, env), + inTypes ? inArray(rewards.type, inTypes) : undefined + ), + orderBy: [desc(rewards.internal_id)], + }); - return results as Reward[]; - } + return results as Reward[]; + } - static async delete({ - db, - internalId, - env, - orgId, - }: { - db: DrizzleCli; - internalId: string; - env: AppEnv; - orgId: string; - }) { - await db - .delete(rewards) - .where( - and( - eq(rewards.internal_id, internalId), - eq(rewards.env, env), - eq(rewards.org_id, orgId), - ), - ); - } + static async delete({ + db, + internalId, + env, + orgId, + }: { + db: DrizzleCli; + internalId: string; + env: AppEnv; + orgId: string; + }) { + await db + .delete(rewards) + .where( + and( + eq(rewards.internal_id, internalId), + eq(rewards.env, env), + eq(rewards.org_id, orgId) + ) + ); + } - static async update({ - db, - internalId, - env, - orgId, - update, - }: { - db: DrizzleCli; - internalId: string; - env: AppEnv; - orgId: string; - update: Partial; - }) { - const result = await db - .update(rewards) - .set(update) - .where( - and( - eq(rewards.internal_id, internalId), - eq(rewards.env, env), - eq(rewards.org_id, orgId), - ), - ) - .returning(); + static async update({ + db, + internalId, + env, + orgId, + update, + }: { + db: DrizzleCli; + internalId: string; + env: AppEnv; + orgId: string; + update: Partial; + }) { + const result = await db + .update(rewards) + .set(update) + .where( + and( + eq(rewards.internal_id, internalId), + eq(rewards.env, env), + eq(rewards.org_id, orgId) + ) + ) + .returning(); - if (result.length === 0) { - throw new RecaseError({ - message: `Reward ${internalId} not found`, - code: ErrCode.InvalidRequest, - }); - } + if (result.length === 0) { + throw new RecaseError({ + message: `Reward ${internalId} not found`, + code: ErrCode.InvalidRequest, + }); + } - return result[0] as Reward; - } + return result[0] as Reward; + } - static async deleteByOrgId({ - db, - orgId, - env, - }: { - db: DrizzleCli; - orgId: string; - env: AppEnv; - }) { - await db - .delete(rewards) - .where(and(eq(rewards.org_id, orgId), eq(rewards.env, env))); - } + static async deleteByOrgId({ + db, + orgId, + env, + }: { + db: DrizzleCli; + orgId: string; + env: AppEnv; + }) { + await db + .delete(rewards) + .where(and(eq(rewards.org_id, orgId), eq(rewards.env, env))); + } } diff --git a/server/src/queue/JobName.ts b/server/src/queue/JobName.ts index 9eafe1ef7..213dad18c 100644 --- a/server/src/queue/JobName.ts +++ b/server/src/queue/JobName.ts @@ -3,6 +3,7 @@ export enum JobName { UpdateUsage = "update-usage", Migration = "migration", + RewardMigration = "reward-migration", TriggerCheckoutReward = "trigger-checkout-reward", GenerateFeatureDisplay = "generate-feature-display", diff --git a/server/src/queue/queueUtils.ts b/server/src/queue/queueUtils.ts index 71b9bf434..0438010a2 100644 --- a/server/src/queue/queueUtils.ts +++ b/server/src/queue/queueUtils.ts @@ -1,21 +1,35 @@ +import type { AppEnv, FullProduct, Price } from "@autumn/shared"; import RecaseError from "@/utils/errorUtils.js"; +import { JobName } from "./JobName.js"; import { QueueManager } from "./QueueManager.js"; -export const addTaskToQueue = async ({ +export interface Payloads { + [JobName.RewardMigration]: { + oldPrices: Price[]; + productId: string; + // newPrices: Price[]; + // product: FullProduct; + orgId: string; + env: AppEnv; + }; + [key: string]: any; +} + +export const addTaskToQueue = async ({ jobName, payload, }: { - jobName: string; - payload: any; + jobName: T; + payload: Payloads[T]; }) => { try { const queue = await QueueManager.getQueue({ useBackup: false }); - await queue.add(jobName, payload); + 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, payload); + await backupQueue.add(jobName as string, payload); } catch (error: any) { throw new RecaseError({ message: `Failed to add ${jobName} to queue (backup)`, diff --git a/server/src/queue/workersInit.ts b/server/src/queue/workersInit.ts index 1ae6259eb..cf59b314e 100644 --- a/server/src/queue/workersInit.ts +++ b/server/src/queue/workersInit.ts @@ -14,228 +14,230 @@ import { generateId } from "@/utils/genUtils.js"; import { JobName } from "./JobName.js"; import { acquireLock, getRedisConnection, releaseLock } from "./lockUtils.js"; import { QueueManager } from "./QueueManager.js"; +import { runRewardMigrationTask } from "@/internal/migrations/runRewardMigrationTask.js"; const NUM_WORKERS = 10; const actionHandlers = [ - JobName.HandleProductsUpdated, - JobName.HandleCustomerCreated, + JobName.HandleProductsUpdated, + JobName.HandleCustomerCreated, ]; -const { db, client } = initDrizzle({ maxConnections: 10 }); +const { db } = initDrizzle({ maxConnections: 10 }); const initWorker = ({ - id, - queue, - useBackup, - db, + id, + queue, + useBackup, + db, }: { - id: number; - queue: Queue; - useBackup: boolean; - db: DrizzleCli; + id: number; + queue: Queue; + useBackup: boolean; + db: DrizzleCli; }) => { - const worker = new Worker( - "autumn", - async (job: Job) => { - const logtail = logger.child({ - context: { - worker: { - task: job.name, - data: job.data, - jobId: generateId("job"), - workerId: id, - }, - }, - }); + const worker = new Worker( + "autumn", + async (job: Job) => { + const logtail = logger.child({ + context: { + worker: { + task: job.name, + data: job.data, + jobId: generateId("job"), + workerId: id, + }, + }, + }); - try { - if (job.name === JobName.DetectBaseVariant) { - await detectBaseVariant({ - db, - curProduct: job.data.curProduct, - logger: logtail as Logger, - }); - return; - } + try { + if (job.name === JobName.DetectBaseVariant) { + await detectBaseVariant({ + db, + curProduct: job.data.curProduct, + logger: logtail as Logger, + }); + return; + } - if (job.name === JobName.GenerateFeatureDisplay) { - await runSaveFeatureDisplayTask({ - db, - feature: job.data.feature, - logger: logtail, - }); - return; - } + if (job.name === JobName.GenerateFeatureDisplay) { + await runSaveFeatureDisplayTask({ + db, + feature: job.data.feature, + logger: logtail, + }); + return; + } - if (job.name === JobName.Migration) { - await runMigrationTask({ - db, - payload: job.data, - logger: logtail, - }); - return; - } + if (job.name === JobName.Migration) { + await runMigrationTask({ + db, + payload: job.data, + logger: logtail, + }); + return; + } - if (actionHandlers.includes(job.name as JobName)) { - await runActionHandlerTask({ - queue, - job, - logger: logtail, - db, - useBackup, - }); - return; - } - } catch (error: any) { - logtail.error(`Failed to process bullmq job: ${job.name}`, { - jobName: job.name, - error: { - message: error.message, - stack: error.stack, - }, - }); - } + if (actionHandlers.includes(job.name as JobName)) { + await runActionHandlerTask({ + queue, + job, + logger: logtail, + db, + useBackup, + }); + return; + } - // TRIGGER CHECKOUT REWARD - if (job.name === JobName.TriggerCheckoutReward) { - const lockKey = `reward_trigger:${job.data.customer?.internal_id}`; - if ( - !(await acquireLock({ - lockKey, - timeout: 10000, - useBackup, - })) - ) { - await queue.add(job.name, job.data, { - delay: 1000, - }); - logger.info( - "Lock not acquired for checkout reward, adding task to queue", - ); - return; - } + if (job.name === JobName.RewardMigration) { + await runRewardMigrationTask({ + db, + payload: job.data, + logger: logtail, + }); + } + } catch (error: any) { + logtail.error(`Failed to process bullmq job: ${job.name}`, { + jobName: job.name, + error: { + message: error.message, + stack: error.stack, + }, + }); + } - try { - logger.info("Running checkout reward"); - await runTriggerCheckoutReward({ - db, - payload: job.data, - logger: logtail, - }); - logger.info("Checkout reward triggered"); - } catch (error) { - logger.error("Error processing job:", error); - } finally { - logger.info("Releasing lock for checkout reward"); - await releaseLock({ lockKey, useBackup }); - logger.info("Lock released for checkout reward"); - } + // TRIGGER CHECKOUT REWARD + if (job.name === JobName.TriggerCheckoutReward) { + const lockKey = `reward_trigger:${job.data.customer?.internal_id}`; + if ( + !(await acquireLock({ + lockKey, + timeout: 10000, + useBackup, + })) + ) { + await queue.add(job.name, job.data, { + delay: 1000, + }); + return; + } - return; - } + try { + await runTriggerCheckoutReward({ + db, + payload: job.data, + logger: logtail, + }); + } catch (error) { + console.error("Error processing job:", error); + } finally { + await releaseLock({ lockKey, useBackup }); + } - // EVENT HANDLERS - const { internalCustomerId } = job.data; // customerId is internal customer id + return; + } - while ( - !(await acquireLock({ - lockKey: `event:${internalCustomerId}`, - timeout: 10000, - useBackup, - })) - ) { - await queue.add(job.name, job.data, { - delay: 200, - }); - return; - } + // EVENT HANDLERS + const { internalCustomerId } = job.data; // customerId is internal customer id - try { - if (job.name === JobName.UpdateBalance) { - await runUpdateBalanceTask({ - payload: job.data, - logger: logtail, - db, - }); - } else if (job.name === JobName.UpdateUsage) { - await runUpdateUsageTask({ - payload: job.data, - logger: logtail, - db, - }); - } - } catch (error) { - console.error("Error processing job:", error); - } finally { - await releaseLock({ - lockKey: `event:${internalCustomerId}`, - useBackup, - }); - } - }, - { - ...getRedisConnection({ useBackup }), - concurrency: 1, - removeOnComplete: { - count: 0, - }, - removeOnFail: { - count: 0, - }, - drainDelay: 1000, - maxStalledCount: 0, - }, - ); + while ( + !(await acquireLock({ + lockKey: `event:${internalCustomerId}`, + timeout: 10000, + useBackup, + })) + ) { + await queue.add(job.name, job.data, { + delay: 200, + }); + return; + } - worker.on("ready", () => { - console.log(`Worker ${id} ready (${useBackup ? "BACKUP" : "MAIN"})`); - }); + try { + if (job.name === JobName.UpdateBalance) { + await runUpdateBalanceTask({ + payload: job.data, + logger: logtail, + db, + }); + } else if (job.name === JobName.UpdateUsage) { + await runUpdateUsageTask({ + payload: job.data, + logger: logtail, + db, + }); + } + } catch (error) { + console.error("Error processing job:", error); + } finally { + await releaseLock({ + lockKey: `event:${internalCustomerId}`, + useBackup, + }); + } + }, + { + ...getRedisConnection({ useBackup }), + concurrency: 1, + removeOnComplete: { + count: 0, + }, + removeOnFail: { + count: 0, + }, + drainDelay: 1000, + maxStalledCount: 0, + } + ); - worker.on("stalled", (jobId: string) => { - console.log(`Worker ${id} stalled (${useBackup ? "BACKUP" : "MAIN"})`); - console.log("JOB ID:", jobId); - }); + worker.on("ready", () => { + console.log(`Worker ${id} ready (${useBackup ? "BACKUP" : "MAIN"})`); + }); - worker.on("error", async (error: any) => { - if (error.code !== "ECONNREFUSED") { - console.log("WORKER ERROR:", error.message); - } - }); + worker.on("stalled", (jobId: string) => { + console.log(`Worker ${id} stalled (${useBackup ? "BACKUP" : "MAIN"})`); + console.log("JOB ID:", jobId); + }); - worker.on("failed", (job, error) => { - console.log("WORKER FAILED:", error.message); - }); + worker.on("error", async (error: any) => { + if (error.code !== "ECONNREFUSED") { + console.log("WORKER ERROR:", error.message); + } + }); + + worker.on("failed", (_, error) => { + console.log("WORKER FAILED:", error.message); + }); }; export const initWorkers = async () => { - const workers = []; + const workers = []; - const mainQueue = await QueueManager.getQueue({ useBackup: false }); - const backupQueue = await QueueManager.getQueue({ useBackup: true }); - await CacheManager.getInstance(); + const mainQueue = await QueueManager.getQueue({ useBackup: false }); + const backupQueue = await QueueManager.getQueue({ useBackup: true }); + await CacheManager.getInstance(); - for (let i = 0; i < NUM_WORKERS; i++) { - workers.push( - initWorker({ - id: i, - queue: mainQueue, - useBackup: false, - db, - }), - ); - workers.push( - initWorker({ - id: i, - queue: backupQueue, - useBackup: true, + for (let i = 0; i < NUM_WORKERS; i++) { + workers.push( + initWorker({ + id: i, + queue: mainQueue, + useBackup: false, + db, + }) + ); + workers.push( + initWorker({ + id: i, + queue: backupQueue, + useBackup: true, - db, - }), - ); - } + db, + }) + ); + } - // Get stalled jobs + // Get stalled jobs - return workers; + return workers; }; diff --git a/shared/index.ts b/shared/index.ts index 8c0e6e508..fade638f1 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -4,153 +4,129 @@ export { schemas }; // Auth Models export * from "./db/auth-schema.js"; +export * from "./enums/APIVersion.js"; +export * from "./enums/AttachErrCode.js"; +export * from "./enums/ErrCode.js"; +export * from "./enums/LoggerAction.js"; +// ENUMS +export * from "./enums/SuccessCode.js"; +export * from "./enums/WebhookEventType.js"; +// ANALYTICS MODELS +export * from "./models/analyticsModels/actionEnums.js"; +export * from "./models/analyticsModels/actionTable.js"; +export * from "./models/attachModels/attachBody.js"; +export * from "./models/attachModels/attachEnums/AttachBranch.js"; +export * from "./models/attachModels/attachEnums/AttachConfig.js"; +export * from "./models/attachModels/attachEnums/AttachFunction.js"; +// Attach Models +export * from "./models/attachModels/attachPreviewModels.js"; +export * from "./models/attachModels/checkoutModels.js"; export * from "./models/authModels/membership.js"; - -// Gen Models -export * from "./models/genModels/genEnums.js"; - -// 1. Org Models -export * from "./models/orgModels/orgTable.js"; -export * from "./models/orgModels/orgConfig.js"; -export * from "./models/orgModels/frontendOrg.js"; - -// 2. Feature Models -export * from "./models/featureModels/featureTable.js"; +export * from "./models/chatResultModels/chatResultFeature.js"; +export * from "./models/chatResultModels/chatResultFeature.js"; +// 4. Chat Result Models +export * from "./models/chatResultModels/chatResultTable.js"; +export * from "./models/checkModels/checkPreviewModels.js"; +export * from "./models/cusModels/cusExpand.js"; +// 8. Customer Models +export * from "./models/cusModels/cusModels.js"; +export * from "./models/cusModels/cusResModels/cusFeatureResponse.js"; +export * from "./models/cusModels/cusResModels/cusProductResponse.js"; +export * from "./models/cusModels/cusResModels/cusReferralsResponse.js"; +// Cus response +export * from "./models/cusModels/cusResponseModels.js"; +export * from "./models/cusModels/cusTable.js"; +export * from "./models/cusModels/entityModels/entityExpand.js"; +export * from "./models/cusModels/entityModels/entityModels.js"; +export * from "./models/cusModels/entityModels/entityResModels.js"; +export * from "./models/cusModels/entityModels/entityTable.js"; +export * from "./models/cusModels/fullCusModel.js"; +export * from "./models/cusModels/invoiceModels/invoiceModels.js"; +export * from "./models/cusModels/invoiceModels/invoiceResponseModels.js"; +export * from "./models/cusModels/invoiceModels/invoiceTable.js"; +export * from "./models/cusProductModels/cusEntModels/cusEntModels.js"; +export * from "./models/cusProductModels/cusEntModels/cusEntTable.js"; +export * from "./models/cusProductModels/cusEntModels/cusEntWithProduct.js"; +export * from "./models/cusProductModels/cusEntModels/replaceableSchema.js"; +export * from "./models/cusProductModels/cusEntModels/replaceableTable.js"; +export * from "./models/cusProductModels/cusEntModels/resetCusEnt.js"; +export * from "./models/cusProductModels/cusEntModels/rolloverModels/rolloverTable.js"; +export * from "./models/cusProductModels/cusPriceModels/cusPriceModels.js"; +export * from "./models/cusProductModels/cusPriceModels/cusPriceTable.js"; +export * from "./models/cusProductModels/cusProductEnums.js"; +// 7. Cus Product Models +export * from "./models/cusProductModels/cusProductModels.js"; +export * from "./models/cusProductModels/cusProductTable.js"; +export * from "./models/devModels/apiKeyModels.js"; +export * from "./models/devModels/apiKeyTable.js"; +// 5. Others: events, apiKeys +export * from "./models/eventModels/eventModels.js"; +export * from "./models/eventModels/eventTable.js"; +export * from "./models/featureModels/featureConfig/creditConfig.js"; +export * from "./models/featureModels/featureConfig/meteredConfig.js"; export * from "./models/featureModels/featureEnums.js"; export * from "./models/featureModels/featureModels.js"; export * from "./models/featureModels/featureResModels.js"; -export * from "./models/featureModels/featureConfig/meteredConfig.js"; -export * from "./models/featureModels/featureConfig/creditConfig.js"; - +// 2. Feature Models +export * from "./models/featureModels/featureTable.js"; +// Gen Models +export * from "./models/genModels/genEnums.js"; +export * from "./models/migrationModels/migrationErrorTable.js"; +export * from "./models/migrationModels/migrationJobTable.js"; +export * from "./models/migrationModels/migrationModels.js"; +export * from "./models/orgModels/frontendOrg.js"; +export * from "./models/orgModels/orgConfig.js"; +// 1. Org Models +export * from "./models/orgModels/orgTable.js"; +export * from "./models/otherModels/metadataModels.js"; +export * from "./models/otherModels/metadataTable.js"; +export * from "./models/productModels/entModels/entEnums.js"; +export * from "./models/productModels/entModels/entModels.js"; // 3. Entitlement Models export * from "./models/productModels/entModels/entTable.js"; -export * from "./models/productModels/entModels/entModels.js"; -export * from "./models/productModels/entModels/entEnums.js"; - // 4. Free Trial Models export * from "./models/productModels/freeTrialModels/freeTrialEnums.js"; export * from "./models/productModels/freeTrialModels/freeTrialModels.js"; export * from "./models/productModels/freeTrialModels/freeTrialTable.js"; - -// 4. Price Models -export * from "./models/productModels/priceModels/priceEnums.js"; export * from "./models/productModels/priceModels/priceConfig/fixedPriceConfig.js"; export * from "./models/productModels/priceModels/priceConfig/usagePriceConfig.js"; -export * from "./models/productModels/priceModels/priceTable.js"; +// 4. Price Models +export * from "./models/productModels/priceModels/priceEnums.js"; export * from "./models/productModels/priceModels/priceModels.js"; - +export * from "./models/productModels/priceModels/priceTable.js"; // 5. Product Models export * from "./models/productModels/productEnums.js"; -export * from "./models/productModels/productTable.js"; export * from "./models/productModels/productModels.js"; export * from "./models/productModels/productRelations.js"; - -// 6. Product V2 Models -export * from "./models/productV2Models/productV2Models.js"; -export * from "./models/productV2Models/productResponseModels.js"; -export * from "./models/productV2Models/productItemModels/productItemModels.js"; -export * from "./models/productV2Models/productItemModels/prodItemResponseModels.js"; +export * from "./models/productModels/productTable.js"; export * from "./models/productV2Models/productItemModels/featureItem.js"; export * from "./models/productV2Models/productItemModels/featurePriceItem.js"; export * from "./models/productV2Models/productItemModels/priceItem.js"; +export * from "./models/productV2Models/productItemModels/prodItemResponseModels.js"; export * from "./models/productV2Models/productItemModels/productItemEnums.js"; - -// 7. Cus Product Models -export * from "./models/cusProductModels/cusProductModels.js"; -export * from "./models/cusProductModels/cusProductTable.js"; -export * from "./models/cusProductModels/cusProductEnums.js"; -export * from "./models/cusProductModels/cusPriceModels/cusPriceModels.js"; -export * from "./models/cusProductModels/cusPriceModels/cusPriceTable.js"; - -export * from "./models/cusProductModels/cusEntModels/cusEntModels.js"; -export * from "./models/cusProductModels/cusEntModels/cusEntWithProduct.js"; -export * from "./models/cusProductModels/cusEntModels/cusEntTable.js"; -export * from "./models/cusProductModels/cusEntModels/replaceableTable.js"; -export * from "./models/cusProductModels/cusEntModels/replaceableSchema.js"; -export * from "./models/cusProductModels/cusEntModels/rolloverModels/rolloverTable.js"; -export * from "./models/cusProductModels/cusEntModels/resetCusEnt.js"; - -// 8. Customer Models -export * from "./models/cusModels/cusModels.js"; -export * from "./models/cusModels/cusTable.js"; -export * from "./models/cusModels/fullCusModel.js"; -export * from "./models/cusModels/cusExpand.js"; -export * from "./models/cusModels/invoiceModels/invoiceResponseModels.js"; -export * from "./models/cusModels/invoiceModels/invoiceTable.js"; -// Cus response -export * from "./models/cusModels/cusResponseModels.js"; -export * from "./models/cusModels/cusResModels/cusProductResponse.js"; -export * from "./models/cusModels/cusResModels/cusFeatureResponse.js"; -export * from "./models/cusModels/cusResModels/cusReferralsResponse.js"; - -export * from "./models/cusModels/entityModels/entityModels.js"; -export * from "./models/cusModels/entityModels/entityTable.js"; -export * from "./models/cusModels/entityModels/entityExpand.js"; -export * from "./models/cusModels/entityModels/entityResModels.js"; - -// 4. Chat Result Models -export * from "./models/chatResultModels/chatResultTable.js"; -export * from "./models/chatResultModels/chatResultFeature.js"; - -// Reward Models -export * from "./models/rewardModels/rewardModels/rewardModels.js"; -export * from "./models/rewardModels/rewardModels/rewardEnums.js"; -export * from "./models/rewardModels/rewardModels/rewardTable.js"; -export * from "./models/rewardModels/rewardModels/rewardResponseModels.js"; - -export * from "./models/rewardModels/rewardProgramModels/rewardProgramModels.js"; -export * from "./models/rewardModels/rewardProgramModels/rewardProgramEnums.js"; -export * from "./models/rewardModels/rewardProgramModels/rewardProgramTable.js"; +export * from "./models/productV2Models/productItemModels/productItemModels.js"; +export * from "./models/productV2Models/productResponseModels.js"; +// 6. Product V2 Models +export * from "./models/productV2Models/productV2Models.js"; +export * from "./models/rewardModels/referralModels/referralCodeTable.js"; export * from "./models/rewardModels/referralModels/referralModels.js"; export * from "./models/rewardModels/referralModels/rewardRedemptionTable.js"; -export * from "./models/rewardModels/referralModels/referralCodeTable.js"; - -// 5. Others: events, apiKeys -export * from "./models/eventModels/eventModels.js"; -export * from "./models/eventModels/eventTable.js"; - -export * from "./models/devModels/apiKeyModels.js"; -export * from "./models/devModels/apiKeyTable.js"; - -export * from "./models/otherModels/metadataModels.js"; -export * from "./models/otherModels/metadataTable.js"; - +export * from "./models/rewardModels/rewardModels/rewardEnums.js"; +// Reward Models +export * from "./models/rewardModels/rewardModels/rewardModels.js"; +export * from "./models/rewardModels/rewardModels/rewardResponseModels.js"; +export * from "./models/rewardModels/rewardModels/rewardTable.js"; +export * from "./models/rewardModels/rewardProgramModels/rewardProgramEnums.js"; +export * from "./models/rewardModels/rewardProgramModels/rewardProgramModels.js"; +export * from "./models/rewardModels/rewardProgramModels/rewardProgramTable.js"; export * from "./models/subModels/subModels.js"; export * from "./models/subModels/subTable.js"; - -export * from "./models/cusModels/invoiceModels/invoiceModels.js"; - -export * from "./models/migrationModels/migrationModels.js"; -export * from "./models/migrationModels/migrationJobTable.js"; -export * from "./models/migrationModels/migrationErrorTable.js"; - -// ANALYTICS MODELS -export * from "./models/analyticsModels/actionEnums.js"; -export * from "./models/analyticsModels/actionTable.js"; - -// Attach Models -export * from "./models/attachModels/attachPreviewModels.js"; -export * from "./models/attachModels/attachEnums/AttachBranch.js"; -export * from "./models/attachModels/attachEnums/AttachFunction.js"; -export * from "./models/attachModels/attachEnums/AttachConfig.js"; -export * from "./models/attachModels/checkoutModels.js"; -export * from "./models/attachModels/attachBody.js"; - // Utils export * from "./utils/displayUtils.js"; -export * from "./models/checkModels/checkPreviewModels.js"; -export * from "./models/chatResultModels/chatResultFeature.js"; -export * from "./utils/productDisplayUtils/getProductItemRes.js"; -export * from "./utils/productUtils.js"; -export * from "./utils/productDisplayUtils/sortProductItems.js"; -export * from "./utils/intervalUtils.js"; -export * from "./utils/productUtils/priceToInvoiceAmount.js"; export * from "./utils/index.js"; - -// ENUMS -export * from "./enums/SuccessCode.js"; -export * from "./enums/ErrCode.js"; -export * from "./enums/LoggerAction.js"; -export * from "./enums/AttachErrCode.js"; -export * from "./enums/APIVersion.js"; -export * from "./enums/WebhookEventType.js"; +export * from "./utils/intervalUtils.js"; +export * from "./utils/productDisplayUtils/getProductItemRes.js"; +export * from "./utils/productDisplayUtils/sortProductItems.js"; +export * from "./utils/productUtils/priceToInvoiceAmount.js"; +export * from "./utils/productUtils.js"; +export * from "./utils/rewardUtils/rewardMigrationUtils.js"; \ No newline at end of file diff --git a/shared/models/productModels/priceModels/priceConfig/usagePriceConfig.ts b/shared/models/productModels/priceModels/priceConfig/usagePriceConfig.ts index d24bf5bbd..319749d00 100644 --- a/shared/models/productModels/priceModels/priceConfig/usagePriceConfig.ts +++ b/shared/models/productModels/priceModels/priceConfig/usagePriceConfig.ts @@ -13,6 +13,8 @@ export const UsageTierSchema = z.object({ amount: z.number(), }); +export type UsageTier = z.infer; + export const UsagePriceConfigSchema = z.object({ type: z.string(), bill_when: z.nativeEnum(BillWhen), diff --git a/shared/utils/rewardUtils/rewardMigrationUtils.ts b/shared/utils/rewardUtils/rewardMigrationUtils.ts new file mode 100644 index 000000000..372565c1d --- /dev/null +++ b/shared/utils/rewardUtils/rewardMigrationUtils.ts @@ -0,0 +1,177 @@ +import type { + FixedPriceConfig, + Price, + Reward, + RewardType, + UsagePriceConfig, +} from "../../index.js"; +import type { UsageTier } from "../../models/productModels/priceModels/priceConfig/usagePriceConfig.js"; +import { isFixedPrice, isUsagePrice } from "../productUtils/priceUtils.js"; + +// Helper function to check if tier structures match +const tiersMatch = (oldTiers: UsageTier[], newTiers: UsageTier[]): boolean => { + if (oldTiers.length !== newTiers.length) return false; + + return oldTiers.every((oldTier, index) => { + const newTier = newTiers[index]; + return oldTier.to === newTier.to && oldTier.amount === newTier.amount; + }); +}; + +// Match fixed prices by amount +const findMatchingFixedPrice = ( + oldPrice: Price, + candidates: Price[], +): Price | null => { + const oldConfig = oldPrice.config as FixedPriceConfig; + + return ( + candidates.find((candidate) => { + const newConfig = candidate.config as FixedPriceConfig; + return newConfig.amount === oldConfig.amount; + }) || null + ); +}; + +// Match usage prices by feature and billing characteristics +const findMatchingUsagePrice = ( + oldPrice: Price, + candidates: Price[], +): Price | null => { + const oldConfig = oldPrice.config as UsagePriceConfig; + + return ( + candidates.find((candidate) => { + const newConfig = candidate.config as UsagePriceConfig; + + // Match by feature + if (newConfig.feature_id !== oldConfig.feature_id) return false; + if (newConfig.internal_feature_id !== oldConfig.internal_feature_id) + return false; + + // Match by billing behavior + if (newConfig.bill_when !== oldConfig.bill_when) return false; + if (newConfig.should_prorate !== oldConfig.should_prorate) return false; + + // Optionally match by tier structure + if (!tiersMatch(oldConfig.usage_tiers, newConfig.usage_tiers)) + return false; + + return true; + }) || null + ); +}; + +// Main matching function with type-specific logic +const findBestMatch = (oldPrice: Price, newPrices: Price[]): Price | null => { + // First, filter by basic characteristics + const candidates = newPrices.filter( + (newPrice) => + newPrice.config.type === oldPrice.config.type && + newPrice.config.interval === oldPrice.config.interval && + newPrice.config.interval_count === oldPrice.config.interval_count, + ); + + if (candidates.length === 0) return null; + if (candidates.length === 1) return candidates[0]; + + // If multiple candidates, use type-specific matching + if (isFixedPrice({ price: oldPrice })) { + return findMatchingFixedPrice(oldPrice, candidates); + } else if (isUsagePrice({ price: oldPrice })) { + return findMatchingUsagePrice(oldPrice, candidates); + } + + // Fallback to first candidate + return candidates[0]; +}; + +export interface RewardMigrationResult { + willMigrateCount: number; + willNotMigrateCount: number; +} + +export interface RewardPriceAnalysisResult { + validPriceCount: number; + invalidPriceCount: number; +} + +export function analyzeRewardMigration({ + rewards, + oldPrices, + newPrices, + rewardTypesToCheck, +}: { + rewards: Reward[]; + oldPrices: Price[]; + newPrices: Price[]; + rewardTypesToCheck: RewardType[]; +}): RewardMigrationResult { + let willMigrateCount = 0; + let willNotMigrateCount = 0; + + // Filter rewards to only those we care about and that have discount configs with price_ids + const relevantRewards = rewards.filter( + (reward) => + rewardTypesToCheck.includes(reward.type) && + reward.discount_config?.price_ids && + reward.discount_config.price_ids.length > 0, + ); + + for (const reward of relevantRewards) { + if (!reward.discount_config?.price_ids) continue; + + for (const priceId of reward.discount_config.price_ids) { + const oldPrice = oldPrices.find((p) => p.id === priceId); + if (!oldPrice) { + // Price not in old prices list, skip + continue; + } + + const matchingNewPrice = findBestMatch(oldPrice, newPrices); + + if (matchingNewPrice) { + willMigrateCount++; + } else { + willNotMigrateCount++; + } + } + } + + return { + willMigrateCount, + willNotMigrateCount, + }; +} + +export function analyzeRewardPrices({ + reward, + availablePriceIds, +}: { + reward: Reward; + availablePriceIds: string[]; +}): RewardPriceAnalysisResult { + let validPriceCount = 0; + let invalidPriceCount = 0; + + // Skip rewards that apply to all products + if (reward.discount_config?.apply_to_all) { + return { validPriceCount: 0, invalidPriceCount: 0 }; + } + + // Check each price ID in the reward + if (reward.discount_config?.price_ids) { + for (const priceId of reward.discount_config.price_ids) { + if (availablePriceIds.includes(priceId)) { + validPriceCount++; + } else { + invalidPriceCount++; + } + } + } + + return { + validPriceCount, + invalidPriceCount, + }; +} diff --git a/vite/src/hooks/queries/useFeaturesQuery.tsx b/vite/src/hooks/queries/useFeaturesQuery.tsx index 29005c71d..56fdf1c36 100644 --- a/vite/src/hooks/queries/useFeaturesQuery.tsx +++ b/vite/src/hooks/queries/useFeaturesQuery.tsx @@ -1,21 +1,21 @@ -import { useAxiosInstance } from "@/services/useAxiosInstance"; -import { Feature } from "@autumn/shared"; +import type { Feature } from "@autumn/shared"; import { useQuery } from "@tanstack/react-query"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; export const useFeaturesQuery = () => { - const axiosInstance = useAxiosInstance(); + const axiosInstance = useAxiosInstance(); - const fetchFeatures = async () => { - const { data } = await axiosInstance.get("/products/features"); - return data; - }; + const fetchFeatures = async () => { + const { data } = await axiosInstance.get("/products/features"); + return data; + }; - const { data, isLoading, error, refetch } = useQuery<{ - features: Feature[]; - }>({ - queryKey: ["features"], - queryFn: fetchFeatures, - }); + const { data, isLoading, error, refetch } = useQuery<{ + features: Feature[]; + }>({ + queryKey: ["features"], + queryFn: fetchFeatures, + }); - return { features: data?.features || [], isLoading, error, refetch }; + return { features: (data?.features || []) as Feature[], isLoading, error, refetch }; }; diff --git a/vite/src/hooks/queries/useProductsQuery.tsx b/vite/src/hooks/queries/useProductsQuery.tsx index 64693a32d..0c010aef1 100644 --- a/vite/src/hooks/queries/useProductsQuery.tsx +++ b/vite/src/hooks/queries/useProductsQuery.tsx @@ -1,46 +1,46 @@ -import { useAxiosInstance } from "@/services/useAxiosInstance"; -import { FullProduct, ProductCounts, ProductV2 } from "@autumn/shared"; +import type { FullProduct, ProductCounts, ProductV2 } from "@autumn/shared"; import { useQuery } from "@tanstack/react-query"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; export const useProductsQuery = () => { - const axiosInstance = useAxiosInstance(); + const axiosInstance = useAxiosInstance(); - const fetchProducts = async () => { - const { data } = await axiosInstance.get("/products/products"); - return data; - }; + const fetchProducts = async () => { + const { data } = await axiosInstance.get("/products/products"); + return data; + }; - const fetchProductCounts = async () => { - const { data } = await axiosInstance.get("/products/product_counts"); - return data; - }; + const fetchProductCounts = async () => { + const { data } = await axiosInstance.get("/products/product_counts"); + return data; + }; - const { data, isLoading, error, refetch } = useQuery<{ - products: ProductV2[]; - groupToDefaults: Record>; - }>({ - queryKey: ["products"], - queryFn: fetchProducts, - }); + const { data, isLoading, error, refetch } = useQuery<{ + products: ProductV2[]; + groupToDefaults: Record>; + }>({ + queryKey: ["products"], + queryFn: fetchProducts, + }); - const { data: countsData, refetch: countsRefetch } = useQuery< - Record - >({ - queryKey: ["product_counts"], - queryFn: fetchProductCounts, - }); + const { data: countsData, refetch: countsRefetch } = useQuery< + Record + >({ + queryKey: ["product_counts"], + queryFn: fetchProductCounts, + }); - return { - products: data?.products || [], - counts: countsData || {}, - groupToDefaults: data?.groupToDefaults || {}, - isLoading, - error, - refetch: async () => { - await Promise.all([countsRefetch(), refetch()]); - }, - // mutate: async () => { - // await Promise.all([countsRefetch(), refetch()]); - // }, - }; + return { + products: (data?.products || []) as ProductV2[], + counts: countsData || {}, + groupToDefaults: data?.groupToDefaults || {}, + isLoading, + error, + refetch: async () => { + await Promise.all([countsRefetch(), refetch()]); + }, + // mutate: async () => { + // await Promise.all([countsRefetch(), refetch()]); + // }, + }; }; diff --git a/vite/src/hooks/queries/useRewardsQuery.tsx b/vite/src/hooks/queries/useRewardsQuery.tsx index e29eeb293..4f9e4acac 100644 --- a/vite/src/hooks/queries/useRewardsQuery.tsx +++ b/vite/src/hooks/queries/useRewardsQuery.tsx @@ -1,24 +1,25 @@ -import { useAxiosInstance } from "@/services/useAxiosInstance"; +import type { Reward, RewardProgram } from "@autumn/shared"; import { useQuery } from "@tanstack/react-query"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; export const useRewardsQuery = () => { - const axiosInstance = useAxiosInstance(); + const axiosInstance = useAxiosInstance(); - const fetchRewards = async () => { - const { data } = await axiosInstance.get("/products/rewards"); - return data; - }; + const fetchRewards = async () => { + const { data } = await axiosInstance.get("/products/rewards"); + return data; + }; - const { data, isLoading, error, refetch } = useQuery({ - queryKey: ["rewards"], - queryFn: fetchRewards, - }); + const { data, isLoading, error, refetch } = useQuery({ + queryKey: ["rewards"], + queryFn: fetchRewards, + }); - return { - rewards: data?.rewards || [], - rewardPrograms: data?.rewardPrograms || [], - isLoading, - error, - refetch, - }; -}; + return { + rewards: (data?.rewards || []) as Reward[], + rewardPrograms: (data?.rewardPrograms || []) as RewardProgram[], + isLoading, + error, + refetch, + }; +}; \ No newline at end of file diff --git a/vite/src/views/products/rewards/reward-config/DiscountConfig.tsx b/vite/src/views/products/rewards/reward-config/DiscountConfig.tsx index d402c38a7..bada9593c 100644 --- a/vite/src/views/products/rewards/reward-config/DiscountConfig.tsx +++ b/vite/src/views/products/rewards/reward-config/DiscountConfig.tsx @@ -38,93 +38,93 @@ import { isFeatureItem } from "@/utils/product/getItemType"; import { formatProductItemText } from "@/utils/product/product-item/formatProductItem"; export const DiscountConfig = ({ - reward, - setReward, + reward, + setReward, }: { - reward: Reward; - setReward: (reward: Reward) => void; + reward: Reward; + setReward: (reward: Reward) => void; }) => { - const { org } = useOrg(); + const { org } = useOrg(); - const config = reward.discount_config!; - const setConfig = (key: any, value: any) => { - setReward({ - ...reward, - discount_config: { ...config, [key]: value }, - }); - }; + const config = reward.discount_config!; + const setConfig = (key: any, value: any) => { + setReward({ + ...reward, + discount_config: { ...config, [key]: value }, + }); + }; - return ( -
-
-
- Amount - - setConfig("discount_value", Number(e.target.value)) - } - endContent={ -

- {reward.type === RewardType.PercentageDiscount - ? "%" - : org?.default_currency || "USD"} -

- } - /> -
-
- Duration -
- {config.duration_type === CouponDurationType.Months && ( - { - setConfig("duration_value", Number(e.target.value)); - }} - type="number" - /> - )} - -
-
-
+ return ( +
+
+
+ Amount + + setConfig("discount_value", Number(e.target.value)) + } + endContent={ +

+ {reward.type === RewardType.PercentageDiscount + ? "%" + : org?.default_currency || "USD"} +

+ } + /> +
+
+ Duration +
+ {config.duration_type === CouponDurationType.Months && ( + { + setConfig("duration_value", Number(e.target.value)); + }} + type="number" + /> + )} + +
+
+
- {/* {config.duration_type !== CouponDurationType.OneOff && + {/* {config.duration_type !== CouponDurationType.OneOff && reward.type === RewardType.FixedDiscount && (
)} */} -
- {/*

Products

*/} - Products +
+ {/*

Products

*/} + Products - -
-
- ); + +
+
+ ); }; const ProductPriceSelector = ({ - reward, - setReward, + reward, + setReward, }: { - reward: Reward; - setReward: (reward: Reward) => void; + reward: Reward; + setReward: (reward: Reward) => void; }) => { - const { org } = useOrg(); - const { products } = useProductsQuery(); - const { features } = useFeaturesQuery(); + const { org } = useOrg(); + const { products } = useProductsQuery(); + const { features } = useFeaturesQuery(); - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(false); - const config = reward.discount_config!; - const setConfig = (key: any, value: any) => { - setReward({ - ...reward, - discount_config: { ...config, [key]: value }, - }); - }; + const config = reward.discount_config!; + const setConfig = (key: any, value: any) => { + setReward({ + ...reward, + discount_config: { ...config, [key]: value }, + }); + }; - // Handle selection/deselection of a price - const handlePriceToggle = (priceId: string) => { - let newPriceIds = [...(config.price_ids || [])]; - if (config.price_ids?.includes(priceId)) { - newPriceIds = config.price_ids?.filter((id) => id !== priceId) || []; - } else { - newPriceIds = [...(config.price_ids || []), priceId]; - } - setConfig("price_ids", newPriceIds); - }; + // Handle selection/deselection of a price + const handlePriceToggle = (priceId: string) => { + let newPriceIds = [...(config.price_ids || [])]; + if (config.price_ids?.includes(priceId)) { + newPriceIds = config.price_ids?.filter((id) => id !== priceId) || []; + } else { + newPriceIds = [...(config.price_ids || []), priceId]; + } + setConfig("price_ids", newPriceIds); + }; - if (!products || products.length === 0) { - return

No products available

; - } + if (!products || products.length === 0) { + return

No products available

; + } - return ( - - - -
- ); - })} - - )} - - - - - - - - - No prices found. - - { - setConfig("apply_to_all", !config.apply_to_all); - }} - className="cursor-pointer" - > -

Apply to all products

- {config.apply_to_all && ( - - )} -
-
- {!config.apply_to_all && - products.map((product: any) => ( - - {product.items.length > 0 ? ( - product.items - ?.filter((item: ProductItem) => { - return !isFeatureItem(item); - }) - .map((item: any) => ( - handlePriceToggle(item.price_id)} - className="cursor-pointer overflow-x-hidden max-w-[380px]" - > - - {formatProductItemText({ - item, - org, - features, - })} - + const text = item + ? formatProductItemText({ + item, + org, + features, + }) + : "Unknown Price"; + return ( +
+

{text}

+ +
+ ); + })} + + + + + + + + + No prices found. + + { + setConfig("apply_to_all", !config.apply_to_all); + }} + className="cursor-pointer" + > +

Apply to all products

+ {config.apply_to_all && ( + + )} +
+
+ {!config.apply_to_all && + products.map((product: any) => ( + + {product.items.length > 0 ? ( + product.items + ?.filter((item: ProductItem) => { + return !isFeatureItem(item); + }) + .map((item: any) => ( + handlePriceToggle(item.price_id)} + className="cursor-pointer overflow-x-hidden max-w-[380px]" + > + + {formatProductItemText({ + item, + org, + features, + })} + - {config.price_ids?.includes(item.price_id) && ( - - )} - - )) - ) : ( - -

No prices available

-
- )} -
- ))} -
-
-
-
- - ); + {config.price_ids?.includes(item.price_id) && ( + + )} +
+ )) + ) : ( + +

No prices available

+
+ )} +
+ ))} +
+
+
+
+ + ); }; diff --git a/vite/src/views/products/rewards/reward-config/RewardConfig.tsx b/vite/src/views/products/rewards/reward-config/RewardConfig.tsx index 092dc6cff..6da6a9ae4 100644 --- a/vite/src/views/products/rewards/reward-config/RewardConfig.tsx +++ b/vite/src/views/products/rewards/reward-config/RewardConfig.tsx @@ -30,207 +30,133 @@ import { DiscountConfig } from "./DiscountConfig"; import { FreeDurationSelect } from "./FreeDurationSelect"; export const RewardConfig = ({ - reward, - setReward, + reward, + setReward, }: { - reward: Reward; - setReward: (reward: Reward) => void; + reward: Reward; + setReward: (reward: Reward) => void; }) => { - const [idChanged, setIdChanged] = useState(false); - const { products } = useProductsQuery(); - const { org } = useOrg(); + const [idChanged, setIdChanged] = useState(false); + const { products } = useProductsQuery(); - useEffect(() => { - if (!idChanged) { - setReward({ - ...reward, - id: slugify(reward.name || ""), - }); - } - }, [reward, idChanged, setReward]); + useEffect(() => { + if (!idChanged) { + setReward({ + ...reward, + id: slugify(reward.name || ""), + }); + } + }, [idChanged, reward, setReward]); - return ( -
-
-
- Name - setReward({ ...reward, name: e.target.value })} - /> -
-
- - ID - - { - setReward({ ...reward, id: e.target.value }); - setIdChanged(true); - }} - /> -
-
-
-
- Promotional Code - 0 ? reward.promo_codes[0].code : "" - } - onChange={(e) => - setReward({ - ...reward, - promo_codes: [{ code: e.target.value }], - }) - } - /> -
-
- Type - -
-
- {reward.type === RewardType.FreeProduct ? ( -
-
- - Product - -
- setReward({ ...reward, name: e.target.value })} + /> +
+
+ + ID + + { + setReward({ ...reward, id: e.target.value }); + setIdChanged(true); + }} + /> +
+
+
+
+ Promotional Code + 0 ? reward.promo_codes[0].code : "" + } + onChange={(e) => + setReward({ + ...reward, + promo_codes: [{ code: e.target.value }], + }) + } + /> +
+
+ Type + +
+
+ {reward.type === RewardType.FreeProduct ? ( +
+ + Product + + - - {(() => { - const selectedProduct = products.find( - (p: ProductV2) => p.id === reward.free_product_id, - ); - - if (!selectedProduct) return null; - - const isPaidSelected = !isFreeProduct(selectedProduct.items); - if (!isPaidSelected) return null; - - const isRecurringSelected = !isOneOffProduct(selectedProduct.items); - const hasUsagePrices = selectedProduct.items.some( - (x) => - isFeaturePriceItem(x) && x.usage_model === UsageModel.PayPerUse, - ); - - const priceItem = selectedProduct.items.find((x) => isPriceItem(x)); - const currency = org?.default_currency || "USD"; - const fixedAmountStr = priceItem?.price - ? formatCurrency({ amount: priceItem.price, currency }) - : undefined; - - if (isRecurringSelected) { - return ( -
- - Users will receive a coupon equal to this product's fixed - price amount.{" "} - {fixedAmountStr - ? `If they're on a different tier, they will receive ${fixedAmountStr} off.` - : "If they're on a different tier, they will receive the fixed amount off."}{" "} - {hasUsagePrices - ? "Charges due to usage prices will not be included in the coupon." - : ""} - -
- ); - } - - return; - })()} -
- ) : notNullish(reward.type) ? ( - - ) : null} - - {reward.type === RewardType.FreeProduct && - notNullish(reward.free_product_id) && - reward.free_product_id && - !isOneOffProduct( - products.find( - (product: ProductV2) => product.id === reward.free_product_id, - )?.items || [], - ) ? ( - - ) : null} - - ); + const empty = freeAddOns.length === 0; + return ( + <> + + + + + {freeAddOns.map((product: ProductV2) => ( + + {product.name} + + ))} + + + ); + })()} + + + ) : notNullish(reward.type) ? ( + + ) : null} + + ); }; diff --git a/vite/src/views/products/rewards/reward-config/UpdateReward.tsx b/vite/src/views/products/rewards/reward-config/UpdateReward.tsx index c57a3cd20..63937f21d 100644 --- a/vite/src/views/products/rewards/reward-config/UpdateReward.tsx +++ b/vite/src/views/products/rewards/reward-config/UpdateReward.tsx @@ -1,79 +1,128 @@ -import React, { useState } from "react"; +import type { ProductV2, Reward } from "@autumn/shared"; +import { analyzeRewardPrices } from "@autumn/shared"; +import { useState } from "react"; +import { toast } from "sonner"; +import { WarningBox } from "@/components/general/modal-components/WarningBox"; +import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogFooter, DialogTitle, } from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { useAxiosInstance } from "@/services/useAxiosInstance"; -import { toast } from "sonner"; -import { Reward } from "@autumn/shared"; -import { useEnv } from "@/utils/envUtils"; -import { RewardService } from "@/services/products/RewardService"; -import { getBackendErr } from "@/utils/genUtils"; -import { WarningBox } from "@/components/general/modal-components/WarningBox"; +import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; import { useRewardsQuery } from "@/hooks/queries/useRewardsQuery"; +import { RewardService } from "@/services/products/RewardService"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; +import { useEnv } from "@/utils/envUtils"; +import { getBackendErr } from "@/utils/genUtils"; import { RewardConfig } from "./RewardConfig"; +const checkRewardMigration = ( + reward: Reward, + products: ProductV2[], +): { willMigrateCount: number; willNotMigrateCount: number } => { + // Extract all available price IDs from current products + const availablePriceIds: string[] = []; + for (const product of products) { + if (product.items) { + for (const item of product.items) { + if (item.price_id) { + availablePriceIds.push(item.price_id); + } + } + } + } + + // Use the shared utility to analyze the reward + const analysis = analyzeRewardPrices({ + reward, + availablePriceIds, + }); + + return { + willMigrateCount: analysis.validPriceCount, + willNotMigrateCount: analysis.invalidPriceCount, + }; +}; + function UpdateReward({ - open, - setOpen, - selectedReward, - setSelectedReward, + open, + setOpen, + selectedReward, + setSelectedReward, }: { - open: boolean; - setOpen: (open: boolean) => void; - selectedReward: Reward | null; - setSelectedReward: (reward: Reward) => void; + open: boolean; + setOpen: (open: boolean) => void; + selectedReward: Reward | null; + setSelectedReward: (reward: Reward) => void; }) { - const [updateLoading, setUpdateLoading] = useState(false); - const { refetch } = useRewardsQuery(); + const [updateLoading, setUpdateLoading] = useState(false); + const { refetch } = useRewardsQuery(); + const { products } = useProductsQuery(); - const env = useEnv(); - const axiosInstance = useAxiosInstance({ env }); + const env = useEnv(); + const axiosInstance = useAxiosInstance({ env }); - const handleUpdate = async () => { - setUpdateLoading(true); - try { - await RewardService.updateReward({ - axiosInstance, - internalId: selectedReward!.internal_id, - data: selectedReward!, - }); - toast.success("Reward updated successfully"); - await refetch(); - setOpen(false); - } catch (error) { - toast.error(getBackendErr(error, "Failed to update coupon")); - } - setUpdateLoading(false); - }; + if (!selectedReward) { + setOpen(false); + return; + } - return ( - - - Update Reward - - Existing customers with this coupon will not be affected - + const handleUpdate = async () => { + setUpdateLoading(true); + try { + // Check migration status and show warning if needed + if (products) { + const migrationResult = checkRewardMigration(selectedReward, products); + if (migrationResult.willNotMigrateCount > 0) { + toast.warning( + `${migrationResult.willNotMigrateCount} price${migrationResult.willNotMigrateCount === 1 ? "" : "s"} won't be migrated to the latest product version.`, + { + duration: 5000, + }, + ); + } + } - {selectedReward && ( - - )} + await RewardService.updateReward({ + axiosInstance, + internalId: selectedReward.internal_id, + data: selectedReward, + }); + toast.success("Reward updated successfully"); + await refetch(); + setOpen(false); + } catch (error) { + toast.error(getBackendErr(error, "Failed to update coupon")); + } + setUpdateLoading(false); + }; - - - - - - ); + return ( + + + Update Reward + + Existing customers with this coupon will not be affected + + + {selectedReward && ( + + )} + + + + + + + ); } export default UpdateReward; diff --git a/vite/src/views/products/rewards/utils/defaultRewardModels.ts b/vite/src/views/products/rewards/utils/defaultRewardModels.ts index 882f66036..2fdf9ad5a 100644 --- a/vite/src/views/products/rewards/utils/defaultRewardModels.ts +++ b/vite/src/views/products/rewards/utils/defaultRewardModels.ts @@ -7,32 +7,32 @@ import { } from "@autumn/shared"; export const defaultDiscountConfig: DiscountConfig = { - discount_value: 0, - duration_type: CouponDurationType.Months, - duration_value: 0, - should_rollover: true, - apply_to_all: true, - price_ids: [], + discount_value: 0, + duration_type: CouponDurationType.Months, + duration_value: 0, + should_rollover: true, + apply_to_all: true, + price_ids: [], }; export const defaultFreeProductConfig: FreeProductConfig = { - duration_type: CouponDurationType.Months, - duration_value: 0, + duration_type: CouponDurationType.Months, + duration_value: 0, }; export const defaultReward: CreateReward = { - name: "", - id: "", - promo_codes: [{ code: "" }], + name: "", + id: "", + promo_codes: [{ code: "" }], - type: RewardType.PercentageDiscount, + type: RewardType.PercentageDiscount, - // For free product coupons - free_product_id: null, + // For free product coupons + free_product_id: null, - // For discount type coupons - discount_config: defaultDiscountConfig, + // For discount type coupons + discount_config: defaultDiscountConfig, - // For free product type coupons - free_product_config: defaultFreeProductConfig, + // For free product type coupons + free_product_config: defaultFreeProductConfig, };