From e2528194fd9be00f4ee9be3600f4091d27504fef Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Fri, 19 Sep 2025 12:53:06 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20toast=20to=20alert=20of?= =?UTF-8?q?=20any=20unmigrated=20prices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/index.ts | 220 ++++++++---------- .../priceConfig/usagePriceConfig.ts | 2 + .../utils/rewardUtils/rewardMigrationUtils.ts | 177 ++++++++++++++ .../rewards/reward-config/UpdateReward.tsx | 167 ++++++++----- 4 files changed, 385 insertions(+), 181 deletions(-) create mode 100644 shared/utils/rewardUtils/rewardMigrationUtils.ts 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/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;