From f4099ac81f1f88dd59ca26aed64170cd2a40d751 Mon Sep 17 00:00:00 2001 From: barsa Date: Fri, 16 Jan 2026 17:11:49 +0900 Subject: [PATCH] feat(eslint): enforce BFF architecture rules for controllers and aggregators --- .../cancellation/cancellation.module.ts | 2 + .../cancellation/cancellation.service.ts | 58 ++--- .../internet-management.module.ts | 10 +- .../services/internet-cancellation.service.ts | 45 ++-- .../src/modules/subscriptions/shared/index.ts | 15 ++ .../shared/shared-subscriptions.module.ts | 17 ++ ...cription-validation-coordinator.service.ts | 216 ++++++++++++++++++ .../shared/subscription-validation.types.ts | 56 +++++ .../services/sim-cancellation.service.ts | 34 ++- .../sim-management/sim-management.module.ts | 2 + 10 files changed, 363 insertions(+), 92 deletions(-) create mode 100644 apps/bff/src/modules/subscriptions/shared/index.ts create mode 100644 apps/bff/src/modules/subscriptions/shared/shared-subscriptions.module.ts create mode 100644 apps/bff/src/modules/subscriptions/shared/subscription-validation-coordinator.service.ts create mode 100644 apps/bff/src/modules/subscriptions/shared/subscription-validation.types.ts diff --git a/apps/bff/src/modules/subscriptions/cancellation/cancellation.module.ts b/apps/bff/src/modules/subscriptions/cancellation/cancellation.module.ts index 2e4540d0..744917ce 100644 --- a/apps/bff/src/modules/subscriptions/cancellation/cancellation.module.ts +++ b/apps/bff/src/modules/subscriptions/cancellation/cancellation.module.ts @@ -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], diff --git a/apps/bff/src/modules/subscriptions/cancellation/cancellation.service.ts b/apps/bff/src/modules/subscriptions/cancellation/cancellation.service.ts index 431371a2..a208f795 100644 --- a/apps/bff/src/modules/subscriptions/cancellation/cancellation.service.ts +++ b/apps/bff/src/modules/subscriptions/cancellation/cancellation.service.ts @@ -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 = 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 | 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); diff --git a/apps/bff/src/modules/subscriptions/internet-management/internet-management.module.ts b/apps/bff/src/modules/subscriptions/internet-management/internet-management.module.ts index 249fe46d..3553df5e 100644 --- a/apps/bff/src/modules/subscriptions/internet-management/internet-management.module.ts +++ b/apps/bff/src/modules/subscriptions/internet-management/internet-management.module.ts @@ -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], diff --git a/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts b/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts index a7e7e580..24f47e16 100644 --- a/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts +++ b/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts @@ -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({ diff --git a/apps/bff/src/modules/subscriptions/shared/index.ts b/apps/bff/src/modules/subscriptions/shared/index.ts new file mode 100644 index 00000000..b53bbef7 --- /dev/null +++ b/apps/bff/src/modules/subscriptions/shared/index.ts @@ -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"; diff --git a/apps/bff/src/modules/subscriptions/shared/shared-subscriptions.module.ts b/apps/bff/src/modules/subscriptions/shared/shared-subscriptions.module.ts new file mode 100644 index 00000000..185a7f31 --- /dev/null +++ b/apps/bff/src/modules/subscriptions/shared/shared-subscriptions.module.ts @@ -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 {} diff --git a/apps/bff/src/modules/subscriptions/shared/subscription-validation-coordinator.service.ts b/apps/bff/src/modules/subscriptions/shared/subscription-validation-coordinator.service.ts new file mode 100644 index 00000000..05623e6b --- /dev/null +++ b/apps/bff/src/modules/subscriptions/shared/subscription-validation-coordinator.service.ts @@ -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 { + 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 { + 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 { + 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 | 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; + } +} diff --git a/apps/bff/src/modules/subscriptions/shared/subscription-validation.types.ts b/apps/bff/src/modules/subscriptions/shared/subscription-validation.types.ts new file mode 100644 index 00000000..18bf4ce0 --- /dev/null +++ b/apps/bff/src/modules/subscriptions/shared/subscription-validation.types.ts @@ -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 | 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 }; diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts index 6993332f..ede90dbb 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts @@ -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 { - 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, }); diff --git a/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts b/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts index 9e3b9884..49058f46 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts @@ -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)