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/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
### User Management

View File

@ -51,7 +51,7 @@ export class CsrfMiddleware implements NestMiddleware {
"/api/auth/request-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/link-whmcs", // Public auth endpoint for WHMCS account linking
"/api/auth/migrate", // Public auth endpoint for account migration
"/api/health",
"/docs",
"/api/webhooks", // Webhooks typically don't use CSRF

View File

@ -304,23 +304,28 @@ export class SalesforceOpportunityService {
*/
async findOpenOpportunityForAccount(
accountId: string,
productType: OpportunityProductTypeValue
productType: OpportunityProductTypeValue,
options?: { stages?: OpportunityStageValue[] }
): Promise<string | null> {
const safeAccountId = assertSalesforceId(accountId, "accountId");
// Get the CommodityType value(s) that match this product type
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", {
accountId: safeAccountId,
productType,
commodityTypes: commodityTypeValues,
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 soql = `

View File

@ -230,11 +230,11 @@ export class AuthController {
}
@Public()
@Post("link-whmcs")
@Post("migrate")
@UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard)
@RateLimit({ limit: 5, ttl: 600 }) // 5 attempts per 10 minutes per IP (industry standard)
@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);
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 { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.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 { getErrorMessage } from "@bff/core/utils/error.util.js";
import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util.js";
@ -28,6 +29,7 @@ import {
OPPORTUNITY_STAGE,
OPPORTUNITY_SOURCE,
OPPORTUNITY_PRODUCT_TYPE,
OPPORTUNITY_MATCH_STAGES_INTERNET_ELIGIBILITY,
} from "@customer-portal/domain/opportunity";
export type InternetEligibilityStatusDto = "not_requested" | "pending" | "eligible" | "ineligible";
@ -49,6 +51,7 @@ export class InternetCatalogService extends BaseCatalogService {
@Inject(Logger) logger: Logger,
private mappingsService: MappingsService,
private catalogCache: CatalogCacheService,
private lockService: DistributedLockService,
private opportunityService: SalesforceOpportunityService,
private caseService: SalesforceCaseService
) {
@ -278,67 +281,88 @@ export class InternetCatalogService extends BaseCatalogService {
}
try {
// 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
const lockKey = `internet:eligibility:${sfAccountId}`;
const caseId = await this.lockService.withLock(
lockKey,
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;
} catch (error) {
this.logger.error("Failed to create eligibility request", {

View File

@ -129,7 +129,8 @@ export class NotificationService {
userId,
expiresAt: { gt: now },
...(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 {

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 { SalesforceOrderService } from "@bff/integrations/salesforce/services/salesforce-order.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 { OrderBuilder } from "./order-builder.service.js";
import { OrderItemBuilder } from "./order-item-builder.service.js";
@ -14,6 +15,7 @@ import {
OPPORTUNITY_SOURCE,
OPPORTUNITY_PRODUCT_TYPE,
type OpportunityProductTypeValue,
OPPORTUNITY_MATCH_STAGES_ORDER_PLACEMENT,
} from "@customer-portal/domain/opportunity";
type OrderDetailsResponse = OrderDetails;
@ -29,6 +31,7 @@ export class OrderOrchestrator {
@Inject(Logger) private readonly logger: Logger,
private readonly salesforceOrderService: SalesforceOrderService,
private readonly opportunityService: SalesforceOpportunityService,
private readonly lockService: DistributedLockService,
private readonly orderValidator: OrderValidator,
private readonly orderBuilder: OrderBuilder,
private readonly orderItemBuilder: OrderItemBuilder,
@ -163,34 +166,43 @@ export class OrderOrchestrator {
}
try {
// Try to find existing open Opportunity (Introduction or Ready stage)
const existingOppId = await this.opportunityService.findOpenOpportunityForAccount(
safeAccountId,
productType
const lockKey = `opportunity:order:${safeAccountId}:${productType}`;
return await this.lockService.withLock(
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 {
this.logger.warn("Failed to resolve Opportunity for order", {
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;
backHref?: string;
backLabel?: string;
/** Use wider layout for forms with more content like signup */
wide?: boolean;
}
export function AuthLayout({
@ -20,10 +22,13 @@ export function AuthLayout({
showBackButton = false,
backHref = "/",
backLabel = "Back to Home",
wide = false,
}: AuthLayoutProps) {
const maxWidth = wide ? "max-w-xl" : "max-w-md";
return (
<div className="w-full flex flex-col items-center">
<div className="w-full max-w-md">
<div className={`w-full ${maxWidth}`}>
{showBackButton && (
<div className="mb-6">
<Link
@ -56,7 +61,7 @@ export function AuthLayout({
</div>
</div>
<div className="mt-8 w-full max-w-md">
<div className={`mt-8 w-full ${maxWidth}`}>
<div className="relative">
{/* 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" />

View File

@ -76,33 +76,45 @@ export function InlineAuthSection({
</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 className="bg-card border border-border rounded-xl p-5 sm:p-6 shadow-[var(--cp-shadow-1)]">
{mode === "signup" && (
<>
<h4 className="text-base font-semibold text-foreground mb-2">Create your account</h4>
<p className="text-sm text-muted-foreground mb-4">
Set up your portal access in a few simple steps.
</p>
<SignupForm redirectTo={redirectTo} showFooterLinks={false} />
</>
)}
{mode === "login" && (
<>
<h4 className="text-base font-semibold text-foreground mb-2">Sign in</h4>
<p className="text-sm text-muted-foreground mb-4">Access your account to continue.</p>
<LoginForm redirectTo={redirectTo} showSignupLink={false} />
</>
)}
{mode === "migrate" && (
<>
<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 && (

View File

@ -83,7 +83,7 @@ export function LoginForm({
});
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">
<FormField label="Email Address" error={touched.email ? errors.email : undefined} required>
<Input

View File

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

View File

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

View File

@ -4,7 +4,6 @@
"use client";
import { useState } from "react";
import { Input } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField";
@ -30,7 +29,6 @@ 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">
@ -112,69 +110,55 @@ export function AccountStep({ form }: AccountStepProps) {
</div>
</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>
{/* DOB + Gender (Required) */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Date of Birth" error={getError("dateOfBirth")} required>
<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")} 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>
{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>
)}
{/* 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>
);
}

View File

@ -280,7 +280,7 @@ export const useAuthStore = create<AuthState>()((set, get) => {
linkWhmcs: async (linkRequest: LinkWhmcsRequest) => {
set({ loading: true, error: null });
try {
const response = await apiClient.POST("/api/auth/link-whmcs", {
const response = await apiClient.POST("/api/auth/migrate", {
body: linkRequest,
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";
@ -10,7 +10,7 @@ import { AuthLayout } from "../components";
import { LinkWhmcsForm } from "@/features/auth/components";
import { MIGRATION_TRANSFER_ITEMS, MIGRATION_STEPS } from "@customer-portal/domain/auth";
export function LinkWhmcsView() {
export function MigrateAccountView() {
const router = useRouter();
return (
@ -32,7 +32,7 @@ export function LinkWhmcsView() {
</div>
{/* 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">
Enter Legacy Portal Credentials
</h2>
@ -51,7 +51,7 @@ export function LinkWhmcsView() {
</div>
{/* 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">
New customer?{" "}
<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(() => {
if (!email) {
router.replace("/auth/link-whmcs");
router.replace("/auth/migrate");
}
}, [email, router]);
@ -36,7 +36,7 @@ function SetPasswordContent() {
again so we can verify your account.
</p>
<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"
>
Go to account transfer

View File

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

View File

@ -3,4 +3,4 @@ export { SignupView } from "./SignupView";
export { ForgotPasswordView } from "./ForgotPasswordView";
export { ResetPasswordView } from "./ResetPasswordView";
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 { PageLayout } from "@/components/templates/PageLayout";
import { ProgressSteps } from "@/components/molecules";
import { Button } from "@/components/atoms/button";
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 { ServerIcon } from "@heroicons/react/24/outline";
import type {
InternetPlanCatalogItem,
InternetInstallationCatalogItem,
@ -19,8 +16,8 @@ import { InstallationStep } from "./steps/InstallationStep";
import { AddonsStep } from "./steps/AddonsStep";
import { ReviewOrderStep } from "./steps/ReviewOrderStep";
import { useConfigureState } from "./hooks/useConfigureState";
import { parsePlanName } from "@/features/catalog/components/internet/utils/planName";
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
import { PlanHeader } from "@/features/catalog/components/internet/PlanHeader";
interface Props {
plan: InternetPlanCatalogItem | null;
@ -61,6 +58,7 @@ export function InternetConfigureContainer({
currentStep,
setCurrentStep,
}: Props) {
const shopBasePath = useShopBasePath();
const [renderedStep, setRenderedStep] = useState(currentStep);
const [transitionPhase, setTransitionPhase] = useState<"idle" | "enter" | "exit">("idle");
// 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="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Plan Header */}
<PlanHeader plan={plan} />
<PlanHeader
plan={plan}
backHref={`${shopBasePath}/internet`}
backLabel="Back to Internet Plans"
/>
{/* Progress Steps */}
<div className="mb-10">
@ -230,60 +232,3 @@ export function InternetConfigureContainer({
</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 isNotRequested = eligibilityStatus === "not_requested";
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(
user?.address?.address1 &&
user?.address?.city &&

View File

@ -1,17 +1,16 @@
"use client";
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 { CatalogHero } from "@/features/catalog/components/base/CatalogHero";
import { InlineAuthSection } from "@/features/auth/components/InlineAuthSection/InlineAuthSection";
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
/**
* Public Internet Configure View
*
* Shows selected plan information and prompts for authentication via modal.
* Much better UX than redirecting to a full signup page.
* Generic signup flow for internet availability check.
* Focuses on account creation, not plan details.
*/
export function PublicInternetConfigureView() {
const shopBasePath = useShopBasePath();
@ -23,74 +22,45 @@ export function PublicInternetConfigureView() {
: "/account/shop/internet?autoEligibilityRequest=1";
return (
<>
<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" />
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<CatalogBackLink href={`${shopBasePath}/internet`} label="Back to plans" />
<CatalogHero
title="Check availability for your address"
description="Create an account so we can verify service availability. We'll submit your request automatically."
/>
<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>
{/* Header */}
<div className="mt-6 mb-8 text-center">
<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>
</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
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" },
]}
/>
{/* Process Steps - Compact */}
<div className="mb-8 grid grid-cols-3 gap-3 text-center">
<div className="flex flex-col items-center gap-2 p-3 rounded-lg bg-muted/50">
<CheckCircleIcon className="h-5 w-5 text-success" />
<span className="text-xs font-medium text-foreground">Create account</span>
</div>
<div className="flex flex-col items-center gap-2 p-3 rounded-lg bg-muted/50">
<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>
</>
{/* 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" />
</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"
>
Migrate account

View File

@ -72,7 +72,7 @@ export function PublicLandingView() {
Login to Portal
</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"
>
Migrate Account

View File

@ -22,7 +22,7 @@ import { logger } from "@/lib/logger";
const AUTH_ENDPOINTS = [
"/api/auth/login",
"/api/auth/signup",
"/api/auth/link-whmcs",
"/api/auth/migrate",
"/api/auth/set-password",
"/api/auth/reset-password",
"/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.
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.
@ -68,7 +68,7 @@
├─────────────────────────────────────────────────────────────────────────────────┤
│ 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
5. Agent updates Account with eligibility result
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:**
| Field | Value Set | When |
@ -425,14 +425,18 @@ NOTE: Introduction/Ready stages may be used by agents for pre-order tracking,
### 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:**
| Scenario | Action |
|----------|--------|
| Order has `opportunityId` | Use it directly |
| 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 |
**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
- 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:
- `Opportunity_Source__c` - Picklist with portal values
- `Portal_Source__c` - Picklist with portal values
- `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).

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`
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)
5. Agent updates Account eligibility fields
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 │
│ └─ 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 │
│ └─ 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] Order.OpportunityId (standard lookup)
**New Fields to Create on Opportunity:**
**Opportunity Fields Required (Portal writes these):**
- [ ] `Portal_Source__c` picklist
- [ ] `WHMCS_Service_ID__c` number field
- [ ] `Portal_Source__c` picklist (used to track how the Opportunity was created)
- [ ] `WHMCS_Service_ID__c` number field (used to link WHMCS service → Salesforce Opportunity for cancellations)
### WHMCS Admin Tasks
- [ ] Create `OpportunityId` custom field on Services/Hosting
- [ ] Document custom field ID for AddOrder API
- [ ] Create an `OpportunityId` custom field on Services/Hosting (optional but recommended for ops/debugging)
- [ ] Confirm whether your WHMCS expects `customfields[]` keys by **name** (`OpportunityId`) or by **numeric field id**, and configure accordingly
### BFF Development Tasks
@ -466,22 +466,20 @@ On/After 25th → Must select NEXT month or later
- [x] Field map configuration
- [x] `SalesforceOpportunityService`
- [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
- [ ] 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
- [ ] Read `OpportunityId` back from WHMCS service custom fields (portal currently relies on Salesforce `WHMCS_Service_ID__c` for linking)
### Frontend Tasks
- [ ] Order tracking page (from Salesforce Order)
- [ ] 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];
// ============================================================================
// Opportunity Matching Constants
// Opportunity Matching / Stage Sets
// ============================================================================
/**
* Stages considered "open" for matching purposes
* Opportunities in these stages can be linked to new orders
* Stages considered "open" (i.e. not closed) in our lifecycle.
*
* 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[] = [
OPPORTUNITY_STAGE.INTRODUCTION,
@ -215,6 +219,27 @@ export const OPEN_OPPORTUNITY_STAGES: OpportunityStageValue[] = [
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
*/

View File

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