diff --git a/apps/bff/src/app.module.ts b/apps/bff/src/app.module.ts index ce605f5c..ffca441d 100644 --- a/apps/bff/src/app.module.ts +++ b/apps/bff/src/app.module.ts @@ -33,6 +33,7 @@ import { CatalogModule } from "@bff/modules/catalog/catalog.module"; import { OrdersModule } from "@bff/modules/orders/orders.module"; import { InvoicesModule } from "@bff/modules/invoices/invoices.module"; import { SubscriptionsModule } from "@bff/modules/subscriptions/subscriptions.module"; +import { CurrencyModule } from "@bff/modules/currency/currency.module"; // System Modules import { HealthModule } from "@bff/modules/health/health.module"; @@ -83,6 +84,7 @@ import { HealthModule } from "@bff/modules/health/health.module"; OrdersModule, InvoicesModule, SubscriptionsModule, + CurrencyModule, // === SYSTEM MODULES === HealthModule, diff --git a/apps/bff/src/modules/catalog/catalog.controller.ts b/apps/bff/src/modules/catalog/catalog.controller.ts index 31237536..8fdaf225 100644 --- a/apps/bff/src/modules/catalog/catalog.controller.ts +++ b/apps/bff/src/modules/catalog/catalog.controller.ts @@ -1,12 +1,13 @@ import { Controller, Get, Request } from "@nestjs/common"; import type { RequestWithUser } from "@bff/modules/auth/auth.types"; -import type { - InternetAddonCatalogItem, - InternetInstallationCatalogItem, - InternetPlanCatalogItem, - SimCatalogProduct, - SimActivationFeeCatalogItem, - VpnCatalogProduct, +import { + parseInternetCatalog, + type InternetAddonCatalogItem, + type InternetInstallationCatalogItem, + type InternetPlanCatalogItem, + type SimActivationFeeCatalogItem, + type SimCatalogProduct, + type VpnCatalogProduct, } from "@customer-portal/domain/catalog"; import { InternetCatalogService } from "./services/internet-catalog.service"; import { SimCatalogService } from "./services/sim-catalog.service"; @@ -28,18 +29,17 @@ export class CatalogController { }> { const userId = req.user?.id; if (!userId) { - // Fallback to all catalog data if no user context - return this.internetCatalog.getCatalogData(); + const catalog = await this.internetCatalog.getCatalogData(); + return parseInternetCatalog(catalog); } - // Get user-specific plans but all installations and addons const [plans, installations, addons] = await Promise.all([ this.internetCatalog.getPlansForUser(userId), this.internetCatalog.getInstallations(), this.internetCatalog.getAddons(), ]); - return { plans, installations, addons }; + return parseInternetCatalog({ plans, installations, addons }); } @Get("internet/addons") diff --git a/apps/bff/src/modules/catalog/services/internet-catalog.service.ts b/apps/bff/src/modules/catalog/services/internet-catalog.service.ts index 37537fc8..d025e960 100644 --- a/apps/bff/src/modules/catalog/services/internet-catalog.service.ts +++ b/apps/bff/src/modules/catalog/services/internet-catalog.service.ts @@ -7,7 +7,12 @@ import type { InternetInstallationCatalogItem, InternetAddonCatalogItem, } from "@customer-portal/domain/catalog"; -import { Providers as CatalogProviders } from "@customer-portal/domain/catalog"; +import { + Providers as CatalogProviders, + enrichInternetPlanMetadata, + inferAddonTypeFromSku, + inferInstallationTermFromSku, +} from "@customer-portal/domain/catalog"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service"; import { Logger } from "nestjs-pino"; @@ -44,7 +49,8 @@ export class InternetCatalogService extends BaseCatalogService { return records.map(record => { const entry = this.extractPricebookEntry(record); - return CatalogProviders.Salesforce.mapInternetPlan(record, entry); + const plan = CatalogProviders.Salesforce.mapInternetPlan(record, entry); + return enrichInternetPlanMetadata(plan); }); } @@ -63,7 +69,14 @@ export class InternetCatalogService extends BaseCatalogService { return records .map(record => { const entry = this.extractPricebookEntry(record); - return CatalogProviders.Salesforce.mapInternetInstallation(record, entry); + const installation = CatalogProviders.Salesforce.mapInternetInstallation(record, entry); + return { + ...installation, + catalogMetadata: { + ...installation.catalogMetadata, + installationTerm: inferInstallationTermFromSku(installation.sku ?? ""), + }, + }; }) .sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0)); } @@ -85,7 +98,14 @@ export class InternetCatalogService extends BaseCatalogService { return records .map(record => { const entry = this.extractPricebookEntry(record); - return CatalogProviders.Salesforce.mapInternetAddon(record, entry); + const addon = CatalogProviders.Salesforce.mapInternetAddon(record, entry); + return { + ...addon, + catalogMetadata: { + ...addon.catalogMetadata, + addonType: inferAddonTypeFromSku(addon.sku ?? ""), + }, + }; }) .sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0)); } @@ -160,78 +180,4 @@ export class InternetCatalogService extends BaseCatalogService { return plan.internetOfferingType === eligibility; } - private getTierData(tier: string) { - const tierData: Record< - string, - { description: string; tierDescription: string; features: string[] } - > = { - Silver: { - description: "Simple package with broadband-modem and ISP only", - tierDescription: "Simple package with broadband-modem and ISP only", - features: [ - "NTT modem + ISP connection", - "Two ISP connection protocols: IPoE (recommended) or PPPoE", - "Self-configuration of router (you provide your own)", - "Monthly: ¥6,000 | One-time: ¥22,800", - ], - }, - Gold: { - description: "Standard all-inclusive package with basic Wi-Fi", - tierDescription: "Standard all-inclusive package with basic Wi-Fi", - features: [ - "NTT modem + wireless router (rental)", - "ISP (IPoE) configured automatically within 24 hours", - "Basic wireless router included", - "Optional: TP-LINK RE650 range extender (¥500/month)", - "Monthly: ¥6,500 | One-time: ¥22,800", - ], - }, - Platinum: { - description: - "Tailored set up with premier Wi-Fi management support - Recommended for homes & apartments larger than 50m²", - tierDescription: "Tailored set up with premier Wi-Fi management support", - features: [ - "NTT modem + Netgear INSIGHT Wi-Fi routers", - "Cloud management support for remote router management", - "Automatic updates and quicker support", - "Seamless wireless network setup", - "Monthly: ¥6,500 | One-time: ¥22,800", - "Cloud management: ¥500/month per router", - ], - }, - }; - return tierData[tier] || tierData["Silver"]; - } - - private inferInstallationTypeFromSku(sku: string): "One-time" | "12-Month" | "24-Month" { - const upperSku = sku.toUpperCase(); - if (upperSku.includes("12M") || upperSku.includes("12-MONTH")) { - return "12-Month"; - } - if (upperSku.includes("24M") || upperSku.includes("24-MONTH")) { - return "24-Month"; - } - return "One-time"; - } - - private inferAddonTypeFromSku( - sku: string - ): "hikari-denwa-service" | "hikari-denwa-installation" | "other" { - const upperSku = sku.toUpperCase(); - if ( - upperSku.includes("DENWA") || - upperSku.includes("HOME-PHONE") || - upperSku.includes("PHONE") - ) { - if ( - upperSku.includes("INSTALL") || - upperSku.includes("SETUP") || - upperSku.includes("ACTIVATION") - ) { - return "hikari-denwa-installation"; - } - return "hikari-denwa-service"; - } - return "other"; - } } diff --git a/apps/bff/src/modules/currency/currency.controller.ts b/apps/bff/src/modules/currency/currency.controller.ts new file mode 100644 index 00000000..caaca492 --- /dev/null +++ b/apps/bff/src/modules/currency/currency.controller.ts @@ -0,0 +1,24 @@ +import { Controller, Get } from "@nestjs/common"; +import { WhmcsCurrencyService } from "../../integrations/whmcs/services/whmcs-currency.service"; + +@Controller("currency") +export class CurrencyController { + constructor(private readonly currencyService: WhmcsCurrencyService) {} + + @Get("default") + getDefaultCurrency() { + const defaultCurrency = this.currencyService.getDefaultCurrency(); + return { + code: defaultCurrency.code, + prefix: defaultCurrency.prefix, + suffix: defaultCurrency.suffix, + format: defaultCurrency.format, + rate: defaultCurrency.rate, + }; + } + + @Get("all") + getAllCurrencies() { + return this.currencyService.getAllCurrencies(); + } +} diff --git a/apps/bff/src/modules/currency/currency.module.ts b/apps/bff/src/modules/currency/currency.module.ts new file mode 100644 index 00000000..2e6c1a8f --- /dev/null +++ b/apps/bff/src/modules/currency/currency.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; +import { CurrencyController } from "./currency.controller"; +import { WhmcsCurrencyService } from "../../integrations/whmcs/services/whmcs-currency.service"; +import { WhmcsModule } from "../../integrations/whmcs/whmcs.module"; + +@Module({ + imports: [WhmcsModule], + controllers: [CurrencyController], + providers: [], +}) +export class CurrencyModule {} diff --git a/apps/bff/src/modules/orders/orders.controller.ts b/apps/bff/src/modules/orders/orders.controller.ts index 74d02d1e..cb010523 100644 --- a/apps/bff/src/modules/orders/orders.controller.ts +++ b/apps/bff/src/modules/orders/orders.controller.ts @@ -9,6 +9,8 @@ import { type CreateOrderRequest, type SfOrderIdParam, } from "@customer-portal/domain/orders"; +import { apiSuccessResponseSchema } from "@customer-portal/domain/common"; +import { z } from "zod"; @Controller("orders") export class OrdersController { @@ -17,6 +19,14 @@ export class OrdersController { private readonly logger: Logger ) {} + private readonly createOrderResponseSchema = apiSuccessResponseSchema( + z.object({ + sfOrderId: z.string(), + status: z.string(), + message: z.string(), + }) + ); + @Post() @UsePipes(new ZodValidationPipe(createOrderRequestSchema)) async create(@Request() req: RequestWithUser, @Body() body: CreateOrderRequest) { @@ -30,7 +40,8 @@ export class OrdersController { ); try { - return await this.orderOrchestrator.createOrder(req.user.id, body); + const result = await this.orderOrchestrator.createOrder(req.user.id, body); + return this.createOrderResponseSchema.parse({ success: true, data: result }); } catch (error) { this.logger.error( { diff --git a/apps/bff/src/modules/orders/services/order-validator.service.ts b/apps/bff/src/modules/orders/services/order-validator.service.ts index c67f5e16..a6fbc315 100644 --- a/apps/bff/src/modules/orders/services/order-validator.service.ts +++ b/apps/bff/src/modules/orders/services/order-validator.service.ts @@ -9,12 +9,6 @@ import { orderBusinessValidationSchema, type CreateOrderRequest, type OrderBusinessValidation, - // Import validation helpers from domain - getOrderTypeValidationError, - hasSimServicePlan, - hasSimActivationFee, - hasVpnActivationFee, - hasInternetServicePlan, } from "@customer-portal/domain/orders"; import type { Providers } from "@customer-portal/domain/subscriptions"; @@ -163,35 +157,6 @@ export class OrderValidator { } } - /** - * Validate business rules based on order type - * - * Note: SKU-based validation logic has been moved to @customer-portal/domain/orders/validation - * This method now delegates to domain validation helpers for consistency. - */ - validateBusinessRules(orderType: string, skus: string[]): void { - // Use domain validation helper to get specific error message - const validationError = getOrderTypeValidationError(orderType, skus); - - if (validationError) { - this.logger.warn({ orderType, skus }, `Order validation failed: ${validationError}`); - throw new BadRequestException(validationError); - } - - // Log successful validation for specific order types - switch (orderType) { - case "SIM": - this.logger.debug({ orderType, skus }, "SIM order validation passed (has service + activation)"); - break; - case "VPN": - this.logger.debug({ orderType, skus }, "VPN order validation passed (has activation)"); - break; - case "Internet": - this.logger.debug({ orderType, skus }, "Internet order validation passed (has service)"); - break; - } - } - /** * Complete order validation - performs ALL validation (format + business) */ @@ -237,7 +202,6 @@ export class OrderValidator { // 3. SKU validation const pricebookId = await this.pricebookService.findPortalPricebookId(); await this.validateSKUs(businessValidatedBody.skus, pricebookId); - this.validateBusinessRules(businessValidatedBody.orderType, businessValidatedBody.skus); // 4. Order-specific business validation if (businessValidatedBody.orderType === "Internet") { diff --git a/apps/bff/src/modules/subscriptions/sim-management/interfaces/sim-base.interface.ts b/apps/bff/src/modules/subscriptions/sim-management/interfaces/sim-base.interface.ts index 2f658fba..06aea603 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/interfaces/sim-base.interface.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/interfaces/sim-base.interface.ts @@ -1,16 +1,12 @@ -export interface SimValidationResult { - account: string; -} - -export interface SimNotificationContext { - userId: string; - subscriptionId: number; - account?: string; - [key: string]: unknown; -} +// Notification types for SIM management (BFF-specific, not domain types) +export type SimNotificationContext = Record; export interface SimActionNotification { action: string; status: "SUCCESS" | "ERROR"; context: SimNotificationContext; } + +export interface SimValidationResult { + account: string; +} diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-validation.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-validation.service.ts index a73d3a3b..26e2688e 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-validation.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-validation.service.ts @@ -3,11 +3,7 @@ import { Logger } from "nestjs-pino"; import { SubscriptionsService } from "../../subscriptions.service"; import { getErrorMessage } from "@bff/core/utils/error.util"; import type { SimValidationResult } from "../interfaces/sim-base.interface"; -import { - isSimSubscription, - extractSimAccountFromSubscription, - cleanSimAccount, -} from "@customer-portal/domain/sim"; +import { cleanSimAccount, extractSimAccountFromSubscription, isSimSubscription } from "@customer-portal/domain/sim"; @Injectable() export class SimValidationService { diff --git a/apps/bff/test/catalog-contract.spec.ts b/apps/bff/test/catalog-contract.spec.ts new file mode 100644 index 00000000..3659971b --- /dev/null +++ b/apps/bff/test/catalog-contract.spec.ts @@ -0,0 +1,33 @@ +/// +import request from "supertest"; +import { INestApplication } from "@nestjs/common"; +import { Test } from "@nestjs/testing"; + +import { AppModule } from "../src/app.module"; +import { parseInternetCatalog } from "@customer-portal/domain/catalog"; + +describe("Catalog contract", () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + it("should return internet catalog matching domain schema", async () => { + const response = await request(app.getHttpServer()).get("/catalog/internet/plans"); + + expect(response.status).toBe(200); + expect(() => parseInternetCatalog(response.body.data)).not.toThrow(); + }); +}); + + diff --git a/apps/bff/test/jest-e2e.json b/apps/bff/test/jest-e2e.json new file mode 100644 index 00000000..dda3baf1 --- /dev/null +++ b/apps/bff/test/jest-e2e.json @@ -0,0 +1,27 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "testRegex": ".spec.ts$", + "transform": { + "^.+\\.(t|j)s$": [ + "ts-jest", + { + "tsconfig": { + "types": ["jest", "node"] + } + } + ] + }, + "moduleNameMapper": { + "^@bff/(.*)$": "/../src/$1" + }, + "globals": { + "ts-jest": { + "tsconfig": { + "types": ["jest", "node"] + } + } + } +} + diff --git a/apps/bff/test/tsconfig.json b/apps/bff/test/tsconfig.json new file mode 100644 index 00000000..1f8f422f --- /dev/null +++ b/apps/bff/test/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "types": ["jest", "node", "supertest"] + }, + "include": ["./**/*.ts", "../src/**/*"] +} + diff --git a/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceTotals.tsx b/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceTotals.tsx index 2ae76790..0619c28e 100644 --- a/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceTotals.tsx +++ b/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceTotals.tsx @@ -9,11 +9,10 @@ interface InvoiceTotalsProps { subtotal: number; tax: number; total: number; - currency: string; } -export function InvoiceTotals({ subtotal, tax, total, currency }: InvoiceTotalsProps) { - const fmt = (amount: number) => formatCurrency(amount, currency); +export function InvoiceTotals({ subtotal, tax, total }: InvoiceTotalsProps) { + const fmt = (amount: number) => formatCurrency(amount); return (
@@ -38,7 +37,7 @@ export function InvoiceTotals({ subtotal, tax, total, currency }: InvoiceTotalsP Total Amount
{fmt(total)}
-
{currency.toUpperCase()}
+
JPY
diff --git a/apps/portal/src/features/billing/components/InvoiceItemRow.tsx b/apps/portal/src/features/billing/components/InvoiceItemRow.tsx index 41aaab5c..fe32456e 100644 --- a/apps/portal/src/features/billing/components/InvoiceItemRow.tsx +++ b/apps/portal/src/features/billing/components/InvoiceItemRow.tsx @@ -1,25 +1,22 @@ "use client"; -import { Formatting } from "@customer-portal/domain/toolkit"; - -const { formatCurrency } = Formatting; +import { useFormatCurrency } from "@/lib/hooks/useFormatCurrency"; import { useRouter } from "next/navigation"; export function InvoiceItemRow({ id, description, amount, - currency, quantity, serviceId, }: { id: number; description: string; amount: number; - currency: string; quantity?: number; serviceId?: number; }) { const router = useRouter(); + const { formatCurrency } = useFormatCurrency(); return (
- {formatCurrency(amount, currency)} + {formatCurrency(amount)}
); diff --git a/apps/portal/src/features/billing/hooks/usePaymentRefresh.ts b/apps/portal/src/features/billing/hooks/usePaymentRefresh.ts index 858fdfcf..0173cfdf 100644 --- a/apps/portal/src/features/billing/hooks/usePaymentRefresh.ts +++ b/apps/portal/src/features/billing/hooks/usePaymentRefresh.ts @@ -2,23 +2,21 @@ import { useCallback, useEffect, useState } from "react"; import { apiClient } from "@/lib/api"; +import { paymentMethodListSchema, type PaymentMethodList } from "@customer-portal/domain/payments"; type Tone = "info" | "success" | "warning" | "error"; -interface UsePaymentRefreshOptions { +interface UsePaymentRefreshOptions { // Refetch function from usePaymentMethods - refetch: () => Promise<{ data: T | undefined }>; - // Given refetch result, determine if user has payment methods - hasMethods: (data: T | undefined) => boolean; + refetch: () => Promise<{ data: PaymentMethodList | undefined }>; // When true, attaches focus/visibility listeners to refresh automatically attachFocusListeners?: boolean; } -export function usePaymentRefresh({ +export function usePaymentRefresh({ refetch, - hasMethods, attachFocusListeners = false, -}: UsePaymentRefreshOptions) { +}: UsePaymentRefreshOptions) { const [toast, setToast] = useState<{ visible: boolean; text: string; tone: Tone }>({ visible: false, text: "", @@ -35,7 +33,9 @@ export function usePaymentRefresh({ // Payment methods cache refresh failed - silently continue } const result = await refetch(); - const has = hasMethods(result.data); + const parsed = paymentMethodListSchema.safeParse(result.data ?? null); + const list = parsed.success ? parsed.data : { paymentMethods: [], totalCount: 0 }; + const has = list.totalCount > 0 || list.paymentMethods.length > 0; setToast({ visible: true, text: has ? "Payment methods updated" : "No payment method found yet", diff --git a/apps/portal/src/features/billing/views/InvoiceDetail.tsx b/apps/portal/src/features/billing/views/InvoiceDetail.tsx index 39a4eba1..141a8332 100644 --- a/apps/portal/src/features/billing/views/InvoiceDetail.tsx +++ b/apps/portal/src/features/billing/views/InvoiceDetail.tsx @@ -188,7 +188,6 @@ export function InvoiceDetailContainer() { subtotal={invoice.subtotal} tax={invoice.tax} total={invoice.total} - currency={invoice.currency} /> diff --git a/apps/portal/src/features/catalog/components/base/EnhancedOrderSummary.tsx b/apps/portal/src/features/catalog/components/base/EnhancedOrderSummary.tsx index d4544ae1..2e40d1f7 100644 --- a/apps/portal/src/features/catalog/components/base/EnhancedOrderSummary.tsx +++ b/apps/portal/src/features/catalog/components/base/EnhancedOrderSummary.tsx @@ -8,6 +8,9 @@ import { InformationCircleIcon, } from "@heroicons/react/24/outline"; import { AnimatedCard } from "@/components/molecules"; +import { Formatting } from "@customer-portal/domain/toolkit"; + +const { formatCurrency } = Formatting; import { Button } from "@/components/atoms/button"; import { useRouter } from "next/navigation"; @@ -125,8 +128,6 @@ export function EnhancedOrderSummary({ } }; - const formatPrice = (price: number) => price.toLocaleString(); - const monthlyItems = items.filter(item => item.billingCycle === "Monthly"); const oneTimeItems = items.filter(item => item.billingCycle === "Onetime"); const serviceItems = items.filter(item => item.itemClass === "Service"); @@ -143,11 +144,11 @@ export function EnhancedOrderSummary({ {variant === "checkout" && (
- ¥{formatPrice(totals.monthlyTotal)}/mo + ¥{formatCurrency(totals.monthlyTotal)}/mo
{totals.oneTimeTotal > 0 && (
- + ¥{formatPrice(totals.oneTimeTotal)} one-time + + ¥{formatCurrency(totals.oneTimeTotal)} one-time
)}
@@ -208,7 +209,7 @@ export function EnhancedOrderSummary({ )} - ¥{formatPrice(Number(item.monthlyPrice || item.unitPrice || 0))} + ¥{formatCurrency(Number(item.monthlyPrice || item.unitPrice || 0))} ))} @@ -230,7 +231,7 @@ export function EnhancedOrderSummary({ )} - ¥{formatPrice(Number(item.oneTimePrice || item.unitPrice || 0))} + ¥{formatCurrency(Number(item.oneTimePrice || item.unitPrice || 0))} ))} @@ -244,7 +245,7 @@ export function EnhancedOrderSummary({
Discount Applied - -¥{formatPrice(totals.discountAmount)} + -¥{formatCurrency(totals.discountAmount)}
@@ -255,7 +256,7 @@ export function EnhancedOrderSummary({
Tax (10%) - ¥{formatPrice(totals.taxAmount)} + ¥{formatCurrency(totals.taxAmount)}
)} @@ -270,7 +271,7 @@ export function EnhancedOrderSummary({ {String(item.name)} ¥ - {formatPrice( + {formatCurrency( Number( item.billingCycle === "Monthly" ? item.monthlyPrice || item.unitPrice || 0 @@ -289,20 +290,20 @@ export function EnhancedOrderSummary({
Monthly Total: - ¥{formatPrice(totals.monthlyTotal)} + {formatCurrency(totals.monthlyTotal)}
{totals.oneTimeTotal > 0 && (
One-time Total: - ¥{formatPrice(totals.oneTimeTotal)} + {formatCurrency(totals.oneTimeTotal)}
)} {totals.annualTotal && (
Annual Total: - ¥{formatPrice(totals.annualTotal)} + {formatCurrency(totals.annualTotal)}
)}
diff --git a/apps/portal/src/features/catalog/components/base/PricingDisplay.tsx b/apps/portal/src/features/catalog/components/base/PricingDisplay.tsx index 94667b19..844bd34f 100644 --- a/apps/portal/src/features/catalog/components/base/PricingDisplay.tsx +++ b/apps/portal/src/features/catalog/components/base/PricingDisplay.tsx @@ -2,6 +2,9 @@ import { ReactNode } from "react"; import { CurrencyYenIcon, InformationCircleIcon } from "@heroicons/react/24/outline"; +import { Formatting } from "@customer-portal/domain/toolkit"; + +const { formatCurrency } = Formatting; export interface PricingTier { name: string; @@ -57,9 +60,6 @@ export function PricingDisplay({ infoText, children, }: PricingDisplayProps) { - const formatPrice = (price: number) => { - return price.toLocaleString(); - }; const getCurrencyIcon = () => { if (!showCurrencySymbol) return null; @@ -117,7 +117,7 @@ export function PricingDisplay({
{getCurrencyIcon()} - {formatPrice(tier.price)} + {formatCurrency(tier.price)} /{tier.billingCycle.toLowerCase()} @@ -167,17 +167,17 @@ export function PricingDisplay({
{getCurrencyIcon()} - {formatPrice(monthlyPrice)} + {formatCurrency(monthlyPrice)} /month
{originalMonthlyPrice && originalMonthlyPrice > monthlyPrice && (
- ¥{formatPrice(originalMonthlyPrice)} + ¥{formatCurrency(originalMonthlyPrice)} - Save ¥{formatPrice(originalMonthlyPrice - monthlyPrice)}/month + Save ¥{formatCurrency(originalMonthlyPrice - monthlyPrice)}/month
)} @@ -190,17 +190,17 @@ export function PricingDisplay({
{getCurrencyIcon()} - {formatPrice(oneTimePrice)} + {formatCurrency(oneTimePrice)} one-time
{originalOneTimePrice && originalOneTimePrice > oneTimePrice && (
- ¥{formatPrice(originalOneTimePrice)} + ¥{formatCurrency(originalOneTimePrice)} - Save ¥{formatPrice(originalOneTimePrice - oneTimePrice)} + Save ¥{formatCurrency(originalOneTimePrice - oneTimePrice)}
)} @@ -232,7 +232,7 @@ export function PricingDisplay({
{getCurrencyIcon()} - {formatPrice(monthlyPrice)} + {formatCurrency(monthlyPrice)} /month
@@ -242,7 +242,7 @@ export function PricingDisplay({
{getCurrencyIcon()} - {formatPrice(oneTimePrice)} + {formatCurrency(oneTimePrice)} one-time
diff --git a/apps/portal/src/features/catalog/services/catalog.service.ts b/apps/portal/src/features/catalog/services/catalog.service.ts index cfee80a9..57a28ddf 100644 --- a/apps/portal/src/features/catalog/services/catalog.service.ts +++ b/apps/portal/src/features/catalog/services/catalog.service.ts @@ -1,143 +1,76 @@ import { apiClient, getDataOrDefault, getDataOrThrow } from "@/lib/api"; -import { z } from "zod"; import { - internetPlanCatalogItemSchema, + EMPTY_INTERNET_CATALOG, + EMPTY_SIM_CATALOG, + EMPTY_VPN_CATALOG, internetInstallationCatalogItemSchema, internetAddonCatalogItemSchema, - simCatalogProductSchema, + parseInternetCatalog, + parseSimCatalog, + parseVpnCatalog, simActivationFeeCatalogItemSchema, + simCatalogProductSchema, vpnCatalogProductSchema, + type InternetCatalogCollection, + type InternetAddonCatalogItem, + type InternetInstallationCatalogItem, + type SimActivationFeeCatalogItem, + type SimCatalogCollection, + type SimCatalogProduct, + type VpnCatalogCollection, + type VpnCatalogProduct, } from "@customer-portal/domain/catalog"; -import type { - InternetPlanCatalogItem, - InternetInstallationCatalogItem, - InternetAddonCatalogItem, - SimCatalogProduct, - SimActivationFeeCatalogItem, - VpnCatalogProduct, -} from "@customer-portal/domain/catalog"; - -const emptyInternetPlans: InternetPlanCatalogItem[] = []; -const emptyInternetAddons: InternetAddonCatalogItem[] = []; -const emptyInternetInstallations: InternetInstallationCatalogItem[] = []; -const emptySimPlans: SimCatalogProduct[] = []; -const emptySimAddons: SimCatalogProduct[] = []; -const emptySimActivationFees: SimActivationFeeCatalogItem[] = []; -const emptyVpnPlans: VpnCatalogProduct[] = []; - -const defaultInternetCatalog = { - plans: emptyInternetPlans, - installations: emptyInternetInstallations, - addons: emptyInternetAddons, -}; - -const defaultSimCatalog = { - plans: emptySimPlans, - activationFees: emptySimActivationFees, - addons: emptySimAddons, -}; - -const defaultVpnCatalog = { - plans: emptyVpnPlans, - activationFees: emptyVpnPlans, -}; - -const internetCatalogSchema = z.object({ - plans: z.array(internetPlanCatalogItemSchema), - installations: z.array(internetInstallationCatalogItemSchema), - addons: z.array(internetAddonCatalogItemSchema), -}); - -const internetInstallationsSchema = z.array(internetInstallationCatalogItemSchema); -const internetAddonsSchema = z.array(internetAddonCatalogItemSchema); - -const simCatalogSchema = z.object({ - plans: z.array(simCatalogProductSchema), - activationFees: z.array(simActivationFeeCatalogItemSchema), - addons: z.array(simCatalogProductSchema), -}); - -const simActivationFeesSchema = z.array(simActivationFeeCatalogItemSchema); -const simAddonsSchema = z.array(simCatalogProductSchema); - -const vpnCatalogSchema = z.object({ - plans: z.array(vpnCatalogProductSchema), - activationFees: z.array(vpnCatalogProductSchema), -}); - -const vpnActivationFeesSchema = z.array(vpnCatalogProductSchema); +import type { InternetPlanCatalogItem } from "@customer-portal/domain/catalog"; export const catalogService = { - async getInternetCatalog(): Promise<{ - plans: InternetPlanCatalogItem[]; - installations: InternetInstallationCatalogItem[]; - addons: InternetAddonCatalogItem[]; - }> { - const response = await apiClient.GET( - "/api/catalog/internet/plans" - ); - const data = getDataOrThrow( + async getInternetCatalog(): Promise { + const response = await apiClient.GET("/api/catalog/internet/plans"); + const data = getDataOrThrow( response, "Failed to load internet catalog" ); - return internetCatalogSchema.parse(data); + return parseInternetCatalog(data); }, async getInternetInstallations(): Promise { - const response = await apiClient.GET( - "/api/catalog/internet/installations" - ); - const data = getDataOrDefault( - response, - emptyInternetInstallations - ); - return internetInstallationsSchema.parse(data); + const response = await apiClient.GET("/api/catalog/internet/installations"); + const data = getDataOrDefault(response, []); + return internetInstallationCatalogItemSchema.array().parse(data); }, async getInternetAddons(): Promise { - const response = await apiClient.GET( - "/api/catalog/internet/addons" - ); - const data = getDataOrDefault(response, emptyInternetAddons); - return internetAddonsSchema.parse(data); + const response = await apiClient.GET("/api/catalog/internet/addons"); + const data = getDataOrDefault(response, []); + return internetAddonCatalogItemSchema.array().parse(data); }, - async getSimCatalog(): Promise<{ - plans: SimCatalogProduct[]; - activationFees: SimActivationFeeCatalogItem[]; - addons: SimCatalogProduct[]; - }> { - const response = await apiClient.GET("/api/catalog/sim/plans"); - const data = getDataOrDefault(response, defaultSimCatalog); - return simCatalogSchema.parse(data); + async getSimCatalog(): Promise { + const response = await apiClient.GET("/api/catalog/sim/plans"); + const data = getDataOrDefault(response, EMPTY_SIM_CATALOG); + return parseSimCatalog(data); }, async getSimActivationFees(): Promise { - const response = await apiClient.GET( - "/api/catalog/sim/activation-fees" - ); - const data = getDataOrDefault(response, emptySimActivationFees); - return simActivationFeesSchema.parse(data); + const response = await apiClient.GET("/api/catalog/sim/activation-fees"); + const data = getDataOrDefault(response, []); + return simActivationFeeCatalogItemSchema.array().parse(data); }, async getSimAddons(): Promise { const response = await apiClient.GET("/api/catalog/sim/addons"); - const data = getDataOrDefault(response, emptySimAddons); - return simAddonsSchema.parse(data); + const data = getDataOrDefault(response, []); + return simCatalogProductSchema.array().parse(data); }, - async getVpnCatalog(): Promise<{ - plans: VpnCatalogProduct[]; - activationFees: VpnCatalogProduct[]; - }> { - const response = await apiClient.GET("/api/catalog/vpn/plans"); - const data = getDataOrDefault(response, defaultVpnCatalog); - return vpnCatalogSchema.parse(data); + async getVpnCatalog(): Promise { + const response = await apiClient.GET("/api/catalog/vpn/plans"); + const data = getDataOrDefault(response, EMPTY_VPN_CATALOG); + return parseVpnCatalog(data); }, async getVpnActivationFees(): Promise { const response = await apiClient.GET("/api/catalog/vpn/activation-fees"); - const data = getDataOrDefault(response, emptyVpnPlans); - return vpnActivationFeesSchema.parse(data); + const data = getDataOrDefault(response, []); + return vpnCatalogProductSchema.array().parse(data); }, }; diff --git a/apps/portal/src/features/catalog/utils/catalog.utils.ts b/apps/portal/src/features/catalog/utils/catalog.utils.ts index 0ad9c081..9fe76d1a 100644 --- a/apps/portal/src/features/catalog/utils/catalog.utils.ts +++ b/apps/portal/src/features/catalog/utils/catalog.utils.ts @@ -29,13 +29,6 @@ type CatalogProduct = | SimCatalogProduct | VpnCatalogProduct; -/** - * Format price with currency (wrapper for centralized utility) - */ -export function formatPrice(price: number, currency: string = "JPY"): string { - return formatCurrency(price, currency); -} - /** * Filter products based on criteria * Note: This is a simplified version. In practice, filtering is done server-side via API params. diff --git a/apps/portal/src/features/catalog/utils/pricing.ts b/apps/portal/src/features/catalog/utils/pricing.ts new file mode 100644 index 00000000..858b66c9 --- /dev/null +++ b/apps/portal/src/features/catalog/utils/pricing.ts @@ -0,0 +1,33 @@ +import type { CatalogProductBase } from "@customer-portal/domain/catalog"; + +export interface PriceInfo { + display: string; + monthly: number | null; + oneTime: number | null; + currency: string; +} + +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, + }; +} + diff --git a/apps/portal/src/features/checkout/hooks/useCheckout.ts b/apps/portal/src/features/checkout/hooks/useCheckout.ts index cf690bab..1628a1ef 100644 --- a/apps/portal/src/features/checkout/hooks/useCheckout.ts +++ b/apps/portal/src/features/checkout/hooks/useCheckout.ts @@ -10,6 +10,7 @@ import type { CatalogProductBase } from "@customer-portal/domain/catalog"; import { createLoadingState, createSuccessState, createErrorState } from "@customer-portal/domain/toolkit"; import type { AsyncState } from "@customer-portal/domain/toolkit"; import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions"; +import { ORDER_TYPE, type OrderConfigurations, type OrderTypeValue } from "@customer-portal/domain/orders"; // Use domain Address type import type { Address } from "@customer-portal/domain/customer"; @@ -30,7 +31,7 @@ interface CheckoutTotals { interface CheckoutCart { items: CheckoutItem[]; totals: CheckoutTotals; - configuration: Record; + configuration: OrderConfigurations; } export function useCheckout() { @@ -57,21 +58,21 @@ export function useCheckout() { const paymentRefresh = usePaymentRefresh({ refetch: refetchPaymentMethods, - hasMethods: (data?: { totalCount?: number }) => !!data && (data.totalCount || 0) > 0, attachFocusListeners: true, }); - const orderType = useMemo(() => { - const type = params.get("type") || "internet"; - switch (type.toLowerCase()) { + const orderType: OrderTypeValue = useMemo(() => { + const type = params.get("type")?.toLowerCase() ?? "internet"; + switch (type) { case "sim": - return "SIM" as const; - case "internet": - return "Internet" as const; + return ORDER_TYPE.SIM; case "vpn": - return "VPN" as const; + return ORDER_TYPE.VPN; + case "other": + return ORDER_TYPE.OTHER; + case "internet": default: - return "Other" as const; + return ORDER_TYPE.INTERNET; } }, [params]); @@ -229,7 +230,7 @@ export function useCheckout() { createSuccessState({ items, totals, - configuration: {}, + configuration: {} as OrderConfigurations, }) ); } catch (error) { @@ -265,30 +266,45 @@ export function useCheckout() { throw new Error("No products selected for order. Please go back and select products."); } - const configurations: Record = {}; - if (selections.accessMode) configurations.accessMode = selections.accessMode; - if (selections.simType) configurations.simType = selections.simType; - if (selections.eid) configurations.eid = selections.eid; - if (selections.activationType) configurations.activationType = selections.activationType; - if (selections.scheduledAt) configurations.scheduledAt = selections.scheduledAt; - if (selections.isMnp) configurations.isMnp = selections.isMnp; - if (selections.reservationNumber) configurations.mnpNumber = selections.reservationNumber; - if (selections.expiryDate) configurations.mnpExpiry = selections.expiryDate; - if (selections.phoneNumber) configurations.mnpPhone = selections.phoneNumber; - if (selections.mvnoAccountNumber) - configurations.mvnoAccountNumber = selections.mvnoAccountNumber; - if (selections.portingLastName) configurations.portingLastName = selections.portingLastName; - if (selections.portingFirstName) - configurations.portingFirstName = selections.portingFirstName; - if (selections.portingLastNameKatakana) - configurations.portingLastNameKatakana = selections.portingLastNameKatakana; - if (selections.portingFirstNameKatakana) - configurations.portingFirstNameKatakana = selections.portingFirstNameKatakana; - if (selections.portingGender) configurations.portingGender = selections.portingGender; - if (selections.portingDateOfBirth) - configurations.portingDateOfBirth = selections.portingDateOfBirth; + const configurations: OrderConfigurations = { + ...(selections.accessMode ? { accessMode: selections.accessMode as OrderConfigurations["accessMode"] } : {}), + ...(selections.activationType + ? { activationType: selections.activationType as OrderConfigurations["activationType"] } + : {}), + ...(selections.scheduledAt ? { scheduledAt: selections.scheduledAt } : {}), + ...(selections.simType ? { simType: selections.simType as OrderConfigurations["simType"] } : {}), + ...(selections.eid ? { eid: selections.eid } : {}), + ...(selections.isMnp ? { isMnp: selections.isMnp } : {}), + ...(selections.reservationNumber ? { mnpNumber: selections.reservationNumber } : {}), + ...(selections.expiryDate ? { mnpExpiry: selections.expiryDate } : {}), + ...(selections.phoneNumber ? { mnpPhone: selections.phoneNumber } : {}), + ...(selections.mvnoAccountNumber ? { mvnoAccountNumber: selections.mvnoAccountNumber } : {}), + ...(selections.portingLastName ? { portingLastName: selections.portingLastName } : {}), + ...(selections.portingFirstName ? { portingFirstName: selections.portingFirstName } : {}), + ...(selections.portingLastNameKatakana + ? { portingLastNameKatakana: selections.portingLastNameKatakana } + : {}), + ...(selections.portingFirstNameKatakana + ? { portingFirstNameKatakana: selections.portingFirstNameKatakana } + : {}), + ...(selections.portingGender + ? { + portingGender: selections.portingGender as OrderConfigurations["portingGender"], + } + : {}), + ...(selections.portingDateOfBirth ? { portingDateOfBirth: selections.portingDateOfBirth } : {}), + }; - if (confirmedAddress) configurations.address = confirmedAddress; + if (confirmedAddress) { + configurations.address = { + street: confirmedAddress.street ?? undefined, + streetLine2: confirmedAddress.streetLine2 ?? undefined, + city: confirmedAddress.city ?? undefined, + state: confirmedAddress.state ?? undefined, + postalCode: confirmedAddress.postalCode ?? undefined, + country: confirmedAddress.country ?? undefined, + }; + } const orderData = { orderType, diff --git a/apps/portal/src/features/dashboard/components/UpcomingPaymentBanner.tsx b/apps/portal/src/features/dashboard/components/UpcomingPaymentBanner.tsx index 3c6a7728..abeb647b 100644 --- a/apps/portal/src/features/dashboard/components/UpcomingPaymentBanner.tsx +++ b/apps/portal/src/features/dashboard/components/UpcomingPaymentBanner.tsx @@ -33,7 +33,7 @@ export function UpcomingPaymentBanner({ invoice, onPay, loading }: UpcomingPayme
- {formatCurrency(invoice.amount, invoice.currency || "JPY")} + {formatCurrency(invoice.amount)}
Exact due date: {format(new Date(invoice.dueDate), "MMMM d, yyyy")} diff --git a/apps/portal/src/features/dashboard/views/DashboardView.tsx b/apps/portal/src/features/dashboard/views/DashboardView.tsx index 172dc809..a73cdcd4 100644 --- a/apps/portal/src/features/dashboard/views/DashboardView.tsx +++ b/apps/portal/src/features/dashboard/views/DashboardView.tsx @@ -194,7 +194,7 @@ export function DashboardView() {
- {formatCurrency(upcomingInvoice.amount, upcomingInvoice.currency || "JPY")} + {formatCurrency(upcomingInvoice.amount)}
Exact due date: {format(new Date(upcomingInvoice.dueDate), "MMMM d, yyyy")} diff --git a/apps/portal/src/features/orders/services/orders.service.ts b/apps/portal/src/features/orders/services/orders.service.ts index ef8566c2..f10db612 100644 --- a/apps/portal/src/features/orders/services/orders.service.ts +++ b/apps/portal/src/features/orders/services/orders.service.ts @@ -1,17 +1,34 @@ import { apiClient } from "@/lib/api"; -import type { CreateOrderRequest, OrderDetails, OrderSummary } from "@customer-portal/domain/orders"; +import { + createOrderRequest, + orderDetailsSchema, + orderSummarySchema, + type CreateOrderRequest, + type OrderDetails, + type OrderSummary, +} from "@customer-portal/domain/orders"; +import { + assertSuccess, + type DomainApiResponse, +} from "@/lib/api/response-helpers"; async function createOrder(payload: CreateOrderRequest): Promise<{ sfOrderId: string }> { - const response = await apiClient.POST("/api/orders", { body: payload }); - if (!response.data) { - throw new Error("Order creation failed"); - } - return response.data; + const body = createOrderRequest({ + orderType: payload.orderType, + skus: payload.skus, + configurations: payload.configurations ?? undefined, + }); + const response = await apiClient.POST("/api/orders", { body }); + const parsed = assertSuccess<{ sfOrderId: string; status: string; message: string }>( + response.data as DomainApiResponse<{ sfOrderId: string; status: string; message: string }> + ); + return { sfOrderId: parsed.data.sfOrderId }; } async function getMyOrders(): Promise { const response = await apiClient.GET("/api/orders/user"); - return (response.data ?? []) as OrderSummary[]; + const data = Array.isArray(response.data) ? response.data : []; + return data.map(item => orderSummarySchema.parse(item)); } async function getOrderById(orderId: string): Promise { @@ -21,7 +38,7 @@ async function getOrderById(orderId: string): Promise { if (!response.data) { throw new Error("Order not found"); } - return response.data as OrderDetails; + return orderDetailsSchema.parse(response.data); } export const ordersService = { diff --git a/apps/portal/src/features/subscriptions/components/SubscriptionCard.tsx b/apps/portal/src/features/subscriptions/components/SubscriptionCard.tsx index 9f1b8bdf..f5b22bed 100644 --- a/apps/portal/src/features/subscriptions/components/SubscriptionCard.tsx +++ b/apps/portal/src/features/subscriptions/components/SubscriptionCard.tsx @@ -111,7 +111,7 @@ export const SubscriptionCard = forwardRef

Price

- {formatCurrency(subscription.amount, subscription.currency)} + {formatCurrency(subscription.amount)}

{getBillingCycleLabel(subscription.cycle)}

@@ -171,7 +171,7 @@ export const SubscriptionCard = forwardRef

- {formatCurrency(subscription.amount, "JPY")} + {formatCurrency(subscription.amount)}

{getBillingCycleLabel(subscription.cycle)}

diff --git a/apps/portal/src/features/subscriptions/components/SubscriptionDetails.tsx b/apps/portal/src/features/subscriptions/components/SubscriptionDetails.tsx index 0fda209d..54ea5c85 100644 --- a/apps/portal/src/features/subscriptions/components/SubscriptionDetails.tsx +++ b/apps/portal/src/features/subscriptions/components/SubscriptionDetails.tsx @@ -135,7 +135,7 @@ export const SubscriptionDetails = forwardRef

- {formatCurrency(subscription.amount, "JPY")} + {formatCurrency(subscription.amount)}

{formatBillingLabel(subscription.cycle)}

diff --git a/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx b/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx index 651bb41b..2e044850 100644 --- a/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx +++ b/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx @@ -129,7 +129,7 @@ export function SubscriptionDetailContainer() { }; const formatCurrency = (amount: number) => - sharedFormatCurrency(amount || 0, subscription?.currency ?? "JPY"); + sharedFormatCurrency(amount || 0); const formatBillingLabel = (cycle: string) => { switch (cycle) { diff --git a/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx b/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx index 07e068ce..e54b8bb3 100644 --- a/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx +++ b/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx @@ -135,7 +135,7 @@ export function SubscriptionsListContainer() { render: (s: Subscription) => (
- {formatCurrency(s.amount, s.currency)} + {formatCurrency(s.amount)}
{s.cycle === "Monthly" diff --git a/apps/portal/src/lib/api/index.ts b/apps/portal/src/lib/api/index.ts index 015a696c..5ce9a0a2 100644 --- a/apps/portal/src/lib/api/index.ts +++ b/apps/portal/src/lib/api/index.ts @@ -3,7 +3,7 @@ export type { ApiClient, AuthHeaderResolver, CreateClientOptions, QueryParams, P export { ApiError } from "./runtime/client"; // Re-export API helpers -export * from "./helpers"; +export * from "./response-helpers"; // Import createClient for internal use import { createClient } from "./runtime/client"; diff --git a/apps/portal/src/lib/api/response-helpers.ts b/apps/portal/src/lib/api/response-helpers.ts index 3592b5cd..3d9edaf6 100644 --- a/apps/portal/src/lib/api/response-helpers.ts +++ b/apps/portal/src/lib/api/response-helpers.ts @@ -1,3 +1,10 @@ +import { + apiErrorResponseSchema, + apiSuccessResponseSchema, + type ApiErrorResponse, + type ApiSuccessResponse, +} from "@customer-portal/domain/common"; + /** * API Response Helper Types and Functions * @@ -60,3 +67,26 @@ export function hasData(response: ApiResponse): boolean { return response.data !== undefined && !response.error; } +export type DomainApiResponse = ApiSuccessResponse | ApiErrorResponse; + +export function assertSuccess(response: DomainApiResponse): ApiSuccessResponse { + if (response.success === true) { + return response; + } + throw new Error(response.error.message); +} + +export function parseDomainResponse(response: DomainApiResponse, parser?: (payload: T) => T): T { + const success = assertSuccess(response); + return parser ? parser(success.data) : success.data; +} + +export function parseDomainError(payload: unknown): ApiErrorResponse | null { + const parsed = apiErrorResponseSchema.safeParse(payload); + return parsed.success ? parsed.data : null; +} + +export function buildSuccessResponse(data: T, schema: (value: T) => T): ApiSuccessResponse { + return apiSuccessResponseSchema({ parse: () => schema(data) } as any).parse({ success: true, data }); +} + diff --git a/apps/portal/src/lib/hooks/useCurrency.ts b/apps/portal/src/lib/hooks/useCurrency.ts new file mode 100644 index 00000000..048da5d5 --- /dev/null +++ b/apps/portal/src/lib/hooks/useCurrency.ts @@ -0,0 +1,43 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { currencyService, type CurrencyInfo } from "@/lib/services/currency.service"; + +export function useCurrency() { + const [defaultCurrency, setDefaultCurrency] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const loadCurrency = async () => { + try { + setLoading(true); + setError(null); + const currency = await currencyService.getDefaultCurrency(); + setDefaultCurrency(currency); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load currency"); + // Fallback to JPY if API fails + setDefaultCurrency({ + code: "JPY", + prefix: "¥", + suffix: "", + format: "1", + rate: "1.00000", + }); + } finally { + setLoading(false); + } + }; + + loadCurrency(); + }, []); + + return { + currency: defaultCurrency, + loading, + error, + currencyCode: defaultCurrency?.code || "JPY", + currencySymbol: defaultCurrency?.prefix || "¥", + }; +} diff --git a/apps/portal/src/lib/hooks/useFormatCurrency.ts b/apps/portal/src/lib/hooks/useFormatCurrency.ts new file mode 100644 index 00000000..95df1e70 --- /dev/null +++ b/apps/portal/src/lib/hooks/useFormatCurrency.ts @@ -0,0 +1,30 @@ +"use client"; + +import { useCurrency } from "@/lib/hooks/useCurrency"; +import { formatCurrency as baseFormatCurrency } from "@customer-portal/domain/toolkit"; + +export function useFormatCurrency() { + const { currencyCode, loading, error } = useCurrency(); + + const formatCurrency = (amount: number) => { + if (loading) { + // Show loading state or fallback + return "¥" + amount.toLocaleString(); + } + + if (error) { + // Fallback to JPY if there's an error + return baseFormatCurrency(amount, "JPY"); + } + + // Use the currency from WHMCS API + return baseFormatCurrency(amount, currencyCode); + }; + + return { + formatCurrency, + currencyCode, + loading, + error, + }; +} diff --git a/apps/portal/src/lib/services/currency.service.ts b/apps/portal/src/lib/services/currency.service.ts new file mode 100644 index 00000000..90730b12 --- /dev/null +++ b/apps/portal/src/lib/services/currency.service.ts @@ -0,0 +1,34 @@ +import { apiClient } from "@/lib/api"; + +export interface CurrencyInfo { + code: string; + prefix: string; + suffix: string; + format: string; + rate: string; +} + +export interface CurrencyService { + getDefaultCurrency(): Promise; + getAllCurrencies(): Promise; +} + +class CurrencyServiceImpl implements CurrencyService { + async getDefaultCurrency(): Promise { + const response = await apiClient.GET("/api/currency/default"); + if (!response.data) { + throw new Error("Failed to get default currency"); + } + return response.data; + } + + async getAllCurrencies(): Promise { + const response = await apiClient.GET("/api/currency/all"); + if (!response.data) { + throw new Error("Failed to get currencies"); + } + return response.data; + } +} + +export const currencyService = new CurrencyServiceImpl(); diff --git a/apps/portal/src/lib/utils/error-handling.ts b/apps/portal/src/lib/utils/error-handling.ts index 37064180..056dafeb 100644 --- a/apps/portal/src/lib/utils/error-handling.ts +++ b/apps/portal/src/lib/utils/error-handling.ts @@ -4,17 +4,9 @@ */ import { ApiError as ClientApiError } from "@/lib/api"; +import { apiErrorResponseSchema, type ApiErrorResponse } from "@customer-portal/domain/common"; -export interface ApiErrorPayload { - success: false; - error: { - code: string; - message: string; - details?: Record; - }; - timestamp?: string; - path?: string; -} +export type ApiErrorPayload = ApiErrorResponse; export interface ApiErrorInfo { code: string; @@ -69,14 +61,8 @@ export function getErrorInfo(error: unknown): ApiErrorInfo { } export function isApiErrorPayload(error: unknown): error is ApiErrorPayload { - return ( - typeof error === "object" && - error !== null && - "success" in error && - (error as { success?: unknown }).success === false && - "error" in error && - typeof (error as { error?: unknown }).error === "object" - ); + const parsed = apiErrorResponseSchema.safeParse(error); + return parsed.success; } /** @@ -153,7 +139,7 @@ function parseClientApiError(error: ClientApiError): ApiErrorInfo | null { const status = error.response?.status; const parsedBody = parseRawErrorBody(error.body); - const payloadInfo = parsedBody ? deriveInfoFromPayload(parsedBody, status) : null; +const payloadInfo = parsedBody ? deriveInfoFromPayload(parsedBody, status) : null; if (payloadInfo) { return payloadInfo; @@ -184,11 +170,12 @@ function parseRawErrorBody(body: unknown): unknown { } function deriveInfoFromPayload(payload: unknown, status?: number): ApiErrorInfo | null { - if (isApiErrorPayload(payload)) { - const code = payload.error.code; + const parsed = apiErrorResponseSchema.safeParse(payload); + if (parsed.success) { + const code = parsed.data.error.code; return { code, - message: payload.error.message, + message: parsed.data.error.message, shouldLogout: shouldLogoutForError(code) || status === 401, shouldRetry: shouldRetryForError(code), }; diff --git a/packages/domain/catalog/index.ts b/packages/domain/catalog/index.ts index e798d8e9..4334259a 100644 --- a/packages/domain/catalog/index.ts +++ b/packages/domain/catalog/index.ts @@ -42,3 +42,6 @@ export type { WhmcsCatalogProduct, WhmcsCatalogProductListResponse, } from "./providers/whmcs/raw.types"; + +// Utilities +export * from "./utils"; diff --git a/packages/domain/catalog/providers/salesforce/mapper.ts b/packages/domain/catalog/providers/salesforce/mapper.ts index 9b12b8b1..a5f91a3c 100644 --- a/packages/domain/catalog/providers/salesforce/mapper.ts +++ b/packages/domain/catalog/providers/salesforce/mapper.ts @@ -9,7 +9,6 @@ import type { InternetPlanCatalogItem, InternetInstallationCatalogItem, InternetAddonCatalogItem, - InternetPlanTemplate, SimCatalogProduct, SimActivationFeeCatalogItem, VpnCatalogProduct, @@ -18,70 +17,16 @@ import type { SalesforceProduct2WithPricebookEntries, SalesforcePricebookEntryRecord, } from "./raw.types"; +import { + enrichInternetPlanMetadata, + inferAddonTypeFromSku, + inferInstallationTermFromSku, +} from "../../utils"; // ============================================================================ // Tier Templates (Hardcoded Product Metadata) // ============================================================================ -const DEFAULT_PLAN_TEMPLATE: InternetPlanTemplate = { - tierDescription: "Standard plan", - description: undefined, - features: undefined, -}; - -function getTierTemplate(tier?: string): InternetPlanTemplate { - if (!tier) { - return DEFAULT_PLAN_TEMPLATE; - } - - const normalized = tier.toLowerCase(); - switch (normalized) { - case "silver": - return { - tierDescription: "Simple package with broadband-modem and ISP only", - description: "Simple package with broadband-modem and ISP only", - features: [ - "NTT modem + ISP connection", - "Two ISP connection protocols: IPoE (recommended) or PPPoE", - "Self-configuration of router (you provide your own)", - "Monthly: ¥6,000 | One-time: ¥22,800", - ], - }; - case "gold": - return { - tierDescription: "Standard all-inclusive package with basic Wi-Fi", - description: "Standard all-inclusive package with basic Wi-Fi", - features: [ - "NTT modem + wireless router (rental)", - "ISP (IPoE) configured automatically within 24 hours", - "Basic wireless router included", - "Optional: TP-LINK RE650 range extender (¥500/month)", - "Monthly: ¥6,500 | One-time: ¥22,800", - ], - }; - case "platinum": - return { - tierDescription: "Tailored set up with premier Wi-Fi management support", - description: - "Tailored set up with premier Wi-Fi management support - Recommended for homes & apartments larger than 50m²", - features: [ - "NTT modem + Netgear INSIGHT Wi-Fi routers", - "Cloud management support for remote router management", - "Automatic updates and quicker support", - "Seamless wireless network setup", - "Monthly: ¥6,500 | One-time: ¥22,800", - "Cloud management: ¥500/month per router", - ], - }; - default: - return { - tierDescription: `${tier} plan`, - description: undefined, - features: undefined, - }; - } -} - // ============================================================================ // Helper Functions // ============================================================================ @@ -115,6 +60,7 @@ function baseProduct( id: product.Id, sku, name: product.Name ?? sku, + catalogMetadata: {}, }; if (product.Description) base.description = product.Description; @@ -148,20 +94,12 @@ export function mapInternetPlan( const base = baseProduct(product, pricebookEntry); const tier = product.Internet_Plan_Tier__c ?? undefined; const offeringType = product.Internet_Offering_Type__c ?? undefined; - const tierData = getTierTemplate(tier); - return { + return enrichInternetPlanMetadata({ ...base, internetPlanTier: tier, internetOfferingType: offeringType, - features: tierData.features, - catalogMetadata: { - tierDescription: tierData.tierDescription, - features: tierData.features, - isRecommended: tier === "Gold", - }, - description: base.description ?? tierData.description, - }; + }); } export function mapInternetInstallation( @@ -173,7 +111,8 @@ export function mapInternetInstallation( return { ...base, catalogMetadata: { - installationTerm: inferInstallationTypeFromSku(base.sku), + ...base.catalogMetadata, + installationTerm: inferInstallationTermFromSku(base.sku), }, }; } @@ -190,6 +129,10 @@ export function mapInternetAddon( ...base, bundledAddonId, isBundledAddon, + catalogMetadata: { + ...base.catalogMetadata, + addonType: inferAddonTypeFromSku(base.sku), + }, }; } diff --git a/packages/domain/catalog/schema.ts b/packages/domain/catalog/schema.ts index 2a3c0d6a..873c3305 100644 --- a/packages/domain/catalog/schema.ts +++ b/packages/domain/catalog/schema.ts @@ -20,6 +20,7 @@ export const catalogProductBaseSchema = z.object({ monthlyPrice: z.number().optional(), oneTimePrice: z.number().optional(), unitPrice: z.number().optional(), + catalogMetadata: z.record(z.string(), z.unknown()).optional(), }); // ============================================================================ @@ -70,6 +71,14 @@ export const internetAddonCatalogItemSchema = internetCatalogProductSchema.exten bundledAddonId: z.string().optional(), }); +export const internetCatalogCollectionSchema = z.object({ + plans: z.array(internetPlanCatalogItemSchema), + installations: z.array(internetInstallationCatalogItemSchema), + addons: z.array(internetAddonCatalogItemSchema), +}); + +export const internetCatalogResponseSchema = internetCatalogCollectionSchema; + // ============================================================================ // SIM Product Schemas // ============================================================================ @@ -88,6 +97,14 @@ export const simActivationFeeCatalogItemSchema = simCatalogProductSchema.extend( }).optional(), }); +export const simCatalogCollectionSchema = z.object({ + plans: z.array(simCatalogProductSchema), + activationFees: z.array(simActivationFeeCatalogItemSchema), + addons: z.array(simCatalogProductSchema), +}); + +export const simCatalogResponseSchema = simCatalogCollectionSchema; + // ============================================================================ // VPN Product Schema // ============================================================================ @@ -96,6 +113,13 @@ export const vpnCatalogProductSchema = catalogProductBaseSchema.extend({ vpnRegion: z.string().optional(), }); +export const vpnCatalogCollectionSchema = z.object({ + plans: z.array(vpnCatalogProductSchema), + activationFees: z.array(vpnCatalogProductSchema), +}); + +export const vpnCatalogResponseSchema = vpnCatalogCollectionSchema; + // ============================================================================ // Inferred Types from Schemas (Schema-First Approach) // ============================================================================ @@ -109,13 +133,16 @@ export type InternetPlanTemplate = z.infer; export type InternetPlanCatalogItem = z.infer; export type InternetInstallationCatalogItem = z.infer; export type InternetAddonCatalogItem = z.infer; +export type InternetCatalogCollection = z.infer; // SIM products export type SimCatalogProduct = z.infer; export type SimActivationFeeCatalogItem = z.infer; +export type SimCatalogCollection = z.infer; // VPN products export type VpnCatalogProduct = z.infer; +export type VpnCatalogCollection = z.infer; // Union type for all catalog products export type CatalogProduct = diff --git a/packages/domain/catalog/utils.ts b/packages/domain/catalog/utils.ts new file mode 100644 index 00000000..7bdee0e0 --- /dev/null +++ b/packages/domain/catalog/utils.ts @@ -0,0 +1,158 @@ +import { + internetCatalogResponseSchema, + internetPlanCatalogItemSchema, + simCatalogResponseSchema, + vpnCatalogResponseSchema, + type InternetCatalogCollection, + type InternetPlanCatalogItem, + type SimCatalogCollection, + type VpnCatalogCollection, + type InternetPlanTemplate, +} from "./schema"; + +/** + * Empty catalog defaults shared by portal and BFF. + */ +export const EMPTY_INTERNET_CATALOG: InternetCatalogCollection = { + plans: [], + installations: [], + addons: [], +}; + +export const EMPTY_SIM_CATALOG: SimCatalogCollection = { + plans: [], + activationFees: [], + addons: [], +}; + +export const EMPTY_VPN_CATALOG: VpnCatalogCollection = { + plans: [], + activationFees: [], +}; + +/** + * Safe parser helpers for catalog payloads coming from HTTP boundaries. + */ +export function parseInternetCatalog(data: unknown): InternetCatalogCollection { + return internetCatalogResponseSchema.parse(data); +} + +export function parseSimCatalog(data: unknown): SimCatalogCollection { + return simCatalogResponseSchema.parse(data); +} + +export function parseVpnCatalog(data: unknown): VpnCatalogCollection { + return vpnCatalogResponseSchema.parse(data); +} + +/** + * Internet tier metadata map shared between BFF and portal presenters. + */ +const INTERNET_TIER_METADATA: Record = { + silver: { + tierDescription: "Simple package with broadband-modem and ISP only", + description: "Simple package with broadband-modem and ISP only", + features: [ + "NTT modem + ISP connection", + "Two ISP connection protocols: IPoE (recommended) or PPPoE", + "Self-configuration of router (you provide your own)", + "Monthly: ¥6,000 | One-time: ¥22,800", + ], + }, + gold: { + tierDescription: "Standard all-inclusive package with basic Wi-Fi", + description: "Standard all-inclusive package with basic Wi-Fi", + features: [ + "NTT modem + wireless router (rental)", + "ISP (IPoE) configured automatically within 24 hours", + "Basic wireless router included", + "Optional: TP-LINK RE650 range extender (¥500/month)", + "Monthly: ¥6,500 | One-time: ¥22,800", + ], + }, + platinum: { + tierDescription: "Tailored set up with premier Wi-Fi management support", + description: + "Tailored set up with premier Wi-Fi management support - Recommended for homes & apartments larger than 50m²", + features: [ + "NTT modem + Netgear INSIGHT Wi-Fi routers", + "Cloud management support for remote router management", + "Automatic updates and quicker support", + "Seamless wireless network setup", + "Monthly: ¥6,500 | One-time: ¥22,800", + "Cloud management: ¥500/month per router", + ], + }, +}; + +const DEFAULT_PLAN_TEMPLATE: InternetPlanTemplate = { + tierDescription: "Standard plan", + description: undefined, + features: undefined, +}; + +export function getInternetTierTemplate(tier?: string | null): InternetPlanTemplate { + if (!tier) { + return DEFAULT_PLAN_TEMPLATE; + } + + const normalized = tier.trim().toLowerCase(); + return INTERNET_TIER_METADATA[normalized] ?? { + tierDescription: `${tier} plan`, + description: undefined, + features: undefined, + }; +} + +export type InternetInstallationTerm = "One-time" | "12-Month" | "24-Month"; + +export function inferInstallationTermFromSku(sku: string): InternetInstallationTerm { + const normalized = sku.toLowerCase(); + if (normalized.includes("24")) return "24-Month"; + if (normalized.includes("12")) return "12-Month"; + return "One-time"; +} + +export type InternetAddonType = "hikari-denwa-service" | "hikari-denwa-installation" | "other"; + +export function inferAddonTypeFromSku(sku: string): InternetAddonType { + const upperSku = sku.toUpperCase(); + const isDenwa = + upperSku.includes("DENWA") || + upperSku.includes("HOME-PHONE") || + upperSku.includes("PHONE"); + + if (!isDenwa) { + return "other"; + } + + const isInstallation = + upperSku.includes("INSTALL") || upperSku.includes("SETUP") || upperSku.includes("ACTIVATION"); + + return isInstallation ? "hikari-denwa-installation" : "hikari-denwa-service"; +} + +/** + * Helper to apply tier metadata to a plan item. + */ +export function enrichInternetPlanMetadata(plan: InternetPlanCatalogItem): InternetPlanCatalogItem { + const template = getInternetTierTemplate(plan.internetPlanTier ?? null); + const existingMetadata = plan.catalogMetadata ?? {}; + const metadata = { + ...existingMetadata, + tierDescription: existingMetadata.tierDescription ?? template.tierDescription, + features: existingMetadata.features ?? template.features, + isRecommended: + existingMetadata.isRecommended ?? (plan.internetPlanTier?.toLowerCase() === "gold" ? true : undefined), + }; + + return internetPlanCatalogItemSchema.parse({ + ...plan, + description: plan.description ?? template.description, + catalogMetadata: metadata, + features: plan.features ?? template.features, + }); +} + +export const internetPlanCollectionSchema = internetPlanCatalogItemSchema.array(); + diff --git a/packages/domain/customer/schema.ts b/packages/domain/customer/schema.ts index dcff6369..19085f63 100644 --- a/packages/domain/customer/schema.ts +++ b/packages/domain/customer/schema.ts @@ -339,7 +339,7 @@ export type UserRole = "USER" | "ADMIN"; export type Address = z.infer; export type AddressFormData = z.infer; export type ProfileEditFormData = z.infer; -export type ProfileDisplayData = ProfileEditFormData; // Alias for display purposes +export type ProfileDisplayData = z.infer; // Convenience aliases export type UserProfile = User; // Alias for user profile diff --git a/packages/domain/orders/index.ts b/packages/domain/orders/index.ts index 60394268..27cd904e 100644 --- a/packages/domain/orders/index.ts +++ b/packages/domain/orders/index.ts @@ -27,6 +27,9 @@ export * from "./schema"; // Validation (extended business rules) export * from "./validation"; +// Utilities +export * from "./utils"; + // Re-export types for convenience export type { // Order item types diff --git a/packages/domain/orders/utils.ts b/packages/domain/orders/utils.ts new file mode 100644 index 00000000..9eea9d91 --- /dev/null +++ b/packages/domain/orders/utils.ts @@ -0,0 +1,73 @@ +import { z } from "zod"; + +import { + orderConfigurationsSchema, + type OrderConfigurations, + type CreateOrderRequest, +} from "./schema"; +import { ORDER_TYPE } from "./contract"; + +const orderSelectionsSchema = z.object({ + accessMode: z.enum(["IPoE-BYOR", "IPoE-HGW", "PPPoE"]).optional(), + activationType: z.enum(["Immediate", "Scheduled"]).optional(), + scheduledAt: z.string().optional(), + simType: z.enum(["eSIM", "Physical SIM"]).optional(), + eid: z.string().optional(), + isMnp: z.string().optional(), + mnpNumber: z.string().optional(), + mnpExpiry: z.string().optional(), + mnpPhone: z.string().optional(), + mvnoAccountNumber: z.string().optional(), + portingLastName: z.string().optional(), + portingFirstName: z.string().optional(), + portingLastNameKatakana: z.string().optional(), + portingFirstNameKatakana: z.string().optional(), + portingGender: z.enum(["Male", "Female", "Corporate/Other"]).optional(), + portingDateOfBirth: z.string().optional(), + address: z + .object({ + street: z.string().optional(), + streetLine2: z.string().optional(), + city: z.string().optional(), + state: z.string().optional(), + postalCode: z.string().optional(), + country: z.string().optional(), + }) + .optional(), +}); + +export type OrderSelections = z.infer; + +export function buildOrderConfigurations(selections: OrderSelections): OrderConfigurations { + return orderConfigurationsSchema.parse({ + ...selections, + address: selections.address + ? { + street: selections.address.street ?? null, + streetLine2: selections.address.streetLine2 ?? null, + city: selections.address.city ?? null, + state: selections.address.state ?? null, + postalCode: selections.address.postalCode ?? null, + country: selections.address.country ?? null, + } + : undefined, + }); +} + +export function createOrderRequest(payload: { + orderType?: string; + skus: string[]; + configurations?: OrderSelections | null; +}): CreateOrderRequest { + const orderType = (payload.orderType ?? ORDER_TYPE.OTHER) as CreateOrderRequest["orderType"]; + const configurations = payload.configurations + ? buildOrderConfigurations(payload.configurations) + : undefined; + return { + orderType, + skus: payload.skus, + ...(configurations ? { configurations } : {}), + }; +} + + diff --git a/packages/domain/toolkit/formatting/currency.ts b/packages/domain/toolkit/formatting/currency.ts index a3a4a3e3..8d5b7fb1 100644 --- a/packages/domain/toolkit/formatting/currency.ts +++ b/packages/domain/toolkit/formatting/currency.ts @@ -8,29 +8,31 @@ export type SupportedCurrency = "JPY" | "USD" | "EUR"; /** - * Format a number as currency + * Format a number as currency using WHMCS default currency * * @param amount - The numeric amount to format - * @param currency - Currency code (defaults to "JPY") + * @param currencyCode - Optional currency code (defaults to WHMCS default) + * @param locale - Optional locale (defaults to currency-specific locale) * * @example - * formatCurrency(1000) // "¥1,000" (defaults to JPY) - * formatCurrency(1000, "JPY") // "¥1,000" - * formatCurrency(1000, invoice.currency) // Uses invoice currency + * formatCurrency(1000) // Uses WHMCS default currency + * formatCurrency(1000, "USD") // Uses specific currency + * formatCurrency(1000, "JPY", "ja-JP") // Uses specific currency and locale */ export function formatCurrency( - amount: number, - currency: string = "JPY" + amount: number, + currencyCode: string = "JPY", + locale?: string ): string { - // Determine locale from currency - const locale = getCurrencyLocale(currency as SupportedCurrency); + // Use provided locale or get from currency + const currencyLocale = locale || getCurrencyLocale(currencyCode as SupportedCurrency); - // JPY doesn't use decimal places, other currencies use 2 - const fractionDigits = currency === "JPY" ? 0 : 2; + // Determine fraction digits based on currency + const fractionDigits = currencyCode === "JPY" ? 0 : 2; - const formatter = new Intl.NumberFormat(locale, { + const formatter = new Intl.NumberFormat(currencyLocale, { style: "currency", - currency: currency, + currency: currencyCode, minimumFractionDigits: fractionDigits, maximumFractionDigits: fractionDigits, });