refactor: simplify order fulfillment and remove unused public pages

- Extract fulfillment step executors and factory from orchestrator
- Remove unused signup, migrate, and internet configure pages
- Simplify PublicShell and landing page components
- Standardize conditional expressions across codebase
This commit is contained in:
barsa 2026-02-03 17:35:47 +09:00
parent ff9ee10860
commit 4cb393bdb8
44 changed files with 1553 additions and 1372 deletions

View File

@ -130,15 +130,15 @@ export class CacheService {
if (!results) { if (!results) {
return; return;
} }
results.forEach((result: [Error | null, unknown] | null) => { for (const result of results) {
if (!result) { if (!result) {
return; continue;
} }
const [error, usage] = result; const [error, usage] = result as [Error | null, unknown];
if (!error && typeof usage === "number") { if (!error && typeof usage === "number") {
total += usage; total += usage;
} }
}); }
}); });
return total; return total;
} }
@ -299,6 +299,7 @@ export class CacheService {
): Promise<void> { ): Promise<void> {
let cursor = "0"; let cursor = "0";
do { do {
// eslint-disable-next-line no-await-in-loop -- Cursor-based Redis SCAN requires sequential iteration
const [next, keys] = (await this.redis.scan( const [next, keys] = (await this.redis.scan(
cursor, cursor,
"MATCH", "MATCH",
@ -308,6 +309,7 @@ export class CacheService {
)) as unknown as [string, string[]]; )) as unknown as [string, string[]];
cursor = next; cursor = next;
if (keys && keys.length) { if (keys && keys.length) {
// eslint-disable-next-line no-await-in-loop -- Sequential processing of batched keys
await onKeys(keys); await onKeys(keys);
} }
} while (cursor !== "0"); } while (cursor !== "0");

View File

@ -58,6 +58,7 @@ export class DistributedLockService {
for (let attempt = 0; attempt <= maxRetries; attempt++) { for (let attempt = 0; attempt <= maxRetries; attempt++) {
// SET key token NX PX ttl - atomic set if not exists with TTL // SET key token NX PX ttl - atomic set if not exists with TTL
// eslint-disable-next-line no-await-in-loop -- Lock retry requires sequential attempts
const result = await this.redis.set(lockKey, token, "PX", ttlMs, "NX"); const result = await this.redis.set(lockKey, token, "PX", ttlMs, "NX");
if (result === "OK") { if (result === "OK") {
@ -71,6 +72,7 @@ export class DistributedLockService {
// Lock is held by someone else, wait and retry // Lock is held by someone else, wait and retry
if (attempt < maxRetries) { if (attempt < maxRetries) {
// eslint-disable-next-line no-await-in-loop -- Intentional delay between retry attempts
await this.delay(retryDelayMs); await this.delay(retryDelayMs);
} }
} }

View File

@ -455,6 +455,7 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest
for (let attempt = 1; attempt <= maxAttempts; attempt++) { for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try { try {
// eslint-disable-next-line no-await-in-loop -- Retry with backoff requires sequential attempts
const result = await requestFn(); const result = await requestFn();
return result; return result;
} catch (error) { } catch (error) {
@ -480,6 +481,7 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest
error: lastError.message, error: lastError.message,
}); });
// eslint-disable-next-line no-await-in-loop -- Exponential backoff delay between retries
await new Promise(resolve => { await new Promise(resolve => {
setTimeout(resolve, delay); setTimeout(resolve, delay);
}); });

View File

@ -6,6 +6,13 @@ import { FreebitVoiceService } from "../services/freebit-voice.service.js";
import { FreebitCancellationService } from "../services/freebit-cancellation.service.js"; import { FreebitCancellationService } from "../services/freebit-cancellation.service.js";
import { FreebitEsimService, type EsimActivationParams } from "../services/freebit-esim.service.js"; import { FreebitEsimService, type EsimActivationParams } from "../services/freebit-esim.service.js";
import { FreebitMapperService } from "../services/freebit-mapper.service.js"; import { FreebitMapperService } from "../services/freebit-mapper.service.js";
import { FreebitAccountRegistrationService } from "../services/freebit-account-registration.service.js";
import {
FreebitVoiceOptionsService,
type VoiceOptionIdentityData,
} from "../services/freebit-voice-options.service.js";
import { FreebitSemiBlackService } from "../services/freebit-semiblack.service.js";
import { FreebitRateLimiterService } from "../services/freebit-rate-limiter.service.js";
import type { SimDetails, SimUsage, SimTopUpHistory } from "../interfaces/freebit.types.js"; import type { SimDetails, SimUsage, SimTopUpHistory } from "../interfaces/freebit.types.js";
/** /**
@ -35,7 +42,11 @@ export class FreebitFacade {
private readonly voiceService: FreebitVoiceService, private readonly voiceService: FreebitVoiceService,
private readonly cancellationService: FreebitCancellationService, private readonly cancellationService: FreebitCancellationService,
private readonly esimService: FreebitEsimService, private readonly esimService: FreebitEsimService,
private readonly mapper: FreebitMapperService private readonly mapper: FreebitMapperService,
private readonly accountRegistrationService: FreebitAccountRegistrationService,
private readonly voiceOptionsService: FreebitVoiceOptionsService,
private readonly semiBlackService: FreebitSemiBlackService,
private readonly rateLimiterService: FreebitRateLimiterService
) {} ) {}
/** /**
@ -191,4 +202,80 @@ export class FreebitFacade {
}; };
return this.esimService.activateEsimAccountNew(normalizedParams); return this.esimService.activateEsimAccountNew(normalizedParams);
} }
// ============================================================================
// Physical SIM Registration Operations
// ============================================================================
/**
* Register a semi-black SIM account (PA05-18)
*
* This MUST be called BEFORE PA02-01 for physical SIMs.
* Semi-black SIMs are pre-provisioned physical SIMs that need to be registered
* to associate them with a customer account and plan.
*
* Error 210 "アカウント不在エラー" on PA02-01 indicates that PA05-18
* was not called first.
*/
async registerSemiBlackAccount(params: {
account: string;
productNumber: string;
planCode: string;
shipDate?: string;
}): Promise<void> {
const normalizedAccount = this.normalizeAccount(params.account);
return this.semiBlackService.registerSemiBlackAccount({
...params,
account: normalizedAccount,
});
}
/**
* Register an MVNO account (PA02-01)
*
* For Physical SIMs, call PA05-18 first, then call this with createType="add".
* For other cases, call this directly with createType="new".
*/
async registerAccount(params: {
account: string;
planCode: string;
createType?: "new" | "add";
}): Promise<void> {
const normalizedAccount = this.normalizeAccount(params.account);
return this.accountRegistrationService.registerAccount({
...params,
account: normalizedAccount,
});
}
/**
* Register voice options for an MVNO account (PA05-05)
*
* This configures voice features for a phone number that was previously
* registered via PA02-01. Must be called after account registration.
*/
async registerVoiceOptions(params: {
account: string;
voiceMailEnabled: boolean;
callWaitingEnabled: boolean;
identificationData: VoiceOptionIdentityData;
}): Promise<void> {
const normalizedAccount = this.normalizeAccount(params.account);
return this.voiceOptionsService.registerVoiceOptions({
...params,
account: normalizedAccount,
});
}
// ============================================================================
// Debug Operations (admin-only)
// ============================================================================
/**
* Clear rate limit state for an account (for debugging/testing)
*/
async clearRateLimitForAccount(account: string): Promise<void> {
const normalizedAccount = this.normalizeAccount(account);
return this.rateLimiterService.clearRateLimitForAccount(normalizedAccount);
}
} }

View File

@ -125,14 +125,12 @@ export class FreebitAccountRegistrationService {
>("/master/addAcnt/", payload); >("/master/addAcnt/", payload);
// Check response for individual account results // Check response for individual account results
if (response.responseDatas && response.responseDatas.length > 0) { const accountResult = response.responseDatas?.[0];
const accountResult = response.responseDatas[0]; if (accountResult && accountResult.resultCode !== "100") {
if (accountResult.resultCode !== "100") {
throw new BadRequestException( throw new BadRequestException(
`Account registration failed for ${account}: result code ${accountResult.resultCode}` `Account registration failed for ${account}: result code ${accountResult.resultCode}`
); );
} }
}
this.logger.log("MVNO account registration successful (PA02-01)", { this.logger.log("MVNO account registration successful (PA02-01)", {
account, account,

View File

@ -64,6 +64,7 @@ export class FreebitAccountService {
if (ep !== candidates[0]) { if (ep !== candidates[0]) {
this.logger.warn(`Retrying Freebit account details with alternative endpoint: ${ep}`); this.logger.warn(`Retrying Freebit account details with alternative endpoint: ${ep}`);
} }
// eslint-disable-next-line no-await-in-loop -- Endpoint fallback requires sequential attempts
response = await this.client.makeAuthenticatedRequest< response = await this.client.makeAuthenticatedRequest<
FreebitAccountDetailsResponse, FreebitAccountDetailsResponse,
typeof request typeof request

View File

@ -60,7 +60,7 @@ export class FreebitClientService {
}); });
if (!response.ok) { if (!response.ok) {
const isProd = process.env.NODE_ENV === "production"; const isProd = process.env["NODE_ENV"] === "production";
const bodySnippet = isProd ? undefined : await this.safeReadBodySnippet(response); const bodySnippet = isProd ? undefined : await this.safeReadBodySnippet(response);
this.logger.error("Freebit API HTTP error", { this.logger.error("Freebit API HTTP error", {
url, url,
@ -81,7 +81,7 @@ export class FreebitClientService {
const statusCode = this.normalizeResultCode(responseData.status?.statusCode); const statusCode = this.normalizeResultCode(responseData.status?.statusCode);
if (resultCode && resultCode !== "100") { if (resultCode && resultCode !== "100") {
const isProd = process.env.NODE_ENV === "production"; const isProd = process.env["NODE_ENV"] === "production";
const errorDetails = { const errorDetails = {
url, url,
resultCode, resultCode,
@ -166,12 +166,12 @@ export class FreebitClientService {
let attempt = 0; let attempt = 0;
// Log request details in dev for debugging // Log request details in dev for debugging
const isProd = process.env.NODE_ENV === "production"; const isProd = process.env["NODE_ENV"] === "production";
if (!isProd) { if (!isProd) {
console.log("[FREEBIT JSON API REQUEST]", JSON.stringify({ this.logger.debug("[FREEBIT JSON API REQUEST]", {
url, url,
payload: redactForLogs(requestPayload), payload: redactForLogs(requestPayload),
}, null, 2)); });
} }
try { try {
const responseData = await withRetry( const responseData = await withRetry(
@ -207,7 +207,7 @@ export class FreebitClientService {
const statusCode = this.normalizeResultCode(responseData.status?.statusCode); const statusCode = this.normalizeResultCode(responseData.status?.statusCode);
if (resultCode && resultCode !== "100") { if (resultCode && resultCode !== "100") {
const isProd = process.env.NODE_ENV === "production"; const isProd = process.env["NODE_ENV"] === "production";
const errorDetails = { const errorDetails = {
url, url,
resultCode, resultCode,
@ -342,7 +342,7 @@ export class FreebitClientService {
): Promise<void> { ): Promise<void> {
const payloadObj = payload as Record<string, unknown>; const payloadObj = payload as Record<string, unknown>;
const phoneNumber = this.testTracker.extractPhoneNumber( const phoneNumber = this.testTracker.extractPhoneNumber(
(payloadObj.account as string) || "", (payloadObj["account"] as string) || "",
payload payload
); );
@ -371,10 +371,10 @@ export class FreebitClientService {
apiEndpoint: endpoint, apiEndpoint: endpoint,
apiMethod: "POST", apiMethod: "POST",
phoneNumber, phoneNumber,
simIdentifier: (payloadObj.account as string) || phoneNumber, simIdentifier: (payloadObj["account"] as string) || phoneNumber,
requestPayload: JSON.stringify(redactForLogs(payload)), requestPayload: JSON.stringify(redactForLogs(payload)),
responseStatus: resultCode === "100" ? "Success" : `Error: ${resultCode}`, responseStatus: resultCode === "100" ? "Success" : `Error: ${resultCode}`,
error: error ? extractErrorMessage(error) : undefined, ...(error ? { error: extractErrorMessage(error) } : {}),
additionalInfo: statusMessage, additionalInfo: statusMessage,
}); });
} }

View File

@ -132,10 +132,7 @@ export class FreebitMapperService {
// No stored options, parse from API response // No stored options, parse from API response
// Default to false - disabled unless API explicitly returns 10 (enabled) // Default to false - disabled unless API explicitly returns 10 (enabled)
voiceMailEnabled = this.parseOptionFlag(account.voicemail ?? account.voiceMail, false); voiceMailEnabled = this.parseOptionFlag(account.voicemail ?? account.voiceMail, false);
callWaitingEnabled = this.parseOptionFlag( callWaitingEnabled = this.parseOptionFlag(account.callwaiting ?? account.callWaiting, false);
account.callwaiting ?? account.callWaiting,
false
);
internationalRoamingEnabled = this.parseOptionFlag( internationalRoamingEnabled = this.parseOptionFlag(
account.worldwing ?? account.worldWing, account.worldwing ?? account.worldWing,
false false
@ -175,13 +172,13 @@ export class FreebitMapperService {
} }
// Log raw account data in dev to debug MSISDN availability // Log raw account data in dev to debug MSISDN availability
if (process.env.NODE_ENV !== "production") { if (process.env["NODE_ENV"] !== "production") {
console.log("[FREEBIT ACCOUNT DATA]", JSON.stringify({ this.logger.debug("[FREEBIT ACCOUNT DATA]", {
account: account.account, account: account.account,
msisdn: account.msisdn, msisdn: account.msisdn,
eid: account.eid, eid: account.eid,
iccid: account.iccid, iccid: account.iccid,
}, null, 2)); });
} }
return { return {

View File

@ -178,39 +178,4 @@ export class FreebitSemiBlackService {
const day = String(now.getDate()).padStart(2, "0"); const day = String(now.getDate()).padStart(2, "0");
return `${year}${month}${day}`; return `${year}${month}${day}`;
} }
/**
* Get human-readable error message for PA05-18 error codes
*/
private getErrorMessage(code: number, defaultMessage?: string): string {
const errorMessages: Record<number, string> = {
201: "Invalid account/phone number parameter",
202: "Invalid master password",
204: "Invalid parameter",
205: "Authentication key error",
208: "Account already exists (duplicate)",
210: "Master account not found",
211: "Account status does not allow this operation",
215: "Invalid plan code",
228: "Invalid authentication key",
230: "Account is in async processing queue",
231: "Invalid global IP parameter",
232: "Plan not found",
266: "Invalid product number (manufacturing number)",
269: "Invalid representative number",
274: "Invalid delivery code",
275: "No phone number stock for representative number",
276: "Invalid ship date",
279: "Invalid create type",
284: "Representative number is locked",
287: "Representative number does not exist or is unavailable",
288: "Product number does not exist, is already used, or not stocked",
289: "SIM type does not match representative number type",
306: "Invalid MNP method",
313: "MNP reservation expires within grace period",
900: "Unexpected system error",
};
return errorMessages[code] ?? defaultMessage ?? "Unknown error";
}
} }

View File

@ -122,7 +122,12 @@ export class FreebitTestTrackerService {
// Try to extract from payload if it's an object // Try to extract from payload if it's an object
if (payload && typeof payload === "object") { if (payload && typeof payload === "object") {
const obj = payload as Record<string, unknown>; const obj = payload as Record<string, unknown>;
return (obj.account as string) || (obj.msisdn as string) || (obj.phoneNumber as string) || ""; return (
(obj["account"] as string) ||
(obj["msisdn"] as string) ||
(obj["phoneNumber"] as string) ||
""
);
} }
return ""; return "";

View File

@ -6,7 +6,6 @@ import { FreebitMapperService } from "./freebit-mapper.service.js";
import type { import type {
FreebitTrafficInfoRequest, FreebitTrafficInfoRequest,
FreebitTrafficInfoResponse, FreebitTrafficInfoResponse,
FreebitTopUpRequest,
FreebitTopUpResponse, FreebitTopUpResponse,
FreebitQuotaHistoryRequest, FreebitQuotaHistoryRequest,
FreebitQuotaHistoryResponse, FreebitQuotaHistoryResponse,

View File

@ -202,7 +202,7 @@ export class FreebitVoiceOptionsService {
if (!date) return ""; if (!date) return "";
const d = typeof date === "string" ? new Date(date) : date; const d = typeof date === "string" ? new Date(date) : date;
if (isNaN(d.getTime())) return ""; if (Number.isNaN(d.getTime())) return "";
const year = d.getFullYear(); const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, "0"); const month = String(d.getMonth() + 1).padStart(2, "0");

View File

@ -12,3 +12,12 @@ export {
FreebitSemiBlackService, FreebitSemiBlackService,
type SemiBlackRegistrationParams, type SemiBlackRegistrationParams,
} from "./freebit-semiblack.service.js"; } from "./freebit-semiblack.service.js";
export {
FreebitAccountRegistrationService,
type AccountRegistrationParams,
} from "./freebit-account-registration.service.js";
export {
FreebitVoiceOptionsService,
type VoiceOptionRegistrationParams,
type VoiceOptionIdentityData,
} from "./freebit-voice-options.service.js";

View File

@ -231,7 +231,7 @@ export class SalesforceSIMInventoryService {
id: raw.Id, id: raw.Id,
phoneNumber: raw.Phone_Number__c ?? "", phoneNumber: raw.Phone_Number__c ?? "",
ptNumber: raw.PT_Number__c ?? "", ptNumber: raw.PT_Number__c ?? "",
oemId: raw.OEM_ID__c ?? undefined, ...(raw.OEM_ID__c && { oemId: raw.OEM_ID__c }),
status: (raw.Status__c as SimInventoryStatus) ?? SIM_INVENTORY_STATUS.AVAILABLE, status: (raw.Status__c as SimInventoryStatus) ?? SIM_INVENTORY_STATUS.AVAILABLE,
}; };
} }

View File

@ -91,13 +91,11 @@ export class WhmcsErrorHandlerService {
/** /**
* Parse HTTP status error from error message (e.g., "HTTP 500: Internal Server Error") * Parse HTTP status error from error message (e.g., "HTTP 500: Internal Server Error")
*/ */
private parseHttpStatusError( private parseHttpStatusError(message: string): { status: number; statusText: string } | null {
message: string
): { status: number; statusText: string } | null {
const match = message.match(/^HTTP (\d{3}):\s*(.*)$/i); const match = message.match(/^HTTP (\d{3}):\s*(.*)$/i);
if (match) { if (match?.[1]) {
return { return {
status: parseInt(match[1], 10), status: Number.parseInt(match[1], 10),
statusText: match[2] || "Unknown", statusText: match[2] || "Unknown",
}; };
} }

View File

@ -124,6 +124,7 @@ export class WhmcsInvoiceService {
if (!batch) continue; if (!batch) continue;
// Process batch in parallel // Process batch in parallel
// eslint-disable-next-line no-await-in-loop -- Batch processing with rate limiting requires sequential batches
const batchResults = await Promise.all( const batchResults = await Promise.all(
batch.map(async (invoice: Invoice) => { batch.map(async (invoice: Invoice) => {
try { try {
@ -144,6 +145,7 @@ export class WhmcsInvoiceService {
// Add delay between batches (except for the last batch) to respect rate limits // Add delay between batches (except for the last batch) to respect rate limits
if (i < batches.length - 1) { if (i < batches.length - 1) {
// eslint-disable-next-line no-await-in-loop -- Intentional rate limit delay between batches
await sleep(BATCH_DELAY_MS); await sleep(BATCH_DELAY_MS);
} }
} }
@ -333,9 +335,10 @@ export class WhmcsInvoiceService {
fullResponse: JSON.stringify(response), fullResponse: JSON.stringify(response),
}); });
const rawError = (response as Record<string, unknown>)["error"];
const errorMessage = const errorMessage =
response.message || response.message ||
(response as Record<string, unknown>).error || (typeof rawError === "string" ? rawError : JSON.stringify(rawError ?? "")) ||
`Unknown error (result: ${response.result})`; `Unknown error (result: ${response.result})`;
throw new WhmcsOperationException(`WHMCS invoice creation failed: ${errorMessage}`, { throw new WhmcsOperationException(`WHMCS invoice creation failed: ${errorMessage}`, {

View File

@ -159,7 +159,7 @@ export class WhmcsOrderService {
const serviceIds = this.parseDelimitedIds(parsedResponse.data.serviceids); const serviceIds = this.parseDelimitedIds(parsedResponse.data.serviceids);
const invoiceId = parsedResponse.data.invoiceid const invoiceId = parsedResponse.data.invoiceid
? parseInt(String(parsedResponse.data.invoiceid), 10) ? Number.parseInt(String(parsedResponse.data.invoiceid), 10)
: undefined; : undefined;
this.logger.log("WHMCS order accepted successfully", { this.logger.log("WHMCS order accepted successfully", {
@ -169,7 +169,7 @@ export class WhmcsOrderService {
sfOrderId, sfOrderId,
}); });
return { serviceIds, invoiceId }; return { serviceIds, ...(invoiceId !== undefined && { invoiceId }) };
} catch (error) { } catch (error) {
// Enhanced error logging with full context // Enhanced error logging with full context
this.logger.error("Failed to accept WHMCS order", { this.logger.error("Failed to accept WHMCS order", {

View File

@ -94,6 +94,7 @@ export class JoseJwtService {
const key = this.verificationKeys[i]; const key = this.verificationKeys[i];
if (!key) continue; if (!key) continue;
try { try {
// eslint-disable-next-line no-await-in-loop -- JWT verification with multiple keys requires sequential attempts
const { payload } = await jwtVerify(token, key, options); const { payload } = await jwtVerify(token, key, options);
return payload as T; return payload as T;
} catch (err) { } catch (err) {

View File

@ -40,6 +40,9 @@ import { OrderFulfillmentOrchestrator } from "./services/order-fulfillment-orche
import { OrderFulfillmentErrorService } from "./services/order-fulfillment-error.service.js"; import { OrderFulfillmentErrorService } from "./services/order-fulfillment-error.service.js";
import { SimFulfillmentService } from "./services/sim-fulfillment.service.js"; import { SimFulfillmentService } from "./services/sim-fulfillment.service.js";
import { FulfillmentSideEffectsService } from "./services/fulfillment-side-effects.service.js"; import { FulfillmentSideEffectsService } from "./services/fulfillment-side-effects.service.js";
import { FulfillmentContextMapper } from "./services/fulfillment-context-mapper.service.js";
import { FulfillmentStepExecutors } from "./services/fulfillment-step-executors.service.js";
import { FulfillmentStepFactory } from "./services/fulfillment-step-factory.service.js";
import { ProvisioningQueueService } from "./queue/provisioning.queue.js"; import { ProvisioningQueueService } from "./queue/provisioning.queue.js";
import { ProvisioningProcessor } from "./queue/provisioning.processor.js"; import { ProvisioningProcessor } from "./queue/provisioning.processor.js";
import { SalesforceOrderFieldConfigModule } from "@bff/integrations/salesforce/config/salesforce-order-field-config.module.js"; import { SalesforceOrderFieldConfigModule } from "@bff/integrations/salesforce/config/salesforce-order-field-config.module.js";
@ -88,6 +91,9 @@ import { SalesforceOrderFieldConfigModule } from "@bff/integrations/salesforce/c
OrderFulfillmentErrorService, OrderFulfillmentErrorService,
SimFulfillmentService, SimFulfillmentService,
FulfillmentSideEffectsService, FulfillmentSideEffectsService,
FulfillmentContextMapper,
FulfillmentStepExecutors,
FulfillmentStepFactory,
// Async provisioning queue // Async provisioning queue
ProvisioningQueueService, ProvisioningQueueService,
ProvisioningProcessor, ProvisioningProcessor,

View File

@ -0,0 +1,180 @@
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import type { SalesforceOrderRecord } from "@customer-portal/domain/orders/providers";
import type { ContactIdentityData } from "./sim-fulfillment.service.js";
/**
* Fulfillment Context Mapper Service
*
* Extracts and transforms configuration data from payload and Salesforce order records.
* Used during fulfillment to prepare data for SIM activation and other operations.
*/
@Injectable()
export class FulfillmentContextMapper {
constructor(@Inject(Logger) private readonly logger: Logger) {}
/**
* Extract configurations from payload and Salesforce order
*
* Merges payload configurations with Salesforce order fields,
* with payload taking precedence over SF fields.
*/
extractConfigurations(
rawConfigurations: unknown,
sfOrder?: SalesforceOrderRecord | null
): Record<string, unknown> {
const config: Record<string, unknown> = {};
// Start with payload configurations if provided
if (rawConfigurations && typeof rawConfigurations === "object") {
Object.assign(config, rawConfigurations as Record<string, unknown>);
}
// Fill in missing fields from Salesforce order (CDC flow fallback)
if (sfOrder) {
if (!config["simType"] && sfOrder.SIM_Type__c) {
config["simType"] = sfOrder.SIM_Type__c;
}
if (!config["eid"] && sfOrder.EID__c) {
config["eid"] = sfOrder.EID__c;
}
if (!config["activationType"] && sfOrder.Activation_Type__c) {
config["activationType"] = sfOrder.Activation_Type__c;
}
if (!config["scheduledAt"] && sfOrder.Activation_Scheduled_At__c) {
config["scheduledAt"] = sfOrder.Activation_Scheduled_At__c;
}
if (!config["mnpPhone"] && sfOrder.MNP_Phone_Number__c) {
config["mnpPhone"] = sfOrder.MNP_Phone_Number__c;
}
// MNP fields
if (!config["isMnp"] && sfOrder.MNP_Application__c) {
config["isMnp"] = sfOrder.MNP_Application__c ? "true" : undefined;
}
if (!config["mnpNumber"] && sfOrder.MNP_Reservation_Number__c) {
config["mnpNumber"] = sfOrder.MNP_Reservation_Number__c;
}
if (!config["mnpExpiry"] && sfOrder.MNP_Expiry_Date__c) {
config["mnpExpiry"] = sfOrder.MNP_Expiry_Date__c;
}
if (!config["mvnoAccountNumber"] && sfOrder.MVNO_Account_Number__c) {
config["mvnoAccountNumber"] = sfOrder.MVNO_Account_Number__c;
}
if (!config["portingFirstName"] && sfOrder.Porting_FirstName__c) {
config["portingFirstName"] = sfOrder.Porting_FirstName__c;
}
if (!config["portingLastName"] && sfOrder.Porting_LastName__c) {
config["portingLastName"] = sfOrder.Porting_LastName__c;
}
if (!config["portingFirstNameKatakana"] && sfOrder.Porting_FirstName_Katakana__c) {
config["portingFirstNameKatakana"] = sfOrder.Porting_FirstName_Katakana__c;
}
if (!config["portingLastNameKatakana"] && sfOrder.Porting_LastName_Katakana__c) {
config["portingLastNameKatakana"] = sfOrder.Porting_LastName_Katakana__c;
}
if (!config["portingGender"] && sfOrder.Porting_Gender__c) {
config["portingGender"] = sfOrder.Porting_Gender__c;
}
if (!config["portingDateOfBirth"] && sfOrder.Porting_DateOfBirth__c) {
config["portingDateOfBirth"] = sfOrder.Porting_DateOfBirth__c;
}
}
return config;
}
/**
* Extract contact identity data from Salesforce order porting fields
*
* Used for PA05-05 Voice Options Registration which requires:
* - Name in Kanji and Kana
* - Gender (M/F)
* - Birthday (YYYYMMDD)
*
* Returns undefined if required fields are missing.
*/
extractContactIdentity(sfOrder?: SalesforceOrderRecord | null): ContactIdentityData | undefined {
if (!sfOrder) return undefined;
// Extract porting fields
const firstnameKanji = sfOrder.Porting_FirstName__c;
const lastnameKanji = sfOrder.Porting_LastName__c;
const firstnameKana = sfOrder.Porting_FirstName_Katakana__c;
const lastnameKana = sfOrder.Porting_LastName_Katakana__c;
const genderRaw = sfOrder.Porting_Gender__c;
const birthdayRaw = sfOrder.Porting_DateOfBirth__c;
// Validate all required fields are present
if (!firstnameKanji || !lastnameKanji) {
this.logger.debug("Missing name fields for contact identity", {
hasFirstName: !!firstnameKanji,
hasLastName: !!lastnameKanji,
});
return undefined;
}
if (!firstnameKana || !lastnameKana) {
this.logger.debug("Missing kana name fields for contact identity", {
hasFirstNameKana: !!firstnameKana,
hasLastNameKana: !!lastnameKana,
});
return undefined;
}
// Validate gender (must be M or F)
const gender = genderRaw === "M" || genderRaw === "F" ? genderRaw : undefined;
if (!gender) {
this.logger.debug("Invalid or missing gender for contact identity", { genderRaw });
return undefined;
}
// Format birthday to YYYYMMDD
const birthday = this.formatBirthdayToYYYYMMDD(birthdayRaw);
if (!birthday) {
this.logger.debug("Invalid or missing birthday for contact identity", { birthdayRaw });
return undefined;
}
return {
firstnameKanji,
lastnameKanji,
firstnameKana,
lastnameKana,
gender,
birthday,
};
}
/**
* Format birthday from various formats to YYYYMMDD
*/
formatBirthdayToYYYYMMDD(dateStr?: string | null): string | undefined {
if (!dateStr) return undefined;
// If already in YYYYMMDD format
if (/^\d{8}$/.test(dateStr)) {
return dateStr;
}
// Try parsing as ISO date (YYYY-MM-DD)
const isoMatch = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (isoMatch) {
return `${isoMatch[1]}${isoMatch[2]}${isoMatch[3]}`;
}
// Try parsing as Date object
try {
const date = new Date(dateStr);
if (!Number.isNaN(date.getTime())) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}${month}${day}`;
}
} catch {
// Parsing failed
}
return undefined;
}
}

View File

@ -0,0 +1,434 @@
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { SalesforceFacade } from "@bff/integrations/salesforce/facades/salesforce.facade.js";
import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js";
import { WhmcsOrderService } from "@bff/integrations/whmcs/services/whmcs-order.service.js";
import type { WhmcsOrderResult } from "@bff/integrations/whmcs/services/whmcs-order.service.js";
import { SimFulfillmentService, type SimFulfillmentResult } from "./sim-fulfillment.service.js";
import { FulfillmentSideEffectsService } from "./fulfillment-side-effects.service.js";
import { FulfillmentContextMapper } from "./fulfillment-context-mapper.service.js";
import type { OrderFulfillmentContext } from "./order-fulfillment-orchestrator.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { createOrderNotes, mapOrderToWhmcsItems } from "@customer-portal/domain/orders/providers";
import { OPPORTUNITY_STAGE } from "@customer-portal/domain/opportunity";
import {
OrderValidationException,
FulfillmentException,
WhmcsOperationException,
} from "@bff/core/exceptions/domain-exceptions.js";
type WhmcsOrderItemMappingResult = ReturnType<typeof mapOrderToWhmcsItems>;
/**
* Execute result containers for step data passing
*/
export interface StepExecutionState {
simFulfillmentResult?: SimFulfillmentResult;
mappingResult?: WhmcsOrderItemMappingResult;
whmcsCreateResult?: WhmcsOrderResult;
}
/**
* Fulfillment Step Executors Service
*
* Contains the actual execution logic for each fulfillment step.
* Extracted from OrderFulfillmentOrchestrator to keep it thin.
*/
@Injectable()
export class FulfillmentStepExecutors {
constructor(
@Inject(Logger) private readonly logger: Logger,
private readonly salesforceFacade: SalesforceFacade,
private readonly opportunityService: SalesforceOpportunityService,
private readonly whmcsOrderService: WhmcsOrderService,
private readonly simFulfillmentService: SimFulfillmentService,
private readonly sideEffects: FulfillmentSideEffectsService,
private readonly contextMapper: FulfillmentContextMapper
) {}
// ============================================
// Execute Methods
// ============================================
/**
* Update Salesforce order status to "Activating"
*/
async executeSfStatusUpdate(ctx: OrderFulfillmentContext): Promise<void> {
const result = await this.salesforceFacade.updateOrder({
Id: ctx.sfOrderId,
Activation_Status__c: "Activating",
});
this.sideEffects.publishActivating(ctx.sfOrderId);
await this.sideEffects.notifyOrderApproved(ctx.sfOrderId, ctx.validation?.sfOrder?.AccountId);
return result;
}
/**
* SIM fulfillment via Freebit (PA05-18 + PA02-01 + PA05-05)
*/
async executeSimFulfillment(
ctx: OrderFulfillmentContext,
payload: Record<string, unknown>
): Promise<SimFulfillmentResult> {
if (ctx.orderDetails?.orderType !== "SIM") {
return { activated: false, simType: "eSIM" as const };
}
const sfOrder = ctx.validation?.sfOrder;
const configurations = this.contextMapper.extractConfigurations(
payload["configurations"],
sfOrder
);
const assignedPhysicalSimId =
typeof sfOrder?.Assign_Physical_SIM__c === "string"
? sfOrder.Assign_Physical_SIM__c
: undefined;
// Extract voice options from SF order
const voiceMailEnabled = sfOrder?.SIM_Voice_Mail__c === true;
const callWaitingEnabled = sfOrder?.SIM_Call_Waiting__c === true;
// Extract contact identity from porting fields (for PA05-05)
const contactIdentity = this.contextMapper.extractContactIdentity(sfOrder);
this.logger.log("Starting SIM fulfillment (before WHMCS)", {
orderId: ctx.sfOrderId,
simType: sfOrder?.SIM_Type__c,
assignedPhysicalSimId,
voiceMailEnabled,
callWaitingEnabled,
hasContactIdentity: !!contactIdentity,
});
// Build request with only defined optional properties
const request: Parameters<typeof this.simFulfillmentService.fulfillSimOrder>[0] = {
orderDetails: ctx.orderDetails,
configurations,
voiceMailEnabled,
callWaitingEnabled,
};
if (assignedPhysicalSimId) {
request.assignedPhysicalSimId = assignedPhysicalSimId;
}
if (contactIdentity) {
request.contactIdentity = contactIdentity;
}
const result = await this.simFulfillmentService.fulfillSimOrder(request);
return result;
}
/**
* Update Salesforce order status to "Activated" after SIM fulfillment
*/
async executeSfActivatedUpdate(
ctx: OrderFulfillmentContext,
simFulfillmentResult?: SimFulfillmentResult
): Promise<{ skipped?: true } | void> {
if (ctx.orderDetails?.orderType !== "SIM" || !simFulfillmentResult?.activated) {
return { skipped: true };
}
const result = await this.salesforceFacade.updateOrder({
Id: ctx.sfOrderId,
Status: "Activated",
Activation_Status__c: "Activated",
});
this.sideEffects.publishStatusUpdate(ctx.sfOrderId, {
status: "Activated",
activationStatus: "Activated",
stage: "in_progress",
source: "fulfillment",
message: "SIM activated, proceeding to billing setup",
timestamp: new Date().toISOString(),
});
return result;
}
/**
* Map OrderItems to WHMCS format with SIM data
*/
executeMapping(
ctx: OrderFulfillmentContext,
simFulfillmentResult?: SimFulfillmentResult
): WhmcsOrderItemMappingResult {
if (!ctx.orderDetails) {
throw new Error("Order details are required for mapping");
}
// Use domain mapper directly - single transformation!
const result = mapOrderToWhmcsItems(ctx.orderDetails);
// Add SIM data if we have it (phone number goes to domain field and custom fields)
if (simFulfillmentResult?.activated && simFulfillmentResult.phoneNumber) {
for (const item of result.whmcsItems) {
// Set phone number as domain (shows in WHMCS Domain field)
item.domain = simFulfillmentResult.phoneNumber!;
// Also add to custom fields for SIM Number field
item.customFields = {
...item.customFields,
SimNumber: simFulfillmentResult.phoneNumber,
...(simFulfillmentResult.serialNumber && {
SerialNumber: simFulfillmentResult.serialNumber,
}),
};
}
}
this.logger.log("OrderItems mapped to WHMCS", {
totalItems: result.summary.totalItems,
serviceItems: result.summary.serviceItems,
activationItems: result.summary.activationItems,
hasSimData: !!simFulfillmentResult?.phoneNumber,
});
return result;
}
/**
* Create order in WHMCS
*/
async executeWhmcsCreate(
ctx: OrderFulfillmentContext,
mappingResult: WhmcsOrderItemMappingResult
): Promise<WhmcsOrderResult> {
if (!ctx.validation) {
throw new OrderValidationException("Validation context is missing", {
sfOrderId: ctx.sfOrderId,
step: "whmcs_create_order",
});
}
if (!mappingResult) {
throw new FulfillmentException("Mapping result is not available", {
sfOrderId: ctx.sfOrderId,
step: "whmcs_create_order",
});
}
const orderNotes = createOrderNotes(
ctx.sfOrderId,
`Provisioned from Salesforce Order ${ctx.sfOrderId}`
);
// Get OpportunityId from order details for WHMCS lifecycle linking
const sfOpportunityId = ctx.orderDetails?.opportunityId;
const result = await this.whmcsOrderService.addOrder({
clientId: ctx.validation.clientId,
items: mappingResult.whmcsItems,
paymentMethod: "stripe",
promoCode: "1st Month Free (Monthly Plan)",
sfOrderId: ctx.sfOrderId,
sfOpportunityId, // Pass to WHMCS for bidirectional linking
notes: orderNotes,
noinvoiceemail: true,
noemail: true,
});
return result;
}
/**
* Accept/provision order in WHMCS
*/
async executeWhmcsAccept(
ctx: OrderFulfillmentContext,
whmcsCreateResult: WhmcsOrderResult
): Promise<{ orderId: number; serviceIds: number[] }> {
if (!whmcsCreateResult?.orderId) {
throw new WhmcsOperationException("WHMCS order ID missing before acceptance step", {
sfOrderId: ctx.sfOrderId,
step: "whmcs_accept_order",
});
}
const acceptResult = await this.whmcsOrderService.acceptOrder(
whmcsCreateResult.orderId,
ctx.sfOrderId
);
// Return both orderId and serviceIds from AcceptOrder
// Note: Services are created on accept, not on add
const orderId = whmcsCreateResult.orderId;
const serviceIds = acceptResult.serviceIds.length > 0 ? acceptResult.serviceIds : [];
return { orderId, serviceIds };
}
/**
* Update Salesforce with WHMCS registration info
*/
async executeSfRegistrationComplete(
ctx: OrderFulfillmentContext,
whmcsCreateResult?: WhmcsOrderResult,
simFulfillmentResult?: SimFulfillmentResult
): Promise<void> {
// For SIM orders that are already "Activated", don't change Status
// Only update WHMCS info. For non-SIM orders, set Status to "Activated"
const isSIMOrder = ctx.orderDetails?.orderType === "SIM";
const isAlreadyActivated = simFulfillmentResult?.activated === true;
const updatePayload: { Id: string; [key: string]: unknown } = {
Id: ctx.sfOrderId,
Activation_Status__c: "Activated",
WHMCS_Order_ID__c: whmcsCreateResult?.orderId?.toString(),
};
// Only set Status if not already activated (non-SIM orders)
if (!isSIMOrder || !isAlreadyActivated) {
updatePayload["Status"] = "Activated";
}
const result = await this.salesforceFacade.updateOrder(updatePayload);
this.sideEffects.publishStatusUpdate(ctx.sfOrderId, {
status: "Activated",
activationStatus: "Activated",
stage: "completed",
source: "fulfillment",
timestamp: new Date().toISOString(),
payload: {
whmcsOrderId: whmcsCreateResult?.orderId,
whmcsServiceIds: whmcsCreateResult?.serviceIds,
simPhoneNumber: simFulfillmentResult?.phoneNumber,
},
});
await this.sideEffects.notifyOrderActivated(ctx.sfOrderId, ctx.validation?.sfOrder?.AccountId);
return result;
}
/**
* Update Opportunity with WHMCS Service ID and Active stage
*/
async executeOpportunityUpdate(
ctx: OrderFulfillmentContext,
whmcsCreateResult?: WhmcsOrderResult
): Promise<
| { skipped: true }
| { opportunityId: string; whmcsServiceId?: number }
| { failed: true; error: string }
> {
const opportunityId = ctx.orderDetails?.opportunityId;
const serviceId = whmcsCreateResult?.serviceIds?.[0];
if (!opportunityId) {
this.logger.debug("No Opportunity linked to order, skipping update", {
sfOrderId: ctx.sfOrderId,
});
return { skipped: true as const };
}
try {
// Update Opportunity stage to Active and set WHMCS Service ID
await this.opportunityService.updateStage(
opportunityId,
OPPORTUNITY_STAGE.ACTIVE,
"Service activated via fulfillment"
);
if (serviceId) {
await this.opportunityService.linkWhmcsServiceToOpportunity(opportunityId, serviceId);
}
this.logger.log("Opportunity updated with Active stage and WHMCS link", {
opportunityIdTail: opportunityId.slice(-4),
whmcsServiceId: serviceId,
sfOrderId: ctx.sfOrderId,
});
// Build result with optional whmcsServiceId only if present
const result: { opportunityId: string; whmcsServiceId?: number } = { opportunityId };
if (serviceId !== undefined) {
result.whmcsServiceId = serviceId;
}
return result;
} catch (error) {
// Log but don't fail - Opportunity update is non-critical
this.logger.warn("Failed to update Opportunity after fulfillment", {
error: extractErrorMessage(error),
opportunityId,
sfOrderId: ctx.sfOrderId,
});
return { failed: true as const, error: extractErrorMessage(error) };
}
}
// ============================================
// Rollback Methods (safe, never throw)
// ============================================
/**
* Rollback SF status update by setting to Failed
*/
async rollbackSfStatus(ctx: OrderFulfillmentContext): Promise<void> {
try {
await this.salesforceFacade.updateOrder({
Id: ctx.sfOrderId,
Activation_Status__c: "Failed",
});
} catch (error) {
this.logger.error("Rollback failed for SF status update", {
sfOrderId: ctx.sfOrderId,
error: extractErrorMessage(error),
});
}
}
/**
* Rollback SIM fulfillment - logs for manual intervention
*/
// eslint-disable-next-line @typescript-eslint/promise-function-async -- Synchronous method returning Promise to match interface
rollbackSimFulfillment(
ctx: OrderFulfillmentContext,
simFulfillmentResult?: SimFulfillmentResult
): Promise<void> {
// SIM activation cannot be easily rolled back
// Log for manual intervention if needed
this.logger.warn("SIM fulfillment step needs rollback - manual intervention may be required", {
sfOrderId: ctx.sfOrderId,
simFulfillmentResult,
});
return Promise.resolve();
}
/**
* Rollback WHMCS order creation - logs for manual cleanup
*/
// eslint-disable-next-line @typescript-eslint/promise-function-async -- Synchronous method returning Promise to match interface
rollbackWhmcsCreate(
ctx: OrderFulfillmentContext,
whmcsCreateResult?: WhmcsOrderResult
): Promise<void> {
if (whmcsCreateResult?.orderId) {
// Note: WHMCS doesn't have an automated cancel API
// Manual intervention required for order cleanup
this.logger.error("WHMCS order created but fulfillment failed - manual cleanup required", {
orderId: whmcsCreateResult.orderId,
sfOrderId: ctx.sfOrderId,
action: "MANUAL_CLEANUP_REQUIRED",
});
}
return Promise.resolve();
}
/**
* Rollback WHMCS order acceptance - logs for manual service termination
*/
// eslint-disable-next-line @typescript-eslint/promise-function-async -- Synchronous method returning Promise to match interface
rollbackWhmcsAccept(
ctx: OrderFulfillmentContext,
whmcsCreateResult?: WhmcsOrderResult
): Promise<void> {
if (whmcsCreateResult?.orderId) {
// Note: WHMCS doesn't have an automated cancel API for accepted orders
// Manual intervention required for service termination
this.logger.error("WHMCS order accepted but fulfillment failed - manual cleanup required", {
orderId: whmcsCreateResult.orderId,
serviceIds: whmcsCreateResult.serviceIds,
sfOrderId: ctx.sfOrderId,
action: "MANUAL_SERVICE_TERMINATION_REQUIRED",
});
}
return Promise.resolve();
}
}

View File

@ -0,0 +1,285 @@
import { Injectable } from "@nestjs/common";
import type { DistributedStep } from "@bff/infra/database/services/distributed-transaction.service.js";
import type { WhmcsOrderResult } from "@bff/integrations/whmcs/services/whmcs-order.service.js";
import type { SimFulfillmentResult } from "./sim-fulfillment.service.js";
import { FulfillmentStepExecutors } from "./fulfillment-step-executors.service.js";
import type {
OrderFulfillmentContext,
OrderFulfillmentStep,
} from "./order-fulfillment-orchestrator.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { mapOrderToWhmcsItems } from "@customer-portal/domain/orders/providers";
type WhmcsOrderItemMappingResult = ReturnType<typeof mapOrderToWhmcsItems>;
/**
* Mutable state container for passing results between steps
*/
interface StepState {
simFulfillmentResult?: SimFulfillmentResult;
mappingResult?: WhmcsOrderItemMappingResult;
whmcsCreateResult?: WhmcsOrderResult;
}
/**
* Fulfillment Step Factory Service
*
* Builds the array of DistributedStep for the fulfillment workflow.
* Conditionally includes SIM steps based on order type.
*/
@Injectable()
export class FulfillmentStepFactory {
constructor(private readonly executors: FulfillmentStepExecutors) {}
/**
* Build the ordered list of fulfillment steps
*
* Step order:
* 1. sf_status_update (Activating)
* 2. order_details (retain in context)
* 3. sim_fulfillment (SIM orders only - PA05-18 + PA02-01 + PA05-05)
* 4. sf_activated_update (SIM orders only)
* 5. mapping (with SIM data for WHMCS)
* 6. whmcs_create
* 7. whmcs_accept
* 8. sf_registration_complete
* 9. opportunity_update
*/
buildSteps(
context: OrderFulfillmentContext,
payload: Record<string, unknown>
): DistributedStep[] {
// Mutable state container for cross-step data
const state: StepState = {};
const steps: DistributedStep[] = [];
// Step 1: SF Status Update
steps.push(this.createSfStatusUpdateStep(context));
// Step 2: Order Details (retain in context)
steps.push(this.createOrderDetailsStep(context));
// Steps 3-4: SIM fulfillment (only for SIM orders)
// Note: orderDetails may not be available yet, so we check orderType from validation
const orderType = context.orderDetails?.orderType ?? context.validation?.sfOrder?.Type;
if (orderType === "SIM") {
steps.push(this.createSimFulfillmentStep(context, payload, state));
steps.push(this.createSfActivatedUpdateStep(context, state));
}
// Steps 5-9: WHMCS and completion
steps.push(this.createMappingStep(context, state));
steps.push(this.createWhmcsCreateStep(context, state));
steps.push(this.createWhmcsAcceptStep(context, state));
steps.push(this.createSfRegistrationCompleteStep(context, state));
steps.push(this.createOpportunityUpdateStep(context, state));
return steps;
}
private createSfStatusUpdateStep(ctx: OrderFulfillmentContext): DistributedStep {
return {
id: "sf_status_update",
description: "Update Salesforce order status to Activating",
execute: this.createTrackedStep(ctx, "sf_status_update", async () => {
return this.executors.executeSfStatusUpdate(ctx);
}),
rollback: async () => {
await this.executors.rollbackSfStatus(ctx);
},
critical: true,
};
}
private createOrderDetailsStep(ctx: OrderFulfillmentContext): DistributedStep {
return {
id: "order_details",
description: "Retain order details in context",
execute: this.createTrackedStep(ctx, "order_details", async () =>
Promise.resolve(ctx.orderDetails)
),
critical: false,
};
}
private createSimFulfillmentStep(
ctx: OrderFulfillmentContext,
payload: Record<string, unknown>,
state: StepState
): DistributedStep {
return {
id: "sim_fulfillment",
description: "SIM activation via Freebit (PA05-18 + PA02-01 + PA05-05)",
execute: this.createTrackedStep(ctx, "sim_fulfillment", async () => {
const result = await this.executors.executeSimFulfillment(ctx, payload);
state.simFulfillmentResult = result;
// eslint-disable-next-line require-atomic-updates -- Sequential step execution, context is scoped to this transaction
ctx.simFulfillmentResult = result;
return result;
}),
// eslint-disable-next-line @typescript-eslint/promise-function-async -- Returns Promise from executor, no async work needed
rollback: () => this.executors.rollbackSimFulfillment(ctx, state.simFulfillmentResult),
critical: ctx.validation?.sfOrder?.SIM_Type__c === "Physical SIM",
};
}
private createSfActivatedUpdateStep(
ctx: OrderFulfillmentContext,
state: StepState
): DistributedStep {
return {
id: "sf_activated_update",
description: "Update Salesforce order status to Activated",
execute: this.createTrackedStep(ctx, "sf_activated_update", async () => {
return this.executors.executeSfActivatedUpdate(ctx, state.simFulfillmentResult);
}),
rollback: async () => {
await this.executors.rollbackSfStatus(ctx);
},
critical: false,
};
}
private createMappingStep(ctx: OrderFulfillmentContext, state: StepState): DistributedStep {
return {
id: "mapping",
description: "Map OrderItems to WHMCS format with SIM data",
// eslint-disable-next-line @typescript-eslint/promise-function-async -- Synchronous mapping wrapped in Promise for interface
execute: this.createTrackedStep(ctx, "mapping", () => {
const result = this.executors.executeMapping(ctx, state.simFulfillmentResult);
state.mappingResult = result;
ctx.mappingResult = result;
return Promise.resolve(result);
}),
critical: true,
};
}
private createWhmcsCreateStep(ctx: OrderFulfillmentContext, state: StepState): DistributedStep {
return {
id: "whmcs_create",
description: "Create order in WHMCS",
execute: this.createTrackedStep(ctx, "whmcs_create", async () => {
if (!state.mappingResult) {
throw new Error("Mapping result is not available");
}
const result = await this.executors.executeWhmcsCreate(ctx, state.mappingResult);
// eslint-disable-next-line require-atomic-updates -- Sequential step execution, state is scoped to this transaction
state.whmcsCreateResult = result;
// eslint-disable-next-line require-atomic-updates -- Sequential step execution, context is scoped to this transaction
ctx.whmcsResult = result;
return result;
}),
// eslint-disable-next-line @typescript-eslint/promise-function-async -- Returns Promise from executor, no async work needed
rollback: () => this.executors.rollbackWhmcsCreate(ctx, state.whmcsCreateResult),
critical: true,
};
}
private createWhmcsAcceptStep(ctx: OrderFulfillmentContext, state: StepState): DistributedStep {
return {
id: "whmcs_accept",
description: "Accept/provision order in WHMCS",
execute: this.createTrackedStep(ctx, "whmcs_accept", async () => {
if (!state.whmcsCreateResult) {
throw new Error("WHMCS create result is not available");
}
const acceptResult = await this.executors.executeWhmcsAccept(ctx, state.whmcsCreateResult);
// Update state with serviceIds from accept (services are created on accept, not on add)
if (acceptResult.serviceIds.length > 0 && state.whmcsCreateResult) {
state.whmcsCreateResult = {
...state.whmcsCreateResult,
serviceIds: acceptResult.serviceIds,
};
}
return acceptResult;
}),
// eslint-disable-next-line @typescript-eslint/promise-function-async -- Returns Promise from executor, no async work needed
rollback: () => this.executors.rollbackWhmcsAccept(ctx, state.whmcsCreateResult),
critical: true,
};
}
private createSfRegistrationCompleteStep(
ctx: OrderFulfillmentContext,
state: StepState
): DistributedStep {
return {
id: "sf_registration_complete",
description: "Update Salesforce with WHMCS registration info",
execute: this.createTrackedStep(ctx, "sf_registration_complete", async () => {
return this.executors.executeSfRegistrationComplete(
ctx,
state.whmcsCreateResult,
state.simFulfillmentResult
);
}),
rollback: async () => {
await this.executors.rollbackSfStatus(ctx);
},
critical: true,
};
}
private createOpportunityUpdateStep(
ctx: OrderFulfillmentContext,
state: StepState
): DistributedStep {
return {
id: "opportunity_update",
description: "Update Opportunity with WHMCS Service ID and Active stage",
execute: this.createTrackedStep(ctx, "opportunity_update", async () => {
return this.executors.executeOpportunityUpdate(ctx, state.whmcsCreateResult);
}),
critical: false, // Opportunity update failure shouldn't rollback fulfillment
};
}
/**
* Wrap step executor with status tracking
*/
private createTrackedStep<TResult>(
context: OrderFulfillmentContext,
stepName: string,
executor: () => Promise<TResult>
): () => Promise<TResult> {
return async () => {
this.updateStepStatus(context, stepName, "in_progress");
try {
const result = await executor();
this.updateStepStatus(context, stepName, "completed");
return result;
} catch (error) {
this.updateStepStatus(context, stepName, "failed", extractErrorMessage(error));
throw error;
}
};
}
private updateStepStatus(
context: OrderFulfillmentContext,
stepName: string,
status: OrderFulfillmentStep["status"],
error?: string
): void {
const step = context.steps.find(s => s.step === stepName);
if (!step) return;
const timestamp = new Date();
if (status === "in_progress") {
step.status = "in_progress";
step.startedAt = timestamp;
delete step.error;
return;
}
step.status = status;
step.completedAt = timestamp;
if (status === "failed" && error) {
step.error = error;
} else {
delete step.error;
}
}
}

View File

@ -1,13 +1,15 @@
import { Injectable, BadRequestException, Inject } from "@nestjs/common"; import { Injectable, BadRequestException, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js"; import { SalesforceFacade } from "@bff/integrations/salesforce/facades/salesforce.facade.js";
import { SalesforceAccountService } from "@bff/integrations/salesforce/services/salesforce-account.service.js"; import { SalesforceAccountService } from "@bff/integrations/salesforce/services/salesforce-account.service.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { sfOrderIdParamSchema } from "@customer-portal/domain/orders"; import { sfOrderIdParamSchema } from "@customer-portal/domain/orders";
import type { OrderFulfillmentValidationResult } from "@customer-portal/domain/orders/providers"; import type {
OrderFulfillmentValidationResult,
SalesforceOrderRecord,
} from "@customer-portal/domain/orders/providers";
import { salesforceAccountIdSchema } from "@customer-portal/domain/common"; import { salesforceAccountIdSchema } from "@customer-portal/domain/common";
import type { SalesforceOrderRecord } from "@customer-portal/domain/orders/providers";
import { PaymentValidatorService } from "./payment-validator.service.js"; import { PaymentValidatorService } from "./payment-validator.service.js";
/** /**
@ -18,7 +20,7 @@ import { PaymentValidatorService } from "./payment-validator.service.js";
export class OrderFulfillmentValidator { export class OrderFulfillmentValidator {
constructor( constructor(
@Inject(Logger) private readonly logger: Logger, @Inject(Logger) private readonly logger: Logger,
private readonly salesforceService: SalesforceService, private readonly salesforceFacade: SalesforceFacade,
private readonly salesforceAccountService: SalesforceAccountService, private readonly salesforceAccountService: SalesforceAccountService,
private readonly mappingsService: MappingsService, private readonly mappingsService: MappingsService,
private readonly paymentValidator: PaymentValidatorService private readonly paymentValidator: PaymentValidatorService
@ -129,7 +131,7 @@ export class OrderFulfillmentValidator {
* Validate Salesforce order exists and is in valid state * Validate Salesforce order exists and is in valid state
*/ */
private async validateSalesforceOrder(sfOrderId: string): Promise<SalesforceOrderRecord> { private async validateSalesforceOrder(sfOrderId: string): Promise<SalesforceOrderRecord> {
const order = await this.salesforceService.getOrder(sfOrderId); const order = await this.salesforceFacade.getOrder(sfOrderId);
if (!order) { if (!order) {
throw new BadRequestException(`Salesforce order ${sfOrderId} not found`); throw new BadRequestException(`Salesforce order ${sfOrderId} not found`);
@ -175,8 +177,8 @@ export class OrderFulfillmentValidator {
return null; return null;
} }
const clientId = parseInt(match[1], 10); const clientId = Number.parseInt(match[1], 10);
if (isNaN(clientId) || clientId <= 0) { if (Number.isNaN(clientId) || clientId <= 0) {
return null; return null;
} }

View File

@ -1,8 +1,6 @@
import { Injectable, Inject } from "@nestjs/common"; import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service.js"; import { FreebitFacade } from "@bff/integrations/freebit/facades/freebit.facade.js";
import { FreebitAccountRegistrationService } from "@bff/integrations/freebit/services/freebit-account-registration.service.js";
import { FreebitVoiceOptionsService } from "@bff/integrations/freebit/services/freebit-voice-options.service.js";
import { SalesforceSIMInventoryService } from "@bff/integrations/salesforce/services/salesforce-sim-inventory.service.js"; import { SalesforceSIMInventoryService } from "@bff/integrations/salesforce/services/salesforce-sim-inventory.service.js";
import type { OrderDetails, OrderItemDetails } from "@customer-portal/domain/orders"; import type { OrderDetails, OrderItemDetails } from "@customer-portal/domain/orders";
import { mapProductToFreebitPlanCode } from "@customer-portal/domain/sim"; import { mapProductToFreebitPlanCode } from "@customer-portal/domain/sim";
@ -56,9 +54,7 @@ export interface SimFulfillmentResult {
@Injectable() @Injectable()
export class SimFulfillmentService { export class SimFulfillmentService {
constructor( constructor(
private readonly freebit: FreebitOrchestratorService, private readonly freebitFacade: FreebitFacade,
private readonly freebitAccountReg: FreebitAccountRegistrationService,
private readonly freebitVoiceOptions: FreebitVoiceOptionsService,
private readonly simInventory: SalesforceSIMInventoryService, private readonly simInventory: SalesforceSIMInventoryService,
@Inject(Logger) private readonly logger: Logger @Inject(Logger) private readonly logger: Logger
) {} ) {}
@ -73,7 +69,7 @@ export class SimFulfillmentService {
contactIdentity, contactIdentity,
} = request; } = request;
const simType = this.readEnum(configurations.simType, ["eSIM", "Physical SIM"]); const simType = this.readEnum(configurations["simType"], ["eSIM", "Physical SIM"]);
this.logger.log("Starting SIM fulfillment", { this.logger.log("Starting SIM fulfillment", {
orderId: orderDetails.id, orderId: orderDetails.id,
@ -91,16 +87,16 @@ export class SimFulfillmentService {
"SIM Type must be explicitly set to 'eSIM' or 'Physical SIM'", "SIM Type must be explicitly set to 'eSIM' or 'Physical SIM'",
{ {
orderId: orderDetails.id, orderId: orderDetails.id,
configuredSimType: configurations.simType, configuredSimType: configurations["simType"],
} }
); );
} }
const eid = this.readString(configurations.eid); const eid = this.readString(configurations["eid"]);
const activationType = const activationType =
this.readEnum(configurations.activationType, ["Immediate", "Scheduled"]) ?? "Immediate"; this.readEnum(configurations["activationType"], ["Immediate", "Scheduled"]) ?? "Immediate";
const scheduledAt = this.readString(configurations.scheduledAt); const scheduledAt = this.readString(configurations["scheduledAt"]);
const phoneNumber = this.readString(configurations.mnpPhone); const phoneNumber = this.readString(configurations["mnpPhone"]);
const mnp = this.extractMnpConfig(configurations); const mnp = this.extractMnpConfig(configurations);
const simPlanItem = orderDetails.items.find( const simPlanItem = orderDetails.items.find(
@ -144,8 +140,8 @@ export class SimFulfillmentService {
eid, eid,
planSku, planSku,
activationType, activationType,
scheduledAt, ...(scheduledAt && { scheduledAt }),
mnp, ...(mnp && { mnp }),
}); });
this.logger.log("eSIM fulfillment completed successfully", { this.logger.log("eSIM fulfillment completed successfully", {
@ -160,7 +156,7 @@ export class SimFulfillmentService {
phoneNumber, phoneNumber,
}; };
} else { } else {
// Physical SIM activation flow (PA02-01 + PA05-05) // Physical SIM activation flow (PA05-18 + PA02-01 + PA05-05)
if (!assignedPhysicalSimId) { if (!assignedPhysicalSimId) {
throw new SimActivationException( throw new SimActivationException(
"Physical SIM requires an assigned SIM from inventory (Assign_Physical_SIM__c)", "Physical SIM requires an assigned SIM from inventory (Assign_Physical_SIM__c)",
@ -222,29 +218,29 @@ export class SimFulfillmentService {
const { account, eid, planSku, activationType, scheduledAt, mnp } = params; const { account, eid, planSku, activationType, scheduledAt, mnp } = params;
try { try {
await this.freebit.activateEsimAccountNew({ await this.freebitFacade.activateEsimAccountNew({
account, account,
eid, eid,
planCode: planSku, planCode: planSku,
contractLine: "5G", contractLine: "5G",
shipDate: activationType === "Scheduled" ? scheduledAt : undefined, ...(activationType === "Scheduled" && scheduledAt && { shipDate: scheduledAt }),
mnp: ...(mnp?.reserveNumber &&
mnp && mnp.reserveNumber && mnp.reserveExpireDate mnp?.reserveExpireDate && {
? { mnp: {
reserveNumber: mnp.reserveNumber, reserveNumber: mnp.reserveNumber,
reserveExpireDate: mnp.reserveExpireDate, reserveExpireDate: mnp.reserveExpireDate,
} },
: undefined, }),
identity: mnp ...(mnp && {
? { identity: {
firstnameKanji: mnp.firstnameKanji, ...(mnp.firstnameKanji && { firstnameKanji: mnp.firstnameKanji }),
lastnameKanji: mnp.lastnameKanji, ...(mnp.lastnameKanji && { lastnameKanji: mnp.lastnameKanji }),
firstnameZenKana: mnp.firstnameZenKana, ...(mnp.firstnameZenKana && { firstnameZenKana: mnp.firstnameZenKana }),
lastnameZenKana: mnp.lastnameZenKana, ...(mnp.lastnameZenKana && { lastnameZenKana: mnp.lastnameZenKana }),
gender: mnp.gender, ...(mnp.gender && { gender: mnp.gender }),
birthday: mnp.birthday, ...(mnp.birthday && { birthday: mnp.birthday }),
} },
: undefined, }),
}); });
this.logger.log("eSIM activated successfully", { this.logger.log("eSIM activated successfully", {
@ -263,24 +259,28 @@ export class SimFulfillmentService {
} }
/** /**
* Activate Physical SIM via Freebit PA02-01 + PA05-05 APIs * Activate Physical SIM via Freebit PA05-18 + PA02-01 + PA05-05 APIs
* *
* Flow for Physical SIMs: * Flow for Physical SIMs:
* 1. Fetch SIM Inventory details from Salesforce * 1. Fetch SIM Inventory details from Salesforce
* 2. Validate SIM status is "Available" * 2. Validate SIM status is "Available"
* 3. Map product SKU to Freebit plan code * 3. Map product SKU to Freebit plan code
* 4. Call Freebit PA02-01 (Account Registration) with createType="new" * 4. Call Freebit PA05-18 (Semi-Black Registration) - MUST be called first!
* 5. Call Freebit PA05-05 (Voice Options) to configure voice features * 5. Call Freebit PA02-01 (Account Registration) with createType="add"
* 6. Update SIM Inventory status to "Used" * 6. Call Freebit PA05-05 (Voice Options) to configure voice features
* 7. Update SIM Inventory status to "Assigned"
*
* Note: PA05-18 must be called before PA02-01, otherwise PA02-01 will fail
* with error 210 "アカウント不在エラー" (Account not found error).
*/ */
private async activatePhysicalSim(params: { private async activatePhysicalSim(params: {
orderId: string; orderId: string;
simInventoryId: string; simInventoryId: string;
planSku: string; planSku: string;
planName?: string; planName?: string | undefined;
voiceMailEnabled: boolean; voiceMailEnabled: boolean;
callWaitingEnabled: boolean; callWaitingEnabled: boolean;
contactIdentity?: ContactIdentityData; contactIdentity?: ContactIdentityData | undefined;
}): Promise<{ phoneNumber: string; serialNumber: string }> { }): Promise<{ phoneNumber: string; serialNumber: string }> {
const { const {
orderId, orderId,
@ -292,7 +292,7 @@ export class SimFulfillmentService {
contactIdentity, contactIdentity,
} = params; } = params;
this.logger.log("Starting Physical SIM activation (PA02-01 + PA05-05)", { this.logger.log("Starting Physical SIM activation (PA05-18 + PA02-01 + PA05-05)", {
orderId, orderId,
simInventoryId, simInventoryId,
planSku, planSku,
@ -315,27 +315,50 @@ export class SimFulfillmentService {
// Use phone number from SIM inventory // Use phone number from SIM inventory
const accountPhoneNumber = simRecord.phoneNumber; const accountPhoneNumber = simRecord.phoneNumber;
// PT Number is the productNumber required for PA05-18
const productNumber = simRecord.ptNumber;
this.logger.log("Physical SIM inventory validated", { this.logger.log("Physical SIM inventory validated", {
orderId, orderId,
simInventoryId, simInventoryId,
accountPhoneNumber, accountPhoneNumber,
ptNumber: simRecord.ptNumber, productNumber,
planCode, planCode,
}); });
try { try {
// Step 4: Call Freebit PA02-01 (Account Registration) // Step 4: Call Freebit PA05-18 (Semi-Black Registration) - MUST be first!
this.logger.log("Calling PA05-18 Semi-Black Registration", {
orderId,
account: accountPhoneNumber,
productNumber,
planCode,
});
await this.freebitFacade.registerSemiBlackAccount({
account: accountPhoneNumber,
productNumber,
planCode,
});
this.logger.log("PA05-18 Semi-Black Registration successful", {
orderId,
account: accountPhoneNumber,
});
// Step 5: Call Freebit PA02-01 (Account Registration) with createType="add"
// Note: After PA05-18, we use createType="add" (not "new")
this.logger.log("Calling PA02-01 Account Registration", { this.logger.log("Calling PA02-01 Account Registration", {
orderId, orderId,
account: accountPhoneNumber, account: accountPhoneNumber,
planCode, planCode,
createType: "add",
}); });
await this.freebitAccountReg.registerAccount({ await this.freebitFacade.registerAccount({
account: accountPhoneNumber, account: accountPhoneNumber,
planCode, planCode,
createType: "new", createType: "add",
}); });
this.logger.log("PA02-01 Account Registration successful", { this.logger.log("PA02-01 Account Registration successful", {
@ -343,7 +366,7 @@ export class SimFulfillmentService {
account: accountPhoneNumber, account: accountPhoneNumber,
}); });
// Step 5: Call Freebit PA05-05 (Voice Options Registration) // Step 6: Call Freebit PA05-05 (Voice Options Registration)
// Only call if we have contact identity data // Only call if we have contact identity data
if (contactIdentity) { if (contactIdentity) {
this.logger.log("Calling PA05-05 Voice Options Registration", { this.logger.log("Calling PA05-05 Voice Options Registration", {
@ -353,7 +376,7 @@ export class SimFulfillmentService {
callWaitingEnabled, callWaitingEnabled,
}); });
await this.freebitVoiceOptions.registerVoiceOptions({ await this.freebitFacade.registerVoiceOptions({
account: accountPhoneNumber, account: accountPhoneNumber,
voiceMailEnabled, voiceMailEnabled,
callWaitingEnabled, callWaitingEnabled,
@ -378,7 +401,7 @@ export class SimFulfillmentService {
}); });
} }
// Step 6: Update SIM Inventory status to "Assigned" // Step 7: Update SIM Inventory status to "Assigned"
await this.simInventory.markAsAssigned(simInventoryId); await this.simInventory.markAsAssigned(simInventoryId);
this.logger.log("Physical SIM activated successfully", { this.logger.log("Physical SIM activated successfully", {
@ -415,28 +438,28 @@ export class SimFulfillmentService {
} }
private extractMnpConfig(config: Record<string, unknown>) { private extractMnpConfig(config: Record<string, unknown>) {
const nested = config.mnp; const nested = config["mnp"];
const source = const source =
nested && typeof nested === "object" ? (nested as Record<string, unknown>) : config; nested && typeof nested === "object" ? (nested as Record<string, unknown>) : config;
const isMnpFlag = this.readString(source.isMnp ?? config.isMnp); const isMnpFlag = this.readString(source["isMnp"] ?? config["isMnp"]);
if (isMnpFlag && isMnpFlag !== "true") { if (isMnpFlag && isMnpFlag !== "true") {
return undefined; return;
} }
const reserveNumber = this.readString(source.mnpNumber ?? source.reserveNumber); const reserveNumber = this.readString(source["mnpNumber"] ?? source["reserveNumber"]);
const reserveExpireDate = this.readString(source.mnpExpiry ?? source.reserveExpireDate); const reserveExpireDate = this.readString(source["mnpExpiry"] ?? source["reserveExpireDate"]);
const account = this.readString(source.mvnoAccountNumber ?? source.account); const account = this.readString(source["mvnoAccountNumber"] ?? source["account"]);
const firstnameKanji = this.readString(source.portingFirstName ?? source.firstnameKanji); const firstnameKanji = this.readString(source["portingFirstName"] ?? source["firstnameKanji"]);
const lastnameKanji = this.readString(source.portingLastName ?? source.lastnameKanji); const lastnameKanji = this.readString(source["portingLastName"] ?? source["lastnameKanji"]);
const firstnameZenKana = this.readString( const firstnameZenKana = this.readString(
source.portingFirstNameKatakana ?? source.firstnameZenKana source["portingFirstNameKatakana"] ?? source["firstnameZenKana"]
); );
const lastnameZenKana = this.readString( const lastnameZenKana = this.readString(
source.portingLastNameKatakana ?? source.lastnameZenKana source["portingLastNameKatakana"] ?? source["lastnameZenKana"]
); );
const gender = this.readString(source.portingGender ?? source.gender); const gender = this.readString(source["portingGender"] ?? source["gender"]);
const birthday = this.readString(source.portingDateOfBirth ?? source.birthday); const birthday = this.readString(source["portingDateOfBirth"] ?? source["birthday"]);
if ( if (
!reserveNumber && !reserveNumber &&
@ -449,19 +472,20 @@ export class SimFulfillmentService {
!gender && !gender &&
!birthday !birthday
) { ) {
return undefined; return;
} }
// Build object with only defined properties (for exactOptionalPropertyTypes)
return { return {
reserveNumber, ...(reserveNumber && { reserveNumber }),
reserveExpireDate, ...(reserveExpireDate && { reserveExpireDate }),
account, ...(account && { account }),
firstnameKanji, ...(firstnameKanji && { firstnameKanji }),
lastnameKanji, ...(lastnameKanji && { lastnameKanji }),
firstnameZenKana, ...(firstnameZenKana && { firstnameZenKana }),
lastnameZenKana, ...(lastnameZenKana && { lastnameZenKana }),
gender, ...(gender && { gender }),
birthday, ...(birthday && { birthday }),
}; };
} }
} }

View File

@ -58,15 +58,15 @@ export class SimBillingService {
description, description,
amount: amountJpy, amount: amountJpy,
currency, currency,
dueDate, ...(dueDate && { dueDate }),
notes, ...(notes && { notes }),
}); });
const paymentResult = await this.whmcsInvoiceService.capturePayment({ const paymentResult = await this.whmcsInvoiceService.capturePayment({
invoiceId: invoice.id, invoiceId: invoice.id,
amount: amountJpy, amount: amountJpy,
currency, currency,
userId, ...(userId && { userId }),
}); });
if (!paymentResult.success) { if (!paymentResult.success) {
@ -127,7 +127,7 @@ export class SimBillingService {
return { return {
invoice, invoice,
transactionId: paymentResult.transactionId, ...(paymentResult.transactionId && { transactionId: paymentResult.transactionId }),
refunded, refunded,
}; };
} }

View File

@ -1,6 +1,6 @@
import { Injectable, Inject, BadRequestException } from "@nestjs/common"; import { Injectable, Inject, BadRequestException } from "@nestjs/common";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { SubscriptionsService } from "../../subscriptions.service.js"; import { SubscriptionsOrchestrator } from "../../subscriptions-orchestrator.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import type { SimValidationResult } from "../interfaces/sim-base.interface.js"; import type { SimValidationResult } from "../interfaces/sim-base.interface.js";
import { import {
@ -12,7 +12,7 @@ import {
@Injectable() @Injectable()
export class SimValidationService { export class SimValidationService {
constructor( constructor(
private readonly subscriptionsService: SubscriptionsService, private readonly subscriptionsService: SubscriptionsOrchestrator,
@Inject(Logger) private readonly logger: Logger @Inject(Logger) private readonly logger: Logger
) {} ) {}
@ -42,21 +42,16 @@ export class SimValidationService {
// If no account found, log detailed info and throw error // If no account found, log detailed info and throw error
if (!account) { if (!account) {
this.logger.error( this.logger.error(`No SIM account identifier found for subscription ${subscriptionId}`, {
`No SIM account identifier found for subscription ${subscriptionId}`,
{
userId, userId,
subscriptionId, subscriptionId,
productName: subscription.productName, productName: subscription.productName,
domain: subscription.domain, domain: subscription.domain,
customFieldKeys: subscription.customFields customFieldKeys: subscription.customFields ? Object.keys(subscription.customFields) : [],
? Object.keys(subscription.customFields)
: [],
customFieldValues: subscription.customFields, customFieldValues: subscription.customFields,
orderNumber: subscription.orderNumber, orderNumber: subscription.orderNumber,
note: "Set the phone number in 'domain' field or a custom field named 'phone', 'msisdn', 'Phone Number', etc.", note: "Set the phone number in 'domain' field or a custom field named 'phone', 'msisdn', 'Phone Number', etc.",
} });
);
throw new BadRequestException( throw new BadRequestException(
`No SIM phone number found for this subscription. Please ensure the phone number is set in WHMCS (domain field or custom field named 'Phone Number', 'MSISDN', etc.)` `No SIM phone number found for this subscription. Please ensure the phone number is set in WHMCS (domain field or custom field named 'Phone Number', 'MSISDN', etc.)`
@ -119,9 +114,9 @@ export class SimValidationService {
// All custom fields for debugging // All custom fields for debugging
customFieldKeys: Object.keys(subscription.customFields || {}), customFieldKeys: Object.keys(subscription.customFields || {}),
customFields: subscription.customFields, customFields: subscription.customFields,
hint: !extractedAccount hint: extractedAccount
? "Set phone number in 'domain' field OR add custom field named: phone, msisdn, phonenumber, Phone Number, MSISDN, etc." ? undefined
: undefined, : "Set phone number in 'domain' field OR add custom field named: phone, msisdn, phonenumber, Phone Number, MSISDN, etc.",
}; };
} catch (error) { } catch (error) {
const sanitizedError = extractErrorMessage(error); const sanitizedError = extractErrorMessage(error);
@ -131,28 +126,4 @@ export class SimValidationService {
throw error; throw error;
} }
} }
private formatCustomFieldValue(value: unknown): string {
if (typeof value === "string") {
return value;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
if (value instanceof Date) {
return value.toISOString();
}
if (typeof value === "object" && value !== null) {
try {
return JSON.stringify(value);
} catch {
return "[unserializable]";
}
}
return "";
}
} }

View File

@ -9,12 +9,12 @@ import {
Header, Header,
UseGuards, UseGuards,
} from "@nestjs/common"; } from "@nestjs/common";
import { SimManagementService } from "../sim-management.service.js"; import { SimOrchestrator } from "./services/sim-orchestrator.service.js";
import { SimTopUpPricingService } from "./services/sim-topup-pricing.service.js"; import { SimTopUpPricingService } from "./services/queries/sim-topup-pricing.service.js";
import { SimPlanService } from "./services/sim-plan.service.js"; import { SimPlanService } from "./services/mutations/sim-plan.service.js";
import { SimCancellationService } from "./services/sim-cancellation.service.js"; import { SimCancellationService } from "./services/mutations/sim-cancellation.service.js";
import { EsimManagementService } from "./services/esim-management.service.js"; import { EsimManagementService } from "./services/mutations/esim-management.service.js";
import { FreebitRateLimiterService } from "@bff/integrations/freebit/services/freebit-rate-limiter.service.js"; import { FreebitFacade } from "@bff/integrations/freebit/facades/freebit.facade.js";
import { AdminGuard } from "@bff/core/security/guards/admin.guard.js"; import { AdminGuard } from "@bff/core/security/guards/admin.guard.js";
import { createZodDto, ZodResponse } from "nestjs-zod"; import { createZodDto, ZodResponse } from "nestjs-zod";
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
@ -26,12 +26,11 @@ import {
} from "@customer-portal/domain/subscriptions"; } from "@customer-portal/domain/subscriptions";
import type { SimActionResponse, SimPlanChangeResult } from "@customer-portal/domain/subscriptions"; import type { SimActionResponse, SimPlanChangeResult } from "@customer-portal/domain/subscriptions";
import { import {
simTopupRequestSchema, simTopUpRequestSchema,
simChangePlanRequestSchema,
simCancelRequestSchema,
simFeaturesRequestSchema,
simTopUpHistoryRequestSchema,
simChangePlanFullRequestSchema, simChangePlanFullRequestSchema,
simCancelRequestSchema,
simFeaturesUpdateRequestSchema,
simTopUpHistoryRequestSchema,
simCancelFullRequestSchema, simCancelFullRequestSchema,
simReissueFullRequestSchema, simReissueFullRequestSchema,
simTopUpPricingSchema, simTopUpPricingSchema,
@ -50,10 +49,10 @@ import {
// DTOs // DTOs
class SubscriptionIdParamDto extends createZodDto(subscriptionIdParamSchema) {} class SubscriptionIdParamDto extends createZodDto(subscriptionIdParamSchema) {}
class SimTopupRequestDto extends createZodDto(simTopupRequestSchema) {} class SimTopupRequestDto extends createZodDto(simTopUpRequestSchema) {}
class SimChangePlanRequestDto extends createZodDto(simChangePlanRequestSchema) {} class SimChangePlanRequestDto extends createZodDto(simChangePlanFullRequestSchema) {}
class SimCancelRequestDto extends createZodDto(simCancelRequestSchema) {} class SimCancelRequestDto extends createZodDto(simCancelRequestSchema) {}
class SimFeaturesRequestDto extends createZodDto(simFeaturesRequestSchema) {} class SimFeaturesRequestDto extends createZodDto(simFeaturesUpdateRequestSchema) {}
class SimChangePlanFullRequestDto extends createZodDto(simChangePlanFullRequestSchema) {} class SimChangePlanFullRequestDto extends createZodDto(simChangePlanFullRequestSchema) {}
class SimCancelFullRequestDto extends createZodDto(simCancelFullRequestSchema) {} class SimCancelFullRequestDto extends createZodDto(simCancelFullRequestSchema) {}
class SimReissueFullRequestDto extends createZodDto(simReissueFullRequestSchema) {} class SimReissueFullRequestDto extends createZodDto(simReissueFullRequestSchema) {}
@ -78,12 +77,12 @@ class SimCancellationPreviewResponseDto extends createZodDto(simCancellationPrev
@Controller("subscriptions") @Controller("subscriptions")
export class SimController { export class SimController {
constructor( constructor(
private readonly simManagementService: SimManagementService, private readonly simOrchestrator: SimOrchestrator,
private readonly simTopUpPricingService: SimTopUpPricingService, private readonly simTopUpPricingService: SimTopUpPricingService,
private readonly simPlanService: SimPlanService, private readonly simPlanService: SimPlanService,
private readonly simCancellationService: SimCancellationService, private readonly simCancellationService: SimCancellationService,
private readonly esimManagementService: EsimManagementService, private readonly esimManagementService: EsimManagementService,
private readonly rateLimiter: FreebitRateLimiterService private readonly freebitFacade: FreebitFacade
) {} ) {}
// ==================== Static SIM Routes (must be before :id routes) ==================== // ==================== Static SIM Routes (must be before :id routes) ====================
@ -110,13 +109,13 @@ export class SimController {
@Get("debug/sim-details/:account") @Get("debug/sim-details/:account")
@UseGuards(AdminGuard) @UseGuards(AdminGuard)
async debugSimDetails(@Param("account") account: string) { async debugSimDetails(@Param("account") account: string) {
return await this.simManagementService.getSimDetailsDebug(account); return await this.simOrchestrator.getSimDetailsDirectly(account);
} }
@Post("debug/sim-rate-limit/clear/:account") @Post("debug/sim-rate-limit/clear/:account")
@UseGuards(AdminGuard) @UseGuards(AdminGuard)
async clearRateLimit(@Param("account") account: string) { async clearRateLimit(@Param("account") account: string) {
await this.rateLimiter.clearRateLimitForAccount(account); await this.freebitFacade.clearRateLimitForAccount(account);
return { message: `Rate limit cleared for account ${account}` }; return { message: `Rate limit cleared for account ${account}` };
} }
@ -128,25 +127,25 @@ export class SimController {
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Param() params: SubscriptionIdParamDto @Param() params: SubscriptionIdParamDto
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
return this.simManagementService.debugSimSubscription(req.user.id, params.id); return this.simOrchestrator.debugSimSubscription(req.user.id, params.id);
} }
@Get(":id/sim") @Get(":id/sim")
@ZodResponse({ description: "Get SIM info", type: SimInfoDto }) @ZodResponse({ description: "Get SIM info", type: SimInfoDto })
async getSimInfo(@Request() req: RequestWithUser, @Param() params: SubscriptionIdParamDto) { async getSimInfo(@Request() req: RequestWithUser, @Param() params: SubscriptionIdParamDto) {
return this.simManagementService.getSimInfo(req.user.id, params.id); return this.simOrchestrator.getSimInfo(req.user.id, params.id);
} }
@Get(":id/sim/details") @Get(":id/sim/details")
@ZodResponse({ description: "Get SIM details", type: SimDetailsDto }) @ZodResponse({ description: "Get SIM details", type: SimDetailsDto })
async getSimDetails(@Request() req: RequestWithUser, @Param() params: SubscriptionIdParamDto) { async getSimDetails(@Request() req: RequestWithUser, @Param() params: SubscriptionIdParamDto) {
return this.simManagementService.getSimDetails(req.user.id, params.id); return this.simOrchestrator.getSimDetails(req.user.id, params.id);
} }
@Get(":id/sim/usage") @Get(":id/sim/usage")
@ZodResponse({ description: "Get SIM usage", type: SimUsageDto }) @ZodResponse({ description: "Get SIM usage", type: SimUsageDto })
async getSimUsage(@Request() req: RequestWithUser, @Param() params: SubscriptionIdParamDto) { async getSimUsage(@Request() req: RequestWithUser, @Param() params: SubscriptionIdParamDto) {
return this.simManagementService.getSimUsage(req.user.id, params.id); return this.simOrchestrator.getSimUsage(req.user.id, params.id);
} }
@Get(":id/sim/top-up-history") @Get(":id/sim/top-up-history")
@ -156,7 +155,7 @@ export class SimController {
@Param() params: SubscriptionIdParamDto, @Param() params: SubscriptionIdParamDto,
@Query() query: SimTopUpHistoryRequestDto @Query() query: SimTopUpHistoryRequestDto
) { ) {
return this.simManagementService.getSimTopUpHistory(req.user.id, params.id, query); return this.simOrchestrator.getSimTopUpHistory(req.user.id, params.id, query);
} }
@Post(":id/sim/top-up") @Post(":id/sim/top-up")
@ -166,7 +165,7 @@ export class SimController {
@Param() params: SubscriptionIdParamDto, @Param() params: SubscriptionIdParamDto,
@Body() body: SimTopupRequestDto @Body() body: SimTopupRequestDto
): Promise<SimActionResponse> { ): Promise<SimActionResponse> {
await this.simManagementService.topUpSim(req.user.id, params.id, body); await this.simOrchestrator.topUpSim(req.user.id, params.id, body);
return { message: "SIM top-up completed successfully" }; return { message: "SIM top-up completed successfully" };
} }
@ -177,7 +176,7 @@ export class SimController {
@Param() params: SubscriptionIdParamDto, @Param() params: SubscriptionIdParamDto,
@Body() body: SimChangePlanRequestDto @Body() body: SimChangePlanRequestDto
): Promise<SimPlanChangeResult> { ): Promise<SimPlanChangeResult> {
const result = await this.simManagementService.changeSimPlan(req.user.id, params.id, body); const result = await this.simOrchestrator.changeSimPlan(req.user.id, params.id, body);
return { return {
message: "SIM plan change completed successfully", message: "SIM plan change completed successfully",
...result, ...result,
@ -191,7 +190,7 @@ export class SimController {
@Param() params: SubscriptionIdParamDto, @Param() params: SubscriptionIdParamDto,
@Body() body: SimCancelRequestDto @Body() body: SimCancelRequestDto
): Promise<SimActionResponse> { ): Promise<SimActionResponse> {
await this.simManagementService.cancelSim(req.user.id, params.id, body); await this.simOrchestrator.cancelSim(req.user.id, params.id, body);
return { message: "SIM cancellation completed successfully" }; return { message: "SIM cancellation completed successfully" };
} }
@ -202,7 +201,7 @@ export class SimController {
@Param() params: SubscriptionIdParamDto, @Param() params: SubscriptionIdParamDto,
@Body() body: SimReissueEsimRequestDto @Body() body: SimReissueEsimRequestDto
): Promise<SimActionResponse> { ): Promise<SimActionResponse> {
await this.simManagementService.reissueEsimProfile(req.user.id, params.id, body.newEid); await this.simOrchestrator.reissueEsimProfile(req.user.id, params.id, body);
return { message: "eSIM profile reissue completed successfully" }; return { message: "eSIM profile reissue completed successfully" };
} }
@ -213,7 +212,7 @@ export class SimController {
@Param() params: SubscriptionIdParamDto, @Param() params: SubscriptionIdParamDto,
@Body() body: SimFeaturesRequestDto @Body() body: SimFeaturesRequestDto
): Promise<SimActionResponse> { ): Promise<SimActionResponse> {
await this.simManagementService.updateSimFeatures(req.user.id, params.id, body); await this.simOrchestrator.updateSimFeatures(req.user.id, params.id, body);
return { message: "SIM features updated successfully" }; return { message: "SIM features updated successfully" };
} }

View File

@ -1,5 +0,0 @@
import { redirect } from "next/navigation";
export default function MigrateAccountPage() {
redirect("/auth/get-started");
}

View File

@ -1,5 +0,0 @@
import { redirect } from "next/navigation";
export default function SignupPage() {
redirect("/auth/get-started");
}

View File

@ -1,57 +0,0 @@
import { Skeleton } from "@/components/atoms/loading-skeleton";
export default function InternetConfigureLoading() {
return (
<div className="container mx-auto px-4 py-12 space-y-8 animate-in fade-in duration-300">
{/* Steps indicator */}
<div className="flex justify-center gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center gap-2">
<Skeleton className="h-8 w-8 rounded-full" />
{i < 3 && <Skeleton className="h-0.5 w-12" />}
</div>
))}
</div>
{/* Title */}
<div className="text-center space-y-2">
<Skeleton className="h-8 w-56 mx-auto" />
<Skeleton className="h-4 w-80 max-w-full mx-auto" />
</div>
{/* Form card */}
<div className="max-w-2xl mx-auto">
<div className="bg-card border border-border rounded-2xl p-6 md:p-8 space-y-6">
{/* Plan options */}
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="border border-border rounded-xl p-4 flex items-center gap-4">
<Skeleton className="h-5 w-5 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-4 w-48" />
</div>
<Skeleton className="h-6 w-20" />
</div>
))}
</div>
{/* Form fields */}
<div className="space-y-4 pt-4 border-t border-border">
<div className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-10 w-full rounded-lg" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-10 w-full rounded-lg" />
</div>
</div>
{/* Button */}
<Skeleton className="h-12 w-full rounded-lg" />
</div>
</div>
</div>
);
}

View File

@ -1,17 +0,0 @@
/**
* Public Internet Configure Page
*
* Configure internet plan for unauthenticated users.
*/
import { PublicInternetConfigureView } from "@/features/services/views/PublicInternetConfigure";
import { RedirectAuthenticatedToAccountServices } from "@/features/services/components/common/RedirectAuthenticatedToAccountServices";
export default function PublicInternetConfigurePage() {
return (
<>
<RedirectAuthenticatedToAccountServices targetPath="/account/services/internet" />
<PublicInternetConfigureView />
</>
);
}

View File

@ -1,5 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { ServicesGrid } from "@/features/services/components/common/ServicesGrid"; import { ServicesOverviewContent } from "@/features/services/components/common/ServicesOverviewContent";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Services for Expats in Japan - Internet, Mobile, VPN & IT Support | Assist Solutions", title: "Services for Expats in Japan - Internet, Mobile, VPN & IT Support | Assist Solutions",
@ -20,25 +20,10 @@ export const metadata: Metadata = {
}, },
}; };
interface ServicesPageProps { export default function ServicesPage() {
basePath?: string;
}
export default function ServicesPage({ basePath = "/services" }: ServicesPageProps) {
return ( return (
<div className="max-w-6xl mx-auto px-4"> <div className="max-w-6xl mx-auto px-4 pt-8">
{/* Header */} <ServicesOverviewContent basePath="/services" />
<div className="text-center mb-16 pt-8">
<h1 className="text-4xl sm:text-5xl font-extrabold text-foreground mb-6 tracking-tight">
Services for Expats in Japan
</h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto leading-relaxed">
Tired of navigating Japanese-only websites and contracts? We provide internet, mobile, and
IT services with full English support. No Japanese required.
</p>
</div>
<ServicesGrid basePath={basePath} />
</div> </div>
); );
} }

View File

@ -11,7 +11,7 @@ export const metadata: Metadata = {
}, },
description: description:
"One stop IT solution for Japan's international community. Internet, mobile, VPN, and tech support with English service since 2002.", "One stop IT solution for Japan's international community. Internet, mobile, VPN, and tech support with English service since 2002.",
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || "https://portal.asolutions.co.jp"), metadataBase: new URL(process.env["NEXT_PUBLIC_SITE_URL"] || "https://portal.asolutions.co.jp"),
alternates: { alternates: {
canonical: "/", canonical: "/",
}, },

View File

@ -7,7 +7,7 @@ import type { MetadataRoute } from "next";
* Allows all public pages, blocks account/authenticated areas. * Allows all public pages, blocks account/authenticated areas.
*/ */
export default function robots(): MetadataRoute.Robots { export default function robots(): MetadataRoute.Robots {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://portal.asolutions.co.jp"; const baseUrl = process.env["NEXT_PUBLIC_SITE_URL"] || "https://portal.asolutions.co.jp";
return { return {
rules: [ rules: [

View File

@ -7,7 +7,7 @@ import type { MetadataRoute } from "next";
* Only includes public pages that should be indexed. * Only includes public pages that should be indexed.
*/ */
export default function sitemap(): MetadataRoute.Sitemap { export default function sitemap(): MetadataRoute.Sitemap {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://portal.asolutions.co.jp"; const baseUrl = process.env["NEXT_PUBLIC_SITE_URL"] || "https://portal.asolutions.co.jp";
// Public pages that should be indexed // Public pages that should be indexed
const publicPages = [ const publicPages = [

View File

@ -278,7 +278,7 @@ export function PublicShell({ children }: PublicShellProps) {
<span className="font-medium">EN</span> <span className="font-medium">EN</span>
</div> </div>
{/* Auth Button - Desktop */} {/* Auth Buttons - Desktop */}
{isAuthenticated ? ( {isAuthenticated ? (
<Link <Link
href="/account" href="/account"
@ -287,12 +287,20 @@ export function PublicShell({ children }: PublicShellProps) {
My Account My Account
</Link> </Link>
) : ( ) : (
<div className="hidden md:flex items-center gap-2">
<Link <Link
href="/auth/login" href="/auth/login"
className="hidden md:inline-flex items-center justify-center rounded-full bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground shadow hover:bg-primary/90 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" className="inline-flex items-center justify-center rounded-full border border-input bg-background px-4 py-2 text-sm font-semibold text-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
> >
Sign in Sign in
</Link> </Link>
<Link
href="/auth/get-started"
className="inline-flex items-center justify-center rounded-full bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground shadow hover:bg-primary/90 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
Get Started
</Link>
</div>
)} )}
{/* Mobile Menu Button */} {/* Mobile Menu Button */}
@ -361,7 +369,7 @@ export function PublicShell({ children }: PublicShellProps) {
</Link> </Link>
</div> </div>
<div className="border-t border-border/40 pt-4"> <div className="border-t border-border/40 pt-4 space-y-3">
{isAuthenticated ? ( {isAuthenticated ? (
<Link <Link
href="/account" href="/account"
@ -371,13 +379,22 @@ export function PublicShell({ children }: PublicShellProps) {
My Account My Account
</Link> </Link>
) : ( ) : (
<>
<Link <Link
href="/auth/login" href="/auth/get-started"
onClick={closeMobileMenu} onClick={closeMobileMenu}
className="flex items-center justify-center rounded-full bg-primary px-6 py-3 text-base font-semibold text-primary-foreground shadow hover:bg-primary/90 transition-colors" className="flex items-center justify-center rounded-full bg-primary px-6 py-3 text-base font-semibold text-primary-foreground shadow hover:bg-primary/90 transition-colors"
>
Get Started
</Link>
<Link
href="/auth/login"
onClick={closeMobileMenu}
className="flex items-center justify-center rounded-full border border-input bg-background px-6 py-3 text-base font-semibold text-foreground shadow-sm hover:bg-accent transition-colors"
> >
Sign in Sign in
</Link> </Link>
</>
)} )}
</div> </div>
</nav> </nav>

View File

@ -235,10 +235,10 @@ export function LoginForm({
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Don&apos;t have an account?{" "} Don&apos;t have an account?{" "}
<Link <Link
href={`/auth/signup${redirectQuery}`} href={`/auth/get-started${redirectQuery}`}
className="font-medium text-primary hover:text-primary-hover transition-colors duration-200" className="font-medium text-primary hover:text-primary-hover transition-colors duration-200"
> >
Sign up Get started
</Link> </Link>
</p> </p>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">

View File

@ -24,7 +24,7 @@ import {
AlertCircle, AlertCircle,
Check, Check,
} from "lucide-react"; } from "lucide-react";
import { Spinner } from "@/components/atoms/Spinner"; import { Spinner } from "@/components/atoms";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
// ============================================================================= // =============================================================================
@ -45,7 +45,7 @@ function useInView(options: IntersectionObserverInit = {}) {
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
([entry]) => { ([entry]) => {
if (entry.isIntersecting) { if (entry?.isIntersecting) {
setIsInView(true); setIsInView(true);
observer.disconnect(); // triggerOnce observer.disconnect(); // triggerOnce
} }
@ -87,6 +87,14 @@ interface FormErrors {
message?: string; message?: string;
} }
interface FormTouched {
subject?: boolean;
name?: boolean;
email?: boolean;
phone?: boolean;
message?: boolean;
}
const CONTACT_SUBJECTS = [ const CONTACT_SUBJECTS = [
{ value: "", label: "Select a topic*" }, { value: "", label: "Select a topic*" },
{ value: "internet", label: "Internet Service Inquiry" }, { value: "internet", label: "Internet Service Inquiry" },
@ -277,7 +285,7 @@ export function PublicLandingView() {
message: "", message: "",
}); });
const [formErrors, setFormErrors] = useState<FormErrors>({}); const [formErrors, setFormErrors] = useState<FormErrors>({});
const [formTouched, setFormTouched] = useState<Record<string, boolean>>({}); const [formTouched, setFormTouched] = useState<FormTouched>({});
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [submitStatus, setSubmitStatus] = useState<"idle" | "success" | "error">("idle"); const [submitStatus, setSubmitStatus] = useState<"idle" | "success" | "error">("idle");
@ -366,12 +374,18 @@ export function PublicLandingView() {
// Touch swipe handlers // Touch swipe handlers
const handleTouchStart = useCallback((e: React.TouchEvent) => { const handleTouchStart = useCallback((e: React.TouchEvent) => {
touchStartXRef.current = e.touches[0].clientX; const touch = e.touches[0];
touchEndXRef.current = e.touches[0].clientX; if (touch) {
touchStartXRef.current = touch.clientX;
touchEndXRef.current = touch.clientX;
}
}, []); }, []);
const handleTouchMove = useCallback((e: React.TouchEvent) => { const handleTouchMove = useCallback((e: React.TouchEvent) => {
touchEndXRef.current = e.touches[0].clientX; const touch = e.touches[0];
if (touch) {
touchEndXRef.current = touch.clientX;
}
}, []); }, []);
const handleTouchEnd = useCallback(() => { const handleTouchEnd = useCallback(() => {
@ -437,7 +451,9 @@ export function PublicLandingView() {
try { try {
// Simulate API call - replace with actual endpoint // Simulate API call - replace with actual endpoint
await new Promise(resolve => setTimeout(resolve, 1500)); await new Promise(resolve => {
setTimeout(resolve, 1500);
});
// Success // Success
setSubmitStatus("success"); setSubmitStatus("success");
@ -495,7 +511,9 @@ export function PublicLandingView() {
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
([entry]) => { ([entry]) => {
if (entry) {
setShowStickyCTA(!entry.isIntersecting); setShowStickyCTA(!entry.isIntersecting);
}
}, },
{ threshold: 0 } { threshold: 0 }
); );
@ -1101,12 +1119,16 @@ export function PublicLandingView() {
</div> </div>
{/* Tab content */} {/* Tab content */}
{(() => {
const currentDownload = supportDownloads[remoteSupportTab];
if (!currentDownload) return null;
return (
<div className="p-8 lg:p-12"> <div className="p-8 lg:p-12">
<div className="flex items-start gap-8 lg:gap-12"> <div className="flex items-start gap-8 lg:gap-12">
<div className="h-32 w-32 lg:h-40 lg:w-40 rounded-2xl flex items-center justify-center flex-shrink-0 transition-colors bg-gradient-to-br from-sky-50 to-cyan-100"> <div className="h-32 w-32 lg:h-40 lg:w-40 rounded-2xl flex items-center justify-center flex-shrink-0 transition-colors bg-gradient-to-br from-sky-50 to-cyan-100">
<Image <Image
src={supportDownloads[remoteSupportTab].image} src={currentDownload.image}
alt={supportDownloads[remoteSupportTab].title} alt={currentDownload.title}
width={96} width={96}
height={96} height={96}
className="object-contain w-20 h-20 lg:w-24 lg:h-24" className="object-contain w-20 h-20 lg:w-24 lg:h-24"
@ -1114,16 +1136,16 @@ export function PublicLandingView() {
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-bold uppercase tracking-wide mb-2 text-sky-600"> <p className="text-sm font-bold uppercase tracking-wide mb-2 text-sky-600">
{supportDownloads[remoteSupportTab].useCase} {currentDownload.useCase}
</p> </p>
<h3 className="text-2xl lg:text-3xl font-bold text-foreground mb-4"> <h3 className="text-2xl lg:text-3xl font-bold text-foreground mb-4">
{supportDownloads[remoteSupportTab].title} {currentDownload.title}
</h3> </h3>
<p className="text-base lg:text-lg text-muted-foreground leading-relaxed mb-6"> <p className="text-base lg:text-lg text-muted-foreground leading-relaxed mb-6">
{supportDownloads[remoteSupportTab].description} {currentDownload.description}
</p> </p>
<Link <Link
href={supportDownloads[remoteSupportTab].href} href={currentDownload.href}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-full px-8 py-3.5 text-base font-semibold text-white transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg bg-gradient-to-r from-sky-500 to-cyan-600 hover:from-sky-600 hover:to-cyan-700 shadow-sky-200" className="inline-flex items-center gap-2 rounded-full px-8 py-3.5 text-base font-semibold text-white transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg bg-gradient-to-r from-sky-500 to-cyan-600 hover:from-sky-600 hover:to-cyan-700 shadow-sky-200"
@ -1147,6 +1169,8 @@ export function PublicLandingView() {
</div> </div>
</div> </div>
</div> </div>
);
})()}
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,132 +0,0 @@
"use client";
import { useSearchParams } from "next/navigation";
import { WifiIcon, ClockIcon, EnvelopeIcon, CheckCircleIcon } from "@heroicons/react/24/outline";
import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink";
import { InlineGetStartedSection } from "@/features/get-started";
import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
import { usePublicInternetPlan } from "@/features/services/hooks";
import { CardPricing } from "@/features/services/components/base/CardPricing";
import { Skeleton } from "@/components/atoms/loading-skeleton";
/**
* Public Internet Configure View
*
* Clean signup flow - auth form is the focus, "what happens next" is secondary info.
*/
export function PublicInternetConfigureView() {
const servicesBasePath = useServicesBasePath();
const searchParams = useSearchParams();
const planSku = searchParams?.get("planSku");
const { plan, isLoading } = usePublicInternetPlan(planSku || undefined);
const redirectTo = planSku
? `/account/services/internet?autoEligibilityRequest=1&planSku=${encodeURIComponent(planSku)}`
: "/account/services/internet?autoEligibilityRequest=1";
if (isLoading) {
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<ServicesBackLink href={`${servicesBasePath}/internet`} label="Back to plans" />
<div className="mt-8 space-y-6">
<Skeleton className="h-10 w-96 mx-auto" />
<Skeleton className="h-32 w-full" />
</div>
</div>
);
}
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<ServicesBackLink href={`${servicesBasePath}/internet`} label="Back to plans" />
{/* Header */}
<div className="mt-6 mb-6 text-center">
<div className="flex justify-center mb-4">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-primary/20 to-primary/5 border border-primary/20">
<WifiIcon className="h-7 w-7 text-primary" />
</div>
</div>
<h1 className="text-2xl md:text-3xl font-bold text-foreground mb-2">
Check Internet Service Availability
</h1>
<p className="text-muted-foreground max-w-md mx-auto text-sm">
Create an account to see what&apos;s available at your address
</p>
</div>
{/* Plan Summary Card - only if plan is selected */}
{plan && (
<div className="mb-6 bg-card border border-border rounded-xl p-4 shadow-[var(--cp-shadow-1)]">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 border border-primary/20 flex-shrink-0">
<WifiIcon className="h-5 w-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div>
<p className="text-xs text-muted-foreground">Selected plan</p>
<h3 className="text-sm font-semibold text-foreground">{plan.name}</h3>
</div>
<CardPricing monthlyPrice={plan.monthlyPrice} size="sm" alignment="right" />
</div>
</div>
</div>
</div>
)}
{/* Auth Section - Primary focus */}
<InlineGetStartedSection
title="Create your account"
description="Verify your email to check internet availability at your address."
serviceContext={{ type: "internet", planSku: planSku || undefined }}
redirectTo={redirectTo}
/>
{/* What happens next - Below auth, secondary info */}
<div className="mt-8 border-t border-border pt-6">
<h3 className="text-sm font-semibold text-foreground mb-4">What happens next</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="flex items-start gap-3">
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-primary/10 text-primary text-xs font-bold flex-shrink-0">
1
</div>
<div>
<p className="text-xs font-medium text-foreground">We verify your address</p>
<p className="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
<ClockIcon className="h-3 w-3" />
1-2 business days
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-muted text-muted-foreground text-xs font-bold flex-shrink-0">
2
</div>
<div>
<p className="text-xs font-medium text-foreground">You get notified</p>
<p className="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
<EnvelopeIcon className="h-3 w-3" />
Email when ready
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-muted text-muted-foreground text-xs font-bold flex-shrink-0">
3
</div>
<div>
<p className="text-xs font-medium text-foreground">Complete your order</p>
<p className="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
<CheckCircleIcon className="h-3 w-3" />
Choose plan & schedule
</p>
</div>
</div>
</div>
</div>
</div>
);
}
export default PublicInternetConfigureView;

View File

@ -751,7 +751,7 @@ export function PublicInternetPlansContent({
// Simple loading check: show skeleton until we have data or an error // Simple loading check: show skeleton until we have data or an error
const isLoading = !servicesCatalog && !error; const isLoading = !servicesCatalog && !error;
const servicesBasePath = useServicesBasePath(); const servicesBasePath = useServicesBasePath();
const defaultCtaPath = `${servicesBasePath}/internet/configure`; const defaultCtaPath = `${servicesBasePath}/internet/check-availability`;
const ctaPath = propCtaPath ?? defaultCtaPath; const ctaPath = propCtaPath ?? defaultCtaPath;
const internetFeatures: HighlightFeature[] = [ const internetFeatures: HighlightFeature[] = [
@ -835,7 +835,7 @@ export function PublicInternetPlansContent({
maxMonthlyPrice: data.maxPrice, maxMonthlyPrice: data.maxPrice,
description: getTierDescription(tier), description: getTierDescription(tier),
features: getTierFeatures(tier), features: getTierFeatures(tier),
pricingNote: tier === "Platinum" ? "+ equipment fees" : undefined, ...(tier === "Platinum" && { pricingNote: "+ equipment fees" }),
})); }));
return { return {
@ -892,8 +892,25 @@ export function PublicInternetPlansContent({
// Ensure all three offering types exist with default data // Ensure all three offering types exist with default data
for (const config of offeringTypeConfigs) { for (const config of offeringTypeConfigs) {
if (!offeringData[config.id]) {
const defaults = defaultOfferingPrices[config.id]; const defaults = defaultOfferingPrices[config.id];
if (!defaults) continue;
const existingData = offeringData[config.id];
if (existingData) {
// Fill in missing tiers with defaults
if (!existingData.tierData["Silver"]) {
existingData.tierData["Silver"] = { price: defaults.silver };
}
if (!existingData.tierData["Gold"]) {
existingData.tierData["Gold"] = { price: defaults.gold };
}
if (!existingData.tierData["Platinum"]) {
existingData.tierData["Platinum"] = { price: defaults.platinum };
}
// Recalculate min price
const allPrices = Object.values(existingData.tierData).map(t => t.price);
existingData.minPrice = Math.min(...allPrices);
} else {
offeringData[config.id] = { offeringData[config.id] = {
minPrice: defaults.silver, minPrice: defaults.silver,
tierData: { tierData: {
@ -902,21 +919,6 @@ export function PublicInternetPlansContent({
Platinum: { price: defaults.platinum }, Platinum: { price: defaults.platinum },
}, },
}; };
} else {
// Fill in missing tiers with defaults
const defaults = defaultOfferingPrices[config.id];
if (!offeringData[config.id].tierData.Silver) {
offeringData[config.id].tierData.Silver = { price: defaults.silver };
}
if (!offeringData[config.id].tierData.Gold) {
offeringData[config.id].tierData.Gold = { price: defaults.gold };
}
if (!offeringData[config.id].tierData.Platinum) {
offeringData[config.id].tierData.Platinum = { price: defaults.platinum };
}
// Recalculate min price
const allPrices = Object.values(offeringData[config.id].tierData).map(t => t.price);
offeringData[config.id].minPrice = Math.min(...allPrices);
} }
} }
@ -931,7 +933,7 @@ export function PublicInternetPlansContent({
monthlyPrice: tierInfo.price, monthlyPrice: tierInfo.price,
description: getTierDescription(tier), description: getTierDescription(tier),
features: getTierFeatures(tier), features: getTierFeatures(tier),
pricingNote: tier === "Platinum" ? "+ equipment fees" : undefined, ...(tier === "Platinum" && { pricingNote: "+ equipment fees" }),
})); }));
result[offeringType] = { result[offeringType] = {
@ -983,7 +985,7 @@ export function PublicInternetPlansContent({
tiers={consolidatedPlanData.tiers} tiers={consolidatedPlanData.tiers}
ctaPath={ctaPath} ctaPath={ctaPath}
ctaLabel={ctaLabel} ctaLabel={ctaLabel}
onCtaClick={onCtaClick} {...(onCtaClick && { onCtaClick })}
/> />
) : null} ) : null}
</section> </section>

View File

@ -187,7 +187,8 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
const msg = err.message.toLowerCase(); const msg = err.message.toLowerCase();
// Check for rate limiting errors // Check for rate limiting errors
if (msg.includes("30 minutes") || msg.includes("must be requested")) { if (msg.includes("30 minutes") || msg.includes("must be requested")) {
errorMessage = "Please wait 30 minutes between voice/network/plan changes before trying again."; errorMessage =
"Please wait 30 minutes between voice/network/plan changes before trying again.";
} else if (msg.includes("another") && msg.includes("in progress")) { } else if (msg.includes("another") && msg.includes("in progress")) {
errorMessage = "Another operation is in progress. Please wait a moment."; errorMessage = "Another operation is in progress. Please wait a moment.";
} else { } else {
@ -316,28 +317,28 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
label="Voice Mail" label="Voice Mail"
subtitle={simInfo.details.voiceMailEnabled ? "Enabled" : "Disabled"} subtitle={simInfo.details.voiceMailEnabled ? "Enabled" : "Disabled"}
checked={simInfo.details.voiceMailEnabled || false} checked={simInfo.details.voiceMailEnabled || false}
loading={featureLoading.voiceMail} loading={featureLoading.voiceMail ?? false}
onChange={checked => void updateFeature("voiceMail", checked)} onChange={checked => void updateFeature("voiceMail", checked)}
/> />
<StatusToggle <StatusToggle
label="Network Type" label="Network Type"
subtitle={simInfo.details.networkType === "5G" ? "5G" : "4G"} subtitle={simInfo.details.networkType === "5G" ? "5G" : "4G"}
checked={simInfo.details.networkType === "5G"} checked={simInfo.details.networkType === "5G"}
loading={featureLoading.networkType} loading={featureLoading.networkType ?? false}
onChange={checked => void updateFeature("networkType", checked ? "5G" : "4G")} onChange={checked => void updateFeature("networkType", checked ? "5G" : "4G")}
/> />
<StatusToggle <StatusToggle
label="Call Waiting" label="Call Waiting"
subtitle={simInfo.details.callWaitingEnabled ? "Enabled" : "Disabled"} subtitle={simInfo.details.callWaitingEnabled ? "Enabled" : "Disabled"}
checked={simInfo.details.callWaitingEnabled || false} checked={simInfo.details.callWaitingEnabled || false}
loading={featureLoading.callWaiting} loading={featureLoading.callWaiting ?? false}
onChange={checked => void updateFeature("callWaiting", checked)} onChange={checked => void updateFeature("callWaiting", checked)}
/> />
<StatusToggle <StatusToggle
label="International Roaming" label="International Roaming"
subtitle={simInfo.details.internationalRoamingEnabled ? "Enabled" : "Disabled"} subtitle={simInfo.details.internationalRoamingEnabled ? "Enabled" : "Disabled"}
checked={simInfo.details.internationalRoamingEnabled || false} checked={simInfo.details.internationalRoamingEnabled || false}
loading={featureLoading.internationalRoaming} loading={featureLoading.internationalRoaming ?? false}
onChange={checked => void updateFeature("internationalRoaming", checked)} onChange={checked => void updateFeature("internationalRoaming", checked)}
/> />
</div> </div>
@ -469,7 +470,14 @@ type StatusToggleProps = {
disabled?: boolean; disabled?: boolean;
}; };
function StatusToggle({ label, subtitle, checked, onChange, loading, disabled }: StatusToggleProps) { function StatusToggle({
label,
subtitle,
checked,
onChange,
loading,
disabled,
}: StatusToggleProps) {
const isDisabled = disabled || loading; const isDisabled = disabled || loading;
const handleClick = () => { const handleClick = () => {
@ -479,13 +487,17 @@ function StatusToggle({ label, subtitle, checked, onChange, loading, disabled }:
}; };
return ( return (
<div className={`p-4 bg-card border border-border rounded-lg ${isDisabled ? "opacity-60" : ""}`}> <div
className={`p-4 bg-card border border-border rounded-lg ${isDisabled ? "opacity-60" : ""}`}
>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="font-medium text-foreground">{label}</p> <p className="font-medium text-foreground">{label}</p>
{subtitle && <p className="text-xs text-muted-foreground mt-0.5">{subtitle}</p>} {subtitle && <p className="text-xs text-muted-foreground mt-0.5">{subtitle}</p>}
</div> </div>
<label className={`relative inline-flex items-center ${isDisabled ? "cursor-not-allowed" : "cursor-pointer"}`}> <label
className={`relative inline-flex items-center ${isDisabled ? "cursor-not-allowed" : "cursor-pointer"}`}
>
<input <input
type="checkbox" type="checkbox"
checked={checked} checked={checked}
@ -493,7 +505,9 @@ function StatusToggle({ label, subtitle, checked, onChange, loading, disabled }:
disabled={isDisabled} disabled={isDisabled}
className="sr-only peer" className="sr-only peer"
/> />
<div className={`w-11 h-6 bg-muted peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-ring/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-background after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-background after:border-border after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary ${loading ? "animate-pulse" : ""}`}></div> <div
className={`w-11 h-6 bg-muted peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-ring/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-background after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-background after:border-border after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary ${loading ? "animate-pulse" : ""}`}
></div>
</label> </label>
</div> </div>
</div> </div>

View File

@ -264,7 +264,7 @@ export function PublicContactView() {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<FormField <FormField
label="Name" label="Name"
error={form.touched.name ? form.errors.name : undefined} error={form.touched["name"] ? form.errors["name"] : undefined}
required required
> >
<Input <Input
@ -278,7 +278,7 @@ export function PublicContactView() {
<FormField <FormField
label="Email" label="Email"
error={form.touched.email ? form.errors.email : undefined} error={form.touched["email"] ? form.errors["email"] : undefined}
required required
> >
<Input <Input
@ -293,7 +293,10 @@ export function PublicContactView() {
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<FormField label="Phone" error={form.touched.phone ? form.errors.phone : undefined}> <FormField
label="Phone"
error={form.touched["phone"] ? form.errors["phone"] : undefined}
>
<Input <Input
value={form.values.phone ?? ""} value={form.values.phone ?? ""}
onChange={e => form.setValue("phone", e.target.value)} onChange={e => form.setValue("phone", e.target.value)}
@ -305,7 +308,7 @@ export function PublicContactView() {
<FormField <FormField
label="Subject" label="Subject"
error={form.touched.subject ? form.errors.subject : undefined} error={form.touched["subject"] ? form.errors["subject"] : undefined}
required required
> >
<Input <Input
@ -320,7 +323,7 @@ export function PublicContactView() {
<FormField <FormField
label="Message" label="Message"
error={form.touched.message ? form.errors.message : undefined} error={form.touched["message"] ? form.errors["message"] : undefined}
required required
> >
<textarea <textarea

View File

@ -115,8 +115,9 @@ export function mapProductToFreebitPlanCode(
const gbMatch = source.match(/(\d+)\s*G(?:B)?/i); const gbMatch = source.match(/(\d+)\s*G(?:B)?/i);
if (gbMatch?.[1]) { if (gbMatch?.[1]) {
const tier = gbMatch[1]; const tier = gbMatch[1];
if (tier in PLAN_CODE_MAPPING) { const planCode = PLAN_CODE_MAPPING[tier];
return PLAN_CODE_MAPPING[tier]; if (planCode) {
return planCode;
} }
} }
@ -124,8 +125,9 @@ export function mapProductToFreebitPlanCode(
const skuMatch = source.match(/[-_](\d+)[-_]?(?:gb|g)?/i); const skuMatch = source.match(/[-_](\d+)[-_]?(?:gb|g)?/i);
if (skuMatch?.[1]) { if (skuMatch?.[1]) {
const tier = skuMatch[1]; const tier = skuMatch[1];
if (tier in PLAN_CODE_MAPPING) { const planCode = PLAN_CODE_MAPPING[tier];
return PLAN_CODE_MAPPING[tier]; if (planCode) {
return planCode;
} }
} }