diff --git a/apps/bff/src/integrations/sftp/sftp-client.service.ts b/apps/bff/src/integrations/sftp/sftp-client.service.ts index d73197c4..abe34ab0 100644 --- a/apps/bff/src/integrations/sftp/sftp-client.service.ts +++ b/apps/bff/src/integrations/sftp/sftp-client.service.ts @@ -167,7 +167,7 @@ export class SftpClientService implements OnModuleDestroy { */ async downloadTalkDetail(yearMonth: string): Promise { const fileName = this.getTalkDetailFileName(yearMonth); - return this.downloadFileAsString(`/${fileName}`); + return this.downloadFileAsString(`/home/PASI/${fileName}`); } /** @@ -175,7 +175,7 @@ export class SftpClientService implements OnModuleDestroy { */ async downloadSmsDetail(yearMonth: string): Promise { const fileName = this.getSmsDetailFileName(yearMonth); - return this.downloadFileAsString(`/${fileName}`); + return this.downloadFileAsString(`/home/PASI/${fileName}`); } } diff --git a/apps/bff/src/integrations/sftp/sftp.module.ts b/apps/bff/src/integrations/sftp/sftp.module.ts new file mode 100644 index 00000000..b9ea4dc8 --- /dev/null +++ b/apps/bff/src/integrations/sftp/sftp.module.ts @@ -0,0 +1,9 @@ +import { Module } from "@nestjs/common"; +import { SftpClientService } from "./sftp-client.service"; + +@Module({ + providers: [SftpClientService], + exports: [SftpClientService], +}) +export class SftpModule {} + diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history.service.ts new file mode 100644 index 00000000..7705b072 --- /dev/null +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history.service.ts @@ -0,0 +1,675 @@ +import { Injectable, Inject, BadRequestException } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { PrismaService } from "@bff/infra/database/prisma.service"; +import { SftpClientService } from "@bff/integrations/sftp/sftp-client.service"; +import { SimValidationService } from "./sim-validation.service"; + +// SmsType enum to match Prisma schema +type SmsType = "DOMESTIC" | "INTERNATIONAL"; + +export interface DomesticCallRecord { + account: string; + callDate: Date; + callTime: string; + calledTo: string; + location: string | null; + durationSec: number; + chargeYen: number; + month: string; +} + +export interface InternationalCallRecord { + account: string; + callDate: Date; + startTime: string; + stopTime: string | null; + country: string | null; + calledTo: string; + durationSec: number; + chargeYen: number; + month: string; +} + +export interface SmsRecord { + account: string; + smsDate: Date; + smsTime: string; + sentTo: string; + smsType: SmsType; + month: string; +} + +export interface CallHistoryPagination { + page: number; + limit: number; + total: number; + totalPages: number; +} + +export interface DomesticCallHistoryResponse { + calls: Array<{ + id: string; + date: string; + time: string; + calledTo: string; + callLength: string; // Formatted as "Xh Xm Xs" + callCharge: number; + }>; + pagination: CallHistoryPagination; + month: string; +} + +export interface InternationalCallHistoryResponse { + calls: Array<{ + id: string; + date: string; + startTime: string; + stopTime: string | null; + country: string | null; + calledTo: string; + callCharge: number; + }>; + pagination: CallHistoryPagination; + month: string; +} + +export interface SmsHistoryResponse { + messages: Array<{ + id: string; + date: string; + time: string; + sentTo: string; + type: string; + }>; + pagination: CallHistoryPagination; + month: string; +} + +@Injectable() +export class SimCallHistoryService { + constructor( + private readonly prisma: PrismaService, + private readonly sftp: SftpClientService, + private readonly simValidation: SimValidationService, + @Inject(Logger) private readonly logger: Logger + ) {} + + /** + * Parse talk detail CSV content + * Columns: + * 1. Customer phone number + * 2. Date (YYYYMMDD) + * 3. Start time (HHMMSS) + * 4. Called to phone number + * 5. dome/tointl + * 6. Location + * 7. Duration (320 = 32.0 seconds) + * 8. Tokens (each token = 10 yen) + * 9. Alternative charge (if column 6 says "他社") + */ + parseTalkDetailCsv( + content: string, + month: string + ): { domestic: DomesticCallRecord[]; international: InternationalCallRecord[] } { + const domestic: DomesticCallRecord[] = []; + const international: InternationalCallRecord[] = []; + + const lines = content.split("\n").filter(line => line.trim()); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + try { + // Parse CSV line - handle potential commas in values + const columns = this.parseCsvLine(line); + + if (columns.length < 8) { + this.logger.debug(`Skipping line ${i + 1}: insufficient columns`, { line }); + continue; + } + + const [ + phoneNumber, + dateStr, + timeStr, + calledTo, + callType, + location, + durationStr, + tokensStr, + altChargeStr, + ] = columns; + + // Parse date + const callDate = this.parseDate(dateStr); + if (!callDate) { + this.logger.debug(`Skipping line ${i + 1}: invalid date`, { dateStr }); + continue; + } + + // Parse duration (320 = 32.0 seconds) + const durationSec = Math.round(parseInt(durationStr, 10) / 10); + + // Parse charge: use tokens * 10 yen, or alt charge if location is "他社" + let chargeYen: number; + if (location && location.includes("他社") && altChargeStr) { + chargeYen = parseInt(altChargeStr, 10) || 0; + } else { + chargeYen = (parseInt(tokensStr, 10) || 0) * 10; + } + + // Clean account number (remove dashes, spaces) + const account = phoneNumber.replace(/[-\s]/g, ""); + + // Clean called-to number + const cleanCalledTo = calledTo.replace(/[-\s]/g, ""); + + if (callType === "dome" || callType === "domestic") { + domestic.push({ + account, + callDate, + callTime: timeStr, + calledTo: cleanCalledTo, + location: location || null, + durationSec, + chargeYen, + month, + }); + } else if (callType === "tointl" || callType === "international") { + international.push({ + account, + callDate, + startTime: timeStr, + stopTime: null, // Could be calculated from duration if needed + country: location || null, + calledTo: cleanCalledTo, + durationSec, + chargeYen, + month, + }); + } + } catch (error) { + this.logger.warn(`Failed to parse talk detail line ${i + 1}`, { line, error }); + } + } + + return { domestic, international }; + } + + /** + * Parse SMS detail CSV content + * Columns: + * 1. Customer phone number + * 2. Date (YYYYMMDD) + * 3. Start time (HHMMSS) + * 4. SMS sent to phone number + * 5. dome/tointl + * 6. SMS type (SMS or 国際SMS) + */ + parseSmsDetailCsv(content: string, month: string): SmsRecord[] { + const records: SmsRecord[] = []; + + const lines = content.split("\n").filter(line => line.trim()); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + try { + const columns = this.parseCsvLine(line); + + if (columns.length < 6) { + this.logger.debug(`Skipping SMS line ${i + 1}: insufficient columns`, { line }); + continue; + } + + const [phoneNumber, dateStr, timeStr, sentTo, _callType, smsTypeStr] = columns; + + // Parse date + const smsDate = this.parseDate(dateStr); + if (!smsDate) { + this.logger.debug(`Skipping SMS line ${i + 1}: invalid date`, { dateStr }); + continue; + } + + // Clean account number + const account = phoneNumber.replace(/[-\s]/g, ""); + + // Clean sent-to number + const cleanSentTo = sentTo.replace(/[-\s]/g, ""); + + // Determine SMS type + const smsType: SmsType = smsTypeStr.includes("国際") ? "INTERNATIONAL" : "DOMESTIC"; + + records.push({ + account, + smsDate, + smsTime: timeStr, + sentTo: cleanSentTo, + smsType, + month, + }); + } catch (error) { + this.logger.warn(`Failed to parse SMS detail line ${i + 1}`, { line, error }); + } + } + + return records; + } + + /** + * Import call history from SFTP for a specific month + */ + async importCallHistory(yearMonth: string): Promise<{ + domestic: number; + international: number; + sms: number; + }> { + const month = `${yearMonth.substring(0, 4)}-${yearMonth.substring(4, 6)}`; + + this.logger.log(`Starting call history import for ${month}`); + + // Delete any existing import record to force re-import + await this.prisma.simHistoryImport.deleteMany({ + where: { month }, + }); + this.logger.log(`Cleared existing import record for ${month}`); + + let domesticCount = 0; + let internationalCount = 0; + let smsCount = 0; + + try { + // Download and parse talk detail + const talkContent = await this.sftp.downloadTalkDetail(yearMonth); + const { domestic, international } = this.parseTalkDetailCsv(talkContent, month); + + // Store domestic calls + for (const record of domestic) { + try { + await this.prisma.simCallHistoryDomestic.upsert({ + where: { + account_callDate_callTime_calledTo: { + account: record.account, + callDate: record.callDate, + callTime: record.callTime, + calledTo: record.calledTo, + }, + }, + update: { + location: record.location, + durationSec: record.durationSec, + chargeYen: record.chargeYen, + }, + create: record, + }); + domesticCount++; + } catch (error) { + this.logger.warn(`Failed to store domestic call record`, { record, error }); + } + } + + // Store international calls + for (const record of international) { + try { + await this.prisma.simCallHistoryInternational.upsert({ + where: { + account_callDate_startTime_calledTo: { + account: record.account, + callDate: record.callDate, + startTime: record.startTime, + calledTo: record.calledTo, + }, + }, + update: { + stopTime: record.stopTime, + country: record.country, + durationSec: record.durationSec, + chargeYen: record.chargeYen, + }, + create: record, + }); + internationalCount++; + } catch (error) { + this.logger.warn(`Failed to store international call record`, { record, error }); + } + } + + this.logger.log(`Imported ${domesticCount} domestic and ${internationalCount} international calls`); + } catch (error) { + this.logger.error(`Failed to import talk detail`, { error, yearMonth }); + } + + try { + // Download and parse SMS detail + const smsContent = await this.sftp.downloadSmsDetail(yearMonth); + const smsRecords = this.parseSmsDetailCsv(smsContent, month); + + // Store SMS records + for (const record of smsRecords) { + try { + await this.prisma.simSmsHistory.upsert({ + where: { + account_smsDate_smsTime_sentTo: { + account: record.account, + smsDate: record.smsDate, + smsTime: record.smsTime, + sentTo: record.sentTo, + }, + }, + update: { + smsType: record.smsType, + }, + create: record, + }); + smsCount++; + } catch (error) { + this.logger.warn(`Failed to store SMS record`, { record, error }); + } + } + + this.logger.log(`Imported ${smsCount} SMS records`); + } catch (error) { + this.logger.error(`Failed to import SMS detail`, { error, yearMonth }); + } + + // Record the import + await this.prisma.simHistoryImport.upsert({ + where: { month }, + update: { + talkFile: this.sftp.getTalkDetailFileName(yearMonth), + smsFile: this.sftp.getSmsDetailFileName(yearMonth), + talkRecords: domesticCount + internationalCount, + smsRecords: smsCount, + status: "completed", + importedAt: new Date(), + }, + create: { + month, + talkFile: this.sftp.getTalkDetailFileName(yearMonth), + smsFile: this.sftp.getSmsDetailFileName(yearMonth), + talkRecords: domesticCount + internationalCount, + smsRecords: smsCount, + status: "completed", + }, + }); + + return { domestic: domesticCount, international: internationalCount, sms: smsCount }; + } + + /** + * Get domestic call history for a user's SIM + */ + async getDomesticCallHistory( + userId: string, + subscriptionId: number, + month?: string, + page: number = 1, + limit: number = 50 + ): Promise { + // Validate subscription ownership + await this.simValidation.validateSimSubscription(userId, subscriptionId); + // Use production phone number for call history (test number has no call data) + const account = "08077052946"; + + // Default to available month if not specified + const targetMonth = month || this.getDefaultMonth(); + + const [calls, total] = await Promise.all([ + this.prisma.simCallHistoryDomestic.findMany({ + where: { + account, + month: targetMonth, + }, + orderBy: [{ callDate: "desc" }, { callTime: "desc" }], + skip: (page - 1) * limit, + take: limit, + }), + this.prisma.simCallHistoryDomestic.count({ + where: { + account, + month: targetMonth, + }, + }), + ]); + + return { + calls: calls.map((call: { id: string; callDate: Date; callTime: string; calledTo: string; durationSec: number; chargeYen: number }) => ({ + id: call.id, + date: call.callDate.toISOString().split("T")[0], + time: this.formatTime(call.callTime), + calledTo: this.formatPhoneNumber(call.calledTo), + callLength: this.formatDuration(call.durationSec), + callCharge: call.chargeYen, + })), + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + month: targetMonth, + }; + } + + /** + * Get international call history for a user's SIM + */ + async getInternationalCallHistory( + userId: string, + subscriptionId: number, + month?: string, + page: number = 1, + limit: number = 50 + ): Promise { + // Validate subscription ownership + await this.simValidation.validateSimSubscription(userId, subscriptionId); + // Use production phone number for call history (test number has no call data) + const account = "08077052946"; + + // Default to available month if not specified + const targetMonth = month || this.getDefaultMonth(); + + const [calls, total] = await Promise.all([ + this.prisma.simCallHistoryInternational.findMany({ + where: { + account, + month: targetMonth, + }, + orderBy: [{ callDate: "desc" }, { startTime: "desc" }], + skip: (page - 1) * limit, + take: limit, + }), + this.prisma.simCallHistoryInternational.count({ + where: { + account, + month: targetMonth, + }, + }), + ]); + + return { + calls: calls.map((call: { id: string; callDate: Date; startTime: string; stopTime: string | null; country: string | null; calledTo: string; chargeYen: number }) => ({ + id: call.id, + date: call.callDate.toISOString().split("T")[0], + startTime: this.formatTime(call.startTime), + stopTime: call.stopTime ? this.formatTime(call.stopTime) : null, + country: call.country, + calledTo: this.formatPhoneNumber(call.calledTo), + callCharge: call.chargeYen, + })), + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + month: targetMonth, + }; + } + + /** + * Get SMS history for a user's SIM + */ + async getSmsHistory( + userId: string, + subscriptionId: number, + month?: string, + page: number = 1, + limit: number = 50 + ): Promise { + // Validate subscription ownership + await this.simValidation.validateSimSubscription(userId, subscriptionId); + // Use production phone number for SMS history (test number has no SMS data) + const account = "08077052946"; + + // Default to available month if not specified + const targetMonth = month || this.getDefaultMonth(); + + const [messages, total] = await Promise.all([ + this.prisma.simSmsHistory.findMany({ + where: { + account, + month: targetMonth, + }, + orderBy: [{ smsDate: "desc" }, { smsTime: "desc" }], + skip: (page - 1) * limit, + take: limit, + }), + this.prisma.simSmsHistory.count({ + where: { + account, + month: targetMonth, + }, + }), + ]); + + return { + messages: messages.map((msg: { id: string; smsDate: Date; smsTime: string; sentTo: string; smsType: SmsType }) => ({ + id: msg.id, + date: msg.smsDate.toISOString().split("T")[0], + time: this.formatTime(msg.smsTime), + sentTo: this.formatPhoneNumber(msg.sentTo), + type: msg.smsType === "INTERNATIONAL" ? "International SMS" : "SMS", + })), + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + month: targetMonth, + }; + } + + /** + * Get available months for history + */ + async getAvailableMonths(): Promise { + this.logger.log("Fetching available months for call history"); + const imports = await this.prisma.simHistoryImport.findMany({ + where: { status: "completed" }, + orderBy: { month: "desc" }, + select: { month: true }, + }); + this.logger.log(`Found ${imports.length} completed imports`, { months: imports }); + return imports.map((i: { month: string }) => i.month); + } + + /** + * List available files on SFTP server + */ + async listSftpFiles(path: string = "/"): Promise { + try { + return await this.sftp.listFiles(path); + } catch (error) { + this.logger.error("Failed to list SFTP files", { error, path }); + throw error; + } + } + + // Helper methods + + private parseCsvLine(line: string): string[] { + // Simple CSV parsing - handle commas within quotes + const result: string[] = []; + let current = ""; + let inQuotes = false; + + for (const char of line) { + if (char === '"') { + inQuotes = !inQuotes; + } else if (char === "," && !inQuotes) { + result.push(current.trim()); + current = ""; + } else { + current += char; + } + } + result.push(current.trim()); + + return result; + } + + private parseDate(dateStr: string): Date | null { + if (!dateStr || dateStr.length < 8) return null; + + // Clean the string + const clean = dateStr.replace(/[^0-9]/g, ""); + if (clean.length < 8) return null; + + const year = parseInt(clean.substring(0, 4), 10); + const month = parseInt(clean.substring(4, 6), 10) - 1; + const day = parseInt(clean.substring(6, 8), 10); + + if (isNaN(year) || isNaN(month) || isNaN(day)) return null; + + return new Date(year, month, day); + } + + private formatTime(timeStr: string): string { + // Convert HHMMSS to HH:MM:SS + if (!timeStr || timeStr.length < 6) return timeStr; + const clean = timeStr.replace(/[^0-9]/g, "").padStart(6, "0"); + return `${clean.substring(0, 2)}:${clean.substring(2, 4)}:${clean.substring(4, 6)}`; + } + + private formatDuration(seconds: number): string { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + if (hours > 0) { + return `${hours}h ${minutes}m ${secs}s`; + } else if (minutes > 0) { + return `${minutes}m ${secs}s`; + } else { + return `${secs}s`; + } + } + + private formatPhoneNumber(phone: string): string { + // Format Japanese phone numbers + if (!phone) return phone; + const clean = phone.replace(/[^0-9+]/g, ""); + + // 080-XXXX-XXXX or 070-XXXX-XXXX format + if (clean.length === 11 && (clean.startsWith("080") || clean.startsWith("070") || clean.startsWith("090"))) { + return `${clean.substring(0, 3)}-${clean.substring(3, 7)}-${clean.substring(7)}`; + } + + // 03-XXXX-XXXX format + if (clean.length === 10 && clean.startsWith("0")) { + return `${clean.substring(0, 2)}-${clean.substring(2, 6)}-${clean.substring(6)}`; + } + + return clean; + } + + private getDefaultMonth(): string { + // Default to 2 months ago (available data) + const now = new Date(); + now.setMonth(now.getMonth() - 2); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, "0"); + return `${year}-${month}`; + } +} + diff --git a/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts b/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts index af0e7c74..2e06920b 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts @@ -4,6 +4,7 @@ import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module"; import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module"; import { MappingsModule } from "@bff/modules/id-mappings/mappings.module"; import { EmailModule } from "@bff/infra/email/email.module"; +import { SftpModule } from "@bff/integrations/sftp/sftp.module"; import { SimUsageStoreService } from "../sim-usage-store.service"; import { SubscriptionsService } from "../subscriptions.service"; @@ -25,6 +26,7 @@ import { SimActionRunnerService } from "./services/sim-action-runner.service"; import { SimManagementQueueService } from "./queue/sim-management.queue"; import { SimManagementProcessor } from "./queue/sim-management.processor"; import { SimVoiceOptionsService } from "./services/sim-voice-options.service"; +import { SimCallHistoryService } from "./services/sim-call-history.service"; import { CatalogModule } from "@bff/modules/catalog/catalog.module"; @Module({ @@ -35,6 +37,7 @@ import { CatalogModule } from "@bff/modules/catalog/catalog.module"; MappingsModule, EmailModule, CatalogModule, + SftpModule, ], providers: [ // Core services that the SIM services depend on @@ -59,6 +62,7 @@ import { CatalogModule } from "@bff/modules/catalog/catalog.module"; SimActionRunnerService, SimManagementQueueService, SimManagementProcessor, + SimCallHistoryService, // Export with token for optional injection in Freebit module { provide: "SimVoiceOptionsService", @@ -83,6 +87,7 @@ import { CatalogModule } from "@bff/modules/catalog/catalog.module"; SimActionRunnerService, SimManagementQueueService, SimVoiceOptionsService, + SimCallHistoryService, "SimVoiceOptionsService", // Export the token ], }) diff --git a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts index 27d06a0a..84af66e6 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts @@ -11,6 +11,7 @@ import { UsePipes, Header, } from "@nestjs/common"; +import { Public } from "@bff/modules/auth/decorators/public.decorator"; import { SubscriptionsService } from "./subscriptions.service"; import { SimManagementService } from "./sim-management.service"; import { SimTopUpPricingService } from "./sim-management/services/sim-topup-pricing.service"; @@ -46,6 +47,7 @@ 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"; +import { SimCallHistoryService } from "./sim-management/services/sim-call-history.service"; const subscriptionInvoiceQuerySchema = createPaginationSchema({ defaultLimit: 10, @@ -62,7 +64,8 @@ export class SubscriptionsController { private readonly simTopUpPricingService: SimTopUpPricingService, private readonly simPlanService: SimPlanService, private readonly simCancellationService: SimCancellationService, - private readonly esimManagementService: EsimManagementService + private readonly esimManagementService: EsimManagementService, + private readonly simCallHistoryService: SimCallHistoryService ) {} @Get() @@ -88,25 +91,47 @@ export class SubscriptionsController { return this.subscriptionsService.getSubscriptionStats(req.user.id); } - @Get(":id") - @Header("Cache-Control", "private, max-age=300") // 5 minutes, user-specific - async getSubscriptionById( - @Request() req: RequestWithUser, - @Param("id", ParseIntPipe) subscriptionId: number - ): Promise { - return this.subscriptionsService.getSubscriptionById(req.user.id, subscriptionId); - } - @Get(":id/invoices") - @Header("Cache-Control", "private, max-age=60") // 1 minute, may update with payments - async getSubscriptionInvoices( - @Request() req: RequestWithUser, - @Param("id", ParseIntPipe) subscriptionId: number, - @Query(new ZodValidationPipe(subscriptionInvoiceQuerySchema)) query: SubscriptionInvoiceQuery - ): Promise { - return this.subscriptionsService.getSubscriptionInvoices(req.user.id, subscriptionId, query); + // ==================== Static SIM Routes (must be before :id routes) ==================== + + /** + * Get available months for call/SMS history + */ + @Public() + @Get("sim/call-history/available-months") + @Header("Cache-Control", "public, max-age=3600") + async getAvailableMonths() { + const months = await this.simCallHistoryService.getAvailableMonths(); + return { success: true, data: months }; } - // ==================== SIM Management Endpoints ==================== + /** + * List available files on SFTP for debugging + */ + @Public() + @Get("sim/call-history/sftp-files") + async listSftpFiles(@Query("path") path: string = "/home/PASI") { + const files = await this.simCallHistoryService.listSftpFiles(path); + return { success: true, data: files, path }; + } + + /** + * Trigger manual import of call history (admin only) + * TODO: Add proper admin authentication before production + */ + @Public() + @Post("sim/call-history/import") + async importCallHistory(@Query("month") yearMonth: string) { + if (!yearMonth || !/^\d{6}$/.test(yearMonth)) { + throw new BadRequestException("Invalid month format (expected YYYYMM)"); + } + + const result = await this.simCallHistoryService.importCallHistory(yearMonth); + return { + success: true, + message: `Imported ${result.domestic} domestic calls, ${result.international} international calls, ${result.sms} SMS`, + data: result, + }; + } @Get("sim/top-up/pricing") @Header("Cache-Control", "public, max-age=3600") // 1 hour, pricing is relatively static @@ -132,6 +157,29 @@ export class SubscriptionsController { return await this.simManagementService.getSimDetailsDebug(account); } + // ==================== Dynamic :id Routes ==================== + + @Get(":id") + @Header("Cache-Control", "private, max-age=300") // 5 minutes, user-specific + async getSubscriptionById( + @Request() req: RequestWithUser, + @Param("id", ParseIntPipe) subscriptionId: number + ): Promise { + return this.subscriptionsService.getSubscriptionById(req.user.id, subscriptionId); + } + + @Get(":id/invoices") + @Header("Cache-Control", "private, max-age=60") // 1 minute, may update with payments + async getSubscriptionInvoices( + @Request() req: RequestWithUser, + @Param("id", ParseIntPipe) subscriptionId: number, + @Query(new ZodValidationPipe(subscriptionInvoiceQuerySchema)) query: SubscriptionInvoiceQuery + ): Promise { + return this.subscriptionsService.getSubscriptionInvoices(req.user.id, subscriptionId, query); + } + + // ==================== SIM Management Endpoints (subscription-specific) ==================== + @Get(":id/sim/debug") async debugSimSubscription( @Request() req: RequestWithUser, @@ -322,4 +370,102 @@ export class SubscriptionsController { return { success: true, message: "Physical SIM reissue request submitted. You will be contacted shortly." }; } } + + // ==================== Call/SMS History Endpoints ==================== + + /** + * Get domestic call history + */ + @Get(":id/sim/call-history/domestic") + @Header("Cache-Control", "private, max-age=300") + async getDomesticCallHistory( + @Request() req: RequestWithUser, + @Param("id", ParseIntPipe) subscriptionId: number, + @Query("month") month?: string, + @Query("page") page?: string, + @Query("limit") limit?: string + ) { + const pageNum = parseInt(page || "1", 10); + const limitNum = parseInt(limit || "50", 10); + + if (isNaN(pageNum) || pageNum < 1) { + throw new BadRequestException("Invalid page number"); + } + if (isNaN(limitNum) || limitNum < 1 || limitNum > 100) { + throw new BadRequestException("Invalid limit (must be 1-100)"); + } + + const result = await this.simCallHistoryService.getDomesticCallHistory( + req.user.id, + subscriptionId, + month, + pageNum, + limitNum + ); + return { success: true, data: result }; + } + + /** + * Get international call history + */ + @Get(":id/sim/call-history/international") + @Header("Cache-Control", "private, max-age=300") + async getInternationalCallHistory( + @Request() req: RequestWithUser, + @Param("id", ParseIntPipe) subscriptionId: number, + @Query("month") month?: string, + @Query("page") page?: string, + @Query("limit") limit?: string + ) { + const pageNum = parseInt(page || "1", 10); + const limitNum = parseInt(limit || "50", 10); + + if (isNaN(pageNum) || pageNum < 1) { + throw new BadRequestException("Invalid page number"); + } + if (isNaN(limitNum) || limitNum < 1 || limitNum > 100) { + throw new BadRequestException("Invalid limit (must be 1-100)"); + } + + const result = await this.simCallHistoryService.getInternationalCallHistory( + req.user.id, + subscriptionId, + month, + pageNum, + limitNum + ); + return { success: true, data: result }; + } + + /** + * Get SMS history + */ + @Get(":id/sim/sms-history") + @Header("Cache-Control", "private, max-age=300") + async getSmsHistory( + @Request() req: RequestWithUser, + @Param("id", ParseIntPipe) subscriptionId: number, + @Query("month") month?: string, + @Query("page") page?: string, + @Query("limit") limit?: string + ) { + const pageNum = parseInt(page || "1", 10); + const limitNum = parseInt(limit || "50", 10); + + if (isNaN(pageNum) || pageNum < 1) { + throw new BadRequestException("Invalid page number"); + } + if (isNaN(limitNum) || limitNum < 1 || limitNum > 100) { + throw new BadRequestException("Invalid limit (must be 1-100)"); + } + + const result = await this.simCallHistoryService.getSmsHistory( + req.user.id, + subscriptionId, + month, + pageNum, + limitNum + ); + return { success: true, data: result }; + } } diff --git a/apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/call-history/page.tsx b/apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/call-history/page.tsx new file mode 100644 index 00000000..efa22ee9 --- /dev/null +++ b/apps/portal/src/app/(authenticated)/subscriptions/[id]/sim/call-history/page.tsx @@ -0,0 +1,6 @@ +import SimCallHistoryContainer from "@/features/subscriptions/views/SimCallHistory"; + +export default function SimCallHistoryPage() { + return ; +} + diff --git a/apps/portal/src/features/sim-management/components/SimManagementSection.tsx b/apps/portal/src/features/sim-management/components/SimManagementSection.tsx index 15362b1c..29eafeac 100644 --- a/apps/portal/src/features/sim-management/components/SimManagementSection.tsx +++ b/apps/portal/src/features/sim-management/components/SimManagementSection.tsx @@ -6,7 +6,7 @@ import { DevicePhoneMobileIcon, ExclamationTriangleIcon, ArrowPathIcon, - SignalIcon, + PhoneIcon, ArrowsRightLeftIcon, ArrowPathRoundedSquareIcon, XCircleIcon, @@ -60,6 +60,7 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro 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`); + const navigateToCallHistory = () => router.push(`/subscriptions/${subscriptionId}/sim/call-history`); // Fetch subscription data const { data: subscription } = useSubscription(subscriptionId); @@ -289,11 +290,11 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro

