From 4cb393bdb8441039b46e4099b2dd974e882aba59 Mon Sep 17 00:00:00 2001 From: barsa Date: Tue, 3 Feb 2026 17:35:47 +0900 Subject: [PATCH] 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 --- apps/bff/src/infra/cache/cache.service.ts | 10 +- .../infra/cache/distributed-lock.service.ts | 2 + .../salesforce-request-queue.service.ts | 2 + .../freebit/facades/freebit.facade.ts | 89 +- .../freebit-account-registration.service.ts | 12 +- .../services/freebit-account.service.ts | 1 + .../services/freebit-client.service.ts | 18 +- .../services/freebit-mapper.service.ts | 11 +- .../services/freebit-semiblack.service.ts | 35 - .../services/freebit-test-tracker.service.ts | 7 +- .../freebit/services/freebit-usage.service.ts | 1 - .../services/freebit-voice-options.service.ts | 2 +- .../integrations/freebit/services/index.ts | 9 + .../salesforce-sim-inventory.service.ts | 2 +- .../services/whmcs-error-handler.service.ts | 8 +- .../whmcs/services/whmcs-invoice.service.ts | 5 +- .../whmcs/services/whmcs-order.service.ts | 4 +- .../auth/infra/token/jose-jwt.service.ts | 1 + apps/bff/src/modules/orders/orders.module.ts | 6 + .../fulfillment-context-mapper.service.ts | 180 ++++ .../fulfillment-step-executors.service.ts | 434 ++++++++ .../fulfillment-step-factory.service.ts | 285 +++++ .../order-fulfillment-orchestrator.service.ts | 976 ++++-------------- .../order-fulfillment-validator.service.ts | 16 +- .../services/sim-fulfillment.service.ts | 164 +-- .../services/queries/sim-billing.service.ts | 8 +- .../services/sim-validation.service.ts | 59 +- .../sim-management/sim.controller.ts | 55 +- .../app/(public)/(site)/auth/migrate/page.tsx | 5 - .../app/(public)/(site)/auth/signup/page.tsx | 5 - .../services/internet/configure/loading.tsx | 57 - .../services/internet/configure/page.tsx | 17 - .../src/app/(public)/(site)/services/page.tsx | 23 +- apps/portal/src/app/layout.tsx | 2 +- apps/portal/src/app/robots.ts | 2 +- apps/portal/src/app/sitemap.ts | 2 +- .../templates/PublicShell/PublicShell.tsx | 47 +- .../auth/components/LoginForm/LoginForm.tsx | 4 +- .../landing-page/views/PublicLandingView.tsx | 128 ++- .../views/PublicInternetConfigure.tsx | 132 --- .../services/views/PublicInternetPlans.tsx | 44 +- .../components/sim/SimManagementSection.tsx | 32 +- .../support/views/PublicContactView.tsx | 13 +- packages/domain/sim/helpers.ts | 10 +- 44 files changed, 1553 insertions(+), 1372 deletions(-) create mode 100644 apps/bff/src/modules/orders/services/fulfillment-context-mapper.service.ts create mode 100644 apps/bff/src/modules/orders/services/fulfillment-step-executors.service.ts create mode 100644 apps/bff/src/modules/orders/services/fulfillment-step-factory.service.ts delete mode 100644 apps/portal/src/app/(public)/(site)/auth/migrate/page.tsx delete mode 100644 apps/portal/src/app/(public)/(site)/auth/signup/page.tsx delete mode 100644 apps/portal/src/app/(public)/(site)/services/internet/configure/loading.tsx delete mode 100644 apps/portal/src/app/(public)/(site)/services/internet/configure/page.tsx delete mode 100644 apps/portal/src/features/services/views/PublicInternetConfigure.tsx diff --git a/apps/bff/src/infra/cache/cache.service.ts b/apps/bff/src/infra/cache/cache.service.ts index 96ecdb1c..b330f879 100644 --- a/apps/bff/src/infra/cache/cache.service.ts +++ b/apps/bff/src/infra/cache/cache.service.ts @@ -130,15 +130,15 @@ export class CacheService { if (!results) { return; } - results.forEach((result: [Error | null, unknown] | null) => { + for (const result of results) { if (!result) { - return; + continue; } - const [error, usage] = result; + const [error, usage] = result as [Error | null, unknown]; if (!error && typeof usage === "number") { total += usage; } - }); + } }); return total; } @@ -299,6 +299,7 @@ export class CacheService { ): Promise { let cursor = "0"; do { + // eslint-disable-next-line no-await-in-loop -- Cursor-based Redis SCAN requires sequential iteration const [next, keys] = (await this.redis.scan( cursor, "MATCH", @@ -308,6 +309,7 @@ export class CacheService { )) as unknown as [string, string[]]; cursor = next; if (keys && keys.length) { + // eslint-disable-next-line no-await-in-loop -- Sequential processing of batched keys await onKeys(keys); } } while (cursor !== "0"); diff --git a/apps/bff/src/infra/cache/distributed-lock.service.ts b/apps/bff/src/infra/cache/distributed-lock.service.ts index 1a583a5f..ba1495ce 100644 --- a/apps/bff/src/infra/cache/distributed-lock.service.ts +++ b/apps/bff/src/infra/cache/distributed-lock.service.ts @@ -58,6 +58,7 @@ export class DistributedLockService { for (let attempt = 0; attempt <= maxRetries; attempt++) { // 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"); if (result === "OK") { @@ -71,6 +72,7 @@ export class DistributedLockService { // Lock is held by someone else, wait and retry if (attempt < maxRetries) { + // eslint-disable-next-line no-await-in-loop -- Intentional delay between retry attempts await this.delay(retryDelayMs); } } diff --git a/apps/bff/src/infra/queue/services/salesforce-request-queue.service.ts b/apps/bff/src/infra/queue/services/salesforce-request-queue.service.ts index 91b6711e..a873e822 100644 --- a/apps/bff/src/infra/queue/services/salesforce-request-queue.service.ts +++ b/apps/bff/src/infra/queue/services/salesforce-request-queue.service.ts @@ -455,6 +455,7 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { + // eslint-disable-next-line no-await-in-loop -- Retry with backoff requires sequential attempts const result = await requestFn(); return result; } catch (error) { @@ -480,6 +481,7 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest error: lastError.message, }); + // eslint-disable-next-line no-await-in-loop -- Exponential backoff delay between retries await new Promise(resolve => { setTimeout(resolve, delay); }); diff --git a/apps/bff/src/integrations/freebit/facades/freebit.facade.ts b/apps/bff/src/integrations/freebit/facades/freebit.facade.ts index 4273196b..ac3b47e7 100644 --- a/apps/bff/src/integrations/freebit/facades/freebit.facade.ts +++ b/apps/bff/src/integrations/freebit/facades/freebit.facade.ts @@ -6,6 +6,13 @@ import { FreebitVoiceService } from "../services/freebit-voice.service.js"; import { FreebitCancellationService } from "../services/freebit-cancellation.service.js"; import { FreebitEsimService, type EsimActivationParams } from "../services/freebit-esim.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"; /** @@ -35,7 +42,11 @@ export class FreebitFacade { private readonly voiceService: FreebitVoiceService, private readonly cancellationService: FreebitCancellationService, 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); } + + // ============================================================================ + // 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 { + 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 { + 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 { + 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 { + const normalizedAccount = this.normalizeAccount(account); + return this.rateLimiterService.clearRateLimitForAccount(normalizedAccount); + } } diff --git a/apps/bff/src/integrations/freebit/services/freebit-account-registration.service.ts b/apps/bff/src/integrations/freebit/services/freebit-account-registration.service.ts index 1d094a68..eb49018e 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-account-registration.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-account-registration.service.ts @@ -125,13 +125,11 @@ export class FreebitAccountRegistrationService { >("/master/addAcnt/", payload); // Check response for individual account results - if (response.responseDatas && response.responseDatas.length > 0) { - const accountResult = response.responseDatas[0]; - if (accountResult.resultCode !== "100") { - throw new BadRequestException( - `Account registration failed for ${account}: result code ${accountResult.resultCode}` - ); - } + const accountResult = response.responseDatas?.[0]; + if (accountResult && accountResult.resultCode !== "100") { + throw new BadRequestException( + `Account registration failed for ${account}: result code ${accountResult.resultCode}` + ); } this.logger.log("MVNO account registration successful (PA02-01)", { diff --git a/apps/bff/src/integrations/freebit/services/freebit-account.service.ts b/apps/bff/src/integrations/freebit/services/freebit-account.service.ts index c4fb90a9..4ad56abb 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-account.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-account.service.ts @@ -64,6 +64,7 @@ export class FreebitAccountService { if (ep !== candidates[0]) { 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< FreebitAccountDetailsResponse, typeof request diff --git a/apps/bff/src/integrations/freebit/services/freebit-client.service.ts b/apps/bff/src/integrations/freebit/services/freebit-client.service.ts index 5bd5a31a..053da70c 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-client.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-client.service.ts @@ -60,7 +60,7 @@ export class FreebitClientService { }); 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); this.logger.error("Freebit API HTTP error", { url, @@ -81,7 +81,7 @@ export class FreebitClientService { const statusCode = this.normalizeResultCode(responseData.status?.statusCode); if (resultCode && resultCode !== "100") { - const isProd = process.env.NODE_ENV === "production"; + const isProd = process.env["NODE_ENV"] === "production"; const errorDetails = { url, resultCode, @@ -166,12 +166,12 @@ export class FreebitClientService { let attempt = 0; // Log request details in dev for debugging - const isProd = process.env.NODE_ENV === "production"; + const isProd = process.env["NODE_ENV"] === "production"; if (!isProd) { - console.log("[FREEBIT JSON API REQUEST]", JSON.stringify({ + this.logger.debug("[FREEBIT JSON API REQUEST]", { url, payload: redactForLogs(requestPayload), - }, null, 2)); + }); } try { const responseData = await withRetry( @@ -207,7 +207,7 @@ export class FreebitClientService { const statusCode = this.normalizeResultCode(responseData.status?.statusCode); if (resultCode && resultCode !== "100") { - const isProd = process.env.NODE_ENV === "production"; + const isProd = process.env["NODE_ENV"] === "production"; const errorDetails = { url, resultCode, @@ -342,7 +342,7 @@ export class FreebitClientService { ): Promise { const payloadObj = payload as Record; const phoneNumber = this.testTracker.extractPhoneNumber( - (payloadObj.account as string) || "", + (payloadObj["account"] as string) || "", payload ); @@ -371,10 +371,10 @@ export class FreebitClientService { apiEndpoint: endpoint, apiMethod: "POST", phoneNumber, - simIdentifier: (payloadObj.account as string) || phoneNumber, + simIdentifier: (payloadObj["account"] as string) || phoneNumber, requestPayload: JSON.stringify(redactForLogs(payload)), responseStatus: resultCode === "100" ? "Success" : `Error: ${resultCode}`, - error: error ? extractErrorMessage(error) : undefined, + ...(error ? { error: extractErrorMessage(error) } : {}), additionalInfo: statusMessage, }); } diff --git a/apps/bff/src/integrations/freebit/services/freebit-mapper.service.ts b/apps/bff/src/integrations/freebit/services/freebit-mapper.service.ts index 9268070b..7430d54d 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-mapper.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-mapper.service.ts @@ -132,10 +132,7 @@ export class FreebitMapperService { // No stored options, parse from API response // Default to false - disabled unless API explicitly returns 10 (enabled) voiceMailEnabled = this.parseOptionFlag(account.voicemail ?? account.voiceMail, false); - callWaitingEnabled = this.parseOptionFlag( - account.callwaiting ?? account.callWaiting, - false - ); + callWaitingEnabled = this.parseOptionFlag(account.callwaiting ?? account.callWaiting, false); internationalRoamingEnabled = this.parseOptionFlag( account.worldwing ?? account.worldWing, false @@ -175,13 +172,13 @@ export class FreebitMapperService { } // Log raw account data in dev to debug MSISDN availability - if (process.env.NODE_ENV !== "production") { - console.log("[FREEBIT ACCOUNT DATA]", JSON.stringify({ + if (process.env["NODE_ENV"] !== "production") { + this.logger.debug("[FREEBIT ACCOUNT DATA]", { account: account.account, msisdn: account.msisdn, eid: account.eid, iccid: account.iccid, - }, null, 2)); + }); } return { diff --git a/apps/bff/src/integrations/freebit/services/freebit-semiblack.service.ts b/apps/bff/src/integrations/freebit/services/freebit-semiblack.service.ts index 171713c3..328744be 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-semiblack.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-semiblack.service.ts @@ -178,39 +178,4 @@ export class FreebitSemiBlackService { const day = String(now.getDate()).padStart(2, "0"); return `${year}${month}${day}`; } - - /** - * Get human-readable error message for PA05-18 error codes - */ - private getErrorMessage(code: number, defaultMessage?: string): string { - const errorMessages: Record = { - 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"; - } } diff --git a/apps/bff/src/integrations/freebit/services/freebit-test-tracker.service.ts b/apps/bff/src/integrations/freebit/services/freebit-test-tracker.service.ts index a8ca2448..3dc77fa3 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-test-tracker.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-test-tracker.service.ts @@ -122,7 +122,12 @@ export class FreebitTestTrackerService { // Try to extract from payload if it's an object if (payload && typeof payload === "object") { const obj = payload as Record; - 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 ""; diff --git a/apps/bff/src/integrations/freebit/services/freebit-usage.service.ts b/apps/bff/src/integrations/freebit/services/freebit-usage.service.ts index e3cc5913..401b8735 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-usage.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-usage.service.ts @@ -6,7 +6,6 @@ import { FreebitMapperService } from "./freebit-mapper.service.js"; import type { FreebitTrafficInfoRequest, FreebitTrafficInfoResponse, - FreebitTopUpRequest, FreebitTopUpResponse, FreebitQuotaHistoryRequest, FreebitQuotaHistoryResponse, diff --git a/apps/bff/src/integrations/freebit/services/freebit-voice-options.service.ts b/apps/bff/src/integrations/freebit/services/freebit-voice-options.service.ts index 7b72e058..6644b49f 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-voice-options.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-voice-options.service.ts @@ -202,7 +202,7 @@ export class FreebitVoiceOptionsService { if (!date) return ""; 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 month = String(d.getMonth() + 1).padStart(2, "0"); diff --git a/apps/bff/src/integrations/freebit/services/index.ts b/apps/bff/src/integrations/freebit/services/index.ts index 734790be..cf9128e3 100644 --- a/apps/bff/src/integrations/freebit/services/index.ts +++ b/apps/bff/src/integrations/freebit/services/index.ts @@ -12,3 +12,12 @@ export { FreebitSemiBlackService, type SemiBlackRegistrationParams, } 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"; diff --git a/apps/bff/src/integrations/salesforce/services/salesforce-sim-inventory.service.ts b/apps/bff/src/integrations/salesforce/services/salesforce-sim-inventory.service.ts index ed4c835d..7596d72b 100644 --- a/apps/bff/src/integrations/salesforce/services/salesforce-sim-inventory.service.ts +++ b/apps/bff/src/integrations/salesforce/services/salesforce-sim-inventory.service.ts @@ -231,7 +231,7 @@ export class SalesforceSIMInventoryService { id: raw.Id, phoneNumber: raw.Phone_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, }; } diff --git a/apps/bff/src/integrations/whmcs/connection/services/whmcs-error-handler.service.ts b/apps/bff/src/integrations/whmcs/connection/services/whmcs-error-handler.service.ts index d09d1ddf..8591480d 100644 --- a/apps/bff/src/integrations/whmcs/connection/services/whmcs-error-handler.service.ts +++ b/apps/bff/src/integrations/whmcs/connection/services/whmcs-error-handler.service.ts @@ -91,13 +91,11 @@ export class WhmcsErrorHandlerService { /** * Parse HTTP status error from error message (e.g., "HTTP 500: Internal Server Error") */ - private parseHttpStatusError( - message: string - ): { status: number; statusText: string } | null { + private parseHttpStatusError(message: string): { status: number; statusText: string } | null { const match = message.match(/^HTTP (\d{3}):\s*(.*)$/i); - if (match) { + if (match?.[1]) { return { - status: parseInt(match[1], 10), + status: Number.parseInt(match[1], 10), statusText: match[2] || "Unknown", }; } diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts index a2e9783a..ace3e95d 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts @@ -124,6 +124,7 @@ export class WhmcsInvoiceService { if (!batch) continue; // 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( batch.map(async (invoice: Invoice) => { try { @@ -144,6 +145,7 @@ export class WhmcsInvoiceService { // Add delay between batches (except for the last batch) to respect rate limits if (i < batches.length - 1) { + // eslint-disable-next-line no-await-in-loop -- Intentional rate limit delay between batches await sleep(BATCH_DELAY_MS); } } @@ -333,9 +335,10 @@ export class WhmcsInvoiceService { fullResponse: JSON.stringify(response), }); + const rawError = (response as Record)["error"]; const errorMessage = response.message || - (response as Record).error || + (typeof rawError === "string" ? rawError : JSON.stringify(rawError ?? "")) || `Unknown error (result: ${response.result})`; throw new WhmcsOperationException(`WHMCS invoice creation failed: ${errorMessage}`, { diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-order.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-order.service.ts index 97a1d765..8e2973e4 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-order.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-order.service.ts @@ -159,7 +159,7 @@ export class WhmcsOrderService { const serviceIds = this.parseDelimitedIds(parsedResponse.data.serviceids); const invoiceId = parsedResponse.data.invoiceid - ? parseInt(String(parsedResponse.data.invoiceid), 10) + ? Number.parseInt(String(parsedResponse.data.invoiceid), 10) : undefined; this.logger.log("WHMCS order accepted successfully", { @@ -169,7 +169,7 @@ export class WhmcsOrderService { sfOrderId, }); - return { serviceIds, invoiceId }; + return { serviceIds, ...(invoiceId !== undefined && { invoiceId }) }; } catch (error) { // Enhanced error logging with full context this.logger.error("Failed to accept WHMCS order", { diff --git a/apps/bff/src/modules/auth/infra/token/jose-jwt.service.ts b/apps/bff/src/modules/auth/infra/token/jose-jwt.service.ts index 8521e3af..9e151769 100644 --- a/apps/bff/src/modules/auth/infra/token/jose-jwt.service.ts +++ b/apps/bff/src/modules/auth/infra/token/jose-jwt.service.ts @@ -94,6 +94,7 @@ export class JoseJwtService { const key = this.verificationKeys[i]; if (!key) continue; try { + // eslint-disable-next-line no-await-in-loop -- JWT verification with multiple keys requires sequential attempts const { payload } = await jwtVerify(token, key, options); return payload as T; } catch (err) { diff --git a/apps/bff/src/modules/orders/orders.module.ts b/apps/bff/src/modules/orders/orders.module.ts index 88ca7dfc..0102d853 100644 --- a/apps/bff/src/modules/orders/orders.module.ts +++ b/apps/bff/src/modules/orders/orders.module.ts @@ -40,6 +40,9 @@ import { OrderFulfillmentOrchestrator } from "./services/order-fulfillment-orche import { OrderFulfillmentErrorService } from "./services/order-fulfillment-error.service.js"; import { SimFulfillmentService } from "./services/sim-fulfillment.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 { ProvisioningProcessor } from "./queue/provisioning.processor.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, SimFulfillmentService, FulfillmentSideEffectsService, + FulfillmentContextMapper, + FulfillmentStepExecutors, + FulfillmentStepFactory, // Async provisioning queue ProvisioningQueueService, ProvisioningProcessor, diff --git a/apps/bff/src/modules/orders/services/fulfillment-context-mapper.service.ts b/apps/bff/src/modules/orders/services/fulfillment-context-mapper.service.ts new file mode 100644 index 00000000..15c81980 --- /dev/null +++ b/apps/bff/src/modules/orders/services/fulfillment-context-mapper.service.ts @@ -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 { + const config: Record = {}; + + // Start with payload configurations if provided + if (rawConfigurations && typeof rawConfigurations === "object") { + Object.assign(config, rawConfigurations as Record); + } + + // 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; + } +} diff --git a/apps/bff/src/modules/orders/services/fulfillment-step-executors.service.ts b/apps/bff/src/modules/orders/services/fulfillment-step-executors.service.ts new file mode 100644 index 00000000..a3c1efb7 --- /dev/null +++ b/apps/bff/src/modules/orders/services/fulfillment-step-executors.service.ts @@ -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; + +/** + * 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 { + 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 + ): Promise { + 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[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 { + 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 { + // 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 { + 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 { + // 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 { + 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 { + 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(); + } +} diff --git a/apps/bff/src/modules/orders/services/fulfillment-step-factory.service.ts b/apps/bff/src/modules/orders/services/fulfillment-step-factory.service.ts new file mode 100644 index 00000000..c1d5ea5a --- /dev/null +++ b/apps/bff/src/modules/orders/services/fulfillment-step-factory.service.ts @@ -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; + +/** + * 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 + ): 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, + 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( + context: OrderFulfillmentContext, + stepName: string, + executor: () => Promise + ): () => Promise { + 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; + } + } +} diff --git a/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts b/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts index 5a427416..588a54e4 100644 --- a/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts +++ b/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts @@ -1,36 +1,21 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; -import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js"; -import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js"; -import { WhmcsOrderService } from "@bff/integrations/whmcs/services/whmcs-order.service.js"; +import { SalesforceFacade } from "@bff/integrations/salesforce/facades/salesforce.facade.js"; import type { WhmcsOrderResult } from "@bff/integrations/whmcs/services/whmcs-order.service.js"; import { OrderOrchestrator } from "./order-orchestrator.service.js"; import { OrderFulfillmentValidator } from "./order-fulfillment-validator.service.js"; import { OrderFulfillmentErrorService } from "./order-fulfillment-error.service.js"; -import { - SimFulfillmentService, - type SimFulfillmentResult, - type ContactIdentityData, -} from "./sim-fulfillment.service.js"; +import { FulfillmentStepFactory } from "./fulfillment-step-factory.service.js"; +import { FulfillmentSideEffectsService } from "./fulfillment-side-effects.service.js"; +import type { SimFulfillmentResult } from "./sim-fulfillment.service.js"; import { DistributedTransactionService } from "@bff/infra/database/services/distributed-transaction.service.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; -import { OrderEventsService } from "./order-events.service.js"; -import { OrdersCacheService } from "./orders-cache.service.js"; -import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; -import { NotificationService } from "@bff/modules/notifications/notifications.service.js"; import type { OrderDetails } from "@customer-portal/domain/orders"; -import type { - OrderFulfillmentValidationResult, - SalesforceOrderRecord, -} from "@customer-portal/domain/orders/providers"; -import { createOrderNotes, mapOrderToWhmcsItems } from "@customer-portal/domain/orders/providers"; -import { OPPORTUNITY_STAGE } from "@customer-portal/domain/opportunity"; -import { NOTIFICATION_SOURCE, NOTIFICATION_TYPE } from "@customer-portal/domain/notifications"; -import { salesforceAccountIdSchema } from "@customer-portal/domain/common"; +import type { OrderFulfillmentValidationResult } from "@customer-portal/domain/orders/providers"; +import { mapOrderToWhmcsItems } from "@customer-portal/domain/orders/providers"; import { OrderValidationException, FulfillmentException, - WhmcsOperationException, } from "@bff/core/exceptions/domain-exceptions.js"; type WhmcsOrderItemMappingResult = ReturnType; @@ -56,24 +41,23 @@ export interface OrderFulfillmentContext { /** * Orchestrates the complete order fulfillment workflow - * Similar to OrderOrchestrator but for fulfillment operations + * + * This is a thin coordinator that delegates to specialized services: + * - FulfillmentStepFactory: Builds the step array + * - FulfillmentStepExecutors: Contains step execution logic + * - FulfillmentSideEffectsService: Handles events, notifications, cache */ @Injectable() export class OrderFulfillmentOrchestrator { constructor( @Inject(Logger) private readonly logger: Logger, - private readonly salesforceService: SalesforceService, - private readonly opportunityService: SalesforceOpportunityService, - private readonly whmcsOrderService: WhmcsOrderService, + private readonly salesforceFacade: SalesforceFacade, private readonly orderOrchestrator: OrderOrchestrator, private readonly orderFulfillmentValidator: OrderFulfillmentValidator, private readonly orderFulfillmentErrorService: OrderFulfillmentErrorService, - private readonly simFulfillmentService: SimFulfillmentService, - private readonly distributedTransactionService: DistributedTransactionService, - private readonly orderEvents: OrderEventsService, - private readonly ordersCache: OrdersCacheService, - private readonly mappingsService: MappingsService, - private readonly notifications: NotificationService + private readonly stepFactory: FulfillmentStepFactory, + private readonly sideEffects: FulfillmentSideEffectsService, + private readonly distributedTransactionService: DistributedTransactionService ) {} /** @@ -84,25 +68,7 @@ export class OrderFulfillmentOrchestrator { payload: Record, idempotencyKey: string ): Promise { - return this.executeFulfillmentWithTransactions(sfOrderId, payload, idempotencyKey); - } - - /** - * Execute fulfillment workflow using distributed transactions for atomicity - */ - private async executeFulfillmentWithTransactions( - sfOrderId: string, - payload: Record, - idempotencyKey: string - ): Promise { - const context: OrderFulfillmentContext = { - sfOrderId, - idempotencyKey, - validation: null, - steps: this.initializeSteps( - typeof payload.orderType === "string" ? payload.orderType : "Unknown" - ), - }; + const context = this.initializeContext(sfOrderId, idempotencyKey, payload); this.logger.log("Starting transactional fulfillment orchestration", { sfOrderId, @@ -111,450 +77,22 @@ export class OrderFulfillmentOrchestrator { try { // Step 1: Validation (no rollback needed) - this.updateStepStatus(context, "validation", "in_progress"); - try { - context.validation = await this.orderFulfillmentValidator.validateFulfillmentRequest( - sfOrderId, - idempotencyKey - ); - this.updateStepStatus(context, "validation", "completed"); - - if (context.validation.isAlreadyProvisioned) { - this.logger.log("Order already provisioned, skipping fulfillment", { sfOrderId }); - this.orderEvents.publish(sfOrderId, { - orderId: sfOrderId, - status: "Completed", - activationStatus: "Activated", - stage: "completed", - source: "fulfillment", - message: "Order already provisioned", - timestamp: new Date().toISOString(), - payload: { - whmcsOrderId: context.validation.whmcsOrderId, - }, - }); - await this.invalidateOrderCaches(sfOrderId, context.validation?.sfOrder?.AccountId); - return context; - } - } catch (error) { - this.updateStepStatus(context, "validation", "failed", extractErrorMessage(error)); - this.logger.error("Fulfillment validation failed", { - sfOrderId, - error: extractErrorMessage(error), - }); - throw error; + await this.executeValidation(context, sfOrderId, idempotencyKey); + if (context.validation?.isAlreadyProvisioned) { + return this.handleAlreadyProvisioned(context, sfOrderId); } // Step 2: Get order details (no rollback needed) - try { - const orderDetails = await this.orderOrchestrator.getOrder(sfOrderId); - if (!orderDetails) { - throw new OrderValidationException("Order details could not be retrieved.", { - sfOrderId, - idempotencyKey, - }); - } - context.orderDetails = orderDetails; - } catch (error) { - this.logger.error("Failed to get order details", { - sfOrderId, - error: extractErrorMessage(error), - }); - throw error; - } - - // Step 3: Execute the main fulfillment workflow as a distributed transaction - // New flow: SIM activation (PA05-18 + PA02-01 + PA05-05) → Activated status → WHMCS → Registration Completed - let simFulfillmentResult: SimFulfillmentResult | undefined; - let mappingResult: WhmcsOrderItemMappingResult | undefined; - let whmcsCreateResult: WhmcsOrderResult | undefined; + await this.fetchOrderDetails(context, sfOrderId, idempotencyKey); + // Step 3: Build and execute the fulfillment steps + const steps = this.stepFactory.buildSteps(context, payload); const fulfillmentResult = - await this.distributedTransactionService.executeDistributedTransaction( - [ - { - id: "sf_status_update", - description: "Update Salesforce order status to Activating", - execute: this.createTrackedStep(context, "sf_status_update", async () => { - const result = await this.salesforceService.updateOrder({ - Id: sfOrderId, - Activation_Status__c: "Activating", - }); - this.orderEvents.publish(sfOrderId, { - orderId: sfOrderId, - status: "Processing", - activationStatus: "Activating", - stage: "in_progress", - source: "fulfillment", - timestamp: new Date().toISOString(), - }); - await this.safeNotifyOrder({ - type: NOTIFICATION_TYPE.ORDER_APPROVED, - sfOrderId, - accountId: context.validation?.sfOrder?.AccountId, - actionUrl: `/account/orders/${sfOrderId}`, - }); - return result; - }), - rollback: async () => { - await this.salesforceService.updateOrder({ - Id: sfOrderId, - Activation_Status__c: "Failed", - }); - }, - critical: true, - }, - { - id: "order_details", - description: "Retain order details in context", - execute: this.createTrackedStep(context, "order_details", () => - Promise.resolve(context.orderDetails) - ), - critical: false, - }, - // SIM fulfillment now runs BEFORE WHMCS (PA05-18 + PA02-01 + PA05-05) - { - id: "sim_fulfillment", - description: "SIM activation via Freebit (PA05-18 + PA02-01 + PA05-05)", - execute: this.createTrackedStep(context, "sim_fulfillment", async () => { - if (context.orderDetails?.orderType === "SIM") { - const sfOrder = context.validation?.sfOrder; - const configurations = this.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) - // These fields are populated when order has MNP/porting data - const contactIdentity = this.extractContactIdentity(sfOrder); - - this.logger.log("Starting SIM fulfillment (before WHMCS)", { - orderId: context.sfOrderId, - simType: sfOrder?.SIM_Type__c, - assignedPhysicalSimId, - voiceMailEnabled, - callWaitingEnabled, - hasContactIdentity: !!contactIdentity, - }); - - const result = await this.simFulfillmentService.fulfillSimOrder({ - orderDetails: context.orderDetails, - configurations, - assignedPhysicalSimId, - voiceMailEnabled, - callWaitingEnabled, - contactIdentity, - }); - - simFulfillmentResult = result; - context.simFulfillmentResult = result; - - return result; - } - return { activated: false, simType: "eSIM" as const }; - }), - rollback: () => { - // 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, - simFulfillmentResult, - } - ); - return Promise.resolve(); - }, - critical: context.validation?.sfOrder?.SIM_Type__c === "Physical SIM", - }, - // Update status to "Activated" after successful SIM fulfillment - { - id: "sf_activated_update", - description: "Update Salesforce order status to Activated", - execute: this.createTrackedStep(context, "sf_activated_update", async () => { - if (context.orderDetails?.orderType === "SIM" && simFulfillmentResult?.activated) { - const result = await this.salesforceService.updateOrder({ - Id: sfOrderId, - Status: "Activated", - Activation_Status__c: "Activated", - }); - this.orderEvents.publish(sfOrderId, { - orderId: sfOrderId, - status: "Activated", - activationStatus: "Activated", - stage: "in_progress", - source: "fulfillment", - message: "SIM activated, proceeding to billing setup", - timestamp: new Date().toISOString(), - }); - return result; - } - return { skipped: true }; - }), - rollback: async () => { - await this.salesforceService.updateOrder({ - Id: sfOrderId, - Activation_Status__c: "Failed", - }); - }, - critical: false, - }, - { - id: "mapping", - description: "Map OrderItems to WHMCS format with SIM data", - execute: this.createTrackedStep(context, "mapping", () => { - if (!context.orderDetails) { - return Promise.reject(new Error("Order details are required for mapping")); - } - // Use domain mapper directly - single transformation! - const result = mapOrderToWhmcsItems(context.orderDetails); - - // Add SIM data if we have it (phone number goes to domain field and custom fields) - if (simFulfillmentResult?.activated && simFulfillmentResult.phoneNumber) { - result.whmcsItems.forEach(item => { - // 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, - }), - }; - }); - } - - mappingResult = result; - - this.logger.log("OrderItems mapped to WHMCS", { - totalItems: result.summary.totalItems, - serviceItems: result.summary.serviceItems, - activationItems: result.summary.activationItems, - hasSimData: !!simFulfillmentResult?.phoneNumber, - }); - - return Promise.resolve(result); - }), - critical: true, - }, - { - id: "whmcs_create", - description: "Create order in WHMCS", - execute: this.createTrackedStep(context, "whmcs_create", async () => { - if (!context.validation) { - throw new OrderValidationException("Validation context is missing", { - sfOrderId, - step: "whmcs_create_order", - }); - } - if (!mappingResult) { - throw new FulfillmentException("Mapping result is not available", { - sfOrderId, - step: "whmcs_create_order", - }); - } - - const orderNotes = createOrderNotes( - sfOrderId, - `Provisioned from Salesforce Order ${sfOrderId}` - ); - - // Get OpportunityId from order details for WHMCS lifecycle linking - const sfOpportunityId = context.orderDetails?.opportunityId; - - const result = await this.whmcsOrderService.addOrder({ - clientId: context.validation.clientId, - items: mappingResult.whmcsItems, - paymentMethod: "stripe", - promoCode: "1st Month Free (Monthly Plan)", - sfOrderId, - sfOpportunityId, // Pass to WHMCS for bidirectional linking - notes: orderNotes, - noinvoiceemail: true, - noemail: true, - }); - - whmcsCreateResult = result; - return result; - }), - rollback: () => { - 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, - action: "MANUAL_CLEANUP_REQUIRED", - } - ); - } - return Promise.resolve(); - }, - critical: true, - }, - { - id: "whmcs_accept", - description: "Accept/provision order in WHMCS", - execute: this.createTrackedStep(context, "whmcs_accept", async () => { - if (!whmcsCreateResult?.orderId) { - throw new WhmcsOperationException( - "WHMCS order ID missing before acceptance step", - { - sfOrderId, - step: "whmcs_accept_order", - } - ); - } - - const acceptResult = await this.whmcsOrderService.acceptOrder( - whmcsCreateResult.orderId, - sfOrderId - ); - - // Update whmcsCreateResult with service IDs from AcceptOrder - // (Services are created on accept, not on add) - if (acceptResult.serviceIds.length > 0) { - whmcsCreateResult.serviceIds = acceptResult.serviceIds; - } - - return { orderId: whmcsCreateResult.orderId, serviceIds: acceptResult.serviceIds }; - }), - rollback: () => { - 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, - action: "MANUAL_SERVICE_TERMINATION_REQUIRED", - } - ); - } - return Promise.resolve(); - }, - critical: true, - }, - // Note: sim_fulfillment step was moved earlier in the flow (before WHMCS) - { - id: "sf_registration_complete", - description: "Update Salesforce with WHMCS registration info", - execute: this.createTrackedStep(context, "sf_registration_complete", async () => { - // 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 = context.orderDetails?.orderType === "SIM"; - const isAlreadyActivated = simFulfillmentResult?.activated === true; - - const updatePayload: { Id: string; [key: string]: unknown } = { - Id: 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.salesforceService.updateOrder(updatePayload); - this.orderEvents.publish(sfOrderId, { - orderId: 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.safeNotifyOrder({ - type: NOTIFICATION_TYPE.ORDER_ACTIVATED, - sfOrderId, - accountId: context.validation?.sfOrder?.AccountId, - actionUrl: "/account/services", - }); - return result; - }), - rollback: async () => { - await this.salesforceService.updateOrder({ - Id: sfOrderId, - Activation_Status__c: "Failed", - }); - }, - critical: true, - }, - { - id: "opportunity_update", - description: "Update Opportunity with WHMCS Service ID and Active stage", - execute: this.createTrackedStep(context, "opportunity_update", async () => { - const opportunityId = context.orderDetails?.opportunityId; - const serviceId = whmcsCreateResult?.serviceIds?.[0]; - - if (!opportunityId) { - this.logger.debug("No Opportunity linked to order, skipping update", { - sfOrderId, - }); - return { skipped: true as const }; - } - - try { - // Update Opportunity stage to Active and set WHMCS Service ID - await this.opportunityService.updateStage( - opportunityId, - OPPORTUNITY_STAGE.ACTIVE, - "Service activated via fulfillment" - ); - - if (serviceId) { - await this.opportunityService.linkWhmcsServiceToOpportunity( - opportunityId, - serviceId, - context.validation?.clientId // Pass client ID for WHMCS admin URL - ); - } - - this.logger.log("Opportunity updated with Active stage and WHMCS link", { - opportunityIdTail: opportunityId.slice(-4), - whmcsServiceId: serviceId, - sfOrderId, - }); - - return { opportunityId, whmcsServiceId: serviceId }; - } catch (error) { - // Log but don't fail - Opportunity update is non-critical - this.logger.warn("Failed to update Opportunity after fulfillment", { - error: extractErrorMessage(error), - opportunityId, - sfOrderId, - }); - return { failed: true as const, error: extractErrorMessage(error) }; - } - }), - critical: false, // Opportunity update failure shouldn't rollback fulfillment - }, - ], - { - description: `Order fulfillment for ${sfOrderId}`, - timeout: 300000, // 5 minutes - continueOnNonCriticalFailure: true, - } - ); + await this.distributedTransactionService.executeDistributedTransaction(steps, { + description: `Order fulfillment for ${sfOrderId}`, + timeout: 300000, // 5 minutes + continueOnNonCriticalFailure: true, + }); if (!fulfillmentResult.success) { this.logger.error("Fulfillment transaction failed", { @@ -574,54 +112,105 @@ export class OrderFulfillmentOrchestrator { ); } - // Update context with results - context.mappingResult = mappingResult; - context.whmcsResult = whmcsCreateResult; - this.logger.log("Transactional fulfillment completed successfully", { sfOrderId, stepsExecuted: fulfillmentResult.stepsExecuted, duration: fulfillmentResult.duration, }); - await this.invalidateOrderCaches(sfOrderId, context.validation?.sfOrder?.AccountId); + await this.sideEffects.invalidateCaches(sfOrderId, context.validation?.sfOrder?.AccountId); return context; } catch (error) { - await this.invalidateOrderCaches(sfOrderId, context.validation?.sfOrder?.AccountId); - await this.handleFulfillmentError(context, error as Error); - await this.safeNotifyOrder({ - type: NOTIFICATION_TYPE.ORDER_FAILED, - sfOrderId, - accountId: context.validation?.sfOrder?.AccountId, - actionUrl: `/account/orders/${sfOrderId}`, - }); - this.orderEvents.publish(sfOrderId, { - orderId: sfOrderId, - status: "Pending Review", - activationStatus: "Failed", - stage: "failed", - source: "fulfillment", - timestamp: new Date().toISOString(), - reason: error instanceof Error ? error.message : String(error), - }); + await this.handleFulfillmentError(context, sfOrderId, error as Error); throw error; } } /** - * Initialize fulfillment steps - * - * New order (Physical SIM activation flow): - * 1. validation - * 2. sf_status_update (Activating) - * 3. order_details - * 4. sim_fulfillment (PA05-18 + PA02-01 + PA05-05) - SIM orders only - * 5. sf_activated_update - SIM orders only - * 6. mapping (with SIM data for WHMCS) - * 7. whmcs_create - * 8. whmcs_accept - * 9. sf_registration_complete (WHMCS info, skip Status for SIM) - * 10. opportunity_update + * Get fulfillment summary from context + */ + getFulfillmentSummary(context: OrderFulfillmentContext): { + success: boolean; + status: "Already Fulfilled" | "Fulfilled" | "Failed"; + whmcsOrderId?: string; + whmcsServiceIds?: number[]; + message: string; + steps: OrderFulfillmentStep[]; + } { + const isSuccess = context.steps.every(s => s.status === "completed"); + const failedStep = context.steps.find(s => s.status === "failed"); + + if (context.validation?.isAlreadyProvisioned) { + const result: { + success: boolean; + status: "Already Fulfilled" | "Fulfilled" | "Failed"; + whmcsOrderId?: string; + whmcsServiceIds?: number[]; + message: string; + steps: OrderFulfillmentStep[]; + } = { + success: true, + status: "Already Fulfilled", + message: "Order was already fulfilled in WHMCS", + steps: context.steps, + }; + if (context.validation.whmcsOrderId) { + result.whmcsOrderId = context.validation.whmcsOrderId; + } + return result; + } + + if (isSuccess) { + const result: { + success: boolean; + status: "Already Fulfilled" | "Fulfilled" | "Failed"; + whmcsOrderId?: string; + whmcsServiceIds?: number[]; + message: string; + steps: OrderFulfillmentStep[]; + } = { + success: true, + status: "Fulfilled", + message: "Order fulfilled successfully in WHMCS", + steps: context.steps, + }; + if (context.whmcsResult?.orderId !== undefined) { + result.whmcsOrderId = context.whmcsResult.orderId.toString(); + } + if (context.whmcsResult?.serviceIds) { + result.whmcsServiceIds = context.whmcsResult.serviceIds; + } + return result; + } + + return { + success: false, + status: "Failed", + message: failedStep?.error || "Fulfillment failed", + steps: context.steps, + }; + } + + // =========================================================================== + // Private Helpers + // =========================================================================== + + private initializeContext( + sfOrderId: string, + idempotencyKey: string, + payload: Record + ): OrderFulfillmentContext { + const orderType = typeof payload["orderType"] === "string" ? payload["orderType"] : "Unknown"; + return { + sfOrderId, + idempotencyKey, + validation: null, + steps: this.initializeSteps(orderType), + }; + } + + /** + * Initialize fulfillment steps based on order type */ private initializeSteps(orderType?: string): OrderFulfillmentStep[] { const steps: OrderFulfillmentStep[] = [ @@ -648,221 +237,66 @@ export class OrderFulfillmentOrchestrator { return steps; } - private extractConfigurations( - rawConfigurations: unknown, - sfOrder?: SalesforceOrderRecord | null - ): Record { - const config: Record = {}; - - // Start with payload configurations if provided - if (rawConfigurations && typeof rawConfigurations === "object") { - Object.assign(config, rawConfigurations as Record); - } - - // 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. - */ - private 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 - */ - private 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 + private async executeValidation( + context: OrderFulfillmentContext, + sfOrderId: string, + idempotencyKey: string + ): Promise { + this.updateStepStatus(context, "validation", "in_progress"); try { - const date = new Date(dateStr); - if (!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; - } - - private async invalidateOrderCaches(orderId: string, accountId?: string | null): Promise { - const tasks: Array> = [this.ordersCache.invalidateOrder(orderId)]; - if (accountId) { - tasks.push(this.ordersCache.invalidateAccountOrders(accountId)); - } - - try { - await Promise.all(tasks); + const validationResult = await this.orderFulfillmentValidator.validateFulfillmentRequest( + sfOrderId, + idempotencyKey + ); + // eslint-disable-next-line require-atomic-updates -- Sequential execution, context is local to this call + context.validation = validationResult; + this.updateStepStatus(context, "validation", "completed"); } catch (error) { - this.logger.warn("Failed to invalidate order caches", { - orderId, - accountId: accountId ?? undefined, + this.updateStepStatus(context, "validation", "failed", extractErrorMessage(error)); + this.logger.error("Fulfillment validation failed", { + sfOrderId, error: extractErrorMessage(error), }); + throw error; } } - private async safeNotifyOrder(params: { - type: (typeof NOTIFICATION_TYPE)[keyof typeof NOTIFICATION_TYPE]; - sfOrderId: string; - accountId?: unknown; - actionUrl: string; - }): Promise { + private handleAlreadyProvisioned( + context: OrderFulfillmentContext, + sfOrderId: string + ): OrderFulfillmentContext { + this.logger.log("Order already provisioned, skipping fulfillment", { sfOrderId }); + this.sideEffects.publishAlreadyProvisioned(sfOrderId, context.validation?.whmcsOrderId); + void this.sideEffects.invalidateCaches(sfOrderId, context.validation?.sfOrder?.AccountId); + return context; + } + + private async fetchOrderDetails( + context: OrderFulfillmentContext, + sfOrderId: string, + idempotencyKey: string + ): Promise { try { - const sfAccountId = salesforceAccountIdSchema.safeParse(params.accountId); - if (!sfAccountId.success) return; - - const mapping = await this.mappingsService.findBySfAccountId(sfAccountId.data); - if (!mapping?.userId) return; - - await this.notifications.createNotification({ - userId: mapping.userId, - type: params.type, - source: NOTIFICATION_SOURCE.SYSTEM, - sourceId: params.sfOrderId, - actionUrl: params.actionUrl, - }); + const orderDetails = await this.orderOrchestrator.getOrder(sfOrderId); + if (!orderDetails) { + throw new OrderValidationException("Order details could not be retrieved.", { + sfOrderId, + idempotencyKey, + }); + } + context.orderDetails = orderDetails; } catch (error) { - this.logger.warn( - { - sfOrderId: params.sfOrderId, - type: params.type, - err: error instanceof Error ? error.message : String(error), - }, - "Failed to create in-app order notification" - ); + this.logger.error("Failed to get order details", { + sfOrderId, + error: extractErrorMessage(error), + }); + throw error; } } - /** - * Handle fulfillment errors and update Salesforce - */ private async handleFulfillmentError( context: OrderFulfillmentContext, + sfOrderId: string, error: Error ): Promise { const errorCode = this.orderFulfillmentErrorService.determineErrorCode(error); @@ -873,26 +307,22 @@ export class OrderFulfillmentOrchestrator { idempotencyKey: context.idempotencyKey, error: error.message, errorCode, - failedStep: context.steps.find((s: OrderFulfillmentStep) => s.status === "failed")?.step, + failedStep: context.steps.find(s => s.status === "failed")?.step, }); - // Try to update Salesforce with failure status + // Update Salesforce with failure status try { - const updates: Record = { - Id: context.sfOrderId, - // Set overall Order.Status to Pending Review for manual attention + await this.salesforceFacade.updateOrder({ + Id: sfOrderId, Status: "Pending Review", Activation_Status__c: "Failed", Activation_Error_Code__c: ( this.orderFulfillmentErrorService.getShortCode(error) || String(errorCode) ) .toString() - .substring(0, 60), - Activation_Error_Message__c: userMessage?.substring(0, 255), - }; - - await this.salesforceService.updateOrder(updates as { Id: string; [key: string]: unknown }); - + .slice(0, 60), + Activation_Error_Message__c: userMessage?.slice(0, 255), + }); this.logger.log("Salesforce updated with failure status", { sfOrderId: context.sfOrderId, errorCode, @@ -903,49 +333,13 @@ export class OrderFulfillmentOrchestrator { updateError: updateError instanceof Error ? updateError.message : String(updateError), }); } - } - /** - * Get fulfillment summary from context - */ - getFulfillmentSummary(context: OrderFulfillmentContext): { - success: boolean; - status: "Already Fulfilled" | "Fulfilled" | "Failed"; - whmcsOrderId?: string; - whmcsServiceIds?: number[]; - message: string; - steps: OrderFulfillmentStep[]; - } { - const isSuccess = context.steps.every((s: OrderFulfillmentStep) => s.status === "completed"); - const failedStep = context.steps.find((s: OrderFulfillmentStep) => s.status === "failed"); - - if (context.validation?.isAlreadyProvisioned) { - return { - success: true, - status: "Already Fulfilled", - whmcsOrderId: context.validation.whmcsOrderId, - message: "Order was already fulfilled in WHMCS", - steps: context.steps, - }; - } - - if (isSuccess) { - return { - success: true, - status: "Fulfilled", - whmcsOrderId: context.whmcsResult?.orderId.toString(), - whmcsServiceIds: context.whmcsResult?.serviceIds, - message: "Order fulfilled successfully in WHMCS", - steps: context.steps, - }; - } - - return { - success: false, - status: "Failed", - message: failedStep?.error || "Fulfillment failed", - steps: context.steps, - }; + // Publish failure events and notifications + await this.sideEffects.onFulfillmentFailed( + sfOrderId, + context.validation?.sfOrder?.AccountId, + error.message + ); } private updateStepStatus( @@ -961,30 +355,16 @@ export class OrderFulfillmentOrchestrator { if (status === "in_progress") { step.status = "in_progress"; step.startedAt = timestamp; - step.error = undefined; + delete step.error; return; } step.status = status; step.completedAt = timestamp; - step.error = status === "failed" ? error : undefined; - } - - private createTrackedStep( - context: OrderFulfillmentContext, - stepName: string, - executor: () => Promise - ): () => Promise { - 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; - } - }; + if (status === "failed" && error) { + step.error = error; + } else { + delete step.error; + } } } diff --git a/apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts b/apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts index f4417f6d..377e3293 100644 --- a/apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts +++ b/apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts @@ -1,13 +1,15 @@ import { Injectable, BadRequestException, Inject } from "@nestjs/common"; 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 { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; 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 type { SalesforceOrderRecord } from "@customer-portal/domain/orders/providers"; import { PaymentValidatorService } from "./payment-validator.service.js"; /** @@ -18,7 +20,7 @@ import { PaymentValidatorService } from "./payment-validator.service.js"; export class OrderFulfillmentValidator { constructor( @Inject(Logger) private readonly logger: Logger, - private readonly salesforceService: SalesforceService, + private readonly salesforceFacade: SalesforceFacade, private readonly salesforceAccountService: SalesforceAccountService, private readonly mappingsService: MappingsService, private readonly paymentValidator: PaymentValidatorService @@ -129,7 +131,7 @@ export class OrderFulfillmentValidator { * Validate Salesforce order exists and is in valid state */ private async validateSalesforceOrder(sfOrderId: string): Promise { - const order = await this.salesforceService.getOrder(sfOrderId); + const order = await this.salesforceFacade.getOrder(sfOrderId); if (!order) { throw new BadRequestException(`Salesforce order ${sfOrderId} not found`); @@ -175,8 +177,8 @@ export class OrderFulfillmentValidator { return null; } - const clientId = parseInt(match[1], 10); - if (isNaN(clientId) || clientId <= 0) { + const clientId = Number.parseInt(match[1], 10); + if (Number.isNaN(clientId) || clientId <= 0) { return null; } diff --git a/apps/bff/src/modules/orders/services/sim-fulfillment.service.ts b/apps/bff/src/modules/orders/services/sim-fulfillment.service.ts index e3510eb4..e0ca48ab 100644 --- a/apps/bff/src/modules/orders/services/sim-fulfillment.service.ts +++ b/apps/bff/src/modules/orders/services/sim-fulfillment.service.ts @@ -1,8 +1,6 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; -import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service.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 { FreebitFacade } from "@bff/integrations/freebit/facades/freebit.facade.js"; import { SalesforceSIMInventoryService } from "@bff/integrations/salesforce/services/salesforce-sim-inventory.service.js"; import type { OrderDetails, OrderItemDetails } from "@customer-portal/domain/orders"; import { mapProductToFreebitPlanCode } from "@customer-portal/domain/sim"; @@ -56,9 +54,7 @@ export interface SimFulfillmentResult { @Injectable() export class SimFulfillmentService { constructor( - private readonly freebit: FreebitOrchestratorService, - private readonly freebitAccountReg: FreebitAccountRegistrationService, - private readonly freebitVoiceOptions: FreebitVoiceOptionsService, + private readonly freebitFacade: FreebitFacade, private readonly simInventory: SalesforceSIMInventoryService, @Inject(Logger) private readonly logger: Logger ) {} @@ -73,7 +69,7 @@ export class SimFulfillmentService { contactIdentity, } = 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", { orderId: orderDetails.id, @@ -91,16 +87,16 @@ export class SimFulfillmentService { "SIM Type must be explicitly set to 'eSIM' or 'Physical SIM'", { orderId: orderDetails.id, - configuredSimType: configurations.simType, + configuredSimType: configurations["simType"], } ); } - const eid = this.readString(configurations.eid); + const eid = this.readString(configurations["eid"]); const activationType = - this.readEnum(configurations.activationType, ["Immediate", "Scheduled"]) ?? "Immediate"; - const scheduledAt = this.readString(configurations.scheduledAt); - const phoneNumber = this.readString(configurations.mnpPhone); + this.readEnum(configurations["activationType"], ["Immediate", "Scheduled"]) ?? "Immediate"; + const scheduledAt = this.readString(configurations["scheduledAt"]); + const phoneNumber = this.readString(configurations["mnpPhone"]); const mnp = this.extractMnpConfig(configurations); const simPlanItem = orderDetails.items.find( @@ -144,8 +140,8 @@ export class SimFulfillmentService { eid, planSku, activationType, - scheduledAt, - mnp, + ...(scheduledAt && { scheduledAt }), + ...(mnp && { mnp }), }); this.logger.log("eSIM fulfillment completed successfully", { @@ -160,7 +156,7 @@ export class SimFulfillmentService { phoneNumber, }; } else { - // Physical SIM activation flow (PA02-01 + PA05-05) + // Physical SIM activation flow (PA05-18 + PA02-01 + PA05-05) if (!assignedPhysicalSimId) { throw new SimActivationException( "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; try { - await this.freebit.activateEsimAccountNew({ + await this.freebitFacade.activateEsimAccountNew({ account, eid, planCode: planSku, contractLine: "5G", - shipDate: activationType === "Scheduled" ? scheduledAt : undefined, - mnp: - mnp && mnp.reserveNumber && mnp.reserveExpireDate - ? { - reserveNumber: mnp.reserveNumber, - reserveExpireDate: mnp.reserveExpireDate, - } - : undefined, - identity: mnp - ? { - firstnameKanji: mnp.firstnameKanji, - lastnameKanji: mnp.lastnameKanji, - firstnameZenKana: mnp.firstnameZenKana, - lastnameZenKana: mnp.lastnameZenKana, - gender: mnp.gender, - birthday: mnp.birthday, - } - : undefined, + ...(activationType === "Scheduled" && scheduledAt && { shipDate: scheduledAt }), + ...(mnp?.reserveNumber && + mnp?.reserveExpireDate && { + mnp: { + reserveNumber: mnp.reserveNumber, + reserveExpireDate: mnp.reserveExpireDate, + }, + }), + ...(mnp && { + identity: { + ...(mnp.firstnameKanji && { firstnameKanji: mnp.firstnameKanji }), + ...(mnp.lastnameKanji && { lastnameKanji: mnp.lastnameKanji }), + ...(mnp.firstnameZenKana && { firstnameZenKana: mnp.firstnameZenKana }), + ...(mnp.lastnameZenKana && { lastnameZenKana: mnp.lastnameZenKana }), + ...(mnp.gender && { gender: mnp.gender }), + ...(mnp.birthday && { birthday: mnp.birthday }), + }, + }), }); 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: * 1. Fetch SIM Inventory details from Salesforce * 2. Validate SIM status is "Available" * 3. Map product SKU to Freebit plan code - * 4. Call Freebit PA02-01 (Account Registration) with createType="new" - * 5. Call Freebit PA05-05 (Voice Options) to configure voice features - * 6. Update SIM Inventory status to "Used" + * 4. Call Freebit PA05-18 (Semi-Black Registration) - MUST be called first! + * 5. Call Freebit PA02-01 (Account Registration) with createType="add" + * 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: { orderId: string; simInventoryId: string; planSku: string; - planName?: string; + planName?: string | undefined; voiceMailEnabled: boolean; callWaitingEnabled: boolean; - contactIdentity?: ContactIdentityData; + contactIdentity?: ContactIdentityData | undefined; }): Promise<{ phoneNumber: string; serialNumber: string }> { const { orderId, @@ -292,7 +292,7 @@ export class SimFulfillmentService { contactIdentity, } = 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, simInventoryId, planSku, @@ -315,27 +315,50 @@ export class SimFulfillmentService { // Use phone number from SIM inventory const accountPhoneNumber = simRecord.phoneNumber; + // PT Number is the productNumber required for PA05-18 + const productNumber = simRecord.ptNumber; this.logger.log("Physical SIM inventory validated", { orderId, simInventoryId, accountPhoneNumber, - ptNumber: simRecord.ptNumber, + productNumber, planCode, }); 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", { orderId, account: accountPhoneNumber, planCode, + createType: "add", }); - await this.freebitAccountReg.registerAccount({ + await this.freebitFacade.registerAccount({ account: accountPhoneNumber, planCode, - createType: "new", + createType: "add", }); this.logger.log("PA02-01 Account Registration successful", { @@ -343,7 +366,7 @@ export class SimFulfillmentService { 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 if (contactIdentity) { this.logger.log("Calling PA05-05 Voice Options Registration", { @@ -353,7 +376,7 @@ export class SimFulfillmentService { callWaitingEnabled, }); - await this.freebitVoiceOptions.registerVoiceOptions({ + await this.freebitFacade.registerVoiceOptions({ account: accountPhoneNumber, voiceMailEnabled, 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); this.logger.log("Physical SIM activated successfully", { @@ -415,28 +438,28 @@ export class SimFulfillmentService { } private extractMnpConfig(config: Record) { - const nested = config.mnp; + const nested = config["mnp"]; const source = nested && typeof nested === "object" ? (nested as Record) : config; - const isMnpFlag = this.readString(source.isMnp ?? config.isMnp); + const isMnpFlag = this.readString(source["isMnp"] ?? config["isMnp"]); if (isMnpFlag && isMnpFlag !== "true") { - return undefined; + return; } - const reserveNumber = this.readString(source.mnpNumber ?? source.reserveNumber); - const reserveExpireDate = this.readString(source.mnpExpiry ?? source.reserveExpireDate); - const account = this.readString(source.mvnoAccountNumber ?? source.account); - const firstnameKanji = this.readString(source.portingFirstName ?? source.firstnameKanji); - const lastnameKanji = this.readString(source.portingLastName ?? source.lastnameKanji); + const reserveNumber = this.readString(source["mnpNumber"] ?? source["reserveNumber"]); + const reserveExpireDate = this.readString(source["mnpExpiry"] ?? source["reserveExpireDate"]); + const account = this.readString(source["mvnoAccountNumber"] ?? source["account"]); + const firstnameKanji = this.readString(source["portingFirstName"] ?? source["firstnameKanji"]); + const lastnameKanji = this.readString(source["portingLastName"] ?? source["lastnameKanji"]); const firstnameZenKana = this.readString( - source.portingFirstNameKatakana ?? source.firstnameZenKana + source["portingFirstNameKatakana"] ?? source["firstnameZenKana"] ); const lastnameZenKana = this.readString( - source.portingLastNameKatakana ?? source.lastnameZenKana + source["portingLastNameKatakana"] ?? source["lastnameZenKana"] ); - const gender = this.readString(source.portingGender ?? source.gender); - const birthday = this.readString(source.portingDateOfBirth ?? source.birthday); + const gender = this.readString(source["portingGender"] ?? source["gender"]); + const birthday = this.readString(source["portingDateOfBirth"] ?? source["birthday"]); if ( !reserveNumber && @@ -449,19 +472,20 @@ export class SimFulfillmentService { !gender && !birthday ) { - return undefined; + return; } + // Build object with only defined properties (for exactOptionalPropertyTypes) return { - reserveNumber, - reserveExpireDate, - account, - firstnameKanji, - lastnameKanji, - firstnameZenKana, - lastnameZenKana, - gender, - birthday, + ...(reserveNumber && { reserveNumber }), + ...(reserveExpireDate && { reserveExpireDate }), + ...(account && { account }), + ...(firstnameKanji && { firstnameKanji }), + ...(lastnameKanji && { lastnameKanji }), + ...(firstnameZenKana && { firstnameZenKana }), + ...(lastnameZenKana && { lastnameZenKana }), + ...(gender && { gender }), + ...(birthday && { birthday }), }; } } diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/queries/sim-billing.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/queries/sim-billing.service.ts index 50327120..341585c7 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/queries/sim-billing.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/queries/sim-billing.service.ts @@ -58,15 +58,15 @@ export class SimBillingService { description, amount: amountJpy, currency, - dueDate, - notes, + ...(dueDate && { dueDate }), + ...(notes && { notes }), }); const paymentResult = await this.whmcsInvoiceService.capturePayment({ invoiceId: invoice.id, amount: amountJpy, currency, - userId, + ...(userId && { userId }), }); if (!paymentResult.success) { @@ -127,7 +127,7 @@ export class SimBillingService { return { invoice, - transactionId: paymentResult.transactionId, + ...(paymentResult.transactionId && { transactionId: paymentResult.transactionId }), refunded, }; } diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-validation.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-validation.service.ts index 022d7ecb..fc5e25eb 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-validation.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-validation.service.ts @@ -1,6 +1,6 @@ import { Injectable, Inject, BadRequestException } from "@nestjs/common"; 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 type { SimValidationResult } from "../interfaces/sim-base.interface.js"; import { @@ -12,7 +12,7 @@ import { @Injectable() export class SimValidationService { constructor( - private readonly subscriptionsService: SubscriptionsService, + private readonly subscriptionsService: SubscriptionsOrchestrator, @Inject(Logger) private readonly logger: Logger ) {} @@ -42,21 +42,16 @@ export class SimValidationService { // If no account found, log detailed info and throw error if (!account) { - this.logger.error( - `No SIM account identifier found for subscription ${subscriptionId}`, - { - userId, - subscriptionId, - productName: subscription.productName, - domain: subscription.domain, - customFieldKeys: subscription.customFields - ? Object.keys(subscription.customFields) - : [], - customFieldValues: subscription.customFields, - orderNumber: subscription.orderNumber, - note: "Set the phone number in 'domain' field or a custom field named 'phone', 'msisdn', 'Phone Number', etc.", - } - ); + this.logger.error(`No SIM account identifier found for subscription ${subscriptionId}`, { + userId, + subscriptionId, + productName: subscription.productName, + domain: subscription.domain, + customFieldKeys: subscription.customFields ? Object.keys(subscription.customFields) : [], + customFieldValues: subscription.customFields, + orderNumber: subscription.orderNumber, + note: "Set the phone number in 'domain' field or a custom field named 'phone', 'msisdn', 'Phone Number', etc.", + }); 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.)` @@ -119,9 +114,9 @@ export class SimValidationService { // All custom fields for debugging customFieldKeys: Object.keys(subscription.customFields || {}), customFields: subscription.customFields, - hint: !extractedAccount - ? "Set phone number in 'domain' field OR add custom field named: phone, msisdn, phonenumber, Phone Number, MSISDN, etc." - : undefined, + hint: extractedAccount + ? undefined + : "Set phone number in 'domain' field OR add custom field named: phone, msisdn, phonenumber, Phone Number, MSISDN, etc.", }; } catch (error) { const sanitizedError = extractErrorMessage(error); @@ -131,28 +126,4 @@ export class SimValidationService { 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 ""; - } } diff --git a/apps/bff/src/modules/subscriptions/sim-management/sim.controller.ts b/apps/bff/src/modules/subscriptions/sim-management/sim.controller.ts index d2d7d429..8a930574 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/sim.controller.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/sim.controller.ts @@ -9,12 +9,12 @@ import { Header, UseGuards, } from "@nestjs/common"; -import { SimManagementService } from "../sim-management.service.js"; -import { SimTopUpPricingService } from "./services/sim-topup-pricing.service.js"; -import { SimPlanService } from "./services/sim-plan.service.js"; -import { SimCancellationService } from "./services/sim-cancellation.service.js"; -import { EsimManagementService } from "./services/esim-management.service.js"; -import { FreebitRateLimiterService } from "@bff/integrations/freebit/services/freebit-rate-limiter.service.js"; +import { SimOrchestrator } from "./services/sim-orchestrator.service.js"; +import { SimTopUpPricingService } from "./services/queries/sim-topup-pricing.service.js"; +import { SimPlanService } from "./services/mutations/sim-plan.service.js"; +import { SimCancellationService } from "./services/mutations/sim-cancellation.service.js"; +import { EsimManagementService } from "./services/mutations/esim-management.service.js"; +import { FreebitFacade } from "@bff/integrations/freebit/facades/freebit.facade.js"; import { AdminGuard } from "@bff/core/security/guards/admin.guard.js"; import { createZodDto, ZodResponse } from "nestjs-zod"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; @@ -26,12 +26,11 @@ import { } from "@customer-portal/domain/subscriptions"; import type { SimActionResponse, SimPlanChangeResult } from "@customer-portal/domain/subscriptions"; import { - simTopupRequestSchema, - simChangePlanRequestSchema, - simCancelRequestSchema, - simFeaturesRequestSchema, - simTopUpHistoryRequestSchema, + simTopUpRequestSchema, simChangePlanFullRequestSchema, + simCancelRequestSchema, + simFeaturesUpdateRequestSchema, + simTopUpHistoryRequestSchema, simCancelFullRequestSchema, simReissueFullRequestSchema, simTopUpPricingSchema, @@ -50,10 +49,10 @@ import { // DTOs class SubscriptionIdParamDto extends createZodDto(subscriptionIdParamSchema) {} -class SimTopupRequestDto extends createZodDto(simTopupRequestSchema) {} -class SimChangePlanRequestDto extends createZodDto(simChangePlanRequestSchema) {} +class SimTopupRequestDto extends createZodDto(simTopUpRequestSchema) {} +class SimChangePlanRequestDto extends createZodDto(simChangePlanFullRequestSchema) {} class SimCancelRequestDto extends createZodDto(simCancelRequestSchema) {} -class SimFeaturesRequestDto extends createZodDto(simFeaturesRequestSchema) {} +class SimFeaturesRequestDto extends createZodDto(simFeaturesUpdateRequestSchema) {} class SimChangePlanFullRequestDto extends createZodDto(simChangePlanFullRequestSchema) {} class SimCancelFullRequestDto extends createZodDto(simCancelFullRequestSchema) {} class SimReissueFullRequestDto extends createZodDto(simReissueFullRequestSchema) {} @@ -78,12 +77,12 @@ class SimCancellationPreviewResponseDto extends createZodDto(simCancellationPrev @Controller("subscriptions") export class SimController { constructor( - private readonly simManagementService: SimManagementService, + private readonly simOrchestrator: SimOrchestrator, private readonly simTopUpPricingService: SimTopUpPricingService, private readonly simPlanService: SimPlanService, private readonly simCancellationService: SimCancellationService, private readonly esimManagementService: EsimManagementService, - private readonly rateLimiter: FreebitRateLimiterService + private readonly freebitFacade: FreebitFacade ) {} // ==================== Static SIM Routes (must be before :id routes) ==================== @@ -110,13 +109,13 @@ export class SimController { @Get("debug/sim-details/:account") @UseGuards(AdminGuard) 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") @UseGuards(AdminGuard) async clearRateLimit(@Param("account") account: string) { - await this.rateLimiter.clearRateLimitForAccount(account); + await this.freebitFacade.clearRateLimitForAccount(account); return { message: `Rate limit cleared for account ${account}` }; } @@ -128,25 +127,25 @@ export class SimController { @Request() req: RequestWithUser, @Param() params: SubscriptionIdParamDto ): Promise> { - return this.simManagementService.debugSimSubscription(req.user.id, params.id); + return this.simOrchestrator.debugSimSubscription(req.user.id, params.id); } @Get(":id/sim") @ZodResponse({ description: "Get SIM info", type: SimInfoDto }) 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") @ZodResponse({ description: "Get SIM details", type: SimDetailsDto }) 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") @ZodResponse({ description: "Get SIM usage", type: SimUsageDto }) 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") @@ -156,7 +155,7 @@ export class SimController { @Param() params: SubscriptionIdParamDto, @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") @@ -166,7 +165,7 @@ export class SimController { @Param() params: SubscriptionIdParamDto, @Body() body: SimTopupRequestDto ): Promise { - 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" }; } @@ -177,7 +176,7 @@ export class SimController { @Param() params: SubscriptionIdParamDto, @Body() body: SimChangePlanRequestDto ): Promise { - 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 { message: "SIM plan change completed successfully", ...result, @@ -191,7 +190,7 @@ export class SimController { @Param() params: SubscriptionIdParamDto, @Body() body: SimCancelRequestDto ): Promise { - 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" }; } @@ -202,7 +201,7 @@ export class SimController { @Param() params: SubscriptionIdParamDto, @Body() body: SimReissueEsimRequestDto ): Promise { - 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" }; } @@ -213,7 +212,7 @@ export class SimController { @Param() params: SubscriptionIdParamDto, @Body() body: SimFeaturesRequestDto ): Promise { - 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" }; } diff --git a/apps/portal/src/app/(public)/(site)/auth/migrate/page.tsx b/apps/portal/src/app/(public)/(site)/auth/migrate/page.tsx deleted file mode 100644 index 67803013..00000000 --- a/apps/portal/src/app/(public)/(site)/auth/migrate/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from "next/navigation"; - -export default function MigrateAccountPage() { - redirect("/auth/get-started"); -} diff --git a/apps/portal/src/app/(public)/(site)/auth/signup/page.tsx b/apps/portal/src/app/(public)/(site)/auth/signup/page.tsx deleted file mode 100644 index c41a43de..00000000 --- a/apps/portal/src/app/(public)/(site)/auth/signup/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from "next/navigation"; - -export default function SignupPage() { - redirect("/auth/get-started"); -} diff --git a/apps/portal/src/app/(public)/(site)/services/internet/configure/loading.tsx b/apps/portal/src/app/(public)/(site)/services/internet/configure/loading.tsx deleted file mode 100644 index dc4bbab6..00000000 --- a/apps/portal/src/app/(public)/(site)/services/internet/configure/loading.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Skeleton } from "@/components/atoms/loading-skeleton"; - -export default function InternetConfigureLoading() { - return ( -
- {/* Steps indicator */} -
- {Array.from({ length: 4 }).map((_, i) => ( -
- - {i < 3 && } -
- ))} -
- - {/* Title */} -
- - -
- - {/* Form card */} -
-
- {/* Plan options */} -
- {Array.from({ length: 3 }).map((_, i) => ( -
- -
- - -
- -
- ))} -
- - {/* Form fields */} -
-
- - -
-
- - -
-
- - {/* Button */} - -
-
-
- ); -} diff --git a/apps/portal/src/app/(public)/(site)/services/internet/configure/page.tsx b/apps/portal/src/app/(public)/(site)/services/internet/configure/page.tsx deleted file mode 100644 index badebabc..00000000 --- a/apps/portal/src/app/(public)/(site)/services/internet/configure/page.tsx +++ /dev/null @@ -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 ( - <> - - - - ); -} diff --git a/apps/portal/src/app/(public)/(site)/services/page.tsx b/apps/portal/src/app/(public)/(site)/services/page.tsx index 75a25c80..31c2a801 100644 --- a/apps/portal/src/app/(public)/(site)/services/page.tsx +++ b/apps/portal/src/app/(public)/(site)/services/page.tsx @@ -1,5 +1,5 @@ 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 = { title: "Services for Expats in Japan - Internet, Mobile, VPN & IT Support | Assist Solutions", @@ -20,25 +20,10 @@ export const metadata: Metadata = { }, }; -interface ServicesPageProps { - basePath?: string; -} - -export default function ServicesPage({ basePath = "/services" }: ServicesPageProps) { +export default function ServicesPage() { return ( -
- {/* Header */} -
-

- Services for Expats in Japan -

-

- Tired of navigating Japanese-only websites and contracts? We provide internet, mobile, and - IT services with full English support. No Japanese required. -

-
- - +
+
); } diff --git a/apps/portal/src/app/layout.tsx b/apps/portal/src/app/layout.tsx index 4762b115..68350fa2 100644 --- a/apps/portal/src/app/layout.tsx +++ b/apps/portal/src/app/layout.tsx @@ -11,7 +11,7 @@ export const metadata: Metadata = { }, description: "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: { canonical: "/", }, diff --git a/apps/portal/src/app/robots.ts b/apps/portal/src/app/robots.ts index a6157906..8ecc747b 100644 --- a/apps/portal/src/app/robots.ts +++ b/apps/portal/src/app/robots.ts @@ -7,7 +7,7 @@ import type { MetadataRoute } from "next"; * Allows all public pages, blocks account/authenticated areas. */ 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 { rules: [ diff --git a/apps/portal/src/app/sitemap.ts b/apps/portal/src/app/sitemap.ts index 277de3e3..8f034eec 100644 --- a/apps/portal/src/app/sitemap.ts +++ b/apps/portal/src/app/sitemap.ts @@ -7,7 +7,7 @@ import type { MetadataRoute } from "next"; * Only includes public pages that should be indexed. */ 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 const publicPages = [ diff --git a/apps/portal/src/components/templates/PublicShell/PublicShell.tsx b/apps/portal/src/components/templates/PublicShell/PublicShell.tsx index c5de3dc6..57e22643 100644 --- a/apps/portal/src/components/templates/PublicShell/PublicShell.tsx +++ b/apps/portal/src/components/templates/PublicShell/PublicShell.tsx @@ -278,7 +278,7 @@ export function PublicShell({ children }: PublicShellProps) { EN
- {/* Auth Button - Desktop */} + {/* Auth Buttons - Desktop */} {isAuthenticated ? ( ) : ( - - Sign in - +
+ + Sign in + + + Get Started + +
)} {/* Mobile Menu Button */} @@ -361,7 +369,7 @@ export function PublicShell({ children }: PublicShellProps) { -
+
{isAuthenticated ? ( ) : ( - - Sign in - + <> + + Get Started + + + Sign in + + )}
diff --git a/apps/portal/src/features/auth/components/LoginForm/LoginForm.tsx b/apps/portal/src/features/auth/components/LoginForm/LoginForm.tsx index 51961e0a..f3542437 100644 --- a/apps/portal/src/features/auth/components/LoginForm/LoginForm.tsx +++ b/apps/portal/src/features/auth/components/LoginForm/LoginForm.tsx @@ -235,10 +235,10 @@ export function LoginForm({

Don't have an account?{" "} - Sign up + Get started

diff --git a/apps/portal/src/features/landing-page/views/PublicLandingView.tsx b/apps/portal/src/features/landing-page/views/PublicLandingView.tsx index 07c497f1..8ae3395f 100644 --- a/apps/portal/src/features/landing-page/views/PublicLandingView.tsx +++ b/apps/portal/src/features/landing-page/views/PublicLandingView.tsx @@ -24,7 +24,7 @@ import { AlertCircle, Check, } from "lucide-react"; -import { Spinner } from "@/components/atoms/Spinner"; +import { Spinner } from "@/components/atoms"; import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; // ============================================================================= @@ -45,7 +45,7 @@ function useInView(options: IntersectionObserverInit = {}) { const observer = new IntersectionObserver( ([entry]) => { - if (entry.isIntersecting) { + if (entry?.isIntersecting) { setIsInView(true); observer.disconnect(); // triggerOnce } @@ -87,6 +87,14 @@ interface FormErrors { message?: string; } +interface FormTouched { + subject?: boolean; + name?: boolean; + email?: boolean; + phone?: boolean; + message?: boolean; +} + const CONTACT_SUBJECTS = [ { value: "", label: "Select a topic*" }, { value: "internet", label: "Internet Service Inquiry" }, @@ -277,7 +285,7 @@ export function PublicLandingView() { message: "", }); const [formErrors, setFormErrors] = useState({}); - const [formTouched, setFormTouched] = useState>({}); + const [formTouched, setFormTouched] = useState({}); const [isSubmitting, setIsSubmitting] = useState(false); const [submitStatus, setSubmitStatus] = useState<"idle" | "success" | "error">("idle"); @@ -366,12 +374,18 @@ export function PublicLandingView() { // Touch swipe handlers const handleTouchStart = useCallback((e: React.TouchEvent) => { - touchStartXRef.current = e.touches[0].clientX; - touchEndXRef.current = e.touches[0].clientX; + const touch = e.touches[0]; + if (touch) { + touchStartXRef.current = touch.clientX; + touchEndXRef.current = touch.clientX; + } }, []); 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(() => { @@ -437,7 +451,9 @@ export function PublicLandingView() { try { // Simulate API call - replace with actual endpoint - await new Promise(resolve => setTimeout(resolve, 1500)); + await new Promise(resolve => { + setTimeout(resolve, 1500); + }); // Success setSubmitStatus("success"); @@ -495,7 +511,9 @@ export function PublicLandingView() { const observer = new IntersectionObserver( ([entry]) => { - setShowStickyCTA(!entry.isIntersecting); + if (entry) { + setShowStickyCTA(!entry.isIntersecting); + } }, { threshold: 0 } ); @@ -1101,52 +1119,58 @@ export function PublicLandingView() {

{/* Tab content */} -
-
-
- {supportDownloads[remoteSupportTab].title} -
-
-

- {supportDownloads[remoteSupportTab].useCase} -

-

- {supportDownloads[remoteSupportTab].title} -

-

- {supportDownloads[remoteSupportTab].description} -

- - - { + const currentDownload = supportDownloads[remoteSupportTab]; + if (!currentDownload) return null; + return ( +
+
+
+ {currentDownload.title} - - Download Now - - +
+
+

+ {currentDownload.useCase} +

+

+ {currentDownload.title} +

+

+ {currentDownload.description} +

+ + + + + Download Now + + +
+
-
-
+ ); + })()}
diff --git a/apps/portal/src/features/services/views/PublicInternetConfigure.tsx b/apps/portal/src/features/services/views/PublicInternetConfigure.tsx deleted file mode 100644 index 5db3a23d..00000000 --- a/apps/portal/src/features/services/views/PublicInternetConfigure.tsx +++ /dev/null @@ -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 ( -
- -
- - -
-
- ); - } - - return ( -
- - - {/* Header */} -
-
-
- -
-
-

- Check Internet Service Availability -

-

- Create an account to see what's available at your address -

-
- - {/* Plan Summary Card - only if plan is selected */} - {plan && ( -
-
-
- -
-
-
-
-

Selected plan

-

{plan.name}

-
- -
-
-
-
- )} - - {/* Auth Section - Primary focus */} - - - {/* What happens next - Below auth, secondary info */} -
-

What happens next

-
-
-
- 1 -
-
-

We verify your address

-

- - 1-2 business days -

-
-
-
-
- 2 -
-
-

You get notified

-

- - Email when ready -

-
-
-
-
- 3 -
-
-

Complete your order

-

- - Choose plan & schedule -

-
-
-
-
-
- ); -} - -export default PublicInternetConfigureView; diff --git a/apps/portal/src/features/services/views/PublicInternetPlans.tsx b/apps/portal/src/features/services/views/PublicInternetPlans.tsx index 7d1f0663..76b5d9fe 100644 --- a/apps/portal/src/features/services/views/PublicInternetPlans.tsx +++ b/apps/portal/src/features/services/views/PublicInternetPlans.tsx @@ -751,7 +751,7 @@ export function PublicInternetPlansContent({ // Simple loading check: show skeleton until we have data or an error const isLoading = !servicesCatalog && !error; const servicesBasePath = useServicesBasePath(); - const defaultCtaPath = `${servicesBasePath}/internet/configure`; + const defaultCtaPath = `${servicesBasePath}/internet/check-availability`; const ctaPath = propCtaPath ?? defaultCtaPath; const internetFeatures: HighlightFeature[] = [ @@ -835,7 +835,7 @@ export function PublicInternetPlansContent({ maxMonthlyPrice: data.maxPrice, description: getTierDescription(tier), features: getTierFeatures(tier), - pricingNote: tier === "Platinum" ? "+ equipment fees" : undefined, + ...(tier === "Platinum" && { pricingNote: "+ equipment fees" }), })); return { @@ -892,8 +892,25 @@ export function PublicInternetPlansContent({ // Ensure all three offering types exist with default data 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] = { minPrice: defaults.silver, tierData: { @@ -902,21 +919,6 @@ export function PublicInternetPlansContent({ 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, description: getTierDescription(tier), features: getTierFeatures(tier), - pricingNote: tier === "Platinum" ? "+ equipment fees" : undefined, + ...(tier === "Platinum" && { pricingNote: "+ equipment fees" }), })); result[offeringType] = { @@ -983,7 +985,7 @@ export function PublicInternetPlansContent({ tiers={consolidatedPlanData.tiers} ctaPath={ctaPath} ctaLabel={ctaLabel} - onCtaClick={onCtaClick} + {...(onCtaClick && { onCtaClick })} /> ) : null} diff --git a/apps/portal/src/features/subscriptions/components/sim/SimManagementSection.tsx b/apps/portal/src/features/subscriptions/components/sim/SimManagementSection.tsx index dd65f029..adcec8df 100644 --- a/apps/portal/src/features/subscriptions/components/sim/SimManagementSection.tsx +++ b/apps/portal/src/features/subscriptions/components/sim/SimManagementSection.tsx @@ -187,7 +187,8 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro const msg = err.message.toLowerCase(); // Check for rate limiting errors 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")) { errorMessage = "Another operation is in progress. Please wait a moment."; } else { @@ -316,28 +317,28 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro label="Voice Mail" subtitle={simInfo.details.voiceMailEnabled ? "Enabled" : "Disabled"} checked={simInfo.details.voiceMailEnabled || false} - loading={featureLoading.voiceMail} + loading={featureLoading.voiceMail ?? false} onChange={checked => void updateFeature("voiceMail", checked)} /> void updateFeature("networkType", checked ? "5G" : "4G")} /> void updateFeature("callWaiting", checked)} /> void updateFeature("internationalRoaming", checked)} /> @@ -469,7 +470,14 @@ type StatusToggleProps = { 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 handleClick = () => { @@ -479,13 +487,17 @@ function StatusToggle({ label, subtitle, checked, onChange, loading, disabled }: }; return ( -
+

{label}

{subtitle &&

{subtitle}

}
-
diff --git a/apps/portal/src/features/support/views/PublicContactView.tsx b/apps/portal/src/features/support/views/PublicContactView.tsx index efdb6a6a..29d81178 100644 --- a/apps/portal/src/features/support/views/PublicContactView.tsx +++ b/apps/portal/src/features/support/views/PublicContactView.tsx @@ -264,7 +264,7 @@ export function PublicContactView() {
- + form.setValue("phone", e.target.value)} @@ -305,7 +308,7 @@ export function PublicContactView() {