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}
+ )}
+
+
+
+
+ setMode("signup")}
+ className="rounded-full"
+ >
+ Create account
+
+ setMode("login")}
+ className="rounded-full"
+ >
+ Sign in
+
+ setMode("migrate")}
+ className="rounded-full"
+ >
+ Migrate
+
+
+
+
+
+ {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"
- />
-
-
-
- setValue("gender", e.target.value || undefined)}
- onBlur={() => setTouchedField("gender")}
- className={[
- "flex h-10 w-full rounded-md border border-input bg-background text-foreground px-3 py-2 text-sm",
- "ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none",
- "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
- "disabled:cursor-not-allowed disabled:opacity-50",
- getError("gender")
- ? "border-danger focus-visible:ring-danger focus-visible:ring-offset-2"
- : "",
- ].join(" ")}
- aria-invalid={Boolean(getError("gender")) || undefined}
- >
- Select…
- Male
- Female
- Other
-
-
+
+ setShowOptional(s => !s)}
+ >
+ {showOptional ? "Hide optional details" : "Add optional details"}
+
- {/* Company (Optional) */}
-
- setValue("company", e.target.value)}
- onBlur={() => setTouchedField("company")}
- placeholder="Company name"
- autoComplete="organization"
- />
-
+ {showOptional && (
+
+ )}
);
}
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"}
)}
+
+
);
}
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 ? (
+ {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ e.stopPropagation();
+ }
+ }}
+ >
+ Confirm Address
+
+ ) : null}
+ {showEditAction ? (
+ }
+ >
+ Edit Address
+
+ ) : 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 && (
- {
- if (e.key === "Enter" || e.key === " ") {
- e.preventDefault();
- e.stopPropagation();
- }
- }}
- >
- Confirm Address
-
- )}
-
- {/* Edit button */}
- {billingInfo.isComplete && !editing && (
- }
- >
- Edit Address
-
- )}
-
) : (
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:
-
-
-
+ {showFeatures && (
+
+
+ Your Plan Includes:
+
+
+
+ )}
{/* 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 ? (
-
- void (async () => {
- const confirmed =
- typeof window === "undefined" ||
- window.confirm(
- `Request an eligibility review for this address?\n\n${addressLabel}`
- );
- if (!confirmed) return;
- eligibilityRequest.mutate({ address: user?.address ?? undefined });
- })()
- }
- className="sm:ml-auto whitespace-nowrap"
- >
- Request review now
-
- ) : (
-
- Add address to continue
-
- )}
-
+ {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 ? (
+
+ void (async () => {
+ const confirmed =
+ typeof window === "undefined" ||
+ window.confirm(
+ `Request an eligibility review for this address?\n\n${addressLabel}`
+ );
+ if (!confirmed) return;
+ eligibilityRequest.mutate({ address: user?.address ?? undefined });
+ })()
+ }
+ className="sm:ml-auto whitespace-nowrap"
+ >
+ Request review now
+
+ ) : (
+
+ Add address to continue
+
+ )}
+
+
+ )}
+
{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.
-
-
- {
- setAuthMode("signup");
- setAuthModalOpen(true);
- }}
- className="flex-1"
- >
- Create account
-
- {
- setAuthMode("login");
- setAuthModalOpen(true);
- }}
- variant="outline"
- className="flex-1"
- >
- Sign in
-
-
-
-
-
-
-
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() {
))}
-
-
- Get started
-
-
- Sign in
-
-
{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.
-
-
-
-
- {
- setAuthMode("signup");
- setAuthModalOpen(true);
- }}
- className="flex-1"
- >
- Create account
-
- {
- setAuthMode("login");
- setAuthModalOpen(true);
- }}
- variant="outline"
- className="flex-1"
- >
- Sign in
-
-
-
-
-
-
-
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.
-
-
-
- Get started
-
-
- Sign in
-
-
-
-
-
@@ -208,7 +181,7 @@ export function PublicSimPlansView() {
-
+
{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}
-
+ void handleManagePayment()}
+ isLoading={openingPaymentPortal}
+ loadingText="Opening..."
+ >
{hasPaymentMethod ? "Change" : "Add"}
@@ -374,7 +430,13 @@ export function AccountCheckoutContainer() {
>
Check Again
-
+ void handleManagePayment()}
+ isLoading={openingPaymentPortal}
+ loadingText="Opening..."
+ >
Add Payment Method
@@ -414,7 +476,13 @@ export function AccountCheckoutContainer() {
>
Check Again
-
+ void handleManagePayment()}
+ isLoading={openingPaymentPortal}
+ loadingText="Opening..."
+ >
Add Payment Method
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/**/*",