feat(eslint): enforce BFF architecture rules for controllers and aggregators
This commit is contained in:
parent
975a11474a
commit
f4099ac81f
@ -7,6 +7,7 @@ import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module
|
|||||||
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
|
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
|
||||||
import { InternetManagementModule } from "../internet-management/internet-management.module.js";
|
import { InternetManagementModule } from "../internet-management/internet-management.module.js";
|
||||||
import { SimManagementModule } from "../sim-management/sim-management.module.js";
|
import { SimManagementModule } from "../sim-management/sim-management.module.js";
|
||||||
|
import { SharedSubscriptionsModule } from "../shared/index.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unified Cancellation Module
|
* Unified Cancellation Module
|
||||||
@ -22,6 +23,7 @@ import { SimManagementModule } from "../sim-management/sim-management.module.js"
|
|||||||
MappingsModule,
|
MappingsModule,
|
||||||
InternetManagementModule,
|
InternetManagementModule,
|
||||||
SimManagementModule,
|
SimManagementModule,
|
||||||
|
SharedSubscriptionsModule,
|
||||||
],
|
],
|
||||||
controllers: [CancellationController],
|
controllers: [CancellationController],
|
||||||
providers: [CancellationService, SubscriptionsService, Logger],
|
providers: [CancellationService, SubscriptionsService, Logger],
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import type {
|
import type {
|
||||||
CancellationPreview,
|
CancellationPreview,
|
||||||
CancellationStatus,
|
CancellationStatus,
|
||||||
ServiceType,
|
|
||||||
InternetCancelRequest,
|
InternetCancelRequest,
|
||||||
} 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";
|
||||||
@ -12,6 +11,7 @@ import { InternetCancellationService } from "../internet-management/services/int
|
|||||||
import { SimCancellationService } from "../sim-management/services/sim-cancellation.service.js";
|
import { SimCancellationService } from "../sim-management/services/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";
|
||||||
|
|
||||||
// Valid stages for portal display
|
// Valid stages for portal display
|
||||||
const VALID_PORTAL_STAGES: ReadonlySet<string> = new Set([
|
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);
|
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()
|
@Injectable()
|
||||||
export class CancellationService {
|
export class CancellationService {
|
||||||
private readonly logger = new Logger(CancellationService.name);
|
private readonly logger = new Logger(CancellationService.name);
|
||||||
@ -73,7 +34,8 @@ export class CancellationService {
|
|||||||
private readonly subscriptionsService: SubscriptionsService,
|
private readonly subscriptionsService: SubscriptionsService,
|
||||||
private readonly opportunityService: SalesforceOpportunityService,
|
private readonly opportunityService: SalesforceOpportunityService,
|
||||||
private readonly internetCancellation: InternetCancellationService,
|
private readonly internetCancellation: InternetCancellationService,
|
||||||
private readonly simCancellation: SimCancellationService
|
private readonly simCancellation: SimCancellationService,
|
||||||
|
private readonly validationCoordinator: SubscriptionValidationCoordinator
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -91,8 +53,14 @@ export class CancellationService {
|
|||||||
userId,
|
userId,
|
||||||
subscriptionId
|
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)
|
// 2) Query Opportunity status ONLY when WHMCS is Active (not already cancelled)
|
||||||
const shouldQueryOpp = subscription.status === "Active" && Boolean(opportunityId);
|
const shouldQueryOpp = subscription.status === "Active" && Boolean(opportunityId);
|
||||||
@ -117,7 +85,7 @@ export class CancellationService {
|
|||||||
userId,
|
userId,
|
||||||
subscriptionId
|
subscriptionId
|
||||||
);
|
);
|
||||||
const serviceType = detectServiceType(subscription.productName);
|
const serviceType = this.validationCoordinator.detectServiceType(subscription.productName);
|
||||||
|
|
||||||
if (serviceType === "internet") {
|
if (serviceType === "internet") {
|
||||||
await this.internetCancellation.submitCancellation(userId, subscriptionId, request);
|
await this.internetCancellation.submitCancellation(userId, subscriptionId, request);
|
||||||
|
|||||||
@ -2,13 +2,19 @@ import { Module } from "@nestjs/common";
|
|||||||
import { InternetCancellationService } from "./services/internet-cancellation.service.js";
|
import { InternetCancellationService } from "./services/internet-cancellation.service.js";
|
||||||
import { InternetController } from "./internet.controller.js";
|
import { InternetController } from "./internet.controller.js";
|
||||||
import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.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 { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module.js";
|
||||||
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";
|
||||||
|
import { SharedSubscriptionsModule } from "../shared/index.js";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [WhmcsModule, MappingsModule, SalesforceModule, NotificationsModule, WorkflowModule],
|
imports: [
|
||||||
|
WhmcsModule,
|
||||||
|
SalesforceModule,
|
||||||
|
NotificationsModule,
|
||||||
|
WorkflowModule,
|
||||||
|
SharedSubscriptionsModule,
|
||||||
|
],
|
||||||
controllers: [InternetController],
|
controllers: [InternetController],
|
||||||
providers: [InternetCancellationService],
|
providers: [InternetCancellationService],
|
||||||
exports: [InternetCancellationService],
|
exports: [InternetCancellationService],
|
||||||
|
|||||||
@ -15,11 +15,11 @@ import { Injectable, Inject, BadRequestException, NotFoundException } from "@nes
|
|||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { WhmcsConnectionFacade } from "@bff/integrations/whmcs/facades/whmcs.facade.js";
|
import { WhmcsConnectionFacade } from "@bff/integrations/whmcs/facades/whmcs.facade.js";
|
||||||
import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.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 { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js";
|
||||||
import { WorkflowCaseManager } from "@bff/modules/shared/workflow/index.js";
|
import { WorkflowCaseManager } from "@bff/modules/shared/workflow/index.js";
|
||||||
import { EmailService } from "@bff/infra/email/email.service.js";
|
import { EmailService } from "@bff/infra/email/email.service.js";
|
||||||
import { NotificationService } from "@bff/modules/notifications/notifications.service.js";
|
import { NotificationService } from "@bff/modules/notifications/notifications.service.js";
|
||||||
|
import { SubscriptionValidationCoordinator } from "../../shared/index.js";
|
||||||
import {
|
import {
|
||||||
generateCancellationMonths,
|
generateCancellationMonths,
|
||||||
getCancellationEffectiveDate,
|
getCancellationEffectiveDate,
|
||||||
@ -39,7 +39,7 @@ export class InternetCancellationService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly whmcsConnectionService: WhmcsConnectionFacade,
|
private readonly whmcsConnectionService: WhmcsConnectionFacade,
|
||||||
private readonly whmcsClientService: WhmcsClientService,
|
private readonly whmcsClientService: WhmcsClientService,
|
||||||
private readonly mappingsService: MappingsService,
|
private readonly validationCoordinator: SubscriptionValidationCoordinator,
|
||||||
private readonly opportunityService: SalesforceOpportunityService,
|
private readonly opportunityService: SalesforceOpportunityService,
|
||||||
private readonly workflowCases: WorkflowCaseManager,
|
private readonly workflowCases: WorkflowCaseManager,
|
||||||
private readonly emailService: EmailService,
|
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(
|
private async validateInternetSubscription(
|
||||||
userId: string,
|
userId: string,
|
||||||
@ -64,14 +65,13 @@ export class InternetCancellationService {
|
|||||||
registrationDate?: string;
|
registrationDate?: string;
|
||||||
};
|
};
|
||||||
}> {
|
}> {
|
||||||
const mapping = await this.mappingsService.findByUserId(userId);
|
// Validate account mapping via coordinator
|
||||||
if (!mapping?.whmcsClientId || !mapping?.sfAccountId) {
|
const { whmcsClientId, sfAccountId } =
|
||||||
throw new BadRequestException("Account mapping not found");
|
await this.validationCoordinator.validateAccountMapping(userId);
|
||||||
}
|
|
||||||
|
|
||||||
// Get subscription from WHMCS
|
// Get subscription from WHMCS
|
||||||
const productsResponse = await this.whmcsConnectionService.getClientsProducts({
|
const productsResponse = await this.whmcsConnectionService.getClientsProducts({
|
||||||
clientid: mapping.whmcsClientId,
|
clientid: whmcsClientId,
|
||||||
});
|
});
|
||||||
const productContainer = productsResponse.products?.product;
|
const productContainer = productsResponse.products?.product;
|
||||||
const products = Array.isArray(productContainer)
|
const products = Array.isArray(productContainer)
|
||||||
@ -88,22 +88,15 @@ export class InternetCancellationService {
|
|||||||
throw new NotFoundException("Subscription not found");
|
throw new NotFoundException("Subscription not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify it's an Internet service
|
// Verify it's an Internet service via coordinator
|
||||||
// Match: "Internet", "SonixNet via NTT Optical Fiber", or any NTT-based fiber service
|
|
||||||
const productName = String(subscription.name || subscription.groupname || "");
|
const productName = String(subscription.name || subscription.groupname || "");
|
||||||
const lowerName = productName.toLowerCase();
|
if (!this.validationCoordinator.isInternetService(productName)) {
|
||||||
const isInternetService =
|
|
||||||
lowerName.includes("internet") ||
|
|
||||||
lowerName.includes("sonixnet") ||
|
|
||||||
(lowerName.includes("ntt") && lowerName.includes("fiber"));
|
|
||||||
|
|
||||||
if (!isInternetService) {
|
|
||||||
throw new BadRequestException("This endpoint is only for Internet subscriptions");
|
throw new BadRequestException("This endpoint is only for Internet subscriptions");
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
whmcsClientId: mapping.whmcsClientId,
|
whmcsClientId,
|
||||||
sfAccountId: mapping.sfAccountId,
|
sfAccountId,
|
||||||
subscription: {
|
subscription: {
|
||||||
id: Number(subscription.id),
|
id: Number(subscription.id),
|
||||||
productName: productName,
|
productName: productName,
|
||||||
@ -184,14 +177,12 @@ export class InternetCancellationService {
|
|||||||
`${clientDetails.firstname || ""} ${clientDetails.lastname || ""}`.trim() || "Customer";
|
`${clientDetails.firstname || ""} ${clientDetails.lastname || ""}`.trim() || "Customer";
|
||||||
const customerEmail = clientDetails.email || "";
|
const customerEmail = clientDetails.email || "";
|
||||||
|
|
||||||
// Find existing Opportunity for this subscription (by WHMCS Service ID)
|
// Resolve OpportunityId via coordinator (handles both custom fields and SF lookup)
|
||||||
let opportunityId: string | null = null;
|
const opportunityResolution = await this.validationCoordinator.resolveOpportunityId(
|
||||||
try {
|
subscriptionId,
|
||||||
opportunityId = await this.opportunityService.findOpportunityByWhmcsServiceId(subscriptionId);
|
{ fallbackToSalesforce: true }
|
||||||
} catch {
|
);
|
||||||
// Opportunity lookup failure is not fatal - we'll create Case without link
|
const opportunityId = opportunityResolution.opportunityId;
|
||||||
this.logger.warn("Could not find Opportunity for subscription", { subscriptionId });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create Salesforce Case for cancellation via workflow manager
|
// Create Salesforce Case for cancellation via workflow manager
|
||||||
await this.workflowCases.notifyInternetCancellation({
|
await this.workflowCases.notifyInternetCancellation({
|
||||||
|
|||||||
15
apps/bff/src/modules/subscriptions/shared/index.ts
Normal file
15
apps/bff/src/modules/subscriptions/shared/index.ts
Normal 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";
|
||||||
@ -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 {}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 };
|
||||||
@ -3,10 +3,10 @@ import { Logger } from "nestjs-pino";
|
|||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { FreebitFacade } from "@bff/integrations/freebit/facades/freebit.facade.js";
|
import { FreebitFacade } from "@bff/integrations/freebit/facades/freebit.facade.js";
|
||||||
import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.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 { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js";
|
||||||
import { WorkflowCaseManager } from "@bff/modules/shared/workflow/index.js";
|
import { WorkflowCaseManager } from "@bff/modules/shared/workflow/index.js";
|
||||||
import { SimValidationService } from "./sim-validation.service.js";
|
import { SimValidationService } from "./sim-validation.service.js";
|
||||||
|
import { SubscriptionValidationCoordinator } from "../../shared/index.js";
|
||||||
import type {
|
import type {
|
||||||
SimCancelRequest,
|
SimCancelRequest,
|
||||||
SimCancelFullRequest,
|
SimCancelFullRequest,
|
||||||
@ -29,7 +29,7 @@ export class SimCancellationService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly freebitService: FreebitFacade,
|
private readonly freebitService: FreebitFacade,
|
||||||
private readonly whmcsClientService: WhmcsClientService,
|
private readonly whmcsClientService: WhmcsClientService,
|
||||||
private readonly mappingsService: MappingsService,
|
private readonly validationCoordinator: SubscriptionValidationCoordinator,
|
||||||
private readonly opportunityService: SalesforceOpportunityService,
|
private readonly opportunityService: SalesforceOpportunityService,
|
||||||
private readonly workflowCases: WorkflowCaseManager,
|
private readonly workflowCases: WorkflowCaseManager,
|
||||||
private readonly simValidation: SimValidationService,
|
private readonly simValidation: SimValidationService,
|
||||||
@ -75,8 +75,8 @@ export class SimCancellationService {
|
|||||||
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||||
const simDetails = await this.freebitService.getSimDetails(validation.account);
|
const simDetails = await this.freebitService.getSimDetails(validation.account);
|
||||||
|
|
||||||
// Get customer info from WHMCS
|
// Get customer info from WHMCS via coordinator
|
||||||
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId);
|
const whmcsClientId = await this.validationCoordinator.getWhmcsClientId(userId);
|
||||||
const clientDetails = await this.whmcsClientService.getClientDetails(whmcsClientId);
|
const clientDetails = await this.whmcsClientService.getClientDetails(whmcsClientId);
|
||||||
const customerName =
|
const customerName =
|
||||||
`${clientDetails.firstname || ""} ${clientDetails.lastname || ""}`.trim() || "Customer";
|
`${clientDetails.firstname || ""} ${clientDetails.lastname || ""}`.trim() || "Customer";
|
||||||
@ -175,17 +175,16 @@ export class SimCancellationService {
|
|||||||
subscriptionId: number,
|
subscriptionId: number,
|
||||||
request: SimCancelFullRequest
|
request: SimCancelFullRequest
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const mapping = await this.mappingsService.findByUserId(userId);
|
// Validate account mapping via coordinator
|
||||||
if (!mapping?.whmcsClientId || !mapping?.sfAccountId) {
|
const { whmcsClientId, sfAccountId } =
|
||||||
throw new BadRequestException("Account mapping not found");
|
await this.validationCoordinator.validateAccountMapping(userId);
|
||||||
}
|
|
||||||
|
|
||||||
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||||
const account = validation.account;
|
const account = validation.account;
|
||||||
const simDetails = await this.freebitService.getSimDetails(account);
|
const simDetails = await this.freebitService.getSimDetails(account);
|
||||||
|
|
||||||
// Get customer info from WHMCS
|
// Get customer info from WHMCS
|
||||||
const clientDetails = await this.whmcsClientService.getClientDetails(mapping.whmcsClientId);
|
const clientDetails = await this.whmcsClientService.getClientDetails(whmcsClientId);
|
||||||
const customerName =
|
const customerName =
|
||||||
`${clientDetails.firstname || ""} ${clientDetails.lastname || ""}`.trim() || "Customer";
|
`${clientDetails.firstname || ""} ${clientDetails.lastname || ""}`.trim() || "Customer";
|
||||||
const customerEmail = clientDetails.email || "";
|
const customerEmail = clientDetails.email || "";
|
||||||
@ -223,17 +222,16 @@ export class SimCancellationService {
|
|||||||
runDate,
|
runDate,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Find existing Opportunity for this subscription (by WHMCS Service ID)
|
// Resolve OpportunityId via coordinator
|
||||||
let opportunityId: string | null = null;
|
const opportunityResolution = await this.validationCoordinator.resolveOpportunityId(
|
||||||
try {
|
subscriptionId,
|
||||||
opportunityId = await this.opportunityService.findOpportunityByWhmcsServiceId(subscriptionId);
|
{ fallbackToSalesforce: true }
|
||||||
} catch {
|
);
|
||||||
this.logger.warn("Could not find Opportunity for SIM subscription", { subscriptionId });
|
const opportunityId = opportunityResolution.opportunityId;
|
||||||
}
|
|
||||||
|
|
||||||
// Create Salesforce Case for cancellation via workflow manager
|
// Create Salesforce Case for cancellation via workflow manager
|
||||||
await this.workflowCases.notifySimCancellation({
|
await this.workflowCases.notifySimCancellation({
|
||||||
accountId: mapping.sfAccountId,
|
accountId: sfAccountId,
|
||||||
...(opportunityId ? { opportunityId } : {}),
|
...(opportunityId ? { opportunityId } : {}),
|
||||||
simAccount: account,
|
simAccount: account,
|
||||||
iccid: simDetails.iccid || "N/A",
|
iccid: simDetails.iccid || "N/A",
|
||||||
@ -244,7 +242,7 @@ export class SimCancellationService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log("SIM cancellation case created via WorkflowCaseManager", {
|
this.logger.log("SIM cancellation case created via WorkflowCaseManager", {
|
||||||
sfAccountIdTail: mapping.sfAccountId.slice(-4),
|
sfAccountIdTail: sfAccountId.slice(-4),
|
||||||
opportunityId: opportunityId ? opportunityId.slice(-4) : null,
|
opportunityId: opportunityId ? opportunityId.slice(-4) : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@ 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 { SubscriptionsService } from "../subscriptions.service.js";
|
||||||
import { SimManagementService } from "../sim-management.service.js";
|
import { SimManagementService } from "../sim-management.service.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 all SIM management services
|
||||||
@ -45,6 +46,7 @@ import { WorkflowModule } from "@bff/modules/shared/workflow/index.js";
|
|||||||
SecurityModule,
|
SecurityModule,
|
||||||
VoiceOptionsModule,
|
VoiceOptionsModule,
|
||||||
WorkflowModule,
|
WorkflowModule,
|
||||||
|
SharedSubscriptionsModule,
|
||||||
],
|
],
|
||||||
// SimController is registered in SubscriptionsModule to ensure route order
|
// SimController is registered in SubscriptionsModule to ensure route order
|
||||||
// (more specific routes like :id/sim must be registered before :id)
|
// (more specific routes like :id/sim must be registered before :id)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user