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.

This commit is contained in:
barsa 2025-10-20 13:53:35 +09:00
parent 75d199cb7f
commit 0233ff2dce
43 changed files with 795 additions and 456 deletions

View File

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

View File

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

View File

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

View File

@ -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();
}
}

View File

@ -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 {}

View File

@ -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(
{

View File

@ -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") {

View File

@ -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<string, unknown>;
export interface SimActionNotification {
action: string;
status: "SUCCESS" | "ERROR";
context: SimNotificationContext;
}
export interface SimValidationResult {
account: string;
}

View File

@ -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 {

View File

@ -0,0 +1,33 @@
/// <reference types="jest" />
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();
});
});

View File

@ -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/(.*)$": "<rootDir>/../src/$1"
},
"globals": {
"ts-jest": {
"tsconfig": {
"types": ["jest", "node"]
}
}
}
}

View File

@ -0,0 +1,8 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"types": ["jest", "node", "supertest"]
},
"include": ["./**/*.ts", "../src/**/*"]
}

View File

@ -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 (
<div className="bg-gradient-to-br from-slate-50 to-slate-100 rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
@ -38,7 +37,7 @@ export function InvoiceTotals({ subtotal, tax, total, currency }: InvoiceTotalsP
<span className="text-xl font-bold text-slate-900">Total Amount</span>
<div className="flex items-baseline gap-2">
<div className="text-3xl font-bold text-slate-900">{fmt(total)}</div>
<div className="text-lg font-medium text-slate-500">{currency.toUpperCase()}</div>
<div className="text-lg font-medium text-slate-500">JPY</div>
</div>
</div>
</div>

View File

@ -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 (
<div
key={id}
@ -47,7 +44,7 @@ export function InvoiceItemRow({
)}
</div>
<div className="text-lg font-bold text-gray-900 ml-4 flex-shrink-0">
{formatCurrency(amount, currency)}
{formatCurrency(amount)}
</div>
</div>
);

View File

@ -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<T> {
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<T>({
export function usePaymentRefresh({
refetch,
hasMethods,
attachFocusListeners = false,
}: UsePaymentRefreshOptions<T>) {
}: UsePaymentRefreshOptions) {
const [toast, setToast] = useState<{ visible: boolean; text: string; tone: Tone }>({
visible: false,
text: "",
@ -35,7 +33,9 @@ export function usePaymentRefresh<T>({
// 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",

View File

@ -188,7 +188,6 @@ export function InvoiceDetailContainer() {
subtotal={invoice.subtotal}
tax={invoice.tax}
total={invoice.total}
currency={invoice.currency}
/>
</div>
</div>

View File

@ -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" && (
<div className="text-right">
<div className="text-2xl font-bold text-blue-600">
¥{formatPrice(totals.monthlyTotal)}/mo
¥{formatCurrency(totals.monthlyTotal)}/mo
</div>
{totals.oneTimeTotal > 0 && (
<div className="text-sm text-orange-600 font-medium">
+ ¥{formatPrice(totals.oneTimeTotal)} one-time
+ ¥{formatCurrency(totals.oneTimeTotal)} one-time
</div>
)}
</div>
@ -208,7 +209,7 @@ export function EnhancedOrderSummary({
)}
</div>
<span className="font-medium text-gray-900 ml-4">
¥{formatPrice(Number(item.monthlyPrice || item.unitPrice || 0))}
¥{formatCurrency(Number(item.monthlyPrice || item.unitPrice || 0))}
</span>
</div>
))}
@ -230,7 +231,7 @@ export function EnhancedOrderSummary({
)}
</div>
<span className="font-medium text-orange-600 ml-4">
¥{formatPrice(Number(item.oneTimePrice || item.unitPrice || 0))}
¥{formatCurrency(Number(item.oneTimePrice || item.unitPrice || 0))}
</span>
</div>
))}
@ -244,7 +245,7 @@ export function EnhancedOrderSummary({
<div className="flex justify-between text-sm">
<span className="text-green-700">Discount Applied</span>
<span className="font-medium text-green-600">
-¥{formatPrice(totals.discountAmount)}
-¥{formatCurrency(totals.discountAmount)}
</span>
</div>
</div>
@ -255,7 +256,7 @@ export function EnhancedOrderSummary({
<div className="mb-4">
<div className="flex justify-between text-sm">
<span className="text-gray-700">Tax (10%)</span>
<span className="font-medium text-gray-900">¥{formatPrice(totals.taxAmount)}</span>
<span className="font-medium text-gray-900">¥{formatCurrency(totals.taxAmount)}</span>
</div>
</div>
)}
@ -270,7 +271,7 @@ export function EnhancedOrderSummary({
<span className="text-gray-700">{String(item.name)}</span>
<span className="font-medium">
¥
{formatPrice(
{formatCurrency(
Number(
item.billingCycle === "Monthly"
? item.monthlyPrice || item.unitPrice || 0
@ -289,20 +290,20 @@ export function EnhancedOrderSummary({
<div className="space-y-2">
<div className="flex justify-between text-lg font-semibold">
<span className="text-gray-900">Monthly Total:</span>
<span className="text-blue-600">¥{formatPrice(totals.monthlyTotal)}</span>
<span className="text-blue-600">{formatCurrency(totals.monthlyTotal)}</span>
</div>
{totals.oneTimeTotal > 0 && (
<div className="flex justify-between text-lg font-semibold">
<span className="text-gray-900">One-time Total:</span>
<span className="text-orange-600">¥{formatPrice(totals.oneTimeTotal)}</span>
<span className="text-orange-600">{formatCurrency(totals.oneTimeTotal)}</span>
</div>
)}
{totals.annualTotal && (
<div className="flex justify-between text-sm text-gray-600">
<span>Annual Total:</span>
<span>¥{formatPrice(totals.annualTotal)}</span>
<span>{formatCurrency(totals.annualTotal)}</span>
</div>
)}
</div>

View File

@ -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({
<div className="flex items-baseline justify-center gap-1">
{getCurrencyIcon()}
<span className={`font-bold text-gray-900 ${sizeClasses[size].price}`}>
{formatPrice(tier.price)}
{formatCurrency(tier.price)}
</span>
<span className={`text-gray-500 ${sizeClasses[size].label}`}>
/{tier.billingCycle.toLowerCase()}
@ -167,17 +167,17 @@ export function PricingDisplay({
<div className="flex items-baseline gap-1">
{getCurrencyIcon()}
<span className={`font-bold text-gray-900 ${sizeClasses[size].price}`}>
{formatPrice(monthlyPrice)}
{formatCurrency(monthlyPrice)}
</span>
<span className={`text-gray-500 ${sizeClasses[size].label}`}>/month</span>
</div>
{originalMonthlyPrice && originalMonthlyPrice > monthlyPrice && (
<div className="flex items-baseline gap-1 mt-1">
<span className="text-gray-400 line-through text-sm">
¥{formatPrice(originalMonthlyPrice)}
¥{formatCurrency(originalMonthlyPrice)}
</span>
<span className="text-green-600 text-sm font-medium">
Save ¥{formatPrice(originalMonthlyPrice - monthlyPrice)}/month
Save ¥{formatCurrency(originalMonthlyPrice - monthlyPrice)}/month
</span>
</div>
)}
@ -190,17 +190,17 @@ export function PricingDisplay({
<div className="flex items-baseline gap-1">
{getCurrencyIcon()}
<span className={`font-bold text-orange-600 ${sizeClasses[size].price}`}>
{formatPrice(oneTimePrice)}
{formatCurrency(oneTimePrice)}
</span>
<span className={`text-orange-500 ${sizeClasses[size].label}`}>one-time</span>
</div>
{originalOneTimePrice && originalOneTimePrice > oneTimePrice && (
<div className="flex items-baseline gap-1 mt-1">
<span className="text-gray-400 line-through text-sm">
¥{formatPrice(originalOneTimePrice)}
¥{formatCurrency(originalOneTimePrice)}
</span>
<span className="text-green-600 text-sm font-medium">
Save ¥{formatPrice(originalOneTimePrice - oneTimePrice)}
Save ¥{formatCurrency(originalOneTimePrice - oneTimePrice)}
</span>
</div>
)}
@ -232,7 +232,7 @@ export function PricingDisplay({
<div className="flex items-baseline gap-1 mb-2">
{getCurrencyIcon()}
<span className={`font-bold text-gray-900 ${sizeClasses[size].price}`}>
{formatPrice(monthlyPrice)}
{formatCurrency(monthlyPrice)}
</span>
<span className={`text-gray-500 ${sizeClasses[size].label}`}>/month</span>
</div>
@ -242,7 +242,7 @@ export function PricingDisplay({
<div className="flex items-baseline gap-1">
{getCurrencyIcon()}
<span className={`font-bold text-orange-600 ${sizeClasses[size].price}`}>
{formatPrice(oneTimePrice)}
{formatCurrency(oneTimePrice)}
</span>
<span className={`text-orange-500 ${sizeClasses[size].label}`}>one-time</span>
</div>

View File

@ -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<typeof defaultInternetCatalog>(
"/api/catalog/internet/plans"
);
const data = getDataOrThrow<typeof defaultInternetCatalog>(
async getInternetCatalog(): Promise<InternetCatalogCollection> {
const response = await apiClient.GET<InternetCatalogCollection>("/api/catalog/internet/plans");
const data = getDataOrThrow<InternetCatalogCollection>(
response,
"Failed to load internet catalog"
);
return internetCatalogSchema.parse(data);
return parseInternetCatalog(data);
},
async getInternetInstallations(): Promise<InternetInstallationCatalogItem[]> {
const response = await apiClient.GET<InternetInstallationCatalogItem[]>(
"/api/catalog/internet/installations"
);
const data = getDataOrDefault<InternetInstallationCatalogItem[]>(
response,
emptyInternetInstallations
);
return internetInstallationsSchema.parse(data);
const response = await apiClient.GET<InternetInstallationCatalogItem[]>("/api/catalog/internet/installations");
const data = getDataOrDefault<InternetInstallationCatalogItem[]>(response, []);
return internetInstallationCatalogItemSchema.array().parse(data);
},
async getInternetAddons(): Promise<InternetAddonCatalogItem[]> {
const response = await apiClient.GET<InternetAddonCatalogItem[]>(
"/api/catalog/internet/addons"
);
const data = getDataOrDefault<InternetAddonCatalogItem[]>(response, emptyInternetAddons);
return internetAddonsSchema.parse(data);
const response = await apiClient.GET<InternetAddonCatalogItem[]>("/api/catalog/internet/addons");
const data = getDataOrDefault<InternetAddonCatalogItem[]>(response, []);
return internetAddonCatalogItemSchema.array().parse(data);
},
async getSimCatalog(): Promise<{
plans: SimCatalogProduct[];
activationFees: SimActivationFeeCatalogItem[];
addons: SimCatalogProduct[];
}> {
const response = await apiClient.GET<typeof defaultSimCatalog>("/api/catalog/sim/plans");
const data = getDataOrDefault<typeof defaultSimCatalog>(response, defaultSimCatalog);
return simCatalogSchema.parse(data);
async getSimCatalog(): Promise<SimCatalogCollection> {
const response = await apiClient.GET<SimCatalogCollection>("/api/catalog/sim/plans");
const data = getDataOrDefault<SimCatalogCollection>(response, EMPTY_SIM_CATALOG);
return parseSimCatalog(data);
},
async getSimActivationFees(): Promise<SimActivationFeeCatalogItem[]> {
const response = await apiClient.GET<SimActivationFeeCatalogItem[]>(
"/api/catalog/sim/activation-fees"
);
const data = getDataOrDefault<SimActivationFeeCatalogItem[]>(response, emptySimActivationFees);
return simActivationFeesSchema.parse(data);
const response = await apiClient.GET<SimActivationFeeCatalogItem[]>("/api/catalog/sim/activation-fees");
const data = getDataOrDefault<SimActivationFeeCatalogItem[]>(response, []);
return simActivationFeeCatalogItemSchema.array().parse(data);
},
async getSimAddons(): Promise<SimCatalogProduct[]> {
const response = await apiClient.GET<SimCatalogProduct[]>("/api/catalog/sim/addons");
const data = getDataOrDefault<SimCatalogProduct[]>(response, emptySimAddons);
return simAddonsSchema.parse(data);
const data = getDataOrDefault<SimCatalogProduct[]>(response, []);
return simCatalogProductSchema.array().parse(data);
},
async getVpnCatalog(): Promise<{
plans: VpnCatalogProduct[];
activationFees: VpnCatalogProduct[];
}> {
const response = await apiClient.GET<typeof defaultVpnCatalog>("/api/catalog/vpn/plans");
const data = getDataOrDefault<typeof defaultVpnCatalog>(response, defaultVpnCatalog);
return vpnCatalogSchema.parse(data);
async getVpnCatalog(): Promise<VpnCatalogCollection> {
const response = await apiClient.GET<VpnCatalogCollection>("/api/catalog/vpn/plans");
const data = getDataOrDefault<VpnCatalogCollection>(response, EMPTY_VPN_CATALOG);
return parseVpnCatalog(data);
},
async getVpnActivationFees(): Promise<VpnCatalogProduct[]> {
const response = await apiClient.GET<VpnCatalogProduct[]>("/api/catalog/vpn/activation-fees");
const data = getDataOrDefault<VpnCatalogProduct[]>(response, emptyVpnPlans);
return vpnActivationFeesSchema.parse(data);
const data = getDataOrDefault<VpnCatalogProduct[]>(response, []);
return vpnCatalogProductSchema.array().parse(data);
},
};

View File

@ -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.

View File

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

View File

@ -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<string, unknown>;
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<string, unknown> = {};
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,

View File

@ -33,7 +33,7 @@ export function UpcomingPaymentBanner({ invoice, onPay, loading }: UpcomingPayme
</span>
</div>
<div className="mt-1 text-2xl font-bold text-gray-900">
{formatCurrency(invoice.amount, invoice.currency || "JPY")}
{formatCurrency(invoice.amount)}
</div>
<div className="mt-1 text-xs text-gray-500">
Exact due date: {format(new Date(invoice.dueDate), "MMMM d, yyyy")}

View File

@ -194,7 +194,7 @@ export function DashboardView() {
</span>
</div>
<div className="mt-1 text-2xl font-bold text-gray-900">
{formatCurrency(upcomingInvoice.amount, upcomingInvoice.currency || "JPY")}
{formatCurrency(upcomingInvoice.amount)}
</div>
<div className="mt-1 text-xs text-gray-500">
Exact due date: {format(new Date(upcomingInvoice.dueDate), "MMMM d, yyyy")}

View File

@ -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<OrderSummary[]> {
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<OrderDetails> {
@ -21,7 +38,7 @@ async function getOrderById(orderId: string): Promise<OrderDetails> {
if (!response.data) {
throw new Error("Order not found");
}
return response.data as OrderDetails;
return orderDetailsSchema.parse(response.data);
}
export const ordersService = {

View File

@ -111,7 +111,7 @@ export const SubscriptionCard = forwardRef<HTMLDivElement, SubscriptionCardProps
<div>
<p className="text-gray-500">Price</p>
<p className="font-semibold text-gray-900">
{formatCurrency(subscription.amount, subscription.currency)}
{formatCurrency(subscription.amount)}
</p>
<p className="text-xs text-gray-500">{getBillingCycleLabel(subscription.cycle)}</p>
</div>
@ -171,7 +171,7 @@ export const SubscriptionCard = forwardRef<HTMLDivElement, SubscriptionCardProps
<div className="flex items-center space-x-6 text-sm">
<div className="text-right">
<p className="font-semibold text-gray-900">
{formatCurrency(subscription.amount, "JPY")}
{formatCurrency(subscription.amount)}
</p>
<p className="text-gray-500">{getBillingCycleLabel(subscription.cycle)}</p>
</div>

View File

@ -135,7 +135,7 @@ export const SubscriptionDetails = forwardRef<HTMLDivElement, SubscriptionDetail
</h4>
</div>
<p className="text-2xl font-bold text-gray-900">
{formatCurrency(subscription.amount, "JPY")}
{formatCurrency(subscription.amount)}
</p>
<p className="text-sm text-gray-500">{formatBillingLabel(subscription.cycle)}</p>
</div>

View File

@ -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) {

View File

@ -135,7 +135,7 @@ export function SubscriptionsListContainer() {
render: (s: Subscription) => (
<div>
<span className="text-sm font-medium text-gray-900">
{formatCurrency(s.amount, s.currency)}
{formatCurrency(s.amount)}
</span>
<div className="text-xs text-gray-500">
{s.cycle === "Monthly"

View File

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

View File

@ -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<T>(response: ApiResponse<T>): boolean {
return response.data !== undefined && !response.error;
}
export type DomainApiResponse<T> = ApiSuccessResponse<T> | ApiErrorResponse;
export function assertSuccess<T>(response: DomainApiResponse<T>): ApiSuccessResponse<T> {
if (response.success === true) {
return response;
}
throw new Error(response.error.message);
}
export function parseDomainResponse<T>(response: DomainApiResponse<T>, 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<T>(data: T, schema: (value: T) => T): ApiSuccessResponse<T> {
return apiSuccessResponseSchema({ parse: () => schema(data) } as any).parse({ success: true, data });
}

View File

@ -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<CurrencyInfo | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 || "¥",
};
}

View File

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

View File

@ -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<CurrencyInfo>;
getAllCurrencies(): Promise<CurrencyInfo[]>;
}
class CurrencyServiceImpl implements CurrencyService {
async getDefaultCurrency(): Promise<CurrencyInfo> {
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<CurrencyInfo[]> {
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();

View File

@ -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<string, unknown>;
};
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;
}
/**
@ -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),
};

View File

@ -42,3 +42,6 @@ export type {
WhmcsCatalogProduct,
WhmcsCatalogProductListResponse,
} from "./providers/whmcs/raw.types";
// Utilities
export * from "./utils";

View File

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

View File

@ -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<typeof internetPlanTemplateSchema>;
export type InternetPlanCatalogItem = z.infer<typeof internetPlanCatalogItemSchema>;
export type InternetInstallationCatalogItem = z.infer<typeof internetInstallationCatalogItemSchema>;
export type InternetAddonCatalogItem = z.infer<typeof internetAddonCatalogItemSchema>;
export type InternetCatalogCollection = z.infer<typeof internetCatalogCollectionSchema>;
// SIM products
export type SimCatalogProduct = z.infer<typeof simCatalogProductSchema>;
export type SimActivationFeeCatalogItem = z.infer<typeof simActivationFeeCatalogItemSchema>;
export type SimCatalogCollection = z.infer<typeof simCatalogCollectionSchema>;
// VPN products
export type VpnCatalogProduct = z.infer<typeof vpnCatalogProductSchema>;
export type VpnCatalogCollection = z.infer<typeof vpnCatalogCollectionSchema>;
// Union type for all catalog products
export type CatalogProduct =

View File

@ -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<string, InternetPlanTemplate> = {
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();

View File

@ -339,7 +339,7 @@ export type UserRole = "USER" | "ADMIN";
export type Address = z.infer<typeof addressSchema>;
export type AddressFormData = z.infer<typeof addressFormSchema>;
export type ProfileEditFormData = z.infer<typeof profileEditFormSchema>;
export type ProfileDisplayData = ProfileEditFormData; // Alias for display purposes
export type ProfileDisplayData = z.infer<typeof profileDisplayDataSchema>;
// Convenience aliases
export type UserProfile = User; // Alias for user profile

View File

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

View File

@ -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<typeof orderSelectionsSchema>;
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 } : {}),
};
}

View File

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