From 0233ff2dcedbd866f73c7e18d158257d47f8a5e1 Mon Sep 17 00:00:00 2001 From: barsa Date: Mon, 20 Oct 2025 13:53:35 +0900 Subject: [PATCH] Enhance catalog and order handling by integrating new currency formatting and validation utilities. Updated various components to utilize the centralized currency formatting function, ensuring consistent display across the application. Refactored order creation and validation processes to improve clarity and maintainability, aligning with the updated domain structure. --- apps/bff/src/app.module.ts | 2 + .../src/modules/catalog/catalog.controller.ts | 22 +-- .../services/internet-catalog.service.ts | 102 +++-------- .../modules/currency/currency.controller.ts | 24 +++ .../src/modules/currency/currency.module.ts | 11 ++ .../src/modules/orders/orders.controller.ts | 13 +- .../services/order-validator.service.ts | 36 ---- .../interfaces/sim-base.interface.ts | 16 +- .../services/sim-validation.service.ts | 6 +- apps/bff/test/catalog-contract.spec.ts | 33 ++++ apps/bff/test/jest-e2e.json | 27 +++ apps/bff/test/tsconfig.json | 8 + .../InvoiceDetail/InvoiceTotals.tsx | 7 +- .../billing/components/InvoiceItemRow.tsx | 9 +- .../billing/hooks/usePaymentRefresh.ts | 16 +- .../features/billing/views/InvoiceDetail.tsx | 1 - .../components/base/EnhancedOrderSummary.tsx | 25 +-- .../components/base/PricingDisplay.tsx | 24 +-- .../catalog/services/catalog.service.ts | 149 +++++------------ .../features/catalog/utils/catalog.utils.ts | 7 - .../src/features/catalog/utils/pricing.ts | 33 ++++ .../features/checkout/hooks/useCheckout.ts | 84 ++++++---- .../components/UpcomingPaymentBanner.tsx | 2 +- .../dashboard/views/DashboardView.tsx | 2 +- .../orders/services/orders.service.ts | 33 +++- .../components/SubscriptionCard.tsx | 4 +- .../components/SubscriptionDetails.tsx | 2 +- .../views/SubscriptionDetail.tsx | 2 +- .../subscriptions/views/SubscriptionsList.tsx | 2 +- apps/portal/src/lib/api/index.ts | 2 +- apps/portal/src/lib/api/response-helpers.ts | 30 ++++ apps/portal/src/lib/hooks/useCurrency.ts | 43 +++++ .../portal/src/lib/hooks/useFormatCurrency.ts | 30 ++++ .../src/lib/services/currency.service.ts | 34 ++++ apps/portal/src/lib/utils/error-handling.ts | 31 +--- packages/domain/catalog/index.ts | 3 + .../catalog/providers/salesforce/mapper.ts | 85 ++-------- packages/domain/catalog/schema.ts | 27 +++ packages/domain/catalog/utils.ts | 158 ++++++++++++++++++ packages/domain/customer/schema.ts | 2 +- packages/domain/orders/index.ts | 3 + packages/domain/orders/utils.ts | 73 ++++++++ .../domain/toolkit/formatting/currency.ts | 28 ++-- 43 files changed, 795 insertions(+), 456 deletions(-) create mode 100644 apps/bff/src/modules/currency/currency.controller.ts create mode 100644 apps/bff/src/modules/currency/currency.module.ts create mode 100644 apps/bff/test/catalog-contract.spec.ts create mode 100644 apps/bff/test/jest-e2e.json create mode 100644 apps/bff/test/tsconfig.json create mode 100644 apps/portal/src/features/catalog/utils/pricing.ts create mode 100644 apps/portal/src/lib/hooks/useCurrency.ts create mode 100644 apps/portal/src/lib/hooks/useFormatCurrency.ts create mode 100644 apps/portal/src/lib/services/currency.service.ts create mode 100644 packages/domain/catalog/utils.ts create mode 100644 packages/domain/orders/utils.ts 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, });