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:
tema 2025-11-29 16:42:08 +09:00
parent f6659e363a
commit f49e5d7574
23 changed files with 2147 additions and 271 deletions

View 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.

View File

@ -44,6 +44,7 @@
"@nestjs/throttler": "^6.4.0",
"@prisma/client": "^6.14.0",
"@sendgrid/mail": "^8.1.6",
"@types/ssh2-sftp-client": "^9.0.5",
"bcrypt": "^6.0.0",
"bullmq": "^5.58.0",
"class-transformer": "^0.5.1",
@ -56,7 +57,6 @@
"jsonwebtoken": "^9.0.2",
"nestjs-pino": "^4.4.0",
"nestjs-zod": "^5.0.1",
"tsconfig-paths": "^4.2.0",
"p-queue": "^7.4.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
@ -69,6 +69,8 @@
"rxjs": "^7.8.2",
"salesforce-pubsub-api-client": "^5.5.0",
"speakeasy": "^2.0.0",
"ssh2-sftp-client": "^12.0.1",
"tsconfig-paths": "^4.2.0",
"uuid": "^13.0.0",
"zod": "^4.1.9"
},

View File

@ -180,3 +180,78 @@ model SimVoiceOptions {
@@map("sim_voice_options")
}
// Call history from SFTP monthly imports (domestic calls)
model SimCallHistoryDomestic {
id String @id @default(uuid())
account String // Customer phone number (e.g., "08077052946")
callDate DateTime @db.Date @map("call_date") // Date the call was made
callTime String @map("call_time") // Start time of the call (HHMMSS)
calledTo String @map("called_to") // Phone number called
location String? // Location info
durationSec Int @map("duration_sec") // Duration in seconds (320 = 32.0 sec)
chargeYen Int @map("charge_yen") // Call charge in JPY
month String // YYYY-MM format for filtering
createdAt DateTime @default(now()) @map("created_at")
@@unique([account, callDate, callTime, calledTo])
@@index([account, month])
@@index([account, callDate])
@@map("sim_call_history_domestic")
}
// Call history from SFTP monthly imports (international calls)
model SimCallHistoryInternational {
id String @id @default(uuid())
account String // Customer phone number
callDate DateTime @db.Date @map("call_date") // Date the call was made
startTime String @map("start_time") // Start time of the call
stopTime String? @map("stop_time") // Stop time (if available)
country String? // Country/location for international calls
calledTo String @map("called_to") // Phone number called
durationSec Int @map("duration_sec") // Duration in seconds
chargeYen Int @map("charge_yen") // Call charge in JPY
month String // YYYY-MM format for filtering
createdAt DateTime @default(now()) @map("created_at")
@@unique([account, callDate, startTime, calledTo])
@@index([account, month])
@@index([account, callDate])
@@map("sim_call_history_international")
}
// SMS history from SFTP monthly imports
model SimSmsHistory {
id String @id @default(uuid())
account String // Customer phone number
smsDate DateTime @db.Date @map("sms_date") // Date the SMS was sent
smsTime String @map("sms_time") // Time the SMS was sent
sentTo String @map("sent_to") // Phone number SMS was sent to
smsType SmsType @default(DOMESTIC) @map("sms_type") // SMS or 国際SMS
month String // YYYY-MM format for filtering
createdAt DateTime @default(now()) @map("created_at")
@@unique([account, smsDate, smsTime, sentTo])
@@index([account, month])
@@index([account, smsDate])
@@map("sim_sms_history")
}
enum SmsType {
DOMESTIC //
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")
}

View File

