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:
parent
ff9ee10860
commit
4cb393bdb8
10
apps/bff/src/infra/cache/cache.service.ts
vendored
10
apps/bff/src/infra/cache/cache.service.ts
vendored
@ -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");
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)", {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 "";
|
||||
|
||||
@ -6,7 +6,6 @@ import { FreebitMapperService } from "./freebit-mapper.service.js";
|
||||
import type {
|
||||
FreebitTrafficInfoRequest,
|
||||
FreebitTrafficInfoResponse,
|
||||
FreebitTopUpRequest,
|
||||
FreebitTopUpResponse,
|
||||
FreebitQuotaHistoryRequest,
|
||||
FreebitQuotaHistoryResponse,
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
@ -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}`, {
|
||||
|
||||
@ -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", {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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 }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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 "";
|
||||
}
|
||||
}
|
||||
|
||||
@ -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" };
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function MigrateAccountPage() {
|
||||
redirect("/auth/get-started");
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function SignupPage() {
|
||||
redirect("/auth/get-started");
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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: "/",
|
||||
},
|
||||
|
||||
@ -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: [
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -235,10 +235,10 @@ export function LoginForm({
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Don'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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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'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;
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user