diff --git a/apps/bff/src/integrations/salesforce/salesforce.module.ts b/apps/bff/src/integrations/salesforce/salesforce.module.ts index e85dd72b..a70b5936 100644 --- a/apps/bff/src/integrations/salesforce/salesforce.module.ts +++ b/apps/bff/src/integrations/salesforce/salesforce.module.ts @@ -11,6 +11,9 @@ import { OpportunityResolutionService } from "./services/opportunity-resolution. import { SalesforceOrderFieldConfigModule } from "./config/salesforce-order-field-config.module.js"; import { SalesforceReadThrottleGuard } from "./guards/salesforce-read-throttle.guard.js"; import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-throttle.guard.js"; +import { OpportunityQueryService } from "./services/opportunity/opportunity-query.service.js"; +import { OpportunityCancellationService } from "./services/opportunity/opportunity-cancellation.service.js"; +import { OpportunityMutationService } from "./services/opportunity/opportunity-mutation.service.js"; @Module({ imports: [QueueModule, ConfigModule, SalesforceOrderFieldConfigModule], @@ -19,6 +22,11 @@ import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-throttle SalesforceAccountService, SalesforceOrderService, SalesforceCaseService, + // Opportunity decomposed services + OpportunityQueryService, + OpportunityCancellationService, + OpportunityMutationService, + // Opportunity facade (depends on decomposed services) SalesforceOpportunityService, OpportunityResolutionService, SalesforceService, diff --git a/apps/bff/src/integrations/salesforce/services/opportunity/index.ts b/apps/bff/src/integrations/salesforce/services/opportunity/index.ts new file mode 100644 index 00000000..6727d9c0 --- /dev/null +++ b/apps/bff/src/integrations/salesforce/services/opportunity/index.ts @@ -0,0 +1,21 @@ +/** + * Opportunity Services - Public API + * + * Decomposed Salesforce Opportunity services for focused responsibilities. + */ + +export { OpportunityQueryService } from "./opportunity-query.service.js"; +export type { + InternetCancellationStatusResult, + SimCancellationStatusResult, +} from "./opportunity-query.service.js"; + +export { OpportunityCancellationService } from "./opportunity-cancellation.service.js"; + +export { OpportunityMutationService } from "./opportunity-mutation.service.js"; + +export type { + SalesforceOpportunityRecord, + InternetCancellationOpportunityDataInput, + SimCancellationOpportunityDataInput, +} from "./opportunity.types.js"; diff --git a/apps/bff/src/integrations/salesforce/services/opportunity/opportunity-cancellation.service.ts b/apps/bff/src/integrations/salesforce/services/opportunity/opportunity-cancellation.service.ts new file mode 100644 index 00000000..398f7cea --- /dev/null +++ b/apps/bff/src/integrations/salesforce/services/opportunity/opportunity-cancellation.service.ts @@ -0,0 +1,195 @@ +/** + * Opportunity Cancellation Service + * + * Handles cancellation-specific operations for Salesforce Opportunities. + * Extracted from SalesforceOpportunityService for single-responsibility. + */ + +import { Injectable, Inject } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { SalesforceConnection } from "../salesforce-connection.service.js"; +import { assertSalesforceId } from "../../utils/soql.util.js"; +import { extractErrorMessage } from "@bff/core/utils/error.util.js"; +import { + type CancellationOpportunityData, + OPPORTUNITY_STAGE, +} from "@customer-portal/domain/opportunity"; +import { OPPORTUNITY_FIELD_MAP } from "../../constants/index.js"; +import { + type InternetCancellationOpportunityDataInput, + type SimCancellationOpportunityDataInput, + isRecord, + requireStringField, +} from "./opportunity.types.js"; + +@Injectable() +export class OpportunityCancellationService { + constructor( + private readonly sf: SalesforceConnection, + @Inject(Logger) private readonly logger: Logger + ) {} + + /** + * Update Opportunity with Internet cancellation data from form submission + * + * Sets: + * - Stage to △Cancelling + * - ScheduledCancellationDateAndTime__c + * - CancellationNotice__c to 有 (received) + * - LineReturn__c to NotYet + */ + async updateInternetCancellationData( + opportunityId: string, + data: InternetCancellationOpportunityDataInput + ): Promise { + const safeOppId = assertSalesforceId(opportunityId, "opportunityId"); + + const safeData = (() => { + const unknownData: unknown = data; + if (!isRecord(unknownData)) throw new Error("Invalid cancellation data"); + + return { + scheduledCancellationDate: requireStringField(unknownData, "scheduledCancellationDate"), + cancellationNotice: requireStringField(unknownData, "cancellationNotice"), + lineReturnStatus: requireStringField(unknownData, "lineReturnStatus"), + }; + })(); + + this.logger.log("Updating Opportunity with Internet cancellation data", { + opportunityId: safeOppId, + scheduledDate: safeData.scheduledCancellationDate, + cancellationNotice: safeData.cancellationNotice, + }); + + const payload: Record = { + Id: safeOppId, + [OPPORTUNITY_FIELD_MAP.stage]: OPPORTUNITY_STAGE.CANCELLING, + [OPPORTUNITY_FIELD_MAP.scheduledCancellationDate]: safeData.scheduledCancellationDate, + [OPPORTUNITY_FIELD_MAP.cancellationNotice]: safeData.cancellationNotice, + [OPPORTUNITY_FIELD_MAP.lineReturnStatus]: safeData.lineReturnStatus, + }; + + try { + const updateMethod = this.sf.sobject("Opportunity").update; + if (!updateMethod) { + throw new Error("Salesforce Opportunity update method not available"); + } + + await updateMethod(payload as Record & { Id: string }); + + this.logger.log("Opportunity Internet cancellation data updated successfully", { + opportunityId: safeOppId, + scheduledDate: safeData.scheduledCancellationDate, + }); + } catch (error) { + this.logger.error("Failed to update Opportunity Internet cancellation data", { + error: extractErrorMessage(error), + opportunityId: safeOppId, + }); + throw new Error("Failed to update cancellation information"); + } + } + + /** + * Update Opportunity with SIM cancellation data from form submission + * + * Sets: + * - Stage to △Cancelling + * - SIMScheduledCancellationDateAndTime__c + * - SIMCancellationNotice__c to 有 (received) + */ + async updateSimCancellationData( + opportunityId: string, + data: SimCancellationOpportunityDataInput + ): Promise { + const safeOppId = assertSalesforceId(opportunityId, "opportunityId"); + + const safeData = (() => { + const unknownData: unknown = data; + if (!isRecord(unknownData)) throw new Error("Invalid SIM cancellation data"); + + return { + scheduledCancellationDate: requireStringField(unknownData, "scheduledCancellationDate"), + cancellationNotice: requireStringField(unknownData, "cancellationNotice"), + }; + })(); + + this.logger.log("Updating Opportunity with SIM cancellation data", { + opportunityId: safeOppId, + scheduledDate: safeData.scheduledCancellationDate, + cancellationNotice: safeData.cancellationNotice, + }); + + const payload: Record = { + Id: safeOppId, + [OPPORTUNITY_FIELD_MAP.stage]: OPPORTUNITY_STAGE.CANCELLING, + [OPPORTUNITY_FIELD_MAP.simScheduledCancellationDate]: safeData.scheduledCancellationDate, + [OPPORTUNITY_FIELD_MAP.simCancellationNotice]: safeData.cancellationNotice, + }; + + try { + const updateMethod = this.sf.sobject("Opportunity").update; + if (!updateMethod) { + throw new Error("Salesforce Opportunity update method not available"); + } + + await updateMethod(payload as Record & { Id: string }); + + this.logger.log("Opportunity SIM cancellation data updated successfully", { + opportunityId: safeOppId, + scheduledDate: safeData.scheduledCancellationDate, + }); + } catch (error) { + this.logger.error("Failed to update Opportunity SIM cancellation data", { + error: extractErrorMessage(error), + opportunityId: safeOppId, + }); + throw new Error("Failed to update SIM cancellation information"); + } + } + + /** + * @deprecated Use updateInternetCancellationData or updateSimCancellationData + */ + async updateCancellationData( + opportunityId: string, + data: CancellationOpportunityData + ): Promise { + return this.updateInternetCancellationData(opportunityId, data); + } + + /** + * Mark cancellation as complete by updating stage to Cancelled + */ + async markCancellationComplete(opportunityId: string): Promise { + const safeOppId = assertSalesforceId(opportunityId, "opportunityId"); + + this.logger.log("Marking Opportunity cancellation complete", { + opportunityId: safeOppId, + }); + + const payload: Record = { + Id: safeOppId, + [OPPORTUNITY_FIELD_MAP.stage]: OPPORTUNITY_STAGE.CANCELLED, + }; + + try { + const updateMethod = this.sf.sobject("Opportunity").update; + if (!updateMethod) { + throw new Error("Salesforce Opportunity update method not available"); + } + + await updateMethod(payload as Record & { Id: string }); + + this.logger.log("Opportunity cancellation marked complete", { + opportunityId: safeOppId, + }); + } catch (error) { + this.logger.error("Failed to mark Opportunity cancellation complete", { + error: extractErrorMessage(error), + opportunityId: safeOppId, + }); + throw new Error("Failed to mark cancellation complete"); + } + } +} diff --git a/apps/bff/src/integrations/salesforce/services/opportunity/opportunity-mutation.service.ts b/apps/bff/src/integrations/salesforce/services/opportunity/opportunity-mutation.service.ts new file mode 100644 index 00000000..133fccb5 --- /dev/null +++ b/apps/bff/src/integrations/salesforce/services/opportunity/opportunity-mutation.service.ts @@ -0,0 +1,281 @@ +/** + * Opportunity Mutation Service + * + * Handles create, update, and link operations for Salesforce Opportunities. + * Extracted from SalesforceOpportunityService for single-responsibility. + */ + +import { Injectable, Inject } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Logger } from "nestjs-pino"; +import { SalesforceConnection } from "../salesforce-connection.service.js"; +import { assertSalesforceId } from "../../utils/soql.util.js"; +import { extractErrorMessage } from "@bff/core/utils/error.util.js"; +import { + type OpportunityStageValue, + type OpportunityProductTypeValue, + type CreateOpportunityRequest, + OPPORTUNITY_STAGE, + APPLICATION_STAGE, + OPPORTUNITY_PRODUCT_TYPE, + getDefaultCommodityType, +} from "@customer-portal/domain/opportunity"; +import { OPPORTUNITY_FIELD_MAP } from "../../constants/index.js"; + +@Injectable() +export class OpportunityMutationService { + private readonly opportunityRecordTypeIds: Partial< + Record + >; + + constructor( + private readonly sf: SalesforceConnection, + private readonly configService: ConfigService, + @Inject(Logger) private readonly logger: Logger + ) { + this.opportunityRecordTypeIds = { + [OPPORTUNITY_PRODUCT_TYPE.INTERNET]: this.configService.get( + "OPPORTUNITY_RECORD_TYPE_ID_INTERNET" + ), + [OPPORTUNITY_PRODUCT_TYPE.SIM]: this.configService.get( + "OPPORTUNITY_RECORD_TYPE_ID_SIM" + ), + [OPPORTUNITY_PRODUCT_TYPE.VPN]: this.configService.get( + "OPPORTUNITY_RECORD_TYPE_ID_VPN" + ), + }; + } + + /** + * Create a new Opportunity in Salesforce + */ + async createOpportunity(request: CreateOpportunityRequest): Promise { + const safeAccountId = assertSalesforceId(request.accountId, "accountId"); + + this.logger.log("Creating Opportunity for service lifecycle tracking", { + accountId: safeAccountId, + productType: request.productType, + stage: request.stage, + source: request.source, + }); + + const opportunityName = `Portal - ${request.productType}`; + const closeDate = + request.closeDate ?? this.calculateCloseDate(request.productType, request.stage); + const applicationStage = request.applicationStage ?? APPLICATION_STAGE.INTRO_1; + const commodityType = getDefaultCommodityType(request.productType); + + const recordTypeId = this.resolveOpportunityRecordTypeId(request.productType); + const payload: Record = { + [OPPORTUNITY_FIELD_MAP.name]: opportunityName, + [OPPORTUNITY_FIELD_MAP.accountId]: safeAccountId, + [OPPORTUNITY_FIELD_MAP.stage]: request.stage, + [OPPORTUNITY_FIELD_MAP.closeDate]: closeDate, + [OPPORTUNITY_FIELD_MAP.applicationStage]: applicationStage, + [OPPORTUNITY_FIELD_MAP.commodityType]: commodityType, + ...(recordTypeId ? { RecordTypeId: recordTypeId } : {}), + }; + + if (request.source) { + payload[OPPORTUNITY_FIELD_MAP.source] = request.source; + } + + try { + const createMethod = this.sf.sobject("Opportunity").create; + if (!createMethod) { + throw new Error("Salesforce Opportunity create method not available"); + } + + const result = (await createMethod(payload)) as { id?: string; success?: boolean }; + + if (!result?.id) { + throw new Error("Salesforce did not return Opportunity ID"); + } + + this.logger.log("Opportunity created successfully", { + opportunityId: result.id, + productType: request.productType, + stage: request.stage, + }); + + return result.id; + } catch (error) { + const errorDetails: Record = { + error: extractErrorMessage(error), + accountId: safeAccountId, + productType: request.productType, + stage: request.stage, + payloadFields: Object.keys(payload), + }; + + if (error && typeof error === "object") { + const err = error as Record; + if (err.errorCode) errorDetails.errorCode = err.errorCode; + if (err.fields) errorDetails.fields = err.fields; + if (err.message) errorDetails.rawMessage = err.message; + } + + this.logger.error(errorDetails, "Failed to create Opportunity"); + throw new Error("Failed to create service lifecycle record"); + } + } + + /** + * Update Opportunity stage + */ + async updateStage( + opportunityId: string, + stage: OpportunityStageValue, + reason?: string + ): Promise { + const safeOppId = assertSalesforceId(opportunityId, "opportunityId"); + + this.logger.log("Updating Opportunity stage", { + opportunityId: safeOppId, + newStage: stage, + reason, + }); + + const payload: Record = { + Id: safeOppId, + [OPPORTUNITY_FIELD_MAP.stage]: stage, + }; + + try { + const updateMethod = this.sf.sobject("Opportunity").update; + if (!updateMethod) { + throw new Error("Salesforce Opportunity update method not available"); + } + + await updateMethod(payload as Record & { Id: string }); + + this.logger.log("Opportunity stage updated successfully", { + opportunityId: safeOppId, + stage, + }); + } catch (error) { + this.logger.error("Failed to update Opportunity stage", { + error: extractErrorMessage(error), + opportunityId: safeOppId, + stage, + }); + throw new Error("Failed to update service lifecycle stage"); + } + } + + /** + * Link a WHMCS Service ID to an Opportunity + */ + async linkWhmcsServiceToOpportunity( + opportunityId: string, + whmcsServiceId: number + ): Promise { + const safeOppId = assertSalesforceId(opportunityId, "opportunityId"); + + this.logger.log("Linking WHMCS Service to Opportunity", { + opportunityId: safeOppId, + whmcsServiceId, + }); + + const payload: Record = { + Id: safeOppId, + [OPPORTUNITY_FIELD_MAP.whmcsServiceId]: whmcsServiceId, + }; + + try { + const updateMethod = this.sf.sobject("Opportunity").update; + if (!updateMethod) { + throw new Error("Salesforce Opportunity update method not available"); + } + + await updateMethod(payload as Record & { Id: string }); + + this.logger.log("WHMCS Service linked to Opportunity", { + opportunityId: safeOppId, + whmcsServiceId, + }); + } catch (error) { + this.logger.error("Failed to link WHMCS Service to Opportunity", { + error: extractErrorMessage(error), + opportunityId: safeOppId, + whmcsServiceId, + }); + // Don't throw - this is a non-critical update + } + } + + /** + * Link an Order to an Opportunity (update Order.OpportunityId) + */ + async linkOrderToOpportunity(orderId: string, opportunityId: string): Promise { + const safeOrderId = assertSalesforceId(orderId, "orderId"); + const safeOppId = assertSalesforceId(opportunityId, "opportunityId"); + + this.logger.log("Linking Order to Opportunity", { + orderId: safeOrderId, + opportunityId: safeOppId, + }); + + try { + const updateMethod = this.sf.sobject("Order").update; + if (!updateMethod) { + throw new Error("Salesforce Order update method not available"); + } + + await updateMethod({ + Id: safeOrderId, + OpportunityId: safeOppId, + }); + + this.logger.log("Order linked to Opportunity", { + orderId: safeOrderId, + opportunityId: safeOppId, + }); + } catch (error) { + this.logger.error("Failed to link Order to Opportunity", { + error: extractErrorMessage(error), + orderId: safeOrderId, + opportunityId: safeOppId, + }); + // Don't throw - this is a non-critical update + } + } + + // ========================================================================== + // Private Helpers + // ========================================================================== + + private calculateCloseDate( + productType: OpportunityProductTypeValue, + stage: OpportunityStageValue + ): string { + const today = new Date(); + let daysToAdd: number; + + switch (stage) { + case OPPORTUNITY_STAGE.INTRODUCTION: + daysToAdd = 30; + break; + case OPPORTUNITY_STAGE.READY: + daysToAdd = 14; + break; + case OPPORTUNITY_STAGE.POST_PROCESSING: + daysToAdd = 7; + break; + default: + daysToAdd = 30; + } + + const closeDate = new Date(today); + closeDate.setDate(closeDate.getDate() + daysToAdd); + + return closeDate.toISOString().slice(0, 10); + } + + private resolveOpportunityRecordTypeId( + productType: OpportunityProductTypeValue + ): string | undefined { + const recordTypeId = this.opportunityRecordTypeIds[productType]; + return recordTypeId ? assertSalesforceId(recordTypeId, "opportunityRecordTypeId") : undefined; + } +} diff --git a/apps/bff/src/integrations/salesforce/services/opportunity/opportunity-query.service.ts b/apps/bff/src/integrations/salesforce/services/opportunity/opportunity-query.service.ts new file mode 100644 index 00000000..6e09cef5 --- /dev/null +++ b/apps/bff/src/integrations/salesforce/services/opportunity/opportunity-query.service.ts @@ -0,0 +1,450 @@ +/** + * Opportunity Query Service + * + * Handles all read/lookup operations for Salesforce Opportunities. + * Extracted from SalesforceOpportunityService for single-responsibility. + */ + +import { Injectable, Inject } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { SalesforceConnection } from "../salesforce-connection.service.js"; +import { assertSalesforceId } from "../../utils/soql.util.js"; +import { extractErrorMessage } from "@bff/core/utils/error.util.js"; +import type { SalesforceResponse } from "@customer-portal/domain/common/providers"; +import { + type OpportunityStageValue, + type OpportunityProductTypeValue, + type CancellationNoticeValue, + type LineReturnStatusValue, + type CommodityTypeValue, + type ApplicationStageValue, + type OpportunitySourceValue, + type OpportunityRecord, + OPPORTUNITY_STAGE, + OPEN_OPPORTUNITY_STAGES, + COMMODITY_TYPE, + OPPORTUNITY_PRODUCT_TYPE, + getCommodityTypeProductType, +} from "@customer-portal/domain/opportunity"; +import { + OPPORTUNITY_FIELD_MAP, + OPPORTUNITY_MATCH_QUERY_FIELDS, + OPPORTUNITY_DETAIL_QUERY_FIELDS, + OPPORTUNITY_INTERNET_CANCELLATION_QUERY_FIELDS, + OPPORTUNITY_SIM_CANCELLATION_QUERY_FIELDS, +} from "../../constants/index.js"; +import type { SalesforceOpportunityRecord } from "./opportunity.types.js"; + +export interface InternetCancellationStatusResult { + stage: OpportunityStageValue; + isPending: boolean; + isComplete: boolean; + scheduledEndDate?: string; + rentalReturnStatus?: LineReturnStatusValue; +} + +export interface SimCancellationStatusResult { + stage: OpportunityStageValue; + isPending: boolean; + isComplete: boolean; + scheduledEndDate?: string; +} + +@Injectable() +export class OpportunityQueryService { + constructor( + private readonly sf: SalesforceConnection, + @Inject(Logger) private readonly logger: Logger + ) {} + + /** + * Find an open Opportunity for an account by product type + */ + async findOpenOpportunityForAccount( + accountId: string, + productType: OpportunityProductTypeValue, + options?: { stages?: OpportunityStageValue[] } + ): Promise { + const safeAccountId = assertSalesforceId(accountId, "accountId"); + + const commodityTypeValues = this.getCommodityTypesForProductType(productType); + + const stages = + Array.isArray(options?.stages) && options?.stages.length > 0 + ? options.stages + : OPEN_OPPORTUNITY_STAGES; + + this.logger.debug("Looking for open Opportunity", { + accountId: safeAccountId, + productType, + commodityTypes: commodityTypeValues, + stages, + }); + + const stageList = stages.map((s: OpportunityStageValue) => `'${s}'`).join(", "); + const commodityTypeList = commodityTypeValues.map(ct => `'${ct}'`).join(", "); + + const soql = ` + SELECT ${OPPORTUNITY_MATCH_QUERY_FIELDS.join(", ")} + FROM Opportunity + WHERE ${OPPORTUNITY_FIELD_MAP.accountId} = '${safeAccountId}' + AND ${OPPORTUNITY_FIELD_MAP.commodityType} IN (${commodityTypeList}) + AND ${OPPORTUNITY_FIELD_MAP.stage} IN (${stageList}) + AND ${OPPORTUNITY_FIELD_MAP.isClosed} = false + ORDER BY CreatedDate DESC + LIMIT 1 + `; + + try { + const result = (await this.sf.query(soql, { + label: "opportunity:findOpenForAccount", + })) as SalesforceResponse; + + const record = result.records?.[0]; + + if (record) { + this.logger.debug("Found open Opportunity", { + opportunityId: record.Id, + stage: record.StageName, + productType, + }); + return record.Id; + } + + this.logger.debug("No open Opportunity found", { + accountId: safeAccountId, + productType, + }); + return null; + } catch (error) { + const errorDetails: Record = { + error: extractErrorMessage(error), + accountId: safeAccountId, + productType, + }; + + if (error && typeof error === "object") { + const err = error as Record; + if (err.errorCode) errorDetails.errorCode = err.errorCode; + if (err.message) errorDetails.rawMessage = err.message; + } + + this.logger.error(errorDetails, "Failed to find open Opportunity"); + return null; + } + } + + /** + * Find Opportunity linked to an Order + */ + async findOpportunityByOrderId(orderId: string): Promise { + const safeOrderId = assertSalesforceId(orderId, "orderId"); + + this.logger.debug("Looking for Opportunity by Order ID", { + orderId: safeOrderId, + }); + + const soql = ` + SELECT OpportunityId + FROM Order + WHERE Id = '${safeOrderId}' + LIMIT 1 + `; + + try { + const result = (await this.sf.query(soql, { + label: "opportunity:findByOrderId", + })) as SalesforceResponse<{ OpportunityId?: string }>; + + const record = result.records?.[0]; + const opportunityId = record?.OpportunityId; + + if (opportunityId) { + this.logger.debug("Found Opportunity for Order", { + orderId: safeOrderId, + opportunityId, + }); + return opportunityId; + } + + return null; + } catch (error) { + this.logger.error("Failed to find Opportunity by Order ID", { + error: extractErrorMessage(error), + orderId: safeOrderId, + }); + return null; + } + } + + /** + * Find Opportunity by WHMCS Service ID + */ + async findOpportunityByWhmcsServiceId(whmcsServiceId: number): Promise { + this.logger.debug("Looking for Opportunity by WHMCS Service ID", { + whmcsServiceId, + }); + + const soql = ` + SELECT Id, ${OPPORTUNITY_FIELD_MAP.stage} + FROM Opportunity + WHERE ${OPPORTUNITY_FIELD_MAP.whmcsServiceId} = ${whmcsServiceId} + ORDER BY CreatedDate DESC + LIMIT 1 + `; + + try { + const result = (await this.sf.query(soql, { + label: "opportunity:findByWhmcsServiceId", + })) as SalesforceResponse; + + const record = result.records?.[0]; + + if (record) { + this.logger.debug("Found Opportunity for WHMCS Service", { + opportunityId: record.Id, + whmcsServiceId, + }); + return record.Id; + } + + return null; + } catch (error) { + this.logger.error("Failed to find Opportunity by WHMCS Service ID", { + error: extractErrorMessage(error), + whmcsServiceId, + }); + return null; + } + } + + /** + * Get full Opportunity details by ID + */ + async getOpportunityById(opportunityId: string): Promise { + const safeOppId = assertSalesforceId(opportunityId, "opportunityId"); + + const soql = ` + SELECT ${OPPORTUNITY_DETAIL_QUERY_FIELDS.join(", ")} + FROM Opportunity + WHERE Id = '${safeOppId}' + LIMIT 1 + `; + + try { + const result = (await this.sf.query(soql, { + label: "opportunity:getById", + })) as SalesforceResponse; + + const record = result.records?.[0]; + + if (!record) { + return null; + } + + return this.transformToOpportunityRecord(record); + } catch (error) { + this.logger.error("Failed to get Opportunity by ID", { + error: extractErrorMessage(error), + opportunityId: safeOppId, + }); + return null; + } + } + + /** + * Get Internet cancellation status by WHMCS Service ID + */ + async getInternetCancellationStatus( + whmcsServiceId: number + ): Promise { + const soql = ` + SELECT ${OPPORTUNITY_INTERNET_CANCELLATION_QUERY_FIELDS.join(", ")} + FROM Opportunity + WHERE ${OPPORTUNITY_FIELD_MAP.whmcsServiceId} = ${whmcsServiceId} + ORDER BY CreatedDate DESC + LIMIT 1 + `; + + try { + const result = (await this.sf.query(soql, { + label: "opportunity:getInternetCancellationStatus", + })) as SalesforceResponse; + + const record = result.records?.[0]; + if (!record) return null; + + return this.buildInternetCancellationResult(record); + } catch (error) { + this.logger.error("Failed to get Internet cancellation status", { + error: extractErrorMessage(error), + whmcsServiceId, + }); + return null; + } + } + + /** + * Get Internet cancellation status by Opportunity ID + */ + async getInternetCancellationStatusByOpportunityId( + opportunityId: string + ): Promise { + const safeOppId = assertSalesforceId(opportunityId, "opportunityId"); + const soql = ` + SELECT ${OPPORTUNITY_INTERNET_CANCELLATION_QUERY_FIELDS.join(", ")} + FROM Opportunity + WHERE Id = '${safeOppId}' + LIMIT 1 + `; + + try { + const result = (await this.sf.query(soql, { + label: "opportunity:getInternetCancellationStatusByOpportunityId", + })) as SalesforceResponse; + + const record = result.records?.[0]; + if (!record) return null; + + return this.buildInternetCancellationResult(record); + } catch (error) { + this.logger.error("Failed to get Internet cancellation status by Opportunity ID", { + error: extractErrorMessage(error), + opportunityId: safeOppId, + }); + return null; + } + } + + /** + * Get SIM cancellation status by WHMCS Service ID + */ + async getSimCancellationStatus( + whmcsServiceId: number + ): Promise { + const soql = ` + SELECT ${OPPORTUNITY_SIM_CANCELLATION_QUERY_FIELDS.join(", ")} + FROM Opportunity + WHERE ${OPPORTUNITY_FIELD_MAP.whmcsServiceId} = ${whmcsServiceId} + ORDER BY CreatedDate DESC + LIMIT 1 + `; + + try { + const result = (await this.sf.query(soql, { + label: "opportunity:getSimCancellationStatus", + })) as SalesforceResponse; + + const record = result.records?.[0]; + if (!record) return null; + + return this.buildSimCancellationResult(record); + } catch (error) { + this.logger.error("Failed to get SIM cancellation status", { + error: extractErrorMessage(error), + whmcsServiceId, + }); + return null; + } + } + + /** + * Get SIM cancellation status by Opportunity ID + */ + async getSimCancellationStatusByOpportunityId( + opportunityId: string + ): Promise { + const safeOppId = assertSalesforceId(opportunityId, "opportunityId"); + const soql = ` + SELECT ${OPPORTUNITY_SIM_CANCELLATION_QUERY_FIELDS.join(", ")} + FROM Opportunity + WHERE Id = '${safeOppId}' + LIMIT 1 + `; + + try { + const result = (await this.sf.query(soql, { + label: "opportunity:getSimCancellationStatusByOpportunityId", + })) as SalesforceResponse; + + const record = result.records?.[0]; + if (!record) return null; + + return this.buildSimCancellationResult(record); + } catch (error) { + this.logger.error("Failed to get SIM cancellation status by Opportunity ID", { + error: extractErrorMessage(error), + opportunityId: safeOppId, + }); + return null; + } + } + + // ========================================================================== + // Private Helpers + // ========================================================================== + + private getCommodityTypesForProductType( + productType: OpportunityProductTypeValue + ): CommodityTypeValue[] { + switch (productType) { + case OPPORTUNITY_PRODUCT_TYPE.INTERNET: + return [COMMODITY_TYPE.PERSONAL_HOME_INTERNET, COMMODITY_TYPE.CORPORATE_HOME_INTERNET]; + case OPPORTUNITY_PRODUCT_TYPE.SIM: + return [COMMODITY_TYPE.SIM]; + case OPPORTUNITY_PRODUCT_TYPE.VPN: + return [COMMODITY_TYPE.VPN]; + default: + return []; + } + } + + private buildInternetCancellationResult( + record: SalesforceOpportunityRecord + ): InternetCancellationStatusResult { + const stage = record.StageName as OpportunityStageValue; + return { + stage, + isPending: stage === OPPORTUNITY_STAGE.CANCELLING, + isComplete: stage === OPPORTUNITY_STAGE.CANCELLED, + scheduledEndDate: record.ScheduledCancellationDateAndTime__c, + rentalReturnStatus: record.LineReturn__c as LineReturnStatusValue | undefined, + }; + } + + private buildSimCancellationResult( + record: SalesforceOpportunityRecord + ): SimCancellationStatusResult { + const stage = record.StageName as OpportunityStageValue; + return { + stage, + isPending: stage === OPPORTUNITY_STAGE.CANCELLING, + isComplete: stage === OPPORTUNITY_STAGE.CANCELLED, + scheduledEndDate: record.SIMScheduledCancellationDateAndTime__c, + }; + } + + private transformToOpportunityRecord(record: SalesforceOpportunityRecord): OpportunityRecord { + const commodityType = record.CommodityType as CommodityTypeValue | undefined; + const productType = commodityType ? getCommodityTypeProductType(commodityType) : undefined; + + return { + id: record.Id, + name: record.Name, + accountId: record.AccountId, + stage: record.StageName as OpportunityStageValue, + closeDate: record.CloseDate, + commodityType, + productType: productType ?? undefined, + source: record.Opportunity_Source__c as OpportunitySourceValue | undefined, + applicationStage: record.Application_Stage__c as ApplicationStageValue | undefined, + isClosed: record.IsClosed, + whmcsServiceId: record.WHMCS_Service_ID__c, + scheduledCancellationDate: record.ScheduledCancellationDateAndTime__c, + cancellationNotice: record.CancellationNotice__c as CancellationNoticeValue | undefined, + lineReturnStatus: record.LineReturn__c as LineReturnStatusValue | undefined, + createdDate: record.CreatedDate, + lastModifiedDate: record.LastModifiedDate, + }; + } +} diff --git a/apps/bff/src/integrations/salesforce/services/opportunity/opportunity.types.ts b/apps/bff/src/integrations/salesforce/services/opportunity/opportunity.types.ts new file mode 100644 index 00000000..74aca886 --- /dev/null +++ b/apps/bff/src/integrations/salesforce/services/opportunity/opportunity.types.ts @@ -0,0 +1,55 @@ +/** + * Shared types for Opportunity services + */ + +import type { CancellationOpportunityData } from "@customer-portal/domain/opportunity"; + +/** + * Raw Opportunity record from Salesforce query + */ +export interface SalesforceOpportunityRecord { + Id: string; + Name: string; + AccountId: string; + StageName: string; + CloseDate: string; + IsClosed: boolean; + IsWon?: boolean; + CreatedDate: string; + LastModifiedDate: string; + // Existing custom fields + Application_Stage__c?: string; + CommodityType?: string; + // Internet cancellation fields + ScheduledCancellationDateAndTime__c?: string; + CancellationNotice__c?: string; + LineReturn__c?: string; + // SIM cancellation fields + SIMCancellationNotice__c?: string; + SIMScheduledCancellationDateAndTime__c?: string; + // Portal integration custom fields + Opportunity_Source__c?: string; + WHMCS_Service_ID__c?: number; + // Relationship fields + Account?: { Name?: string }; +} + +export type InternetCancellationOpportunityDataInput = Pick< + CancellationOpportunityData, + "scheduledCancellationDate" | "cancellationNotice" | "lineReturnStatus" +>; + +export type SimCancellationOpportunityDataInput = Pick< + CancellationOpportunityData, + "scheduledCancellationDate" | "cancellationNotice" +>; + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +export function requireStringField(record: Record, field: string): string { + const value = record[field]; + if (typeof value === "string") return value; + throw new Error(`Invalid ${field}`); +} diff --git a/apps/bff/src/integrations/salesforce/services/salesforce-opportunity.service.ts b/apps/bff/src/integrations/salesforce/services/salesforce-opportunity.service.ts index 41f98d3d..978aae76 100644 --- a/apps/bff/src/integrations/salesforce/services/salesforce-opportunity.service.ts +++ b/apps/bff/src/integrations/salesforce/services/salesforce-opportunity.service.ts @@ -1,8 +1,8 @@ /** - * Salesforce Opportunity Integration Service + * Salesforce Opportunity Integration Service (Facade) * * Manages Opportunity records for service lifecycle tracking. - * Opportunities track customer journeys from interest through cancellation. + * Delegates to focused sub-services for different operations. * * Key responsibilities: * - Create Opportunities at interest triggers (eligibility request, registration) @@ -16,406 +16,79 @@ * @see docs/salesforce/OPPORTUNITY-LIFECYCLE-GUIDE.md for complete documentation */ -import { Injectable, Inject } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { Logger } from "nestjs-pino"; -import { SalesforceConnection } from "./salesforce-connection.service.js"; -import { assertSalesforceId } from "../utils/soql.util.js"; -import { extractErrorMessage } from "@bff/core/utils/error.util.js"; -import type { SalesforceResponse } from "@customer-portal/domain/common/providers"; +import { Injectable } from "@nestjs/common"; import { type OpportunityStageValue, type OpportunityProductTypeValue, - type OpportunitySourceValue, - type ApplicationStageValue, - type CancellationNoticeValue, - type LineReturnStatusValue, - type CommodityTypeValue, type CancellationOpportunityData, + type LineReturnStatusValue, type CreateOpportunityRequest, type OpportunityRecord, - OPPORTUNITY_STAGE, - APPLICATION_STAGE, - OPEN_OPPORTUNITY_STAGES, - COMMODITY_TYPE, - OPPORTUNITY_PRODUCT_TYPE, - getDefaultCommodityType, - getCommodityTypeProductType, } from "@customer-portal/domain/opportunity"; -import { - OPPORTUNITY_FIELD_MAP, - OPPORTUNITY_MATCH_QUERY_FIELDS, - OPPORTUNITY_DETAIL_QUERY_FIELDS, - OPPORTUNITY_INTERNET_CANCELLATION_QUERY_FIELDS, - OPPORTUNITY_SIM_CANCELLATION_QUERY_FIELDS, -} from "../constants/index.js"; - -// ============================================================================ -// Types -// ============================================================================ +import { OpportunityQueryService } from "./opportunity/opportunity-query.service.js"; +import { OpportunityCancellationService } from "./opportunity/opportunity-cancellation.service.js"; +import { OpportunityMutationService } from "./opportunity/opportunity-mutation.service.js"; +import type { + InternetCancellationOpportunityDataInput, + SimCancellationOpportunityDataInput, +} from "./opportunity/opportunity.types.js"; /** - * Raw Opportunity record from Salesforce query + * Facade service that provides a unified API for Opportunity operations. + * Delegates to specialized sub-services for focused responsibilities. */ -interface SalesforceOpportunityRecord { - Id: string; - Name: string; - AccountId: string; - StageName: string; - CloseDate: string; - IsClosed: boolean; - IsWon?: boolean; - CreatedDate: string; - LastModifiedDate: string; - // Existing custom fields - Application_Stage__c?: string; - CommodityType?: string; // Existing product type field - // Internet cancellation fields - ScheduledCancellationDateAndTime__c?: string; - CancellationNotice__c?: string; - LineReturn__c?: string; - // SIM cancellation fields - SIMCancellationNotice__c?: string; - SIMScheduledCancellationDateAndTime__c?: string; - // Portal integration custom fields (existing) - Opportunity_Source__c?: string; - WHMCS_Service_ID__c?: number; - // Note: Cases link TO Opportunity via Case.Opportunity__c and Orders via Order.OpportunityId - // Relationship fields - Account?: { Name?: string }; -} - -type InternetCancellationOpportunityDataInput = Pick< - CancellationOpportunityData, - "scheduledCancellationDate" | "cancellationNotice" | "lineReturnStatus" ->; - -type SimCancellationOpportunityDataInput = Pick< - CancellationOpportunityData, - "scheduledCancellationDate" | "cancellationNotice" ->; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function requireStringField(record: Record, field: string): string { - const value = record[field]; - if (typeof value === "string") return value; - throw new Error(`Invalid ${field}`); -} - -// ============================================================================ -// Service -// ============================================================================ - @Injectable() export class SalesforceOpportunityService { constructor( - private readonly sf: SalesforceConnection, - private readonly configService: ConfigService, - @Inject(Logger) private readonly logger: Logger - ) { - this.opportunityRecordTypeIds = { - [OPPORTUNITY_PRODUCT_TYPE.INTERNET]: this.configService.get( - "OPPORTUNITY_RECORD_TYPE_ID_INTERNET" - ), - [OPPORTUNITY_PRODUCT_TYPE.SIM]: this.configService.get( - "OPPORTUNITY_RECORD_TYPE_ID_SIM" - ), - [OPPORTUNITY_PRODUCT_TYPE.VPN]: this.configService.get( - "OPPORTUNITY_RECORD_TYPE_ID_VPN" - ), - }; - } - - private readonly opportunityRecordTypeIds: Partial< - Record - >; + private readonly queryService: OpportunityQueryService, + private readonly cancellationService: OpportunityCancellationService, + private readonly mutationService: OpportunityMutationService + ) {} // ========================================================================== - // Core CRUD Operations + // Core CRUD Operations (via MutationService) // ========================================================================== /** * Create a new Opportunity in Salesforce - * - * @param request - Opportunity creation parameters - * @returns The created Opportunity ID - * - * @example - * // Create for Internet eligibility request - * const oppId = await service.createOpportunity({ - * accountId: 'SF_ACCOUNT_ID', - * productType: 'Internet', - * stage: 'Introduction', - * source: 'Portal - Internet Eligibility Request', - * }); - * - * // Then create a Case linked to this Opportunity: - * await caseService.createCase({ - * type: 'Eligibility Check', - * opportunityId: oppId, // Case links TO Opportunity - * ... - * }); */ async createOpportunity(request: CreateOpportunityRequest): Promise { - const safeAccountId = assertSalesforceId(request.accountId, "accountId"); - - this.logger.log("Creating Opportunity for service lifecycle tracking", { - accountId: safeAccountId, - productType: request.productType, - stage: request.stage, - source: request.source, - }); - - // Opportunity Name - Salesforce workflow will auto-generate the real name - // We provide a placeholder that includes product type for debugging - const opportunityName = `Portal - ${request.productType}`; - - // Calculate close date (default: 30 days from now) - const closeDate = - request.closeDate ?? this.calculateCloseDate(request.productType, request.stage); - - // Application stage defaults to INTRO-1 for portal - const applicationStage = request.applicationStage ?? APPLICATION_STAGE.INTRO_1; - - // Get the CommodityType from the simplified product type - const commodityType = getDefaultCommodityType(request.productType); - - const recordTypeId = this.resolveOpportunityRecordTypeId(request.productType); - const payload: Record = { - [OPPORTUNITY_FIELD_MAP.name]: opportunityName, - [OPPORTUNITY_FIELD_MAP.accountId]: safeAccountId, - [OPPORTUNITY_FIELD_MAP.stage]: request.stage, - [OPPORTUNITY_FIELD_MAP.closeDate]: closeDate, - [OPPORTUNITY_FIELD_MAP.applicationStage]: applicationStage, - [OPPORTUNITY_FIELD_MAP.commodityType]: commodityType, - ...(recordTypeId ? { RecordTypeId: recordTypeId } : {}), - }; - - // Add optional custom fields (only if they exist in Salesforce) - if (request.source) { - payload[OPPORTUNITY_FIELD_MAP.source] = request.source; - } - // Note: Cases (eligibility, ID verification) link TO Opportunity via Case.Opportunity__c - // Orders link TO Opportunity via Order.OpportunityId - - try { - const createMethod = this.sf.sobject("Opportunity").create; - if (!createMethod) { - throw new Error("Salesforce Opportunity create method not available"); - } - - const result = (await createMethod(payload)) as { id?: string; success?: boolean }; - - if (!result?.id) { - throw new Error("Salesforce did not return Opportunity ID"); - } - - this.logger.log("Opportunity created successfully", { - opportunityId: result.id, - productType: request.productType, - stage: request.stage, - }); - - return result.id; - } catch (error) { - const errorDetails: Record = { - error: extractErrorMessage(error), - accountId: safeAccountId, - productType: request.productType, - stage: request.stage, - payloadFields: Object.keys(payload), - }; - - if (error && typeof error === "object") { - const err = error as Record; - if (err.errorCode) errorDetails.errorCode = err.errorCode; - if (err.fields) errorDetails.fields = err.fields; - if (err.message) errorDetails.rawMessage = err.message; - } - - this.logger.error(errorDetails, "Failed to create Opportunity"); - throw new Error("Failed to create service lifecycle record"); - } + return this.mutationService.createOpportunity(request); } /** * Update Opportunity stage - * - * @param opportunityId - Salesforce Opportunity ID - * @param stage - New stage value (must be valid Salesforce picklist value) - * @param reason - Optional reason for stage change (for audit) */ async updateStage( opportunityId: string, stage: OpportunityStageValue, reason?: string ): Promise { - const safeOppId = assertSalesforceId(opportunityId, "opportunityId"); - - this.logger.log("Updating Opportunity stage", { - opportunityId: safeOppId, - newStage: stage, - reason, - }); - - const payload: Record = { - Id: safeOppId, - [OPPORTUNITY_FIELD_MAP.stage]: stage, - }; - - try { - const updateMethod = this.sf.sobject("Opportunity").update; - if (!updateMethod) { - throw new Error("Salesforce Opportunity update method not available"); - } - - await updateMethod(payload as Record & { Id: string }); - - this.logger.log("Opportunity stage updated successfully", { - opportunityId: safeOppId, - stage, - }); - } catch (error) { - this.logger.error("Failed to update Opportunity stage", { - error: extractErrorMessage(error), - opportunityId: safeOppId, - stage, - }); - throw new Error("Failed to update service lifecycle stage"); - } + return this.mutationService.updateStage(opportunityId, stage, reason); } + // ========================================================================== + // Cancellation Operations (via CancellationService) + // ========================================================================== + /** * Update Opportunity with Internet cancellation data from form submission - * - * Sets: - * - Stage to △Cancelling - * - ScheduledCancellationDateAndTime__c - * - CancellationNotice__c to 有 (received) - * - LineReturn__c to NotYet - * - * NOTE: Comments and alternative email go on the Cancellation Case, not Opportunity. - * The Case is created separately and linked to this Opportunity via Case.Opportunity__c. - * - * @param opportunityId - Salesforce Opportunity ID - * @param data - Internet cancellation data (dates and status flags) */ async updateInternetCancellationData( opportunityId: string, data: InternetCancellationOpportunityDataInput ): Promise { - const safeOppId = assertSalesforceId(opportunityId, "opportunityId"); - - const safeData = (() => { - const unknownData: unknown = data; - if (!isRecord(unknownData)) throw new Error("Invalid cancellation data"); - - return { - scheduledCancellationDate: requireStringField(unknownData, "scheduledCancellationDate"), - cancellationNotice: requireStringField(unknownData, "cancellationNotice"), - lineReturnStatus: requireStringField(unknownData, "lineReturnStatus"), - }; - })(); - - this.logger.log("Updating Opportunity with Internet cancellation data", { - opportunityId: safeOppId, - scheduledDate: safeData.scheduledCancellationDate, - cancellationNotice: safeData.cancellationNotice, - }); - - const payload: Record = { - Id: safeOppId, - [OPPORTUNITY_FIELD_MAP.stage]: OPPORTUNITY_STAGE.CANCELLING, - [OPPORTUNITY_FIELD_MAP.scheduledCancellationDate]: safeData.scheduledCancellationDate, - [OPPORTUNITY_FIELD_MAP.cancellationNotice]: safeData.cancellationNotice, - [OPPORTUNITY_FIELD_MAP.lineReturnStatus]: safeData.lineReturnStatus, - }; - - try { - const updateMethod = this.sf.sobject("Opportunity").update; - if (!updateMethod) { - throw new Error("Salesforce Opportunity update method not available"); - } - - await updateMethod(payload as Record & { Id: string }); - - this.logger.log("Opportunity Internet cancellation data updated successfully", { - opportunityId: safeOppId, - scheduledDate: safeData.scheduledCancellationDate, - }); - } catch (error) { - this.logger.error("Failed to update Opportunity Internet cancellation data", { - error: extractErrorMessage(error), - opportunityId: safeOppId, - }); - throw new Error("Failed to update cancellation information"); - } + return this.cancellationService.updateInternetCancellationData(opportunityId, data); } /** * Update Opportunity with SIM cancellation data from form submission - * - * Sets: - * - Stage to △Cancelling - * - SIMScheduledCancellationDateAndTime__c - * - SIMCancellationNotice__c to 有 (received) - * - * NOTE: Customer comments go on the Cancellation Case, not Opportunity (same as Internet). - * - * @param opportunityId - Salesforce Opportunity ID - * @param data - SIM cancellation data (dates and status flags) */ async updateSimCancellationData( opportunityId: string, data: SimCancellationOpportunityDataInput ): Promise { - const safeOppId = assertSalesforceId(opportunityId, "opportunityId"); - - const safeData = (() => { - const unknownData: unknown = data; - if (!isRecord(unknownData)) throw new Error("Invalid SIM cancellation data"); - - return { - scheduledCancellationDate: requireStringField(unknownData, "scheduledCancellationDate"), - cancellationNotice: requireStringField(unknownData, "cancellationNotice"), - }; - })(); - - this.logger.log("Updating Opportunity with SIM cancellation data", { - opportunityId: safeOppId, - scheduledDate: safeData.scheduledCancellationDate, - cancellationNotice: safeData.cancellationNotice, - }); - - const payload: Record = { - Id: safeOppId, - [OPPORTUNITY_FIELD_MAP.stage]: OPPORTUNITY_STAGE.CANCELLING, - [OPPORTUNITY_FIELD_MAP.simScheduledCancellationDate]: safeData.scheduledCancellationDate, - [OPPORTUNITY_FIELD_MAP.simCancellationNotice]: safeData.cancellationNotice, - }; - - try { - const updateMethod = this.sf.sobject("Opportunity").update; - if (!updateMethod) { - throw new Error("Salesforce Opportunity update method not available"); - } - - await updateMethod(payload as Record & { Id: string }); - - this.logger.log("Opportunity SIM cancellation data updated successfully", { - opportunityId: safeOppId, - scheduledDate: safeData.scheduledCancellationDate, - }); - } catch (error) { - this.logger.error("Failed to update Opportunity SIM cancellation data", { - error: extractErrorMessage(error), - opportunityId: safeOppId, - }); - throw new Error("Failed to update SIM cancellation information"); - } + return this.cancellationService.updateSimCancellationData(opportunityId, data); } /** @@ -425,239 +98,54 @@ export class SalesforceOpportunityService { opportunityId: string, data: CancellationOpportunityData ): Promise { - return this.updateInternetCancellationData(opportunityId, data); + return this.cancellationService.updateCancellationData(opportunityId, data); + } + + /** + * Mark cancellation as complete + */ + async markCancellationComplete(opportunityId: string): Promise { + return this.cancellationService.markCancellationComplete(opportunityId); } // ========================================================================== - // Lookup Operations + // Lookup Operations (via QueryService) // ========================================================================== /** * Find an open Opportunity for an account by product type - * - * Used for matching orders to existing Opportunities - * - * @param accountId - Salesforce Account ID - * @param productType - Product type to match - * @returns Opportunity ID if found, null otherwise */ async findOpenOpportunityForAccount( accountId: string, productType: OpportunityProductTypeValue, options?: { stages?: OpportunityStageValue[] } ): Promise { - const safeAccountId = assertSalesforceId(accountId, "accountId"); - - // Get the CommodityType value(s) that match this product type - const commodityTypeValues = this.getCommodityTypesForProductType(productType); - - const stages = - Array.isArray(options?.stages) && options?.stages.length > 0 - ? options.stages - : OPEN_OPPORTUNITY_STAGES; - - this.logger.debug("Looking for open Opportunity", { - accountId: safeAccountId, - productType, - commodityTypes: commodityTypeValues, - stages, - }); - - // Build stage filter for open stages - const stageList = stages.map((s: OpportunityStageValue) => `'${s}'`).join(", "); - const commodityTypeList = commodityTypeValues.map(ct => `'${ct}'`).join(", "); - - const soql = ` - SELECT ${OPPORTUNITY_MATCH_QUERY_FIELDS.join(", ")} - FROM Opportunity - WHERE ${OPPORTUNITY_FIELD_MAP.accountId} = '${safeAccountId}' - AND ${OPPORTUNITY_FIELD_MAP.commodityType} IN (${commodityTypeList}) - AND ${OPPORTUNITY_FIELD_MAP.stage} IN (${stageList}) - AND ${OPPORTUNITY_FIELD_MAP.isClosed} = false - ORDER BY CreatedDate DESC - LIMIT 1 - `; - - try { - const result = (await this.sf.query(soql, { - label: "opportunity:findOpenForAccount", - })) as SalesforceResponse; - - const record = result.records?.[0]; - - if (record) { - this.logger.debug("Found open Opportunity", { - opportunityId: record.Id, - stage: record.StageName, - productType, - }); - return record.Id; - } - - this.logger.debug("No open Opportunity found", { - accountId: safeAccountId, - productType, - }); - return null; - } catch (error) { - const errorDetails: Record = { - error: extractErrorMessage(error), - accountId: safeAccountId, - productType, - }; - - if (error && typeof error === "object") { - const err = error as Record; - if (err.errorCode) errorDetails.errorCode = err.errorCode; - if (err.message) errorDetails.rawMessage = err.message; - } - - this.logger.error(errorDetails, "Failed to find open Opportunity"); - // Don't throw - return null to allow fallback to creation - return null; - } + return this.queryService.findOpenOpportunityForAccount(accountId, productType, options); } /** * Find Opportunity linked to an Order - * - * @param orderId - Salesforce Order ID - * @returns Opportunity ID if found, null otherwise */ async findOpportunityByOrderId(orderId: string): Promise { - const safeOrderId = assertSalesforceId(orderId, "orderId"); - - this.logger.debug("Looking for Opportunity by Order ID", { - orderId: safeOrderId, - }); - - const soql = ` - SELECT OpportunityId - FROM Order - WHERE Id = '${safeOrderId}' - LIMIT 1 - `; - - try { - const result = (await this.sf.query(soql, { - label: "opportunity:findByOrderId", - })) as SalesforceResponse<{ OpportunityId?: string }>; - - const record = result.records?.[0]; - const opportunityId = record?.OpportunityId; - - if (opportunityId) { - this.logger.debug("Found Opportunity for Order", { - orderId: safeOrderId, - opportunityId, - }); - return opportunityId; - } - - return null; - } catch (error) { - this.logger.error("Failed to find Opportunity by Order ID", { - error: extractErrorMessage(error), - orderId: safeOrderId, - }); - return null; - } + return this.queryService.findOpportunityByOrderId(orderId); } /** * Find Opportunity by WHMCS Service ID - * - * Used for cancellation workflows to find the Opportunity to update - * - * @param whmcsServiceId - WHMCS Service/Hosting ID - * @returns Opportunity ID if found, null otherwise */ async findOpportunityByWhmcsServiceId(whmcsServiceId: number): Promise { - this.logger.debug("Looking for Opportunity by WHMCS Service ID", { - whmcsServiceId, - }); - - const soql = ` - SELECT Id, ${OPPORTUNITY_FIELD_MAP.stage} - FROM Opportunity - WHERE ${OPPORTUNITY_FIELD_MAP.whmcsServiceId} = ${whmcsServiceId} - ORDER BY CreatedDate DESC - LIMIT 1 - `; - - try { - const result = (await this.sf.query(soql, { - label: "opportunity:findByWhmcsServiceId", - })) as SalesforceResponse; - - const record = result.records?.[0]; - - if (record) { - this.logger.debug("Found Opportunity for WHMCS Service", { - opportunityId: record.Id, - whmcsServiceId, - }); - return record.Id; - } - - return null; - } catch (error) { - this.logger.error("Failed to find Opportunity by WHMCS Service ID", { - error: extractErrorMessage(error), - whmcsServiceId, - }); - return null; - } + return this.queryService.findOpportunityByWhmcsServiceId(whmcsServiceId); } /** * Get full Opportunity details by ID - * - * @param opportunityId - Salesforce Opportunity ID - * @returns Opportunity record or null if not found */ async getOpportunityById(opportunityId: string): Promise { - const safeOppId = assertSalesforceId(opportunityId, "opportunityId"); - - const soql = ` - SELECT ${OPPORTUNITY_DETAIL_QUERY_FIELDS.join(", ")} - FROM Opportunity - WHERE Id = '${safeOppId}' - LIMIT 1 - `; - - try { - const result = (await this.sf.query(soql, { - label: "opportunity:getById", - })) as SalesforceResponse; - - const record = result.records?.[0]; - - if (!record) { - return null; - } - - return this.transformToOpportunityRecord(record); - } catch (error) { - this.logger.error("Failed to get Opportunity by ID", { - error: extractErrorMessage(error), - opportunityId: safeOppId, - }); - return null; - } + return this.queryService.getOpportunityById(opportunityId); } /** - * Get cancellation status for display in portal - * - * @param whmcsServiceId - WHMCS Service ID - * @returns Cancellation status details or null - */ - /** - * Get Internet cancellation status for display in portal - * - * @param whmcsServiceId - WHMCS Service ID - * @returns Internet cancellation status details or null + * Get Internet cancellation status by WHMCS Service ID */ async getInternetCancellationStatus(whmcsServiceId: number): Promise<{ stage: OpportunityStageValue; @@ -666,46 +154,11 @@ export class SalesforceOpportunityService { scheduledEndDate?: string; rentalReturnStatus?: LineReturnStatusValue; } | null> { - const soql = ` - SELECT ${OPPORTUNITY_INTERNET_CANCELLATION_QUERY_FIELDS.join(", ")} - FROM Opportunity - WHERE ${OPPORTUNITY_FIELD_MAP.whmcsServiceId} = ${whmcsServiceId} - ORDER BY CreatedDate DESC - LIMIT 1 - `; - - try { - const result = (await this.sf.query(soql, { - label: "opportunity:getInternetCancellationStatus", - })) as SalesforceResponse; - - const record = result.records?.[0]; - if (!record) return null; - - const stage = record.StageName as OpportunityStageValue; - const isPending = stage === OPPORTUNITY_STAGE.CANCELLING; - const isComplete = stage === OPPORTUNITY_STAGE.CANCELLED; - - return { - stage, - isPending, - isComplete, - scheduledEndDate: record.ScheduledCancellationDateAndTime__c, - rentalReturnStatus: record.LineReturn__c as LineReturnStatusValue | undefined, - }; - } catch (error) { - this.logger.error("Failed to get Internet cancellation status", { - error: extractErrorMessage(error), - whmcsServiceId, - }); - return null; - } + return this.queryService.getInternetCancellationStatus(whmcsServiceId); } /** - * Get Internet cancellation status by Opportunity ID (direct lookup) - * - * Preferred when OpportunityId is already known (e.g., stored on WHMCS service custom fields). + * Get Internet cancellation status by Opportunity ID */ async getInternetCancellationStatusByOpportunityId(opportunityId: string): Promise<{ stage: OpportunityStageValue; @@ -714,47 +167,11 @@ export class SalesforceOpportunityService { scheduledEndDate?: string; rentalReturnStatus?: LineReturnStatusValue; } | null> { - const safeOppId = assertSalesforceId(opportunityId, "opportunityId"); - const soql = ` - SELECT ${OPPORTUNITY_INTERNET_CANCELLATION_QUERY_FIELDS.join(", ")} - FROM Opportunity - WHERE Id = '${safeOppId}' - LIMIT 1 - `; - - try { - const result = (await this.sf.query(soql, { - label: "opportunity:getInternetCancellationStatusByOpportunityId", - })) as SalesforceResponse; - - const record = result.records?.[0]; - if (!record) return null; - - const stage = record.StageName as OpportunityStageValue; - const isPending = stage === OPPORTUNITY_STAGE.CANCELLING; - const isComplete = stage === OPPORTUNITY_STAGE.CANCELLED; - - return { - stage, - isPending, - isComplete, - scheduledEndDate: record.ScheduledCancellationDateAndTime__c, - rentalReturnStatus: record.LineReturn__c as LineReturnStatusValue | undefined, - }; - } catch (error) { - this.logger.error("Failed to get Internet cancellation status by Opportunity ID", { - error: extractErrorMessage(error), - opportunityId: safeOppId, - }); - return null; - } + return this.queryService.getInternetCancellationStatusByOpportunityId(opportunityId); } /** - * Get SIM cancellation status for display in portal - * - * @param whmcsServiceId - WHMCS Service ID - * @returns SIM cancellation status details or null + * Get SIM cancellation status by WHMCS Service ID */ async getSimCancellationStatus(whmcsServiceId: number): Promise<{ stage: OpportunityStageValue; @@ -762,45 +179,11 @@ export class SalesforceOpportunityService { isComplete: boolean; scheduledEndDate?: string; } | null> { - const soql = ` - SELECT ${OPPORTUNITY_SIM_CANCELLATION_QUERY_FIELDS.join(", ")} - FROM Opportunity - WHERE ${OPPORTUNITY_FIELD_MAP.whmcsServiceId} = ${whmcsServiceId} - ORDER BY CreatedDate DESC - LIMIT 1 - `; - - try { - const result = (await this.sf.query(soql, { - label: "opportunity:getSimCancellationStatus", - })) as SalesforceResponse; - - const record = result.records?.[0]; - if (!record) return null; - - const stage = record.StageName as OpportunityStageValue; - const isPending = stage === OPPORTUNITY_STAGE.CANCELLING; - const isComplete = stage === OPPORTUNITY_STAGE.CANCELLED; - - return { - stage, - isPending, - isComplete, - scheduledEndDate: record.SIMScheduledCancellationDateAndTime__c, - }; - } catch (error) { - this.logger.error("Failed to get SIM cancellation status", { - error: extractErrorMessage(error), - whmcsServiceId, - }); - return null; - } + return this.queryService.getSimCancellationStatus(whmcsServiceId); } /** - * Get SIM cancellation status by Opportunity ID (direct lookup) - * - * Preferred when OpportunityId is already known (e.g., stored on WHMCS service custom fields). + * Get SIM cancellation status by Opportunity ID */ async getSimCancellationStatusByOpportunityId(opportunityId: string): Promise<{ stage: OpportunityStageValue; @@ -808,39 +191,7 @@ export class SalesforceOpportunityService { isComplete: boolean; scheduledEndDate?: string; } | null> { - const safeOppId = assertSalesforceId(opportunityId, "opportunityId"); - const soql = ` - SELECT ${OPPORTUNITY_SIM_CANCELLATION_QUERY_FIELDS.join(", ")} - FROM Opportunity - WHERE Id = '${safeOppId}' - LIMIT 1 - `; - - try { - const result = (await this.sf.query(soql, { - label: "opportunity:getSimCancellationStatusByOpportunityId", - })) as SalesforceResponse; - - const record = result.records?.[0]; - if (!record) return null; - - const stage = record.StageName as OpportunityStageValue; - const isPending = stage === OPPORTUNITY_STAGE.CANCELLING; - const isComplete = stage === OPPORTUNITY_STAGE.CANCELLED; - - return { - stage, - isPending, - isComplete, - scheduledEndDate: record.SIMScheduledCancellationDateAndTime__c, - }; - } catch (error) { - this.logger.error("Failed to get SIM cancellation status by Opportunity ID", { - error: extractErrorMessage(error), - opportunityId: safeOppId, - }); - return null; - } + return this.queryService.getSimCancellationStatusByOpportunityId(opportunityId); } /** @@ -853,203 +204,27 @@ export class SalesforceOpportunityService { scheduledEndDate?: string; rentalReturnStatus?: LineReturnStatusValue; } | null> { - return this.getInternetCancellationStatus(whmcsServiceId); + return this.queryService.getInternetCancellationStatus(whmcsServiceId); } // ========================================================================== - // Lifecycle Helpers + // Lifecycle Helpers (via MutationService) // ========================================================================== /** * Link a WHMCS Service ID to an Opportunity - * - * Called after provisioning to enable cancellation workflows - * - * @param opportunityId - Salesforce Opportunity ID - * @param whmcsServiceId - WHMCS Service/Hosting ID */ async linkWhmcsServiceToOpportunity( opportunityId: string, whmcsServiceId: number ): Promise { - const safeOppId = assertSalesforceId(opportunityId, "opportunityId"); - - this.logger.log("Linking WHMCS Service to Opportunity", { - opportunityId: safeOppId, - whmcsServiceId, - }); - - const payload: Record = { - Id: safeOppId, - [OPPORTUNITY_FIELD_MAP.whmcsServiceId]: whmcsServiceId, - }; - - try { - const updateMethod = this.sf.sobject("Opportunity").update; - if (!updateMethod) { - throw new Error("Salesforce Opportunity update method not available"); - } - - await updateMethod(payload as Record & { Id: string }); - - this.logger.log("WHMCS Service linked to Opportunity", { - opportunityId: safeOppId, - whmcsServiceId, - }); - } catch (error) { - this.logger.error("Failed to link WHMCS Service to Opportunity", { - error: extractErrorMessage(error), - opportunityId: safeOppId, - whmcsServiceId, - }); - // Don't throw - this is a non-critical update - } + return this.mutationService.linkWhmcsServiceToOpportunity(opportunityId, whmcsServiceId); } /** - * Link an Order to an Opportunity (update Order.OpportunityId) - * - * Note: This updates the Order record, not the Opportunity - * - * @param orderId - Salesforce Order ID - * @param opportunityId - Salesforce Opportunity ID + * Link an Order to an Opportunity */ async linkOrderToOpportunity(orderId: string, opportunityId: string): Promise { - const safeOrderId = assertSalesforceId(orderId, "orderId"); - const safeOppId = assertSalesforceId(opportunityId, "opportunityId"); - - this.logger.log("Linking Order to Opportunity", { - orderId: safeOrderId, - opportunityId: safeOppId, - }); - - try { - const updateMethod = this.sf.sobject("Order").update; - if (!updateMethod) { - throw new Error("Salesforce Order update method not available"); - } - - await updateMethod({ - Id: safeOrderId, - OpportunityId: safeOppId, - }); - - this.logger.log("Order linked to Opportunity", { - orderId: safeOrderId, - opportunityId: safeOppId, - }); - } catch (error) { - this.logger.error("Failed to link Order to Opportunity", { - error: extractErrorMessage(error), - orderId: safeOrderId, - opportunityId: safeOppId, - }); - // Don't throw - this is a non-critical update - } - } - - /** - * Mark cancellation as complete - * - * @param opportunityId - Opportunity ID - */ - async markCancellationComplete(opportunityId: string): Promise { - await this.updateStage(opportunityId, OPPORTUNITY_STAGE.CANCELLED, "Cancellation completed"); - } - - // ========================================================================== - // Private Helpers - // ========================================================================== - - /** - * Calculate close date based on product type and stage - */ - private calculateCloseDate( - productType: OpportunityProductTypeValue, - stage: OpportunityStageValue - ): string { - const today = new Date(); - let daysToAdd: number; - - // Different close date expectations based on stage/product - switch (stage) { - case OPPORTUNITY_STAGE.INTRODUCTION: - // Internet eligibility - may take 30 days - daysToAdd = 30; - break; - case OPPORTUNITY_STAGE.READY: - // Ready to order - expected soon - daysToAdd = 14; - break; - case OPPORTUNITY_STAGE.POST_PROCESSING: - // Order placed - expected within 7 days - daysToAdd = 7; - break; - default: - // Default: 30 days - daysToAdd = 30; - } - - const closeDate = new Date(today); - closeDate.setDate(closeDate.getDate() + daysToAdd); - - return closeDate.toISOString().slice(0, 10); - } - - /** - * Get CommodityType values that match a simplified product type - * Used for querying opportunities by product category - */ - private getCommodityTypesForProductType( - productType: OpportunityProductTypeValue - ): CommodityTypeValue[] { - switch (productType) { - case OPPORTUNITY_PRODUCT_TYPE.INTERNET: - return [COMMODITY_TYPE.PERSONAL_HOME_INTERNET, COMMODITY_TYPE.CORPORATE_HOME_INTERNET]; - case OPPORTUNITY_PRODUCT_TYPE.SIM: - return [COMMODITY_TYPE.SIM]; - case OPPORTUNITY_PRODUCT_TYPE.VPN: - return [COMMODITY_TYPE.VPN]; - default: - return []; - } - } - - private resolveOpportunityRecordTypeId( - productType: OpportunityProductTypeValue - ): string | undefined { - const recordTypeId = this.opportunityRecordTypeIds[productType]; - return recordTypeId ? assertSalesforceId(recordTypeId, "opportunityRecordTypeId") : undefined; - } - - /** - * Transform Salesforce record to domain OpportunityRecord - */ - private transformToOpportunityRecord(record: SalesforceOpportunityRecord): OpportunityRecord { - // Derive productType from CommodityType (existing Salesforce field) - const commodityType = record.CommodityType as CommodityTypeValue | undefined; - const productType = commodityType ? getCommodityTypeProductType(commodityType) : undefined; - - return { - id: record.Id, - name: record.Name, - accountId: record.AccountId, - stage: record.StageName as OpportunityStageValue, - closeDate: record.CloseDate, - commodityType, - productType: productType ?? undefined, - source: record.Opportunity_Source__c as OpportunitySourceValue | undefined, - applicationStage: record.Application_Stage__c as ApplicationStageValue | undefined, - isClosed: record.IsClosed, - // Note: Related Cases use Case.Opportunity__c, Orders use Order.OpportunityId - whmcsServiceId: record.WHMCS_Service_ID__c, - // Cancellation fields (updated by CS when processing cancellation Case) - scheduledCancellationDate: record.ScheduledCancellationDateAndTime__c, - cancellationNotice: record.CancellationNotice__c as CancellationNoticeValue | undefined, - lineReturnStatus: record.LineReturn__c as LineReturnStatusValue | undefined, - // NOTE: alternativeContactEmail and cancellationComments are on Cancellation Case - createdDate: record.CreatedDate, - lastModifiedDate: record.LastModifiedDate, - }; + return this.mutationService.linkOrderToOpportunity(orderId, opportunityId); } } diff --git a/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx b/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx index b5b4983f..2d70bdc0 100644 --- a/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx +++ b/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx @@ -2,14 +2,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { ShieldCheck, CreditCard } from "lucide-react"; +import { ShieldCheck } from "lucide-react"; import { PageLayout } from "@/components/templates/PageLayout"; import { SubCard } from "@/components/molecules/SubCard/SubCard"; import { Button } from "@/components/atoms/button"; import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; import { InlineToast } from "@/components/atoms/inline-toast"; -import { StatusPill } from "@/components/atoms/status-pill"; import { AddressConfirmation } from "@/features/services/components/base/AddressConfirmation"; import { useCheckoutStore } from "@/features/checkout/stores/checkout.store"; import { ordersService } from "@/features/orders/api/orders.api"; @@ -33,6 +32,11 @@ import { } from "@customer-portal/domain/orders"; import { buildPaymentMethodDisplay, formatAddressLabel } from "@/shared/utils"; import { CheckoutStatusBanners } from "./CheckoutStatusBanners"; +import { + PaymentMethodSection, + IdentityVerificationSection, + OrderSubmitSection, +} from "./checkout-sections"; export function AccountCheckoutContainer() { const router = useRouter(); @@ -54,6 +58,7 @@ export function AccountCheckoutContainer() { const isInternetOrder = orderType === ORDER_TYPE.INTERNET; + // Active subscriptions check const { data: activeSubs } = useActiveSubscriptions(); const hasActiveInternetSubscription = useMemo(() => { if (!Array.isArray(activeSubs)) return false; @@ -68,6 +73,7 @@ export function AccountCheckoutContainer() { const activeInternetWarning = isInternetOrder && hasActiveInternetSubscription ? ACTIVE_INTERNET_SUBSCRIPTION_WARNING : null; + // Payment methods const { data: paymentMethods, isLoading: paymentMethodsLoading, @@ -82,13 +88,13 @@ export function AccountCheckoutContainer() { const paymentMethodList = paymentMethods?.paymentMethods ?? []; const hasPaymentMethod = paymentMethodList.length > 0; - const defaultPaymentMethod = paymentMethodList.find(method => method.isDefault) ?? paymentMethodList[0] ?? null; const paymentMethodDisplay = defaultPaymentMethod ? buildPaymentMethodDisplay(defaultPaymentMethod) : null; + // Eligibility const eligibilityQuery = useInternetEligibility({ enabled: isInternetOrder }); const eligibilityValue = eligibilityQuery.data?.eligibility; const eligibilityStatus = eligibilityQuery.data?.status; @@ -112,6 +118,7 @@ export function AccountCheckoutContainer() { typeof eligibilityValue === "string" && eligibilityValue.trim().length > 0); + // Address const hasServiceAddress = Boolean( user?.address?.address1 && user?.address?.city && @@ -120,21 +127,19 @@ export function AccountCheckoutContainer() { ); const addressLabel = useMemo(() => formatAddressLabel(user?.address), [user?.address]); + // Residence card verification const residenceCardQuery = useResidenceCardVerification(); const submitResidenceCard = useSubmitResidenceCard(); - const [residenceFile, setResidenceFile] = useState(null); - const residenceFileInputRef = useRef(null); - const residenceStatus = residenceCardQuery.data?.status; const residenceSubmitted = residenceStatus === "pending" || residenceStatus === "verified"; + // Toast handler const showPaymentToast = useCallback( (text: string, tone: "info" | "success" | "warning" | "error") => { if (paymentToastTimeoutRef.current) { clearTimeout(paymentToastTimeoutRef.current); paymentToastTimeoutRef.current = null; } - paymentRefresh.setToast({ visible: true, text, tone }); paymentToastTimeoutRef.current = window.setTimeout(() => { paymentRefresh.setToast(current => ({ ...current, visible: false })); @@ -165,9 +170,6 @@ export function AccountCheckoutContainer() { const params = new URLSearchParams(searchParams?.toString() ?? ""); const type = (params.get("type") ?? "").toLowerCase(); params.delete("type"); - - // Configure flows use `planSku` as the canonical selection. - // Additional params (addons, simType, etc.) may also be present for restore. const planSku = params.get("planSku")?.trim(); if (!planSku) { params.delete("planSku"); @@ -230,6 +232,27 @@ export function AccountCheckoutContainer() { } }, [openingPaymentPortal, showPaymentToast]); + const handleSubmitResidenceCard = useCallback( + (file: File) => { + submitResidenceCard.mutate(file); + }, + [submitResidenceCard] + ); + + // Calculate if form can be submitted + const canSubmit = + addressConfirmed && + !paymentMethodsLoading && + hasPaymentMethod && + residenceSubmitted && + isEligible && + !eligibilityLoading && + !eligibilityPending && + !eligibilityNotRequested && + !eligibilityIneligible && + !eligibilityError; + + // Error state - no cart item if (!cartItem || !orderType) { const shopHref = pathname.startsWith("/account") ? "/account/services" : "/services"; return ( @@ -294,501 +317,38 @@ export function AccountCheckoutContainer() { /> - } - right={ -
- {hasPaymentMethod ? : undefined} - -
- } - > - {paymentMethodsLoading ? ( -
Checking payment methods...
- ) : paymentMethodsError ? ( - -
- - -
-
- ) : hasPaymentMethod ? ( -
- {paymentMethodDisplay ? ( -
-
-
-

- Default payment method -

-

- {paymentMethodDisplay.title} -

- {paymentMethodDisplay.subtitle ? ( -

- {paymentMethodDisplay.subtitle} -

- ) : null} -
-
-
- ) : null} -

- We securely charge your saved payment method after the order is approved. -

-
- ) : ( - -
- - -
-
- )} -
+ void handleManagePayment()} + onRefresh={() => void paymentRefresh.triggerRefresh()} + isOpeningPortal={openingPaymentPortal} + /> - } - right={ - residenceStatus === "verified" ? ( - - ) : residenceStatus === "pending" ? ( - - ) : residenceStatus === "rejected" ? ( - - ) : ( - - ) - } - > - {residenceCardQuery.isLoading ? ( -
Checking residence card status…
- ) : residenceCardQuery.isError ? ( - - - - ) : residenceStatus === "verified" ? ( -
- - Your identity verification is complete. - - - {residenceCardQuery.data?.submittedAt || residenceCardQuery.data?.reviewedAt ? ( -
-
- Verification status -
-
- {formatDateTime(residenceCardQuery.data?.submittedAt) ? ( -
- Submitted: {formatDateTime(residenceCardQuery.data?.submittedAt)} -
- ) : null} - {formatDateTime(residenceCardQuery.data?.reviewedAt) ? ( -
Reviewed: {formatDateTime(residenceCardQuery.data?.reviewedAt)}
- ) : null} -
-
- ) : null} - -
- - Replace residence card - -
-

- Replacing the file restarts the verification process. -

- setResidenceFile(e.target.files?.[0] ?? null)} - className="block w-full text-sm text-foreground file:mr-4 file:py-2 file:px-3 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-muted file:text-foreground hover:file:bg-muted/80" - /> - - {residenceFile ? ( -
-
-
- Selected file -
-
- {residenceFile.name} -
-
- -
- ) : null} - -
- -
- - {submitResidenceCard.isError && ( -
- {submitResidenceCard.error instanceof Error - ? submitResidenceCard.error.message - : "Failed to submit residence card."} -
- )} -
-
-
- ) : residenceStatus === "pending" ? ( -
- - We’ll verify your residence card before activating SIM service. - - - {residenceCardQuery.data?.submittedAt ? ( -
-
- Submission status -
-
- Submitted: {formatDateTime(residenceCardQuery.data?.submittedAt)} -
-
- ) : null} - -
- - Replace residence card - -
-

- If you uploaded the wrong file, you can replace it. This restarts the - review. -

- setResidenceFile(e.target.files?.[0] ?? null)} - className="block w-full text-sm text-foreground file:mr-4 file:py-2 file:px-3 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-muted file:text-foreground hover:file:bg-muted/80" - /> - - {residenceFile ? ( -
-
-
- Selected file -
-
- {residenceFile.name} -
-
- -
- ) : null} - -
- -
- - {submitResidenceCard.isError && ( -
- {submitResidenceCard.error instanceof Error - ? submitResidenceCard.error.message - : "Failed to submit residence card."} -
- )} -
-
-
- ) : ( - -
- {residenceStatus === "rejected" && residenceCardQuery.data?.reviewerNotes ? ( -
-
Rejection note
-
{residenceCardQuery.data.reviewerNotes}
-
- ) : residenceStatus === "rejected" ? ( -

- Your document couldn’t be approved. Please upload a new file to continue. -

- ) : null} -

- Upload a JPG, PNG, or PDF (max 5MB). We’ll verify it before activating SIM - service. -

- -
- setResidenceFile(e.target.files?.[0] ?? null)} - className="block w-full text-sm text-foreground file:mr-4 file:py-2 file:px-3 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-muted file:text-foreground hover:file:bg-muted/80" - /> - - {residenceFile ? ( -
-
-
- Selected file -
-
- {residenceFile.name} -
-
- -
- ) : null} -
- -
- -
- - {submitResidenceCard.isError && ( -
- {submitResidenceCard.error instanceof Error - ? submitResidenceCard.error.message - : "Failed to submit residence card."} -
- )} -
-
- )} -
+ void residenceCardQuery.refetch()} + onSubmitFile={handleSubmitResidenceCard} + isSubmitting={submitResidenceCard.isPending} + submitError={submitResidenceCard.error ?? undefined} + formatDateTime={formatDateTime} + /> -
-
- -
-

