Enhance Opportunity Management and Eligibility Handling

- Updated SalesforceOpportunityService to allow filtering by stages during opportunity retrieval, improving flexibility in eligibility checks.
- Integrated DistributedLockService into InternetCatalogService and OrderOrchestrator to prevent race conditions when creating or reusing opportunities.
- Refactored opportunity matching logic to ensure proper handling of stages during eligibility requests and order placements.
- Improved documentation to clarify the opportunity lifecycle and eligibility verification processes, ensuring better understanding for future development.
This commit is contained in:
barsa 2025-12-23 16:44:45 +09:00
parent 90ab71b94d
commit a61c2dd68b
33 changed files with 484 additions and 971 deletions

View File

@ -280,7 +280,7 @@ When running `pnpm dev:tools`, you get access to:
- `POST /api/auth/signup` - Create portal user → WHMCS AddClient → SF upsert - `POST /api/auth/signup` - Create portal user → WHMCS AddClient → SF upsert
- `POST /api/auth/login` - Portal authentication - `POST /api/auth/login` - Portal authentication
- `POST /api/auth/link-whmcs` - OIDC callback or ValidateLogin - `POST /api/auth/migrate` - Account migration from legacy portal
- `POST /api/auth/set-password` - Required after WHMCS link - `POST /api/auth/set-password` - Required after WHMCS link
### User Management ### User Management

View File