@ -24,6 +24,8 @@ import type {
FreebitVoiceOptionResponse,
FreebitCancelPlanRequest,
FreebitCancelPlanResponse,
FreebitCancelAccountRequest,
FreebitCancelAccountResponse,
FreebitEsimReissueRequest,
FreebitEsimReissueResponse,
FreebitEsimAddAccountRequest,
@ -738,15 +740,7 @@ export class FreebitOperationsService {
}
/**
* Cancel SIM service
* Uses PA02-04 cancellation endpoint
*
* IMPORTANT CONSTRAINTS:
* - Must be sent with runDate as 1st of client's cancellation month n+1
* (e.g., cancel end of Jan = runDate 20250201)
* - After PA02-04 is sent, any subsequent PA05-21 calls will cancel it
* - PA05-21 and PA02-04 cannot coexist
* - Must prevent clients from making further changes after cancellation is requested
* Cancel SIM plan (PA05-04 - plan cancellation only)
*/
async cancelSim(account: string, scheduledAt?: string): Promise<void> {
try {
@ -755,10 +749,9 @@ export class FreebitOperationsService {
runTime: scheduledAt,
};
this.logger.log(`Cancelling SIM service via PA02-04 for account ${account}`, {
this.logger.log(`Cancelling SIM plan via PA05-04 for account ${account}`, {
account,
runTime: scheduledAt,
note: "After this, PA05-21 plan changes will cancel the cancellation",
});
await this.client.makeAuthenticatedRequest<FreebitCancelPlanResponse, typeof request>(
@ -766,19 +759,64 @@ export class FreebitOperationsService {
request
);
this.logger.log(`Successfully cancelled SIM for account ${account}`, {
this.logger.log(`Successfully cancelled SIM plan for account ${account}`, {
account,
runTime: scheduledAt,
});
this.stampOperation(account, "cancellation");
} catch (error) {
const message = getErrorMessage(error);
this.logger.error(`Failed to cancel SIM for account ${account}`, {
this.logger.error(`Failed to cancel SIM plan for account ${account}`, {
account,
scheduledAt,
error: message,
});
throw new BadRequestException(`Failed to cancel SIM: ${message}`);
throw new BadRequestException(`Failed to cancel SIM plan: ${message}`);
}
}
/**
* Cancel SIM account (PA02-04 - full account cancellation)
*
* IMPORTANT CONSTRAINTS:
* - Must be sent with runDate as 1st of client's cancellation month n+1
* (e.g., cancel end of Jan = runDate 20250201)
* - After PA02-04 is sent, any subsequent PA05-21 calls will cancel it
* - PA05-21 and PA02-04 cannot coexist
* - Must prevent clients from making further changes after cancellation is requested
*/
async cancelAccount(account: string, runDate?: string): Promise<void> {
try {
const request: Omit<FreebitCancelAccountRequest, "authKey"> = {
kind: "MVNO",
account,
runDate,
};
this.logger.log(`Cancelling SIM account via PA02-04 for account ${account}`, {
account,
runDate,
note: "After this, PA05-21 plan changes will cancel the cancellation",
});
await this.client.makeAuthenticatedRequest<FreebitCancelAccountResponse, typeof request>(
"/master/cnclAcnt/",
request
);
this.logger.log(`Successfully cancelled SIM account for account ${account}`, {
account,
runDate,
});
this.stampOperation(account, "cancellation");
} catch (error) {
const message = getErrorMessage(error);
this.logger.error(`Failed to cancel SIM account for account ${account}`, {
account,
runDate,
error: message,
});
throw new BadRequestException(`Failed to cancel SIM account: ${message}`);
}
}

View File

@ -79,13 +79,21 @@ export class FreebitOrchestratorService {
}
/**
* Cancel SIM service
* Cancel SIM service (plan cancellation - PA05-04)
*/
async cancelSim(account: string, scheduledAt?: string): Promise<void> {
const normalizedAccount = this.mapper.normalizeAccount(account);
return this.operations.cancelSim(normalizedAccount, scheduledAt);
}
/**
* Cancel SIM account (full account cancellation - PA02-04)
*/
async cancelAccount(account: string, runDate?: string): Promise<void> {
const normalizedAccount = this.mapper.normalizeAccount(account);
return this.operations.cancelAccount(normalizedAccount, runDate);
}
/**
* Reissue eSIM profile (simple)
*/

View 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}`);
}
}

View File

@ -1,22 +1,39 @@
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { ConfigService } from "@nestjs/config";
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { SimValidationService } from "./sim-validation.service";
import { SimNotificationService } from "./sim-notification.service";
import { SimApiNotificationService } from "./sim-api-notification.service";
import { getErrorMessage } from "@bff/core/utils/error.util";
import type { SimReissueRequest } from "@customer-portal/domain/sim";
export interface ReissueSimRequest {
simType: "physical" | "esim";
newEid?: string;
}
@Injectable()
export class EsimManagementService {
constructor(
private readonly freebitService: FreebitOrchestratorService,
private readonly whmcsService: WhmcsService,
private readonly mappingsService: MappingsService,
private readonly simValidation: SimValidationService,
private readonly simNotification: SimNotificationService,
private readonly apiNotification: SimApiNotificationService,
private readonly configService: ConfigService,
@Inject(Logger) private readonly logger: Logger
) {}
private get freebitBaseUrl(): string {
return this.configService.get<string>("FREEBIT_BASE_URL") || "https://i1.mvno.net/emptool/api";
}
/**
* Reissue eSIM profile
* Reissue eSIM profile (legacy method)
*/
async reissueEsimProfile(
userId: string,
@ -75,4 +92,124 @@ export class EsimManagementService {
throw error;
}
}
/**
* Reissue SIM with full flow (eSIM via PA05-41, Physical SIM via email)
*/
async reissueSim(
userId: string,
subscriptionId: number,
request: ReissueSimRequest
): Promise<void> {
const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId);
const simDetails = await this.freebitService.getSimDetails(account);
// Get customer info from WHMCS
const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.whmcsClientId) {
throw new BadRequestException("WHMCS client mapping not found");
}
const clientDetails = await this.whmcsService.getClientDetails(mapping.whmcsClientId);
const customerName = `${clientDetails.firstname || ""} ${clientDetails.lastname || ""}`.trim() || "Customer";
const customerEmail = clientDetails.email || "";
if (request.simType === "esim") {
// eSIM reissue via PA05-41
if (!request.newEid) {
throw new BadRequestException("New EID is required for eSIM reissue");
}
const oldEid = simDetails.eid;
this.logger.log(`Reissuing eSIM via PA05-41`, {
userId,
subscriptionId,
account,
oldEid,
newEid: request.newEid,
});
// Call PA05-41 with addKind: "R" for reissue
await this.freebitService.activateEsimAccountNew({
account,
eid: request.newEid,
addKind: "R",
planCode: simDetails.planCode,
});
// Send API results email to admin
await this.apiNotification.sendApiResultsEmail(
"SIM Re-issue Request",
[
{
url: `${this.freebitBaseUrl}/mvno/esim/addAcnt/`,
json: {
reissue: { oldEid },
account,
addKind: "R",
eid: request.newEid,
authKey: "[REDACTED]",
},
result: {
resultCode: "100",
status: { message: "OK", statusCode: "200" },
},
},
]
);
// Send customer email
const customerEmailBody = this.apiNotification.buildEsimReissueEmail(
customerName,
account,
request.newEid
);
await this.apiNotification.sendCustomerEmail(
customerEmail,
"SIM Re-issue Request",
customerEmailBody
);
this.logger.log(`Successfully reissued eSIM for subscription ${subscriptionId}`, {
userId,
subscriptionId,
account,
oldEid,
newEid: request.newEid,
});
} else {
// Physical SIM reissue - email only, no API call
this.logger.log(`Processing physical SIM reissue request`, {
userId,
subscriptionId,
account,
});
// Send admin notification email
await this.apiNotification.sendApiResultsEmail(
"Physical SIM Re-issue Request",
[],
`Physical SIM reissue requested for:\nCustomer: ${customerName}\nSIM #: ${account}\nSerial #: ${simDetails.iccid || "N/A"}\nEmail: ${customerEmail}`
);
// Send customer email
const customerEmailBody = this.apiNotification.buildPhysicalSimReissueEmail(
customerName,
account
);
await this.apiNotification.sendCustomerEmail(
customerEmail,
"Physical SIM Re-issue Request",
customerEmailBody
);
this.logger.log(`Sent physical SIM reissue request emails`, {
userId,
subscriptionId,
account,
customerEmail,
});
}
}
}

View File

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

View File

@ -1,24 +1,154 @@
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { ConfigService } from "@nestjs/config";
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { SimValidationService } from "./sim-validation.service";
import { getErrorMessage } from "@bff/core/utils/error.util";
import type { SimCancelRequest } from "@customer-portal/domain/sim";
import type { SimCancelRequest, SimCancelFullRequest } from "@customer-portal/domain/sim";
import { SimScheduleService } from "./sim-schedule.service";
import { SimActionRunnerService } from "./sim-action-runner.service";
import { SimApiNotificationService } from "./sim-api-notification.service";
export interface CancellationMonth {
value: string; // YYYY-MM format
label: string; // Display label like "November 2025"
runDate: string; // YYYYMMDD format for API (1st of next month)
}
export interface CancellationPreview {
simNumber: string;
serialNumber?: string;
planCode: string;
startDate?: string;
minimumContractEndDate?: string;
isWithinMinimumTerm: boolean;
availableMonths: CancellationMonth[];
customerEmail: string;
customerName: string;
}
@Injectable()
export class SimCancellationService {
constructor(
private readonly freebitService: FreebitOrchestratorService,
private readonly whmcsService: WhmcsService,
private readonly mappingsService: MappingsService,
private readonly simValidation: SimValidationService,
private readonly simSchedule: SimScheduleService,
private readonly simActionRunner: SimActionRunnerService,
private readonly apiNotification: SimApiNotificationService,
private readonly configService: ConfigService,
@Inject(Logger) private readonly logger: Logger
) {}
private get freebitBaseUrl(): string {
return this.configService.get<string>("FREEBIT_BASE_URL") || "https://i1.mvno.net/emptool/api";
}
/**
* Cancel SIM service
* Generate available cancellation months (next 12 months)
*/
private generateCancellationMonths(): CancellationMonth[] {
const months: CancellationMonth[] = [];
const today = new Date();
const dayOfMonth = today.getDate();
// Start from current month if before 25th, otherwise next month
const startOffset = dayOfMonth <= 25 ? 0 : 1;
for (let i = startOffset; i < startOffset + 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const year = date.getFullYear();
const month = date.getMonth() + 1;
const monthStr = String(month).padStart(2, "0");
// runDate is the 1st of the NEXT month (cancellation takes effect at month end)
const nextMonth = new Date(year, month, 1);
const runYear = nextMonth.getFullYear();
const runMonth = String(nextMonth.getMonth() + 1).padStart(2, "0");
months.push({
value: `${year}-${monthStr}`,
label: date.toLocaleDateString("en-US", { month: "long", year: "numeric" }),
runDate: `${runYear}${runMonth}01`,
});
}
return months;
}
/**
* Calculate minimum contract end date (3 months after start, signup month not included)
*/
private calculateMinimumContractEndDate(startDateStr: string): Date | null {
if (!startDateStr || startDateStr.length < 8) return null;
// Parse YYYYMMDD format
const year = parseInt(startDateStr.substring(0, 4), 10);
const month = parseInt(startDateStr.substring(4, 6), 10) - 1;
const day = parseInt(startDateStr.substring(6, 8), 10);
if (isNaN(year) || isNaN(month) || isNaN(day)) return null;
const startDate = new Date(year, month, day);
// Minimum term is 3 months after signup month (signup month not included)
// e.g., signup in January = minimum term ends April 30
const endDate = new Date(startDate.getFullYear(), startDate.getMonth() + 4, 0);
return endDate;
}
/**
* Get cancellation preview with available months
*/
async getCancellationPreview(
userId: string,
subscriptionId: number
): Promise<CancellationPreview> {
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
const simDetails = await this.freebitService.getSimDetails(validation.account);
// Get customer info from WHMCS
const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.whmcsClientId) {
throw new BadRequestException("WHMCS client mapping not found");
}
const clientDetails = await this.whmcsService.getClientDetails(mapping.whmcsClientId);
const customerName = `${clientDetails.firstname || ""} ${clientDetails.lastname || ""}`.trim() || "Customer";
const customerEmail = clientDetails.email || "";
// Calculate minimum contract end date
const startDate = simDetails.startDate;
const minEndDate = startDate ? this.calculateMinimumContractEndDate(startDate) : null;
const today = new Date();
const isWithinMinimumTerm = minEndDate ? today < minEndDate : false;
// Format minimum contract end date for display
let minimumContractEndDate: string | undefined;
if (minEndDate) {
const year = minEndDate.getFullYear();
const month = String(minEndDate.getMonth() + 1).padStart(2, "0");
minimumContractEndDate = `${year}-${month}`;
}
return {
simNumber: validation.account,
serialNumber: simDetails.iccid,
planCode: simDetails.planCode,
startDate,
minimumContractEndDate,
isWithinMinimumTerm,
availableMonths: this.generateCancellationMonths(),
customerEmail,
customerName,
};
}
/**
* Cancel SIM service (legacy)
*/
async cancelSim(
userId: string,
@ -65,4 +195,123 @@ export class SimCancellationService {
}
);
}
/**
* Cancel SIM service with full flow (PA02-04 and email notifications)
*/
async cancelSimFull(
userId: string,
subscriptionId: number,
request: SimCancelFullRequest
): Promise<void> {
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
const account = validation.account;
const simDetails = await this.freebitService.getSimDetails(account);
// Get customer info from WHMCS
const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.whmcsClientId) {
throw new BadRequestException("WHMCS client mapping not found");
}
const clientDetails = await this.whmcsService.getClientDetails(mapping.whmcsClientId);
const customerName = `${clientDetails.firstname || ""} ${clientDetails.lastname || ""}`.trim() || "Customer";
const customerEmail = clientDetails.email || "";
// Validate confirmations
if (!request.confirmRead || !request.confirmCancel) {
throw new BadRequestException("You must confirm both checkboxes to proceed");
}
// Parse cancellation month and calculate runDate
const [year, month] = request.cancellationMonth.split("-").map(Number);
if (!year || !month) {
throw new BadRequestException("Invalid cancellation month format");
}
// runDate is 1st of the NEXT month (cancellation at end of selected month)
const nextMonth = new Date(year, month, 1);
const runYear = nextMonth.getFullYear();
const runMonth = String(nextMonth.getMonth() + 1).padStart(2, "0");
const runDate = `${runYear}${runMonth}01`;
this.logger.log(`Processing SIM cancellation via PA02-04`, {
userId,
subscriptionId,
account,
cancellationMonth: request.cancellationMonth,
runDate,
});
// Call PA02-04 cancellation API
await this.freebitService.cancelAccount(account, runDate);
this.logger.log(`Successfully cancelled SIM for subscription ${subscriptionId}`, {
userId,
subscriptionId,
account,
runDate,
});
// Send admin notification email
const adminEmailBody = this.apiNotification.buildCancellationAdminEmail({
customerName,
simNumber: account,
serialNumber: simDetails.iccid,
cancellationMonth: request.cancellationMonth,
registeredEmail: customerEmail,
otherEmail: request.alternativeEmail || undefined,
comments: request.comments,
});
await this.apiNotification.sendApiResultsEmail(
"SonixNet SIM Online Cancellation",
[
{
url: `${this.freebitBaseUrl}/master/cnclAcnt/`,
json: {
kind: "MVNO",
account,
runDate,
authKey: "[REDACTED]",
},
result: {
resultCode: "100",
status: { message: "OK", statusCode: "200" },
},
},
],
adminEmailBody
);
// Send confirmation email to customer (and alternative if provided)
const confirmationSubject = "SonixNet SIM Cancellation Confirmation";
const confirmationBody = `Dear ${customerName},
Your cancellation request for SIM #${account} has been confirmed.
The cancellation will take effect at the end of ${request.cancellationMonth}.
If you have any questions, please contact us at info@asolutions.co.jp
With best regards,
Assist Solutions Customer Support
TEL: 0120-660-470 (Mon-Fri / 10AM-6PM)
Email: info@asolutions.co.jp`;
await this.apiNotification.sendCustomerEmail(
customerEmail,
confirmationSubject,
confirmationBody
);
// Send to alternative email if provided
if (request.alternativeEmail && request.alternativeEmail !== customerEmail) {
await this.apiNotification.sendCustomerEmail(
request.alternativeEmail,
confirmationSubject,
confirmationBody
);
}
}
}

