Assist_Design/apps/bff/src/modules/subscriptions/subscriptions.controller.ts

532 lines
18 KiB
TypeScript
Raw Normal View History

2025-08-22 17:02:49 +09:00
import {
Controller,
Get,
Post,
2025-08-22 17:02:49 +09:00
Param,
Query,
Body,
Request,
ParseIntPipe,
BadRequestException,
UsePipes,
Header,
UseGuards,
2025-08-22 17:02:49 +09:00
} from "@nestjs/common";
import { Public } from "@bff/modules/auth/decorators/public.decorator.js";
import { SubscriptionsService } from "./subscriptions.service.js";
import { SimManagementService } from "./sim-management.service.js";
import { SimTopUpPricingService } from "./sim-management/services/sim-topup-pricing.service.js";
2025-08-28 16:57:57 +09:00
import { subscriptionQuerySchema } from "@customer-portal/domain/subscriptions";
import type {
Subscription,
SubscriptionList,
SubscriptionStats,
SimActionResponse,
SimPlanChangeResult,
SubscriptionQuery,
} from "@customer-portal/domain/subscriptions";
import type { InvoiceList } from "@customer-portal/domain/billing";
import type { ApiSuccessResponse } from "@customer-portal/domain/common";
import { createPaginationSchema } from "@customer-portal/domain/toolkit/validation/helpers";
import type { z } from "zod";
import {
simTopupRequestSchema,
simChangePlanRequestSchema,
simCancelRequestSchema,
simFeaturesRequestSchema,
simCancelFullRequestSchema,
simChangePlanFullRequestSchema,
simReissueFullRequestSchema,
type SimTopupRequest,
type SimChangePlanRequest,
type SimCancelRequest,
type SimFeaturesRequest,
type SimCancelFullRequest,
type SimChangePlanFullRequest,
type SimAvailablePlan,
type SimCancellationPreview,
type SimDomesticCallHistoryResponse,
type SimInternationalCallHistoryResponse,
type SimSmsHistoryResponse,
type SimReissueFullRequest,
} from "@customer-portal/domain/sim";
import { ZodValidationPipe } from "nestjs-zod";
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
import { SimPlanService } from "./sim-management/services/sim-plan.service.js";
import { SimCancellationService } from "./sim-management/services/sim-cancellation.service.js";
import { AdminGuard } from "@bff/core/security/guards/admin.guard.js";
import { EsimManagementService } from "./sim-management/services/esim-management.service.js";
import { SimCallHistoryService } from "./sim-management/services/sim-call-history.service.js";
import { InternetCancellationService } from "./internet-management/services/internet-cancellation.service.js";
import {
internetCancelRequestSchema,
type InternetCancelRequest,
type SimActionResponse as SubscriptionActionResponse,
} from "@customer-portal/domain/subscriptions";
const subscriptionInvoiceQuerySchema = createPaginationSchema({
defaultLimit: 10,
maxLimit: 100,
minLimit: 1,
});
type SubscriptionInvoiceQuery = z.infer<typeof subscriptionInvoiceQuerySchema>;
2025-08-22 17:02:49 +09:00
@Controller("subscriptions")
export class SubscriptionsController {
constructor(
private readonly subscriptionsService: SubscriptionsService,
private readonly simManagementService: SimManagementService,
private readonly simTopUpPricingService: SimTopUpPricingService,
private readonly simPlanService: SimPlanService,
private readonly simCancellationService: SimCancellationService,
private readonly esimManagementService: EsimManagementService,
private readonly simCallHistoryService: SimCallHistoryService,
private readonly internetCancellationService: InternetCancellationService
) {}
@Get()
@Header("Cache-Control", "private, max-age=300") // 5 minutes, user-specific
@UsePipes(new ZodValidationPipe(subscriptionQuerySchema))
async getSubscriptions(
@Request() req: RequestWithUser,
@Query() query: SubscriptionQuery
): Promise<SubscriptionList> {
const { status } = query;
return this.subscriptionsService.getSubscriptions(req.user.id, { status });
}
2025-08-22 17:02:49 +09:00
@Get("active")
@Header("Cache-Control", "private, max-age=300") // 5 minutes, user-specific
async getActiveSubscriptions(@Request() req: RequestWithUser): Promise<Subscription[]> {
2025-08-27 10:54:05 +09:00
return this.subscriptionsService.getActiveSubscriptions(req.user.id);
}
2025-08-22 17:02:49 +09:00
@Get("stats")
@Header("Cache-Control", "private, max-age=300") // 5 minutes, user-specific
async getSubscriptionStats(@Request() req: RequestWithUser): Promise<SubscriptionStats> {
2025-08-27 10:54:05 +09:00
return this.subscriptionsService.getSubscriptionStats(req.user.id);
}
// ==================== 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 };
}
/**
* List available files on SFTP for debugging
*/
@UseGuards(AdminGuard)
@Get("sim/call-history/sftp-files")
async listSftpFiles(@Query("path") path: string = "/home/PASI") {
if (!path.startsWith("/home/PASI")) {
throw new BadRequestException("Invalid path");
}
const files = await this.simCallHistoryService.listSftpFiles(path);
return { success: true, data: files, path };
}
/**
* Trigger manual import of call history (admin only)
*/
@UseGuards(AdminGuard)
@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
async getSimTopUpPricing() {
const pricing = await this.simTopUpPricingService.getTopUpPricing();
return { success: true, data: pricing };
}
@Get("sim/top-up/pricing/preview")
@Header("Cache-Control", "public, max-age=3600") // 1 hour, pricing calculation is deterministic
async previewSimTopUpPricing(@Query("quotaMb") quotaMb: string) {
const quotaMbNum = parseInt(quotaMb, 10);
if (isNaN(quotaMbNum) || quotaMbNum <= 0) {
throw new BadRequestException("Invalid quotaMb parameter");
}
const preview = await this.simTopUpPricingService.calculatePricingPreview(quotaMbNum);
return { success: true, data: preview };
}
@Get("debug/sim-details/:account")
@UseGuards(AdminGuard)
async debugSimDetails(@Param("account") account: string) {
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<Subscription> {
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<InvoiceList> {
return this.subscriptionsService.getSubscriptionInvoices(req.user.id, subscriptionId, query);
}
// ==================== SIM Management Endpoints (subscription-specific) ====================
@Get(":id/sim/debug")
@UseGuards(AdminGuard)
async debugSimSubscription(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number
): Promise<Record<string, unknown>> {
return this.simManagementService.debugSimSubscription(req.user.id, subscriptionId);
}
@Get(":id/sim")
async getSimInfo(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number
) {
return this.simManagementService.getSimInfo(req.user.id, subscriptionId);
}
@Get(":id/sim/details")
async getSimDetails(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number
) {
return this.simManagementService.getSimDetails(req.user.id, subscriptionId);
}
@Get(":id/sim/usage")
async getSimUsage(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number
) {
return this.simManagementService.getSimUsage(req.user.id, subscriptionId);
}
@Get(":id/sim/top-up-history")
async getSimTopUpHistory(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number,
@Query("fromDate") fromDate: string,
@Query("toDate") toDate: string
) {
if (!fromDate || !toDate) {
throw new BadRequestException("fromDate and toDate are required");
}
return this.simManagementService.getSimTopUpHistory(req.user.id, subscriptionId, {
fromDate,
toDate,
});
}
@Post(":id/sim/top-up")
@UsePipes(new ZodValidationPipe(simTopupRequestSchema))
async topUpSim(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number,
@Body() body: SimTopupRequest
): Promise<SimActionResponse> {
await this.simManagementService.topUpSim(req.user.id, subscriptionId, body);
return { success: true, message: "SIM top-up completed successfully" };
}
@Post(":id/sim/change-plan")
@UsePipes(new ZodValidationPipe(simChangePlanRequestSchema))
async changeSimPlan(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number,
@Body() body: SimChangePlanRequest
): Promise<SimPlanChangeResult> {
const result = await this.simManagementService.changeSimPlan(req.user.id, subscriptionId, body);
return {
success: true,
message: "SIM plan change completed successfully",
...result,
};
}
@Post(":id/sim/cancel")
@UsePipes(new ZodValidationPipe(simCancelRequestSchema))
async cancelSim(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number,
@Body() body: SimCancelRequest
): Promise<SimActionResponse> {
await this.simManagementService.cancelSim(req.user.id, subscriptionId, body);
return { success: true, message: "SIM cancellation completed successfully" };
}
@Post(":id/sim/reissue-esim")
async reissueEsimProfile(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number,
@Body() body: { newEid?: string } = {}
): Promise<SimActionResponse> {
await this.simManagementService.reissueEsimProfile(req.user.id, subscriptionId, body.newEid);
return { success: true, message: "eSIM profile reissue completed successfully" };
}
@Post(":id/sim/features")
@UsePipes(new ZodValidationPipe(simFeaturesRequestSchema))
async updateSimFeatures(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number,
@Body() body: SimFeaturesRequest
): Promise<SimActionResponse> {
await this.simManagementService.updateSimFeatures(req.user.id, subscriptionId, body);
return { success: true, message: "SIM features updated successfully" };
}
// ==================== Enhanced SIM Management Endpoints ====================
/**
* Get available plans for plan change (filtered by current plan type)
*/
@Get(":id/sim/available-plans")
@Header("Cache-Control", "private, max-age=300")
async getAvailablePlans(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number
): Promise<ApiSuccessResponse<SimAvailablePlan[]>> {
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
): Promise<ApiSuccessResponse<SimCancellationPreview>> {
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")
@UsePipes(new ZodValidationPipe(simReissueFullRequestSchema))
async reissueSim(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number,
@Body() body: SimReissueFullRequest
): 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.",
};
}
}
// ==================== Internet Management Endpoints ====================
/**
* Get Internet cancellation preview (available months, service details)
*/
@Get(":id/internet/cancellation-preview")
@Header("Cache-Control", "private, max-age=60")
async getInternetCancellationPreview(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number
) {
const preview = await this.internetCancellationService.getCancellationPreview(
req.user.id,
subscriptionId
);
return { success: true, data: preview };
}
/**
* Submit Internet cancellation request
*/
@Post(":id/internet/cancel")
@UsePipes(new ZodValidationPipe(internetCancelRequestSchema))
async cancelInternet(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number,
@Body() body: InternetCancelRequest
): Promise<SubscriptionActionResponse> {
await this.internetCancellationService.submitCancellation(req.user.id, subscriptionId, body);
return {
success: true,
message: `Internet cancellation scheduled for end of ${body.cancellationMonth}`,
};
}
// ==================== 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
): Promise<ApiSuccessResponse<SimDomesticCallHistoryResponse>> {
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
): Promise<ApiSuccessResponse<SimInternationalCallHistoryResponse>> {
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
): Promise<ApiSuccessResponse<SimSmsHistoryResponse>> {
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 };
}
}