Refactor order and subscription services to enhance validation and type safety. Updated order validation to use new SKU validation schema, improved SIM reissue profile handling by integrating a structured request object, and refined subscription fetching logic to utilize updated schemas. Additionally, enhanced error handling and logging for better traceability in service operations.

This commit is contained in:
barsa 2025-10-21 11:44:06 +09:00
parent 7ffd2d562f
commit 939922a40e
31 changed files with 868 additions and 586 deletions

View File

@ -6,7 +6,7 @@ import { WhmcsConnectionOrchestratorService } from "@bff/integrations/whmcs/conn
import { getErrorMessage } from "@bff/core/utils/error.util";
import {
createOrderRequestSchema,
orderBusinessValidationSchema,
orderWithSkuValidationSchema,
type CreateOrderRequest,
type OrderBusinessValidation,
} from "@customer-portal/domain/orders";
@ -176,10 +176,11 @@ export class OrderValidator {
// 1b. Business validation (ensures userId-specific constraints)
let businessValidatedBody: OrderBusinessValidation;
try {
businessValidatedBody = orderBusinessValidationSchema.parse({
const skuValidatedBody = orderWithSkuValidationSchema.parse({
...validatedBody,
userId,
});
businessValidatedBody = skuValidatedBody;
} catch (error) {
if (error instanceof ZodError) {
const issues = error.issues.map(issue => {

View File

@ -8,6 +8,7 @@ import type {
SimCancelRequest,
SimTopUpHistoryRequest,
SimFeaturesUpdateRequest,
SimReissueRequest,
} from "@customer-portal/domain/sim";
import type { SimNotificationContext } from "./sim-management/interfaces/sim-base.interface";
@ -108,8 +109,12 @@ export class SimManagementService {
/**
* Reissue eSIM profile
*/
async reissueEsimProfile(userId: string, subscriptionId: number, newEid?: string): Promise<void> {
return this.simOrchestrator.reissueEsimProfile(userId, subscriptionId, newEid);
async reissueEsimProfile(
userId: string,
subscriptionId: number,
request: SimReissueRequest
): Promise<void> {
return this.simOrchestrator.reissueEsimProfile(userId, subscriptionId, request);
}
/**

View File

@ -4,6 +4,7 @@ import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/f
import { SimValidationService } from "./sim-validation.service";
import { SimNotificationService } from "./sim-notification.service";
import { getErrorMessage } from "@bff/core/utils/error.util";
import type { SimReissueRequest } from "@customer-portal/domain/sim";
@Injectable()
export class EsimManagementService {
@ -17,7 +18,11 @@ export class EsimManagementService {
/**
* Reissue eSIM profile
*/
async reissueEsimProfile(userId: string, subscriptionId: number, newEid?: string): Promise<void> {
async reissueEsimProfile(
userId: string,
subscriptionId: number,
request: SimReissueRequest
): Promise<void> {
try {
const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId);
@ -27,10 +32,9 @@ export class EsimManagementService {
throw new BadRequestException("This operation is only available for eSIM subscriptions");
}
const newEid = request.newEid;
if (newEid) {
if (!/^\d{32}$/.test(newEid)) {
throw new BadRequestException("Invalid EID format. Expected 32 digits.");
}
await this.freebitService.reissueEsimProfileEnhanced(account, newEid, {
oldEid: simDetails.eid,
planCode: simDetails.planCode,
@ -60,12 +64,12 @@ export class EsimManagementService {
error: sanitizedError,
userId,
subscriptionId,
newEid: newEid || undefined,
newEid: request.newEid || undefined,
});
await this.simNotification.notifySimAction("Reissue eSIM", "ERROR", {
userId,
subscriptionId,
newEid: newEid || undefined,
newEid: request.newEid || undefined,
error: sanitizedError,
});
throw error;

View File

@ -15,6 +15,7 @@ import type {
SimCancelRequest,
SimTopUpHistoryRequest,
SimFeaturesUpdateRequest,
SimReissueRequest,
} from "@customer-portal/domain/sim";
@Injectable()
@ -98,8 +99,12 @@ export class SimOrchestratorService {
/**
* Reissue eSIM profile
*/
async reissueEsimProfile(userId: string, subscriptionId: number, newEid?: string): Promise<void> {
return this.esimManagement.reissueEsimProfile(userId, subscriptionId, newEid);
async reissueEsimProfile(
userId: string,
subscriptionId: number,
request: SimReissueRequest
): Promise<void> {
return this.esimManagement.reissueEsimProfile(userId, subscriptionId, request);
}
/**

View File

@ -23,6 +23,9 @@ export class SimPlanService {
subscriptionId: number,
request: SimPlanChangeRequest
): Promise<{ ipv4?: string; ipv6?: string }> {
const assignGlobalIp = request.assignGlobalIp ?? false;
let scheduledAt: string | undefined;
try {
const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId);
@ -31,27 +34,35 @@ export class SimPlanService {
throw new BadRequestException("Invalid plan code");
}
// Automatically set to 1st of next month
scheduledAt = request.scheduledAt;
if (scheduledAt) {
if (!/^\d{8}$/.test(scheduledAt)) {
throw new BadRequestException("scheduledAt must be in YYYYMMDD format");
}
} else {
// Default to the 1st of the next month if no schedule provided.
const nextMonth = new Date();
nextMonth.setMonth(nextMonth.getMonth() + 1);
nextMonth.setDate(1); // Set to 1st of the month
// Format as YYYYMMDD for Freebit API
nextMonth.setDate(1);
const year = nextMonth.getFullYear();
const month = String(nextMonth.getMonth() + 1).padStart(2, "0");
const day = String(nextMonth.getDate()).padStart(2, "0");
const scheduledAt = `${year}${month}${day}`;
scheduledAt = `${year}${month}${day}`;
}
this.logger.log(`Auto-scheduled plan change to 1st of next month: ${scheduledAt}`, {
this.logger.log("Submitting SIM plan change request", {
userId,
subscriptionId,
account,
newPlanCode: request.newPlanCode,
scheduledAt,
assignGlobalIp,
scheduleOrigin: request.scheduledAt ? "user-provided" : "auto-default",
});
const result = await this.freebitService.changeSimPlan(account, request.newPlanCode, {
assignGlobalIp: false, // Default to no global IP
scheduledAt: scheduledAt,
assignGlobalIp,
scheduledAt: scheduledAt!,
});
this.logger.log(`Successfully changed SIM plan for subscription ${subscriptionId}`, {
@ -59,8 +70,8 @@ export class SimPlanService {
subscriptionId,
account,
newPlanCode: request.newPlanCode,
scheduledAt: scheduledAt,
assignGlobalIp: false,
scheduledAt,
assignGlobalIp,
});
await this.simNotification.notifySimAction("Change Plan", "SUCCESS", {
@ -69,6 +80,7 @@ export class SimPlanService {
account,
newPlanCode: request.newPlanCode,
scheduledAt,
assignGlobalIp,
});
return result;
@ -79,11 +91,15 @@ export class SimPlanService {
userId,
subscriptionId,
newPlanCode: request.newPlanCode,
assignGlobalIp,
scheduledAt,
});
await this.simNotification.notifySimAction("Change Plan", "ERROR", {
userId,
subscriptionId,
newPlanCode: request.newPlanCode,
assignGlobalIp,
scheduledAt,
error: sanitizedError,
});
throw error;

View File

@ -32,10 +32,12 @@ import {
simChangePlanRequestSchema,
simCancelRequestSchema,
simFeaturesRequestSchema,
simReissueRequestSchema,
type SimTopupRequest,
type SimChangePlanRequest,
type SimCancelRequest,
type SimFeaturesRequest,
type SimReissueRequest,
} from "@customer-portal/domain/sim";
import { ZodValidationPipe } from "@bff/core/validation";
import type { RequestWithUser } from "@bff/modules/auth/auth.types";
@ -52,12 +54,9 @@ export class SubscriptionsController {
async getSubscriptions(
@Request() req: RequestWithUser,
@Query() query: SubscriptionQuery
): Promise<SubscriptionList | Subscription[]> {
if (query.status) {
return this.subscriptionsService.getSubscriptionsByStatus(req.user.id, query.status);
}
return this.subscriptionsService.getSubscriptions(req.user.id);
): Promise<SubscriptionList> {
const { status } = query;
return this.subscriptionsService.getSubscriptions(req.user.id, { status });
}
@Get("active")
@ -185,12 +184,13 @@ export class SubscriptionsController {
}
@Post(":id/sim/reissue-esim")
@UsePipes(new ZodValidationPipe(simReissueRequestSchema))
async reissueEsimProfile(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number,
@Body() body: { newEid?: string } = {}
@Body() body: SimReissueRequest
): Promise<SimActionResponse> {
await this.simManagementService.reissueEsimProfile(req.user.id, subscriptionId, body.newEid);
await this.simManagementService.reissueEsimProfile(req.user.id, subscriptionId, body);
return { success: true, message: "eSIM profile reissue completed successfully" };
}

View File

@ -1,18 +1,22 @@
import { getErrorMessage } from "@bff/core/utils/error.util";
import { Injectable, NotFoundException, Inject } from "@nestjs/common";
import { Subscription, SubscriptionList } from "@customer-portal/domain/subscriptions";
import {
Subscription,
SubscriptionList,
subscriptionListSchema,
subscriptionStatusSchema,
type SubscriptionStatus,
} from "@customer-portal/domain/subscriptions";
import type { Invoice, InvoiceItem, InvoiceList } from "@customer-portal/domain/billing";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { Logger } from "nestjs-pino";
import { z } from "zod";
import { subscriptionSchema } from "@customer-portal/domain/subscriptions";
import type { Providers } from "@customer-portal/domain/subscriptions";
type WhmcsProduct = Providers.WhmcsRaw.WhmcsProductRaw;
export interface GetSubscriptionsOptions {
status?: string;
status?: SubscriptionStatus;
}
@Injectable()
@ -44,25 +48,18 @@ export class SubscriptionsService {
{ status }
);
const parsed = z
.object({
subscriptions: z.array(subscriptionSchema),
totalCount: z.number(),
})
.safeParse(subscriptionList);
const parsed = subscriptionListSchema.parse(subscriptionList);
if (!parsed.success) {
throw new Error(parsed.error.message);
let subscriptions = parsed.subscriptions;
if (status) {
const normalizedStatus = subscriptionStatusSchema.parse(status);
subscriptions = subscriptions.filter(sub => sub.status === normalizedStatus);
}
const filtered = status
? parsed.data.subscriptions.filter(sub => sub.status.toLowerCase() === status.toLowerCase())
: parsed.data.subscriptions;
return {
subscriptions: filtered,
totalCount: filtered.length,
} satisfies SubscriptionList;
return subscriptionListSchema.parse({
subscriptions,
totalCount: subscriptions.length,
});
} catch (error) {
this.logger.error(`Failed to get subscriptions for user ${userId}`, {
error: getErrorMessage(error),
@ -141,22 +138,10 @@ export class SubscriptionsService {
/**
* Get subscriptions by status
*/
async getSubscriptionsByStatus(userId: string, status: string): Promise<Subscription[]> {
async getSubscriptionsByStatus(userId: string, status: SubscriptionStatus): Promise<Subscription[]> {
try {
// Validate status
const validStatuses = [
"Active",
"Suspended",
"Terminated",
"Cancelled",
"Pending",
"Completed",
];
if (!validStatuses.includes(status)) {
throw new Error(`Invalid status. Must be one of: ${validStatuses.join(", ")}`);
}
const subscriptionList = await this.getSubscriptions(userId, { status });
const normalizedStatus = subscriptionStatusSchema.parse(status);
const subscriptionList = await this.getSubscriptions(userId, { status: normalizedStatus });
return subscriptionList.subscriptions;
} catch (error) {
this.logger.error(`Failed to get ${status} subscriptions for user ${userId}`, {

View File

@ -11,11 +11,14 @@ interface UsePaymentRefreshOptions {
refetch: () => Promise<{ data: PaymentMethodList | undefined }>;
// When true, attaches focus/visibility listeners to refresh automatically
attachFocusListeners?: boolean;
// Optional custom detector for whether payment methods exist
hasMethods?: (data?: PaymentMethodList | undefined) => boolean;
}
export function usePaymentRefresh({
refetch,
attachFocusListeners = false,
hasMethods,
}: UsePaymentRefreshOptions) {
const [toast, setToast] = useState<{ visible: boolean; text: string; tone: Tone }>({
visible: false,
@ -35,7 +38,10 @@ export function usePaymentRefresh({
const result = await refetch();
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;
const has =
typeof hasMethods === "function"
? hasMethods(parsed.success ? parsed.data : undefined)
: list.totalCount > 0 || list.paymentMethods.length > 0;
setToast({
visible: true,
text: has ? "Payment methods updated" : "No payment method found yet",

View File

@ -42,7 +42,8 @@ export function PaymentMethodsContainer() {
const result = await paymentMethodsQuery.refetch();
return { data: result.data };
},
hasMethods: (data?: { totalCount?: number }) => !!data && (data.totalCount || 0) > 0,
hasMethods: data =>
Boolean(data && (data.totalCount > 0 || data.paymentMethods.length > 0)),
attachFocusListeners: true,
});

View File

@ -185,7 +185,7 @@ export function OrderSummary({
<div key={index} className="flex justify-between">
<span className="text-gray-600">{String(fee.name)}</span>
<span className="font-medium">
¥{getOneTimePrice(fee).toLocaleString()} one-time
¥{(fee.oneTimePrice ?? fee.unitPrice ?? 0).toLocaleString()} one-time
</span>
</div>
))}

View File

@ -53,8 +53,17 @@ function getPriceLabel(installation: InternetInstallationCatalogItem): string {
if (!priceInfo) {
return "Price not available";
}
const suffix = priceInfo.billingCycle === "Monthly" ? "/month" : " one-time";
return `¥${priceInfo.amount.toLocaleString()}${suffix}`;
const billingCycle = installation.billingCycle?.toLowerCase();
if (billingCycle === "monthly" && priceInfo.monthly !== null) {
return `¥${priceInfo.monthly.toLocaleString()}/month`;
}
if (priceInfo.oneTime !== null) {
return `¥${priceInfo.oneTime.toLocaleString()} one-time`;
}
if (priceInfo.monthly !== null) {
return `¥${priceInfo.monthly.toLocaleString()}`;
}
return priceInfo.display || "Price not available";
}
export function InstallationOptions({

View File

@ -169,7 +169,3 @@ function calculateOneTimeTotal(
return total;
}
type InstallationTerm = NonNullable<
NonNullable<InternetInstallationCatalogItem["catalogMetadata"]>["installationTerm"]
>;

View File

@ -1,17 +1,5 @@
import React from "react";
interface MnpData {
reservationNumber: string;
expiryDate: string;
phoneNumber: string;
mvnoAccountNumber: string;
portingLastName: string;
portingFirstName: string;
portingLastNameKatakana: string;
portingFirstNameKatakana: string;
portingGender: "Male" | "Female" | "Corporate/Other" | "";
portingDateOfBirth: string;
}
import type { MnpData } from "@customer-portal/domain/sim";
interface MnpFormProps {
wantsMnp: boolean;
@ -77,7 +65,7 @@ export function MnpForm({
<input
type="text"
id="reservationNumber"
value={mnpData.reservationNumber}
value={mnpData.reservationNumber ?? ""}
onChange={e => handleInputChange("reservationNumber", e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="10-digit reservation number"
@ -95,7 +83,7 @@ export function MnpForm({
<input
type="date"
id="expiryDate"
value={mnpData.expiryDate}
value={mnpData.expiryDate ?? ""}
onChange={e => handleInputChange("expiryDate", e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
@ -112,7 +100,7 @@ export function MnpForm({
<input
type="tel"
id="phoneNumber"
value={mnpData.phoneNumber}
value={mnpData.phoneNumber ?? ""}
onChange={e => handleInputChange("phoneNumber", e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="090-1234-5678"
@ -133,7 +121,7 @@ export function MnpForm({
<input
type="text"
id="mvnoAccountNumber"
value={mnpData.mvnoAccountNumber}
value={mnpData.mvnoAccountNumber ?? ""}
onChange={e => handleInputChange("mvnoAccountNumber", e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Your current carrier account number"
@ -151,7 +139,7 @@ export function MnpForm({
<input
type="text"
id="portingLastName"
value={mnpData.portingLastName}
value={mnpData.portingLastName ?? ""}
onChange={e => handleInputChange("portingLastName", e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Tanaka"
@ -172,7 +160,7 @@ export function MnpForm({
<input
type="text"
id="portingFirstName"
value={mnpData.portingFirstName}
value={mnpData.portingFirstName ?? ""}
onChange={e => handleInputChange("portingFirstName", e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Taro"
@ -193,7 +181,7 @@ export function MnpForm({
<input
type="text"
id="portingLastNameKatakana"
value={mnpData.portingLastNameKatakana}
value={mnpData.portingLastNameKatakana ?? ""}
onChange={e => handleInputChange("portingLastNameKatakana", e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="タナカ"
@ -214,7 +202,7 @@ export function MnpForm({
<input
type="text"
id="portingFirstNameKatakana"
value={mnpData.portingFirstNameKatakana}
value={mnpData.portingFirstNameKatakana ?? ""}
onChange={e => handleInputChange("portingFirstNameKatakana", e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="タロウ"
@ -234,10 +222,8 @@ export function MnpForm({
</label>
<select
id="portingGender"
value={mnpData.portingGender}
onChange={e =>
handleInputChange("portingGender", e.target.value as MnpData["portingGender"])
}
value={mnpData.portingGender ?? ""}
onChange={e => handleInputChange("portingGender", e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">Select gender</option>
@ -261,7 +247,7 @@ export function MnpForm({
<input
type="date"
id="portingDateOfBirth"
value={mnpData.portingDateOfBirth}
value={mnpData.portingDateOfBirth ?? ""}
onChange={e => handleInputChange("portingDateOfBirth", e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>

View File

@ -31,24 +31,48 @@ export function useSimConfigureParams() {
activationTypeParam === "Immediate" || activationTypeParam === "Scheduled"
? activationTypeParam
: null;
const scheduledAt = params.get("scheduledAt");
const scheduledAt = params.get("scheduledAt") ?? params.get("scheduledDate");
const addonSkus = params.getAll("addonSku");
const isMnp = params.get("isMnp") === "true";
const isMnpParam = params.get("isMnp") ?? params.get("wantsMnp");
const isMnp = isMnpParam === "true";
// Optional detailed MNP fields if present
const mnp = {
reservationNumber: params.get("reservationNumber") || undefined,
expiryDate: params.get("expiryDate") || undefined,
phoneNumber: params.get("phoneNumber") || undefined,
mvnoAccountNumber: params.get("mvnoAccountNumber") || undefined,
portingLastName: params.get("portingLastName") || undefined,
portingFirstName: params.get("portingFirstName") || undefined,
portingLastNameKatakana: params.get("portingLastNameKatakana") || undefined,
portingFirstNameKatakana: params.get("portingFirstNameKatakana") || undefined,
portingGender:
(params.get("portingGender") as "Male" | "Female" | "Corporate/Other" | undefined) ||
reservationNumber:
params.get("mnpNumber") ??
params.get("reservationNumber") ??
params.get("mnp_reservationNumber") ??
undefined,
portingDateOfBirth: params.get("portingDateOfBirth") || undefined,
expiryDate:
params.get("mnpExpiry") ??
params.get("expiryDate") ??
params.get("mnp_expiryDate") ??
undefined,
phoneNumber:
params.get("mnpPhone") ??
params.get("phoneNumber") ??
params.get("mnp_phoneNumber") ??
undefined,
mvnoAccountNumber:
params.get("mvnoAccountNumber") ?? params.get("mnp_mvnoAccountNumber") ?? undefined,
portingLastName:
params.get("portingLastName") ?? params.get("mnp_portingLastName") ?? undefined,
portingFirstName:
params.get("portingFirstName") ?? params.get("mnp_portingFirstName") ?? undefined,
portingLastNameKatakana:
params.get("portingLastNameKatakana") ??
params.get("mnp_portingLastNameKatakana") ??
undefined,
portingFirstNameKatakana:
params.get("portingFirstNameKatakana") ??
params.get("mnp_portingFirstNameKatakana") ??
undefined,
portingGender:
(params.get("portingGender") as "Male" | "Female" | "Corporate/Other" | undefined) ??
(params.get("mnp_portingGender") as "Male" | "Female" | "Corporate/Other" | undefined) ??
undefined,
portingDateOfBirth:
params.get("portingDateOfBirth") ?? params.get("mnp_portingDateOfBirth") ?? undefined,
} as const;
return {

View File

@ -6,12 +6,12 @@ import { useSimCatalog, useSimPlan, useSimConfigureParams } from ".";
import { useZodForm } from "@customer-portal/validation";
import {
simConfigureFormSchema,
simConfigureFormToRequest,
type SimConfigureFormData,
type SimType,
type SimCardType,
type ActivationType,
type MnpData,
} from "@customer-portal/domain/catalog";
} from "@customer-portal/domain/sim";
import { buildSimOrderConfigurations } from "@customer-portal/domain/orders";
import type { SimCatalogProduct, SimActivationFeeCatalogItem } from "@customer-portal/domain/catalog";
export type UseSimConfigureResult = {
@ -31,8 +31,8 @@ export type UseSimConfigureResult = {
validate: () => boolean;
// Convenience getters for specific fields
simType: SimType;
setSimType: (value: SimType) => void;
simType: SimCardType;
setSimType: (value: SimCardType) => void;
eid: string;
setEid: (value: string) => void;
selectedAddons: string[];
@ -63,6 +63,7 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
const searchParams = useSearchParams();
const { data: simData, isLoading: simLoading } = useSimCatalog();
const { plan: selectedPlan } = useSimPlan(planId);
const configureParams = useSimConfigureParams();
// Step orchestration state
const [currentStep, setCurrentStep] = useState(0);
@ -93,13 +94,10 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
const { values, errors, setValue, validate } = useZodForm<SimConfigureFormData>({
schema: simConfigureFormSchema,
initialValues,
onSubmit: data => {
simConfigureFormToRequest(data);
},
});
// Convenience setters that update the Zod form
const setSimType = useCallback((value: SimType) => setValue("simType", value), [setValue]);
const setSimType = useCallback((value: SimCardType) => setValue("simType", value), [setValue]);
const setEid = useCallback((value: string) => setValue("eid", value), [setValue]);
const setSelectedAddons = useCallback(
(value: SimConfigureFormData["selectedAddons"]) => setValue("selectedAddons", value),
@ -124,16 +122,70 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
if (mounted) {
// Set initial values from URL params or defaults
const initialSimType = (searchParams.get("simType") as SimType) || "eSIM";
const initialSimType =
(configureParams.simType as SimCardType | null) ??
(searchParams.get("simType") as SimCardType) ??
"eSIM";
const initialActivationType =
(searchParams.get("activationType") as ActivationType) || "Immediate";
(configureParams.activationType as ActivationType | null) ??
(searchParams.get("activationType") as ActivationType) ??
"Immediate";
setSimType(initialSimType);
setEid(searchParams.get("eid") || "");
setSelectedAddons(searchParams.get("addons")?.split(",").filter(Boolean) || []);
setEid(configureParams.eid ?? searchParams.get("eid") ?? "");
const addonSkuSet = new Set<string>();
configureParams.addonSkus.forEach(sku => addonSkuSet.add(sku));
searchParams
.get("addons")
?.split(",")
.filter(Boolean)
.forEach(sku => addonSkuSet.add(sku));
setSelectedAddons(Array.from(addonSkuSet));
setActivationType(initialActivationType);
setScheduledActivationDate(searchParams.get("scheduledDate") || "");
setWantsMnp(searchParams.get("wantsMnp") === "true");
const scheduledAt =
configureParams.scheduledAt ??
searchParams.get("scheduledAt") ??
searchParams.get("scheduledDate") ??
"";
if (scheduledAt) {
setScheduledActivationDate(
scheduledAt.includes("-")
? scheduledAt
: `${scheduledAt.slice(0, 4)}-${scheduledAt.slice(4, 6)}-${scheduledAt.slice(6, 8)}`
);
}
const wantsMnp = configureParams.isMnp;
setWantsMnp(wantsMnp);
if (wantsMnp) {
const mnp = configureParams.mnp;
setMnpData({
reservationNumber: mnp.reservationNumber ?? "",
expiryDate: mnp.expiryDate ?? "",
phoneNumber: mnp.phoneNumber ?? "",
mvnoAccountNumber: mnp.mvnoAccountNumber ?? "",
portingLastName: mnp.portingLastName ?? "",
portingFirstName: mnp.portingFirstName ?? "",
portingLastNameKatakana: mnp.portingLastNameKatakana ?? "",
portingFirstNameKatakana: mnp.portingFirstNameKatakana ?? "",
portingGender: mnp.portingGender ?? "",
portingDateOfBirth: mnp.portingDateOfBirth ?? "",
});
} else {
setMnpData({
reservationNumber: "",
expiryDate: "",
phoneNumber: "",
mvnoAccountNumber: "",
portingLastName: "",
portingFirstName: "",
portingLastNameKatakana: "",
portingFirstNameKatakana: "",
portingGender: "",
portingDateOfBirth: "",
});
}
}
};
@ -144,14 +196,15 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
}, [
simLoading,
simData,
selectedPlan,
searchParams,
configureParams,
setSimType,
setEid,
setSelectedAddons,
setActivationType,
setScheduledActivationDate,
setWantsMnp,
setMnpData,
]);
// Step transition handler (memoized)
@ -227,15 +280,57 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
params.set("activationType", values.activationType);
if (values.scheduledActivationDate) {
params.set("scheduledDate", values.scheduledActivationDate);
params.set(
"scheduledAt",
values.scheduledActivationDate.replace(/-/g, "")
);
} else {
params.delete("scheduledDate");
params.delete("scheduledAt");
}
if (values.wantsMnp) {
params.set("wantsMnp", "true");
if (values.mnpData) {
Object.entries(values.mnpData).forEach(([key, value]) => {
if (value) params.set(`mnp_${key}`, value.toString());
});
const simConfig = buildSimOrderConfigurations(values);
params.set("simConfig", JSON.stringify(simConfig));
if (simConfig.scheduledAt && !values.scheduledActivationDate) {
params.set("scheduledAt", simConfig.scheduledAt);
}
params.set("wantsMnp", simConfig.isMnp === "true" ? "true" : "false");
if (simConfig.isMnp === "true") {
params.set("isMnp", "true");
if (simConfig.mnpNumber) params.set("mnpNumber", simConfig.mnpNumber);
if (simConfig.mnpExpiry) params.set("mnpExpiry", simConfig.mnpExpiry);
if (simConfig.mnpPhone) params.set("mnpPhone", simConfig.mnpPhone);
if (simConfig.mvnoAccountNumber)
params.set("mvnoAccountNumber", simConfig.mvnoAccountNumber);
if (simConfig.portingLastName)
params.set("portingLastName", simConfig.portingLastName);
if (simConfig.portingFirstName)
params.set("portingFirstName", simConfig.portingFirstName);
if (simConfig.portingLastNameKatakana)
params.set("portingLastNameKatakana", simConfig.portingLastNameKatakana);
if (simConfig.portingFirstNameKatakana)
params.set("portingFirstNameKatakana", simConfig.portingFirstNameKatakana);
if (simConfig.portingGender)
params.set("portingGender", simConfig.portingGender);
if (simConfig.portingDateOfBirth)
params.set("portingDateOfBirth", simConfig.portingDateOfBirth);
} else {
params.set("isMnp", "false");
[
"mnpNumber",
"mnpExpiry",
"mnpPhone",
"mvnoAccountNumber",
"portingLastName",
"portingFirstName",
"portingLastNameKatakana",
"portingFirstNameKatakana",
"portingGender",
"portingDateOfBirth",
].forEach(key => params.delete(key));
}
}

View File

@ -10,7 +10,12 @@ 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";
import {
ORDER_TYPE,
orderConfigurationsSchema,
type OrderConfigurations,
type OrderTypeValue,
} from "@customer-portal/domain/orders";
// Use domain Address type
import type { Address } from "@customer-portal/domain/customer";
@ -84,6 +89,25 @@ export function useCheckout() {
return obj;
}, [params]);
const simConfig = useMemo(() => {
if (orderType !== ORDER_TYPE.SIM) {
return null;
}
const rawConfig = params.get("simConfig");
if (!rawConfig) {
return null;
}
try {
const parsed = JSON.parse(rawConfig) as unknown;
return orderConfigurationsSchema.parse(parsed);
} catch (error) {
console.warn("Failed to parse SIM order configuration from query params", error);
return null;
}
}, [orderType, params]);
useEffect(() => {
let mounted = true;
@ -226,20 +250,20 @@ export function useCheckout() {
if (!mounted) return;
const totals = calculateTotals(items);
const configuration =
orderType === ORDER_TYPE.SIM && simConfig ? simConfig : ({} as OrderConfigurations);
setCheckoutState(
createSuccessState({
items,
totals,
configuration: {} as OrderConfigurations,
configuration,
})
);
} catch (error) {
if (mounted) {
setCheckoutState(
createErrorState(
error instanceof Error ? error.message : "Failed to load checkout data"
)
);
const reason =
error instanceof Error ? error.message : "Failed to load checkout data";
setCheckoutState(createErrorState(new Error(reason)));
}
}
})();
@ -247,7 +271,7 @@ export function useCheckout() {
return () => {
mounted = false;
};
}, [orderType, params, selections]);
}, [orderType, params, selections, simConfig]);
const handleSubmitOrder = useCallback(async () => {
try {
@ -266,19 +290,28 @@ export function useCheckout() {
throw new Error("No products selected for order. Please go back and select products.");
}
const configurations: OrderConfigurations = {
...(selections.accessMode ? { accessMode: selections.accessMode as OrderConfigurations["accessMode"] } : {}),
let configurationAccumulator: Partial<OrderConfigurations> = {};
if (orderType === ORDER_TYPE.SIM) {
if (simConfig) {
configurationAccumulator = { ...simConfig };
} else {
configurationAccumulator = {
...(selections.simType
? { simType: selections.simType as OrderConfigurations["simType"] }
: {}),
...(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.mvnoAccountNumber
? { mvnoAccountNumber: selections.mvnoAccountNumber }
: {}),
...(selections.portingLastName ? { portingLastName: selections.portingLastName } : {}),
...(selections.portingFirstName ? { portingFirstName: selections.portingFirstName } : {}),
...(selections.portingLastNameKatakana
@ -292,33 +325,55 @@ export function useCheckout() {
portingGender: selections.portingGender as OrderConfigurations["portingGender"],
}
: {}),
...(selections.portingDateOfBirth ? { portingDateOfBirth: selections.portingDateOfBirth } : {}),
...(selections.portingDateOfBirth
? { portingDateOfBirth: selections.portingDateOfBirth }
: {}),
};
}
} else {
configurationAccumulator = {
...(selections.accessMode
? { accessMode: selections.accessMode as OrderConfigurations["accessMode"] }
: {}),
...(selections.activationType
? { activationType: selections.activationType as OrderConfigurations["activationType"] }
: {}),
...(selections.scheduledAt ? { scheduledAt: selections.scheduledAt } : {}),
};
}
if (confirmedAddress) {
configurations.address = {
street: confirmedAddress.street ?? undefined,
streetLine2: confirmedAddress.streetLine2 ?? undefined,
configurationAccumulator.address = {
street: confirmedAddress.address1 ?? undefined,
streetLine2: confirmedAddress.address2 ?? undefined,
city: confirmedAddress.city ?? undefined,
state: confirmedAddress.state ?? undefined,
postalCode: confirmedAddress.postalCode ?? undefined,
postalCode: confirmedAddress.postcode ?? undefined,
country: confirmedAddress.country ?? undefined,
};
}
const hasConfiguration = Object.keys(configurationAccumulator).length > 0;
const configurations = hasConfiguration
? orderConfigurationsSchema.parse(configurationAccumulator)
: undefined;
const orderData = {
orderType,
skus: uniqueSkus,
...(Object.keys(configurations).length > 0 && { configurations }),
...(configurations ? { configurations } : {}),
};
if (orderType === "SIM") {
if (!selections.eid && selections.simType === "eSIM") {
if (orderType === ORDER_TYPE.SIM) {
if (!configurations) {
throw new Error("SIM configuration is incomplete. Please restart the SIM configuration flow.");
}
if (configurations?.simType === "eSIM" && !configurations.eid) {
throw new Error(
"EID is required for eSIM activation. Please go back and provide your EID."
);
}
if (!selections.phoneNumber && !selections.mnpPhone) {
if (!configurations?.mnpPhone) {
throw new Error(
"Phone number is required for SIM activation. Please go back and provide a phone number."
);
@ -340,16 +395,16 @@ export function useCheckout() {
}
}
const response = await ordersService.createOrder<{ sfOrderId: string }>(orderData);
const response = await ordersService.createOrder(orderData);
router.push(`/orders/${response.sfOrderId}?status=success`);
} catch (error) {
let errorMessage = "Order submission failed";
if (error instanceof Error) errorMessage = error.message;
setCheckoutState(createErrorState(errorMessage));
setCheckoutState(createErrorState(new Error(errorMessage)));
} finally {
setSubmitting(false);
}
}, [checkoutState, confirmedAddress, orderType, selections, router]);
}, [checkoutState, confirmedAddress, orderType, selections, router, simConfig, activeSubs]);
const confirmAddress = useCallback((address?: Address) => {
setAddressConfirmed(true);

View File

@ -55,7 +55,7 @@ export function CheckoutContainer() {
<div className="py-6">
<AlertBanner variant="error" title="Unable to load checkout" elevated>
<div className="flex items-center justify-between">
<span>{checkoutState.error}</span>
<span>{checkoutState.error.message}</span>
<Button variant="link" onClick={navigateBackToConfigure}>
Go Back
</Button>

View File

@ -1,6 +1,5 @@
import { apiClient } from "@/lib/api";
import {
createOrderRequest,
orderDetailsSchema,
orderSummarySchema,
type CreateOrderRequest,
@ -13,11 +12,11 @@ import {
} from "@/lib/api/response-helpers";
async function createOrder(payload: CreateOrderRequest): Promise<{ sfOrderId: string }> {
const body = createOrderRequest({
const body: CreateOrderRequest = {
orderType: payload.orderType,
skus: payload.skus,
configurations: payload.configurations ?? undefined,
});
...(payload.configurations ? { configurations: payload.configurations } : {}),
};
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 }>

View File

@ -55,7 +55,7 @@ export function OrderDetailContainer() {
? deriveOrderStatusDescriptor({
status: data.status,
activationStatus: data.activationStatus,
scheduledAt: data.scheduledAt,
scheduledAt: data.activationScheduledAt,
})
: null;
@ -68,7 +68,7 @@ export function OrderDetailContainer() {
const totals = calculateOrderTotals(
data?.items?.map(item => ({
totalPrice: item.totalPrice,
billingCycle: item.product?.billingCycle,
billingCycle: item.billingCycle,
})),
data?.totalAmount
);
@ -176,7 +176,7 @@ export function OrderDetailContainer() {
{data.items.map(item => {
const productName = item.product?.name ?? "Product";
const sku = item.product?.sku ?? "N/A";
const billingCycle = item.product?.billingCycle ?? "";
const billingCycle = item.billingCycle ?? "";
return (
<div

View File

@ -4,12 +4,10 @@ import React from "react";
import { formatPlanShort } from "@/lib/utils";
import {
DevicePhoneMobileIcon,
WifiIcon,
SignalIcon,
ClockIcon,
CheckCircleIcon,
ExclamationTriangleIcon,
XCircleIcon,
ClockIcon,
} from "@heroicons/react/24/outline";
import type { SimDetails } from "@customer-portal/domain/sim";
@ -17,363 +15,206 @@ interface SimDetailsCardProps {
simDetails: SimDetails;
isLoading?: boolean;
error?: string | null;
embedded?: boolean; // when true, render content without card container
showFeaturesSummary?: boolean; // show the right-side Service Features summary
embedded?: boolean;
showFeaturesSummary?: boolean;
}
const statusIconMap: Record<string, React.ReactNode> = {
active: <CheckCircleIcon className="h-5 w-5 text-green-500" />,
suspended: <ExclamationTriangleIcon className="h-5 w-5 text-yellow-500" />,
cancelled: <XCircleIcon className="h-5 w-5 text-red-500" />,
pending: <ClockIcon className="h-5 w-5 text-blue-500" />,
};
const statusBadgeClass: Record<string, string> = {
active: "bg-green-100 text-green-800",
suspended: "bg-yellow-100 text-yellow-800",
cancelled: "bg-red-100 text-red-800",
pending: "bg-blue-100 text-blue-800",
};
const formatDate = (value?: string | null) => {
if (!value) return "-";
const date = new Date(value);
return Number.isNaN(date.getTime())
? value
: date.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
};
const formatQuota = (remainingMb: number) => {
if (remainingMb >= 1000) {
return `${(remainingMb / 1000).toFixed(1)} GB`;
}
return `${remainingMb.toFixed(0)} MB`;
};
const FeatureToggleRow = ({
label,
enabled,
}: {
label: string;
enabled: boolean;
}) => (
<div className="flex items-center justify-between py-2 px-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-700">{label}</span>
<span
className={`text-xs font-semibold px-2 py-1 rounded-full ${
enabled ? "bg-green-100 text-green-700" : "bg-gray-200 text-gray-600"
}`}
>
{enabled ? "Enabled" : "Disabled"}
</span>
</div>
);
const LoadingCard = ({ embedded }: { embedded: boolean }) => (
<div
className={`${embedded ? "" : "bg-white shadow rounded-xl border border-gray-100"} p-6 lg:p-8`}
>
<div className="animate-pulse space-y-4">
<div className="flex items-center gap-4">
<div className="h-12 w-12 rounded-full bg-gray-200" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-gray-200 rounded w-1/2" />
<div className="h-4 bg-gray-200 rounded w-1/3" />
</div>
</div>
<div className="h-4 bg-gray-200 rounded" />
<div className="h-4 bg-gray-200 rounded w-5/6" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="h-24 bg-gray-200 rounded" />
<div className="h-24 bg-gray-200 rounded" />
</div>
</div>
</div>
);
const ErrorCard = ({ embedded, message }: { embedded: boolean; message: string }) => (
<div
className={`${embedded ? "" : "bg-white shadow rounded-xl border border-red-100"} p-6 lg:p-8`}
>
<div className="text-center text-red-600 text-sm">{message}</div>
</div>
);
export function SimDetailsCard({
simDetails,
isLoading,
error,
isLoading = false,
error = null,
embedded = false,
showFeaturesSummary = true,
}: SimDetailsCardProps) {
const formatPlan = (code?: string) => formatPlanShort(code);
const isEsim = simDetails.simType === "esim";
const hasVoice = Boolean(simDetails.hasVoice ?? simDetails.voiceMailEnabled);
const hasSms = Boolean(simDetails.hasSms);
const voiceMailEnabled = Boolean(simDetails.voiceMailEnabled);
const callWaitingEnabled = Boolean(simDetails.callWaitingEnabled);
const internationalRoamingEnabled = Boolean(simDetails.internationalRoamingEnabled);
const sizeLabel = simDetails.size ?? simDetails.simType;
const ipv4Address = simDetails.ipv4;
const ipv6Address = simDetails.ipv6;
const getStatusIcon = (status: string) => {
switch (status) {
case "active":
return <CheckCircleIcon className="h-6 w-6 text-green-500" />;
case "suspended":
return <ExclamationTriangleIcon className="h-6 w-6 text-yellow-500" />;
case "cancelled":
return <XCircleIcon className="h-6 w-6 text-red-500" />;
case "pending":
return <ClockIcon className="h-6 w-6 text-blue-500" />;
default:
return <DevicePhoneMobileIcon className="h-6 w-6 text-gray-500" />;
}
};
const getStatusColor = (status: string) => {
switch (status) {
case "active":
return "bg-green-100 text-green-800";
case "suspended":
return "bg-yellow-100 text-yellow-800";
case "cancelled":
return "bg-red-100 text-red-800";
case "pending":
return "bg-blue-100 text-blue-800";
default:
return "bg-gray-100 text-gray-800";
}
};
const formatDate = (dateString: string) => {
try {
const date = new Date(dateString);
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
} catch {
return dateString;
}
};
const formatQuota = (quotaMb: number) => {
if (quotaMb >= 1000) {
return `${(quotaMb / 1000).toFixed(1)} GB`;
}
return `${quotaMb.toFixed(0)} MB`;
};
if (isLoading) {
const Skeleton = (
<div
className={`${embedded ? "" : "bg-white shadow-lg rounded-xl border border-gray-100 hover:shadow-xl transition-shadow duration-300 "}p-6 lg:p-8`}
>
<div className="animate-pulse">
<div className="flex items-center space-x-4">
<div className="rounded-full bg-gradient-to-br from-blue-200 to-blue-300 h-14 w-14"></div>
<div className="flex-1 space-y-3">
<div className="h-5 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-3/4"></div>
<div className="h-4 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-1/2"></div>
</div>
</div>
<div className="mt-8 space-y-4">
<div className="h-4 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg"></div>
<div className="h-4 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-5/6"></div>
<div className="h-4 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-4/6"></div>
</div>
</div>
</div>
);
return Skeleton;
return <LoadingCard embedded={embedded} />;
}
if (error) {
return (
<div
className={`${embedded ? "" : "bg-white shadow-lg rounded-xl border border-red-100 "}p-6 lg:p-8`}
>
<div className="text-center">
<div className="bg-red-50 rounded-full p-3 w-16 h-16 mx-auto mb-4">
<ExclamationTriangleIcon className="h-10 w-10 text-red-500 mx-auto" />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">Error Loading SIM Details</h3>
<p className="text-red-600 text-sm">{error}</p>
</div>
</div>
);
return <ErrorCard embedded={embedded} message={error} />;
}
// Specialized, minimal eSIM details view
if (simDetails.simType === "esim") {
return (
<div
className={`${embedded ? "" : "bg-white shadow-lg rounded-xl border border-gray-100 hover:shadow-xl transition-shadow duration-300"}`}
>
{/* Header */}
<div className={`${embedded ? "" : "px-6 lg:px-8 py-5 border-b border-gray-200"}`}>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between space-y-3 sm:space-y-0">
<div className="flex items-center">
<div className="bg-blue-50 rounded-xl p-2 mr-4">
<WifiIcon className="h-8 w-8 text-blue-600" />
</div>
<div>
<h3 className="text-xl font-semibold text-gray-900">eSIM Details</h3>
<p className="text-sm text-gray-600 font-medium">
Current Plan: {formatPlan(simDetails.planCode)}
</p>
</div>
</div>
<span
className={`inline-flex px-4 py-2 text-sm font-semibold rounded-full ${getStatusColor(simDetails.status)} self-start sm:self-auto`}
>
{simDetails.status.charAt(0).toUpperCase() + simDetails.status.slice(1)}
</span>
</div>
</div>
<div className={`${embedded ? "" : "px-6 lg:px-8 py-6"}`}>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="space-y-6">
<div>
<h4 className="text-sm font-semibold text-gray-700 uppercase tracking-wider mb-4 flex items-center">
<DevicePhoneMobileIcon className="h-4 w-4 mr-2 text-blue-500" />
SIM Information
</h4>
<div className="bg-gray-50 rounded-lg p-4">
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Phone Number
</label>
<p className="text-lg font-semibold text-gray-900 mt-1">{simDetails.msisdn}</p>
</div>
</div>
</div>
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Data Remaining
</label>
<p className="text-2xl font-bold text-green-600 mt-1">
{formatQuota(simDetails.remainingQuotaMb)}
</p>
</div>
</div>
{showFeaturesSummary && (
<div>
<h4 className="text-sm font-semibold text-gray-700 uppercase tracking-wider mb-4 flex items-center">
<CheckCircleIcon className="h-4 w-4 mr-2 text-green-500" />
Service Features
</h4>
<div className="space-y-3">
<div className="flex justify-between items-center py-2 px-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-700">Voice Mail (¥300/month)</span>
<span
className={`text-sm font-semibold px-2 py-1 rounded-full ${voiceMailEnabled ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-600"}`}
>
{voiceMailEnabled ? "Enabled" : "Disabled"}
</span>
</div>
<div className="flex justify-between items-center py-2 px-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-700">Call Waiting (¥300/month)</span>
<span
className={`text-sm font-semibold px-2 py-1 rounded-full ${callWaitingEnabled ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-600"}`}
>
{callWaitingEnabled ? "Enabled" : "Disabled"}
</span>
</div>
<div className="flex justify-between items-center py-2 px-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-700">International Roaming</span>
<span
className={`text-sm font-semibold px-2 py-1 rounded-full ${internationalRoamingEnabled ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-600"}`}
>
{internationalRoamingEnabled ? "Enabled" : "Disabled"}
</span>
</div>
<div className="flex justify-between items-center py-2 px-3 bg-blue-50 rounded-lg">
<span className="text-sm text-gray-700">4G/5G</span>
<span className="text-sm font-semibold px-2 py-1 rounded-full bg-blue-100 text-blue-800">
{simDetails.networkType || "5G"}
</span>
</div>
</div>
</div>
)}
</div>
</div>
</div>
const planName = simDetails.planName || formatPlanShort(simDetails.planCode) || "SIM Plan";
const normalizedStatus = simDetails.status?.toLowerCase() ?? "unknown";
const statusIcon = statusIconMap[normalizedStatus] ?? (
<DevicePhoneMobileIcon className="h-5 w-5 text-gray-500" />
);
}
const statusClass = statusBadgeClass[normalizedStatus] ?? "bg-gray-100 text-gray-800";
const containerClasses = embedded
? ""
: "bg-white shadow-lg rounded-xl border border-gray-100";
return (
<div className={`${embedded ? "" : "bg-white shadow rounded-lg"}`}>
{/* Header */}
<div className={`${embedded ? "" : "px-6 py-4 border-b border-gray-200"}`}>
<div className="flex items-center justify-between">
<div className="flex items-center">
<div className="text-2xl mr-3">
<DevicePhoneMobileIcon className="h-8 w-8 text-blue-600" />
<div className={`${containerClasses} ${embedded ? "" : "p-6 lg:p-8"}`}>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-center gap-3">
<div className="rounded-xl bg-blue-50 p-3">
<DevicePhoneMobileIcon className="h-7 w-7 text-blue-600" />
</div>
<div>
<h3 className="text-lg font-medium text-gray-900">Physical SIM Details</h3>
<p className="text-sm text-gray-500">
{formatPlan(simDetails.planCode)} {`${sizeLabel ?? "Unknown"} SIM`}
</p>
<h3 className="text-xl font-semibold text-gray-900">{planName}</h3>
<p className="text-sm text-gray-600">Account #{simDetails.account}</p>
</div>
</div>
<div className="flex items-center space-x-3">
{getStatusIcon(simDetails.status)}
<span
className={`inline-flex px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(simDetails.status)}`}
>
{simDetails.status.charAt(0).toUpperCase() + simDetails.status.slice(1)}
<span className={`inline-flex items-center gap-2 px-3 py-1 rounded-full ${statusClass}`}>
{statusIcon}
<span className="text-sm font-medium capitalize">{simDetails.status}</span>
</span>
</div>
</div>
</div>
{/* Content */}
<div className={`${embedded ? "" : "px-6 py-4"}`}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* SIM Information */}
<div>
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-3">
<div className="mt-6 grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="space-y-4">
<section className="bg-gray-50 rounded-lg p-4">
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">
SIM Information
</h4>
<div className="space-y-3">
<div>
<label className="text-xs text-gray-500">Phone Number</label>
<p className="text-sm font-medium text-gray-900">{simDetails.msisdn}</p>
<dl className="space-y-2 text-sm text-gray-700">
<div className="flex justify-between">
<dt className="font-medium text-gray-600">Phone Number</dt>
<dd className="font-semibold text-gray-900">{simDetails.msisdn}</dd>
</div>
{simDetails.simType === "physical" && (
<div>
<label className="text-xs text-gray-500">ICCID</label>
<p className="text-sm font-mono text-gray-900 break-all">{simDetails.iccid}</p>
<div className="flex justify-between">
<dt className="font-medium text-gray-600">SIM Type</dt>
<dd className="font-semibold text-gray-900">{simDetails.simType}</dd>
</div>
<div className="flex justify-between">
<dt className="font-medium text-gray-600">ICCID</dt>
<dd className="font-mono text-gray-900 break-all">{simDetails.iccid}</dd>
</div>
)}
{simDetails.eid && (
<div>
<label className="text-xs text-gray-500">EID (eSIM)</label>
<p className="text-sm font-mono text-gray-900 break-all">{simDetails.eid}</p>
<div className="flex justify-between">
<dt className="font-medium text-gray-600">EID</dt>
<dd className="font-mono text-gray-900 break-all">{simDetails.eid}</dd>
</div>
)}
<div className="flex justify-between">
<dt className="font-medium text-gray-600">Network Type</dt>
<dd className="font-semibold text-gray-900">{simDetails.networkType}</dd>
</div>
</dl>
</section>
{simDetails.imsi && (
<div>
<label className="text-xs text-gray-500">IMSI</label>
<p className="text-sm font-mono text-gray-900">{simDetails.imsi}</p>
</div>
)}
{simDetails.startDate && (
<div>
<label className="text-xs text-gray-500">Service Start Date</label>
<p className="text-sm text-gray-900">{formatDate(simDetails.startDate)}</p>
</div>
)}
</div>
</div>
{/* Service Features */}
{showFeaturesSummary && (
<div>
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-3">
Service Features
<section className="bg-gray-50 rounded-lg p-4">
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">
Data Remaining
</h4>
<div className="space-y-3">
<div>
<label className="text-xs text-gray-500">Data Remaining</label>
<p className="text-lg font-semibold text-green-600">
<p className="text-2xl font-bold text-green-600">
{formatQuota(simDetails.remainingQuotaMb)}
</p>
</div>
<p className="text-xs text-gray-500 mt-1">Remaining allowance in current cycle</p>
</section>
<div className="flex items-center space-x-4">
<div className="flex items-center">
<SignalIcon
className={`h-4 w-4 mr-1 ${hasVoice ? "text-green-500" : "text-gray-400"}`}
/>
<span className={`text-sm ${hasVoice ? "text-green-600" : "text-gray-500"}`}>
Voice {hasVoice ? "Enabled" : "Disabled"}
</span>
</div>
<div className="flex items-center">
<DevicePhoneMobileIcon
className={`h-4 w-4 mr-1 ${hasSms ? "text-green-500" : "text-gray-400"}`}
/>
<span className={`text-sm ${hasSms ? "text-green-600" : "text-gray-500"}`}>
SMS {hasSms ? "Enabled" : "Disabled"}
</span>
</div>
</div>
{(simDetails.ipv4 || simDetails.ipv6) && (
<div>
<label className="text-xs text-gray-500">IP Address</label>
<div className="space-y-1">
{ipv4Address && (
<p className="text-sm font-mono text-gray-900">IPv4: {ipv4Address}</p>
)}
{ipv6Address && (
<p className="text-sm font-mono text-gray-900">IPv6: {ipv6Address}</p>
)}
{!ipv4Address && !ipv6Address && (
<p className="text-sm text-gray-500">No IP assigned</p>
)}
</div>
</div>
)}
</div>
</div>
)}
</div>
{/* Pending Operations */}
{simDetails.pendingOperations && simDetails.pendingOperations.length > 0 && (
<div className="mt-6 pt-6 border-t border-gray-200">
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-3">
Pending Operations
<section className="bg-gray-50 rounded-lg p-4">
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">
Activation Timeline
</h4>
<div className="bg-blue-50 rounded-lg p-4">
{simDetails.pendingOperations.map((operation, index) => (
<div key={index} className="flex items-center text-sm">
<ClockIcon className="h-4 w-4 text-blue-500 mr-2" />
<span className="text-blue-800">
{operation.operation} scheduled for {formatDate(operation.scheduledDate)}
</span>
<dl className="space-y-2 text-sm text-gray-700">
<div className="flex justify-between">
<dt className="font-medium text-gray-600">Activated</dt>
<dd>{formatDate(simDetails.activatedAt)}</dd>
</div>
))}
<div className="flex justify-between">
<dt className="font-medium text-gray-600">Expires</dt>
<dd>{formatDate(simDetails.expiresAt)}</dd>
</div>
</dl>
</section>
</div>
{showFeaturesSummary && (
<section className="space-y-3">
<h4 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">
Service Features
</h4>
<FeatureToggleRow label="Voice Mail" enabled={simDetails.voiceMailEnabled} />
<FeatureToggleRow label="Call Waiting" enabled={simDetails.callWaitingEnabled} />
<FeatureToggleRow
label="International Roaming"
enabled={simDetails.internationalRoamingEnabled}
/>
</section>
)}
</div>
</div>
);
}
export type { SimDetails };

View File

@ -6,11 +6,12 @@ import {
ExclamationTriangleIcon,
ArrowPathIcon,
} from "@heroicons/react/24/outline";
import { SimDetailsCard, type SimDetails } from "./SimDetailsCard";
import { SimDetailsCard } from "./SimDetailsCard";
import { DataUsageChart, type SimUsage } from "./DataUsageChart";
import { SimActions } from "./SimActions";
import { apiClient } from "@/lib/api";
import { SimFeatureToggles } from "./SimFeatureToggles";
import type { SimDetails } from "@customer-portal/domain/sim";
interface SimManagementSectionProps {
subscriptionId: number;
@ -137,6 +138,9 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
return null;
}
const actionSimType: "esim" | "physical" =
simInfo.details.simType.toLowerCase() === "esim" ? "esim" : "physical";
return (
<div id="sim-management" className="space-y-8">
{/* SIM Details and Usage - Main Content */}
@ -146,7 +150,7 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
<div className="bg-white shadow-lg rounded-xl border border-gray-100 p-6">
<SimActions
subscriptionId={subscriptionId}
simType={simInfo.details.simType}
simType={actionSimType}
status={simInfo.details.status}
currentPlanCode={simInfo.details.planCode}
onTopUpSuccess={handleActionSuccess}

View File

@ -148,7 +148,7 @@ export function SimCancelContainer() {
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
<InfoRow label="SIM" value={details?.msisdn || "—"} />
<InfoRow label="Start Date" value={details?.startDate || "—"} />
<InfoRow label="Activated" value={details?.activatedAt || "—"} />
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Cancellation Month

View File

@ -14,11 +14,6 @@ interface UseSubscriptionsOptions {
status?: string;
}
const emptySubscriptionList: SubscriptionList = {
subscriptions: [],
totalCount: 0,
};
const emptyStats = {
total: 0,
active: 0,
@ -35,19 +30,6 @@ const emptyInvoiceList: InvoiceList = {
},
};
function toSubscriptionList(payload?: SubscriptionList | Subscription[] | null): SubscriptionList {
if (!payload) {
return emptySubscriptionList;
}
if (Array.isArray(payload)) {
return {
subscriptions: payload,
totalCount: payload.length,
};
}
return payload;
}
/**
* Hook to fetch all subscriptions
*/
@ -62,9 +44,7 @@ export function useSubscriptions(options: UseSubscriptionsOptions = {}) {
"/api/subscriptions",
status ? { params: { query: { status } } } : undefined
);
return toSubscriptionList(
getDataOrThrow<SubscriptionList>(response, "Failed to load subscriptions")
);
return getDataOrThrow<SubscriptionList>(response, "Failed to load subscriptions");
},
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,

View File

@ -146,7 +146,7 @@ export function SimCancelContainer() {
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
<InfoRow label="SIM" value={details?.msisdn || "—"} />
<InfoRow label="Start Date" value={details?.startDate || "—"} />
<InfoRow label="Activated" value={details?.activatedAt || "—"} />
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Cancellation Month

View File

@ -1,6 +1,6 @@
export { createClient, resolveBaseUrl } from "./runtime/client";
export type { ApiClient, AuthHeaderResolver, CreateClientOptions, QueryParams, PathParams } from "./runtime/client";
export { ApiError } from "./runtime/client";
export { ApiError, isApiError } from "./runtime/client";
// Re-export API helpers
export * from "./response-helpers";

View File

@ -22,7 +22,7 @@ export type HttpMethod =
| "HEAD"
| "OPTIONS";
type PathParams = Record<string, string | number>;
export type PathParams = Record<string, string | number>;
export type QueryPrimitive = string | number | boolean;
export type QueryParams = Record<
string,
@ -383,8 +383,12 @@ export function createClient(options: CreateClientOptions = {}): ApiClient {
const parsedBody = await parseResponseBody(response);
if (parsedBody === undefined || parsedBody === null) {
return {};
}
return {
data: (parsedBody as T | null | undefined) ?? null,
data: parsedBody as T,
};
};

View File

@ -0,0 +1,71 @@
import { orderConfigurationsSchema, type OrderConfigurations } from "./schema";
import type { SimConfigureFormData } from "../sim";
export interface BuildSimOrderConfigurationsOptions {
/**
* Optional fallback phone number when the SIM form does not include it directly.
* Useful for flows where the phone number is collected separately.
*/
phoneNumber?: string | null | undefined;
}
const normalizeString = (value: unknown): string | undefined => {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
};
const normalizeDate = (value: unknown): string | undefined => {
const str = normalizeString(value);
if (!str) return undefined;
return str.replace(/-/g, "");
};
/**
* Build an OrderConfigurations object for SIM orders from the shared SimConfigureFormData.
* Ensures the resulting payload conforms to the domain schema before it is sent to the BFF.
*/
export function buildSimOrderConfigurations(
formData: SimConfigureFormData,
options: BuildSimOrderConfigurationsOptions = {}
): OrderConfigurations {
const base: Record<string, unknown> = {
simType: formData.simType,
activationType: formData.activationType,
};
const eid = normalizeString(formData.eid);
if (formData.simType === "eSIM" && eid) {
base.eid = eid;
}
const scheduledDate = normalizeDate(formData.scheduledActivationDate);
if (formData.activationType === "Scheduled" && scheduledDate) {
base.scheduledAt = scheduledDate;
}
const phoneCandidate =
normalizeString(formData.mnpData?.phoneNumber) ?? normalizeString(options.phoneNumber);
if (phoneCandidate) {
base.mnpPhone = phoneCandidate;
}
if (formData.wantsMnp && formData.mnpData) {
const mnp = formData.mnpData;
base.isMnp = "true";
base.mnpNumber = normalizeString(mnp.reservationNumber);
base.mnpExpiry = normalizeDate(mnp.expiryDate);
base.mvnoAccountNumber = normalizeString(mnp.mvnoAccountNumber);
base.portingLastName = normalizeString(mnp.portingLastName);
base.portingFirstName = normalizeString(mnp.portingFirstName);
base.portingLastNameKatakana = normalizeString(mnp.portingLastNameKatakana);
base.portingFirstNameKatakana = normalizeString(mnp.portingFirstNameKatakana);
base.portingGender = normalizeString(mnp.portingGender);
base.portingDateOfBirth = normalizeDate(mnp.portingDateOfBirth);
} else if (formData.wantsMnp) {
// When wantsMnp is true but data is missing, mark the flag so validation fails clearly downstream.
base.isMnp = "true";
}
return orderConfigurationsSchema.parse(base);
}

View File

@ -11,6 +11,7 @@ export {
type OrderCreationType,
type OrderStatus,
type OrderType,
type OrderTypeValue,
type UserMapping,
// Constants
ORDER_TYPE,
@ -29,6 +30,10 @@ export * from "./validation";
// Utilities
export * from "./utils";
export {
buildSimOrderConfigurations,
type BuildSimOrderConfigurationsOptions,
} from "./helpers";
// Re-export types for convenience
export type {

View File

@ -30,6 +30,11 @@ export type {
SimCancelRequest,
SimTopUpHistoryRequest,
SimFeaturesUpdateRequest,
SimReissueRequest,
SimConfigureFormData,
SimCardType,
ActivationType,
MnpData,
// Activation types
SimOrderActivationRequest,
SimOrderActivationMnp,

View File

@ -111,6 +111,95 @@ export const simReissueRequestSchema = z.object({
.optional(),
});
const simMnpFormSchema = z.object({
reservationNumber: z.string().min(1, "Reservation number is required"),
expiryDate: z.string().regex(/^\d{8}$/, "Expiry date must be in YYYYMMDD format"),
phoneNumber: z.string().min(1, "Phone number is required"),
mvnoAccountNumber: z.string().optional(),
portingLastName: z.string().optional(),
portingFirstName: z.string().optional(),
portingLastNameKatakana: z.string().optional(),
portingFirstNameKatakana: z.string().optional(),
portingGender: z.string().optional(),
portingDateOfBirth: z
.string()
.regex(/^\d{8}$/, "Birthday must be in YYYYMMDD format")
.optional(),
});
export const simCardTypeSchema = z.enum(["eSIM", "Physical SIM"]);
export const simActivationTypeSchema = z.enum(["Immediate", "Scheduled"]);
export const simConfigureFormSchema = z
.object({
simType: simCardTypeSchema,
eid: z
.string()
.min(15, "EID must be at least 15 characters")
.max(32, "EID must be at most 32 characters")
.optional(),
selectedAddons: z.array(z.string()).default([]),
activationType: simActivationTypeSchema,
scheduledActivationDate: z
.string()
.regex(/^\d{8}$/, "Scheduled date must be in YYYYMMDD format")
.optional(),
wantsMnp: z.boolean().default(false),
mnpData: simMnpFormSchema.optional(),
})
.superRefine((data, ctx) => {
if (data.simType === "eSIM" && (!data.eid || data.eid.trim().length < 15)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["eid"],
message: "EID is required for eSIM configuration",
});
}
if (data.activationType === "Scheduled" && !data.scheduledActivationDate) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["scheduledActivationDate"],
message: "Scheduled activation date is required when activation type is Scheduled",
});
}
if (data.wantsMnp) {
if (!data.mnpData) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["mnpData"],
message: "MNP data is required when porting is selected",
});
return;
}
const { reservationNumber, expiryDate, phoneNumber } = data.mnpData;
if (!reservationNumber) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["mnpData", "reservationNumber"],
message: "Reservation number is required",
});
}
if (!expiryDate) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["mnpData", "expiryDate"],
message: "Reservation expiry date is required",
});
}
if (!phoneNumber) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["mnpData", "phoneNumber"],
message: "Phone number is required for porting",
});
}
}
});
// ============================================================================
// SIM Order Activation Schemas
// ============================================================================
@ -134,9 +223,9 @@ export const simOrderActivationAddonsSchema = z.object({
export const simOrderActivationRequestSchema = z.object({
planSku: z.string().min(1, "Plan SKU is required"),
simType: z.enum(["eSIM", "Physical SIM"]),
simType: simCardTypeSchema,
eid: z.string().min(15, "EID must be at least 15 characters").optional(),
activationType: z.enum(["Immediate", "Scheduled"]),
activationType: simActivationTypeSchema,
scheduledAt: z.string().regex(/^\d{8}$/, "Scheduled date must be in YYYYMMDD format").optional(),
addons: simOrderActivationAddonsSchema.optional(),
mnp: simOrderActivationMnpSchema.optional(),
@ -202,3 +291,51 @@ export type SimCancelRequest = z.infer<typeof simCancelRequestSchema>;
export type SimTopUpHistoryRequest = z.infer<typeof simTopUpHistoryRequestSchema>;
export type SimFeaturesUpdateRequest = z.infer<typeof simFeaturesUpdateRequestSchema>;
export type SimReissueRequest = z.infer<typeof simReissueRequestSchema>;
export type SimConfigureFormData = z.infer<typeof simConfigureFormSchema>;
export type SimCardType = z.infer<typeof simCardTypeSchema>;
export type ActivationType = z.infer<typeof simActivationTypeSchema>;
export type MnpData = z.infer<typeof simMnpFormSchema>;
export interface SimConfigureFormToRequestOptions {
planSku: string;
msisdn: string;
monthlyAmountJpy: number;
oneTimeAmountJpy: number;
addons?: SimOrderActivationAddons;
}
export function simConfigureFormToRequest(
formData: SimConfigureFormData,
options: SimConfigureFormToRequestOptions
): SimOrderActivationRequest {
const scheduledAt =
formData.activationType === "Scheduled" ? formData.scheduledActivationDate : undefined;
const mnp =
formData.wantsMnp && formData.mnpData
? {
reserveNumber: formData.mnpData.reservationNumber,
reserveExpireDate: formData.mnpData.expiryDate,
account: formData.mnpData.mvnoAccountNumber ?? undefined,
firstnameKanji: formData.mnpData.portingFirstName ?? undefined,
lastnameKanji: formData.mnpData.portingLastName ?? undefined,
firstnameZenKana: formData.mnpData.portingFirstNameKatakana ?? undefined,
lastnameZenKana: formData.mnpData.portingLastNameKatakana ?? undefined,
gender: formData.mnpData.portingGender ?? undefined,
birthday: formData.mnpData.portingDateOfBirth ?? undefined,
}
: undefined;
return simOrderActivationRequestSchema.parse({
planSku: options.planSku,
simType: formData.simType,
eid: formData.simType === "eSIM" ? formData.eid?.trim() || undefined : undefined,
activationType: formData.activationType,
scheduledAt,
addons: options.addons,
mnp,
msisdn: options.msisdn,
oneTimeAmountJpy: options.oneTimeAmountJpy,
monthlyAmountJpy: options.monthlyAmountJpy,
});
}

View File

@ -1,49 +1,97 @@
/**
* Toolkit - Currency Formatting
*
* Simple currency formatting. Currency code comes from user's WHMCS profile.
* Typically JPY for this application.
* Our product currently operates in Japanese Yen only, but we keep a single
* helper so the portal and BFF share the same formatting behaviour.
*
* The function still accepts legacy signatures (`formatCurrency(amount, "JPY", "¥")`)
* so existing call sites remain compatible.
*/
export type SupportedCurrency = "JPY" | "USD" | "EUR";
export type SupportedCurrency = "JPY";
type LegacyOptions = {
/**
* Format a number as currency using WHMCS currency data
*
* @param amount - The numeric amount to format
* @param currencyCode - Currency code from WHMCS API (e.g., "JPY", "USD", "EUR")
* @param currencyPrefix - Currency symbol from WHMCS API (e.g., "¥", "$", "€")
*
* @example
* formatCurrency(1000, "JPY", "¥") // ¥1,000
* formatCurrency(1000, "USD", "$") // $1,000.00
* formatCurrency(1000, "EUR", "€") // €1,000.00
* Optional locale override. Defaults to "ja-JP".
*/
locale?: string;
/**
* Set to false if you ever want to hide the symbol. Defaults to true.
*/
showSymbol?: boolean;
/**
* Optional custom symbol. Defaults to "¥".
*/
currencySymbol?: string;
};
const DEFAULT_CURRENCY: SupportedCurrency = "JPY";
const DEFAULT_SYMBOL = "¥";
const DEFAULT_LOCALE = "ja-JP";
export const getCurrencyLocale = (_currency?: string): string => DEFAULT_LOCALE;
const normalizeOptions = (
currencyOrOptions?: string | LegacyOptions,
symbolOrOptions?: string | LegacyOptions
) => {
const result: {
currency: string;
symbol: string;
locale: string;
showSymbol: boolean;
} = {
currency: DEFAULT_CURRENCY,
symbol: DEFAULT_SYMBOL,
locale: DEFAULT_LOCALE,
showSymbol: true,
};
const applyOptions = (opts?: LegacyOptions) => {
if (!opts) return;
if (opts.locale) result.locale = opts.locale;
if (typeof opts.showSymbol === "boolean") result.showSymbol = opts.showSymbol;
if (opts.currencySymbol) result.symbol = opts.currencySymbol;
};
if (typeof currencyOrOptions === "string") {
result.currency = currencyOrOptions;
} else {
applyOptions(currencyOrOptions);
}
if (typeof symbolOrOptions === "string") {
result.symbol = symbolOrOptions;
} else {
applyOptions(symbolOrOptions);
}
// Even if a different currency code is provided, we treat it like JPY for now.
const fractionDigits = result.currency.toUpperCase() === "JPY" ? 0 : 2;
return { ...result, fractionDigits };
};
export function formatCurrency(
amount: number,
currencyCode: string,
currencyPrefix: string
currencyOrOptions?: string | LegacyOptions,
symbolOrOptions?: string | LegacyOptions
): string {
// Determine fraction digits based on currency
const fractionDigits = currencyCode === "JPY" ? 0 : 2;
const { locale, symbol, showSymbol, fractionDigits } = normalizeOptions(
currencyOrOptions,
symbolOrOptions
);
// Format the number with appropriate decimal places
const formattedAmount = amount.toLocaleString("en-US", {
const formatted = amount.toLocaleString(locale, {
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
});
// Add currency prefix
return `${currencyPrefix}${formattedAmount}`;
return showSymbol ? `${symbol}${formatted}` : formatted;
}
/**
* Parse a currency string to a number
*/
export function parseCurrency(value: string): number | null {
// Remove currency symbols, commas, and whitespace
const cleaned = value.replace(/[¥$€,\s]/g, "");
const parsed = Number.parseFloat(cleaned);
return Number.isFinite(parsed) ? parsed : null;
}