From d9734b0c8286af111f06f8c435d533cb12787ba7 Mon Sep 17 00:00:00 2001 From: barsa Date: Mon, 22 Dec 2025 18:59:38 +0900 Subject: [PATCH] Enhance Signup Workflow and Update Catalog Components - Refactored the SignupWorkflowService to throw a DomainHttpException for legacy account conflicts, improving error handling. - Updated the SignupForm component to include initialEmail and showFooterLinks props, enhancing user experience during account creation. - Improved the AccountStep in the SignupForm to allow users to add optional details, such as date of birth and gender, for a more personalized signup process. - Enhanced the PasswordStep to include terms acceptance and marketing consent options, ensuring compliance and user engagement. - Updated various catalog views to improve layout and user guidance, streamlining the onboarding process for new users. --- .../config/opportunity-field-map.ts | 368 +++++++++ .../salesforce/salesforce.module.ts | 3 + .../services/salesforce-case.service.ts | 144 ++++ .../salesforce-opportunity.service.ts | 736 +++++++++++++++++ .../workflows/signup-workflow.service.ts | 11 +- .../services/internet-catalog.service.ts | 128 +-- .../orders/config/order-field-map.service.ts | 1 + .../services/opportunity-matching.service.ts | 495 ++++++++++++ .../order-fulfillment-orchestrator.service.ts | 55 ++ .../services/order-orchestrator.service.ts | 130 ++- apps/portal/next.config.mjs | 1 - apps/portal/scripts/bundle-monitor.mjs | 2 - apps/portal/scripts/dev-prep.mjs | 2 - .../scripts/test-request-password-reset.cjs | 2 - .../InlineAuthSection/InlineAuthSection.tsx | 122 +++ .../auth/components/LoginForm/LoginForm.tsx | 11 +- .../auth/components/SignupForm/SignupForm.tsx | 83 +- .../SignupForm/steps/AccountStep.tsx | 110 +-- .../SignupForm/steps/PasswordStep.tsx | 58 +- .../src/features/auth/components/index.ts | 1 + .../src/features/auth/hooks/use-auth.ts | 10 +- .../features/auth/utils/route-protection.ts | 18 +- .../components/base/AddressConfirmation.tsx | 67 +- .../components/internet/InternetPlanCard.tsx | 59 +- .../features/catalog/views/CatalogHome.tsx | 30 + .../features/catalog/views/InternetPlans.tsx | 209 +++-- .../catalog/views/PublicCatalogHome.tsx | 30 + .../catalog/views/PublicInternetConfigure.tsx | 203 ++--- .../catalog/views/PublicInternetPlans.tsx | 26 +- .../catalog/views/PublicSimConfigure.tsx | 224 +++--- .../features/catalog/views/PublicSimPlans.tsx | 31 +- .../components/AccountCheckoutContainer.tsx | 76 +- .../salesforce/OPPORTUNITY-LIFECYCLE-GUIDE.md | 491 ++++++++++++ packages/domain/common/errors.ts | 16 +- packages/domain/index.ts | 1 + packages/domain/opportunity/contract.ts | 739 ++++++++++++++++++ packages/domain/opportunity/helpers.ts | 382 +++++++++ packages/domain/opportunity/index.ts | 140 ++++ packages/domain/opportunity/schema.ts | 306 ++++++++ .../orders/providers/salesforce/mapper.ts | 21 +- .../orders/providers/salesforce/raw.types.ts | 61 +- .../domain/orders/providers/whmcs/mapper.ts | 13 +- .../orders/providers/whmcs/raw.types.ts | 9 +- packages/domain/orders/schema.ts | 161 ++-- packages/domain/package.json | 8 + packages/domain/tsconfig.json | 1 + 46 files changed, 5054 insertions(+), 741 deletions(-) create mode 100644 apps/bff/src/integrations/salesforce/config/opportunity-field-map.ts create mode 100644 apps/bff/src/integrations/salesforce/services/salesforce-opportunity.service.ts create mode 100644 apps/bff/src/modules/orders/services/opportunity-matching.service.ts create mode 100644 apps/portal/src/features/auth/components/InlineAuthSection/InlineAuthSection.tsx create mode 100644 docs/salesforce/OPPORTUNITY-LIFECYCLE-GUIDE.md create mode 100644 packages/domain/opportunity/contract.ts create mode 100644 packages/domain/opportunity/helpers.ts create mode 100644 packages/domain/opportunity/index.ts create mode 100644 packages/domain/opportunity/schema.ts diff --git a/apps/bff/src/integrations/salesforce/config/opportunity-field-map.ts b/apps/bff/src/integrations/salesforce/config/opportunity-field-map.ts new file mode 100644 index 00000000..0cb9a287 --- /dev/null +++ b/apps/bff/src/integrations/salesforce/config/opportunity-field-map.ts @@ -0,0 +1,368 @@ +/** + * Opportunity Field Map Configuration + * + * Maps logical field names to Salesforce API field names. + * Uses existing Salesforce fields where available. + * + * @see docs/salesforce/OPPORTUNITY-LIFECYCLE-GUIDE.md for setup instructions + */ + +// ============================================================================ +// Standard Salesforce Opportunity Fields +// ============================================================================ + +export const OPPORTUNITY_STANDARD_FIELDS = { + /** Salesforce Opportunity ID */ + id: "Id", + + /** Opportunity name (auto-generated: "{Account Name} - {Product Type} Inquiry") */ + name: "Name", + + /** Related Account ID */ + accountId: "AccountId", + + /** Account name (via relationship query) */ + accountName: "Account.Name", + + /** Current stage in the lifecycle */ + stage: "StageName", + + /** Expected close date */ + closeDate: "CloseDate", + + /** Whether the Opportunity is closed (read-only, derived from stage) */ + isClosed: "IsClosed", + + /** Whether the Opportunity is won (read-only, derived from stage) */ + isWon: "IsWon", + + /** Opportunity description */ + description: "Description", + + /** Created date */ + createdDate: "CreatedDate", + + /** Last modified date */ + lastModifiedDate: "LastModifiedDate", +} as const; + +// ============================================================================ +// Existing Custom Opportunity Fields +// ============================================================================ + +/** + * These fields already exist in Salesforce + */ +export const OPPORTUNITY_EXISTING_CUSTOM_FIELDS = { + // ---- Application Stage ---- + + /** Application process stage (INTRO-1, N/A, etc.) */ + applicationStage: "Application_Stage__c", + + // ---- Cancellation Fields (existing) ---- + + /** Scheduled cancellation date/time (end of month) */ + scheduledCancellationDate: "ScheduledCancellationDateAndTime__c", + + /** Cancellation notice status: 有 (received), 未 (not yet), 不要 (not required), 移転 (transfer) */ + cancellationNotice: "CancellationNotice__c", + + /** Line return status for rental equipment */ + lineReturnStatus: "LineReturn__c", +} as const; + +// ============================================================================ +// Existing Custom Fields for Product Type +// ============================================================================ + +/** + * CommodityType field already exists in Salesforce + * Used to track product/service type + */ +export const OPPORTUNITY_COMMODITY_FIELD = { + /** Product/commodity type (existing field) */ + commodityType: "CommodityType", +} as const; + +// ============================================================================ +// New Custom Fields (to be created in Salesforce) +// ============================================================================ + +/** + * New custom fields to be created in Salesforce. + * + * NOTE: + * - CommodityType already exists - no need to create Product_Type__c + * - Cases link TO Opportunity via Case.OpportunityId (no custom field on Opp) + * - Orders link TO Opportunity via Order.OpportunityId (standard field) + * - Alternative email and cancellation comments go on Case, not Opportunity + * + * TODO: Confirm with Salesforce admin and update API names after creation + */ +export const OPPORTUNITY_NEW_CUSTOM_FIELDS = { + // ---- Source Field (to be created) ---- + + /** Source of the Opportunity creation */ + source: "Portal_Source__c", + + // ---- Integration Fields (to be created) ---- + + /** WHMCS Service ID (populated after provisioning) */ + whmcsServiceId: "WHMCS_Service_ID__c", + + // NOTE: Cancellation comments and alternative email go on the Cancellation Case, + // not on the Opportunity. This keeps Opportunity clean and Case contains all details. +} as const; + +// ============================================================================ +// Combined Field Map +// ============================================================================ + +/** + * Complete Opportunity field map for portal operations + */ +export const OPPORTUNITY_FIELD_MAP = { + ...OPPORTUNITY_STANDARD_FIELDS, + ...OPPORTUNITY_EXISTING_CUSTOM_FIELDS, + ...OPPORTUNITY_COMMODITY_FIELD, + ...OPPORTUNITY_NEW_CUSTOM_FIELDS, +} as const; + +export type OpportunityFieldMap = typeof OPPORTUNITY_FIELD_MAP; + +// ============================================================================ +// Query Field Sets +// ============================================================================ + +/** + * Fields to select when querying Opportunities for matching + */ +export const OPPORTUNITY_MATCH_QUERY_FIELDS = [ + OPPORTUNITY_FIELD_MAP.id, + OPPORTUNITY_FIELD_MAP.name, + OPPORTUNITY_FIELD_MAP.accountId, + OPPORTUNITY_FIELD_MAP.stage, + OPPORTUNITY_FIELD_MAP.closeDate, + OPPORTUNITY_FIELD_MAP.isClosed, + OPPORTUNITY_FIELD_MAP.applicationStage, + OPPORTUNITY_FIELD_MAP.commodityType, + OPPORTUNITY_FIELD_MAP.source, + OPPORTUNITY_FIELD_MAP.createdDate, +] as const; + +/** + * Fields to select when querying full Opportunity details + */ +export const OPPORTUNITY_DETAIL_QUERY_FIELDS = [ + ...OPPORTUNITY_MATCH_QUERY_FIELDS, + OPPORTUNITY_FIELD_MAP.whmcsServiceId, + OPPORTUNITY_FIELD_MAP.scheduledCancellationDate, + OPPORTUNITY_FIELD_MAP.cancellationNotice, + OPPORTUNITY_FIELD_MAP.lineReturnStatus, + OPPORTUNITY_FIELD_MAP.lastModifiedDate, + // NOTE: Cancellation comments and alternative email are on the Cancellation Case +] as const; + +/** + * Fields to select for cancellation status display + */ +export const OPPORTUNITY_CANCELLATION_QUERY_FIELDS = [ + OPPORTUNITY_FIELD_MAP.id, + OPPORTUNITY_FIELD_MAP.stage, + OPPORTUNITY_FIELD_MAP.commodityType, + OPPORTUNITY_FIELD_MAP.scheduledCancellationDate, + OPPORTUNITY_FIELD_MAP.cancellationNotice, + OPPORTUNITY_FIELD_MAP.lineReturnStatus, + OPPORTUNITY_FIELD_MAP.whmcsServiceId, +] as const; + +// ============================================================================ +// Stage Picklist Reference (Existing Values) +// ============================================================================ + +/** + * Opportunity stage picklist values (already exist in Salesforce) + * + * These stages track the complete service lifecycle. + * The portal uses these exact values. + */ +export const OPPORTUNITY_STAGE_REFERENCE = { + INTRODUCTION: { + value: "Introduction", + probability: 30, + forecastCategory: "Pipeline", + isClosed: false, + description: "Initial customer interest / eligibility pending", + }, + WIKI: { + value: "WIKI", + probability: 10, + forecastCategory: "Omitted", + isClosed: false, + description: "Low priority / informational only", + }, + READY: { + value: "Ready", + probability: 60, + forecastCategory: "Pipeline", + isClosed: false, + description: "Eligible and ready to order", + }, + POST_PROCESSING: { + value: "Post Processing", + probability: 75, + forecastCategory: "Pipeline", + isClosed: false, + description: "Order placed, processing", + }, + ACTIVE: { + value: "Active", + probability: 90, + forecastCategory: "Pipeline", + isClosed: false, + description: "Service is active", + }, + CANCELLING: { + value: "△Cancelling", + probability: 100, + forecastCategory: "Pipeline", + isClosed: false, + description: "Cancellation requested, pending processing", + }, + CANCELLED: { + value: "〇Cancelled", + probability: 100, + forecastCategory: "Closed", + isClosed: true, + isWon: true, + description: "Successfully cancelled", + }, + COMPLETED: { + value: "Completed", + probability: 100, + forecastCategory: "Closed", + isClosed: true, + isWon: true, + description: "Service completed normally", + }, + VOID: { + value: "Void", + probability: 0, + forecastCategory: "Omitted", + isClosed: true, + isWon: false, + description: "Lost / not eligible", + }, + PENDING: { + value: "Pending", + probability: 0, + forecastCategory: "Omitted", + isClosed: true, + isWon: false, + description: "On hold / abandoned", + }, +} as const; + +// ============================================================================ +// Application Stage Reference (Existing Values) +// ============================================================================ + +/** + * Application stage picklist values (already exist in Salesforce) + * Portal uses INTRO-1 for new opportunities + */ +export const APPLICATION_STAGE_REFERENCE = { + INTRO_1: { value: "INTRO-1", description: "Portal introduction (default)" }, + NA: { value: "N/A", description: "Not applicable" }, +} as const; + +// ============================================================================ +// Cancellation Notice Picklist Reference (Existing Values) +// ============================================================================ + +/** + * Cancellation notice picklist values (already exist in Salesforce) + */ +export const CANCELLATION_NOTICE_REFERENCE = { + RECEIVED: { value: "有", label: "Received", description: "Cancellation form received" }, + NOT_YET: { value: "未", label: "Not Yet", description: "Not yet received (default)" }, + NOT_REQUIRED: { value: "不要", label: "Not Required", description: "Not required" }, + TRANSFER: { value: "移転", label: "Transfer", description: "Customer moving/transferring" }, +} as const; + +// ============================================================================ +// Line Return Status Picklist Reference (Existing Values) +// ============================================================================ + +/** + * Line return status picklist values (already exist in Salesforce) + */ +export const LINE_RETURN_STATUS_REFERENCE = { + NOT_YET: { value: "NotYet", label: "Not Yet", description: "Return kit not sent" }, + SENT_KIT: { value: "SentKit", label: "Kit Sent", description: "Return kit sent to customer" }, + PICKUP_SCHEDULED: { + value: "AS/Pickup予定", + label: "Pickup Scheduled", + description: "Pickup scheduled", + }, + RETURNED_1: { value: "Returned1", label: "Returned", description: "Equipment returned" }, + RETURNED: { value: "Returned2", label: "Returned", description: "Equipment returned" }, + NTT_DISPATCH: { value: "NTT派遣", label: "NTT Dispatch", description: "NTT handling return" }, + COMPENSATED: { + value: "Compensated", + label: "Compensated", + description: "Compensation fee charged", + }, + NA: { value: "N/A", label: "N/A", description: "No rental equipment" }, +} as const; + +// ============================================================================ +// Commodity Type Reference (Existing Values) +// ============================================================================ + +/** + * CommodityType picklist values (already exist in Salesforce) + * Maps to simplified product types for portal logic + */ +export const COMMODITY_TYPE_REFERENCE = { + PERSONAL_HOME_INTERNET: { + value: "Personal SonixNet Home Internet", + portalProductType: "Internet", + description: "Personal home internet service", + }, + CORPORATE_HOME_INTERNET: { + value: "Corporate SonixNet Home Internet", + portalProductType: "Internet", + description: "Corporate home internet service", + }, + SIM: { + value: "SIM", + portalProductType: "SIM", + description: "SIM / mobile service", + }, + VPN: { + value: "VPN", + portalProductType: "VPN", + description: "VPN service", + }, + TECH_SUPPORT: { + value: "Onsite Support", + portalProductType: null, + description: "Tech support (not used by portal)", + }, +} as const; + +// ============================================================================ +// New Picklist Values (to be created in Salesforce) +// ============================================================================ + +/** + * Source picklist values for Portal_Source__c field + * (needs to be created in Salesforce) + */ +export const PORTAL_SOURCE_PICKLIST = [ + { value: "Portal - Internet Eligibility Request", label: "Portal - Internet Eligibility" }, + { value: "Portal - SIM Checkout Registration", label: "Portal - SIM Checkout" }, + { value: "Portal - Order Placement", label: "Portal - Order Placement" }, + { value: "Agent Created", label: "Agent Created" }, +] as const; diff --git a/apps/bff/src/integrations/salesforce/salesforce.module.ts b/apps/bff/src/integrations/salesforce/salesforce.module.ts index 36f99625..3bc061be 100644 --- a/apps/bff/src/integrations/salesforce/salesforce.module.ts +++ b/apps/bff/src/integrations/salesforce/salesforce.module.ts @@ -6,6 +6,7 @@ import { SalesforceConnection } from "./services/salesforce-connection.service.j import { SalesforceAccountService } from "./services/salesforce-account.service.js"; import { SalesforceOrderService } from "./services/salesforce-order.service.js"; import { SalesforceCaseService } from "./services/salesforce-case.service.js"; +import { SalesforceOpportunityService } from "./services/salesforce-opportunity.service.js"; import { OrderFieldConfigModule } from "@bff/modules/orders/config/order-field-config.module.js"; import { SalesforceReadThrottleGuard } from "./guards/salesforce-read-throttle.guard.js"; import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-throttle.guard.js"; @@ -17,6 +18,7 @@ import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-throttle SalesforceAccountService, SalesforceOrderService, SalesforceCaseService, + SalesforceOpportunityService, SalesforceService, SalesforceReadThrottleGuard, SalesforceWriteThrottleGuard, @@ -28,6 +30,7 @@ import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-throttle SalesforceAccountService, SalesforceOrderService, SalesforceCaseService, + SalesforceOpportunityService, SalesforceReadThrottleGuard, SalesforceWriteThrottleGuard, ], diff --git a/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts b/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts index ea9fab7b..e9c1b4aa 100644 --- a/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts +++ b/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts @@ -258,4 +258,148 @@ export class SalesforceCaseService { return result.records?.[0] ?? null; } + + // ========================================================================== + // Opportunity-Linked Cases + // ========================================================================== + + /** + * Create an eligibility check case linked to an Opportunity + * + * @param params - Case parameters including Opportunity link + * @returns Created case ID + */ + async createEligibilityCase(params: { + accountId: string; + opportunityId: string; + subject: string; + description: string; + }): Promise { + const safeAccountId = assertSalesforceId(params.accountId, "accountId"); + const safeOpportunityId = assertSalesforceId(params.opportunityId, "opportunityId"); + + this.logger.log("Creating eligibility check case linked to Opportunity", { + accountIdTail: safeAccountId.slice(-4), + opportunityIdTail: safeOpportunityId.slice(-4), + }); + + const casePayload: Record = { + Origin: "Portal", + Status: SALESFORCE_CASE_STATUS.NEW, + Priority: SALESFORCE_CASE_PRIORITY.MEDIUM, + Subject: params.subject, + Description: params.description, + AccountId: safeAccountId, + // Link Case to Opportunity - this is a standard lookup field + OpportunityId: safeOpportunityId, + }; + + try { + const created = (await this.sf.sobject("Case").create(casePayload)) as { id?: string }; + + if (!created.id) { + throw new Error("Salesforce did not return a case ID"); + } + + this.logger.log("Eligibility case created and linked to Opportunity", { + caseId: created.id, + opportunityIdTail: safeOpportunityId.slice(-4), + }); + + return created.id; + } catch (error: unknown) { + this.logger.error("Failed to create eligibility case", { + error: getErrorMessage(error), + accountIdTail: safeAccountId.slice(-4), + }); + throw new Error("Failed to create eligibility check case"); + } + } + + /** + * Create a cancellation request case linked to an Opportunity + * + * All customer-provided details (comments, alternative email) go here. + * The Opportunity only gets the core lifecycle fields (dates, status). + * + * @param params - Cancellation case parameters + * @returns Created case ID + */ + async createCancellationCase(params: { + accountId: string; + opportunityId?: string; + whmcsServiceId: number; + productType: string; + cancellationMonth: string; + cancellationDate: string; + alternativeEmail?: string; + comments?: string; + }): Promise { + const safeAccountId = assertSalesforceId(params.accountId, "accountId"); + const safeOpportunityId = params.opportunityId + ? assertSalesforceId(params.opportunityId, "opportunityId") + : null; + + this.logger.log("Creating cancellation request case", { + accountIdTail: safeAccountId.slice(-4), + opportunityId: safeOpportunityId ? safeOpportunityId.slice(-4) : "none", + whmcsServiceId: params.whmcsServiceId, + }); + + // Build description with all form data + const descriptionLines = [ + `Cancellation Request from Portal`, + ``, + `Product Type: ${params.productType}`, + `WHMCS Service ID: ${params.whmcsServiceId}`, + `Cancellation Month: ${params.cancellationMonth}`, + `Service End Date: ${params.cancellationDate}`, + ``, + ]; + + if (params.alternativeEmail) { + descriptionLines.push(`Alternative Contact Email: ${params.alternativeEmail}`); + } + + if (params.comments) { + descriptionLines.push(``, `Customer Comments:`, params.comments); + } + + descriptionLines.push(``, `Submitted: ${new Date().toISOString()}`); + + const casePayload: Record = { + Origin: "Portal", + Status: SALESFORCE_CASE_STATUS.NEW, + Priority: SALESFORCE_CASE_PRIORITY.HIGH, + Subject: `Cancellation Request - ${params.productType} (${params.cancellationMonth})`, + Description: descriptionLines.join("\n"), + AccountId: safeAccountId, + }; + + // Link to Opportunity if we have one + if (safeOpportunityId) { + casePayload.OpportunityId = safeOpportunityId; + } + + try { + const created = (await this.sf.sobject("Case").create(casePayload)) as { id?: string }; + + if (!created.id) { + throw new Error("Salesforce did not return a case ID"); + } + + this.logger.log("Cancellation case created", { + caseId: created.id, + hasOpportunityLink: !!safeOpportunityId, + }); + + return created.id; + } catch (error: unknown) { + this.logger.error("Failed to create cancellation case", { + error: getErrorMessage(error), + accountIdTail: safeAccountId.slice(-4), + }); + throw new Error("Failed to create cancellation request case"); + } + } } diff --git a/apps/bff/src/integrations/salesforce/services/salesforce-opportunity.service.ts b/apps/bff/src/integrations/salesforce/services/salesforce-opportunity.service.ts new file mode 100644 index 00000000..34736957 --- /dev/null +++ b/apps/bff/src/integrations/salesforce/services/salesforce-opportunity.service.ts @@ -0,0 +1,736 @@ +/** + * Salesforce Opportunity Integration Service + * + * Manages Opportunity records for service lifecycle tracking. + * Opportunities track customer journeys from interest through cancellation. + * + * Key responsibilities: + * - Create Opportunities at interest triggers (eligibility request, registration) + * - Update Opportunity stages as orders progress + * - Link WHMCS services to Opportunities for cancellation workflows + * - Store cancellation form data on Opportunities + * + * Uses existing Salesforce stage values: + * - Introduction → Ready → Post Processing → Active → △Cancelling → 〇Cancelled + * + * @see docs/salesforce/OPPORTUNITY-LIFECYCLE-GUIDE.md for complete documentation + */ + +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 { getErrorMessage } from "@bff/core/utils/error.util.js"; +import type { SalesforceResponse } from "@customer-portal/domain/common"; +import { + type OpportunityStageValue, + type OpportunityProductTypeValue, + type OpportunitySourceValue, + type ApplicationStageValue, + type CancellationNoticeValue, + type LineReturnStatusValue, + type CommodityTypeValue, + type CancellationOpportunityData, + 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_CANCELLATION_QUERY_FIELDS, +} from "../config/opportunity-field-map.js"; + +// ============================================================================ +// Types +// ============================================================================ + +/** + * Raw Opportunity record from Salesforce query + */ +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 + ScheduledCancellationDateAndTime__c?: string; + CancellationNotice__c?: string; + LineReturn__c?: string; + // New custom fields (to be created) + Portal_Source__c?: string; + WHMCS_Service_ID__c?: number; + // Note: Cases and Orders link TO Opportunity via their OpportunityId field + // Cancellation comments and alternative email are on the Cancellation Case + // Relationship fields + Account?: { Name?: string }; +} + +// ============================================================================ +// Service +// ============================================================================ + +@Injectable() +export class SalesforceOpportunityService { + constructor( + private readonly sf: SalesforceConnection, + @Inject(Logger) private readonly logger: Logger + ) {} + + // ========================================================================== + // Core CRUD Operations + // ========================================================================== + + /** + * 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 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, + }; + + // 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.OpportunityId + // 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) { + this.logger.error("Failed to create Opportunity", { + error: getErrorMessage(error), + accountId: safeAccountId, + productType: request.productType, + }); + throw new Error("Failed to create service lifecycle record"); + } + } + + /** + * 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: getErrorMessage(error), + opportunityId: safeOppId, + stage, + }); + throw new Error("Failed to update service lifecycle stage"); + } + } + + /** + * Update Opportunity with 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.OpportunityId. + * + * @param opportunityId - Salesforce Opportunity ID + * @param data - Cancellation data (dates and status flags) + */ + async updateCancellationData( + opportunityId: string, + data: CancellationOpportunityData + ): Promise { + const safeOppId = assertSalesforceId(opportunityId, "opportunityId"); + + this.logger.log("Updating Opportunity with cancellation data", { + opportunityId: safeOppId, + scheduledDate: data.scheduledCancellationDate, + cancellationNotice: data.cancellationNotice, + }); + + const payload: Record = { + Id: safeOppId, + [OPPORTUNITY_FIELD_MAP.stage]: OPPORTUNITY_STAGE.CANCELLING, + [OPPORTUNITY_FIELD_MAP.scheduledCancellationDate]: data.scheduledCancellationDate, + [OPPORTUNITY_FIELD_MAP.cancellationNotice]: data.cancellationNotice, + [OPPORTUNITY_FIELD_MAP.lineReturnStatus]: data.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 cancellation data updated successfully", { + opportunityId: safeOppId, + scheduledDate: data.scheduledCancellationDate, + }); + } catch (error) { + this.logger.error("Failed to update Opportunity cancellation data", { + error: getErrorMessage(error), + opportunityId: safeOppId, + }); + throw new Error("Failed to update cancellation information"); + } + } + + // ========================================================================== + // Lookup Operations + // ========================================================================== + + /** + * 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 + ): Promise { + const safeAccountId = assertSalesforceId(accountId, "accountId"); + + // Get the CommodityType value(s) that match this product type + const commodityTypeValues = this.getCommodityTypesForProductType(productType); + + this.logger.debug("Looking for open Opportunity", { + accountId: safeAccountId, + productType, + commodityTypes: commodityTypeValues, + }); + + // Build stage filter for open stages + const stageList = OPEN_OPPORTUNITY_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) { + this.logger.error("Failed to find open Opportunity", { + error: getErrorMessage(error), + accountId: safeAccountId, + productType, + }); + // Don't throw - return null to allow fallback to creation + return null; + } + } + + /** + * 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: getErrorMessage(error), + orderId: safeOrderId, + }); + return null; + } + } + + /** + * 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: getErrorMessage(error), + whmcsServiceId, + }); + return null; + } + } + + /** + * 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: getErrorMessage(error), + opportunityId: safeOppId, + }); + return null; + } + } + + /** + * Get cancellation status for display in portal + * + * @param whmcsServiceId - WHMCS Service ID + * @returns Cancellation status details or null + */ + async getCancellationStatus(whmcsServiceId: number): Promise<{ + stage: OpportunityStageValue; + isPending: boolean; + isComplete: boolean; + scheduledEndDate?: string; + rentalReturnStatus?: LineReturnStatusValue; + } | null> { + const soql = ` + SELECT ${OPPORTUNITY_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:getCancellationStatus", + })) 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 cancellation status", { + error: getErrorMessage(error), + whmcsServiceId, + }); + return null; + } + } + + // ========================================================================== + // Lifecycle Helpers + // ========================================================================== + + /** + * 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: getErrorMessage(error), + opportunityId: safeOppId, + whmcsServiceId, + }); + // Don't throw - this is a non-critical update + } + } + + /** + * 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 + */ + 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: getErrorMessage(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 []; + } + } + + /** + * 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.Portal_Source__c as OpportunitySourceValue | undefined, + applicationStage: record.Application_Stage__c as ApplicationStageValue | undefined, + isClosed: record.IsClosed, + // Note: Related Cases and Orders are queried separately via their OpportunityId field + 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, + }; + } +} diff --git a/apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts index 91b2744d..f405fdea 100644 --- a/apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, ConflictException, + HttpStatus, Inject, Injectable, NotFoundException, @@ -19,11 +20,13 @@ import { PrismaService } from "@bff/infra/database/prisma.service.js"; import { AuthTokenService } from "../../token/token.service.js"; import { AuthRateLimitService } from "../../rate-limiting/auth-rate-limit.service.js"; import { getErrorMessage } from "@bff/core/utils/error.util.js"; +import { DomainHttpException } from "@bff/core/http/domain-http.exception.js"; import { signupRequestSchema, type SignupRequest, type ValidateSignupRequest, } from "@customer-portal/domain/auth"; +import { ErrorCode } from "@customer-portal/domain/common"; import { Providers as CustomerProviders } from "@customer-portal/domain/customer"; import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js"; import type { User as PrismaUser } from "@prisma/client"; @@ -302,9 +305,7 @@ export class SignupWorkflowService { throw new ConflictException("You already have an account. Please sign in."); } - throw new ConflictException( - "We found an existing billing account for this email. Please link your account instead." - ); + throw new DomainHttpException(ErrorCode.LEGACY_ACCOUNT_EXISTS, HttpStatus.CONFLICT); } } catch (pre) { if (!(pre instanceof NotFoundException)) { @@ -556,7 +557,7 @@ export class SignupWorkflowService { result.nextAction = "link_whmcs"; result.messages.push( - "We found an existing billing account for this email. Please transfer your account." + "We found an existing billing account for this email. Please transfer your account to continue." ); return result; } @@ -623,7 +624,7 @@ export class SignupWorkflowService { result.nextAction = "link_whmcs"; result.messages.push( - "We found an existing billing account for this email. Please link your account." + "We found an existing billing account for this email. Please transfer your account to continue." ); return result; } diff --git a/apps/bff/src/modules/catalog/services/internet-catalog.service.ts b/apps/bff/src/modules/catalog/services/internet-catalog.service.ts index 9f478d25..05a491e9 100644 --- a/apps/bff/src/modules/catalog/services/internet-catalog.service.ts +++ b/apps/bff/src/modules/catalog/services/internet-catalog.service.ts @@ -16,12 +16,19 @@ import { } from "@customer-portal/domain/catalog"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js"; +import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js"; +import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js"; import { Logger } from "nestjs-pino"; import { getErrorMessage } from "@bff/core/utils/error.util.js"; import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util.js"; import { assertSoqlFieldName } from "@bff/integrations/salesforce/utils/soql.util.js"; import type { InternetEligibilityCheckRequest } from "./internet-eligibility.types.js"; import type { SalesforceResponse } from "@customer-portal/domain/common"; +import { + OPPORTUNITY_STAGE, + OPPORTUNITY_SOURCE, + OPPORTUNITY_PRODUCT_TYPE, +} from "@customer-portal/domain/opportunity"; export type InternetEligibilityStatusDto = "not_requested" | "pending" | "eligible" | "ineligible"; @@ -41,7 +48,9 @@ export class InternetCatalogService extends BaseCatalogService { private readonly config: ConfigService, @Inject(Logger) logger: Logger, private mappingsService: MappingsService, - private catalogCache: CatalogCacheService + private catalogCache: CatalogCacheService, + private opportunityService: SalesforceOpportunityService, + private caseService: SalesforceCaseService ) { super(sf, config, logger); } @@ -268,39 +277,71 @@ export class InternetCatalogService extends BaseCatalogService { throw new BadRequestException("Service address is required to request eligibility review."); } - const subject = "Internet availability check request (Portal)"; - const descriptionLines: string[] = [ - "Portal internet availability check requested.", - "", - `UserId: ${userId}`, - `Email: ${request.email}`, - `SalesforceAccountId: ${sfAccountId}`, - "", - request.notes ? `Notes: ${request.notes}` : "", - request.address ? `Address: ${formatAddressForLog(request.address)}` : "", - "", - `RequestedAt: ${new Date().toISOString()}`, - ].filter(Boolean); - try { - const requestId = await this.createEligibilityCaseOrTask(sfAccountId, { + // 1. Find or create Opportunity for Internet eligibility + // Only match Introduction stage (not Ready/Post Processing - those have progressed) + let opportunityId = await this.opportunityService.findOpenOpportunityForAccount( + sfAccountId, + OPPORTUNITY_PRODUCT_TYPE.INTERNET + ); + + let opportunityCreated = false; + if (!opportunityId) { + // Create Opportunity - Salesforce workflow auto-generates the name + opportunityId = await this.opportunityService.createOpportunity({ + accountId: sfAccountId, + productType: OPPORTUNITY_PRODUCT_TYPE.INTERNET, + stage: OPPORTUNITY_STAGE.INTRODUCTION, + source: OPPORTUNITY_SOURCE.INTERNET_ELIGIBILITY, + }); + opportunityCreated = true; + + this.logger.log("Created Opportunity for eligibility request", { + opportunityIdTail: opportunityId.slice(-4), + sfAccountIdTail: sfAccountId.slice(-4), + }); + } + + // 2. Build case description + const subject = "Internet availability check request (Portal)"; + const descriptionLines: string[] = [ + "Portal internet availability check requested.", + "", + `UserId: ${userId}`, + `Email: ${request.email}`, + `SalesforceAccountId: ${sfAccountId}`, + `OpportunityId: ${opportunityId}`, + "", + request.notes ? `Notes: ${request.notes}` : "", + request.address ? `Address: ${formatAddressForLog(request.address)}` : "", + "", + `RequestedAt: ${new Date().toISOString()}`, + ].filter(Boolean); + + // 3. Create Case linked to Opportunity + const caseId = await this.caseService.createEligibilityCase({ + accountId: sfAccountId, + opportunityId, subject, description: descriptionLines.join("\n"), }); - await this.updateAccountEligibilityRequestState(sfAccountId, requestId); + // 4. Update Account eligibility status + await this.updateAccountEligibilityRequestState(sfAccountId, caseId); await this.catalogCache.invalidateEligibility(sfAccountId); - this.logger.log("Created Salesforce Task for internet eligibility request", { + this.logger.log("Created eligibility Case linked to Opportunity", { userId, sfAccountIdTail: sfAccountId.slice(-4), - taskIdTail: requestId.slice(-4), + caseIdTail: caseId.slice(-4), + opportunityIdTail: opportunityId.slice(-4), + opportunityCreated, }); - return requestId; + return caseId; } catch (error) { - this.logger.error("Failed to create Salesforce Task for internet eligibility request", { + this.logger.error("Failed to create eligibility request", { userId, sfAccountId, error: getErrorMessage(error), @@ -413,49 +454,8 @@ export class InternetCatalogService extends BaseCatalogService { return { status, eligibility, requestId, requestedAt, checkedAt, notes }; } - private async createEligibilityCaseOrTask( - sfAccountId: string, - payload: { subject: string; description: string } - ): Promise { - const caseCreate = this.sf.sobject("Case")?.create; - if (caseCreate) { - try { - const result = await caseCreate({ - Subject: payload.subject, - Description: payload.description, - Origin: "Portal", - AccountId: sfAccountId, - }); - const id = (result as { id?: unknown })?.id; - if (typeof id === "string" && id.trim().length > 0) { - return id; - } - } catch (error) { - this.logger.warn( - "Failed to create Salesforce Case for eligibility request; falling back to Task", - { - sfAccountIdTail: sfAccountId.slice(-4), - error: getErrorMessage(error), - } - ); - } - } - - const taskCreate = this.sf.sobject("Task")?.create; - if (!taskCreate) { - throw new Error("Salesforce Case/Task create methods not available"); - } - const result = await taskCreate({ - Subject: payload.subject, - Description: payload.description, - WhatId: sfAccountId, - }); - const id = (result as { id?: unknown })?.id; - if (typeof id !== "string" || id.trim().length === 0) { - throw new Error("Salesforce did not return a request id"); - } - return id; - } + // Note: createEligibilityCaseOrTask was removed - now using this.caseService.createEligibilityCase() + // which links the Case to the Opportunity private async updateAccountEligibilityRequestState( sfAccountId: string, diff --git a/apps/bff/src/modules/orders/config/order-field-map.service.ts b/apps/bff/src/modules/orders/config/order-field-map.service.ts index c2ab098f..53d9bf29 100644 --- a/apps/bff/src/modules/orders/config/order-field-map.service.ts +++ b/apps/bff/src/modules/orders/config/order-field-map.service.ts @@ -42,6 +42,7 @@ export class OrderFieldMapService { "CreatedDate", "LastModifiedDate", "Pricebook2Id", + "OpportunityId", // Linked Opportunity for lifecycle tracking order.activationType, order.activationStatus, order.activationScheduledAt, diff --git a/apps/bff/src/modules/orders/services/opportunity-matching.service.ts b/apps/bff/src/modules/orders/services/opportunity-matching.service.ts new file mode 100644 index 00000000..586b20ea --- /dev/null +++ b/apps/bff/src/modules/orders/services/opportunity-matching.service.ts @@ -0,0 +1,495 @@ +/** + * Opportunity Matching Service + * + * Resolves which Opportunity to use for orders based on matching rules. + * Handles finding existing Opportunities or creating new ones as needed. + * + * Uses existing Salesforce stages: + * - Introduction → Ready → Post Processing → Active → △Cancelling → 〇Cancelled + * + * Matching Rules: + * 1. If order already has opportunityId → use it directly + * 2. For Internet orders → find Introduction/Ready stage Opportunity or create new + * 3. For SIM orders → find open Opportunity for account or create new + * 4. For VPN orders → create new Opportunity (no matching) + * + * @see docs/salesforce/OPPORTUNITY-LIFECYCLE-GUIDE.md for complete documentation + */ + +import { Injectable, Inject } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js"; +import { + type OpportunityProductTypeValue, + type OpportunityStageValue, + type CancellationFormData, + type CancellationOpportunityData, + type CancellationEligibility, + OPPORTUNITY_STAGE, + OPPORTUNITY_SOURCE, + OPPORTUNITY_PRODUCT_TYPE, + APPLICATION_STAGE, + getCancellationEligibility, + validateCancellationMonth, + transformCancellationFormToOpportunityData, +} from "@customer-portal/domain/opportunity"; +import type { OrderTypeValue } from "@customer-portal/domain/orders"; + +// ============================================================================ +// Types +// ============================================================================ + +/** + * Context for Opportunity resolution + */ +export interface OpportunityResolutionContext { + /** Salesforce Account ID */ + accountId: string; + + /** Order type (Internet, SIM, VPN) */ + orderType: OrderTypeValue; + + /** Existing Opportunity ID if provided with order */ + existingOpportunityId?: string; + + /** Source of the order (for new Opportunity creation) */ + source?: "checkout" | "eligibility" | "order"; +} + +/** + * Result of Opportunity resolution + */ +export interface ResolvedOpportunity { + /** The Opportunity ID to use */ + opportunityId: string; + + /** Whether a new Opportunity was created */ + wasCreated: boolean; + + /** The stage of the Opportunity */ + stage: OpportunityStageValue; +} + +// ============================================================================ +// Service +// ============================================================================ + +@Injectable() +export class OpportunityMatchingService { + constructor( + private readonly opportunityService: SalesforceOpportunityService, + @Inject(Logger) private readonly logger: Logger + ) {} + + // ========================================================================== + // Opportunity Resolution for Orders + // ========================================================================== + + /** + * Resolve the Opportunity to use for an order + * + * This is the main entry point for Opportunity matching. + * It handles finding existing Opportunities or creating new ones. + * + * @param context - Resolution context with account and order details + * @returns Resolved Opportunity with ID and metadata + */ + async resolveOpportunityForOrder( + context: OpportunityResolutionContext + ): Promise { + this.logger.log("Resolving Opportunity for order", { + accountId: context.accountId, + orderType: context.orderType, + hasExistingOpportunityId: !!context.existingOpportunityId, + }); + + // Rule 1: If order already has opportunityId, validate and use it + if (context.existingOpportunityId) { + return this.useExistingOpportunity(context.existingOpportunityId); + } + + // Rule 2-4: Find or create based on order type + const productType = this.mapOrderTypeToProductType(context.orderType); + + if (!productType) { + this.logger.warn("Unknown order type, creating new Opportunity", { + orderType: context.orderType, + }); + return this.createNewOpportunity(context, "Internet"); + } + + // Try to find existing open Opportunity + const existingOppId = await this.opportunityService.findOpenOpportunityForAccount( + context.accountId, + productType + ); + + if (existingOppId) { + return this.useExistingOpportunity(existingOppId); + } + + // No existing Opportunity found - create new one + return this.createNewOpportunity(context, productType); + } + + // ========================================================================== + // Opportunity Creation Triggers + // ========================================================================== + + /** + * Create Opportunity at eligibility request (Internet only) + * + * Called when customer requests Internet eligibility check. + * Creates an Opportunity in "Introduction" stage. + * + * NOTE: The Case is linked TO the Opportunity via Case.OpportunityId, + * not the other way around. So we don't need to store Case ID on Opportunity. + * NOTE: Opportunity Name is auto-generated by Salesforce workflow. + * + * @param accountId - Salesforce Account ID + * @returns Created Opportunity ID + */ + async createOpportunityForEligibility(accountId: string): Promise { + this.logger.log("Creating Opportunity for Internet eligibility request", { + accountId, + }); + + const opportunityId = await this.opportunityService.createOpportunity({ + accountId, + productType: OPPORTUNITY_PRODUCT_TYPE.INTERNET, + stage: OPPORTUNITY_STAGE.INTRODUCTION, + source: OPPORTUNITY_SOURCE.INTERNET_ELIGIBILITY, + applicationStage: APPLICATION_STAGE.INTRO_1, + }); + + return opportunityId; + } + + /** + * Create Opportunity at checkout registration (SIM only) + * + * Called when customer creates account during SIM checkout. + * Creates an Opportunity in "Introduction" stage. + * NOTE: Opportunity Name is auto-generated by Salesforce workflow. + * + * @param accountId - Salesforce Account ID + * @returns Created Opportunity ID + */ + async createOpportunityForCheckoutRegistration(accountId: string): Promise { + this.logger.log("Creating Opportunity for SIM checkout registration", { + accountId, + }); + + const opportunityId = await this.opportunityService.createOpportunity({ + accountId, + productType: OPPORTUNITY_PRODUCT_TYPE.SIM, + stage: OPPORTUNITY_STAGE.INTRODUCTION, + source: OPPORTUNITY_SOURCE.SIM_CHECKOUT_REGISTRATION, + applicationStage: APPLICATION_STAGE.INTRO_1, + }); + + return opportunityId; + } + + // ========================================================================== + // Lifecycle Stage Updates + // ========================================================================== + + /** + * Update Opportunity stage to Ready (eligible) + * + * Called when Internet eligibility is confirmed. + * + * @param opportunityId - Opportunity ID to update + */ + async markEligible(opportunityId: string): Promise { + this.logger.log("Marking Opportunity as eligible (Ready)", { opportunityId }); + await this.opportunityService.updateStage( + opportunityId, + OPPORTUNITY_STAGE.READY, + "Eligibility confirmed" + ); + } + + /** + * Update Opportunity stage to Void (not eligible) + * + * Called when Internet eligibility check fails. + * + * @param opportunityId - Opportunity ID to update + */ + async markNotEligible(opportunityId: string): Promise { + this.logger.log("Marking Opportunity as not eligible (Void)", { opportunityId }); + await this.opportunityService.updateStage( + opportunityId, + OPPORTUNITY_STAGE.VOID, + "Not eligible for service" + ); + } + + /** + * Update Opportunity stage after order placement + * + * Called after order is successfully created in Salesforce. + * + * @param opportunityId - Opportunity ID to update + */ + async markOrderPlaced(opportunityId: string): Promise { + this.logger.log("Marking Opportunity as order placed (Post Processing)", { opportunityId }); + await this.opportunityService.updateStage( + opportunityId, + OPPORTUNITY_STAGE.POST_PROCESSING, + "Order placed via portal" + ); + } + + /** + * Update Opportunity stage after provisioning + * + * Called after order is successfully provisioned. + * + * @param opportunityId - Opportunity ID to update + * @param whmcsServiceId - WHMCS Service ID to link + */ + async markProvisioned(opportunityId: string, whmcsServiceId: number): Promise { + this.logger.log("Marking Opportunity as active", { + opportunityId, + whmcsServiceId, + }); + + // Update stage to Active + await this.opportunityService.updateStage( + opportunityId, + OPPORTUNITY_STAGE.ACTIVE, + "Service provisioned successfully" + ); + + // Link WHMCS Service ID for cancellation workflows + await this.opportunityService.linkWhmcsServiceToOpportunity(opportunityId, whmcsServiceId); + } + + /** + * Update Opportunity for eligibility result + * + * Called when eligibility check is completed (eligible or not). + * + * @param opportunityId - Opportunity ID to update + * @param isEligible - Whether the customer is eligible + */ + async updateEligibilityResult(opportunityId: string, isEligible: boolean): Promise { + if (isEligible) { + await this.markEligible(opportunityId); + } else { + await this.markNotEligible(opportunityId); + } + } + + // ========================================================================== + // Cancellation Flow + // ========================================================================== + + /** + * Get cancellation eligibility for a customer + * + * Calculates available cancellation months based on the 25th rule. + * + * @returns Cancellation eligibility details + */ + getCancellationEligibility(): CancellationEligibility { + return getCancellationEligibility(); + } + + /** + * Validate a cancellation request + * + * Checks: + * - Month format is valid + * - Month is not before earliest allowed + * - Both confirmations are checked + * + * @param formData - Form data from customer + * @returns Validation result + */ + validateCancellationRequest(formData: CancellationFormData): { valid: boolean; error?: string } { + // Validate month + const monthValidation = validateCancellationMonth(formData.cancellationMonth); + if (!monthValidation.valid) { + return monthValidation; + } + + // Validate confirmations + if (!formData.confirmTermsRead) { + return { + valid: false, + error: "You must confirm you have read the cancellation terms", + }; + } + + if (!formData.confirmMonthEndCancellation) { + return { + valid: false, + error: "You must confirm you understand cancellation is at month end", + }; + } + + return { valid: true }; + } + + /** + * Process a cancellation request + * + * Finds the Opportunity for the service and updates it with cancellation data. + * + * @param whmcsServiceId - WHMCS Service ID + * @param formData - Cancellation form data + * @returns Result of the cancellation update + */ + async processCancellationRequest( + whmcsServiceId: number, + formData: CancellationFormData + ): Promise<{ success: boolean; opportunityId?: string; error?: string }> { + this.logger.log("Processing cancellation request", { + whmcsServiceId, + cancellationMonth: formData.cancellationMonth, + }); + + // Validate the request + const validation = this.validateCancellationRequest(formData); + if (!validation.valid) { + return { success: false, error: validation.error }; + } + + // Find the Opportunity + const opportunityId = + await this.opportunityService.findOpportunityByWhmcsServiceId(whmcsServiceId); + + if (!opportunityId) { + this.logger.warn("No Opportunity found for WHMCS Service", { whmcsServiceId }); + // This is not necessarily an error - older services may not have Opportunities + // Return success but note no Opportunity was found + return { success: true }; + } + + // Transform form data to Opportunity data + const cancellationData: CancellationOpportunityData = + transformCancellationFormToOpportunityData(formData); + + // Update the Opportunity + await this.opportunityService.updateCancellationData(opportunityId, cancellationData); + + this.logger.log("Cancellation request processed successfully", { + opportunityId, + scheduledDate: cancellationData.scheduledCancellationDate, + }); + + return { success: true, opportunityId }; + } + + // ========================================================================== + // Private Helpers + // ========================================================================== + + /** + * Use an existing Opportunity (validate it exists) + */ + private async useExistingOpportunity(opportunityId: string): Promise { + this.logger.debug("Using existing Opportunity", { opportunityId }); + + const opportunity = await this.opportunityService.getOpportunityById(opportunityId); + + if (!opportunity) { + this.logger.warn("Existing Opportunity not found, will need to create new", { + opportunityId, + }); + throw new Error(`Opportunity ${opportunityId} not found`); + } + + return { + opportunityId: opportunity.id, + wasCreated: false, + stage: opportunity.stage, + }; + } + + /** + * Create a new Opportunity for an order + */ + private async createNewOpportunity( + context: OpportunityResolutionContext, + productType: OpportunityProductTypeValue + ): Promise { + this.logger.debug("Creating new Opportunity for order", { + accountId: context.accountId, + productType, + }); + + const stage = this.determineInitialStage(context); + const source = this.determineSource(context); + + // Salesforce workflow auto-generates Opportunity Name + const opportunityId = await this.opportunityService.createOpportunity({ + accountId: context.accountId, + productType, + stage, + source, + applicationStage: APPLICATION_STAGE.INTRO_1, + }); + + return { + opportunityId, + wasCreated: true, + stage, + }; + } + + /** + * Map OrderType to OpportunityProductType + */ + private mapOrderTypeToProductType(orderType: OrderTypeValue): OpportunityProductTypeValue | null { + switch (orderType) { + case "Internet": + return OPPORTUNITY_PRODUCT_TYPE.INTERNET; + case "SIM": + return OPPORTUNITY_PRODUCT_TYPE.SIM; + case "VPN": + return OPPORTUNITY_PRODUCT_TYPE.VPN; + default: + return null; + } + } + + /** + * Determine initial stage for new Opportunity + */ + private determineInitialStage(context: OpportunityResolutionContext): OpportunityStageValue { + // If coming from eligibility request + if (context.source === "eligibility") { + return OPPORTUNITY_STAGE.INTRODUCTION; + } + + // If coming from checkout registration + if (context.source === "checkout") { + return OPPORTUNITY_STAGE.INTRODUCTION; + } + + // Default: order placement - go to Post Processing + return OPPORTUNITY_STAGE.POST_PROCESSING; + } + + /** + * Determine source for new Opportunity + */ + private determineSource( + context: OpportunityResolutionContext + ): (typeof OPPORTUNITY_SOURCE)[keyof typeof OPPORTUNITY_SOURCE] { + switch (context.source) { + case "eligibility": + return OPPORTUNITY_SOURCE.INTERNET_ELIGIBILITY; + case "checkout": + return OPPORTUNITY_SOURCE.SIM_CHECKOUT_REGISTRATION; + case "order": + default: + return OPPORTUNITY_SOURCE.ORDER_PLACEMENT; + } + } +} diff --git a/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts b/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts index de95bd49..110063fd 100644 --- a/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts +++ b/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts @@ -1,6 +1,7 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js"; +import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js"; import { WhmcsOrderService } from "@bff/integrations/whmcs/services/whmcs-order.service.js"; import type { WhmcsOrderResult } from "@bff/integrations/whmcs/services/whmcs-order.service.js"; import { OrderOrchestrator } from "./order-orchestrator.service.js"; @@ -16,6 +17,7 @@ import { type OrderFulfillmentValidationResult, Providers as OrderProviders, } from "@customer-portal/domain/orders"; +import { OPPORTUNITY_STAGE } from "@customer-portal/domain/opportunity"; import { OrderValidationException, FulfillmentException, @@ -51,6 +53,7 @@ export class OrderFulfillmentOrchestrator { constructor( @Inject(Logger) private readonly logger: Logger, private readonly salesforceService: SalesforceService, + private readonly opportunityService: SalesforceOpportunityService, private readonly whmcsOrderService: WhmcsOrderService, private readonly orderOrchestrator: OrderOrchestrator, private readonly orderFulfillmentValidator: OrderFulfillmentValidator, @@ -232,12 +235,16 @@ export class OrderFulfillmentOrchestrator { `Provisioned from Salesforce Order ${sfOrderId}` ); + // Get OpportunityId from order details for WHMCS lifecycle linking + const sfOpportunityId = context.orderDetails?.opportunityId; + const result = await this.whmcsOrderService.addOrder({ clientId: context.validation.clientId, items: mappingResult.whmcsItems, paymentMethod: "stripe", promoCode: "1st Month Free (Monthly Plan)", sfOrderId, + sfOpportunityId, // Pass to WHMCS for bidirectional linking notes: orderNotes, noinvoiceemail: true, noemail: true, @@ -346,6 +353,54 @@ export class OrderFulfillmentOrchestrator { }, critical: true, }, + { + id: "opportunity_update", + description: "Update Opportunity with WHMCS Service ID and Active stage", + execute: this.createTrackedStep(context, "opportunity_update", async () => { + const opportunityId = context.orderDetails?.opportunityId; + const serviceId = whmcsCreateResult?.serviceIds?.[0]; + + if (!opportunityId) { + this.logger.debug("No Opportunity linked to order, skipping update", { + sfOrderId, + }); + return { skipped: true as const }; + } + + try { + // Update Opportunity stage to Active and set WHMCS Service ID + await this.opportunityService.updateStage( + opportunityId, + OPPORTUNITY_STAGE.ACTIVE, + "Service activated via fulfillment" + ); + + if (serviceId) { + await this.opportunityService.linkWhmcsServiceToOpportunity( + opportunityId, + serviceId + ); + } + + this.logger.log("Opportunity updated with Active stage and WHMCS link", { + opportunityIdTail: opportunityId.slice(-4), + whmcsServiceId: serviceId, + sfOrderId, + }); + + return { opportunityId, whmcsServiceId: serviceId }; + } catch (error) { + // Log but don't fail - Opportunity update is non-critical + this.logger.warn("Failed to update Opportunity after fulfillment", { + error: getErrorMessage(error), + opportunityId, + sfOrderId, + }); + return { failed: true as const, error: getErrorMessage(error) }; + } + }), + critical: false, // Opportunity update failure shouldn't rollback fulfillment + }, ], { description: `Order fulfillment for ${sfOrderId}`, diff --git a/apps/bff/src/modules/orders/services/order-orchestrator.service.ts b/apps/bff/src/modules/orders/services/order-orchestrator.service.ts index 43f7ff72..1be9a0d6 100644 --- a/apps/bff/src/modules/orders/services/order-orchestrator.service.ts +++ b/apps/bff/src/modules/orders/services/order-orchestrator.service.ts @@ -1,13 +1,20 @@ import { Injectable, Inject, NotFoundException } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { SalesforceOrderService } from "@bff/integrations/salesforce/services/salesforce-order.service.js"; +import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js"; import { OrderValidator } from "./order-validator.service.js"; import { OrderBuilder } from "./order-builder.service.js"; import { OrderItemBuilder } from "./order-item-builder.service.js"; import type { OrderItemCompositePayload } from "./order-item-builder.service.js"; import { OrdersCacheService } from "./orders-cache.service.js"; -import type { OrderDetails, OrderSummary } from "@customer-portal/domain/orders"; +import type { OrderDetails, OrderSummary, OrderTypeValue } from "@customer-portal/domain/orders"; import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util.js"; +import { + OPPORTUNITY_STAGE, + OPPORTUNITY_SOURCE, + OPPORTUNITY_PRODUCT_TYPE, + type OpportunityProductTypeValue, +} from "@customer-portal/domain/opportunity"; type OrderDetailsResponse = OrderDetails; type OrderSummaryResponse = OrderSummary; @@ -21,6 +28,7 @@ export class OrderOrchestrator { constructor( @Inject(Logger) private readonly logger: Logger, private readonly salesforceOrderService: SalesforceOrderService, + private readonly opportunityService: SalesforceOpportunityService, private readonly orderValidator: OrderValidator, private readonly orderBuilder: OrderBuilder, private readonly orderItemBuilder: OrderItemBuilder, @@ -46,9 +54,18 @@ export class OrderOrchestrator { "Order validation completed successfully" ); - // 2) Build order fields (includes address snapshot) + // 2) Resolve Opportunity for this order + const opportunityId = await this.resolveOpportunityForOrder( + validatedBody.orderType, + userMapping.sfAccountId ?? null, + validatedBody.opportunityId + ); + + // 3) Build order fields with Opportunity link + const bodyWithOpportunity = opportunityId ? { ...validatedBody, opportunityId } : validatedBody; + const orderFields = await this.orderBuilder.buildOrderFields( - validatedBody, + bodyWithOpportunity, userMapping, pricebookId, validatedBody.userId @@ -63,6 +80,7 @@ export class OrderOrchestrator { orderType: validatedBody.orderType, skuCount: validatedBody.skus.length, orderItemCount: orderItemsPayload.length, + hasOpportunity: !!opportunityId, }, "Order payload prepared" ); @@ -72,6 +90,27 @@ export class OrderOrchestrator { orderItemsPayload ); + // 4) Update Opportunity stage to Post Processing + if (opportunityId) { + try { + await this.opportunityService.updateStage( + opportunityId, + OPPORTUNITY_STAGE.POST_PROCESSING, + "Order placed via Portal" + ); + this.logger.log("Opportunity stage updated to Post Processing", { + opportunityIdTail: opportunityId.slice(-4), + orderId: created.id, + }); + } catch { + // Log but don't fail the order + this.logger.warn("Failed to update Opportunity stage after order", { + opportunityId, + orderId: created.id, + }); + } + } + if (userMapping.sfAccountId) { await this.ordersCache.invalidateAccountOrders(userMapping.sfAccountId); } @@ -82,6 +121,7 @@ export class OrderOrchestrator { orderId: created.id, skuCount: validatedBody.skus.length, orderItemCount: orderItemsPayload.length, + opportunityId, }, "Order creation workflow completed successfully" ); @@ -93,6 +133,90 @@ export class OrderOrchestrator { }; } + /** + * Resolve Opportunity for an order + * + * - If order already has an Opportunity ID, use it + * - Otherwise, find existing open Opportunity for this product type + * - If none found, create a new one with Post Processing stage + */ + private async resolveOpportunityForOrder( + orderType: OrderTypeValue, + sfAccountId: string | null, + existingOpportunityId?: string + ): Promise { + // If account ID is missing, can't create Opportunity + if (!sfAccountId) { + this.logger.warn("Cannot resolve Opportunity: no Salesforce Account ID"); + return null; + } + + const safeAccountId = assertSalesforceId(sfAccountId, "sfAccountId"); + const productType = this.mapOrderTypeToProductType(orderType); + + // If order already has Opportunity ID, use it + if (existingOpportunityId) { + this.logger.debug("Using existing Opportunity from order", { + opportunityId: existingOpportunityId, + }); + return existingOpportunityId; + } + + try { + // Try to find existing open Opportunity (Introduction or Ready stage) + const existingOppId = await this.opportunityService.findOpenOpportunityForAccount( + safeAccountId, + productType + ); + + if (existingOppId) { + this.logger.log("Found existing Opportunity for order", { + opportunityIdTail: existingOppId.slice(-4), + productType, + }); + return existingOppId; + } + + // Create new Opportunity - Salesforce workflow auto-generates the name + const newOppId = await this.opportunityService.createOpportunity({ + accountId: safeAccountId, + productType, + stage: OPPORTUNITY_STAGE.POST_PROCESSING, + source: OPPORTUNITY_SOURCE.ORDER_PLACEMENT, + }); + + this.logger.log("Created new Opportunity for order", { + opportunityIdTail: newOppId.slice(-4), + productType, + }); + + return newOppId; + } catch { + this.logger.warn("Failed to resolve Opportunity for order", { + orderType, + accountIdTail: safeAccountId.slice(-4), + }); + // Don't fail the order if Opportunity resolution fails + return null; + } + } + + /** + * Map order type to Opportunity product type + */ + private mapOrderTypeToProductType(orderType: OrderTypeValue): OpportunityProductTypeValue { + switch (orderType) { + case "Internet": + return OPPORTUNITY_PRODUCT_TYPE.INTERNET; + case "SIM": + return OPPORTUNITY_PRODUCT_TYPE.SIM; + case "VPN": + return OPPORTUNITY_PRODUCT_TYPE.VPN; + default: + return OPPORTUNITY_PRODUCT_TYPE.SIM; // Default fallback + } + } + /** * Get order by ID with order items */ diff --git a/apps/portal/next.config.mjs b/apps/portal/next.config.mjs index 3d5252aa..cb2a54e4 100644 --- a/apps/portal/next.config.mjs +++ b/apps/portal/next.config.mjs @@ -1,4 +1,3 @@ -/* eslint-env node */ import path from "node:path"; import { fileURLToPath } from "node:url"; diff --git a/apps/portal/scripts/bundle-monitor.mjs b/apps/portal/scripts/bundle-monitor.mjs index 1fe77fb5..882ca6dd 100644 --- a/apps/portal/scripts/bundle-monitor.mjs +++ b/apps/portal/scripts/bundle-monitor.mjs @@ -1,6 +1,4 @@ #!/usr/bin/env node -/* eslint-env node */ - /** * Bundle size monitoring script * Analyzes bundle size and reports on performance metrics diff --git a/apps/portal/scripts/dev-prep.mjs b/apps/portal/scripts/dev-prep.mjs index 9923b8e2..9b7da345 100644 --- a/apps/portal/scripts/dev-prep.mjs +++ b/apps/portal/scripts/dev-prep.mjs @@ -1,6 +1,4 @@ #!/usr/bin/env node -/* eslint-env node */ - // Ensure dev-time Next.js manifests exist to avoid noisy ENOENT errors import { mkdirSync, existsSync, writeFileSync, rmSync } from "fs"; import { join } from "path"; diff --git a/apps/portal/scripts/test-request-password-reset.cjs b/apps/portal/scripts/test-request-password-reset.cjs index f1503d89..85091ccb 100755 --- a/apps/portal/scripts/test-request-password-reset.cjs +++ b/apps/portal/scripts/test-request-password-reset.cjs @@ -1,6 +1,4 @@ #!/usr/bin/env node -/* eslint-env node */ - const fs = require("node:fs"); const path = require("node:path"); const Module = require("node:module"); diff --git a/apps/portal/src/features/auth/components/InlineAuthSection/InlineAuthSection.tsx b/apps/portal/src/features/auth/components/InlineAuthSection/InlineAuthSection.tsx new file mode 100644 index 00000000..3d5ebce8 --- /dev/null +++ b/apps/portal/src/features/auth/components/InlineAuthSection/InlineAuthSection.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/atoms/button"; +import { SignupForm } from "../SignupForm/SignupForm"; +import { LoginForm } from "../LoginForm/LoginForm"; +import { LinkWhmcsForm } from "../LinkWhmcsForm/LinkWhmcsForm"; +import { getSafeRedirect } from "@/features/auth/utils/route-protection"; + +interface HighlightItem { + title: string; + description: string; +} + +interface InlineAuthSectionProps { + title: string; + description?: string; + redirectTo?: string; + initialMode?: "signup" | "login"; + highlights?: HighlightItem[]; + className?: string; +} + +export function InlineAuthSection({ + title, + description, + redirectTo, + initialMode = "signup", + highlights = [], + className = "", +}: InlineAuthSectionProps) { + const router = useRouter(); + const [mode, setMode] = useState<"signup" | "login" | "migrate">(initialMode); + const safeRedirect = getSafeRedirect(redirectTo, "/account"); + + return ( +
+
+

