feat: add services for SIM billing, details, top-up pricing, and usage management
This commit is contained in:
parent
be164cf287
commit
6a7ea6e057
@ -65,6 +65,7 @@ import { WorkflowModule } from "@bff/modules/shared/workflow/index.js";
|
|||||||
FailedLoginThrottleGuard,
|
FailedLoginThrottleGuard,
|
||||||
AuthRateLimitService,
|
AuthRateLimitService,
|
||||||
LoginResultInterceptor,
|
LoginResultInterceptor,
|
||||||
|
PermissionsGuard,
|
||||||
{
|
{
|
||||||
provide: APP_GUARD,
|
provide: APP_GUARD,
|
||||||
useClass: GlobalAuthGuard,
|
useClass: GlobalAuthGuard,
|
||||||
|
|||||||
@ -98,19 +98,12 @@ export function setAuthCookies(res: Response, tokens: AuthTokens): void {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear authentication cookies from the response.
|
* Clear authentication cookies from the response.
|
||||||
* Also clears legacy cookies that may have been set on "/" path.
|
|
||||||
*/
|
*/
|
||||||
export function clearAuthCookies(res: Response): void {
|
export function clearAuthCookies(res: Response): void {
|
||||||
const setSecureCookie = getSecureCookie(res);
|
const setSecureCookie = getSecureCookie(res);
|
||||||
if (setSecureCookie) {
|
if (setSecureCookie) {
|
||||||
// Clear current cookie paths
|
|
||||||
setSecureCookie("access_token", "", { maxAge: 0, path: ACCESS_COOKIE_PATH });
|
setSecureCookie("access_token", "", { maxAge: 0, path: ACCESS_COOKIE_PATH });
|
||||||
setSecureCookie("refresh_token", "", { maxAge: 0, path: REFRESH_COOKIE_PATH });
|
setSecureCookie("refresh_token", "", { maxAge: 0, path: REFRESH_COOKIE_PATH });
|
||||||
|
|
||||||
// DEPRECATED: Clear legacy cookies that were set on `/`
|
|
||||||
// TODO: Remove after 90 days (2025-04-XX) when legacy cookies have expired
|
|
||||||
setSecureCookie("access_token", "", { maxAge: 0, path: "/" });
|
|
||||||
setSecureCookie("refresh_token", "", { maxAge: 0, path: "/" });
|
|
||||||
} else {
|
} else {
|
||||||
// Fallback to standard cookie clearing
|
// Fallback to standard cookie clearing
|
||||||
const isProduction = process.env["NODE_ENV"] === "production";
|
const isProduction = process.env["NODE_ENV"] === "production";
|
||||||
@ -123,10 +116,6 @@ export function clearAuthCookies(res: Response): void {
|
|||||||
|
|
||||||
res.cookie("access_token", "", { ...clearOptions, path: ACCESS_COOKIE_PATH });
|
res.cookie("access_token", "", { ...clearOptions, path: ACCESS_COOKIE_PATH });
|
||||||
res.cookie("refresh_token", "", { ...clearOptions, path: REFRESH_COOKIE_PATH });
|
res.cookie("refresh_token", "", { ...clearOptions, path: REFRESH_COOKIE_PATH });
|
||||||
|
|
||||||
// DEPRECATED: Clear legacy cookies
|
|
||||||
res.cookie("access_token", "", { ...clearOptions, path: "/" });
|
|
||||||
res.cookie("refresh_token", "", { ...clearOptions, path: "/" });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,16 @@ import { VerificationModule } from "@bff/modules/verification/verification.modul
|
|||||||
import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js";
|
import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js";
|
||||||
import { WorkflowModule } from "@bff/modules/shared/workflow/index.js";
|
import { WorkflowModule } from "@bff/modules/shared/workflow/index.js";
|
||||||
|
|
||||||
|
// Validators (modular, composable)
|
||||||
|
import {
|
||||||
|
UserMappingValidator,
|
||||||
|
PaymentValidator,
|
||||||
|
SkuValidator,
|
||||||
|
SimOrderValidator,
|
||||||
|
InternetOrderValidator,
|
||||||
|
OrderCompositeValidator,
|
||||||
|
} from "./validators/index.js";
|
||||||
|
|
||||||
// Clean modular order services
|
// Clean modular order services
|
||||||
import { OrderValidator } from "./services/order-validator.service.js";
|
import { OrderValidator } from "./services/order-validator.service.js";
|
||||||
import { OrderBuilder } from "./services/order-builder.service.js";
|
import { OrderBuilder } from "./services/order-builder.service.js";
|
||||||
@ -48,7 +58,15 @@ import { SalesforceOrderFieldConfigModule } from "@bff/integrations/salesforce/c
|
|||||||
],
|
],
|
||||||
controllers: [OrdersController, CheckoutController],
|
controllers: [OrdersController, CheckoutController],
|
||||||
providers: [
|
providers: [
|
||||||
// Shared services
|
// Validators (modular, composable)
|
||||||
|
UserMappingValidator,
|
||||||
|
PaymentValidator,
|
||||||
|
SkuValidator,
|
||||||
|
SimOrderValidator,
|
||||||
|
InternetOrderValidator,
|
||||||
|
OrderCompositeValidator,
|
||||||
|
|
||||||
|
// Shared services (legacy - PaymentValidatorService used by OrderFulfillmentValidator)
|
||||||
PaymentValidatorService,
|
PaymentValidatorService,
|
||||||
OrderEventsService,
|
OrderEventsService,
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,14 @@ export {
|
|||||||
|
|
||||||
// Individual validators
|
// Individual validators
|
||||||
export { UserMappingValidator, type UserMappingData } from "./user-mapping.validator.js";
|
export { UserMappingValidator, type UserMappingData } from "./user-mapping.validator.js";
|
||||||
|
export { PaymentValidator } from "./payment.validator.js";
|
||||||
export { SkuValidator } from "./sku.validator.js";
|
export { SkuValidator } from "./sku.validator.js";
|
||||||
export { SimOrderValidator } from "./sim-order.validator.js";
|
export { SimOrderValidator } from "./sim-order.validator.js";
|
||||||
export { InternetOrderValidator } from "./internet-order.validator.js";
|
export { InternetOrderValidator } from "./internet-order.validator.js";
|
||||||
|
|
||||||
|
// Composite validator
|
||||||
|
export {
|
||||||
|
OrderCompositeValidator,
|
||||||
|
type ValidatedOrderContext,
|
||||||
|
type OrderValidationOptions,
|
||||||
|
} from "./order-composite.validator.js";
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { Controller, Get, Post, Param, Query, Request, Header, UseGuards } from "@nestjs/common";
|
import { Controller, Get, Post, Param, Query, Request, Header, UseGuards } from "@nestjs/common";
|
||||||
import { Public } from "@bff/modules/auth/decorators/public.decorator.js";
|
import { Public } from "@bff/modules/auth/decorators/public.decorator.js";
|
||||||
import { SimCallHistoryService } from "../sim-management/services/sim-call-history.service.js";
|
import { SimCallHistoryService } from "../sim-management/services/support/sim-call-history.service.js";
|
||||||
import { AdminGuard } from "@bff/core/security/guards/admin.guard.js";
|
import { AdminGuard } from "@bff/core/security/guards/admin.guard.js";
|
||||||
import { createZodDto, ZodResponse } from "nestjs-zod";
|
import { createZodDto, ZodResponse } from "nestjs-zod";
|
||||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Logger, Module } from "@nestjs/common";
|
import { Logger, Module } from "@nestjs/common";
|
||||||
import { CancellationController } from "./cancellation.controller.js";
|
import { CancellationController } from "./cancellation.controller.js";
|
||||||
import { CancellationService } from "./cancellation.service.js";
|
import { CancellationService } from "./cancellation.service.js";
|
||||||
import { SubscriptionsService } from "../subscriptions.service.js";
|
import { SubscriptionsOrchestrator } from "../subscriptions-orchestrator.service.js";
|
||||||
import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js";
|
import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js";
|
||||||
import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module.js";
|
import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module.js";
|
||||||
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
|
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
|
||||||
@ -26,7 +26,7 @@ import { SharedSubscriptionsModule } from "../shared/index.js";
|
|||||||
SharedSubscriptionsModule,
|
SharedSubscriptionsModule,
|
||||||
],
|
],
|
||||||
controllers: [CancellationController],
|
controllers: [CancellationController],
|
||||||
providers: [CancellationService, SubscriptionsService, Logger],
|
providers: [CancellationService, SubscriptionsOrchestrator, Logger],
|
||||||
exports: [CancellationService],
|
exports: [CancellationService],
|
||||||
})
|
})
|
||||||
export class CancellationModule {}
|
export class CancellationModule {}
|
||||||
|
|||||||
@ -6,9 +6,9 @@ import type {
|
|||||||
} from "@customer-portal/domain/subscriptions";
|
} from "@customer-portal/domain/subscriptions";
|
||||||
import type { SimCancelFullRequest } from "@customer-portal/domain/sim";
|
import type { SimCancelFullRequest } from "@customer-portal/domain/sim";
|
||||||
import { OPPORTUNITY_STAGE } from "@customer-portal/domain/opportunity";
|
import { OPPORTUNITY_STAGE } from "@customer-portal/domain/opportunity";
|
||||||
import { SubscriptionsService } from "../subscriptions.service.js";
|
import { SubscriptionsOrchestrator } from "../subscriptions-orchestrator.service.js";
|
||||||
import { InternetCancellationService } from "../internet-management/services/internet-cancellation.service.js";
|
import { InternetCancellationService } from "../internet-management/services/internet-cancellation.service.js";
|
||||||
import { SimCancellationService } from "../sim-management/services/sim-cancellation.service.js";
|
import { SimCancellationService } from "../sim-management/services/mutations/sim-cancellation.service.js";
|
||||||
import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js";
|
import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js";
|
||||||
import { CANCELLATION_STEP3_NOTICES, CANCELLATION_TERMS } from "./cancellation-terms.config.js";
|
import { CANCELLATION_STEP3_NOTICES, CANCELLATION_TERMS } from "./cancellation-terms.config.js";
|
||||||
import { SubscriptionValidationCoordinator } from "../shared/index.js";
|
import { SubscriptionValidationCoordinator } from "../shared/index.js";
|
||||||
@ -31,7 +31,7 @@ export class CancellationService {
|
|||||||
private readonly logger = new Logger(CancellationService.name);
|
private readonly logger = new Logger(CancellationService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly subscriptionsService: SubscriptionsService,
|
private readonly subscriptionsOrchestrator: SubscriptionsOrchestrator,
|
||||||
private readonly opportunityService: SalesforceOpportunityService,
|
private readonly opportunityService: SalesforceOpportunityService,
|
||||||
private readonly internetCancellation: InternetCancellationService,
|
private readonly internetCancellation: InternetCancellationService,
|
||||||
private readonly simCancellation: SimCancellationService,
|
private readonly simCancellation: SimCancellationService,
|
||||||
@ -49,7 +49,7 @@ export class CancellationService {
|
|||||||
*/
|
*/
|
||||||
async getPreview(userId: string, subscriptionId: number): Promise<CancellationPreview> {
|
async getPreview(userId: string, subscriptionId: number): Promise<CancellationPreview> {
|
||||||
// 1) Read subscription from WHMCS (includes custom fields)
|
// 1) Read subscription from WHMCS (includes custom fields)
|
||||||
const subscription = await this.subscriptionsService.getSubscriptionById(
|
const subscription = await this.subscriptionsOrchestrator.getSubscriptionById(
|
||||||
userId,
|
userId,
|
||||||
subscriptionId
|
subscriptionId
|
||||||
);
|
);
|
||||||
@ -81,7 +81,7 @@ export class CancellationService {
|
|||||||
subscriptionId: number,
|
subscriptionId: number,
|
||||||
request: InternetCancelRequest
|
request: InternetCancelRequest
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const subscription = await this.subscriptionsService.getSubscriptionById(
|
const subscription = await this.subscriptionsOrchestrator.getSubscriptionById(
|
||||||
userId,
|
userId,
|
||||||
subscriptionId
|
subscriptionId
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,124 +0,0 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
|
||||||
import { SimOrchestrator } from "./sim-management/services/sim-orchestrator.service.js";
|
|
||||||
import type {
|
|
||||||
SimDetails,
|
|
||||||
SimUsage,
|
|
||||||
SimTopUpHistory,
|
|
||||||
SimTopUpRequest,
|
|
||||||
SimPlanChangeRequest,
|
|
||||||
SimCancelRequest,
|
|
||||||
SimTopUpHistoryRequest,
|
|
||||||
SimFeaturesUpdateRequest,
|
|
||||||
SimReissueRequest,
|
|
||||||
} from "@customer-portal/domain/sim";
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class SimManagementService {
|
|
||||||
constructor(private readonly simOrchestrator: SimOrchestrator) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Debug method to check subscription data for SIM services
|
|
||||||
*/
|
|
||||||
async debugSimSubscription(
|
|
||||||
userId: string,
|
|
||||||
subscriptionId: number
|
|
||||||
): Promise<Record<string, unknown>> {
|
|
||||||
return this.simOrchestrator.debugSimSubscription(userId, subscriptionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Debug method to query Freebit directly for any account's details
|
|
||||||
*/
|
|
||||||
async getSimDetailsDebug(account: string): Promise<SimDetails> {
|
|
||||||
return this.simOrchestrator.getSimDetailsDirectly(account);
|
|
||||||
}
|
|
||||||
|
|
||||||
// This method is now handled by SimValidationService internally
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get SIM details for a subscription
|
|
||||||
*/
|
|
||||||
async getSimDetails(userId: string, subscriptionId: number): Promise<SimDetails> {
|
|
||||||
return this.simOrchestrator.getSimDetails(userId, subscriptionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get SIM data usage for a subscription
|
|
||||||
*/
|
|
||||||
async getSimUsage(userId: string, subscriptionId: number): Promise<SimUsage> {
|
|
||||||
return this.simOrchestrator.getSimUsage(userId, subscriptionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Top up SIM data quota with payment processing
|
|
||||||
* Pricing: 1GB = 500 JPY
|
|
||||||
*/
|
|
||||||
async topUpSim(userId: string, subscriptionId: number, request: SimTopUpRequest): Promise<void> {
|
|
||||||
return this.simOrchestrator.topUpSim(userId, subscriptionId, request);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get SIM top-up history
|
|
||||||
*/
|
|
||||||
async getSimTopUpHistory(
|
|
||||||
userId: string,
|
|
||||||
subscriptionId: number,
|
|
||||||
request: SimTopUpHistoryRequest
|
|
||||||
): Promise<SimTopUpHistory> {
|
|
||||||
return this.simOrchestrator.getSimTopUpHistory(userId, subscriptionId, request);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Change SIM plan
|
|
||||||
*/
|
|
||||||
async changeSimPlan(
|
|
||||||
userId: string,
|
|
||||||
subscriptionId: number,
|
|
||||||
request: SimPlanChangeRequest
|
|
||||||
): Promise<{ ipv4?: string; ipv6?: string }> {
|
|
||||||
return this.simOrchestrator.changeSimPlan(userId, subscriptionId, request);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update SIM features (voicemail, call waiting, roaming, network type)
|
|
||||||
*/
|
|
||||||
async updateSimFeatures(
|
|
||||||
userId: string,
|
|
||||||
subscriptionId: number,
|
|
||||||
request: SimFeaturesUpdateRequest
|
|
||||||
): Promise<void> {
|
|
||||||
return this.simOrchestrator.updateSimFeatures(userId, subscriptionId, request);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancel SIM service
|
|
||||||
*/
|
|
||||||
async cancelSim(
|
|
||||||
userId: string,
|
|
||||||
subscriptionId: number,
|
|
||||||
request: SimCancelRequest = {}
|
|
||||||
): Promise<void> {
|
|
||||||
return this.simOrchestrator.cancelSim(userId, subscriptionId, request);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reissue eSIM profile
|
|
||||||
*/
|
|
||||||
async reissueEsimProfile(userId: string, subscriptionId: number, newEid?: string): Promise<void> {
|
|
||||||
const request: SimReissueRequest = newEid ? { newEid } : {};
|
|
||||||
return this.simOrchestrator.reissueEsimProfile(userId, subscriptionId, request);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get comprehensive SIM information (details + usage combined)
|
|
||||||
*/
|
|
||||||
async getSimInfo(
|
|
||||||
userId: string,
|
|
||||||
subscriptionId: number
|
|
||||||
): Promise<{
|
|
||||||
details: SimDetails;
|
|
||||||
usage: SimUsage;
|
|
||||||
}> {
|
|
||||||
return this.simOrchestrator.getSimInfo(userId, subscriptionId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,13 +1,16 @@
|
|||||||
// Services
|
// Core services
|
||||||
export { SimOrchestrator } from "./services/sim-orchestrator.service.js";
|
export { SimOrchestrator } from "./services/sim-orchestrator.service.js";
|
||||||
export { SimDetailsService } from "./services/sim-details.service.js";
|
|
||||||
export { SimUsageService } from "./services/sim-usage.service.js";
|
|
||||||
export { SimTopUpService } from "./services/sim-topup.service.js";
|
|
||||||
export { SimPlanService } from "./services/sim-plan.service.js";
|
|
||||||
export { SimCancellationService } from "./services/sim-cancellation.service.js";
|
|
||||||
export { EsimManagementService } from "./services/esim-management.service.js";
|
|
||||||
export { SimValidationService } from "./services/sim-validation.service.js";
|
export { SimValidationService } from "./services/sim-validation.service.js";
|
||||||
export { SimNotificationService } from "./services/sim-notification.service.js";
|
// Query services
|
||||||
|
export { SimDetailsService } from "./services/queries/sim-details.service.js";
|
||||||
|
export { SimUsageService } from "./services/queries/sim-usage.service.js";
|
||||||
|
// Mutation services
|
||||||
|
export { SimTopUpService } from "./services/mutations/sim-topup.service.js";
|
||||||
|
export { SimPlanService } from "./services/mutations/sim-plan.service.js";
|
||||||
|
export { SimCancellationService } from "./services/mutations/sim-cancellation.service.js";
|
||||||
|
export { EsimManagementService } from "./services/mutations/esim-management.service.js";
|
||||||
|
// Support services
|
||||||
|
export { SimNotificationService } from "./services/support/sim-notification.service.js";
|
||||||
|
|
||||||
// Types (re-export from domain for module convenience)
|
// Types (re-export from domain for module convenience)
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@ -1,202 +0,0 @@
|
|||||||
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
|
||||||
import { Logger } from "nestjs-pino";
|
|
||||||
import { ConfigService } from "@nestjs/config";
|
|
||||||
import { FreebitFacade } from "@bff/integrations/freebit/facades/freebit.facade.js";
|
|
||||||
import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js";
|
|
||||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
|
||||||
import { SimValidationService } from "./sim-validation.service.js";
|
|
||||||
import { SimNotificationService } from "./sim-notification.service.js";
|
|
||||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
|
||||||
import type { SimReissueRequest, SimReissueFullRequest } from "@customer-portal/domain/sim";
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class EsimManagementService {
|
|
||||||
constructor(
|
|
||||||
private readonly freebitService: FreebitFacade,
|
|
||||||
private readonly whmcsClientService: WhmcsClientService,
|
|
||||||
private readonly mappingsService: MappingsService,
|
|
||||||
private readonly simValidation: SimValidationService,
|
|
||||||
private readonly simNotification: SimNotificationService,
|
|
||||||
private readonly configService: ConfigService,
|
|
||||||
@Inject(Logger) private readonly logger: Logger
|
|
||||||
) {}
|
|
||||||
|
|
||||||
private get freebitBaseUrl(): string {
|
|
||||||
return this.configService.get<string>("FREEBIT_BASE_URL") || "https://i1.mvno.net/emptool/api";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reissue eSIM profile (legacy method)
|
|
||||||
*/
|
|
||||||
async reissueEsimProfile(
|
|
||||||
userId: string,
|
|
||||||
subscriptionId: number,
|
|
||||||
request: SimReissueRequest
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
|
||||||
|
|
||||||
// First check if this is actually an eSIM
|
|
||||||
const simDetails = await this.freebitService.getSimDetails(account);
|
|
||||||
if (simDetails.simType !== "esim") {
|
|
||||||
throw new BadRequestException("This operation is only available for eSIM subscriptions");
|
|
||||||
}
|
|
||||||
|
|
||||||
const newEid = request.newEid;
|
|
||||||
|
|
||||||
if (newEid) {
|
|
||||||
await this.freebitService.reissueEsimProfileEnhanced(account, newEid, {
|
|
||||||
oldEid: simDetails.eid,
|
|
||||||
planCode: simDetails.planCode,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await this.freebitService.reissueEsimProfile(account);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`Successfully reissued eSIM profile for subscription ${subscriptionId}`, {
|
|
||||||
userId,
|
|
||||||
subscriptionId,
|
|
||||||
account,
|
|
||||||
oldEid: simDetails.eid,
|
|
||||||
newEid: newEid || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.simNotification.notifySimAction("Reissue eSIM", "SUCCESS", {
|
|
||||||
userId,
|
|
||||||
subscriptionId,
|
|
||||||
account,
|
|
||||||
oldEid: simDetails.eid,
|
|
||||||
newEid: newEid || undefined,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
const sanitizedError = extractErrorMessage(error);
|
|
||||||
this.logger.error(`Failed to reissue eSIM profile for subscription ${subscriptionId}`, {
|
|
||||||
error: sanitizedError,
|
|
||||||
userId,
|
|
||||||
subscriptionId,
|
|
||||||
newEid: request.newEid || undefined,
|
|
||||||
});
|
|
||||||
await this.simNotification.notifySimAction("Reissue eSIM", "ERROR", {
|
|
||||||
userId,
|
|
||||||
subscriptionId,
|
|
||||||
newEid: request.newEid || undefined,
|
|
||||||
error: sanitizedError,
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reissue SIM with full flow (eSIM via PA05-41, Physical SIM via email)
|
|
||||||
*/
|
|
||||||
async reissueSim(
|
|
||||||
userId: string,
|
|
||||||
subscriptionId: number,
|
|
||||||
request: SimReissueFullRequest
|
|
||||||
): Promise<void> {
|
|
||||||
const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
|
||||||
const simDetails = await this.freebitService.getSimDetails(account);
|
|
||||||
|
|
||||||
// Get customer info from WHMCS
|
|
||||||
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId);
|
|
||||||
const clientDetails = await this.whmcsClientService.getClientDetails(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.simNotification.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.simNotification.buildEsimReissueEmail(
|
|
||||||
customerName,
|
|
||||||
account,
|
|
||||||
request.newEid
|
|
||||||
);
|
|
||||||
await this.simNotification.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.simNotification.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.simNotification.buildPhysicalSimReissueEmail(
|
|
||||||
customerName,
|
|
||||||
account
|
|
||||||
);
|
|
||||||
await this.simNotification.sendCustomerEmail(
|
|
||||||
customerEmail,
|
|
||||||
"Physical SIM Re-issue Request",
|
|
||||||
customerEmailBody
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log(`Sent physical SIM reissue request emails`, {
|
|
||||||
userId,
|
|
||||||
subscriptionId,
|
|
||||||
account,
|
|
||||||
customerEmail,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { Injectable, Inject } from "@nestjs/common";
|
import { Injectable, Inject } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { FreebitFacade } from "@bff/integrations/freebit/facades/freebit.facade.js";
|
import { FreebitFacade } from "@bff/integrations/freebit/facades/freebit.facade.js";
|
||||||
import { SimValidationService } from "./sim-validation.service.js";
|
import { SimValidationService } from "../sim-validation.service.js";
|
||||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
import type { SimDetails } from "@customer-portal/domain/sim";
|
import type { SimDetails } from "@customer-portal/domain/sim";
|
||||||
|
|
||||||
@ -47,25 +47,25 @@ export class SimDetailsService {
|
|||||||
*/
|
*/
|
||||||
async getSimDetailsDirectly(account: string): Promise<SimDetails> {
|
async getSimDetailsDirectly(account: string): Promise<SimDetails> {
|
||||||
try {
|
try {
|
||||||
this.logger.log(`[DEBUG] Querying Freebit for account: ${account}`);
|
this.logger.debug({ account }, "Querying Freebit for SIM details");
|
||||||
|
|
||||||
const simDetails = await this.freebitService.getSimDetails(account);
|
const simDetails = await this.freebitService.getSimDetails(account);
|
||||||
|
|
||||||
this.logger.log(`[DEBUG] Retrieved SIM details from Freebit`, {
|
this.logger.debug(
|
||||||
account,
|
{
|
||||||
planCode: simDetails.planCode,
|
account,
|
||||||
planName: simDetails.planName,
|
planCode: simDetails.planCode,
|
||||||
status: simDetails.status,
|
planName: simDetails.planName,
|
||||||
simType: simDetails.simType,
|
status: simDetails.status,
|
||||||
});
|
simType: simDetails.simType,
|
||||||
|
},
|
||||||
|
"Retrieved SIM details from Freebit"
|
||||||
|
);
|
||||||
|
|
||||||
return simDetails;
|
return simDetails;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const sanitizedError = extractErrorMessage(error);
|
const sanitizedError = extractErrorMessage(error);
|
||||||
this.logger.error(`[DEBUG] Failed to get SIM details for account ${account}`, {
|
this.logger.error({ error: sanitizedError, account }, "Failed to get SIM details");
|
||||||
error: sanitizedError,
|
|
||||||
account,
|
|
||||||
});
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,15 +1,15 @@
|
|||||||
import { Injectable, Inject } from "@nestjs/common";
|
import { Injectable, Inject } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { FreebitFacade } from "@bff/integrations/freebit/facades/freebit.facade.js";
|
import { FreebitFacade } from "@bff/integrations/freebit/facades/freebit.facade.js";
|
||||||
import { SimValidationService } from "./sim-validation.service.js";
|
import { SimValidationService } from "../sim-validation.service.js";
|
||||||
import { SimUsageStoreService } from "../../sim-usage-store.service.js";
|
import { SimUsageStoreService } from "../../../sim-usage-store.service.js";
|
||||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
import type {
|
import type {
|
||||||
SimTopUpHistory,
|
SimTopUpHistory,
|
||||||
SimUsage,
|
SimUsage,
|
||||||
SimTopUpHistoryRequest,
|
SimTopUpHistoryRequest,
|
||||||
} from "@customer-portal/domain/sim";
|
} from "@customer-portal/domain/sim";
|
||||||
import { SimScheduleService } from "./sim-schedule.service.js";
|
import { SimScheduleService } from "../support/sim-schedule.service.js";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SimUsageService {
|
export class SimUsageService {
|
||||||
@ -1,84 +0,0 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Service for formatting call history data for display.
|
|
||||||
* Handles time, duration, and phone number formatting.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class SimCallHistoryFormatterService {
|
|
||||||
/**
|
|
||||||
* Convert HHMMSS to HH:MM:SS display format
|
|
||||||
*/
|
|
||||||
formatTime(timeStr: string): string {
|
|
||||||
if (!timeStr || timeStr.length < 6) return timeStr;
|
|
||||||
const clean = timeStr.replace(/[^0-9]/g, "").padStart(6, "0");
|
|
||||||
return `${clean.slice(0, 2)}:${clean.slice(2, 4)}:${clean.slice(4, 6)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert seconds to readable duration format (Xh Ym Zs)
|
|
||||||
*/
|
|
||||||
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`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format Japanese phone numbers with appropriate separators
|
|
||||||
*/
|
|
||||||
formatPhoneNumber(phone: string): string {
|
|
||||||
if (!phone) return phone;
|
|
||||||
const clean = phone.replace(/[^0-9+]/g, "");
|
|
||||||
|
|
||||||
// 080-XXXX-XXXX or 070-XXXX-XXXX or 090-XXXX-XXXX format
|
|
||||||
if (
|
|
||||||
clean.length === 11 &&
|
|
||||||
(clean.startsWith("080") || clean.startsWith("070") || clean.startsWith("090"))
|
|
||||||
) {
|
|
||||||
return `${clean.slice(0, 3)}-${clean.slice(3, 7)}-${clean.slice(7)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 03-XXXX-XXXX format (landline)
|
|
||||||
if (clean.length === 10 && clean.startsWith("0")) {
|
|
||||||
return `${clean.slice(0, 2)}-${clean.slice(2, 6)}-${clean.slice(6)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return clean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get default month for call history queries (2 months ago)
|
|
||||||
*/
|
|
||||||
getDefaultMonth(): string {
|
|
||||||
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}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format a date to YYYY-MM format
|
|
||||||
*/
|
|
||||||
formatYearMonth(date: Date): string {
|
|
||||||
const year = date.getFullYear();
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
||||||
return `${year}-${month}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert YYYYMM to YYYY-MM format
|
|
||||||
*/
|
|
||||||
normalizeMonth(yearMonth: string): string {
|
|
||||||
return `${yearMonth.slice(0, 4)}-${yearMonth.slice(4, 6)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,296 +0,0 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
|
||||||
|
|
||||||
// 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 ParsedTalkDetail {
|
|
||||||
domestic: DomesticCallRecord[];
|
|
||||||
international: InternationalCallRecord[];
|
|
||||||
skipped: number;
|
|
||||||
errors: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ParsedSmsDetail {
|
|
||||||
records: SmsRecord[];
|
|
||||||
skipped: number;
|
|
||||||
errors: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Service for parsing call history CSV files from Freebit SFTP.
|
|
||||||
* Handles the specific CSV formats for talk detail and SMS detail files.
|
|
||||||
*
|
|
||||||
* Talk Detail CSV Columns:
|
|
||||||
* 1. Customer phone number
|
|
||||||
* 2. Date (YYYYMMDD)
|
|
||||||
* 3. Start time (HHMMSS)
|
|
||||||
* 4. Called to phone number
|
|
||||||
* 5. dome/tointl (call type)
|
|
||||||
* 6. Location
|
|
||||||
* 7. Duration (MMSST format - minutes, seconds, tenths)
|
|
||||||
* 8. Tokens (each token = 10 yen)
|
|
||||||
* 9. Alternative charge (if location is "他社")
|
|
||||||
*
|
|
||||||
* SMS Detail CSV 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)
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class SimCallHistoryParserService {
|
|
||||||
/**
|
|
||||||
* Parse talk detail CSV content into domestic and international call records
|
|
||||||
*/
|
|
||||||
parseTalkDetailCsv(content: string, month: string): ParsedTalkDetail {
|
|
||||||
const domestic: DomesticCallRecord[] = [];
|
|
||||||
const international: InternationalCallRecord[] = [];
|
|
||||||
let skipped = 0;
|
|
||||||
let errors = 0;
|
|
||||||
|
|
||||||
const lines = content.split(/\r?\n/).filter(line => line.trim());
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
try {
|
|
||||||
const columns = this.parseCsvLine(line);
|
|
||||||
|
|
||||||
if (columns.length < 8) {
|
|
||||||
skipped++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [
|
|
||||||
phoneNumber = "",
|
|
||||||
dateStr = "",
|
|
||||||
timeStr = "",
|
|
||||||
calledTo = "",
|
|
||||||
callType = "",
|
|
||||||
location = "",
|
|
||||||
durationStr = "",
|
|
||||||
tokensStr = "",
|
|
||||||
altChargeStr = "",
|
|
||||||
] = columns;
|
|
||||||
|
|
||||||
// Parse date
|
|
||||||
const callDate = this.parseDate(dateStr);
|
|
||||||
if (!callDate) {
|
|
||||||
skipped++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse duration - format is MMSST (minutes, seconds, tenths)
|
|
||||||
// e.g., 36270 = 36 min 27.0 sec, 320 = 0 min 32.0 sec
|
|
||||||
const durationSec = this.parseDuration(durationStr);
|
|
||||||
|
|
||||||
// Parse charge: use tokens * 10 yen, or alt charge if location is "他社"
|
|
||||||
const chargeYen = this.parseCharge(location, tokensStr, altChargeStr);
|
|
||||||
|
|
||||||
// Clean account number (remove dashes, spaces)
|
|
||||||
const account = this.cleanPhoneNumber(phoneNumber);
|
|
||||||
|
|
||||||
// Clean called-to number
|
|
||||||
const cleanCalledTo = this.cleanPhoneNumber(calledTo);
|
|
||||||
|
|
||||||
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,
|
|
||||||
country: location || null,
|
|
||||||
calledTo: cleanCalledTo,
|
|
||||||
durationSec,
|
|
||||||
chargeYen,
|
|
||||||
month,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
skipped++;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
errors++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { domestic, international, skipped, errors };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse SMS detail CSV content
|
|
||||||
*/
|
|
||||||
parseSmsDetailCsv(content: string, month: string): ParsedSmsDetail {
|
|
||||||
const records: SmsRecord[] = [];
|
|
||||||
let skipped = 0;
|
|
||||||
let errors = 0;
|
|
||||||
|
|
||||||
const lines = content.split(/\r?\n/).filter(line => line.trim());
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
try {
|
|
||||||
const columns = this.parseCsvLine(line);
|
|
||||||
|
|
||||||
if (columns.length < 6) {
|
|
||||||
skipped++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [phoneNumber = "", dateStr = "", timeStr = "", sentTo = "", , smsTypeStr = ""] =
|
|
||||||
columns;
|
|
||||||
|
|
||||||
// Parse date
|
|
||||||
const smsDate = this.parseDate(dateStr);
|
|
||||||
if (!smsDate) {
|
|
||||||
skipped++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean account number
|
|
||||||
const account = this.cleanPhoneNumber(phoneNumber);
|
|
||||||
|
|
||||||
// Clean sent-to number
|
|
||||||
const cleanSentTo = this.cleanPhoneNumber(sentTo);
|
|
||||||
|
|
||||||
// Determine SMS type
|
|
||||||
const smsType: SmsType = smsTypeStr.includes("国際") ? "INTERNATIONAL" : "DOMESTIC";
|
|
||||||
|
|
||||||
records.push({
|
|
||||||
account,
|
|
||||||
smsDate,
|
|
||||||
smsTime: timeStr,
|
|
||||||
sentTo: cleanSentTo,
|
|
||||||
smsType,
|
|
||||||
month,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
errors++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { records, skipped, errors };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a CSV line handling quoted fields and escaped quotes
|
|
||||||
*/
|
|
||||||
private parseCsvLine(line: string): string[] {
|
|
||||||
const normalizedLine = line.replace(/\r$/, "").replace(/^\uFEFF/, "");
|
|
||||||
const result: string[] = [];
|
|
||||||
let current = "";
|
|
||||||
let inQuotes = false;
|
|
||||||
|
|
||||||
for (let i = 0; i < normalizedLine.length; i++) {
|
|
||||||
const char = normalizedLine[i];
|
|
||||||
if (char === '"') {
|
|
||||||
if (inQuotes && normalizedLine[i + 1] === '"') {
|
|
||||||
current += '"';
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
inQuotes = !inQuotes;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (char === "," && !inQuotes) {
|
|
||||||
result.push(current.trim());
|
|
||||||
current = "";
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
current += char;
|
|
||||||
}
|
|
||||||
result.push(current.trim());
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse YYYYMMDD date string to Date object
|
|
||||||
*/
|
|
||||||
private parseDate(dateStr: string): Date | null {
|
|
||||||
if (!dateStr || dateStr.length < 8) return null;
|
|
||||||
|
|
||||||
const clean = dateStr.replace(/[^0-9]/g, "");
|
|
||||||
if (clean.length < 8) return null;
|
|
||||||
|
|
||||||
const year = Number.parseInt(clean.slice(0, 4), 10);
|
|
||||||
const month = Number.parseInt(clean.slice(4, 6), 10) - 1;
|
|
||||||
const day = Number.parseInt(clean.slice(6, 8), 10);
|
|
||||||
|
|
||||||
if (Number.isNaN(year) || Number.isNaN(month) || Number.isNaN(day)) return null;
|
|
||||||
|
|
||||||
return new Date(year, month, day);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse duration string (MMSST format) to seconds
|
|
||||||
*/
|
|
||||||
private parseDuration(durationStr: string): number {
|
|
||||||
const durationVal = durationStr.padStart(5, "0");
|
|
||||||
const minutes = Number.parseInt(durationVal.slice(0, -3), 10) || 0;
|
|
||||||
const seconds = Number.parseInt(durationVal.slice(-3, -1), 10) || 0;
|
|
||||||
return minutes * 60 + seconds;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse charge from tokens or alternative charge
|
|
||||||
*/
|
|
||||||
private parseCharge(
|
|
||||||
location: string | undefined,
|
|
||||||
tokensStr: string,
|
|
||||||
altChargeStr: string | undefined
|
|
||||||
): number {
|
|
||||||
if (location && location.includes("他社") && altChargeStr) {
|
|
||||||
return Number.parseInt(altChargeStr, 10) || 0;
|
|
||||||
}
|
|
||||||
return (Number.parseInt(tokensStr, 10) || 0) * 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean phone number by removing dashes and spaces
|
|
||||||
*/
|
|
||||||
private cleanPhoneNumber(phone: string): string {
|
|
||||||
return phone.replace(/[-\s]/g, "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,419 +0,0 @@
|
|||||||
import { Injectable, Inject } from "@nestjs/common";
|
|
||||||
import { Logger } from "nestjs-pino";
|
|
||||||
import { PrismaService } from "@bff/infra/database/prisma.service.js";
|
|
||||||
import { SftpClientService } from "@bff/integrations/sftp/sftp-client.service.js";
|
|
||||||
import { SimValidationService } from "./sim-validation.service.js";
|
|
||||||
import { SimCallHistoryParserService } from "./sim-call-history-parser.service.js";
|
|
||||||
import { SimCallHistoryFormatterService } from "./sim-call-history-formatter.service.js";
|
|
||||||
import type {
|
|
||||||
SimDomesticCallHistoryResponse,
|
|
||||||
SimInternationalCallHistoryResponse,
|
|
||||||
SimSmsHistoryResponse,
|
|
||||||
} from "@customer-portal/domain/sim";
|
|
||||||
|
|
||||||
// Re-export types for consumers
|
|
||||||
export type {
|
|
||||||
DomesticCallRecord,
|
|
||||||
InternationalCallRecord,
|
|
||||||
SmsRecord,
|
|
||||||
} from "./sim-call-history-parser.service.js";
|
|
||||||
|
|
||||||
// SmsType enum to match Prisma schema
|
|
||||||
type SmsType = "DOMESTIC" | "INTERNATIONAL";
|
|
||||||
|
|
||||||
export interface CallHistoryPagination {
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
total: number;
|
|
||||||
totalPages: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Service for managing SIM call history data.
|
|
||||||
* Coordinates importing data from SFTP and querying stored history.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class SimCallHistoryService {
|
|
||||||
constructor(
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly sftp: SftpClientService,
|
|
||||||
private readonly simValidation: SimValidationService,
|
|
||||||
private readonly parser: SimCallHistoryParserService,
|
|
||||||
private readonly formatter: SimCallHistoryFormatterService,
|
|
||||||
@Inject(Logger) private readonly logger: Logger
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Import call history from SFTP for a specific month
|
|
||||||
*/
|
|
||||||
async importCallHistory(yearMonth: string): Promise<{
|
|
||||||
domestic: number;
|
|
||||||
international: number;
|
|
||||||
sms: number;
|
|
||||||
}> {
|
|
||||||
const month = this.formatter.normalizeMonth(yearMonth);
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
// Import talk detail (calls)
|
|
||||||
try {
|
|
||||||
const talkContent = await this.sftp.downloadTalkDetail(yearMonth);
|
|
||||||
const parsed = this.parser.parseTalkDetailCsv(talkContent, month);
|
|
||||||
|
|
||||||
this.logger.log(`Parsed talk detail`, {
|
|
||||||
domestic: parsed.domestic.length,
|
|
||||||
international: parsed.international.length,
|
|
||||||
skipped: parsed.skipped,
|
|
||||||
errors: parsed.errors,
|
|
||||||
});
|
|
||||||
|
|
||||||
domesticCount = await this.processInBatches(
|
|
||||||
parsed.domestic,
|
|
||||||
50,
|
|
||||||
async record =>
|
|
||||||
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,
|
|
||||||
}),
|
|
||||||
(record, error) =>
|
|
||||||
this.logger.warn("Failed to store domestic call record", { record, error })
|
|
||||||
);
|
|
||||||
|
|
||||||
internationalCount = await this.processInBatches(
|
|
||||||
parsed.international,
|
|
||||||
50,
|
|
||||||
async record =>
|
|
||||||
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,
|
|
||||||
}),
|
|
||||||
(record, 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 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Import SMS detail
|
|
||||||
try {
|
|
||||||
const smsContent = await this.sftp.downloadSmsDetail(yearMonth);
|
|
||||||
const parsed = this.parser.parseSmsDetailCsv(smsContent, month);
|
|
||||||
|
|
||||||
this.logger.log(`Parsed SMS detail`, {
|
|
||||||
records: parsed.records.length,
|
|
||||||
skipped: parsed.skipped,
|
|
||||||
errors: parsed.errors,
|
|
||||||
});
|
|
||||||
|
|
||||||
smsCount = await this.processInBatches(
|
|
||||||
parsed.records,
|
|
||||||
50,
|
|
||||||
async record =>
|
|
||||||
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,
|
|
||||||
}),
|
|
||||||
(record, 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<SimDomesticCallHistoryResponse> {
|
|
||||||
await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
|
||||||
// Dev/testing mode: call history data is currently sourced from a fixed account.
|
|
||||||
// TODO: Replace with the validated subscription account once call history data is available per user.
|
|
||||||
const account = "08077052946";
|
|
||||||
|
|
||||||
const targetMonth = month || this.formatter.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.formatter.formatTime(call.callTime),
|
|
||||||
calledTo: this.formatter.formatPhoneNumber(call.calledTo),
|
|
||||||
callLength: this.formatter.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<SimInternationalCallHistoryResponse> {
|
|
||||||
await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
|
||||||
const account = "08077052946";
|
|
||||||
|
|
||||||
const targetMonth = month || this.formatter.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.formatter.formatTime(call.startTime),
|
|
||||||
stopTime: call.stopTime ? this.formatter.formatTime(call.stopTime) : null,
|
|
||||||
country: call.country,
|
|
||||||
calledTo: this.formatter.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<SimSmsHistoryResponse> {
|
|
||||||
await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
|
||||||
const account = "08077052946";
|
|
||||||
|
|
||||||
const targetMonth = month || this.formatter.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.formatter.formatTime(msg.smsTime),
|
|
||||||
sentTo: this.formatter.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<string[]> {
|
|
||||||
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<string[]> {
|
|
||||||
try {
|
|
||||||
return await this.sftp.listFiles(path);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error("Failed to list SFTP files", { error, path });
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process records in batches with error handling
|
|
||||||
*/
|
|
||||||
private async processInBatches<T>(
|
|
||||||
records: T[],
|
|
||||||
batchSize: number,
|
|
||||||
handler: (record: T) => Promise<unknown>,
|
|
||||||
onError: (record: T, error: unknown) => void
|
|
||||||
): Promise<number> {
|
|
||||||
let successCount = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < records.length; i += batchSize) {
|
|
||||||
const batch = records.slice(i, i + batchSize);
|
|
||||||
const results = await Promise.allSettled(batch.map(async record => handler(record)));
|
|
||||||
|
|
||||||
for (const [index, result] of results.entries()) {
|
|
||||||
if (result.status === "fulfilled") {
|
|
||||||
successCount++;
|
|
||||||
} else {
|
|
||||||
const record = batch[index];
|
|
||||||
if (record !== undefined) {
|
|
||||||
onError(record, result.reason);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return successCount;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,347 +0,0 @@
|
|||||||
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
|
||||||
import { Logger } from "nestjs-pino";
|
|
||||||
import { ConfigService } from "@nestjs/config";
|
|
||||||
import { FreebitFacade } from "@bff/integrations/freebit/facades/freebit.facade.js";
|
|
||||||
import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js";
|
|
||||||
import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js";
|
|
||||||
import { WorkflowCaseManager } from "@bff/modules/shared/workflow/index.js";
|
|
||||||
import { SimValidationService } from "./sim-validation.service.js";
|
|
||||||
import { SubscriptionValidationCoordinator } from "../../shared/index.js";
|
|
||||||
import type {
|
|
||||||
SimCancelRequest,
|
|
||||||
SimCancelFullRequest,
|
|
||||||
SimCancellationMonth,
|
|
||||||
SimCancellationPreview,
|
|
||||||
} from "@customer-portal/domain/sim";
|
|
||||||
import {
|
|
||||||
generateCancellationMonths,
|
|
||||||
getCancellationEffectiveDate,
|
|
||||||
getRunDateFromMonth,
|
|
||||||
} from "@customer-portal/domain/subscriptions";
|
|
||||||
import { SIM_CANCELLATION_NOTICE } from "@customer-portal/domain/opportunity";
|
|
||||||
import { SimScheduleService } from "./sim-schedule.service.js";
|
|
||||||
import { SimNotificationService } from "./sim-notification.service.js";
|
|
||||||
import { NotificationService } from "@bff/modules/notifications/notifications.service.js";
|
|
||||||
import { NOTIFICATION_SOURCE, NOTIFICATION_TYPE } from "@customer-portal/domain/notifications";
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class SimCancellationService {
|
|
||||||
constructor(
|
|
||||||
private readonly freebitService: FreebitFacade,
|
|
||||||
private readonly whmcsClientService: WhmcsClientService,
|
|
||||||
private readonly validationCoordinator: SubscriptionValidationCoordinator,
|
|
||||||
private readonly opportunityService: SalesforceOpportunityService,
|
|
||||||
private readonly workflowCases: WorkflowCaseManager,
|
|
||||||
private readonly simValidation: SimValidationService,
|
|
||||||
private readonly simSchedule: SimScheduleService,
|
|
||||||
private readonly simNotification: SimNotificationService,
|
|
||||||
private readonly notifications: NotificationService,
|
|
||||||
private readonly configService: ConfigService,
|
|
||||||
@Inject(Logger) private readonly logger: Logger
|
|
||||||
) {}
|
|
||||||
|
|
||||||
private get freebitBaseUrl(): string {
|
|
||||||
return this.configService.get<string>("FREEBIT_BASE_URL") || "https://i1.mvno.net/emptool/api";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 = Number.parseInt(startDateStr.slice(0, 4), 10);
|
|
||||||
const month = Number.parseInt(startDateStr.slice(4, 6), 10) - 1;
|
|
||||||
const day = Number.parseInt(startDateStr.slice(6, 8), 10);
|
|
||||||
|
|
||||||
if (Number.isNaN(year) || Number.isNaN(month) || Number.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<SimCancellationPreview> {
|
|
||||||
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
|
||||||
const simDetails = await this.freebitService.getSimDetails(validation.account);
|
|
||||||
|
|
||||||
// Get customer info from WHMCS via coordinator
|
|
||||||
const whmcsClientId = await this.validationCoordinator.getWhmcsClientId(userId);
|
|
||||||
const clientDetails = await this.whmcsClientService.getClientDetails(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: generateCancellationMonths({
|
|
||||||
includeRunDate: true,
|
|
||||||
}) as SimCancellationMonth[],
|
|
||||||
customerEmail,
|
|
||||||
customerName,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancel SIM service (legacy)
|
|
||||||
*/
|
|
||||||
async cancelSim(
|
|
||||||
userId: string,
|
|
||||||
subscriptionId: number,
|
|
||||||
request: SimCancelRequest = {}
|
|
||||||
): Promise<void> {
|
|
||||||
let account = "";
|
|
||||||
|
|
||||||
await this.simNotification.runWithNotification(
|
|
||||||
"Cancel SIM",
|
|
||||||
{
|
|
||||||
baseContext: {
|
|
||||||
userId,
|
|
||||||
subscriptionId,
|
|
||||||
scheduledAt: request.scheduledAt,
|
|
||||||
},
|
|
||||||
enrichSuccess: result => ({
|
|
||||||
account: result.account,
|
|
||||||
runDate: result.runDate,
|
|
||||||
}),
|
|
||||||
enrichError: () => ({
|
|
||||||
account,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
async () => {
|
|
||||||
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
|
||||||
account = validation.account;
|
|
||||||
|
|
||||||
const scheduleResolution = this.simSchedule.resolveScheduledDate(request.scheduledAt);
|
|
||||||
|
|
||||||
await this.freebitService.cancelSim(account, scheduleResolution.date);
|
|
||||||
|
|
||||||
this.logger.log(`Successfully cancelled SIM for subscription ${subscriptionId}`, {
|
|
||||||
userId,
|
|
||||||
subscriptionId,
|
|
||||||
account,
|
|
||||||
runDate: scheduleResolution.date,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
account,
|
|
||||||
runDate: scheduleResolution.date,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancel SIM service with full flow (PA02-04, Salesforce Case + Opportunity, and email notifications)
|
|
||||||
*
|
|
||||||
* Flow:
|
|
||||||
* 1. Validate SIM subscription
|
|
||||||
* 2. Call Freebit PA02-04 API to schedule cancellation
|
|
||||||
* 3. Create Salesforce Case with all form details
|
|
||||||
* 4. Update Salesforce Opportunity (if linked)
|
|
||||||
* 5. Send email notifications
|
|
||||||
*/
|
|
||||||
async cancelSimFull(
|
|
||||||
userId: string,
|
|
||||||
subscriptionId: number,
|
|
||||||
request: SimCancelFullRequest
|
|
||||||
): Promise<void> {
|
|
||||||
// Validate account mapping via coordinator
|
|
||||||
const { whmcsClientId, sfAccountId } =
|
|
||||||
await this.validationCoordinator.validateAccountMapping(userId);
|
|
||||||
|
|
||||||
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 clientDetails = await this.whmcsClientService.getClientDetails(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");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate runDate and cancellation date using shared utilities
|
|
||||||
let runDate: string;
|
|
||||||
let cancellationDate: string;
|
|
||||||
try {
|
|
||||||
runDate = getRunDateFromMonth(request.cancellationMonth);
|
|
||||||
cancellationDate = getCancellationEffectiveDate(request.cancellationMonth);
|
|
||||||
} catch {
|
|
||||||
throw new BadRequestException("Invalid cancellation month format");
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Resolve OpportunityId via coordinator
|
|
||||||
const opportunityResolution = await this.validationCoordinator.resolveOpportunityId(
|
|
||||||
subscriptionId,
|
|
||||||
{ fallbackToSalesforce: true }
|
|
||||||
);
|
|
||||||
const opportunityId = opportunityResolution.opportunityId;
|
|
||||||
|
|
||||||
// Create Salesforce Case for cancellation via workflow manager
|
|
||||||
await this.workflowCases.notifySimCancellation({
|
|
||||||
accountId: sfAccountId,
|
|
||||||
...(opportunityId ? { opportunityId } : {}),
|
|
||||||
simAccount: account,
|
|
||||||
iccid: simDetails.iccid || "N/A",
|
|
||||||
subscriptionId,
|
|
||||||
cancellationMonth: request.cancellationMonth,
|
|
||||||
serviceEndDate: cancellationDate,
|
|
||||||
...(request.comments ? { comments: request.comments } : {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log("SIM cancellation case created via WorkflowCaseManager", {
|
|
||||||
sfAccountIdTail: sfAccountId.slice(-4),
|
|
||||||
opportunityId: opportunityId ? opportunityId.slice(-4) : null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Use a placeholder caseId for notification since workflow manager doesn't return it
|
|
||||||
const caseId = `cancellation:sim:${subscriptionId}:${request.cancellationMonth}`;
|
|
||||||
|
|
||||||
// Update Salesforce Opportunity (if linked via WHMCS_Service_ID__c)
|
|
||||||
if (opportunityId) {
|
|
||||||
try {
|
|
||||||
const cancellationData = {
|
|
||||||
scheduledCancellationDate: `${cancellationDate}T23:59:59.000Z`,
|
|
||||||
cancellationNotice: SIM_CANCELLATION_NOTICE.RECEIVED,
|
|
||||||
};
|
|
||||||
|
|
||||||
await this.opportunityService.updateSimCancellationData(opportunityId, cancellationData);
|
|
||||||
|
|
||||||
this.logger.log("Opportunity updated with SIM cancellation data", {
|
|
||||||
opportunityId,
|
|
||||||
scheduledDate: cancellationDate,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
// Log but don't fail - Freebit API was already called successfully
|
|
||||||
this.logger.warn("Failed to update Opportunity with SIM cancellation data", {
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
subscriptionId,
|
|
||||||
opportunityId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.logger.debug("No Opportunity linked to SIM subscription", { subscriptionId });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.notifications.createNotification({
|
|
||||||
userId,
|
|
||||||
type: NOTIFICATION_TYPE.CANCELLATION_SCHEDULED,
|
|
||||||
source: NOTIFICATION_SOURCE.SYSTEM,
|
|
||||||
sourceId: caseId || opportunityId || `sim:${subscriptionId}:${runDate}`,
|
|
||||||
actionUrl: `/account/services/${subscriptionId}`,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn("Failed to create SIM cancellation notification", {
|
|
||||||
userId,
|
|
||||||
subscriptionId,
|
|
||||||
account,
|
|
||||||
runDate,
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send admin notification email
|
|
||||||
const adminEmailBody = this.simNotification.buildCancellationAdminEmail({
|
|
||||||
customerName,
|
|
||||||
simNumber: account,
|
|
||||||
...(simDetails.iccid === undefined ? {} : { serialNumber: simDetails.iccid }),
|
|
||||||
cancellationMonth: request.cancellationMonth,
|
|
||||||
registeredEmail: customerEmail,
|
|
||||||
...(request.comments === undefined ? {} : { comments: request.comments }),
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.simNotification.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
|
|
||||||
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.simNotification.sendCustomerEmail(
|
|
||||||
customerEmail,
|
|
||||||
confirmationSubject,
|
|
||||||
confirmationBody
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,345 +0,0 @@
|
|||||||
import { Injectable, Inject } from "@nestjs/common";
|
|
||||||
import { Logger } from "nestjs-pino";
|
|
||||||
import { ConfigService } from "@nestjs/config";
|
|
||||||
import { EmailService } from "@bff/infra/email/email.service.js";
|
|
||||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
|
||||||
import type { SimNotificationContext } from "../interfaces/sim-base.interface.js";
|
|
||||||
|
|
||||||
const ADMIN_EMAIL = "info@asolutions.co.jp";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* API call log structure for notification emails
|
|
||||||
*/
|
|
||||||
export interface ApiCallLog {
|
|
||||||
url: string;
|
|
||||||
senddata?: Record<string, unknown> | string;
|
|
||||||
json?: Record<string, unknown> | string;
|
|
||||||
result: Record<string, unknown> | string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unified SIM notification service.
|
|
||||||
* Handles all SIM-related email notifications including:
|
|
||||||
* - Internal action notifications (success/error alerts)
|
|
||||||
* - API results notifications to admin
|
|
||||||
* - Customer-facing emails (eSIM reissue, cancellation confirmations)
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class SimNotificationService {
|
|
||||||
constructor(
|
|
||||||
@Inject(Logger) private readonly logger: Logger,
|
|
||||||
private readonly email: EmailService,
|
|
||||||
private readonly configService: ConfigService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Internal Action Notifications
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send notification for SIM actions to configured alert email
|
|
||||||
*/
|
|
||||||
async notifySimAction(
|
|
||||||
action: string,
|
|
||||||
status: "SUCCESS" | "ERROR",
|
|
||||||
context: SimNotificationContext
|
|
||||||
): Promise<void> {
|
|
||||||
const subject = `[SIM ACTION] ${action} - ${status}`;
|
|
||||||
const toAddress = this.configService.get<string>("SIM_ALERT_EMAIL_TO");
|
|
||||||
const fromAddress = this.configService.get<string>("SIM_ALERT_EMAIL_FROM");
|
|
||||||
|
|
||||||
if (!toAddress || !fromAddress) {
|
|
||||||
this.logger.debug("SIM action notification skipped: email config missing", {
|
|
||||||
action,
|
|
||||||
status,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const publicContext = this.redactSensitiveFields(context);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const lines: string[] = [
|
|
||||||
`Action: ${action}`,
|
|
||||||
`Result: ${status}`,
|
|
||||||
`Timestamp: ${new Date().toISOString()}`,
|
|
||||||
"",
|
|
||||||
"Context:",
|
|
||||||
JSON.stringify(publicContext, null, 2),
|
|
||||||
];
|
|
||||||
await this.email.sendEmail({
|
|
||||||
to: toAddress,
|
|
||||||
from: fromAddress,
|
|
||||||
subject,
|
|
||||||
text: lines.join("\n"),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn("Failed to send SIM action notification email", {
|
|
||||||
action,
|
|
||||||
status,
|
|
||||||
error: extractErrorMessage(err),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// API Results Notifications (Admin)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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: extractErrorMessage(err),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Customer Notifications
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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: extractErrorMessage(err),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Email Body Builders
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
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}
|
|
||||||
Comments: ${params.comments || "N/A"}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Error Message Utilities
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert technical errors to user-friendly messages for SIM operations
|
|
||||||
*/
|
|
||||||
getUserFriendlySimError(technicalError: string): string {
|
|
||||||
if (!technicalError) {
|
|
||||||
return "SIM operation failed. Please try again or contact support.";
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorLower = technicalError.toLowerCase();
|
|
||||||
|
|
||||||
// Freebit API errors
|
|
||||||
if (errorLower.includes("api error: ng") || errorLower.includes("account not found")) {
|
|
||||||
return "SIM account not found. Please contact support to verify your SIM configuration.";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errorLower.includes("authentication failed") || errorLower.includes("auth")) {
|
|
||||||
return "SIM service is temporarily unavailable. Please try again later.";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errorLower.includes("timeout") || errorLower.includes("network")) {
|
|
||||||
return "SIM service request timed out. Please try again.";
|
|
||||||
}
|
|
||||||
|
|
||||||
// WHMCS errors
|
|
||||||
if (errorLower.includes("invalid permissions") || errorLower.includes("not allowed")) {
|
|
||||||
return "SIM service is temporarily unavailable. Please contact support for assistance.";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generic errors
|
|
||||||
if (errorLower.includes("failed") || errorLower.includes("error")) {
|
|
||||||
return "SIM operation failed. Please try again or contact support.";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default fallback
|
|
||||||
return "SIM operation failed. Please try again or contact support.";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Action Runner (for wrapping operations with notifications)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run an operation with automatic success/error notifications.
|
|
||||||
* Replaces the separate SimActionRunnerService.
|
|
||||||
*/
|
|
||||||
async runWithNotification<T>(
|
|
||||||
action: string,
|
|
||||||
options: {
|
|
||||||
baseContext: SimNotificationContext;
|
|
||||||
enrichSuccess?: (result: T) => Partial<SimNotificationContext>;
|
|
||||||
enrichError?: (error: unknown) => Partial<SimNotificationContext>;
|
|
||||||
},
|
|
||||||
handler: () => Promise<T>
|
|
||||||
): Promise<T> {
|
|
||||||
try {
|
|
||||||
const result = await handler();
|
|
||||||
const successContext = {
|
|
||||||
...options.baseContext,
|
|
||||||
...(options.enrichSuccess ? options.enrichSuccess(result) : {}),
|
|
||||||
};
|
|
||||||
await this.notifySimAction(action, "SUCCESS", successContext);
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
const errorContext = {
|
|
||||||
...options.baseContext,
|
|
||||||
error: extractErrorMessage(error),
|
|
||||||
...(options.enrichError ? options.enrichError(error) : {}),
|
|
||||||
};
|
|
||||||
await this.notifySimAction(action, "ERROR", errorContext);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Private Helpers
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Redact sensitive information from notification context
|
|
||||||
*/
|
|
||||||
private redactSensitiveFields(context: Record<string, unknown>): Record<string, unknown> {
|
|
||||||
const sanitized: Record<string, unknown> = {};
|
|
||||||
for (const [key, value] of Object.entries(context)) {
|
|
||||||
if (typeof key === "string" && key.toLowerCase().includes("password")) {
|
|
||||||
sanitized[key] = "[REDACTED]";
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === "string" && value.length > 200) {
|
|
||||||
sanitized[key] = `${value.slice(0, 200)}…`;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
sanitized[key] = value;
|
|
||||||
}
|
|
||||||
return sanitized;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,11 +1,11 @@
|
|||||||
import { Injectable, Inject } from "@nestjs/common";
|
import { Injectable, Inject } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { SimDetailsService } from "./sim-details.service.js";
|
import { SimDetailsService } from "./queries/sim-details.service.js";
|
||||||
import { SimUsageService } from "./sim-usage.service.js";
|
import { SimUsageService } from "./queries/sim-usage.service.js";
|
||||||
import { SimTopUpService } from "./sim-topup.service.js";
|
import { SimTopUpService } from "./mutations/sim-topup.service.js";
|
||||||
import { SimPlanService } from "./sim-plan.service.js";
|
import { SimPlanService } from "./mutations/sim-plan.service.js";
|
||||||
import { SimCancellationService } from "./sim-cancellation.service.js";
|
import { SimCancellationService } from "./mutations/sim-cancellation.service.js";
|
||||||
import { EsimManagementService } from "./esim-management.service.js";
|
import { EsimManagementService } from "./mutations/esim-management.service.js";
|
||||||
import { SimValidationService } from "./sim-validation.service.js";
|
import { SimValidationService } from "./sim-validation.service.js";
|
||||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
import { simInfoSchema } from "@customer-portal/domain/sim";
|
import { simInfoSchema } from "@customer-portal/domain/sim";
|
||||||
|
|||||||
@ -1,353 +0,0 @@
|
|||||||
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
|
||||||
import { Logger } from "nestjs-pino";
|
|
||||||
import { ConfigService } from "@nestjs/config";
|
|
||||||
import { FreebitFacade } from "@bff/integrations/freebit/facades/freebit.facade.js";
|
|
||||||
import { SimValidationService } from "./sim-validation.service.js";
|
|
||||||
import type {
|
|
||||||
SimPlanChangeRequest,
|
|
||||||
SimFeaturesUpdateRequest,
|
|
||||||
SimChangePlanFullRequest,
|
|
||||||
SimAvailablePlan,
|
|
||||||
} from "@customer-portal/domain/sim";
|
|
||||||
import { SimScheduleService } from "./sim-schedule.service.js";
|
|
||||||
import { SimManagementQueueService } from "../queue/sim-management.queue.js";
|
|
||||||
import { SimNotificationService } from "./sim-notification.service.js";
|
|
||||||
import { SimServicesService } from "@bff/modules/services/application/sim-services.service.js";
|
|
||||||
|
|
||||||
// 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])
|
|
||||||
);
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class SimPlanService {
|
|
||||||
constructor(
|
|
||||||
private readonly freebitService: FreebitFacade,
|
|
||||||
private readonly simValidation: SimValidationService,
|
|
||||||
private readonly simSchedule: SimScheduleService,
|
|
||||||
private readonly simQueue: SimManagementQueueService,
|
|
||||||
private readonly simNotification: SimNotificationService,
|
|
||||||
private readonly simCatalog: SimServicesService,
|
|
||||||
private readonly configService: ConfigService,
|
|
||||||
@Inject(Logger) private readonly logger: Logger
|
|
||||||
) {}
|
|
||||||
|
|
||||||
private get freebitBaseUrl(): string {
|
|
||||||
return this.configService.get<string>("FREEBIT_BASE_URL") || "https://i1.mvno.net/emptool/api";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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<SimAvailablePlan[]> {
|
|
||||||
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 SimAvailablePlan with Freebit codes
|
|
||||||
return filteredPlans.map(plan => {
|
|
||||||
const freebitPlanCode = SKU_TO_FREEBIT_PLAN_CODE[plan.sku] || plan.sku;
|
|
||||||
return {
|
|
||||||
...plan,
|
|
||||||
freebitPlanCode,
|
|
||||||
isCurrentPlan: freebitPlanCode === currentPlanCode,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Freebit plan code from Salesforce SKU
|
|
||||||
*/
|
|
||||||
getFreebitPlanCode(sku: string): string | undefined {
|
|
||||||
return SKU_TO_FREEBIT_PLAN_CODE[sku];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Salesforce SKU from Freebit plan code
|
|
||||||
*/
|
|
||||||
getSalesforceSku(planCode: string): string | undefined {
|
|
||||||
return FREEBIT_PLAN_CODE_TO_SKU[planCode];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Change SIM plan (basic)
|
|
||||||
*/
|
|
||||||
async changeSimPlan(
|
|
||||||
userId: string,
|
|
||||||
subscriptionId: number,
|
|
||||||
request: SimPlanChangeRequest
|
|
||||||
): Promise<{ ipv4?: string; ipv6?: string }> {
|
|
||||||
let account = "";
|
|
||||||
const assignGlobalIp = request.assignGlobalIp ?? false;
|
|
||||||
|
|
||||||
const response = await this.simNotification.runWithNotification(
|
|
||||||
"Change Plan",
|
|
||||||
{
|
|
||||||
baseContext: {
|
|
||||||
userId,
|
|
||||||
subscriptionId,
|
|
||||||
newPlanCode: request.newPlanCode,
|
|
||||||
assignGlobalIp,
|
|
||||||
},
|
|
||||||
enrichSuccess: result => ({
|
|
||||||
account: result.account,
|
|
||||||
scheduledAt: result.scheduledAt,
|
|
||||||
}),
|
|
||||||
enrichError: () => ({
|
|
||||||
account,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
async () => {
|
|
||||||
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
|
||||||
account = validation.account;
|
|
||||||
|
|
||||||
if (!request.newPlanCode || request.newPlanCode.length < 3) {
|
|
||||||
throw new BadRequestException("Invalid plan code");
|
|
||||||
}
|
|
||||||
|
|
||||||
const scheduleResolution = this.simSchedule.resolveScheduledDate(request.scheduledAt);
|
|
||||||
|
|
||||||
this.logger.log("Submitting SIM plan change request", {
|
|
||||||
userId,
|
|
||||||
subscriptionId,
|
|
||||||
account,
|
|
||||||
newPlanCode: request.newPlanCode,
|
|
||||||
scheduledAt: scheduleResolution.date,
|
|
||||||
assignGlobalIp,
|
|
||||||
scheduleOrigin: scheduleResolution.source,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await this.freebitService.changeSimPlan(account, request.newPlanCode, {
|
|
||||||
assignGlobalIp,
|
|
||||||
scheduledAt: scheduleResolution.date,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(`Successfully changed SIM plan for subscription ${subscriptionId}`, {
|
|
||||||
userId,
|
|
||||||
subscriptionId,
|
|
||||||
account,
|
|
||||||
newPlanCode: request.newPlanCode,
|
|
||||||
scheduledAt: scheduleResolution.date,
|
|
||||||
assignGlobalIp,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
...result,
|
|
||||||
account,
|
|
||||||
scheduledAt: scheduleResolution.date,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...(response.ipv4 === undefined ? {} : { ipv4: response.ipv4 }),
|
|
||||||
...(response.ipv6 === undefined ? {} : { ipv6: response.ipv6 }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.simNotification.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 {
|
|
||||||
...(result.ipv4 === undefined ? {} : { ipv4: result.ipv4 }),
|
|
||||||
...(result.ipv6 === undefined ? {} : { ipv6: result.ipv6 }),
|
|
||||||
scheduledAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update SIM features (voicemail, call waiting, roaming, network type)
|
|
||||||
*/
|
|
||||||
async updateSimFeatures(
|
|
||||||
userId: string,
|
|
||||||
subscriptionId: number,
|
|
||||||
request: SimFeaturesUpdateRequest
|
|
||||||
): Promise<void> {
|
|
||||||
let account = "";
|
|
||||||
|
|
||||||
await this.simNotification.runWithNotification(
|
|
||||||
"Update Features",
|
|
||||||
{
|
|
||||||
baseContext: {
|
|
||||||
userId,
|
|
||||||
subscriptionId,
|
|
||||||
...request,
|
|
||||||
},
|
|
||||||
enrichSuccess: () => ({
|
|
||||||
account,
|
|
||||||
}),
|
|
||||||
enrichError: () => ({
|
|
||||||
account,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
async () => {
|
|
||||||
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
|
||||||
account = validation.account;
|
|
||||||
|
|
||||||
if (request.networkType && !["4G", "5G"].includes(request.networkType)) {
|
|
||||||
throw new BadRequestException('networkType must be either "4G" or "5G"');
|
|
||||||
}
|
|
||||||
|
|
||||||
const doVoice =
|
|
||||||
typeof request.voiceMailEnabled === "boolean" ||
|
|
||||||
typeof request.callWaitingEnabled === "boolean" ||
|
|
||||||
typeof request.internationalRoamingEnabled === "boolean";
|
|
||||||
const doContract = typeof request.networkType === "string";
|
|
||||||
|
|
||||||
if (doVoice && doContract) {
|
|
||||||
await this.freebitService.updateSimFeatures(account, {
|
|
||||||
...(request.voiceMailEnabled === undefined
|
|
||||||
? {}
|
|
||||||
: { voiceMailEnabled: request.voiceMailEnabled }),
|
|
||||||
...(request.callWaitingEnabled === undefined
|
|
||||||
? {}
|
|
||||||
: { callWaitingEnabled: request.callWaitingEnabled }),
|
|
||||||
...(request.internationalRoamingEnabled === undefined
|
|
||||||
? {}
|
|
||||||
: { internationalRoamingEnabled: request.internationalRoamingEnabled }),
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.simQueue.scheduleNetworkTypeChange({
|
|
||||||
account,
|
|
||||||
networkType: request.networkType as "4G" | "5G",
|
|
||||||
userId,
|
|
||||||
subscriptionId,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log("Scheduled contract line change via queue after voice option change", {
|
|
||||||
userId,
|
|
||||||
subscriptionId,
|
|
||||||
account,
|
|
||||||
networkType: request.networkType,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Filter out undefined values to satisfy exactOptionalPropertyTypes
|
|
||||||
const filteredRequest: {
|
|
||||||
voiceMailEnabled?: boolean;
|
|
||||||
callWaitingEnabled?: boolean;
|
|
||||||
internationalRoamingEnabled?: boolean;
|
|
||||||
networkType?: "4G" | "5G";
|
|
||||||
} = {};
|
|
||||||
if (request.voiceMailEnabled !== undefined) {
|
|
||||||
filteredRequest.voiceMailEnabled = request.voiceMailEnabled;
|
|
||||||
}
|
|
||||||
if (request.callWaitingEnabled !== undefined) {
|
|
||||||
filteredRequest.callWaitingEnabled = request.callWaitingEnabled;
|
|
||||||
}
|
|
||||||
if (request.internationalRoamingEnabled !== undefined) {
|
|
||||||
filteredRequest.internationalRoamingEnabled = request.internationalRoamingEnabled;
|
|
||||||
}
|
|
||||||
if (request.networkType !== undefined) {
|
|
||||||
filteredRequest.networkType = request.networkType;
|
|
||||||
}
|
|
||||||
await this.freebitService.updateSimFeatures(account, filteredRequest);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`Updated SIM features for subscription ${subscriptionId}`, {
|
|
||||||
userId,
|
|
||||||
subscriptionId,
|
|
||||||
account,
|
|
||||||
...request,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
|
||||||
|
|
||||||
type ScheduleResolution = {
|
|
||||||
date: string;
|
|
||||||
source: "user" | "auto";
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class SimScheduleService {
|
|
||||||
private static readonly DATE_REGEX = /^\d{8}$/;
|
|
||||||
|
|
||||||
ensureYyyyMmDd(value: string | undefined, fieldName = "date"): string {
|
|
||||||
if (!value) {
|
|
||||||
throw new BadRequestException(`${fieldName} is required in YYYYMMDD format`);
|
|
||||||
}
|
|
||||||
if (!SimScheduleService.DATE_REGEX.test(value)) {
|
|
||||||
throw new BadRequestException(`${fieldName} must be in YYYYMMDD format`);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
validateOptionalYyyyMmDd(value: string | undefined, fieldName = "date"): string | undefined {
|
|
||||||
if (value == null) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (!SimScheduleService.DATE_REGEX.test(value)) {
|
|
||||||
throw new BadRequestException(`${fieldName} must be in YYYYMMDD format`);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
resolveScheduledDate(value?: string, fieldName = "scheduledAt"): ScheduleResolution {
|
|
||||||
if (value) {
|
|
||||||
return {
|
|
||||||
date: this.ensureYyyyMmDd(value, fieldName),
|
|
||||||
source: "user",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
date: this.firstDayOfNextMonthYyyyMmDd(),
|
|
||||||
source: "auto",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
firstDayOfNextMonth(): Date {
|
|
||||||
const nextMonth = new Date();
|
|
||||||
nextMonth.setHours(0, 0, 0, 0);
|
|
||||||
nextMonth.setMonth(nextMonth.getMonth() + 1);
|
|
||||||
nextMonth.setDate(1);
|
|
||||||
return nextMonth;
|
|
||||||
}
|
|
||||||
|
|
||||||
firstDayOfNextMonthYyyyMmDd(): string {
|
|
||||||
return this.formatYyyyMmDd(this.firstDayOfNextMonth());
|
|
||||||
}
|
|
||||||
|
|
||||||
firstDayOfNextMonthIsoDate(): string {
|
|
||||||
return this.formatIsoDate(this.firstDayOfNextMonth());
|
|
||||||
}
|
|
||||||
|
|
||||||
formatYyyyMmDd(date: Date): string {
|
|
||||||
const year = date.getFullYear();
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
||||||
const day = String(date.getDate()).padStart(2, "0");
|
|
||||||
return `${year}${month}${day}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
formatIsoDate(date: Date): string {
|
|
||||||
return date.toISOString().split("T")[0] ?? "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,255 +0,0 @@
|
|||||||
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
|
||||||
import { Logger } from "nestjs-pino";
|
|
||||||
import { ConfigService } from "@nestjs/config";
|
|
||||||
import { FreebitFacade } from "@bff/integrations/freebit/facades/freebit.facade.js";
|
|
||||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
|
||||||
import { SimValidationService } from "./sim-validation.service.js";
|
|
||||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
|
||||||
import type { SimTopUpRequest } from "@customer-portal/domain/sim";
|
|
||||||
import { SimBillingService } from "./sim-billing.service.js";
|
|
||||||
import { SimNotificationService } from "./sim-notification.service.js";
|
|
||||||
import { SimTopUpPricingService } from "./sim-topup-pricing.service.js";
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class SimTopUpService {
|
|
||||||
constructor(
|
|
||||||
private readonly freebitService: FreebitFacade,
|
|
||||||
private readonly mappingsService: MappingsService,
|
|
||||||
private readonly simValidation: SimValidationService,
|
|
||||||
private readonly simBilling: SimBillingService,
|
|
||||||
private readonly simNotification: SimNotificationService,
|
|
||||||
private readonly configService: ConfigService,
|
|
||||||
private readonly simTopUpPricing: SimTopUpPricingService,
|
|
||||||
@Inject(Logger) private readonly logger: Logger
|
|
||||||
) {}
|
|
||||||
|
|
||||||
private get whmcsBaseUrl(): string {
|
|
||||||
return (
|
|
||||||
this.configService.get<string>("WHMCS_BASE_URL") ||
|
|
||||||
"https://accounts.asolutions.co.jp/includes/api.php"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private get freebitBaseUrl(): string {
|
|
||||||
return this.configService.get<string>("FREEBIT_BASE_URL") || "https://i1.mvno.net/emptool/api";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Top up SIM data quota with payment processing
|
|
||||||
* Pricing: 1GB = 500 JPY
|
|
||||||
*/
|
|
||||||
async topUpSim(userId: string, subscriptionId: number, request: SimTopUpRequest): Promise<void> {
|
|
||||||
let latestAccount = "";
|
|
||||||
|
|
||||||
await this.simNotification.runWithNotification(
|
|
||||||
"Top Up Data",
|
|
||||||
{
|
|
||||||
baseContext: {
|
|
||||||
userId,
|
|
||||||
subscriptionId,
|
|
||||||
quotaMb: request.quotaMb,
|
|
||||||
},
|
|
||||||
enrichSuccess: meta => ({
|
|
||||||
account: meta.account,
|
|
||||||
costJpy: meta.costJpy,
|
|
||||||
invoiceId: meta.invoiceId,
|
|
||||||
transactionId: meta.transactionId,
|
|
||||||
}),
|
|
||||||
enrichError: () => ({
|
|
||||||
account: latestAccount,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
async () => {
|
|
||||||
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
|
||||||
latestAccount = validation.account;
|
|
||||||
|
|
||||||
if (request.quotaMb <= 0) {
|
|
||||||
throw new BadRequestException("Quota must be greater than 0MB");
|
|
||||||
}
|
|
||||||
|
|
||||||
const pricing = await this.simTopUpPricing.getTopUpPricing();
|
|
||||||
const minQuotaMb = pricing.minQuotaMb;
|
|
||||||
const maxQuotaMb = pricing.maxQuotaMb;
|
|
||||||
const quotaGb = request.quotaMb / 1000;
|
|
||||||
const units = Math.ceil(quotaGb);
|
|
||||||
const costJpy = units * pricing.pricePerGbJpy;
|
|
||||||
|
|
||||||
if (request.quotaMb < minQuotaMb || request.quotaMb > maxQuotaMb) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Quota must be between ${minQuotaMb}MB and ${maxQuotaMb}MB for Freebit API compatibility`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId);
|
|
||||||
|
|
||||||
this.logger.log(`Starting SIM top-up process for subscription ${subscriptionId}`, {
|
|
||||||
userId,
|
|
||||||
subscriptionId,
|
|
||||||
account: latestAccount,
|
|
||||||
quotaMb: request.quotaMb,
|
|
||||||
quotaGb: quotaGb.toFixed(2),
|
|
||||||
costJpy,
|
|
||||||
});
|
|
||||||
|
|
||||||
const billing = await this.simBilling.createOneTimeCharge({
|
|
||||||
clientId: whmcsClientId,
|
|
||||||
userId,
|
|
||||||
description: `SIM Data Top-up: ${units}GB for ${latestAccount}`,
|
|
||||||
amountJpy: costJpy,
|
|
||||||
currency: "JPY",
|
|
||||||
dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
|
||||||
notes: `Subscription ID: ${subscriptionId}, Phone: ${latestAccount}`,
|
|
||||||
failureNotesPrefix: "Payment capture failed",
|
|
||||||
publicErrorMessage: "SIM top-up failed: payment could not be processed",
|
|
||||||
metadata: { subscriptionId },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Call Freebit API to add quota
|
|
||||||
let freebitResult: {
|
|
||||||
resultCode: string;
|
|
||||||
status: { message: string; statusCode: string };
|
|
||||||
} | null = null;
|
|
||||||
try {
|
|
||||||
await this.freebitService.topUpSim(latestAccount, request.quotaMb, {});
|
|
||||||
freebitResult = { resultCode: "100", status: { message: "OK", statusCode: "200" } };
|
|
||||||
} catch (freebitError) {
|
|
||||||
await this.handleFreebitFailureAfterPayment(
|
|
||||||
freebitError,
|
|
||||||
billing.invoice,
|
|
||||||
billing.transactionId || "unknown",
|
|
||||||
userId,
|
|
||||||
subscriptionId,
|
|
||||||
latestAccount,
|
|
||||||
request.quotaMb
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`Successfully topped up SIM for subscription ${subscriptionId}`, {
|
|
||||||
userId,
|
|
||||||
subscriptionId,
|
|
||||||
account: latestAccount,
|
|
||||||
quotaMb: request.quotaMb,
|
|
||||||
costJpy,
|
|
||||||
invoiceId: billing.invoice.id,
|
|
||||||
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.simNotification.sendApiResultsEmail("API results", [
|
|
||||||
{
|
|
||||||
url: this.whmcsBaseUrl,
|
|
||||||
senddata: {
|
|
||||||
itemdescription1: `Top-up data (${units}GB)\nSIM Number: ${latestAccount}`,
|
|
||||||
itemamount1: String(costJpy),
|
|
||||||
userid: String(whmcsClientId),
|
|
||||||
date: dateStr,
|
|
||||||
responsetype: "json",
|
|
||||||
itemtaxed1: "1",
|
|
||||||
action: "CreateInvoice",
|
|
||||||
duedate: dueDate,
|
|
||||||
paymentmethod: "stripe",
|
|
||||||
sendinvoice: "1",
|
|
||||||
},
|
|
||||||
result: {
|
|
||||||
result: "success",
|
|
||||||
invoiceid: billing.invoice.id,
|
|
||||||
status: billing.invoice.status,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: this.whmcsBaseUrl,
|
|
||||||
senddata: {
|
|
||||||
responsetype: "json",
|
|
||||||
action: "CapturePayment",
|
|
||||||
invoiceid: billing.invoice.id,
|
|
||||||
},
|
|
||||||
result: { result: "success" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: `${this.freebitBaseUrl}/master/addSpec/`,
|
|
||||||
json: {
|
|
||||||
quota: request.quotaMb,
|
|
||||||
kind: "MVNO",
|
|
||||||
account: latestAccount,
|
|
||||||
authKey: "[REDACTED]",
|
|
||||||
},
|
|
||||||
result: freebitResult || {
|
|
||||||
resultCode: "100",
|
|
||||||
status: { message: "OK", statusCode: "200" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
account: latestAccount,
|
|
||||||
costJpy,
|
|
||||||
invoiceId: billing.invoice.id,
|
|
||||||
transactionId: billing.transactionId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle Freebit API failure after successful payment
|
|
||||||
*/
|
|
||||||
private async handleFreebitFailureAfterPayment(
|
|
||||||
freebitError: unknown,
|
|
||||||
invoice: { id: number; number: string },
|
|
||||||
transactionId: string,
|
|
||||||
userId: string,
|
|
||||||
subscriptionId: number,
|
|
||||||
account: string,
|
|
||||||
quotaMb: number
|
|
||||||
): Promise<void> {
|
|
||||||
this.logger.error(
|
|
||||||
`Freebit API failed after successful payment for subscription ${subscriptionId}`,
|
|
||||||
{
|
|
||||||
error: extractErrorMessage(freebitError),
|
|
||||||
userId,
|
|
||||||
subscriptionId,
|
|
||||||
account,
|
|
||||||
quotaMb,
|
|
||||||
invoiceId: invoice.id,
|
|
||||||
transactionId,
|
|
||||||
paymentCaptured: true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add a note to the invoice about the Freebit failure
|
|
||||||
try {
|
|
||||||
await this.simBilling.appendInvoiceNote(
|
|
||||||
invoice.id,
|
|
||||||
`Payment successful but SIM top-up failed: ${extractErrorMessage(
|
|
||||||
freebitError
|
|
||||||
)}. Manual intervention required.`
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log(`Added failure note to invoice ${invoice.id}`, {
|
|
||||||
invoiceId: invoice.id,
|
|
||||||
reason: "Freebit API failure after payment",
|
|
||||||
});
|
|
||||||
} catch (updateError) {
|
|
||||||
this.logger.error(`Failed to update invoice ${invoice.id} with failure note`, {
|
|
||||||
invoiceId: invoice.id,
|
|
||||||
updateError: extractErrorMessage(updateError),
|
|
||||||
originalError: extractErrorMessage(freebitError),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refund logic is handled by the caller (via top-up failure handling)
|
|
||||||
// Automatic refunds should be implemented at the payment processing layer
|
|
||||||
// to ensure consistency across all failure scenarios.
|
|
||||||
// For manual refunds, use the WHMCS admin panel or dedicated refund endpoints.
|
|
||||||
|
|
||||||
throw new Error(
|
|
||||||
`Payment was processed but SIM data top-up failed. Please contact support with invoice ${invoice.number} for assistance.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { SubscriptionsService } from "../../subscriptions.service.js";
|
import { SubscriptionsOrchestrator } from "../../subscriptions-orchestrator.service.js";
|
||||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
import type { SimValidationResult } from "../interfaces/sim-base.interface.js";
|
import type { SimValidationResult } from "../interfaces/sim-base.interface.js";
|
||||||
import {
|
import {
|
||||||
@ -13,7 +13,7 @@ import {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class SimValidationService {
|
export class SimValidationService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly subscriptionsService: SubscriptionsService,
|
private readonly subscriptionsOrchestrator: SubscriptionsOrchestrator,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {}
|
) {}
|
||||||
@ -29,7 +29,7 @@ export class SimValidationService {
|
|||||||
): Promise<SimValidationResult> {
|
): Promise<SimValidationResult> {
|
||||||
try {
|
try {
|
||||||
// Get subscription details to verify it's a SIM service
|
// Get subscription details to verify it's a SIM service
|
||||||
const subscription = await this.subscriptionsService.getSubscriptionById(
|
const subscription = await this.subscriptionsOrchestrator.getSubscriptionById(
|
||||||
userId,
|
userId,
|
||||||
subscriptionId
|
subscriptionId
|
||||||
);
|
);
|
||||||
@ -105,7 +105,7 @@ export class SimValidationService {
|
|||||||
subscriptionId: number
|
subscriptionId: number
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
const subscription = await this.subscriptionsService.getSubscriptionById(
|
const subscription = await this.subscriptionsOrchestrator.getSubscriptionById(
|
||||||
userId,
|
userId,
|
||||||
subscriptionId
|
subscriptionId
|
||||||
);
|
);
|
||||||
|
|||||||
@ -6,29 +6,32 @@ import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
|
|||||||
import { SftpModule } from "@bff/integrations/sftp/sftp.module.js";
|
import { SftpModule } from "@bff/integrations/sftp/sftp.module.js";
|
||||||
import { SecurityModule } from "@bff/core/security/security.module.js";
|
import { SecurityModule } from "@bff/core/security/security.module.js";
|
||||||
import { SimUsageStoreService } from "../sim-usage-store.service.js";
|
import { SimUsageStoreService } from "../sim-usage-store.service.js";
|
||||||
import { SubscriptionsService } from "../subscriptions.service.js";
|
import { SubscriptionsOrchestrator } from "../subscriptions-orchestrator.service.js";
|
||||||
import { SimManagementService } from "../sim-management.service.js";
|
|
||||||
import { SharedSubscriptionsModule } from "../shared/index.js";
|
import { SharedSubscriptionsModule } from "../shared/index.js";
|
||||||
// SimController is registered in SubscriptionsModule to ensure route order
|
// SimController is registered in SubscriptionsModule to ensure route order
|
||||||
|
|
||||||
// Import all SIM management services
|
// Import SIM management services - organized by responsibility
|
||||||
import { SimOrchestrator } from "./services/sim-orchestrator.service.js";
|
import { SimOrchestrator } from "./services/sim-orchestrator.service.js";
|
||||||
import { SimDetailsService } from "./services/sim-details.service.js";
|
|
||||||
import { SimUsageService } from "./services/sim-usage.service.js";
|
|
||||||
import { SimTopUpService } from "./services/sim-topup.service.js";
|
|
||||||
import { SimTopUpPricingService } from "./services/sim-topup-pricing.service.js";
|
|
||||||
import { SimPlanService } from "./services/sim-plan.service.js";
|
|
||||||
import { SimCancellationService } from "./services/sim-cancellation.service.js";
|
|
||||||
import { EsimManagementService } from "./services/esim-management.service.js";
|
|
||||||
import { SimValidationService } from "./services/sim-validation.service.js";
|
import { SimValidationService } from "./services/sim-validation.service.js";
|
||||||
import { SimNotificationService } from "./services/sim-notification.service.js";
|
// Query services (read operations)
|
||||||
import { SimBillingService } from "./services/sim-billing.service.js";
|
import { SimDetailsService } from "./services/queries/sim-details.service.js";
|
||||||
import { SimScheduleService } from "./services/sim-schedule.service.js";
|
import { SimUsageService } from "./services/queries/sim-usage.service.js";
|
||||||
|
import { SimBillingService } from "./services/queries/sim-billing.service.js";
|
||||||
|
import { SimTopUpPricingService } from "./services/queries/sim-topup-pricing.service.js";
|
||||||
|
// Mutation services (write operations)
|
||||||
|
import { SimTopUpService } from "./services/mutations/sim-topup.service.js";
|
||||||
|
import { SimPlanService } from "./services/mutations/sim-plan.service.js";
|
||||||
|
import { SimCancellationService } from "./services/mutations/sim-cancellation.service.js";
|
||||||
|
import { EsimManagementService } from "./services/mutations/esim-management.service.js";
|
||||||
|
// Support services (utilities)
|
||||||
|
import { SimCallHistoryService } from "./services/support/sim-call-history.service.js";
|
||||||
|
import { SimCallHistoryParserService } from "./services/support/sim-call-history-parser.service.js";
|
||||||
|
import { SimCallHistoryFormatterService } from "./services/support/sim-call-history-formatter.service.js";
|
||||||
|
import { SimNotificationService } from "./services/support/sim-notification.service.js";
|
||||||
|
import { SimScheduleService } from "./services/support/sim-schedule.service.js";
|
||||||
|
// Queue services
|
||||||
import { SimManagementQueueService } from "./queue/sim-management.queue.js";
|
import { SimManagementQueueService } from "./queue/sim-management.queue.js";
|
||||||
import { SimManagementProcessor } from "./queue/sim-management.processor.js";
|
import { SimManagementProcessor } from "./queue/sim-management.processor.js";
|
||||||
import { SimCallHistoryService } from "./services/sim-call-history.service.js";
|
|
||||||
import { SimCallHistoryParserService } from "./services/sim-call-history-parser.service.js";
|
|
||||||
import { SimCallHistoryFormatterService } from "./services/sim-call-history-formatter.service.js";
|
|
||||||
import { ServicesModule } from "@bff/modules/services/services.module.js";
|
import { ServicesModule } from "@bff/modules/services/services.module.js";
|
||||||
import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js";
|
import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js";
|
||||||
import { VoiceOptionsModule, VoiceOptionsService } from "@bff/modules/voice-options/index.js";
|
import { VoiceOptionsModule, VoiceOptionsService } from "@bff/modules/voice-options/index.js";
|
||||||
@ -54,8 +57,7 @@ import { WorkflowModule } from "@bff/modules/shared/workflow/index.js";
|
|||||||
providers: [
|
providers: [
|
||||||
// Core services that the SIM services depend on
|
// Core services that the SIM services depend on
|
||||||
SimUsageStoreService,
|
SimUsageStoreService,
|
||||||
SubscriptionsService,
|
SubscriptionsOrchestrator,
|
||||||
SimManagementService,
|
|
||||||
|
|
||||||
// SIM management services
|
// SIM management services
|
||||||
SimValidationService,
|
SimValidationService,
|
||||||
|
|||||||
@ -9,11 +9,11 @@ import {
|
|||||||
Header,
|
Header,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { SimManagementService } from "../sim-management.service.js";
|
import { SimOrchestrator } from "./services/sim-orchestrator.service.js";
|
||||||
import { SimTopUpPricingService } from "./services/sim-topup-pricing.service.js";
|
import { SimTopUpPricingService } from "./services/queries/sim-topup-pricing.service.js";
|
||||||
import { SimPlanService } from "./services/sim-plan.service.js";
|
import { SimPlanService } from "./services/mutations/sim-plan.service.js";
|
||||||
import { SimCancellationService } from "./services/sim-cancellation.service.js";
|
import { SimCancellationService } from "./services/mutations/sim-cancellation.service.js";
|
||||||
import { EsimManagementService } from "./services/esim-management.service.js";
|
import { EsimManagementService } from "./services/mutations/esim-management.service.js";
|
||||||
import { AdminGuard } from "@bff/core/security/guards/admin.guard.js";
|
import { AdminGuard } from "@bff/core/security/guards/admin.guard.js";
|
||||||
import { createZodDto, ZodResponse } from "nestjs-zod";
|
import { createZodDto, ZodResponse } from "nestjs-zod";
|
||||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
||||||
@ -77,7 +77,7 @@ class SimCancellationPreviewResponseDto extends createZodDto(simCancellationPrev
|
|||||||
@Controller("subscriptions")
|
@Controller("subscriptions")
|
||||||
export class SimController {
|
export class SimController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly simManagementService: SimManagementService,
|
private readonly simOrchestrator: SimOrchestrator,
|
||||||
private readonly simTopUpPricingService: SimTopUpPricingService,
|
private readonly simTopUpPricingService: SimTopUpPricingService,
|
||||||
private readonly simPlanService: SimPlanService,
|
private readonly simPlanService: SimPlanService,
|
||||||
private readonly simCancellationService: SimCancellationService,
|
private readonly simCancellationService: SimCancellationService,
|
||||||
@ -108,7 +108,7 @@ export class SimController {
|
|||||||
@Get("debug/sim-details/:account")
|
@Get("debug/sim-details/:account")
|
||||||
@UseGuards(AdminGuard)
|
@UseGuards(AdminGuard)
|
||||||
async debugSimDetails(@Param("account") account: string) {
|
async debugSimDetails(@Param("account") account: string) {
|
||||||
return await this.simManagementService.getSimDetailsDebug(account);
|
return await this.simOrchestrator.getSimDetailsDirectly(account);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Subscription-specific SIM Routes ====================
|
// ==================== Subscription-specific SIM Routes ====================
|
||||||
@ -119,25 +119,25 @@ export class SimController {
|
|||||||
@Request() req: RequestWithUser,
|
@Request() req: RequestWithUser,
|
||||||
@Param() params: SubscriptionIdParamDto
|
@Param() params: SubscriptionIdParamDto
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
return this.simManagementService.debugSimSubscription(req.user.id, params.id);
|
return this.simOrchestrator.debugSimSubscription(req.user.id, params.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(":id/sim")
|
@Get(":id/sim")
|
||||||
@ZodResponse({ description: "Get SIM info", type: SimInfoDto })
|
@ZodResponse({ description: "Get SIM info", type: SimInfoDto })
|
||||||
async getSimInfo(@Request() req: RequestWithUser, @Param() params: SubscriptionIdParamDto) {
|
async getSimInfo(@Request() req: RequestWithUser, @Param() params: SubscriptionIdParamDto) {
|
||||||
return this.simManagementService.getSimInfo(req.user.id, params.id);
|
return this.simOrchestrator.getSimInfo(req.user.id, params.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(":id/sim/details")
|
@Get(":id/sim/details")
|
||||||
@ZodResponse({ description: "Get SIM details", type: SimDetailsDto })
|
@ZodResponse({ description: "Get SIM details", type: SimDetailsDto })
|
||||||
async getSimDetails(@Request() req: RequestWithUser, @Param() params: SubscriptionIdParamDto) {
|
async getSimDetails(@Request() req: RequestWithUser, @Param() params: SubscriptionIdParamDto) {
|
||||||
return this.simManagementService.getSimDetails(req.user.id, params.id);
|
return this.simOrchestrator.getSimDetails(req.user.id, params.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(":id/sim/usage")
|
@Get(":id/sim/usage")
|
||||||
@ZodResponse({ description: "Get SIM usage", type: SimUsageDto })
|
@ZodResponse({ description: "Get SIM usage", type: SimUsageDto })
|
||||||
async getSimUsage(@Request() req: RequestWithUser, @Param() params: SubscriptionIdParamDto) {
|
async getSimUsage(@Request() req: RequestWithUser, @Param() params: SubscriptionIdParamDto) {
|
||||||
return this.simManagementService.getSimUsage(req.user.id, params.id);
|
return this.simOrchestrator.getSimUsage(req.user.id, params.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(":id/sim/top-up-history")
|
@Get(":id/sim/top-up-history")
|
||||||
@ -147,7 +147,7 @@ export class SimController {
|
|||||||
@Param() params: SubscriptionIdParamDto,
|
@Param() params: SubscriptionIdParamDto,
|
||||||
@Query() query: SimTopUpHistoryRequestDto
|
@Query() query: SimTopUpHistoryRequestDto
|
||||||
) {
|
) {
|
||||||
return this.simManagementService.getSimTopUpHistory(req.user.id, params.id, query);
|
return this.simOrchestrator.getSimTopUpHistory(req.user.id, params.id, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(":id/sim/top-up")
|
@Post(":id/sim/top-up")
|
||||||
@ -157,7 +157,7 @@ export class SimController {
|
|||||||
@Param() params: SubscriptionIdParamDto,
|
@Param() params: SubscriptionIdParamDto,
|
||||||
@Body() body: SimTopUpRequestDto
|
@Body() body: SimTopUpRequestDto
|
||||||
): Promise<SimActionResponse> {
|
): Promise<SimActionResponse> {
|
||||||
await this.simManagementService.topUpSim(req.user.id, params.id, body);
|
await this.simOrchestrator.topUpSim(req.user.id, params.id, body);
|
||||||
return { message: "SIM top-up completed successfully" };
|
return { message: "SIM top-up completed successfully" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -168,7 +168,7 @@ export class SimController {
|
|||||||
@Param() params: SubscriptionIdParamDto,
|
@Param() params: SubscriptionIdParamDto,
|
||||||
@Body() body: SimPlanChangeRequestDto
|
@Body() body: SimPlanChangeRequestDto
|
||||||
): Promise<SimPlanChangeResult> {
|
): Promise<SimPlanChangeResult> {
|
||||||
const result = await this.simManagementService.changeSimPlan(req.user.id, params.id, body);
|
const result = await this.simOrchestrator.changeSimPlan(req.user.id, params.id, body);
|
||||||
return {
|
return {
|
||||||
message: "SIM plan change completed successfully",
|
message: "SIM plan change completed successfully",
|
||||||
...result,
|
...result,
|
||||||
@ -182,7 +182,7 @@ export class SimController {
|
|||||||
@Param() params: SubscriptionIdParamDto,
|
@Param() params: SubscriptionIdParamDto,
|
||||||
@Body() body: SimCancelRequestDto
|
@Body() body: SimCancelRequestDto
|
||||||
): Promise<SimActionResponse> {
|
): Promise<SimActionResponse> {
|
||||||
await this.simManagementService.cancelSim(req.user.id, params.id, body);
|
await this.simOrchestrator.cancelSim(req.user.id, params.id, body);
|
||||||
return { message: "SIM cancellation completed successfully" };
|
return { message: "SIM cancellation completed successfully" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -193,7 +193,11 @@ export class SimController {
|
|||||||
@Param() params: SubscriptionIdParamDto,
|
@Param() params: SubscriptionIdParamDto,
|
||||||
@Body() body: SimReissueEsimRequestDto
|
@Body() body: SimReissueEsimRequestDto
|
||||||
): Promise<SimActionResponse> {
|
): Promise<SimActionResponse> {
|
||||||
await this.simManagementService.reissueEsimProfile(req.user.id, params.id, body.newEid);
|
await this.simOrchestrator.reissueEsimProfile(
|
||||||
|
req.user.id,
|
||||||
|
params.id,
|
||||||
|
body.newEid ? { newEid: body.newEid } : {}
|
||||||
|
);
|
||||||
return { message: "eSIM profile reissue completed successfully" };
|
return { message: "eSIM profile reissue completed successfully" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -204,7 +208,7 @@ export class SimController {
|
|||||||
@Param() params: SubscriptionIdParamDto,
|
@Param() params: SubscriptionIdParamDto,
|
||||||
@Body() body: SimFeaturesUpdateRequestDto
|
@Body() body: SimFeaturesUpdateRequestDto
|
||||||
): Promise<SimActionResponse> {
|
): Promise<SimActionResponse> {
|
||||||
await this.simManagementService.updateSimFeatures(req.user.id, params.id, body);
|
await this.simOrchestrator.updateSimFeatures(req.user.id, params.id, body);
|
||||||
return { message: "SIM features updated successfully" };
|
return { message: "SIM features updated successfully" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,8 +7,8 @@ import { CacheService } from "@bff/infra/cache/cache.service.js";
|
|||||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
import type { SimOrderActivationRequest } from "@customer-portal/domain/sim";
|
import type { SimOrderActivationRequest } from "@customer-portal/domain/sim";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import { SimBillingService } from "./sim-management/services/sim-billing.service.js";
|
import { SimBillingService } from "./sim-management/services/queries/sim-billing.service.js";
|
||||||
import { SimScheduleService } from "./sim-management/services/sim-schedule.service.js";
|
import { SimScheduleService } from "./sim-management/services/support/sim-schedule.service.js";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SimOrderActivationService {
|
export class SimOrderActivationService {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Controller, Get, Param, Query, Request, Header } from "@nestjs/common";
|
import { Controller, Get, Param, Query, Request, Header } from "@nestjs/common";
|
||||||
import { SubscriptionsService } from "./subscriptions.service.js";
|
import { SubscriptionsOrchestrator } from "./subscriptions-orchestrator.service.js";
|
||||||
import {
|
import {
|
||||||
subscriptionQuerySchema,
|
subscriptionQuerySchema,
|
||||||
subscriptionListSchema,
|
subscriptionListSchema,
|
||||||
@ -47,7 +47,7 @@ class InvoiceListDto extends createZodDto(invoiceListSchema) {}
|
|||||||
*/
|
*/
|
||||||
@Controller("subscriptions")
|
@Controller("subscriptions")
|
||||||
export class SubscriptionsController {
|
export class SubscriptionsController {
|
||||||
constructor(private readonly subscriptionsService: SubscriptionsService) {}
|
constructor(private readonly subscriptionsOrchestrator: SubscriptionsOrchestrator) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@Header("Cache-Control", "private, max-age=300")
|
@Header("Cache-Control", "private, max-age=300")
|
||||||
@ -57,7 +57,7 @@ export class SubscriptionsController {
|
|||||||
@Query() query: SubscriptionQueryDto
|
@Query() query: SubscriptionQueryDto
|
||||||
): Promise<SubscriptionList> {
|
): Promise<SubscriptionList> {
|
||||||
const { status } = query;
|
const { status } = query;
|
||||||
return this.subscriptionsService.getSubscriptions(
|
return this.subscriptionsOrchestrator.getSubscriptions(
|
||||||
req.user.id,
|
req.user.id,
|
||||||
status === undefined ? {} : { status }
|
status === undefined ? {} : { status }
|
||||||
);
|
);
|
||||||
@ -67,14 +67,14 @@ export class SubscriptionsController {
|
|||||||
@Header("Cache-Control", "private, max-age=300")
|
@Header("Cache-Control", "private, max-age=300")
|
||||||
@ZodResponse({ description: "List active subscriptions", type: ActiveSubscriptionsDto })
|
@ZodResponse({ description: "List active subscriptions", type: ActiveSubscriptionsDto })
|
||||||
async getActiveSubscriptions(@Request() req: RequestWithUser): Promise<Subscription[]> {
|
async getActiveSubscriptions(@Request() req: RequestWithUser): Promise<Subscription[]> {
|
||||||
return this.subscriptionsService.getActiveSubscriptions(req.user.id);
|
return this.subscriptionsOrchestrator.getActiveSubscriptions(req.user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("stats")
|
@Get("stats")
|
||||||
@Header("Cache-Control", "private, max-age=300")
|
@Header("Cache-Control", "private, max-age=300")
|
||||||
@ZodResponse({ description: "Get subscription stats", type: SubscriptionStatsDto })
|
@ZodResponse({ description: "Get subscription stats", type: SubscriptionStatsDto })
|
||||||
async getSubscriptionStats(@Request() req: RequestWithUser): Promise<SubscriptionStats> {
|
async getSubscriptionStats(@Request() req: RequestWithUser): Promise<SubscriptionStats> {
|
||||||
return this.subscriptionsService.getSubscriptionStats(req.user.id);
|
return this.subscriptionsOrchestrator.getSubscriptionStats(req.user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(":id")
|
@Get(":id")
|
||||||
@ -84,7 +84,7 @@ export class SubscriptionsController {
|
|||||||
@Request() req: RequestWithUser,
|
@Request() req: RequestWithUser,
|
||||||
@Param() params: SubscriptionIdParamDto
|
@Param() params: SubscriptionIdParamDto
|
||||||
): Promise<Subscription> {
|
): Promise<Subscription> {
|
||||||
return this.subscriptionsService.getSubscriptionById(req.user.id, params.id);
|
return this.subscriptionsOrchestrator.getSubscriptionById(req.user.id, params.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(":id/invoices")
|
@Get(":id/invoices")
|
||||||
@ -95,6 +95,6 @@ export class SubscriptionsController {
|
|||||||
@Param() params: SubscriptionIdParamDto,
|
@Param() params: SubscriptionIdParamDto,
|
||||||
@Query() query: SubscriptionInvoiceQueryDto
|
@Query() query: SubscriptionInvoiceQueryDto
|
||||||
): Promise<InvoiceList> {
|
): Promise<InvoiceList> {
|
||||||
return this.subscriptionsService.getSubscriptionInvoices(req.user.id, params.id, query);
|
return this.subscriptionsOrchestrator.getSubscriptionInvoices(req.user.id, params.id, query);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { SubscriptionsController } from "./subscriptions.controller.js";
|
import { SubscriptionsController } from "./subscriptions.controller.js";
|
||||||
import { SubscriptionsService } from "./subscriptions.service.js";
|
import { SubscriptionsOrchestrator } from "./subscriptions-orchestrator.service.js";
|
||||||
import { SimManagementService } from "./sim-management.service.js";
|
|
||||||
import { SimUsageStoreService } from "./sim-usage-store.service.js";
|
import { SimUsageStoreService } from "./sim-usage-store.service.js";
|
||||||
import { SimOrdersController } from "./sim-orders.controller.js";
|
import { SimOrdersController } from "./sim-orders.controller.js";
|
||||||
import { SimOrderActivationService } from "./sim-order-activation.service.js";
|
import { SimOrderActivationService } from "./sim-order-activation.service.js";
|
||||||
@ -37,11 +36,6 @@ import { CancellationController } from "./cancellation/cancellation.controller.j
|
|||||||
SubscriptionsController,
|
SubscriptionsController,
|
||||||
SimOrdersController,
|
SimOrdersController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [SubscriptionsOrchestrator, SimUsageStoreService, SimOrderActivationService],
|
||||||
SubscriptionsService,
|
|
||||||
SimManagementService,
|
|
||||||
SimUsageStoreService,
|
|
||||||
SimOrderActivationService,
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
export class SubscriptionsModule {}
|
export class SubscriptionsModule {}
|
||||||
|
|||||||
@ -1,466 +0,0 @@
|
|||||||
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
|
||||||
import {
|
|
||||||
subscriptionListSchema,
|
|
||||||
subscriptionStatusSchema,
|
|
||||||
subscriptionStatsSchema,
|
|
||||||
} from "@customer-portal/domain/subscriptions";
|
|
||||||
import type {
|
|
||||||
Subscription,
|
|
||||||
SubscriptionList,
|
|
||||||
SubscriptionStatus,
|
|
||||||
} from "@customer-portal/domain/subscriptions";
|
|
||||||
import type { Invoice, InvoiceItem, InvoiceList } from "@customer-portal/domain/billing";
|
|
||||||
import { WhmcsCacheService } from "@bff/integrations/whmcs/cache/whmcs-cache.service.js";
|
|
||||||
import { WhmcsConnectionFacade } from "@bff/integrations/whmcs/facades/whmcs.facade.js";
|
|
||||||
import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js";
|
|
||||||
import { WhmcsInvoiceService } from "@bff/integrations/whmcs/services/whmcs-invoice.service.js";
|
|
||||||
import { WhmcsSubscriptionService } from "@bff/integrations/whmcs/services/whmcs-subscription.service.js";
|
|
||||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
|
||||||
import { Logger } from "nestjs-pino";
|
|
||||||
import { withErrorHandling } from "@bff/core/utils/error-handler.util.js";
|
|
||||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
|
||||||
|
|
||||||
export interface GetSubscriptionsOptions {
|
|
||||||
status?: SubscriptionStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class SubscriptionsService {
|
|
||||||
constructor(
|
|
||||||
private readonly whmcsSubscriptionService: WhmcsSubscriptionService,
|
|
||||||
private readonly whmcsInvoiceService: WhmcsInvoiceService,
|
|
||||||
private readonly whmcsClientService: WhmcsClientService,
|
|
||||||
private readonly whmcsConnectionService: WhmcsConnectionFacade,
|
|
||||||
private readonly cacheService: WhmcsCacheService,
|
|
||||||
private readonly mappingsService: MappingsService,
|
|
||||||
@Inject(Logger) private readonly logger: Logger
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all subscriptions for a user
|
|
||||||
*/
|
|
||||||
async getSubscriptions(
|
|
||||||
userId: string,
|
|
||||||
options: GetSubscriptionsOptions = {}
|
|
||||||
): Promise<SubscriptionList> {
|
|
||||||
const { status } = options;
|
|
||||||
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId);
|
|
||||||
|
|
||||||
return withErrorHandling(
|
|
||||||
async () => {
|
|
||||||
const subscriptionList = await this.whmcsSubscriptionService.getSubscriptions(
|
|
||||||
whmcsClientId,
|
|
||||||
userId,
|
|
||||||
status === undefined ? {} : { status }
|
|
||||||
);
|
|
||||||
|
|
||||||
const parsed = subscriptionListSchema.parse(subscriptionList);
|
|
||||||
|
|
||||||
let subscriptions = parsed.subscriptions;
|
|
||||||
if (status) {
|
|
||||||
const normalizedStatus = subscriptionStatusSchema.parse(status);
|
|
||||||
subscriptions = subscriptions.filter(sub => sub.status === normalizedStatus);
|
|
||||||
}
|
|
||||||
|
|
||||||
return subscriptionListSchema.parse({
|
|
||||||
subscriptions,
|
|
||||||
totalCount: subscriptions.length,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
this.logger,
|
|
||||||
{
|
|
||||||
context: `Get subscriptions for user ${userId}`,
|
|
||||||
fallbackMessage: "Failed to retrieve subscriptions",
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get individual subscription by ID
|
|
||||||
*/
|
|
||||||
async getSubscriptionById(userId: string, subscriptionId: number): Promise<Subscription> {
|
|
||||||
// Validate subscription ID
|
|
||||||
if (!subscriptionId || subscriptionId < 1) {
|
|
||||||
throw new BadRequestException("Subscription ID must be a positive number");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get WHMCS client ID from user mapping
|
|
||||||
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId);
|
|
||||||
|
|
||||||
return withErrorHandling(
|
|
||||||
async () => {
|
|
||||||
const subscription = await this.whmcsSubscriptionService.getSubscriptionById(
|
|
||||||
whmcsClientId,
|
|
||||||
userId,
|
|
||||||
subscriptionId
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log(`Retrieved subscription ${subscriptionId} for user ${userId}`, {
|
|
||||||
productName: subscription.productName,
|
|
||||||
status: subscription.status,
|
|
||||||
amount: subscription.amount,
|
|
||||||
currency: subscription.currency,
|
|
||||||
});
|
|
||||||
|
|
||||||
return subscription;
|
|
||||||
},
|
|
||||||
this.logger,
|
|
||||||
{
|
|
||||||
context: `Get subscription ${subscriptionId} for user ${userId}`,
|
|
||||||
fallbackMessage: "Failed to retrieve subscription",
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get active subscriptions for a user
|
|
||||||
*/
|
|
||||||
async getActiveSubscriptions(userId: string): Promise<Subscription[]> {
|
|
||||||
try {
|
|
||||||
const subscriptionList = await this.getSubscriptions(userId, {
|
|
||||||
status: "Active",
|
|
||||||
});
|
|
||||||
return subscriptionList.subscriptions;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to get active subscriptions for user ${userId}`, {
|
|
||||||
error: extractErrorMessage(error),
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get subscriptions by status
|
|
||||||
*/
|
|
||||||
async getSubscriptionsByStatus(
|
|
||||||
userId: string,
|
|
||||||
status: SubscriptionStatus
|
|
||||||
): Promise<Subscription[]> {
|
|
||||||
try {
|
|
||||||
const normalizedStatus = subscriptionStatusSchema.parse(status);
|
|
||||||
const subscriptionList = await this.getSubscriptions(userId, { status: normalizedStatus });
|
|
||||||
return subscriptionList.subscriptions;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to get ${status} subscriptions for user ${userId}`, {
|
|
||||||
error: extractErrorMessage(error),
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get subscription statistics for a user
|
|
||||||
*/
|
|
||||||
async getSubscriptionStats(userId: string): Promise<{
|
|
||||||
total: number;
|
|
||||||
active: number;
|
|
||||||
completed: number;
|
|
||||||
cancelled: number;
|
|
||||||
}> {
|
|
||||||
return withErrorHandling(
|
|
||||||
async () => {
|
|
||||||
const subscriptionList = await this.getSubscriptions(userId);
|
|
||||||
const subscriptions: Subscription[] = subscriptionList.subscriptions;
|
|
||||||
|
|
||||||
const stats = {
|
|
||||||
total: subscriptions.length,
|
|
||||||
active: subscriptions.filter(s => s.status === "Active").length,
|
|
||||||
completed: subscriptions.filter(s => s.status === "Completed").length,
|
|
||||||
cancelled: subscriptions.filter(s => s.status === "Cancelled").length,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.logger.log(`Generated subscription stats for user ${userId}`, stats);
|
|
||||||
|
|
||||||
return subscriptionStatsSchema.parse(stats);
|
|
||||||
},
|
|
||||||
this.logger,
|
|
||||||
{
|
|
||||||
context: `Generate subscription stats for user ${userId}`,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get subscriptions expiring soon (within next 30 days)
|
|
||||||
*/
|
|
||||||
async getExpiringSoon(userId: string, days: number = 30): Promise<Subscription[]> {
|
|
||||||
return withErrorHandling(
|
|
||||||
async () => {
|
|
||||||
const subscriptionList = await this.getSubscriptions(userId);
|
|
||||||
const subscriptions: Subscription[] = subscriptionList.subscriptions;
|
|
||||||
|
|
||||||
const cutoffDate = new Date();
|
|
||||||
cutoffDate.setDate(cutoffDate.getDate() + days);
|
|
||||||
|
|
||||||
const expiringSoon = subscriptions.filter(subscription => {
|
|
||||||
if (!subscription.nextDue || subscription.status !== "Active") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextDueDate = new Date(subscription.nextDue);
|
|
||||||
return nextDueDate <= cutoffDate;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`Found ${expiringSoon.length} subscriptions expiring within ${days} days for user ${userId}`
|
|
||||||
);
|
|
||||||
return expiringSoon;
|
|
||||||
},
|
|
||||||
this.logger,
|
|
||||||
{
|
|
||||||
context: `Get expiring subscriptions for user ${userId}`,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get recent subscription activity (newly created or status changed)
|
|
||||||
*/
|
|
||||||
async getRecentActivity(userId: string, days: number = 30): Promise<Subscription[]> {
|
|
||||||
return withErrorHandling(
|
|
||||||
async () => {
|
|
||||||
const subscriptionList = await this.getSubscriptions(userId);
|
|
||||||
const subscriptions = subscriptionList.subscriptions;
|
|
||||||
|
|
||||||
const cutoffDate = new Date();
|
|
||||||
cutoffDate.setDate(cutoffDate.getDate() - days);
|
|
||||||
|
|
||||||
const recentActivity = subscriptions.filter((subscription: Subscription) => {
|
|
||||||
const registrationDate = new Date(subscription.registrationDate);
|
|
||||||
return registrationDate >= cutoffDate;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`Found ${recentActivity.length} recent subscription activities within ${days} days for user ${userId}`
|
|
||||||
);
|
|
||||||
return recentActivity;
|
|
||||||
},
|
|
||||||
this.logger,
|
|
||||||
{
|
|
||||||
context: `Get recent subscription activity for user ${userId}`,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search subscriptions by product name or domain
|
|
||||||
*/
|
|
||||||
async searchSubscriptions(userId: string, query: string): Promise<Subscription[]> {
|
|
||||||
if (!query || query.trim().length < 2) {
|
|
||||||
throw new BadRequestException("Search query must be at least 2 characters long");
|
|
||||||
}
|
|
||||||
|
|
||||||
return withErrorHandling(
|
|
||||||
async () => {
|
|
||||||
const subscriptionList = await this.getSubscriptions(userId);
|
|
||||||
const subscriptions = subscriptionList.subscriptions;
|
|
||||||
|
|
||||||
const searchTerm = query.toLowerCase().trim();
|
|
||||||
const matches = subscriptions.filter((subscription: Subscription) => {
|
|
||||||
const productName = subscription.productName.toLowerCase();
|
|
||||||
const domain = subscription.domain?.toLowerCase() || "";
|
|
||||||
|
|
||||||
return productName.includes(searchTerm) || domain.includes(searchTerm);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`Found ${matches.length} subscriptions matching query "${query}" for user ${userId}`
|
|
||||||
);
|
|
||||||
return matches;
|
|
||||||
},
|
|
||||||
this.logger,
|
|
||||||
{
|
|
||||||
context: `Search subscriptions for user ${userId}`,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get invoices related to a specific subscription
|
|
||||||
*/
|
|
||||||
async getSubscriptionInvoices(
|
|
||||||
userId: string,
|
|
||||||
subscriptionId: number,
|
|
||||||
options: { page?: number; limit?: number } = {}
|
|
||||||
): Promise<InvoiceList> {
|
|
||||||
const { page = 1, limit = 10 } = options;
|
|
||||||
const batchSize = Math.min(100, Math.max(limit, 25));
|
|
||||||
|
|
||||||
return withErrorHandling(
|
|
||||||
async () => {
|
|
||||||
// Try page cache first
|
|
||||||
const cached = await this.cacheService.getSubscriptionInvoices(
|
|
||||||
userId,
|
|
||||||
subscriptionId,
|
|
||||||
page,
|
|
||||||
limit
|
|
||||||
);
|
|
||||||
if (cached) {
|
|
||||||
this.logger.debug(
|
|
||||||
`Cache hit for subscription invoices: user ${userId}, subscription ${subscriptionId}`
|
|
||||||
);
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try full list cache to avoid rescanning all WHMCS invoices per page
|
|
||||||
const cachedAll = await this.cacheService.getSubscriptionInvoicesAll(
|
|
||||||
userId,
|
|
||||||
subscriptionId
|
|
||||||
);
|
|
||||||
if (cachedAll) {
|
|
||||||
const startIndex = (page - 1) * limit;
|
|
||||||
const endIndex = startIndex + limit;
|
|
||||||
const paginatedInvoices = cachedAll.slice(startIndex, endIndex);
|
|
||||||
|
|
||||||
const result: InvoiceList = {
|
|
||||||
invoices: paginatedInvoices,
|
|
||||||
pagination: {
|
|
||||||
page,
|
|
||||||
totalPages: cachedAll.length === 0 ? 0 : Math.ceil(cachedAll.length / limit),
|
|
||||||
totalItems: cachedAll.length,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await this.cacheService.setSubscriptionInvoices(
|
|
||||||
userId,
|
|
||||||
subscriptionId,
|
|
||||||
page,
|
|
||||||
limit,
|
|
||||||
result
|
|
||||||
);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate subscription exists and belongs to user
|
|
||||||
await this.getSubscriptionById(userId, subscriptionId);
|
|
||||||
|
|
||||||
// Get WHMCS client ID from user mapping
|
|
||||||
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId);
|
|
||||||
|
|
||||||
const relatedInvoices: Invoice[] = [];
|
|
||||||
let currentPage = 1;
|
|
||||||
let totalPages = 1;
|
|
||||||
|
|
||||||
do {
|
|
||||||
const invoiceBatch = await this.whmcsInvoiceService.getInvoicesWithItems(
|
|
||||||
whmcsClientId,
|
|
||||||
userId,
|
|
||||||
{ page: currentPage, limit: batchSize }
|
|
||||||
);
|
|
||||||
|
|
||||||
totalPages = invoiceBatch.pagination.totalPages;
|
|
||||||
|
|
||||||
for (const invoice of invoiceBatch.invoices) {
|
|
||||||
if (!invoice.items?.length) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasMatchingService = invoice.items.some(
|
|
||||||
(item: InvoiceItem) => item.serviceId === subscriptionId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hasMatchingService) {
|
|
||||||
relatedInvoices.push(invoice);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
currentPage += 1;
|
|
||||||
} while (currentPage <= totalPages);
|
|
||||||
|
|
||||||
// Apply pagination to filtered results
|
|
||||||
const startIndex = (page - 1) * limit;
|
|
||||||
const endIndex = startIndex + limit;
|
|
||||||
const paginatedInvoices = relatedInvoices.slice(startIndex, endIndex);
|
|
||||||
|
|
||||||
const result: InvoiceList = {
|
|
||||||
invoices: paginatedInvoices,
|
|
||||||
pagination: {
|
|
||||||
page,
|
|
||||||
totalPages:
|
|
||||||
relatedInvoices.length === 0 ? 0 : Math.ceil(relatedInvoices.length / limit),
|
|
||||||
totalItems: relatedInvoices.length,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`Retrieved ${paginatedInvoices.length} invoices for subscription ${subscriptionId}`,
|
|
||||||
{
|
|
||||||
userId,
|
|
||||||
subscriptionId,
|
|
||||||
totalRelated: relatedInvoices.length,
|
|
||||||
page,
|
|
||||||
limit,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
await this.cacheService.setSubscriptionInvoices(
|
|
||||||
userId,
|
|
||||||
subscriptionId,
|
|
||||||
page,
|
|
||||||
limit,
|
|
||||||
result
|
|
||||||
);
|
|
||||||
await this.cacheService.setSubscriptionInvoicesAll(userId, subscriptionId, relatedInvoices);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
this.logger,
|
|
||||||
{
|
|
||||||
context: `Get invoices for subscription ${subscriptionId}`,
|
|
||||||
fallbackMessage: "Failed to retrieve subscription invoices",
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Invalidate subscription cache for a user
|
|
||||||
*/
|
|
||||||
async invalidateCache(userId: string, subscriptionId?: number): Promise<void> {
|
|
||||||
try {
|
|
||||||
if (subscriptionId) {
|
|
||||||
await this.whmcsSubscriptionService.invalidateSubscriptionCache(userId, subscriptionId);
|
|
||||||
} else {
|
|
||||||
await this.whmcsClientService.invalidateUserCache(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`Invalidated subscription cache for user ${userId}${subscriptionId ? `, subscription ${subscriptionId}` : ""}`
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to invalidate subscription cache for user ${userId}`, {
|
|
||||||
error: extractErrorMessage(error),
|
|
||||||
subscriptionId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Health check for subscription service
|
|
||||||
*/
|
|
||||||
async healthCheck(): Promise<{ status: string; details: unknown }> {
|
|
||||||
try {
|
|
||||||
const whmcsHealthy = await this.whmcsConnectionService.healthCheck();
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: whmcsHealthy ? "healthy" : "unhealthy",
|
|
||||||
details: {
|
|
||||||
whmcsApi: whmcsHealthy ? "connected" : "disconnected",
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error("Subscription service health check failed", {
|
|
||||||
error: extractErrorMessage(error),
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
status: "unhealthy",
|
|
||||||
details: {
|
|
||||||
error: extractErrorMessage(error),
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -106,6 +106,17 @@ export const useCheckoutStore = create<CheckoutStore>()(
|
|||||||
name: "checkout-store",
|
name: "checkout-store",
|
||||||
version: 1,
|
version: 1,
|
||||||
storage: createJSONStorage(() => localStorage),
|
storage: createJSONStorage(() => localStorage),
|
||||||
|
migrate: (persistedState, version) => {
|
||||||
|
// Version 0 (or undefined) -> Version 1: no structural changes, just initialize missing fields
|
||||||
|
if (version === 0 || version === undefined) {
|
||||||
|
const state = persistedState as Partial<CheckoutState>;
|
||||||
|
return {
|
||||||
|
...initialState,
|
||||||
|
...state,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return persistedState as CheckoutState;
|
||||||
|
},
|
||||||
partialize: state => ({
|
partialize: state => ({
|
||||||
// Persist only essential data
|
// Persist only essential data
|
||||||
cartItem: state.cartItem
|
cartItem: state.cartItem
|
||||||
|
|||||||
@ -13,6 +13,8 @@ import {
|
|||||||
} from "@customer-portal/domain/support";
|
} from "@customer-portal/domain/support";
|
||||||
import { apiClient, ApiError, isApiError } from "@/core/api";
|
import { apiClient, ApiError, isApiError } from "@/core/api";
|
||||||
|
|
||||||
|
const SEND_ERROR_MESSAGE = "Failed to send message";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PublicContactView - Contact page with form, phone, chat, and location info
|
* PublicContactView - Contact page with form, phone, chat, and location info
|
||||||
*/
|
*/
|
||||||
@ -29,14 +31,14 @@ export function PublicContactView() {
|
|||||||
setIsSubmitted(true);
|
setIsSubmitted(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isApiError(error)) {
|
if (isApiError(error)) {
|
||||||
setSubmitError(error.message || "Failed to send message");
|
setSubmitError(error.message || SEND_ERROR_MESSAGE);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (error instanceof ApiError) {
|
if (error instanceof ApiError) {
|
||||||
setSubmitError(error.message || "Failed to send message");
|
setSubmitError(error.message || SEND_ERROR_MESSAGE);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSubmitError(error instanceof Error ? error.message : "Failed to send message");
|
setSubmitError(error instanceof Error ? error.message : SEND_ERROR_MESSAGE);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@ -27,6 +27,8 @@ import { formatIsoDate, formatIsoRelative } from "@/shared/utils";
|
|||||||
import type { CaseMessage } from "@customer-portal/domain/support";
|
import type { CaseMessage } from "@customer-portal/domain/support";
|
||||||
import { CLOSED_STATUSES } from "@customer-portal/domain/support";
|
import { CLOSED_STATUSES } from "@customer-portal/domain/support";
|
||||||
|
|
||||||
|
const SUPPORT_HREF = "/account/support";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Helper Functions
|
// Helper Functions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -161,7 +163,7 @@ export function SupportCaseDetailView({ caseId }: SupportCaseDetailViewProps) {
|
|||||||
actions={
|
actions={
|
||||||
<Button
|
<Button
|
||||||
as="a"
|
as="a"
|
||||||
href="/account/support"
|
href={SUPPORT_HREF}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
leftIcon={<ArrowLeftIcon className="h-4 w-4" />}
|
leftIcon={<ArrowLeftIcon className="h-4 w-4" />}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,67 +0,0 @@
|
|||||||
import { NextResponse, type NextRequest } from "next/server";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Public paths that don't require authentication.
|
|
||||||
* These routes are accessible without auth cookies.
|
|
||||||
*/
|
|
||||||
const PUBLIC_PATHS = ["/auth/", "/", "/services", "/about", "/contact", "/vpn-configuration"];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a path is public (doesn't require authentication)
|
|
||||||
*/
|
|
||||||
function isPublicPath(pathname: string): boolean {
|
|
||||||
return PUBLIC_PATHS.some(publicPath => {
|
|
||||||
if (publicPath.endsWith("/")) {
|
|
||||||
// Path prefix match (e.g., /auth/ matches /auth/login)
|
|
||||||
return pathname.startsWith(publicPath);
|
|
||||||
}
|
|
||||||
// Exact match
|
|
||||||
return pathname === publicPath;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Edge middleware for optimistic auth checks.
|
|
||||||
*
|
|
||||||
* This middleware runs at the edge before requests reach the origin.
|
|
||||||
* It performs a fast cookie-based check to redirect unauthenticated
|
|
||||||
* users to the login page, reducing unnecessary BFF calls.
|
|
||||||
*
|
|
||||||
* Note: This is an optimistic check. The BFF performs the authoritative
|
|
||||||
* token validation. This middleware only checks for cookie presence.
|
|
||||||
*/
|
|
||||||
export function middleware(request: NextRequest) {
|
|
||||||
const { pathname } = request.nextUrl;
|
|
||||||
|
|
||||||
// Allow public routes
|
|
||||||
if (isPublicPath(pathname)) {
|
|
||||||
return NextResponse.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for auth cookies (optimistic check - BFF validates the token)
|
|
||||||
const hasAccessToken = request.cookies.has("access_token");
|
|
||||||
const hasRefreshToken = request.cookies.has("refresh_token");
|
|
||||||
|
|
||||||
if (!hasAccessToken && !hasRefreshToken) {
|
|
||||||
// No auth cookies present - redirect to login
|
|
||||||
const loginUrl = new URL("/auth/login", request.url);
|
|
||||||
loginUrl.searchParams.set("next", pathname);
|
|
||||||
return NextResponse.redirect(loginUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
export const config = {
|
|
||||||
matcher: [
|
|
||||||
/*
|
|
||||||
* Match all request paths except:
|
|
||||||
* - api (API routes)
|
|
||||||
* - _next/static (static files)
|
|
||||||
* - _next/image (image optimization files)
|
|
||||||
* - favicon.ico (favicon file)
|
|
||||||
* - public assets (images, fonts, etc.)
|
|
||||||
*/
|
|
||||||
"/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|woff|woff2|ttf|eot)$).*)",
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@ -1,47 +1,41 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Next.js 16 Proxy (formerly Middleware)
|
* Next.js 16 Proxy (replaces deprecated Middleware)
|
||||||
*
|
*
|
||||||
* Generates a cryptographic nonce for each request to allow inline scripts
|
* Handles:
|
||||||
* while maintaining Content Security Policy protection.
|
* 1. Optimistic auth checks - redirects unauthenticated users to login
|
||||||
|
* 2. CSP nonce generation for inline script protection
|
||||||
|
* 3. Security headers
|
||||||
*
|
*
|
||||||
|
* @see https://nextjs.org/docs/app/guides/authentication
|
||||||
* @see https://nextjs.org/docs/app/guides/content-security-policy
|
* @see https://nextjs.org/docs/app/guides/content-security-policy
|
||||||
* @see https://nextjs.org/docs/messages/middleware-to-proxy
|
* @see https://nextjs.org/docs/messages/middleware-to-proxy
|
||||||
*/
|
*/
|
||||||
export function proxy(request: NextRequest) {
|
|
||||||
// Generate a random nonce for this request
|
|
||||||
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
|
|
||||||
|
|
||||||
// Determine environment
|
/**
|
||||||
const isDev = process.env["NODE_ENV"] === "development";
|
* Public paths that don't require authentication.
|
||||||
|
* These routes are accessible without auth cookies.
|
||||||
|
*/
|
||||||
|
const PUBLIC_PATHS = ["/auth/", "/", "/services", "/about", "/contact", "/vpn-configuration"];
|
||||||
|
|
||||||
// Build CSP header value
|
/**
|
||||||
const cspHeader = buildCSP(nonce, isDev);
|
* Check if a path is public (doesn't require authentication)
|
||||||
|
*/
|
||||||
// Clone the request headers
|
function isPublicPath(pathname: string): boolean {
|
||||||
const requestHeaders = new Headers(request.headers);
|
return PUBLIC_PATHS.some(publicPath => {
|
||||||
requestHeaders.set("x-nonce", nonce);
|
if (publicPath.endsWith("/")) {
|
||||||
|
// Path prefix match (e.g., /auth/ matches /auth/login)
|
||||||
// Create response with updated headers
|
return pathname.startsWith(publicPath);
|
||||||
const response = NextResponse.next({
|
}
|
||||||
request: {
|
// Exact match
|
||||||
headers: requestHeaders,
|
return pathname === publicPath;
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set CSP header on response
|
|
||||||
response.headers.set("Content-Security-Policy", cspHeader);
|
|
||||||
|
|
||||||
// Add additional security headers
|
|
||||||
response.headers.set("X-Frame-Options", "DENY");
|
|
||||||
response.headers.set("X-Content-Type-Options", "nosniff");
|
|
||||||
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
||||||
response.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build Content Security Policy header
|
||||||
|
*/
|
||||||
function buildCSP(nonce: string, isDev: boolean): string {
|
function buildCSP(nonce: string, isDev: boolean): string {
|
||||||
if (isDev) {
|
if (isDev) {
|
||||||
// Development: More permissive for HMR and dev tools
|
// Development: More permissive for HMR and dev tools
|
||||||
@ -75,22 +69,65 @@ function buildCSP(nonce: string, isDev: boolean): string {
|
|||||||
].join("; ");
|
].join("; ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function proxy(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
|
// --- Auth Check ---
|
||||||
|
// Skip auth for public routes
|
||||||
|
if (!isPublicPath(pathname)) {
|
||||||
|
// Check for auth cookies (optimistic check - BFF validates the token)
|
||||||
|
const hasAccessToken = request.cookies.has("access_token");
|
||||||
|
const hasRefreshToken = request.cookies.has("refresh_token");
|
||||||
|
|
||||||
|
if (!hasAccessToken && !hasRefreshToken) {
|
||||||
|
// No auth cookies present - redirect to login
|
||||||
|
const loginUrl = new URL("/auth/login", request.url);
|
||||||
|
loginUrl.searchParams.set("next", pathname);
|
||||||
|
return NextResponse.redirect(loginUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CSP & Security Headers ---
|
||||||
|
// Generate a random nonce for this request
|
||||||
|
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
|
||||||
|
const isDev = process.env["NODE_ENV"] === "development";
|
||||||
|
const cspHeader = buildCSP(nonce, isDev);
|
||||||
|
|
||||||
|
// Clone the request headers and add nonce
|
||||||
|
const requestHeaders = new Headers(request.headers);
|
||||||
|
requestHeaders.set("x-nonce", nonce);
|
||||||
|
|
||||||
|
// Create response with updated headers
|
||||||
|
const response = NextResponse.next({
|
||||||
|
request: {
|
||||||
|
headers: requestHeaders,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set CSP header on response
|
||||||
|
response.headers.set("Content-Security-Policy", cspHeader);
|
||||||
|
|
||||||
|
// Add additional security headers
|
||||||
|
response.headers.set("X-Frame-Options", "DENY");
|
||||||
|
response.headers.set("X-Content-Type-Options", "nosniff");
|
||||||
|
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||||
|
response.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
// Apply proxy to all routes except static assets
|
// Apply proxy to all routes except static assets
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: [
|
||||||
/*
|
/*
|
||||||
* Match all request paths except:
|
* Match all request paths except:
|
||||||
|
* - api (API routes)
|
||||||
* - _next/static (static files)
|
* - _next/static (static files)
|
||||||
* - _next/image (image optimization files)
|
* - _next/image (image optimization files)
|
||||||
* - favicon.ico, sitemap.xml, robots.txt (metadata files)
|
* - favicon.ico, sitemap.xml, robots.txt (metadata files)
|
||||||
* - _health (health check endpoint)
|
* - _health (health check endpoint)
|
||||||
|
* - Static assets (svg, png, jpg, etc.)
|
||||||
*/
|
*/
|
||||||
{
|
"/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|_health|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|woff|woff2|ttf|eot)$).*)",
|
||||||
source: "/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|_health).*)",
|
|
||||||
missing: [
|
|
||||||
{ type: "header", key: "next-router-prefetch" },
|
|
||||||
{ type: "header", key: "purpose", value: "prefetch" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { Formatting } from "@customer-portal/domain/toolkit";
|
import { Formatting } from "@customer-portal/domain/toolkit";
|
||||||
|
|
||||||
|
const INVALID_DATE = "Invalid date";
|
||||||
|
|
||||||
export type FormatDateFallbackOptions = {
|
export type FormatDateFallbackOptions = {
|
||||||
fallback?: string | undefined;
|
fallback?: string | undefined;
|
||||||
locale?: string | undefined;
|
locale?: string | undefined;
|
||||||
@ -23,7 +25,7 @@ export function formatIsoDate(
|
|||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
if (!iso) return fallback;
|
if (!iso) return fallback;
|
||||||
if (!Formatting.isValidDate(iso)) return "Invalid date";
|
if (!Formatting.isValidDate(iso)) return INVALID_DATE;
|
||||||
|
|
||||||
return Formatting.formatDate(iso, {
|
return Formatting.formatDate(iso, {
|
||||||
locale: locale ?? undefined,
|
locale: locale ?? undefined,
|
||||||
@ -40,7 +42,7 @@ export function formatIsoRelative(
|
|||||||
): string {
|
): string {
|
||||||
const { fallback = "N/A", locale } = options;
|
const { fallback = "N/A", locale } = options;
|
||||||
if (!iso) return fallback;
|
if (!iso) return fallback;
|
||||||
if (!Formatting.isValidDate(iso)) return "Invalid date";
|
if (!Formatting.isValidDate(iso)) return INVALID_DATE;
|
||||||
return Formatting.formatRelativeDate(iso, { locale: locale ?? undefined });
|
return Formatting.formatRelativeDate(iso, { locale: locale ?? undefined });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,12 +52,12 @@ export function formatIsoMonthDay(
|
|||||||
): string {
|
): string {
|
||||||
const { fallback = "N/A", locale = "en-US" } = options;
|
const { fallback = "N/A", locale = "en-US" } = options;
|
||||||
if (!iso) return fallback;
|
if (!iso) return fallback;
|
||||||
if (!Formatting.isValidDate(iso)) return "Invalid date";
|
if (!Formatting.isValidDate(iso)) return INVALID_DATE;
|
||||||
try {
|
try {
|
||||||
const date = new Date(iso);
|
const date = new Date(iso);
|
||||||
return date.toLocaleDateString(locale, { month: "short", day: "numeric" });
|
return date.toLocaleDateString(locale, { month: "short", day: "numeric" });
|
||||||
} catch {
|
} catch {
|
||||||
return "Invalid date";
|
return INVALID_DATE;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -446,6 +446,30 @@ export default [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// BFF Architecture: Services must use Facades, not direct connection internals
|
||||||
|
// This enforces the facade pattern for integration layer access
|
||||||
|
// Target: HTTP client services in connection/ directories (e.g., whmcs-http-client.service.ts)
|
||||||
|
// Note: Domain services like WhmcsClientService (customer operations) are allowed
|
||||||
|
// =============================================================================
|
||||||
|
{
|
||||||
|
files: ["apps/bff/src/modules/**/*.service.ts"],
|
||||||
|
rules: {
|
||||||
|
"no-restricted-imports": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
patterns: [
|
||||||
|
{
|
||||||
|
group: ["@bff/integrations/*/connection/**/*"],
|
||||||
|
message:
|
||||||
|
"Do not import connection internals (HTTP clients, etc.) directly. Use the facade from @bff/integrations/*/facades/* instead.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Node globals for tooling/config files
|
// Node globals for tooling/config files
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@ -70,6 +70,36 @@ export const ErrorCode = {
|
|||||||
TIMEOUT: "NET_002",
|
TIMEOUT: "NET_002",
|
||||||
RATE_LIMITED: "NET_003",
|
RATE_LIMITED: "NET_003",
|
||||||
|
|
||||||
|
// SIM Errors (SIM_*)
|
||||||
|
SIM_ACTIVATION_FEE_REQUIRED: "SIM_001",
|
||||||
|
SIM_NOT_FOUND: "SIM_002",
|
||||||
|
SIM_PLAN_CHANGE_FAILED: "SIM_003",
|
||||||
|
SIM_TOPUP_FAILED: "SIM_004",
|
||||||
|
SIM_ACTIVATION_FAILED: "SIM_005",
|
||||||
|
SIM_CANCELLATION_FAILED: "SIM_006",
|
||||||
|
|
||||||
|
// Internet Errors (INT_*)
|
||||||
|
INTERNET_ELIGIBILITY_NOT_REQUESTED: "INT_001",
|
||||||
|
INTERNET_ELIGIBILITY_PENDING: "INT_002",
|
||||||
|
INTERNET_INELIGIBLE: "INT_003",
|
||||||
|
INTERNET_SERVICE_EXISTS: "INT_004",
|
||||||
|
INTERNET_CHECK_FAILED: "INT_005",
|
||||||
|
|
||||||
|
// Order Validation Errors (ORD_*)
|
||||||
|
USER_MAPPING_NOT_FOUND: "ORD_001",
|
||||||
|
WHMCS_CLIENT_NOT_LINKED: "ORD_002",
|
||||||
|
NO_PAYMENT_METHOD: "ORD_003",
|
||||||
|
INVALID_SKU: "ORD_004",
|
||||||
|
RESIDENCE_CARD_NOT_SUBMITTED: "ORD_005",
|
||||||
|
RESIDENCE_CARD_REJECTED: "ORD_006",
|
||||||
|
ORDER_VALIDATION_FAILED: "ORD_007",
|
||||||
|
FULFILLMENT_FAILED: "ORD_008",
|
||||||
|
|
||||||
|
// Subscription Errors (SUB_*)
|
||||||
|
SUBSCRIPTION_NOT_FOUND: "SUB_001",
|
||||||
|
SUBSCRIPTION_CANCELLATION_FAILED: "SUB_002",
|
||||||
|
SUBSCRIPTION_UPDATE_FAILED: "SUB_003",
|
||||||
|
|
||||||
// Generic
|
// Generic
|
||||||
UNKNOWN: "GEN_001",
|
UNKNOWN: "GEN_001",
|
||||||
} as const;
|
} as const;
|
||||||
@ -133,6 +163,42 @@ export const ErrorMessages: Record<ErrorCodeType, string> = {
|
|||||||
[ErrorCode.TIMEOUT]: "The request timed out. Please try again.",
|
[ErrorCode.TIMEOUT]: "The request timed out. Please try again.",
|
||||||
[ErrorCode.RATE_LIMITED]: "Too many requests. Please wait a moment and try again.",
|
[ErrorCode.RATE_LIMITED]: "Too many requests. Please wait a moment and try again.",
|
||||||
|
|
||||||
|
// SIM
|
||||||
|
[ErrorCode.SIM_ACTIVATION_FEE_REQUIRED]: "SIM orders require an activation fee.",
|
||||||
|
[ErrorCode.SIM_NOT_FOUND]: "SIM subscription not found.",
|
||||||
|
[ErrorCode.SIM_PLAN_CHANGE_FAILED]: "Failed to change SIM plan. Please try again.",
|
||||||
|
[ErrorCode.SIM_TOPUP_FAILED]: "Failed to top up SIM data. Please try again.",
|
||||||
|
[ErrorCode.SIM_ACTIVATION_FAILED]: "SIM activation failed. Please contact support.",
|
||||||
|
[ErrorCode.SIM_CANCELLATION_FAILED]: "SIM cancellation failed. Please try again.",
|
||||||
|
|
||||||
|
// Internet
|
||||||
|
[ErrorCode.INTERNET_ELIGIBILITY_NOT_REQUESTED]:
|
||||||
|
"Internet eligibility review is required before ordering.",
|
||||||
|
[ErrorCode.INTERNET_ELIGIBILITY_PENDING]:
|
||||||
|
"Internet eligibility review is still in progress. Please wait for review to complete.",
|
||||||
|
[ErrorCode.INTERNET_INELIGIBLE]:
|
||||||
|
"Internet service is not available for your address. Please contact support.",
|
||||||
|
[ErrorCode.INTERNET_SERVICE_EXISTS]:
|
||||||
|
"An active Internet service already exists for this account.",
|
||||||
|
[ErrorCode.INTERNET_CHECK_FAILED]: "Unable to verify Internet eligibility. Please try again.",
|
||||||
|
|
||||||
|
// Order Validation
|
||||||
|
[ErrorCode.USER_MAPPING_NOT_FOUND]: "User account mapping is required before ordering.",
|
||||||
|
[ErrorCode.WHMCS_CLIENT_NOT_LINKED]: "Billing system integration is required before ordering.",
|
||||||
|
[ErrorCode.NO_PAYMENT_METHOD]: "A payment method is required before ordering.",
|
||||||
|
[ErrorCode.INVALID_SKU]: "One or more products in your order are invalid.",
|
||||||
|
[ErrorCode.RESIDENCE_CARD_NOT_SUBMITTED]: "Residence card submission is required for SIM orders.",
|
||||||
|
[ErrorCode.RESIDENCE_CARD_REJECTED]:
|
||||||
|
"Your residence card was rejected. Please resubmit and try again.",
|
||||||
|
[ErrorCode.ORDER_VALIDATION_FAILED]: "Order validation failed. Please check your order details.",
|
||||||
|
[ErrorCode.FULFILLMENT_FAILED]: "Order fulfillment failed. Please contact support.",
|
||||||
|
|
||||||
|
// Subscription
|
||||||
|
[ErrorCode.SUBSCRIPTION_NOT_FOUND]: "Subscription not found.",
|
||||||
|
[ErrorCode.SUBSCRIPTION_CANCELLATION_FAILED]:
|
||||||
|
"Subscription cancellation failed. Please try again.",
|
||||||
|
[ErrorCode.SUBSCRIPTION_UPDATE_FAILED]: "Subscription update failed. Please try again.",
|
||||||
|
|
||||||
// Generic
|
// Generic
|
||||||
[ErrorCode.UNKNOWN]: "An unexpected error occurred. Please try again.",
|
[ErrorCode.UNKNOWN]: "An unexpected error occurred. Please try again.",
|
||||||
};
|
};
|
||||||
@ -363,6 +429,168 @@ export const ErrorMetadata: Record<ErrorCodeType, ErrorMetadata> = {
|
|||||||
logLevel: "warn",
|
logLevel: "warn",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// SIM Errors
|
||||||
|
[ErrorCode.SIM_ACTIVATION_FEE_REQUIRED]: {
|
||||||
|
category: "validation",
|
||||||
|
severity: "low",
|
||||||
|
shouldLogout: false,
|
||||||
|
shouldRetry: false,
|
||||||
|
logLevel: "info",
|
||||||
|
},
|
||||||
|
[ErrorCode.SIM_NOT_FOUND]: {
|
||||||
|
category: "validation",
|
||||||
|
severity: "low",
|
||||||
|
shouldLogout: false,
|
||||||
|
shouldRetry: false,
|
||||||
|
logLevel: "warn",
|
||||||
|
},
|
||||||
|
[ErrorCode.SIM_PLAN_CHANGE_FAILED]: {
|
||||||
|
category: "business",
|
||||||
|
severity: "medium",
|
||||||
|
shouldLogout: false,
|
||||||
|
shouldRetry: true,
|
||||||
|
logLevel: "error",
|
||||||
|
},
|
||||||
|
[ErrorCode.SIM_TOPUP_FAILED]: {
|
||||||
|
category: "business",
|
||||||
|
severity: "medium",
|
||||||
|
shouldLogout: false,
|
||||||
|
shouldRetry: true,
|
||||||
|
logLevel: "error",
|
||||||
|
},
|
||||||
|
[ErrorCode.SIM_ACTIVATION_FAILED]: {
|
||||||
|
category: "business",
|
||||||
|
severity: "medium",
|
||||||
|
shouldLogout: false,
|
||||||
|
shouldRetry: true,
|
||||||
|
logLevel: "error",
|
||||||
|
},
|
||||||
|
[ErrorCode.SIM_CANCELLATION_FAILED]: {
|
||||||
|
category: "business",
|
||||||
|
severity: "medium",
|
||||||
|
shouldLogout: false,
|
||||||
|
shouldRetry: true,
|
||||||
|
logLevel: "error",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Internet Errors
|
||||||
|
[ErrorCode.INTERNET_ELIGIBILITY_NOT_REQUESTED]: {
|
||||||
|
category: "validation",
|
||||||
|
severity: "low",
|
||||||
|
shouldLogout: false,
|
||||||
|
shouldRetry: false,
|
||||||
|
logLevel: "info",
|
||||||
|
},
|
||||||
|
[ErrorCode.INTERNET_ELIGIBILITY_PENDING]: {
|
||||||
|
category: "validation",
|
||||||
|
severity: "low",
|
||||||
|
shouldLogout: false,
|
||||||
|
shouldRetry: false,
|
||||||
|
logLevel: "info",
|
||||||
|
},
|
||||||
|
[ErrorCode.INTERNET_INELIGIBLE]: {
|
||||||
|
category: "validation",
|
||||||
|
severity: "low",
|
||||||
|
shouldLogout: false,
|
||||||
|
shouldRetry: false,
|
||||||
|
logLevel: "info",
|
||||||
|
},
|
||||||
|
[ErrorCode.INTERNET_SERVICE_EXISTS]: {
|
||||||
|
category: "validation",
|
||||||
|
severity: "low",
|
||||||
|
shouldLogout: false,
|
||||||
|
shouldRetry: false,
|
||||||
|
logLevel: "warn",
|
||||||
|
},
|
||||||
|
[ErrorCode.INTERNET_CHECK_FAILED]: {
|
||||||
|
category: "system",
|
||||||
|
severity: "medium",
|
||||||
|
shouldLogout: false,
|
||||||
|
shouldRetry: true,
|
||||||
|
logLevel: "error",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Order Validation Errors
|
||||||
|
[ErrorCode.USER_MAPPING_NOT_FOUND]: {
|
||||||
|
category: "validation",
|
||||||
|
severity: "low",
|
||||||
|
shouldLogout: false,
|
||||||
|
shouldRetry: false,
|
||||||
|
logLevel: "warn",
|
||||||
|
},
|
||||||
|
[ErrorCode.WHMCS_CLIENT_NOT_LINKED]: {
|
||||||
|
category: "validation",
|
||||||
|
severity: "low",
|
||||||
|
shouldLogout: false,
|
||||||
|
shouldRetry: false,
|
||||||
|
logLevel: "warn",
|
||||||
|
},
|
||||||
|
[ErrorCode.NO_PAYMENT_METHOD]: {
|
||||||
|
category: "validation",
|
||||||
|
severity: "low",
|
||||||
|
shouldLogout: false,
|
||||||
|
shouldRetry: false,
|
||||||
|
logLevel: "info",
|
||||||
|
},
|
||||||
|
[ErrorCode.INVALID_SKU]: {
|
||||||
|
category: "validation",
|
||||||
|
severity: "low",
|
||||||
|
shouldLogout: false,
|
||||||
|
shouldRetry: false,
|
||||||
|
logLevel: "warn",
|
||||||
|
},
|
||||||
|
[ErrorCode.RESIDENCE_CARD_NOT_SUBMITTED]: {
|
||||||
|
category: "validation",
|
||||||
|
severity: "low",
|
||||||
|
shouldLogout: false,
|
||||||
|
shouldRetry: false,
|
||||||
|
logLevel: "info",
|
||||||
|
},
|
||||||
|
[ErrorCode.RESIDENCE_CARD_REJECTED]: {
|
||||||
|
category: "validation",
|
||||||
|
severity: "low",
|
||||||
|
shouldLogout: false,
|
||||||
|
shouldRetry: false,
|
||||||
|
logLevel: "warn",
|
||||||
|
},
|
||||||
|
[ErrorCode.ORDER_VALIDATION_FAILED]: {
|
||||||
|
category: "validation",
|
||||||
|
severity: "low",
|
||||||
|
shouldLogout: false,
|
||||||
|
shouldRetry: false,
|
||||||
|
logLevel: "warn",
|
||||||
|
},
|
||||||
|
[ErrorCode.FULFILLMENT_FAILED]: {
|
||||||
|
category: "system",
|
||||||
|
severity: "high",
|
||||||
|
shouldLogout: false,
|
||||||
|
shouldRetry: true,
|
||||||
|
logLevel: "error",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Subscription Errors
|
||||||
|
[ErrorCode.SUBSCRIPTION_NOT_FOUND]: {
|
||||||
|
category: "validation",
|
||||||
|
severity: "low",
|
||||||
|
shouldLogout: false,
|
||||||
|
shouldRetry: false,
|
||||||
|
logLevel: "warn",
|
||||||
|
},
|
||||||
|
[ErrorCode.SUBSCRIPTION_CANCELLATION_FAILED]: {
|
||||||
|
category: "business",
|
||||||
|
severity: "medium",
|
||||||
|
shouldLogout: false,
|
||||||
|
shouldRetry: true,
|
||||||
|
logLevel: "error",
|
||||||
|
},
|
||||||
|
[ErrorCode.SUBSCRIPTION_UPDATE_FAILED]: {
|
||||||
|
category: "business",
|
||||||
|
severity: "medium",
|
||||||
|
shouldLogout: false,
|
||||||
|
shouldRetry: true,
|
||||||
|
logLevel: "error",
|
||||||
|
},
|
||||||
|
|
||||||
// Generic
|
// Generic
|
||||||
[ErrorCode.UNKNOWN]: {
|
[ErrorCode.UNKNOWN]: {
|
||||||
category: "system",
|
category: "system",
|
||||||
|
|||||||
@ -16,6 +16,55 @@ const normalizeCustomFieldEntries = (value: unknown): Array<Record<string, unkno
|
|||||||
return [];
|
return [];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert raw id value (string or number) to trimmed string key
|
||||||
|
*/
|
||||||
|
const toIdKey = (raw: unknown): string | undefined => {
|
||||||
|
if (typeof raw === "string") return raw.trim() || undefined;
|
||||||
|
if (typeof raw === "number") return String(raw);
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert raw value to string (handles string, number, boolean)
|
||||||
|
*/
|
||||||
|
const toStringValue = (raw: unknown): string | undefined => {
|
||||||
|
if (raw === undefined || raw === null) return undefined;
|
||||||
|
if (typeof raw === "string") return raw;
|
||||||
|
if (typeof raw === "number" || typeof raw === "boolean") return String(raw);
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a plain object as a field map (key-value pairs)
|
||||||
|
*/
|
||||||
|
const processPlainObjectFields = (obj: Record<string, unknown>): Record<string, string> => {
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const trimmedKey = key.trim();
|
||||||
|
if (trimmedKey) result[trimmedKey] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a single custom field entry and add to map
|
||||||
|
*/
|
||||||
|
const processCustomFieldEntry = (
|
||||||
|
entry: Record<string, unknown>,
|
||||||
|
map: Record<string, string>
|
||||||
|
): void => {
|
||||||
|
const id = toIdKey("id" in entry ? entry["id"] : undefined);
|
||||||
|
const name = typeof entry["name"] === "string" ? entry["name"].trim() : undefined;
|
||||||
|
const value = toStringValue("value" in entry ? entry["value"] : undefined);
|
||||||
|
|
||||||
|
if (!value) return;
|
||||||
|
if (id) map[id] = value;
|
||||||
|
if (name) map[name] = value;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a lightweight map of WHMCS custom field identifiers to values.
|
* Build a lightweight map of WHMCS custom field identifiers to values.
|
||||||
* Accepts the documented WHMCS response shapes (array or { customfield }).
|
* Accepts the documented WHMCS response shapes (array or { customfield }).
|
||||||
@ -23,41 +72,16 @@ const normalizeCustomFieldEntries = (value: unknown): Array<Record<string, unkno
|
|||||||
export function getCustomFieldsMap(customFields: unknown): Record<string, string> {
|
export function getCustomFieldsMap(customFields: unknown): Record<string, string> {
|
||||||
if (!customFields) return {};
|
if (!customFields) return {};
|
||||||
|
|
||||||
|
// Handle plain object (key-value pairs)
|
||||||
if (isObject(customFields) && !Array.isArray(customFields) && !("customfield" in customFields)) {
|
if (isObject(customFields) && !Array.isArray(customFields) && !("customfield" in customFields)) {
|
||||||
return Object.entries(customFields).reduce<Record<string, string>>((acc, [key, value]) => {
|
return processPlainObjectFields(customFields);
|
||||||
if (typeof value === "string") {
|
|
||||||
const trimmedKey = key.trim();
|
|
||||||
if (trimmedKey) acc[trimmedKey] = value;
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle array or { customfield } structure
|
||||||
const map: Record<string, string> = {};
|
const map: Record<string, string> = {};
|
||||||
for (const entry of normalizeCustomFieldEntries(customFields)) {
|
for (const entry of normalizeCustomFieldEntries(customFields)) {
|
||||||
const idRaw = "id" in entry ? entry["id"] : undefined;
|
processCustomFieldEntry(entry, map);
|
||||||
const id =
|
|
||||||
typeof idRaw === "string"
|
|
||||||
? idRaw.trim()
|
|
||||||
: typeof idRaw === "number"
|
|
||||||
? String(idRaw)
|
|
||||||
: undefined;
|
|
||||||
const nameRaw = "name" in entry ? entry["name"] : undefined;
|
|
||||||
const name = typeof nameRaw === "string" ? nameRaw.trim() : undefined;
|
|
||||||
const rawValue = "value" in entry ? entry["value"] : undefined;
|
|
||||||
if (rawValue === undefined || rawValue === null) continue;
|
|
||||||
const value =
|
|
||||||
typeof rawValue === "string"
|
|
||||||
? rawValue
|
|
||||||
: typeof rawValue === "number" || typeof rawValue === "boolean"
|
|
||||||
? String(rawValue)
|
|
||||||
: undefined;
|
|
||||||
if (!value) continue;
|
|
||||||
|
|
||||||
if (id) map[id] = value;
|
|
||||||
if (name) map[name] = value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -71,6 +71,21 @@ export const transformWhmcsClientAddress = (raw: unknown): Address | undefined =
|
|||||||
return normalizeAddress(client);
|
return normalizeAddress(client);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapping from domain Address keys to WHMCS API field names
|
||||||
|
*/
|
||||||
|
const ADDRESS_FIELD_MAP: Record<keyof Address, string> = {
|
||||||
|
address1: "address1",
|
||||||
|
address2: "address2",
|
||||||
|
city: "city",
|
||||||
|
state: "state",
|
||||||
|
postcode: "postcode",
|
||||||
|
country: "country",
|
||||||
|
countryCode: "countrycode",
|
||||||
|
phoneNumber: "phonenumber",
|
||||||
|
phoneCountryCode: "phonecc",
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prepare address update for WHMCS API
|
* Prepare address update for WHMCS API
|
||||||
* Converts domain Address to WHMCS field names
|
* Converts domain Address to WHMCS field names
|
||||||
@ -79,14 +94,11 @@ export const prepareWhmcsClientAddressUpdate = (
|
|||||||
address: Partial<Address>
|
address: Partial<Address>
|
||||||
): Record<string, unknown> => {
|
): Record<string, unknown> => {
|
||||||
const update: Record<string, unknown> = {};
|
const update: Record<string, unknown> = {};
|
||||||
if (address.address1 !== undefined) update["address1"] = address.address1 ?? "";
|
for (const [domainKey, whmcsKey] of Object.entries(ADDRESS_FIELD_MAP)) {
|
||||||
if (address.address2 !== undefined) update["address2"] = address.address2 ?? "";
|
const value = address[domainKey as keyof Address];
|
||||||
if (address.city !== undefined) update["city"] = address.city ?? "";
|
if (value !== undefined) {
|
||||||
if (address.state !== undefined) update["state"] = address.state ?? "";
|
update[whmcsKey] = value ?? "";
|
||||||
if (address.postcode !== undefined) update["postcode"] = address.postcode ?? "";
|
}
|
||||||
if (address.country !== undefined) update["country"] = address.country ?? "";
|
}
|
||||||
if (address.countryCode !== undefined) update["countrycode"] = address.countryCode ?? "";
|
|
||||||
if (address.phoneNumber !== undefined) update["phonenumber"] = address.phoneNumber ?? "";
|
|
||||||
if (address.phoneCountryCode !== undefined) update["phonecc"] = address.phoneCountryCode ?? "";
|
|
||||||
return update;
|
return update;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -4,6 +4,12 @@
|
|||||||
* Business constants, enums, and templates for in-app notifications.
|
* Business constants, enums, and templates for in-app notifications.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Constants
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const ACTION_URL_SERVICES = "/account/services";
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Enums
|
// Enums
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@ -95,7 +101,7 @@ export const NOTIFICATION_TEMPLATES: Record<NotificationTypeValue, NotificationT
|
|||||||
type: NOTIFICATION_TYPE.ORDER_ACTIVATED,
|
type: NOTIFICATION_TYPE.ORDER_ACTIVATED,
|
||||||
title: "Service activated",
|
title: "Service activated",
|
||||||
message: "Your service is now active and ready to use.",
|
message: "Your service is now active and ready to use.",
|
||||||
actionUrl: "/account/services",
|
actionUrl: ACTION_URL_SERVICES,
|
||||||
actionLabel: "View Service",
|
actionLabel: "View Service",
|
||||||
priority: "high",
|
priority: "high",
|
||||||
},
|
},
|
||||||
@ -111,7 +117,7 @@ export const NOTIFICATION_TEMPLATES: Record<NotificationTypeValue, NotificationT
|
|||||||
type: NOTIFICATION_TYPE.CANCELLATION_SCHEDULED,
|
type: NOTIFICATION_TYPE.CANCELLATION_SCHEDULED,
|
||||||
title: "Cancellation scheduled",
|
title: "Cancellation scheduled",
|
||||||
message: "Your cancellation request has been received and scheduled.",
|
message: "Your cancellation request has been received and scheduled.",
|
||||||
actionUrl: "/account/services",
|
actionUrl: ACTION_URL_SERVICES,
|
||||||
actionLabel: "View Details",
|
actionLabel: "View Details",
|
||||||
priority: "medium",
|
priority: "medium",
|
||||||
},
|
},
|
||||||
@ -119,7 +125,7 @@ export const NOTIFICATION_TEMPLATES: Record<NotificationTypeValue, NotificationT
|
|||||||
type: NOTIFICATION_TYPE.CANCELLATION_COMPLETE,
|
type: NOTIFICATION_TYPE.CANCELLATION_COMPLETE,
|
||||||
title: "Service cancelled",
|
title: "Service cancelled",
|
||||||
message: "Your service has been successfully cancelled.",
|
message: "Your service has been successfully cancelled.",
|
||||||
actionUrl: "/account/services",
|
actionUrl: ACTION_URL_SERVICES,
|
||||||
actionLabel: "View Details",
|
actionLabel: "View Details",
|
||||||
priority: "medium",
|
priority: "medium",
|
||||||
},
|
},
|
||||||
|
|||||||
@ -31,6 +31,47 @@ function getItemClassSortPriority(itemClass?: string): number {
|
|||||||
return 4;
|
return 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract product info from Salesforce product record using field map
|
||||||
|
*/
|
||||||
|
function extractProductInfo(
|
||||||
|
product: SalesforceProduct2WithPricebookEntries,
|
||||||
|
productFields: SalesforceOrderFieldMap["product"]
|
||||||
|
): OrderItemDetails["product"] {
|
||||||
|
return {
|
||||||
|
id: ensureString(product.Id),
|
||||||
|
name: ensureString(product.Name),
|
||||||
|
sku: ensureString(product[productFields.sku]) ?? undefined,
|
||||||
|
itemClass: ensureString(product[productFields.itemClass]) ?? undefined,
|
||||||
|
whmcsProductId: resolveWhmcsProductId(product[productFields.whmcsProductId]),
|
||||||
|
internetOfferingType: ensureString(product[productFields.internetOfferingType]) ?? undefined,
|
||||||
|
internetPlanTier: ensureString(product[productFields.internetPlanTier]) ?? undefined,
|
||||||
|
vpnRegion: ensureString(product[productFields.vpnRegion]) ?? undefined,
|
||||||
|
isBundledAddon: coerceBoolean(product[productFields.isBundledAddon]),
|
||||||
|
bundledAddonId: ensureString(product[productFields.bundledAddon]) ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build order item summary from details
|
||||||
|
*/
|
||||||
|
function buildOrderItemSummary(details: OrderItemDetails): OrderItemSummary {
|
||||||
|
return {
|
||||||
|
productName: details.product?.name,
|
||||||
|
name: details.product?.name,
|
||||||
|
sku: details.product?.sku,
|
||||||
|
productId: details.product?.id,
|
||||||
|
status: undefined,
|
||||||
|
billingCycle: details.billingCycle,
|
||||||
|
itemClass: details.product?.itemClass,
|
||||||
|
quantity: details.quantity,
|
||||||
|
unitPrice: details.unitPrice,
|
||||||
|
totalPrice: details.totalPrice,
|
||||||
|
isBundledAddon: details.product?.isBundledAddon,
|
||||||
|
bundledAddonId: details.product?.bundledAddonId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transform a Salesforce OrderItem record into domain details + summary.
|
* Transform a Salesforce OrderItem record into domain details + summary.
|
||||||
*/
|
*/
|
||||||
@ -41,12 +82,9 @@ export function transformSalesforceOrderItem(
|
|||||||
const pricebookEntry = (record.PricebookEntry ?? null) as SalesforcePricebookEntryRecord | null;
|
const pricebookEntry = (record.PricebookEntry ?? null) as SalesforcePricebookEntryRecord | null;
|
||||||
const product = pricebookEntry?.Product2 as SalesforceProduct2WithPricebookEntries | undefined;
|
const product = pricebookEntry?.Product2 as SalesforceProduct2WithPricebookEntries | undefined;
|
||||||
|
|
||||||
const orderItemFields = fieldMap.orderItem;
|
|
||||||
const productFields = fieldMap.product;
|
|
||||||
|
|
||||||
const billingCycleRaw =
|
const billingCycleRaw =
|
||||||
record[orderItemFields.billingCycle] ??
|
record[fieldMap.orderItem.billingCycle] ??
|
||||||
(product ? (product[productFields.billingCycle] as unknown) : undefined);
|
(product ? (product[fieldMap.product.billingCycle] as unknown) : undefined);
|
||||||
const billingCycle =
|
const billingCycle =
|
||||||
billingCycleRaw !== undefined && billingCycleRaw !== null
|
billingCycleRaw !== undefined && billingCycleRaw !== null
|
||||||
? normalizeBillingCycle(billingCycleRaw)
|
? normalizeBillingCycle(billingCycleRaw)
|
||||||
@ -59,40 +97,10 @@ export function transformSalesforceOrderItem(
|
|||||||
unitPrice: coerceNumber(record.UnitPrice),
|
unitPrice: coerceNumber(record.UnitPrice),
|
||||||
totalPrice: coerceNumber(record.TotalPrice),
|
totalPrice: coerceNumber(record.TotalPrice),
|
||||||
billingCycle,
|
billingCycle,
|
||||||
product: product
|
product: product ? extractProductInfo(product, fieldMap.product) : undefined,
|
||||||
? {
|
|
||||||
id: ensureString(product.Id),
|
|
||||||
name: ensureString(product.Name),
|
|
||||||
sku: ensureString(product[productFields.sku]) ?? undefined,
|
|
||||||
itemClass: ensureString(product[productFields.itemClass]) ?? undefined,
|
|
||||||
whmcsProductId: resolveWhmcsProductId(product[productFields.whmcsProductId]),
|
|
||||||
internetOfferingType:
|
|
||||||
ensureString(product[productFields.internetOfferingType]) ?? undefined,
|
|
||||||
internetPlanTier: ensureString(product[productFields.internetPlanTier]) ?? undefined,
|
|
||||||
vpnRegion: ensureString(product[productFields.vpnRegion]) ?? undefined,
|
|
||||||
isBundledAddon: coerceBoolean(product[productFields.isBundledAddon]),
|
|
||||||
bundledAddonId: ensureString(product[productFields.bundledAddon]) ?? undefined,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return { details, summary: buildOrderItemSummary(details) };
|
||||||
details,
|
|
||||||
summary: {
|
|
||||||
productName: details.product?.name,
|
|
||||||
name: details.product?.name,
|
|
||||||
sku: details.product?.sku,
|
|
||||||
productId: details.product?.id,
|
|
||||||
status: undefined,
|
|
||||||
billingCycle: details.billingCycle,
|
|
||||||
itemClass: details.product?.itemClass,
|
|
||||||
quantity: details.quantity,
|
|
||||||
unitPrice: details.unitPrice,
|
|
||||||
totalPrice: details.totalPrice,
|
|
||||||
isBundledAddon: details.product?.isBundledAddon,
|
|
||||||
bundledAddonId: details.product?.bundledAddonId,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -44,6 +44,49 @@ function coerceNumber(value: unknown): number | undefined {
|
|||||||
// Base Product Mapper
|
// Base Product Mapper
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive unit price from pricebook entry or product fields
|
||||||
|
*/
|
||||||
|
function deriveUnitPrice(
|
||||||
|
pricebookEntry: SalesforcePricebookEntryRecord | undefined,
|
||||||
|
product: SalesforceProduct2WithPricebookEntries
|
||||||
|
): number | undefined {
|
||||||
|
return coerceNumber(pricebookEntry?.UnitPrice) ?? coerceNumber(product.Price__c);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive and assign price fields to base product
|
||||||
|
*/
|
||||||
|
function assignPrices(
|
||||||
|
base: CatalogProductBase,
|
||||||
|
product: SalesforceProduct2WithPricebookEntries,
|
||||||
|
pricebookEntry: SalesforcePricebookEntryRecord | undefined
|
||||||
|
): void {
|
||||||
|
const billingCycle = product.Billing_Cycle__c?.toLowerCase();
|
||||||
|
const unitPrice = deriveUnitPrice(pricebookEntry, product);
|
||||||
|
const monthlyField = coerceNumber(product.Monthly_Price__c);
|
||||||
|
const oneTimeField = coerceNumber(product.One_Time_Price__c);
|
||||||
|
|
||||||
|
if (unitPrice !== undefined) base.unitPrice = unitPrice;
|
||||||
|
if (monthlyField !== undefined) base.monthlyPrice = monthlyField;
|
||||||
|
if (oneTimeField !== undefined) base.oneTimePrice = oneTimeField;
|
||||||
|
|
||||||
|
// Derive primary price fallback based on billing cycle
|
||||||
|
const primaryPrice =
|
||||||
|
coerceNumber(pricebookEntry?.UnitPrice) ??
|
||||||
|
monthlyField ??
|
||||||
|
coerceNumber(product.Price__c) ??
|
||||||
|
oneTimeField;
|
||||||
|
if (primaryPrice === undefined) return;
|
||||||
|
|
||||||
|
const isMonthly = billingCycle === "monthly" || !billingCycle;
|
||||||
|
if (isMonthly) {
|
||||||
|
base.monthlyPrice = base.monthlyPrice ?? primaryPrice;
|
||||||
|
} else {
|
||||||
|
base.oneTimePrice = base.oneTimePrice ?? primaryPrice;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function baseProduct(
|
function baseProduct(
|
||||||
product: SalesforceProduct2WithPricebookEntries,
|
product: SalesforceProduct2WithPricebookEntries,
|
||||||
pricebookEntry?: SalesforcePricebookEntryRecord
|
pricebookEntry?: SalesforcePricebookEntryRecord
|
||||||
@ -59,38 +102,7 @@ function baseProduct(
|
|||||||
if (product.Billing_Cycle__c) base.billingCycle = product.Billing_Cycle__c;
|
if (product.Billing_Cycle__c) base.billingCycle = product.Billing_Cycle__c;
|
||||||
if (typeof product.Catalog_Order__c === "number") base.displayOrder = product.Catalog_Order__c;
|
if (typeof product.Catalog_Order__c === "number") base.displayOrder = product.Catalog_Order__c;
|
||||||
|
|
||||||
// Derive prices
|
assignPrices(base, product, pricebookEntry);
|
||||||
const billingCycle = product.Billing_Cycle__c?.toLowerCase();
|
|
||||||
const unitPriceFromPricebook = coerceNumber(pricebookEntry?.UnitPrice);
|
|
||||||
const priceField = coerceNumber(product.Price__c);
|
|
||||||
const monthlyField = coerceNumber(product.Monthly_Price__c);
|
|
||||||
const oneTimeField = coerceNumber(product.One_Time_Price__c);
|
|
||||||
|
|
||||||
if (unitPriceFromPricebook !== undefined) {
|
|
||||||
base.unitPrice = unitPriceFromPricebook;
|
|
||||||
} else if (priceField !== undefined) {
|
|
||||||
base.unitPrice = priceField;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (monthlyField !== undefined) {
|
|
||||||
base.monthlyPrice = monthlyField;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (oneTimeField !== undefined) {
|
|
||||||
base.oneTimePrice = oneTimeField;
|
|
||||||
}
|
|
||||||
|
|
||||||
const primaryPrice = unitPriceFromPricebook ?? monthlyField ?? priceField ?? oneTimeField;
|
|
||||||
|
|
||||||
if (primaryPrice !== undefined) {
|
|
||||||
if (billingCycle === "monthly") {
|
|
||||||
base.monthlyPrice = base.monthlyPrice ?? primaryPrice;
|
|
||||||
} else if (billingCycle) {
|
|
||||||
base.oneTimePrice = base.oneTimePrice ?? primaryPrice;
|
|
||||||
} else {
|
|
||||||
base.monthlyPrice = base.monthlyPrice ?? primaryPrice;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,14 @@
|
|||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Validation Constants
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const MSG_ACCOUNT_REQUIRED = "Account is required";
|
||||||
|
const YYYYMMDD_REGEX = /^\d{8}$/;
|
||||||
|
const MSG_SHIP_DATE_FORMAT = "Ship date must be in YYYYMMDD format";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Account Details & Traffic Info
|
// Account Details & Traffic Info
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -23,7 +31,7 @@ export const freebitAccountDetailsRequestSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const freebitTrafficInfoRequestSchema = z.object({
|
export const freebitTrafficInfoRequestSchema = z.object({
|
||||||
account: z.string().min(1, "Account is required"),
|
account: z.string().min(1, MSG_ACCOUNT_REQUIRED),
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -37,13 +45,13 @@ export const freebitTopUpOptionsSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const freebitTopUpRequestPayloadSchema = z.object({
|
export const freebitTopUpRequestPayloadSchema = z.object({
|
||||||
account: z.string().min(1, "Account is required"),
|
account: z.string().min(1, MSG_ACCOUNT_REQUIRED),
|
||||||
quotaMb: z.number().positive("Quota must be positive"),
|
quotaMb: z.number().positive("Quota must be positive"),
|
||||||
options: freebitTopUpOptionsSchema.optional(),
|
options: freebitTopUpOptionsSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const freebitTopUpApiRequestSchema = z.object({
|
export const freebitTopUpApiRequestSchema = z.object({
|
||||||
account: z.string().min(1, "Account is required"),
|
account: z.string().min(1, MSG_ACCOUNT_REQUIRED),
|
||||||
quota: z.number().positive("Quota must be positive"),
|
quota: z.number().positive("Quota must be positive"),
|
||||||
quotaCode: z.string().optional(),
|
quotaCode: z.string().optional(),
|
||||||
expire: z.string().optional(),
|
expire: z.string().optional(),
|
||||||
@ -55,43 +63,43 @@ export const freebitTopUpApiRequestSchema = z.object({
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export const freebitPlanChangeRequestSchema = z.object({
|
export const freebitPlanChangeRequestSchema = z.object({
|
||||||
account: z.string().min(1, "Account is required"),
|
account: z.string().min(1, MSG_ACCOUNT_REQUIRED),
|
||||||
newPlanCode: z.string().min(1, "New plan code is required"),
|
newPlanCode: z.string().min(1, "New plan code is required"),
|
||||||
assignGlobalIp: z.boolean().optional(),
|
assignGlobalIp: z.boolean().optional(),
|
||||||
scheduledAt: z.string().optional(),
|
scheduledAt: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const freebitPlanChangeApiRequestSchema = z.object({
|
export const freebitPlanChangeApiRequestSchema = z.object({
|
||||||
account: z.string().min(1, "Account is required"),
|
account: z.string().min(1, MSG_ACCOUNT_REQUIRED),
|
||||||
plancode: z.string().min(1, "Plan code is required"),
|
plancode: z.string().min(1, "Plan code is required"),
|
||||||
globalip: z.enum(["0", "1"]).optional(),
|
globalip: z.enum(["0", "1"]).optional(),
|
||||||
runTime: z.string().optional(),
|
runTime: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const freebitAddSpecRequestSchema = z.object({
|
export const freebitAddSpecRequestSchema = z.object({
|
||||||
account: z.string().min(1, "Account is required"),
|
account: z.string().min(1, MSG_ACCOUNT_REQUIRED),
|
||||||
specCode: z.string().min(1, "Spec code is required"),
|
specCode: z.string().min(1, "Spec code is required"),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
networkType: z.enum(["4G", "5G"]).optional(),
|
networkType: z.enum(["4G", "5G"]).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const freebitRemoveSpecRequestSchema = z.object({
|
export const freebitRemoveSpecRequestSchema = z.object({
|
||||||
account: z.string().min(1, "Account is required"),
|
account: z.string().min(1, MSG_ACCOUNT_REQUIRED),
|
||||||
specCode: z.string().min(1, "Spec code is required"),
|
specCode: z.string().min(1, "Spec code is required"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const freebitCancelPlanRequestSchema = z.object({
|
export const freebitCancelPlanRequestSchema = z.object({
|
||||||
account: z.string().min(1, "Account is required"),
|
account: z.string().min(1, MSG_ACCOUNT_REQUIRED),
|
||||||
runDate: z.string().optional(),
|
runDate: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const freebitCancelPlanApiRequestSchema = z.object({
|
export const freebitCancelPlanApiRequestSchema = z.object({
|
||||||
account: z.string().min(1, "Account is required"),
|
account: z.string().min(1, MSG_ACCOUNT_REQUIRED),
|
||||||
runTime: z.string().optional(),
|
runTime: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const freebitQuotaHistoryRequestSchema = z.object({
|
export const freebitQuotaHistoryRequestSchema = z.object({
|
||||||
account: z.string().min(1, "Account is required"),
|
account: z.string().min(1, MSG_ACCOUNT_REQUIRED),
|
||||||
fromDate: z.string().regex(/^\d{8}$/, "From date must be in YYYYMMDD format"),
|
fromDate: z.string().regex(/^\d{8}$/, "From date must be in YYYYMMDD format"),
|
||||||
toDate: z.string().regex(/^\d{8}$/, "To date must be in YYYYMMDD format"),
|
toDate: z.string().regex(/^\d{8}$/, "To date must be in YYYYMMDD format"),
|
||||||
});
|
});
|
||||||
@ -122,7 +130,7 @@ export const freebitEsimMnpSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const freebitEsimReissueRequestSchema = z.object({
|
export const freebitEsimReissueRequestSchema = z.object({
|
||||||
account: z.string().min(1, "Account is required"),
|
account: z.string().min(1, MSG_ACCOUNT_REQUIRED),
|
||||||
newEid: z.string().min(1, "New EID is required"),
|
newEid: z.string().min(1, "New EID is required"),
|
||||||
oldEid: z.string().optional(),
|
oldEid: z.string().optional(),
|
||||||
planCode: z.string().optional(),
|
planCode: z.string().optional(),
|
||||||
@ -132,13 +140,10 @@ export const freebitEsimReissueRequestSchema = z.object({
|
|||||||
export const freebitEsimAddAccountRequestSchema = z.object({
|
export const freebitEsimAddAccountRequestSchema = z.object({
|
||||||
authKey: z.string().min(1).optional(),
|
authKey: z.string().min(1).optional(),
|
||||||
aladinOperated: z.enum(["10", "20"]).default("10"),
|
aladinOperated: z.enum(["10", "20"]).default("10"),
|
||||||
account: z.string().min(1, "Account is required"),
|
account: z.string().min(1, MSG_ACCOUNT_REQUIRED),
|
||||||
eid: z.string().min(1, "EID is required"),
|
eid: z.string().min(1, "EID is required"),
|
||||||
addKind: z.enum(["N", "R"]).default("N"),
|
addKind: z.enum(["N", "R"]).default("N"),
|
||||||
shipDate: z
|
shipDate: z.string().regex(YYYYMMDD_REGEX, MSG_SHIP_DATE_FORMAT).optional(),
|
||||||
.string()
|
|
||||||
.regex(/^\d{8}$/, "Ship date must be in YYYYMMDD format")
|
|
||||||
.optional(),
|
|
||||||
planCode: z.string().optional(),
|
planCode: z.string().optional(),
|
||||||
contractLine: z.enum(["4G", "5G"]).optional(),
|
contractLine: z.enum(["4G", "5G"]).optional(),
|
||||||
mnp: freebitEsimMnpSchema.optional(),
|
mnp: freebitEsimMnpSchema.optional(),
|
||||||
@ -149,7 +154,7 @@ export const freebitEsimAddAccountRequestSchema = z.object({
|
|||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
export const freebitSimFeaturesRequestSchema = z.object({
|
export const freebitSimFeaturesRequestSchema = z.object({
|
||||||
account: z.string().min(1, "Account is required"),
|
account: z.string().min(1, MSG_ACCOUNT_REQUIRED),
|
||||||
voiceMailEnabled: z.boolean().optional(),
|
voiceMailEnabled: z.boolean().optional(),
|
||||||
callWaitingEnabled: z.boolean().optional(),
|
callWaitingEnabled: z.boolean().optional(),
|
||||||
callForwardingEnabled: z.boolean().optional(),
|
callForwardingEnabled: z.boolean().optional(),
|
||||||
@ -157,7 +162,7 @@ export const freebitSimFeaturesRequestSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const freebitGlobalIpRequestSchema = z.object({
|
export const freebitGlobalIpRequestSchema = z.object({
|
||||||
account: z.string().min(1, "Account is required"),
|
account: z.string().min(1, MSG_ACCOUNT_REQUIRED),
|
||||||
assign: z.boolean(), // true to assign, false to remove
|
assign: z.boolean(), // true to assign, false to remove
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -200,10 +205,7 @@ export const freebitEsimActivationRequestSchema = z.object({
|
|||||||
simkind: z.enum(["esim", "psim"]).default("esim"),
|
simkind: z.enum(["esim", "psim"]).default("esim"),
|
||||||
planCode: z.string().optional(),
|
planCode: z.string().optional(),
|
||||||
contractLine: z.enum(["4G", "5G"]).optional(),
|
contractLine: z.enum(["4G", "5G"]).optional(),
|
||||||
shipDate: z
|
shipDate: z.string().regex(YYYYMMDD_REGEX, MSG_SHIP_DATE_FORMAT).optional(),
|
||||||
.string()
|
|
||||||
.regex(/^\d{8}$/, "Ship date must be in YYYYMMDD format")
|
|
||||||
.optional(),
|
|
||||||
mnp: freebitEsimMnpSchema.optional(),
|
mnp: freebitEsimMnpSchema.optional(),
|
||||||
// Identity fields (flattened for API)
|
// Identity fields (flattened for API)
|
||||||
firstnameKanji: z.string().optional(),
|
firstnameKanji: z.string().optional(),
|
||||||
@ -250,15 +252,12 @@ export const freebitEsimActivationResponseSchema = z.object({
|
|||||||
* Used for business logic layer before mapping to API request
|
* Used for business logic layer before mapping to API request
|
||||||
*/
|
*/
|
||||||
export const freebitEsimActivationParamsSchema = z.object({
|
export const freebitEsimActivationParamsSchema = z.object({
|
||||||
account: z.string().min(1, "Account is required"),
|
account: z.string().min(1, MSG_ACCOUNT_REQUIRED),
|
||||||
eid: z.string().min(1, "EID is required"),
|
eid: z.string().min(1, "EID is required"),
|
||||||
planCode: z.string().optional(),
|
planCode: z.string().optional(),
|
||||||
contractLine: z.enum(["4G", "5G"]).optional(),
|
contractLine: z.enum(["4G", "5G"]).optional(),
|
||||||
aladinOperated: z.enum(["10", "20"]).default("10"),
|
aladinOperated: z.enum(["10", "20"]).default("10"),
|
||||||
shipDate: z
|
shipDate: z.string().regex(YYYYMMDD_REGEX, MSG_SHIP_DATE_FORMAT).optional(),
|
||||||
.string()
|
|
||||||
.regex(/^\d{8}$/, "Ship date must be in YYYYMMDD format")
|
|
||||||
.optional(),
|
|
||||||
mnp: freebitEsimMnpSchema.optional(),
|
mnp: freebitEsimMnpSchema.optional(),
|
||||||
identity: freebitEsimIdentitySchema.optional(),
|
identity: freebitEsimIdentitySchema.optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,6 +5,15 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { simCatalogProductSchema } from "../services/schema.js";
|
import { simCatalogProductSchema } from "../services/schema.js";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Validation Constants
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const YYYYMMDD_REGEX = /^\d{8}$/;
|
||||||
|
const MSG_SCHEDULED_DATE_FORMAT = "Scheduled date must be in YYYYMMDD format";
|
||||||
|
const EID_REGEX = /^\d{32}$/;
|
||||||
|
const MSG_EID_FORMAT = "EID must be exactly 32 digits";
|
||||||
|
|
||||||
export const simStatusSchema = z.enum(["active", "suspended", "cancelled", "pending"]);
|
export const simStatusSchema = z.enum(["active", "suspended", "cancelled", "pending"]);
|
||||||
|
|
||||||
export const simTypeSchema = z.enum(["standard", "nano", "micro", "esim"]);
|
export const simTypeSchema = z.enum(["standard", "nano", "micro", "esim"]);
|
||||||
@ -125,17 +134,11 @@ export type SimTopUpPricingPreviewResponse = z.infer<typeof simTopUpPricingPrevi
|
|||||||
export const simPlanChangeRequestSchema = z.object({
|
export const simPlanChangeRequestSchema = z.object({
|
||||||
newPlanCode: z.string().min(1, "New plan code is required"),
|
newPlanCode: z.string().min(1, "New plan code is required"),
|
||||||
assignGlobalIp: z.boolean().optional(),
|
assignGlobalIp: z.boolean().optional(),
|
||||||
scheduledAt: z
|
scheduledAt: z.string().regex(YYYYMMDD_REGEX, MSG_SCHEDULED_DATE_FORMAT).optional(),
|
||||||
.string()
|
|
||||||
.regex(/^\d{8}$/, "Scheduled date must be in YYYYMMDD format")
|
|
||||||
.optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const simCancelRequestSchema = z.object({
|
export const simCancelRequestSchema = z.object({
|
||||||
scheduledAt: z
|
scheduledAt: z.string().regex(YYYYMMDD_REGEX, MSG_SCHEDULED_DATE_FORMAT).optional(),
|
||||||
.string()
|
|
||||||
.regex(/^\d{8}$/, "Scheduled date must be in YYYYMMDD format")
|
|
||||||
.optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const simTopUpHistoryRequestSchema = z.object({
|
export const simTopUpHistoryRequestSchema = z.object({
|
||||||
@ -151,10 +154,7 @@ export const simFeaturesUpdateRequestSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const simReissueRequestSchema = z.object({
|
export const simReissueRequestSchema = z.object({
|
||||||
newEid: z
|
newEid: z.string().regex(EID_REGEX, MSG_EID_FORMAT).optional(),
|
||||||
.string()
|
|
||||||
.regex(/^\d{32}$/, "EID must be exactly 32 digits")
|
|
||||||
.optional(),
|
|
||||||
simType: z.enum(["physical", "esim"]).optional(),
|
simType: z.enum(["physical", "esim"]).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -216,10 +216,7 @@ export type SimCancellationPreview = z.infer<typeof simCancellationPreviewSchema
|
|||||||
export const simReissueFullRequestSchema = z
|
export const simReissueFullRequestSchema = z
|
||||||
.object({
|
.object({
|
||||||
simType: z.enum(["physical", "esim"]),
|
simType: z.enum(["physical", "esim"]),
|
||||||
newEid: z
|
newEid: z.string().regex(EID_REGEX, MSG_EID_FORMAT).optional(),
|
||||||
.string()
|
|
||||||
.regex(/^\d{32}$/, "EID must be exactly 32 digits")
|
|
||||||
.optional(),
|
|
||||||
})
|
})
|
||||||
.superRefine((data, ctx) => {
|
.superRefine((data, ctx) => {
|
||||||
if (data.simType === "esim" && !data.newEid) {
|
if (data.simType === "esim" && !data.newEid) {
|
||||||
@ -347,10 +344,7 @@ export type SimCallHistoryImportQuery = z.infer<typeof simCallHistoryImportQuery
|
|||||||
* Schema for SIM eSIM reissue request
|
* Schema for SIM eSIM reissue request
|
||||||
*/
|
*/
|
||||||
export const simReissueEsimRequestSchema = z.object({
|
export const simReissueEsimRequestSchema = z.object({
|
||||||
newEid: z
|
newEid: z.string().regex(EID_REGEX, MSG_EID_FORMAT).optional(),
|
||||||
.string()
|
|
||||||
.regex(/^\d{32}$/, "EID must be exactly 32 digits")
|
|
||||||
.optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type SimReissueEsimRequest = z.infer<typeof simReissueEsimRequestSchema>;
|
export type SimReissueEsimRequest = z.infer<typeof simReissueEsimRequestSchema>;
|
||||||
@ -434,10 +428,7 @@ export const simConfigureFormSchema = z
|
|||||||
.optional(),
|
.optional(),
|
||||||
selectedAddons: z.array(z.string()).default([]),
|
selectedAddons: z.array(z.string()).default([]),
|
||||||
activationType: simActivationTypeSchema,
|
activationType: simActivationTypeSchema,
|
||||||
scheduledActivationDate: z
|
scheduledActivationDate: z.string().regex(YYYYMMDD_REGEX, MSG_SCHEDULED_DATE_FORMAT).optional(),
|
||||||
.string()
|
|
||||||
.regex(/^\d{8}$/, "Scheduled date must be in YYYYMMDD format")
|
|
||||||
.optional(),
|
|
||||||
wantsMnp: z.boolean().default(false),
|
wantsMnp: z.boolean().default(false),
|
||||||
mnpData: simMnpFormSchema.optional(),
|
mnpData: simMnpFormSchema.optional(),
|
||||||
})
|
})
|
||||||
@ -523,10 +514,7 @@ export const simOrderActivationRequestSchema = z
|
|||||||
simType: simCardTypeSchema,
|
simType: simCardTypeSchema,
|
||||||
eid: z.string().min(15, "EID must be at least 15 characters").optional(),
|
eid: z.string().min(15, "EID must be at least 15 characters").optional(),
|
||||||
activationType: simActivationTypeSchema,
|
activationType: simActivationTypeSchema,
|
||||||
scheduledAt: z
|
scheduledAt: z.string().regex(YYYYMMDD_REGEX, MSG_SCHEDULED_DATE_FORMAT).optional(),
|
||||||
.string()
|
|
||||||
.regex(/^\d{8}$/, "Scheduled date must be in YYYYMMDD format")
|
|
||||||
.optional(),
|
|
||||||
addons: simOrderActivationAddonsSchema.optional(),
|
addons: simOrderActivationAddonsSchema.optional(),
|
||||||
mnp: simOrderActivationMnpSchema.optional(),
|
mnp: simOrderActivationMnpSchema.optional(),
|
||||||
msisdn: z.string().min(1, "Phone number (msisdn) is required"),
|
msisdn: z.string().min(1, "Phone number (msisdn) is required"),
|
||||||
@ -536,10 +524,7 @@ export const simOrderActivationRequestSchema = z
|
|||||||
.refine(
|
.refine(
|
||||||
data => {
|
data => {
|
||||||
// If simType is eSIM, eid is required
|
// If simType is eSIM, eid is required
|
||||||
if (data.simType === "eSIM" && (!data.eid || data.eid.length < 15)) {
|
return !(data.simType === "eSIM" && (!data.eid || data.eid.length < 15));
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
message: "EID is required for eSIM and must be at least 15 characters",
|
message: "EID is required for eSIM and must be at least 15 characters",
|
||||||
@ -549,10 +534,7 @@ export const simOrderActivationRequestSchema = z
|
|||||||
.refine(
|
.refine(
|
||||||
data => {
|
data => {
|
||||||
// If activationType is Scheduled, scheduledAt is required
|
// If activationType is Scheduled, scheduledAt is required
|
||||||
if (data.activationType === "Scheduled" && !data.scheduledAt) {
|
return !(data.activationType === "Scheduled" && !data.scheduledAt);
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
message: "Scheduled date is required for Scheduled activation",
|
message: "Scheduled date is required for Scheduled activation",
|
||||||
|
|||||||
@ -52,15 +52,17 @@ const STATUS_MAP: Record<string, SubscriptionStatus> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Cycle mapping
|
// Cycle mapping
|
||||||
|
const CYCLE_SEMI_ANNUALLY: SubscriptionCycle = "Semi-Annually";
|
||||||
|
|
||||||
const CYCLE_MAP: Record<string, SubscriptionCycle> = {
|
const CYCLE_MAP: Record<string, SubscriptionCycle> = {
|
||||||
monthly: "Monthly",
|
monthly: "Monthly",
|
||||||
annually: "Annually",
|
annually: "Annually",
|
||||||
annual: "Annually",
|
annual: "Annually",
|
||||||
yearly: "Annually",
|
yearly: "Annually",
|
||||||
quarterly: "Quarterly",
|
quarterly: "Quarterly",
|
||||||
"semi annually": "Semi-Annually",
|
"semi annually": CYCLE_SEMI_ANNUALLY,
|
||||||
semiannually: "Semi-Annually",
|
semiannually: CYCLE_SEMI_ANNUALLY,
|
||||||
"semi-annually": "Semi-Annually",
|
"semi-annually": CYCLE_SEMI_ANNUALLY,
|
||||||
biennially: "Biennially",
|
biennially: "Biennially",
|
||||||
triennially: "Triennially",
|
triennially: "Triennially",
|
||||||
"one time": "One-time",
|
"one time": "One-time",
|
||||||
@ -97,6 +99,44 @@ function extractCustomFields(raw: unknown): Record<string, string> | undefined {
|
|||||||
return Object.keys(entries).length > 0 ? entries : undefined;
|
return Object.keys(entries).length > 0 ? entries : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract currency info from product and options
|
||||||
|
*/
|
||||||
|
function extractCurrencyInfo(
|
||||||
|
product: WhmcsProductRaw,
|
||||||
|
options: TransformSubscriptionOptions
|
||||||
|
): { currency: string; currencySymbol: string | undefined } {
|
||||||
|
const currency = product.pricing?.currency || options.defaultCurrencyCode || "JPY";
|
||||||
|
const currencySymbol =
|
||||||
|
product.pricing?.currencyprefix ||
|
||||||
|
product.pricing?.currencysuffix ||
|
||||||
|
options.defaultCurrencySymbol;
|
||||||
|
return { currency, currencySymbol };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract amount from product pricing fields
|
||||||
|
*/
|
||||||
|
function extractAmount(product: WhmcsProductRaw): number {
|
||||||
|
const rawAmount =
|
||||||
|
product.amount ||
|
||||||
|
product.recurringamount ||
|
||||||
|
product.pricing?.amount ||
|
||||||
|
product.firstpaymentamount ||
|
||||||
|
0;
|
||||||
|
return parseAmount(rawAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract optional string field with fallback
|
||||||
|
*/
|
||||||
|
function firstDefined(...values: (string | null | undefined)[]): string | undefined {
|
||||||
|
for (const v of values) {
|
||||||
|
if (v) return v;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transform raw WHMCS product/service into normalized Subscription
|
* Transform raw WHMCS product/service into normalized Subscription
|
||||||
*/
|
*/
|
||||||
@ -104,26 +144,9 @@ export function transformWhmcsSubscription(
|
|||||||
rawProduct: unknown,
|
rawProduct: unknown,
|
||||||
options: TransformSubscriptionOptions = {}
|
options: TransformSubscriptionOptions = {}
|
||||||
): Subscription {
|
): Subscription {
|
||||||
// Validate raw data
|
|
||||||
const product = whmcsProductRawSchema.parse(rawProduct);
|
const product = whmcsProductRawSchema.parse(rawProduct);
|
||||||
|
const { currency, currencySymbol } = extractCurrencyInfo(product, options);
|
||||||
|
|
||||||
// Extract currency info
|
|
||||||
const currency = product.pricing?.currency || options.defaultCurrencyCode || "JPY";
|
|
||||||
const currencySymbol =
|
|
||||||
product.pricing?.currencyprefix ||
|
|
||||||
product.pricing?.currencysuffix ||
|
|
||||||
options.defaultCurrencySymbol;
|
|
||||||
|
|
||||||
// Determine amount
|
|
||||||
const amount = parseAmount(
|
|
||||||
product.amount ||
|
|
||||||
product.recurringamount ||
|
|
||||||
product.pricing?.amount ||
|
|
||||||
product.firstpaymentamount ||
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
// Transform to domain model
|
|
||||||
const subscription: Subscription = {
|
const subscription: Subscription = {
|
||||||
id: product.id,
|
id: product.id,
|
||||||
serviceId: product.serviceid || product.id,
|
serviceId: product.serviceid || product.id,
|
||||||
@ -132,19 +155,18 @@ export function transformWhmcsSubscription(
|
|||||||
cycle: mapCycle(product.billingcycle),
|
cycle: mapCycle(product.billingcycle),
|
||||||
status: mapStatus(product.status),
|
status: mapStatus(product.status),
|
||||||
nextDue: formatDate(product.nextduedate || product.nextinvoicedate),
|
nextDue: formatDate(product.nextduedate || product.nextinvoicedate),
|
||||||
amount,
|
amount: extractAmount(product),
|
||||||
currency,
|
currency,
|
||||||
currencySymbol,
|
currencySymbol,
|
||||||
registrationDate: formatDate(product.regdate) || new Date().toISOString(),
|
registrationDate: formatDate(product.regdate) || new Date().toISOString(),
|
||||||
notes: product.notes || undefined,
|
notes: product.notes || undefined,
|
||||||
customFields: extractCustomFields(product.customfields),
|
customFields: extractCustomFields(product.customfields),
|
||||||
orderNumber: product.ordernumber || undefined,
|
orderNumber: product.ordernumber || undefined,
|
||||||
groupName: product.groupname || product.translated_groupname || undefined,
|
groupName: firstDefined(product.groupname, product.translated_groupname),
|
||||||
paymentMethod: product.paymentmethodname || product.paymentmethod || undefined,
|
paymentMethod: firstDefined(product.paymentmethodname, product.paymentmethod),
|
||||||
serverName: product.servername || product.serverhostname || undefined,
|
serverName: firstDefined(product.servername, product.serverhostname),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Validate against domain schema
|
|
||||||
return subscriptionSchema.parse(subscription);
|
return subscriptionSchema.parse(subscription);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -342,6 +342,8 @@ export type SalesforceCasePriority =
|
|||||||
// Portal Display Labels (for UI rendering)
|
// Portal Display Labels (for UI rendering)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
const STATUS_LABEL_IN_PROGRESS = "In Progress";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map Salesforce status API names to customer-friendly portal display labels.
|
* Map Salesforce status API names to customer-friendly portal display labels.
|
||||||
*
|
*
|
||||||
@ -352,11 +354,11 @@ export type SalesforceCasePriority =
|
|||||||
*/
|
*/
|
||||||
export const STATUS_DISPLAY_LABELS: Record<string, string> = {
|
export const STATUS_DISPLAY_LABELS: Record<string, string> = {
|
||||||
[SALESFORCE_CASE_STATUS.NEW]: "New",
|
[SALESFORCE_CASE_STATUS.NEW]: "New",
|
||||||
[SALESFORCE_CASE_STATUS.IN_PROGRESS]: "In Progress",
|
[SALESFORCE_CASE_STATUS.IN_PROGRESS]: STATUS_LABEL_IN_PROGRESS,
|
||||||
// Internal workflow statuses - show as "In Progress" to customer
|
// Internal workflow statuses - show as "In Progress" to customer
|
||||||
[SALESFORCE_CASE_STATUS.AWAITING_APPROVAL]: "In Progress",
|
[SALESFORCE_CASE_STATUS.AWAITING_APPROVAL]: STATUS_LABEL_IN_PROGRESS,
|
||||||
[SALESFORCE_CASE_STATUS.VPN_PENDING]: "In Progress",
|
[SALESFORCE_CASE_STATUS.VPN_PENDING]: STATUS_LABEL_IN_PROGRESS,
|
||||||
[SALESFORCE_CASE_STATUS.PENDING]: "In Progress",
|
[SALESFORCE_CASE_STATUS.PENDING]: STATUS_LABEL_IN_PROGRESS,
|
||||||
// Replied = support has responded, waiting for customer
|
// Replied = support has responded, waiting for customer
|
||||||
[SALESFORCE_CASE_STATUS.REPLIED]: "Awaiting Customer",
|
[SALESFORCE_CASE_STATUS.REPLIED]: "Awaiting Customer",
|
||||||
[SALESFORCE_CASE_STATUS.CLOSED]: "Closed",
|
[SALESFORCE_CASE_STATUS.CLOSED]: "Closed",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user