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

View File

@ -180,3 +180,78 @@ model SimVoiceOptions {
@@map("sim_voice_options") @@map("sim_voice_options")
} }
// Call history from SFTP monthly imports (domestic calls)
model SimCallHistoryDomestic {
id String @id @default(uuid())
account String // Customer phone number (e.g., "08077052946")
callDate DateTime @db.Date @map("call_date") // Date the call was made
callTime String @map("call_time") // Start time of the call (HHMMSS)
calledTo String @map("called_to") // Phone number called
location String? // Location info
durationSec Int @map("duration_sec") // Duration in seconds (320 = 32.0 sec)
chargeYen Int @map("charge_yen") // Call charge in JPY
month String // YYYY-MM format for filtering
createdAt DateTime @default(now()) @map("created_at")
@@unique([account, callDate, callTime, calledTo])
@@index([account, month])
@@index([account, callDate])
@@map("sim_call_history_domestic")
}
// Call history from SFTP monthly imports (international calls)
model SimCallHistoryInternational {
id String @id @default(uuid())
account String // Customer phone number
callDate DateTime @db.Date @map("call_date") // Date the call was made
startTime String @map("start_time") // Start time of the call
stopTime String? @map("stop_time") // Stop time (if available)
country String? // Country/location for international calls
calledTo String @map("called_to") // Phone number called
durationSec Int @map("duration_sec") // Duration in seconds
chargeYen Int @map("charge_yen") // Call charge in JPY
month String // YYYY-MM format for filtering
createdAt DateTime @default(now()) @map("created_at")
@@unique([account, callDate, startTime, calledTo])
@@index([account, month])
@@index([account, callDate])
@@map("sim_call_history_international")
}
// SMS history from SFTP monthly imports
model SimSmsHistory {
id String @id @default(uuid())
account String // Customer phone number
smsDate DateTime @db.Date @map("sms_date") // Date the SMS was sent
smsTime String @map("sms_time") // Time the SMS was sent
sentTo String @map("sent_to") // Phone number SMS was sent to
smsType SmsType @default(DOMESTIC) @map("sms_type") // SMS or 国際SMS
month String // YYYY-MM format for filtering
createdAt DateTime @default(now()) @map("created_at")
@@unique([account, smsDate, smsTime, sentTo])
@@index([account, month])
@@index([account, smsDate])
@@map("sim_sms_history")
}
enum SmsType {
DOMESTIC //
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, FreebitVoiceOptionResponse,
FreebitCancelPlanRequest, FreebitCancelPlanRequest,
FreebitCancelPlanResponse, FreebitCancelPlanResponse,
FreebitCancelAccountRequest,
FreebitCancelAccountResponse,
FreebitEsimReissueRequest, FreebitEsimReissueRequest,
FreebitEsimReissueResponse, FreebitEsimReissueResponse,
FreebitEsimAddAccountRequest, FreebitEsimAddAccountRequest,
@ -738,15 +740,7 @@ export class FreebitOperationsService {
} }
/** /**
* Cancel SIM service * Cancel SIM plan (PA05-04 - plan cancellation only)
* Uses PA02-04 cancellation endpoint
*
* IMPORTANT CONSTRAINTS:
* - Must be sent with runDate as 1st of client's cancellation month n+1
* (e.g., cancel end of Jan = runDate 20250201)
* - After PA02-04 is sent, any subsequent PA05-21 calls will cancel it
* - PA05-21 and PA02-04 cannot coexist
* - Must prevent clients from making further changes after cancellation is requested
*/ */
async cancelSim(account: string, scheduledAt?: string): Promise<void> { async cancelSim(account: string, scheduledAt?: string): Promise<void> {
try { try {
@ -755,10 +749,9 @@ export class FreebitOperationsService {
runTime: scheduledAt, runTime: scheduledAt,
}; };
this.logger.log(`Cancelling SIM service via PA02-04 for account ${account}`, { this.logger.log(`Cancelling SIM plan via PA05-04 for account ${account}`, {
account, account,
runTime: scheduledAt, runTime: scheduledAt,
note: "After this, PA05-21 plan changes will cancel the cancellation",
}); });
await this.client.makeAuthenticatedRequest<FreebitCancelPlanResponse, typeof request>( await this.client.makeAuthenticatedRequest<FreebitCancelPlanResponse, typeof request>(
@ -766,19 +759,64 @@ export class FreebitOperationsService {
request request
); );
this.logger.log(`Successfully cancelled SIM for account ${account}`, { this.logger.log(`Successfully cancelled SIM plan for account ${account}`, {
account, account,
runTime: scheduledAt, runTime: scheduledAt,
}); });
this.stampOperation(account, "cancellation"); this.stampOperation(account, "cancellation");
} catch (error) { } catch (error) {
const message = getErrorMessage(error); const message = getErrorMessage(error);
this.logger.error(`Failed to cancel SIM for account ${account}`, { this.logger.error(`Failed to cancel SIM plan for account ${account}`, {
account, account,
scheduledAt, scheduledAt,
error: message, error: message,
}); });
throw new BadRequestException(`Failed to cancel SIM: ${message}`); throw new BadRequestException(`Failed to cancel SIM plan: ${message}`);
}
}
/**
* Cancel SIM account (PA02-04 - full account cancellation)
*
* IMPORTANT CONSTRAINTS:
* - Must be sent with runDate as 1st of client's cancellation month n+1
* (e.g., cancel end of Jan = runDate 20250201)
* - After PA02-04 is sent, any subsequent PA05-21 calls will cancel it
* - PA05-21 and PA02-04 cannot coexist
* - Must prevent clients from making further changes after cancellation is requested
*/
async cancelAccount(account: string, runDate?: string): Promise<void> {
try {
const request: Omit<FreebitCancelAccountRequest, "authKey"> = {
kind: "MVNO",
account,
runDate,
};
this.logger.log(`Cancelling SIM account via PA02-04 for account ${account}`, {
account,
runDate,
note: "After this, PA05-21 plan changes will cancel the cancellation",
});
await this.client.makeAuthenticatedRequest<FreebitCancelAccountResponse, typeof request>(
"/master/cnclAcnt/",
request
);
this.logger.log(`Successfully cancelled SIM account for account ${account}`, {
account,
runDate,
});
this.stampOperation(account, "cancellation");
} catch (error) {
const message = getErrorMessage(error);
this.logger.error(`Failed to cancel SIM account for account ${account}`, {
account,
runDate,
error: message,
});
throw new BadRequestException(`Failed to cancel SIM account: ${message}`);
} }
} }

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> { async cancelSim(account: string, scheduledAt?: string): Promise<void> {
const normalizedAccount = this.mapper.normalizeAccount(account); const normalizedAccount = this.mapper.normalizeAccount(account);
return this.operations.cancelSim(normalizedAccount, scheduledAt); return this.operations.cancelSim(normalizedAccount, scheduledAt);
} }
/**
* Cancel SIM account (full account cancellation - PA02-04)
*/
async cancelAccount(account: string, runDate?: string): Promise<void> {
const normalizedAccount = this.mapper.normalizeAccount(account);
return this.operations.cancelAccount(normalizedAccount, runDate);
}
/** /**
* Reissue eSIM profile (simple) * Reissue eSIM profile (simple)
*/ */

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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": "^5.9.2",
"typescript-eslint": "^8.40.0", "typescript-eslint": "^8.40.0",
"zod": "^4.1.9" "zod": "^4.1.9"
},
"dependencies": {
"@types/ssh2-sftp-client": "^9.0.5",
"ssh2-sftp-client": "^12.0.1"
} }
} }

View File

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

View File

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

102
pnpm-lock.yaml generated
View File

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