diff --git a/apps/bff/src/modules/catalog/services/catalog-cache.service.ts b/apps/bff/src/modules/catalog/services/catalog-cache.service.ts index 587e8ea8..2877842b 100644 --- a/apps/bff/src/modules/catalog/services/catalog-cache.service.ts +++ b/apps/bff/src/modules/catalog/services/catalog-cache.service.ts @@ -17,6 +17,12 @@ export interface CatalogCacheOptions { ) => CacheDependencies | Promise | undefined; } +interface LegacyCatalogCachePayload { + value: T; + __catalogCache?: boolean; + dependencies?: CacheDependencies; +} + /** * Catalog cache service * @@ -188,13 +194,12 @@ export class CatalogCacheService { const allowNull = options?.allowNull ?? false; // Check Redis cache first - const cached = await this.cache.get(key); + const cached = await this.cache.get>(key); if (cached !== null) { - if (allowNull || cached !== null) { - this.metrics[bucket].hits++; - return cached; - } + const normalized = await this.normalizeCachedValue(key, cached, ttlSeconds); + this.metrics[bucket].hits++; + return normalized; } // Check for in-flight request (prevents thundering herd) @@ -243,6 +248,43 @@ export class CatalogCacheService { return fetchPromise; } + private async normalizeCachedValue( + key: string, + cached: T | LegacyCatalogCachePayload, + ttlSeconds: number | null + ): Promise { + if (this.isLegacyCatalogCachePayload(cached)) { + const ttlArg = ttlSeconds === null ? undefined : ttlSeconds; + const normalizedValue = cached.value; + + if (ttlArg === undefined) { + await this.cache.set(key, normalizedValue); + } else { + await this.cache.set(key, normalizedValue, ttlArg); + } + + if (cached.dependencies) { + await this.storeDependencies(key, cached.dependencies); + await this.linkDependencies(key, cached.dependencies); + } + + return normalizedValue; + } + + return cached; + } + + private isLegacyCatalogCachePayload( + payload: unknown + ): payload is LegacyCatalogCachePayload { + if (!payload || typeof payload !== "object") { + return false; + } + + const record = payload as Record; + return record.__catalogCache === true && Object.prototype.hasOwnProperty.call(record, "value"); + } + /** * Invalidate catalog entries by product IDs * Returns true if any entries were invalidated, false if no matches found diff --git a/apps/portal/src/features/catalog/components/base/EnhancedOrderSummary.tsx b/apps/portal/src/features/catalog/components/base/EnhancedOrderSummary.tsx index 2bfeadbc..5dbbe283 100644 --- a/apps/portal/src/features/catalog/components/base/EnhancedOrderSummary.tsx +++ b/apps/portal/src/features/catalog/components/base/EnhancedOrderSummary.tsx @@ -21,7 +21,7 @@ export type OrderItem = CatalogProductBase & { itemClass?: string; }; -export interface OrderConfiguration { +export interface OrderSummaryConfiguration { label: string; value: string; important?: boolean; @@ -45,7 +45,7 @@ export interface EnhancedOrderSummaryProps { planDescription?: string; // Configuration details - configurations?: OrderConfiguration[]; + configurations?: OrderSummaryConfiguration[]; // Additional information infoLines?: string[]; diff --git a/apps/portal/src/features/catalog/components/index.ts b/apps/portal/src/features/catalog/components/index.ts index b7fa0154..cc5786d7 100644 --- a/apps/portal/src/features/catalog/components/index.ts +++ b/apps/portal/src/features/catalog/components/index.ts @@ -35,7 +35,7 @@ export type { export type { EnhancedOrderSummaryProps, OrderItem, - OrderConfiguration, + OrderSummaryConfiguration, OrderTotals, } from "./base/EnhancedOrderSummary"; export type { ConfigurationStepProps, StepValidation } from "./base/ConfigurationStep"; diff --git a/apps/portal/src/features/catalog/utils/pricing.ts b/apps/portal/src/features/catalog/utils/pricing.ts index aea8d379..ec9e4e57 100644 --- a/apps/portal/src/features/catalog/utils/pricing.ts +++ b/apps/portal/src/features/catalog/utils/pricing.ts @@ -3,35 +3,15 @@ * These are UI-specific formatting helpers, not business logic */ -import type { CatalogProductBase } from "@customer-portal/domain/catalog"; +import { + getCatalogProductPriceDisplay, + type CatalogProductBase, + type CatalogPriceInfo, +} from "@customer-portal/domain/catalog"; -export interface PriceInfo { - display: string; - monthly: number | null; - oneTime: number | null; - currency: string; -} +// Re-export domain type for compatibility +export type PriceInfo = CatalogPriceInfo; export function getDisplayPrice(item: CatalogProductBase): PriceInfo | null { - const monthlyPrice = item.monthlyPrice ?? null; - const oneTimePrice = item.oneTimePrice ?? null; - const currency = "JPY"; - - if (monthlyPrice === null && oneTimePrice === null) { - return null; - } - - let display = ""; - if (monthlyPrice !== null && monthlyPrice > 0) { - display = `¥${monthlyPrice.toLocaleString()}/month`; - } else if (oneTimePrice !== null && oneTimePrice > 0) { - display = `¥${oneTimePrice.toLocaleString()} (one-time)`; - } - - return { - display, - monthly: monthlyPrice, - oneTime: oneTimePrice, - currency, - }; + return getCatalogProductPriceDisplay(item); } diff --git a/apps/portal/src/features/checkout/hooks/useCheckout.ts b/apps/portal/src/features/checkout/hooks/useCheckout.ts index 6f259a2a..0a760a24 100644 --- a/apps/portal/src/features/checkout/hooks/useCheckout.ts +++ b/apps/portal/src/features/checkout/hooks/useCheckout.ts @@ -18,6 +18,7 @@ import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscr import { ORDER_TYPE, orderWithSkuValidationSchema, + prepareOrderFromCart, type CheckoutCart, } from "@customer-portal/domain/orders"; import { CheckoutParamsService } from "@/features/checkout/services/checkout-params.service"; @@ -150,31 +151,15 @@ export function useCheckout() { // Debug logging to check cart contents console.log("[DEBUG] Cart data:", cart); console.log("[DEBUG] Cart items:", cart.items); - - const uniqueSkus = Array.from( - new Set( - cart.items - .map(item => item.sku) - .filter((sku): sku is string => typeof sku === "string" && sku.trim().length > 0) - ) - ); - - console.log("[DEBUG] Extracted SKUs from cart:", uniqueSkus); - - if (uniqueSkus.length === 0) { - throw new Error("No products selected for order. Please go back and select products."); - } // Validate cart before submission await checkoutService.validateCart(cart); - const orderData = { - orderType, - skus: uniqueSkus, - ...(Object.keys(cart.configuration).length > 0 - ? { configurations: cart.configuration } - : {}), - }; + // Use domain helper to prepare order data + // This encapsulates SKU extraction and payload formatting + const orderData = prepareOrderFromCart(cart, orderType); + + console.log("[DEBUG] Extracted SKUs from cart:", orderData.skus); const currentUserId = useAuthStore.getState().user?.id; if (currentUserId) { diff --git a/packages/domain/catalog/contract.ts b/packages/domain/catalog/contract.ts index c92d7422..0a6d56b6 100644 --- a/packages/domain/catalog/contract.ts +++ b/packages/domain/catalog/contract.ts @@ -50,6 +50,17 @@ export interface PricingTier { originalPrice?: number; } +/** + * Standardized pricing display info + * Used for consistent price rendering across Frontend and BFF + */ +export interface CatalogPriceInfo { + display: string; + monthly: number | null; + oneTime: number | null; + currency: string; +} + /** * Catalog filtering options */ diff --git a/packages/domain/catalog/utils.ts b/packages/domain/catalog/utils.ts index 7bdee0e0..be940102 100644 --- a/packages/domain/catalog/utils.ts +++ b/packages/domain/catalog/utils.ts @@ -8,7 +8,9 @@ import { type SimCatalogCollection, type VpnCatalogCollection, type InternetPlanTemplate, + type CatalogProductBase, } from "./schema"; +import type { CatalogPriceInfo } from "./contract"; /** * Empty catalog defaults shared by portal and BFF. @@ -156,3 +158,30 @@ export function enrichInternetPlanMetadata(plan: InternetPlanCatalogItem): Inter export const internetPlanCollectionSchema = internetPlanCatalogItemSchema.array(); +/** + * Calculates display price information for a catalog item + * Centralized logic for price formatting + */ +export function getCatalogProductPriceDisplay(item: CatalogProductBase): CatalogPriceInfo | null { + const monthlyPrice = item.monthlyPrice ?? null; + const oneTimePrice = item.oneTimePrice ?? null; + const currency = "JPY"; + + if (monthlyPrice === null && oneTimePrice === null) { + return null; + } + + let display = ""; + if (monthlyPrice !== null && monthlyPrice > 0) { + display = `¥${monthlyPrice.toLocaleString()}/month`; + } else if (oneTimePrice !== null && oneTimePrice > 0) { + display = `¥${oneTimePrice.toLocaleString()} (one-time)`; + } + + return { + display, + monthly: monthlyPrice, + oneTime: oneTimePrice, + currency, + }; +} diff --git a/packages/domain/orders/utils.ts b/packages/domain/orders/utils.ts index 271f2d19..6895338b 100644 --- a/packages/domain/orders/utils.ts +++ b/packages/domain/orders/utils.ts @@ -5,7 +5,7 @@ import { type CreateOrderRequest, type OrderSelections, } from "./schema"; -import { ORDER_TYPE } from "./contract"; +import { ORDER_TYPE, type CheckoutCart, type OrderTypeValue } from "./contract"; export function buildOrderConfigurations(selections: OrderSelections): OrderConfigurations { const normalizedSelections = orderSelectionsSchema.parse(selections); @@ -41,3 +41,38 @@ export function createOrderRequest(payload: { }; } +/** + * Transform CheckoutCart into CreateOrderRequest + * Handles SKU extraction, validation, and payload formatting + * + * @throws Error if no products are selected + */ +export function prepareOrderFromCart( + cart: CheckoutCart, + orderType: OrderTypeValue +): CreateOrderRequest { + const uniqueSkus = Array.from( + new Set( + cart.items + .map(item => item.sku) + .filter((sku): sku is string => typeof sku === "string" && sku.trim().length > 0) + ) + ); + + if (uniqueSkus.length === 0) { + throw new Error("No products selected for order. Please go back and select products."); + } + + // Note: Zod validation of the final structure should happen at the boundary or via schema.parse + // This function focuses on the structural transformation logic. + + const orderData: CreateOrderRequest = { + orderType, + skus: uniqueSkus, + ...(Object.keys(cart.configuration).length > 0 + ? { configurations: cart.configuration } + : {}), + }; + + return orderData; +}