Enhance Signup Workflow and Update Catalog Components

- Refactored the SignupWorkflowService to throw a DomainHttpException for legacy account conflicts, improving error handling.
- Updated the SignupForm component to include initialEmail and showFooterLinks props, enhancing user experience during account creation.
- Improved the AccountStep in the SignupForm to allow users to add optional details, such as date of birth and gender, for a more personalized signup process.
- Enhanced the PasswordStep to include terms acceptance and marketing consent options, ensuring compliance and user engagement.
- Updated various catalog views to improve layout and user guidance, streamlining the onboarding process for new users.
This commit is contained in:
barsa 2025-12-22 18:59:38 +09:00
parent f5cde96027
commit d9734b0c82
46 changed files with 5054 additions and 741 deletions

View File

@ -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;

View File

@ -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,
],

View File

@ -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<string> {
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<string, unknown> = {
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<string> {
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<string, unknown> = {
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");
}
}
}

View File

@ -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<string> {
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<string, unknown> = {
[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<void> {
const safeOppId = assertSalesforceId(opportunityId, "opportunityId");
this.logger.log("Updating Opportunity stage", {
opportunityId: safeOppId,
newStage: stage,
reason,
});
const payload: Record<string, unknown> = {
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<string, unknown> & { 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<void> {
const safeOppId = assertSalesforceId(opportunityId, "opportunityId");
this.logger.log("Updating Opportunity with cancellation data", {
opportunityId: safeOppId,
scheduledDate: data.scheduledCancellationDate,
cancellationNotice: data.cancellationNotice,
});
const payload: Record<string, unknown> = {
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<string, unknown> & { 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<string | null> {
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<SalesforceOpportunityRecord>;
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<string | null> {
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<string | null> {
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<SalesforceOpportunityRecord>;
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<OpportunityRecord | null> {
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<SalesforceOpportunityRecord>;
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<SalesforceOpportunityRecord>;
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<void> {
const safeOppId = assertSalesforceId(opportunityId, "opportunityId");
this.logger.log("Linking WHMCS Service to Opportunity", {
opportunityId: safeOppId,
whmcsServiceId,
});
const payload: Record<string, unknown> = {
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<string, unknown> & { 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<void> {
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<void> {
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,
};
}
}

View File

@ -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;
}

View File

@ -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<string> {
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,

View File

@ -42,6 +42,7 @@ export class OrderFieldMapService {
"CreatedDate",
"LastModifiedDate",
"Pricebook2Id",
"OpportunityId", // Linked Opportunity for lifecycle tracking
order.activationType,
order.activationStatus,
order.activationScheduledAt,

View File

@ -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<ResolvedOpportunity> {
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<string> {
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<string> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<ResolvedOpportunity> {
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<ResolvedOpportunity> {
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;
}
}
}

View File

@ -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}`,

View File

@ -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<string | null> {
// 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
*/

View File

@ -1,4 +1,3 @@
/* eslint-env node */
import path from "node:path";
import { fileURLToPath } from "node:url";

View File

@ -1,6 +1,4 @@
#!/usr/bin/env node
/* eslint-env node */
/**
* Bundle size monitoring script
* Analyzes bundle size and reports on performance metrics

View File

@ -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";

View File

@ -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");

View File

@ -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 (
<div className={`bg-muted/50 border border-border rounded-2xl p-6 md:p-8 ${className}`}>
<div className="text-center mb-6">
<h3 className="text-lg font-semibold text-foreground mb-2">{title}</h3>
{description && (
<p className="text-sm text-muted-foreground max-w-2xl mx-auto">{description}</p>
)}
</div>
<div className="flex justify-center">
<div className="inline-flex flex-wrap justify-center rounded-full border border-border bg-background p-1 shadow-[var(--cp-shadow-1)] gap-1">
<Button
type="button"
size="sm"
variant={mode === "signup" ? "default" : "ghost"}
onClick={() => setMode("signup")}
className="rounded-full"
>
Create account
</Button>
<Button
type="button"
size="sm"
variant={mode === "login" ? "default" : "ghost"}
onClick={() => setMode("login")}
className="rounded-full"
>
Sign in
</Button>
<Button
type="button"
size="sm"
variant={mode === "migrate" ? "default" : "ghost"}
onClick={() => setMode("migrate")}
className="rounded-full"
>
Migrate
</Button>
</div>
</div>
<div className="mt-6">
{mode === "signup" && (
<SignupForm redirectTo={redirectTo} className="max-w-none" showFooterLinks={false} />
)}
{mode === "login" && (
<LoginForm redirectTo={redirectTo} showSignupLink={false} className="max-w-none" />
)}
{mode === "migrate" && (
<div className="bg-card border border-border rounded-xl p-5 shadow-[var(--cp-shadow-1)]">
<h4 className="text-base font-semibold text-foreground mb-2">Migrate your account</h4>
<p className="text-sm text-muted-foreground mb-4">
Use your legacy portal credentials to transfer your account.
</p>
<LinkWhmcsForm
onTransferred={result => {
if (result.needsPasswordSet) {
const params = new URLSearchParams({
email: result.user.email,
redirect: safeRedirect,
});
router.push(`/auth/set-password?${params.toString()}`);
return;
}
router.push(safeRedirect);
}}
/>
</div>
)}
</div>
{highlights.length > 0 && (
<div className="mt-6 pt-6 border-t border-border">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-center">
{highlights.map(item => (
<div key={item.title}>
<div className="text-sm font-medium text-foreground mb-1">{item.title}</div>
<div className="text-xs text-muted-foreground">{item.description}</div>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@ -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<LoginFormValues>({
schema: loginFormSchema,
initialValues: {
email: "",
email: initialEmail ?? "",
password: "",
rememberMe: false,
},

View File

@ -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<keyof SignupFormData>> = {
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<SignupFormData>({
@ -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 = [
<AccountStep key="account" form={formProps} />,
<AddressStep key="address" form={formProps} />,
<PasswordStep key="password" form={formProps} />,
<ReviewStep key="review" form={formProps} />,
<PasswordStep key="security" form={formProps} />,
];
const steps = STEPS.map((s, i) => ({
@ -276,26 +275,28 @@ export function SignupForm({ onSuccess, onError, className = "", redirectTo }: S
</ErrorMessage>
)}
<div className="mt-6 text-center border-t border-border pt-6 space-y-3">
<p className="text-sm text-muted-foreground">
Already have an account?{" "}
<Link
href={`/auth/login${redirectQuery}`}
className="font-medium text-primary hover:underline transition-colors"
>
Sign in
</Link>
</p>
<p className="text-sm text-muted-foreground">
Existing customer?{" "}
<Link
href="/auth/link-whmcs"
className="font-medium text-primary hover:underline transition-colors"
>
Migrate your account
</Link>
</p>
</div>
{showFooterLinks && (
<div className="mt-6 text-center border-t border-border pt-6 space-y-3">
<p className="text-sm text-muted-foreground">
Already have an account?{" "}
<Link
href={`/auth/login${redirectQuery}`}
className="font-medium text-primary hover:underline transition-colors"
>
Sign in
</Link>
</p>
<p className="text-sm text-muted-foreground">
Existing customer?{" "}
<Link
href="/auth/link-whmcs"
className="font-medium text-primary hover:underline transition-colors"
>
Migrate your account
</Link>
</p>
</div>
)}
</div>
</div>
);

View File

@ -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 (
<div className="space-y-5">
@ -110,55 +112,69 @@ export function AccountStep({ form }: AccountStepProps) {
</div>
</FormField>
{/* DOB + Gender (Optional WHMCS custom fields) */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Date of Birth" error={getError("dateOfBirth")} helperText="Optional">
<Input
name="bday"
type="date"
value={values.dateOfBirth ?? ""}
onChange={e => setValue("dateOfBirth", e.target.value || undefined)}
onBlur={() => setTouchedField("dateOfBirth")}
autoComplete="bday"
/>
</FormField>
<FormField label="Gender" error={getError("gender")} helperText="Optional">
<select
name="sex"
value={values.gender ?? ""}
onChange={e => 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}
>
<option value="">Select</option>
<option value="male">Male</option>
<option value="female">Female</option>
<option value="other">Other</option>
</select>
</FormField>
<div className="pt-2">
<button
type="button"
className="text-sm font-medium text-primary hover:underline"
onClick={() => setShowOptional(s => !s)}
>
{showOptional ? "Hide optional details" : "Add optional details"}
</button>
</div>
{/* Company (Optional) */}
<FormField label="Company" error={getError("company")} helperText="Optional">
<Input
name="organization"
value={values.company ?? ""}
onChange={e => setValue("company", e.target.value)}
onBlur={() => setTouchedField("company")}
placeholder="Company name"
autoComplete="organization"
/>
</FormField>
{showOptional && (
<div className="space-y-5">
{/* DOB + Gender (Optional WHMCS custom fields) */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Date of Birth" error={getError("dateOfBirth")} helperText="Optional">
<Input
name="bday"
type="date"
value={values.dateOfBirth ?? ""}
onChange={e => setValue("dateOfBirth", e.target.value || undefined)}
onBlur={() => setTouchedField("dateOfBirth")}
autoComplete="bday"
/>
</FormField>
<FormField label="Gender" error={getError("gender")} helperText="Optional">
<select
name="sex"
value={values.gender ?? ""}
onChange={e => 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}
>
<option value="">Select</option>
<option value="male">Male</option>
<option value="female">Female</option>
<option value="other">Other</option>
</select>
</FormField>
</div>
{/* Company (Optional) */}
<FormField label="Company" error={getError("company")} helperText="Optional">
<Input
name="organization"
value={values.company ?? ""}
onChange={e => setValue("company", e.target.value)}
onBlur={() => setTouchedField("company")}
placeholder="Company name"
autoComplete="organization"
/>
</FormField>
</div>
)}
</div>
);
}

View File

@ -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<string, string | undefined>;
touched: Record<string, boolean | undefined>;
setValue: (field: string, value: unknown) => void;
@ -99,6 +106,55 @@ export function PasswordStep({ form }: PasswordStepProps) {
{passwordsMatch ? "✓ Passwords match" : "✗ Passwords do not match"}
</p>
)}
<div className="space-y-3 rounded-xl border border-border bg-muted/30 p-4">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={values.acceptTerms}
onChange={e => setValue("acceptTerms", e.target.checked)}
onBlur={() => setTouchedField("acceptTerms")}
className="mt-0.5 h-5 w-5 text-primary border-input rounded focus:ring-ring"
/>
<span className="text-sm text-foreground/80">
I accept the{" "}
<Link
href="/terms"
className="text-primary hover:underline font-medium"
target="_blank"
>
Terms of Service
</Link>{" "}
and{" "}
<Link
href="/privacy"
className="text-primary hover:underline font-medium"
target="_blank"
>
Privacy Policy
</Link>
<span className="text-red-500 ml-1">*</span>
</span>
</label>
{touched.acceptTerms && errors.acceptTerms && (
<p className="text-sm text-danger ml-8">{errors.acceptTerms}</p>
)}
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={values.marketingConsent ?? false}
onChange={e => setValue("marketingConsent", e.target.checked)}
className="mt-0.5 h-5 w-5 text-primary border-input rounded focus:ring-ring"
/>
<span className="text-sm text-foreground/80">
Send me updates about new products and promotions
<span className="block text-xs text-muted-foreground mt-0.5">
You can unsubscribe anytime
</span>
</span>
</label>
</div>
</div>
);
}

View File

@ -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";

View File

@ -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,

View File

@ -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");
}

View File

@ -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(
<>
<div className="flex items-center justify-between mb-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between mb-4">
<div className="flex items-center space-x-3">
<MapPinIcon className="h-5 w-5 text-primary" />
<div>
@ -261,12 +265,37 @@ export function AddressConfirmation({
</h3>
</div>
</div>
<div className="flex items-center gap-2">
{/* Consistent status pill placement (right side) */}
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
<StatusPill
label={statusVariant === "success" ? "Verified" : "Pending confirmation"}
variant={statusVariant}
/>
{showConfirmAction ? (
<Button
type="button"
size="sm"
onClick={handleConfirmAddress}
onKeyDown={e => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
}
}}
>
Confirm Address
</Button>
) : null}
{showEditAction ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleEdit}
leftIcon={<PencilIcon className="h-4 w-4" />}
>
Edit Address
</Button>
) : null}
</div>
</div>
@ -415,38 +444,6 @@ export function AddressConfirmation({
Please confirm this is the correct installation address for your internet service.
</AlertBanner>
)}
{/* Action buttons */}
<div className="flex items-center gap-3 pt-4 border-t border-border">
{/* Primary action when pending for Internet orders */}
{isInternetOrder && !addressConfirmed && !editing && (
<Button
type="button"
onClick={handleConfirmAddress}
onKeyDown={e => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
}
}}
>
Confirm Address
</Button>
)}
{/* Edit button */}
{billingInfo.isComplete && !editing && (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleEdit}
leftIcon={<PencilIcon className="h-4 w-4" />}
>
Edit Address
</Button>
)}
</div>
</div>
) : (
<div className="text-center py-8">

View File

@ -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 */}
<div className={`flex flex-col gap-3 pb-4 border-b ${tierStyle.border}`}>
<div className="inline-flex flex-wrap items-center gap-2 text-sm">
<CardBadge
text={plan.internetPlanTier ?? "Plan"}
variant={getTierBadgeVariant()}
size="sm"
/>
{isGold && <CardBadge text="Recommended" variant="recommended" size="xs" />}
{planDetail && <CardBadge text={planDetail} variant="family" size="xs" />}
{showTierBadge && (
<CardBadge
text={plan.internetPlanTier ?? "Plan"}
variant={getTierBadgeVariant()}
size="sm"
/>
)}
{showTierBadge && isGold && (
<CardBadge text="Recommended" variant="recommended" size="xs" />
)}
</div>
{/* Plan name and description - Full width */}
<div className="w-full space-y-2">
<h3 className="text-xl sm:text-2xl font-bold text-foreground leading-tight">
{planBaseName}
{planTitle}
</h3>
{plan.catalogMetadata?.tierDescription || plan.description ? (
<p className="text-sm text-muted-foreground leading-relaxed">
{plan.catalogMetadata?.tierDescription || plan.description}
{showPlanSubtitle && planSubtitle && (
<p className="text-xs uppercase tracking-wide text-muted-foreground">
{planSubtitle}
</p>
)}
{planDescription ? (
<p className="text-sm text-muted-foreground leading-relaxed">{planDescription}</p>
) : null}
</div>
@ -201,12 +224,14 @@ export function InternetPlanCard({
</div>
{/* Features */}
<div className="flex-grow pt-1">
<h4 className="font-semibold text-foreground mb-4 text-sm uppercase tracking-wide">
Your Plan Includes:
</h4>
<ul className="space-y-3 text-sm">{renderPlanFeatures()}</ul>
</div>
{showFeatures && (
<div className="flex-grow pt-1">
<h4 className="font-semibold text-foreground mb-4 text-sm uppercase tracking-wide">
Your Plan Includes:
</h4>
<ul className="space-y-3 text-sm">{renderPlanFeatures()}</ul>
</div>
)}
{/* Action Button */}
<Button

View File

@ -9,6 +9,7 @@ import {
ShieldCheckIcon,
WifiIcon,
GlobeAltIcon,
CheckCircleIcon,
} from "@heroicons/react/24/outline";
import { ServiceHeroCard } from "@/features/catalog/components/common/ServiceHeroCard";
import { FeatureCard } from "@/features/catalog/components/common/FeatureCard";
@ -37,6 +38,35 @@ export function CatalogHomeView() {
</p>
</div>
<div className="bg-muted/40 border border-border rounded-2xl p-6 mb-10">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{[
{
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 => (
<div key={step.title} className="flex items-start gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-primary">
<CheckCircleIcon className="h-5 w-5" />
</div>
<div>
<div className="text-sm font-semibold text-foreground">{step.title}</div>
<div className="text-xs text-muted-foreground">{step.description}</div>
</div>
</div>
))}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-10">
<ServiceHeroCard
title="Internet Service"

View File

@ -1,6 +1,7 @@
"use client";
import { useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { PageLayout } from "@/components/templates/PageLayout";
import { WifiIcon, ServerIcon, HomeIcon, BuildingOfficeIcon } from "@heroicons/react/24/outline";
import { useInternetCatalog } from "@/features/catalog/hooks";
@ -24,12 +25,18 @@ import {
} from "@/features/catalog/hooks";
import { useAuthSession } from "@/features/auth/services/auth.store";
type AutoRequestStatus = "idle" | "submitting" | "submitted" | "failed" | "missing_address";
export function InternetPlansContainer() {
const shopBasePath = useShopBasePath();
const searchParams = useSearchParams();
const { user } = useAuthSession();
const { data, isLoading, error } = useInternetCatalog();
const eligibilityQuery = useInternetEligibility();
const eligibilityLoading = eligibilityQuery.isLoading;
const refetchEligibility = eligibilityQuery.refetch;
const eligibilityRequest = useRequestInternetEligibilityCheck();
const submitEligibilityRequest = eligibilityRequest.mutateAsync;
const plans: InternetPlanCatalogItem[] = useMemo(() => 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<AutoRequestStatus>("idle");
const [autoRequestId, setAutoRequestId] = useState<string | null>(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 <HomeIcon className="h-5 w-5" />;
@ -165,16 +231,36 @@ export function InternetPlansContainer() {
<CatalogBackLink href={shopBasePath} label="Back to Services" />
<CatalogHero
title="Choose Your Internet Plan"
description="High-speed fiber internet with reliable connectivity for your home or business."
title="Choose Your Internet Type"
description="Compare apartment vs home options and pick the speed that fits your address."
>
{eligibilityQuery.isLoading ? (
{eligibilityLoading ? (
<div className="flex flex-col items-center gap-2">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-border bg-muted shadow-[var(--cp-shadow-1)]">
<span className="font-semibold text-foreground">Checking availability</span>
</div>
<p className="text-sm text-muted-foreground text-center max-w-md">
Were verifying whether our service is available at your residence.
We're verifying whether our service is available at your residence.
</p>
</div>
) : autoRequestStatus === "submitting" ? (
<div className="flex flex-col items-center gap-2">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-border bg-muted shadow-[var(--cp-shadow-1)]">
<span className="font-semibold text-foreground">
Submitting availability request
</span>
</div>
<p className="text-sm text-muted-foreground text-center max-w-md">
We're sending your request now.
</p>
</div>
) : autoRequestStatus === "submitted" ? (
<div className="flex flex-col items-center gap-2">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-info/25 bg-info-soft text-info shadow-[var(--cp-shadow-1)]">
<span className="font-semibold">Availability review in progress</span>
</div>
<p className="text-sm text-muted-foreground text-center max-w-md">
We've received your request and will notify you when the review is complete.
</p>
</div>
) : isNotRequested ? (
@ -221,49 +307,81 @@ export function InternetPlansContainer() {
) : null}
</CatalogHero>
{isNotRequested && (
<AlertBanner variant="info" title="Request an eligibility review" className="mb-8">
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<p className="text-sm text-foreground/80">
Our team will verify NTT serviceability and update your eligible offerings. Well
notify you when review is complete.
</p>
{hasServiceAddress ? (
<Button
type="button"
size="sm"
disabled={eligibilityRequest.isPending}
isLoading={eligibilityRequest.isPending}
loadingText="Requesting…"
onClick={() =>
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
</Button>
) : (
<Button
as="a"
href="/account/settings"
size="sm"
className="sm:ml-auto whitespace-nowrap"
>
Add address to continue
</Button>
)}
</div>
{autoRequestStatus === "submitting" && (
<AlertBanner variant="info" title="Submitting availability request" className="mb-8">
We're sending your request now. You'll see updated eligibility once the review begins.
</AlertBanner>
)}
{autoRequestStatus === "submitted" && (
<AlertBanner variant="success" title="Request received" className="mb-8">
We've received your availability request. Our team will investigate and notify you when
the review is complete.
{autoRequestId && (
<div className="text-xs text-muted-foreground mt-2">
Request ID: <span className="font-mono">{autoRequestId}</span>
</div>
)}
</AlertBanner>
)}
{autoRequestStatus === "failed" && (
<AlertBanner variant="warning" title="We couldn't submit your request" className="mb-8">
Please try again below or contact support if this keeps happening.
</AlertBanner>
)}
{autoRequestStatus === "missing_address" && (
<AlertBanner variant="warning" title="We need your address to continue" className="mb-8">
Add your service address so we can submit the availability request.
</AlertBanner>
)}
{isNotRequested &&
autoRequestStatus !== "submitting" &&
autoRequestStatus !== "submitted" && (
<AlertBanner variant="info" title="Request an eligibility review" className="mb-8">
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<p className="text-sm text-foreground/80">
Our team will verify NTT serviceability and update your eligible offerings. Well
notify you when review is complete.
</p>
{hasServiceAddress ? (
<Button
type="button"
size="sm"
disabled={eligibilityRequest.isPending}
isLoading={eligibilityRequest.isPending}
loadingText="Requesting…"
onClick={() =>
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
</Button>
) : (
<Button
as="a"
href="/account/settings"
size="sm"
className="sm:ml-auto whitespace-nowrap"
>
Add address to continue
</Button>
)}
</div>
</AlertBanner>
)}
{isPending && (
<AlertBanner variant="info" title="Review in progress" className="mb-8">
<div className="space-y-2">
@ -347,6 +465,7 @@ export function InternetPlansContainer() {
<InternetPlanCard
plan={plan}
installations={installations}
titlePriority="base"
disabled={hasActiveInternet || orderingLocked}
disabledReason={
hasActiveInternet

View File

@ -8,6 +8,7 @@ import {
ShieldCheckIcon,
WifiIcon,
GlobeAltIcon,
CheckCircleIcon,
} from "@heroicons/react/24/outline";
import { ServiceHeroCard } from "@/features/catalog/components/common/ServiceHeroCard";
import { FeatureCard } from "@/features/catalog/components/common/FeatureCard";
@ -39,6 +40,35 @@ export function PublicCatalogHomeView() {
</p>
</div>
<div className="bg-muted/40 border border-border rounded-2xl p-6 mb-10">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{[
{
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 => (
<div key={step.title} className="flex items-start gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-primary">
<CheckCircleIcon className="h-5 w-5" />
</div>
<div>
<div className="text-sm font-semibold text-foreground">{step.title}</div>
<div className="text-xs text-muted-foreground">{step.description}</div>
</div>
</div>
))}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-10">
<ServiceHeroCard
title="Internet Service"

View File

@ -1,17 +1,11 @@
"use client";
import { useState } from "react";
import { useSearchParams } from "next/navigation";
import { ServerIcon, CheckIcon } from "@heroicons/react/24/outline";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { Button } from "@/components/atoms/button";
import { CheckIcon, ServerIcon } from "@heroicons/react/24/outline";
import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink";
import { CatalogHero } from "@/features/catalog/components/base/CatalogHero";
import { InlineAuthSection } from "@/features/auth/components/InlineAuthSection/InlineAuthSection";
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
import { useInternetPlan } from "@/features/catalog/hooks";
import { AuthModal } from "@/features/auth/components/AuthModal/AuthModal";
import { CardPricing } from "@/features/catalog/components/base/CardPricing";
import { Skeleton } from "@/components/atoms/loading-skeleton";
/**
* Public Internet Configure View
@ -23,170 +17,79 @@ export function PublicInternetConfigureView() {
const shopBasePath = useShopBasePath();
const searchParams = useSearchParams();
const planSku = searchParams?.get("planSku");
const { plan, isLoading } = useInternetPlan(planSku || undefined);
const [authModalOpen, setAuthModalOpen] = useState(false);
const [authMode, setAuthMode] = useState<"signup" | "login">("signup");
const redirectTo = planSku
? `/account/shop/internet/configure?planSku=${encodeURIComponent(planSku)}`
: "/account/shop/internet";
if (isLoading) {
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<CatalogBackLink href={`${shopBasePath}/internet`} label="Back to Internet plans" />
<div className="mt-8 space-y-6">
<Skeleton className="h-10 w-96" />
<Skeleton className="h-32 w-full" />
</div>
</div>
);
}
if (!plan) {
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<CatalogBackLink href={`${shopBasePath}/internet`} label="Back to Internet plans" />
<AlertBanner variant="error" title="Plan not found">
The selected plan could not be found. Please go back and select a plan.
</AlertBanner>
</div>
);
}
? `/account/shop/internet?autoEligibilityRequest=1&planSku=${encodeURIComponent(planSku)}`
: "/account/shop/internet?autoEligibilityRequest=1";
return (
<>
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<CatalogBackLink href={`${shopBasePath}/internet`} label="Back to Internet plans" />
<CatalogHero
title="Get started with your internet plan"
description="Create an account to verify service availability for your address and configure your plan."
title="Check availability for your address"
description="Create an account so we can verify service availability. We'll submit your request automatically."
/>
{/* Plan Summary Card */}
<div className="mt-8 bg-card border border-border rounded-2xl p-6 md:p-8 shadow-[var(--cp-shadow-1)]">
<div className="flex items-start gap-4 mb-6">
<div className="flex-shrink-0">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 border border-primary/20">
<ServerIcon className="h-6 w-6 text-primary" />
<div className="mt-8 space-y-8">
<div className="bg-card border border-border rounded-2xl p-6 md:p-8 shadow-[var(--cp-shadow-1)]">
<div className="flex items-start gap-4 mb-6">
<div className="flex-shrink-0">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 border border-primary/20">
<ServerIcon className="h-6 w-6 text-primary" />
</div>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-xl font-semibold text-foreground mb-2">
We'll check availability for your address
</h3>
<p className="text-sm text-muted-foreground">
This request unlocks the internet plans that match your building type and local
infrastructure.
</p>
</div>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-xl font-semibold text-foreground mb-2">{plan.name}</h3>
{plan.description && (
<p className="text-sm text-muted-foreground mb-4">{plan.description}</p>
)}
<div className="flex flex-wrap gap-2 mb-4">
{plan.internetPlanTier && (
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-primary/10 text-primary border border-primary/20">
{plan.internetPlanTier} Tier
</span>
)}
{plan.internetOfferingType && (
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-muted text-muted-foreground border border-border">
{plan.internetOfferingType}
</span>
)}
</div>
<div className="mt-4">
<CardPricing
monthlyPrice={plan.monthlyPrice}
oneTimePrice={plan.oneTimePrice}
size="lg"
alignment="left"
/>
</div>
</div>
</div>
{/* Plan Features */}
{plan.catalogMetadata?.features && plan.catalogMetadata.features.length > 0 && (
<div className="border-t border-border pt-6 mt-6">
<h4 className="text-sm font-semibold text-foreground mb-4 uppercase tracking-wide">
Plan Includes:
What happens next
</h4>
<ul className="grid grid-cols-1 md:grid-cols-2 gap-3">
{plan.catalogMetadata.features.slice(0, 6).map((feature, index) => {
const [label, detail] = feature.split(":");
return (
<li key={index} className="flex items-start gap-2">
<CheckIcon className="h-4 w-4 text-success mt-0.5 flex-shrink-0" />
<span className="text-sm text-muted-foreground">
{label.trim()}
{detail && (
<>
: <span className="text-foreground font-medium">{detail.trim()}</span>
</>
)}
</span>
</li>
);
})}
<ul className="space-y-3">
<li className="flex items-start gap-2">
<CheckIcon className="h-4 w-4 text-success mt-0.5 flex-shrink-0" />
<span className="text-sm text-muted-foreground">
Create your account and confirm your service address.
</span>
</li>
<li className="flex items-start gap-2">
<CheckIcon className="h-4 w-4 text-success mt-0.5 flex-shrink-0" />
<span className="text-sm text-muted-foreground">
We submit an availability request to our team.
</span>
</li>
<li className="flex items-start gap-2">
<CheckIcon className="h-4 w-4 text-success mt-0.5 flex-shrink-0" />
<span className="text-sm text-muted-foreground">
You'll be notified when your personalized plans are ready.
</span>
</li>
</ul>
</div>
)}
</div>
{/* Auth Prompt */}
<div className="mt-8 bg-muted/50 border border-border rounded-2xl p-6 md:p-8">
<div className="text-center mb-6">
<h3 className="text-lg font-semibold text-foreground mb-2">Ready to get started?</h3>
<p className="text-sm text-muted-foreground max-w-2xl mx-auto">
Create an account to verify service availability for your address and complete your
order. We&apos;ll guide you through the setup process.
</p>
</div>
<div className="flex flex-col sm:flex-row gap-3 justify-center max-w-md mx-auto">
<Button
onClick={() => {
setAuthMode("signup");
setAuthModalOpen(true);
}}
className="flex-1"
>
Create account
</Button>
<Button
onClick={() => {
setAuthMode("login");
setAuthModalOpen(true);
}}
variant="outline"
className="flex-1"
>
Sign in
</Button>
</div>
<div className="mt-6 pt-6 border-t border-border">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-center">
<div>
<div className="text-sm font-medium text-foreground mb-1">Verify Availability</div>
<div className="text-xs text-muted-foreground">Check service at your address</div>
</div>
<div>
<div className="text-sm font-medium text-foreground mb-1">Personalized Plans</div>
<div className="text-xs text-muted-foreground">See plans tailored to you</div>
</div>
<div>
<div className="text-sm font-medium text-foreground mb-1">Secure Ordering</div>
<div className="text-xs text-muted-foreground">Complete your order safely</div>
</div>
</div>
</div>
<InlineAuthSection
title="Ready to get started?"
description="Create an account so we can verify service availability for your address. We'll submit your request automatically and notify you when it's reviewed."
redirectTo={redirectTo}
highlights={[
{ title: "Verify Availability", description: "Check service at your address" },
{ title: "Personalized Plans", description: "See plans tailored to you" },
{ title: "Secure Ordering", description: "Complete your order safely" },
]}
/>
</div>
</div>
<AuthModal
isOpen={authModalOpen}
onClose={() => 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."
/>
</>
);
}

View File

@ -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() {
<Skeleton className="h-4 w-[32rem] max-w-full mx-auto" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
@ -107,14 +106,13 @@ export function PublicInternetPlansView() {
<CatalogBackLink href={shopBasePath} label="Back to Services" />
<CatalogHero
title="Choose Your Internet Plan"
description="High-speed fiber internet with reliable connectivity for your home or business."
title="Choose Your Internet Type"
description="Compare apartment vs home options and pick the speed that fits your address."
>
<div className="flex flex-col items-center gap-3">
<p className="text-sm text-muted-foreground text-center max-w-xl">
Prices shown are the <span className="font-medium text-foreground">Silver</span> 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.
</p>
<div className="flex flex-wrap justify-center gap-2">
{offeringTypes.map(type => (
@ -127,31 +125,27 @@ export function PublicInternetPlansView() {
</div>
))}
</div>
<div className="flex flex-col sm:flex-row gap-3 pt-2">
<Button as="a" href="/auth/signup" className="whitespace-nowrap">
Get started
</Button>
<Button as="a" href="/auth/login" variant="outline" className="whitespace-nowrap">
Sign in
</Button>
</div>
</div>
</CatalogHero>
{silverPlans.length > 0 ? (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 lg:gap-8">
<div id="plans" className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 lg:gap-8">
{silverPlans.map(plan => (
<div key={plan.id}>
<InternetPlanCard
plan={plan}
installations={installations}
titlePriority="detail"
disabled={false}
pricingPrefix="Starting from"
action={{
label: "Get started",
href: `/shop/internet/configure?planSku=${encodeURIComponent(plan.sku)}`,
}}
showTierBadge={false}
showPlanSubtitle={false}
showFeatures={false}
/>
</div>
))}

View File

@ -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 (
<>
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<CatalogBackLink href={`${shopBasePath}/sim`} label="Back to SIM plans" />
<CatalogHero
@ -64,143 +60,99 @@ export function PublicSimConfigureView() {
description="Create an account to complete your order, add a payment method, and complete identity verification."
/>
{/* Plan Summary Card */}
<div className="mt-8 bg-card border border-border rounded-2xl p-6 md:p-8 shadow-[var(--cp-shadow-1)]">
<div className="flex items-start gap-4 mb-6">
<div className="flex-shrink-0">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 border border-primary/20">
<DevicePhoneMobileIcon className="h-6 w-6 text-primary" />
</div>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-xl font-semibold text-foreground mb-2">{plan.name}</h3>
{plan.description && (
<p className="text-sm text-muted-foreground mb-4">{plan.description}</p>
)}
<div className="flex flex-wrap gap-2 mb-4">
{plan.simPlanType && (
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-primary/10 text-primary border border-primary/20">
{plan.simPlanType}
</span>
)}
</div>
<div className="mt-4">
<CardPricing
monthlyPrice={plan.monthlyPrice}
oneTimePrice={plan.oneTimePrice}
size="lg"
alignment="left"
/>
</div>
</div>
</div>
{/* Plan Details */}
{(plan.simDataSize || plan.description) && (
<div className="border-t border-border pt-6 mt-6">
<h4 className="text-sm font-semibold text-foreground mb-4 uppercase tracking-wide">
Plan Details:
</h4>
<ul className="grid grid-cols-1 md:grid-cols-2 gap-3">
{plan.simDataSize && (
<li className="flex items-start gap-2">
<CheckIcon className="h-4 w-4 text-success mt-0.5 flex-shrink-0" />
<span className="text-sm text-muted-foreground">
Data: <span className="text-foreground font-medium">{plan.simDataSize}</span>
</span>
</li>
)}
{plan.simPlanType && (
<li className="flex items-start gap-2">
<CheckIcon className="h-4 w-4 text-success mt-0.5 flex-shrink-0" />
<span className="text-sm text-muted-foreground">
Type: <span className="text-foreground font-medium">{plan.simPlanType}</span>
</span>
</li>
)}
{plan.simHasFamilyDiscount && (
<li className="flex items-start gap-2">
<CheckIcon className="h-4 w-4 text-success mt-0.5 flex-shrink-0" />
<span className="text-sm text-muted-foreground">
<span className="text-foreground font-medium">Family Discount Available</span>
</span>
</li>
)}
{plan.billingCycle && (
<li className="flex items-start gap-2">
<CheckIcon className="h-4 w-4 text-success mt-0.5 flex-shrink-0" />
<span className="text-sm text-muted-foreground">
Billing:{" "}
<span className="text-foreground font-medium">{plan.billingCycle}</span>
</span>
</li>
)}
</ul>
</div>
)}
</div>
{/* Auth Prompt */}
<div className="mt-8 bg-muted/50 border border-border rounded-2xl p-6 md:p-8">
<div className="text-center mb-6">
<h3 className="text-lg font-semibold text-foreground mb-2">Ready to order?</h3>
<p className="text-sm text-muted-foreground max-w-2xl mx-auto">
Create an account to complete your SIM order. You&apos;ll need to add a payment method
and complete identity verification.
</p>
</div>
<div className="flex flex-col sm:flex-row gap-3 justify-center max-w-md mx-auto">
<Button
onClick={() => {
setAuthMode("signup");
setAuthModalOpen(true);
}}
className="flex-1"
>
Create account
</Button>
<Button
onClick={() => {
setAuthMode("login");
setAuthModalOpen(true);
}}
variant="outline"
className="flex-1"
>
Sign in
</Button>
</div>
<div className="mt-6 pt-6 border-t border-border">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-center">
<div>
<div className="text-sm font-medium text-foreground mb-1">Secure Payment</div>
<div className="text-xs text-muted-foreground">Add payment method safely</div>
</div>
<div>
<div className="text-sm font-medium text-foreground mb-1">
Identity Verification
<div className="mt-8 space-y-8">
{/* Plan Summary Card */}
<div className="bg-card border border-border rounded-2xl p-6 md:p-8 shadow-[var(--cp-shadow-1)]">
<div className="flex items-start gap-4 mb-6">
<div className="flex-shrink-0">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 border border-primary/20">
<DevicePhoneMobileIcon className="h-6 w-6 text-primary" />
</div>
<div className="text-xs text-muted-foreground">Complete verification process</div>
</div>
<div>
<div className="text-sm font-medium text-foreground mb-1">Order Management</div>
<div className="text-xs text-muted-foreground">Track your order status</div>
<div className="flex-1 min-w-0">
<h3 className="text-xl font-semibold text-foreground mb-2">{plan.name}</h3>
{plan.description && (
<p className="text-sm text-muted-foreground mb-4">{plan.description}</p>
)}
<div className="flex flex-wrap gap-2 mb-4">
{plan.simPlanType && (
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-primary/10 text-primary border border-primary/20">
{plan.simPlanType}
</span>
)}
</div>
<div className="mt-4">
<CardPricing
monthlyPrice={plan.monthlyPrice}
oneTimePrice={plan.oneTimePrice}
size="lg"
alignment="left"
/>
</div>
</div>
</div>
{/* Plan Details */}
{(plan.simDataSize || plan.description) && (
<div className="border-t border-border pt-6 mt-6">
<h4 className="text-sm font-semibold text-foreground mb-4 uppercase tracking-wide">
Plan Details:
</h4>
<ul className="grid grid-cols-1 md:grid-cols-2 gap-3">
{plan.simDataSize && (
<li className="flex items-start gap-2">
<CheckIcon className="h-4 w-4 text-success mt-0.5 flex-shrink-0" />
<span className="text-sm text-muted-foreground">
Data:{" "}
<span className="text-foreground font-medium">{plan.simDataSize}</span>
</span>
</li>
)}
{plan.simPlanType && (
<li className="flex items-start gap-2">
<CheckIcon className="h-4 w-4 text-success mt-0.5 flex-shrink-0" />
<span className="text-sm text-muted-foreground">
Type:{" "}
<span className="text-foreground font-medium">{plan.simPlanType}</span>
</span>
</li>
)}
{plan.simHasFamilyDiscount && (
<li className="flex items-start gap-2">
<CheckIcon className="h-4 w-4 text-success mt-0.5 flex-shrink-0" />
<span className="text-sm text-muted-foreground">
<span className="text-foreground font-medium">
Family Discount Available
</span>
</span>
</li>
)}
{plan.billingCycle && (
<li className="flex items-start gap-2">
<CheckIcon className="h-4 w-4 text-success mt-0.5 flex-shrink-0" />
<span className="text-sm text-muted-foreground">
Billing:{" "}
<span className="text-foreground font-medium">{plan.billingCycle}</span>
</span>
</li>
)}
</ul>
</div>
)}
</div>
<InlineAuthSection
title="Ready to order?"
description="Create an account to complete your SIM order. You'll need to add a payment method and complete identity verification."
redirectTo={redirectTarget}
highlights={[
{ title: "Secure Payment", description: "Add payment method safely" },
{ title: "Identity Verification", description: "Complete verification process" },
{ title: "Order Management", description: "Track your order status" },
]}
/>
</div>
</div>
<AuthModal
isOpen={authModalOpen}
onClose={() => setAuthModalOpen(false)}
initialMode={authMode}
redirectTo={redirectTarget}
title="Create your account"
description="Ordering requires a payment method and identity verification."
/>
</>
);
}

View File

@ -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."
/>
<AlertBanner
variant="info"
title="Account required to order"
className="mb-8 max-w-4xl mx-auto"
>
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<p className="text-sm text-foreground/80">
To place a SIM order youll need an account, a payment method, and identity
verification.
</p>
<div className="flex gap-3 sm:ml-auto">
<Button as="a" href="/auth/signup" size="sm" className="whitespace-nowrap">
Get started
</Button>
<Button
as="a"
href={`/auth/login?redirect=${encodeURIComponent("/account/shop/sim")}`}
size="sm"
variant="outline"
className="whitespace-nowrap"
>
Sign in
</Button>
</div>
</div>
</AlertBanner>
<div className="mb-8 flex justify-center">
<div className="border-b border-border">
<nav className="-mb-px flex space-x-6" aria-label="Tabs">
@ -208,7 +181,7 @@ export function PublicSimPlansView() {
</div>
</div>
<div className="min-h-[500px] relative">
<div id="plans" className="min-h-[500px] relative">
{activeTab === "data-voice" && (
<div className="animate-in fade-in duration-300">
<SimPlanTypeSection

View File

@ -1,6 +1,6 @@
"use client";
import { useCallback, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { ShieldCheckIcon, CreditCardIcon } from "@heroicons/react/24/outline";
@ -24,8 +24,10 @@ import {
useSubmitResidenceCard,
} from "@/features/verification/hooks/useResidenceCardVerification";
import { useAuthSession } from "@/features/auth/services/auth.store";
import { apiClient } from "@/lib/api";
import { ORDER_TYPE, type OrderTypeValue } from "@customer-portal/domain/orders";
import { ssoLinkResponseSchema } from "@customer-portal/domain/auth";
import type { PaymentMethod } from "@customer-portal/domain/payments";
export function AccountCheckoutContainer() {
@ -39,6 +41,8 @@ export function AccountCheckoutContainer() {
const [submitting, setSubmitting] = useState(false);
const [addressConfirmed, setAddressConfirmed] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [openingPaymentPortal, setOpeningPaymentPortal] = useState(false);
const paymentToastTimeoutRef = useRef<number | null>(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={
<div className="flex items-center gap-2">
{hasPaymentMethod ? <StatusPill label="Verified" variant="success" /> : undefined}
<Button as="a" href="/account/billing/payments" size="sm" variant="outline">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => void handleManagePayment()}
isLoading={openingPaymentPortal}
loadingText="Opening..."
>
{hasPaymentMethod ? "Change" : "Add"}
</Button>
</div>
@ -374,7 +430,13 @@ export function AccountCheckoutContainer() {
>
Check Again
</Button>
<Button as="a" href="/account/billing/payments" size="sm">
<Button
type="button"
size="sm"
onClick={() => void handleManagePayment()}
isLoading={openingPaymentPortal}
loadingText="Opening..."
>
Add Payment Method
</Button>
</div>
@ -414,7 +476,13 @@ export function AccountCheckoutContainer() {
>
Check Again
</Button>
<Button as="a" href="/account/billing/payments" size="sm">
<Button
type="button"
size="sm"
onClick={() => void handleManagePayment()}
isLoading={openingPaymentPortal}
loadingText="Opening..."
>
Add Payment Method
</Button>
</div>

View File

@ -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)

View File

@ -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<ErrorCodeType, string> = {
[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<ErrorCodeType, ErrorMetadata> = {
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",

View File

@ -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";

View File

@ -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;
}

View File

@ -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";
}

View File

@ -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";

View File

@ -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<typeof opportunityRecordSchema>;
// ============================================================================
// 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<typeof createOpportunityRequestSchema>;
// ============================================================================
// 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<typeof updateOpportunityStageRequestSchema>;
// ============================================================================
// 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<typeof cancellationFormDataSchema>;
/**
* 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<typeof cancellationOpportunityDataSchema>;
// ============================================================================
// 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<typeof cancellationMonthOptionSchema>;
/**
* 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<typeof cancellationEligibilitySchema>;
// ============================================================================
// 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<typeof cancellationStatusSchema>;
// ============================================================================
// 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<typeof opportunityLookupCriteriaSchema>;
// ============================================================================
// 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<typeof opportunityMatchResultSchema>;
// ============================================================================
// 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(),
}),
});

View File

@ -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]),

View File

@ -43,6 +43,7 @@ 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(),
@ -107,20 +108,26 @@ export type SalesforceOrderRecord = z.infer<typeof salesforceOrderRecordSchema>;
/**
* 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<typeof salesforceOrderProvisionEventPayloadSchema>;
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<typeof salesforceOrderProvisionEventSchema>;
@ -140,19 +147,23 @@ export type SalesforcePubSubSubscription = z.infer<typeof salesforcePubSubSubscr
/**
* Pub/Sub error metadata
*/
export const salesforcePubSubErrorMetadataSchema = z.object({
"error-code": z.array(z.string()).optional(),
}).passthrough();
export const salesforcePubSubErrorMetadataSchema = z
.object({
"error-code": z.array(z.string()).optional(),
})
.passthrough();
export type SalesforcePubSubErrorMetadata = z.infer<typeof salesforcePubSubErrorMetadataSchema>;
/**
* 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<typeof salesforcePubSubErrorSchema>;

View File

@ -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<string, string> = {
...(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 = {

View File

@ -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

View File

@ -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,

View File

@ -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"

View File

@ -18,6 +18,7 @@
"customer/**/*",
"dashboard/**/*",
"mappings/**/*",
"opportunity/**/*",
"orders/**/*",
"payments/**/*",
"providers/**/*",