refactor: simplify order fulfillment and remove unused public pages

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

View File

@ -130,15 +130,15 @@ export class CacheService {
if (!results) {
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<void> {
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");

View File

@ -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);
}
}

View File

@ -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);
});

View File

@ -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<void> {
const normalizedAccount = this.normalizeAccount(params.account);
return this.semiBlackService.registerSemiBlackAccount({
...params,
account: normalizedAccount,
});
}
/**
* Register an MVNO account (PA02-01)
*
* For Physical SIMs, call PA05-18 first, then call this with createType="add".
* For other cases, call this directly with createType="new".
*/
async registerAccount(params: {
account: string;
planCode: string;
createType?: "new" | "add";
}): Promise<void> {
const normalizedAccount = this.normalizeAccount(params.account);
return this.accountRegistrationService.registerAccount({
...params,
account: normalizedAccount,
});
}
/**
* Register voice options for an MVNO account (PA05-05)
*
* This configures voice features for a phone number that was previously
* registered via PA02-01. Must be called after account registration.
*/
async registerVoiceOptions(params: {
account: string;
voiceMailEnabled: boolean;
callWaitingEnabled: boolean;
identificationData: VoiceOptionIdentityData;
}): Promise<void> {
const normalizedAccount = this.normalizeAccount(params.account);
return this.voiceOptionsService.registerVoiceOptions({
...params,
account: normalizedAccount,
});
}
// ============================================================================
// Debug Operations (admin-only)
// ============================================================================
/**
* Clear rate limit state for an account (for debugging/testing)
*/
async clearRateLimitForAccount(account: string): Promise<void> {
const normalizedAccount = this.normalizeAccount(account);
return this.rateLimiterService.clearRateLimitForAccount(normalizedAccount);
}
}

View File

@ -125,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)", {

View File

@ -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

View File

@ -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<void> {
const payloadObj = payload as Record<string, unknown>;
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,
});
}

View File

@ -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 {

View File

@ -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<number, string> = {
201: "Invalid account/phone number parameter",
202: "Invalid master password",
204: "Invalid parameter",
205: "Authentication key error",
208: "Account already exists (duplicate)",
210: "Master account not found",
211: "Account status does not allow this operation",
215: "Invalid plan code",
228: "Invalid authentication key",
230: "Account is in async processing queue",
231: "Invalid global IP parameter",
232: "Plan not found",
266: "Invalid product number (manufacturing number)",
269: "Invalid representative number",
274: "Invalid delivery code",
275: "No phone number stock for representative number",
276: "Invalid ship date",
279: "Invalid create type",
284: "Representative number is locked",
287: "Representative number does not exist or is unavailable",
288: "Product number does not exist, is already used, or not stocked",
289: "SIM type does not match representative number type",
306: "Invalid MNP method",
313: "MNP reservation expires within grace period",
900: "Unexpected system error",
};
return errorMessages[code] ?? defaultMessage ?? "Unknown error";
}
}

View File

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

View File

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

View File

@ -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");

View File

@ -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";

View File

@ -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,
};
}

View File

@ -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",
};
}

View File

@ -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<string, unknown>)["error"];
const errorMessage =
response.message ||
(response as Record<string, unknown>).error ||
(typeof rawError === "string" ? rawError : JSON.stringify(rawError ?? "")) ||
`Unknown error (result: ${response.result})`;
throw new WhmcsOperationException(`WHMCS invoice creation failed: ${errorMessage}`, {

View File

@ -159,7 +159,7 @@ export class WhmcsOrderService {
const serviceIds = this.parseDelimitedIds(parsedResponse.data.serviceids);
const 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", {

View File

@ -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) {

View File

@ -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,

View File

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

View File

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

View File

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

View File

@ -1,13 +1,15 @@
import { Injectable, BadRequestException, Inject } from "@nestjs/common";
import { 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<SalesforceOrderRecord> {
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;
}

View File

@ -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<string, unknown>) {
const nested = config.mnp;
const nested = config["mnp"];
const source =
nested && typeof nested === "object" ? (nested as Record<string, unknown>) : config;
const isMnpFlag = this.readString(source.isMnp ?? config.isMnp);
const isMnpFlag = this.readString(source["isMnp"] ?? config["isMnp"]);
if (isMnpFlag && isMnpFlag !== "true") {
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 }),
};
}
}

