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:
parent
c30afc4bec
commit
bccc476283
@ -549,9 +549,7 @@ export class SimManagementService {
|
||||
// type: 'refund'
|
||||
// });
|
||||
|
||||
const errMsg =
|
||||
`Payment was processed but SIM data top-up failed. Please contact support with invoice ${invoice.number} for assistance.`
|
||||
;
|
||||
const errMsg = `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", {
|
||||
userId,
|
||||
subscriptionId,
|
||||
@ -710,10 +708,10 @@ export class SimManagementService {
|
||||
}
|
||||
|
||||
const doVoice =
|
||||
typeof request.voiceMailEnabled === 'boolean' ||
|
||||
typeof request.callWaitingEnabled === 'boolean' ||
|
||||
typeof request.internationalRoamingEnabled === 'boolean';
|
||||
const doContract = typeof request.networkType === 'string';
|
||||
typeof request.voiceMailEnabled === "boolean" ||
|
||||
typeof request.callWaitingEnabled === "boolean" ||
|
||||
typeof request.internationalRoamingEnabled === "boolean";
|
||||
const doContract = typeof request.networkType === "string";
|
||||
|
||||
if (doVoice && doContract) {
|
||||
// First apply voice options immediately (PA05-06)
|
||||
@ -729,7 +727,7 @@ export class SimManagementService {
|
||||
this.freebititService
|
||||
.updateSimFeatures(account, { networkType: request.networkType })
|
||||
.then(() =>
|
||||
this.logger.log('Deferred contract line change executed after 30 minutes', {
|
||||
this.logger.log("Deferred contract line change executed after 30 minutes", {
|
||||
userId,
|
||||
subscriptionId,
|
||||
account,
|
||||
@ -737,7 +735,7 @@ export class SimManagementService {
|
||||
})
|
||||
)
|
||||
.catch(err =>
|
||||
this.logger.error('Deferred contract line change failed', {
|
||||
this.logger.error("Deferred contract line change failed", {
|
||||
error: getErrorMessage(err),
|
||||
userId,
|
||||
subscriptionId,
|
||||
@ -746,7 +744,7 @@ export class SimManagementService {
|
||||
);
|
||||
}, 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,
|
||||
subscriptionId,
|
||||
account,
|
||||
@ -810,8 +808,8 @@ export class SimManagementService {
|
||||
nextMonth.setMonth(nextMonth.getMonth() + 1);
|
||||
nextMonth.setDate(1);
|
||||
const y = nextMonth.getFullYear();
|
||||
const m = String(nextMonth.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(nextMonth.getDate()).padStart(2, '0');
|
||||
const m = String(nextMonth.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(nextMonth.getDate()).padStart(2, "0");
|
||||
runDate = `${y}${m}${d}`;
|
||||
}
|
||||
|
||||
@ -859,7 +857,7 @@ export class SimManagementService {
|
||||
|
||||
if (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, {
|
||||
oldEid: simDetails.eid,
|
||||
|
||||
169
apps/bff/src/subscriptions/sim-order-activation.service.ts
Normal file
169
apps/bff/src/subscriptions/sim-order-activation.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
21
apps/bff/src/subscriptions/sim-orders.controller.ts
Normal file
21
apps/bff/src/subscriptions/sim-orders.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -9,9 +9,21 @@ export class SimUsageStoreService {
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
private get store(): any | null {
|
||||
const s = (this.prisma as any)?.simUsageDaily;
|
||||
return s && typeof s === 'object' ? s : null;
|
||||
private get store(): {
|
||||
upsert: (args: unknown) => Promise<unknown>;
|
||||
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 {
|
||||
@ -26,7 +38,7 @@ export class SimUsageStoreService {
|
||||
try {
|
||||
const store = this.store;
|
||||
if (!store) {
|
||||
this.logger.debug('SIM usage store not configured; skipping persist');
|
||||
this.logger.debug("SIM usage store not configured; skipping persist");
|
||||
return;
|
||||
}
|
||||
await store.upsert({
|
||||
|
||||
@ -373,7 +373,8 @@ export class SubscriptionsController {
|
||||
@Post(":id/sim/reissue-esim")
|
||||
@ApiOperation({
|
||||
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" })
|
||||
@ApiBody({
|
||||
@ -381,7 +382,11 @@ export class SubscriptionsController {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
newEid: { type: "string", description: "32-digit EID", example: "89049032000001000000043598005455" },
|
||||
newEid: {
|
||||
type: "string",
|
||||
description: "32-digit EID",
|
||||
example: "89049032000001000000043598005455",
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
|
||||
@ -3,6 +3,8 @@ import { SubscriptionsController } from "./subscriptions.controller";
|
||||
import { SubscriptionsService } from "./subscriptions.service";
|
||||
import { SimManagementService } from "./sim-management.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 { MappingsModule } from "../mappings/mappings.module";
|
||||
import { FreebititModule } from "../vendors/freebit/freebit.module";
|
||||
@ -10,7 +12,12 @@ import { EmailModule } from "../common/email/email.module";
|
||||
|
||||
@Module({
|
||||
imports: [WhmcsModule, MappingsModule, FreebititModule, EmailModule],
|
||||
controllers: [SubscriptionsController],
|
||||
providers: [SubscriptionsService, SimManagementService, SimUsageStoreService],
|
||||
controllers: [SubscriptionsController, SimOrdersController],
|
||||
providers: [
|
||||
SubscriptionsService,
|
||||
SimManagementService,
|
||||
SimUsageStoreService,
|
||||
SimOrderActivationService,
|
||||
],
|
||||
})
|
||||
export class SubscriptionsModule {}
|
||||
|
||||
209
apps/bff/src/vendors/freebit/freebit.service.ts
vendored
209
apps/bff/src/vendors/freebit/freebit.service.ts
vendored
@ -24,13 +24,21 @@ import type {
|
||||
FreebititCancelPlanResponse,
|
||||
FreebititEsimAddAccountRequest,
|
||||
FreebititEsimAddAccountResponse,
|
||||
FreebititEsimAccountActivationRequest,
|
||||
FreebititEsimAccountActivationResponse,
|
||||
SimDetails,
|
||||
SimUsage,
|
||||
SimTopUpHistory,
|
||||
FreebititAddSpecRequest,
|
||||
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";
|
||||
|
||||
@Injectable()
|
||||
@ -183,16 +191,30 @@ export class FreebititService {
|
||||
const responseData = (await response.json()) as T;
|
||||
|
||||
// Check for API-level errors (some endpoints return resultCode '101' with message 'OK')
|
||||
const rc = String((responseData as any)?.resultCode ?? "");
|
||||
const statusObj: any = (responseData as any)?.status ?? {};
|
||||
const errorMessage = String((statusObj?.message ?? (responseData as any)?.message ?? "Unknown error"));
|
||||
const statusCodeStr = String(statusObj?.statusCode ?? (responseData as any)?.statusCode ?? "");
|
||||
const rc = String(
|
||||
(responseData as { resultCode?: string | number } | undefined)?.resultCode ?? ""
|
||||
);
|
||||
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 isOkByRc = rc === "100" || rc === "101";
|
||||
const isOkByMessage = msgUpper === "OK" || msgUpper === "SUCCESS";
|
||||
const isOkByStatus = statusCodeStr === "200";
|
||||
if (!(isOkByRc || isOkByMessage || isOkByStatus)) {
|
||||
|
||||
// Provide more specific error messages for common cases
|
||||
let userFriendlyMessage = `API Error: ${errorMessage}`;
|
||||
if (errorMessage === "NG") {
|
||||
@ -211,12 +233,7 @@ export class FreebititService {
|
||||
userFriendlyMessage,
|
||||
});
|
||||
|
||||
throw new FreebititErrorImpl(
|
||||
userFriendlyMessage,
|
||||
rc,
|
||||
statusCodeStr,
|
||||
errorMessage
|
||||
);
|
||||
throw new FreebititErrorImpl(userFriendlyMessage, rc, statusCodeStr, errorMessage);
|
||||
}
|
||||
|
||||
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)
|
||||
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 url = `${this.config.baseUrl}${endpoint}`;
|
||||
const payload = { ...body, authKey };
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => null);
|
||||
this.logger.error('Freebit JSON API non-OK', {
|
||||
this.logger.error("Freebit JSON API non-OK", {
|
||||
endpoint,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
@ -256,19 +276,29 @@ export class FreebititService {
|
||||
});
|
||||
throw new InternalServerErrorException(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
const data = (await response.json()) as T;
|
||||
const rc = String((data as any)?.resultCode ?? '');
|
||||
if (rc !== '100') {
|
||||
const message = (data as any)?.message || (data as any)?.status?.message || 'Unknown error';
|
||||
this.logger.error('Freebit JSON API error response', {
|
||||
const data = (await response.json()) as T & {
|
||||
resultCode?: string | number;
|
||||
message?: string;
|
||||
status?: { message?: string; statusCode?: string | number };
|
||||
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,
|
||||
resultCode: rc,
|
||||
statusCode: (data as any)?.statusCode || (data as any)?.status?.statusCode,
|
||||
statusCode: data?.statusCode || data?.status?.statusCode,
|
||||
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;
|
||||
}
|
||||
|
||||
@ -391,8 +421,13 @@ export class FreebititService {
|
||||
simType: simData.eid ? "esim" : "physical",
|
||||
size: ((): "standard" | "nano" | "micro" | "esim" => {
|
||||
const sizeVal = String(simData.size ?? "").toLowerCase();
|
||||
if (sizeVal === "standard" || sizeVal === "nano" || sizeVal === "micro" || sizeVal === "esim") {
|
||||
return sizeVal as "standard" | "nano" | "micro" | "esim";
|
||||
if (
|
||||
sizeVal === "standard" ||
|
||||
sizeVal === "nano" ||
|
||||
sizeVal === "micro" ||
|
||||
sizeVal === "esim"
|
||||
) {
|
||||
return sizeVal;
|
||||
}
|
||||
return simData.eid ? "esim" : "nano";
|
||||
})(),
|
||||
@ -645,44 +680,48 @@ export class FreebititService {
|
||||
): Promise<void> {
|
||||
try {
|
||||
const doVoice =
|
||||
typeof features.voiceMailEnabled === 'boolean' ||
|
||||
typeof features.callWaitingEnabled === 'boolean' ||
|
||||
typeof features.internationalRoamingEnabled === 'boolean';
|
||||
const doContract = typeof features.networkType === 'string';
|
||||
typeof features.voiceMailEnabled === "boolean" ||
|
||||
typeof features.callWaitingEnabled === "boolean" ||
|
||||
typeof features.internationalRoamingEnabled === "boolean";
|
||||
const doContract = typeof features.networkType === "string";
|
||||
|
||||
if (doVoice) {
|
||||
const talkOption: any = {};
|
||||
if (typeof features.voiceMailEnabled === 'boolean') {
|
||||
talkOption.voiceMail = features.voiceMailEnabled ? '10' : '20';
|
||||
const talkOption: {
|
||||
voiceMail?: "10" | "20";
|
||||
callWaiting?: "10" | "20";
|
||||
worldWing?: "10" | "20";
|
||||
} = {};
|
||||
if (typeof features.voiceMailEnabled === "boolean") {
|
||||
talkOption.voiceMail = features.voiceMailEnabled ? "10" : "20";
|
||||
}
|
||||
if (typeof features.callWaitingEnabled === 'boolean') {
|
||||
talkOption.callWaiting = features.callWaitingEnabled ? '10' : '20';
|
||||
if (typeof features.callWaitingEnabled === "boolean") {
|
||||
talkOption.callWaiting = features.callWaitingEnabled ? "10" : "20";
|
||||
}
|
||||
if (typeof features.internationalRoamingEnabled === 'boolean') {
|
||||
talkOption.worldWing = features.internationalRoamingEnabled ? '10' : '20';
|
||||
if (typeof features.internationalRoamingEnabled === "boolean") {
|
||||
talkOption.worldWing = features.internationalRoamingEnabled ? "10" : "20";
|
||||
}
|
||||
await this.makeAuthenticatedRequest<import('./interfaces/freebit.types').FreebititVoiceOptionChangeResponse>(
|
||||
'/mvno/talkoption/changeOrder/',
|
||||
await this.makeAuthenticatedRequest<FreebititVoiceOptionChangeResponse>(
|
||||
"/mvno/talkoption/changeOrder/",
|
||||
{
|
||||
account,
|
||||
userConfirmed: '10',
|
||||
aladinOperated: '10',
|
||||
userConfirmed: "10",
|
||||
aladinOperated: "10",
|
||||
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) {
|
||||
// Contract line change endpoint expects form-encoded payload (json=...)
|
||||
await this.makeAuthenticatedRequest<import('./interfaces/freebit.types').FreebititContractLineChangeResponse>(
|
||||
'/mvno/contractline/change/',
|
||||
await this.makeAuthenticatedRequest<FreebititContractLineChangeResponse>(
|
||||
"/mvno/contractline/change/",
|
||||
{
|
||||
account,
|
||||
contractLine: features.networkType,
|
||||
}
|
||||
);
|
||||
this.logger.log('Applied contract line change (PA05-38)', {
|
||||
this.logger.log("Applied contract line change (PA05-38)", {
|
||||
account,
|
||||
contractLine: features.networkType,
|
||||
});
|
||||
@ -697,7 +736,10 @@ export class FreebititService {
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -707,15 +749,12 @@ export class FreebititService {
|
||||
*/
|
||||
async cancelSim(account: string, scheduledAt?: string): Promise<void> {
|
||||
try {
|
||||
const req: Omit<import('./interfaces/freebit.types').FreebititCancelAccountRequest, 'authKey'> = {
|
||||
kind: 'MVNO',
|
||||
const req: Omit<FreebititCancelAccountRequest, "authKey"> = {
|
||||
kind: "MVNO",
|
||||
account,
|
||||
runDate: scheduledAt,
|
||||
};
|
||||
await this.makeAuthenticatedRequest<import('./interfaces/freebit.types').FreebititCancelAccountResponse>(
|
||||
'/master/cnclAcnt/',
|
||||
req
|
||||
);
|
||||
await this.makeAuthenticatedRequest<FreebititCancelAccountResponse>("/master/cnclAcnt/", req);
|
||||
this.logger.log(`Successfully requested cancellation (PA02-04) for account ${account}`, {
|
||||
account,
|
||||
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
|
||||
*/
|
||||
|
||||
@ -191,15 +191,15 @@ export interface FreebititPlanChangeResponse {
|
||||
export interface FreebititVoiceOptionChangeRequest {
|
||||
authKey: string;
|
||||
account: string;
|
||||
userConfirmed: '10' | '20';
|
||||
aladinOperated: '10' | '20';
|
||||
userConfirmed: "10" | "20";
|
||||
aladinOperated: "10" | "20";
|
||||
talkOption: {
|
||||
voiceMail?: '10' | '20';
|
||||
callWaiting?: '10' | '20';
|
||||
worldWing?: '10' | '20';
|
||||
worldCall?: '10' | '20';
|
||||
callTransfer?: '10' | '20';
|
||||
callTransferNoId?: '10' | '20';
|
||||
voiceMail?: "10" | "20";
|
||||
callWaiting?: "10" | "20";
|
||||
worldWing?: "10" | "20";
|
||||
worldCall?: "10" | "20";
|
||||
callTransfer?: "10" | "20";
|
||||
callTransferNoId?: "10" | "20";
|
||||
worldCallCreditLimit?: string;
|
||||
worldWingCreditLimit?: string;
|
||||
};
|
||||
@ -217,7 +217,7 @@ export interface FreebititVoiceOptionChangeResponse {
|
||||
export interface FreebititContractLineChangeRequest {
|
||||
authKey: string;
|
||||
account: string;
|
||||
contractLine: '4G' | '5G';
|
||||
contractLine: "4G" | "5G";
|
||||
productNumber?: string;
|
||||
eid?: string;
|
||||
}
|
||||
|
||||
@ -242,6 +242,59 @@ function CheckoutContent() {
|
||||
...(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);
|
||||
router.push(`/orders/${response.sfOrderId}?status=success`);
|
||||
} catch (error) {
|
||||
|
||||
@ -31,9 +31,11 @@ export default function SimCancelPage() {
|
||||
useEffect(() => {
|
||||
const fetchDetails = async () => {
|
||||
try {
|
||||
const d = await authenticatedApi.get<SimDetails>(`/subscriptions/${subscriptionId}/sim/details`);
|
||||
const d = await authenticatedApi.get<SimDetails>(
|
||||
`/subscriptions/${subscriptionId}/sim/details`
|
||||
);
|
||||
setDetails(d);
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load SIM details");
|
||||
}
|
||||
};
|
||||
@ -69,9 +71,11 @@ export default function SimCancelPage() {
|
||||
const canProceedStep2 = !!details;
|
||||
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
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 canProceedStep3 = acceptTerms && !!cancelMonth && confirmMonthEnd && emailValid && emailsMatch;
|
||||
const canProceedStep3 =
|
||||
acceptTerms && !!cancelMonth && confirmMonthEnd && emailValid && emailsMatch;
|
||||
const runDate = cancelMonth ? `${cancelMonth}01` : undefined; // YYYYMM01
|
||||
|
||||
const submit = async () => {
|
||||
@ -84,7 +88,7 @@ export default function SimCancelPage() {
|
||||
});
|
||||
setMessage("Cancellation request submitted. You will receive a confirmation email.");
|
||||
setTimeout(() => router.push(`/subscriptions/${subscriptionId}#sim-management`), 1500);
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Failed to submit cancellation");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@ -95,7 +99,12 @@ export default function SimCancelPage() {
|
||||
<DashboardLayout>
|
||||
<div className="max-w-4xl mx-auto p-6 space-y-6">
|
||||
<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>
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
{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">
|
||||
@ -111,18 +122,37 @@ export default function SimCancelPage() {
|
||||
|
||||
{step === 1 && (
|
||||
<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">
|
||||
<InfoRow label="SIM #" value={details?.msisdn || "—"} />
|
||||
<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="SIM Status" value={details?.status || "—"} />
|
||||
</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">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
@ -131,31 +161,48 @@ export default function SimCancelPage() {
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<Notice title="Cancellation Procedure">
|
||||
Online cancellations must be made from this website by the 25th of the desired cancellation month.
|
||||
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.
|
||||
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
|
||||
Online cancellations must be made from this website by the 25th of the desired
|
||||
cancellation month. 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. 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 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).
|
||||
If the minimum contract term is not fulfilled, the monthly fees of the remaining months will be charged upon cancellation.
|
||||
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). If the minimum contract term is not
|
||||
fulfilled, the monthly fees of the remaining months will be charged upon
|
||||
cancellation.
|
||||
</Notice>
|
||||
<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.
|
||||
Upon cancelling the base plan, all additional options associated with the requested SIM plan will be cancelled.
|
||||
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. Upon
|
||||
cancelling the base plan, all additional options associated with the requested SIM
|
||||
plan will be cancelled.
|
||||
</Notice>
|
||||
<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.
|
||||
</Notice>4
|
||||
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.
|
||||
</Notice>
|
||||
4
|
||||
</div>
|
||||
<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 || "—"} />
|
||||
<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
|
||||
value={cancelMonth}
|
||||
onChange={(e) => {
|
||||
onChange={e => {
|
||||
setCancelMonth(e.target.value);
|
||||
// Require re-confirmation if month changes
|
||||
setConfirmMonthEnd(false);
|
||||
@ -164,31 +211,54 @@ The cancellation request through this website retains to your SIM subscriptions
|
||||
>
|
||||
<option value="">Select month…</option>
|
||||
{monthOptions.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</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 className="flex items-center gap-2">
|
||||
<input 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>
|
||||
<input
|
||||
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 className="flex items-start gap-2">
|
||||
<input
|
||||
id="confirmMonthEnd"
|
||||
type="checkbox"
|
||||
checked={confirmMonthEnd}
|
||||
onChange={(e) => setConfirmMonthEnd(e.target.checked)}
|
||||
onChange={e => setConfirmMonthEnd(e.target.checked)}
|
||||
disabled={!cancelMonth}
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
<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 disabled={!canProceedStep3} onClick={() => setStep(3)} className="px-4 py-2 rounded-md bg-blue-600 text-white text-sm disabled:opacity-50">Next</button>
|
||||
<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>
|
||||
)}
|
||||
@ -196,54 +266,94 @@ The cancellation request through this website retains to your SIM subscriptions
|
||||
{step === 3 && (
|
||||
<div className="space-y-6">
|
||||
<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
|
||||
{" "}
|
||||
<a href="mailto:info@asolutions.co.jp" className="text-blue-600 underline">info@asolutions.co.jp</a>.
|
||||
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{" "}
|
||||
<a href="mailto:info@asolutions.co.jp" className="text-blue-600 underline">
|
||||
info@asolutions.co.jp
|
||||
</a>
|
||||
.
|
||||
</Notice>
|
||||
{registeredEmail && (
|
||||
<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 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 className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<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>
|
||||
<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 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>
|
||||
<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." />
|
||||
<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>
|
||||
<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>
|
||||
{/* Validation messages for email fields */}
|
||||
{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 && (
|
||||
<div className="text-xs text-red-600">Email addresses do not match.</div>
|
||||
)}
|
||||
<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 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
|
||||
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();
|
||||
}
|
||||
}}
|
||||
disabled={loading || !runDate || !canProceedStep3}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -25,7 +25,7 @@ export default function EsimReissuePage() {
|
||||
`/subscriptions/${subscriptionId}/sim/details`
|
||||
);
|
||||
setOldEid(data?.eid || null);
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load SIM details");
|
||||
} finally {
|
||||
setDetailsLoading(false);
|
||||
@ -49,7 +49,7 @@ export default function EsimReissuePage() {
|
||||
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/reissue-esim`, { newEid });
|
||||
setMessage("eSIM reissue requested successfully. You will receive the new profile shortly.");
|
||||
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");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@ -60,11 +60,19 @@ export default function EsimReissuePage() {
|
||||
<DashboardLayout>
|
||||
<div className="max-w-2xl mx-auto p-6">
|
||||
<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 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>
|
||||
<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 ? (
|
||||
<div className="text-gray-600">Loading current eSIM details…</div>
|
||||
@ -78,20 +86,24 @@ export default function EsimReissuePage() {
|
||||
)}
|
||||
|
||||
{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 && (
|
||||
<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>
|
||||
<label className="block text-sm font-medium text-gray-700">New EID</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={newEid}
|
||||
onChange={(e) => setNewEid(e.target.value.trim())}
|
||||
onChange={e => setNewEid(e.target.value.trim())}
|
||||
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"
|
||||
maxLength={32}
|
||||
@ -106,7 +118,10 @@ export default function EsimReissuePage() {
|
||||
>
|
||||
{loading ? "Processing…" : "Submit Reissue"}
|
||||
</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
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@ -258,9 +258,9 @@ export function SimDetailsCard({
|
||||
</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)} • {`${simDetails.size} SIM`}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{formatPlan(simDetails.planCode)} • {`${simDetails.size} SIM`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
|
||||
@ -18,4 +18,3 @@ export function formatPlanShort(planCode?: string): string {
|
||||
}
|
||||
return planCode;
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user