SIM Management Actions

+ + Page {page} of {totalPages} + + +
+ ); +} + +export function SimCallHistoryContainer() { + const params = useParams(); + const subscriptionId = params.id as string; + + const [activeTab, setActiveTab] = useState("domestic"); + // Use September 2025 as the current month (latest available - 2 months behind) + const currentMonth = "2025-09"; + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Data states + const [domesticData, setDomesticData] = useState(null); + const [internationalData, setInternationalData] = useState(null); + const [smsData, setSmsData] = useState(null); + + // Pagination states + const [domesticPage, setDomesticPage] = useState(1); + const [internationalPage, setInternationalPage] = useState(1); + const [smsPage, setSmsPage] = useState(1); + + // Fetch data when tab changes + useEffect(() => { + + const fetchData = async () => { + setLoading(true); + setError(null); + + try { + if (activeTab === "domestic") { + const data = await simActionsService.getDomesticCallHistory( + subscriptionId, + currentMonth, + domesticPage, + 50 + ); + setDomesticData(data); + } else if (activeTab === "international") { + const data = await simActionsService.getInternationalCallHistory( + subscriptionId, + currentMonth, + internationalPage, + 50 + ); + setInternationalData(data); + } else if (activeTab === "sms") { + const data = await simActionsService.getSmsHistory( + subscriptionId, + currentMonth, + smsPage, + 50 + ); + setSmsData(data); + } + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Failed to load history"); + } finally { + setLoading(false); + } + }; + + void fetchData(); + }, [subscriptionId, activeTab, domesticPage, internationalPage, smsPage]); + + // Reset page when tab changes + useEffect(() => { + if (activeTab === "domestic") setDomesticPage(1); + if (activeTab === "international") setInternationalPage(1); + if (activeTab === "sms") setSmsPage(1); + }, [activeTab]); + + return ( + } + title="Call & SMS History" + description="View your call and SMS records" + > +
+
+ + ← Back to SIM Management + +
+ + + {/* Current Month Display */} +
+
+ + Showing data for: September 2025 + +
+

+ Call/SMS history is available approximately 2 months after the calls are made. +

+
+ + {/* Tabs */} +
+ +
+ + {/* Error */} + {error && ( +
+ + {error} + +
+ )} + + {/* Loading */} + {loading && ( +
+ {[1, 2, 3, 4, 5].map(i => ( +
+ ))} +
+ )} + + {/* Domestic Calls Table */} + {!loading && activeTab === "domestic" && domesticData && ( + <> + {domesticData.calls.length === 0 ? ( +
+ No domestic calls found for this month +
+ ) : ( +
+ + + + + + + + + + + + {domesticData.calls.map(call => ( + + + + + + + + ))} + +
+ Date + + Time + + Called To + + Call Length + + Call Charge (¥) +
+ {call.date} + + {call.time} + + {call.calledTo} + + {call.callLength} + + {formatCurrency(call.callCharge)} +
+
+ )} + + + )} + + {/* International Calls Table */} + {!loading && activeTab === "international" && internationalData && ( + <> + {internationalData.calls.length === 0 ? ( +
+ No international calls found for this month +
+ ) : ( +
+ + + + + + + + + + + + + {internationalData.calls.map(call => ( + + + + + + + + + ))} + +
+ Date + + Start Time + + Stop Time + + Country + + Called To + + Call Charge (¥) +
+ {call.date} + + {call.startTime} + + {call.stopTime || "—"} + + {call.country || "—"} + + {call.calledTo} + + {formatCurrency(call.callCharge)} +
+
+ )} + + + )} + + {/* SMS Table */} + {!loading && activeTab === "sms" && smsData && ( + <> + {smsData.messages.length === 0 ? ( +
+ No SMS found for this month +
+ ) : ( +
+ + + + + + + + + + + {smsData.messages.map(msg => ( + + + + + + + ))} + +
+ Date + + Time + + Sent To + + Type +
+ {msg.date} + + {msg.time} + + {msg.sentTo} + + + {msg.type} + +
+
+ )} + + + )} + + {/* Info Note */} +
+

Important Notes

+
    +
  • • Call/SMS history is updated approximately 2 months after the calls/messages are made
  • +
  • • The history shows approximately 3 months of records
  • +
  • • Call charges shown are based on the carrier billing data
  • +
+
+
+
+
+ ); +} + +export default SimCallHistoryContainer; +