View File

@ -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,
};
}

View File

@ -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 "";
}
}

View File

@ -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<Record<string, unknown>> {
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<SimActionResponse> {
await this.simManagementService.topUpSim(req.user.id, params.id, body);
await this.simOrchestrator.topUpSim(req.user.id, params.id, body);
return { message: "SIM top-up completed successfully" };
}
@ -177,7 +176,7 @@ export class SimController {
@Param() params: SubscriptionIdParamDto,
@Body() body: SimChangePlanRequestDto
): Promise<SimPlanChangeResult> {
const result = await this.simManagementService.changeSimPlan(req.user.id, params.id, body);
const result = await this.simOrchestrator.changeSimPlan(req.user.id, params.id, body);
return {
message: "SIM plan change completed successfully",
...result,
@ -191,7 +190,7 @@ export class SimController {
@Param() params: SubscriptionIdParamDto,
@Body() body: SimCancelRequestDto
): Promise<SimActionResponse> {
await this.simManagementService.cancelSim(req.user.id, params.id, body);
await this.simOrchestrator.cancelSim(req.user.id, params.id, body);
return { message: "SIM cancellation completed successfully" };
}
@ -202,7 +201,7 @@ export class SimController {
@Param() params: SubscriptionIdParamDto,
@Body() body: SimReissueEsimRequestDto
): Promise<SimActionResponse> {
await this.simManagementService.reissueEsimProfile(req.user.id, params.id, body.newEid);
await this.simOrchestrator.reissueEsimProfile(req.user.id, params.id, body);
return { message: "eSIM profile reissue completed successfully" };
}
@ -213,7 +212,7 @@ export class SimController {
@Param() params: SubscriptionIdParamDto,
@Body() body: SimFeaturesRequestDto
): Promise<SimActionResponse> {
await this.simManagementService.updateSimFeatures(req.user.id, params.id, body);
await this.simOrchestrator.updateSimFeatures(req.user.id, params.id, body);
return { message: "SIM features updated successfully" };
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import type { Metadata } from "next";
import { 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 (
<div className="max-w-6xl mx-auto px-4">
{/* Header */}
<div className="text-center mb-16 pt-8">
<h1 className="text-4xl sm:text-5xl font-extrabold text-foreground mb-6 tracking-tight">
Services for Expats in Japan
</h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto leading-relaxed">
Tired of navigating Japanese-only websites and contracts? We provide internet, mobile, and
IT services with full English support. No Japanese required.
</p>
</div>
<ServicesGrid basePath={basePath} />
<div className="max-w-6xl mx-auto px-4 pt-8">
<ServicesOverviewContent basePath="/services" />
</div>
);
}

View File

@ -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: "/",
},

View File

@ -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: [

View File

@ -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 = [

View File

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

View File

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

View File

@ -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<FormErrors>({});
const [formTouched, setFormTouched] = useState<Record<string, boolean>>({});
const [formTouched, setFormTouched] = useState<FormTouched>({});
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() {
</div>
{/* Tab content */}
<div className="p-8 lg:p-12">
<div className="flex items-start gap-8 lg:gap-12">
<div className="h-32 w-32 lg:h-40 lg:w-40 rounded-2xl flex items-center justify-center flex-shrink-0 transition-colors bg-gradient-to-br from-sky-50 to-cyan-100">
<Image
src={supportDownloads[remoteSupportTab].image}
alt={supportDownloads[remoteSupportTab].title}
width={96}
height={96}
className="object-contain w-20 h-20 lg:w-24 lg:h-24"
/>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-bold uppercase tracking-wide mb-2 text-sky-600">
{supportDownloads[remoteSupportTab].useCase}
</p>
<h3 className="text-2xl lg:text-3xl font-bold text-foreground mb-4">
{supportDownloads[remoteSupportTab].title}
</h3>
<p className="text-base lg:text-lg text-muted-foreground leading-relaxed mb-6">
{supportDownloads[remoteSupportTab].description}
</p>
<Link
href={supportDownloads[remoteSupportTab].href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-full px-8 py-3.5 text-base font-semibold text-white transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg bg-gradient-to-r from-sky-500 to-cyan-600 hover:from-sky-600 hover:to-cyan-700 shadow-sky-200"
>
<svg
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
{(() => {
const currentDownload = supportDownloads[remoteSupportTab];
if (!currentDownload) return null;
return (
<div className="p-8 lg:p-12">
<div className="flex items-start gap-8 lg:gap-12">
<div className="h-32 w-32 lg:h-40 lg:w-40 rounded-2xl flex items-center justify-center flex-shrink-0 transition-colors bg-gradient-to-br from-sky-50 to-cyan-100">
<Image
src={currentDownload.image}
alt={currentDownload.title}
width={96}
height={96}
className="object-contain w-20 h-20 lg:w-24 lg:h-24"
/>
</svg>
Download Now
<ArrowRight className="h-4 w-4" />
</Link>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-bold uppercase tracking-wide mb-2 text-sky-600">
{currentDownload.useCase}
</p>
<h3 className="text-2xl lg:text-3xl font-bold text-foreground mb-4">
{currentDownload.title}
</h3>
<p className="text-base lg:text-lg text-muted-foreground leading-relaxed mb-6">
{currentDownload.description}
</p>
<Link
href={currentDownload.href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-full px-8 py-3.5 text-base font-semibold text-white transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg bg-gradient-to-r from-sky-500 to-cyan-600 hover:from-sky-600 hover:to-cyan-700 shadow-sky-200"
>
<svg
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
Download Now
<ArrowRight className="h-4 w-4" />
</Link>
</div>
</div>
</div>
</div>
</div>
);
})()}
</div>
</div>
</div>

View File

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

View File

@ -751,7 +751,7 @@ export function PublicInternetPlansContent({
// Simple loading check: show skeleton until we have data or an error
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}
</section>

View File

@ -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)}
/>
<StatusToggle
label="Network Type"
subtitle={simInfo.details.networkType === "5G" ? "5G" : "4G"}
checked={simInfo.details.networkType === "5G"}
loading={featureLoading.networkType}
loading={featureLoading.networkType ?? false}
onChange={checked => void updateFeature("networkType", checked ? "5G" : "4G")}
/>
<StatusToggle
label="Call Waiting"
subtitle={simInfo.details.callWaitingEnabled ? "Enabled" : "Disabled"}
checked={simInfo.details.callWaitingEnabled || false}
loading={featureLoading.callWaiting}
loading={featureLoading.callWaiting ?? false}
onChange={checked => void updateFeature("callWaiting", checked)}
/>
<StatusToggle
label="International Roaming"
subtitle={simInfo.details.internationalRoamingEnabled ? "Enabled" : "Disabled"}
checked={simInfo.details.internationalRoamingEnabled || false}
loading={featureLoading.internationalRoaming}
loading={featureLoading.internationalRoaming ?? false}
onChange={checked => void updateFeature("internationalRoaming", checked)}
/>
</div>
@ -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 (
<div className={`p-4 bg-card border border-border rounded-lg ${isDisabled ? "opacity-60" : ""}`}>
<div
className={`p-4 bg-card border border-border rounded-lg ${isDisabled ? "opacity-60" : ""}`}
>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-foreground">{label}</p>
{subtitle && <p className="text-xs text-muted-foreground mt-0.5">{subtitle}</p>}
</div>
<label className={`relative inline-flex items-center ${isDisabled ? "cursor-not-allowed" : "cursor-pointer"}`}>
<label
className={`relative inline-flex items-center ${isDisabled ? "cursor-not-allowed" : "cursor-pointer"}`}
>
<input
type="checkbox"
checked={checked}
@ -493,7 +505,9 @@ function StatusToggle({ label, subtitle, checked, onChange, loading, disabled }:
disabled={isDisabled}
className="sr-only peer"
/>
<div className={`w-11 h-6 bg-muted peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-ring/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-background after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-background after:border-border after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary ${loading ? "animate-pulse" : ""}`}></div>
<div
className={`w-11 h-6 bg-muted peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-ring/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-background after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-background after:border-border after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary ${loading ? "animate-pulse" : ""}`}
></div>
</label>
</div>
</div>

View File

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

View File

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