feat: 🎸 toast to alert of any unmigrated prices

This commit is contained in:
amianthus
2025-09-19 12:53:06 +01:00
parent 590a2ae86f
commit e2528194fd
4 changed files with 385 additions and 181 deletions

View File

@@ -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";

View File

@@ -13,6 +13,8 @@ export const UsageTierSchema = z.object({
amount: z.number(),
});
export type UsageTier = z.infer<typeof UsageTierSchema>;
export const UsagePriceConfigSchema = z.object({
type: z.string(),
bill_when: z.nativeEnum(BillWhen),

View File

@@ -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,
};
}

View File

@@ -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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="w-[500px]">
<DialogTitle>Update Reward</DialogTitle>
<WarningBox>
Existing customers with this coupon will not be affected
</WarningBox>
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 && (
<RewardConfig reward={selectedReward} setReward={setSelectedReward} />
)}
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);
};
<DialogFooter>
<Button
isLoading={updateLoading}
onClick={() => handleUpdate()}
variant="gradientPrimary"
>
Update
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="w-[500px]">
<DialogTitle>Update Reward</DialogTitle>
<WarningBox>
Existing customers with this coupon will not be affected
</WarningBox>
{selectedReward && (
<RewardConfig reward={selectedReward} setReward={setSelectedReward} />
)}
<DialogFooter>
<Button
isLoading={updateLoading}
onClick={() => handleUpdate()}
variant="gradientPrimary"
>
Update
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default UpdateReward;