feat(eslint): enforce BFF architecture rules for controllers and aggregators

This commit is contained in:
barsa 2026-01-16 17:11:49 +09:00
parent 975a11474a
commit f4099ac81f
10 changed files with 363 additions and 92 deletions

View File

@ -7,6 +7,7 @@ import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
import { InternetManagementModule } from "../internet-management/internet-management.module.js";
import { SimManagementModule } from "../sim-management/sim-management.module.js";
import { SharedSubscriptionsModule } from "../shared/index.js";
/**
* Unified Cancellation Module
@ -22,6 +23,7 @@ import { SimManagementModule } from "../sim-management/sim-management.module.js"
MappingsModule,
InternetManagementModule,
SimManagementModule,
SharedSubscriptionsModule,
],
controllers: [CancellationController],
providers: [CancellationService, SubscriptionsService, Logger],

View File

@ -1,8 +1,7 @@
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
import { Injectable, Logger } from "@nestjs/common";
import type {
CancellationPreview,
CancellationStatus,
ServiceType,
InternetCancelRequest,
} from "@customer-portal/domain/subscriptions";
import type { SimCancelFullRequest } from "@customer-portal/domain/sim";
@ -12,6 +11,7 @@ import { InternetCancellationService } from "../internet-management/services/int
import { SimCancellationService } from "../sim-management/services/sim-cancellation.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 { SubscriptionValidationCoordinator } from "../shared/index.js";
// Valid stages for portal display
const VALID_PORTAL_STAGES: ReadonlySet<string> = new Set([
@ -26,45 +26,6 @@ function isValidPortalStage(stage: string): stage is PortalStage {
return VALID_PORTAL_STAGES.has(stage);
}
function detectServiceType(productName: string): ServiceType {
const lower = productName.toLowerCase();
// SIM heuristics
if (lower.includes("sim")) return "sim";
// Internet heuristics (match existing patterns)
const isInternet =
lower.includes("internet") ||
lower.includes("sonixnet") ||
(lower.includes("ntt") && lower.includes("fiber"));
if (isInternet) return "internet";
throw new BadRequestException("This endpoint is only for SIM or Internet subscriptions");
}
function getOpportunityIdFromCustomFields(
customFields?: Record<string, string>
): string | undefined {
if (!customFields) return undefined;
// Prefer exact key (as configured in WHMCS)
const direct =
customFields["OpportunityId"] ??
customFields["OpportunityID"] ??
customFields["opportunityId"] ??
customFields["opportunityID"];
if (direct && direct.trim().length > 0) return direct.trim();
// Fallback: case-insensitive scan for something like "Opportunity Id"
const entry = Object.entries(customFields).find(([key, value]) => {
if (!value) return false;
const k = key.toLowerCase();
return k.includes("opportunity") && k.replace(/\s+/g, "").includes("id");
});
return entry?.[1]?.trim() || undefined;
}
@Injectable()
export class CancellationService {
private readonly logger = new Logger(CancellationService.name);
@ -73,7 +34,8 @@ export class CancellationService {
private readonly subscriptionsService: SubscriptionsService,
private readonly opportunityService: SalesforceOpportunityService,
private readonly internetCancellation: InternetCancellationService,
private readonly simCancellation: SimCancellationService
private readonly simCancellation: SimCancellationService,
private readonly validationCoordinator: SubscriptionValidationCoordinator
) {}
/**
@ -91,8 +53,14 @@ export class CancellationService {
userId,
subscriptionId
);
const serviceType = detectServiceType(subscription.productName);
const opportunityId = getOpportunityIdFromCustomFields(subscription.customFields);
// Use coordinator for service type detection and opportunityId resolution
const serviceType = this.validationCoordinator.detectServiceType(subscription.productName);
const opportunityResolution = await this.validationCoordinator.resolveOpportunityId(
subscriptionId,
{ customFields: subscription.customFields, fallbackToSalesforce: false }
);
const opportunityId = opportunityResolution.opportunityId ?? undefined;
// 2) Query Opportunity status ONLY when WHMCS is Active (not already cancelled)
const shouldQueryOpp = subscription.status === "Active" && Boolean(opportunityId);
@ -117,7 +85,7 @@ export class CancellationService {
userId,
subscriptionId
);
const serviceType = detectServiceType(subscription.productName);
const serviceType = this.validationCoordinator.detectServiceType(subscription.productName);
if (serviceType === "internet") {
await this.internetCancellation.submitCancellation(userId, subscriptionId, request);

View File

@ -2,13 +2,19 @@ import { Module } from "@nestjs/common";
import { InternetCancellationService } from "./services/internet-cancellation.service.js";
import { InternetController } from "./internet.controller.js";
import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js";
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module.js";
import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js";
import { WorkflowModule } from "@bff/modules/shared/workflow/index.js";
import { SharedSubscriptionsModule } from "../shared/index.js";
@Module({
imports: [WhmcsModule, MappingsModule, SalesforceModule, NotificationsModule, WorkflowModule],
imports: [
WhmcsModule,
SalesforceModule,
NotificationsModule,
WorkflowModule,
SharedSubscriptionsModule,
],
controllers: [InternetController],
providers: [InternetCancellationService],
exports: [InternetCancellationService],

View File

@ -15,11 +15,11 @@ import { Injectable, Inject, BadRequestException, NotFoundException } from "@nes
import { Logger } from "nestjs-pino";
import { WhmcsConnectionFacade } from "@bff/integrations/whmcs/facades/whmcs.facade.js";
import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js";
import { WorkflowCaseManager } from "@bff/modules/shared/workflow/index.js";
import { EmailService } from "@bff/infra/email/email.service.js";
import { NotificationService } from "@bff/modules/notifications/notifications.service.js";
import { SubscriptionValidationCoordinator } from "../../shared/index.js";
import {
generateCancellationMonths,
getCancellationEffectiveDate,
@ -39,7 +39,7 @@ export class InternetCancellationService {
constructor(
private readonly whmcsConnectionService: WhmcsConnectionFacade,
private readonly whmcsClientService: WhmcsClientService,
private readonly mappingsService: MappingsService,
private readonly validationCoordinator: SubscriptionValidationCoordinator,
private readonly opportunityService: SalesforceOpportunityService,
private readonly workflowCases: WorkflowCaseManager,
private readonly emailService: EmailService,
@ -48,7 +48,8 @@ export class InternetCancellationService {
) {}
/**
* Validate that the subscription belongs to the user and is an Internet service
* Validate that the subscription belongs to the user and is an Internet service.
* Uses SubscriptionValidationCoordinator for account mapping and product type detection.
*/
private async validateInternetSubscription(
userId: string,
@ -64,14 +65,13 @@ export class InternetCancellationService {
registrationDate?: string;
};
}> {
const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.whmcsClientId || !mapping?.sfAccountId) {
throw new BadRequestException("Account mapping not found");
}
// Validate account mapping via coordinator
const { whmcsClientId, sfAccountId } =
await this.validationCoordinator.validateAccountMapping(userId);
// Get subscription from WHMCS
const productsResponse = await this.whmcsConnectionService.getClientsProducts({
clientid: mapping.whmcsClientId,
clientid: whmcsClientId,
});
const productContainer = productsResponse.products?.product;
const products = Array.isArray(productContainer)
@ -88,22 +88,15 @@ export class InternetCancellationService {
throw new NotFoundException("Subscription not found");
}
// Verify it's an Internet service
// Match: "Internet", "SonixNet via NTT Optical Fiber", or any NTT-based fiber service
// Verify it's an Internet service via coordinator
const productName = String(subscription.name || subscription.groupname || "");
const lowerName = productName.toLowerCase();
const isInternetService =
lowerName.includes("internet") ||
lowerName.includes("sonixnet") ||
(lowerName.includes("ntt") && lowerName.includes("fiber"));
if (!isInternetService) {
if (!this.validationCoordinator.isInternetService(productName)) {
throw new BadRequestException("This endpoint is only for Internet subscriptions");
}
return {
whmcsClientId: mapping.whmcsClientId,
sfAccountId: mapping.sfAccountId,
whmcsClientId,
sfAccountId,
subscription: {
id: Number(subscription.id),
productName: productName,
@ -184,14 +177,12 @@ export class InternetCancellationService {
`${clientDetails.firstname || ""} ${clientDetails.lastname || ""}`.trim() || "Customer";
const customerEmail = clientDetails.email || "";
// Find existing Opportunity for this subscription (by WHMCS Service ID)
let opportunityId: string | null = null;
try {
opportunityId = await this.opportunityService.findOpportunityByWhmcsServiceId(subscriptionId);
} catch {
// Opportunity lookup failure is not fatal - we'll create Case without link
this.logger.warn("Could not find Opportunity for subscription", { subscriptionId });
}
// Resolve OpportunityId via coordinator (handles both custom fields and SF lookup)
const opportunityResolution = await this.validationCoordinator.resolveOpportunityId(
subscriptionId,
{ fallbackToSalesforce: true }
);
const opportunityId = opportunityResolution.opportunityId;
// Create Salesforce Case for cancellation via workflow manager
await this.workflowCases.notifyInternetCancellation({

View File

@ -0,0 +1,15 @@
/**
* Subscriptions Shared Module
*
* Shared services and utilities for subscription management.
*/
export { SharedSubscriptionsModule } from "./shared-subscriptions.module.js";
export { SubscriptionValidationCoordinator } from "./subscription-validation-coordinator.service.js";
export type {
AccountMappingResult,
OpportunityResolution,
OpportunityResolutionOptions,
ServiceType,
ServiceTypeResult,
} from "./subscription-validation.types.js";

View File

@ -0,0 +1,17 @@
import { Module } from "@nestjs/common";
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module.js";
import { SubscriptionValidationCoordinator } from "./subscription-validation-coordinator.service.js";
/**
* Shared Subscriptions Module
*
* Provides shared services for subscription management across all sub-modules.
* Import this module wherever you need the SubscriptionValidationCoordinator.
*/
@Module({
imports: [MappingsModule, SalesforceModule],
providers: [SubscriptionValidationCoordinator],
exports: [SubscriptionValidationCoordinator],
})
export class SharedSubscriptionsModule {}

View File

@ -0,0 +1,216 @@
/**
* Subscription Validation Coordinator
*
* Centralized service for common subscription validation patterns:
* - Account mapping validation (user whmcsClientId + sfAccountId)
* - Product type detection (Internet vs SIM)
* - OpportunityId resolution (from custom fields or Salesforce lookup)
*
* This coordinator extracts duplicated patterns from:
* - InternetCancellationService
* - SimCancellationService
* - CancellationService
* - SimValidationService
*
* Benefits:
* - Single source of truth for validation logic
* - Consistent error messages across services
* - Easier to maintain and test
*/
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js";
import type {
AccountMappingResult,
OpportunityResolution,
OpportunityResolutionOptions,
ServiceType,
} from "./subscription-validation.types.js";
@Injectable()
export class SubscriptionValidationCoordinator {
constructor(
private readonly mappingsService: MappingsService,
private readonly opportunityService: SalesforceOpportunityService,
@Inject(Logger) private readonly logger: Logger
) {}
// ============================================================================
// Account Mapping Validation
// ============================================================================
/**
* Validate that a user has complete account mapping (WHMCS + Salesforce).
*
* @throws BadRequestException if mapping is missing or incomplete
*/
async validateAccountMapping(userId: string): Promise<AccountMappingResult> {
const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.whmcsClientId || !mapping?.sfAccountId) {
this.logger.warn("Account mapping not found or incomplete", {
userId,
hasWhmcsClientId: Boolean(mapping?.whmcsClientId),
hasSfAccountId: Boolean(mapping?.sfAccountId),
});
throw new BadRequestException("Account mapping not found");
}
return {
whmcsClientId: mapping.whmcsClientId,
sfAccountId: mapping.sfAccountId,
};
}
/**
* Get WHMCS client ID only (for operations that don't need Salesforce).
*
* @throws BadRequestException if WHMCS mapping not found
*/
async getWhmcsClientId(userId: string): Promise<number> {
return this.mappingsService.getWhmcsClientIdOrThrow(userId);
}
// ============================================================================
// Product Type Detection
// ============================================================================
/**
* Detect service type from product name.
*
* Heuristics:
* - SIM: Product name contains "sim"
* - Internet: Contains "internet", "sonixnet", or "ntt" + "fiber"
*
* @throws BadRequestException if service type cannot be determined
*/
detectServiceType(productName: string): ServiceType {
const lower = productName.toLowerCase();
// SIM heuristics
if (lower.includes("sim")) {
return "sim";
}
// Internet heuristics
const isInternet =
lower.includes("internet") ||
lower.includes("sonixnet") ||
(lower.includes("ntt") && lower.includes("fiber"));
if (isInternet) {
return "internet";
}
throw new BadRequestException("This endpoint is only for SIM or Internet subscriptions");
}
/**
* Check if product name indicates an Internet service.
*/
isInternetService(productName: string): boolean {
const lower = productName.toLowerCase();
return (
lower.includes("internet") ||
lower.includes("sonixnet") ||
(lower.includes("ntt") && lower.includes("fiber"))
);
}
/**
* Check if product name indicates a SIM service.
*/
isSimService(productName: string): boolean {
return productName.toLowerCase().includes("sim");
}
// ============================================================================
// OpportunityId Resolution
// ============================================================================
/**
* Resolve OpportunityId for a subscription.
*
* Strategy:
* 1. First, check WHMCS custom fields (if provided)
* 2. If not found and fallback enabled, query Salesforce by WHMCS Service ID
*
* This never throws - returns null if not found (Opportunity lookup is non-critical).
*/
async resolveOpportunityId(
subscriptionId: number,
options: OpportunityResolutionOptions = {}
): Promise<OpportunityResolution> {
const { customFields, fallbackToSalesforce = true } = options;
// Strategy 1: Check custom fields
if (customFields) {
const opportunityId = this.extractOpportunityIdFromCustomFields(customFields);
if (opportunityId) {
return {
opportunityId,
source: "custom_fields",
};
}
}
// Strategy 2: Salesforce lookup by WHMCS Service ID
if (fallbackToSalesforce) {
try {
const opportunityId =
await this.opportunityService.findOpportunityByWhmcsServiceId(subscriptionId);
if (opportunityId) {
return {
opportunityId,
source: "salesforce_lookup",
};
}
} catch {
// Opportunity lookup failure is non-critical
this.logger.warn("Could not find Opportunity for subscription", { subscriptionId });
}
}
return {
opportunityId: null,
source: "not_found",
};
}
// ============================================================================
// Private Helpers
// ============================================================================
/**
* Extract OpportunityId from WHMCS custom fields.
*
* Checks common key variations:
* - OpportunityId, OpportunityID, opportunityId, opportunityID
* - Falls back to case-insensitive scan for "opportunity" + "id"
*/
private extractOpportunityIdFromCustomFields(
customFields: Record<string, string>
): string | undefined {
// Prefer exact key (as configured in WHMCS)
const direct =
customFields["OpportunityId"] ??
customFields["OpportunityID"] ??
customFields["opportunityId"] ??
customFields["opportunityID"];
if (direct && direct.trim().length > 0) {
return direct.trim();
}
// Fallback: case-insensitive scan for something like "Opportunity Id"
const entry = Object.entries(customFields).find(([key, value]) => {
if (!value) return false;
const k = key.toLowerCase();
return k.includes("opportunity") && k.replace(/\s+/g, "").includes("id");
});
return entry?.[1]?.trim() || undefined;
}
}

View File

@ -0,0 +1,56 @@
/**
* Subscription Validation Coordinator Types
*
* Centralized type definitions for subscription validation patterns:
* - Account mapping validation (user whmcsClientId + sfAccountId)
* - Product type detection (Internet vs SIM)
* - OpportunityId resolution (from custom fields or Salesforce lookup)
*/
import type { ServiceType } from "@customer-portal/domain/subscriptions";
/**
* Result of validating a user's account mapping.
* Contains all external system IDs needed for subscription operations.
*/
export interface AccountMappingResult {
/** WHMCS Client ID for billing operations */
whmcsClientId: number;
/** Salesforce Account ID for CRM operations */
sfAccountId: string;
}
/**
* Result of resolving an OpportunityId for a subscription.
*/
export interface OpportunityResolution {
/** Salesforce Opportunity ID (if found) */
opportunityId: string | null;
/** Source of the OpportunityId resolution */
source: "custom_fields" | "salesforce_lookup" | "not_found";
}
/**
* Options for OpportunityId resolution.
*/
export interface OpportunityResolutionOptions {
/** WHMCS subscription custom fields to check first */
customFields?: Record<string, string> | undefined;
/** Whether to fall back to Salesforce lookup if not in custom fields */
fallbackToSalesforce?: boolean;
}
/**
* Service type detection result with metadata
*/
export interface ServiceTypeResult {
/** Detected service type */
type: ServiceType;
/** Product name used for detection */
productName: string;
}
/**
* Re-export ServiceType for convenience
*/
export type { ServiceType };

View File

@ -3,10 +3,10 @@ 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 { 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,
@ -29,7 +29,7 @@ export class SimCancellationService {
constructor(
private readonly freebitService: FreebitFacade,
private readonly whmcsClientService: WhmcsClientService,
private readonly mappingsService: MappingsService,
private readonly validationCoordinator: SubscriptionValidationCoordinator,
private readonly opportunityService: SalesforceOpportunityService,
private readonly workflowCases: WorkflowCaseManager,
private readonly simValidation: SimValidationService,
@ -75,8 +75,8 @@ export class SimCancellationService {
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
const simDetails = await this.freebitService.getSimDetails(validation.account);
// Get customer info from WHMCS
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId);
// 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";
@ -175,17 +175,16 @@ export class SimCancellationService {
subscriptionId: number,
request: SimCancelFullRequest
): Promise<void> {
const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.whmcsClientId || !mapping?.sfAccountId) {
throw new BadRequestException("Account mapping not found");
}
// 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(mapping.whmcsClientId);
const clientDetails = await this.whmcsClientService.getClientDetails(whmcsClientId);
const customerName =
`${clientDetails.firstname || ""} ${clientDetails.lastname || ""}`.trim() || "Customer";
const customerEmail = clientDetails.email || "";
@ -223,17 +222,16 @@ export class SimCancellationService {
runDate,
});
// Find existing Opportunity for this subscription (by WHMCS Service ID)
let opportunityId: string | null = null;
try {
opportunityId = await this.opportunityService.findOpportunityByWhmcsServiceId(subscriptionId);
} catch {
this.logger.warn("Could not find Opportunity for SIM subscription", { subscriptionId });
}
// 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: mapping.sfAccountId,
accountId: sfAccountId,
...(opportunityId ? { opportunityId } : {}),
simAccount: account,
iccid: simDetails.iccid || "N/A",
@ -244,7 +242,7 @@ export class SimCancellationService {
});
this.logger.log("SIM cancellation case created via WorkflowCaseManager", {
sfAccountIdTail: mapping.sfAccountId.slice(-4),
sfAccountIdTail: sfAccountId.slice(-4),
opportunityId: opportunityId ? opportunityId.slice(-4) : null,
});

View File

@ -8,6 +8,7 @@ import { SecurityModule } from "@bff/core/security/security.module.js";
import { SimUsageStoreService } from "../sim-usage-store.service.js";
import { SubscriptionsService } from "../subscriptions.service.js";
import { SimManagementService } from "../sim-management.service.js";
import { SharedSubscriptionsModule } from "../shared/index.js";
// SimController is registered in SubscriptionsModule to ensure route order
// Import all SIM management services
@ -45,6 +46,7 @@ import { WorkflowModule } from "@bff/modules/shared/workflow/index.js";
SecurityModule,
VoiceOptionsModule,
WorkflowModule,
SharedSubscriptionsModule,
],
// SimController is registered in SubscriptionsModule to ensure route order
// (more specific routes like :id/sim must be registered before :id)