View File

@ -1,12 +1,39 @@
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { ConfigService } from "@nestjs/config";
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service";
import { SimValidationService } from "./sim-validation.service";
import { getErrorMessage } from "@bff/core/utils/error.util";
import type { SimPlanChangeRequest, SimFeaturesUpdateRequest } from "@customer-portal/domain/sim";
import type { SimPlanChangeRequest, SimFeaturesUpdateRequest, SimChangePlanFullRequest } from "@customer-portal/domain/sim";
import { SimScheduleService } from "./sim-schedule.service";
import { SimActionRunnerService } from "./sim-action-runner.service";
import { SimManagementQueueService } from "../queue/sim-management.queue";
import { SimApiNotificationService } from "./sim-api-notification.service";
import { SimCatalogService } from "@bff/modules/catalog/services/sim-catalog.service";
import type { SimCatalogProduct } from "@customer-portal/domain/catalog";
// Mapping from Salesforce SKU to Freebit plan code
const SKU_TO_FREEBIT_PLAN_CODE: Record<string, string> = {
"SIM-DATA-VOICE-5GB": "PASI_5G",
"SIM-DATA-VOICE-10GB": "PASI_10G",
"SIM-DATA-VOICE-25GB": "PASI_25G",
"SIM-DATA-VOICE-50GB": "PASI_50G",
"SIM-DATA-ONLY-5GB": "PASI_5G_DATA",
"SIM-DATA-ONLY-10GB": "PASI_10G_DATA",
"SIM-DATA-ONLY-25GB": "PASI_25G_DATA",
"SIM-DATA-ONLY-50GB": "PASI_50G_DATA",
"SIM-VOICE-ONLY": "PASI_VOICE",
};
// Reverse mapping: Freebit plan code to Salesforce SKU
const FREEBIT_PLAN_CODE_TO_SKU: Record<string, string> = Object.fromEntries(
Object.entries(SKU_TO_FREEBIT_PLAN_CODE).map(([sku, code]) => [code, sku])
);
export interface AvailablePlan extends SimCatalogProduct {
freebitPlanCode: string;
isCurrentPlan: boolean;
}
@Injectable()
export class SimPlanService {
@ -16,11 +43,71 @@ export class SimPlanService {
private readonly simSchedule: SimScheduleService,
private readonly simActionRunner: SimActionRunnerService,
private readonly simQueue: SimManagementQueueService,
private readonly apiNotification: SimApiNotificationService,
private readonly simCatalog: SimCatalogService,
private readonly configService: ConfigService,
@Inject(Logger) private readonly logger: Logger
) {}
private get freebitBaseUrl(): string {
return this.configService.get<string>("FREEBIT_BASE_URL") || "https://i1.mvno.net/emptool/api";
}
/**
* Change SIM plan
* Get available plans for plan change
* Filters by current plan type (e.g., only show DataSmsVoice plans if current is DataSmsVoice)
*/
async getAvailablePlans(
userId: string,
subscriptionId: number
): Promise<AvailablePlan[]> {
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
const simDetails = await this.freebitService.getSimDetails(validation.account);
const currentPlanCode = simDetails.planCode;
const currentSku = FREEBIT_PLAN_CODE_TO_SKU[currentPlanCode];
// Get all plans from Salesforce
const allPlans = await this.simCatalog.getPlans();
// Determine current plan type
let currentPlanType: string | undefined;
if (currentSku) {
const currentPlan = allPlans.find(p => p.sku === currentSku);
currentPlanType = currentPlan?.simPlanType;
}
// Filter plans by type (e.g., only show DataSmsVoice if current is DataSmsVoice)
const filteredPlans = currentPlanType
? allPlans.filter(p => p.simPlanType === currentPlanType)
: allPlans.filter(p => !p.simHasFamilyDiscount); // Default: non-family plans
// Map to AvailablePlan with Freebit codes
return filteredPlans.map(plan => {
const freebitPlanCode = SKU_TO_FREEBIT_PLAN_CODE[plan.sku] || plan.sku;
return {
...plan,
freebitPlanCode,
isCurrentPlan: freebitPlanCode === currentPlanCode,
};
});
}
/**
* Get Freebit plan code from Salesforce SKU
*/
getFreebitPlanCode(sku: string): string | undefined {
return SKU_TO_FREEBIT_PLAN_CODE[sku];
}
/**
* Get Salesforce SKU from Freebit plan code
*/
getSalesforceSku(planCode: string): string | undefined {
return FREEBIT_PLAN_CODE_TO_SKU[planCode];
}
/**
* Change SIM plan (basic)
*/
async changeSimPlan(
userId: string,
@ -95,6 +182,84 @@ export class SimPlanService {
};
}
/**
* Change SIM plan with enhanced notifications and Salesforce SKU mapping
*/
async changeSimPlanFull(
userId: string,
subscriptionId: number,
request: SimChangePlanFullRequest
): Promise<{ ipv4?: string; ipv6?: string; scheduledAt?: string }> {
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
const account = validation.account;
// Get or derive Freebit plan code from SKU
const freebitPlanCode = SKU_TO_FREEBIT_PLAN_CODE[request.newPlanSku] || request.newPlanCode;
if (!freebitPlanCode || freebitPlanCode.length < 3) {
throw new BadRequestException("Invalid plan code");
}
// Always schedule for 1st of following month
const nextMonth = new Date();
nextMonth.setMonth(nextMonth.getMonth() + 1);
nextMonth.setDate(1);
const year = nextMonth.getFullYear();
const month = String(nextMonth.getMonth() + 1).padStart(2, "0");
const scheduledAt = `${year}${month}01`;
this.logger.log("Submitting SIM plan change request (full)", {
userId,
subscriptionId,
account,
newPlanSku: request.newPlanSku,
freebitPlanCode,
scheduledAt,
});
const result = await this.freebitService.changeSimPlan(account, freebitPlanCode, {
assignGlobalIp: request.assignGlobalIp ?? false,
scheduledAt,
});
this.logger.log(`Successfully changed SIM plan for subscription ${subscriptionId}`, {
userId,
subscriptionId,
account,
newPlanCode: freebitPlanCode,
scheduledAt,
});
// Send API results email
await this.apiNotification.sendApiResultsEmail(
"API results - Plan Change",
[
{
url: `${this.freebitBaseUrl}/mvno/changePlan/`,
json: {
account,
planCode: freebitPlanCode,
runTime: scheduledAt,
authKey: "[REDACTED]",
},
result: {
resultCode: "100",
status: { message: "OK", statusCode: "200" },
ipv4: result.ipv4 || "",
ipv6: result.ipv6 || "",
},
},
],
`Plan changed to: ${request.newPlanName || freebitPlanCode}\nScheduled for: ${scheduledAt}`
);
return {
ipv4: result.ipv4,
ipv6: result.ipv6,
scheduledAt,
};
}
/**
* Update SIM features (voicemail, call waiting, roaming, network type)
*/

View File

@ -1,5 +1,6 @@
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { ConfigService } from "@nestjs/config";
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { SimValidationService } from "./sim-validation.service";
@ -7,6 +8,7 @@ import { getErrorMessage } from "@bff/core/utils/error.util";
import type { SimTopUpRequest } from "@customer-portal/domain/sim";
import { SimBillingService } from "./sim-billing.service";
import { SimActionRunnerService } from "./sim-action-runner.service";
import { SimApiNotificationService } from "./sim-api-notification.service";
@Injectable()
export class SimTopUpService {
@ -16,9 +18,19 @@ export class SimTopUpService {
private readonly simValidation: SimValidationService,
private readonly simBilling: SimBillingService,
private readonly simActionRunner: SimActionRunnerService,
private readonly apiNotification: SimApiNotificationService,
private readonly configService: ConfigService,
@Inject(Logger) private readonly logger: Logger
) {}
private get whmcsBaseUrl(): string {
return this.configService.get<string>("WHMCS_BASE_URL") || "https://accounts.asolutions.co.jp/includes/api.php";
}
private get freebitBaseUrl(): string {
return this.configService.get<string>("FREEBIT_BASE_URL") || "https://i1.mvno.net/emptool/api";
}
/**
* Top up SIM data quota with payment processing
* Pricing: 1GB = 500 JPY
@ -88,8 +100,11 @@ export class SimTopUpService {
metadata: { subscriptionId },
});
// Call Freebit API to add quota
let freebitResult: { resultCode: string; status: { message: string; statusCode: string } } | null = null;
try {
await this.freebitService.topUpSim(latestAccount, request.quotaMb, {});
freebitResult = { resultCode: "100", status: { message: "OK", statusCode: "200" } };
} catch (freebitError) {
await this.handleFreebitFailureAfterPayment(
freebitError,
@ -112,6 +127,52 @@ export class SimTopUpService {
transactionId: billing.transactionId,
});
// Send API results email notification
const today = new Date();
const dateStr = today.toISOString().split("T")[0];
const dueDate = new Date(today.getTime() + 2 * 24 * 60 * 60 * 1000).toISOString().split("T")[0];
await this.apiNotification.sendApiResultsEmail(
"API results",
[
{
url: this.whmcsBaseUrl,
senddata: {
itemdescription1: `Top-up data (${units}GB)\nSIM Number: ${latestAccount}`,
itemamount1: String(costJpy),
userid: String(mapping.whmcsClientId),
date: dateStr,
responsetype: "json",
itemtaxed1: "1",
action: "CreateInvoice",
duedate: dueDate,
paymentmethod: "stripe",
sendinvoice: "1",
},
result: { result: "success", invoiceid: billing.invoice.id, status: billing.invoice.status },
},
{
url: this.whmcsBaseUrl,
senddata: {
responsetype: "json",
action: "CapturePayment",
invoiceid: billing.invoice.id,
},
result: { result: "success" },
},
{
url: `${this.freebitBaseUrl}/master/addSpec/`,
json: {
quota: request.quotaMb,
kind: "MVNO",
account: latestAccount,
authKey: "[REDACTED]",
},
result: freebitResult || { resultCode: "100", status: { message: "OK", statusCode: "200" } },
},
]
);
return {
account: latestAccount,
costJpy,

View File

@ -18,12 +18,14 @@ import { SimCancellationService } from "./services/sim-cancellation.service";
import { EsimManagementService } from "./services/esim-management.service";
import { SimValidationService } from "./services/sim-validation.service";
import { SimNotificationService } from "./services/sim-notification.service";
import { SimApiNotificationService } from "./services/sim-api-notification.service";
import { SimBillingService } from "./services/sim-billing.service";
import { SimScheduleService } from "./services/sim-schedule.service";
import { SimActionRunnerService } from "./services/sim-action-runner.service";
import { SimManagementQueueService } from "./queue/sim-management.queue";
import { SimManagementProcessor } from "./queue/sim-management.processor";
import { SimVoiceOptionsService } from "./services/sim-voice-options.service";
import { CatalogModule } from "@bff/modules/catalog/catalog.module";
@Module({
imports: [
@ -32,6 +34,7 @@ import { SimVoiceOptionsService } from "./services/sim-voice-options.service";
SalesforceModule,
MappingsModule,
EmailModule,
CatalogModule,
],
providers: [
// Core services that the SIM services depend on
@ -41,6 +44,7 @@ import { SimVoiceOptionsService } from "./services/sim-voice-options.service";
// SIM management services
SimValidationService,
SimNotificationService,
SimApiNotificationService,
SimVoiceOptionsService,
SimDetailsService,
SimUsageService,
@ -73,6 +77,7 @@ import { SimVoiceOptionsService } from "./services/sim-voice-options.service";
EsimManagementService,
SimValidationService,
SimNotificationService,
SimApiNotificationService,
SimBillingService,
SimScheduleService,
SimActionRunnerService,

View File

@ -32,13 +32,20 @@ import {
simChangePlanRequestSchema,
simCancelRequestSchema,
simFeaturesRequestSchema,
simCancelFullRequestSchema,
simChangePlanFullRequestSchema,
type SimTopupRequest,
type SimChangePlanRequest,
type SimCancelRequest,
type SimFeaturesRequest,
type SimCancelFullRequest,
type SimChangePlanFullRequest,
} from "@customer-portal/domain/sim";
import { ZodValidationPipe } from "@customer-portal/validation/nestjs";
import type { RequestWithUser } from "@bff/modules/auth/auth.types";
import { SimPlanService } from "./sim-management/services/sim-plan.service";
import { SimCancellationService } from "./sim-management/services/sim-cancellation.service";
import { EsimManagementService, type ReissueSimRequest } from "./sim-management/services/esim-management.service";
const subscriptionInvoiceQuerySchema = createPaginationSchema({
defaultLimit: 10,
@ -52,7 +59,10 @@ export class SubscriptionsController {
constructor(
private readonly subscriptionsService: SubscriptionsService,
private readonly simManagementService: SimManagementService,
private readonly simTopUpPricingService: SimTopUpPricingService
private readonly simTopUpPricingService: SimTopUpPricingService,
private readonly simPlanService: SimPlanService,
private readonly simCancellationService: SimCancellationService,
private readonly esimManagementService: EsimManagementService
) {}
@Get()
@ -228,4 +238,88 @@ export class SubscriptionsController {
await this.simManagementService.updateSimFeatures(req.user.id, subscriptionId, body);
return { success: true, message: "SIM features updated successfully" };
}
// ==================== Enhanced SIM Management Endpoints ====================
/**
* Get available plans for plan change (filtered by current plan type)
*/
@Get(":id/sim/available-plans")
@Header("Cache-Control", "private, max-age=300")
async getAvailablePlans(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number
) {
const plans = await this.simPlanService.getAvailablePlans(req.user.id, subscriptionId);
return { success: true, data: plans };
}
/**
* Change SIM plan with enhanced flow (Salesforce SKU mapping + email notifications)
*/
@Post(":id/sim/change-plan-full")
@UsePipes(new ZodValidationPipe(simChangePlanFullRequestSchema))
async changeSimPlanFull(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number,
@Body() body: SimChangePlanFullRequest
): Promise<SimPlanChangeResult> {
const result = await this.simPlanService.changeSimPlanFull(req.user.id, subscriptionId, body);
return {
success: true,
message: `SIM plan change scheduled for ${result.scheduledAt}`,
...result,
};
}
/**
* Get cancellation preview (available months, customer info, minimum contract term)
*/
@Get(":id/sim/cancellation-preview")
@Header("Cache-Control", "private, max-age=60")
async getCancellationPreview(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number
) {
const preview = await this.simCancellationService.getCancellationPreview(
req.user.id,
subscriptionId
);
return { success: true, data: preview };
}
/**
* Cancel SIM with full flow (PA02-04 + email notifications)
*/
@Post(":id/sim/cancel-full")
@UsePipes(new ZodValidationPipe(simCancelFullRequestSchema))
async cancelSimFull(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number,
@Body() body: SimCancelFullRequest
): Promise<SimActionResponse> {
await this.simCancellationService.cancelSimFull(req.user.id, subscriptionId, body);
return {
success: true,
message: `SIM cancellation scheduled for end of ${body.cancellationMonth}`,
};
}
/**
* Reissue SIM (both eSIM and physical SIM)
*/
@Post(":id/sim/reissue")
async reissueSim(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number,
@Body() body: ReissueSimRequest
): Promise<SimActionResponse> {
await this.esimManagementService.reissueSim(req.user.id, subscriptionId, body);
if (body.simType === "esim") {
return { success: true, message: "eSIM profile reissue request submitted" };
} else {
return { success: true, message: "Physical SIM reissue request submitted. You will be contacted shortly." };
}
}
}

View File

@ -1,3 +1,5 @@
export default function Page() {
return null;
import SimReissueContainer from "@/features/subscriptions/views/SimReissue";
export default function SimReissuePage() {
return <SimReissueContainer />;
}

View File

@ -1,6 +1,7 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import {
DevicePhoneMobileIcon,
ExclamationTriangleIcon,
@ -49,10 +50,17 @@ interface SimInfo {
}
export function SimManagementSection({ subscriptionId }: SimManagementSectionProps) {
const router = useRouter();
const [simInfo, setSimInfo] = useState<SimInfo | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Navigation handlers
const navigateToTopUp = () => router.push(`/subscriptions/${subscriptionId}/sim/top-up`);
const navigateToChangePlan = () => router.push(`/subscriptions/${subscriptionId}/sim/change-plan`);
const navigateToReissue = () => router.push(`/subscriptions/${subscriptionId}/sim/reissue`);
const navigateToCancel = () => router.push(`/subscriptions/${subscriptionId}/sim/cancel`);
// Fetch subscription data
const { data: subscription } = useSubscription(subscriptionId);
@ -234,7 +242,7 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
{/* Top Up Data Button */}
<div className="pt-4">
<button
onClick={() => {/* TODO: Open top-up modal */}}
onClick={navigateToTopUp}
className="w-full px-6 py-3 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 transition-colors"
>
Top Up Data
@ -281,7 +289,7 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
<h3 className="text-lg font-semibold text-gray-900 mb-4">SIM Management Actions</h3>
<div className="grid grid-cols-2 gap-4">
<button
onClick={() => {/* TODO: Open top-up modal */}}
onClick={navigateToTopUp}
className="flex flex-col items-center justify-center p-6 bg-white border-2 border-gray-200 rounded-lg hover:border-blue-500 hover:shadow-md transition-all duration-200"
>
<SignalIcon className="h-8 w-8 text-gray-700 mb-2" />
@ -289,7 +297,7 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
</button>
<button
onClick={() => {/* TODO: Open change plan modal */}}
onClick={navigateToChangePlan}
className="flex flex-col items-center justify-center p-6 bg-white border-2 border-gray-200 rounded-lg hover:border-blue-500 hover:shadow-md transition-all duration-200"
>
<ArrowsRightLeftIcon className="h-8 w-8 text-gray-700 mb-2" />
@ -297,16 +305,15 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
</button>
<button
onClick={() => {/* TODO: Open reissue modal */}}
disabled={simInfo.details.simType !== 'esim'}
className="flex flex-col items-center justify-center p-6 bg-white border-2 border-gray-200 rounded-lg hover:border-blue-500 hover:shadow-md transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={navigateToReissue}
className="flex flex-col items-center justify-center p-6 bg-white border-2 border-gray-200 rounded-lg hover:border-blue-500 hover:shadow-md transition-all duration-200"
>
<ArrowPathRoundedSquareIcon className="h-8 w-8 text-gray-700 mb-2" />
<span className="text-sm font-medium text-gray-900">Reissue SIM</span>
</button>
<button
onClick={() => {/* TODO: Open cancel modal */}}
onClick={navigateToCancel}
className="flex flex-col items-center justify-center p-6 bg-white border-2 border-gray-200 rounded-lg hover:border-red-500 hover:shadow-md transition-all duration-200"
>
<XCircleIcon className="h-8 w-8 text-gray-700 mb-2" />

View File

@ -1,5 +1,5 @@
import { apiClient } from "@/lib/api";
import { simInfoSchema, type SimInfo } from "@customer-portal/domain/sim";
import { simInfoSchema, type SimInfo, type SimCancelFullRequest, type SimChangePlanFullRequest } from "@customer-portal/domain/sim";
import type {
SimTopUpRequest,
SimPlanChangeRequest,
@ -12,6 +12,41 @@ import type {
// - SimPlanChangeRequest: newPlanCode, assignGlobalIp, scheduledAt (YYYYMMDD format)
// - SimCancelRequest: scheduledAt (YYYYMMDD format)
export interface AvailablePlan {
id: string;
name: string;
sku: string;
description?: string;
monthlyPrice?: number;
simDataSize?: string;
simPlanType?: string;
freebitPlanCode: string;
isCurrentPlan: boolean;
}
export interface CancellationMonth {
value: string;
label: string;
runDate: string;
}
export interface CancellationPreview {
simNumber: string;
serialNumber?: string;
planCode: string;
startDate?: string;
minimumContractEndDate?: string;
isWithinMinimumTerm: boolean;
availableMonths: CancellationMonth[];
customerEmail: string;
customerName: string;
}
export interface ReissueSimRequest {
simType: "physical" | "esim";
newEid?: string;
}
export const simActionsService = {
async topUp(subscriptionId: string, request: SimTopUpRequest): Promise<void> {
await apiClient.POST("/api/subscriptions/{subscriptionId}/sim/top-up", {
@ -27,6 +62,17 @@ export const simActionsService = {
});
},
async changePlanFull(subscriptionId: string, request: SimChangePlanFullRequest): Promise<{ scheduledAt?: string }> {
const response = await apiClient.POST<{ success: boolean; message: string; scheduledAt?: string }>(
"/api/subscriptions/{subscriptionId}/sim/change-plan-full",
{
params: { path: { subscriptionId } },
body: request,
}
);
return { scheduledAt: response.data?.scheduledAt };
},
async cancel(subscriptionId: string, request: SimCancelRequest): Promise<void> {
await apiClient.POST("/api/subscriptions/{subscriptionId}/sim/cancel", {
params: { path: { subscriptionId } },
@ -34,6 +80,13 @@ export const simActionsService = {
});
},
async cancelFull(subscriptionId: string, request: SimCancelFullRequest): Promise<void> {
await apiClient.POST("/api/subscriptions/{subscriptionId}/sim/cancel-full", {
params: { path: { subscriptionId } },
body: request,
});
},
async getSimInfo(subscriptionId: string): Promise<SimInfo | null> {
const response = await apiClient.GET<SimInfo>("/api/subscriptions/{subscriptionId}/sim", {
params: { path: { subscriptionId } },
@ -45,4 +98,31 @@ export const simActionsService = {
return simInfoSchema.parse(response.data);
},
async getAvailablePlans(subscriptionId: string): Promise<AvailablePlan[]> {
const response = await apiClient.GET<{ success: boolean; data: AvailablePlan[] }>(
"/api/subscriptions/{subscriptionId}/sim/available-plans",
{
params: { path: { subscriptionId } },
}
);
return response.data?.data || [];
},
async getCancellationPreview(subscriptionId: string): Promise<CancellationPreview | null> {
const response = await apiClient.GET<{ success: boolean; data: CancellationPreview }>(
"/api/subscriptions/{subscriptionId}/sim/cancellation-preview",
{
params: { path: { subscriptionId } },
}
);
return response.data?.data || null;
},
async reissueSim(subscriptionId: string, request: ReissueSimRequest): Promise<void> {
await apiClient.POST("/api/subscriptions/{subscriptionId}/sim/reissue", {
params: { path: { subscriptionId } },
body: request,
});
},
};

View File

@ -2,18 +2,16 @@
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { simActionsService } from "@/features/subscriptions/services/sim-actions.service";
import { useAuthStore } from "@/features/auth/services/auth.store";
import type { SimDetails } from "@/features/sim-management/components/SimDetailsCard";
import { useEffect, useState, type ReactNode } from "react";
import { simActionsService, type CancellationPreview } from "@/features/subscriptions/services/sim-actions.service";
type Step = 1 | 2 | 3;
function Notice({ title, children }: { title: string; children: ReactNode }) {
return (
<div className="bg-yellow-50 border border-yellow-200 rounded p-3">
<div className="text-sm font-medium text-yellow-900 mb-1">{title}</div>
<div className="text-sm text-yellow-800">{children}</div>
<div className="bg-yellow-50 border border-yellow-200 rounded p-4">
<div className="text-sm font-semibold text-yellow-900 mb-2">{title}</div>
<div className="text-sm text-yellow-800 leading-relaxed">{children}</div>
</div>
);
}
@ -34,79 +32,63 @@ export function SimCancelContainer() {
const [step, setStep] = useState<Step>(1);
const [loading, setLoading] = useState(false);
const [details, setDetails] = useState<SimDetails | null>(null);
const [preview, setPreview] = useState<CancellationPreview | null>(null);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [acceptTerms, setAcceptTerms] = useState(false);
const [confirmMonthEnd, setConfirmMonthEnd] = useState(false);
const [cancelMonth, setCancelMonth] = useState<string>("");
const [email, setEmail] = useState<string>("");
const [email2, setEmail2] = useState<string>("");
const [notes, setNotes] = useState<string>("");
const [registeredEmail, setRegisteredEmail] = useState<string | null>(null);
const [selectedMonth, setSelectedMonth] = useState<string>("");
const [alternativeEmail, setAlternativeEmail] = useState<string>("");
const [alternativeEmail2, setAlternativeEmail2] = useState<string>("");
const [comments, setComments] = useState<string>("");
const [loadingPreview, setLoadingPreview] = useState(true);
useEffect(() => {
const fetchDetails = async () => {
const fetchPreview = async () => {
try {
const info = await simActionsService.getSimInfo(subscriptionId);
setDetails(info?.details || null);
const data = await simActionsService.getCancellationPreview(subscriptionId);
setPreview(data);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to load SIM details");
setError(e instanceof Error ? e.message : "Failed to load cancellation information");
} finally {
setLoadingPreview(false);
}
};
void fetchDetails();
void fetchPreview();
}, [subscriptionId]);
useEffect(() => {
const fetchEmail = () => {
try {
const emailFromStore = useAuthStore.getState().user?.email;
if (emailFromStore) {
setRegisteredEmail(emailFromStore);
return;
}
} catch {
// ignore
}
};
fetchEmail();
}, []);
const monthOptions = useMemo(() => {
const opts: { value: string; label: string }[] = [];
const now = new Date();
for (let i = 1; i <= 12; i++) {
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + i, 1));
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
opts.push({ value: `${y}${m}`, label: `${y} / ${m}` });
}
return opts;
}, []);
const canProceedStep2 = !!details;
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const emailProvided = email.trim().length > 0 || email2.trim().length > 0;
const emailProvided = alternativeEmail.trim().length > 0 || alternativeEmail2.trim().length > 0;
const emailValid =
!emailProvided || (emailPattern.test(email.trim()) && emailPattern.test(email2.trim()));
const emailsMatch = !emailProvided || email.trim() === email2.trim();
const canProceedStep3 =
acceptTerms && !!cancelMonth && confirmMonthEnd && emailValid && emailsMatch;
const runDate = cancelMonth ? `${cancelMonth}01` : null;
!emailProvided ||
(emailPattern.test(alternativeEmail.trim()) && emailPattern.test(alternativeEmail2.trim()));
const emailsMatch = !emailProvided || alternativeEmail.trim() === alternativeEmail2.trim();
const canProceedStep2 = !!preview && !!selectedMonth;
const canProceedStep3 = acceptTerms && confirmMonthEnd && emailValid && emailsMatch;
const selectedMonthInfo = preview?.availableMonths.find(m => m.value === selectedMonth);
const submit = async () => {
setLoading(true);
setError(null);
setMessage(null);
if (!runDate) {
if (!selectedMonth) {
setError("Please select a cancellation month before submitting.");
setLoading(false);
return;
}
try {
await simActionsService.cancel(subscriptionId, { scheduledAt: runDate });
await simActionsService.cancelFull(subscriptionId, {
cancellationMonth: selectedMonth,
confirmRead: acceptTerms,
confirmCancel: confirmMonthEnd,
alternativeEmail: alternativeEmail.trim() || undefined,
comments: comments.trim() || undefined,
});
setMessage("Cancellation request submitted. You will receive a confirmation email.");
setTimeout(() => router.push(`/subscriptions/${subscriptionId}#sim-management`), 1500);
setTimeout(() => router.push(`/subscriptions/${subscriptionId}#sim-management`), 2000);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to submit cancellation");
} finally {
@ -114,6 +96,17 @@ export function SimCancelContainer() {
}
};
if (loadingPreview) {
return (
<div className="max-w-3xl mx-auto p-6">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-200 rounded w-1/3"></div>
<div className="h-64 bg-gray-200 rounded"></div>
</div>
</div>
);
}
return (
<div className="max-w-3xl mx-auto p-6">
<div className="mb-4">
@ -123,14 +116,24 @@ export function SimCancelContainer() {
>
Back to SIM Management
</Link>
<div className="text-sm text-gray-500">Step {step} of 3</div>
<div className="flex items-center gap-2 mt-2">
{[1, 2, 3].map(s => (
<div
key={s}
className={`h-2 flex-1 rounded-full ${
s <= step ? "bg-blue-600" : "bg-gray-200"
}`}
></div>
))}
</div>
<div className="text-sm text-gray-500 mt-1">Step {step} of 3</div>
</div>
{error && (
<div className="text-red-700 bg-red-50 border border-red-200 rounded p-3">{error}</div>
<div className="text-red-700 bg-red-50 border border-red-200 rounded p-4 mb-4">{error}</div>
)}
{message && (
<div className="text-green-700 bg-green-50 border border-green-200 rounded p-3">
<div className="text-green-700 bg-green-50 border border-green-200 rounded p-4 mb-4">
{message}
</div>
)}
@ -138,44 +141,59 @@ export function SimCancelContainer() {
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h1 className="text-xl font-semibold text-gray-900 mb-2">Cancel SIM</h1>
<p className="text-sm text-gray-600 mb-6">
Cancel SIM: Permanently cancel your SIM service. This action cannot be undone and will
terminate your service immediately.
Cancel your SIM subscription. Please read all the information carefully before proceeding.
</p>
{/* Minimum Contract Warning */}
{preview?.isWithinMinimumTerm && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<div className="text-sm font-semibold text-red-900 mb-1">Minimum Contract Term Warning</div>
<div className="text-sm text-red-800">
Your subscription is still within the minimum contract period (ends {preview.minimumContractEndDate}).
Early cancellation may result in additional charges for the remaining months.
</div>
</div>
)}
{step === 1 && (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
<InfoRow label="SIM" value={details?.msisdn || "—"} />
<InfoRow label="Activated" value={details?.activatedAt || "—"} />
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Cancellation Month
</label>
<select
value={cancelMonth}
onChange={e => {
setCancelMonth(e.target.value);
setConfirmMonthEnd(false);
}}
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
>
<option value="">Select month</option>
{monthOptions.map(opt => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<p className="text-xs text-gray-500 mt-1">
Cancellation takes effect at the start of the selected month.
</p>
</div>
{/* SIM Info */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 p-4 bg-gray-50 rounded-lg">
<InfoRow label="SIM Number" value={preview?.simNumber || "—"} />
<InfoRow label="Serial #" value={preview?.serialNumber || "—"} />
<InfoRow label="Start Date" value={preview?.startDate || "—"} />
</div>
{/* Month Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Select Cancellation Month
</label>
<select
value={selectedMonth}
onChange={e => {
setSelectedMonth(e.target.value);
setConfirmMonthEnd(false);
}}
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500"
>
<option value="">Select month</option>
{preview?.availableMonths.map(month => (
<option key={month.value} value={month.value}>
{month.label}
</option>
))}
</select>
<p className="text-xs text-gray-500 mt-1">
Your subscription will be cancelled at the end of the selected month.
</p>
</div>
<div className="flex justify-end">
<button
disabled={!canProceedStep2}
onClick={() => setStep(2)}
className="px-4 py-2 rounded-md bg-blue-600 text-white text-sm disabled:opacity-50"
className="px-6 py-2 rounded-md bg-blue-600 text-white text-sm font-medium disabled:opacity-50 hover:bg-blue-700 transition-colors"
>
Next
</button>
@ -185,8 +203,8 @@ export function SimCancelContainer() {
{step === 2 && (
<div className="space-y-6">
<div className="space-y-3">
<Notice title="Cancellation Procedure">
<div className="space-y-4">
<Notice title="[Cancellation Procedure]">
Online cancellations must be made from this website by the 25th of the desired
cancellation month. Once a request of a cancellation of the SONIXNET SIM is accepted
from this online form, a confirmation email containing details of the SIM plan will
@ -196,63 +214,73 @@ export function SimCancelContainer() {
services with Assist Solutions (home internet etc.) please contact Assist Solutions
at info@asolutions.co.jp
</Notice>
<Notice title="Minimum Contract Term">
<Notice title="[Minimum Contract Term]">
The SONIXNET SIM has a minimum contract term agreement of three months (sign-up
month is not included in the minimum term of three months; ie. sign-up in January =
minimum term is February, March, April). If the minimum contract term is not
fulfilled, the monthly fees of the remaining months will be charged upon
cancellation.
</Notice>
<Notice title="Option Services">
<Notice title="[Cancellation of Option Services (for Data+SMS/Voice Plan)]">
Cancellation of option services only (Voice Mail, Call Waiting) while keeping the
base plan active is not possible from this online form. Please contact Assist
Solutions Customer Support (info@asolutions.co.jp) for more information. Upon
cancelling the base plan, all additional options associated with the requested SIM
plan will be cancelled.
</Notice>
<Notice title="MNP Transfer (Voice Plans)">
<Notice title="[MNP Transfer (for Data+SMS/Voice Plan)]">
Upon cancellation the SIM phone number will be lost. In order to keep the phone
number active to be used with a different cellular provider, a request for an MNP
transfer (administrative fee \1,000yen+tax) is necessary. The MNP cannot be
requested from this online form. Please contact Assist Solutions Customer Support
transfer (administrative fee ¥1,000+tax) is necessary. The MNP cannot be requested
from this online form. Please contact Assist Solutions Customer Support
(info@asolutions.co.jp) for more information.
</Notice>
</div>
<div className="flex items-center gap-2">
<input
id="acceptTerms"
type="checkbox"
checked={acceptTerms}
onChange={e => setAcceptTerms(e.target.checked)}
/>
<label htmlFor="acceptTerms" className="text-sm text-gray-700">
I have read and accepted the conditions above.
</label>
</div>
<div className="flex items-start gap-2">
<input
id="confirmMonthEnd"
type="checkbox"
checked={confirmMonthEnd}
onChange={e => setConfirmMonthEnd(e.target.checked)}
disabled={!cancelMonth}
/>
<label htmlFor="confirmMonthEnd" className="text-sm text-gray-700">
I would like to cancel my SonixNet SIM subscription at the end of the selected month
above.
</label>
<div className="space-y-3 bg-gray-50 rounded-lg p-4">
<div className="flex items-start gap-3">
<input
id="acceptTerms"
type="checkbox"
checked={acceptTerms}
onChange={e => setAcceptTerms(e.target.checked)}
className="h-4 w-4 text-blue-600 border-gray-300 rounded mt-0.5"
/>
<label htmlFor="acceptTerms" className="text-sm text-gray-700">
I have read and accepted the conditions above.
</label>
</div>
<div className="flex items-start gap-3">
<input
id="confirmMonthEnd"
type="checkbox"
checked={confirmMonthEnd}
onChange={e => setConfirmMonthEnd(e.target.checked)}
disabled={!selectedMonth}
className="h-4 w-4 text-blue-600 border-gray-300 rounded mt-0.5"
/>
<label htmlFor="confirmMonthEnd" className="text-sm text-gray-700">
I would like to cancel my SonixNet SIM subscription at the end of{" "}
<strong>{selectedMonthInfo?.label || "the selected month"}</strong>.
</label>
</div>
</div>
<div className="flex justify-between">
<button
onClick={() => setStep(1)}
className="px-4 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50"
className="px-6 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50 transition-colors"
>
Back
</button>
<button
disabled={!canProceedStep3}
onClick={() => setStep(3)}
className="px-4 py-2 rounded-md bg-blue-600 text-white text-sm disabled:opacity-50"
className="px-6 py-2 rounded-md bg-blue-600 text-white text-sm font-medium disabled:opacity-50 hover:bg-blue-700 transition-colors"
>
Next
</button>
@ -262,65 +290,93 @@ export function SimCancelContainer() {
{step === 3 && (
<div className="space-y-6">
{registeredEmail && (
<div className="text-sm text-gray-800">
Your registered email address is:{" "}
<span className="font-medium">{registeredEmail}</span>
{/* Voice SIM Notice */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="text-sm font-semibold text-blue-900 mb-2">
For Voice-enabled SIM subscriptions:
</div>
)}
<div className="text-sm text-gray-700">
<div className="text-sm text-blue-800">
Calling charges are post payment. Your bill for the final month&apos;s calling charges
will be charged on your credit card on file during the first week of the second month
after the cancellation.
</div>
<div className="text-sm text-blue-800 mt-2">
If you would like to make the payment with a different credit card, please contact
Assist Solutions at info@asolutions.co.jp
</div>
</div>
{/* Registered Email */}
<div className="text-sm text-gray-800">
Your registered email address is:{" "}
<span className="font-medium">{preview?.customerEmail || "—"}</span>
</div>
<div className="text-sm text-gray-600">
You will receive a cancellation confirmation email. If you would like to receive this
email on a different address, please enter the address below.
</div>
{/* Alternative Email */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">Email address</label>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email address:
</label>
<input
className="mt-1 w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
value={email}
onChange={e => setEmail(e.target.value)}
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500"
value={alternativeEmail}
onChange={e => setAlternativeEmail(e.target.value)}
placeholder="you@example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">(Confirm)</label>
<label className="block text-sm font-medium text-gray-700 mb-1">(Confirm):</label>
<input
className="mt-1 w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
value={email2}
onChange={e => setEmail2(e.target.value)}
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500"
value={alternativeEmail2}
onChange={e => setAlternativeEmail2(e.target.value)}
placeholder="you@example.com"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700">
If you have any other questions/comments/requests regarding your cancellation,
please note them below and an Assist Solutions staff will contact you shortly.
</label>
<textarea
className="mt-1 w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
rows={4}
value={notes}
onChange={e => setNotes(e.target.value)}
placeholder="If you have any questions or requests, note them here."
/>
</div>
</div>
{emailProvided && !emailValid && (
<div className="text-xs text-red-600">
Please enter a valid email address in both fields.
</div>
<div className="text-xs text-red-600">Please enter a valid email address in both fields.</div>
)}
{emailProvided && emailValid && !emailsMatch && (
<div className="text-xs text-red-600">Email addresses do not match.</div>
)}
<div className="text-sm text-gray-700">
Your cancellation request is not confirmed yet. This is the final page. To finalize
your cancellation request please proceed from REQUEST CANCELLATION below.
{/* Comments */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
If you have any other questions/comments/requests regarding your cancellation, please
note them below and an Assist Solutions staff will contact you shortly.
</label>
<textarea
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500"
rows={4}
value={comments}
onChange={e => setComments(e.target.value)}
placeholder="Optional: Enter any questions or requests here."
/>
</div>
{/* Final Warning */}
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="text-sm font-semibold text-red-900 mb-1">
Your cancellation request is not confirmed yet.
</div>
<div className="text-sm text-red-800">
This is the final page. To finalize your cancellation request please proceed from
REQUEST CANCELLATION below.
</div>
</div>
<div className="flex justify-between">
<button
onClick={() => setStep(2)}
className="px-4 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50"
className="px-6 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50 transition-colors"
>
Back
</button>
@ -328,18 +384,16 @@ export function SimCancelContainer() {
onClick={() => {
if (
window.confirm(
"Request cancellation now? This will schedule the cancellation for " +
(runDate || "") +
"."
`Are you sure you want to cancel your SIM subscription? This will take effect at the end of ${selectedMonthInfo?.label || selectedMonth}.`
)
) {
void submit();
}
}}
disabled={loading || !runDate || !canProceedStep3}
className="px-4 py-2 rounded-md bg-red-600 text-white text-sm disabled:opacity-50"
disabled={loading || !canProceedStep3}
className="px-6 py-2 rounded-md bg-red-600 text-white text-sm font-medium disabled:opacity-50 hover:bg-red-700 transition-colors"
>
{loading ? "Processing…" : "Request Cancellation"}
{loading ? "Processing…" : "REQUEST CANCELLATION"}
</button>
</div>
</div>

View File

@ -1,39 +1,47 @@
"use client";
import { useMemo, useState } from "react";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { PageLayout } from "@/components/templates/PageLayout";
import { SubCard } from "@/components/molecules/SubCard/SubCard";
import { DevicePhoneMobileIcon } from "@heroicons/react/24/outline";
import { simActionsService } from "@/features/subscriptions/services/sim-actions.service";
import { DevicePhoneMobileIcon, CheckCircleIcon } from "@heroicons/react/24/outline";
import { simActionsService, type AvailablePlan } from "@/features/subscriptions/services/sim-actions.service";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { Formatting } from "@customer-portal/domain/toolkit";
import {
SIM_PLAN_OPTIONS,
type SimPlanCode,
getSimPlanLabel,
} from "@customer-portal/domain/sim";
const { formatCurrency } = Formatting;
export function SimChangePlanContainer() {
const params = useParams();
const subscriptionId = params.id as string;
const [currentPlanCode] = useState<string>("");
const [newPlanCode, setNewPlanCode] = useState<"" | SimPlanCode>("");
const [plans, setPlans] = useState<AvailablePlan[]>([]);
const [selectedPlan, setSelectedPlan] = useState<AvailablePlan | null>(null);
const [assignGlobalIp, setAssignGlobalIp] = useState(false);
const [scheduledAt, setScheduledAt] = useState("");
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [loadingPlans, setLoadingPlans] = useState(true);
const options = useMemo(
() => SIM_PLAN_OPTIONS.filter(option => option.code !== currentPlanCode),
[currentPlanCode]
);
useEffect(() => {
const fetchPlans = async () => {
try {
const availablePlans = await simActionsService.getAvailablePlans(subscriptionId);
setPlans(availablePlans);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to load available plans");
} finally {
setLoadingPlans(false);
}
};
void fetchPlans();
}, [subscriptionId]);
const currentPlan = plans.find(p => p.isCurrentPlan);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!newPlanCode) {
if (!selectedPlan) {
setError("Please select a new plan");
return;
}
@ -41,12 +49,14 @@ export function SimChangePlanContainer() {
setMessage(null);
setError(null);
try {
await simActionsService.changePlan(subscriptionId, {
newPlanCode,
const result = await simActionsService.changePlanFull(subscriptionId, {
newPlanCode: selectedPlan.freebitPlanCode,
newPlanSku: selectedPlan.sku,
newPlanName: selectedPlan.name,
assignGlobalIp,
scheduledAt: scheduledAt ? scheduledAt.replace(/-/g, "") : undefined,
});
setMessage("Plan change submitted successfully");
setMessage(`Plan change scheduled for ${result.scheduledAt || "the 1st of next month"}`);
setSelectedPlan(null);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to change plan");
} finally {
@ -60,7 +70,7 @@ export function SimChangePlanContainer() {
title="Change Plan"
description="Switch to a different data plan"
>
<div className="max-w-3xl mx-auto">
<div className="max-w-4xl mx-auto">
<div className="mb-4">
<Link
href={`/subscriptions/${subscriptionId}#sim-management`}
@ -71,11 +81,13 @@ export function SimChangePlanContainer() {
</div>
<SubCard>
<p className="text-sm text-gray-600 mb-6">
Change Plan: Switch to a different data plan. Important: Plan changes must be requested
before the 25th of the month. Changes will take effect on the 1st of the following
month.
</p>
<div className="mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-2">Change Your Plan</h2>
<p className="text-sm text-gray-600">
Select a new plan below. Plan changes will take effect on the 1st of the following month.
Changes must be requested before the 25th of the current month.
</p>
</div>
{message && (
<div className="mb-4">
@ -92,64 +104,127 @@ export function SimChangePlanContainer() {
</div>
)}
<form onSubmit={e => void submit(e)} className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">New Plan</label>
<select
value={newPlanCode}
onChange={e => setNewPlanCode(e.target.value as SimPlanCode)}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
>
<option value="">Choose a plan</option>
{options.map(option => (
<option key={option.code} value={option.code}>
{getSimPlanLabel(option.code)}
</option>
))}
</select>
{loadingPlans ? (
<div className="animate-pulse space-y-4">
{[1, 2, 3, 4].map(i => (
<div key={i} className="h-24 bg-gray-100 rounded-lg"></div>
))}
</div>
) : (
<form onSubmit={e => void submit(e)} className="space-y-6">
{/* Current Plan */}
{currentPlan && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<div className="flex items-center justify-between">
<div>
<div className="text-xs text-blue-600 font-medium uppercase">Current Plan</div>
<div className="text-lg font-semibold text-blue-900">{currentPlan.name}</div>
<div className="text-sm text-blue-700">{currentPlan.simDataSize}</div>
</div>
<div className="text-right">
<div className="text-xl font-bold text-blue-900">
{currentPlan.monthlyPrice ? formatCurrency(currentPlan.monthlyPrice) : "—"}
</div>
<div className="text-xs text-blue-600">/month</div>
</div>
</div>
</div>
)}
<div className="flex items-center">
<input
id="globalip"
type="checkbox"
checked={assignGlobalIp}
onChange={e => setAssignGlobalIp(e.target.checked)}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<label htmlFor="globalip" className="ml-2 text-sm text-gray-700">
Assign global IP
</label>
</div>
{/* Available Plans */}
<div className="space-y-3">
<label className="block text-sm font-medium text-gray-700">Select a New Plan</label>
<div className="grid gap-3">
{plans
.filter(p => !p.isCurrentPlan)
.map(plan => (
<label
key={plan.id}
className={`relative flex items-center justify-between p-4 border-2 rounded-lg cursor-pointer transition-all ${
selectedPlan?.id === plan.id
? "border-blue-500 bg-blue-50"
: "border-gray-200 hover:border-gray-300 bg-white"
}`}
>
<div className="flex items-center">
<input
type="radio"
name="plan"
value={plan.id}
checked={selectedPlan?.id === plan.id}
onChange={() => setSelectedPlan(plan)}
className="sr-only"
/>
<div
className={`w-5 h-5 rounded-full border-2 mr-4 flex items-center justify-center ${
selectedPlan?.id === plan.id ? "border-blue-500" : "border-gray-300"
}`}
>
{selectedPlan?.id === plan.id && (
<div className="w-3 h-3 rounded-full bg-blue-500"></div>
)}
</div>
<div>
<div className="font-medium text-gray-900">{plan.name}</div>
<div className="text-sm text-gray-500">{plan.simDataSize} {plan.simPlanType}</div>
</div>
</div>
<div className="text-right">
<div className="text-lg font-semibold text-gray-900">
{plan.monthlyPrice ? formatCurrency(plan.monthlyPrice) : "—"}
</div>
<div className="text-xs text-gray-500">/month</div>
</div>
{selectedPlan?.id === plan.id && (
<CheckCircleIcon className="absolute top-2 right-2 h-5 w-5 text-blue-500" />
)}
</label>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Schedule (optional)
</label>
<input
type="date"
value={scheduledAt}
onChange={e => setScheduledAt(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
{/* Global IP Option */}
<div className="flex items-center p-4 bg-gray-50 rounded-lg">
<input
id="globalip"
type="checkbox"
checked={assignGlobalIp}
onChange={e => setAssignGlobalIp(e.target.checked)}
className="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
<label htmlFor="globalip" className="ml-3 text-sm text-gray-700">
Assign a global IP address (additional charges may apply)
</label>
</div>
<div className="flex gap-3">
<button
type="submit"
disabled={loading}
className="px-4 py-2 rounded-md bg-blue-600 text-white text-sm disabled:opacity-50"
>
{loading ? "Processing…" : "Submit Plan Change"}
</button>
<Link
href={`/subscriptions/${subscriptionId}#sim-management`}
className="px-4 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50"
>
Back
</Link>
</div>
</form>
{/* Info Box */}
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<h3 className="text-sm font-medium text-yellow-900 mb-1">Important Notes</h3>
<ul className="text-sm text-yellow-800 space-y-1">
<li> Plan changes take effect on the 1st of the following month</li>
<li> Requests must be made before the 25th of the current month</li>
<li> Your current data balance will be reset when the new plan activates</li>
</ul>
</div>
{/* Submit */}
<div className="flex gap-3">
<button
type="submit"
disabled={loading || !selectedPlan}
className="px-6 py-2 rounded-md bg-blue-600 text-white font-medium text-sm disabled:opacity-50 hover:bg-blue-700 transition-colors"
>
{loading ? "Processing…" : "Confirm Plan Change"}
</button>
<Link
href={`/subscriptions/${subscriptionId}#sim-management`}
className="px-6 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50 transition-colors"
>
Cancel
</Link>
</div>
</form>
)}
</SubCard>
</div>
</PageLayout>

View 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;

View File

@ -72,5 +72,9 @@
"typescript": "^5.9.2",
"typescript-eslint": "^8.40.0",
"zod": "^4.1.9"
},
"dependencies": {
"@types/ssh2-sftp-client": "^9.0.5",
"ssh2-sftp-client": "^12.0.1"
}
}

View File

@ -47,6 +47,10 @@ export type {
SimCardType,
ActivationType,
MnpData,
// Enhanced request types
SimCancelFullRequest,
SimTopUpFullRequest,
SimChangePlanFullRequest,
// Activation types
SimOrderActivationRequest,
SimOrderActivationMnp,

View File

@ -26,6 +26,7 @@ export const simDetailsSchema = z.object({
networkType: z.string(),
activatedAt: z.string().optional(),
expiresAt: z.string().optional(),
startDate: z.string().optional(),
});
export const recentDayUsageSchema = z.object({
@ -151,6 +152,36 @@ export const simReissueRequestSchema = z.object({
.string()
.regex(/^\d{32}$/, "EID must be exactly 32 digits")
.optional(),
simType: z.enum(["physical", "esim"]).optional(),
});
// Enhanced cancellation request with more details
export const simCancelFullRequestSchema = z.object({
cancellationMonth: z.string().regex(/^\d{4}-\d{2}$/, "Cancellation month must be in YYYY-MM format"),
confirmRead: z.boolean(),
confirmCancel: z.boolean(),
alternativeEmail: z.string().email().optional().or(z.literal("")),
comments: z.string().max(1000).optional(),
}).refine(
(data) => data.confirmRead === true && data.confirmCancel === true,
{ message: "You must confirm both checkboxes to proceed" }
);
// Top-up request with enhanced details for email
export const simTopUpFullRequestSchema = z.object({
quotaMb: z
.number()
.int()
.min(100, "Quota must be at least 100MB")
.max(51200, "Quota must be 50GB or less"),
});
// Change plan request with Salesforce product info
export const simChangePlanFullRequestSchema = z.object({
newPlanCode: z.string().min(1, "New plan code is required"),
newPlanSku: z.string().min(1, "New plan SKU is required"),
newPlanName: z.string().optional(),
assignGlobalIp: z.boolean().optional(),
});
const simMnpFormSchema = z.object({
@ -334,6 +365,9 @@ export type SimCancelRequest = z.infer<typeof simCancelRequestSchema>;
export type SimTopUpHistoryRequest = z.infer<typeof simTopUpHistoryRequestSchema>;
export type SimFeaturesUpdateRequest = z.infer<typeof simFeaturesUpdateRequestSchema>;
export type SimReissueRequest = z.infer<typeof simReissueRequestSchema>;
export type SimCancelFullRequest = z.infer<typeof simCancelFullRequestSchema>;
export type SimTopUpFullRequest = z.infer<typeof simTopUpFullRequestSchema>;
export type SimChangePlanFullRequest = z.infer<typeof simChangePlanFullRequestSchema>;
export type SimConfigureFormData = z.infer<typeof simConfigureFormSchema>;
export type SimCardType = z.infer<typeof simCardTypeSchema>;
export type ActivationType = z.infer<typeof simActivationTypeSchema>;

102
pnpm-lock.yaml generated
View File

@ -7,6 +7,13 @@ settings:
importers:
.:
dependencies:
'@types/ssh2-sftp-client':
specifier: ^9.0.5
version: 9.0.5
ssh2-sftp-client:
specifier: ^12.0.1
version: 12.0.1
devDependencies:
'@eslint/eslintrc':
specifier: ^3.3.1
@ -95,6 +102,9 @@ importers:
'@sendgrid/mail':
specifier: ^8.1.6
version: 8.1.6
'@types/ssh2-sftp-client':
specifier: ^9.0.5
version: 9.0.5
bcrypt:
specifier: ^6.0.0
version: 6.0.0
@ -167,6 +177,9 @@ importers:
speakeasy:
specifier: ^2.0.0
version: 2.0.0
ssh2-sftp-client:
specifier: ^12.0.1
version: 12.0.1
tsconfig-paths:
specifier: ^4.2.0
version: 4.2.0
@ -1827,6 +1840,9 @@ packages:
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/node@18.19.130':
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
'@types/node@24.3.1':
resolution: {integrity: sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==}
@ -1865,6 +1881,12 @@ packages:
'@types/speakeasy@2.0.10':
resolution: {integrity: sha512-QVRlDW5r4yl7p7xkNIbAIC/JtyOcClDIIdKfuG7PWdDT1MmyhtXSANsildohy0K+Lmvf/9RUtLbNLMacvrVwxA==}
'@types/ssh2-sftp-client@9.0.5':
resolution: {integrity: sha512-cpUO6okDusnfLw2hnmaBiomlSchIWNVcCdpywLRsg/h9Q1TTiUSrzhkn5sJeeyTM8h6xRbZEZZjgWtUXFDogHg==}
'@types/ssh2@1.15.5':
resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==}
'@types/stack-utils@2.0.3':
resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==}
@ -2245,6 +2267,9 @@ packages:
asap@2.0.6:
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
asn1@0.2.6:
resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==}
ast-types-flow@0.0.8:
resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
@ -2315,6 +2340,9 @@ packages:
resolution: {integrity: sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==}
engines: {node: '>=6.0.0'}
bcrypt-pbkdf@1.0.2:
resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==}
bcrypt@6.0.0:
resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==}
engines: {node: '>= 18'}
@ -2357,6 +2385,10 @@ packages:
buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
buildcheck@0.0.7:
resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==}
engines: {node: '>=10.0.0'}
bullmq@5.58.5:
resolution: {integrity: sha512-0A6Qjxdn8j7aOcxfRZY798vO/aMuwvoZwfE6a9EOXHb1pzpBVAogsc/OfRWeUf+5wMBoYB5nthstnJo/zrQOeQ==}
@ -2595,6 +2627,10 @@ packages:
typescript:
optional: true
cpu-features@0.0.10:
resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==}
engines: {node: '>=10.0.0'}
create-require@1.1.1:
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
@ -4089,6 +4125,9 @@ packages:
resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==}
engines: {node: ^18.17.0 || >=20.5.0}
nan@2.23.1:
resolution: {integrity: sha512-r7bBUGKzlqk8oPBDYxt6Z0aEdF1G1rwlMcLk8LCOMbOzf0mG+JUfUzG4fIMWwHWP0iyaLWEQZJmtB7nOHEm/qw==}
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@ -4782,6 +4821,14 @@ packages:
sprintf-js@1.0.3:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
ssh2-sftp-client@12.0.1:
resolution: {integrity: sha512-ICJ1L2PmBel2Q2ctbyxzTFZCPKSHYYD6s2TFZv7NXmZDrDNGk8lHBb/SK2WgXLMXNANH78qoumeJzxlWZqSqWg==}
engines: {node: '>=18.20.4'}
ssh2@1.17.0:
resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==}
engines: {node: '>=10.16.0'}
stable-hash@0.0.5:
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
@ -5097,6 +5144,9 @@ packages:
tw-animate-css@1.3.8:
resolution: {integrity: sha512-Qrk3PZ7l7wUcGYhwZloqfkWCmaXZAoqjkdbIDvzfGshwGtexa/DAs9koXxIkrpEasyevandomzCBAV1Yyop5rw==}
tweetnacl@0.14.5:
resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==}
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@ -5182,6 +5232,9 @@ packages:
underscore@1.13.7:
resolution: {integrity: sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==}
undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
undici-types@7.10.0:
resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==}
@ -6869,6 +6922,10 @@ snapshots:
'@types/ms@2.1.0': {}
'@types/node@18.19.130':
dependencies:
undici-types: 5.26.5
'@types/node@24.3.1':
dependencies:
undici-types: 7.10.0
@ -6920,6 +6977,14 @@ snapshots:
dependencies:
'@types/node': 24.3.1
'@types/ssh2-sftp-client@9.0.5':
dependencies:
'@types/ssh2': 1.15.5
'@types/ssh2@1.15.5':
dependencies:
'@types/node': 18.19.130
'@types/stack-utils@2.0.3': {}
'@types/superagent@8.1.9':
@ -7344,6 +7409,10 @@ snapshots:
asap@2.0.6: {}
asn1@0.2.6:
dependencies:
safer-buffer: 2.1.2
ast-types-flow@0.0.8: {}
async-function@1.0.0: {}
@ -7434,6 +7503,10 @@ snapshots:
base64url@3.0.1: {}
bcrypt-pbkdf@1.0.2:
dependencies:
tweetnacl: 0.14.5
bcrypt@6.0.0:
dependencies:
node-addon-api: 8.5.0
@ -7496,6 +7569,9 @@ snapshots:
base64-js: 1.5.1
ieee754: 1.2.1
buildcheck@0.0.7:
optional: true
bullmq@5.58.5:
dependencies:
cron-parser: 4.9.0
@ -7717,6 +7793,12 @@ snapshots:
optionalDependencies:
typescript: 5.8.3
cpu-features@0.0.10:
dependencies:
buildcheck: 0.0.7
nan: 2.23.1
optional: true
create-require@1.1.1: {}
cron-parser@4.9.0:
@ -9571,6 +9653,9 @@ snapshots:
mute-stream@2.0.0: {}
nan@2.23.1:
optional: true
nanoid@3.3.11: {}
napi-postinstall@0.3.3: {}
@ -10341,6 +10426,19 @@ snapshots:
sprintf-js@1.0.3: {}
ssh2-sftp-client@12.0.1:
dependencies:
concat-stream: 2.0.0
ssh2: 1.17.0
ssh2@1.17.0:
dependencies:
asn1: 0.2.6
bcrypt-pbkdf: 1.0.2
optionalDependencies:
cpu-features: 0.0.10
nan: 2.23.1
stable-hash@0.0.5: {}
stack-utils@2.0.6:
@ -10673,6 +10771,8 @@ snapshots:
tw-animate-css@1.3.8: {}
tweetnacl@0.14.5: {}
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
@ -10767,6 +10867,8 @@ snapshots:
underscore@1.13.7: {}
undici-types@5.26.5: {}
undici-types@7.10.0: {}
undici@7.16.0: {}