Temuulen Ankhbayar 454fb29c85 fix: reset order config state when selecting a new plan and validate MNP phone length
Order wizard was skipping steps (jumping to add-ons) due to stale currentStep
persisting in localStorage from previous orders. Reset store on plan selection
and exclude currentStep from persistence. Also add max(11) validation on MNP
phone number to prevent Salesforce STRING_TOO_LONG errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 11:07:51 +09:00

623 lines
21 KiB
TypeScript

/**
* SIM Domain - Schemas
*/
import { z } from "zod";
import { simCatalogProductSchema } from "../services/schema.js";
// ============================================================================
// Validation Constants
// ============================================================================
const YYYYMMDD_REGEX = /^\d{8}$/;
const MSG_SCHEDULED_DATE_FORMAT = "Scheduled date must be in YYYYMMDD format";
const EID_REGEX = /^\d{32}$/;
const MSG_EID_FORMAT = "EID must be exactly 32 digits";
export const simStatusSchema = z.enum(["active", "suspended", "cancelled", "pending"]);
export const simTypeSchema = z.enum(["standard", "nano", "micro", "esim"]);
export const simDetailsSchema = z.object({
account: z.string(),
status: simStatusSchema,
planCode: z.string(),
planName: z.string(),
simType: simTypeSchema,
iccid: z.string(),
eid: z.string(),
msisdn: z.string(),
imsi: z.string(),
remainingQuotaMb: z.number(),
remainingQuotaKb: z.number(),
voiceMailEnabled: z.boolean(),
callWaitingEnabled: z.boolean(),
internationalRoamingEnabled: z.boolean(),
networkType: z.string(),
activatedAt: z.string().optional(),
expiresAt: z.string().optional(),
startDate: z.string().optional(),
// Optional network/connectivity fields
ipv4: z.string().optional(),
ipv6: z.string().optional(),
// Optional capability flags
hasVoice: z.boolean().optional(),
hasSms: z.boolean().optional(),
});
export const recentDayUsageSchema = z.object({
date: z.string(),
usageKb: z.number(),
usageMb: z.number(),
});
export const simUsageSchema = z.object({
account: z.string(),
todayUsageMb: z.number(),
todayUsageKb: z.number(),
monthlyUsageMb: z.number().optional(),
monthlyUsageKb: z.number().optional(),
recentDaysUsage: z.array(recentDayUsageSchema),
isBlacklisted: z.boolean(),
lastUpdated: z.string().optional(),
});
export const simTopUpHistoryEntrySchema = z.object({
quotaKb: z.number(),
quotaMb: z.number(),
addedDate: z.string(),
expiryDate: z.string(),
campaignCode: z.string(),
});
export const simTopUpHistorySchema = z.object({
account: z.string(),
totalAdditions: z.number(),
additionCount: z.number(),
history: z.array(simTopUpHistoryEntrySchema),
});
/**
* Combined SIM info payload (details + usage)
*/
export const simInfoSchema = z.object({
details: simDetailsSchema,
usage: simUsageSchema,
});
// ============================================================================
// SIM Management Request Schemas
// ============================================================================
export const simTopUpRequestSchema = z.object({
quotaMb: z
.number()
.int()
.min(100, "Quota must be at least 100MB")
.max(51200, "Quota must be 50GB or less"),
});
/**
* Schema for SIM top-up pricing information
*/
export const simTopUpPricingSchema = z.object({
pricePerGbJpy: z.number().positive("Price per GB must be positive"),
minQuotaMb: z.number().int().positive("Minimum quota must be positive"),
maxQuotaMb: z.number().int().positive("Maximum quota must be positive"),
currency: z.string().default("JPY"),
});
export type SimTopUpPricing = z.infer<typeof simTopUpPricingSchema>;
/**
* Schema for top-up pricing preview request
*/
export const simTopUpPricingPreviewRequestSchema = z.object({
quotaMb: z.number().int().positive("Quota must be positive"),
});
export type SimTopUpPricingPreviewRequest = z.infer<typeof simTopUpPricingPreviewRequestSchema>;
/**
* Schema for top-up pricing preview response
*/
export const simTopUpPricingPreviewResponseSchema = z.object({
quotaMb: z.number(),
quotaGb: z.number(),
totalPriceJpy: z.number(),
pricePerGbJpy: z.number(),
currency: z.string(),
});
export type SimTopUpPricingPreviewResponse = z.infer<typeof simTopUpPricingPreviewResponseSchema>;
export const simPlanChangeRequestSchema = z.object({
newPlanCode: z.string().min(1, "New plan code is required"),
assignGlobalIp: z.boolean().optional(),
scheduledAt: z.string().regex(YYYYMMDD_REGEX, MSG_SCHEDULED_DATE_FORMAT).optional(),
});
export const simCancelRequestSchema = z.object({
scheduledAt: z.string().regex(YYYYMMDD_REGEX, MSG_SCHEDULED_DATE_FORMAT).optional(),
});
export const simTopUpHistoryRequestSchema = z.object({
fromDate: z.string().regex(/^\d{8}$/, "From date must be in YYYYMMDD format"),
toDate: z.string().regex(/^\d{8}$/, "To date must be in YYYYMMDD format"),
});
export const simFeaturesUpdateRequestSchema = z.object({
voiceMailEnabled: z.boolean().optional(),
callWaitingEnabled: z.boolean().optional(),
internationalRoamingEnabled: z.boolean().optional(),
networkType: z.enum(["4G", "5G"]).optional(),
});
export const simReissueRequestSchema = z.object({
newEid: z.string().regex(EID_REGEX, MSG_EID_FORMAT).optional(),
simType: z.enum(["physical", "esim"]).optional(),
});
// ============================================================================
// Portal-facing SIM DTOs (responses + request contracts)
// ============================================================================
/**
* Available plan for SIM plan change (portal-facing)
*
* This extends the catalog's SIM product shape with:
* - freebitPlanCode: the actual plan code used by Freebit
* - isCurrentPlan: whether this is the customer's current plan
*/
export const simAvailablePlanSchema = simCatalogProductSchema.extend({
freebitPlanCode: z.string(),
isCurrentPlan: z.boolean(),
});
export type SimAvailablePlan = z.infer<typeof simAvailablePlanSchema>;
export const simAvailablePlanArraySchema = z.array(simAvailablePlanSchema);
export type SimAvailablePlanArray = z.infer<typeof simAvailablePlanArraySchema>;
/**
* Cancellation month option for SIM cancellation preview
*/
export const simCancellationMonthSchema = z.object({
value: z.string().regex(/^\d{4}-\d{2}$/, "Month must be in YYYY-MM format"),
label: z.string(),
runDate: z.string().regex(/^\d{8}$/, "runDate must be in YYYYMMDD format"),
});
export type SimCancellationMonth = z.infer<typeof simCancellationMonthSchema>;
/**
* SIM cancellation preview payload (portal-facing)
*/
export const simCancellationPreviewSchema = z.object({
simNumber: z.string(),
serialNumber: z.string().optional(),
planCode: z.string(),
startDate: z.string().optional(),
minimumContractEndDate: z.string().optional(),
isWithinMinimumTerm: z.boolean(),
availableMonths: z.array(simCancellationMonthSchema),
customerEmail: z.string(),
customerName: z.string(),
});
export type SimCancellationPreview = z.infer<typeof simCancellationPreviewSchema>;
/**
* Reissue SIM request (full flow) for `/api/subscriptions/:id/sim/reissue`
*
* - Physical SIM: simType="physical" (newEid optional/ignored)
* - eSIM: simType="esim" and newEid is required
*/
export const simReissueFullRequestSchema = z
.object({
simType: z.enum(["physical", "esim"]),
newEid: z.string().regex(EID_REGEX, MSG_EID_FORMAT).optional(),
})
.superRefine((data, ctx) => {
if (data.simType === "esim" && !data.newEid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "newEid is required for eSIM reissue",
path: ["newEid"],
});
}
});
export type SimReissueFullRequest = z.infer<typeof simReissueFullRequestSchema>;
// ============================================================================
// SIM Call/SMS History (portal-facing)
// ============================================================================
export const simHistoryMonthSchema = z
.string()
.regex(/^\d{4}-\d{2}$/, "Month must be in YYYY-MM format");
export type SimHistoryMonth = z.infer<typeof simHistoryMonthSchema>;
export const simHistoryAvailableMonthsSchema = z.array(simHistoryMonthSchema);
export type SimHistoryAvailableMonths = z.infer<typeof simHistoryAvailableMonthsSchema>;
export const simCallHistoryImportResultSchema = z.object({
domestic: z.number().int().min(0),
international: z.number().int().min(0),
sms: z.number().int().min(0),
});
export type SimCallHistoryImportResult = z.infer<typeof simCallHistoryImportResultSchema>;
export const simSftpFilesSchema = z.array(z.string());
export type SimSftpFiles = z.infer<typeof simSftpFilesSchema>;
export const simSftpListResultSchema = z.object({
path: z.string(),
files: simSftpFilesSchema,
});
export type SimSftpListResult = z.infer<typeof simSftpListResultSchema>;
const isoDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be in YYYY-MM-DD format");
const timeHmsSchema = z.string().regex(/^\d{2}:\d{2}:\d{2}$/, "Time must be in HH:MM:SS format");
export const simCallHistoryPaginationSchema = z.object({
page: z.number().int().min(1),
limit: z.number().int().min(1),
total: z.number().int().min(0),
totalPages: z.number().int().min(0),
});
export type SimCallHistoryPagination = z.infer<typeof simCallHistoryPaginationSchema>;
export const simDomesticCallRecordSchema = z.object({
id: z.string(),
date: isoDateSchema,
time: timeHmsSchema,
calledTo: z.string(),
callLength: z.string(),
callCharge: z.number(),
});
export type SimDomesticCallRecord = z.infer<typeof simDomesticCallRecordSchema>;
export const simDomesticCallHistoryResponseSchema = z.object({
calls: z.array(simDomesticCallRecordSchema),
pagination: simCallHistoryPaginationSchema,
month: simHistoryMonthSchema,
});
export type SimDomesticCallHistoryResponse = z.infer<typeof simDomesticCallHistoryResponseSchema>;
export const simInternationalCallRecordSchema = z.object({
id: z.string(),
date: isoDateSchema,
startTime: timeHmsSchema,
stopTime: z.string().nullable(),
country: z.string().nullable(),
calledTo: z.string(),
callCharge: z.number(),
});
export type SimInternationalCallRecord = z.infer<typeof simInternationalCallRecordSchema>;
export const simInternationalCallHistoryResponseSchema = z.object({
calls: z.array(simInternationalCallRecordSchema),
pagination: simCallHistoryPaginationSchema,
month: simHistoryMonthSchema,
});
export type SimInternationalCallHistoryResponse = z.infer<
typeof simInternationalCallHistoryResponseSchema
>;
/**
* Schema for SIM history query parameters (pagination + month)
*/
export const simHistoryQuerySchema = z.object({
month: simHistoryMonthSchema.optional(),
page: z.coerce.number().int().min(1).optional().default(1),
limit: z.coerce.number().int().min(1).max(100).optional().default(50),
});
export type SimHistoryQuery = z.infer<typeof simHistoryQuerySchema>;
/**
* Schema for SFTP file listing query
*/
export const simSftpListQuerySchema = z.object({
path: z.string().startsWith("/home/PASI", "Invalid path").default("/home/PASI"),
});
export type SimSftpListQuery = z.infer<typeof simSftpListQuerySchema>;
/**
* Schema for call history import query
*/
export const simCallHistoryImportQuerySchema = z.object({
month: z.string().regex(/^\d{6}$/, "Invalid month format (expected YYYYMM)"),
});
export type SimCallHistoryImportQuery = z.infer<typeof simCallHistoryImportQuerySchema>;
/**
* Schema for SIM eSIM reissue request
*/
export const simReissueEsimRequestSchema = z.object({
newEid: z.string().regex(EID_REGEX, MSG_EID_FORMAT).optional(),
});
export type SimReissueEsimRequest = z.infer<typeof simReissueEsimRequestSchema>;
export const simSmsRecordSchema = z.object({
id: z.string(),
date: isoDateSchema,
time: timeHmsSchema,
sentTo: z.string(),
type: z.string(),
});
export type SimSmsRecord = z.infer<typeof simSmsRecordSchema>;
export const simSmsHistoryResponseSchema = z.object({
messages: z.array(simSmsRecordSchema),
pagination: simCallHistoryPaginationSchema,
month: simHistoryMonthSchema,
});
export type SimSmsHistoryResponse = z.infer<typeof simSmsHistoryResponseSchema>;
// Enhanced cancellation request with more details
export const simCancelFullRequestSchema = z
.object({
cancellationMonth: z
.string()
.regex(/^\d{4}-\d{2}$/, "Cancellation month must be in YYYY-MM format"),
confirmRead: z.boolean(),
confirmCancel: z.boolean(),
comments: z.string().max(1000).optional(),
})
.refine(data => data.confirmRead === true && data.confirmCancel === true, {
message: "You must confirm both checkboxes to proceed",
});
// Top-up request with enhanced details for email
export const simTopUpFullRequestSchema = z.object({
quotaMb: z
.number()
.int()
.min(100, "Quota must be at least 100MB")
.max(51200, "Quota must be 50GB or less"),
});
// Change plan request with Salesforce product info
export const simChangePlanFullRequestSchema = z.object({
newPlanCode: z.string().min(1, "New plan code is required"),
newPlanSku: z.string().min(1, "New plan SKU is required"),
newPlanName: z.string().optional(),
assignGlobalIp: z.boolean().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")
.max(11, "Phone number must be 11 digits or fewer"),
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(YYYYMMDD_REGEX, MSG_SCHEDULED_DATE_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
// ============================================================================
export const simOrderActivationMnpSchema = z.object({
reserveNumber: z.string().min(1, "Reserve number is required"),
reserveExpireDate: z.string().regex(/^\d{8}$/, "Reserve expire date must be in YYYYMMDD format"),
account: z.string().optional(),
firstnameKanji: z.string().optional(),
lastnameKanji: z.string().optional(),
firstnameZenKana: z.string().optional(),
lastnameZenKana: z.string().optional(),
gender: z.string().optional(),
birthday: z
.string()
.regex(/^\d{8}$/, "Birthday must be in YYYYMMDD format")
.optional(),
});
export const simOrderActivationAddonsSchema = z.object({
voiceMail: z.boolean().optional(),
callWaiting: z.boolean().optional(),
});
export const simOrderActivationRequestSchema = z
.object({
planSku: z.string().min(1, "Plan SKU is required"),
simType: simCardTypeSchema,
eid: z.string().min(15, "EID must be at least 15 characters").optional(),
activationType: simActivationTypeSchema,
scheduledAt: z.string().regex(YYYYMMDD_REGEX, MSG_SCHEDULED_DATE_FORMAT).optional(),
addons: simOrderActivationAddonsSchema.optional(),
mnp: simOrderActivationMnpSchema.optional(),
msisdn: z.string().min(1, "Phone number (msisdn) is required"),
oneTimeAmountJpy: z.number().nonnegative("One-time amount must be non-negative"),
monthlyAmountJpy: z.number().nonnegative("Monthly amount must be non-negative"),
})
.refine(
data => {
// If simType is eSIM, eid is required
return !(data.simType === "eSIM" && (!data.eid || data.eid.length < 15));
},
{
message: "EID is required for eSIM and must be at least 15 characters",
path: ["eid"],
}
)
.refine(
data => {
// If activationType is Scheduled, scheduledAt is required
return !(data.activationType === "Scheduled" && !data.scheduledAt);
},
{
message: "Scheduled date is required for Scheduled activation",
path: ["scheduledAt"],
}
);
export type SimOrderActivationRequest = z.infer<typeof simOrderActivationRequestSchema>;
export type SimOrderActivationMnp = z.infer<typeof simOrderActivationMnpSchema>;
export type SimOrderActivationAddons = z.infer<typeof simOrderActivationAddonsSchema>;
// ============================================================================
// Inferred Types from Schemas (Schema-First Approach)
// ============================================================================
export type SimStatus = z.infer<typeof simStatusSchema>;
export type SimType = z.infer<typeof simTypeSchema>;
export type SimDetails = z.infer<typeof simDetailsSchema>;
export type RecentDayUsage = z.infer<typeof recentDayUsageSchema>;
export type SimUsage = z.infer<typeof simUsageSchema>;
export type SimTopUpHistoryEntry = z.infer<typeof simTopUpHistoryEntrySchema>;
export type SimTopUpHistory = z.infer<typeof simTopUpHistorySchema>;
export type SimInfo = z.infer<typeof simInfoSchema>;
// Request types (derived from request schemas)
export type SimTopUpRequest = z.infer<typeof simTopUpRequestSchema>;
export type SimPlanChangeRequest = z.infer<typeof simPlanChangeRequestSchema>;
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 SimCancelFullRequest = z.infer<typeof simCancelFullRequestSchema>;
export type SimTopUpFullRequest = z.infer<typeof simTopUpFullRequestSchema>;
export type SimChangePlanFullRequest = z.infer<typeof simChangePlanFullRequestSchema>;
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,
});
}