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

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
) {}
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({

View File

@ -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: [],
},

View File

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

View File

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

View File

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

View File

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

View File

@ -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&apos;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>

View File

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

View File

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

View File

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