@ -51,7 +51,7 @@ export class CsrfMiddleware implements NestMiddleware {
"/api/auth/request-password-reset", "/api/auth/request-password-reset",
"/api/auth/reset-password", // Public auth endpoint for password reset "/api/auth/reset-password", // Public auth endpoint for password reset
"/api/auth/set-password", // Public auth endpoint for setting password after WHMCS link "/api/auth/set-password", // Public auth endpoint for setting password after WHMCS link
"/api/auth/link-whmcs", // Public auth endpoint for WHMCS account linking "/api/auth/migrate", // Public auth endpoint for account migration
"/api/health", "/api/health",
"/docs", "/docs",
"/api/webhooks", // Webhooks typically don't use CSRF "/api/webhooks", // Webhooks typically don't use CSRF

View File

@ -304,23 +304,28 @@ export class SalesforceOpportunityService {
*/ */
async findOpenOpportunityForAccount( async findOpenOpportunityForAccount(
accountId: string, accountId: string,
productType: OpportunityProductTypeValue productType: OpportunityProductTypeValue,
options?: { stages?: OpportunityStageValue[] }
): Promise<string | null> { ): Promise<string | null> {
const safeAccountId = assertSalesforceId(accountId, "accountId"); const safeAccountId = assertSalesforceId(accountId, "accountId");
// Get the CommodityType value(s) that match this product type // Get the CommodityType value(s) that match this product type
const commodityTypeValues = this.getCommodityTypesForProductType(productType); const commodityTypeValues = this.getCommodityTypesForProductType(productType);
const stages =
Array.isArray(options?.stages) && options?.stages.length > 0
? options.stages
: OPEN_OPPORTUNITY_STAGES;
this.logger.debug("Looking for open Opportunity", { this.logger.debug("Looking for open Opportunity", {
accountId: safeAccountId, accountId: safeAccountId,
productType, productType,
commodityTypes: commodityTypeValues, commodityTypes: commodityTypeValues,
stages,
}); });
// Build stage filter for open stages // Build stage filter for open stages
const stageList = OPEN_OPPORTUNITY_STAGES.map((s: OpportunityStageValue) => `'${s}'`).join( const stageList = stages.map((s: OpportunityStageValue) => `'${s}'`).join(", ");
", "
);
const commodityTypeList = commodityTypeValues.map(ct => `'${ct}'`).join(", "); const commodityTypeList = commodityTypeValues.map(ct => `'${ct}'`).join(", ");
const soql = ` const soql = `

View File

@ -230,11 +230,11 @@ export class AuthController {
} }
@Public() @Public()
@Post("link-whmcs") @Post("migrate")
@UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard) @UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard)
@RateLimit({ limit: 5, ttl: 600 }) // 5 attempts per 10 minutes per IP (industry standard) @RateLimit({ limit: 5, ttl: 600 }) // 5 attempts per 10 minutes per IP (industry standard)
@UsePipes(new ZodValidationPipe(linkWhmcsRequestSchema)) @UsePipes(new ZodValidationPipe(linkWhmcsRequestSchema))
async linkWhmcs(@Body() linkData: LinkWhmcsRequest, @Req() _req: Request) { async migrateAccount(@Body() linkData: LinkWhmcsRequest, @Req() _req: Request) {
const result = await this.authFacade.linkWhmcsUser(linkData); const result = await this.authFacade.linkWhmcsUser(linkData);
return linkWhmcsResponseSchema.parse(result); return linkWhmcsResponseSchema.parse(result);
} }

View File

@ -18,6 +18,7 @@ import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.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 { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js";
import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js"; import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js";
import { DistributedLockService } from "@bff/infra/cache/distributed-lock.service.js";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { getErrorMessage } from "@bff/core/utils/error.util.js"; import { getErrorMessage } from "@bff/core/utils/error.util.js";
import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util.js"; import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util.js";
@ -28,6 +29,7 @@ import {
OPPORTUNITY_STAGE, OPPORTUNITY_STAGE,
OPPORTUNITY_SOURCE, OPPORTUNITY_SOURCE,
OPPORTUNITY_PRODUCT_TYPE, OPPORTUNITY_PRODUCT_TYPE,
OPPORTUNITY_MATCH_STAGES_INTERNET_ELIGIBILITY,
} from "@customer-portal/domain/opportunity"; } from "@customer-portal/domain/opportunity";
export type InternetEligibilityStatusDto = "not_requested" | "pending" | "eligible" | "ineligible"; export type InternetEligibilityStatusDto = "not_requested" | "pending" | "eligible" | "ineligible";
@ -49,6 +51,7 @@ export class InternetCatalogService extends BaseCatalogService {
@Inject(Logger) logger: Logger, @Inject(Logger) logger: Logger,
private mappingsService: MappingsService, private mappingsService: MappingsService,
private catalogCache: CatalogCacheService, private catalogCache: CatalogCacheService,
private lockService: DistributedLockService,
private opportunityService: SalesforceOpportunityService, private opportunityService: SalesforceOpportunityService,
private caseService: SalesforceCaseService private caseService: SalesforceCaseService
) { ) {
@ -278,67 +281,88 @@ export class InternetCatalogService extends BaseCatalogService {
} }
try { try {
// 1. Find or create Opportunity for Internet eligibility const lockKey = `internet:eligibility:${sfAccountId}`;
// Only match Introduction stage (not Ready/Post Processing - those have progressed)
let opportunityId = await this.opportunityService.findOpenOpportunityForAccount( const caseId = await this.lockService.withLock(
sfAccountId, lockKey,
OPPORTUNITY_PRODUCT_TYPE.INTERNET async () => {
// Idempotency: if we already have a pending request, return the existing request id.
const existing = await this.queryEligibilityDetails(sfAccountId);
if (existing.status === "pending" && existing.requestId) {
this.logger.log("Eligibility request already pending; returning existing request id", {
userId,
sfAccountIdTail: sfAccountId.slice(-4),
caseIdTail: existing.requestId.slice(-4),
});
return existing.requestId;
}
// 1) Find or create Opportunity for Internet eligibility
// Only match Introduction stage. "Ready"/"Post Processing"/"Active" indicate the journey progressed.
let opportunityId = await this.opportunityService.findOpenOpportunityForAccount(
sfAccountId,
OPPORTUNITY_PRODUCT_TYPE.INTERNET,
{ stages: OPPORTUNITY_MATCH_STAGES_INTERNET_ELIGIBILITY }
);
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 createdCaseId = await this.caseService.createEligibilityCase({
accountId: sfAccountId,
opportunityId,
subject,
description: descriptionLines.join("\n"),
});
// 4) Update Account eligibility status
await this.updateAccountEligibilityRequestState(sfAccountId, createdCaseId);
await this.catalogCache.invalidateEligibility(sfAccountId);
this.logger.log("Created eligibility Case linked to Opportunity", {
userId,
sfAccountIdTail: sfAccountId.slice(-4),
caseIdTail: createdCaseId.slice(-4),
opportunityIdTail: opportunityId.slice(-4),
opportunityCreated,
});
return createdCaseId;
},
{ ttlMs: 10_000 }
); );
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"),
});
// 4. Update Account eligibility status
await this.updateAccountEligibilityRequestState(sfAccountId, caseId);
await this.catalogCache.invalidateEligibility(sfAccountId);
this.logger.log("Created eligibility Case linked to Opportunity", {
userId,
sfAccountIdTail: sfAccountId.slice(-4),
caseIdTail: caseId.slice(-4),
opportunityIdTail: opportunityId.slice(-4),
opportunityCreated,
});
return caseId; return caseId;
} catch (error) { } catch (error) {
this.logger.error("Failed to create eligibility request", { this.logger.error("Failed to create eligibility request", {

View File

@ -129,7 +129,8 @@ export class NotificationService {
userId, userId,
expiresAt: { gt: now }, expiresAt: { gt: now },
...(options?.includeDismissed ? {} : { dismissed: false }), ...(options?.includeDismissed ? {} : { dismissed: false }),
...(options?.includeRead ? {} : {}), // Include all by default for the list // By default we include read notifications. If includeRead=false, filter them out.
...(options?.includeRead === false ? { read: false } : {}),
}; };
try { try {

View File

@ -1,561 +0,0 @@
/**
* 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 { DistributedLockService } from "@bff/infra/cache/distributed-lock.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,
private readonly lockService: DistributedLockService,
@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.
*
* Uses a distributed lock to prevent race conditions when multiple
* concurrent requests try to create Opportunities for the same account.
*
* @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");
}
// Use distributed lock to prevent race conditions
// Lock key is specific to account + product type
const lockKey = `opportunity:${context.accountId}:${productType}`;
return this.lockService.withLock(
lockKey,
async () => {
// Re-check for existing Opportunity after acquiring lock
// Another request may have created one while we were waiting
const existingOppId = await this.opportunityService.findOpenOpportunityForAccount(
context.accountId,
productType
);
if (existingOppId) {
this.logger.debug("Found existing Opportunity after acquiring lock", {
opportunityId: existingOppId,
});
return this.useExistingOpportunity(existingOppId);
}
// No existing Opportunity found - create new one
return this.createNewOpportunity(context, productType);
},
{ ttlMs: 10_000 } // 10 second lock TTL
);
}
// ==========================================================================
// Opportunity Creation Triggers
// ==========================================================================
/**
* Create Opportunity at eligibility request (Internet only)
*
* Called when customer requests Internet eligibility check.
* Uses distributed lock to prevent duplicate Opportunities.
* First checks for existing open Opportunity before creating.
*
* 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 Opportunity ID (existing or newly created)
*/
async createOpportunityForEligibility(accountId: string): Promise<string> {
this.logger.log("Creating Opportunity for Internet eligibility request", {
accountId,
});
const lockKey = `opportunity:${accountId}:${OPPORTUNITY_PRODUCT_TYPE.INTERNET}`;
return this.lockService.withLock(
lockKey,
async () => {
// Check for existing open Opportunity first
const existingOppId = await this.opportunityService.findOpenOpportunityForAccount(
accountId,
OPPORTUNITY_PRODUCT_TYPE.INTERNET
);
if (existingOppId) {
this.logger.debug("Found existing Internet Opportunity, reusing", {
opportunityId: existingOppId,
});
return existingOppId;
}
// Create new Opportunity
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;
},
{ ttlMs: 10_000 }
);
}
/**
* Create Opportunity at checkout registration (SIM only)
*
* Called when customer creates account during SIM checkout.
* Uses distributed lock to prevent duplicate Opportunities.
* First checks for existing open Opportunity before creating.
*
* NOTE: Opportunity Name is auto-generated by Salesforce workflow.
*
* @param accountId - Salesforce Account ID
* @returns Opportunity ID (existing or newly created)
*/
async createOpportunityForCheckoutRegistration(accountId: string): Promise<string> {
this.logger.log("Creating Opportunity for SIM checkout registration", {
accountId,
});
const lockKey = `opportunity:${accountId}:${OPPORTUNITY_PRODUCT_TYPE.SIM}`;
return this.lockService.withLock(
lockKey,
async () => {
// Check for existing open Opportunity first
const existingOppId = await this.opportunityService.findOpenOpportunityForAccount(
accountId,
OPPORTUNITY_PRODUCT_TYPE.SIM
);
if (existingOppId) {
this.logger.debug("Found existing SIM Opportunity, reusing", {
opportunityId: existingOppId,
});
return existingOppId;
}
// Create new Opportunity
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;
},
{ ttlMs: 10_000 }
);
}
// ==========================================================================
// Lifecycle Stage Updates
// ==========================================================================
/**
* Update Opportunity stage to Ready (eligible)
*
* Called when Internet eligibility is confirmed.
*
* @param opportunityId - Opportunity ID to update
*/
async markEligible(opportunityId: string): Promise<void> {
this.logger.log("Marking Opportunity as eligible (Ready)", { opportunityId });
await this.opportunityService.updateStage(
opportunityId,
OPPORTUNITY_STAGE.READY,
"Eligibility confirmed"
);
}
/**
* Update Opportunity stage to Void (not eligible)
*
* Called when Internet eligibility check fails.
*
* @param opportunityId - Opportunity ID to update
*/
async markNotEligible(opportunityId: string): Promise<void> {
this.logger.log("Marking Opportunity as not eligible (Void)", { opportunityId });
await this.opportunityService.updateStage(
opportunityId,
OPPORTUNITY_STAGE.VOID,
"Not eligible for service"
);
}
/**
* Update Opportunity stage after order placement
*
* Called after order is successfully created in Salesforce.
*
* @param opportunityId - Opportunity ID to update
*/
async markOrderPlaced(opportunityId: string): Promise<void> {
this.logger.log("Marking Opportunity as order placed (Post Processing)", { opportunityId });
await this.opportunityService.updateStage(
opportunityId,
OPPORTUNITY_STAGE.POST_PROCESSING,
"Order placed via portal"
);
}
/**
* Update Opportunity stage after provisioning
*
* Called after order is successfully provisioned.
*
* @param opportunityId - Opportunity ID to update
* @param whmcsServiceId - WHMCS Service ID to link
*/
async markProvisioned(opportunityId: string, whmcsServiceId: number): Promise<void> {
this.logger.log("Marking Opportunity as active", {
opportunityId,
whmcsServiceId,
});
// Update stage to Active
await this.opportunityService.updateStage(
opportunityId,
OPPORTUNITY_STAGE.ACTIVE,
"Service provisioned successfully"
);
// Link WHMCS Service ID for cancellation workflows
await this.opportunityService.linkWhmcsServiceToOpportunity(opportunityId, whmcsServiceId);
}
/**
* Update Opportunity for eligibility result
*
* Called when eligibility check is completed (eligible or not).
*
* @param opportunityId - Opportunity ID to update
* @param isEligible - Whether the customer is eligible
*/
async updateEligibilityResult(opportunityId: string, isEligible: boolean): Promise<void> {
if (isEligible) {
await this.markEligible(opportunityId);
} else {
await this.markNotEligible(opportunityId);
}
}
// ==========================================================================
// Cancellation Flow
// ==========================================================================
/**
* Get cancellation eligibility for a customer
*
* Calculates available cancellation months based on the 25th rule.
*
* @returns Cancellation eligibility details
*/
getCancellationEligibility(): CancellationEligibility {
return getCancellationEligibility();
}
/**
* Validate a cancellation request
*
* Checks:
* - Month format is valid
* - Month is not before earliest allowed
* - Both confirmations are checked
*
* @param formData - Form data from customer
* @returns Validation result
*/
validateCancellationRequest(formData: CancellationFormData): { valid: boolean; error?: string } {
// Validate month
const monthValidation = validateCancellationMonth(formData.cancellationMonth);
if (!monthValidation.valid) {
return monthValidation;
}
// Validate confirmations
if (!formData.confirmTermsRead) {
return {
valid: false,
error: "You must confirm you have read the cancellation terms",
};
}
if (!formData.confirmMonthEndCancellation) {
return {
valid: false,
error: "You must confirm you understand cancellation is at month end",
};
}
return { valid: true };
}
/**
* Process a cancellation request
*
* Finds the Opportunity for the service and updates it with cancellation data.
*
* @param whmcsServiceId - WHMCS Service ID
* @param formData - Cancellation form data
* @returns Result of the cancellation update
*/
async processCancellationRequest(
whmcsServiceId: number,
formData: CancellationFormData
): Promise<{ success: boolean; opportunityId?: string; error?: string }> {
this.logger.log("Processing cancellation request", {
whmcsServiceId,
cancellationMonth: formData.cancellationMonth,
});
// Validate the request
const validation = this.validateCancellationRequest(formData);
if (!validation.valid) {
return { success: false, error: validation.error };
}
// Find the Opportunity
const opportunityId =
await this.opportunityService.findOpportunityByWhmcsServiceId(whmcsServiceId);
if (!opportunityId) {
this.logger.warn("No Opportunity found for WHMCS Service", { whmcsServiceId });
// This is not necessarily an error - older services may not have Opportunities
// Return success but note no Opportunity was found
return { success: true };
}
// Transform form data to Opportunity data
const cancellationData: CancellationOpportunityData =
transformCancellationFormToOpportunityData(formData);
// Update the Opportunity
await this.opportunityService.updateCancellationData(opportunityId, cancellationData);
this.logger.log("Cancellation request processed successfully", {
opportunityId,
scheduledDate: cancellationData.scheduledCancellationDate,
});
return { success: true, opportunityId };
}
// ==========================================================================
// Private Helpers
// ==========================================================================
/**
* Use an existing Opportunity (validate it exists)
*/
private async useExistingOpportunity(opportunityId: string): Promise<ResolvedOpportunity> {
this.logger.debug("Using existing Opportunity", { opportunityId });
const opportunity = await this.opportunityService.getOpportunityById(opportunityId);
if (!opportunity) {
this.logger.warn("Existing Opportunity not found, will need to create new", {
opportunityId,
});
throw new Error(`Opportunity ${opportunityId} not found`);
}
return {
opportunityId: opportunity.id,
wasCreated: false,
stage: opportunity.stage,
};
}
/**
* Create a new Opportunity for an order
*/
private async createNewOpportunity(
context: OpportunityResolutionContext,
productType: OpportunityProductTypeValue
): Promise<ResolvedOpportunity> {
this.logger.debug("Creating new Opportunity for order", {
accountId: context.accountId,
productType,
});
const stage = this.determineInitialStage(context);
const source = this.determineSource(context);
// Salesforce workflow auto-generates Opportunity Name
const opportunityId = await this.opportunityService.createOpportunity({
accountId: context.accountId,
productType,
stage,
source,
applicationStage: APPLICATION_STAGE.INTRO_1,
});
return {
opportunityId,
wasCreated: true,
stage,
};
}
/**
* Map OrderType to OpportunityProductType
*/
private mapOrderTypeToProductType(orderType: OrderTypeValue): OpportunityProductTypeValue | null {
switch (orderType) {
case "Internet":
return OPPORTUNITY_PRODUCT_TYPE.INTERNET;
case "SIM":
return OPPORTUNITY_PRODUCT_TYPE.SIM;
case "VPN":
return OPPORTUNITY_PRODUCT_TYPE.VPN;
default:
return null;
}
}
/**
* Determine initial stage for new Opportunity
*/
private determineInitialStage(context: OpportunityResolutionContext): OpportunityStageValue {
// If coming from eligibility request
if (context.source === "eligibility") {
return OPPORTUNITY_STAGE.INTRODUCTION;
}
// If coming from checkout registration
if (context.source === "checkout") {
return OPPORTUNITY_STAGE.INTRODUCTION;
}
// Default: order placement - go to Post Processing
return OPPORTUNITY_STAGE.POST_PROCESSING;
}
/**
* Determine source for new Opportunity
*/
private determineSource(
context: OpportunityResolutionContext
): (typeof OPPORTUNITY_SOURCE)[keyof typeof OPPORTUNITY_SOURCE] {
switch (context.source) {
case "eligibility":
return OPPORTUNITY_SOURCE.INTERNET_ELIGIBILITY;
case "checkout":
return OPPORTUNITY_SOURCE.SIM_CHECKOUT_REGISTRATION;
case "order":
default:
return OPPORTUNITY_SOURCE.ORDER_PLACEMENT;
}
}
}

View File

@ -2,6 +2,7 @@ import { Injectable, Inject, NotFoundException } from "@nestjs/common";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { SalesforceOrderService } from "@bff/integrations/salesforce/services/salesforce-order.service.js"; import { SalesforceOrderService } from "@bff/integrations/salesforce/services/salesforce-order.service.js";
import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js"; import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js";
import { DistributedLockService } from "@bff/infra/cache/distributed-lock.service.js";
import { OrderValidator } from "./order-validator.service.js"; import { OrderValidator } from "./order-validator.service.js";
import { OrderBuilder } from "./order-builder.service.js"; import { OrderBuilder } from "./order-builder.service.js";
import { OrderItemBuilder } from "./order-item-builder.service.js"; import { OrderItemBuilder } from "./order-item-builder.service.js";
@ -14,6 +15,7 @@ import {
OPPORTUNITY_SOURCE, OPPORTUNITY_SOURCE,
OPPORTUNITY_PRODUCT_TYPE, OPPORTUNITY_PRODUCT_TYPE,
type OpportunityProductTypeValue, type OpportunityProductTypeValue,
OPPORTUNITY_MATCH_STAGES_ORDER_PLACEMENT,
} from "@customer-portal/domain/opportunity"; } from "@customer-portal/domain/opportunity";
type OrderDetailsResponse = OrderDetails; type OrderDetailsResponse = OrderDetails;
@ -29,6 +31,7 @@ export class OrderOrchestrator {
@Inject(Logger) private readonly logger: Logger, @Inject(Logger) private readonly logger: Logger,
private readonly salesforceOrderService: SalesforceOrderService, private readonly salesforceOrderService: SalesforceOrderService,
private readonly opportunityService: SalesforceOpportunityService, private readonly opportunityService: SalesforceOpportunityService,
private readonly lockService: DistributedLockService,
private readonly orderValidator: OrderValidator, private readonly orderValidator: OrderValidator,
private readonly orderBuilder: OrderBuilder, private readonly orderBuilder: OrderBuilder,
private readonly orderItemBuilder: OrderItemBuilder, private readonly orderItemBuilder: OrderItemBuilder,
@ -163,34 +166,43 @@ export class OrderOrchestrator {
} }
try { try {
// Try to find existing open Opportunity (Introduction or Ready stage) const lockKey = `opportunity:order:${safeAccountId}:${productType}`;
const existingOppId = await this.opportunityService.findOpenOpportunityForAccount(
safeAccountId, return await this.lockService.withLock(
productType lockKey,
async () => {
// Try to find existing matchable Opportunity (Introduction or Ready stage)
const existingOppId = await this.opportunityService.findOpenOpportunityForAccount(
safeAccountId,
productType,
{ stages: OPPORTUNITY_MATCH_STAGES_ORDER_PLACEMENT }
);
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;
},
{ ttlMs: 10_000 }
); );
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 { } catch {
this.logger.warn("Failed to resolve Opportunity for order", { this.logger.warn("Failed to resolve Opportunity for order", {
orderType, orderType,

View File

@ -1,5 +0,0 @@
import LinkWhmcsView from "@/features/auth/views/LinkWhmcsView";
export default function LinkWhmcsPage() {
return <LinkWhmcsView />;
}

View File

@ -0,0 +1,5 @@
import MigrateAccountView from "@/features/auth/views/MigrateAccountView";
export default function MigrateAccountPage() {
return <MigrateAccountView />;
}

View File

@ -11,6 +11,8 @@ export interface AuthLayoutProps {
showBackButton?: boolean; showBackButton?: boolean;
backHref?: string; backHref?: string;
backLabel?: string; backLabel?: string;
/** Use wider layout for forms with more content like signup */
wide?: boolean;
} }
export function AuthLayout({ export function AuthLayout({
@ -20,10 +22,13 @@ export function AuthLayout({
showBackButton = false, showBackButton = false,
backHref = "/", backHref = "/",
backLabel = "Back to Home", backLabel = "Back to Home",
wide = false,
}: AuthLayoutProps) { }: AuthLayoutProps) {
const maxWidth = wide ? "max-w-xl" : "max-w-md";
return ( return (
<div className="w-full flex flex-col items-center"> <div className="w-full flex flex-col items-center">
<div className="w-full max-w-md"> <div className={`w-full ${maxWidth}`}>
{showBackButton && ( {showBackButton && (
<div className="mb-6"> <div className="mb-6">
<Link <Link
@ -56,7 +61,7 @@ export function AuthLayout({
</div> </div>
</div> </div>
<div className="mt-8 w-full max-w-md"> <div className={`mt-8 w-full ${maxWidth}`}>
<div className="relative"> <div className="relative">
{/* Subtle gradient glow behind card */} {/* Subtle gradient glow behind card */}
<div className="absolute -inset-1 bg-gradient-to-r from-primary/10 via-transparent to-primary/10 rounded-[1.75rem] blur-xl opacity-50" /> <div className="absolute -inset-1 bg-gradient-to-r from-primary/10 via-transparent to-primary/10 rounded-[1.75rem] blur-xl opacity-50" />

View File

@ -76,33 +76,45 @@ export function InlineAuthSection({
</div> </div>
<div className="mt-6"> <div className="mt-6">
{mode === "signup" && ( <div className="bg-card border border-border rounded-xl p-5 sm:p-6 shadow-[var(--cp-shadow-1)]">
<SignupForm redirectTo={redirectTo} className="max-w-none" showFooterLinks={false} /> {mode === "signup" && (
)} <>
{mode === "login" && ( <h4 className="text-base font-semibold text-foreground mb-2">Create your account</h4>
<LoginForm redirectTo={redirectTo} showSignupLink={false} className="max-w-none" /> <p className="text-sm text-muted-foreground mb-4">
)} Set up your portal access in a few simple steps.
{mode === "migrate" && ( </p>
<div className="bg-card border border-border rounded-xl p-5 shadow-[var(--cp-shadow-1)]"> <SignupForm redirectTo={redirectTo} showFooterLinks={false} />
<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. {mode === "login" && (
</p> <>
<LinkWhmcsForm <h4 className="text-base font-semibold text-foreground mb-2">Sign in</h4>
onTransferred={result => { <p className="text-sm text-muted-foreground mb-4">Access your account to continue.</p>
if (result.needsPasswordSet) { <LoginForm redirectTo={redirectTo} showSignupLink={false} />
const params = new URLSearchParams({ </>
email: result.user.email, )}
redirect: safeRedirect, {mode === "migrate" && (
}); <>
router.push(`/auth/set-password?${params.toString()}`); <h4 className="text-base font-semibold text-foreground mb-2">Migrate your account</h4>
return; <p className="text-sm text-muted-foreground mb-4">
} Use your legacy portal credentials to transfer your account.
router.push(safeRedirect); </p>
}} <LinkWhmcsForm
/> onTransferred={result => {
</div> 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> </div>
{highlights.length > 0 && ( {highlights.length > 0 && (

View File

@ -83,7 +83,7 @@ export function LoginForm({
}); });
return ( return (
<div className={`w-full max-w-md mx-auto ${className}`}> <div className={`w-full ${className}`}>
<form onSubmit={event => void handleSubmit(event)} className="space-y-6"> <form onSubmit={event => void handleSubmit(event)} className="space-y-6">
<FormField label="Email Address" error={touched.email ? errors.email : undefined} required> <FormField label="Email Address" error={touched.email ? errors.email : undefined} required>
<Input <Input

View File

@ -44,14 +44,13 @@ export function MultiStepForm({
onStepChange?.(currentStep); onStepChange?.(currentStep);
}, [currentStep, onStepChange]); }, [currentStep, onStepChange]);
const totalSteps = steps.length;
const step = steps[currentStep] ?? steps[0]; const step = steps[currentStep] ?? steps[0];
const isFirstStep = currentStep === 0; const isFirstStep = currentStep === 0;
const disableNext = isSubmitting || (!canProceed && !isLastStep); const disableNext = isSubmitting || (!canProceed && !isLastStep);
return ( return (
<div className={`space-y-6 ${className}`}> <div className={`space-y-6 ${className}`}>
{/* Step Indicators */} {/* Simple Step Indicators */}
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
{steps.map((s, idx) => { {steps.map((s, idx) => {
const isCompleted = idx < currentStep; const isCompleted = idx < currentStep;
@ -61,20 +60,20 @@ export function MultiStepForm({
<div key={s.key} className="flex items-center"> <div key={s.key} className="flex items-center">
<div <div
className={` className={`
flex items-center justify-center w-8 h-8 rounded-full text-sm font-medium flex items-center justify-center w-8 h-8 rounded-full text-sm font-semibold
transition-all duration-200 transition-colors duration-200
${ ${
isCompleted isCompleted
? "bg-success text-success-foreground" ? "bg-success text-success-foreground"
: isCurrent : isCurrent
? "bg-primary text-primary-foreground ring-4 ring-primary/15" ? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground" : "bg-muted text-muted-foreground"
} }
`} `}
> >
{isCompleted ? <CheckIcon className="w-4 h-4" /> : idx + 1} {isCompleted ? <CheckIcon className="w-4 h-4" /> : idx + 1}
</div> </div>
{idx < totalSteps - 1 && ( {idx < steps.length - 1 && (
<div <div
className={`w-8 h-0.5 mx-1 transition-colors duration-200 ${ className={`w-8 h-0.5 mx-1 transition-colors duration-200 ${
isCompleted ? "bg-success" : "bg-border" isCompleted ? "bg-success" : "bg-border"

View File

@ -27,11 +27,17 @@ import { PasswordStep } from "./steps/PasswordStep";
* - confirmPassword: UI-only field for password confirmation * - confirmPassword: UI-only field for password confirmation
* - phoneCountryCode: Separate field for country code input * - phoneCountryCode: Separate field for country code input
* - address: Required addressFormSchema (domain schema makes it optional) * - address: Required addressFormSchema (domain schema makes it optional)
* - dateOfBirth: Required for signup (domain schema makes it optional)
* - gender: Required for signup (domain schema makes it optional)
*/ */
const genderSchema = z.enum(["male", "female", "other"]);
const signupFormBaseSchema = signupInputSchema.omit({ sfNumber: true }).extend({ const signupFormBaseSchema = signupInputSchema.omit({ sfNumber: true }).extend({
confirmPassword: z.string().min(1, "Please confirm your password"), confirmPassword: z.string().min(1, "Please confirm your password"),
phoneCountryCode: z.string().regex(/^\+\d{1,4}$/, "Enter a valid country code (e.g., +81)"), phoneCountryCode: z.string().regex(/^\+\d{1,4}$/, "Enter a valid country code (e.g., +81)"),
address: addressFormSchema, address: addressFormSchema,
dateOfBirth: z.string().min(1, "Date of birth is required"),
gender: genderSchema,
}); });
const signupFormSchema = signupFormBaseSchema const signupFormSchema = signupFormBaseSchema
@ -132,8 +138,8 @@ export function SignupForm({
phone: "", phone: "",
phoneCountryCode: "+81", phoneCountryCode: "+81",
company: "", company: "",
dateOfBirth: undefined, dateOfBirth: "",
gender: undefined, gender: "" as unknown as "male" | "female" | "other", // Will be validated on submit
address: { address: {
address1: "", address1: "",
address2: "", address2: "",
@ -257,47 +263,45 @@ export function SignupForm({
})); }));
return ( return (
<div className={`w-full max-w-lg mx-auto ${className}`}> <div className={`w-full ${className}`}>
<div className="bg-card text-card-foreground shadow-[var(--cp-shadow-2)] rounded-2xl border border-border p-6 sm:p-8"> <MultiStepForm
<MultiStepForm steps={steps}
steps={steps} currentStep={step}
currentStep={step} onNext={handleNext}
onNext={handleNext} onPrevious={handlePrevious}
onPrevious={handlePrevious} isLastStep={isLastStep}
isLastStep={isLastStep} isSubmitting={isSubmitting || loading}
isSubmitting={isSubmitting || loading} canProceed={isLastStep || isStepValid(step)}
canProceed={isLastStep || isStepValid(step)} />
/>
{error && ( {error && (
<ErrorMessage className="mt-4 text-center p-3 bg-danger-soft rounded-lg"> <ErrorMessage className="mt-4 text-center p-3 bg-danger-soft rounded-lg">
{error} {error}
</ErrorMessage> </ErrorMessage>
)} )}
{showFooterLinks && ( {showFooterLinks && (
<div className="mt-6 text-center border-t border-border pt-6 space-y-3"> <div className="mt-6 text-center border-t border-border pt-6 space-y-3">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Already have an account?{" "} Already have an account?{" "}
<Link <Link
href={`/auth/login${redirectQuery}`} href={`/auth/login${redirectQuery}`}
className="font-medium text-primary hover:underline transition-colors" className="font-medium text-primary hover:underline transition-colors"
> >
Sign in Sign in
</Link> </Link>
</p> </p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Existing customer?{" "} Existing customer?{" "}
<Link <Link
href="/auth/link-whmcs" href="/auth/migrate"
className="font-medium text-primary hover:underline transition-colors" className="font-medium text-primary hover:underline transition-colors"
> >
Migrate your account Migrate your account
</Link> </Link>
</p> </p>
</div> </div>
)} )}
</div>
</div> </div>
); );
} }

View File

@ -4,7 +4,6 @@
"use client"; "use client";
import { useState } from "react";
import { Input } from "@/components/atoms"; import { Input } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField"; import { FormField } from "@/components/molecules/FormField/FormField";
@ -30,7 +29,6 @@ interface AccountStepProps {
export function AccountStep({ form }: AccountStepProps) { export function AccountStep({ form }: AccountStepProps) {
const { values, errors, touched, setValue, setTouchedField } = form; const { values, errors, touched, setValue, setTouchedField } = form;
const getError = (field: string) => (touched[field] ? errors[field] : undefined); const getError = (field: string) => (touched[field] ? errors[field] : undefined);
const [showOptional, setShowOptional] = useState(false);
return ( return (
<div className="space-y-5"> <div className="space-y-5">
@ -112,69 +110,55 @@ export function AccountStep({ form }: AccountStepProps) {
</div> </div>
</FormField> </FormField>
<div className="pt-2"> {/* DOB + Gender (Required) */}
<button <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
type="button" <FormField label="Date of Birth" error={getError("dateOfBirth")} required>
className="text-sm font-medium text-primary hover:underline" <Input
onClick={() => setShowOptional(s => !s)} name="bday"
> type="date"
{showOptional ? "Hide optional details" : "Add optional details"} value={values.dateOfBirth ?? ""}
</button> onChange={e => setValue("dateOfBirth", e.target.value || undefined)}
onBlur={() => setTouchedField("dateOfBirth")}
autoComplete="bday"
/>
</FormField>
<FormField label="Gender" error={getError("gender")} required>
<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> </div>
{showOptional && ( {/* Company (Optional) */}
<div className="space-y-5"> <FormField label="Company" error={getError("company")} helperText="Optional">
{/* DOB + Gender (Optional WHMCS custom fields) */} <Input
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> name="organization"
<FormField label="Date of Birth" error={getError("dateOfBirth")} helperText="Optional"> value={values.company ?? ""}
<Input onChange={e => setValue("company", e.target.value)}
name="bday" onBlur={() => setTouchedField("company")}
type="date" placeholder="Company name"
value={values.dateOfBirth ?? ""} autoComplete="organization"
onChange={e => setValue("dateOfBirth", e.target.value || undefined)} />
onBlur={() => setTouchedField("dateOfBirth")} </FormField>
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> </div>
); );
} }

View File

@ -280,7 +280,7 @@ export const useAuthStore = create<AuthState>()((set, get) => {
linkWhmcs: async (linkRequest: LinkWhmcsRequest) => { linkWhmcs: async (linkRequest: LinkWhmcsRequest) => {
set({ loading: true, error: null }); set({ loading: true, error: null });
try { try {
const response = await apiClient.POST("/api/auth/link-whmcs", { const response = await apiClient.POST("/api/auth/migrate", {
body: linkRequest, body: linkRequest,
disableCsrf: true, // Public auth endpoint, exempt from CSRF disableCsrf: true, // Public auth endpoint, exempt from CSRF
}); });

View File

@ -1,5 +1,5 @@
/** /**
* Link WHMCS View - Account migration page * Migrate Account View - Account migration page
*/ */
"use client"; "use client";
@ -10,7 +10,7 @@ import { AuthLayout } from "../components";
import { LinkWhmcsForm } from "@/features/auth/components"; import { LinkWhmcsForm } from "@/features/auth/components";
import { MIGRATION_TRANSFER_ITEMS, MIGRATION_STEPS } from "@customer-portal/domain/auth"; import { MIGRATION_TRANSFER_ITEMS, MIGRATION_STEPS } from "@customer-portal/domain/auth";
export function LinkWhmcsView() { export function MigrateAccountView() {
const router = useRouter(); const router = useRouter();
return ( return (
@ -32,7 +32,7 @@ export function LinkWhmcsView() {
</div> </div>
{/* Form */} {/* Form */}
<div className="bg-card text-card-foreground rounded-lg border border-border p-6 shadow-[var(--cp-shadow-1)]"> <div>
<h2 className="text-lg font-semibold text-foreground mb-1"> <h2 className="text-lg font-semibold text-foreground mb-1">
Enter Legacy Portal Credentials Enter Legacy Portal Credentials
</h2> </h2>
@ -51,7 +51,7 @@ export function LinkWhmcsView() {
</div> </div>
{/* Links */} {/* Links */}
<div className="flex justify-center gap-6 text-sm"> <div className="flex flex-wrap justify-center gap-x-6 gap-y-2 text-sm">
<span className="text-muted-foreground"> <span className="text-muted-foreground">
New customer?{" "} New customer?{" "}
<Link href="/auth/signup" className="text-primary hover:underline"> <Link href="/auth/signup" className="text-primary hover:underline">
@ -92,4 +92,4 @@ export function LinkWhmcsView() {
); );
} }
export default LinkWhmcsView; export default MigrateAccountView;

View File

@ -15,7 +15,7 @@ function SetPasswordContent() {
useEffect(() => { useEffect(() => {
if (!email) { if (!email) {
router.replace("/auth/link-whmcs"); router.replace("/auth/migrate");
} }
}, [email, router]); }, [email, router]);
@ -36,7 +36,7 @@ function SetPasswordContent() {
again so we can verify your account. again so we can verify your account.
</p> </p>
<Link <Link
href="/auth/link-whmcs" href="/auth/migrate"
className="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md" className="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md"
> >
Go to account transfer Go to account transfer

View File

@ -13,6 +13,7 @@ export function SignupView() {
<AuthLayout <AuthLayout
title="Create Your Account" title="Create Your Account"
subtitle="Set up your portal access in a few simple steps" subtitle="Set up your portal access in a few simple steps"
wide
> >
<SignupForm /> <SignupForm />
</AuthLayout> </AuthLayout>

View File

@ -3,4 +3,4 @@ export { SignupView } from "./SignupView";
export { ForgotPasswordView } from "./ForgotPasswordView"; export { ForgotPasswordView } from "./ForgotPasswordView";
export { ResetPasswordView } from "./ResetPasswordView"; export { ResetPasswordView } from "./ResetPasswordView";
export { SetPasswordView } from "./SetPasswordView"; export { SetPasswordView } from "./SetPasswordView";
export { LinkWhmcsView } from "./LinkWhmcsView"; export { MigrateAccountView } from "./MigrateAccountView";

View File

@ -0,0 +1,80 @@
"use client";
import { ArrowLeftIcon } from "@heroicons/react/24/outline";
import { Button } from "@/components/atoms/button";
import { CardBadge } from "@/features/catalog/components/base/CardBadge";
import type { BadgeVariant } from "@/features/catalog/components/base/CardBadge";
import { parsePlanName } from "@/features/catalog/components/internet/utils/planName";
import type { InternetPlanCatalogItem } from "@customer-portal/domain/catalog";
interface PlanHeaderProps {
plan: InternetPlanCatalogItem;
backHref?: string;
backLabel?: string;
title?: string;
className?: string;
}
export function PlanHeader({
plan,
backHref,
backLabel = "Back to Internet Plans",
title = "Configure your plan",
className = "",
}: PlanHeaderProps) {
const { baseName: planBaseName, detail: planDetail } = parsePlanName(plan);
return (
<div className={`text-center mb-8 animate-in fade-in duration-300 ${className}`}>
{backHref && (
<Button
as="a"
href={backHref}
variant="ghost"
size="sm"
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
className="mb-6 text-muted-foreground hover:text-foreground"
>
{backLabel}
</Button>
)}
<h1 className="text-2xl md:text-3xl font-bold text-foreground mb-5">{title}</h1>
<span className="sr-only">
{planBaseName}
{planDetail ? ` (${planDetail})` : ""}
</span>
<div className="inline-flex flex-wrap items-center justify-center gap-3 bg-card px-6 py-3 rounded-full border border-border shadow-sm text-sm">
{plan.internetPlanTier ? (
<CardBadge
text={plan.internetPlanTier}
variant={getTierBadgeVariant(plan.internetPlanTier)}
size="sm"
/>
) : null}
{planDetail ? <CardBadge text={planDetail} variant="family" size="sm" /> : null}
{plan.monthlyPrice && plan.monthlyPrice > 0 ? (
<span className="inline-flex items-center rounded-full bg-primary/10 px-3 py-1 text-sm font-semibold text-primary">
¥{plan.monthlyPrice.toLocaleString()}/month
</span>
) : null}
</div>
</div>
);
}
function getTierBadgeVariant(tier?: string | null): BadgeVariant {
switch (tier) {
case "Gold":
return "gold";
case "Platinum":
return "platinum";
case "Silver":
return "silver";
case "Recommended":
return "recommended";
default:
return "default";
}
}

View File

@ -3,10 +3,7 @@
import { useEffect, useState, type ReactElement } from "react"; import { useEffect, useState, type ReactElement } from "react";
import { PageLayout } from "@/components/templates/PageLayout"; import { PageLayout } from "@/components/templates/PageLayout";
import { ProgressSteps } from "@/components/molecules"; import { ProgressSteps } from "@/components/molecules";
import { Button } from "@/components/atoms/button"; import { ServerIcon } from "@heroicons/react/24/outline";
import { CardBadge } from "@/features/catalog/components/base/CardBadge";
import type { BadgeVariant } from "@/features/catalog/components/base/CardBadge";
import { ServerIcon, ArrowLeftIcon } from "@heroicons/react/24/outline";
import type { import type {
InternetPlanCatalogItem, InternetPlanCatalogItem,
InternetInstallationCatalogItem, InternetInstallationCatalogItem,
@ -19,8 +16,8 @@ import { InstallationStep } from "./steps/InstallationStep";
import { AddonsStep } from "./steps/AddonsStep"; import { AddonsStep } from "./steps/AddonsStep";
import { ReviewOrderStep } from "./steps/ReviewOrderStep"; import { ReviewOrderStep } from "./steps/ReviewOrderStep";
import { useConfigureState } from "./hooks/useConfigureState"; import { useConfigureState } from "./hooks/useConfigureState";
import { parsePlanName } from "@/features/catalog/components/internet/utils/planName";
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath"; import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
import { PlanHeader } from "@/features/catalog/components/internet/PlanHeader";
interface Props { interface Props {
plan: InternetPlanCatalogItem | null; plan: InternetPlanCatalogItem | null;
@ -61,6 +58,7 @@ export function InternetConfigureContainer({
currentStep, currentStep,
setCurrentStep, setCurrentStep,
}: Props) { }: Props) {
const shopBasePath = useShopBasePath();
const [renderedStep, setRenderedStep] = useState(currentStep); const [renderedStep, setRenderedStep] = useState(currentStep);
const [transitionPhase, setTransitionPhase] = useState<"idle" | "enter" | "exit">("idle"); const [transitionPhase, setTransitionPhase] = useState<"idle" | "enter" | "exit">("idle");
// Use local state ONLY for step validation, step management now in Zustand // Use local state ONLY for step validation, step management now in Zustand
@ -214,7 +212,11 @@ export function InternetConfigureContainer({
<div className="min-h-[70vh]"> <div className="min-h-[70vh]">
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Plan Header */} {/* Plan Header */}
<PlanHeader plan={plan} /> <PlanHeader
plan={plan}
backHref={`${shopBasePath}/internet`}
backLabel="Back to Internet Plans"
/>
{/* Progress Steps */} {/* Progress Steps */}
<div className="mb-10"> <div className="mb-10">
@ -230,60 +232,3 @@ export function InternetConfigureContainer({
</PageLayout> </PageLayout>
); );
} }
function PlanHeader({ plan }: { plan: InternetPlanCatalogItem }) {
const shopBasePath = useShopBasePath();
const { baseName: planBaseName, detail: planDetail } = parsePlanName(plan);
return (
<div className="text-center mb-8 animate-in fade-in duration-300">
<Button
as="a"
href={`${shopBasePath}/internet`}
variant="ghost"
size="sm"
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
className="mb-6 text-muted-foreground hover:text-foreground"
>
Back to Internet Plans
</Button>
<h1 className="text-2xl md:text-3xl font-bold text-foreground mb-5">Configure your plan</h1>
<span className="sr-only">
{planBaseName}
{planDetail ? ` (${planDetail})` : ""}
</span>
<div className="inline-flex flex-wrap items-center justify-center gap-3 bg-card px-6 py-3 rounded-full border border-border shadow-sm text-sm">
{plan.internetPlanTier ? (
<CardBadge
text={plan.internetPlanTier}
variant={getTierBadgeVariant(plan.internetPlanTier)}
size="sm"
/>
) : null}
{planDetail ? <CardBadge text={planDetail} variant="family" size="sm" /> : null}
{plan.monthlyPrice && plan.monthlyPrice > 0 ? (
<span className="inline-flex items-center rounded-full bg-primary/10 px-3 py-1 text-sm font-semibold text-primary">
¥{plan.monthlyPrice.toLocaleString()}/month
</span>
) : null}
</div>
</div>
);
}
function getTierBadgeVariant(tier?: string | null): BadgeVariant {
switch (tier) {
case "Gold":
return "gold";
case "Platinum":
return "platinum";
case "Silver":
return "silver";
case "Recommended":
return "recommended";
default:
return "default";
}
}

View File

@ -69,7 +69,10 @@ export function InternetPlansContainer() {
const isPending = eligibilityStatus === "pending"; const isPending = eligibilityStatus === "pending";
const isNotRequested = eligibilityStatus === "not_requested"; const isNotRequested = eligibilityStatus === "not_requested";
const isIneligible = eligibilityStatus === "ineligible"; const isIneligible = eligibilityStatus === "ineligible";
const orderingLocked = isPending || isNotRequested || isIneligible;
// While loading eligibility, we assume locked to prevent showing unfiltered catalog for a split second
const orderingLocked =
eligibilityLoading || isPending || isNotRequested || isIneligible || !eligibilityStatus;
const hasServiceAddress = Boolean( const hasServiceAddress = Boolean(
user?.address?.address1 && user?.address?.address1 &&
user?.address?.city && user?.address?.city &&

View File

@ -1,17 +1,16 @@
"use client"; "use client";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { CheckIcon, ServerIcon } from "@heroicons/react/24/outline"; import { WifiIcon, CheckCircleIcon, ClockIcon, BellIcon } from "@heroicons/react/24/outline";
import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink"; 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 { InlineAuthSection } from "@/features/auth/components/InlineAuthSection/InlineAuthSection";
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath"; import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
/** /**
* Public Internet Configure View * Public Internet Configure View
* *
* Shows selected plan information and prompts for authentication via modal. * Generic signup flow for internet availability check.
* Much better UX than redirecting to a full signup page. * Focuses on account creation, not plan details.
*/ */
export function PublicInternetConfigureView() { export function PublicInternetConfigureView() {
const shopBasePath = useShopBasePath(); const shopBasePath = useShopBasePath();
@ -23,74 +22,45 @@ export function PublicInternetConfigureView() {
: "/account/shop/internet?autoEligibilityRequest=1"; : "/account/shop/internet?autoEligibilityRequest=1";
return ( return (
<> <div className="max-w-2xl 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 plans" />
<CatalogBackLink href={`${shopBasePath}/internet`} label="Back to Internet plans" />
<CatalogHero {/* Header */}
title="Check availability for your address" <div className="mt-6 mb-8 text-center">
description="Create an account so we can verify service availability. We'll submit your request automatically." <div className="flex justify-center mb-4">
/> <div className="flex h-14 w-14 items-center justify-center rounded-full bg-primary/10 border border-primary/20">
<WifiIcon className="h-7 w-7 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="border-t border-border pt-6 mt-6">
<h4 className="text-sm font-semibold text-foreground mb-4 uppercase tracking-wide">
What happens next
</h4>
<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> </div>
</div>
<h1 className="text-2xl font-bold text-foreground mb-2">Check Internet Availability</h1>
<p className="text-muted-foreground">
Create an account to verify service availability at your address.
</p>
</div>
<InlineAuthSection {/* Process Steps - Compact */}
title="Ready to get started?" <div className="mb-8 grid grid-cols-3 gap-3 text-center">
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." <div className="flex flex-col items-center gap-2 p-3 rounded-lg bg-muted/50">
redirectTo={redirectTo} <CheckCircleIcon className="h-5 w-5 text-success" />
highlights={[ <span className="text-xs font-medium text-foreground">Create account</span>
{ title: "Verify Availability", description: "Check service at your address" }, </div>
{ title: "Personalized Plans", description: "See plans tailored to you" }, <div className="flex flex-col items-center gap-2 p-3 rounded-lg bg-muted/50">
{ title: "Secure Ordering", description: "Complete your order safely" }, <ClockIcon className="h-5 w-5 text-info" />
]} <span className="text-xs font-medium text-foreground">We verify availability</span>
/> </div>
<div className="flex flex-col items-center gap-2 p-3 rounded-lg bg-muted/50">
<BellIcon className="h-5 w-5 text-primary" />
<span className="text-xs font-medium text-foreground">Get notified</span>
</div> </div>
</div> </div>
</>
{/* Auth Section */}
<InlineAuthSection
title="Create your account"
description="We'll verify internet availability at your address and notify you when ready."
redirectTo={redirectTo}
/>
</div>
); );
} }

View File

@ -127,7 +127,7 @@ export function PublicLandingView() {
<ArrowRightIcon className="h-4 w-4" /> <ArrowRightIcon className="h-4 w-4" />
</Link> </Link>
<Link <Link
href="/auth/link-whmcs" href="/auth/migrate"
className="inline-flex items-center justify-center rounded-xl px-6 py-3 text-sm font-medium border border-border bg-card hover:bg-muted/50 transition-colors" className="inline-flex items-center justify-center rounded-xl px-6 py-3 text-sm font-medium border border-border bg-card hover:bg-muted/50 transition-colors"
> >
Migrate account Migrate account

View File

@ -72,7 +72,7 @@ export function PublicLandingView() {
Login to Portal Login to Portal
</Link> </Link>
<Link <Link
href="/auth/link-whmcs" href="/auth/migrate"
className="block border-2 border-gray-200 text-gray-700 px-6 py-3 rounded-lg hover:border-blue-300 hover:bg-blue-50 transition-all duration-200" className="block border-2 border-gray-200 text-gray-700 px-6 py-3 rounded-lg hover:border-blue-300 hover:bg-blue-50 transition-all duration-200"
> >
Migrate Account Migrate Account

View File

@ -22,7 +22,7 @@ import { logger } from "@/lib/logger";
const AUTH_ENDPOINTS = [ const AUTH_ENDPOINTS = [
"/api/auth/login", "/api/auth/login",
"/api/auth/signup", "/api/auth/signup",
"/api/auth/link-whmcs", "/api/auth/migrate",
"/api/auth/set-password", "/api/auth/set-password",
"/api/auth/reset-password", "/api/auth/reset-password",
"/api/auth/check-password-needed", "/api/auth/check-password-needed",

View File

@ -40,7 +40,7 @@
2. **ID Verification Integrated** - Upload functionality is now built into the Profile page (`/account/settings`) rather than requiring a separate page. 2. **ID Verification Integrated** - Upload functionality is now built into the Profile page (`/account/settings`) rather than requiring a separate page.
3. **Opportunity Lifecycle Fields Exist** - `Opportunity_Source__c` (with portal picklist values) and `WHMCS_Service_ID__c` are in place and working. 3. **Opportunity Lifecycle Fields Exist** - `Portal_Source__c` (with portal picklist values) and `WHMCS_Service_ID__c` are in place and working.
4. **SIM vs Internet Flows Have Different Requirements** - SIM requires ID verification but not eligibility; Internet requires eligibility but not ID verification. 4. **SIM vs Internet Flows Have Different Requirements** - SIM requires ID verification but not eligibility; Internet requires eligibility but not ID verification.
@ -68,7 +68,7 @@
├─────────────────────────────────────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────────────────────────────────────┤
│ CatalogService │ CheckoutService │ VerificationService │ OrderService │ │ CatalogService │ CheckoutService │ VerificationService │ OrderService │
│ │ │ │ │ │ │ │ │ │
│ OpportunityMatchingService │ OrderOrchestrator │ FulfillmentOrchestrator │ │ Opportunity Resolution │ OrderOrchestrator │ FulfillmentOrchestrator │
└───────┬──────────────────────┴─────────┬──────────┴────────────┬───────────────┘ └───────┬──────────────────────┴─────────┬──────────┴────────────┬───────────────┘
│ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼
@ -364,7 +364,7 @@ NOTE: Introduction/Ready stages may be used by agents for pre-order tracking,
4. **Manual Agent Work Required** - Agent checks NTT serviceability 4. **Manual Agent Work Required** - Agent checks NTT serviceability
5. Agent updates Account with eligibility result 5. Agent updates Account with eligibility result
6. Salesforce Flow sends email notification to customer 6. Salesforce Flow sends email notification to customer
7. **Note:** Opportunity is NOT created during eligibility - only at order placement 7. **Note:** Opportunity **is created/reused during eligibility** (Stage = Introduction) so the Case can link to it (`Case.OpportunityId`) and the journey can be reused at order placement.
**Account Fields Updated:** **Account Fields Updated:**
| Field | Value Set | When | | Field | Value Set | When |
@ -425,14 +425,18 @@ NOTE: Introduction/Ready stages may be used by agents for pre-order tracking,
### Opportunity Management Module ### Opportunity Management Module
**Location:** `apps/bff/src/modules/orders/services/opportunity-matching.service.ts` **Location (current code paths):**
- `apps/bff/src/modules/catalog/services/internet-catalog.service.ts` (eligibility request creates/reuses Opportunity + Case)
- `apps/bff/src/modules/orders/services/order-orchestrator.service.ts` (order placement resolves Opportunity)
- `apps/bff/src/integrations/salesforce/services/salesforce-opportunity.service.ts` (Salesforce query/create/update)
**Matching Rules:** **Matching Rules:**
| Scenario | Action | | Scenario | Action |
|----------|--------| |----------|--------|
| Order has `opportunityId` | Use it directly | | Order has `opportunityId` | Use it directly |
| Internet order without Opp | Find Introduction/Ready stage or create new | | Internet order without Opp | Find Introduction/Ready stage or create new |
| SIM order without Opp | Find open Opportunity or create new | | SIM order without Opp | Find Introduction/Ready stage or create new |
| VPN order | Always create new Opportunity | | VPN order | Always create new Opportunity |
**Stage Transitions by Trigger:** **Stage Transitions by Trigger:**
@ -539,7 +543,7 @@ NOTE: Introduction/Ready stages may be used by agents for pre-order tracking,
- Salesforce Flow automatically sends email to customer when eligibility fields are updated - Salesforce Flow automatically sends email to customer when eligibility fields are updated
- Portal polls Account for eligibility changes (customer sees result on next visit) - Portal polls Account for eligibility changes (customer sees result on next visit)
- **Opportunity is created later at order placement** (not during eligibility check) - Opportunity is created/reused at eligibility request (Stage = Introduction) and then reused at order placement when possible
--- ---
@ -662,7 +666,7 @@ LONG TERM:
**Status:** ✅ Confirmed fields exist: **Status:** ✅ Confirmed fields exist:
- `Opportunity_Source__c` - Picklist with portal values - `Portal_Source__c` - Picklist with portal values
- `WHMCS_Service_ID__c` - Number field for WHMCS linking - `WHMCS_Service_ID__c` - Number field for WHMCS linking
**Note:** Emails for eligibility and ID verification status changes are sent automatically from Salesforce (via Flow/Process Builder). **Note:** Emails for eligibility and ID verification status changes are sent automatically from Salesforce (via Flow/Process Builder).

View File

@ -21,7 +21,7 @@ This guide describes how eligibility and verification work in the customer porta
1. Customer navigates to `/account/shop/internet` 1. Customer navigates to `/account/shop/internet`
2. Customer enters service address and requests eligibility check 2. Customer enters service address and requests eligibility check
3. Portal creates a Salesforce Case for agent review 3. Portal **finds/creates a Salesforce Opportunity** (Stage = `Introduction`) and creates a Salesforce Case **linked to that Opportunity** for agent review
4. Agent performs NTT serviceability check (manual process) 4. Agent performs NTT serviceability check (manual process)
5. Agent updates Account eligibility fields 5. Agent updates Account eligibility fields
6. Salesforce Flow sends email notification to customer 6. Salesforce Flow sends email notification to customer

View File

@ -352,10 +352,10 @@ This guide documents the Salesforce Opportunity integration for service lifecycl
│ │ │ │
│ 4. BIDIRECTIONAL LINK COMPLETE │ │ 4. BIDIRECTIONAL LINK COMPLETE │
│ └─ Opportunity.WHMCS_Service_ID__c → WHMCS Service │ │ └─ Opportunity.WHMCS_Service_ID__c → WHMCS Service │
│ └─ WHMCS Service.OpportunityId → Opportunity │ └─ WHMCS Service.OpportunityId → Opportunity (optional; helpful for ops/debugging)
│ │ │ │
│ 5. CUSTOMER SEES SERVICE PAGE │ │ 5. CUSTOMER SEES SERVICE PAGE │
│ └─ Portal queries WHMCS → gets OpportunityId → queries Opp │ └─ Portal shows active services from WHMCS; lifecycle/cancellation tracking lives on the linked Salesforce Opportunity (via WHMCS_Service_ID__c)
│ │ │ │
└─────────────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────────────┘
``` ```
@ -448,15 +448,15 @@ On/After 25th → Must select NEXT month or later
- [x] Case.OpportunityId (standard lookup) - [x] Case.OpportunityId (standard lookup)
- [x] Order.OpportunityId (standard lookup) - [x] Order.OpportunityId (standard lookup)
**New Fields to Create on Opportunity:** **Opportunity Fields Required (Portal writes these):**
- [ ] `Portal_Source__c` picklist - [ ] `Portal_Source__c` picklist (used to track how the Opportunity was created)
- [ ] `WHMCS_Service_ID__c` number field - [ ] `WHMCS_Service_ID__c` number field (used to link WHMCS service → Salesforce Opportunity for cancellations)
### WHMCS Admin Tasks ### WHMCS Admin Tasks
- [ ] Create `OpportunityId` custom field on Services/Hosting - [ ] Create an `OpportunityId` custom field on Services/Hosting (optional but recommended for ops/debugging)
- [ ] Document custom field ID for AddOrder API - [ ] Confirm whether your WHMCS expects `customfields[]` keys by **name** (`OpportunityId`) or by **numeric field id**, and configure accordingly
### BFF Development Tasks ### BFF Development Tasks
@ -466,22 +466,20 @@ On/After 25th → Must select NEXT month or later
- [x] Field map configuration - [x] Field map configuration
- [x] `SalesforceOpportunityService` - [x] `SalesforceOpportunityService`
- [x] Cancellation deadline helpers (25th rule) - [x] Cancellation deadline helpers (25th rule)
- [x] Eligibility request creates/reuses Opportunity (Stage `Introduction`) and links Case (`Case.OpportunityId`)
- [x] Order placement reuses Opportunity in `Introduction`/`Ready` (otherwise creates new `Post Processing`)
- [x] Fulfillment passes `OpportunityId` to WHMCS and links `WHMCS_Service_ID__c` back to the Opportunity
- [x] Cancellation Case creation + Opportunity cancellation field updates (Internet cancellations)
**Pending:** **Optional / Future:**
- [ ] Register services in Salesforce module - [ ] Read `OpportunityId` back from WHMCS service custom fields (portal currently relies on Salesforce `WHMCS_Service_ID__c` for linking)
- [ ] 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 ### Frontend Tasks
- [ ] Order tracking page (from Salesforce Order) - [ ] Order tracking page (from Salesforce Order)
- [ ] Service page with cancellation status - [ ] Service page with cancellation status
- [ ] Cancellation form with 25th deadline logic - [x] Cancellation forms (Internet + SIM) with 25th deadline logic
--- ---

View File

@ -201,12 +201,16 @@ export const OPPORTUNITY_SOURCE = {
export type OpportunitySourceValue = (typeof OPPORTUNITY_SOURCE)[keyof typeof OPPORTUNITY_SOURCE]; export type OpportunitySourceValue = (typeof OPPORTUNITY_SOURCE)[keyof typeof OPPORTUNITY_SOURCE];
// ============================================================================ // ============================================================================
// Opportunity Matching Constants // Opportunity Matching / Stage Sets
// ============================================================================ // ============================================================================
/** /**
* Stages considered "open" for matching purposes * Stages considered "open" (i.e. not closed) in our lifecycle.
* Opportunities in these stages can be linked to new orders *
* IMPORTANT:
* - Do NOT use this list to match a *new* order or eligibility request to an existing Opportunity.
* For those flows we intentionally match a much smaller set of early-stage Opportunities.
* - Use the `OPPORTUNITY_MATCH_STAGES_*` constants below instead.
*/ */
export const OPEN_OPPORTUNITY_STAGES: OpportunityStageValue[] = [ export const OPEN_OPPORTUNITY_STAGES: OpportunityStageValue[] = [
OPPORTUNITY_STAGE.INTRODUCTION, OPPORTUNITY_STAGE.INTRODUCTION,
@ -215,6 +219,27 @@ export const OPEN_OPPORTUNITY_STAGES: OpportunityStageValue[] = [
OPPORTUNITY_STAGE.ACTIVE, OPPORTUNITY_STAGE.ACTIVE,
]; ];
/**
* Stages eligible for matching during an Internet eligibility request.
*
* We only ever reuse the initial "Introduction" opportunity; later stages indicate the
* customer journey has progressed beyond eligibility.
*/
export const OPPORTUNITY_MATCH_STAGES_INTERNET_ELIGIBILITY: OpportunityStageValue[] = [
OPPORTUNITY_STAGE.INTRODUCTION,
];
/**
* Stages eligible for matching during order placement.
*
* If a customer came from eligibility, the Opportunity will usually be in "Introduction" or "Ready".
* We must never match an "Active" Opportunity for a new order (that would corrupt lifecycle tracking).
*/
export const OPPORTUNITY_MATCH_STAGES_ORDER_PLACEMENT: OpportunityStageValue[] = [
OPPORTUNITY_STAGE.INTRODUCTION,
OPPORTUNITY_STAGE.READY,
];
/** /**
* Stages that indicate the Opportunity is closed * Stages that indicate the Opportunity is closed
*/ */

View File

@ -45,6 +45,8 @@ export {
type OpportunitySourceValue, type OpportunitySourceValue,
// Matching constants // Matching constants
OPEN_OPPORTUNITY_STAGES, OPEN_OPPORTUNITY_STAGES,
OPPORTUNITY_MATCH_STAGES_INTERNET_ELIGIBILITY,
OPPORTUNITY_MATCH_STAGES_ORDER_PLACEMENT,
CLOSED_OPPORTUNITY_STAGES, CLOSED_OPPORTUNITY_STAGES,
// Deadline constants // Deadline constants
CANCELLATION_DEADLINE_DAY, CANCELLATION_DEADLINE_DAY,