Review & Submit

-

- You’re almost done. Confirm your details above, then submit your order. We’ll review and - notify you when everything is ready. -

- - {submitError ? ( -
- - {submitError} - -
- ) : null} - -
-

What to expect

-
-

• Our team reviews your order and schedules setup if needed

-

• We may contact you to confirm details or availability

-

• We verify your residence card before service activation

-

• We only charge your card after the order is approved

-

• You’ll receive confirmation and next steps by email

-
-
- -
-
- Estimated Total -
-
- ¥{cartItem.pricing.monthlyTotal.toLocaleString()}/mo -
- {cartItem.pricing.oneTimeTotal > 0 && ( -
- + ¥{cartItem.pricing.oneTimeTotal.toLocaleString()} one-time -
- )} -
-
-
-
- -
- - -
+ void handleSubmitOrder()} + onBack={navigateBackToConfigure} + /> ); diff --git a/apps/portal/src/features/checkout/components/checkout-sections/IdentityVerificationSection.tsx b/apps/portal/src/features/checkout/components/checkout-sections/IdentityVerificationSection.tsx new file mode 100644 index 00000000..1f8c0a3b --- /dev/null +++ b/apps/portal/src/features/checkout/components/checkout-sections/IdentityVerificationSection.tsx @@ -0,0 +1,259 @@ +"use client"; + +import { ShieldCheck } from "lucide-react"; +import { SubCard } from "@/components/molecules/SubCard/SubCard"; +import { Button } from "@/components/atoms/button"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; +import { StatusPill } from "@/components/atoms/status-pill"; +import { ResidenceCardUploadInput } from "./ResidenceCardUploadInput"; + +type VerificationStatus = "verified" | "pending" | "rejected" | "not_submitted" | undefined; + +interface VerificationData { + submittedAt?: string | null; + reviewedAt?: string | null; + reviewerNotes?: string | null; +} + +interface IdentityVerificationSectionProps { + isLoading: boolean; + isError: boolean; + status: VerificationStatus; + data?: VerificationData; + onRefetch: () => void; + onSubmitFile: (file: File) => void; + isSubmitting: boolean; + submitError?: Error | null; + formatDateTime: (iso?: string | null) => string | null; +} + +export function IdentityVerificationSection({ + isLoading, + isError, + status, + data, + onRefetch, + onSubmitFile, + isSubmitting, + submitError, + formatDateTime, +}: IdentityVerificationSectionProps) { + const getStatusPill = () => { + switch (status) { + case "verified": + return ; + case "pending": + return ; + case "rejected": + return ; + default: + return ; + } + }; + + return ( + } + right={getStatusPill()} + > + {isLoading ? ( +
Checking residence card status...
+ ) : isError ? ( + + + + ) : status === "verified" ? ( + + ) : status === "pending" ? ( + + ) : ( + + )} +
+ ); +} + +interface VerifiedContentProps { + data?: VerificationData; + formatDateTime: (iso?: string | null) => string | null; + onSubmitFile: (file: File) => void; + isSubmitting: boolean; + submitError?: Error | null; +} + +function VerifiedContent({ + data, + formatDateTime, + onSubmitFile, + isSubmitting, + submitError, +}: VerifiedContentProps) { + return ( +
+ + Your identity verification is complete. + + + {(data?.submittedAt || data?.reviewedAt) && ( +
+
+ Verification status +
+
+ {formatDateTime(data?.submittedAt) && ( +
Submitted: {formatDateTime(data?.submittedAt)}
+ )} + {formatDateTime(data?.reviewedAt) && ( +
Reviewed: {formatDateTime(data?.reviewedAt)}
+ )} +
+
+ )} + +
+ + Replace residence card + +
+ +
+
+
+ ); +} + +interface PendingContentProps { + data?: VerificationData; + formatDateTime: (iso?: string | null) => string | null; + onSubmitFile: (file: File) => void; + isSubmitting: boolean; + submitError?: Error | null; +} + +function PendingContent({ + data, + formatDateTime, + onSubmitFile, + isSubmitting, + submitError, +}: PendingContentProps) { + return ( +
+ + We'll verify your residence card before activating SIM service. + + + {data?.submittedAt && ( +
+
+ Submission status +
+
+ Submitted: {formatDateTime(data?.submittedAt)} +
+
+ )} + +
+ + Replace residence card + +
+ +
+
+
+ ); +} + +interface NotSubmittedContentProps { + status: VerificationStatus; + reviewerNotes?: string | null; + onSubmitFile: (file: File) => void; + isSubmitting: boolean; + submitError?: Error | null; +} + +function NotSubmittedContent({ + status, + reviewerNotes, + onSubmitFile, + isSubmitting, + submitError, +}: NotSubmittedContentProps) { + const isRejected = status === "rejected"; + + return ( + +
+ {isRejected && reviewerNotes ? ( +
+
Rejection note
+
{reviewerNotes}
+
+ ) : isRejected ? ( +

+ Your document couldn't be approved. Please upload a new file to continue. +

+ ) : null} + +

+ Upload a JPG, PNG, or PDF (max 5MB). We'll verify it before activating SIM service. +

+ + +
+
+ ); +} diff --git a/apps/portal/src/features/checkout/components/checkout-sections/OrderSubmitSection.tsx b/apps/portal/src/features/checkout/components/checkout-sections/OrderSubmitSection.tsx new file mode 100644 index 00000000..ebbf73a8 --- /dev/null +++ b/apps/portal/src/features/checkout/components/checkout-sections/OrderSubmitSection.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { ShieldCheck } from "lucide-react"; +import { Button } from "@/components/atoms/button"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; + +interface CartPricing { + monthlyTotal: number; + oneTimeTotal: number; +} + +interface OrderSubmitSectionProps { + pricing: CartPricing; + submitError: string | null; + isSubmitting: boolean; + canSubmit: boolean; + onSubmit: () => void; + onBack: () => void; +} + +export function OrderSubmitSection({ + pricing, + submitError, + isSubmitting, + canSubmit, + onSubmit, + onBack, +}: OrderSubmitSectionProps) { + return ( + <> +
+
+ +
+

Review & Submit

+

+ You're almost done. Confirm your details above, then submit your order. We'll review and + notify you when everything is ready. +

+ + {submitError && ( +
+ + {submitError} + +
+ )} + +
+

What to expect

+
+

• Our team reviews your order and schedules setup if needed

+

• We may contact you to confirm details or availability

+

• We verify your residence card before service activation

+

• We only charge your card after the order is approved

+

• You'll receive confirmation and next steps by email

+
+
+ +
+
+ Estimated Total +
+
+ ¥{pricing.monthlyTotal.toLocaleString()}/mo +
+ {pricing.oneTimeTotal > 0 && ( +
+ + ¥{pricing.oneTimeTotal.toLocaleString()} one-time +
+ )} +
+
+
+
+ +
+ + +
+ + ); +} diff --git a/apps/portal/src/features/checkout/components/checkout-sections/PaymentMethodSection.tsx b/apps/portal/src/features/checkout/components/checkout-sections/PaymentMethodSection.tsx new file mode 100644 index 00000000..bacc4941 --- /dev/null +++ b/apps/portal/src/features/checkout/components/checkout-sections/PaymentMethodSection.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { CreditCard } from "lucide-react"; +import { SubCard } from "@/components/molecules/SubCard/SubCard"; +import { Button } from "@/components/atoms/button"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; +import { StatusPill } from "@/components/atoms/status-pill"; + +interface PaymentMethodDisplay { + title: string; + subtitle?: string; +} + +interface PaymentMethodSectionProps { + isLoading: boolean; + isError: boolean; + hasPaymentMethod: boolean; + paymentMethodDisplay: PaymentMethodDisplay | null; + onManagePayment: () => void; + onRefresh: () => void; + isOpeningPortal: boolean; +} + +export function PaymentMethodSection({ + isLoading, + isError, + hasPaymentMethod, + paymentMethodDisplay, + onManagePayment, + onRefresh, + isOpeningPortal, +}: PaymentMethodSectionProps) { + return ( + } + right={ +
+ {hasPaymentMethod && } + +
+ } + > + {isLoading ? ( +
Checking payment methods...
+ ) : isError ? ( + +
+ + +
+
+ ) : hasPaymentMethod ? ( +
+ {paymentMethodDisplay && ( +
+
+
+

+ Default payment method +

+

+ {paymentMethodDisplay.title} +

+ {paymentMethodDisplay.subtitle && ( +

+ {paymentMethodDisplay.subtitle} +

+ )} +
+
+
+ )} +

+ We securely charge your saved payment method after the order is approved. +

+
+ ) : ( + +
+ + +
+
+ )} +
+ ); +} diff --git a/apps/portal/src/features/checkout/components/checkout-sections/ResidenceCardUploadInput.tsx b/apps/portal/src/features/checkout/components/checkout-sections/ResidenceCardUploadInput.tsx new file mode 100644 index 00000000..0cdb0c58 --- /dev/null +++ b/apps/portal/src/features/checkout/components/checkout-sections/ResidenceCardUploadInput.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useRef, useState } from "react"; +import { Button } from "@/components/atoms/button"; + +interface ResidenceCardUploadInputProps { + onSubmit: (file: File) => void; + isPending: boolean; + isError: boolean; + error?: Error | null; + submitLabel?: string; + description?: string; +} + +export function ResidenceCardUploadInput({ + onSubmit, + isPending, + isError, + error, + submitLabel = "Submit for review", + description, +}: ResidenceCardUploadInputProps) { + const [file, setFile] = useState(null); + const inputRef = useRef(null); + + const handleClear = () => { + setFile(null); + if (inputRef.current) { + inputRef.current.value = ""; + } + }; + + const handleSubmit = () => { + if (!file) return; + onSubmit(file); + handleClear(); + }; + + return ( +
+ {description &&

{description}

} + + setFile(e.target.files?.[0] ?? null)} + className="block w-full text-sm text-foreground file:mr-4 file:py-2 file:px-3 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-muted file:text-foreground hover:file:bg-muted/80" + /> + + {file && ( +
+
+
Selected file
+
{file.name}
+
+ +
+ )} + +
+ +
+ + {isError && error && ( +
+ {error instanceof Error ? error.message : "Failed to submit residence card."} +
+ )} +
+ ); +} diff --git a/apps/portal/src/features/checkout/components/checkout-sections/index.ts b/apps/portal/src/features/checkout/components/checkout-sections/index.ts new file mode 100644 index 00000000..7b71069e --- /dev/null +++ b/apps/portal/src/features/checkout/components/checkout-sections/index.ts @@ -0,0 +1,4 @@ +export { ResidenceCardUploadInput } from "./ResidenceCardUploadInput"; +export { PaymentMethodSection } from "./PaymentMethodSection"; +export { IdentityVerificationSection } from "./IdentityVerificationSection"; +export { OrderSubmitSection } from "./OrderSubmitSection";