{title}

+ {description && ( +

{description}

+ )} +
+ +
+
+ + + +
+
+ +
+ {mode === "signup" && ( + + )} + {mode === "login" && ( + + )} + {mode === "migrate" && ( +
+

Migrate your account

+

+ Use your legacy portal credentials to transfer your account. +

+ { + if (result.needsPasswordSet) { + const params = new URLSearchParams({ + email: result.user.email, + redirect: safeRedirect, + }); + router.push(`/auth/set-password?${params.toString()}`); + return; + } + router.push(safeRedirect); + }} + /> +
+ )} +
+ + {highlights.length > 0 && ( +
+
+ {highlights.map(item => ( +
+
{item.title}
+
{item.description}
+
+ ))} +
+
+ )} +
+ ); +} diff --git a/apps/portal/src/features/auth/components/LoginForm/LoginForm.tsx b/apps/portal/src/features/auth/components/LoginForm/LoginForm.tsx index 4dd41b02..f0ae978b 100644 --- a/apps/portal/src/features/auth/components/LoginForm/LoginForm.tsx +++ b/apps/portal/src/features/auth/components/LoginForm/LoginForm.tsx @@ -14,6 +14,7 @@ import { useLogin } from "../../hooks/use-auth"; import { loginRequestSchema } from "@customer-portal/domain/auth"; import { useZodForm } from "@/hooks/useZodForm"; import { z } from "zod"; +import { getSafeRedirect } from "@/features/auth/utils/route-protection"; interface LoginFormProps { onSuccess?: () => void; @@ -22,6 +23,7 @@ interface LoginFormProps { showForgotPasswordLink?: boolean; className?: string; redirectTo?: string; + initialEmail?: string; } /** @@ -43,10 +45,13 @@ export function LoginForm({ showForgotPasswordLink = true, className = "", redirectTo, + initialEmail, }: LoginFormProps) { const searchParams = useSearchParams(); - const { login, loading, error, clearError } = useLogin(); - const redirect = redirectTo || searchParams?.get("next") || searchParams?.get("redirect"); + const { login, loading, error, clearError } = useLogin({ redirectTo }); + const redirectCandidate = + redirectTo || searchParams?.get("next") || searchParams?.get("redirect"); + const redirect = getSafeRedirect(redirectCandidate, ""); const redirectQuery = redirect ? `?redirect=${encodeURIComponent(redirect)}` : ""; const handleLogin = useCallback( @@ -70,7 +75,7 @@ export function LoginForm({ useZodForm({ schema: loginFormSchema, initialValues: { - email: "", + email: initialEmail ?? "", password: "", rememberMe: false, }, diff --git a/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx b/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx index d0879ec6..706487b6 100644 --- a/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx +++ b/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx @@ -14,12 +14,12 @@ import { signupInputSchema, buildSignupRequest } from "@customer-portal/domain/a import { addressFormSchema } from "@customer-portal/domain/customer"; import { useZodForm } from "@/hooks/useZodForm"; import { z } from "zod"; +import { getSafeRedirect } from "@/features/auth/utils/route-protection"; import { MultiStepForm } from "./MultiStepForm"; import { AccountStep } from "./steps/AccountStep"; import { AddressStep } from "./steps/AddressStep"; import { PasswordStep } from "./steps/PasswordStep"; -import { ReviewStep } from "./steps/ReviewStep"; /** * Frontend signup form schema @@ -51,6 +51,8 @@ interface SignupFormProps { onError?: (error: string) => void; className?: string; redirectTo?: string; + initialEmail?: string; + showFooterLinks?: boolean; } const STEPS = [ @@ -65,22 +67,16 @@ const STEPS = [ description: "Used for service eligibility and delivery", }, { - key: "password", - title: "Create Password", - description: "Secure your account", - }, - { - key: "review", - title: "Review & Confirm", - description: "Verify your information", + key: "security", + title: "Security & Terms", + description: "Create a password and confirm agreements", }, ] as const; const STEP_FIELD_KEYS: Record<(typeof STEPS)[number]["key"], Array> = { account: ["firstName", "lastName", "email", "phone", "phoneCountryCode", "dateOfBirth", "gender"], address: ["address"], - password: ["password", "confirmPassword"], - review: ["acceptTerms"], + security: ["password", "confirmPassword", "acceptTerms", "marketingConsent"], }; const STEP_VALIDATION_SCHEMAS: Record<(typeof STEPS)[number]["key"], z.ZodTypeAny | undefined> = { @@ -96,18 +92,15 @@ const STEP_VALIDATION_SCHEMAS: Record<(typeof STEPS)[number]["key"], z.ZodTypeAn address: signupFormBaseSchema.pick({ address: true, }), - password: signupFormBaseSchema + security: signupFormBaseSchema .pick({ password: true, confirmPassword: true, + acceptTerms: true, }) .refine(data => data.password === data.confirmPassword, { message: "Passwords do not match", path: ["confirmPassword"], - }), - review: signupFormBaseSchema - .pick({ - acceptTerms: true, }) .refine(data => data.acceptTerms === true, { message: "You must accept the terms and conditions", @@ -115,12 +108,19 @@ const STEP_VALIDATION_SCHEMAS: Record<(typeof STEPS)[number]["key"], z.ZodTypeAn }), }; -export function SignupForm({ onSuccess, onError, className = "", redirectTo }: SignupFormProps) { +export function SignupForm({ + onSuccess, + onError, + className = "", + redirectTo, + initialEmail, + showFooterLinks = true, +}: SignupFormProps) { const searchParams = useSearchParams(); const { signup, loading, error, clearError } = useSignupWithRedirect({ redirectTo }); const [step, setStep] = useState(0); const redirectFromQuery = searchParams?.get("next") || searchParams?.get("redirect"); - const redirect = redirectTo || redirectFromQuery; + const redirect = getSafeRedirect(redirectTo || redirectFromQuery, ""); const redirectQuery = redirect ? `?redirect=${encodeURIComponent(redirect)}` : ""; const form = useZodForm({ @@ -128,7 +128,7 @@ export function SignupForm({ onSuccess, onError, className = "", redirectTo }: S initialValues: { firstName: "", lastName: "", - email: "", + email: initialEmail ?? "", phone: "", phoneCountryCode: "+81", company: "", @@ -248,8 +248,7 @@ export function SignupForm({ onSuccess, onError, className = "", redirectTo }: S const stepContent = [ , , - , - , + , ]; const steps = STEPS.map((s, i) => ({ @@ -276,26 +275,28 @@ export function SignupForm({ onSuccess, onError, className = "", redirectTo }: S )} -
-

- Already have an account?{" "} - - Sign in - -

-

- Existing customer?{" "} - - Migrate your account - -

-
+ {showFooterLinks && ( +
+

+ Already have an account?{" "} + + Sign in + +

+

+ Existing customer?{" "} + + Migrate your account + +

+
+ )} ); diff --git a/apps/portal/src/features/auth/components/SignupForm/steps/AccountStep.tsx b/apps/portal/src/features/auth/components/SignupForm/steps/AccountStep.tsx index 9fb9af15..a453a556 100644 --- a/apps/portal/src/features/auth/components/SignupForm/steps/AccountStep.tsx +++ b/apps/portal/src/features/auth/components/SignupForm/steps/AccountStep.tsx @@ -4,6 +4,7 @@ "use client"; +import { useState } from "react"; import { Input } from "@/components/atoms"; import { FormField } from "@/components/molecules/FormField/FormField"; @@ -29,6 +30,7 @@ interface AccountStepProps { export function AccountStep({ form }: AccountStepProps) { const { values, errors, touched, setValue, setTouchedField } = form; const getError = (field: string) => (touched[field] ? errors[field] : undefined); + const [showOptional, setShowOptional] = useState(false); return (
@@ -110,55 +112,69 @@ export function AccountStep({ form }: AccountStepProps) {
- {/* DOB + Gender (Optional WHMCS custom fields) */} -
- - setValue("dateOfBirth", e.target.value || undefined)} - onBlur={() => setTouchedField("dateOfBirth")} - autoComplete="bday" - /> - - - - - +
+
- {/* Company (Optional) */} - - setValue("company", e.target.value)} - onBlur={() => setTouchedField("company")} - placeholder="Company name" - autoComplete="organization" - /> - + {showOptional && ( +
+ {/* DOB + Gender (Optional WHMCS custom fields) */} +
+ + setValue("dateOfBirth", e.target.value || undefined)} + onBlur={() => setTouchedField("dateOfBirth")} + autoComplete="bday" + /> + + + + + +
+ + {/* Company (Optional) */} + + setValue("company", e.target.value)} + onBlur={() => setTouchedField("company")} + placeholder="Company name" + autoComplete="organization" + /> + +
+ )}
); } diff --git a/apps/portal/src/features/auth/components/SignupForm/steps/PasswordStep.tsx b/apps/portal/src/features/auth/components/SignupForm/steps/PasswordStep.tsx index b54b34e8..cf79d8a1 100644 --- a/apps/portal/src/features/auth/components/SignupForm/steps/PasswordStep.tsx +++ b/apps/portal/src/features/auth/components/SignupForm/steps/PasswordStep.tsx @@ -4,13 +4,20 @@ "use client"; +import Link from "next/link"; import { Input } from "@/components/atoms"; import { FormField } from "@/components/molecules/FormField/FormField"; import { checkPasswordStrength, getPasswordStrengthDisplay } from "@customer-portal/domain/auth"; interface PasswordStepProps { form: { - values: { email: string; password: string; confirmPassword: string }; + values: { + email: string; + password: string; + confirmPassword: string; + acceptTerms: boolean; + marketingConsent?: boolean; + }; errors: Record; touched: Record; setValue: (field: string, value: unknown) => void; @@ -99,6 +106,55 @@ export function PasswordStep({ form }: PasswordStepProps) { {passwordsMatch ? "✓ Passwords match" : "✗ Passwords do not match"}

)} + +
+ + {touched.acceptTerms && errors.acceptTerms && ( +

{errors.acceptTerms}

+ )} + + +
); } diff --git a/apps/portal/src/features/auth/components/index.ts b/apps/portal/src/features/auth/components/index.ts index 5742e6ad..b98495f1 100644 --- a/apps/portal/src/features/auth/components/index.ts +++ b/apps/portal/src/features/auth/components/index.ts @@ -8,4 +8,5 @@ export { SignupForm } from "./SignupForm/SignupForm"; export { PasswordResetForm } from "./PasswordResetForm/PasswordResetForm"; export { SetPasswordForm } from "./SetPasswordForm/SetPasswordForm"; export { LinkWhmcsForm } from "./LinkWhmcsForm/LinkWhmcsForm"; +export { InlineAuthSection } from "./InlineAuthSection/InlineAuthSection"; export { AuthLayout } from "@/components/templates/AuthLayout"; diff --git a/apps/portal/src/features/auth/hooks/use-auth.ts b/apps/portal/src/features/auth/hooks/use-auth.ts index 07782dcf..4d35d42f 100644 --- a/apps/portal/src/features/auth/hooks/use-auth.ts +++ b/apps/portal/src/features/auth/hooks/use-auth.ts @@ -41,10 +41,10 @@ export function useAuth() { // Enhanced login with redirect handling const login = useCallback( - async (credentials: LoginRequest) => { + async (credentials: LoginRequest, options?: { redirectTo?: string }) => { await loginAction(credentials); // Keep loading state active during redirect - const redirectTo = getPostLoginRedirect(searchParams); + const redirectTo = getPostLoginRedirect(searchParams, options?.redirectTo); router.push(redirectTo); // Note: loading will be cleared when the new page loads }, @@ -55,7 +55,7 @@ export function useAuth() { const signup = useCallback( async (data: SignupRequest, options?: { redirectTo?: string }) => { await signupAction(data); - const dest = options?.redirectTo ?? getPostLoginRedirect(searchParams); + const dest = getPostLoginRedirect(searchParams, options?.redirectTo); router.push(dest); }, [signupAction, router, searchParams] @@ -100,11 +100,11 @@ export function useAuth() { /** * Hook for login functionality */ -export function useLogin() { +export function useLogin(options?: { redirectTo?: string }) { const { login, loading, error, clearError } = useAuth(); return { - login, + login: (credentials: LoginRequest) => login(credentials, options), loading, error, clearError, diff --git a/apps/portal/src/features/auth/utils/route-protection.ts b/apps/portal/src/features/auth/utils/route-protection.ts index 1726f135..10c4ec8a 100644 --- a/apps/portal/src/features/auth/utils/route-protection.ts +++ b/apps/portal/src/features/auth/utils/route-protection.ts @@ -1,8 +1,18 @@ import type { ReadonlyURLSearchParams } from "next/navigation"; -export function getPostLoginRedirect(searchParams: ReadonlyURLSearchParams): string { - const dest = searchParams.get("next") || searchParams.get("redirect") || "/account"; - // prevent open redirects - if (dest.startsWith("http://") || dest.startsWith("https://")) return "/account"; +export function getSafeRedirect(candidate?: string | null, fallback = "/account"): string { + const dest = (candidate ?? "").trim(); + if (!dest) return fallback; + if (!dest.startsWith("/")) return fallback; + if (dest.startsWith("//")) return fallback; + if (dest.startsWith("http://") || dest.startsWith("https://")) return fallback; return dest; } + +export function getPostLoginRedirect( + searchParams: ReadonlyURLSearchParams, + override?: string | null +): string { + const candidate = override || searchParams.get("next") || searchParams.get("redirect"); + return getSafeRedirect(candidate, "/account"); +} diff --git a/apps/portal/src/features/catalog/components/base/AddressConfirmation.tsx b/apps/portal/src/features/catalog/components/base/AddressConfirmation.tsx index db5cd99e..4906c0d8 100644 --- a/apps/portal/src/features/catalog/components/base/AddressConfirmation.tsx +++ b/apps/portal/src/features/catalog/components/base/AddressConfirmation.tsx @@ -246,9 +246,13 @@ export function AddressConfirmation({ ? (getCountryName(address.country) ?? address.country) : null; + const showConfirmAction = + isInternetOrder && !addressConfirmed && !editing && billingInfo.isComplete; + const showEditAction = billingInfo.isComplete && !editing; + return wrap( <> -
+
@@ -261,12 +265,37 @@ export function AddressConfirmation({
-
- {/* Consistent status pill placement (right side) */} +
+ {showConfirmAction ? ( + + ) : null} + {showEditAction ? ( + + ) : null}
@@ -415,38 +444,6 @@ export function AddressConfirmation({ Please confirm this is the correct installation address for your internet service. )} - - {/* Action buttons */} -
- {/* Primary action when pending for Internet orders */} - {isInternetOrder && !addressConfirmed && !editing && ( - - )} - - {/* Edit button */} - {billingInfo.isComplete && !editing && ( - - )} -
) : (
diff --git a/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx b/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx index 4f627284..6cfb4893 100644 --- a/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx +++ b/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx @@ -27,6 +27,14 @@ interface InternetPlanCardProps { action?: { label: string; href: string }; /** Optional small prefix above pricing (e.g. "Starting from") */ pricingPrefix?: string; + /** Show tier badge (default: true) */ + showTierBadge?: boolean; + /** Show plan subtitle (default: true) */ + showPlanSubtitle?: boolean; + /** Show features list (default: true) */ + showFeatures?: boolean; + /** Prefer which label becomes the title when details exist */ + titlePriority?: "detail" | "base"; } // Tier-based styling using design tokens @@ -57,6 +65,10 @@ export function InternetPlanCard({ configureHref, action, pricingPrefix, + showTierBadge = true, + showPlanSubtitle = true, + showFeatures = true, + titlePriority = "detail", }: InternetPlanCardProps) { const router = useRouter(); const shopBasePath = useShopBasePath(); @@ -66,6 +78,11 @@ export function InternetPlanCard({ const isSilver = tier === "Silver"; const isDisabled = disabled && !IS_DEVELOPMENT; const { baseName: planBaseName, detail: planDetail } = parsePlanName(plan); + const hasDetail = Boolean(planDetail); + const showDetailAsTitle = titlePriority === "detail" && hasDetail; + const planTitle = showDetailAsTitle ? planDetail : planBaseName; + const planSubtitle = showDetailAsTitle ? planBaseName : hasDetail ? planDetail : null; + const planDescription = plan.catalogMetadata?.tierDescription || plan.description || null; const installationPrices = installations .map(installation => { @@ -163,24 +180,30 @@ export function InternetPlanCard({ {/* Header with badges */}
- - {isGold && } - {planDetail && } + {showTierBadge && ( + + )} + {showTierBadge && isGold && ( + + )}
{/* Plan name and description - Full width */}

- {planBaseName} + {planTitle}

- {plan.catalogMetadata?.tierDescription || plan.description ? ( -

- {plan.catalogMetadata?.tierDescription || plan.description} + {showPlanSubtitle && planSubtitle && ( +

+ {planSubtitle}

+ )} + {planDescription ? ( +

{planDescription}

) : null}
@@ -201,12 +224,14 @@ export function InternetPlanCard({
{/* Features */} -
-

- Your Plan Includes: -

-
    {renderPlanFeatures()}
-
+ {showFeatures && ( +
+

+ Your Plan Includes: +

+
    {renderPlanFeatures()}
+
+ )} {/* Action Button */}
+
+
+ {[ + { + title: "Pick a service", + description: "Internet, SIM, or VPN based on your needs.", + }, + { + title: "Confirm availability", + description: "We verify your address for internet plans.", + }, + { + title: "Configure and order", + description: "Choose options and complete checkout.", + }, + ].map(step => ( +
+
+ +
+
+
{step.title}
+
{step.description}
+
+
+ ))} +
+
+
data?.plans ?? [], [data?.plans]); const installations: InternetInstallationCatalogItem[] = useMemo( () => data?.installations ?? [], @@ -69,6 +76,10 @@ export function InternetPlansContainer() { user?.address?.postcode && (user?.address?.country || user?.address?.countryCode) ); + const autoEligibilityRequest = searchParams?.get("autoEligibilityRequest") === "1"; + const autoPlanSku = searchParams?.get("planSku"); + const [autoRequestStatus, setAutoRequestStatus] = useState("idle"); + const [autoRequestId, setAutoRequestId] = useState(null); const addressLabel = useMemo(() => { const a = user?.address; if (!a) return ""; @@ -84,6 +95,61 @@ export function InternetPlansContainer() { return eligibilityValue.trim(); }, [eligibilityValue, isEligible]); + useEffect(() => { + if (!autoEligibilityRequest) return; + if (autoRequestStatus !== "idle") return; + if (eligibilityLoading) return; + if (!isNotRequested) { + if (typeof window !== "undefined") { + window.history.replaceState(null, "", `${shopBasePath}/internet`); + } + return; + } + if (!hasServiceAddress) { + setAutoRequestStatus("missing_address"); + if (typeof window !== "undefined") { + window.history.replaceState(null, "", `${shopBasePath}/internet`); + } + return; + } + + const submit = async () => { + setAutoRequestStatus("submitting"); + try { + const notes = autoPlanSku + ? `Requested after signup. Selected plan SKU: ${autoPlanSku}` + : "Requested after signup."; + const result = await submitEligibilityRequest({ + address: user?.address ?? undefined, + notes, + }); + setAutoRequestId(result.requestId ?? null); + setAutoRequestStatus("submitted"); + await refetchEligibility(); + } catch (err) { + void err; + setAutoRequestStatus("failed"); + } finally { + if (typeof window !== "undefined") { + window.history.replaceState(null, "", `${shopBasePath}/internet`); + } + } + }; + + void submit(); + }, [ + autoEligibilityRequest, + autoPlanSku, + autoRequestStatus, + eligibilityLoading, + refetchEligibility, + submitEligibilityRequest, + hasServiceAddress, + isNotRequested, + shopBasePath, + user?.address, + ]); + const getEligibilityIcon = (offeringType?: string) => { const lower = (offeringType || "").toLowerCase(); if (lower.includes("home")) return ; @@ -165,16 +231,36 @@ export function InternetPlansContainer() { - {eligibilityQuery.isLoading ? ( + {eligibilityLoading ? (
Checking availability…

- We’re verifying whether our service is available at your residence. + We're verifying whether our service is available at your residence. +

+
+ ) : autoRequestStatus === "submitting" ? ( +
+
+ + Submitting availability request + +
+

+ We're sending your request now. +

+
+ ) : autoRequestStatus === "submitted" ? ( +
+
+ Availability review in progress +
+

+ We've received your request and will notify you when the review is complete.

) : isNotRequested ? ( @@ -221,49 +307,81 @@ export function InternetPlansContainer() { ) : null}
- {isNotRequested && ( - -
-

- Our team will verify NTT serviceability and update your eligible offerings. We’ll - notify you when review is complete. -

- {hasServiceAddress ? ( - - ) : ( - - )} -
+ {autoRequestStatus === "submitting" && ( + + We're sending your request now. You'll see updated eligibility once the review begins. )} + {autoRequestStatus === "submitted" && ( + + We've received your availability request. Our team will investigate and notify you when + the review is complete. + {autoRequestId && ( +
+ Request ID: {autoRequestId} +
+ )} +
+ )} + + {autoRequestStatus === "failed" && ( + + Please try again below or contact support if this keeps happening. + + )} + + {autoRequestStatus === "missing_address" && ( + + Add your service address so we can submit the availability request. + + )} + + {isNotRequested && + autoRequestStatus !== "submitting" && + autoRequestStatus !== "submitted" && ( + +
+

+ Our team will verify NTT serviceability and update your eligible offerings. We’ll + notify you when review is complete. +

+ {hasServiceAddress ? ( + + ) : ( + + )} +
+
+ )} + {isPending && (
@@ -347,6 +465,7 @@ export function InternetPlansContainer() {
+
+
+ {[ + { + title: "Pick a service", + description: "Internet, SIM, or VPN based on your needs.", + }, + { + title: "Create an account", + description: "Confirm your address and unlock eligibility checks.", + }, + { + title: "Configure and order", + description: "Choose your plan and complete checkout.", + }, + ].map(step => ( +
+
+ +
+
+
{step.title}
+
{step.description}
+
+
+ ))} +
+
+
("signup"); const redirectTo = planSku - ? `/account/shop/internet/configure?planSku=${encodeURIComponent(planSku)}` - : "/account/shop/internet"; - - if (isLoading) { - return ( -
- -
- - -
-
- ); - } - - if (!plan) { - return ( -
- - - The selected plan could not be found. Please go back and select a plan. - -
- ); - } + ? `/account/shop/internet?autoEligibilityRequest=1&planSku=${encodeURIComponent(planSku)}` + : "/account/shop/internet?autoEligibilityRequest=1"; return ( <> -
+
- {/* Plan Summary Card */} -
-
-
-
- +
+
+
+
+
+ +
+
+
+

+ We'll check availability for your address +

+

+ This request unlocks the internet plans that match your building type and local + infrastructure. +

-
-

{plan.name}

- {plan.description && ( -

{plan.description}

- )} -
- {plan.internetPlanTier && ( - - {plan.internetPlanTier} Tier - - )} - {plan.internetOfferingType && ( - - {plan.internetOfferingType} - - )} -
-
- -
-
-
- {/* Plan Features */} - {plan.catalogMetadata?.features && plan.catalogMetadata.features.length > 0 && (

- Plan Includes: + What happens next

-
    - {plan.catalogMetadata.features.slice(0, 6).map((feature, index) => { - const [label, detail] = feature.split(":"); - return ( -
  • - - - {label.trim()} - {detail && ( - <> - : {detail.trim()} - - )} - -
  • - ); - })} +
      +
    • + + + Create your account and confirm your service address. + +
    • +
    • + + + We submit an availability request to our team. + +
    • +
    • + + + You'll be notified when your personalized plans are ready. + +
- )} -
- - {/* Auth Prompt */} -
-
-

Ready to get started?

-

- Create an account to verify service availability for your address and complete your - order. We'll guide you through the setup process. -

-
- - -
- -
-
-
-
Verify Availability
-
Check service at your address
-
-
-
Personalized Plans
-
See plans tailored to you
-
-
-
Secure Ordering
-
Complete your order safely
-
-
-
+
- - setAuthModalOpen(false)} - initialMode={authMode} - redirectTo={redirectTo} - title="Create your account" - description="We'll verify service availability for your address, then show personalized internet plans and configuration options." - /> ); } diff --git a/apps/portal/src/features/catalog/views/PublicInternetPlans.tsx b/apps/portal/src/features/catalog/views/PublicInternetPlans.tsx index 30b640ec..933a6d04 100644 --- a/apps/portal/src/features/catalog/views/PublicInternetPlans.tsx +++ b/apps/portal/src/features/catalog/views/PublicInternetPlans.tsx @@ -13,7 +13,6 @@ import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink"; import { CatalogHero } from "@/features/catalog/components/base/CatalogHero"; import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath"; -import { Button } from "@/components/atoms/button"; import { InternetImportantNotes } from "@/features/catalog/components/internet/InternetImportantNotes"; /** @@ -75,7 +74,7 @@ export function PublicInternetPlansView() {
-
+
{Array.from({ length: 6 }).map((_, i) => (

- Prices shown are the Silver tier so - you can compare starting prices. Create an account to check internet availability for - your residence and unlock personalized plan options. + Compare starting prices for each internet type. Create an account to check availability + for your residence and unlock personalized plan options.

{offeringTypes.map(type => ( @@ -127,31 +125,27 @@ export function PublicInternetPlansView() {
))}
-
- - -
{silverPlans.length > 0 ? ( <> -
+
{silverPlans.map(plan => (
))} diff --git a/apps/portal/src/features/catalog/views/PublicSimConfigure.tsx b/apps/portal/src/features/catalog/views/PublicSimConfigure.tsx index 8bf69741..0b2b9c7b 100644 --- a/apps/portal/src/features/catalog/views/PublicSimConfigure.tsx +++ b/apps/portal/src/features/catalog/views/PublicSimConfigure.tsx @@ -1,15 +1,13 @@ "use client"; -import { useState } from "react"; import { useSearchParams } from "next/navigation"; import { DevicePhoneMobileIcon, CheckIcon } from "@heroicons/react/24/outline"; import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; -import { Button } from "@/components/atoms/button"; import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink"; import { CatalogHero } from "@/features/catalog/components/base/CatalogHero"; import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath"; import { useSimPlan } from "@/features/catalog/hooks"; -import { AuthModal } from "@/features/auth/components/AuthModal/AuthModal"; +import { InlineAuthSection } from "@/features/auth/components/InlineAuthSection/InlineAuthSection"; import { CardPricing } from "@/features/catalog/components/base/CardPricing"; import { Skeleton } from "@/components/atoms/loading-skeleton"; @@ -24,8 +22,6 @@ export function PublicSimConfigureView() { const searchParams = useSearchParams(); const planSku = searchParams?.get("planSku"); const { plan, isLoading } = useSimPlan(planSku || undefined); - const [authModalOpen, setAuthModalOpen] = useState(false); - const [authMode, setAuthMode] = useState<"signup" | "login">("signup"); const redirectTarget = planSku ? `/account/shop/sim/configure?planSku=${encodeURIComponent(planSku)}` @@ -56,7 +52,7 @@ export function PublicSimConfigureView() { return ( <> -
+
- {/* Plan Summary Card */} -
-
-
-
- -
-
-
-

{plan.name}

- {plan.description && ( -

{plan.description}

- )} -
- {plan.simPlanType && ( - - {plan.simPlanType} - - )} -
-
- -
-
-
- - {/* Plan Details */} - {(plan.simDataSize || plan.description) && ( -
-

- Plan Details: -

-
    - {plan.simDataSize && ( -
  • - - - Data: {plan.simDataSize} - -
  • - )} - {plan.simPlanType && ( -
  • - - - Type: {plan.simPlanType} - -
  • - )} - {plan.simHasFamilyDiscount && ( -
  • - - - Family Discount Available - -
  • - )} - {plan.billingCycle && ( -
  • - - - Billing:{" "} - {plan.billingCycle} - -
  • - )} -
-
- )} -
- - {/* Auth Prompt */} -
-
-

Ready to order?

-

- Create an account to complete your SIM order. You'll need to add a payment method - and complete identity verification. -

-
- -
- - -
- -
-
-
-
Secure Payment
-
Add payment method safely
-
-
-
- Identity Verification +
+ {/* Plan Summary Card */} +
+
+
+
+
-
Complete verification process
-
-
Order Management
-
Track your order status
+
+

{plan.name}

+ {plan.description && ( +

{plan.description}

+ )} +
+ {plan.simPlanType && ( + + {plan.simPlanType} + + )} +
+
+ +
+ + {/* Plan Details */} + {(plan.simDataSize || plan.description) && ( +
+

+ Plan Details: +

+
    + {plan.simDataSize && ( +
  • + + + Data:{" "} + {plan.simDataSize} + +
  • + )} + {plan.simPlanType && ( +
  • + + + Type:{" "} + {plan.simPlanType} + +
  • + )} + {plan.simHasFamilyDiscount && ( +
  • + + + + Family Discount Available + + +
  • + )} + {plan.billingCycle && ( +
  • + + + Billing:{" "} + {plan.billingCycle} + +
  • + )} +
+
+ )}
+ +
- - setAuthModalOpen(false)} - initialMode={authMode} - redirectTo={redirectTarget} - title="Create your account" - description="Ordering requires a payment method and identity verification." - /> ); } diff --git a/apps/portal/src/features/catalog/views/PublicSimPlans.tsx b/apps/portal/src/features/catalog/views/PublicSimPlans.tsx index affb9711..af374111 100644 --- a/apps/portal/src/features/catalog/views/PublicSimPlans.tsx +++ b/apps/portal/src/features/catalog/views/PublicSimPlans.tsx @@ -10,13 +10,13 @@ import { } from "@heroicons/react/24/outline"; import { Skeleton } from "@/components/atoms/loading-skeleton"; import { Button } from "@/components/atoms/button"; -import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; import { useSimCatalog } from "@/features/catalog/hooks"; import type { SimCatalogProduct } from "@customer-portal/domain/catalog"; import { SimPlanTypeSection } from "@/features/catalog/components/sim/SimPlanTypeSection"; import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink"; import { CatalogHero } from "@/features/catalog/components/base/CatalogHero"; import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; interface PlansByType { DataOnly: SimCatalogProduct[]; @@ -108,33 +108,6 @@ export function PublicSimPlansView() { description="Browse plan options now. Create an account to order, manage billing, and complete verification." /> - -
-

- To place a SIM order you’ll need an account, a payment method, and identity - verification. -

-
- - -
-
-
-
-
+
{activeTab === "data-voice" && (
(null); + const [openingPaymentPortal, setOpeningPaymentPortal] = useState(false); + const paymentToastTimeoutRef = useRef(null); const orderType: OrderTypeValue | null = useMemo(() => { if (!cartItem?.orderType) return null; @@ -140,6 +144,30 @@ export function AccountCheckoutContainer() { const residenceSubmitted = !isSimOrder || residenceStatus === "pending" || residenceStatus === "verified"; + 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 })); + paymentToastTimeoutRef.current = null; + }, 2200); + }, + [paymentRefresh] + ); + + useEffect(() => { + return () => { + if (paymentToastTimeoutRef.current) { + clearTimeout(paymentToastTimeoutRef.current); + } + }; + }, []); + const formatDateTime = useCallback((iso?: string | null) => { if (!iso) return null; const date = new Date(iso); @@ -201,6 +229,27 @@ export function AccountCheckoutContainer() { } }, [checkoutSessionId, clear, isSimOrder, pathname, router, searchParams]); + const handleManagePayment = useCallback(async () => { + if (openingPaymentPortal) return; + setOpeningPaymentPortal(true); + + try { + const response = await apiClient.POST("/api/auth/sso-link", { + body: { destination: "index.php?rp=/account/paymentmethods" }, + }); + const data = ssoLinkResponseSchema.parse(response.data); + if (!data.url) { + throw new Error("No payment portal URL returned"); + } + window.open(data.url, "_blank", "noopener,noreferrer"); + } catch (error) { + const message = error instanceof Error ? error.message : "Unable to open the payment portal"; + showPaymentToast(message, "error"); + } finally { + setOpeningPaymentPortal(false); + } + }, [openingPaymentPortal, showPaymentToast]); + if (!cartItem || !orderType) { const shopHref = pathname.startsWith("/account") ? "/account/shop" : "/shop"; return ( @@ -351,7 +400,14 @@ export function AccountCheckoutContainer() { right={
{hasPaymentMethod ? : undefined} -
@@ -374,7 +430,13 @@ export function AccountCheckoutContainer() { > Check Again -
@@ -414,7 +476,13 @@ export function AccountCheckoutContainer() { > Check Again -
diff --git a/docs/salesforce/OPPORTUNITY-LIFECYCLE-GUIDE.md b/docs/salesforce/OPPORTUNITY-LIFECYCLE-GUIDE.md new file mode 100644 index 00000000..d2039e3b --- /dev/null +++ b/docs/salesforce/OPPORTUNITY-LIFECYCLE-GUIDE.md @@ -0,0 +1,491 @@ +# Opportunity Lifecycle Management Guide + +This guide documents the Salesforce Opportunity integration for service lifecycle tracking. + +## Table of Contents + +1. [Overview](#overview) +2. [Existing Field Architecture](#existing-field-architecture) +3. [Opportunity Matching Rules](#opportunity-matching-rules) +4. [Internet Eligibility Flow](#internet-eligibility-flow) +5. [ID Verification Flow](#id-verification-flow) +6. [Order Placement Flow](#order-placement-flow) +7. [Service Activation Flow](#service-activation-flow) +8. [Cancellation Flow](#cancellation-flow) +9. [Implementation Checklist](#implementation-checklist) + +--- + +## Overview + +### What Customers See vs What's Internal + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ INTERNAL ONLY (Sales/CS Pipeline) │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Opportunity Stages: Introduction, Ready (before Order placed) │ +│ Application Stages: INTRO-1, UNRESPONSIVE-1, etc. (CS workflow) │ +│ Eligibility fields on Account (eligibility status) │ +│ ID Verification fields on Account (verification status) │ +│ │ +│ These are for internal tracking - customer sees results, not fields. │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────────────┐ +│ CUSTOMER-FACING (Portal) │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. ELIGIBILITY STATUS (from Account field) │ +│ └─ "Checking...", "Eligible", "Not Eligible" │ +│ │ +│ 2. ID VERIFICATION STATUS (from Account field) │ +│ └─ "Pending", "Verified", "Rejected" │ +│ │ +│ 3. ORDER TRACKING (from Salesforce Order) │ +│ └─ After order placed, before WHMCS activates │ +│ │ +│ 4. SERVICE PAGE (from WHMCS) │ +│ └─ After service is active │ +│ │ +│ 5. CANCELLATION STATUS (from Opportunity) │ +│ └─ End date, return deadline, kit status │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Existing Field Architecture + +### Account Fields (Already Exist) + +**Internet Eligibility Fields:** + +| Field | API Name | Purpose | +| ----------------- | ------------------------------------------- | ----------------------------- | +| Eligibility Value | `Internet_Eligibility__c` | The actual eligibility result | +| Status | `Internet_Eligibility_Status__c` | Pending, Checked | +| Requested At | `Internet_Eligibility_Request_Date_Time__c` | When request was made | +| Checked At | `Internet_Eligibility_Checked_Date_Time__c` | When eligibility was checked | +| Notes | `Internet_Eligibility_Notes__c` | Agent notes | +| Case ID | `Internet_Eligibility_Case_Id__c` | Linked Case for request | + +**ID Verification Fields:** + +| Field | API Name | Purpose | +| ----------------- | ---------------------------------------- | --------------------------- | +| Status | `Id_Verification_Status__c` | Pending, Verified, Rejected | +| Submitted At | `Id_Verification_Submitted_Date_Time__c` | When submitted | +| Verified At | `Id_Verification_Verified_Date_Time__c` | When verified | +| Notes | `Id_Verification_Note__c` | Agent notes | +| Rejection Message | `Id_Verification_Rejection_Message__c` | Reason for rejection | + +### Opportunity Fields (Existing) + +| Field | API Name | Purpose | +| ---------------------- | ------------------------------------- | --------------------------------------------------------------- | +| Stage | `StageName` | Introduction, Ready, Post Processing, Active, △Cancelling, etc. | +| Commodity Type | `CommodityType` | Personal SonixNet Home Internet, SIM, VPN | +| Application Stage | `Application_Stage__c` | INTRO-1 (for portal) | +| Cancellation Notice | `CancellationNotice__c` | 有, 未, 不要, 移転 | +| Scheduled Cancellation | `ScheduledCancellationDateAndTime__c` | End of cancellation month | +| Line Return Status | `LineReturn__c` | NotYet, SentKit, Returned, etc. | + +### New Opportunity Fields (To Create) + +| Field | API Name | Type | Purpose | +| ---------------- | --------------------- | -------- | -------------------------------- | +| Portal Source | `Portal_Source__c` | Picklist | How Opportunity was created | +| WHMCS Service ID | `WHMCS_Service_ID__c` | Number | Link to WHMCS after provisioning | + +### Order Fields (Existing) + +| Field | API Name | Purpose | +| ----------- | --------------- | -------------------------- | +| Opportunity | `OpportunityId` | Links Order TO Opportunity | + +### Case Fields (Existing) + +| Field | API Name | Purpose | +| ----------- | --------------- | ------------------------- | +| Opportunity | `OpportunityId` | Links Case TO Opportunity | + +**Key Principle:** Cases and Orders link TO Opportunity, not vice versa. + +--- + +## Opportunity Matching Rules + +### When to Find vs Create Opportunity + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ OPPORTUNITY MATCHING RULES │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ INTERNET ELIGIBILITY REQUEST: │ +│ ───────────────────────────── │ +│ 1. Check if Account already has eligibility status set │ +│ └─ If already "Eligible" or "Not Eligible" → Don't create Opp │ +│ │ +│ 2. Find existing Opportunity: │ +│ └─ WHERE AccountId = ? │ +│ AND CommodityType IN ('Personal SonixNet Home Internet', │ +│ 'Corporate SonixNet Home Internet') │ +│ AND StageName = 'Introduction' │ +│ AND IsClosed = false │ +│ │ +│ 3. If found → Use existing, create Case linked to it │ +│ If not → Create new Opportunity, then create Case linked to it │ +│ │ +│ ORDER PLACEMENT: │ +│ ───────────────── │ +│ 1. Find existing Opportunity: │ +│ └─ WHERE AccountId = ? │ +│ AND CommodityType = ? (based on order type) │ +│ AND StageName IN ('Introduction', 'Ready') │ +│ AND IsClosed = false │ +│ │ +│ 2. If found → Use existing, update stage to 'Post Processing' │ +│ If not → Create new Opportunity with stage 'Post Processing' │ +│ │ +│ 3. Create Order with Order.OpportunityId = Opportunity.Id │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### Stage Matching by Flow + +| Flow | Match Stages | If Not Found | +| -------------------- | ------------------- | ------------------------------- | +| Internet Eligibility | Introduction only | Create with Introduction | +| Order Placement | Introduction, Ready | Create with Post Processing | +| Provisioning | Post Processing | Error (should exist from order) | +| Cancellation | Active | Error (service must be active) | + +--- + +## Internet Eligibility Flow + +### Complete Flow Diagram + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ INTERNET ELIGIBILITY FLOW │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. CUSTOMER ENTERS ADDRESS │ +│ └─ Portal: POST /api/catalog/internet/eligibility-request │ +│ │ +│ 2. CHECK IF ELIGIBILITY ALREADY KNOWN │ +│ └─ Query: SELECT Internet_Eligibility__c FROM Account │ +│ └─ If already set → Return cached result, no action needed │ +│ │ +│ 3. FIND OR CREATE OPPORTUNITY │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ Query: │ │ +│ │ SELECT Id FROM Opportunity │ │ +│ │ WHERE AccountId = ? │ │ +│ │ AND CommodityType IN ('Personal SonixNet Home Internet', │ │ +│ │ 'Corporate SonixNet Home Internet') │ │ +│ │ AND StageName = 'Introduction' │ │ +│ │ AND IsClosed = false │ │ +│ │ │ │ +│ │ If found → Use existing │ │ +│ │ If not → Create new: │ │ +│ │ - Stage: Introduction │ │ +│ │ - CommodityType: Personal SonixNet Home Internet │ │ +│ │ - Portal_Source__c: Portal - Internet Eligibility Request │ │ +│ │ - Application_Stage__c: INTRO-1 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 4. CREATE CASE (linked to Opportunity) │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ Case.Type = "Eligibility Check" │ │ +│ │ Case.AccountId = customer's account │ │ +│ │ Case.OpportunityId = opportunity from step 3 ←── THE LINK │ │ +│ │ Case.Subject = "Internet Eligibility - {Address}" │ │ +│ │ Case.Description = address details, postal code, etc. │ │ +│ │ Case.Status = "New" │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 5. UPDATE ACCOUNT │ +│ └─ Internet_Eligibility_Status__c = "Pending" │ +│ └─ Internet_Eligibility_Request_Date_Time__c = now() │ +│ └─ Internet_Eligibility_Case_Id__c = Case.Id │ +│ │ +│ 6. CS PROCESSES CASE │ +│ └─ Checks with NTT / provider │ +│ └─ Updates Account: │ +│ - Internet_Eligibility__c = result │ +│ - Internet_Eligibility_Status__c = "Checked" │ +│ - Internet_Eligibility_Checked_Date_Time__c = now() │ +│ └─ Updates Opportunity: │ +│ - Eligible → Stage: Ready │ +│ - Not Eligible → Stage: Void │ +│ │ +│ 7. PORTAL DETECTS CHANGE (via CDC or polling) │ +│ └─ Shows eligibility result to customer │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### What Customer Sees + +| Account Field State | Portal Shows | +| ---------------------------- | -------------------------------------- | +| Status = null | Address input form | +| Status = "Pending" | "Checking your address..." spinner | +| Eligibility = eligible value | Plan selection enabled | +| Eligibility = not eligible | "Sorry, service not available" message | + +--- + +## ID Verification Flow + +### Complete Flow Diagram + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ ID VERIFICATION (eKYC) FLOW │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ WHEN: During checkout registration or before order placement │ +│ │ +│ 1. CHECK IF ALREADY VERIFIED │ +│ └─ Query: SELECT Id_Verification_Status__c FROM Account │ +│ └─ If "Verified" → Skip verification, proceed to checkout │ +│ │ +│ 2. CUSTOMER UPLOADS ID DOCUMENTS │ +│ └─ Portal: POST /api/verification/submit │ +│ └─ eKYC service processes documents │ +│ │ +│ 3. UPDATE ACCOUNT │ +│ └─ Id_Verification_Status__c = "Pending" │ +│ └─ Id_Verification_Submitted_Date_Time__c = now() │ +│ │ +│ 4. IF eKYC AUTO-APPROVED │ +│ └─ Id_Verification_Status__c = "Verified" │ +│ └─ Id_Verification_Verified_Date_Time__c = now() │ +│ └─ Customer can proceed to order immediately │ +│ │ +│ 5. IF MANUAL REVIEW NEEDED │ +│ └─ Create Case for CS review │ +│ └─ Case.Type = "ID Verification" │ +│ └─ Case.OpportunityId = linked Opportunity (if exists) │ +│ └─ CS reviews and updates Account │ +│ │ +│ 6. IF REJECTED │ +│ └─ Id_Verification_Status__c = "Rejected" │ +│ └─ Id_Verification_Rejection_Message__c = reason │ +│ └─ Customer must resubmit │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### What Customer Sees + +| Account Field State | Portal Shows | +| ------------------- | ------------------------------- | +| Status = null | ID upload form | +| Status = "Pending" | "Verifying your identity..." | +| Status = "Verified" | Proceed to checkout enabled | +| Status = "Rejected" | Error message + resubmit option | + +--- + +## Order Placement Flow + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ ORDER PLACEMENT FLOW │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ PREREQUISITES: │ +│ - Internet: Eligibility = Eligible (Account field) │ +│ - SIM: ID Verification = Verified (Account field) │ +│ │ +│ 1. CUSTOMER SUBMITS ORDER │ +│ └─ Portal: POST /api/orders │ +│ │ +│ 2. FIND OR CREATE OPPORTUNITY │ +│ └─ Query for existing (Introduction or Ready stage) │ +│ └─ If found → Use existing │ +│ └─ If not → Create with stage 'Post Processing' │ +│ │ +│ 3. CREATE SALESFORCE ORDER │ +│ └─ Order.OpportunityId = Opportunity.Id ←── THE LINK │ +│ └─ Order.Status = "Pending Review" │ +│ │ +│ 4. UPDATE OPPORTUNITY │ +│ └─ Stage = "Post Processing" │ +│ │ +│ 5. CUSTOMER SEES ORDER TRACKING │ +│ └─ "Your order is being processed" │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Service Activation Flow + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ SERVICE ACTIVATION FLOW │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. AGENT APPROVES ORDER │ +│ └─ Order.Status = "Approved" (triggers CDC) │ +│ │ +│ 2. BFF PROVISIONS TO WHMCS │ +│ └─ Calls WHMCS AddOrder API │ +│ └─ Passes OpportunityId as custom field ←── WHMCS GETS OPP ID │ +│ └─ WHMCS returns serviceId │ +│ │ +│ 3. UPDATE OPPORTUNITY │ +│ └─ WHMCS_Service_ID__c = serviceId ←── OPP GETS WHMCS ID │ +│ └─ Stage = "Active" │ +│ │ +│ 4. BIDIRECTIONAL LINK COMPLETE │ +│ └─ Opportunity.WHMCS_Service_ID__c → WHMCS Service │ +│ └─ WHMCS Service.OpportunityId → Opportunity │ +│ │ +│ 5. CUSTOMER SEES SERVICE PAGE │ +│ └─ Portal queries WHMCS → gets OpportunityId → queries Opp │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Cancellation Flow + +### Always Create Case + +For **every** cancellation request, create a Case (notification to CS): + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ CANCELLATION FLOW │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. CUSTOMER SUBMITS CANCELLATION FORM │ +│ └─ Selects month (25th rule applies) │ +│ └─ Enters comments, alternative email (optional) │ +│ │ +│ 2. CREATE CANCELLATION CASE │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ Case.Type = "Cancellation Request" │ │ +│ │ Case.AccountId = customer's account │ │ +│ │ Case.Subject = "Cancellation Request - {Product}" │ │ +│ │ Case.Description = ALL form data: │ │ +│ │ - WHMCS Service ID │ │ +│ │ - Cancellation month │ │ +│ │ - Alternative email (if provided) │ │ +│ │ - Customer comments (if provided) │ │ +│ │ Case.OpportunityId = linked Opportunity (if found) │ │ +│ │ Case.Status = "New" │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 3. IF OPPORTUNITY IS LINKED (via WHMCS_Service_ID__c) │ +│ └─ Update Opportunity: │ +│ - Stage = "△Cancelling" │ +│ - ScheduledCancellationDateAndTime__c = end of month │ +│ - CancellationNotice__c = "有" │ +│ - LineReturn__c = "NotYet" │ +│ │ +│ 4. IF NOT LINKED (Legacy) │ +│ └─ Case contains all info for CS to process │ +│ └─ CS will manually find and update correct Opportunity │ +│ │ +│ 5. CUSTOMER SEES │ +│ └─ If linked: Cancellation status from Opportunity │ +│ └─ If not linked: "Request received, we'll confirm by email" │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### The 25th Rule + +``` +TODAY'S DATE AVAILABLE CANCELLATION MONTHS +───────────────────────────────────────────────────────── +Before 25th of month → Can select THIS month or later +On/After 25th → Must select NEXT month or later +``` + +### Cancellation Data Location + +| Data | Where It Goes | Why | +| ----------------- | -------------------- | ------------------------- | +| Scheduled date | Opportunity | Lifecycle tracking | +| Notice status | Opportunity | Lifecycle tracking | +| Return status | Opportunity | Lifecycle tracking | +| Customer comments | **Case** | Not needed on Opp | +| Alternative email | **Case** | Not needed on Opp | +| WHMCS Service ID | Case (for reference) | Helps CS identify service | + +--- + +## Implementation Checklist + +### Salesforce Admin Tasks + +**Existing Fields (No Changes):** + +- [x] Opportunity Stage picklist +- [x] CommodityType field +- [x] Application_Stage\_\_c +- [x] CancellationNotice\_\_c +- [x] LineReturn\_\_c +- [x] ScheduledCancellationDateAndTime\_\_c +- [x] Account Internet eligibility fields +- [x] Account ID verification fields +- [x] Case.OpportunityId (standard lookup) +- [x] Order.OpportunityId (standard lookup) + +**New Fields to Create on Opportunity:** + +- [ ] `Portal_Source__c` picklist +- [ ] `WHMCS_Service_ID__c` number field + +### WHMCS Admin Tasks + +- [ ] Create `OpportunityId` custom field on Services/Hosting +- [ ] Document custom field ID for AddOrder API + +### BFF Development Tasks + +**Completed:** + +- [x] Domain types in `packages/domain/opportunity/` +- [x] Field map configuration +- [x] `SalesforceOpportunityService` +- [x] Cancellation deadline helpers (25th rule) + +**Pending:** + +- [ ] Register services in Salesforce module +- [ ] Integrate Opportunity matching into eligibility flow +- [ ] Integrate Opportunity matching into order placement +- [ ] Update order provisioning to pass OpportunityId to WHMCS +- [ ] Update WHMCS mapper to read OpportunityId custom field +- [ ] Create cancellation Case service +- [ ] Integrate Case creation into cancellation flow + +### Frontend Tasks + +- [ ] Order tracking page (from Salesforce Order) +- [ ] Service page with cancellation status +- [ ] Cancellation form with 25th deadline logic + +--- + +## Related Documentation + +- [Salesforce-WHMCS Mapping](./SALESFORCE-WHMCS-MAPPING-REFERENCE.md) +- [Order Provisioning](../orders/PORTAL-ORDERING-PROVISIONING.md) diff --git a/packages/domain/common/errors.ts b/packages/domain/common/errors.ts index 5b54f19b..c3e500fd 100644 --- a/packages/domain/common/errors.ts +++ b/packages/domain/common/errors.ts @@ -56,6 +56,7 @@ export const ErrorCode = { ORDER_ALREADY_PROCESSED: "BIZ_004", INSUFFICIENT_BALANCE: "BIZ_005", SERVICE_UNAVAILABLE: "BIZ_006", + LEGACY_ACCOUNT_EXISTS: "BIZ_007", // System Errors (SYS_*) INTERNAL_ERROR: "SYS_001", @@ -104,8 +105,12 @@ export const ErrorMessages: Record = { [ErrorCode.NOT_FOUND]: "The requested resource was not found.", // Business Logic - [ErrorCode.ACCOUNT_EXISTS]: "An account with this email already exists. Please sign in.", - [ErrorCode.ACCOUNT_ALREADY_LINKED]: "This billing account is already linked. Please sign in.", + [ErrorCode.ACCOUNT_EXISTS]: + "We couldn't create a new account with these details. Please sign in or contact support.", + [ErrorCode.ACCOUNT_ALREADY_LINKED]: + "This billing account is already linked to a portal account. Please sign in.", + [ErrorCode.LEGACY_ACCOUNT_EXISTS]: + "We couldn't create a new account with these details. Please transfer your account or contact support.", [ErrorCode.CUSTOMER_NOT_FOUND]: "Customer account not found. Please contact support.", [ErrorCode.ORDER_ALREADY_PROCESSED]: "This order has already been processed.", [ErrorCode.INSUFFICIENT_BALANCE]: "Insufficient account balance.", @@ -259,6 +264,13 @@ export const ErrorMetadata: Record = { shouldRetry: false, logLevel: "info", }, + [ErrorCode.LEGACY_ACCOUNT_EXISTS]: { + category: "business", + severity: "low", + shouldLogout: false, + shouldRetry: false, + logLevel: "info", + }, [ErrorCode.CUSTOMER_NOT_FOUND]: { category: "business", severity: "medium", diff --git a/packages/domain/index.ts b/packages/domain/index.ts index d63d0b57..fb7038a8 100644 --- a/packages/domain/index.ts +++ b/packages/domain/index.ts @@ -12,6 +12,7 @@ export * as Customer from "./customer/index.js"; export * as Dashboard from "./dashboard/index.js"; export * as Auth from "./auth/index.js"; export * as Mappings from "./mappings/index.js"; +export * as Opportunity from "./opportunity/index.js"; export * as Orders from "./orders/index.js"; export * as Payments from "./payments/index.js"; export * as Realtime from "./realtime/index.js"; diff --git a/packages/domain/opportunity/contract.ts b/packages/domain/opportunity/contract.ts new file mode 100644 index 00000000..6746d909 --- /dev/null +++ b/packages/domain/opportunity/contract.ts @@ -0,0 +1,739 @@ +/** + * Opportunity Domain - Contract + * + * Business types and constants for Salesforce Opportunity lifecycle management. + * Used to track customer journeys from interest through service cancellation. + * + * IMPORTANT: Stage values match existing Salesforce picklist values. + * See docs/salesforce/OPPORTUNITY-LIFECYCLE-GUIDE.md for complete documentation. + */ + +// ============================================================================ +// Opportunity Stage Constants (Existing Salesforce Values) +// ============================================================================ + +/** + * Opportunity lifecycle stages - these match existing Salesforce picklist values + * + * Portal Flow: Introduction -> Ready -> Post Processing -> Active -> △Cancelling -> 〇Cancelled + */ +export const OPPORTUNITY_STAGE = { + // Interest/Lead stages + INTRODUCTION: "Introduction", // Initial interest / eligibility pending (30%) + WIKI: "WIKI", // Low priority / informational (10%) + + // Ready to proceed + READY: "Ready", // Eligible / ready to order (60%) + + // Order/Processing stages + POST_PROCESSING: "Post Processing", // Order placed, processing (75%) + + // Active service + ACTIVE: "Active", // Service active (90%) + + // Cancellation stages + CANCELLING: "△Cancelling", // Cancellation pending (100%, Open) + CANCELLED: "〇Cancelled", // Successfully cancelled (100%, Closed/Won) + + // Completed/Closed stages + COMPLETED: "Completed", // Service completed normally (100%, Closed/Won) + VOID: "Void", // Lost / not eligible (0%, Closed/Lost) + PENDING: "Pending", // On hold / abandoned (0%, Closed/Lost) +} as const; + +export type OpportunityStageValue = (typeof OPPORTUNITY_STAGE)[keyof typeof OPPORTUNITY_STAGE]; + +// ============================================================================ +// Application Stage Constants (Existing Salesforce Values) +// ============================================================================ + +/** + * Application stage picklist (Application_Stage__c) + * Tracks application process step + * + * For portal, we use INTRO_1 (single-step intro, not multi-email legacy flow) + */ +export const APPLICATION_STAGE = { + INTRO_1: "INTRO-1", // Portal default - simple introduction + NA: "N/A", // Not applicable +} as const; + +export type ApplicationStageValue = (typeof APPLICATION_STAGE)[keyof typeof APPLICATION_STAGE]; + +// ============================================================================ +// Cancellation Notice Constants (Existing Salesforce Values) +// ============================================================================ + +/** + * Cancellation notice status (CancellationNotice__c) + * Tracks whether cancellation form has been received + */ +export const CANCELLATION_NOTICE = { + RECEIVED: "有", // Form received + NOT_YET: "未", // Not yet received (default) + NOT_REQUIRED: "不要", // Not required + TRANSFER: "移転", // Transferring (moving) +} as const; + +export type CancellationNoticeValue = + (typeof CANCELLATION_NOTICE)[keyof typeof CANCELLATION_NOTICE]; + +// ============================================================================ +// Line Return Status Constants (Existing Salesforce Values) +// ============================================================================ + +/** + * Rental equipment return status (LineReturn__c) + * Tracks equipment return process after cancellation + */ +export const LINE_RETURN_STATUS = { + NOT_YET: "NotYet", // Not yet initiated (default) + SENT_KIT: "SentKit", // Return kit sent to customer + PICKUP_SCHEDULED: "AS/Pickup予定", // Pickup scheduled + RETURNED_1: "Returned1", // Returned (variant 1) + RETURNED: "Returned2", // Returned + NTT_DISPATCH: "NTT派遣", // NTT dispatch + COMPENSATED: "Compensated", // Compensation fee charged + NA: "N/A", // Not applicable (no equipment) +} as const; + +export type LineReturnStatusValue = (typeof LINE_RETURN_STATUS)[keyof typeof LINE_RETURN_STATUS]; + +// ============================================================================ +// Commodity Type Constants (Existing Salesforce Field: CommodityType) +// ============================================================================ + +/** + * Commodity types - uses existing Salesforce CommodityType picklist + * Maps to WHMCS product categories + */ +export const COMMODITY_TYPE = { + // Internet types + PERSONAL_HOME_INTERNET: "Personal SonixNet Home Internet", + CORPORATE_HOME_INTERNET: "Corporate SonixNet Home Internet", + // SIM + SIM: "SIM", + // VPN + VPN: "VPN", + // Other (not used by portal) + TECH_SUPPORT: "Onsite Support", +} as const; + +export type CommodityTypeValue = (typeof COMMODITY_TYPE)[keyof typeof COMMODITY_TYPE]; + +/** + * Simplified product types for portal logic + * Maps multiple CommodityType values to simple categories + */ +export const OPPORTUNITY_PRODUCT_TYPE = { + INTERNET: "Internet", + SIM: "SIM", + VPN: "VPN", +} as const; + +export type OpportunityProductTypeValue = + (typeof OPPORTUNITY_PRODUCT_TYPE)[keyof typeof OPPORTUNITY_PRODUCT_TYPE]; + +/** + * Map CommodityType to simplified product type + */ +export function getCommodityTypeProductType( + commodityType: CommodityTypeValue +): OpportunityProductTypeValue | null { + switch (commodityType) { + case COMMODITY_TYPE.PERSONAL_HOME_INTERNET: + case COMMODITY_TYPE.CORPORATE_HOME_INTERNET: + return OPPORTUNITY_PRODUCT_TYPE.INTERNET; + case COMMODITY_TYPE.SIM: + return OPPORTUNITY_PRODUCT_TYPE.SIM; + case COMMODITY_TYPE.VPN: + return OPPORTUNITY_PRODUCT_TYPE.VPN; + default: + return null; + } +} + +/** + * Get default CommodityType for a product type + * Used when creating new Opportunities + * + * NOTE: Currently only Personal SonixNet Home Internet is used for Internet. + * Corporate can be added later when needed. + */ +export function getDefaultCommodityType( + productType: OpportunityProductTypeValue +): CommodityTypeValue { + switch (productType) { + case OPPORTUNITY_PRODUCT_TYPE.INTERNET: + // Always use Personal for now - Corporate support can be added later + return COMMODITY_TYPE.PERSONAL_HOME_INTERNET; + case OPPORTUNITY_PRODUCT_TYPE.SIM: + return COMMODITY_TYPE.SIM; + case OPPORTUNITY_PRODUCT_TYPE.VPN: + return COMMODITY_TYPE.VPN; + } +} + +/** + * Default commodity types used when creating opportunities from portal + * These are the only values the portal will create - agents can use others + */ +export const PORTAL_DEFAULT_COMMODITY_TYPES = { + INTERNET: COMMODITY_TYPE.PERSONAL_HOME_INTERNET, + SIM: COMMODITY_TYPE.SIM, + VPN: COMMODITY_TYPE.VPN, +} as const; + +// ============================================================================ +// Opportunity Source Constants +// ============================================================================ + +/** + * Sources from which Opportunities are created (Portal_Source__c) + */ +export const OPPORTUNITY_SOURCE = { + INTERNET_ELIGIBILITY: "Portal - Internet Eligibility Request", + SIM_CHECKOUT_REGISTRATION: "Portal - SIM Checkout Registration", + ORDER_PLACEMENT: "Portal - Order Placement", + AGENT_CREATED: "Agent Created", +} as const; + +export type OpportunitySourceValue = (typeof OPPORTUNITY_SOURCE)[keyof typeof OPPORTUNITY_SOURCE]; + +// ============================================================================ +// Opportunity Matching Constants +// ============================================================================ + +/** + * Stages considered "open" for matching purposes + * Opportunities in these stages can be linked to new orders + */ +export const OPEN_OPPORTUNITY_STAGES: OpportunityStageValue[] = [ + OPPORTUNITY_STAGE.INTRODUCTION, + OPPORTUNITY_STAGE.READY, + OPPORTUNITY_STAGE.POST_PROCESSING, + OPPORTUNITY_STAGE.ACTIVE, +]; + +/** + * Stages that indicate the Opportunity is closed + */ +export const CLOSED_OPPORTUNITY_STAGES: OpportunityStageValue[] = [ + OPPORTUNITY_STAGE.CANCELLED, + OPPORTUNITY_STAGE.COMPLETED, + OPPORTUNITY_STAGE.VOID, + OPPORTUNITY_STAGE.PENDING, +]; + +// ============================================================================ +// Cancellation Deadline Constants +// ============================================================================ + +/** + * Day of month by which cancellation form must be received + * If after this day, cancellation applies to next month + */ +export const CANCELLATION_DEADLINE_DAY = 25; + +/** + * Day of following month by which rental equipment must be returned + */ +export const RENTAL_RETURN_DEADLINE_DAY = 10; + +// ============================================================================ +// Business Types +// ============================================================================ + +/** + * Opportunity record as returned from Salesforce + */ +export interface OpportunityRecord { + id: string; + name: string; + accountId: string; + stage: OpportunityStageValue; + closeDate: string; + /** CommodityType - existing Salesforce field for product categorization */ + commodityType?: CommodityTypeValue; + /** Simplified product type - derived from commodityType */ + productType?: OpportunityProductTypeValue; + source?: OpportunitySourceValue; + applicationStage?: ApplicationStageValue; + isClosed: boolean; + + // Linked entities + // Note: Cases and Orders link TO Opportunity (not stored here) + // - Case.OpportunityId → for eligibility, ID verification, cancellation + // - Order.OpportunityId → for order tracking + whmcsServiceId?: number; + + // Cancellation fields (updated by CS when processing cancellation Case) + cancellationNotice?: CancellationNoticeValue; + scheduledCancellationDate?: string; + lineReturnStatus?: LineReturnStatusValue; + // NOTE: alternativeContactEmail and cancellationComments are on the Cancellation Case + + // Metadata + createdDate: string; + lastModifiedDate: string; +} + +/** + * Request to create a new Opportunity + * + * Note: Opportunity Name is auto-generated by Salesforce workflow, + * so we don't need to provide account name. The service will use + * a placeholder that gets overwritten by Salesforce. + */ +export interface CreateOpportunityRequest { + accountId: string; + productType: OpportunityProductTypeValue; + stage: OpportunityStageValue; + source: OpportunitySourceValue; + + /** Application stage, defaults to INTRO-1 */ + applicationStage?: ApplicationStageValue; + + /** Expected close date, defaults to 30 days from now */ + closeDate?: string; +} + +/** + * Request to update Opportunity stage + */ +export interface UpdateOpportunityStageRequest { + opportunityId: string; + stage: OpportunityStageValue; + + /** Optional: reason for stage change (for audit) */ + reason?: string; +} + +/** + * Cancellation form data from customer + */ +export interface CancellationFormData { + /** + * Selected cancellation month (YYYY-MM format) + * Service ends at end of this month + */ + cancellationMonth: string; + + /** Customer confirms they have read cancellation terms */ + confirmTermsRead: boolean; + + /** Customer confirms they understand month-end cancellation */ + confirmMonthEndCancellation: boolean; + + /** Optional alternative email for post-cancellation communication */ + alternativeEmail?: string; + + /** Optional customer comments/notes */ + comments?: string; +} + +/** + * Cancellation data to populate on Opportunity (transformed from form) + * Only core lifecycle fields - details go on Cancellation Case + */ +export interface CancellationOpportunityData { + /** End of cancellation month (YYYY-MM-DD format) */ + scheduledCancellationDate: string; + + /** Cancellation notice status (always 有 from portal) */ + cancellationNotice: CancellationNoticeValue; + + /** Initial line return status (always NotYet from portal) */ + lineReturnStatus: LineReturnStatusValue; +} + +/** + * Data to populate on Cancellation Case + * This contains all customer-provided details + */ +export interface CancellationCaseData { + /** Account ID */ + accountId: string; + + /** Linked Opportunity ID (if known) */ + opportunityId?: string; + + /** WHMCS Service ID for reference */ + whmcsServiceId: number; + + /** Product type (Internet, SIM, VPN) */ + productType: OpportunityProductTypeValue; + + /** Selected cancellation month (YYYY-MM) */ + cancellationMonth: string; + + /** End of cancellation month (YYYY-MM-DD) */ + cancellationDate: string; + + /** Optional alternative contact email */ + alternativeEmail?: string; + + /** Optional customer comments */ + comments?: string; +} + +/** + * Cancellation eligibility check result + */ +export interface CancellationEligibility { + /** Whether cancellation can be requested now */ + canCancel: boolean; + + /** Earliest month available for cancellation (YYYY-MM) */ + earliestCancellationMonth: string; + + /** Available cancellation months (up to 12 months ahead) */ + availableMonths: CancellationMonthOption[]; + + /** Deadline for requesting cancellation this month */ + currentMonthDeadline: string | null; + + /** If cannot cancel, the reason why */ + reason?: string; +} + +/** + * A month option for cancellation selection + */ +export interface CancellationMonthOption { + /** Value in YYYY-MM format */ + value: string; + + /** Display label (e.g., "January 2025") */ + label: string; + + /** End date of the month (service end date) */ + serviceEndDate: string; + + /** Rental return deadline (10th of following month) */ + rentalReturnDeadline: string; + + /** Whether this is the current month (may have deadline) */ + isCurrentMonth: boolean; +} + +/** + * Cancellation status for display in portal + */ +export interface CancellationStatus { + /** Current stage */ + stage: OpportunityStageValue; + + /** Whether cancellation is pending */ + isPending: boolean; + + /** Whether cancellation is complete */ + isComplete: boolean; + + /** Scheduled end date */ + scheduledEndDate?: string; + + /** Rental return status */ + rentalReturnStatus?: LineReturnStatusValue; + + /** Rental return deadline */ + rentalReturnDeadline?: string; + + /** Whether rental equipment needs to be returned */ + hasRentalEquipment: boolean; +} + +/** + * Result of Opportunity matching/resolution + */ +export interface OpportunityMatchResult { + /** The Opportunity ID (existing or newly created) */ + opportunityId: string; + + /** Whether a new Opportunity was created */ + wasCreated: boolean; + + /** Previous stage if updated */ + previousStage?: OpportunityStageValue; +} + +/** + * Lookup criteria for finding existing Opportunities + */ +export interface OpportunityLookupCriteria { + accountId: string; + productType: OpportunityProductTypeValue; + + /** Only match Opportunities in these stages */ + allowedStages?: OpportunityStageValue[]; +} + +// ============================================================================ +// Customer-Facing Service Display Model +// ============================================================================ + +/** + * Customer-facing service phase + * Only shows what's relevant to customers (no internal sales stages) + */ +export type CustomerServicePhase = + | "order" // Order placed, awaiting activation (has SF Order, no WHMCS) + | "active" // Service is live (has WHMCS service) + | "cancelling" // Cancellation pending + | "cancelled"; // Service ended + +/** + * Whether the WHMCS service is linked to an Opportunity + */ +export type ServiceLinkStatus = + | "linked" // Opportunity has WHMCS_Service_ID__c (full features) + | "unlinked"; // Legacy - WHMCS exists but Opp not linked + +/** + * Order tracking info (shown after order placed, before WHMCS activates) + * Source: Salesforce Order + */ +export interface OrderTrackingInfo { + /** Salesforce Order ID */ + orderId: string; + + /** Order number for display */ + orderNumber: string; + + /** Product being ordered */ + productName: string; + + /** Order status */ + status: string; + + /** When order was placed */ + orderDate: string; + + /** Expected activation date (estimated) */ + expectedActivation?: string; + + /** Progress steps */ + steps: Array<{ + label: string; + status: "completed" | "current" | "upcoming"; + }>; +} + +/** + * Service details from WHMCS (shown when service is active) + * This is the primary data source for customer service page + */ +export interface WhmcsServiceDetails { + /** WHMCS Service ID */ + whmcsServiceId: number; + + /** Product/plan name */ + productName: string; + + /** WHMCS status (Active, Suspended, etc.) */ + status: string; + + /** Service address (for Internet) */ + serviceAddress?: string; + + /** Monthly amount */ + monthlyAmount?: number; + + /** Currency code */ + currency?: string; + + /** Next billing date */ + nextDueDate?: string; + + /** Registration date */ + registrationDate?: string; + + /** Linked Opportunity ID (from WHMCS custom field) */ + opportunityId?: string; +} + +/** + * Cancellation info for display (shown when cancelling) + */ +export interface CancellationDisplayInfo { + /** When the service will end (end of month) */ + serviceEndDate: string; + + /** Deadline for returning equipment */ + equipmentReturnDeadline: string; + + /** Current return kit status */ + returnKitStatus: LineReturnStatusValue; + + /** Human-readable return status */ + returnKitStatusLabel: string; + + /** Whether equipment needs to be returned */ + hasEquipmentToReturn: boolean; + + /** Days remaining until service ends */ + daysUntilServiceEnd: number; + + /** Days remaining until return deadline */ + daysUntilReturnDeadline: number; +} + +/** + * Customer-facing service view for the portal + * + * Combines data from WHMCS (primary) and Opportunity (lifecycle/cancellation) + * Handles both linked and legacy (unlinked) services + */ +export interface CustomerServiceView { + // ---- Identification ---- + + /** Unique ID for this view (WHMCS Service ID as string) */ + id: string; + + /** WHMCS Service ID */ + whmcsServiceId: number; + + /** Linked Opportunity ID (null for legacy services) */ + opportunityId?: string; + + /** Product type (Internet, SIM, VPN) */ + productType: OpportunityProductTypeValue; + + // ---- Display State ---- + + /** + * Customer-facing phase + * - order: Waiting for activation + * - active: Service is live + * - cancelling: Cancellation pending + * - cancelled: Service ended + */ + phase: CustomerServicePhase; + + /** + * Whether this service has full Opportunity linking + * - linked: Full features (can show cancellation status, etc.) + * - unlinked: Legacy service (limited features) + */ + linkStatus: ServiceLinkStatus; + + // ---- Service Data (from WHMCS) ---- + + /** Product/plan name */ + productName: string; + + /** WHMCS status */ + status: string; + + /** Monthly amount */ + monthlyAmount?: number; + + /** Currency */ + currency?: string; + + /** Next billing date */ + nextDueDate?: string; + + // ---- Cancellation Data (from Opportunity, if linked) ---- + + /** + * Cancellation info (only when phase = 'cancelling' and linked) + */ + cancellation?: CancellationDisplayInfo; + + // ---- Actions ---- + + /** Whether cancel button should be shown */ + canCancel: boolean; + + /** Whether user can view invoices */ + canViewInvoices: boolean; + + /** Message to show if features are limited (for legacy) */ + limitedFeaturesMessage?: string; +} + +/** + * Map Opportunity stage to customer-facing phase + */ +export function getCustomerPhaseFromStage( + stage: OpportunityStageValue | undefined, + whmcsStatus: string +): CustomerServicePhase { + // If no Opportunity or WHMCS is active, derive from WHMCS status + if (!stage) { + if (whmcsStatus === "Active") return "active"; + if (whmcsStatus === "Cancelled" || whmcsStatus === "Terminated") return "cancelled"; + return "active"; // Default for legacy + } + + switch (stage) { + case OPPORTUNITY_STAGE.INTRODUCTION: + case OPPORTUNITY_STAGE.READY: + case OPPORTUNITY_STAGE.POST_PROCESSING: + return "order"; // Customer sees "order in progress" + + case OPPORTUNITY_STAGE.ACTIVE: + return "active"; + + case OPPORTUNITY_STAGE.CANCELLING: + return "cancelling"; + + case OPPORTUNITY_STAGE.CANCELLED: + case OPPORTUNITY_STAGE.COMPLETED: + case OPPORTUNITY_STAGE.VOID: + case OPPORTUNITY_STAGE.PENDING: + return "cancelled"; + + default: + return "active"; + } +} + +/** + * Get order tracking progress steps + * Used for customers who have placed an order but WHMCS is not yet active + */ +export function getOrderTrackingSteps( + currentStage: OpportunityStageValue +): OrderTrackingInfo["steps"] { + const stages = [ + { stage: OPPORTUNITY_STAGE.POST_PROCESSING, label: "Order Received" }, + { stage: OPPORTUNITY_STAGE.POST_PROCESSING, label: "Processing" }, + { stage: OPPORTUNITY_STAGE.ACTIVE, label: "Activating" }, + { stage: OPPORTUNITY_STAGE.ACTIVE, label: "Active" }, + ]; + + // Simplify to 4 steps for customer clarity + let currentStep = 0; + if (currentStage === OPPORTUNITY_STAGE.POST_PROCESSING) { + currentStep = 1; // Processing + } else if (currentStage === OPPORTUNITY_STAGE.ACTIVE) { + currentStep = 3; // Active + } + + return stages.map((s, index) => ({ + label: s.label, + status: index < currentStep ? "completed" : index === currentStep ? "current" : "upcoming", + })); +} + +/** + * Determine if a service can be cancelled + */ +export function canServiceBeCancelled( + whmcsStatus: string, + opportunityStage?: OpportunityStageValue +): boolean { + // Can't cancel if WHMCS is not active + if (whmcsStatus !== "Active") return false; + + // If linked to Opportunity, check stage + if (opportunityStage) { + // Can only cancel from Active stage + return opportunityStage === OPPORTUNITY_STAGE.ACTIVE; + } + + // Legacy service - allow cancellation (will create Case for agent) + return true; +} diff --git a/packages/domain/opportunity/helpers.ts b/packages/domain/opportunity/helpers.ts new file mode 100644 index 00000000..c0d6f547 --- /dev/null +++ b/packages/domain/opportunity/helpers.ts @@ -0,0 +1,382 @@ +/** + * Opportunity Domain - Helpers + * + * Utility functions for cancellation date calculations and validation. + * Implements the "25th rule" and rental return deadline logic. + */ + +import { + CANCELLATION_DEADLINE_DAY, + RENTAL_RETURN_DEADLINE_DAY, + CANCELLATION_NOTICE, + LINE_RETURN_STATUS, + type CancellationFormData, + type CancellationOpportunityData, + type CancellationEligibility, + type CancellationMonthOption, +} from "./contract.js"; + +// ============================================================================ +// Date Utilities +// ============================================================================ + +/** + * Get the last day of a month + * @param year - Full year (e.g., 2025) + * @param month - Month (1-12) + * @returns Date string in YYYY-MM-DD format + */ +export function getLastDayOfMonth(year: number, month: number): string { + // Day 0 of next month = last day of current month + const lastDay = new Date(year, month, 0).getDate(); + const monthStr = String(month).padStart(2, "0"); + const dayStr = String(lastDay).padStart(2, "0"); + return `${year}-${monthStr}-${dayStr}`; +} + +/** + * Get the rental return deadline (10th of following month) + * @param year - Year of cancellation month + * @param month - Month of cancellation (1-12) + * @returns Date string in YYYY-MM-DD format + */ +export function getRentalReturnDeadline(year: number, month: number): string { + // Move to next month + let nextYear = year; + let nextMonth = month + 1; + if (nextMonth > 12) { + nextMonth = 1; + nextYear += 1; + } + + const monthStr = String(nextMonth).padStart(2, "0"); + const dayStr = String(RENTAL_RETURN_DEADLINE_DAY).padStart(2, "0"); + return `${nextYear}-${monthStr}-${dayStr}`; +} + +/** + * Parse YYYY-MM format to year and month + */ +export function parseYearMonth(value: string): { year: number; month: number } | null { + const match = value.match(/^(\d{4})-(0[1-9]|1[0-2])$/); + if (!match) return null; + return { + year: parseInt(match[1], 10), + month: parseInt(match[2], 10), + }; +} + +/** + * Format a date as YYYY-MM + */ +export function formatYearMonth(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + return `${year}-${month}`; +} + +/** + * Format a month for display (e.g., "January 2025") + */ +export function formatMonthLabel(year: number, month: number): string { + const date = new Date(year, month - 1, 1); + return date.toLocaleDateString("en-US", { year: "numeric", month: "long" }); +} + +/** + * Format a date for display (e.g., "January 31, 2025") + */ +export function formatDateLabel(dateStr: string): string { + const date = new Date(dateStr); + return date.toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); +} + +// ============================================================================ +// Cancellation Deadline Logic +// ============================================================================ + +/** + * Check if current date is before the cancellation deadline for a given month + * + * Rule: Cancellation form must be received by the 25th of the cancellation month + * + * @param cancellationYear - Year of cancellation + * @param cancellationMonth - Month of cancellation (1-12) + * @param today - Current date (for testing, defaults to now) + * @returns true if can still cancel for this month + */ +export function isBeforeCancellationDeadline( + cancellationYear: number, + cancellationMonth: number, + today: Date = new Date() +): boolean { + const deadlineDate = new Date( + cancellationYear, + cancellationMonth - 1, + CANCELLATION_DEADLINE_DAY, + 23, + 59, + 59 + ); + return today <= deadlineDate; +} + +/** + * Get the cancellation deadline date for a month + * + * @param year - Year + * @param month - Month (1-12) + * @returns Date string in YYYY-MM-DD format + */ +export function getCancellationDeadline(year: number, month: number): string { + const monthStr = String(month).padStart(2, "0"); + const dayStr = String(CANCELLATION_DEADLINE_DAY).padStart(2, "0"); + return `${year}-${monthStr}-${dayStr}`; +} + +/** + * Calculate the earliest month that can be selected for cancellation + * + * If today is on or after the 25th, earliest is next month. + * Otherwise, earliest is current month. + * + * @param today - Current date (for testing, defaults to now) + * @returns YYYY-MM format string + */ +export function getEarliestCancellationMonth(today: Date = new Date()): string { + const year = today.getFullYear(); + const month = today.getMonth() + 1; // 1-indexed + const day = today.getDate(); + + if (day > CANCELLATION_DEADLINE_DAY) { + // After 25th - earliest is next month + let nextMonth = month + 1; + let nextYear = year; + if (nextMonth > 12) { + nextMonth = 1; + nextYear += 1; + } + return `${nextYear}-${String(nextMonth).padStart(2, "0")}`; + } else { + // On or before 25th - can cancel this month + return `${year}-${String(month).padStart(2, "0")}`; + } +} + +/** + * Generate available cancellation months + * + * Returns 12 months starting from the earliest available month. + * + * @param today - Current date (for testing) + * @returns Array of month options + */ +export function generateCancellationMonthOptions( + today: Date = new Date() +): CancellationMonthOption[] { + const earliestMonth = getEarliestCancellationMonth(today); + const parsed = parseYearMonth(earliestMonth); + if (!parsed) return []; + + let { year, month } = parsed; + const currentYearMonth = formatYearMonth(today); + const options: CancellationMonthOption[] = []; + + for (let i = 0; i < 12; i++) { + const value = `${year}-${String(month).padStart(2, "0")}`; + const serviceEndDate = getLastDayOfMonth(year, month); + const rentalReturnDeadline = getRentalReturnDeadline(year, month); + const isCurrentMonth = value === currentYearMonth; + + options.push({ + value, + label: formatMonthLabel(year, month), + serviceEndDate, + rentalReturnDeadline, + isCurrentMonth, + }); + + // Move to next month + month += 1; + if (month > 12) { + month = 1; + year += 1; + } + } + + return options; +} + +/** + * Get full cancellation eligibility information + * + * @param today - Current date (for testing) + * @returns Cancellation eligibility details + */ +export function getCancellationEligibility(today: Date = new Date()): CancellationEligibility { + const availableMonths = generateCancellationMonthOptions(today); + const earliestMonth = getEarliestCancellationMonth(today); + const currentYearMonth = formatYearMonth(today); + const day = today.getDate(); + + // Check if current month is still available + const canCancelThisMonth = day <= CANCELLATION_DEADLINE_DAY; + const currentMonthDeadline = canCancelThisMonth + ? getCancellationDeadline(today.getFullYear(), today.getMonth() + 1) + : null; + + return { + canCancel: true, + earliestCancellationMonth: earliestMonth, + availableMonths, + currentMonthDeadline, + }; +} + +/** + * Validate that a selected cancellation month is allowed + * + * @param selectedMonth - YYYY-MM format + * @param today - Current date (for testing) + * @returns Validation result + */ +export function validateCancellationMonth( + selectedMonth: string, + today: Date = new Date() +): { valid: boolean; error?: string } { + const parsed = parseYearMonth(selectedMonth); + if (!parsed) { + return { valid: false, error: "Invalid month format" }; + } + + const earliestMonth = getEarliestCancellationMonth(today); + const earliestParsed = parseYearMonth(earliestMonth); + if (!earliestParsed) { + return { valid: false, error: "Unable to determine earliest month" }; + } + + // Compare dates + const selectedDate = new Date(parsed.year, parsed.month - 1, 1); + const earliestDate = new Date(earliestParsed.year, earliestParsed.month - 1, 1); + + if (selectedDate < earliestDate) { + const deadline = CANCELLATION_DEADLINE_DAY; + return { + valid: false, + error: `Cancellation requests for this month must be submitted by the ${deadline}th. The earliest available month is ${formatMonthLabel(earliestParsed.year, earliestParsed.month)}.`, + }; + } + + return { valid: true }; +} + +// ============================================================================ +// Data Transformation +// ============================================================================ + +/** + * Transform cancellation form data to Opportunity update data + * + * @param formData - Customer form submission + * @returns Data to update on Opportunity + */ +export function transformCancellationFormToOpportunityData( + formData: CancellationFormData +): CancellationOpportunityData { + const parsed = parseYearMonth(formData.cancellationMonth); + if (!parsed) { + throw new Error("Invalid cancellation month format"); + } + + const scheduledCancellationDate = getLastDayOfMonth(parsed.year, parsed.month); + + // NOTE: alternativeEmail and comments go to the Cancellation Case, not to Opportunity + return { + scheduledCancellationDate, + cancellationNotice: CANCELLATION_NOTICE.RECEIVED, + lineReturnStatus: LINE_RETURN_STATUS.NOT_YET, + }; +} + +/** + * Calculate rental return deadline from scheduled cancellation date + * + * @param scheduledCancellationDate - End of cancellation month (YYYY-MM-DD) + * @returns Rental return deadline (10th of following month) + */ +export function calculateRentalReturnDeadline(scheduledCancellationDate: string): string { + const date = new Date(scheduledCancellationDate); + const year = date.getFullYear(); + const month = date.getMonth() + 1; // 1-indexed + + return getRentalReturnDeadline(year, month); +} + +// ============================================================================ +// Display Helpers +// ============================================================================ + +/** + * Get human-readable status for line return + */ +export function getLineReturnStatusLabel(status: string | undefined): { + label: string; + description: string; +} { + switch (status) { + case LINE_RETURN_STATUS.NOT_YET: + return { + label: "Return Pending", + description: "A return kit will be sent to you", + }; + case LINE_RETURN_STATUS.SENT_KIT: + return { + label: "Return Kit Sent", + description: "Please return equipment using the provided kit", + }; + case LINE_RETURN_STATUS.PICKUP_SCHEDULED: + return { + label: "Pickup Scheduled", + description: "Equipment pickup has been scheduled", + }; + case LINE_RETURN_STATUS.RETURNED: + case "Returned1": + return { + label: "Returned", + description: "Equipment has been returned successfully", + }; + case LINE_RETURN_STATUS.NTT_DISPATCH: + return { + label: "NTT Dispatch", + description: "NTT will handle equipment return", + }; + case LINE_RETURN_STATUS.COMPENSATED: + return { + label: "Compensated", + description: "Compensation fee has been charged for unreturned equipment", + }; + case LINE_RETURN_STATUS.NA: + return { + label: "Not Applicable", + description: "No rental equipment to return", + }; + default: + return { + label: "Unknown", + description: "", + }; + } +} + +/** + * Check if rental equipment is applicable for a product type + */ +export function hasRentalEquipment(productType: string): boolean { + // Internet typically has rental equipment (router, modem) + // SIM and VPN do not + return productType === "Internet"; +} diff --git a/packages/domain/opportunity/index.ts b/packages/domain/opportunity/index.ts new file mode 100644 index 00000000..bb915817 --- /dev/null +++ b/packages/domain/opportunity/index.ts @@ -0,0 +1,140 @@ +/** + * Opportunity Domain + * + * Exports all Opportunity-related contracts, schemas, helpers, and types. + * Used for Salesforce Opportunity lifecycle management including cancellation. + * + * Key features: + * - Service lifecycle tracking (Introduction -> Active -> Cancelling -> Cancelled) + * - Cancellation deadline logic (25th of month rule) + * - Rental equipment return tracking + * + * Types are derived from Zod schemas (Schema-First Approach) + */ + +// ============================================================================ +// Constants +// ============================================================================ + +export { + // Stage constants (existing Salesforce picklist values) + OPPORTUNITY_STAGE, + type OpportunityStageValue, + // Application stage constants + APPLICATION_STAGE, + type ApplicationStageValue, + // Cancellation notice constants + CANCELLATION_NOTICE, + type CancellationNoticeValue, + // Line return status constants + LINE_RETURN_STATUS, + type LineReturnStatusValue, + // Commodity type constants (existing Salesforce CommodityType field) + COMMODITY_TYPE, + type CommodityTypeValue, + // Product type constants (simplified, derived from CommodityType) + OPPORTUNITY_PRODUCT_TYPE, + type OpportunityProductTypeValue, + // Default commodity types for portal + PORTAL_DEFAULT_COMMODITY_TYPES, + // Commodity type helpers + getCommodityTypeProductType, + getDefaultCommodityType, + // Source constants + OPPORTUNITY_SOURCE, + type OpportunitySourceValue, + // Matching constants + OPEN_OPPORTUNITY_STAGES, + CLOSED_OPPORTUNITY_STAGES, + // Deadline constants + CANCELLATION_DEADLINE_DAY, + RENTAL_RETURN_DEADLINE_DAY, + // Customer-facing service display types + type CustomerServicePhase, + type ServiceLinkStatus, + type OrderTrackingInfo, + type WhmcsServiceDetails, + type CancellationDisplayInfo, + type CustomerServiceView, + // Customer-facing helpers + getCustomerPhaseFromStage, + getOrderTrackingSteps, + canServiceBeCancelled, +} from "./contract.js"; + +// ============================================================================ +// Contract Types (business types, not validated) +// ============================================================================ + +export type { + OpportunityRecord as OpportunityRecordContract, + CreateOpportunityRequest as CreateOpportunityRequestContract, + UpdateOpportunityStageRequest as UpdateOpportunityStageRequestContract, + CancellationFormData as CancellationFormDataContract, + CancellationOpportunityData as CancellationOpportunityDataContract, + CancellationCaseData as CancellationCaseDataContract, + CancellationEligibility as CancellationEligibilityContract, + CancellationMonthOption as CancellationMonthOptionContract, + CancellationStatus as CancellationStatusContract, + OpportunityMatchResult as OpportunityMatchResultContract, + OpportunityLookupCriteria as OpportunityLookupCriteriaContract, +} from "./contract.js"; + +// ============================================================================ +// Schemas and Validated Types (preferred) +// ============================================================================ + +export { + // Schemas + opportunityRecordSchema, + createOpportunityRequestSchema, + updateOpportunityStageRequestSchema, + cancellationFormDataSchema, + cancellationOpportunityDataSchema, + cancellationMonthOptionSchema, + cancellationEligibilitySchema, + cancellationStatusSchema, + opportunityLookupCriteriaSchema, + opportunityMatchResultSchema, + opportunityResponseSchema, + createOpportunityResponseSchema, + cancellationPreviewResponseSchema, + // Derived types (preferred - validated) + type OpportunityRecord, + type CreateOpportunityRequest, + type UpdateOpportunityStageRequest, + type CancellationFormData, + type CancellationOpportunityData, + type CancellationMonthOption, + type CancellationEligibility, + type CancellationStatus, + type OpportunityLookupCriteria, + type OpportunityMatchResult, +} from "./schema.js"; + +// ============================================================================ +// Helpers +// ============================================================================ + +export { + // Date utilities + getLastDayOfMonth, + getRentalReturnDeadline, + parseYearMonth, + formatYearMonth, + formatMonthLabel, + formatDateLabel, + // Cancellation deadline logic + isBeforeCancellationDeadline, + getCancellationDeadline, + getEarliestCancellationMonth, + generateCancellationMonthOptions, + getCancellationEligibility, + validateCancellationMonth, + // Data transformation + transformCancellationFormToOpportunityData, + calculateRentalReturnDeadline, + // Display helpers + getLineReturnStatusLabel, + hasRentalEquipment, +} from "./helpers.js"; diff --git a/packages/domain/opportunity/schema.ts b/packages/domain/opportunity/schema.ts new file mode 100644 index 00000000..b8f85b92 --- /dev/null +++ b/packages/domain/opportunity/schema.ts @@ -0,0 +1,306 @@ +/** + * Opportunity Domain - Schemas + * + * Zod schemas for runtime validation of Opportunity data. + * Includes cancellation form validation with deadline logic. + */ + +import { z } from "zod"; + +// ============================================================================ +// Enum Value Arrays (for Zod schemas) +// ============================================================================ + +/** + * Opportunity stage values - match existing Salesforce picklist + */ +const OPPORTUNITY_STAGE_VALUES = [ + "Introduction", + "WIKI", + "Ready", + "Post Processing", + "Active", + "△Cancelling", + "〇Cancelled", + "Completed", + "Void", + "Pending", +] as const; + +/** + * Application stage values + */ +const APPLICATION_STAGE_VALUES = ["INTRO-1", "N/A"] as const; + +/** + * Cancellation notice values + */ +const CANCELLATION_NOTICE_VALUES = ["有", "未", "不要", "移転"] as const; + +/** + * Line return status values + */ +const LINE_RETURN_STATUS_VALUES = [ + "NotYet", + "SentKit", + "AS/Pickup予定", + "Returned1", + "Returned2", + "NTT派遣", + "Compensated", + "N/A", +] as const; + +const OPPORTUNITY_PRODUCT_TYPE_VALUES = ["Internet", "SIM", "VPN"] as const; + +/** + * Commodity type values - existing Salesforce picklist + */ +const COMMODITY_TYPE_VALUES = [ + "Personal SonixNet Home Internet", + "Corporate SonixNet Home Internet", + "SIM", + "VPN", + "Onsite Support", +] as const; + +const OPPORTUNITY_SOURCE_VALUES = [ + "Portal - Internet Eligibility Request", + "Portal - SIM Checkout Registration", + "Portal - Order Placement", + "Agent Created", +] as const; + +// ============================================================================ +// Opportunity Record Schema +// ============================================================================ + +/** + * Schema for Opportunity record returned from Salesforce + */ +export const opportunityRecordSchema = z.object({ + id: z.string(), + name: z.string(), + accountId: z.string(), + stage: z.enum(OPPORTUNITY_STAGE_VALUES), + closeDate: z.string(), + commodityType: z.enum(COMMODITY_TYPE_VALUES).optional(), + productType: z.enum(OPPORTUNITY_PRODUCT_TYPE_VALUES).optional(), // Derived from commodityType + source: z.enum(OPPORTUNITY_SOURCE_VALUES).optional(), + applicationStage: z.enum(APPLICATION_STAGE_VALUES).optional(), + isClosed: z.boolean(), + + // Linked entities + // Note: Cases and Orders link TO Opportunity via their OpportunityId field + whmcsServiceId: z.number().int().optional(), + + // Cancellation fields (updated by CS when processing cancellation Case) + cancellationNotice: z.enum(CANCELLATION_NOTICE_VALUES).optional(), + scheduledCancellationDate: z.string().optional(), + lineReturnStatus: z.enum(LINE_RETURN_STATUS_VALUES).optional(), + // NOTE: alternativeContactEmail and cancellationComments are on the Cancellation Case + + // Metadata + createdDate: z.string(), + lastModifiedDate: z.string(), +}); + +export type OpportunityRecord = z.infer; + +// ============================================================================ +// Create Opportunity Request Schema +// ============================================================================ + +/** + * Schema for creating a new Opportunity + * + * Note: Opportunity Name is auto-generated by Salesforce workflow, + * so we don't need to provide account name. + */ +export const createOpportunityRequestSchema = z.object({ + accountId: z.string().min(15, "Salesforce Account ID must be at least 15 characters"), + productType: z.enum(OPPORTUNITY_PRODUCT_TYPE_VALUES), + stage: z.enum(OPPORTUNITY_STAGE_VALUES), + source: z.enum(OPPORTUNITY_SOURCE_VALUES), + applicationStage: z.enum(APPLICATION_STAGE_VALUES).optional(), + closeDate: z.string().optional(), + // Note: Create Case separately with Case.OpportunityId = returned Opportunity ID +}); + +export type CreateOpportunityRequest = z.infer; + +// ============================================================================ +// Update Stage Request Schema +// ============================================================================ + +/** + * Schema for updating Opportunity stage + */ +export const updateOpportunityStageRequestSchema = z.object({ + opportunityId: z.string().min(15, "Salesforce Opportunity ID must be at least 15 characters"), + stage: z.enum(OPPORTUNITY_STAGE_VALUES), + reason: z.string().optional(), +}); + +export type UpdateOpportunityStageRequest = z.infer; + +// ============================================================================ +// Cancellation Form Schema +// ============================================================================ + +/** + * Regex for YYYY-MM format + */ +const CANCELLATION_MONTH_REGEX = /^\d{4}-(0[1-9]|1[0-2])$/; + +/** + * Schema for cancellation form data from customer + * + * Validates: + * - cancellationMonth is in YYYY-MM format + * - Both confirmations are checked + * - Email is valid if provided + */ +export const cancellationFormDataSchema = z + .object({ + cancellationMonth: z + .string() + .regex(CANCELLATION_MONTH_REGEX, "Cancellation month must be in YYYY-MM format"), + confirmTermsRead: z.boolean(), + confirmMonthEndCancellation: z.boolean(), + alternativeEmail: z.string().email().optional().or(z.literal("")), + comments: z.string().max(2000, "Comments must be 2000 characters or less").optional(), + }) + .refine(data => data.confirmTermsRead === true, { + message: "You must confirm you have read the cancellation terms", + path: ["confirmTermsRead"], + }) + .refine(data => data.confirmMonthEndCancellation === true, { + message: "You must confirm you understand cancellation is at month end", + path: ["confirmMonthEndCancellation"], + }); + +export type CancellationFormData = z.infer; + +/** + * Schema for cancellation data to populate on Opportunity + */ +export const cancellationOpportunityDataSchema = z.object({ + scheduledCancellationDate: z.string(), + cancellationNotice: z.enum(CANCELLATION_NOTICE_VALUES), + lineReturnStatus: z.enum(LINE_RETURN_STATUS_VALUES), + alternativeEmail: z.string().email().optional(), + comments: z.string().max(2000).optional(), +}); + +export type CancellationOpportunityData = z.infer; + +// ============================================================================ +// Cancellation Eligibility Schema +// ============================================================================ + +/** + * Schema for a cancellation month option + */ +export const cancellationMonthOptionSchema = z.object({ + value: z.string(), + label: z.string(), + serviceEndDate: z.string(), + rentalReturnDeadline: z.string(), + isCurrentMonth: z.boolean(), +}); + +export type CancellationMonthOption = z.infer; + +/** + * Schema for cancellation eligibility check result + */ +export const cancellationEligibilitySchema = z.object({ + canCancel: z.boolean(), + earliestCancellationMonth: z.string(), + availableMonths: z.array(cancellationMonthOptionSchema), + currentMonthDeadline: z.string().nullable(), + reason: z.string().optional(), +}); + +export type CancellationEligibility = z.infer; + +// ============================================================================ +// Cancellation Status Schema +// ============================================================================ + +/** + * Schema for cancellation status display + */ +export const cancellationStatusSchema = z.object({ + stage: z.enum(OPPORTUNITY_STAGE_VALUES), + isPending: z.boolean(), + isComplete: z.boolean(), + scheduledEndDate: z.string().optional(), + rentalReturnStatus: z.enum(LINE_RETURN_STATUS_VALUES).optional(), + rentalReturnDeadline: z.string().optional(), + hasRentalEquipment: z.boolean(), +}); + +export type CancellationStatus = z.infer; + +// ============================================================================ +// Opportunity Lookup Schema +// ============================================================================ + +/** + * Schema for Opportunity lookup criteria + */ +export const opportunityLookupCriteriaSchema = z.object({ + accountId: z.string().min(15), + productType: z.enum(OPPORTUNITY_PRODUCT_TYPE_VALUES), + allowedStages: z.array(z.enum(OPPORTUNITY_STAGE_VALUES)).optional(), +}); + +export type OpportunityLookupCriteria = z.infer; + +// ============================================================================ +// Opportunity Match Result Schema +// ============================================================================ + +/** + * Schema for Opportunity matching result + */ +export const opportunityMatchResultSchema = z.object({ + opportunityId: z.string(), + wasCreated: z.boolean(), + previousStage: z.enum(OPPORTUNITY_STAGE_VALUES).optional(), +}); + +export type OpportunityMatchResult = z.infer; + +// ============================================================================ +// API Response Schemas +// ============================================================================ + +/** + * Schema for Opportunity API response + */ +export const opportunityResponseSchema = z.object({ + opportunity: opportunityRecordSchema, +}); + +/** + * Schema for create Opportunity response + */ +export const createOpportunityResponseSchema = z.object({ + opportunityId: z.string(), + stage: z.enum(OPPORTUNITY_STAGE_VALUES), +}); + +/** + * Schema for cancellation preview response + */ +export const cancellationPreviewResponseSchema = z.object({ + eligibility: cancellationEligibilitySchema, + terms: z.object({ + deadlineDay: z.number(), + rentalReturnDeadlineDay: z.number(), + fullMonthCharge: z.boolean(), + }), +}); diff --git a/packages/domain/orders/providers/salesforce/mapper.ts b/packages/domain/orders/providers/salesforce/mapper.ts index 3e558b34..ab6b2997 100644 --- a/packages/domain/orders/providers/salesforce/mapper.ts +++ b/packages/domain/orders/providers/salesforce/mapper.ts @@ -16,14 +16,8 @@ import type { SalesforceProduct2WithPricebookEntries, SalesforcePricebookEntryRecord, } from "../../../catalog/providers/salesforce/raw.types.js"; -import { - defaultSalesforceOrderFieldMap, - type SalesforceOrderFieldMap, -} from "./field-map.js"; -import type { - SalesforceOrderItemRecord, - SalesforceOrderRecord, -} from "./raw.types.js"; +import { defaultSalesforceOrderFieldMap, type SalesforceOrderFieldMap } from "./field-map.js"; +import type { SalesforceOrderItemRecord, SalesforceOrderRecord } from "./raw.types.js"; /** * Helper function to get sort priority for item class @@ -31,9 +25,9 @@ import type { function getItemClassSortPriority(itemClass?: string): number { if (!itemClass) return 4; const normalized = itemClass.toLowerCase(); - if (normalized === 'service') return 1; - if (normalized === 'installation' || normalized === 'activation') return 2; - if (normalized === 'add-on') return 3; + if (normalized === "service") return 1; + if (normalized === "installation" || normalized === "activation") return 2; + if (normalized === "add-on") return 3; return 4; } @@ -44,9 +38,7 @@ export function transformSalesforceOrderItem( record: SalesforceOrderItemRecord, fieldMap: SalesforceOrderFieldMap = defaultSalesforceOrderFieldMap ): { details: OrderItemDetails; summary: OrderItemSummary } { - const pricebookEntry = (record.PricebookEntry ?? null) as - | SalesforcePricebookEntryRecord - | null; + const pricebookEntry = (record.PricebookEntry ?? null) as SalesforcePricebookEntryRecord | null; const product = pricebookEntry?.Product2 as SalesforceProduct2WithPricebookEntries | undefined; const orderItemFields = fieldMap.orderItem; @@ -133,6 +125,7 @@ export function transformSalesforceOrderDetails( accountId: ensureString(order.AccountId), accountName: ensureString(order.Account?.Name), pricebook2Id: ensureString(order.Pricebook2Id), + opportunityId: ensureString(order.OpportunityId), // Linked Opportunity for lifecycle tracking activationType: ensureString(order[orderFields.activationType]), activationStatus: summary.activationStatus, activationScheduledAt: ensureString(order[orderFields.activationScheduledAt]), diff --git a/packages/domain/orders/providers/salesforce/raw.types.ts b/packages/domain/orders/providers/salesforce/raw.types.ts index c6fbaaa4..06d1707d 100644 --- a/packages/domain/orders/providers/salesforce/raw.types.ts +++ b/packages/domain/orders/providers/salesforce/raw.types.ts @@ -1,6 +1,6 @@ /** * Orders Domain - Salesforce Provider Raw Types - * + * * Raw Salesforce API response types for Order and OrderItem sobjects. * Product and Pricebook types belong in the catalog domain. */ @@ -43,7 +43,8 @@ export const salesforceOrderRecordSchema = z.object({ // Note: Account nested object comes from customer domain Account: z.object({ Name: z.string().nullable().optional() }).nullable().optional(), Pricebook2Id: z.string().nullable().optional(), - + OpportunityId: z.string().nullable().optional(), // Linked Opportunity for lifecycle tracking + // Activation fields Activation_Type__c: z.string().nullable().optional(), Activation_Status__c: z.string().nullable().optional(), @@ -52,23 +53,23 @@ export const salesforceOrderRecordSchema = z.object({ Activation_Error_Message__c: z.string().nullable().optional(), Activation_Last_Attempt_At__c: z.string().nullable().optional(), ActivatedDate: z.string().nullable().optional(), - + // Internet fields Internet_Plan_Tier__c: z.string().nullable().optional(), Installment_Plan__c: z.string().nullable().optional(), Access_Mode__c: z.string().nullable().optional(), Weekend_Install__c: z.boolean().nullable().optional(), Hikari_Denwa__c: z.boolean().nullable().optional(), - + // VPN fields VPN_Region__c: z.string().nullable().optional(), - + // SIM fields SIM_Type__c: z.string().nullable().optional(), SIM_Voice_Mail__c: z.boolean().nullable().optional(), SIM_Call_Waiting__c: z.boolean().nullable().optional(), EID__c: z.string().nullable().optional(), - + // MNP (Mobile Number Portability) fields MNP_Application__c: z.string().nullable().optional(), MNP_Reservation_Number__c: z.string().nullable().optional(), @@ -81,18 +82,18 @@ export const salesforceOrderRecordSchema = z.object({ Porting_First_Name_Katakana__c: z.string().nullable().optional(), Porting_Last_Name_Katakana__c: z.string().nullable().optional(), Porting_Gender__c: z.string().nullable().optional(), - + // Billing address snapshot fields (standard Salesforce Order columns) BillingStreet: z.string().nullable().optional(), BillingCity: z.string().nullable().optional(), BillingState: z.string().nullable().optional(), BillingPostalCode: z.string().nullable().optional(), BillingCountry: z.string().nullable().optional(), - + // Other fields Address_Changed__c: z.boolean().nullable().optional(), WHMCS_Order_ID__c: z.string().nullable().optional(), - + // Audit fields CreatedDate: z.string().optional(), LastModifiedDate: z.string().optional(), @@ -107,20 +108,26 @@ export type SalesforceOrderRecord = z.infer; /** * Platform Event payload for Order Fulfillment */ -export const salesforceOrderProvisionEventPayloadSchema = z.object({ - OrderId__c: z.string().optional(), - OrderId: z.string().optional(), -}).passthrough(); +export const salesforceOrderProvisionEventPayloadSchema = z + .object({ + OrderId__c: z.string().optional(), + OrderId: z.string().optional(), + }) + .passthrough(); -export type SalesforceOrderProvisionEventPayload = z.infer; +export type SalesforceOrderProvisionEventPayload = z.infer< + typeof salesforceOrderProvisionEventPayloadSchema +>; /** * Platform Event structure */ -export const salesforceOrderProvisionEventSchema = z.object({ - payload: salesforceOrderProvisionEventPayloadSchema, - replayId: z.number().optional(), -}).passthrough(); +export const salesforceOrderProvisionEventSchema = z + .object({ + payload: salesforceOrderProvisionEventPayloadSchema, + replayId: z.number().optional(), + }) + .passthrough(); export type SalesforceOrderProvisionEvent = z.infer; @@ -140,19 +147,23 @@ export type SalesforcePubSubSubscription = z.infer; /** * Pub/Sub error structure */ -export const salesforcePubSubErrorSchema = z.object({ - details: z.string().optional(), - metadata: salesforcePubSubErrorMetadataSchema.optional(), -}).passthrough(); +export const salesforcePubSubErrorSchema = z + .object({ + details: z.string().optional(), + metadata: salesforcePubSubErrorMetadataSchema.optional(), + }) + .passthrough(); export type SalesforcePubSubError = z.infer; diff --git a/packages/domain/orders/providers/whmcs/mapper.ts b/packages/domain/orders/providers/whmcs/mapper.ts index d8416325..70b8cabf 100644 --- a/packages/domain/orders/providers/whmcs/mapper.ts +++ b/packages/domain/orders/providers/whmcs/mapper.ts @@ -123,8 +123,19 @@ export function buildWhmcsAddOrderPayload(params: WhmcsAddOrderParams): WhmcsAdd // Handle config options - WHMCS expects base64 encoded serialized arrays configOptions.push(serializeWhmcsKeyValueMap(item.configOptions)); + // Build custom fields - include item-level fields plus order-level OpportunityId + const mergedCustomFields: Record = { + ...(item.customFields ?? {}), + }; + + // Inject OpportunityId into each item's custom fields for lifecycle tracking + // This links the WHMCS service back to the Salesforce Opportunity + if (params.sfOpportunityId) { + mergedCustomFields["OpportunityId"] = params.sfOpportunityId; + } + // Handle custom fields - WHMCS expects base64 encoded serialized arrays - customFields.push(serializeWhmcsKeyValueMap(item.customFields)); + customFields.push(serializeWhmcsKeyValueMap(mergedCustomFields)); }); const payload: WhmcsAddOrderPayload = { diff --git a/packages/domain/orders/providers/whmcs/raw.types.ts b/packages/domain/orders/providers/whmcs/raw.types.ts index e7efeec6..112594d4 100644 --- a/packages/domain/orders/providers/whmcs/raw.types.ts +++ b/packages/domain/orders/providers/whmcs/raw.types.ts @@ -1,13 +1,13 @@ /** * Orders Domain - WHMCS Provider Raw Types - * + * * Raw types for WHMCS API request/response. - * + * * WHMCS AddOrder API: * @see https://developers.whmcs.com/api-reference/addorder/ * Adds an order to a client. Returns orderid, serviceids, addonids, domainids, invoiceid. * CRITICAL: Must include qty[] parameter or no products will be added to the order! - * + * * WHMCS AcceptOrder API: * @see https://developers.whmcs.com/api-reference/acceptorder/ * Accepts a pending order. Parameters: @@ -35,7 +35,7 @@ export const whmcsOrderItemSchema = z.object({ "biennially", "triennially", "onetime", - "free" + "free", ]), quantity: z.number().int().positive("Quantity must be positive").default(1), configOptions: z.record(z.string(), z.string()).optional(), @@ -55,6 +55,7 @@ export const whmcsAddOrderParamsSchema = z.object({ promoCode: z.string().optional(), notes: z.string().optional(), sfOrderId: z.string().optional(), // For tracking back to Salesforce + sfOpportunityId: z.string().optional(), // Salesforce Opportunity ID for lifecycle tracking noinvoice: z.boolean().optional(), // Don't create invoice during provisioning noinvoiceemail: z.boolean().optional(), // Suppress invoice email noemail: z.boolean().optional(), // Don't send any emails diff --git a/packages/domain/orders/schema.ts b/packages/domain/orders/schema.ts index b2db9552..325e86d1 100644 --- a/packages/domain/orders/schema.ts +++ b/packages/domain/orders/schema.ts @@ -1,6 +1,6 @@ /** * Orders Domain - Schemas - * + * * Zod schemas for runtime validation of order data. */ @@ -48,18 +48,20 @@ export const orderItemDetailsSchema = z.object({ unitPrice: z.number().optional(), totalPrice: z.number().optional(), billingCycle: z.string().optional(), - product: z.object({ - id: z.string().optional(), - name: z.string().optional(), - sku: z.string().optional(), - itemClass: z.string().optional(), - whmcsProductId: z.string().optional(), - internetOfferingType: z.string().optional(), - internetPlanTier: z.string().optional(), - vpnRegion: z.string().optional(), - isBundledAddon: z.boolean().optional(), - bundledAddonId: z.string().optional(), - }).optional(), + product: z + .object({ + id: z.string().optional(), + name: z.string().optional(), + sku: z.string().optional(), + itemClass: z.string().optional(), + whmcsProductId: z.string().optional(), + internetOfferingType: z.string().optional(), + internetPlanTier: z.string().optional(), + vpnRegion: z.string().optional(), + isBundledAddon: z.boolean().optional(), + bundledAddonId: z.string().optional(), + }) + .optional(), }); // ============================================================================ @@ -88,6 +90,7 @@ export const orderDetailsSchema = orderSummarySchema.extend({ accountId: z.string().optional(), accountName: z.string().optional(), pricebook2Id: z.string().optional(), + opportunityId: z.string().optional(), // Linked Opportunity for lifecycle tracking activationType: z.string().optional(), activationStatus: z.string().optional(), activationScheduledAt: z.string().optional(), // IsoDateTimeString @@ -197,74 +200,70 @@ const baseCreateOrderSchema = z.object({ export const createOrderRequestSchema = baseCreateOrderSchema; -export const orderBusinessValidationSchema = - baseCreateOrderSchema - .extend({ - userId: z.string().uuid(), - opportunityId: z.string().optional(), - }) - .refine( - (data) => { - if (data.orderType === "Internet") { - const mainServiceSkus = data.skus.filter(sku => { - const upperSku = sku.toUpperCase(); - return ( - !upperSku.includes("INSTALL") && - !upperSku.includes("ADDON") && - !upperSku.includes("ACTIVATION") && - !upperSku.includes("FEE") - ); - }); - return mainServiceSkus.length >= 1; - } - return true; - }, - { - message: "Internet orders must have at least one main service SKU (non-installation, non-addon)", - path: ["skus"], +export const orderBusinessValidationSchema = baseCreateOrderSchema + .extend({ + userId: z.string().uuid(), + opportunityId: z.string().optional(), + }) + .refine( + data => { + if (data.orderType === "Internet") { + const mainServiceSkus = data.skus.filter(sku => { + const upperSku = sku.toUpperCase(); + return ( + !upperSku.includes("INSTALL") && + !upperSku.includes("ADDON") && + !upperSku.includes("ACTIVATION") && + !upperSku.includes("FEE") + ); + }); + return mainServiceSkus.length >= 1; } - ) - .refine( - (data) => { - if (data.orderType === "SIM" && data.configurations) { - return data.configurations.simType !== undefined; - } - return true; - }, - { - message: "SIM orders must specify SIM type", - path: ["configurations", "simType"], + return true; + }, + { + message: + "Internet orders must have at least one main service SKU (non-installation, non-addon)", + path: ["skus"], + } + ) + .refine( + data => { + if (data.orderType === "SIM" && data.configurations) { + return data.configurations.simType !== undefined; } - ) - .refine( - (data) => { - if (data.configurations?.simType === "eSIM") { - return data.configurations.eid !== undefined && data.configurations.eid.length > 0; - } - return true; - }, - { - message: "eSIM orders must provide EID", - path: ["configurations", "eid"], + return true; + }, + { + message: "SIM orders must specify SIM type", + path: ["configurations", "simType"], + } + ) + .refine( + data => { + if (data.configurations?.simType === "eSIM") { + return data.configurations.eid !== undefined && data.configurations.eid.length > 0; } - ) - .refine( - (data) => { - if (data.configurations?.isMnp === "true") { - const required = [ - "mnpNumber", - "portingLastName", - "portingFirstName", - ] as const; - return required.every(field => data.configurations?.[field] !== undefined); - } - return true; - }, - { - message: "MNP orders must provide porting information", - path: ["configurations"], + return true; + }, + { + message: "eSIM orders must provide EID", + path: ["configurations", "eid"], + } + ) + .refine( + data => { + if (data.configurations?.isMnp === "true") { + const required = ["mnpNumber", "portingLastName", "portingFirstName"] as const; + return required.every(field => data.configurations?.[field] !== undefined); } - ); + return true; + }, + { + message: "MNP orders must provide porting information", + path: ["configurations"], + } + ); export const sfOrderIdParamSchema = z.object({ sfOrderId: z @@ -290,7 +289,7 @@ export const checkoutItemSchema = z.object({ monthlyPrice: z.number().optional(), oneTimePrice: z.number().optional(), quantity: z.number().positive(), - itemType: z.enum(['plan', 'installation', 'addon', 'activation', 'vpn']), + itemType: z.enum(["plan", "installation", "addon", "activation", "vpn"]), autoAdded: z.boolean().optional(), }); @@ -361,11 +360,7 @@ export const orderDisplayItemCategorySchema = z.enum([ "other", ]); -export const orderDisplayItemChargeKindSchema = z.enum([ - "monthly", - "one-time", - "other", -]); +export const orderDisplayItemChargeKindSchema = z.enum(["monthly", "one-time", "other"]); export const orderDisplayItemChargeSchema = z.object({ kind: orderDisplayItemChargeKindSchema, diff --git a/packages/domain/package.json b/packages/domain/package.json index 0a89b5f7..241d4c6d 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -79,6 +79,14 @@ "import": "./dist/mappings/*.js", "types": "./dist/mappings/*.d.ts" }, + "./opportunity": { + "import": "./dist/opportunity/index.js", + "types": "./dist/opportunity/index.d.ts" + }, + "./opportunity/*": { + "import": "./dist/opportunity/*.js", + "types": "./dist/opportunity/*.d.ts" + }, "./orders": { "import": "./dist/orders/index.js", "types": "./dist/orders/index.d.ts" diff --git a/packages/domain/tsconfig.json b/packages/domain/tsconfig.json index ce9667f3..63b3851b 100644 --- a/packages/domain/tsconfig.json +++ b/packages/domain/tsconfig.json @@ -18,6 +18,7 @@ "customer/**/*", "dashboard/**/*", "mappings/**/*", + "opportunity/**/*", "orders/**/*", "payments/**/*", "providers/**/*",