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:
parent
f5cde96027
commit
d9734b0c82
@ -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;
|
||||
@ -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,
|
||||
],
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -42,6 +42,7 @@ export class OrderFieldMapService {
|
||||
"CreatedDate",
|
||||
"LastModifiedDate",
|
||||
"Pricebook2Id",
|
||||
"OpportunityId", // Linked Opportunity for lifecycle tracking
|
||||
order.activationType,
|
||||
order.activationStatus,
|
||||
order.activationScheduledAt,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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}`,
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
/* eslint-env node */
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
/* eslint-env node */
|
||||
|
||||
/**
|
||||
* Bundle size monitoring script
|
||||
* Analyzes bundle size and reports on performance metrics
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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">
|
||||
We’re 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. We’ll
|
||||
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. We’ll
|
||||
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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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'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."
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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'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."
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 you’ll 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
|
||||
|
||||
@ -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>
|
||||
|
||||
491
docs/salesforce/OPPORTUNITY-LIFECYCLE-GUIDE.md
Normal file
491
docs/salesforce/OPPORTUNITY-LIFECYCLE-GUIDE.md
Normal 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)
|
||||
@ -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",
|
||||
|
||||
@ -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";
|
||||
|
||||
739
packages/domain/opportunity/contract.ts
Normal file
739
packages/domain/opportunity/contract.ts
Normal 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;
|
||||
}
|
||||
382
packages/domain/opportunity/helpers.ts
Normal file
382
packages/domain/opportunity/helpers.ts
Normal 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";
|
||||
}
|
||||
140
packages/domain/opportunity/index.ts
Normal file
140
packages/domain/opportunity/index.ts
Normal 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";
|
||||
306
packages/domain/opportunity/schema.ts
Normal file
306
packages/domain/opportunity/schema.ts
Normal 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(),
|
||||
}),
|
||||
});
|
||||
@ -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]),
|
||||
|
||||
@ -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>;
|
||||
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
"customer/**/*",
|
||||
"dashboard/**/*",
|
||||
"mappings/**/*",
|
||||
"opportunity/**/*",
|
||||
"orders/**/*",
|
||||
"payments/**/*",
|
||||
"providers/**/*",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user