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",
|
"@nestjs/throttler": "^6.4.0",
|
||||||
"@prisma/client": "^6.14.0",
|
"@prisma/client": "^6.14.0",
|
||||||
"@sendgrid/mail": "^8.1.6",
|
"@sendgrid/mail": "^8.1.6",
|
||||||
|
"@types/ssh2-sftp-client": "^9.0.5",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"bullmq": "^5.58.0",
|
"bullmq": "^5.58.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
@ -56,7 +57,6 @@
|
|||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"nestjs-pino": "^4.4.0",
|
"nestjs-pino": "^4.4.0",
|
||||||
"nestjs-zod": "^5.0.1",
|
"nestjs-zod": "^5.0.1",
|
||||||
"tsconfig-paths": "^4.2.0",
|
|
||||||
"p-queue": "^7.4.1",
|
"p-queue": "^7.4.1",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
@ -69,6 +69,8 @@
|
|||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
"salesforce-pubsub-api-client": "^5.5.0",
|
"salesforce-pubsub-api-client": "^5.5.0",
|
||||||
"speakeasy": "^2.0.0",
|
"speakeasy": "^2.0.0",
|
||||||
|
"ssh2-sftp-client": "^12.0.1",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"zod": "^4.1.9"
|
"zod": "^4.1.9"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -180,3 +180,78 @@ model SimVoiceOptions {
|
|||||||
|
|
||||||
@@map("sim_voice_options")
|
@@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,
|
FreebitVoiceOptionResponse,
|
||||||
FreebitCancelPlanRequest,
|
FreebitCancelPlanRequest,
|
||||||
FreebitCancelPlanResponse,
|
FreebitCancelPlanResponse,
|
||||||
|
FreebitCancelAccountRequest,
|
||||||
|
FreebitCancelAccountResponse,
|
||||||
FreebitEsimReissueRequest,
|
FreebitEsimReissueRequest,
|
||||||
FreebitEsimReissueResponse,
|
FreebitEsimReissueResponse,
|
||||||
FreebitEsimAddAccountRequest,
|
FreebitEsimAddAccountRequest,
|
||||||
@ -738,15 +740,7 @@ export class FreebitOperationsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cancel SIM service
|
* Cancel SIM plan (PA05-04 - plan cancellation only)
|
||||||
* 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
|
|
||||||
*/
|
*/
|
||||||
async cancelSim(account: string, scheduledAt?: string): Promise<void> {
|
async cancelSim(account: string, scheduledAt?: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@ -755,10 +749,9 @@ export class FreebitOperationsService {
|
|||||||
runTime: scheduledAt,
|
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,
|
account,
|
||||||
runTime: scheduledAt,
|
runTime: scheduledAt,
|
||||||
note: "After this, PA05-21 plan changes will cancel the cancellation",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.client.makeAuthenticatedRequest<FreebitCancelPlanResponse, typeof request>(
|
await this.client.makeAuthenticatedRequest<FreebitCancelPlanResponse, typeof request>(
|
||||||
@ -766,19 +759,64 @@ export class FreebitOperationsService {
|
|||||||
request
|
request
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.log(`Successfully cancelled SIM for account ${account}`, {
|
this.logger.log(`Successfully cancelled SIM plan for account ${account}`, {
|
||||||
account,
|
account,
|
||||||
runTime: scheduledAt,
|
runTime: scheduledAt,
|
||||||
});
|
});
|
||||||
this.stampOperation(account, "cancellation");
|
this.stampOperation(account, "cancellation");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getErrorMessage(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,
|
account,
|
||||||
scheduledAt,
|
scheduledAt,
|
||||||
error: message,
|
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> {
|
async cancelSim(account: string, scheduledAt?: string): Promise<void> {
|
||||||
const normalizedAccount = this.mapper.normalizeAccount(account);
|
const normalizedAccount = this.mapper.normalizeAccount(account);
|
||||||
return this.operations.cancelSim(normalizedAccount, scheduledAt);
|
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)
|
* 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 { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service";
|
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 { SimValidationService } from "./sim-validation.service";
|
||||||
import { SimNotificationService } from "./sim-notification.service";
|
import { SimNotificationService } from "./sim-notification.service";
|
||||||
|
import { SimApiNotificationService } from "./sim-api-notification.service";
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
import type { SimReissueRequest } from "@customer-portal/domain/sim";
|
import type { SimReissueRequest } from "@customer-portal/domain/sim";
|
||||||
|
|
||||||
|
export interface ReissueSimRequest {
|
||||||
|
simType: "physical" | "esim";
|
||||||
|
newEid?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EsimManagementService {
|
export class EsimManagementService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly freebitService: FreebitOrchestratorService,
|
private readonly freebitService: FreebitOrchestratorService,
|
||||||
|
private readonly whmcsService: WhmcsService,
|
||||||
|
private readonly mappingsService: MappingsService,
|
||||||
private readonly simValidation: SimValidationService,
|
private readonly simValidation: SimValidationService,
|
||||||
private readonly simNotification: SimNotificationService,
|
private readonly simNotification: SimNotificationService,
|
||||||
|
private readonly apiNotification: SimApiNotificationService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@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(
|
async reissueEsimProfile(
|
||||||
userId: string,
|
userId: string,
|
||||||
@ -75,4 +92,124 @@ export class EsimManagementService {
|
|||||||
throw error;
|
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 { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service";
|
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 { SimValidationService } from "./sim-validation.service";
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
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 { SimScheduleService } from "./sim-schedule.service";
|
||||||
import { SimActionRunnerService } from "./sim-action-runner.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()
|
@Injectable()
|
||||||
export class SimCancellationService {
|
export class SimCancellationService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly freebitService: FreebitOrchestratorService,
|
private readonly freebitService: FreebitOrchestratorService,
|
||||||
|
private readonly whmcsService: WhmcsService,
|
||||||
|
private readonly mappingsService: MappingsService,
|
||||||
private readonly simValidation: SimValidationService,
|
private readonly simValidation: SimValidationService,
|
||||||
private readonly simSchedule: SimScheduleService,
|
private readonly simSchedule: SimScheduleService,
|
||||||
private readonly simActionRunner: SimActionRunnerService,
|
private readonly simActionRunner: SimActionRunnerService,
|
||||||
|
private readonly apiNotification: SimApiNotificationService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@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(
|
async cancelSim(
|
||||||
userId: string,
|
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 { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service";
|
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service";
|
||||||
import { SimValidationService } from "./sim-validation.service";
|
import { SimValidationService } from "./sim-validation.service";
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
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 { SimScheduleService } from "./sim-schedule.service";
|
||||||
import { SimActionRunnerService } from "./sim-action-runner.service";
|
import { SimActionRunnerService } from "./sim-action-runner.service";
|
||||||
import { SimManagementQueueService } from "../queue/sim-management.queue";
|
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()
|
@Injectable()
|
||||||
export class SimPlanService {
|
export class SimPlanService {
|
||||||
@ -16,11 +43,71 @@ export class SimPlanService {
|
|||||||
private readonly simSchedule: SimScheduleService,
|
private readonly simSchedule: SimScheduleService,
|
||||||
private readonly simActionRunner: SimActionRunnerService,
|
private readonly simActionRunner: SimActionRunnerService,
|
||||||
private readonly simQueue: SimManagementQueueService,
|
private readonly simQueue: SimManagementQueueService,
|
||||||
|
private readonly apiNotification: SimApiNotificationService,
|
||||||
|
private readonly simCatalog: SimCatalogService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@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(
|
async changeSimPlan(
|
||||||
userId: string,
|
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)
|
* Update SIM features (voicemail, call waiting, roaming, network type)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service";
|
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service";
|
||||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
||||||
import { SimValidationService } from "./sim-validation.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 type { SimTopUpRequest } from "@customer-portal/domain/sim";
|
||||||
import { SimBillingService } from "./sim-billing.service";
|
import { SimBillingService } from "./sim-billing.service";
|
||||||
import { SimActionRunnerService } from "./sim-action-runner.service";
|
import { SimActionRunnerService } from "./sim-action-runner.service";
|
||||||
|
import { SimApiNotificationService } from "./sim-api-notification.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SimTopUpService {
|
export class SimTopUpService {
|
||||||
@ -16,9 +18,19 @@ export class SimTopUpService {
|
|||||||
private readonly simValidation: SimValidationService,
|
private readonly simValidation: SimValidationService,
|
||||||
private readonly simBilling: SimBillingService,
|
private readonly simBilling: SimBillingService,
|
||||||
private readonly simActionRunner: SimActionRunnerService,
|
private readonly simActionRunner: SimActionRunnerService,
|
||||||
|
private readonly apiNotification: SimApiNotificationService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@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
|
* Top up SIM data quota with payment processing
|
||||||
* Pricing: 1GB = 500 JPY
|
* Pricing: 1GB = 500 JPY
|
||||||
@ -88,8 +100,11 @@ export class SimTopUpService {
|
|||||||
metadata: { subscriptionId },
|
metadata: { subscriptionId },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Call Freebit API to add quota
|
||||||
|
let freebitResult: { resultCode: string; status: { message: string; statusCode: string } } | null = null;
|
||||||
try {
|
try {
|
||||||
await this.freebitService.topUpSim(latestAccount, request.quotaMb, {});
|
await this.freebitService.topUpSim(latestAccount, request.quotaMb, {});
|
||||||
|
freebitResult = { resultCode: "100", status: { message: "OK", statusCode: "200" } };
|
||||||
} catch (freebitError) {
|
} catch (freebitError) {
|
||||||
await this.handleFreebitFailureAfterPayment(
|
await this.handleFreebitFailureAfterPayment(
|
||||||
freebitError,
|
freebitError,
|
||||||
@ -112,6 +127,52 @@ export class SimTopUpService {
|
|||||||
transactionId: billing.transactionId,
|
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 {
|
return {
|
||||||
account: latestAccount,
|
account: latestAccount,
|
||||||
costJpy,
|
costJpy,
|
||||||
|
|||||||
@ -18,12 +18,14 @@ import { SimCancellationService } from "./services/sim-cancellation.service";
|
|||||||
import { EsimManagementService } from "./services/esim-management.service";
|
import { EsimManagementService } from "./services/esim-management.service";
|
||||||
import { SimValidationService } from "./services/sim-validation.service";
|
import { SimValidationService } from "./services/sim-validation.service";
|
||||||
import { SimNotificationService } from "./services/sim-notification.service";
|
import { SimNotificationService } from "./services/sim-notification.service";
|
||||||
|
import { SimApiNotificationService } from "./services/sim-api-notification.service";
|
||||||
import { SimBillingService } from "./services/sim-billing.service";
|
import { SimBillingService } from "./services/sim-billing.service";
|
||||||
import { SimScheduleService } from "./services/sim-schedule.service";
|
import { SimScheduleService } from "./services/sim-schedule.service";
|
||||||
import { SimActionRunnerService } from "./services/sim-action-runner.service";
|
import { SimActionRunnerService } from "./services/sim-action-runner.service";
|
||||||
import { SimManagementQueueService } from "./queue/sim-management.queue";
|
import { SimManagementQueueService } from "./queue/sim-management.queue";
|
||||||
import { SimManagementProcessor } from "./queue/sim-management.processor";
|
import { SimManagementProcessor } from "./queue/sim-management.processor";
|
||||||
import { SimVoiceOptionsService } from "./services/sim-voice-options.service";
|
import { SimVoiceOptionsService } from "./services/sim-voice-options.service";
|
||||||
|
import { CatalogModule } from "@bff/modules/catalog/catalog.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -32,6 +34,7 @@ import { SimVoiceOptionsService } from "./services/sim-voice-options.service";
|
|||||||
SalesforceModule,
|
SalesforceModule,
|
||||||
MappingsModule,
|
MappingsModule,
|
||||||
EmailModule,
|
EmailModule,
|
||||||
|
CatalogModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
// Core services that the SIM services depend on
|
// Core services that the SIM services depend on
|
||||||
@ -41,6 +44,7 @@ import { SimVoiceOptionsService } from "./services/sim-voice-options.service";
|
|||||||
// SIM management services
|
// SIM management services
|
||||||
SimValidationService,
|
SimValidationService,
|
||||||
SimNotificationService,
|
SimNotificationService,
|
||||||
|
SimApiNotificationService,
|
||||||
SimVoiceOptionsService,
|
SimVoiceOptionsService,
|
||||||
SimDetailsService,
|
SimDetailsService,
|
||||||
SimUsageService,
|
SimUsageService,
|
||||||
@ -73,6 +77,7 @@ import { SimVoiceOptionsService } from "./services/sim-voice-options.service";
|
|||||||
EsimManagementService,
|
EsimManagementService,
|
||||||
SimValidationService,
|
SimValidationService,
|
||||||
SimNotificationService,
|
SimNotificationService,
|
||||||
|
SimApiNotificationService,
|
||||||
SimBillingService,
|
SimBillingService,
|
||||||
SimScheduleService,
|
SimScheduleService,
|
||||||
SimActionRunnerService,
|
SimActionRunnerService,
|
||||||
|
|||||||
@ -32,13 +32,20 @@ import {
|
|||||||
simChangePlanRequestSchema,
|
simChangePlanRequestSchema,
|
||||||
simCancelRequestSchema,
|
simCancelRequestSchema,
|
||||||
simFeaturesRequestSchema,
|
simFeaturesRequestSchema,
|
||||||
|
simCancelFullRequestSchema,
|
||||||
|
simChangePlanFullRequestSchema,
|
||||||
type SimTopupRequest,
|
type SimTopupRequest,
|
||||||
type SimChangePlanRequest,
|
type SimChangePlanRequest,
|
||||||
type SimCancelRequest,
|
type SimCancelRequest,
|
||||||
type SimFeaturesRequest,
|
type SimFeaturesRequest,
|
||||||
|
type SimCancelFullRequest,
|
||||||
|
type SimChangePlanFullRequest,
|
||||||
} from "@customer-portal/domain/sim";
|
} from "@customer-portal/domain/sim";
|
||||||
import { ZodValidationPipe } from "@customer-portal/validation/nestjs";
|
import { ZodValidationPipe } from "@customer-portal/validation/nestjs";
|
||||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types";
|
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({
|
const subscriptionInvoiceQuerySchema = createPaginationSchema({
|
||||||
defaultLimit: 10,
|
defaultLimit: 10,
|
||||||
@ -52,7 +59,10 @@ export class SubscriptionsController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly subscriptionsService: SubscriptionsService,
|
private readonly subscriptionsService: SubscriptionsService,
|
||||||
private readonly simManagementService: SimManagementService,
|
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()
|
@Get()
|
||||||
@ -228,4 +238,88 @@ export class SubscriptionsController {
|
|||||||
await this.simManagementService.updateSimFeatures(req.user.id, subscriptionId, body);
|
await this.simManagementService.updateSimFeatures(req.user.id, subscriptionId, body);
|
||||||
return { success: true, message: "SIM features updated successfully" };
|
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() {
|
import SimReissueContainer from "@/features/subscriptions/views/SimReissue";
|
||||||
return null;
|
|
||||||
|
export default function SimReissuePage() {
|
||||||
|
return <SimReissueContainer />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
DevicePhoneMobileIcon,
|
DevicePhoneMobileIcon,
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
@ -49,10 +50,17 @@ interface SimInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SimManagementSection({ subscriptionId }: SimManagementSectionProps) {
|
export function SimManagementSection({ subscriptionId }: SimManagementSectionProps) {
|
||||||
|
const router = useRouter();
|
||||||
const [simInfo, setSimInfo] = useState<SimInfo | null>(null);
|
const [simInfo, setSimInfo] = useState<SimInfo | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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
|
// Fetch subscription data
|
||||||
const { data: subscription } = useSubscription(subscriptionId);
|
const { data: subscription } = useSubscription(subscriptionId);
|
||||||
|
|
||||||
@ -234,7 +242,7 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
|||||||
{/* Top Up Data Button */}
|
{/* Top Up Data Button */}
|
||||||
<div className="pt-4">
|
<div className="pt-4">
|
||||||
<button
|
<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"
|
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
|
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>
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">SIM Management Actions</h3>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<button
|
<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"
|
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" />
|
<SignalIcon className="h-8 w-8 text-gray-700 mb-2" />
|
||||||
@ -289,7 +297,7 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<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"
|
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" />
|
<ArrowsRightLeftIcon className="h-8 w-8 text-gray-700 mb-2" />
|
||||||
@ -297,16 +305,15 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {/* TODO: Open reissue modal */}}
|
onClick={navigateToReissue}
|
||||||
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"
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
<ArrowPathRoundedSquareIcon className="h-8 w-8 text-gray-700 mb-2" />
|
<ArrowPathRoundedSquareIcon className="h-8 w-8 text-gray-700 mb-2" />
|
||||||
<span className="text-sm font-medium text-gray-900">Reissue SIM</span>
|
<span className="text-sm font-medium text-gray-900">Reissue SIM</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<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"
|
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" />
|
<XCircleIcon className="h-8 w-8 text-gray-700 mb-2" />
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { apiClient } from "@/lib/api";
|
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 {
|
import type {
|
||||||
SimTopUpRequest,
|
SimTopUpRequest,
|
||||||
SimPlanChangeRequest,
|
SimPlanChangeRequest,
|
||||||
@ -12,6 +12,41 @@ import type {
|
|||||||
// - SimPlanChangeRequest: newPlanCode, assignGlobalIp, scheduledAt (YYYYMMDD format)
|
// - SimPlanChangeRequest: newPlanCode, assignGlobalIp, scheduledAt (YYYYMMDD format)
|
||||||
// - SimCancelRequest: 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 = {
|
export const simActionsService = {
|
||||||
async topUp(subscriptionId: string, request: SimTopUpRequest): Promise<void> {
|
async topUp(subscriptionId: string, request: SimTopUpRequest): Promise<void> {
|
||||||
await apiClient.POST("/api/subscriptions/{subscriptionId}/sim/top-up", {
|
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> {
|
async cancel(subscriptionId: string, request: SimCancelRequest): Promise<void> {
|
||||||
await apiClient.POST("/api/subscriptions/{subscriptionId}/sim/cancel", {
|
await apiClient.POST("/api/subscriptions/{subscriptionId}/sim/cancel", {
|
||||||
params: { path: { subscriptionId } },
|
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> {
|
async getSimInfo(subscriptionId: string): Promise<SimInfo | null> {
|
||||||
const response = await apiClient.GET<SimInfo>("/api/subscriptions/{subscriptionId}/sim", {
|
const response = await apiClient.GET<SimInfo>("/api/subscriptions/{subscriptionId}/sim", {
|
||||||
params: { path: { subscriptionId } },
|
params: { path: { subscriptionId } },
|
||||||
@ -45,4 +98,31 @@ export const simActionsService = {
|
|||||||
|
|
||||||
return simInfoSchema.parse(response.data);
|
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 Link from "next/link";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
import { simActionsService } from "@/features/subscriptions/services/sim-actions.service";
|
import { simActionsService, type CancellationPreview } from "@/features/subscriptions/services/sim-actions.service";
|
||||||
import { useAuthStore } from "@/features/auth/services/auth.store";
|
|
||||||
import type { SimDetails } from "@/features/sim-management/components/SimDetailsCard";
|
|
||||||
|
|
||||||
type Step = 1 | 2 | 3;
|
type Step = 1 | 2 | 3;
|
||||||
|
|
||||||
function Notice({ title, children }: { title: string; children: ReactNode }) {
|
function Notice({ title, children }: { title: string; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-yellow-50 border border-yellow-200 rounded p-3">
|
<div className="bg-yellow-50 border border-yellow-200 rounded p-4">
|
||||||
<div className="text-sm font-medium text-yellow-900 mb-1">{title}</div>
|
<div className="text-sm font-semibold text-yellow-900 mb-2">{title}</div>
|
||||||
<div className="text-sm text-yellow-800">{children}</div>
|
<div className="text-sm text-yellow-800 leading-relaxed">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -34,79 +32,63 @@ export function SimCancelContainer() {
|
|||||||
|
|
||||||
const [step, setStep] = useState<Step>(1);
|
const [step, setStep] = useState<Step>(1);
|
||||||
const [loading, setLoading] = useState(false);
|
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 [error, setError] = useState<string | null>(null);
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
const [acceptTerms, setAcceptTerms] = useState(false);
|
const [acceptTerms, setAcceptTerms] = useState(false);
|
||||||
const [confirmMonthEnd, setConfirmMonthEnd] = useState(false);
|
const [confirmMonthEnd, setConfirmMonthEnd] = useState(false);
|
||||||
const [cancelMonth, setCancelMonth] = useState<string>("");
|
const [selectedMonth, setSelectedMonth] = useState<string>("");
|
||||||
const [email, setEmail] = useState<string>("");
|
const [alternativeEmail, setAlternativeEmail] = useState<string>("");
|
||||||
const [email2, setEmail2] = useState<string>("");
|
const [alternativeEmail2, setAlternativeEmail2] = useState<string>("");
|
||||||
const [notes, setNotes] = useState<string>("");
|
const [comments, setComments] = useState<string>("");
|
||||||
const [registeredEmail, setRegisteredEmail] = useState<string | null>(null);
|
const [loadingPreview, setLoadingPreview] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchDetails = async () => {
|
const fetchPreview = async () => {
|
||||||
try {
|
try {
|
||||||
const info = await simActionsService.getSimInfo(subscriptionId);
|
const data = await simActionsService.getCancellationPreview(subscriptionId);
|
||||||
setDetails(info?.details || null);
|
setPreview(data);
|
||||||
} catch (e: unknown) {
|
} 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]);
|
}, [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 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 =
|
const emailValid =
|
||||||
!emailProvided || (emailPattern.test(email.trim()) && emailPattern.test(email2.trim()));
|
!emailProvided ||
|
||||||
const emailsMatch = !emailProvided || email.trim() === email2.trim();
|
(emailPattern.test(alternativeEmail.trim()) && emailPattern.test(alternativeEmail2.trim()));
|
||||||
const canProceedStep3 =
|
const emailsMatch = !emailProvided || alternativeEmail.trim() === alternativeEmail2.trim();
|
||||||
acceptTerms && !!cancelMonth && confirmMonthEnd && emailValid && emailsMatch;
|
const canProceedStep2 = !!preview && !!selectedMonth;
|
||||||
const runDate = cancelMonth ? `${cancelMonth}01` : null;
|
const canProceedStep3 = acceptTerms && confirmMonthEnd && emailValid && emailsMatch;
|
||||||
|
|
||||||
|
const selectedMonthInfo = preview?.availableMonths.find(m => m.value === selectedMonth);
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
if (!runDate) {
|
|
||||||
|
if (!selectedMonth) {
|
||||||
setError("Please select a cancellation month before submitting.");
|
setError("Please select a cancellation month before submitting.");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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.");
|
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) {
|
} catch (e: unknown) {
|
||||||
setError(e instanceof Error ? e.message : "Failed to submit cancellation");
|
setError(e instanceof Error ? e.message : "Failed to submit cancellation");
|
||||||
} finally {
|
} finally {
|
||||||
@ -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 (
|
return (
|
||||||
<div className="max-w-3xl mx-auto p-6">
|
<div className="max-w-3xl mx-auto p-6">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
@ -123,14 +116,24 @@ export function SimCancelContainer() {
|
|||||||
>
|
>
|
||||||
← Back to SIM Management
|
← Back to SIM Management
|
||||||
</Link>
|
</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>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{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 && (
|
{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}
|
{message}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -138,44 +141,59 @@ export function SimCancelContainer() {
|
|||||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
<h1 className="text-xl font-semibold text-gray-900 mb-2">Cancel SIM</h1>
|
<h1 className="text-xl font-semibold text-gray-900 mb-2">Cancel SIM</h1>
|
||||||
<p className="text-sm text-gray-600 mb-6">
|
<p className="text-sm text-gray-600 mb-6">
|
||||||
Cancel SIM: Permanently cancel your SIM service. This action cannot be undone and will
|
Cancel your SIM subscription. Please read all the information carefully before proceeding.
|
||||||
terminate your service immediately.
|
|
||||||
</p>
|
</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 && (
|
{step === 1 && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
|
{/* SIM Info */}
|
||||||
<InfoRow label="SIM" value={details?.msisdn || "—"} />
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 p-4 bg-gray-50 rounded-lg">
|
||||||
<InfoRow label="Activated" value={details?.activatedAt || "—"} />
|
<InfoRow label="SIM Number" value={preview?.simNumber || "—"} />
|
||||||
<div>
|
<InfoRow label="Serial #" value={preview?.serialNumber || "—"} />
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<InfoRow label="Start Date" value={preview?.startDate || "—"} />
|
||||||
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>
|
|
||||||
</div>
|
</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">
|
<div className="flex justify-end">
|
||||||
<button
|
<button
|
||||||
disabled={!canProceedStep2}
|
disabled={!canProceedStep2}
|
||||||
onClick={() => setStep(2)}
|
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
|
Next
|
||||||
</button>
|
</button>
|
||||||
@ -185,8 +203,8 @@ export function SimCancelContainer() {
|
|||||||
|
|
||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
<Notice title="Cancellation Procedure">
|
<Notice title="[Cancellation Procedure]">
|
||||||
Online cancellations must be made from this website by the 25th of the desired
|
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
|
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
|
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
|
services with Assist Solutions (home internet etc.) please contact Assist Solutions
|
||||||
at info@asolutions.co.jp
|
at info@asolutions.co.jp
|
||||||
</Notice>
|
</Notice>
|
||||||
<Notice title="Minimum Contract Term">
|
|
||||||
|
<Notice title="[Minimum Contract Term]">
|
||||||
The SONIXNET SIM has a minimum contract term agreement of three months (sign-up
|
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 =
|
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
|
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
|
fulfilled, the monthly fees of the remaining months will be charged upon
|
||||||
cancellation.
|
cancellation.
|
||||||
</Notice>
|
</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
|
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
|
base plan active is not possible from this online form. Please contact Assist
|
||||||
Solutions Customer Support (info@asolutions.co.jp) for more information. Upon
|
Solutions Customer Support (info@asolutions.co.jp) for more information. Upon
|
||||||
cancelling the base plan, all additional options associated with the requested SIM
|
cancelling the base plan, all additional options associated with the requested SIM
|
||||||
plan will be cancelled.
|
plan will be cancelled.
|
||||||
</Notice>
|
</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
|
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
|
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
|
transfer (administrative fee ¥1,000+tax) is necessary. The MNP cannot be requested
|
||||||
requested from this online form. Please contact Assist Solutions Customer Support
|
from this online form. Please contact Assist Solutions Customer Support
|
||||||
(info@asolutions.co.jp) for more information.
|
(info@asolutions.co.jp) for more information.
|
||||||
</Notice>
|
</Notice>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
<div className="space-y-3 bg-gray-50 rounded-lg p-4">
|
||||||
id="acceptTerms"
|
<div className="flex items-start gap-3">
|
||||||
type="checkbox"
|
<input
|
||||||
checked={acceptTerms}
|
id="acceptTerms"
|
||||||
onChange={e => setAcceptTerms(e.target.checked)}
|
type="checkbox"
|
||||||
/>
|
checked={acceptTerms}
|
||||||
<label htmlFor="acceptTerms" className="text-sm text-gray-700">
|
onChange={e => setAcceptTerms(e.target.checked)}
|
||||||
I have read and accepted the conditions above.
|
className="h-4 w-4 text-blue-600 border-gray-300 rounded mt-0.5"
|
||||||
</label>
|
/>
|
||||||
</div>
|
<label htmlFor="acceptTerms" className="text-sm text-gray-700">
|
||||||
<div className="flex items-start gap-2">
|
I have read and accepted the conditions above.
|
||||||
<input
|
</label>
|
||||||
id="confirmMonthEnd"
|
</div>
|
||||||
type="checkbox"
|
|
||||||
checked={confirmMonthEnd}
|
<div className="flex items-start gap-3">
|
||||||
onChange={e => setConfirmMonthEnd(e.target.checked)}
|
<input
|
||||||
disabled={!cancelMonth}
|
id="confirmMonthEnd"
|
||||||
/>
|
type="checkbox"
|
||||||
<label htmlFor="confirmMonthEnd" className="text-sm text-gray-700">
|
checked={confirmMonthEnd}
|
||||||
I would like to cancel my SonixNet SIM subscription at the end of the selected month
|
onChange={e => setConfirmMonthEnd(e.target.checked)}
|
||||||
above.
|
disabled={!selectedMonth}
|
||||||
</label>
|
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>
|
||||||
|
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<button
|
<button
|
||||||
onClick={() => setStep(1)}
|
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
|
Back
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
disabled={!canProceedStep3}
|
disabled={!canProceedStep3}
|
||||||
onClick={() => setStep(3)}
|
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
|
Next
|
||||||
</button>
|
</button>
|
||||||
@ -262,65 +290,93 @@ export function SimCancelContainer() {
|
|||||||
|
|
||||||
{step === 3 && (
|
{step === 3 && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{registeredEmail && (
|
{/* Voice SIM Notice */}
|
||||||
<div className="text-sm text-gray-800">
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
Your registered email address is:{" "}
|
<div className="text-sm font-semibold text-blue-900 mb-2">
|
||||||
<span className="font-medium">{registeredEmail}</span>
|
For Voice-enabled SIM subscriptions:
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="text-sm text-blue-800">
|
||||||
<div className="text-sm text-gray-700">
|
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
|
You will receive a cancellation confirmation email. If you would like to receive this
|
||||||
email on a different address, please enter the address below.
|
email on a different address, please enter the address below.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Alternative Email */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">Email address</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Email address:
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
className="mt-1 w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500"
|
||||||
value={email}
|
value={alternativeEmail}
|
||||||
onChange={e => setEmail(e.target.value)}
|
onChange={e => setAlternativeEmail(e.target.value)}
|
||||||
placeholder="you@example.com"
|
placeholder="you@example.com"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">(Confirm)</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">(Confirm):</label>
|
||||||
<input
|
<input
|
||||||
className="mt-1 w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500"
|
||||||
value={email2}
|
value={alternativeEmail2}
|
||||||
onChange={e => setEmail2(e.target.value)}
|
onChange={e => setAlternativeEmail2(e.target.value)}
|
||||||
placeholder="you@example.com"
|
placeholder="you@example.com"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{emailProvided && !emailValid && (
|
{emailProvided && !emailValid && (
|
||||||
<div className="text-xs text-red-600">
|
<div className="text-xs text-red-600">Please enter a valid email address in both fields.</div>
|
||||||
Please enter a valid email address in both fields.
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{emailProvided && emailValid && !emailsMatch && (
|
{emailProvided && emailValid && !emailsMatch && (
|
||||||
<div className="text-xs text-red-600">Email addresses do not match.</div>
|
<div className="text-xs text-red-600">Email addresses do not match.</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-sm text-gray-700">
|
|
||||||
Your cancellation request is not confirmed yet. This is the final page. To finalize
|
{/* Comments */}
|
||||||
your cancellation request please proceed from REQUEST CANCELLATION below.
|
<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>
|
</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">
|
<div className="flex justify-between">
|
||||||
<button
|
<button
|
||||||
onClick={() => setStep(2)}
|
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
|
Back
|
||||||
</button>
|
</button>
|
||||||
@ -328,18 +384,16 @@ export function SimCancelContainer() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (
|
if (
|
||||||
window.confirm(
|
window.confirm(
|
||||||
"Request cancellation now? This will schedule the cancellation for " +
|
`Are you sure you want to cancel your SIM subscription? This will take effect at the end of ${selectedMonthInfo?.label || selectedMonth}.`
|
||||||
(runDate || "") +
|
|
||||||
"."
|
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
void submit();
|
void submit();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={loading || !runDate || !canProceedStep3}
|
disabled={loading || !canProceedStep3}
|
||||||
className="px-4 py-2 rounded-md bg-red-600 text-white text-sm disabled:opacity-50"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,39 +1,47 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { PageLayout } from "@/components/templates/PageLayout";
|
import { PageLayout } from "@/components/templates/PageLayout";
|
||||||
import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
||||||
import { DevicePhoneMobileIcon } from "@heroicons/react/24/outline";
|
import { DevicePhoneMobileIcon, CheckCircleIcon } from "@heroicons/react/24/outline";
|
||||||
import { simActionsService } from "@/features/subscriptions/services/sim-actions.service";
|
import { simActionsService, type AvailablePlan } from "@/features/subscriptions/services/sim-actions.service";
|
||||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||||
|
import { Formatting } from "@customer-portal/domain/toolkit";
|
||||||
|
|
||||||
import {
|
const { formatCurrency } = Formatting;
|
||||||
SIM_PLAN_OPTIONS,
|
|
||||||
type SimPlanCode,
|
|
||||||
getSimPlanLabel,
|
|
||||||
} from "@customer-portal/domain/sim";
|
|
||||||
|
|
||||||
export function SimChangePlanContainer() {
|
export function SimChangePlanContainer() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const subscriptionId = params.id as string;
|
const subscriptionId = params.id as string;
|
||||||
const [currentPlanCode] = useState<string>("");
|
const [plans, setPlans] = useState<AvailablePlan[]>([]);
|
||||||
const [newPlanCode, setNewPlanCode] = useState<"" | SimPlanCode>("");
|
const [selectedPlan, setSelectedPlan] = useState<AvailablePlan | null>(null);
|
||||||
const [assignGlobalIp, setAssignGlobalIp] = useState(false);
|
const [assignGlobalIp, setAssignGlobalIp] = useState(false);
|
||||||
const [scheduledAt, setScheduledAt] = useState("");
|
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [loadingPlans, setLoadingPlans] = useState(true);
|
||||||
|
|
||||||
const options = useMemo(
|
useEffect(() => {
|
||||||
() => SIM_PLAN_OPTIONS.filter(option => option.code !== currentPlanCode),
|
const fetchPlans = async () => {
|
||||||
[currentPlanCode]
|
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) => {
|
const submit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!newPlanCode) {
|
if (!selectedPlan) {
|
||||||
setError("Please select a new plan");
|
setError("Please select a new plan");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -41,12 +49,14 @@ export function SimChangePlanContainer() {
|
|||||||
setMessage(null);
|
setMessage(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await simActionsService.changePlan(subscriptionId, {
|
const result = await simActionsService.changePlanFull(subscriptionId, {
|
||||||
newPlanCode,
|
newPlanCode: selectedPlan.freebitPlanCode,
|
||||||
|
newPlanSku: selectedPlan.sku,
|
||||||
|
newPlanName: selectedPlan.name,
|
||||||
assignGlobalIp,
|
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) {
|
} catch (e: unknown) {
|
||||||
setError(e instanceof Error ? e.message : "Failed to change plan");
|
setError(e instanceof Error ? e.message : "Failed to change plan");
|
||||||
} finally {
|
} finally {
|
||||||
@ -60,7 +70,7 @@ export function SimChangePlanContainer() {
|
|||||||
title="Change Plan"
|
title="Change Plan"
|
||||||
description="Switch to a different data 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">
|
<div className="mb-4">
|
||||||
<Link
|
<Link
|
||||||
href={`/subscriptions/${subscriptionId}#sim-management`}
|
href={`/subscriptions/${subscriptionId}#sim-management`}
|
||||||
@ -71,11 +81,13 @@ export function SimChangePlanContainer() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SubCard>
|
<SubCard>
|
||||||
<p className="text-sm text-gray-600 mb-6">
|
<div className="mb-6">
|
||||||
Change Plan: Switch to a different data plan. Important: Plan changes must be requested
|
<h2 className="text-lg font-semibold text-gray-900 mb-2">Change Your Plan</h2>
|
||||||
before the 25th of the month. Changes will take effect on the 1st of the following
|
<p className="text-sm text-gray-600">
|
||||||
month.
|
Select a new plan below. Plan changes will take effect on the 1st of the following month.
|
||||||
</p>
|
Changes must be requested before the 25th of the current month.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{message && (
|
{message && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
@ -92,64 +104,127 @@ export function SimChangePlanContainer() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={e => void submit(e)} className="space-y-6">
|
{loadingPlans ? (
|
||||||
<div>
|
<div className="animate-pulse space-y-4">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">New Plan</label>
|
{[1, 2, 3, 4].map(i => (
|
||||||
<select
|
<div key={i} className="h-24 bg-gray-100 rounded-lg"></div>
|
||||||
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>
|
|
||||||
</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">
|
{/* Available Plans */}
|
||||||
<input
|
<div className="space-y-3">
|
||||||
id="globalip"
|
<label className="block text-sm font-medium text-gray-700">Select a New Plan</label>
|
||||||
type="checkbox"
|
<div className="grid gap-3">
|
||||||
checked={assignGlobalIp}
|
{plans
|
||||||
onChange={e => setAssignGlobalIp(e.target.checked)}
|
.filter(p => !p.isCurrentPlan)
|
||||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
.map(plan => (
|
||||||
/>
|
<label
|
||||||
<label htmlFor="globalip" className="ml-2 text-sm text-gray-700">
|
key={plan.id}
|
||||||
Assign global IP
|
className={`relative flex items-center justify-between p-4 border-2 rounded-lg cursor-pointer transition-all ${
|
||||||
</label>
|
selectedPlan?.id === plan.id
|
||||||
</div>
|
? "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>
|
{/* Global IP Option */}
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<div className="flex items-center p-4 bg-gray-50 rounded-lg">
|
||||||
Schedule (optional)
|
<input
|
||||||
</label>
|
id="globalip"
|
||||||
<input
|
type="checkbox"
|
||||||
type="date"
|
checked={assignGlobalIp}
|
||||||
value={scheduledAt}
|
onChange={e => setAssignGlobalIp(e.target.checked)}
|
||||||
onChange={e => setScheduledAt(e.target.value)}
|
className="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
/>
|
||||||
/>
|
<label htmlFor="globalip" className="ml-3 text-sm text-gray-700">
|
||||||
</div>
|
Assign a global IP address (additional charges may apply)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3">
|
{/* Info Box */}
|
||||||
<button
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||||
type="submit"
|
<h3 className="text-sm font-medium text-yellow-900 mb-1">Important Notes</h3>
|
||||||
disabled={loading}
|
<ul className="text-sm text-yellow-800 space-y-1">
|
||||||
className="px-4 py-2 rounded-md bg-blue-600 text-white text-sm disabled:opacity-50"
|
<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>
|
||||||
{loading ? "Processing…" : "Submit Plan Change"}
|
<li>• Your current data balance will be reset when the new plan activates</li>
|
||||||
</button>
|
</ul>
|
||||||
<Link
|
</div>
|
||||||
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"
|
{/* Submit */}
|
||||||
>
|
<div className="flex gap-3">
|
||||||
Back
|
<button
|
||||||
</Link>
|
type="submit"
|
||||||
</div>
|
disabled={loading || !selectedPlan}
|
||||||
</form>
|
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>
|
</SubCard>
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</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": "^5.9.2",
|
||||||
"typescript-eslint": "^8.40.0",
|
"typescript-eslint": "^8.40.0",
|
||||||
"zod": "^4.1.9"
|
"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,
|
SimCardType,
|
||||||
ActivationType,
|
ActivationType,
|
||||||
MnpData,
|
MnpData,
|
||||||
|
// Enhanced request types
|
||||||
|
SimCancelFullRequest,
|
||||||
|
SimTopUpFullRequest,
|
||||||
|
SimChangePlanFullRequest,
|
||||||
// Activation types
|
// Activation types
|
||||||
SimOrderActivationRequest,
|
SimOrderActivationRequest,
|
||||||
SimOrderActivationMnp,
|
SimOrderActivationMnp,
|
||||||
|
|||||||
@ -26,6 +26,7 @@ export const simDetailsSchema = z.object({
|
|||||||
networkType: z.string(),
|
networkType: z.string(),
|
||||||
activatedAt: z.string().optional(),
|
activatedAt: z.string().optional(),
|
||||||
expiresAt: z.string().optional(),
|
expiresAt: z.string().optional(),
|
||||||
|
startDate: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const recentDayUsageSchema = z.object({
|
export const recentDayUsageSchema = z.object({
|
||||||
@ -151,6 +152,36 @@ export const simReissueRequestSchema = z.object({
|
|||||||
.string()
|
.string()
|
||||||
.regex(/^\d{32}$/, "EID must be exactly 32 digits")
|
.regex(/^\d{32}$/, "EID must be exactly 32 digits")
|
||||||
.optional(),
|
.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({
|
const simMnpFormSchema = z.object({
|
||||||
@ -334,6 +365,9 @@ export type SimCancelRequest = z.infer<typeof simCancelRequestSchema>;
|
|||||||
export type SimTopUpHistoryRequest = z.infer<typeof simTopUpHistoryRequestSchema>;
|
export type SimTopUpHistoryRequest = z.infer<typeof simTopUpHistoryRequestSchema>;
|
||||||
export type SimFeaturesUpdateRequest = z.infer<typeof simFeaturesUpdateRequestSchema>;
|
export type SimFeaturesUpdateRequest = z.infer<typeof simFeaturesUpdateRequestSchema>;
|
||||||
export type SimReissueRequest = z.infer<typeof simReissueRequestSchema>;
|
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 SimConfigureFormData = z.infer<typeof simConfigureFormSchema>;
|
||||||
export type SimCardType = z.infer<typeof simCardTypeSchema>;
|
export type SimCardType = z.infer<typeof simCardTypeSchema>;
|
||||||
export type ActivationType = z.infer<typeof simActivationTypeSchema>;
|
export type ActivationType = z.infer<typeof simActivationTypeSchema>;
|
||||||
|
|||||||
102
pnpm-lock.yaml
generated
102
pnpm-lock.yaml
generated
@ -7,6 +7,13 @@ settings:
|
|||||||
importers:
|
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:
|
devDependencies:
|
||||||
'@eslint/eslintrc':
|
'@eslint/eslintrc':
|
||||||
specifier: ^3.3.1
|
specifier: ^3.3.1
|
||||||
@ -95,6 +102,9 @@ importers:
|
|||||||
'@sendgrid/mail':
|
'@sendgrid/mail':
|
||||||
specifier: ^8.1.6
|
specifier: ^8.1.6
|
||||||
version: 8.1.6
|
version: 8.1.6
|
||||||
|
'@types/ssh2-sftp-client':
|
||||||
|
specifier: ^9.0.5
|
||||||
|
version: 9.0.5
|
||||||
bcrypt:
|
bcrypt:
|
||||||
specifier: ^6.0.0
|
specifier: ^6.0.0
|
||||||
version: 6.0.0
|
version: 6.0.0
|
||||||
@ -167,6 +177,9 @@ importers:
|
|||||||
speakeasy:
|
speakeasy:
|
||||||
specifier: ^2.0.0
|
specifier: ^2.0.0
|
||||||
version: 2.0.0
|
version: 2.0.0
|
||||||
|
ssh2-sftp-client:
|
||||||
|
specifier: ^12.0.1
|
||||||
|
version: 12.0.1
|
||||||
tsconfig-paths:
|
tsconfig-paths:
|
||||||
specifier: ^4.2.0
|
specifier: ^4.2.0
|
||||||
version: 4.2.0
|
version: 4.2.0
|
||||||
@ -1827,6 +1840,9 @@ packages:
|
|||||||
'@types/ms@2.1.0':
|
'@types/ms@2.1.0':
|
||||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||||
|
|
||||||
|
'@types/node@18.19.130':
|
||||||
|
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
|
||||||
|
|
||||||
'@types/node@24.3.1':
|
'@types/node@24.3.1':
|
||||||
resolution: {integrity: sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==}
|
resolution: {integrity: sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==}
|
||||||
|
|
||||||
@ -1865,6 +1881,12 @@ packages:
|
|||||||
'@types/speakeasy@2.0.10':
|
'@types/speakeasy@2.0.10':
|
||||||
resolution: {integrity: sha512-QVRlDW5r4yl7p7xkNIbAIC/JtyOcClDIIdKfuG7PWdDT1MmyhtXSANsildohy0K+Lmvf/9RUtLbNLMacvrVwxA==}
|
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':
|
'@types/stack-utils@2.0.3':
|
||||||
resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==}
|
resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==}
|
||||||
|
|
||||||
@ -2245,6 +2267,9 @@ packages:
|
|||||||
asap@2.0.6:
|
asap@2.0.6:
|
||||||
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
|
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:
|
ast-types-flow@0.0.8:
|
||||||
resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
|
resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
|
||||||
|
|
||||||
@ -2315,6 +2340,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==}
|
resolution: {integrity: sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==}
|
||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
||||||
|
bcrypt-pbkdf@1.0.2:
|
||||||
|
resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==}
|
||||||
|
|
||||||
bcrypt@6.0.0:
|
bcrypt@6.0.0:
|
||||||
resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==}
|
resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==}
|
||||||
engines: {node: '>= 18'}
|
engines: {node: '>= 18'}
|
||||||
@ -2357,6 +2385,10 @@ packages:
|
|||||||
buffer@5.7.1:
|
buffer@5.7.1:
|
||||||
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
|
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:
|
bullmq@5.58.5:
|
||||||
resolution: {integrity: sha512-0A6Qjxdn8j7aOcxfRZY798vO/aMuwvoZwfE6a9EOXHb1pzpBVAogsc/OfRWeUf+5wMBoYB5nthstnJo/zrQOeQ==}
|
resolution: {integrity: sha512-0A6Qjxdn8j7aOcxfRZY798vO/aMuwvoZwfE6a9EOXHb1pzpBVAogsc/OfRWeUf+5wMBoYB5nthstnJo/zrQOeQ==}
|
||||||
|
|
||||||
@ -2595,6 +2627,10 @@ packages:
|
|||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
cpu-features@0.0.10:
|
||||||
|
resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==}
|
||||||
|
engines: {node: '>=10.0.0'}
|
||||||
|
|
||||||
create-require@1.1.1:
|
create-require@1.1.1:
|
||||||
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
|
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
|
||||||
|
|
||||||
@ -4089,6 +4125,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==}
|
resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==}
|
||||||
engines: {node: ^18.17.0 || >=20.5.0}
|
engines: {node: ^18.17.0 || >=20.5.0}
|
||||||
|
|
||||||
|
nan@2.23.1:
|
||||||
|
resolution: {integrity: sha512-r7bBUGKzlqk8oPBDYxt6Z0aEdF1G1rwlMcLk8LCOMbOzf0mG+JUfUzG4fIMWwHWP0iyaLWEQZJmtB7nOHEm/qw==}
|
||||||
|
|
||||||
nanoid@3.3.11:
|
nanoid@3.3.11:
|
||||||
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
@ -4782,6 +4821,14 @@ packages:
|
|||||||
sprintf-js@1.0.3:
|
sprintf-js@1.0.3:
|
||||||
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
|
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:
|
stable-hash@0.0.5:
|
||||||
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
|
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
|
||||||
|
|
||||||
@ -5097,6 +5144,9 @@ packages:
|
|||||||
tw-animate-css@1.3.8:
|
tw-animate-css@1.3.8:
|
||||||
resolution: {integrity: sha512-Qrk3PZ7l7wUcGYhwZloqfkWCmaXZAoqjkdbIDvzfGshwGtexa/DAs9koXxIkrpEasyevandomzCBAV1Yyop5rw==}
|
resolution: {integrity: sha512-Qrk3PZ7l7wUcGYhwZloqfkWCmaXZAoqjkdbIDvzfGshwGtexa/DAs9koXxIkrpEasyevandomzCBAV1Yyop5rw==}
|
||||||
|
|
||||||
|
tweetnacl@0.14.5:
|
||||||
|
resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==}
|
||||||
|
|
||||||
type-check@0.4.0:
|
type-check@0.4.0:
|
||||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@ -5182,6 +5232,9 @@ packages:
|
|||||||
underscore@1.13.7:
|
underscore@1.13.7:
|
||||||
resolution: {integrity: sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==}
|
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:
|
undici-types@7.10.0:
|
||||||
resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==}
|
resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==}
|
||||||
|
|
||||||
@ -6869,6 +6922,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/ms@2.1.0': {}
|
'@types/ms@2.1.0': {}
|
||||||
|
|
||||||
|
'@types/node@18.19.130':
|
||||||
|
dependencies:
|
||||||
|
undici-types: 5.26.5
|
||||||
|
|
||||||
'@types/node@24.3.1':
|
'@types/node@24.3.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.10.0
|
undici-types: 7.10.0
|
||||||
@ -6920,6 +6977,14 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 24.3.1
|
'@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/stack-utils@2.0.3': {}
|
||||||
|
|
||||||
'@types/superagent@8.1.9':
|
'@types/superagent@8.1.9':
|
||||||
@ -7344,6 +7409,10 @@ snapshots:
|
|||||||
|
|
||||||
asap@2.0.6: {}
|
asap@2.0.6: {}
|
||||||
|
|
||||||
|
asn1@0.2.6:
|
||||||
|
dependencies:
|
||||||
|
safer-buffer: 2.1.2
|
||||||
|
|
||||||
ast-types-flow@0.0.8: {}
|
ast-types-flow@0.0.8: {}
|
||||||
|
|
||||||
async-function@1.0.0: {}
|
async-function@1.0.0: {}
|
||||||
@ -7434,6 +7503,10 @@ snapshots:
|
|||||||
|
|
||||||
base64url@3.0.1: {}
|
base64url@3.0.1: {}
|
||||||
|
|
||||||
|
bcrypt-pbkdf@1.0.2:
|
||||||
|
dependencies:
|
||||||
|
tweetnacl: 0.14.5
|
||||||
|
|
||||||
bcrypt@6.0.0:
|
bcrypt@6.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
node-addon-api: 8.5.0
|
node-addon-api: 8.5.0
|
||||||
@ -7496,6 +7569,9 @@ snapshots:
|
|||||||
base64-js: 1.5.1
|
base64-js: 1.5.1
|
||||||
ieee754: 1.2.1
|
ieee754: 1.2.1
|
||||||
|
|
||||||
|
buildcheck@0.0.7:
|
||||||
|
optional: true
|
||||||
|
|
||||||
bullmq@5.58.5:
|
bullmq@5.58.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
cron-parser: 4.9.0
|
cron-parser: 4.9.0
|
||||||
@ -7717,6 +7793,12 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 5.8.3
|
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: {}
|
create-require@1.1.1: {}
|
||||||
|
|
||||||
cron-parser@4.9.0:
|
cron-parser@4.9.0:
|
||||||
@ -9571,6 +9653,9 @@ snapshots:
|
|||||||
|
|
||||||
mute-stream@2.0.0: {}
|
mute-stream@2.0.0: {}
|
||||||
|
|
||||||
|
nan@2.23.1:
|
||||||
|
optional: true
|
||||||
|
|
||||||
nanoid@3.3.11: {}
|
nanoid@3.3.11: {}
|
||||||
|
|
||||||
napi-postinstall@0.3.3: {}
|
napi-postinstall@0.3.3: {}
|
||||||
@ -10341,6 +10426,19 @@ snapshots:
|
|||||||
|
|
||||||
sprintf-js@1.0.3: {}
|
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: {}
|
stable-hash@0.0.5: {}
|
||||||
|
|
||||||
stack-utils@2.0.6:
|
stack-utils@2.0.6:
|
||||||
@ -10673,6 +10771,8 @@ snapshots:
|
|||||||
|
|
||||||
tw-animate-css@1.3.8: {}
|
tw-animate-css@1.3.8: {}
|
||||||
|
|
||||||
|
tweetnacl@0.14.5: {}
|
||||||
|
|
||||||
type-check@0.4.0:
|
type-check@0.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
@ -10767,6 +10867,8 @@ snapshots:
|
|||||||
|
|
||||||
underscore@1.13.7: {}
|
underscore@1.13.7: {}
|
||||||
|
|
||||||
|
undici-types@5.26.5: {}
|
||||||
|
|
||||||
undici-types@7.10.0: {}
|
undici-types@7.10.0: {}
|
||||||
|
|
||||||
undici@7.16.0: {}
|
undici@7.16.0: {}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user