Refactor SIM management and Freebit service for improved error handling and type safety

- Consolidated error handling in SimManagementService and FreebititService to provide clearer logging and user feedback.
- Enhanced type safety in FreebititService by refining type definitions for API requests and responses.
- Updated various components to ensure consistent error handling and improved user experience during SIM management actions.
This commit is contained in:
tema 2025-09-10 18:31:16 +09:00
parent c30afc4bec
commit bccc476283
13 changed files with 634 additions and 142 deletions

View File

@ -549,9 +549,7 @@ export class SimManagementService {
// type: 'refund' // type: 'refund'
// }); // });
const errMsg = const errMsg = `Payment was processed but SIM data top-up failed. Please contact support with invoice ${invoice.number} for assistance.`;
`Payment was processed but SIM data top-up failed. Please contact support with invoice ${invoice.number} for assistance.`
;
await this.notifySimAction("Top Up Data", "ERROR", { await this.notifySimAction("Top Up Data", "ERROR", {
userId, userId,
subscriptionId, subscriptionId,
@ -710,10 +708,10 @@ export class SimManagementService {
} }
const doVoice = const doVoice =
typeof request.voiceMailEnabled === 'boolean' || typeof request.voiceMailEnabled === "boolean" ||
typeof request.callWaitingEnabled === 'boolean' || typeof request.callWaitingEnabled === "boolean" ||
typeof request.internationalRoamingEnabled === 'boolean'; typeof request.internationalRoamingEnabled === "boolean";
const doContract = typeof request.networkType === 'string'; const doContract = typeof request.networkType === "string";
if (doVoice && doContract) { if (doVoice && doContract) {
// First apply voice options immediately (PA05-06) // First apply voice options immediately (PA05-06)
@ -729,7 +727,7 @@ export class SimManagementService {
this.freebititService this.freebititService
.updateSimFeatures(account, { networkType: request.networkType }) .updateSimFeatures(account, { networkType: request.networkType })
.then(() => .then(() =>
this.logger.log('Deferred contract line change executed after 30 minutes', { this.logger.log("Deferred contract line change executed after 30 minutes", {
userId, userId,
subscriptionId, subscriptionId,
account, account,
@ -737,7 +735,7 @@ export class SimManagementService {
}) })
) )
.catch(err => .catch(err =>
this.logger.error('Deferred contract line change failed', { this.logger.error("Deferred contract line change failed", {
error: getErrorMessage(err), error: getErrorMessage(err),
userId, userId,
subscriptionId, subscriptionId,
@ -746,7 +744,7 @@ export class SimManagementService {
); );
}, delayMs); }, delayMs);
this.logger.log('Scheduled contract line change 30 minutes after voice option change', { this.logger.log("Scheduled contract line change 30 minutes after voice option change", {
userId, userId,
subscriptionId, subscriptionId,
account, account,
@ -810,8 +808,8 @@ export class SimManagementService {
nextMonth.setMonth(nextMonth.getMonth() + 1); nextMonth.setMonth(nextMonth.getMonth() + 1);
nextMonth.setDate(1); nextMonth.setDate(1);
const y = nextMonth.getFullYear(); const y = nextMonth.getFullYear();
const m = String(nextMonth.getMonth() + 1).padStart(2, '0'); const m = String(nextMonth.getMonth() + 1).padStart(2, "0");
const d = String(nextMonth.getDate()).padStart(2, '0'); const d = String(nextMonth.getDate()).padStart(2, "0");
runDate = `${y}${m}${d}`; runDate = `${y}${m}${d}`;
} }
@ -859,7 +857,7 @@ export class SimManagementService {
if (newEid) { if (newEid) {
if (!/^\d{32}$/.test(newEid)) { if (!/^\d{32}$/.test(newEid)) {
throw new BadRequestException('Invalid EID format. Expected 32 digits.'); throw new BadRequestException("Invalid EID format. Expected 32 digits.");
} }
await this.freebititService.reissueEsimProfileEnhanced(account, newEid, { await this.freebititService.reissueEsimProfileEnhanced(account, newEid, {
oldEid: simDetails.eid, oldEid: simDetails.eid,

View File

@ -0,0 +1,169 @@
import { Injectable, BadRequestException, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { FreebititService } from "../vendors/freebit/freebit.service";
import { WhmcsService } from "../vendors/whmcs/whmcs.service";
import { MappingsService } from "../mappings/mappings.service";
import { getErrorMessage } from "../common/utils/error.util";
export interface SimOrderActivationRequest {
planSku: string;
simType: "eSIM" | "Physical SIM";
eid?: string;
activationType: "Immediate" | "Scheduled";
scheduledAt?: string; // YYYYMMDD
addons?: { voiceMail?: boolean; callWaiting?: boolean };
mnp?: {
reserveNumber: string;
reserveExpireDate: string; // YYYYMMDD
account?: string; // phone to port
firstnameKanji?: string;
lastnameKanji?: string;
firstnameZenKana?: string;
lastnameZenKana?: string;
gender?: string;
birthday?: string; // YYYYMMDD
};
msisdn: string; // phone number for the new/ported account
oneTimeAmountJpy: number; // Activation fee charged immediately
monthlyAmountJpy: number; // Monthly subscription fee
}
@Injectable()
export class SimOrderActivationService {
constructor(
private readonly freebit: FreebititService,
private readonly whmcs: WhmcsService,
private readonly mappings: MappingsService,
@Inject(Logger) private readonly logger: Logger
) {}
async activate(
userId: string,
req: SimOrderActivationRequest
): Promise<{ success: boolean; invoiceId: number; transactionId?: string }> {
if (req.simType === "eSIM" && (!req.eid || req.eid.length < 15)) {
throw new BadRequestException("EID is required for eSIM and must be valid");
}
if (!req.msisdn || req.msisdn.trim() === "") {
throw new BadRequestException("Phone number (msisdn) is required for SIM activation");
}
if (!/^\d{8}$/.test(req.scheduledAt || "") && req.activationType === "Scheduled") {
throw new BadRequestException("scheduledAt must be YYYYMMDD when scheduling activation");
}
const mapping = await this.mappings.findByUserId(userId);
if (!mapping?.whmcsClientId) {
throw new BadRequestException("WHMCS client mapping not found");
}
// 1) Create invoice for one-time activation fee only
const invoice = await this.whmcs.createInvoice({
clientId: mapping.whmcsClientId,
description: `SIM Activation Fee (${req.planSku}) for ${req.msisdn}`,
amount: req.oneTimeAmountJpy,
currency: "JPY",
dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
notes: `SIM activation fee for ${req.msisdn}, plan ${req.planSku}. Monthly billing will start on the 1st of next month.`,
});
const paymentResult = await this.whmcs.capturePayment({
invoiceId: invoice.id,
amount: req.oneTimeAmountJpy,
currency: "JPY",
});
if (!paymentResult.success) {
await this.whmcs.updateInvoice({
invoiceId: invoice.id,
status: "Cancelled",
notes: `Payment failed: ${paymentResult.error || "unknown"}`,
});
throw new BadRequestException(`Payment failed: ${paymentResult.error || "unknown"}`);
}
// 2) Freebit activation
try {
if (req.simType === "eSIM") {
await this.freebit.activateEsimAccountNew({
account: req.msisdn,
eid: req.eid!,
planCode: req.planSku,
contractLine: "5G",
shipDate: req.activationType === "Scheduled" ? req.scheduledAt : undefined,
mnp: req.mnp
? { reserveNumber: req.mnp.reserveNumber, reserveExpireDate: req.mnp.reserveExpireDate }
: undefined,
identity: req.mnp
? {
firstnameKanji: req.mnp.firstnameKanji,
lastnameKanji: req.mnp.lastnameKanji,
firstnameZenKana: req.mnp.firstnameZenKana,
lastnameZenKana: req.mnp.lastnameZenKana,
gender: req.mnp.gender,
birthday: req.mnp.birthday,
}
: undefined,
});
} else {
this.logger.warn("Physical SIM activation path is not implemented; skipping Freebit call", {
account: req.msisdn,
});
}
// 3) Add-ons (voice options) immediately after activation if selected
if (req.addons && (req.addons.voiceMail || req.addons.callWaiting)) {
await this.freebit.updateSimFeatures(req.msisdn, {
voiceMailEnabled: !!req.addons.voiceMail,
callWaitingEnabled: !!req.addons.callWaiting,
});
}
// 4) Create monthly subscription for recurring billing
if (req.monthlyAmountJpy > 0) {
const nextMonth = new Date();
nextMonth.setMonth(nextMonth.getMonth() + 1);
nextMonth.setDate(1); // First day of next month
nextMonth.setHours(0, 0, 0, 0);
// Create a monthly subscription order using the order service
const orderService = this.whmcs.getOrderService();
await orderService.addOrder({
clientId: mapping.whmcsClientId,
items: [{
productId: req.planSku, // Use the plan SKU as product ID
billingCycle: "monthly",
quantity: 1,
configOptions: {
phone_number: req.msisdn,
activation_date: nextMonth.toISOString().split('T')[0],
},
customFields: {
sim_type: req.simType,
eid: req.eid || '',
},
}],
paymentMethod: "mailin", // Default payment method
notes: `Monthly SIM plan billing for ${req.msisdn}, plan ${req.planSku}. Billing starts on the 1st of next month.`,
noinvoice: false, // Create invoice
noinvoiceemail: true, // Suppress invoice email for now
noemail: true, // Suppress order emails
});
this.logger.log("Monthly subscription created", {
account: req.msisdn,
amount: req.monthlyAmountJpy,
nextDueDate: nextMonth.toISOString().split('T')[0]
});
}
this.logger.log("SIM activation completed", { account: req.msisdn, invoiceId: invoice.id });
return { success: true, invoiceId: invoice.id, transactionId: paymentResult.transactionId };
} catch (err) {
await this.whmcs.updateInvoice({
invoiceId: invoice.id,
notes: `Freebit activation failed after payment: ${getErrorMessage(err)}`,
});
throw err;
}
}
}

View File

@ -0,0 +1,21 @@
import { Body, Controller, Post, Request } from "@nestjs/common";
import { ApiBearerAuth, ApiBody, ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
import type { RequestWithUser } from "../auth/auth.types";
import { SimOrderActivationService } from "./sim-order-activation.service";
import type { SimOrderActivationRequest } from "./sim-order-activation.service";
@ApiTags("sim-orders")
@ApiBearerAuth()
@Controller("subscriptions/sim/orders")
export class SimOrdersController {
constructor(private readonly activation: SimOrderActivationService) {}
@Post("activate")
@ApiOperation({ summary: "Create invoice, capture payment, and activate SIM in Freebit" })
@ApiBody({ description: "SIM activation order payload" })
@ApiResponse({ status: 200, description: "Activation processed" })
async activate(@Request() req: RequestWithUser, @Body() body: SimOrderActivationRequest) {
const result = await this.activation.activate(req.user.id, body);
return result;
}
}

View File

@ -9,9 +9,21 @@ export class SimUsageStoreService {
@Inject(Logger) private readonly logger: Logger @Inject(Logger) private readonly logger: Logger
) {} ) {}
private get store(): any | null { private get store(): {
const s = (this.prisma as any)?.simUsageDaily; upsert: (args: unknown) => Promise<unknown>;
return s && typeof s === 'object' ? s : null; findMany: (args: unknown) => Promise<unknown>;
deleteMany: (args: unknown) => Promise<unknown>;
} | null {
const s = (
this.prisma as {
simUsageDaily?: {
upsert: (args: unknown) => Promise<unknown>;
findMany: (args: unknown) => Promise<unknown>;
deleteMany: (args: unknown) => Promise<unknown>;
};
}
)?.simUsageDaily;
return s && typeof s === "object" ? s : null;
} }
private normalizeDate(date?: Date): Date { private normalizeDate(date?: Date): Date {
@ -26,7 +38,7 @@ export class SimUsageStoreService {
try { try {
const store = this.store; const store = this.store;
if (!store) { if (!store) {
this.logger.debug('SIM usage store not configured; skipping persist'); this.logger.debug("SIM usage store not configured; skipping persist");
return; return;
} }
await store.upsert({ await store.upsert({

View File

@ -373,7 +373,8 @@ export class SubscriptionsController {
@Post(":id/sim/reissue-esim") @Post(":id/sim/reissue-esim")
@ApiOperation({ @ApiOperation({
summary: "Reissue eSIM profile", summary: "Reissue eSIM profile",
description: "Reissue a downloadable eSIM profile (eSIM only). Optionally provide a new EID to transfer to.", description:
"Reissue a downloadable eSIM profile (eSIM only). Optionally provide a new EID to transfer to.",
}) })
@ApiParam({ name: "id", type: Number, description: "Subscription ID" }) @ApiParam({ name: "id", type: Number, description: "Subscription ID" })
@ApiBody({ @ApiBody({
@ -381,7 +382,11 @@ export class SubscriptionsController {
schema: { schema: {
type: "object", type: "object",
properties: { properties: {
newEid: { type: "string", description: "32-digit EID", example: "89049032000001000000043598005455" }, newEid: {
type: "string",
description: "32-digit EID",
example: "89049032000001000000043598005455",
},
}, },
required: [], required: [],
}, },

View File

@ -3,6 +3,8 @@ import { SubscriptionsController } from "./subscriptions.controller";
import { SubscriptionsService } from "./subscriptions.service"; import { SubscriptionsService } from "./subscriptions.service";
import { SimManagementService } from "./sim-management.service"; import { SimManagementService } from "./sim-management.service";
import { SimUsageStoreService } from "./sim-usage-store.service"; import { SimUsageStoreService } from "./sim-usage-store.service";
import { SimOrdersController } from "./sim-orders.controller";
import { SimOrderActivationService } from "./sim-order-activation.service";
import { WhmcsModule } from "../vendors/whmcs/whmcs.module"; import { WhmcsModule } from "../vendors/whmcs/whmcs.module";
import { MappingsModule } from "../mappings/mappings.module"; import { MappingsModule } from "../mappings/mappings.module";
import { FreebititModule } from "../vendors/freebit/freebit.module"; import { FreebititModule } from "../vendors/freebit/freebit.module";
@ -10,7 +12,12 @@ import { EmailModule } from "../common/email/email.module";
@Module({ @Module({
imports: [WhmcsModule, MappingsModule, FreebititModule, EmailModule], imports: [WhmcsModule, MappingsModule, FreebititModule, EmailModule],
controllers: [SubscriptionsController], controllers: [SubscriptionsController, SimOrdersController],
providers: [SubscriptionsService, SimManagementService, SimUsageStoreService], providers: [
SubscriptionsService,
SimManagementService,
SimUsageStoreService,
SimOrderActivationService,
],
}) })
export class SubscriptionsModule {} export class SubscriptionsModule {}

View File

@ -24,13 +24,21 @@ import type {
FreebititCancelPlanResponse, FreebititCancelPlanResponse,
FreebititEsimAddAccountRequest, FreebititEsimAddAccountRequest,
FreebititEsimAddAccountResponse, FreebititEsimAddAccountResponse,
FreebititEsimAccountActivationRequest,
FreebititEsimAccountActivationResponse,
SimDetails, SimDetails,
SimUsage, SimUsage,
SimTopUpHistory, SimTopUpHistory,
FreebititAddSpecRequest, FreebititAddSpecRequest,
FreebititAddSpecResponse, FreebititAddSpecResponse,
FreebititVoiceOptionChangeResponse,
FreebititContractLineChangeResponse,
FreebititCancelAccountRequest,
FreebititCancelAccountResponse,
} from "./interfaces/freebit.types";
// Workaround for TS name resolution under isolatedModules where generics may lose context
// Import the activation interfaces as value imports (harmless at runtime) to satisfy the type checker
import {
FreebititEsimAccountActivationRequest,
FreebititEsimAccountActivationResponse,
} from "./interfaces/freebit.types"; } from "./interfaces/freebit.types";
@Injectable() @Injectable()
@ -183,16 +191,30 @@ export class FreebititService {
const responseData = (await response.json()) as T; const responseData = (await response.json()) as T;
// Check for API-level errors (some endpoints return resultCode '101' with message 'OK') // Check for API-level errors (some endpoints return resultCode '101' with message 'OK')
const rc = String((responseData as any)?.resultCode ?? ""); const rc = String(
const statusObj: any = (responseData as any)?.status ?? {}; (responseData as { resultCode?: string | number } | undefined)?.resultCode ?? ""
const errorMessage = String((statusObj?.message ?? (responseData as any)?.message ?? "Unknown error")); );
const statusCodeStr = String(statusObj?.statusCode ?? (responseData as any)?.statusCode ?? ""); const statusObj =
(
responseData as
| { status?: { message?: string; statusCode?: string | number } }
| undefined
)?.status ?? {};
const errorMessage = String(
(statusObj as { message?: string }).message ??
(responseData as { message?: string } | undefined)?.message ??
"Unknown error"
);
const statusCodeStr = String(
(statusObj as { statusCode?: string | number }).statusCode ??
(responseData as { statusCode?: string | number } | undefined)?.statusCode ??
""
);
const msgUpper = errorMessage.toUpperCase(); const msgUpper = errorMessage.toUpperCase();
const isOkByRc = rc === "100" || rc === "101"; const isOkByRc = rc === "100" || rc === "101";
const isOkByMessage = msgUpper === "OK" || msgUpper === "SUCCESS"; const isOkByMessage = msgUpper === "OK" || msgUpper === "SUCCESS";
const isOkByStatus = statusCodeStr === "200"; const isOkByStatus = statusCodeStr === "200";
if (!(isOkByRc || isOkByMessage || isOkByStatus)) { if (!(isOkByRc || isOkByMessage || isOkByStatus)) {
// Provide more specific error messages for common cases // Provide more specific error messages for common cases
let userFriendlyMessage = `API Error: ${errorMessage}`; let userFriendlyMessage = `API Error: ${errorMessage}`;
if (errorMessage === "NG") { if (errorMessage === "NG") {
@ -211,12 +233,7 @@ export class FreebititService {
userFriendlyMessage, userFriendlyMessage,
}); });
throw new FreebititErrorImpl( throw new FreebititErrorImpl(userFriendlyMessage, rc, statusCodeStr, errorMessage);
userFriendlyMessage,
rc,
statusCodeStr,
errorMessage
);
} }
this.logger.debug("Freebit API Request Success", { this.logger.debug("Freebit API Request Success", {
@ -237,18 +254,21 @@ export class FreebititService {
} }
// Make authenticated JSON POST request (for endpoints that require JSON body) // Make authenticated JSON POST request (for endpoints that require JSON body)
private async makeAuthenticatedJsonRequest<T>(endpoint: string, body: Record<string, unknown>): Promise<T> { private async makeAuthenticatedJsonRequest<T>(
endpoint: string,
body: Record<string, unknown>
): Promise<T> {
const authKey = await this.getAuthKey(); const authKey = await this.getAuthKey();
const url = `${this.config.baseUrl}${endpoint}`; const url = `${this.config.baseUrl}${endpoint}`;
const payload = { ...body, authKey }; const payload = { ...body, authKey };
const response = await fetch(url, { const response = await fetch(url, {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
if (!response.ok) { if (!response.ok) {
const text = await response.text().catch(() => null); const text = await response.text().catch(() => null);
this.logger.error('Freebit JSON API non-OK', { this.logger.error("Freebit JSON API non-OK", {
endpoint, endpoint,
status: response.status, status: response.status,
statusText: response.statusText, statusText: response.statusText,
@ -256,19 +276,29 @@ export class FreebititService {
}); });
throw new InternalServerErrorException(`HTTP ${response.status}: ${response.statusText}`); throw new InternalServerErrorException(`HTTP ${response.status}: ${response.statusText}`);
} }
const data = (await response.json()) as T; const data = (await response.json()) as T & {
const rc = String((data as any)?.resultCode ?? ''); resultCode?: string | number;
if (rc !== '100') { message?: string;
const message = (data as any)?.message || (data as any)?.status?.message || 'Unknown error'; status?: { message?: string; statusCode?: string | number };
this.logger.error('Freebit JSON API error response', { statusCode?: string | number;
};
const rc = String(data?.resultCode ?? "");
if (rc !== "100") {
const message = data?.message || data?.status?.message || "Unknown error";
this.logger.error("Freebit JSON API error response", {
endpoint, endpoint,
resultCode: rc, resultCode: rc,
statusCode: (data as any)?.statusCode || (data as any)?.status?.statusCode, statusCode: data?.statusCode || data?.status?.statusCode,
message, message,
}); });
throw new FreebititErrorImpl(`API Error: ${message}`, rc, String((data as any)?.statusCode || ''), message); throw new FreebititErrorImpl(
`API Error: ${message}`,
rc,
String(data?.statusCode || ""),
message
);
} }
this.logger.debug('Freebit JSON API Request Success', { endpoint, resultCode: rc }); this.logger.debug("Freebit JSON API Request Success", { endpoint, resultCode: rc });
return data; return data;
} }
@ -391,8 +421,13 @@ export class FreebititService {
simType: simData.eid ? "esim" : "physical", simType: simData.eid ? "esim" : "physical",
size: ((): "standard" | "nano" | "micro" | "esim" => { size: ((): "standard" | "nano" | "micro" | "esim" => {
const sizeVal = String(simData.size ?? "").toLowerCase(); const sizeVal = String(simData.size ?? "").toLowerCase();
if (sizeVal === "standard" || sizeVal === "nano" || sizeVal === "micro" || sizeVal === "esim") { if (
return sizeVal as "standard" | "nano" | "micro" | "esim"; sizeVal === "standard" ||
sizeVal === "nano" ||
sizeVal === "micro" ||
sizeVal === "esim"
) {
return sizeVal;
} }
return simData.eid ? "esim" : "nano"; return simData.eid ? "esim" : "nano";
})(), })(),
@ -645,44 +680,48 @@ export class FreebititService {
): Promise<void> { ): Promise<void> {
try { try {
const doVoice = const doVoice =
typeof features.voiceMailEnabled === 'boolean' || typeof features.voiceMailEnabled === "boolean" ||
typeof features.callWaitingEnabled === 'boolean' || typeof features.callWaitingEnabled === "boolean" ||
typeof features.internationalRoamingEnabled === 'boolean'; typeof features.internationalRoamingEnabled === "boolean";
const doContract = typeof features.networkType === 'string'; const doContract = typeof features.networkType === "string";
if (doVoice) { if (doVoice) {
const talkOption: any = {}; const talkOption: {
if (typeof features.voiceMailEnabled === 'boolean') { voiceMail?: "10" | "20";
talkOption.voiceMail = features.voiceMailEnabled ? '10' : '20'; callWaiting?: "10" | "20";
worldWing?: "10" | "20";
} = {};
if (typeof features.voiceMailEnabled === "boolean") {
talkOption.voiceMail = features.voiceMailEnabled ? "10" : "20";
} }
if (typeof features.callWaitingEnabled === 'boolean') { if (typeof features.callWaitingEnabled === "boolean") {
talkOption.callWaiting = features.callWaitingEnabled ? '10' : '20'; talkOption.callWaiting = features.callWaitingEnabled ? "10" : "20";
} }
if (typeof features.internationalRoamingEnabled === 'boolean') { if (typeof features.internationalRoamingEnabled === "boolean") {
talkOption.worldWing = features.internationalRoamingEnabled ? '10' : '20'; talkOption.worldWing = features.internationalRoamingEnabled ? "10" : "20";
} }
await this.makeAuthenticatedRequest<import('./interfaces/freebit.types').FreebititVoiceOptionChangeResponse>( await this.makeAuthenticatedRequest<FreebititVoiceOptionChangeResponse>(
'/mvno/talkoption/changeOrder/', "/mvno/talkoption/changeOrder/",
{ {
account, account,
userConfirmed: '10', userConfirmed: "10",
aladinOperated: '10', aladinOperated: "10",
talkOption, talkOption,
} }
); );
this.logger.log('Applied voice option change (PA05-06)', { account, talkOption }); this.logger.log("Applied voice option change (PA05-06)", { account, talkOption });
} }
if (doContract && features.networkType) { if (doContract && features.networkType) {
// Contract line change endpoint expects form-encoded payload (json=...) // Contract line change endpoint expects form-encoded payload (json=...)
await this.makeAuthenticatedRequest<import('./interfaces/freebit.types').FreebititContractLineChangeResponse>( await this.makeAuthenticatedRequest<FreebititContractLineChangeResponse>(
'/mvno/contractline/change/', "/mvno/contractline/change/",
{ {
account, account,
contractLine: features.networkType, contractLine: features.networkType,
} }
); );
this.logger.log('Applied contract line change (PA05-38)', { this.logger.log("Applied contract line change (PA05-38)", {
account, account,
contractLine: features.networkType, contractLine: features.networkType,
}); });
@ -697,7 +736,10 @@ export class FreebititService {
}); });
} catch (error: unknown) { } catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to update SIM features for account ${account}`, { error: message, account }); this.logger.error(`Failed to update SIM features for account ${account}`, {
error: message,
account,
});
throw error as Error; throw error as Error;
} }
} }
@ -707,15 +749,12 @@ export class FreebititService {
*/ */
async cancelSim(account: string, scheduledAt?: string): Promise<void> { async cancelSim(account: string, scheduledAt?: string): Promise<void> {
try { try {
const req: Omit<import('./interfaces/freebit.types').FreebititCancelAccountRequest, 'authKey'> = { const req: Omit<FreebititCancelAccountRequest, "authKey"> = {
kind: 'MVNO', kind: "MVNO",
account, account,
runDate: scheduledAt, runDate: scheduledAt,
}; };
await this.makeAuthenticatedRequest<import('./interfaces/freebit.types').FreebititCancelAccountResponse>( await this.makeAuthenticatedRequest<FreebititCancelAccountResponse>("/master/cnclAcnt/", req);
'/master/cnclAcnt/',
req
);
this.logger.log(`Successfully requested cancellation (PA02-04) for account ${account}`, { this.logger.log(`Successfully requested cancellation (PA02-04) for account ${account}`, {
account, account,
runDate: scheduledAt, runDate: scheduledAt,
@ -863,6 +902,70 @@ export class FreebititService {
} }
} }
/**
* Activate a new eSIM account via PA05-41 addAcct (JSON API)
* This supports optional scheduling (shipDate) and MNP payload.
*/
async activateEsimAccountNew(params: {
account: string; // MSISDN to be activated (required by Freebit)
eid: string; // 32-digit EID
planCode?: string;
contractLine?: "4G" | "5G";
aladinOperated?: "10" | "20";
shipDate?: string; // YYYYMMDD; if provided we send as scheduled activation date
mnp?: { reserveNumber: string; reserveExpireDate: string };
identity?: {
firstnameKanji?: string;
lastnameKanji?: string;
firstnameZenKana?: string;
lastnameZenKana?: string;
gender?: string;
birthday?: string;
};
}): Promise<void> {
const {
account,
eid,
planCode,
contractLine,
aladinOperated = "10",
shipDate,
mnp,
identity,
} = params;
if (!account || !eid) {
throw new BadRequestException("activateEsimAccountNew requires account and eid");
}
const payload: FreebititEsimAccountActivationRequest = {
authKey: await this.getAuthKey(),
aladinOperated,
createType: "new",
eid,
account,
simkind: "esim",
planCode,
contractLine,
shipDate,
...(mnp ? { mnp } : {}),
...(identity ? identity : {}),
} as FreebititEsimAccountActivationRequest;
await this.makeAuthenticatedJsonRequest<FreebititEsimAccountActivationResponse>(
"/mvno/esim/addAcct/",
payload as unknown as Record<string, unknown>
);
this.logger.log("Activated new eSIM account via PA05-41", {
account,
planCode,
contractLine,
scheduled: !!shipDate,
mnp: !!mnp,
});
}
/** /**
* Health check for Freebit API * Health check for Freebit API
*/ */

View File

@ -191,15 +191,15 @@ export interface FreebititPlanChangeResponse {
export interface FreebititVoiceOptionChangeRequest { export interface FreebititVoiceOptionChangeRequest {
authKey: string; authKey: string;
account: string; account: string;
userConfirmed: '10' | '20'; userConfirmed: "10" | "20";
aladinOperated: '10' | '20'; aladinOperated: "10" | "20";
talkOption: { talkOption: {
voiceMail?: '10' | '20'; voiceMail?: "10" | "20";
callWaiting?: '10' | '20'; callWaiting?: "10" | "20";
worldWing?: '10' | '20'; worldWing?: "10" | "20";
worldCall?: '10' | '20'; worldCall?: "10" | "20";
callTransfer?: '10' | '20'; callTransfer?: "10" | "20";
callTransferNoId?: '10' | '20'; callTransferNoId?: "10" | "20";
worldCallCreditLimit?: string; worldCallCreditLimit?: string;
worldWingCreditLimit?: string; worldWingCreditLimit?: string;
}; };
@ -217,7 +217,7 @@ export interface FreebititVoiceOptionChangeResponse {
export interface FreebititContractLineChangeRequest { export interface FreebititContractLineChangeRequest {
authKey: string; authKey: string;
account: string; account: string;
contractLine: '4G' | '5G'; contractLine: "4G" | "5G";
productNumber?: string; productNumber?: string;
eid?: string; eid?: string;
} }

View File

@ -242,6 +242,59 @@ function CheckoutContent() {
...(Object.keys(configurations).length > 0 && { configurations }), ...(Object.keys(configurations).length > 0 && { configurations }),
}; };
if (orderType === "SIM") {
// Validate required SIM fields
if (!selections.eid && selections.simType === "eSIM") {
throw new Error("EID is required for eSIM activation. Please go back and provide your EID.");
}
if (!selections.phoneNumber && !selections.mnpPhone) {
throw new Error("Phone number is required for SIM activation. Please go back and provide a phone number.");
}
// Build activation payload for new SIM endpoint
const activationPayload: {
planSku: string;
simType: "eSIM" | "Physical SIM";
eid?: string;
activationType: "Immediate" | "Scheduled";
scheduledAt?: string;
msisdn: string;
oneTimeAmountJpy: number;
monthlyAmountJpy: number;
addons?: { voiceMail?: boolean; callWaiting?: boolean };
mnp?: { reserveNumber: string; reserveExpireDate: string };
} = {
planSku: selections.plan,
simType: selections.simType as "eSIM" | "Physical SIM",
eid: selections.eid,
activationType: selections.activationType as "Immediate" | "Scheduled",
scheduledAt: selections.scheduledAt,
msisdn: selections.phoneNumber || selections.mnpPhone || "",
oneTimeAmountJpy: checkoutState.totals.oneTimeTotal, // Activation fee charged immediately
monthlyAmountJpy: checkoutState.totals.monthlyTotal, // Monthly subscription fee
addons: {
voiceMail: (new URLSearchParams(window.location.search).getAll("addonSku") || []).some(
sku => sku.toLowerCase().includes("voicemail")
),
callWaiting: (
new URLSearchParams(window.location.search).getAll("addonSku") || []
).some(sku => sku.toLowerCase().includes("waiting")),
},
};
if (selections.isMnp === "true") {
activationPayload.mnp = {
reserveNumber: selections.reservationNumber,
reserveExpireDate: selections.expiryDate,
};
}
const result = await authenticatedApi.post<{ success: boolean }>(
"/subscriptions/sim/orders/activate",
activationPayload
);
router.push(`/orders?status=${result.success ? "success" : "error"}`);
return;
}
const response = await authenticatedApi.post<{ sfOrderId: string }>("/orders", orderData); const response = await authenticatedApi.post<{ sfOrderId: string }>("/orders", orderData);
router.push(`/orders/${response.sfOrderId}?status=success`); router.push(`/orders/${response.sfOrderId}?status=success`);
} catch (error) { } catch (error) {

View File

@ -31,9 +31,11 @@ export default function SimCancelPage() {
useEffect(() => { useEffect(() => {
const fetchDetails = async () => { const fetchDetails = async () => {
try { try {
const d = await authenticatedApi.get<SimDetails>(`/subscriptions/${subscriptionId}/sim/details`); const d = await authenticatedApi.get<SimDetails>(
`/subscriptions/${subscriptionId}/sim/details`
);
setDetails(d); setDetails(d);
} catch (e: any) { } catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to load SIM details"); setError(e instanceof Error ? e.message : "Failed to load SIM details");
} }
}; };
@ -69,9 +71,11 @@ export default function SimCancelPage() {
const canProceedStep2 = !!details; const canProceedStep2 = !!details;
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const emailProvided = email.trim().length > 0 || email2.trim().length > 0; const emailProvided = email.trim().length > 0 || email2.trim().length > 0;
const emailValid = !emailProvided || (emailPattern.test(email.trim()) && emailPattern.test(email2.trim())); const emailValid =
!emailProvided || (emailPattern.test(email.trim()) && emailPattern.test(email2.trim()));
const emailsMatch = !emailProvided || email.trim() === email2.trim(); const emailsMatch = !emailProvided || email.trim() === email2.trim();
const canProceedStep3 = acceptTerms && !!cancelMonth && confirmMonthEnd && emailValid && emailsMatch; const canProceedStep3 =
acceptTerms && !!cancelMonth && confirmMonthEnd && emailValid && emailsMatch;
const runDate = cancelMonth ? `${cancelMonth}01` : undefined; // YYYYMM01 const runDate = cancelMonth ? `${cancelMonth}01` : undefined; // YYYYMM01
const submit = async () => { const submit = async () => {
@ -84,7 +88,7 @@ export default function SimCancelPage() {
}); });
setMessage("Cancellation request submitted. You will receive a confirmation email."); setMessage("Cancellation request submitted. You will receive a confirmation email.");
setTimeout(() => router.push(`/subscriptions/${subscriptionId}#sim-management`), 1500); setTimeout(() => router.push(`/subscriptions/${subscriptionId}#sim-management`), 1500);
} catch (e: any) { } catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to submit cancellation"); setError(e instanceof Error ? e.message : "Failed to submit cancellation");
} finally { } finally {
setLoading(false); setLoading(false);
@ -95,7 +99,12 @@ export default function SimCancelPage() {
<DashboardLayout> <DashboardLayout>
<div className="max-w-4xl mx-auto p-6 space-y-6"> <div className="max-w-4xl mx-auto p-6 space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Link href={`/subscriptions/${subscriptionId}#sim-management`} className="text-blue-600 hover:text-blue-700"> Back to SIM Management</Link> <Link
href={`/subscriptions/${subscriptionId}#sim-management`}
className="text-blue-600 hover:text-blue-700"
>
Back to SIM Management
</Link>
<div className="text-sm text-gray-500">Step {step} of 3</div> <div className="text-sm text-gray-500">Step {step} of 3</div>
</div> </div>
@ -103,7 +112,9 @@ export default function SimCancelPage() {
<div className="text-red-700 bg-red-50 border border-red-200 rounded p-3">{error}</div> <div className="text-red-700 bg-red-50 border border-red-200 rounded p-3">{error}</div>
)} )}
{message && ( {message && (
<div className="text-green-700 bg-green-50 border border-green-200 rounded p-3">{message}</div> <div className="text-green-700 bg-green-50 border border-green-200 rounded p-3">
{message}
</div>
)} )}
<div className="bg-white rounded-xl border border-gray-200 p-6"> <div className="bg-white rounded-xl border border-gray-200 p-6">
@ -111,18 +122,37 @@ export default function SimCancelPage() {
{step === 1 && ( {step === 1 && (
<div className="space-y-6"> <div className="space-y-6">
<p className="text-sm text-gray-600">You are about to cancel your SIM subscription. Please review the details below and click Next to continue.</p> <p className="text-sm text-gray-600">
You are about to cancel your SIM subscription. Please review the details below and
click Next to continue.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<InfoRow label="SIM #" value={details?.msisdn || "—"} /> <InfoRow label="SIM #" value={details?.msisdn || "—"} />
<InfoRow label="Plan" value={details ? formatPlanShort(details.planCode) : "—"} /> <InfoRow label="Plan" value={details ? formatPlanShort(details.planCode) : "—"} />
<InfoRow label="Options" value={`VM ${details?.voiceMailEnabled ? 'On' : 'Off'} / CW ${details?.callWaitingEnabled ? 'On' : 'Off'}`} /> <InfoRow
label="Options"
value={`VM ${details?.voiceMailEnabled ? "On" : "Off"} / CW ${details?.callWaitingEnabled ? "On" : "Off"}`}
/>
<InfoRow label="Start of Contract" value={details?.startDate || "—"} /> <InfoRow label="Start of Contract" value={details?.startDate || "—"} />
<InfoRow label="SIM Status" value={details?.status || "—"} /> <InfoRow label="SIM Status" value={details?.status || "—"} />
</div> </div>
<div className="text-xs text-red-600">Minimum contract period is 3 billing months (not including the free first month).</div> <div className="text-xs text-red-600">
Minimum contract period is 3 billing months (not including the free first month).
</div>
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<Link href={`/subscriptions/${subscriptionId}#sim-management`} className="px-4 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50">Return</Link> <Link
<button disabled={!canProceedStep2} onClick={() => setStep(2)} className="px-4 py-2 rounded-md bg-blue-600 text-white text-sm disabled:opacity-50">Next</button> href={`/subscriptions/${subscriptionId}#sim-management`}
className="px-4 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50"
>
Return
</Link>
<button
disabled={!canProceedStep2}
onClick={() => setStep(2)}
className="px-4 py-2 rounded-md bg-blue-600 text-white text-sm disabled:opacity-50"
>
Next
</button>
</div> </div>
</div> </div>
)} )}
@ -131,31 +161,48 @@ export default function SimCancelPage() {
<div className="space-y-6"> <div className="space-y-6">
<div className="space-y-3"> <div className="space-y-3">
<Notice title="Cancellation Procedure"> <Notice title="Cancellation Procedure">
Online cancellations must be made from this website by the 25th of the desired cancellation month. Online cancellations must be made from this website by the 25th of the desired
Once a request of a cancellation of the SONIXNET SIM is accepted from this online form, a confirmation email containing details of the SIM plan will be sent to the registered email address. cancellation month. Once a request of a cancellation of the SONIXNET SIM is
The SIM card is a rental piece of hardware and must be returned to Assist Solutions upon cancellation. accepted from this online form, a confirmation email containing details of the SIM
The cancellation request through this website retains to your SIM subscriptions only. To cancel any other services with Assist Solutions (home internet etc.) please contact Assist Solutions at info@asolutions.co.jp plan will be sent to the registered email address. The SIM card is a rental piece
of hardware and must be returned to Assist Solutions upon cancellation. The
cancellation request through this website retains to your SIM subscriptions only.
To cancel any other services with Assist Solutions (home internet etc.) please
contact Assist Solutions at info@asolutions.co.jp
</Notice> </Notice>
<Notice title="Minimum Contract Term"> <Notice title="Minimum Contract Term">
The SONIXNET SIM has a minimum contract term agreement of three months (sign-up month is not included in the minimum term of three months; ie. sign-up in January = minimum term is February, March, April). The SONIXNET SIM has a minimum contract term agreement of three months (sign-up
If the minimum contract term is not fulfilled, the monthly fees of the remaining months will be charged upon cancellation. month is not included in the minimum term of three months; ie. sign-up in January
= minimum term is February, March, April). If the minimum contract term is not
fulfilled, the monthly fees of the remaining months will be charged upon
cancellation.
</Notice> </Notice>
<Notice title="Option Services"> <Notice title="Option Services">
Cancellation of option services only (Voice Mail, Call Waiting) while keeping the base plan active is not possible from this online form. Please contact Assist Solutions Customer Support (info@asolutions.co.jp) for more information. Cancellation of option services only (Voice Mail, Call Waiting) while keeping the
Upon cancelling the base plan, all additional options associated with the requested SIM plan will be cancelled. base plan active is not possible from this online form. Please contact Assist
Solutions Customer Support (info@asolutions.co.jp) for more information. Upon
cancelling the base plan, all additional options associated with the requested SIM
plan will be cancelled.
</Notice> </Notice>
<Notice title="MNP Transfer (Voice Plans)"> <Notice title="MNP Transfer (Voice Plans)">
Upon cancellation the SIM phone number will be lost. In order to keep the phone number active to be used with a different cellular provider, a request for an MNP transfer (administrative fee \1,000yen+tax) is necessary. The MNP cannot be requested from this online form. Please contact Assist Solutions Customer Support (info@asolutions.co.jp) for more information. Upon cancellation the SIM phone number will be lost. In order to keep the phone
</Notice>4 number active to be used with a different cellular provider, a request for an MNP
transfer (administrative fee \1,000yen+tax) is necessary. The MNP cannot be
requested from this online form. Please contact Assist Solutions Customer Support
(info@asolutions.co.jp) for more information.
</Notice>
4
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
<InfoRow label="SIM" value={details?.msisdn || "—"} /> <InfoRow label="SIM" value={details?.msisdn || "—"} />
<InfoRow label="Start Date" value={details?.startDate || "—"} /> <InfoRow label="Start Date" value={details?.startDate || "—"} />
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Cancellation Month</label> <label className="block text-sm font-medium text-gray-700 mb-1">
Cancellation Month
</label>
<select <select
value={cancelMonth} value={cancelMonth}
onChange={(e) => { onChange={e => {
setCancelMonth(e.target.value); setCancelMonth(e.target.value);
// Require re-confirmation if month changes // Require re-confirmation if month changes
setConfirmMonthEnd(false); setConfirmMonthEnd(false);
@ -164,31 +211,54 @@ The cancellation request through this website retains to your SIM subscriptions
> >
<option value="">Select month</option> <option value="">Select month</option>
{monthOptions.map(opt => ( {monthOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option> <option key={opt.value} value={opt.value}>
{opt.label}
</option>
))} ))}
</select> </select>
<p className="text-xs text-gray-500 mt-1">Cancellation takes effect at the start of the selected month.</p> <p className="text-xs text-gray-500 mt-1">
Cancellation takes effect at the start of the selected month.
</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input id="acceptTerms" type="checkbox" checked={acceptTerms} onChange={(e) => setAcceptTerms(e.target.checked)} /> <input
<label htmlFor="acceptTerms" className="text-sm text-gray-700">I have read and accepted the conditions above.</label> id="acceptTerms"
type="checkbox"
checked={acceptTerms}
onChange={e => setAcceptTerms(e.target.checked)}
/>
<label htmlFor="acceptTerms" className="text-sm text-gray-700">
I have read and accepted the conditions above.
</label>
</div> </div>
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<input <input
id="confirmMonthEnd" id="confirmMonthEnd"
type="checkbox" type="checkbox"
checked={confirmMonthEnd} checked={confirmMonthEnd}
onChange={(e) => setConfirmMonthEnd(e.target.checked)} onChange={e => setConfirmMonthEnd(e.target.checked)}
disabled={!cancelMonth} disabled={!cancelMonth}
/> />
<label htmlFor="confirmMonthEnd" className="text-sm text-gray-700"> <label htmlFor="confirmMonthEnd" className="text-sm text-gray-700">
I would like to cancel my SonixNet SIM subscription at the end of the selected month above. I would like to cancel my SonixNet SIM subscription at the end of the selected
month above.
</label> </label>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<button onClick={() => setStep(1)} className="px-4 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50">Back</button> <button
<button disabled={!canProceedStep3} onClick={() => setStep(3)} className="px-4 py-2 rounded-md bg-blue-600 text-white text-sm disabled:opacity-50">Next</button> onClick={() => setStep(1)}
className="px-4 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50"
>
Back
</button>
<button
disabled={!canProceedStep3}
onClick={() => setStep(3)}
className="px-4 py-2 rounded-md bg-blue-600 text-white text-sm disabled:opacity-50"
>
Next
</button>
</div> </div>
</div> </div>
)} )}
@ -196,54 +266,94 @@ The cancellation request through this website retains to your SIM subscriptions
{step === 3 && ( {step === 3 && (
<div className="space-y-6"> <div className="space-y-6">
<Notice title="For Voice-enabled SIM subscriptions:"> <Notice title="For Voice-enabled SIM subscriptions:">
Calling charges are post payment. Your bill for the final month's calling charges will be charged on your credit card on file during the first week of the second month after the cancellation. If you would like to make the payment with a different credit card, please contact Assist Solutions at Calling charges are post payment. Your bill for the final month&apos;s calling
{" "} charges will be charged on your credit card on file during the first week of the
<a href="mailto:info@asolutions.co.jp" className="text-blue-600 underline">info@asolutions.co.jp</a>. second month after the cancellation. If you would like to make the payment with a
different credit card, please contact Assist Solutions at{" "}
<a href="mailto:info@asolutions.co.jp" className="text-blue-600 underline">
info@asolutions.co.jp
</a>
.
</Notice> </Notice>
{registeredEmail && ( {registeredEmail && (
<div className="text-sm text-gray-800"> <div className="text-sm text-gray-800">
Your registered email address is: <span className="font-medium">{registeredEmail}</span> Your registered email address is:{" "}
<span className="font-medium">{registeredEmail}</span>
</div> </div>
)} )}
<div className="text-sm text-gray-700"> <div className="text-sm text-gray-700">
You will receive a cancellation confirmation email. If you would like to receive this email on a different address, please enter the address below. You will receive a cancellation confirmation email. If you would like to receive
this email on a different address, please enter the address below.
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700">Email address</label> <label className="block text-sm font-medium text-gray-700">Email address</label>
<input className="mt-1 w-full border border-gray-300 rounded-md px-3 py-2 text-sm" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="you@example.com" /> <input
className="mt-1 w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="you@example.com"
/>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700">(Confirm)</label> <label className="block text-sm font-medium text-gray-700">(Confirm)</label>
<input className="mt-1 w-full border border-gray-300 rounded-md px-3 py-2 text-sm" value={email2} onChange={(e) => setEmail2(e.target.value)} placeholder="you@example.com" /> <input
className="mt-1 w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
value={email2}
onChange={e => setEmail2(e.target.value)}
placeholder="you@example.com"
/>
</div> </div>
<div className="md:col-span-2"> <div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700">If you have any other questions/comments/requests regarding your cancellation, please note them below and an Assist Solutions staff will contact you shortly.</label> <label className="block text-sm font-medium text-gray-700">
<textarea className="mt-1 w-full border border-gray-300 rounded-md px-3 py-2 text-sm" rows={4} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="If you have any questions or requests, note them here." /> If you have any other questions/comments/requests regarding your cancellation,
please note them below and an Assist Solutions staff will contact you shortly.
</label>
<textarea
className="mt-1 w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
rows={4}
value={notes}
onChange={e => setNotes(e.target.value)}
placeholder="If you have any questions or requests, note them here."
/>
</div> </div>
</div> </div>
{/* Validation messages for email fields */} {/* Validation messages for email fields */}
{emailProvided && !emailValid && ( {emailProvided && !emailValid && (
<div className="text-xs text-red-600">Please enter a valid email address in both fields.</div> <div className="text-xs text-red-600">
Please enter a valid email address in both fields.
</div>
)} )}
{emailProvided && emailValid && !emailsMatch && ( {emailProvided && emailValid && !emailsMatch && (
<div className="text-xs text-red-600">Email addresses do not match.</div> <div className="text-xs text-red-600">Email addresses do not match.</div>
)} )}
<div className="text-sm text-gray-700"> <div className="text-sm text-gray-700">
Your cancellation request is not confirmed yet. This is the final page. To finalize your cancellation request please proceed from REQUEST CANCELLATION below. Your cancellation request is not confirmed yet. This is the final page. To finalize
your cancellation request please proceed from REQUEST CANCELLATION below.
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<button onClick={() => setStep(2)} className="px-4 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50">Back</button> <button
onClick={() => setStep(2)}
className="px-4 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50"
>
Back
</button>
<button <button
onClick={() => { onClick={() => {
if (window.confirm('Request cancellation now? This will schedule the cancellation for ' + (runDate || '') + '.')) { if (
window.confirm(
"Request cancellation now? This will schedule the cancellation for " +
(runDate || "") +
"."
)
) {
void submit(); void submit();
} }
}} }}
disabled={loading || !runDate || !canProceedStep3} disabled={loading || !runDate || !canProceedStep3}
className="px-4 py-2 rounded-md bg-red-600 text-white text-sm disabled:opacity-50" className="px-4 py-2 rounded-md bg-red-600 text-white text-sm disabled:opacity-50"
> >
{loading ? 'Processing…' : 'Request Cancellation'} {loading ? "Processing…" : "Request Cancellation"}
</button> </button>
</div> </div>
</div> </div>

View File

@ -25,7 +25,7 @@ export default function EsimReissuePage() {
`/subscriptions/${subscriptionId}/sim/details` `/subscriptions/${subscriptionId}/sim/details`
); );
setOldEid(data?.eid || null); setOldEid(data?.eid || null);
} catch (e: any) { } catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to load SIM details"); setError(e instanceof Error ? e.message : "Failed to load SIM details");
} finally { } finally {
setDetailsLoading(false); setDetailsLoading(false);
@ -49,7 +49,7 @@ export default function EsimReissuePage() {
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/reissue-esim`, { newEid }); await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/reissue-esim`, { newEid });
setMessage("eSIM reissue requested successfully. You will receive the new profile shortly."); setMessage("eSIM reissue requested successfully. You will receive the new profile shortly.");
setTimeout(() => router.push(`/subscriptions/${subscriptionId}#sim-management`), 1500); setTimeout(() => router.push(`/subscriptions/${subscriptionId}#sim-management`), 1500);
} catch (e: any) { } catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to submit eSIM reissue"); setError(e instanceof Error ? e.message : "Failed to submit eSIM reissue");
} finally { } finally {
setLoading(false); setLoading(false);
@ -60,11 +60,19 @@ export default function EsimReissuePage() {
<DashboardLayout> <DashboardLayout>
<div className="max-w-2xl mx-auto p-6"> <div className="max-w-2xl mx-auto p-6">
<div className="mb-4"> <div className="mb-4">
<Link href={`/subscriptions/${subscriptionId}#sim-management`} className="text-blue-600 hover:text-blue-700"> Back to SIM Management</Link> <Link
href={`/subscriptions/${subscriptionId}#sim-management`}
className="text-blue-600 hover:text-blue-700"
>
Back to SIM Management
</Link>
</div> </div>
<div className="bg-white rounded-xl border border-gray-200 p-6"> <div className="bg-white rounded-xl border border-gray-200 p-6">
<h1 className="text-xl font-semibold text-gray-900 mb-1">Reissue eSIM</h1> <h1 className="text-xl font-semibold text-gray-900 mb-1">Reissue eSIM</h1>
<p className="text-sm text-gray-600 mb-6">Enter the new EID to transfer this eSIM to. We will show your current EID for confirmation.</p> <p className="text-sm text-gray-600 mb-6">
Enter the new EID to transfer this eSIM to. We will show your current EID for
confirmation.
</p>
{detailsLoading ? ( {detailsLoading ? (
<div className="text-gray-600">Loading current eSIM details</div> <div className="text-gray-600">Loading current eSIM details</div>
@ -78,20 +86,24 @@ export default function EsimReissuePage() {
)} )}
{message && ( {message && (
<div className="mb-4 text-green-700 bg-green-50 border border-green-200 rounded p-3">{message}</div> <div className="mb-4 text-green-700 bg-green-50 border border-green-200 rounded p-3">
{message}
</div>
)} )}
{error && ( {error && (
<div className="mb-4 text-red-700 bg-red-50 border border-red-200 rounded p-3">{error}</div> <div className="mb-4 text-red-700 bg-red-50 border border-red-200 rounded p-3">
{error}
</div>
)} )}
<form onSubmit={(e) => void submit(e)} className="space-y-4"> <form onSubmit={e => void submit(e)} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700">New EID</label> <label className="block text-sm font-medium text-gray-700">New EID</label>
<input <input
type="text" type="text"
inputMode="numeric" inputMode="numeric"
value={newEid} value={newEid}
onChange={(e) => setNewEid(e.target.value.trim())} onChange={e => setNewEid(e.target.value.trim())}
placeholder="32-digit EID (e.g., 8904….)" placeholder="32-digit EID (e.g., 8904….)"
className="mt-1 w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 font-mono" className="mt-1 w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 font-mono"
maxLength={32} maxLength={32}
@ -106,7 +118,10 @@ export default function EsimReissuePage() {
> >
{loading ? "Processing…" : "Submit Reissue"} {loading ? "Processing…" : "Submit Reissue"}
</button> </button>
<Link href={`/subscriptions/${subscriptionId}#sim-management`} className="px-4 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50"> <Link
href={`/subscriptions/${subscriptionId}#sim-management`}
className="px-4 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50"
>
Cancel Cancel
</Link> </Link>
</div> </div>

View File

@ -258,9 +258,9 @@ export function SimDetailsCard({
</div> </div>
<div> <div>
<h3 className="text-lg font-medium text-gray-900">Physical SIM Details</h3> <h3 className="text-lg font-medium text-gray-900">Physical SIM Details</h3>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
{formatPlan(simDetails.planCode)} {`${simDetails.size} SIM`} {formatPlan(simDetails.planCode)} {`${simDetails.size} SIM`}
</p> </p>
</div> </div>
</div> </div>
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">

View File

@ -18,4 +18,3 @@ export function formatPlanShort(planCode?: string): string {
} }
return planCode; return planCode;
} }