Enhance SIM management features and introduce new cancellation and plan change flows
- Added new models and request types for enhanced SIM cancellation and plan change functionalities. - Implemented full cancellation flow with email notifications and confirmation handling. - Introduced enhanced plan change request with Salesforce product mapping and scheduling. - Updated UI components for better user experience during SIM management actions. - Improved error handling and validation for cancellation and plan change requests.
This commit is contained in:
parent
f6659e363a
commit
f49e5d7574
9
.cursor/rules/portal-rule.mdc
Normal file
9
.cursor/rules/portal-rule.mdc
Normal file
@ -0,0 +1,9 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
## Codebase Coding Standard
|
||||
|
||||
1. Have types and validation in the shared domain layer.
|
||||
2. Keep business logic out of the frontend; use services and APIs instead.
|
||||
3. Reuse existing types and functions; extend them when additional behavior is needed.
|
||||
4. Follow the established folder structures documented in docs/STRUCTURE.md.
|
||||
@ -44,6 +44,7 @@
|
||||
"@nestjs/throttler": "^6.4.0",
|
||||
"@prisma/client": "^6.14.0",
|
||||
"@sendgrid/mail": "^8.1.6",
|
||||
"@types/ssh2-sftp-client": "^9.0.5",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bullmq": "^5.58.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
@ -56,7 +57,6 @@
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"nestjs-pino": "^4.4.0",
|
||||
"nestjs-zod": "^5.0.1",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"p-queue": "^7.4.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
@ -69,6 +69,8 @@
|
||||
"rxjs": "^7.8.2",
|
||||
"salesforce-pubsub-api-client": "^5.5.0",
|
||||
"speakeasy": "^2.0.0",
|
||||
"ssh2-sftp-client": "^12.0.1",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"uuid": "^13.0.0",
|
||||
"zod": "^4.1.9"
|
||||
},
|
||||
|
||||
@ -180,3 +180,78 @@ model SimVoiceOptions {
|
||||
|
||||
@@map("sim_voice_options")
|
||||
}
|
||||
|
||||
// Call history from SFTP monthly imports (domestic calls)
|
||||
model SimCallHistoryDomestic {
|
||||
id String @id @default(uuid())
|
||||
account String // Customer phone number (e.g., "08077052946")
|
||||
callDate DateTime @db.Date @map("call_date") // Date the call was made
|
||||
callTime String @map("call_time") // Start time of the call (HHMMSS)
|
||||
calledTo String @map("called_to") // Phone number called
|
||||
location String? // Location info
|
||||
durationSec Int @map("duration_sec") // Duration in seconds (320 = 32.0 sec)
|
||||
chargeYen Int @map("charge_yen") // Call charge in JPY
|
||||
month String // YYYY-MM format for filtering
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@unique([account, callDate, callTime, calledTo])
|
||||
@@index([account, month])
|
||||
@@index([account, callDate])
|
||||
@@map("sim_call_history_domestic")
|
||||
}
|
||||
|
||||
// Call history from SFTP monthly imports (international calls)
|
||||
model SimCallHistoryInternational {
|
||||
id String @id @default(uuid())
|
||||
account String // Customer phone number
|
||||
callDate DateTime @db.Date @map("call_date") // Date the call was made
|
||||
startTime String @map("start_time") // Start time of the call
|
||||
stopTime String? @map("stop_time") // Stop time (if available)
|
||||
country String? // Country/location for international calls
|
||||
calledTo String @map("called_to") // Phone number called
|
||||
durationSec Int @map("duration_sec") // Duration in seconds
|
||||
chargeYen Int @map("charge_yen") // Call charge in JPY
|
||||
month String // YYYY-MM format for filtering
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@unique([account, callDate, startTime, calledTo])
|
||||
@@index([account, month])
|
||||
@@index([account, callDate])
|
||||
@@map("sim_call_history_international")
|
||||
}
|
||||
|
||||
// SMS history from SFTP monthly imports
|
||||
model SimSmsHistory {
|
||||
id String @id @default(uuid())
|
||||
account String // Customer phone number
|
||||
smsDate DateTime @db.Date @map("sms_date") // Date the SMS was sent
|
||||
smsTime String @map("sms_time") // Time the SMS was sent
|
||||
sentTo String @map("sent_to") // Phone number SMS was sent to
|
||||
smsType SmsType @default(DOMESTIC) @map("sms_type") // SMS or 国際SMS
|
||||
month String // YYYY-MM format for filtering
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@unique([account, smsDate, smsTime, sentTo])
|
||||
@@index([account, month])
|
||||
@@index([account, smsDate])
|
||||
@@map("sim_sms_history")
|
||||
}
|
||||
|
||||
enum SmsType {
|
||||
DOMESTIC // SMS
|
||||
INTERNATIONAL // 国際SMS
|
||||
}
|
||||
|
||||
// Track which months have been imported
|
||||
model SimHistoryImport {
|
||||
id String @id @default(uuid())
|
||||
month String @unique // YYYY-MM format
|
||||
talkFile String? @map("talk_file") // Filename imported
|
||||
smsFile String? @map("sms_file") // Filename imported
|
||||
talkRecords Int @default(0) @map("talk_records") // Records imported
|
||||
smsRecords Int @default(0) @map("sms_records") // Records imported
|
||||
importedAt DateTime @default(now()) @map("imported_at")
|
||||
status String @default("completed") // completed, failed, partial
|
||||
|
||||
@@map("sim_history_imports")
|
||||
}
|
||||
|
||||
@ -24,6 +24,8 @@ import type {
|
||||
FreebitVoiceOptionResponse,
|
||||
FreebitCancelPlanRequest,
|
||||
FreebitCancelPlanResponse,
|
||||
FreebitCancelAccountRequest,
|
||||
FreebitCancelAccountResponse,
|
||||
FreebitEsimReissueRequest,
|
||||
FreebitEsimReissueResponse,
|
||||
FreebitEsimAddAccountRequest,
|
||||
@ -738,15 +740,7 @@ export class FreebitOperationsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel SIM service
|
||||
* Uses PA02-04 cancellation endpoint
|
||||
*
|
||||
* IMPORTANT CONSTRAINTS:
|
||||
* - Must be sent with runDate as 1st of client's cancellation month n+1
|
||||
* (e.g., cancel end of Jan = runDate 20250201)
|
||||
* - After PA02-04 is sent, any subsequent PA05-21 calls will cancel it
|
||||
* - PA05-21 and PA02-04 cannot coexist
|
||||
* - Must prevent clients from making further changes after cancellation is requested
|
||||
* Cancel SIM plan (PA05-04 - plan cancellation only)
|
||||
*/
|
||||
async cancelSim(account: string, scheduledAt?: string): Promise<void> {
|
||||
try {
|
||||
@ -755,10 +749,9 @@ export class FreebitOperationsService {
|
||||
runTime: scheduledAt,
|
||||
};
|
||||
|
||||
this.logger.log(`Cancelling SIM service via PA02-04 for account ${account}`, {
|
||||
this.logger.log(`Cancelling SIM plan via PA05-04 for account ${account}`, {
|
||||
account,
|
||||
runTime: scheduledAt,
|
||||
note: "After this, PA05-21 plan changes will cancel the cancellation",
|
||||
});
|
||||
|
||||
await this.client.makeAuthenticatedRequest<FreebitCancelPlanResponse, typeof request>(
|
||||
@ -766,19 +759,64 @@ export class FreebitOperationsService {
|
||||
request
|
||||
);
|
||||
|
||||
this.logger.log(`Successfully cancelled SIM for account ${account}`, {
|
||||
this.logger.log(`Successfully cancelled SIM plan for account ${account}`, {
|
||||
account,
|
||||
runTime: scheduledAt,
|
||||
});
|
||||
this.stampOperation(account, "cancellation");
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
this.logger.error(`Failed to cancel SIM for account ${account}`, {
|
||||
this.logger.error(`Failed to cancel SIM plan for account ${account}`, {
|
||||
account,
|
||||
scheduledAt,
|
||||
error: message,
|
||||
});
|
||||
throw new BadRequestException(`Failed to cancel SIM: ${message}`);
|
||||
throw new BadRequestException(`Failed to cancel SIM plan: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel SIM account (PA02-04 - full account cancellation)
|
||||
*
|
||||
* IMPORTANT CONSTRAINTS:
|
||||
* - Must be sent with runDate as 1st of client's cancellation month n+1
|
||||
* (e.g., cancel end of Jan = runDate 20250201)
|
||||
* - After PA02-04 is sent, any subsequent PA05-21 calls will cancel it
|
||||
* - PA05-21 and PA02-04 cannot coexist
|
||||
* - Must prevent clients from making further changes after cancellation is requested
|
||||
*/
|
||||
async cancelAccount(account: string, runDate?: string): Promise<void> {
|
||||
try {
|
||||
const request: Omit<FreebitCancelAccountRequest, "authKey"> = {
|
||||
kind: "MVNO",
|
||||
account,
|
||||
runDate,
|
||||
};
|
||||
|
||||
this.logger.log(`Cancelling SIM account via PA02-04 for account ${account}`, {
|
||||
account,
|
||||
runDate,
|
||||
note: "After this, PA05-21 plan changes will cancel the cancellation",
|
||||
});
|
||||
|
||||
await this.client.makeAuthenticatedRequest<FreebitCancelAccountResponse, typeof request>(
|
||||
"/master/cnclAcnt/",
|
||||
request
|
||||
);
|
||||
|
||||
this.logger.log(`Successfully cancelled SIM account for account ${account}`, {
|
||||
account,
|
||||
runDate,
|
||||
});
|
||||
this.stampOperation(account, "cancellation");
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
this.logger.error(`Failed to cancel SIM account for account ${account}`, {
|
||||
account,
|
||||
runDate,
|
||||
error: message,
|
||||
});
|
||||
throw new BadRequestException(`Failed to cancel SIM account: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -79,13 +79,21 @@ export class FreebitOrchestratorService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel SIM service
|
||||
* Cancel SIM service (plan cancellation - PA05-04)
|
||||
*/
|
||||
async cancelSim(account: string, scheduledAt?: string): Promise<void> {
|
||||
const normalizedAccount = this.mapper.normalizeAccount(account);
|
||||
return this.operations.cancelSim(normalizedAccount, scheduledAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel SIM account (full account cancellation - PA02-04)
|
||||
*/
|
||||
async cancelAccount(account: string, runDate?: string): Promise<void> {
|
||||
const normalizedAccount = this.mapper.normalizeAccount(account);
|
||||
return this.operations.cancelAccount(normalizedAccount, runDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reissue eSIM profile (simple)
|
||||
*/
|
||||
|
||||
181
apps/bff/src/integrations/sftp/sftp-client.service.ts
Normal file
181
apps/bff/src/integrations/sftp/sftp-client.service.ts
Normal file
@ -0,0 +1,181 @@
|
||||
import { Injectable, Inject, OnModuleDestroy } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import SftpClient from "ssh2-sftp-client";
|
||||
|
||||
export interface SftpConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SftpClientService implements OnModuleDestroy {
|
||||
private client: SftpClient | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
private getConfig(): SftpConfig {
|
||||
return {
|
||||
host: this.configService.get<string>("SFTP_HOST") || "fs.mvno.net",
|
||||
port: this.configService.get<number>("SFTP_PORT") || 22,
|
||||
username: this.configService.get<string>("SFTP_USERNAME") || "PASI",
|
||||
password: this.configService.get<string>("SFTP_PASSWORD") || "",
|
||||
};
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.disconnect();
|
||||
}
|
||||
|
||||
private async connect(): Promise<SftpClient> {
|
||||
if (this.client) {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
const config = this.getConfig();
|
||||
this.client = new SftpClient();
|
||||
|
||||
try {
|
||||
this.logger.log(`Connecting to SFTP: ${config.host}:${config.port}`);
|
||||
await this.client.connect({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
username: config.username,
|
||||
password: config.password,
|
||||
});
|
||||
this.logger.log(`Connected to SFTP: ${config.host}`);
|
||||
return this.client;
|
||||
} catch (error) {
|
||||
this.client = null;
|
||||
this.logger.error(`SFTP connection failed`, { error, host: config.host });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.client) {
|
||||
try {
|
||||
await this.client.end();
|
||||
this.logger.log("SFTP connection closed");
|
||||
} catch (error) {
|
||||
this.logger.warn("Error closing SFTP connection", { error });
|
||||
} finally {
|
||||
this.client = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a file from SFTP and return its contents as string
|
||||
*/
|
||||
async downloadFileAsString(remotePath: string): Promise<string> {
|
||||
const client = await this.connect();
|
||||
|
||||
try {
|
||||
this.logger.log(`Downloading file: ${remotePath}`);
|
||||
const buffer = await client.get(remotePath);
|
||||
|
||||
if (Buffer.isBuffer(buffer)) {
|
||||
const content = buffer.toString("utf-8");
|
||||
this.logger.log(`Downloaded file: ${remotePath} (${content.length} bytes)`);
|
||||
return content;
|
||||
}
|
||||
|
||||
// If it's a stream, convert to string
|
||||
if (typeof buffer === "string") {
|
||||
return buffer;
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected response type from SFTP get: ${typeof buffer}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to download file: ${remotePath}`, { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List files in a directory
|
||||
*/
|
||||
async listFiles(remotePath: string = "/"): Promise<string[]> {
|
||||
const client = await this.connect();
|
||||
|
||||
try {
|
||||
this.logger.log(`Listing files in: ${remotePath}`);
|
||||
const files = await client.list(remotePath);
|
||||
const fileNames = files.map(f => f.name);
|
||||
this.logger.log(`Found ${fileNames.length} files in ${remotePath}`);
|
||||
return fileNames;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to list files: ${remotePath}`, { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file exists
|
||||
*/
|
||||
async fileExists(remotePath: string): Promise<boolean> {
|
||||
const client = await this.connect();
|
||||
|
||||
try {
|
||||
const exists = await client.exists(remotePath);
|
||||
return exists !== false;
|
||||
} catch (error) {
|
||||
this.logger.warn(`Error checking file existence: ${remotePath}`, { error });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the talk detail file for a specific month
|
||||
* Format: PASI_talk-detail-YYYYMM.csv
|
||||
*/
|
||||
getTalkDetailFileName(yearMonth: string): string {
|
||||
// yearMonth should be YYYYMM format (e.g., "202509")
|
||||
return `PASI_talk-detail-${yearMonth}.csv`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the SMS detail file for a specific month
|
||||
* Format: PASI_sms-detail-YYYYMM.csv
|
||||
*/
|
||||
getSmsDetailFileName(yearMonth: string): string {
|
||||
// yearMonth should be YYYYMM format (e.g., "202509")
|
||||
return `PASI_sms-detail-${yearMonth}.csv`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the month string for import (2 months behind current)
|
||||
* e.g., If current month is November 2025, returns "202509" (September 2025)
|
||||
*/
|
||||
getAvailableMonth(): string {
|
||||
const now = new Date();
|
||||
// Go back 2 months
|
||||
now.setMonth(now.getMonth() - 2);
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
return `${year}${month}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download talk detail CSV for a specific month
|
||||
*/
|
||||
async downloadTalkDetail(yearMonth: string): Promise<string> {
|
||||
const fileName = this.getTalkDetailFileName(yearMonth);
|
||||
return this.downloadFileAsString(`/${fileName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download SMS detail CSV for a specific month
|
||||
*/
|
||||
async downloadSmsDetail(yearMonth: string): Promise<string> {
|
||||
const fileName = this.getSmsDetailFileName(yearMonth);
|
||||
return this.downloadFileAsString(`/${fileName}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,22 +1,39 @@
|
||||
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service";
|
||||
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
||||
import { SimValidationService } from "./sim-validation.service";
|
||||
import { SimNotificationService } from "./sim-notification.service";
|
||||
import { SimApiNotificationService } from "./sim-api-notification.service";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
import type { SimReissueRequest } from "@customer-portal/domain/sim";
|
||||
|
||||
export interface ReissueSimRequest {
|
||||
simType: "physical" | "esim";
|
||||
newEid?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EsimManagementService {
|
||||
constructor(
|
||||
private readonly freebitService: FreebitOrchestratorService,
|
||||
private readonly whmcsService: WhmcsService,
|
||||
private readonly mappingsService: MappingsService,
|
||||
private readonly simValidation: SimValidationService,
|
||||
private readonly simNotification: SimNotificationService,
|
||||
private readonly apiNotification: SimApiNotificationService,
|
||||
private readonly configService: ConfigService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
private get freebitBaseUrl(): string {
|
||||
return this.configService.get<string>("FREEBIT_BASE_URL") || "https://i1.mvno.net/emptool/api";
|
||||
}
|
||||
|
||||
/**
|
||||
* Reissue eSIM profile
|
||||
* Reissue eSIM profile (legacy method)
|
||||
*/
|
||||
async reissueEsimProfile(
|
||||
userId: string,
|
||||
@ -75,4 +92,124 @@ export class EsimManagementService {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reissue SIM with full flow (eSIM via PA05-41, Physical SIM via email)
|
||||
*/
|
||||
async reissueSim(
|
||||
userId: string,
|
||||
subscriptionId: number,
|
||||
request: ReissueSimRequest
|
||||
): Promise<void> {
|
||||
const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||
const simDetails = await this.freebitService.getSimDetails(account);
|
||||
|
||||
// Get customer info from WHMCS
|
||||
const mapping = await this.mappingsService.findByUserId(userId);
|
||||
if (!mapping?.whmcsClientId) {
|
||||
throw new BadRequestException("WHMCS client mapping not found");
|
||||
}
|
||||
|
||||
const clientDetails = await this.whmcsService.getClientDetails(mapping.whmcsClientId);
|
||||
const customerName = `${clientDetails.firstname || ""} ${clientDetails.lastname || ""}`.trim() || "Customer";
|
||||
const customerEmail = clientDetails.email || "";
|
||||
|
||||
if (request.simType === "esim") {
|
||||
// eSIM reissue via PA05-41
|
||||
if (!request.newEid) {
|
||||
throw new BadRequestException("New EID is required for eSIM reissue");
|
||||
}
|
||||
|
||||
const oldEid = simDetails.eid;
|
||||
|
||||
this.logger.log(`Reissuing eSIM via PA05-41`, {
|
||||
userId,
|
||||
subscriptionId,
|
||||
account,
|
||||
oldEid,
|
||||
newEid: request.newEid,
|
||||
});
|
||||
|
||||
// Call PA05-41 with addKind: "R" for reissue
|
||||
await this.freebitService.activateEsimAccountNew({
|
||||
account,
|
||||
eid: request.newEid,
|
||||
addKind: "R",
|
||||
planCode: simDetails.planCode,
|
||||
});
|
||||
|
||||
// Send API results email to admin
|
||||
await this.apiNotification.sendApiResultsEmail(
|
||||
"SIM Re-issue Request",
|
||||
[
|
||||
{
|
||||
url: `${this.freebitBaseUrl}/mvno/esim/addAcnt/`,
|
||||
json: {
|
||||
reissue: { oldEid },
|
||||
account,
|
||||
addKind: "R",
|
||||
eid: request.newEid,
|
||||
authKey: "[REDACTED]",
|
||||
},
|
||||
result: {
|
||||
resultCode: "100",
|
||||
status: { message: "OK", statusCode: "200" },
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
// Send customer email
|
||||
const customerEmailBody = this.apiNotification.buildEsimReissueEmail(
|
||||
customerName,
|
||||
account,
|
||||
request.newEid
|
||||
);
|
||||
await this.apiNotification.sendCustomerEmail(
|
||||
customerEmail,
|
||||
"SIM Re-issue Request",
|
||||
customerEmailBody
|
||||
);
|
||||
|
||||
this.logger.log(`Successfully reissued eSIM for subscription ${subscriptionId}`, {
|
||||
userId,
|
||||
subscriptionId,
|
||||
account,
|
||||
oldEid,
|
||||
newEid: request.newEid,
|
||||
});
|
||||
} else {
|
||||
// Physical SIM reissue - email only, no API call
|
||||
this.logger.log(`Processing physical SIM reissue request`, {
|
||||
userId,
|
||||
subscriptionId,
|
||||
account,
|
||||
});
|
||||
|
||||
// Send admin notification email
|
||||
await this.apiNotification.sendApiResultsEmail(
|
||||
"Physical SIM Re-issue Request",
|
||||
[],
|
||||
`Physical SIM reissue requested for:\nCustomer: ${customerName}\nSIM #: ${account}\nSerial #: ${simDetails.iccid || "N/A"}\nEmail: ${customerEmail}`
|
||||
);
|
||||
|
||||
// Send customer email
|
||||
const customerEmailBody = this.apiNotification.buildPhysicalSimReissueEmail(
|
||||
customerName,
|
||||
account
|
||||
);
|
||||
await this.apiNotification.sendCustomerEmail(
|
||||
customerEmail,
|
||||
"Physical SIM Re-issue Request",
|
||||
customerEmailBody
|
||||
);
|
||||
|
||||
this.logger.log(`Sent physical SIM reissue request emails`, {
|
||||
userId,
|
||||
subscriptionId,
|
||||
account,
|
||||
customerEmail,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,179 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { EmailService } from "@bff/infra/email/email.service";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
|
||||
const ADMIN_EMAIL = "info@asolutions.co.jp";
|
||||
|
||||
export interface ApiCallLog {
|
||||
url: string;
|
||||
senddata?: Record<string, unknown> | string;
|
||||
json?: Record<string, unknown> | string;
|
||||
result: Record<string, unknown> | string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SimApiNotificationService {
|
||||
constructor(
|
||||
@Inject(Logger) private readonly logger: Logger,
|
||||
private readonly email: EmailService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Send API results notification email to admin
|
||||
*/
|
||||
async sendApiResultsEmail(
|
||||
subject: string,
|
||||
apiCalls: ApiCallLog[],
|
||||
additionalInfo?: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const call of apiCalls) {
|
||||
lines.push(`url: ${call.url}`);
|
||||
lines.push("");
|
||||
|
||||
if (call.senddata) {
|
||||
const senddataStr =
|
||||
typeof call.senddata === "string"
|
||||
? call.senddata
|
||||
: JSON.stringify(call.senddata, null, 2);
|
||||
lines.push(`senddata: ${senddataStr}`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (call.json) {
|
||||
const jsonStr =
|
||||
typeof call.json === "string"
|
||||
? call.json
|
||||
: JSON.stringify(call.json, null, 2);
|
||||
lines.push(`json: ${jsonStr}`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
const resultStr =
|
||||
typeof call.result === "string"
|
||||
? call.result
|
||||
: JSON.stringify(call.result, null, 2);
|
||||
lines.push(`result: ${resultStr}`);
|
||||
lines.push("");
|
||||
lines.push("---");
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (additionalInfo) {
|
||||
lines.push(additionalInfo);
|
||||
}
|
||||
|
||||
await this.email.sendEmail({
|
||||
to: ADMIN_EMAIL,
|
||||
from: ADMIN_EMAIL,
|
||||
subject,
|
||||
text: lines.join("\n"),
|
||||
});
|
||||
|
||||
this.logger.log("Sent API results notification email", {
|
||||
subject,
|
||||
to: ADMIN_EMAIL,
|
||||
callCount: apiCalls.length,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.warn("Failed to send API results notification email", {
|
||||
subject,
|
||||
error: getErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send customer notification email
|
||||
*/
|
||||
async sendCustomerEmail(
|
||||
to: string,
|
||||
subject: string,
|
||||
body: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.email.sendEmail({
|
||||
to,
|
||||
from: ADMIN_EMAIL,
|
||||
subject,
|
||||
text: body,
|
||||
});
|
||||
|
||||
this.logger.log("Sent customer notification email", {
|
||||
subject,
|
||||
to,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.warn("Failed to send customer notification email", {
|
||||
subject,
|
||||
to,
|
||||
error: getErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build eSIM reissue customer email body
|
||||
*/
|
||||
buildEsimReissueEmail(customerName: string, simNumber: string, newEid: string): string {
|
||||
return `Dear ${customerName},
|
||||
|
||||
This is to confirm that your request to re-issue the SIM card ${simNumber}
|
||||
to the EID=${newEid} has been accepted.
|
||||
|
||||
Please download the SIM plan, then follow the instructions to install the APN profile.
|
||||
|
||||
eSIM plan download: https://www.asolutions.co.jp/uploads/pdf/esim.pdf
|
||||
APN profile instructions: https://www.asolutions.co.jp/sim-card/
|
||||
|
||||
With best regards,
|
||||
Assist Solutions Customer Support
|
||||
TEL: 0120-660-470 (Mon-Fri / 10AM-6PM)
|
||||
Email: ${ADMIN_EMAIL}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build physical SIM reissue customer email body
|
||||
*/
|
||||
buildPhysicalSimReissueEmail(customerName: string, simNumber: string): string {
|
||||
return `Dear ${customerName},
|
||||
|
||||
This is to confirm that your request to re-issue the SIM card ${simNumber}
|
||||
as a physical SIM has been accepted.
|
||||
|
||||
You will be contacted by us again as soon as details about the shipping
|
||||
schedule can be disclosed (typically in 3-5 business days).
|
||||
|
||||
With best regards,
|
||||
Assist Solutions Customer Support
|
||||
TEL: 0120-660-470 (Mon-Fri / 10AM-6PM)
|
||||
Email: ${ADMIN_EMAIL}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build cancellation notification email body for admin
|
||||
*/
|
||||
buildCancellationAdminEmail(params: {
|
||||
customerName: string;
|
||||
simNumber: string;
|
||||
serialNumber?: string;
|
||||
cancellationMonth: string;
|
||||
registeredEmail: string;
|
||||
otherEmail?: string;
|
||||
comments?: string;
|
||||
}): string {
|
||||
return `The following SONIXNET SIM cancellation has been requested.
|
||||
|
||||
Customer name: ${params.customerName}
|
||||
SIM #: ${params.simNumber}
|
||||
Serial #: ${params.serialNumber || "N/A"}
|
||||
Cancellation month: ${params.cancellationMonth}
|
||||
Registered email address: ${params.registeredEmail}
|
||||
Other email address: ${params.otherEmail || "N/A"}
|
||||
Comments: ${params.comments || "N/A"}`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,24 +1,154 @@
|
||||
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service";
|
||||
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
||||
import { SimValidationService } from "./sim-validation.service";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
import type { SimCancelRequest } from "@customer-portal/domain/sim";
|
||||
import type { SimCancelRequest, SimCancelFullRequest } from "@customer-portal/domain/sim";
|
||||
import { SimScheduleService } from "./sim-schedule.service";
|
||||
import { SimActionRunnerService } from "./sim-action-runner.service";
|
||||
import { SimApiNotificationService } from "./sim-api-notification.service";
|
||||
|
||||
export interface CancellationMonth {
|
||||
value: string; // YYYY-MM format
|
||||
label: string; // Display label like "November 2025"
|
||||
runDate: string; // YYYYMMDD format for API (1st of next month)
|
||||
}
|
||||
|
||||
export interface CancellationPreview {
|
||||
simNumber: string;
|
||||
serialNumber?: string;
|
||||
planCode: string;
|
||||
startDate?: string;
|
||||
minimumContractEndDate?: string;
|
||||
isWithinMinimumTerm: boolean;
|
||||
availableMonths: CancellationMonth[];
|
||||
customerEmail: string;
|
||||
customerName: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SimCancellationService {
|
||||
constructor(
|
||||
private readonly freebitService: FreebitOrchestratorService,
|
||||
private readonly whmcsService: WhmcsService,
|
||||
private readonly mappingsService: MappingsService,
|
||||
private readonly simValidation: SimValidationService,
|
||||
private readonly simSchedule: SimScheduleService,
|
||||
private readonly simActionRunner: SimActionRunnerService,
|
||||
private readonly apiNotification: SimApiNotificationService,
|
||||
private readonly configService: ConfigService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
private get freebitBaseUrl(): string {
|
||||
return this.configService.get<string>("FREEBIT_BASE_URL") || "https://i1.mvno.net/emptool/api";
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel SIM service
|
||||
* Generate available cancellation months (next 12 months)
|
||||
*/
|
||||
private generateCancellationMonths(): CancellationMonth[] {
|
||||
const months: CancellationMonth[] = [];
|
||||
const today = new Date();
|
||||
const dayOfMonth = today.getDate();
|
||||
|
||||
// Start from current month if before 25th, otherwise next month
|
||||
const startOffset = dayOfMonth <= 25 ? 0 : 1;
|
||||
|
||||
for (let i = startOffset; i < startOffset + 12; i++) {
|
||||
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth() + 1;
|
||||
const monthStr = String(month).padStart(2, "0");
|
||||
|
||||
// runDate is the 1st of the NEXT month (cancellation takes effect at month end)
|
||||
const nextMonth = new Date(year, month, 1);
|
||||
const runYear = nextMonth.getFullYear();
|
||||
const runMonth = String(nextMonth.getMonth() + 1).padStart(2, "0");
|
||||
|
||||
months.push({
|
||||
value: `${year}-${monthStr}`,
|
||||
label: date.toLocaleDateString("en-US", { month: "long", year: "numeric" }),
|
||||
runDate: `${runYear}${runMonth}01`,
|
||||
});
|
||||
}
|
||||
|
||||
return months;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate minimum contract end date (3 months after start, signup month not included)
|
||||
*/
|
||||
private calculateMinimumContractEndDate(startDateStr: string): Date | null {
|
||||
if (!startDateStr || startDateStr.length < 8) return null;
|
||||
|
||||
// Parse YYYYMMDD format
|
||||
const year = parseInt(startDateStr.substring(0, 4), 10);
|
||||
const month = parseInt(startDateStr.substring(4, 6), 10) - 1;
|
||||
const day = parseInt(startDateStr.substring(6, 8), 10);
|
||||
|
||||
if (isNaN(year) || isNaN(month) || isNaN(day)) return null;
|
||||
|
||||
const startDate = new Date(year, month, day);
|
||||
// Minimum term is 3 months after signup month (signup month not included)
|
||||
// e.g., signup in January = minimum term ends April 30
|
||||
const endDate = new Date(startDate.getFullYear(), startDate.getMonth() + 4, 0);
|
||||
|
||||
return endDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cancellation preview with available months
|
||||
*/
|
||||
async getCancellationPreview(
|
||||
userId: string,
|
||||
subscriptionId: number
|
||||
): Promise<CancellationPreview> {
|
||||
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||
const simDetails = await this.freebitService.getSimDetails(validation.account);
|
||||
|
||||
// Get customer info from WHMCS
|
||||
const mapping = await this.mappingsService.findByUserId(userId);
|
||||
if (!mapping?.whmcsClientId) {
|
||||
throw new BadRequestException("WHMCS client mapping not found");
|
||||
}
|
||||
|
||||
const clientDetails = await this.whmcsService.getClientDetails(mapping.whmcsClientId);
|
||||
const customerName = `${clientDetails.firstname || ""} ${clientDetails.lastname || ""}`.trim() || "Customer";
|
||||
const customerEmail = clientDetails.email || "";
|
||||
|
||||
// Calculate minimum contract end date
|
||||
const startDate = simDetails.startDate;
|
||||
const minEndDate = startDate ? this.calculateMinimumContractEndDate(startDate) : null;
|
||||
const today = new Date();
|
||||
const isWithinMinimumTerm = minEndDate ? today < minEndDate : false;
|
||||
|
||||
// Format minimum contract end date for display
|
||||
let minimumContractEndDate: string | undefined;
|
||||
if (minEndDate) {
|
||||
const year = minEndDate.getFullYear();
|
||||
const month = String(minEndDate.getMonth() + 1).padStart(2, "0");
|
||||
minimumContractEndDate = `${year}-${month}`;
|
||||
}
|
||||
|
||||
return {
|
||||
simNumber: validation.account,
|
||||
serialNumber: simDetails.iccid,
|
||||
planCode: simDetails.planCode,
|
||||
startDate,
|
||||
minimumContractEndDate,
|
||||
isWithinMinimumTerm,
|
||||
availableMonths: this.generateCancellationMonths(),
|
||||
customerEmail,
|
||||
customerName,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel SIM service (legacy)
|
||||
*/
|
||||
async cancelSim(
|
||||
userId: string,
|
||||
@ -65,4 +195,123 @@ export class SimCancellationService {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel SIM service with full flow (PA02-04 and email notifications)
|
||||
*/
|
||||
async cancelSimFull(
|
||||
userId: string,
|
||||
subscriptionId: number,
|
||||
request: SimCancelFullRequest
|
||||
): Promise<void> {
|
||||
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||
const account = validation.account;
|
||||
const simDetails = await this.freebitService.getSimDetails(account);
|
||||
|
||||
// Get customer info from WHMCS
|
||||
const mapping = await this.mappingsService.findByUserId(userId);
|
||||
if (!mapping?.whmcsClientId) {
|
||||
throw new BadRequestException("WHMCS client mapping not found");
|
||||
}
|
||||
|
||||
const clientDetails = await this.whmcsService.getClientDetails(mapping.whmcsClientId);
|
||||
const customerName = `${clientDetails.firstname || ""} ${clientDetails.lastname || ""}`.trim() || "Customer";
|
||||
const customerEmail = clientDetails.email || "";
|
||||
|
||||
// Validate confirmations
|
||||
if (!request.confirmRead || !request.confirmCancel) {
|
||||
throw new BadRequestException("You must confirm both checkboxes to proceed");
|
||||
}
|
||||
|
||||
// Parse cancellation month and calculate runDate
|
||||
const [year, month] = request.cancellationMonth.split("-").map(Number);
|
||||
if (!year || !month) {
|
||||
throw new BadRequestException("Invalid cancellation month format");
|
||||
}
|
||||
|
||||
// runDate is 1st of the NEXT month (cancellation at end of selected month)
|
||||
const nextMonth = new Date(year, month, 1);
|
||||
const runYear = nextMonth.getFullYear();
|
||||
const runMonth = String(nextMonth.getMonth() + 1).padStart(2, "0");
|
||||
const runDate = `${runYear}${runMonth}01`;
|
||||
|
||||
this.logger.log(`Processing SIM cancellation via PA02-04`, {
|
||||
userId,
|
||||
subscriptionId,
|
||||
account,
|
||||
cancellationMonth: request.cancellationMonth,
|
||||
runDate,
|
||||
});
|
||||
|
||||
// Call PA02-04 cancellation API
|
||||
await this.freebitService.cancelAccount(account, runDate);
|
||||
|
||||
this.logger.log(`Successfully cancelled SIM for subscription ${subscriptionId}`, {
|
||||
userId,
|
||||
subscriptionId,
|
||||
account,
|
||||
runDate,
|
||||
});
|
||||
|
||||
// Send admin notification email
|
||||
const adminEmailBody = this.apiNotification.buildCancellationAdminEmail({
|
||||
customerName,
|
||||
simNumber: account,
|
||||
serialNumber: simDetails.iccid,
|
||||
cancellationMonth: request.cancellationMonth,
|
||||
registeredEmail: customerEmail,
|
||||
otherEmail: request.alternativeEmail || undefined,
|
||||
comments: request.comments,
|
||||
});
|
||||
|
||||
await this.apiNotification.sendApiResultsEmail(
|
||||
"SonixNet SIM Online Cancellation",
|
||||
[
|
||||
{
|
||||
url: `${this.freebitBaseUrl}/master/cnclAcnt/`,
|
||||
json: {
|
||||
kind: "MVNO",
|
||||
account,
|
||||
runDate,
|
||||
authKey: "[REDACTED]",
|
||||
},
|
||||
result: {
|
||||
resultCode: "100",
|
||||
status: { message: "OK", statusCode: "200" },
|
||||
},
|
||||
},
|
||||
],
|
||||
adminEmailBody
|
||||
);
|
||||
|
||||
// Send confirmation email to customer (and alternative if provided)
|
||||
const confirmationSubject = "SonixNet SIM Cancellation Confirmation";
|
||||
const confirmationBody = `Dear ${customerName},
|
||||
|
||||
Your cancellation request for SIM #${account} has been confirmed.
|
||||
|
||||
The cancellation will take effect at the end of ${request.cancellationMonth}.
|
||||
|
||||
If you have any questions, please contact us at info@asolutions.co.jp
|
||||
|
||||
With best regards,
|
||||
Assist Solutions Customer Support
|
||||
TEL: 0120-660-470 (Mon-Fri / 10AM-6PM)
|
||||
Email: info@asolutions.co.jp`;
|
||||
|
||||
await this.apiNotification.sendCustomerEmail(
|
||||
customerEmail,
|
||||
confirmationSubject,
|
||||
confirmationBody
|
||||
);
|
||||
|
||||
// Send to alternative email if provided
|
||||
if (request.alternativeEmail && request.alternativeEmail !== customerEmail) {
|
||||
await this.apiNotification.sendCustomerEmail(
|
||||
request.alternativeEmail,
|
||||
confirmationSubject,
|
||||
confirmationBody
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,12 +1,39 @@
|
||||
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service";
|
||||
import { SimValidationService } from "./sim-validation.service";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
import type { SimPlanChangeRequest, SimFeaturesUpdateRequest } from "@customer-portal/domain/sim";
|
||||
import type { SimPlanChangeRequest, SimFeaturesUpdateRequest, SimChangePlanFullRequest } from "@customer-portal/domain/sim";
|
||||
import { SimScheduleService } from "./sim-schedule.service";
|
||||
import { SimActionRunnerService } from "./sim-action-runner.service";
|
||||
import { SimManagementQueueService } from "../queue/sim-management.queue";
|
||||
import { SimApiNotificationService } from "./sim-api-notification.service";
|
||||
import { SimCatalogService } from "@bff/modules/catalog/services/sim-catalog.service";
|
||||
import type { SimCatalogProduct } from "@customer-portal/domain/catalog";
|
||||
|
||||
// Mapping from Salesforce SKU to Freebit plan code
|
||||
const SKU_TO_FREEBIT_PLAN_CODE: Record<string, string> = {
|
||||
"SIM-DATA-VOICE-5GB": "PASI_5G",
|
||||
"SIM-DATA-VOICE-10GB": "PASI_10G",
|
||||
"SIM-DATA-VOICE-25GB": "PASI_25G",
|
||||
"SIM-DATA-VOICE-50GB": "PASI_50G",
|
||||
"SIM-DATA-ONLY-5GB": "PASI_5G_DATA",
|
||||
"SIM-DATA-ONLY-10GB": "PASI_10G_DATA",
|
||||
"SIM-DATA-ONLY-25GB": "PASI_25G_DATA",
|
||||
"SIM-DATA-ONLY-50GB": "PASI_50G_DATA",
|
||||
"SIM-VOICE-ONLY": "PASI_VOICE",
|
||||
};
|
||||
|
||||
// Reverse mapping: Freebit plan code to Salesforce SKU
|
||||
const FREEBIT_PLAN_CODE_TO_SKU: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(SKU_TO_FREEBIT_PLAN_CODE).map(([sku, code]) => [code, sku])
|
||||
);
|
||||
|
||||
export interface AvailablePlan extends SimCatalogProduct {
|
||||
freebitPlanCode: string;
|
||||
isCurrentPlan: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SimPlanService {
|
||||
@ -16,11 +43,71 @@ export class SimPlanService {
|
||||
private readonly simSchedule: SimScheduleService,
|
||||
private readonly simActionRunner: SimActionRunnerService,
|
||||
private readonly simQueue: SimManagementQueueService,
|
||||
private readonly apiNotification: SimApiNotificationService,
|
||||
private readonly simCatalog: SimCatalogService,
|
||||
private readonly configService: ConfigService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
private get freebitBaseUrl(): string {
|
||||
return this.configService.get<string>("FREEBIT_BASE_URL") || "https://i1.mvno.net/emptool/api";
|
||||
}
|
||||
|
||||
/**
|
||||
* Change SIM plan
|
||||
* Get available plans for plan change
|
||||
* Filters by current plan type (e.g., only show DataSmsVoice plans if current is DataSmsVoice)
|
||||
*/
|
||||
async getAvailablePlans(
|
||||
userId: string,
|
||||
subscriptionId: number
|
||||
): Promise<AvailablePlan[]> {
|
||||
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||
const simDetails = await this.freebitService.getSimDetails(validation.account);
|
||||
const currentPlanCode = simDetails.planCode;
|
||||
const currentSku = FREEBIT_PLAN_CODE_TO_SKU[currentPlanCode];
|
||||
|
||||
// Get all plans from Salesforce
|
||||
const allPlans = await this.simCatalog.getPlans();
|
||||
|
||||
// Determine current plan type
|
||||
let currentPlanType: string | undefined;
|
||||
if (currentSku) {
|
||||
const currentPlan = allPlans.find(p => p.sku === currentSku);
|
||||
currentPlanType = currentPlan?.simPlanType;
|
||||
}
|
||||
|
||||
// Filter plans by type (e.g., only show DataSmsVoice if current is DataSmsVoice)
|
||||
const filteredPlans = currentPlanType
|
||||
? allPlans.filter(p => p.simPlanType === currentPlanType)
|
||||
: allPlans.filter(p => !p.simHasFamilyDiscount); // Default: non-family plans
|
||||
|
||||
// Map to AvailablePlan with Freebit codes
|
||||
return filteredPlans.map(plan => {
|
||||
const freebitPlanCode = SKU_TO_FREEBIT_PLAN_CODE[plan.sku] || plan.sku;
|
||||
return {
|
||||
...plan,
|
||||
freebitPlanCode,
|
||||
isCurrentPlan: freebitPlanCode === currentPlanCode,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Freebit plan code from Salesforce SKU
|
||||
*/
|
||||
getFreebitPlanCode(sku: string): string | undefined {
|
||||
return SKU_TO_FREEBIT_PLAN_CODE[sku];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Salesforce SKU from Freebit plan code
|
||||
*/
|
||||
getSalesforceSku(planCode: string): string | undefined {
|
||||
return FREEBIT_PLAN_CODE_TO_SKU[planCode];
|
||||
}
|
||||
|
||||
/**
|
||||
* Change SIM plan (basic)
|
||||
*/
|
||||
async changeSimPlan(
|
||||
userId: string,
|
||||
@ -95,6 +182,84 @@ export class SimPlanService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Change SIM plan with enhanced notifications and Salesforce SKU mapping
|
||||
*/
|
||||
async changeSimPlanFull(
|
||||
userId: string,
|
||||
subscriptionId: number,
|
||||
request: SimChangePlanFullRequest
|
||||
): Promise<{ ipv4?: string; ipv6?: string; scheduledAt?: string }> {
|
||||
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||
const account = validation.account;
|
||||
|
||||
// Get or derive Freebit plan code from SKU
|
||||
const freebitPlanCode = SKU_TO_FREEBIT_PLAN_CODE[request.newPlanSku] || request.newPlanCode;
|
||||
|
||||
if (!freebitPlanCode || freebitPlanCode.length < 3) {
|
||||
throw new BadRequestException("Invalid plan code");
|
||||
}
|
||||
|
||||
// Always schedule for 1st of following month
|
||||
const nextMonth = new Date();
|
||||
nextMonth.setMonth(nextMonth.getMonth() + 1);
|
||||
nextMonth.setDate(1);
|
||||
const year = nextMonth.getFullYear();
|
||||
const month = String(nextMonth.getMonth() + 1).padStart(2, "0");
|
||||
const scheduledAt = `${year}${month}01`;
|
||||
|
||||
this.logger.log("Submitting SIM plan change request (full)", {
|
||||
userId,
|
||||
subscriptionId,
|
||||
account,
|
||||
newPlanSku: request.newPlanSku,
|
||||
freebitPlanCode,
|
||||
scheduledAt,
|
||||
});
|
||||
|
||||
const result = await this.freebitService.changeSimPlan(account, freebitPlanCode, {
|
||||
assignGlobalIp: request.assignGlobalIp ?? false,
|
||||
scheduledAt,
|
||||
});
|
||||
|
||||
this.logger.log(`Successfully changed SIM plan for subscription ${subscriptionId}`, {
|
||||
userId,
|
||||
subscriptionId,
|
||||
account,
|
||||
newPlanCode: freebitPlanCode,
|
||||
scheduledAt,
|
||||
});
|
||||
|
||||
// Send API results email
|
||||
await this.apiNotification.sendApiResultsEmail(
|
||||
"API results - Plan Change",
|
||||
[
|
||||
{
|
||||
url: `${this.freebitBaseUrl}/mvno/changePlan/`,
|
||||
json: {
|
||||
account,
|
||||
planCode: freebitPlanCode,
|
||||
runTime: scheduledAt,
|
||||
authKey: "[REDACTED]",
|
||||
},
|
||||
result: {
|
||||
resultCode: "100",
|
||||
status: { message: "OK", statusCode: "200" },
|
||||
ipv4: result.ipv4 || "",
|
||||
ipv6: result.ipv6 || "",
|
||||
},
|
||||
},
|
||||
],
|
||||
`Plan changed to: ${request.newPlanName || freebitPlanCode}\nScheduled for: ${scheduledAt}`
|
||||
);
|
||||
|
||||
return {
|
||||
ipv4: result.ipv4,
|
||||
ipv6: result.ipv6,
|
||||
scheduledAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update SIM features (voicemail, call waiting, roaming, network type)
|
||||
*/
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
||||
import { SimValidationService } from "./sim-validation.service";
|
||||
@ -7,6 +8,7 @@ import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
import type { SimTopUpRequest } from "@customer-portal/domain/sim";
|
||||
import { SimBillingService } from "./sim-billing.service";
|
||||
import { SimActionRunnerService } from "./sim-action-runner.service";
|
||||
import { SimApiNotificationService } from "./sim-api-notification.service";
|
||||
|
||||
@Injectable()
|
||||
export class SimTopUpService {
|
||||
@ -16,9 +18,19 @@ export class SimTopUpService {
|
||||
private readonly simValidation: SimValidationService,
|
||||
private readonly simBilling: SimBillingService,
|
||||
private readonly simActionRunner: SimActionRunnerService,
|
||||
private readonly apiNotification: SimApiNotificationService,
|
||||
private readonly configService: ConfigService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
private get whmcsBaseUrl(): string {
|
||||
return this.configService.get<string>("WHMCS_BASE_URL") || "https://accounts.asolutions.co.jp/includes/api.php";
|
||||
}
|
||||
|
||||
private get freebitBaseUrl(): string {
|
||||
return this.configService.get<string>("FREEBIT_BASE_URL") || "https://i1.mvno.net/emptool/api";
|
||||
}
|
||||
|
||||
/**
|
||||
* Top up SIM data quota with payment processing
|
||||
* Pricing: 1GB = 500 JPY
|
||||
@ -88,8 +100,11 @@ export class SimTopUpService {
|
||||
metadata: { subscriptionId },
|
||||
});
|
||||
|
||||
// Call Freebit API to add quota
|
||||
let freebitResult: { resultCode: string; status: { message: string; statusCode: string } } | null = null;
|
||||
try {
|
||||
await this.freebitService.topUpSim(latestAccount, request.quotaMb, {});
|
||||
freebitResult = { resultCode: "100", status: { message: "OK", statusCode: "200" } };
|
||||
} catch (freebitError) {
|
||||
await this.handleFreebitFailureAfterPayment(
|
||||
freebitError,
|
||||
@ -112,6 +127,52 @@ export class SimTopUpService {
|
||||
transactionId: billing.transactionId,
|
||||
});
|
||||
|
||||
// Send API results email notification
|
||||
const today = new Date();
|
||||
const dateStr = today.toISOString().split("T")[0];
|
||||
const dueDate = new Date(today.getTime() + 2 * 24 * 60 * 60 * 1000).toISOString().split("T")[0];
|
||||
|
||||
await this.apiNotification.sendApiResultsEmail(
|
||||
"API results",
|
||||
[
|
||||
{
|
||||
url: this.whmcsBaseUrl,
|
||||
senddata: {
|
||||
itemdescription1: `Top-up data (${units}GB)\nSIM Number: ${latestAccount}`,
|
||||
itemamount1: String(costJpy),
|
||||
userid: String(mapping.whmcsClientId),
|
||||
date: dateStr,
|
||||
responsetype: "json",
|
||||
itemtaxed1: "1",
|
||||
action: "CreateInvoice",
|
||||
duedate: dueDate,
|
||||
paymentmethod: "stripe",
|
||||
sendinvoice: "1",
|
||||
},
|
||||
result: { result: "success", invoiceid: billing.invoice.id, status: billing.invoice.status },
|
||||
},
|
||||
{
|
||||
url: this.whmcsBaseUrl,
|
||||
senddata: {
|
||||
responsetype: "json",
|
||||
action: "CapturePayment",
|
||||
invoiceid: billing.invoice.id,
|
||||
},
|
||||
result: { result: "success" },
|
||||
},
|
||||
{
|
||||
url: `${this.freebitBaseUrl}/master/addSpec/`,
|
||||
json: {
|
||||
quota: request.quotaMb,
|
||||
kind: "MVNO",
|
||||
account: latestAccount,
|
||||
authKey: "[REDACTED]",
|
||||
},
|
||||
result: freebitResult || { resultCode: "100", status: { message: "OK", statusCode: "200" } },
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
return {
|
||||
account: latestAccount,
|
||||
costJpy,
|
||||
|
||||
@ -18,12 +18,14 @@ import { SimCancellationService } from "./services/sim-cancellation.service";
|
||||
import { EsimManagementService } from "./services/esim-management.service";
|
||||
import { SimValidationService } from "./services/sim-validation.service";
|
||||
import { SimNotificationService } from "./services/sim-notification.service";
|
||||
import { SimApiNotificationService } from "./services/sim-api-notification.service";
|
||||
import { SimBillingService } from "./services/sim-billing.service";
|
||||
import { SimScheduleService } from "./services/sim-schedule.service";
|
||||
import { SimActionRunnerService } from "./services/sim-action-runner.service";
|
||||
import { SimManagementQueueService } from "./queue/sim-management.queue";
|
||||
import { SimManagementProcessor } from "./queue/sim-management.processor";
|
||||
import { SimVoiceOptionsService } from "./services/sim-voice-options.service";
|
||||
import { CatalogModule } from "@bff/modules/catalog/catalog.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -32,6 +34,7 @@ import { SimVoiceOptionsService } from "./services/sim-voice-options.service";
|
||||
SalesforceModule,
|
||||
MappingsModule,
|
||||
EmailModule,
|
||||
CatalogModule,
|
||||
],
|
||||
providers: [
|
||||
// Core services that the SIM services depend on
|
||||
@ -41,6 +44,7 @@ import { SimVoiceOptionsService } from "./services/sim-voice-options.service";
|
||||
// SIM management services
|
||||
SimValidationService,
|
||||
SimNotificationService,
|
||||
SimApiNotificationService,
|
||||
SimVoiceOptionsService,
|
||||
SimDetailsService,
|
||||
SimUsageService,
|
||||
@ -73,6 +77,7 @@ import { SimVoiceOptionsService } from "./services/sim-voice-options.service";
|
||||
EsimManagementService,
|
||||
SimValidationService,
|
||||
SimNotificationService,
|
||||
SimApiNotificationService,
|
||||
SimBillingService,
|
||||
SimScheduleService,
|
||||
SimActionRunnerService,
|
||||
|
||||
@ -32,13 +32,20 @@ import {
|
||||
simChangePlanRequestSchema,
|
||||
simCancelRequestSchema,
|
||||
simFeaturesRequestSchema,
|
||||
simCancelFullRequestSchema,
|
||||
simChangePlanFullRequestSchema,
|
||||
type SimTopupRequest,
|
||||
type SimChangePlanRequest,
|
||||
type SimCancelRequest,
|
||||
type SimFeaturesRequest,
|
||||
type SimCancelFullRequest,
|
||||
type SimChangePlanFullRequest,
|
||||
} from "@customer-portal/domain/sim";
|
||||
import { ZodValidationPipe } from "@customer-portal/validation/nestjs";
|
||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types";
|
||||
import { SimPlanService } from "./sim-management/services/sim-plan.service";
|
||||
import { SimCancellationService } from "./sim-management/services/sim-cancellation.service";
|
||||
import { EsimManagementService, type ReissueSimRequest } from "./sim-management/services/esim-management.service";
|
||||
|
||||
const subscriptionInvoiceQuerySchema = createPaginationSchema({
|
||||
defaultLimit: 10,
|
||||
@ -52,7 +59,10 @@ export class SubscriptionsController {
|
||||
constructor(
|
||||
private readonly subscriptionsService: SubscriptionsService,
|
||||
private readonly simManagementService: SimManagementService,
|
||||
private readonly simTopUpPricingService: SimTopUpPricingService
|
||||
private readonly simTopUpPricingService: SimTopUpPricingService,
|
||||
private readonly simPlanService: SimPlanService,
|
||||
private readonly simCancellationService: SimCancellationService,
|
||||
private readonly esimManagementService: EsimManagementService
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ -228,4 +238,88 @@ export class SubscriptionsController {
|
||||
await this.simManagementService.updateSimFeatures(req.user.id, subscriptionId, body);
|
||||
return { success: true, message: "SIM features updated successfully" };
|
||||
}
|
||||
|
||||
// ==================== Enhanced SIM Management Endpoints ====================
|
||||
|
||||
/**
|
||||
* Get available plans for plan change (filtered by current plan type)
|
||||
*/
|
||||
@Get(":id/sim/available-plans")
|
||||
@Header("Cache-Control", "private, max-age=300")
|
||||
async getAvailablePlans(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number
|
||||
) {
|
||||
const plans = await this.simPlanService.getAvailablePlans(req.user.id, subscriptionId);
|
||||
return { success: true, data: plans };
|
||||
}
|
||||
|
||||
/**
|
||||
* Change SIM plan with enhanced flow (Salesforce SKU mapping + email notifications)
|
||||
*/
|
||||
@Post(":id/sim/change-plan-full")
|
||||
@UsePipes(new ZodValidationPipe(simChangePlanFullRequestSchema))
|
||||
async changeSimPlanFull(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||
@Body() body: SimChangePlanFullRequest
|
||||
): Promise<SimPlanChangeResult> {
|
||||
const result = await this.simPlanService.changeSimPlanFull(req.user.id, subscriptionId, body);
|
||||
return {
|
||||
success: true,
|
||||
message: `SIM plan change scheduled for ${result.scheduledAt}`,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cancellation preview (available months, customer info, minimum contract term)
|
||||
*/
|
||||
@Get(":id/sim/cancellation-preview")
|
||||
@Header("Cache-Control", "private, max-age=60")
|
||||
async getCancellationPreview(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number
|
||||
) {
|
||||
const preview = await this.simCancellationService.getCancellationPreview(
|
||||
req.user.id,
|
||||
subscriptionId
|
||||
);
|
||||
return { success: true, data: preview };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel SIM with full flow (PA02-04 + email notifications)
|
||||
*/
|
||||
@Post(":id/sim/cancel-full")
|
||||
@UsePipes(new ZodValidationPipe(simCancelFullRequestSchema))
|
||||
async cancelSimFull(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||
@Body() body: SimCancelFullRequest
|
||||
): Promise<SimActionResponse> {
|
||||
await this.simCancellationService.cancelSimFull(req.user.id, subscriptionId, body);
|
||||
return {
|
||||
success: true,
|
||||
message: `SIM cancellation scheduled for end of ${body.cancellationMonth}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reissue SIM (both eSIM and physical SIM)
|
||||
*/
|
||||
@Post(":id/sim/reissue")
|
||||
async reissueSim(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||
@Body() body: ReissueSimRequest
|
||||
): Promise<SimActionResponse> {
|
||||
await this.esimManagementService.reissueSim(req.user.id, subscriptionId, body);
|
||||
|
||||
if (body.simType === "esim") {
|
||||
return { success: true, message: "eSIM profile reissue request submitted" };
|
||||
} else {
|
||||
return { success: true, message: "Physical SIM reissue request submitted. You will be contacted shortly." };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
export default function Page() {
|
||||
return null;
|
||||
import SimReissueContainer from "@/features/subscriptions/views/SimReissue";
|
||||
|
||||
export default function SimReissuePage() {
|
||||
return <SimReissueContainer />;
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
DevicePhoneMobileIcon,
|
||||
ExclamationTriangleIcon,
|
||||
@ -49,10 +50,17 @@ interface SimInfo {
|
||||
}
|
||||
|
||||
export function SimManagementSection({ subscriptionId }: SimManagementSectionProps) {
|
||||
const router = useRouter();
|
||||
const [simInfo, setSimInfo] = useState<SimInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Navigation handlers
|
||||
const navigateToTopUp = () => router.push(`/subscriptions/${subscriptionId}/sim/top-up`);
|
||||
const navigateToChangePlan = () => router.push(`/subscriptions/${subscriptionId}/sim/change-plan`);
|
||||
const navigateToReissue = () => router.push(`/subscriptions/${subscriptionId}/sim/reissue`);
|
||||
const navigateToCancel = () => router.push(`/subscriptions/${subscriptionId}/sim/cancel`);
|
||||
|
||||
// Fetch subscription data
|
||||
const { data: subscription } = useSubscription(subscriptionId);
|
||||
|
||||
@ -234,7 +242,7 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
||||
{/* Top Up Data Button */}
|
||||
<div className="pt-4">
|
||||
<button
|
||||
onClick={() => {/* TODO: Open top-up modal */}}
|
||||
onClick={navigateToTopUp}
|
||||
className="w-full px-6 py-3 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Top Up Data
|
||||
@ -281,7 +289,7 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">SIM Management Actions</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button
|
||||
onClick={() => {/* TODO: Open top-up modal */}}
|
||||
onClick={navigateToTopUp}
|
||||
className="flex flex-col items-center justify-center p-6 bg-white border-2 border-gray-200 rounded-lg hover:border-blue-500 hover:shadow-md transition-all duration-200"
|
||||
>
|
||||
<SignalIcon className="h-8 w-8 text-gray-700 mb-2" />
|
||||
@ -289,7 +297,7 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {/* TODO: Open change plan modal */}}
|
||||
onClick={navigateToChangePlan}
|
||||
className="flex flex-col items-center justify-center p-6 bg-white border-2 border-gray-200 rounded-lg hover:border-blue-500 hover:shadow-md transition-all duration-200"
|
||||
>
|
||||
<ArrowsRightLeftIcon className="h-8 w-8 text-gray-700 mb-2" />
|
||||
@ -297,16 +305,15 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {/* TODO: Open reissue modal */}}
|
||||
disabled={simInfo.details.simType !== 'esim'}
|
||||
className="flex flex-col items-center justify-center p-6 bg-white border-2 border-gray-200 rounded-lg hover:border-blue-500 hover:shadow-md transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={navigateToReissue}
|
||||
className="flex flex-col items-center justify-center p-6 bg-white border-2 border-gray-200 rounded-lg hover:border-blue-500 hover:shadow-md transition-all duration-200"
|
||||
>
|
||||
<ArrowPathRoundedSquareIcon className="h-8 w-8 text-gray-700 mb-2" />
|
||||
<span className="text-sm font-medium text-gray-900">Reissue SIM</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {/* TODO: Open cancel modal */}}
|
||||
onClick={navigateToCancel}
|
||||
className="flex flex-col items-center justify-center p-6 bg-white border-2 border-gray-200 rounded-lg hover:border-red-500 hover:shadow-md transition-all duration-200"
|
||||
>
|
||||
<XCircleIcon className="h-8 w-8 text-gray-700 mb-2" />
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { simInfoSchema, type SimInfo } from "@customer-portal/domain/sim";
|
||||
import { simInfoSchema, type SimInfo, type SimCancelFullRequest, type SimChangePlanFullRequest } from "@customer-portal/domain/sim";
|
||||
import type {
|
||||
SimTopUpRequest,
|
||||
SimPlanChangeRequest,
|
||||
@ -12,6 +12,41 @@ import type {
|
||||
// - SimPlanChangeRequest: newPlanCode, assignGlobalIp, scheduledAt (YYYYMMDD format)
|
||||
// - SimCancelRequest: scheduledAt (YYYYMMDD format)
|
||||
|
||||
export interface AvailablePlan {
|
||||
id: string;
|
||||
name: string;
|
||||
sku: string;
|
||||
description?: string;
|
||||
monthlyPrice?: number;
|
||||
simDataSize?: string;
|
||||
simPlanType?: string;
|
||||
freebitPlanCode: string;
|
||||
isCurrentPlan: boolean;
|
||||
}
|
||||
|
||||
export interface CancellationMonth {
|
||||
value: string;
|
||||
label: string;
|
||||
runDate: string;
|
||||
}
|
||||
|
||||
export interface CancellationPreview {
|
||||
simNumber: string;
|
||||
serialNumber?: string;
|
||||
planCode: string;
|
||||
startDate?: string;
|
||||
minimumContractEndDate?: string;
|
||||
isWithinMinimumTerm: boolean;
|
||||
availableMonths: CancellationMonth[];
|
||||
customerEmail: string;
|
||||
customerName: string;
|
||||
}
|
||||
|
||||
export interface ReissueSimRequest {
|
||||
simType: "physical" | "esim";
|
||||
newEid?: string;
|
||||
}
|
||||
|
||||
export const simActionsService = {
|
||||
async topUp(subscriptionId: string, request: SimTopUpRequest): Promise<void> {
|
||||
await apiClient.POST("/api/subscriptions/{subscriptionId}/sim/top-up", {
|
||||
@ -27,6 +62,17 @@ export const simActionsService = {
|
||||
});
|
||||
},
|
||||
|
||||
async changePlanFull(subscriptionId: string, request: SimChangePlanFullRequest): Promise<{ scheduledAt?: string }> {
|
||||
const response = await apiClient.POST<{ success: boolean; message: string; scheduledAt?: string }>(
|
||||
"/api/subscriptions/{subscriptionId}/sim/change-plan-full",
|
||||
{
|
||||
params: { path: { subscriptionId } },
|
||||
body: request,
|
||||
}
|
||||
);
|
||||
return { scheduledAt: response.data?.scheduledAt };
|
||||
},
|
||||
|
||||
async cancel(subscriptionId: string, request: SimCancelRequest): Promise<void> {
|
||||
await apiClient.POST("/api/subscriptions/{subscriptionId}/sim/cancel", {
|
||||
params: { path: { subscriptionId } },
|
||||
@ -34,6 +80,13 @@ export const simActionsService = {
|
||||
});
|
||||
},
|
||||
|
||||
async cancelFull(subscriptionId: string, request: SimCancelFullRequest): Promise<void> {
|
||||
await apiClient.POST("/api/subscriptions/{subscriptionId}/sim/cancel-full", {
|
||||
params: { path: { subscriptionId } },
|
||||
body: request,
|
||||
});
|
||||
},
|
||||
|
||||
async getSimInfo(subscriptionId: string): Promise<SimInfo | null> {
|
||||
const response = await apiClient.GET<SimInfo>("/api/subscriptions/{subscriptionId}/sim", {
|
||||
params: { path: { subscriptionId } },
|
||||
@ -45,4 +98,31 @@ export const simActionsService = {
|
||||
|
||||
return simInfoSchema.parse(response.data);
|
||||
},
|
||||
|
||||
async getAvailablePlans(subscriptionId: string): Promise<AvailablePlan[]> {
|
||||
const response = await apiClient.GET<{ success: boolean; data: AvailablePlan[] }>(
|
||||
"/api/subscriptions/{subscriptionId}/sim/available-plans",
|
||||
{
|
||||
params: { path: { subscriptionId } },
|
||||
}
|
||||
);
|
||||
return response.data?.data || [];
|
||||
},
|
||||
|
||||
async getCancellationPreview(subscriptionId: string): Promise<CancellationPreview | null> {
|
||||
const response = await apiClient.GET<{ success: boolean; data: CancellationPreview }>(
|
||||
"/api/subscriptions/{subscriptionId}/sim/cancellation-preview",
|
||||
{
|
||||
params: { path: { subscriptionId } },
|
||||
}
|
||||
);
|
||||
return response.data?.data || null;
|
||||
},
|
||||
|
||||
async reissueSim(subscriptionId: string, request: ReissueSimRequest): Promise<void> {
|
||||
await apiClient.POST("/api/subscriptions/{subscriptionId}/sim/reissue", {
|
||||
params: { path: { subscriptionId } },
|
||||
body: request,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@ -2,18 +2,16 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { simActionsService } from "@/features/subscriptions/services/sim-actions.service";
|
||||
import { useAuthStore } from "@/features/auth/services/auth.store";
|
||||
import type { SimDetails } from "@/features/sim-management/components/SimDetailsCard";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { simActionsService, type CancellationPreview } from "@/features/subscriptions/services/sim-actions.service";
|
||||
|
||||
type Step = 1 | 2 | 3;
|
||||
|
||||
function Notice({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded p-3">
|
||||
<div className="text-sm font-medium text-yellow-900 mb-1">{title}</div>
|
||||
<div className="text-sm text-yellow-800">{children}</div>
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded p-4">
|
||||
<div className="text-sm font-semibold text-yellow-900 mb-2">{title}</div>
|
||||
<div className="text-sm text-yellow-800 leading-relaxed">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -34,79 +32,63 @@ export function SimCancelContainer() {
|
||||
|
||||
const [step, setStep] = useState<Step>(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [details, setDetails] = useState<SimDetails | null>(null);
|
||||
const [preview, setPreview] = useState<CancellationPreview | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [acceptTerms, setAcceptTerms] = useState(false);
|
||||
const [confirmMonthEnd, setConfirmMonthEnd] = useState(false);
|
||||
const [cancelMonth, setCancelMonth] = useState<string>("");
|
||||
const [email, setEmail] = useState<string>("");
|
||||
const [email2, setEmail2] = useState<string>("");
|
||||
const [notes, setNotes] = useState<string>("");
|
||||
const [registeredEmail, setRegisteredEmail] = useState<string | null>(null);
|
||||
const [selectedMonth, setSelectedMonth] = useState<string>("");
|
||||
const [alternativeEmail, setAlternativeEmail] = useState<string>("");
|
||||
const [alternativeEmail2, setAlternativeEmail2] = useState<string>("");
|
||||
const [comments, setComments] = useState<string>("");
|
||||
const [loadingPreview, setLoadingPreview] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDetails = async () => {
|
||||
const fetchPreview = async () => {
|
||||
try {
|
||||
const info = await simActionsService.getSimInfo(subscriptionId);
|
||||
setDetails(info?.details || null);
|
||||
const data = await simActionsService.getCancellationPreview(subscriptionId);
|
||||
setPreview(data);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load SIM details");
|
||||
setError(e instanceof Error ? e.message : "Failed to load cancellation information");
|
||||
} finally {
|
||||
setLoadingPreview(false);
|
||||
}
|
||||
};
|
||||
void fetchDetails();
|
||||
void fetchPreview();
|
||||
}, [subscriptionId]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchEmail = () => {
|
||||
try {
|
||||
const emailFromStore = useAuthStore.getState().user?.email;
|
||||
if (emailFromStore) {
|
||||
setRegisteredEmail(emailFromStore);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
fetchEmail();
|
||||
}, []);
|
||||
|
||||
const monthOptions = useMemo(() => {
|
||||
const opts: { value: string; label: string }[] = [];
|
||||
const now = new Date();
|
||||
for (let i = 1; i <= 12; i++) {
|
||||
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + i, 1));
|
||||
const y = d.getUTCFullYear();
|
||||
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||
opts.push({ value: `${y}${m}`, label: `${y} / ${m}` });
|
||||
}
|
||||
return opts;
|
||||
}, []);
|
||||
|
||||
const canProceedStep2 = !!details;
|
||||
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const emailProvided = email.trim().length > 0 || email2.trim().length > 0;
|
||||
const emailProvided = alternativeEmail.trim().length > 0 || alternativeEmail2.trim().length > 0;
|
||||
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 runDate = cancelMonth ? `${cancelMonth}01` : null;
|
||||
!emailProvided ||
|
||||
(emailPattern.test(alternativeEmail.trim()) && emailPattern.test(alternativeEmail2.trim()));
|
||||
const emailsMatch = !emailProvided || alternativeEmail.trim() === alternativeEmail2.trim();
|
||||
const canProceedStep2 = !!preview && !!selectedMonth;
|
||||
const canProceedStep3 = acceptTerms && confirmMonthEnd && emailValid && emailsMatch;
|
||||
|
||||
const selectedMonthInfo = preview?.availableMonths.find(m => m.value === selectedMonth);
|
||||
|
||||
const submit = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
if (!runDate) {
|
||||
|
||||
if (!selectedMonth) {
|
||||
setError("Please select a cancellation month before submitting.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await simActionsService.cancel(subscriptionId, { scheduledAt: runDate });
|
||||
await simActionsService.cancelFull(subscriptionId, {
|
||||
cancellationMonth: selectedMonth,
|
||||
confirmRead: acceptTerms,
|
||||
confirmCancel: confirmMonthEnd,
|
||||
alternativeEmail: alternativeEmail.trim() || undefined,
|
||||
comments: comments.trim() || undefined,
|
||||
});
|
||||
setMessage("Cancellation request submitted. You will receive a confirmation email.");
|
||||
setTimeout(() => router.push(`/subscriptions/${subscriptionId}#sim-management`), 1500);
|
||||
setTimeout(() => router.push(`/subscriptions/${subscriptionId}#sim-management`), 2000);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Failed to submit cancellation");
|
||||
} finally {
|
||||
@ -114,6 +96,17 @@ export function SimCancelContainer() {
|
||||
}
|
||||
};
|
||||
|
||||
if (loadingPreview) {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto p-6">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/3"></div>
|
||||
<div className="h-64 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto p-6">
|
||||
<div className="mb-4">
|
||||
@ -123,14 +116,24 @@ export function SimCancelContainer() {
|
||||
>
|
||||
← Back to SIM Management
|
||||
</Link>
|
||||
<div className="text-sm text-gray-500">Step {step} of 3</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{[1, 2, 3].map(s => (
|
||||
<div
|
||||
key={s}
|
||||
className={`h-2 flex-1 rounded-full ${
|
||||
s <= step ? "bg-blue-600" : "bg-gray-200"
|
||||
}`}
|
||||
></div>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-1">Step {step} of 3</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-red-700 bg-red-50 border border-red-200 rounded p-3">{error}</div>
|
||||
<div className="text-red-700 bg-red-50 border border-red-200 rounded p-4 mb-4">{error}</div>
|
||||
)}
|
||||
{message && (
|
||||
<div className="text-green-700 bg-green-50 border border-green-200 rounded p-3">
|
||||
<div className="text-green-700 bg-green-50 border border-green-200 rounded p-4 mb-4">
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
@ -138,44 +141,59 @@ export function SimCancelContainer() {
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h1 className="text-xl font-semibold text-gray-900 mb-2">Cancel SIM</h1>
|
||||
<p className="text-sm text-gray-600 mb-6">
|
||||
Cancel SIM: Permanently cancel your SIM service. This action cannot be undone and will
|
||||
terminate your service immediately.
|
||||
Cancel your SIM subscription. Please read all the information carefully before proceeding.
|
||||
</p>
|
||||
|
||||
{/* Minimum Contract Warning */}
|
||||
{preview?.isWithinMinimumTerm && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
|
||||
<div className="text-sm font-semibold text-red-900 mb-1">Minimum Contract Term Warning</div>
|
||||
<div className="text-sm text-red-800">
|
||||
Your subscription is still within the minimum contract period (ends {preview.minimumContractEndDate}).
|
||||
Early cancellation may result in additional charges for the remaining months.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
|
||||
<InfoRow label="SIM" value={details?.msisdn || "—"} />
|
||||
<InfoRow label="Activated" value={details?.activatedAt || "—"} />
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Cancellation Month
|
||||
</label>
|
||||
<select
|
||||
value={cancelMonth}
|
||||
onChange={e => {
|
||||
setCancelMonth(e.target.value);
|
||||
setConfirmMonthEnd(false);
|
||||
}}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Select month…</option>
|
||||
{monthOptions.map(opt => (
|
||||
<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>
|
||||
</div>
|
||||
{/* SIM Info */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 p-4 bg-gray-50 rounded-lg">
|
||||
<InfoRow label="SIM Number" value={preview?.simNumber || "—"} />
|
||||
<InfoRow label="Serial #" value={preview?.serialNumber || "—"} />
|
||||
<InfoRow label="Start Date" value={preview?.startDate || "—"} />
|
||||
</div>
|
||||
|
||||
{/* Month Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Select Cancellation Month
|
||||
</label>
|
||||
<select
|
||||
value={selectedMonth}
|
||||
onChange={e => {
|
||||
setSelectedMonth(e.target.value);
|
||||
setConfirmMonthEnd(false);
|
||||
}}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="">Select month…</option>
|
||||
{preview?.availableMonths.map(month => (
|
||||
<option key={month.value} value={month.value}>
|
||||
{month.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Your subscription will be cancelled at the end of the selected month.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
disabled={!canProceedStep2}
|
||||
onClick={() => setStep(2)}
|
||||
className="px-4 py-2 rounded-md bg-blue-600 text-white text-sm disabled:opacity-50"
|
||||
className="px-6 py-2 rounded-md bg-blue-600 text-white text-sm font-medium disabled:opacity-50 hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
@ -185,8 +203,8 @@ export function SimCancelContainer() {
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<Notice title="Cancellation Procedure">
|
||||
<div className="space-y-4">
|
||||
<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
|
||||
@ -196,63 +214,73 @@ export function SimCancelContainer() {
|
||||
services with Assist Solutions (home internet etc.) please contact Assist Solutions
|
||||
at info@asolutions.co.jp
|
||||
</Notice>
|
||||
<Notice title="Minimum Contract Term">
|
||||
|
||||
<Notice title="[Minimum Contract Term]">
|
||||
The SONIXNET SIM has a minimum contract term agreement of three months (sign-up
|
||||
month is not included in the minimum term of three months; ie. sign-up in January =
|
||||
minimum term is February, March, April). 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">
|
||||
|
||||
<Notice title="[Cancellation of Option Services (for Data+SMS/Voice Plan)]">
|
||||
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)">
|
||||
|
||||
<Notice title="[MNP Transfer (for Data+SMS/Voice Plan)]">
|
||||
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
|
||||
transfer (administrative fee ¥1,000+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>
|
||||
</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>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<input
|
||||
id="confirmMonthEnd"
|
||||
type="checkbox"
|
||||
checked={confirmMonthEnd}
|
||||
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.
|
||||
</label>
|
||||
|
||||
<div className="space-y-3 bg-gray-50 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
id="acceptTerms"
|
||||
type="checkbox"
|
||||
checked={acceptTerms}
|
||||
onChange={e => setAcceptTerms(e.target.checked)}
|
||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded mt-0.5"
|
||||
/>
|
||||
<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-3">
|
||||
<input
|
||||
id="confirmMonthEnd"
|
||||
type="checkbox"
|
||||
checked={confirmMonthEnd}
|
||||
onChange={e => setConfirmMonthEnd(e.target.checked)}
|
||||
disabled={!selectedMonth}
|
||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded mt-0.5"
|
||||
/>
|
||||
<label htmlFor="confirmMonthEnd" className="text-sm text-gray-700">
|
||||
I would like to cancel my SonixNet SIM subscription at the end of{" "}
|
||||
<strong>{selectedMonthInfo?.label || "the selected month"}</strong>.
|
||||
</label>
|
||||
</div>
|
||||
</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"
|
||||
className="px-6 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
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"
|
||||
className="px-6 py-2 rounded-md bg-blue-600 text-white text-sm font-medium disabled:opacity-50 hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
@ -262,65 +290,93 @@ export function SimCancelContainer() {
|
||||
|
||||
{step === 3 && (
|
||||
<div className="space-y-6">
|
||||
{registeredEmail && (
|
||||
<div className="text-sm text-gray-800">
|
||||
Your registered email address is:{" "}
|
||||
<span className="font-medium">{registeredEmail}</span>
|
||||
{/* Voice SIM Notice */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="text-sm font-semibold text-blue-900 mb-2">
|
||||
For Voice-enabled SIM subscriptions:
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm text-gray-700">
|
||||
<div className="text-sm text-blue-800">
|
||||
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.
|
||||
</div>
|
||||
<div className="text-sm text-blue-800 mt-2">
|
||||
If you would like to make the payment with a different credit card, please contact
|
||||
Assist Solutions at info@asolutions.co.jp
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Registered Email */}
|
||||
<div className="text-sm text-gray-800">
|
||||
Your registered email address is:{" "}
|
||||
<span className="font-medium">{preview?.customerEmail || "—"}</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
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>
|
||||
|
||||
{/* Alternative Email */}
|
||||
<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>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
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)}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500"
|
||||
value={alternativeEmail}
|
||||
onChange={e => setAlternativeEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">(Confirm)</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">(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)}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500"
|
||||
value={alternativeEmail2}
|
||||
onChange={e => setAlternativeEmail2(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."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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.
|
||||
|
||||
{/* Comments */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
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="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500"
|
||||
rows={4}
|
||||
value={comments}
|
||||
onChange={e => setComments(e.target.value)}
|
||||
placeholder="Optional: Enter any questions or requests here."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Final Warning */}
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="text-sm font-semibold text-red-900 mb-1">
|
||||
Your cancellation request is not confirmed yet.
|
||||
</div>
|
||||
<div className="text-sm text-red-800">
|
||||
This is the final page. To finalize your cancellation request please proceed from
|
||||
REQUEST CANCELLATION below.
|
||||
</div>
|
||||
</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"
|
||||
className="px-6 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
@ -328,18 +384,16 @@ export function SimCancelContainer() {
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
"Request cancellation now? This will schedule the cancellation for " +
|
||||
(runDate || "") +
|
||||
"."
|
||||
`Are you sure you want to cancel your SIM subscription? This will take effect at the end of ${selectedMonthInfo?.label || selectedMonth}.`
|
||||
)
|
||||
) {
|
||||
void submit();
|
||||
}
|
||||
}}
|
||||
disabled={loading || !runDate || !canProceedStep3}
|
||||
className="px-4 py-2 rounded-md bg-red-600 text-white text-sm disabled:opacity-50"
|
||||
disabled={loading || !canProceedStep3}
|
||||
className="px-6 py-2 rounded-md bg-red-600 text-white text-sm font-medium disabled:opacity-50 hover:bg-red-700 transition-colors"
|
||||
>
|
||||
{loading ? "Processing…" : "Request Cancellation"}
|
||||
{loading ? "Processing…" : "REQUEST CANCELLATION"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,39 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { PageLayout } from "@/components/templates/PageLayout";
|
||||
import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
||||
import { DevicePhoneMobileIcon } from "@heroicons/react/24/outline";
|
||||
import { simActionsService } from "@/features/subscriptions/services/sim-actions.service";
|
||||
import { DevicePhoneMobileIcon, CheckCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { simActionsService, type AvailablePlan } from "@/features/subscriptions/services/sim-actions.service";
|
||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||
import { Formatting } from "@customer-portal/domain/toolkit";
|
||||
|
||||
import {
|
||||
SIM_PLAN_OPTIONS,
|
||||
type SimPlanCode,
|
||||
getSimPlanLabel,
|
||||
} from "@customer-portal/domain/sim";
|
||||
const { formatCurrency } = Formatting;
|
||||
|
||||
export function SimChangePlanContainer() {
|
||||
const params = useParams();
|
||||
const subscriptionId = params.id as string;
|
||||
const [currentPlanCode] = useState<string>("");
|
||||
const [newPlanCode, setNewPlanCode] = useState<"" | SimPlanCode>("");
|
||||
const [plans, setPlans] = useState<AvailablePlan[]>([]);
|
||||
const [selectedPlan, setSelectedPlan] = useState<AvailablePlan | null>(null);
|
||||
const [assignGlobalIp, setAssignGlobalIp] = useState(false);
|
||||
const [scheduledAt, setScheduledAt] = useState("");
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingPlans, setLoadingPlans] = useState(true);
|
||||
|
||||
const options = useMemo(
|
||||
() => SIM_PLAN_OPTIONS.filter(option => option.code !== currentPlanCode),
|
||||
[currentPlanCode]
|
||||
);
|
||||
useEffect(() => {
|
||||
const fetchPlans = async () => {
|
||||
try {
|
||||
const availablePlans = await simActionsService.getAvailablePlans(subscriptionId);
|
||||
setPlans(availablePlans);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load available plans");
|
||||
} finally {
|
||||
setLoadingPlans(false);
|
||||
}
|
||||
};
|
||||
void fetchPlans();
|
||||
}, [subscriptionId]);
|
||||
|
||||
const currentPlan = plans.find(p => p.isCurrentPlan);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newPlanCode) {
|
||||
if (!selectedPlan) {
|
||||
setError("Please select a new plan");
|
||||
return;
|
||||
}
|
||||
@ -41,12 +49,14 @@ export function SimChangePlanContainer() {
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
await simActionsService.changePlan(subscriptionId, {
|
||||
newPlanCode,
|
||||
const result = await simActionsService.changePlanFull(subscriptionId, {
|
||||
newPlanCode: selectedPlan.freebitPlanCode,
|
||||
newPlanSku: selectedPlan.sku,
|
||||
newPlanName: selectedPlan.name,
|
||||
assignGlobalIp,
|
||||
scheduledAt: scheduledAt ? scheduledAt.replace(/-/g, "") : undefined,
|
||||
});
|
||||
setMessage("Plan change submitted successfully");
|
||||
setMessage(`Plan change scheduled for ${result.scheduledAt || "the 1st of next month"}`);
|
||||
setSelectedPlan(null);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Failed to change plan");
|
||||
} finally {
|
||||
@ -60,7 +70,7 @@ export function SimChangePlanContainer() {
|
||||
title="Change Plan"
|
||||
description="Switch to a different data plan"
|
||||
>
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="mb-4">
|
||||
<Link
|
||||
href={`/subscriptions/${subscriptionId}#sim-management`}
|
||||
@ -71,11 +81,13 @@ export function SimChangePlanContainer() {
|
||||
</div>
|
||||
|
||||
<SubCard>
|
||||
<p className="text-sm text-gray-600 mb-6">
|
||||
Change Plan: Switch to a different data plan. Important: Plan changes must be requested
|
||||
before the 25th of the month. Changes will take effect on the 1st of the following
|
||||
month.
|
||||
</p>
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-2">Change Your Plan</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
Select a new plan below. Plan changes will take effect on the 1st of the following month.
|
||||
Changes must be requested before the 25th of the current month.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className="mb-4">
|
||||
@ -92,64 +104,127 @@ export function SimChangePlanContainer() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={e => void submit(e)} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">New Plan</label>
|
||||
<select
|
||||
value={newPlanCode}
|
||||
onChange={e => setNewPlanCode(e.target.value as SimPlanCode)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="">Choose a plan</option>
|
||||
{options.map(option => (
|
||||
<option key={option.code} value={option.code}>
|
||||
{getSimPlanLabel(option.code)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{loadingPlans ? (
|
||||
<div className="animate-pulse space-y-4">
|
||||
{[1, 2, 3, 4].map(i => (
|
||||
<div key={i} className="h-24 bg-gray-100 rounded-lg"></div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={e => void submit(e)} className="space-y-6">
|
||||
{/* Current Plan */}
|
||||
{currentPlan && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-xs text-blue-600 font-medium uppercase">Current Plan</div>
|
||||
<div className="text-lg font-semibold text-blue-900">{currentPlan.name}</div>
|
||||
<div className="text-sm text-blue-700">{currentPlan.simDataSize}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xl font-bold text-blue-900">
|
||||
{currentPlan.monthlyPrice ? formatCurrency(currentPlan.monthlyPrice) : "—"}
|
||||
</div>
|
||||
<div className="text-xs text-blue-600">/month</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="globalip"
|
||||
type="checkbox"
|
||||
checked={assignGlobalIp}
|
||||
onChange={e => setAssignGlobalIp(e.target.checked)}
|
||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="globalip" className="ml-2 text-sm text-gray-700">
|
||||
Assign global IP
|
||||
</label>
|
||||
</div>
|
||||
{/* Available Plans */}
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-medium text-gray-700">Select a New Plan</label>
|
||||
<div className="grid gap-3">
|
||||
{plans
|
||||
.filter(p => !p.isCurrentPlan)
|
||||
.map(plan => (
|
||||
<label
|
||||
key={plan.id}
|
||||
className={`relative flex items-center justify-between p-4 border-2 rounded-lg cursor-pointer transition-all ${
|
||||
selectedPlan?.id === plan.id
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: "border-gray-200 hover:border-gray-300 bg-white"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="plan"
|
||||
value={plan.id}
|
||||
checked={selectedPlan?.id === plan.id}
|
||||
onChange={() => setSelectedPlan(plan)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div
|
||||
className={`w-5 h-5 rounded-full border-2 mr-4 flex items-center justify-center ${
|
||||
selectedPlan?.id === plan.id ? "border-blue-500" : "border-gray-300"
|
||||
}`}
|
||||
>
|
||||
{selectedPlan?.id === plan.id && (
|
||||
<div className="w-3 h-3 rounded-full bg-blue-500"></div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{plan.name}</div>
|
||||
<div className="text-sm text-gray-500">{plan.simDataSize} • {plan.simPlanType}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-semibold text-gray-900">
|
||||
{plan.monthlyPrice ? formatCurrency(plan.monthlyPrice) : "—"}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">/month</div>
|
||||
</div>
|
||||
{selectedPlan?.id === plan.id && (
|
||||
<CheckCircleIcon className="absolute top-2 right-2 h-5 w-5 text-blue-500" />
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Schedule (optional)
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={scheduledAt}
|
||||
onChange={e => setScheduledAt(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
{/* Global IP Option */}
|
||||
<div className="flex items-center p-4 bg-gray-50 rounded-lg">
|
||||
<input
|
||||
id="globalip"
|
||||
type="checkbox"
|
||||
checked={assignGlobalIp}
|
||||
onChange={e => setAssignGlobalIp(e.target.checked)}
|
||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<label htmlFor="globalip" className="ml-3 text-sm text-gray-700">
|
||||
Assign a global IP address (additional charges may apply)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-4 py-2 rounded-md bg-blue-600 text-white text-sm disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Processing…" : "Submit Plan Change"}
|
||||
</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"
|
||||
>
|
||||
Back
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
{/* Info Box */}
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-yellow-900 mb-1">Important Notes</h3>
|
||||
<ul className="text-sm text-yellow-800 space-y-1">
|
||||
<li>• Plan changes take effect on the 1st of the following month</li>
|
||||
<li>• Requests must be made before the 25th of the current month</li>
|
||||
<li>• Your current data balance will be reset when the new plan activates</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !selectedPlan}
|
||||
className="px-6 py-2 rounded-md bg-blue-600 text-white font-medium text-sm disabled:opacity-50 hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
{loading ? "Processing…" : "Confirm Plan Change"}
|
||||
</button>
|
||||
<Link
|
||||
href={`/subscriptions/${subscriptionId}#sim-management`}
|
||||
className="px-6 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</SubCard>
|
||||
</div>
|
||||
</PageLayout>
|
||||
|
||||
311
apps/portal/src/features/subscriptions/views/SimReissue.tsx
Normal file
311
apps/portal/src/features/subscriptions/views/SimReissue.tsx
Normal file
@ -0,0 +1,311 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { PageLayout } from "@/components/templates/PageLayout";
|
||||
import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
||||
import { DevicePhoneMobileIcon, DeviceTabletIcon, CpuChipIcon } from "@heroicons/react/24/outline";
|
||||
import { simActionsService, type ReissueSimRequest } from "@/features/subscriptions/services/sim-actions.service";
|
||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||
import type { SimDetails } from "@/features/sim-management/components/SimDetailsCard";
|
||||
|
||||
type SimType = "physical" | "esim";
|
||||
|
||||
export function SimReissueContainer() {
|
||||
const params = useParams();
|
||||
const subscriptionId = params.id as string;
|
||||
const [simType, setSimType] = useState<SimType | null>(null);
|
||||
const [newEid, setNewEid] = useState("");
|
||||
const [currentEid, setCurrentEid] = useState<string | null>(null);
|
||||
const [simDetails, setSimDetails] = useState<SimDetails | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingDetails, setLoadingDetails] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDetails = async () => {
|
||||
try {
|
||||
const info = await simActionsService.getSimInfo(subscriptionId);
|
||||
if (info?.details) {
|
||||
setSimDetails(info.details);
|
||||
setCurrentEid(info.details.eid || null);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load SIM details");
|
||||
} finally {
|
||||
setLoadingDetails(false);
|
||||
}
|
||||
};
|
||||
void fetchDetails();
|
||||
}, [subscriptionId]);
|
||||
|
||||
const isValidEid = () => {
|
||||
return /^\d{32}$/.test(newEid.trim());
|
||||
};
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!simType) {
|
||||
setError("Please select a SIM type");
|
||||
return;
|
||||
}
|
||||
if (simType === "esim" && !isValidEid()) {
|
||||
setError("Please enter a valid 32-digit EID");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const request: ReissueSimRequest = {
|
||||
simType,
|
||||
...(simType === "esim" && { newEid: newEid.trim() }),
|
||||
};
|
||||
await simActionsService.reissueSim(subscriptionId, request);
|
||||
|
||||
if (simType === "esim") {
|
||||
setMessage(
|
||||
"eSIM reissue request submitted successfully. You will receive instructions via email to download your new eSIM profile."
|
||||
);
|
||||
} else {
|
||||
setMessage(
|
||||
"Physical SIM reissue request submitted successfully. You will be contacted shortly with shipping details (typically 3-5 business days)."
|
||||
);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Failed to submit reissue request");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
icon={<DevicePhoneMobileIcon />}
|
||||
title="Reissue SIM"
|
||||
description="Request a replacement SIM card"
|
||||
>
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="mb-4">
|
||||
<Link
|
||||
href={`/subscriptions/${subscriptionId}#sim-management`}
|
||||
className="text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
← Back to SIM Management
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<SubCard>
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-2">Request SIM Reissue</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
If your SIM card is lost, damaged, or you need to switch between physical SIM and eSIM,
|
||||
you can request a replacement here.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className="mb-4">
|
||||
<AlertBanner variant="success" title="Request Submitted">
|
||||
{message}
|
||||
</AlertBanner>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="mb-4">
|
||||
<AlertBanner variant="error" title="Request Failed">
|
||||
{error}
|
||||
</AlertBanner>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadingDetails ? (
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-32 bg-gray-100 rounded-lg"></div>
|
||||
<div className="h-32 bg-gray-100 rounded-lg"></div>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={e => void submit(e)} className="space-y-6">
|
||||
{/* Current SIM Info */}
|
||||
{simDetails && (
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-6">
|
||||
<div className="text-xs text-gray-500 font-medium uppercase mb-2">Current SIM</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Number:</span>{" "}
|
||||
<span className="font-medium">{simDetails.msisdn}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Type:</span>{" "}
|
||||
<span className="font-medium capitalize">{simDetails.simType}</span>
|
||||
</div>
|
||||
{simDetails.iccid && (
|
||||
<div className="col-span-2">
|
||||
<span className="text-gray-500">ICCID:</span>{" "}
|
||||
<span className="font-mono text-xs">{simDetails.iccid}</span>
|
||||
</div>
|
||||
)}
|
||||
{currentEid && (
|
||||
<div className="col-span-2">
|
||||
<span className="text-gray-500">Current EID:</span>{" "}
|
||||
<span className="font-mono text-xs">{currentEid}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SIM Type Selection */}
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Select Replacement SIM Type
|
||||
</label>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Physical SIM Option */}
|
||||
<label
|
||||
className={`relative flex flex-col items-center p-6 border-2 rounded-xl cursor-pointer transition-all ${
|
||||
simType === "physical"
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: "border-gray-200 hover:border-gray-300 bg-white"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="simType"
|
||||
value="physical"
|
||||
checked={simType === "physical"}
|
||||
onChange={() => setSimType("physical")}
|
||||
className="sr-only"
|
||||
/>
|
||||
<DeviceTabletIcon className="h-12 w-12 text-gray-600 mb-3" />
|
||||
<div className="text-lg font-medium text-gray-900">Physical SIM</div>
|
||||
<div className="text-sm text-gray-500 text-center mt-1">
|
||||
A new physical SIM card will be shipped to you
|
||||
</div>
|
||||
{simType === "physical" && (
|
||||
<div className="absolute top-2 right-2 w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center">
|
||||
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{/* eSIM Option */}
|
||||
<label
|
||||
className={`relative flex flex-col items-center p-6 border-2 rounded-xl cursor-pointer transition-all ${
|
||||
simType === "esim"
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: "border-gray-200 hover:border-gray-300 bg-white"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="simType"
|
||||
value="esim"
|
||||
checked={simType === "esim"}
|
||||
onChange={() => setSimType("esim")}
|
||||
className="sr-only"
|
||||
/>
|
||||
<CpuChipIcon className="h-12 w-12 text-gray-600 mb-3" />
|
||||
<div className="text-lg font-medium text-gray-900">eSIM</div>
|
||||
<div className="text-sm text-gray-500 text-center mt-1">
|
||||
Download your eSIM profile instantly
|
||||
</div>
|
||||
{simType === "esim" && (
|
||||
<div className="absolute top-2 right-2 w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center">
|
||||
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* eSIM EID Input */}
|
||||
{simType === "esim" && (
|
||||
<div className="space-y-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-blue-900 mb-2">
|
||||
New EID (eSIM Identifier)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newEid}
|
||||
onChange={e => setNewEid(e.target.value.replace(/\D/g, ""))}
|
||||
placeholder="Enter your device's 32-digit EID"
|
||||
maxLength={32}
|
||||
className="w-full px-3 py-2 border border-blue-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 font-mono"
|
||||
/>
|
||||
<p className="text-xs text-blue-700 mt-1">
|
||||
The EID is a 32-digit number unique to your device. You can find it in your
|
||||
device settings under "About" or "SIM status".
|
||||
</p>
|
||||
{newEid && !isValidEid() && (
|
||||
<p className="text-xs text-red-600 mt-1">
|
||||
Please enter exactly 32 digits ({newEid.length}/32)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{currentEid && (
|
||||
<div className="text-sm text-blue-800">
|
||||
<strong>Current EID:</strong>{" "}
|
||||
<span className="font-mono text-xs">{currentEid}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Physical SIM Info */}
|
||||
{simType === "physical" && (
|
||||
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<h3 className="text-sm font-medium text-yellow-900 mb-2">Physical SIM Information</h3>
|
||||
<ul className="text-sm text-yellow-800 space-y-1">
|
||||
<li>• A new physical SIM card will be shipped to your registered address</li>
|
||||
<li>• Typical delivery time: 3-5 business days</li>
|
||||
<li>• You will receive an email with tracking information</li>
|
||||
<li>• The old SIM card will be deactivated once the new one is activated</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Buttons */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !simType || (simType === "esim" && !isValidEid())}
|
||||
className="px-6 py-2 rounded-md bg-blue-600 text-white font-medium text-sm disabled:opacity-50 hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
{loading ? "Processing…" : "Submit Reissue Request"}
|
||||
</button>
|
||||
<Link
|
||||
href={`/subscriptions/${subscriptionId}#sim-management`}
|
||||
className="px-6 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</SubCard>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default SimReissueContainer;
|
||||
|
||||
@ -72,5 +72,9 @@
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.40.0",
|
||||
"zod": "^4.1.9"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/ssh2-sftp-client": "^9.0.5",
|
||||
"ssh2-sftp-client": "^12.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@ -47,6 +47,10 @@ export type {
|
||||
SimCardType,
|
||||
ActivationType,
|
||||
MnpData,
|
||||
// Enhanced request types
|
||||
SimCancelFullRequest,
|
||||
SimTopUpFullRequest,
|
||||
SimChangePlanFullRequest,
|
||||
// Activation types
|
||||
SimOrderActivationRequest,
|
||||
SimOrderActivationMnp,
|
||||
|
||||
@ -26,6 +26,7 @@ export const simDetailsSchema = z.object({
|
||||
networkType: z.string(),
|
||||
activatedAt: z.string().optional(),
|
||||
expiresAt: z.string().optional(),
|
||||
startDate: z.string().optional(),
|
||||
});
|
||||
|
||||
export const recentDayUsageSchema = z.object({
|
||||
@ -151,6 +152,36 @@ export const simReissueRequestSchema = z.object({
|
||||
.string()
|
||||
.regex(/^\d{32}$/, "EID must be exactly 32 digits")
|
||||
.optional(),
|
||||
simType: z.enum(["physical", "esim"]).optional(),
|
||||
});
|
||||
|
||||
// Enhanced cancellation request with more details
|
||||
export const simCancelFullRequestSchema = z.object({
|
||||
cancellationMonth: z.string().regex(/^\d{4}-\d{2}$/, "Cancellation month must be in YYYY-MM format"),
|
||||
confirmRead: z.boolean(),
|
||||
confirmCancel: z.boolean(),
|
||||
alternativeEmail: z.string().email().optional().or(z.literal("")),
|
||||
comments: z.string().max(1000).optional(),
|
||||
}).refine(
|
||||
(data) => data.confirmRead === true && data.confirmCancel === true,
|
||||
{ message: "You must confirm both checkboxes to proceed" }
|
||||
);
|
||||
|
||||
// Top-up request with enhanced details for email
|
||||
export const simTopUpFullRequestSchema = z.object({
|
||||
quotaMb: z
|
||||
.number()
|
||||
.int()
|
||||
.min(100, "Quota must be at least 100MB")
|
||||
.max(51200, "Quota must be 50GB or less"),
|
||||
});
|
||||
|
||||
// Change plan request with Salesforce product info
|
||||
export const simChangePlanFullRequestSchema = z.object({
|
||||
newPlanCode: z.string().min(1, "New plan code is required"),
|
||||
newPlanSku: z.string().min(1, "New plan SKU is required"),
|
||||
newPlanName: z.string().optional(),
|
||||
assignGlobalIp: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const simMnpFormSchema = z.object({
|
||||
@ -334,6 +365,9 @@ export type SimCancelRequest = z.infer<typeof simCancelRequestSchema>;
|
||||
export type SimTopUpHistoryRequest = z.infer<typeof simTopUpHistoryRequestSchema>;
|
||||
export type SimFeaturesUpdateRequest = z.infer<typeof simFeaturesUpdateRequestSchema>;
|
||||
export type SimReissueRequest = z.infer<typeof simReissueRequestSchema>;
|
||||
export type SimCancelFullRequest = z.infer<typeof simCancelFullRequestSchema>;
|
||||
export type SimTopUpFullRequest = z.infer<typeof simTopUpFullRequestSchema>;
|
||||
export type SimChangePlanFullRequest = z.infer<typeof simChangePlanFullRequestSchema>;
|
||||
export type SimConfigureFormData = z.infer<typeof simConfigureFormSchema>;
|
||||
export type SimCardType = z.infer<typeof simCardTypeSchema>;
|
||||
export type ActivationType = z.infer<typeof simActivationTypeSchema>;
|
||||
|
||||
102
pnpm-lock.yaml
generated
102
pnpm-lock.yaml
generated
@ -7,6 +7,13 @@ settings:
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@types/ssh2-sftp-client':
|
||||
specifier: ^9.0.5
|
||||
version: 9.0.5
|
||||
ssh2-sftp-client:
|
||||
specifier: ^12.0.1
|
||||
version: 12.0.1
|
||||
devDependencies:
|
||||
'@eslint/eslintrc':
|
||||
specifier: ^3.3.1
|
||||
@ -95,6 +102,9 @@ importers:
|
||||
'@sendgrid/mail':
|
||||
specifier: ^8.1.6
|
||||
version: 8.1.6
|
||||
'@types/ssh2-sftp-client':
|
||||
specifier: ^9.0.5
|
||||
version: 9.0.5
|
||||
bcrypt:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
@ -167,6 +177,9 @@ importers:
|
||||
speakeasy:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0
|
||||
ssh2-sftp-client:
|
||||
specifier: ^12.0.1
|
||||
version: 12.0.1
|
||||
tsconfig-paths:
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.0
|
||||
@ -1827,6 +1840,9 @@ packages:
|
||||
'@types/ms@2.1.0':
|
||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||
|
||||
'@types/node@18.19.130':
|
||||
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
|
||||
|
||||
'@types/node@24.3.1':
|
||||
resolution: {integrity: sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==}
|
||||
|
||||
@ -1865,6 +1881,12 @@ packages:
|
||||
'@types/speakeasy@2.0.10':
|
||||
resolution: {integrity: sha512-QVRlDW5r4yl7p7xkNIbAIC/JtyOcClDIIdKfuG7PWdDT1MmyhtXSANsildohy0K+Lmvf/9RUtLbNLMacvrVwxA==}
|
||||
|
||||
'@types/ssh2-sftp-client@9.0.5':
|
||||
resolution: {integrity: sha512-cpUO6okDusnfLw2hnmaBiomlSchIWNVcCdpywLRsg/h9Q1TTiUSrzhkn5sJeeyTM8h6xRbZEZZjgWtUXFDogHg==}
|
||||
|
||||
'@types/ssh2@1.15.5':
|
||||
resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==}
|
||||
|
||||
'@types/stack-utils@2.0.3':
|
||||
resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==}
|
||||
|
||||
@ -2245,6 +2267,9 @@ packages:
|
||||
asap@2.0.6:
|
||||
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
|
||||
|
||||
asn1@0.2.6:
|
||||
resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==}
|
||||
|
||||
ast-types-flow@0.0.8:
|
||||
resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
|
||||
|
||||
@ -2315,6 +2340,9 @@ packages:
|
||||
resolution: {integrity: sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
bcrypt-pbkdf@1.0.2:
|
||||
resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==}
|
||||
|
||||
bcrypt@6.0.0:
|
||||
resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==}
|
||||
engines: {node: '>= 18'}
|
||||
@ -2357,6 +2385,10 @@ packages:
|
||||
buffer@5.7.1:
|
||||
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
|
||||
|
||||
buildcheck@0.0.7:
|
||||
resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
bullmq@5.58.5:
|
||||
resolution: {integrity: sha512-0A6Qjxdn8j7aOcxfRZY798vO/aMuwvoZwfE6a9EOXHb1pzpBVAogsc/OfRWeUf+5wMBoYB5nthstnJo/zrQOeQ==}
|
||||
|
||||
@ -2595,6 +2627,10 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
cpu-features@0.0.10:
|
||||
resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
create-require@1.1.1:
|
||||
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
|
||||
|
||||
@ -4089,6 +4125,9 @@ packages:
|
||||
resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==}
|
||||
engines: {node: ^18.17.0 || >=20.5.0}
|
||||
|
||||
nan@2.23.1:
|
||||
resolution: {integrity: sha512-r7bBUGKzlqk8oPBDYxt6Z0aEdF1G1rwlMcLk8LCOMbOzf0mG+JUfUzG4fIMWwHWP0iyaLWEQZJmtB7nOHEm/qw==}
|
||||
|
||||
nanoid@3.3.11:
|
||||
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
@ -4782,6 +4821,14 @@ packages:
|
||||
sprintf-js@1.0.3:
|
||||
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
|
||||
|
||||
ssh2-sftp-client@12.0.1:
|
||||
resolution: {integrity: sha512-ICJ1L2PmBel2Q2ctbyxzTFZCPKSHYYD6s2TFZv7NXmZDrDNGk8lHBb/SK2WgXLMXNANH78qoumeJzxlWZqSqWg==}
|
||||
engines: {node: '>=18.20.4'}
|
||||
|
||||
ssh2@1.17.0:
|
||||
resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==}
|
||||
engines: {node: '>=10.16.0'}
|
||||
|
||||
stable-hash@0.0.5:
|
||||
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
|
||||
|
||||
@ -5097,6 +5144,9 @@ packages:
|
||||
tw-animate-css@1.3.8:
|
||||
resolution: {integrity: sha512-Qrk3PZ7l7wUcGYhwZloqfkWCmaXZAoqjkdbIDvzfGshwGtexa/DAs9koXxIkrpEasyevandomzCBAV1Yyop5rw==}
|
||||
|
||||
tweetnacl@0.14.5:
|
||||
resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==}
|
||||
|
||||
type-check@0.4.0:
|
||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@ -5182,6 +5232,9 @@ packages:
|
||||
underscore@1.13.7:
|
||||
resolution: {integrity: sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==}
|
||||
|
||||
undici-types@5.26.5:
|
||||
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
|
||||
|
||||
undici-types@7.10.0:
|
||||
resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==}
|
||||
|
||||
@ -6869,6 +6922,10 @@ snapshots:
|
||||
|
||||
'@types/ms@2.1.0': {}
|
||||
|
||||
'@types/node@18.19.130':
|
||||
dependencies:
|
||||
undici-types: 5.26.5
|
||||
|
||||
'@types/node@24.3.1':
|
||||
dependencies:
|
||||
undici-types: 7.10.0
|
||||
@ -6920,6 +6977,14 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 24.3.1
|
||||
|
||||
'@types/ssh2-sftp-client@9.0.5':
|
||||
dependencies:
|
||||
'@types/ssh2': 1.15.5
|
||||
|
||||
'@types/ssh2@1.15.5':
|
||||
dependencies:
|
||||
'@types/node': 18.19.130
|
||||
|
||||
'@types/stack-utils@2.0.3': {}
|
||||
|
||||
'@types/superagent@8.1.9':
|
||||
@ -7344,6 +7409,10 @@ snapshots:
|
||||
|
||||
asap@2.0.6: {}
|
||||
|
||||
asn1@0.2.6:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
ast-types-flow@0.0.8: {}
|
||||
|
||||
async-function@1.0.0: {}
|
||||
@ -7434,6 +7503,10 @@ snapshots:
|
||||
|
||||
base64url@3.0.1: {}
|
||||
|
||||
bcrypt-pbkdf@1.0.2:
|
||||
dependencies:
|
||||
tweetnacl: 0.14.5
|
||||
|
||||
bcrypt@6.0.0:
|
||||
dependencies:
|
||||
node-addon-api: 8.5.0
|
||||
@ -7496,6 +7569,9 @@ snapshots:
|
||||
base64-js: 1.5.1
|
||||
ieee754: 1.2.1
|
||||
|
||||
buildcheck@0.0.7:
|
||||
optional: true
|
||||
|
||||
bullmq@5.58.5:
|
||||
dependencies:
|
||||
cron-parser: 4.9.0
|
||||
@ -7717,6 +7793,12 @@ snapshots:
|
||||
optionalDependencies:
|
||||
typescript: 5.8.3
|
||||
|
||||
cpu-features@0.0.10:
|
||||
dependencies:
|
||||
buildcheck: 0.0.7
|
||||
nan: 2.23.1
|
||||
optional: true
|
||||
|
||||
create-require@1.1.1: {}
|
||||
|
||||
cron-parser@4.9.0:
|
||||
@ -9571,6 +9653,9 @@ snapshots:
|
||||
|
||||
mute-stream@2.0.0: {}
|
||||
|
||||
nan@2.23.1:
|
||||
optional: true
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
napi-postinstall@0.3.3: {}
|
||||
@ -10341,6 +10426,19 @@ snapshots:
|
||||
|
||||
sprintf-js@1.0.3: {}
|
||||
|
||||
ssh2-sftp-client@12.0.1:
|
||||
dependencies:
|
||||
concat-stream: 2.0.0
|
||||
ssh2: 1.17.0
|
||||
|
||||
ssh2@1.17.0:
|
||||
dependencies:
|
||||
asn1: 0.2.6
|
||||
bcrypt-pbkdf: 1.0.2
|
||||
optionalDependencies:
|
||||
cpu-features: 0.0.10
|
||||
nan: 2.23.1
|
||||
|
||||
stable-hash@0.0.5: {}
|
||||
|
||||
stack-utils@2.0.6:
|
||||
@ -10673,6 +10771,8 @@ snapshots:
|
||||
|
||||
tw-animate-css@1.3.8: {}
|
||||
|
||||
tweetnacl@0.14.5: {}
|
||||
|
||||
type-check@0.4.0:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
@ -10767,6 +10867,8 @@ snapshots:
|
||||
|
||||
underscore@1.13.7: {}
|
||||
|
||||
undici-types@5.26.5: {}
|
||||
|
||||
undici-types@7.10.0: {}
|
||||
|
||||
undici@7.16.0: {}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user