diff --git a/shared/index.ts b/shared/index.ts index 1936c5226..01453d971 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -197,8 +197,14 @@ export * from "./utils/productDisplayUtils.js"; export * from "./utils/productUtils/convertProductUtils.js"; export * from "./utils/productUtils/priceToInvoiceAmount.js"; export * from "./utils/productUtils/productUtils.js"; -export * from "./utils/productV2Utils/compareProductUtils.ts/compareItemUtils.js"; -export * from "./utils/productV2Utils/compareProductUtils.ts/compareProductUtils.js"; +export * from "./utils/productV2Utils/compareProductUtils/buildEditsForItem.js"; +export * from "./utils/productV2Utils/compareProductUtils/compareItemUtils.js"; +export * from "./utils/productV2Utils/compareProductUtils/compareProductUtils.js"; +export * from "./utils/productV2Utils/compareProductUtils/generateItemChanges.js"; +export * from "./utils/productV2Utils/compareProductUtils/generatePrepaidChanges.js"; +export * from "./utils/productV2Utils/compareProductUtils/generateTrialChanges.js"; +export * from "./utils/productV2Utils/compareProductUtils/generateVersionChanges.js"; +export * from "./utils/productV2Utils/compareProductUtils/itemEditTypes.js"; export * from "./utils/productV2Utils/productItemUtils/convertItemUtils.js"; export * from "./utils/productV2Utils/productItemUtils/getProductItemRes.js"; export * from "./utils/productV2Utils/productItemUtils/itemIntervalUtils.js"; diff --git a/shared/utils/productV2Utils/compareProductUtils.ts/add.txt b/shared/utils/productV2Utils/compareProductUtils.ts/add.txt deleted file mode 100644 index e69de29bb..000000000 diff --git a/vite/src/components/forms/update-subscription-v2/utils/buildEditsForItem.ts b/shared/utils/productV2Utils/compareProductUtils/buildEditsForItem.ts similarity index 67% rename from vite/src/components/forms/update-subscription-v2/utils/buildEditsForItem.ts rename to shared/utils/productV2Utils/compareProductUtils/buildEditsForItem.ts index 6477ab430..d7771689f 100644 --- a/vite/src/components/forms/update-subscription-v2/utils/buildEditsForItem.ts +++ b/shared/utils/productV2Utils/compareProductUtils/buildEditsForItem.ts @@ -1,44 +1,47 @@ -import { type ProductItem, UsageModel } from "@autumn/shared"; -import type { ItemEdit } from "../types/summary"; +import { + type ProductItem, + UsageModel, +} from "../../../models/productV2Models/productItemModels/productItemModels.js"; +import type { ItemEdit } from "./itemEditTypes.js"; -/** Format tier threshold value for display */ function formatTierValue(value: string | number | "inf"): string { return value === "inf" ? "Infinite" : String(value); } +/** Builds a list of granular edits for a single product item comparison */ export function buildEditsForItem({ - item, originalItem, - prepaidQuantity, - initialPrepaidQuantity, + updatedItem, + originalPrepaidQuantity, + updatedPrepaidQuantity, }: { - item: ProductItem; originalItem?: ProductItem; - prepaidQuantity?: number; - initialPrepaidQuantity?: number; + updatedItem: ProductItem; + originalPrepaidQuantity?: number; + updatedPrepaidQuantity?: number; }): ItemEdit[] { const edits: ItemEdit[] = []; - const isPrepaid = item.usage_model === UsageModel.Prepaid; + const isPrepaid = updatedItem.usage_model === UsageModel.Prepaid; if (originalItem) { if ( - originalItem.price !== item.price && + originalItem.price !== updatedItem.price && originalItem.price !== null && originalItem.price !== undefined && - item.price !== null && - item.price !== undefined + updatedItem.price !== null && + updatedItem.price !== undefined ) { const oldPrice = originalItem.price; - const newPrice = item.price; - const isUpgrade = newPrice < oldPrice; + const newPrice = updatedItem.price; + const isUpgrade = newPrice > oldPrice; edits.push({ - id: `price-${item.feature_id}`, + id: `price-${updatedItem.feature_id}`, type: "config", icon: "price", label: "Price", description: isUpgrade - ? `Price decreased from $${oldPrice} to $${newPrice}` - : `Price increased from $${oldPrice} to $${newPrice}`, + ? `Price increased from $${oldPrice} to $${newPrice}` + : `Price decreased from $${oldPrice} to $${newPrice}`, oldValue: `$${oldPrice}`, newValue: `$${newPrice}`, isUpgrade, @@ -46,7 +49,7 @@ export function buildEditsForItem({ } const oldTiers = originalItem.tiers ?? []; - const newTiers = item.tiers ?? []; + const newTiers = updatedItem.tiers ?? []; for (let i = 0; i < newTiers.length; i++) { const newTier = newTiers[i]; @@ -57,7 +60,7 @@ export function buildEditsForItem({ const prevTierTo = i === 0 ? "0" : (oldTiers[i - 1]?.to ?? "0"); const prevLabel = formatTierValue(prevTierTo); edits.push({ - id: `tier-${item.feature_id}-${i}`, + id: `tier-${updatedItem.feature_id}-${i}`, type: "config", icon: "tier", label: "Pricing Tier", @@ -67,15 +70,15 @@ export function buildEditsForItem({ isUpgrade: true, }); } else if (oldTier.amount !== newTier.amount) { - const isUpgrade = newTier.amount < oldTier.amount; + const isUpgrade = newTier.amount > oldTier.amount; edits.push({ - id: `tier-${item.feature_id}-${i}`, + id: `tier-${updatedItem.feature_id}-${i}`, type: "config", icon: "tier", label: "Pricing Tier", description: isUpgrade - ? `Tier price decreased from $${oldTier.amount} to $${newTier.amount} (up to ${tierLabel})` - : `Tier price increased from $${oldTier.amount} to $${newTier.amount} (up to ${tierLabel})`, + ? `Tier price increased from $${oldTier.amount} to $${newTier.amount} (up to ${tierLabel})` + : `Tier price decreased from $${oldTier.amount} to $${newTier.amount} (up to ${tierLabel})`, oldValue: `$${oldTier.amount}`, newValue: `$${newTier.amount}`, isUpgrade, @@ -87,7 +90,7 @@ export function buildEditsForItem({ newTier.to === "inf" || (oldTier.to !== "inf" && Number(newTier.to) > Number(oldTier.to)); edits.push({ - id: `tier-${item.feature_id}-${i}-threshold`, + id: `tier-${updatedItem.feature_id}-${i}-threshold`, type: "config", icon: "tier", label: "Pricing Tier", @@ -105,7 +108,7 @@ export function buildEditsForItem({ const oldTier = oldTiers[i]; const tierLabel = formatTierValue(oldTier.to); edits.push({ - id: `tier-${item.feature_id}-${i}-removed`, + id: `tier-${updatedItem.feature_id}-${i}-removed`, type: "config", icon: "tier", label: "Pricing Tier", @@ -117,7 +120,7 @@ export function buildEditsForItem({ } const oldUsage = originalItem.included_usage ?? 0; - const newUsage = item.included_usage ?? 0; + const newUsage = updatedItem.included_usage ?? 0; if (oldUsage !== newUsage) { const formatUsageValue = (val: string | number) => val === "inf" ? "unlimited" : String(val); @@ -129,7 +132,7 @@ export function buildEditsForItem({ newUsage === "inf" ? Number.POSITIVE_INFINITY : Number(newUsage); const isUpgrade = newNum > oldNum; edits.push({ - id: `usage-${item.feature_id}`, + id: `usage-${updatedItem.feature_id}`, type: "config", icon: "usage", label: "Included Usage", @@ -143,11 +146,11 @@ export function buildEditsForItem({ } const oldUnits = originalItem.billing_units ?? 1; - const newUnits = item.billing_units ?? 1; + const newUnits = updatedItem.billing_units ?? 1; if (oldUnits !== newUnits) { const isUpgrade = newUnits > oldUnits; edits.push({ - id: `units-${item.feature_id}`, + id: `units-${updatedItem.feature_id}`, type: "config", icon: "units", label: "Billing Units", @@ -163,21 +166,21 @@ export function buildEditsForItem({ if ( isPrepaid && - initialPrepaidQuantity !== undefined && - prepaidQuantity !== undefined && - prepaidQuantity !== initialPrepaidQuantity + originalPrepaidQuantity !== undefined && + updatedPrepaidQuantity !== undefined && + updatedPrepaidQuantity !== originalPrepaidQuantity ) { - const isUpgrade = prepaidQuantity > initialPrepaidQuantity; + const isUpgrade = updatedPrepaidQuantity > originalPrepaidQuantity; edits.push({ - id: `prepaid-${item.feature_id}`, + id: `prepaid-${updatedItem.feature_id}`, type: "prepaid", icon: "prepaid", label: "Prepaid Quantity", description: isUpgrade - ? `Prepaid quantity increased from ${initialPrepaidQuantity} to ${prepaidQuantity}` - : `Prepaid quantity decreased from ${initialPrepaidQuantity} to ${prepaidQuantity}`, - oldValue: `${initialPrepaidQuantity} Prepaid`, - newValue: `${prepaidQuantity} Prepaid`, + ? `Prepaid quantity increased from ${originalPrepaidQuantity} to ${updatedPrepaidQuantity}` + : `Prepaid quantity decreased from ${originalPrepaidQuantity} to ${updatedPrepaidQuantity}`, + oldValue: `${originalPrepaidQuantity} Prepaid`, + newValue: `${updatedPrepaidQuantity} Prepaid`, isUpgrade, editable: true, }); diff --git a/shared/utils/productV2Utils/compareProductUtils.ts/compareItemUtils.ts b/shared/utils/productV2Utils/compareProductUtils/compareItemUtils.ts similarity index 100% rename from shared/utils/productV2Utils/compareProductUtils.ts/compareItemUtils.ts rename to shared/utils/productV2Utils/compareProductUtils/compareItemUtils.ts diff --git a/shared/utils/productV2Utils/compareProductUtils.ts/compareProductUtils.ts b/shared/utils/productV2Utils/compareProductUtils/compareProductUtils.ts similarity index 100% rename from shared/utils/productV2Utils/compareProductUtils.ts/compareProductUtils.ts rename to shared/utils/productV2Utils/compareProductUtils/compareProductUtils.ts diff --git a/vite/src/components/forms/update-subscription-v2/utils/generateItemChanges.ts b/shared/utils/productV2Utils/compareProductUtils/generateItemChanges.ts similarity index 62% rename from vite/src/components/forms/update-subscription-v2/utils/generateItemChanges.ts rename to shared/utils/productV2Utils/compareProductUtils/generateItemChanges.ts index 2c5ac8ce6..64c1b345d 100644 --- a/vite/src/components/forms/update-subscription-v2/utils/generateItemChanges.ts +++ b/shared/utils/productV2Utils/compareProductUtils/generateItemChanges.ts @@ -1,10 +1,10 @@ +import type { Feature } from "../../../models/featureModels/featureModels.js"; +import type { PriceTier } from "../../../models/productV2Models/productItemModels/productItemModels.js"; import { - type Feature, - type PriceTier, type ProductItem, UsageModel, -} from "@autumn/shared"; -import type { SummaryItem } from "../types/summary"; +} from "../../../models/productV2Models/productItemModels/productItemModels.js"; +import type { ItemEdit } from "./itemEditTypes.js"; function hasValue(value: T | null | undefined): value is T { return value !== null && value !== undefined; @@ -37,18 +37,19 @@ function formatTierPricing({ : `$${firstPricedTier.amount} per unit`; } +/** Generates edit items for product item additions, removals, and modifications */ export function generateItemChanges({ originalItems, - customizedItems, + updatedItems, features, prepaidOptions, }: { originalItems: ProductItem[] | undefined; - customizedItems: ProductItem[] | null; + updatedItems: ProductItem[] | null; features?: Feature[]; prepaidOptions?: Record; -}): SummaryItem[] { - if (!customizedItems || !originalItems) return []; +}): ItemEdit[] { + if (!updatedItems || !originalItems) return []; const featureNameMap = new Map(); if (features) { @@ -65,103 +66,120 @@ export function generateItemChanges({ return item.feature_id ?? "Item"; }; - const changes: SummaryItem[] = []; + const changes: ItemEdit[] = []; const originalFeatureMap = new Map( originalItems .filter((item) => item.feature_id) .map((item) => [item.feature_id, item]), ); - const customizedFeatureMap = new Map( - customizedItems + const updatedFeatureMap = new Map( + updatedItems .filter((item) => item.feature_id) .map((item) => [item.feature_id, item]), ); for (const [featureId, original] of originalFeatureMap) { - const customized = customizedFeatureMap.get(featureId); + const updated = updatedFeatureMap.get(featureId); - if (!customized) { + if (!updated) { + const oldFormatted = formatItemValue(original); changes.push({ id: `item-removed-${featureId}`, type: "item", label: getFeatureName(original), - oldValue: formatItemValue(original), + icon: "item", + description: `${getFeatureName(original)} removed (was ${oldFormatted})`, + oldValue: oldFormatted, newValue: null, - productItem: original, + isUpgrade: false, }); - } else if (hasItemChanged(original, customized)) { + } else if ( + hasItemChanged({ originalItem: original, updatedItem: updated }) + ) { const oldValueFormatted = formatChangedItemValue({ item: original, - original, - customized, + originalItem: original, + updatedItem: updated, }); const newValueFormatted = formatChangedItemValue({ - item: customized, - original, - customized, + item: updated, + originalItem: original, + updatedItem: updated, }); changes.push({ id: `item-modified-${featureId}`, type: "item", label: getFeatureName(original), + icon: "item", + description: `${getFeatureName(original)} changed from ${oldValueFormatted} to ${newValueFormatted}`, oldValue: oldValueFormatted, newValue: newValueFormatted, - productItem: customized, + isUpgrade: true, }); } } - for (const [featureId, customized] of customizedFeatureMap) { + for (const [featureId, updated] of updatedFeatureMap) { if (!originalFeatureMap.has(featureId)) { const prepaidQuantity = featureId && prepaidOptions ? prepaidOptions[featureId] : undefined; const isPrepaidWithQuantity = - customized.usage_model === UsageModel.Prepaid && + updated.usage_model === UsageModel.Prepaid && hasValue(prepaidQuantity) && prepaidQuantity > 0; - const billingUnits = customized.billing_units ?? 1; + const billingUnits = updated.billing_units ?? 1; const displayQuantity = prepaidQuantity ? prepaidQuantity * billingUnits : 0; + const newValue = isPrepaidWithQuantity + ? displayQuantity + : formatItemValue(updated); + changes.push({ id: `item-added-${featureId}`, type: "item", - label: getFeatureName(customized), + label: getFeatureName(updated), + icon: "item", + description: `${getFeatureName(updated)} added (${newValue})`, oldValue: isPrepaidWithQuantity ? 0 : null, - newValue: isPrepaidWithQuantity - ? displayQuantity - : formatItemValue(customized), - productItem: customized, + newValue, + isUpgrade: true, }); } } const originalPriceItems = originalItems.filter((item) => !item.feature_id); - const customizedPriceItems = customizedItems.filter( - (item) => !item.feature_id, - ); + const updatedPriceItems = updatedItems.filter((item) => !item.feature_id); - if (originalPriceItems.length !== customizedPriceItems.length) { - const priceDiff = customizedPriceItems.length - originalPriceItems.length; + if (originalPriceItems.length !== updatedPriceItems.length) { + const priceDiff = updatedPriceItems.length - originalPriceItems.length; if (priceDiff > 0) { + const label = `${priceDiff} Price Item${priceDiff > 1 ? "s" : ""}`; changes.push({ id: "price-items-added", type: "item", - label: `${priceDiff} Price Item${priceDiff > 1 ? "s" : ""}`, + label, + icon: "price", + description: `${label} added`, oldValue: null, newValue: String(priceDiff), + isUpgrade: true, }); } else { + const label = `${Math.abs(priceDiff)} Price Item${Math.abs(priceDiff) > 1 ? "s" : ""}`; changes.push({ id: "price-items-removed", type: "item", - label: `${Math.abs(priceDiff)} Price Item${Math.abs(priceDiff) > 1 ? "s" : ""}`, + label, + icon: "price", + description: `${label} removed`, oldValue: String(Math.abs(priceDiff)), newValue: null, + isUpgrade: false, }); } } @@ -169,16 +187,19 @@ export function generateItemChanges({ return changes; } -function hasItemChanged( - original: ProductItem, - customized: ProductItem, -): boolean { +function hasItemChanged({ + originalItem, + updatedItem, +}: { + originalItem: ProductItem; + updatedItem: ProductItem; +}): boolean { return ( - original.price !== customized.price || - original.included_usage !== customized.included_usage || - JSON.stringify(original.tiers) !== JSON.stringify(customized.tiers) || - original.billing_units !== customized.billing_units || - original.interval !== customized.interval + originalItem.price !== updatedItem.price || + originalItem.included_usage !== updatedItem.included_usage || + JSON.stringify(originalItem.tiers) !== JSON.stringify(updatedItem.tiers) || + originalItem.billing_units !== updatedItem.billing_units || + originalItem.interval !== updatedItem.interval ); } @@ -209,28 +230,23 @@ function formatItemValue(item: ProductItem): string { return `$${item.price}`; } - // Boolean features have no price/tiers/usage - just "enabled" when present if (parts.length === 0) return "Enabled"; return parts.join(" + "); } -/** - * Format only the parts of an item that changed between original and customized. - * Avoids showing unchanged values like "+ $10 per unit" when only included_usage changed. - */ function formatChangedItemValue({ item, - original, - customized, + originalItem, + updatedItem, }: { item: ProductItem; - original: ProductItem; - customized: ProductItem; + originalItem: ProductItem; + updatedItem: ProductItem; }): string { const parts: string[] = []; const includedUsageChanged = - original.included_usage !== customized.included_usage; + originalItem.included_usage !== updatedItem.included_usage; if (includedUsageChanged) { if (item.included_usage === "inf") { parts.push("Unlimited"); @@ -239,32 +255,31 @@ function formatChangedItemValue({ } } - const priceChanged = original.price !== customized.price; + const priceChanged = originalItem.price !== updatedItem.price; if (priceChanged && hasValue(item.price)) { const billingUnits = item.billing_units ?? 1; parts.push(formatPriceWithUnits(item.price, billingUnits)); } const tiersChanged = - JSON.stringify(original.tiers) !== JSON.stringify(customized.tiers); + JSON.stringify(originalItem.tiers) !== JSON.stringify(updatedItem.tiers); if (tiersChanged && item.tiers?.length) { const billingUnits = item.billing_units ?? 1; parts.push(formatTierPricing({ tiers: item.tiers, billingUnits })); } const billingUnitsChanged = - original.billing_units !== customized.billing_units; + originalItem.billing_units !== updatedItem.billing_units; if (billingUnitsChanged && !priceChanged && hasValue(item.price)) { const billingUnits = item.billing_units ?? 1; parts.push(formatPriceWithUnits(item.price, billingUnits)); } - const intervalChanged = original.interval !== customized.interval; + const intervalChanged = originalItem.interval !== updatedItem.interval; if (intervalChanged && item.interval) { parts.push(`${item.interval}`); } - // Boolean features or edge cases (null vs undefined) - just "enabled" when present if (parts.length === 0) return "Enabled"; return parts.join(" + "); } diff --git a/shared/utils/productV2Utils/compareProductUtils/generatePrepaidChanges.ts b/shared/utils/productV2Utils/compareProductUtils/generatePrepaidChanges.ts new file mode 100644 index 000000000..f5cea64e4 --- /dev/null +++ b/shared/utils/productV2Utils/compareProductUtils/generatePrepaidChanges.ts @@ -0,0 +1,59 @@ +import type { ProductItem } from "../../../models/productV2Models/productItemModels/productItemModels.js"; +import { formatAmount } from "../../common/formatUtils/formatAmount.js"; +import type { ItemEdit } from "./itemEditTypes.js"; + +/** Generates edit items for prepaid quantity changes */ +export function generatePrepaidChanges({ + prepaidItems, + originalOptions, + updatedOptions, + currency = "usd", +}: { + prepaidItems: ProductItem[]; + originalOptions: Record; + updatedOptions: Record; + currency?: string; +}): ItemEdit[] { + return prepaidItems + .map((item) => { + const featureId = item.feature_id ?? ""; + const oldQuantity = originalOptions[featureId] ?? 0; + const newQuantity = updatedOptions[featureId] ?? 0; + + if (oldQuantity === newQuantity) return null; + + const billingUnits = item.billing_units ?? 1; + const oldDisplayQuantity = oldQuantity * billingUnits; + const newDisplayQuantity = newQuantity * billingUnits; + + const unitPrice = item.price ?? null; + const costDelta = + unitPrice !== null ? (newQuantity - oldQuantity) * unitPrice : null; + + const featureName = item.feature?.name ?? "Items"; + const isUpgrade = newQuantity > oldQuantity; + + let description = `Prepaid quantity changed from ${oldDisplayQuantity} to ${newDisplayQuantity}`; + if (costDelta !== null && costDelta !== 0) { + const formattedCost = formatAmount({ + amount: Math.abs(costDelta), + currency, + minFractionDigits: 2, + amountFormatOptions: { currencyDisplay: "narrowSymbol" }, + }); + description += ` (${costDelta > 0 ? "+" : "-"}${formattedCost})`; + } + + return { + id: `prepaid-${featureId}`, + type: "prepaid" as const, + label: featureName, + icon: "prepaid" as const, + description, + oldValue: oldDisplayQuantity, + newValue: newDisplayQuantity, + isUpgrade, + }; + }) + .filter(Boolean) as ItemEdit[]; +} diff --git a/vite/src/components/forms/update-subscription-v2/utils/generateTrialChanges.ts b/shared/utils/productV2Utils/compareProductUtils/generateTrialChanges.ts similarity index 59% rename from vite/src/components/forms/update-subscription-v2/utils/generateTrialChanges.ts rename to shared/utils/productV2Utils/compareProductUtils/generateTrialChanges.ts index 32db21cef..8303c0e3c 100644 --- a/vite/src/components/forms/update-subscription-v2/utils/generateTrialChanges.ts +++ b/shared/utils/productV2Utils/compareProductUtils/generateTrialChanges.ts @@ -1,12 +1,12 @@ +import { addDays, formatDuration, intervalToDuration } from "date-fns"; +import type { FullCusProduct } from "../../../models/cusProductModels/cusProductModels.js"; +import type { FreeTrialDuration } from "../../../models/productModels/freeTrialModels/freeTrialEnums.js"; +import { isCustomerProductTrialing } from "../../cusProductUtils/classifyCustomerProduct/classifyCustomerProduct.js"; import { - type FreeTrialDuration, - type FullCusProduct, getRemainingTrialDays, getTrialLengthInDays, - isCustomerProductTrialing, -} from "@autumn/shared"; -import { addDays, formatDuration, intervalToDuration } from "date-fns"; -import type { SummaryItem } from "../types/summary"; +} from "../../productUtils/freeTrialUtils.js"; +import type { ItemEdit } from "./itemEditTypes.js"; function formatDaysAsReadable(totalDays: number): string { if (totalDays <= 0) return "0 days"; @@ -21,6 +21,7 @@ function formatDaysAsReadable(totalDays: number): string { }); } +/** Generates edit items for trial period changes */ export function generateTrialChanges({ customerProduct, removeTrial, @@ -31,20 +32,23 @@ export function generateTrialChanges({ removeTrial: boolean; trialLength: number | null; trialDuration: FreeTrialDuration; -}): SummaryItem[] { +}): ItemEdit[] { const isCurrentlyTrialing = isCustomerProductTrialing(customerProduct); const remainingDays = getRemainingTrialDays({ trialEndsAt: customerProduct.trial_ends_at, }); - const changes: SummaryItem[] = []; + const changes: ItemEdit[] = []; if (removeTrial && isCurrentlyTrialing) { changes.push({ id: "trial-remove", type: "trial", label: "End Trial", + icon: "trial", + description: "Trial ended", oldValue: "Active", newValue: null, + isUpgrade: false, }); return changes; } @@ -53,25 +57,35 @@ export function generateTrialChanges({ const newTrialDays = getTrialLengthInDays({ trialLength, trialDuration }); if (isCurrentlyTrialing && remainingDays !== null) { - // Skip if no actual change (same number of days) if (newTrialDays === remainingDays) return changes; const isExtending = newTrialDays > remainingDays; + const oldFormatted = formatDaysAsReadable(remainingDays); + const newFormatted = formatDaysAsReadable(newTrialDays); + changes.push({ id: isExtending ? "trial-extend" : "trial-shorten", type: "trial", label: isExtending ? "Extend Trial" : "Shorten Trial", - oldValue: formatDaysAsReadable(remainingDays), - newValue: formatDaysAsReadable(newTrialDays), + icon: "trial", + description: isExtending + ? `Trial extended from ${oldFormatted} to ${newFormatted}` + : `Trial shortened from ${oldFormatted} to ${newFormatted}`, + oldValue: oldFormatted, + newValue: newFormatted, + isUpgrade: isExtending, }); } else { - // Not currently trialing - adding new trial + const newFormatted = formatDaysAsReadable(newTrialDays); changes.push({ id: "trial-add", type: "trial", label: "Free Trial", + icon: "trial", + description: `Free trial added for ${newFormatted}`, oldValue: null, - newValue: formatDaysAsReadable(newTrialDays), + newValue: newFormatted, + isUpgrade: true, }); } } diff --git a/shared/utils/productV2Utils/compareProductUtils/generateVersionChanges.ts b/shared/utils/productV2Utils/compareProductUtils/generateVersionChanges.ts new file mode 100644 index 000000000..25ba1ac93 --- /dev/null +++ b/shared/utils/productV2Utils/compareProductUtils/generateVersionChanges.ts @@ -0,0 +1,31 @@ +import type { ItemEdit } from "./itemEditTypes.js"; + +/** Generates edit items for plan version changes */ +export function generateVersionChanges({ + originalVersion, + updatedVersion, +}: { + originalVersion: number; + updatedVersion: number; +}): ItemEdit[] { + if (updatedVersion === originalVersion) { + return []; + } + + const isUpgrade = updatedVersion > originalVersion; + + return [ + { + id: "version-change", + type: "version", + label: "Plan Version", + icon: "version", + description: isUpgrade + ? `Plan version upgraded from v${originalVersion} to v${updatedVersion}` + : `Plan version downgraded from v${originalVersion} to v${updatedVersion}`, + oldValue: originalVersion, + newValue: updatedVersion, + isUpgrade, + }, + ]; +} diff --git a/shared/utils/productV2Utils/compareProductUtils/itemEditTypes.ts b/shared/utils/productV2Utils/compareProductUtils/itemEditTypes.ts new file mode 100644 index 000000000..3d6d3e8dd --- /dev/null +++ b/shared/utils/productV2Utils/compareProductUtils/itemEditTypes.ts @@ -0,0 +1,30 @@ +/** Type of change being made */ +export type EditType = "config" | "prepaid" | "trial" | "version" | "item"; + +/** Icon type for visual representation of the edit */ +export type EditIconType = + | "price" + | "tier" + | "usage" + | "units" + | "prepaid" + | "trial" + | "version" + | "item"; + +/** Represents a single edit/change to a subscription or product item */ +export interface ItemEdit { + id: string; + type: EditType; + label: string; + /** Icon type for the edit */ + icon: EditIconType; + /** Full sentence description for display */ + description: string; + oldValue: string | number | null; + newValue: string | number | null; + /** Whether this is an upgrade (true) or downgrade (false) */ + isUpgrade: boolean; + /** Whether this edit has an inline editor (prepaid quantity) */ + editable?: boolean; +} diff --git a/vite/src/components/forms/update-subscription-v2/components/CompactValueChange.tsx b/vite/src/components/forms/update-subscription-v2/components/CompactValueChange.tsx index f7231db28..d29c63121 100644 --- a/vite/src/components/forms/update-subscription-v2/components/CompactValueChange.tsx +++ b/vite/src/components/forms/update-subscription-v2/components/CompactValueChange.tsx @@ -1,17 +1,25 @@ +import { cn } from "@/lib/utils"; + interface CompactValueChangeProps { oldValue: string | number | null; newValue: string | number | null; + isUpgrade?: boolean; } export function CompactValueChange({ oldValue, newValue, + isUpgrade = true, }: CompactValueChangeProps) { return ( - {oldValue} + + {oldValue} + - {newValue} + + {newValue} + ); } diff --git a/vite/src/components/forms/update-subscription-v2/components/EditPlanSection.tsx b/vite/src/components/forms/update-subscription-v2/components/EditPlanSection.tsx index 12745f0e2..4b6390b92 100644 --- a/vite/src/components/forms/update-subscription-v2/components/EditPlanSection.tsx +++ b/vite/src/components/forms/update-subscription-v2/components/EditPlanSection.tsx @@ -4,13 +4,16 @@ import type { ProductItem, ProductV2, } from "@autumn/shared"; -import { featureToOptions, UsageModel } from "@autumn/shared"; +import { + buildEditsForItem, + featureToOptions, + UsageModel, +} from "@autumn/shared"; import { PencilSimpleIcon } from "@phosphor-icons/react"; import { Button } from "@/components/v2/buttons/Button"; import { SheetSection } from "@/components/v2/sheets/SharedSheetComponents"; import { BasePriceDisplay } from "@/views/products/plan/components/plan-card/BasePriceDisplay"; import type { UseUpdateSubscriptionForm } from "../hooks/useUpdateSubscriptionForm"; -import { buildEditsForItem } from "../utils/buildEditsForItem"; import { SectionTitle } from "./SectionTitle"; import { SubscriptionItemRow } from "./SubscriptionItemRow"; @@ -96,10 +99,10 @@ export function EditPlanSection({ const isCreated = !originalItem && originalItems && originalItems.length > 0; const edits = buildEditsForItem({ - item, + updatedItem: item, originalItem, - prepaidQuantity: currentPrepaidQuantity, - initialPrepaidQuantity, + updatedPrepaidQuantity: currentPrepaidQuantity, + originalPrepaidQuantity: initialPrepaidQuantity, }); return ( diff --git a/vite/src/components/forms/update-subscription-v2/components/SubscriptionItemRow.tsx b/vite/src/components/forms/update-subscription-v2/components/SubscriptionItemRow.tsx index 2edd7d69f..f3f44a115 100644 --- a/vite/src/components/forms/update-subscription-v2/components/SubscriptionItemRow.tsx +++ b/vite/src/components/forms/update-subscription-v2/components/SubscriptionItemRow.tsx @@ -1,5 +1,6 @@ import { getProductItemDisplay, + type ItemEdit, type ProductItem, UsageModel, } from "@autumn/shared"; @@ -21,7 +22,6 @@ import { cn } from "@/lib/utils"; import { PlanFeatureIcon } from "@/views/products/plan/components/plan-card/PlanFeatureIcon"; import { CustomDotIcon } from "@/views/products/plan/components/plan-card/PlanFeatureRow"; import type { UseUpdateSubscriptionForm } from "../hooks/useUpdateSubscriptionForm"; -import type { ItemEdit } from "../types/summary"; import { getEditIcon } from "../utils/getEditIcon"; import { CompactValueChange } from "./CompactValueChange"; import { StatusBadge } from "./StatusBadge"; @@ -56,9 +56,23 @@ function EditRow({ return ( {prefix} - {oldVal} + + {oldVal} + {middle} - {newVal} + + {newVal} + {suffix} ); @@ -78,7 +92,11 @@ function EditRow({ {showRing ? ( renderDescription() ) : ( - + )} ); @@ -155,6 +173,7 @@ export function SubscriptionItemRow({ ); } diff --git a/vite/src/components/forms/update-subscription-v2/components/SummaryItemRow.tsx b/vite/src/components/forms/update-subscription-v2/components/SummaryItemRow.tsx index 72c5f2126..318c90c7b 100644 --- a/vite/src/components/forms/update-subscription-v2/components/SummaryItemRow.tsx +++ b/vite/src/components/forms/update-subscription-v2/components/SummaryItemRow.tsx @@ -1,55 +1,72 @@ -import { formatAmount } from "@autumn/shared"; -import { CalendarIcon, GitBranchIcon, WrenchIcon } from "@phosphor-icons/react"; -import { cn } from "@/lib/utils"; -import { PlanFeatureIcon } from "@/views/products/plan/components/plan-card/PlanFeatureIcon"; -import { CustomDotIcon } from "@/views/products/plan/components/plan-card/PlanFeatureRow"; -import type { SummaryItem } from "../types/summary"; +import type { EditIconType, ItemEdit } from "@autumn/shared"; +import { + CalendarIcon, + CurrencyDollarIcon, + GitBranchIcon, + HashIcon, + PackageIcon, + StackIcon, + TagIcon, + WrenchIcon, +} from "@phosphor-icons/react"; -export function SummaryItemRow({ - item, - currency, -}: { - item: SummaryItem; - currency: string; -}) { - const renderIcons = () => { - if (item.type === "prepaid" && item.productItem) { - return ( -
- - - -
- ); - } - - if (item.type === "trial") { +function getIcon(iconType: EditIconType) { + const iconProps = { size: 16, weight: "duotone" as const }; + switch (iconType) { + case "trial": return (
- +
); - } - - if (item.type === "version") { + case "version": return (
- +
); - } - - if (item.type === "item") { + case "item": return (
- +
); - } - - return null; - }; + case "prepaid": + return ( +
+ +
+ ); + case "price": + return ( +
+ +
+ ); + case "tier": + return ( +
+ +
+ ); + case "usage": + return ( +
+ +
+ ); + case "units": + return ( +
+ +
+ ); + default: + return null; + } +} +export function SummaryItemRow({ item }: { item: ItemEdit }) { const renderChangeIndicator = () => { if (item.newValue === null) { return ( @@ -87,7 +104,7 @@ export function SummaryItemRow({ return (
- {renderIcons()} + {getIcon(item.icon)}

{item.label} @@ -96,23 +113,6 @@ export function SummaryItemRow({

{renderChangeIndicator()} - - {item.costDelta !== undefined && item.costDelta !== 0 && ( - 0 ? "text-t2" : "text-green-600", - )} - > - {item.costDelta > 0 ? "+" : ""} - {formatAmount({ - amount: item.costDelta, - currency, - minFractionDigits: 2, - amountFormatOptions: { currencyDisplay: "narrowSymbol" }, - })} - - )}
); diff --git a/vite/src/components/forms/update-subscription-v2/components/UpdateSubscriptionSummary.tsx b/vite/src/components/forms/update-subscription-v2/components/UpdateSubscriptionSummary.tsx index 14b41e2ae..e930d0247 100644 --- a/vite/src/components/forms/update-subscription-v2/components/UpdateSubscriptionSummary.tsx +++ b/vite/src/components/forms/update-subscription-v2/components/UpdateSubscriptionSummary.tsx @@ -1,29 +1,29 @@ -import type { FullCusProduct } from "@autumn/shared"; +import { + type FullCusProduct, + generateTrialChanges, + generateVersionChanges, + type ItemEdit, +} from "@autumn/shared"; import { useStore } from "@tanstack/react-form"; import { useMemo } from "react"; import { SheetSection } from "@/components/v2/sheets/SharedSheetComponents"; import type { UseUpdateSubscriptionForm } from "../hooks/useUpdateSubscriptionForm"; -import type { SummaryItem } from "../types/summary"; -import { generateTrialChanges } from "../utils/generateTrialChanges"; -import { generateVersionChanges } from "../utils/generateVersionChanges"; import { SummaryItemRow } from "./SummaryItemRow"; interface UpdateSubscriptionSummaryProps { form: UseUpdateSubscriptionForm; customerProduct: FullCusProduct; currentVersion: number; - currency?: string; } export function UpdateSubscriptionSummary({ form, customerProduct, currentVersion, - currency = "usd", }: UpdateSubscriptionSummaryProps) { const formValues = useStore(form.store, (state) => state.values); - const changes = useMemo((): SummaryItem[] => { + const changes = useMemo((): ItemEdit[] => { const trialChanges = generateTrialChanges({ customerProduct, removeTrial: formValues.removeTrial, @@ -32,8 +32,8 @@ export function UpdateSubscriptionSummary({ }); const versionChanges = generateVersionChanges({ - currentVersion, - selectedVersion: formValues.version, + originalVersion: currentVersion, + updatedVersion: formValues.version, }); return [...versionChanges, ...trialChanges]; @@ -52,7 +52,7 @@ export function UpdateSubscriptionSummary({
{changes.map((change) => ( - + ))}
diff --git a/vite/src/components/forms/update-subscription-v2/hooks/useHasSubscriptionChanges.ts b/vite/src/components/forms/update-subscription-v2/hooks/useHasSubscriptionChanges.ts index fd41caa4b..7dd5a3a6d 100644 --- a/vite/src/components/forms/update-subscription-v2/hooks/useHasSubscriptionChanges.ts +++ b/vite/src/components/forms/update-subscription-v2/hooks/useHasSubscriptionChanges.ts @@ -1,11 +1,15 @@ -import type { Feature, FullCusProduct, ProductItem } from "@autumn/shared"; +import { + type Feature, + type FullCusProduct, + generateItemChanges, + generatePrepaidChanges, + generateTrialChanges, + generateVersionChanges, + type ProductItem, +} from "@autumn/shared"; import { useMemo } from "react"; import type { PrepaidItemWithFeature } from "@/hooks/stores/useProductStore"; import type { UpdateSubscriptionForm } from "../updateSubscriptionFormSchema"; -import { generateItemChanges } from "../utils/generateItemChanges"; -import { generatePrepaidChanges } from "../utils/generatePrepaidChanges"; -import { generateTrialChanges } from "../utils/generateTrialChanges"; -import { generateVersionChanges } from "../utils/generateVersionChanges"; export function useHasSubscriptionChanges({ formValues, @@ -35,15 +39,15 @@ export function useHasSubscriptionChanges({ if (trialChanges.length > 0) return true; const versionChanges = generateVersionChanges({ - currentVersion, - selectedVersion: formValues.version, + originalVersion: currentVersion, + updatedVersion: formValues.version, }); if (versionChanges.length > 0) return true; const itemChanges = generateItemChanges({ originalItems, - customizedItems: formValues.items, + updatedItems: formValues.items, features, prepaidOptions: formValues.prepaidOptions, }); @@ -58,8 +62,8 @@ export function useHasSubscriptionChanges({ const prepaidChanges = generatePrepaidChanges({ prepaidItems, - currentOptions: formValues.prepaidOptions, - initialOptions: initialPrepaidOptions, + updatedOptions: formValues.prepaidOptions, + originalOptions: initialPrepaidOptions, }).filter((change) => { const featureId = change.id.replace("prepaid-", ""); return !newlyAddedFeatureIds.has(featureId); diff --git a/vite/src/components/forms/update-subscription-v2/index.ts b/vite/src/components/forms/update-subscription-v2/index.ts index 8cfa8663c..5075e61c1 100644 --- a/vite/src/components/forms/update-subscription-v2/index.ts +++ b/vite/src/components/forms/update-subscription-v2/index.ts @@ -1,5 +1,3 @@ -// Context - // Components export * from "./components/EditPlanSection"; export * from "./components/FreeTrialSection"; @@ -17,10 +15,7 @@ export * from "./hooks/useUpdateSubscriptionMutation"; export * from "./hooks/useUpdateSubscriptionRequestBody"; // Types -export * from "./types/summary"; export * from "./updateSubscriptionFormSchema"; // Utils -export * from "./utils/generateItemChanges"; -export * from "./utils/generateVersionChanges"; export * from "./utils/getFreeTrial"; diff --git a/vite/src/components/forms/update-subscription-v2/types/summary.ts b/vite/src/components/forms/update-subscription-v2/types/summary.ts deleted file mode 100644 index f30bcf7a0..000000000 --- a/vite/src/components/forms/update-subscription-v2/types/summary.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { ProductItem } from "@autumn/shared"; - -export interface SummaryItem { - id: string; - type: "prepaid" | "trial" | "version" | "item"; - label: string; - oldValue: string | number | null; - newValue: string | number | null; - costDelta?: number; - currency?: string; - /** The product item for rendering icons (prepaid and item changes) */ - productItem?: ProductItem; -} - -export type EditIconType = "price" | "tier" | "usage" | "units" | "prepaid"; - -export interface ItemEdit { - id: string; - type: "config" | "prepaid"; - label: string; - /** Icon type for the edit */ - icon: EditIconType; - /** Full sentence description for accordion display */ - description: string; - oldValue: string | number | null; - newValue: string | number | null; - /** Whether this is an upgrade (true) or downgrade (false) */ - isUpgrade: boolean; - /** Whether this edit has an inline editor (prepaid quantity) */ - editable?: boolean; -} diff --git a/vite/src/components/forms/update-subscription-v2/utils/generatePrepaidChanges.ts b/vite/src/components/forms/update-subscription-v2/utils/generatePrepaidChanges.ts deleted file mode 100644 index 0d6a668df..000000000 --- a/vite/src/components/forms/update-subscription-v2/utils/generatePrepaidChanges.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { PrepaidItemWithFeature } from "@/hooks/stores/useProductStore"; -import type { SummaryItem } from "../types/summary"; - -export function generatePrepaidChanges({ - prepaidItems, - currentOptions, - initialOptions, - currency, -}: { - prepaidItems: PrepaidItemWithFeature[]; - currentOptions: Record; - initialOptions: Record; - currency?: string; -}): SummaryItem[] { - return prepaidItems - .map((item) => { - const featureId = item.feature_id ?? ""; - const oldQuantity = initialOptions[featureId] ?? 0; - const newQuantity = currentOptions[featureId] ?? 0; - - if (oldQuantity === newQuantity) return null; - - const billingUnits = item.billing_units ?? 1; - const oldDisplayQuantity = oldQuantity * billingUnits; - const newDisplayQuantity = newQuantity * billingUnits; - - const unitPrice = item.price ?? null; - const costDelta = - unitPrice !== null - ? (newQuantity - oldQuantity) * unitPrice - : undefined; - - const featureName = item.feature?.name ?? "Items"; - - return { - id: `prepaid-${featureId}`, - type: "prepaid" as const, - label: featureName, - oldValue: oldDisplayQuantity, - newValue: newDisplayQuantity, - costDelta, - currency, - productItem: item, - }; - }) - .filter(Boolean) as SummaryItem[]; -} diff --git a/vite/src/components/forms/update-subscription-v2/utils/generateVersionChanges.ts b/vite/src/components/forms/update-subscription-v2/utils/generateVersionChanges.ts deleted file mode 100644 index c687f0b2b..000000000 --- a/vite/src/components/forms/update-subscription-v2/utils/generateVersionChanges.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { SummaryItem } from "../types/summary"; - -export function generateVersionChanges({ - currentVersion, - selectedVersion, -}: { - currentVersion: number; - selectedVersion: number; -}): SummaryItem[] { - if (selectedVersion === currentVersion) { - return []; - } - - return [ - { - id: "version-change", - type: "version", - label: "Plan Version", - oldValue: currentVersion, - newValue: selectedVersion, - }, - ]; -} diff --git a/vite/src/components/forms/update-subscription-v2/utils/getEditIcon.tsx b/vite/src/components/forms/update-subscription-v2/utils/getEditIcon.tsx index 75ee54475..b3a949a54 100644 --- a/vite/src/components/forms/update-subscription-v2/utils/getEditIcon.tsx +++ b/vite/src/components/forms/update-subscription-v2/utils/getEditIcon.tsx @@ -1,3 +1,4 @@ +import type { EditIconType } from "@autumn/shared"; import { CurrencyDollarIcon, HashIcon, @@ -6,7 +7,6 @@ import { TagIcon, } from "@phosphor-icons/react"; import { cn } from "@/lib/utils"; -import type { EditIconType } from "../types/summary"; export function getEditIcon(iconType: EditIconType, isUpgrade: boolean) { const iconProps = { diff --git a/vite/src/views/customers2/components/sheets/SubscriptionUpdateSheet2.tsx b/vite/src/views/customers2/components/sheets/SubscriptionUpdateSheet2.tsx index 44908febc..fccc273f5 100644 --- a/vite/src/views/customers2/components/sheets/SubscriptionUpdateSheet2.tsx +++ b/vite/src/views/customers2/components/sheets/SubscriptionUpdateSheet2.tsx @@ -259,7 +259,6 @@ function SheetContent({ form={form} customerProduct={customerProduct} currentVersion={currentVersion} - currency={previewQuery.data?.currency} />