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) {
|
if (!results) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
results.forEach((result: [Error | null, unknown] | null) => {
|
for (const result of results) {
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return;
|
continue;
|
||||||
}
|
}
|
||||||
const [error, usage] = result;
|
const [error, usage] = result as [Error | null, unknown];
|
||||||
if (!error && typeof usage === "number") {
|
if (!error && typeof usage === "number") {
|
||||||
total += usage;
|
total += usage;
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
});
|
});
|
||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
@ -299,6 +299,7 @@ export class CacheService {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let cursor = "0";
|
let cursor = "0";
|
||||||
do {
|
do {
|
||||||
|
// eslint-disable-next-line no-await-in-loop -- Cursor-based Redis SCAN requires sequential iteration
|
||||||
const [next, keys] = (await this.redis.scan(
|
const [next, keys] = (await this.redis.scan(
|
||||||
cursor,
|
cursor,
|
||||||
"MATCH",
|
"MATCH",
|
||||||
@ -308,6 +309,7 @@ export class CacheService {
|
|||||||
)) as unknown as [string, string[]];
|
)) as unknown as [string, string[]];
|
||||||
cursor = next;
|
cursor = next;
|
||||||
if (keys && keys.length) {
|
if (keys && keys.length) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop -- Sequential processing of batched keys
|
||||||
await onKeys(keys);
|
await onKeys(keys);
|
||||||
}
|
}
|
||||||
} while (cursor !== "0");
|
} while (cursor !== "0");
|
||||||
|
|||||||
@ -58,6 +58,7 @@ export class DistributedLockService {
|
|||||||
|
|
||||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
// SET key token NX PX ttl - atomic set if not exists with TTL
|
// SET key token NX PX ttl - atomic set if not exists with TTL
|
||||||
|
// eslint-disable-next-line no-await-in-loop -- Lock retry requires sequential attempts
|
||||||
const result = await this.redis.set(lockKey, token, "PX", ttlMs, "NX");
|
const result = await this.redis.set(lockKey, token, "PX", ttlMs, "NX");
|
||||||
|
|
||||||
if (result === "OK") {
|
if (result === "OK") {
|
||||||
@ -71,6 +72,7 @@ export class DistributedLockService {
|
|||||||
|
|
||||||
// Lock is held by someone else, wait and retry
|
// Lock is held by someone else, wait and retry
|
||||||
if (attempt < maxRetries) {
|
if (attempt < maxRetries) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop -- Intentional delay between retry attempts
|
||||||
await this.delay(retryDelayMs);
|
await this.delay(retryDelayMs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -455,6 +455,7 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest
|
|||||||
|
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
try {
|
try {
|
||||||
|
// eslint-disable-next-line no-await-in-loop -- Retry with backoff requires sequential attempts
|
||||||
const result = await requestFn();
|
const result = await requestFn();
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -480,6 +481,7 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest
|
|||||||
error: lastError.message,
|
error: lastError.message,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-await-in-loop -- Exponential backoff delay between retries
|
||||||
await new Promise(resolve => {
|
await new Promise(resolve => {
|
||||||
setTimeout(resolve, delay);
|
setTimeout(resolve, delay);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -6,6 +6,13 @@ import { FreebitVoiceService } from "../services/freebit-voice.service.js";
|
|||||||
import { FreebitCancellationService } from "../services/freebit-cancellation.service.js";
|
import { FreebitCancellationService } from "../services/freebit-cancellation.service.js";
|
||||||
import { FreebitEsimService, type EsimActivationParams } from "../services/freebit-esim.service.js";
|
import { FreebitEsimService, type EsimActivationParams } from "../services/freebit-esim.service.js";
|
||||||
import { FreebitMapperService } from "../services/freebit-mapper.service.js";
|
import { FreebitMapperService } from "../services/freebit-mapper.service.js";
|
||||||
|
import { FreebitAccountRegistrationService } from "../services/freebit-account-registration.service.js";
|
||||||
|
import {
|
||||||
|
FreebitVoiceOptionsService,
|
||||||
|
type VoiceOptionIdentityData,
|
||||||
|
} from "../services/freebit-voice-options.service.js";
|
||||||
|
import { FreebitSemiBlackService } from "../services/freebit-semiblack.service.js";
|
||||||
|
import { FreebitRateLimiterService } from "../services/freebit-rate-limiter.service.js";
|
||||||
import type { SimDetails, SimUsage, SimTopUpHistory } from "../interfaces/freebit.types.js";
|
import type { SimDetails, SimUsage, SimTopUpHistory } from "../interfaces/freebit.types.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -35,7 +42,11 @@ export class FreebitFacade {
|
|||||||
private readonly voiceService: FreebitVoiceService,
|
private readonly voiceService: FreebitVoiceService,
|
||||||
private readonly cancellationService: FreebitCancellationService,
|
private readonly cancellationService: FreebitCancellationService,
|
||||||
private readonly esimService: FreebitEsimService,
|
private readonly esimService: FreebitEsimService,
|
||||||
private readonly mapper: FreebitMapperService
|
private readonly mapper: FreebitMapperService,
|
||||||
|
private readonly accountRegistrationService: FreebitAccountRegistrationService,
|
||||||
|
private readonly voiceOptionsService: FreebitVoiceOptionsService,
|
||||||
|
private readonly semiBlackService: FreebitSemiBlackService,
|
||||||
|
private readonly rateLimiterService: FreebitRateLimiterService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -191,4 +202,80 @@ export class FreebitFacade {
|
|||||||
};
|
};
|
||||||
return this.esimService.activateEsimAccountNew(normalizedParams);
|
return this.esimService.activateEsimAccountNew(normalizedParams);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Physical SIM Registration Operations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a semi-black SIM account (PA05-18)
|
||||||
|
*
|
||||||
|
* This MUST be called BEFORE PA02-01 for physical SIMs.
|
||||||
|
* Semi-black SIMs are pre-provisioned physical SIMs that need to be registered
|
||||||
|
* to associate them with a customer account and plan.
|
||||||
|
*
|
||||||
|
* Error 210 "アカウント不在エラー" on PA02-01 indicates that PA05-18
|
||||||
|
* was not called first.
|
||||||
|
*/
|
||||||
|
async registerSemiBlackAccount(params: {
|
||||||
|
account: string;
|
||||||
|
productNumber: string;
|
||||||
|
planCode: string;
|
||||||
|
shipDate?: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const normalizedAccount = this.normalizeAccount(params.account);
|
||||||
|
return this.semiBlackService.registerSemiBlackAccount({
|
||||||
|
...params,
|
||||||
|
account: normalizedAccount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register an MVNO account (PA02-01)
|
||||||
|
*
|
||||||
|
* For Physical SIMs, call PA05-18 first, then call this with createType="add".
|
||||||
|
* For other cases, call this directly with createType="new".
|
||||||
|
*/
|
||||||
|
async registerAccount(params: {
|
||||||
|
account: string;
|
||||||
|
planCode: string;
|
||||||
|
createType?: "new" | "add";
|
||||||
|
}): Promise<void> {
|
||||||
|
const normalizedAccount = this.normalizeAccount(params.account);
|
||||||
|
return this.accountRegistrationService.registerAccount({
|
||||||
|
...params,
|
||||||
|
account: normalizedAccount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register voice options for an MVNO account (PA05-05)
|
||||||
|
*
|
||||||
|
* This configures voice features for a phone number that was previously
|
||||||
|
* registered via PA02-01. Must be called after account registration.
|
||||||
|
*/
|
||||||
|
async registerVoiceOptions(params: {
|
||||||
|
account: string;
|
||||||
|
voiceMailEnabled: boolean;
|
||||||
|
callWaitingEnabled: boolean;
|
||||||
|
identificationData: VoiceOptionIdentityData;
|
||||||
|
}): Promise<void> {
|
||||||
|
const normalizedAccount = this.normalizeAccount(params.account);
|
||||||
|
return this.voiceOptionsService.registerVoiceOptions({
|
||||||
|
...params,
|
||||||
|
account: normalizedAccount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Debug Operations (admin-only)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear rate limit state for an account (for debugging/testing)
|
||||||
|
*/
|
||||||
|
async clearRateLimitForAccount(account: string): Promise<void> {
|
||||||
|
const normalizedAccount = this.normalizeAccount(account);
|
||||||
|
return this.rateLimiterService.clearRateLimitForAccount(normalizedAccount);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -125,14 +125,12 @@ export class FreebitAccountRegistrationService {
|
|||||||
>("/master/addAcnt/", payload);
|
>("/master/addAcnt/", payload);
|
||||||
|
|
||||||
// Check response for individual account results
|
// Check response for individual account results
|
||||||
if (response.responseDatas && response.responseDatas.length > 0) {
|
const accountResult = response.responseDatas?.[0];
|
||||||
const accountResult = response.responseDatas[0];
|
if (accountResult && accountResult.resultCode !== "100") {
|
||||||
if (accountResult.resultCode !== "100") {
|
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Account registration failed for ${account}: result code ${accountResult.resultCode}`
|
`Account registration failed for ${account}: result code ${accountResult.resultCode}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log("MVNO account registration successful (PA02-01)", {
|
this.logger.log("MVNO account registration successful (PA02-01)", {
|
||||||
account,
|
account,
|
||||||
|
|||||||
@ -64,6 +64,7 @@ export class FreebitAccountService {
|
|||||||
if (ep !== candidates[0]) {
|
if (ep !== candidates[0]) {
|
||||||
this.logger.warn(`Retrying Freebit account details with alternative endpoint: ${ep}`);
|
this.logger.warn(`Retrying Freebit account details with alternative endpoint: ${ep}`);
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line no-await-in-loop -- Endpoint fallback requires sequential attempts
|
||||||
response = await this.client.makeAuthenticatedRequest<
|
response = await this.client.makeAuthenticatedRequest<
|
||||||
FreebitAccountDetailsResponse,
|
FreebitAccountDetailsResponse,
|
||||||
typeof request
|
typeof request
|
||||||
|
|||||||
@ -60,7 +60,7 @@ export class FreebitClientService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const isProd = process.env.NODE_ENV === "production";
|
const isProd = process.env["NODE_ENV"] === "production";
|
||||||
const bodySnippet = isProd ? undefined : await this.safeReadBodySnippet(response);
|
const bodySnippet = isProd ? undefined : await this.safeReadBodySnippet(response);
|
||||||
this.logger.error("Freebit API HTTP error", {
|
this.logger.error("Freebit API HTTP error", {
|
||||||
url,
|
url,
|
||||||
@ -81,7 +81,7 @@ export class FreebitClientService {
|
|||||||
const statusCode = this.normalizeResultCode(responseData.status?.statusCode);
|
const statusCode = this.normalizeResultCode(responseData.status?.statusCode);
|
||||||
|
|
||||||
if (resultCode && resultCode !== "100") {
|
if (resultCode && resultCode !== "100") {
|
||||||
const isProd = process.env.NODE_ENV === "production";
|
const isProd = process.env["NODE_ENV"] === "production";
|
||||||
const errorDetails = {
|
const errorDetails = {
|
||||||
url,
|
url,
|
||||||
resultCode,
|
resultCode,
|
||||||
@ -166,12 +166,12 @@ export class FreebitClientService {
|
|||||||
|
|
||||||
let attempt = 0;
|
let attempt = 0;
|
||||||
// Log request details in dev for debugging
|
// Log request details in dev for debugging
|
||||||
const isProd = process.env.NODE_ENV === "production";
|
const isProd = process.env["NODE_ENV"] === "production";
|
||||||
if (!isProd) {
|
if (!isProd) {
|
||||||
console.log("[FREEBIT JSON API REQUEST]", JSON.stringify({
|
this.logger.debug("[FREEBIT JSON API REQUEST]", {
|
||||||
url,
|
url,
|
||||||
payload: redactForLogs(requestPayload),
|
payload: redactForLogs(requestPayload),
|
||||||
}, null, 2));
|
});
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const responseData = await withRetry(
|
const responseData = await withRetry(
|
||||||
@ -207,7 +207,7 @@ export class FreebitClientService {
|
|||||||
const statusCode = this.normalizeResultCode(responseData.status?.statusCode);
|
const statusCode = this.normalizeResultCode(responseData.status?.statusCode);
|
||||||
|
|
||||||
if (resultCode && resultCode !== "100") {
|
if (resultCode && resultCode !== "100") {
|
||||||
const isProd = process.env.NODE_ENV === "production";
|
const isProd = process.env["NODE_ENV"] === "production";
|
||||||
const errorDetails = {
|
const errorDetails = {
|
||||||
url,
|
url,
|
||||||
resultCode,
|
resultCode,
|
||||||
@ -342,7 +342,7 @@ export class FreebitClientService {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const payloadObj = payload as Record<string, unknown>;
|
const payloadObj = payload as Record<string, unknown>;
|
||||||
const phoneNumber = this.testTracker.extractPhoneNumber(
|
const phoneNumber = this.testTracker.extractPhoneNumber(
|
||||||
(payloadObj.account as string) || "",
|
(payloadObj["account"] as string) || "",
|
||||||
payload
|
payload
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -371,10 +371,10 @@ export class FreebitClientService {
|
|||||||
apiEndpoint: endpoint,
|
apiEndpoint: endpoint,
|
||||||
apiMethod: "POST",
|
apiMethod: "POST",
|
||||||
phoneNumber,
|
phoneNumber,
|
||||||
simIdentifier: (payloadObj.account as string) || phoneNumber,
|
simIdentifier: (payloadObj["account"] as string) || phoneNumber,
|
||||||
requestPayload: JSON.stringify(redactForLogs(payload)),
|
requestPayload: JSON.stringify(redactForLogs(payload)),
|
||||||
responseStatus: resultCode === "100" ? "Success" : `Error: ${resultCode}`,
|
responseStatus: resultCode === "100" ? "Success" : `Error: ${resultCode}`,
|
||||||
error: error ? extractErrorMessage(error) : undefined,
|
...(error ? { error: extractErrorMessage(error) } : {}),
|
||||||
additionalInfo: statusMessage,
|
additionalInfo: statusMessage,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -132,10 +132,7 @@ export class FreebitMapperService {
|
|||||||
// No stored options, parse from API response
|
// No stored options, parse from API response
|
||||||
// Default to false - disabled unless API explicitly returns 10 (enabled)
|
// Default to false - disabled unless API explicitly returns 10 (enabled)
|
||||||
voiceMailEnabled = this.parseOptionFlag(account.voicemail ?? account.voiceMail, false);
|
voiceMailEnabled = this.parseOptionFlag(account.voicemail ?? account.voiceMail, false);
|
||||||
callWaitingEnabled = this.parseOptionFlag(
|
callWaitingEnabled = this.parseOptionFlag(account.callwaiting ?? account.callWaiting, false);
|
||||||
account.callwaiting ?? account.callWaiting,
|
|
||||||
false
|
|
||||||
);
|
|
||||||
internationalRoamingEnabled = this.parseOptionFlag(
|
internationalRoamingEnabled = this.parseOptionFlag(
|
||||||
account.worldwing ?? account.worldWing,
|
account.worldwing ?? account.worldWing,
|
||||||
false
|
false
|
||||||
@ -175,13 +172,13 @@ export class FreebitMapperService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Log raw account data in dev to debug MSISDN availability
|
// Log raw account data in dev to debug MSISDN availability
|
||||||
if (process.env.NODE_ENV !== "production") {
|
if (process.env["NODE_ENV"] !== "production") {
|
||||||
console.log("[FREEBIT ACCOUNT DATA]", JSON.stringify({
|
this.logger.debug("[FREEBIT ACCOUNT DATA]", {
|
||||||
account: account.account,
|
account: account.account,
|
||||||
msisdn: account.msisdn,
|
msisdn: account.msisdn,
|
||||||
eid: account.eid,
|
eid: account.eid,
|
||||||
iccid: account.iccid,
|
iccid: account.iccid,
|
||||||
}, null, 2));
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -178,39 +178,4 @@ export class FreebitSemiBlackService {
|
|||||||
const day = String(now.getDate()).padStart(2, "0");
|
const day = String(now.getDate()).padStart(2, "0");
|
||||||
return `${year}${month}${day}`;
|
return `${year}${month}${day}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get human-readable error message for PA05-18 error codes
|
|
||||||
*/
|
|
||||||
private getErrorMessage(code: number, defaultMessage?: string): string {
|
|
||||||
const errorMessages: Record<number, string> = {
|
|
||||||
201: "Invalid account/phone number parameter",
|
|
||||||
202: "Invalid master password",
|
|
||||||
204: "Invalid parameter",
|
|
||||||
205: "Authentication key error",
|
|
||||||
208: "Account already exists (duplicate)",
|
|
||||||
210: "Master account not found",
|
|
||||||
211: "Account status does not allow this operation",
|
|
||||||
215: "Invalid plan code",
|
|
||||||
228: "Invalid authentication key",
|
|
||||||
230: "Account is in async processing queue",
|
|
||||||
231: "Invalid global IP parameter",
|
|
||||||
232: "Plan not found",
|
|
||||||
266: "Invalid product number (manufacturing number)",
|
|
||||||
269: "Invalid representative number",
|
|
||||||
274: "Invalid delivery code",
|
|
||||||
275: "No phone number stock for representative number",
|
|
||||||
276: "Invalid ship date",
|
|
||||||
279: "Invalid create type",
|
|
||||||
284: "Representative number is locked",
|
|
||||||
287: "Representative number does not exist or is unavailable",
|
|
||||||
288: "Product number does not exist, is already used, or not stocked",
|
|
||||||
289: "SIM type does not match representative number type",
|
|
||||||
306: "Invalid MNP method",
|
|
||||||
313: "MNP reservation expires within grace period",
|
|
||||||
900: "Unexpected system error",
|
|
||||||
};
|
|
||||||
|
|
||||||
return errorMessages[code] ?? defaultMessage ?? "Unknown error";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -122,7 +122,12 @@ export class FreebitTestTrackerService {
|
|||||||
// Try to extract from payload if it's an object
|
// Try to extract from payload if it's an object
|
||||||
if (payload && typeof payload === "object") {
|
if (payload && typeof payload === "object") {
|
||||||
const obj = payload as Record<string, unknown>;
|
const obj = payload as Record<string, unknown>;
|
||||||
return (obj.account as string) || (obj.msisdn as string) || (obj.phoneNumber as string) || "";
|
return (
|
||||||
|
(obj["account"] as string) ||
|
||||||
|
(obj["msisdn"] as string) ||
|
||||||
|
(obj["phoneNumber"] as string) ||
|
||||||
|
""
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return "";
|
return "";
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import { FreebitMapperService } from "./freebit-mapper.service.js";
|
|||||||
import type {
|
import type {
|
||||||
FreebitTrafficInfoRequest,
|
FreebitTrafficInfoRequest,
|
||||||
FreebitTrafficInfoResponse,
|
FreebitTrafficInfoResponse,
|
||||||
FreebitTopUpRequest,
|
|
||||||
FreebitTopUpResponse,
|
FreebitTopUpResponse,
|
||||||
FreebitQuotaHistoryRequest,
|
FreebitQuotaHistoryRequest,
|
||||||
FreebitQuotaHistoryResponse,
|
FreebitQuotaHistoryResponse,
|
||||||
|
|||||||
@ -202,7 +202,7 @@ export class FreebitVoiceOptionsService {
|
|||||||
if (!date) return "";
|
if (!date) return "";
|
||||||
|
|
||||||
const d = typeof date === "string" ? new Date(date) : date;
|
const d = typeof date === "string" ? new Date(date) : date;
|
||||||
if (isNaN(d.getTime())) return "";
|
if (Number.isNaN(d.getTime())) return "";
|
||||||
|
|
||||||
const year = d.getFullYear();
|
const year = d.getFullYear();
|
||||||
const month = String(d.getMonth() + 1).padStart(2, "0");
|
const month = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
|||||||
@ -12,3 +12,12 @@ export {
|
|||||||
FreebitSemiBlackService,
|
FreebitSemiBlackService,
|
||||||
type SemiBlackRegistrationParams,
|
type SemiBlackRegistrationParams,
|
||||||
} from "./freebit-semiblack.service.js";
|
} from "./freebit-semiblack.service.js";
|
||||||
|
export {
|
||||||
|
FreebitAccountRegistrationService,
|
||||||
|
type AccountRegistrationParams,
|
||||||
|
} from "./freebit-account-registration.service.js";
|
||||||
|
export {
|
||||||
|
FreebitVoiceOptionsService,
|
||||||
|
type VoiceOptionRegistrationParams,
|
||||||
|
type VoiceOptionIdentityData,
|
||||||
|
} from "./freebit-voice-options.service.js";
|
||||||
|
|||||||
@ -231,7 +231,7 @@ export class SalesforceSIMInventoryService {
|
|||||||
id: raw.Id,
|
id: raw.Id,
|
||||||
phoneNumber: raw.Phone_Number__c ?? "",
|
phoneNumber: raw.Phone_Number__c ?? "",
|
||||||
ptNumber: raw.PT_Number__c ?? "",
|
ptNumber: raw.PT_Number__c ?? "",
|
||||||
oemId: raw.OEM_ID__c ?? undefined,
|
...(raw.OEM_ID__c && { oemId: raw.OEM_ID__c }),
|
||||||
status: (raw.Status__c as SimInventoryStatus) ?? SIM_INVENTORY_STATUS.AVAILABLE,
|
status: (raw.Status__c as SimInventoryStatus) ?? SIM_INVENTORY_STATUS.AVAILABLE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -91,13 +91,11 @@ export class WhmcsErrorHandlerService {
|
|||||||
/**
|
/**
|
||||||
* Parse HTTP status error from error message (e.g., "HTTP 500: Internal Server Error")
|
* Parse HTTP status error from error message (e.g., "HTTP 500: Internal Server Error")
|
||||||
*/
|
*/
|
||||||
private parseHttpStatusError(
|
private parseHttpStatusError(message: string): { status: number; statusText: string } | null {
|
||||||
message: string
|
|
||||||
): { status: number; statusText: string } | null {
|
|
||||||
const match = message.match(/^HTTP (\d{3}):\s*(.*)$/i);
|
const match = message.match(/^HTTP (\d{3}):\s*(.*)$/i);
|
||||||
if (match) {
|
if (match?.[1]) {
|
||||||
return {
|
return {
|
||||||
status: parseInt(match[1], 10),
|
status: Number.parseInt(match[1], 10),
|
||||||
statusText: match[2] || "Unknown",
|
statusText: match[2] || "Unknown",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -124,6 +124,7 @@ export class WhmcsInvoiceService {
|
|||||||
if (!batch) continue;
|
if (!batch) continue;
|
||||||
|
|
||||||
// Process batch in parallel
|
// Process batch in parallel
|
||||||
|
// eslint-disable-next-line no-await-in-loop -- Batch processing with rate limiting requires sequential batches
|
||||||
const batchResults = await Promise.all(
|
const batchResults = await Promise.all(
|
||||||
batch.map(async (invoice: Invoice) => {
|
batch.map(async (invoice: Invoice) => {
|
||||||
try {
|
try {
|
||||||
@ -144,6 +145,7 @@ export class WhmcsInvoiceService {
|
|||||||
|
|
||||||
// Add delay between batches (except for the last batch) to respect rate limits
|
// Add delay between batches (except for the last batch) to respect rate limits
|
||||||
if (i < batches.length - 1) {
|
if (i < batches.length - 1) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop -- Intentional rate limit delay between batches
|
||||||
await sleep(BATCH_DELAY_MS);
|
await sleep(BATCH_DELAY_MS);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -333,9 +335,10 @@ export class WhmcsInvoiceService {
|
|||||||
fullResponse: JSON.stringify(response),
|
fullResponse: JSON.stringify(response),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const rawError = (response as Record<string, unknown>)["error"];
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
response.message ||
|
response.message ||
|
||||||
(response as Record<string, unknown>).error ||
|
(typeof rawError === "string" ? rawError : JSON.stringify(rawError ?? "")) ||
|
||||||
`Unknown error (result: ${response.result})`;
|
`Unknown error (result: ${response.result})`;
|
||||||
|
|
||||||
throw new WhmcsOperationException(`WHMCS invoice creation failed: ${errorMessage}`, {
|
throw new WhmcsOperationException(`WHMCS invoice creation failed: ${errorMessage}`, {
|
||||||
|
|||||||
@ -159,7 +159,7 @@ export class WhmcsOrderService {
|
|||||||
|
|
||||||
const serviceIds = this.parseDelimitedIds(parsedResponse.data.serviceids);
|
const serviceIds = this.parseDelimitedIds(parsedResponse.data.serviceids);
|
||||||
const invoiceId = parsedResponse.data.invoiceid
|
const invoiceId = parsedResponse.data.invoiceid
|
||||||
? parseInt(String(parsedResponse.data.invoiceid), 10)
|
? Number.parseInt(String(parsedResponse.data.invoiceid), 10)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
this.logger.log("WHMCS order accepted successfully", {
|
this.logger.log("WHMCS order accepted successfully", {
|
||||||
@ -169,7 +169,7 @@ export class WhmcsOrderService {
|
|||||||
sfOrderId,
|
sfOrderId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { serviceIds, invoiceId };
|
return { serviceIds, ...(invoiceId !== undefined && { invoiceId }) };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Enhanced error logging with full context
|
// Enhanced error logging with full context
|
||||||
this.logger.error("Failed to accept WHMCS order", {
|
this.logger.error("Failed to accept WHMCS order", {
|
||||||
|
|||||||
@ -94,6 +94,7 @@ export class JoseJwtService {
|
|||||||
const key = this.verificationKeys[i];
|
const key = this.verificationKeys[i];
|
||||||
if (!key) continue;
|
if (!key) continue;
|
||||||
try {
|
try {
|
||||||
|
// eslint-disable-next-line no-await-in-loop -- JWT verification with multiple keys requires sequential attempts
|
||||||
const { payload } = await jwtVerify(token, key, options);
|
const { payload } = await jwtVerify(token, key, options);
|
||||||
return payload as T;
|
return payload as T;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -40,6 +40,9 @@ import { OrderFulfillmentOrchestrator } from "./services/order-fulfillment-orche
|
|||||||
import { OrderFulfillmentErrorService } from "./services/order-fulfillment-error.service.js";
|
import { OrderFulfillmentErrorService } from "./services/order-fulfillment-error.service.js";
|
||||||
import { SimFulfillmentService } from "./services/sim-fulfillment.service.js";
|
import { SimFulfillmentService } from "./services/sim-fulfillment.service.js";
|
||||||
import { FulfillmentSideEffectsService } from "./services/fulfillment-side-effects.service.js";
|
import { FulfillmentSideEffectsService } from "./services/fulfillment-side-effects.service.js";
|
||||||
|
import { FulfillmentContextMapper } from "./services/fulfillment-context-mapper.service.js";
|
||||||
|
import { FulfillmentStepExecutors } from "./services/fulfillment-step-executors.service.js";
|
||||||
|
import { FulfillmentStepFactory } from "./services/fulfillment-step-factory.service.js";
|
||||||
import { ProvisioningQueueService } from "./queue/provisioning.queue.js";
|
import { ProvisioningQueueService } from "./queue/provisioning.queue.js";
|
||||||
import { ProvisioningProcessor } from "./queue/provisioning.processor.js";
|
import { ProvisioningProcessor } from "./queue/provisioning.processor.js";
|
||||||
import { SalesforceOrderFieldConfigModule } from "@bff/integrations/salesforce/config/salesforce-order-field-config.module.js";
|
import { SalesforceOrderFieldConfigModule } from "@bff/integrations/salesforce/config/salesforce-order-field-config.module.js";
|
||||||
@ -88,6 +91,9 @@ import { SalesforceOrderFieldConfigModule } from "@bff/integrations/salesforce/c
|
|||||||
OrderFulfillmentErrorService,
|
OrderFulfillmentErrorService,
|
||||||
SimFulfillmentService,
|
SimFulfillmentService,
|
||||||
FulfillmentSideEffectsService,
|
FulfillmentSideEffectsService,
|
||||||
|
FulfillmentContextMapper,
|
||||||
|
FulfillmentStepExecutors,
|
||||||
|
FulfillmentStepFactory,
|
||||||
// Async provisioning queue
|
// Async provisioning queue
|
||||||
ProvisioningQueueService,
|
ProvisioningQueueService,
|
||||||
ProvisioningProcessor,
|
ProvisioningProcessor,
|
||||||
|
|||||||
@ -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 { Injectable, BadRequestException, Inject } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js";
|
import { SalesforceFacade } from "@bff/integrations/salesforce/facades/salesforce.facade.js";
|
||||||
import { SalesforceAccountService } from "@bff/integrations/salesforce/services/salesforce-account.service.js";
|
import { SalesforceAccountService } from "@bff/integrations/salesforce/services/salesforce-account.service.js";
|
||||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
import { sfOrderIdParamSchema } from "@customer-portal/domain/orders";
|
import { sfOrderIdParamSchema } from "@customer-portal/domain/orders";
|
||||||
import type { OrderFulfillmentValidationResult } from "@customer-portal/domain/orders/providers";
|
import type {
|
||||||
|
OrderFulfillmentValidationResult,
|
||||||
|
SalesforceOrderRecord,
|
||||||
|
} from "@customer-portal/domain/orders/providers";
|
||||||
import { salesforceAccountIdSchema } from "@customer-portal/domain/common";
|
import { salesforceAccountIdSchema } from "@customer-portal/domain/common";
|
||||||
import type { SalesforceOrderRecord } from "@customer-portal/domain/orders/providers";
|
|
||||||
import { PaymentValidatorService } from "./payment-validator.service.js";
|
import { PaymentValidatorService } from "./payment-validator.service.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -18,7 +20,7 @@ import { PaymentValidatorService } from "./payment-validator.service.js";
|
|||||||
export class OrderFulfillmentValidator {
|
export class OrderFulfillmentValidator {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(Logger) private readonly logger: Logger,
|
@Inject(Logger) private readonly logger: Logger,
|
||||||
private readonly salesforceService: SalesforceService,
|
private readonly salesforceFacade: SalesforceFacade,
|
||||||
private readonly salesforceAccountService: SalesforceAccountService,
|
private readonly salesforceAccountService: SalesforceAccountService,
|
||||||
private readonly mappingsService: MappingsService,
|
private readonly mappingsService: MappingsService,
|
||||||
private readonly paymentValidator: PaymentValidatorService
|
private readonly paymentValidator: PaymentValidatorService
|
||||||
@ -129,7 +131,7 @@ export class OrderFulfillmentValidator {
|
|||||||
* Validate Salesforce order exists and is in valid state
|
* Validate Salesforce order exists and is in valid state
|
||||||
*/
|
*/
|
||||||
private async validateSalesforceOrder(sfOrderId: string): Promise<SalesforceOrderRecord> {
|
private async validateSalesforceOrder(sfOrderId: string): Promise<SalesforceOrderRecord> {
|
||||||
const order = await this.salesforceService.getOrder(sfOrderId);
|
const order = await this.salesforceFacade.getOrder(sfOrderId);
|
||||||
|
|
||||||
if (!order) {
|
if (!order) {
|
||||||
throw new BadRequestException(`Salesforce order ${sfOrderId} not found`);
|
throw new BadRequestException(`Salesforce order ${sfOrderId} not found`);
|
||||||
@ -175,8 +177,8 @@ export class OrderFulfillmentValidator {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clientId = parseInt(match[1], 10);
|
const clientId = Number.parseInt(match[1], 10);
|
||||||
if (isNaN(clientId) || clientId <= 0) {
|
if (Number.isNaN(clientId) || clientId <= 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
import { Injectable, Inject } from "@nestjs/common";
|
import { Injectable, Inject } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service.js";
|
import { FreebitFacade } from "@bff/integrations/freebit/facades/freebit.facade.js";
|
||||||
import { FreebitAccountRegistrationService } from "@bff/integrations/freebit/services/freebit-account-registration.service.js";
|
|
||||||
import { FreebitVoiceOptionsService } from "@bff/integrations/freebit/services/freebit-voice-options.service.js";
|
|
||||||
import { SalesforceSIMInventoryService } from "@bff/integrations/salesforce/services/salesforce-sim-inventory.service.js";
|
import { SalesforceSIMInventoryService } from "@bff/integrations/salesforce/services/salesforce-sim-inventory.service.js";
|
||||||
import type { OrderDetails, OrderItemDetails } from "@customer-portal/domain/orders";
|
import type { OrderDetails, OrderItemDetails } from "@customer-portal/domain/orders";
|
||||||
import { mapProductToFreebitPlanCode } from "@customer-portal/domain/sim";
|
import { mapProductToFreebitPlanCode } from "@customer-portal/domain/sim";
|
||||||
@ -56,9 +54,7 @@ export interface SimFulfillmentResult {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class SimFulfillmentService {
|
export class SimFulfillmentService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly freebit: FreebitOrchestratorService,
|
private readonly freebitFacade: FreebitFacade,
|
||||||
private readonly freebitAccountReg: FreebitAccountRegistrationService,
|
|
||||||
private readonly freebitVoiceOptions: FreebitVoiceOptionsService,
|
|
||||||
private readonly simInventory: SalesforceSIMInventoryService,
|
private readonly simInventory: SalesforceSIMInventoryService,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {}
|
) {}
|
||||||
@ -73,7 +69,7 @@ export class SimFulfillmentService {
|
|||||||
contactIdentity,
|
contactIdentity,
|
||||||
} = request;
|
} = request;
|
||||||
|
|
||||||
const simType = this.readEnum(configurations.simType, ["eSIM", "Physical SIM"]);
|
const simType = this.readEnum(configurations["simType"], ["eSIM", "Physical SIM"]);
|
||||||
|
|
||||||
this.logger.log("Starting SIM fulfillment", {
|
this.logger.log("Starting SIM fulfillment", {
|
||||||
orderId: orderDetails.id,
|
orderId: orderDetails.id,
|
||||||
@ -91,16 +87,16 @@ export class SimFulfillmentService {
|
|||||||
"SIM Type must be explicitly set to 'eSIM' or 'Physical SIM'",
|
"SIM Type must be explicitly set to 'eSIM' or 'Physical SIM'",
|
||||||
{
|
{
|
||||||
orderId: orderDetails.id,
|
orderId: orderDetails.id,
|
||||||
configuredSimType: configurations.simType,
|
configuredSimType: configurations["simType"],
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const eid = this.readString(configurations.eid);
|
const eid = this.readString(configurations["eid"]);
|
||||||
const activationType =
|
const activationType =
|
||||||
this.readEnum(configurations.activationType, ["Immediate", "Scheduled"]) ?? "Immediate";
|
this.readEnum(configurations["activationType"], ["Immediate", "Scheduled"]) ?? "Immediate";
|
||||||
const scheduledAt = this.readString(configurations.scheduledAt);
|
const scheduledAt = this.readString(configurations["scheduledAt"]);
|
||||||
const phoneNumber = this.readString(configurations.mnpPhone);
|
const phoneNumber = this.readString(configurations["mnpPhone"]);
|
||||||
const mnp = this.extractMnpConfig(configurations);
|
const mnp = this.extractMnpConfig(configurations);
|
||||||
|
|
||||||
const simPlanItem = orderDetails.items.find(
|
const simPlanItem = orderDetails.items.find(
|
||||||
@ -144,8 +140,8 @@ export class SimFulfillmentService {
|
|||||||
eid,
|
eid,
|
||||||
planSku,
|
planSku,
|
||||||
activationType,
|
activationType,
|
||||||
scheduledAt,
|
...(scheduledAt && { scheduledAt }),
|
||||||
mnp,
|
...(mnp && { mnp }),
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log("eSIM fulfillment completed successfully", {
|
this.logger.log("eSIM fulfillment completed successfully", {
|
||||||
@ -160,7 +156,7 @@ export class SimFulfillmentService {
|
|||||||
phoneNumber,
|
phoneNumber,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// Physical SIM activation flow (PA02-01 + PA05-05)
|
// Physical SIM activation flow (PA05-18 + PA02-01 + PA05-05)
|
||||||
if (!assignedPhysicalSimId) {
|
if (!assignedPhysicalSimId) {
|
||||||
throw new SimActivationException(
|
throw new SimActivationException(
|
||||||
"Physical SIM requires an assigned SIM from inventory (Assign_Physical_SIM__c)",
|
"Physical SIM requires an assigned SIM from inventory (Assign_Physical_SIM__c)",
|
||||||
@ -222,29 +218,29 @@ export class SimFulfillmentService {
|
|||||||
const { account, eid, planSku, activationType, scheduledAt, mnp } = params;
|
const { account, eid, planSku, activationType, scheduledAt, mnp } = params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.freebit.activateEsimAccountNew({
|
await this.freebitFacade.activateEsimAccountNew({
|
||||||
account,
|
account,
|
||||||
eid,
|
eid,
|
||||||
planCode: planSku,
|
planCode: planSku,
|
||||||
contractLine: "5G",
|
contractLine: "5G",
|
||||||
shipDate: activationType === "Scheduled" ? scheduledAt : undefined,
|
...(activationType === "Scheduled" && scheduledAt && { shipDate: scheduledAt }),
|
||||||
mnp:
|
...(mnp?.reserveNumber &&
|
||||||
mnp && mnp.reserveNumber && mnp.reserveExpireDate
|
mnp?.reserveExpireDate && {
|
||||||
? {
|
mnp: {
|
||||||
reserveNumber: mnp.reserveNumber,
|
reserveNumber: mnp.reserveNumber,
|
||||||
reserveExpireDate: mnp.reserveExpireDate,
|
reserveExpireDate: mnp.reserveExpireDate,
|
||||||
}
|
},
|
||||||
: undefined,
|
}),
|
||||||
identity: mnp
|
...(mnp && {
|
||||||
? {
|
identity: {
|
||||||
firstnameKanji: mnp.firstnameKanji,
|
...(mnp.firstnameKanji && { firstnameKanji: mnp.firstnameKanji }),
|
||||||
lastnameKanji: mnp.lastnameKanji,
|
...(mnp.lastnameKanji && { lastnameKanji: mnp.lastnameKanji }),
|
||||||
firstnameZenKana: mnp.firstnameZenKana,
|
...(mnp.firstnameZenKana && { firstnameZenKana: mnp.firstnameZenKana }),
|
||||||
lastnameZenKana: mnp.lastnameZenKana,
|
...(mnp.lastnameZenKana && { lastnameZenKana: mnp.lastnameZenKana }),
|
||||||
gender: mnp.gender,
|
...(mnp.gender && { gender: mnp.gender }),
|
||||||
birthday: mnp.birthday,
|
...(mnp.birthday && { birthday: mnp.birthday }),
|
||||||
}
|
},
|
||||||
: undefined,
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log("eSIM activated successfully", {
|
this.logger.log("eSIM activated successfully", {
|
||||||
@ -263,24 +259,28 @@ export class SimFulfillmentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Activate Physical SIM via Freebit PA02-01 + PA05-05 APIs
|
* Activate Physical SIM via Freebit PA05-18 + PA02-01 + PA05-05 APIs
|
||||||
*
|
*
|
||||||
* Flow for Physical SIMs:
|
* Flow for Physical SIMs:
|
||||||
* 1. Fetch SIM Inventory details from Salesforce
|
* 1. Fetch SIM Inventory details from Salesforce
|
||||||
* 2. Validate SIM status is "Available"
|
* 2. Validate SIM status is "Available"
|
||||||
* 3. Map product SKU to Freebit plan code
|
* 3. Map product SKU to Freebit plan code
|
||||||
* 4. Call Freebit PA02-01 (Account Registration) with createType="new"
|
* 4. Call Freebit PA05-18 (Semi-Black Registration) - MUST be called first!
|
||||||
* 5. Call Freebit PA05-05 (Voice Options) to configure voice features
|
* 5. Call Freebit PA02-01 (Account Registration) with createType="add"
|
||||||
* 6. Update SIM Inventory status to "Used"
|
* 6. Call Freebit PA05-05 (Voice Options) to configure voice features
|
||||||
|
* 7. Update SIM Inventory status to "Assigned"
|
||||||
|
*
|
||||||
|
* Note: PA05-18 must be called before PA02-01, otherwise PA02-01 will fail
|
||||||
|
* with error 210 "アカウント不在エラー" (Account not found error).
|
||||||
*/
|
*/
|
||||||
private async activatePhysicalSim(params: {
|
private async activatePhysicalSim(params: {
|
||||||
orderId: string;
|
orderId: string;
|
||||||
simInventoryId: string;
|
simInventoryId: string;
|
||||||
planSku: string;
|
planSku: string;
|
||||||
planName?: string;
|
planName?: string | undefined;
|
||||||
voiceMailEnabled: boolean;
|
voiceMailEnabled: boolean;
|
||||||
callWaitingEnabled: boolean;
|
callWaitingEnabled: boolean;
|
||||||
contactIdentity?: ContactIdentityData;
|
contactIdentity?: ContactIdentityData | undefined;
|
||||||
}): Promise<{ phoneNumber: string; serialNumber: string }> {
|
}): Promise<{ phoneNumber: string; serialNumber: string }> {
|
||||||
const {
|
const {
|
||||||
orderId,
|
orderId,
|
||||||
@ -292,7 +292,7 @@ export class SimFulfillmentService {
|
|||||||
contactIdentity,
|
contactIdentity,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
this.logger.log("Starting Physical SIM activation (PA02-01 + PA05-05)", {
|
this.logger.log("Starting Physical SIM activation (PA05-18 + PA02-01 + PA05-05)", {
|
||||||
orderId,
|
orderId,
|
||||||
simInventoryId,
|
simInventoryId,
|
||||||
planSku,
|
planSku,
|
||||||
@ -315,27 +315,50 @@ export class SimFulfillmentService {
|
|||||||
|
|
||||||
// Use phone number from SIM inventory
|
// Use phone number from SIM inventory
|
||||||
const accountPhoneNumber = simRecord.phoneNumber;
|
const accountPhoneNumber = simRecord.phoneNumber;
|
||||||
|
// PT Number is the productNumber required for PA05-18
|
||||||
|
const productNumber = simRecord.ptNumber;
|
||||||
|
|
||||||
this.logger.log("Physical SIM inventory validated", {
|
this.logger.log("Physical SIM inventory validated", {
|
||||||
orderId,
|
orderId,
|
||||||
simInventoryId,
|
simInventoryId,
|
||||||
accountPhoneNumber,
|
accountPhoneNumber,
|
||||||
ptNumber: simRecord.ptNumber,
|
productNumber,
|
||||||
planCode,
|
planCode,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 4: Call Freebit PA02-01 (Account Registration)
|
// Step 4: Call Freebit PA05-18 (Semi-Black Registration) - MUST be first!
|
||||||
|
this.logger.log("Calling PA05-18 Semi-Black Registration", {
|
||||||
|
orderId,
|
||||||
|
account: accountPhoneNumber,
|
||||||
|
productNumber,
|
||||||
|
planCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.freebitFacade.registerSemiBlackAccount({
|
||||||
|
account: accountPhoneNumber,
|
||||||
|
productNumber,
|
||||||
|
planCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log("PA05-18 Semi-Black Registration successful", {
|
||||||
|
orderId,
|
||||||
|
account: accountPhoneNumber,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 5: Call Freebit PA02-01 (Account Registration) with createType="add"
|
||||||
|
// Note: After PA05-18, we use createType="add" (not "new")
|
||||||
this.logger.log("Calling PA02-01 Account Registration", {
|
this.logger.log("Calling PA02-01 Account Registration", {
|
||||||
orderId,
|
orderId,
|
||||||
account: accountPhoneNumber,
|
account: accountPhoneNumber,
|
||||||
planCode,
|
planCode,
|
||||||
|
createType: "add",
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.freebitAccountReg.registerAccount({
|
await this.freebitFacade.registerAccount({
|
||||||
account: accountPhoneNumber,
|
account: accountPhoneNumber,
|
||||||
planCode,
|
planCode,
|
||||||
createType: "new",
|
createType: "add",
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log("PA02-01 Account Registration successful", {
|
this.logger.log("PA02-01 Account Registration successful", {
|
||||||
@ -343,7 +366,7 @@ export class SimFulfillmentService {
|
|||||||
account: accountPhoneNumber,
|
account: accountPhoneNumber,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 5: Call Freebit PA05-05 (Voice Options Registration)
|
// Step 6: Call Freebit PA05-05 (Voice Options Registration)
|
||||||
// Only call if we have contact identity data
|
// Only call if we have contact identity data
|
||||||
if (contactIdentity) {
|
if (contactIdentity) {
|
||||||
this.logger.log("Calling PA05-05 Voice Options Registration", {
|
this.logger.log("Calling PA05-05 Voice Options Registration", {
|
||||||
@ -353,7 +376,7 @@ export class SimFulfillmentService {
|
|||||||
callWaitingEnabled,
|
callWaitingEnabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.freebitVoiceOptions.registerVoiceOptions({
|
await this.freebitFacade.registerVoiceOptions({
|
||||||
account: accountPhoneNumber,
|
account: accountPhoneNumber,
|
||||||
voiceMailEnabled,
|
voiceMailEnabled,
|
||||||
callWaitingEnabled,
|
callWaitingEnabled,
|
||||||
@ -378,7 +401,7 @@ export class SimFulfillmentService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 6: Update SIM Inventory status to "Assigned"
|
// Step 7: Update SIM Inventory status to "Assigned"
|
||||||
await this.simInventory.markAsAssigned(simInventoryId);
|
await this.simInventory.markAsAssigned(simInventoryId);
|
||||||
|
|
||||||
this.logger.log("Physical SIM activated successfully", {
|
this.logger.log("Physical SIM activated successfully", {
|
||||||
@ -415,28 +438,28 @@ export class SimFulfillmentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private extractMnpConfig(config: Record<string, unknown>) {
|
private extractMnpConfig(config: Record<string, unknown>) {
|
||||||
const nested = config.mnp;
|
const nested = config["mnp"];
|
||||||
const source =
|
const source =
|
||||||
nested && typeof nested === "object" ? (nested as Record<string, unknown>) : config;
|
nested && typeof nested === "object" ? (nested as Record<string, unknown>) : config;
|
||||||
|
|
||||||
const isMnpFlag = this.readString(source.isMnp ?? config.isMnp);
|
const isMnpFlag = this.readString(source["isMnp"] ?? config["isMnp"]);
|
||||||
if (isMnpFlag && isMnpFlag !== "true") {
|
if (isMnpFlag && isMnpFlag !== "true") {
|
||||||
return undefined;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const reserveNumber = this.readString(source.mnpNumber ?? source.reserveNumber);
|
const reserveNumber = this.readString(source["mnpNumber"] ?? source["reserveNumber"]);
|
||||||
const reserveExpireDate = this.readString(source.mnpExpiry ?? source.reserveExpireDate);
|
const reserveExpireDate = this.readString(source["mnpExpiry"] ?? source["reserveExpireDate"]);
|
||||||
const account = this.readString(source.mvnoAccountNumber ?? source.account);
|
const account = this.readString(source["mvnoAccountNumber"] ?? source["account"]);
|
||||||
const firstnameKanji = this.readString(source.portingFirstName ?? source.firstnameKanji);
|
const firstnameKanji = this.readString(source["portingFirstName"] ?? source["firstnameKanji"]);
|
||||||
const lastnameKanji = this.readString(source.portingLastName ?? source.lastnameKanji);
|
const lastnameKanji = this.readString(source["portingLastName"] ?? source["lastnameKanji"]);
|
||||||
const firstnameZenKana = this.readString(
|
const firstnameZenKana = this.readString(
|
||||||
source.portingFirstNameKatakana ?? source.firstnameZenKana
|
source["portingFirstNameKatakana"] ?? source["firstnameZenKana"]
|
||||||
);
|
);
|
||||||
const lastnameZenKana = this.readString(
|
const lastnameZenKana = this.readString(
|
||||||
source.portingLastNameKatakana ?? source.lastnameZenKana
|
source["portingLastNameKatakana"] ?? source["lastnameZenKana"]
|
||||||
);
|
);
|
||||||
const gender = this.readString(source.portingGender ?? source.gender);
|
const gender = this.readString(source["portingGender"] ?? source["gender"]);
|
||||||
const birthday = this.readString(source.portingDateOfBirth ?? source.birthday);
|
const birthday = this.readString(source["portingDateOfBirth"] ?? source["birthday"]);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!reserveNumber &&
|
!reserveNumber &&
|
||||||
@ -449,19 +472,20 @@ export class SimFulfillmentService {
|
|||||||
!gender &&
|
!gender &&
|
||||||
!birthday
|
!birthday
|
||||||
) {
|
) {
|
||||||
return undefined;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build object with only defined properties (for exactOptionalPropertyTypes)
|
||||||
return {
|
return {
|
||||||
reserveNumber,
|
...(reserveNumber && { reserveNumber }),
|
||||||
reserveExpireDate,
|
...(reserveExpireDate && { reserveExpireDate }),
|
||||||
account,
|
...(account && { account }),
|
||||||
firstnameKanji,
|
...(firstnameKanji && { firstnameKanji }),
|
||||||
lastnameKanji,
|
...(lastnameKanji && { lastnameKanji }),
|
||||||
firstnameZenKana,
|
...(firstnameZenKana && { firstnameZenKana }),
|
||||||
lastnameZenKana,
|
...(lastnameZenKana && { lastnameZenKana }),
|
||||||
gender,
|
...(gender && { gender }),
|
||||||
birthday,
|
...(birthday && { birthday }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,15 +58,15 @@ export class SimBillingService {
|
|||||||
description,
|
description,
|
||||||
amount: amountJpy,
|
amount: amountJpy,
|
||||||
currency,
|
currency,
|
||||||
dueDate,
|
...(dueDate && { dueDate }),
|
||||||
notes,
|
...(notes && { notes }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const paymentResult = await this.whmcsInvoiceService.capturePayment({
|
const paymentResult = await this.whmcsInvoiceService.capturePayment({
|
||||||
invoiceId: invoice.id,
|
invoiceId: invoice.id,
|
||||||
amount: amountJpy,
|
amount: amountJpy,
|
||||||
currency,
|
currency,
|
||||||
userId,
|
...(userId && { userId }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!paymentResult.success) {
|
if (!paymentResult.success) {
|
||||||
@ -127,7 +127,7 @@ export class SimBillingService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
invoice,
|
invoice,
|
||||||
transactionId: paymentResult.transactionId,
|
...(paymentResult.transactionId && { transactionId: paymentResult.transactionId }),
|
||||||
refunded,
|
refunded,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { SubscriptionsService } from "../../subscriptions.service.js";
|
import { SubscriptionsOrchestrator } from "../../subscriptions-orchestrator.service.js";
|
||||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
import type { SimValidationResult } from "../interfaces/sim-base.interface.js";
|
import type { SimValidationResult } from "../interfaces/sim-base.interface.js";
|
||||||
import {
|
import {
|
||||||
@ -12,7 +12,7 @@ import {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class SimValidationService {
|
export class SimValidationService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly subscriptionsService: SubscriptionsService,
|
private readonly subscriptionsService: SubscriptionsOrchestrator,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -42,21 +42,16 @@ export class SimValidationService {
|
|||||||
|
|
||||||
// If no account found, log detailed info and throw error
|
// If no account found, log detailed info and throw error
|
||||||
if (!account) {
|
if (!account) {
|
||||||
this.logger.error(
|
this.logger.error(`No SIM account identifier found for subscription ${subscriptionId}`, {
|
||||||
`No SIM account identifier found for subscription ${subscriptionId}`,
|
|
||||||
{
|
|
||||||
userId,
|
userId,
|
||||||
subscriptionId,
|
subscriptionId,
|
||||||
productName: subscription.productName,
|
productName: subscription.productName,
|
||||||
domain: subscription.domain,
|
domain: subscription.domain,
|
||||||
customFieldKeys: subscription.customFields
|
customFieldKeys: subscription.customFields ? Object.keys(subscription.customFields) : [],
|
||||||
? Object.keys(subscription.customFields)
|
|
||||||
: [],
|
|
||||||
customFieldValues: subscription.customFields,
|
customFieldValues: subscription.customFields,
|
||||||
orderNumber: subscription.orderNumber,
|
orderNumber: subscription.orderNumber,
|
||||||
note: "Set the phone number in 'domain' field or a custom field named 'phone', 'msisdn', 'Phone Number', etc.",
|
note: "Set the phone number in 'domain' field or a custom field named 'phone', 'msisdn', 'Phone Number', etc.",
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`No SIM phone number found for this subscription. Please ensure the phone number is set in WHMCS (domain field or custom field named 'Phone Number', 'MSISDN', etc.)`
|
`No SIM phone number found for this subscription. Please ensure the phone number is set in WHMCS (domain field or custom field named 'Phone Number', 'MSISDN', etc.)`
|
||||||
@ -119,9 +114,9 @@ export class SimValidationService {
|
|||||||
// All custom fields for debugging
|
// All custom fields for debugging
|
||||||
customFieldKeys: Object.keys(subscription.customFields || {}),
|
customFieldKeys: Object.keys(subscription.customFields || {}),
|
||||||
customFields: subscription.customFields,
|
customFields: subscription.customFields,
|
||||||
hint: !extractedAccount
|
hint: extractedAccount
|
||||||
? "Set phone number in 'domain' field OR add custom field named: phone, msisdn, phonenumber, Phone Number, MSISDN, etc."
|
? undefined
|
||||||
: undefined,
|
: "Set phone number in 'domain' field OR add custom field named: phone, msisdn, phonenumber, Phone Number, MSISDN, etc.",
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const sanitizedError = extractErrorMessage(error);
|
const sanitizedError = extractErrorMessage(error);
|
||||||
@ -131,28 +126,4 @@ export class SimValidationService {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private formatCustomFieldValue(value: unknown): string {
|
|
||||||
if (typeof value === "string") {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === "number" || typeof value === "boolean") {
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value instanceof Date) {
|
|
||||||
return value.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === "object" && value !== null) {
|
|
||||||
try {
|
|
||||||
return JSON.stringify(value);
|
|
||||||
} catch {
|
|
||||||
return "[unserializable]";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,12 +9,12 @@ import {
|
|||||||
Header,
|
Header,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { SimManagementService } from "../sim-management.service.js";
|
import { SimOrchestrator } from "./services/sim-orchestrator.service.js";
|
||||||
import { SimTopUpPricingService } from "./services/sim-topup-pricing.service.js";
|
import { SimTopUpPricingService } from "./services/queries/sim-topup-pricing.service.js";
|
||||||
import { SimPlanService } from "./services/sim-plan.service.js";
|
import { SimPlanService } from "./services/mutations/sim-plan.service.js";
|
||||||
import { SimCancellationService } from "./services/sim-cancellation.service.js";
|
import { SimCancellationService } from "./services/mutations/sim-cancellation.service.js";
|
||||||
import { EsimManagementService } from "./services/esim-management.service.js";
|
import { EsimManagementService } from "./services/mutations/esim-management.service.js";
|
||||||
import { FreebitRateLimiterService } from "@bff/integrations/freebit/services/freebit-rate-limiter.service.js";
|
import { FreebitFacade } from "@bff/integrations/freebit/facades/freebit.facade.js";
|
||||||
import { AdminGuard } from "@bff/core/security/guards/admin.guard.js";
|
import { AdminGuard } from "@bff/core/security/guards/admin.guard.js";
|
||||||
import { createZodDto, ZodResponse } from "nestjs-zod";
|
import { createZodDto, ZodResponse } from "nestjs-zod";
|
||||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
||||||
@ -26,12 +26,11 @@ import {
|
|||||||
} from "@customer-portal/domain/subscriptions";
|
} from "@customer-portal/domain/subscriptions";
|
||||||
import type { SimActionResponse, SimPlanChangeResult } from "@customer-portal/domain/subscriptions";
|
import type { SimActionResponse, SimPlanChangeResult } from "@customer-portal/domain/subscriptions";
|
||||||
import {
|
import {
|
||||||
simTopupRequestSchema,
|
simTopUpRequestSchema,
|
||||||
simChangePlanRequestSchema,
|
|
||||||
simCancelRequestSchema,
|
|
||||||
simFeaturesRequestSchema,
|
|
||||||
simTopUpHistoryRequestSchema,
|
|
||||||
simChangePlanFullRequestSchema,
|
simChangePlanFullRequestSchema,
|
||||||
|
simCancelRequestSchema,
|
||||||
|
simFeaturesUpdateRequestSchema,
|
||||||
|
simTopUpHistoryRequestSchema,
|
||||||
simCancelFullRequestSchema,
|
simCancelFullRequestSchema,
|
||||||
simReissueFullRequestSchema,
|
simReissueFullRequestSchema,
|
||||||
simTopUpPricingSchema,
|
simTopUpPricingSchema,
|
||||||
@ -50,10 +49,10 @@ import {
|
|||||||
|
|
||||||
// DTOs
|
// DTOs
|
||||||
class SubscriptionIdParamDto extends createZodDto(subscriptionIdParamSchema) {}
|
class SubscriptionIdParamDto extends createZodDto(subscriptionIdParamSchema) {}
|
||||||
class SimTopupRequestDto extends createZodDto(simTopupRequestSchema) {}
|
class SimTopupRequestDto extends createZodDto(simTopUpRequestSchema) {}
|
||||||
class SimChangePlanRequestDto extends createZodDto(simChangePlanRequestSchema) {}
|
class SimChangePlanRequestDto extends createZodDto(simChangePlanFullRequestSchema) {}
|
||||||
class SimCancelRequestDto extends createZodDto(simCancelRequestSchema) {}
|
class SimCancelRequestDto extends createZodDto(simCancelRequestSchema) {}
|
||||||
class SimFeaturesRequestDto extends createZodDto(simFeaturesRequestSchema) {}
|
class SimFeaturesRequestDto extends createZodDto(simFeaturesUpdateRequestSchema) {}
|
||||||
class SimChangePlanFullRequestDto extends createZodDto(simChangePlanFullRequestSchema) {}
|
class SimChangePlanFullRequestDto extends createZodDto(simChangePlanFullRequestSchema) {}
|
||||||
class SimCancelFullRequestDto extends createZodDto(simCancelFullRequestSchema) {}
|
class SimCancelFullRequestDto extends createZodDto(simCancelFullRequestSchema) {}
|
||||||
class SimReissueFullRequestDto extends createZodDto(simReissueFullRequestSchema) {}
|
class SimReissueFullRequestDto extends createZodDto(simReissueFullRequestSchema) {}
|
||||||
@ -78,12 +77,12 @@ class SimCancellationPreviewResponseDto extends createZodDto(simCancellationPrev
|
|||||||
@Controller("subscriptions")
|
@Controller("subscriptions")
|
||||||
export class SimController {
|
export class SimController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly simManagementService: SimManagementService,
|
private readonly simOrchestrator: SimOrchestrator,
|
||||||
private readonly simTopUpPricingService: SimTopUpPricingService,
|
private readonly simTopUpPricingService: SimTopUpPricingService,
|
||||||
private readonly simPlanService: SimPlanService,
|
private readonly simPlanService: SimPlanService,
|
||||||
private readonly simCancellationService: SimCancellationService,
|
private readonly simCancellationService: SimCancellationService,
|
||||||
private readonly esimManagementService: EsimManagementService,
|
private readonly esimManagementService: EsimManagementService,
|
||||||
private readonly rateLimiter: FreebitRateLimiterService
|
private readonly freebitFacade: FreebitFacade
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ==================== Static SIM Routes (must be before :id routes) ====================
|
// ==================== Static SIM Routes (must be before :id routes) ====================
|
||||||
@ -110,13 +109,13 @@ export class SimController {
|
|||||||
@Get("debug/sim-details/:account")
|
@Get("debug/sim-details/:account")
|
||||||
@UseGuards(AdminGuard)
|
@UseGuards(AdminGuard)
|
||||||
async debugSimDetails(@Param("account") account: string) {
|
async debugSimDetails(@Param("account") account: string) {
|
||||||
return await this.simManagementService.getSimDetailsDebug(account);
|
return await this.simOrchestrator.getSimDetailsDirectly(account);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("debug/sim-rate-limit/clear/:account")
|
@Post("debug/sim-rate-limit/clear/:account")
|
||||||
@UseGuards(AdminGuard)
|
@UseGuards(AdminGuard)
|
||||||
async clearRateLimit(@Param("account") account: string) {
|
async clearRateLimit(@Param("account") account: string) {
|
||||||
await this.rateLimiter.clearRateLimitForAccount(account);
|
await this.freebitFacade.clearRateLimitForAccount(account);
|
||||||
return { message: `Rate limit cleared for account ${account}` };
|
return { message: `Rate limit cleared for account ${account}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -128,25 +127,25 @@ export class SimController {
|
|||||||
@Request() req: RequestWithUser,
|
@Request() req: RequestWithUser,
|
||||||
@Param() params: SubscriptionIdParamDto
|
@Param() params: SubscriptionIdParamDto
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
return this.simManagementService.debugSimSubscription(req.user.id, params.id);
|
return this.simOrchestrator.debugSimSubscription(req.user.id, params.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(":id/sim")
|
@Get(":id/sim")
|
||||||
@ZodResponse({ description: "Get SIM info", type: SimInfoDto })
|
@ZodResponse({ description: "Get SIM info", type: SimInfoDto })
|
||||||
async getSimInfo(@Request() req: RequestWithUser, @Param() params: SubscriptionIdParamDto) {
|
async getSimInfo(@Request() req: RequestWithUser, @Param() params: SubscriptionIdParamDto) {
|
||||||
return this.simManagementService.getSimInfo(req.user.id, params.id);
|
return this.simOrchestrator.getSimInfo(req.user.id, params.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(":id/sim/details")
|
@Get(":id/sim/details")
|
||||||
@ZodResponse({ description: "Get SIM details", type: SimDetailsDto })
|
@ZodResponse({ description: "Get SIM details", type: SimDetailsDto })
|
||||||
async getSimDetails(@Request() req: RequestWithUser, @Param() params: SubscriptionIdParamDto) {
|
async getSimDetails(@Request() req: RequestWithUser, @Param() params: SubscriptionIdParamDto) {
|
||||||
return this.simManagementService.getSimDetails(req.user.id, params.id);
|
return this.simOrchestrator.getSimDetails(req.user.id, params.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(":id/sim/usage")
|
@Get(":id/sim/usage")
|
||||||
@ZodResponse({ description: "Get SIM usage", type: SimUsageDto })
|
@ZodResponse({ description: "Get SIM usage", type: SimUsageDto })
|
||||||
async getSimUsage(@Request() req: RequestWithUser, @Param() params: SubscriptionIdParamDto) {
|
async getSimUsage(@Request() req: RequestWithUser, @Param() params: SubscriptionIdParamDto) {
|
||||||
return this.simManagementService.getSimUsage(req.user.id, params.id);
|
return this.simOrchestrator.getSimUsage(req.user.id, params.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(":id/sim/top-up-history")
|
@Get(":id/sim/top-up-history")
|
||||||
@ -156,7 +155,7 @@ export class SimController {
|
|||||||
@Param() params: SubscriptionIdParamDto,
|
@Param() params: SubscriptionIdParamDto,
|
||||||
@Query() query: SimTopUpHistoryRequestDto
|
@Query() query: SimTopUpHistoryRequestDto
|
||||||
) {
|
) {
|
||||||
return this.simManagementService.getSimTopUpHistory(req.user.id, params.id, query);
|
return this.simOrchestrator.getSimTopUpHistory(req.user.id, params.id, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(":id/sim/top-up")
|
@Post(":id/sim/top-up")
|
||||||
@ -166,7 +165,7 @@ export class SimController {
|
|||||||
@Param() params: SubscriptionIdParamDto,
|
@Param() params: SubscriptionIdParamDto,
|
||||||
@Body() body: SimTopupRequestDto
|
@Body() body: SimTopupRequestDto
|
||||||
): Promise<SimActionResponse> {
|
): Promise<SimActionResponse> {
|
||||||
await this.simManagementService.topUpSim(req.user.id, params.id, body);
|
await this.simOrchestrator.topUpSim(req.user.id, params.id, body);
|
||||||
return { message: "SIM top-up completed successfully" };
|
return { message: "SIM top-up completed successfully" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -177,7 +176,7 @@ export class SimController {
|
|||||||
@Param() params: SubscriptionIdParamDto,
|
@Param() params: SubscriptionIdParamDto,
|
||||||
@Body() body: SimChangePlanRequestDto
|
@Body() body: SimChangePlanRequestDto
|
||||||
): Promise<SimPlanChangeResult> {
|
): Promise<SimPlanChangeResult> {
|
||||||
const result = await this.simManagementService.changeSimPlan(req.user.id, params.id, body);
|
const result = await this.simOrchestrator.changeSimPlan(req.user.id, params.id, body);
|
||||||
return {
|
return {
|
||||||
message: "SIM plan change completed successfully",
|
message: "SIM plan change completed successfully",
|
||||||
...result,
|
...result,
|
||||||
@ -191,7 +190,7 @@ export class SimController {
|
|||||||
@Param() params: SubscriptionIdParamDto,
|
@Param() params: SubscriptionIdParamDto,
|
||||||
@Body() body: SimCancelRequestDto
|
@Body() body: SimCancelRequestDto
|
||||||
): Promise<SimActionResponse> {
|
): Promise<SimActionResponse> {
|
||||||
await this.simManagementService.cancelSim(req.user.id, params.id, body);
|
await this.simOrchestrator.cancelSim(req.user.id, params.id, body);
|
||||||
return { message: "SIM cancellation completed successfully" };
|
return { message: "SIM cancellation completed successfully" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -202,7 +201,7 @@ export class SimController {
|
|||||||
@Param() params: SubscriptionIdParamDto,
|
@Param() params: SubscriptionIdParamDto,
|
||||||
@Body() body: SimReissueEsimRequestDto
|
@Body() body: SimReissueEsimRequestDto
|
||||||
): Promise<SimActionResponse> {
|
): Promise<SimActionResponse> {
|
||||||
await this.simManagementService.reissueEsimProfile(req.user.id, params.id, body.newEid);
|
await this.simOrchestrator.reissueEsimProfile(req.user.id, params.id, body);
|
||||||
return { message: "eSIM profile reissue completed successfully" };
|
return { message: "eSIM profile reissue completed successfully" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -213,7 +212,7 @@ export class SimController {
|
|||||||
@Param() params: SubscriptionIdParamDto,
|
@Param() params: SubscriptionIdParamDto,
|
||||||
@Body() body: SimFeaturesRequestDto
|
@Body() body: SimFeaturesRequestDto
|
||||||
): Promise<SimActionResponse> {
|
): Promise<SimActionResponse> {
|
||||||
await this.simManagementService.updateSimFeatures(req.user.id, params.id, body);
|
await this.simOrchestrator.updateSimFeatures(req.user.id, params.id, body);
|
||||||
return { message: "SIM features updated successfully" };
|
return { message: "SIM features updated successfully" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 type { Metadata } from "next";
|
||||||
import { ServicesGrid } from "@/features/services/components/common/ServicesGrid";
|
import { ServicesOverviewContent } from "@/features/services/components/common/ServicesOverviewContent";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Services for Expats in Japan - Internet, Mobile, VPN & IT Support | Assist Solutions",
|
title: "Services for Expats in Japan - Internet, Mobile, VPN & IT Support | Assist Solutions",
|
||||||
@ -20,25 +20,10 @@ export const metadata: Metadata = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ServicesPageProps {
|
export default function ServicesPage() {
|
||||||
basePath?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ServicesPage({ basePath = "/services" }: ServicesPageProps) {
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto px-4">
|
<div className="max-w-6xl mx-auto px-4 pt-8">
|
||||||
{/* Header */}
|
<ServicesOverviewContent basePath="/services" />
|
||||||
<div className="text-center mb-16 pt-8">
|
|
||||||
<h1 className="text-4xl sm:text-5xl font-extrabold text-foreground mb-6 tracking-tight">
|
|
||||||
Services for Expats in Japan
|
|
||||||
</h1>
|
|
||||||
<p className="text-xl text-muted-foreground max-w-2xl mx-auto leading-relaxed">
|
|
||||||
Tired of navigating Japanese-only websites and contracts? We provide internet, mobile, and
|
|
||||||
IT services with full English support. No Japanese required.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ServicesGrid basePath={basePath} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,7 +11,7 @@ export const metadata: Metadata = {
|
|||||||
},
|
},
|
||||||
description:
|
description:
|
||||||
"One stop IT solution for Japan's international community. Internet, mobile, VPN, and tech support with English service since 2002.",
|
"One stop IT solution for Japan's international community. Internet, mobile, VPN, and tech support with English service since 2002.",
|
||||||
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || "https://portal.asolutions.co.jp"),
|
metadataBase: new URL(process.env["NEXT_PUBLIC_SITE_URL"] || "https://portal.asolutions.co.jp"),
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: "/",
|
canonical: "/",
|
||||||
},
|
},
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import type { MetadataRoute } from "next";
|
|||||||
* Allows all public pages, blocks account/authenticated areas.
|
* Allows all public pages, blocks account/authenticated areas.
|
||||||
*/
|
*/
|
||||||
export default function robots(): MetadataRoute.Robots {
|
export default function robots(): MetadataRoute.Robots {
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://portal.asolutions.co.jp";
|
const baseUrl = process.env["NEXT_PUBLIC_SITE_URL"] || "https://portal.asolutions.co.jp";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rules: [
|
rules: [
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import type { MetadataRoute } from "next";
|
|||||||
* Only includes public pages that should be indexed.
|
* Only includes public pages that should be indexed.
|
||||||
*/
|
*/
|
||||||
export default function sitemap(): MetadataRoute.Sitemap {
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://portal.asolutions.co.jp";
|
const baseUrl = process.env["NEXT_PUBLIC_SITE_URL"] || "https://portal.asolutions.co.jp";
|
||||||
|
|
||||||
// Public pages that should be indexed
|
// Public pages that should be indexed
|
||||||
const publicPages = [
|
const publicPages = [
|
||||||
|
|||||||
@ -278,7 +278,7 @@ export function PublicShell({ children }: PublicShellProps) {
|
|||||||
<span className="font-medium">EN</span>
|
<span className="font-medium">EN</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Auth Button - Desktop */}
|
{/* Auth Buttons - Desktop */}
|
||||||
{isAuthenticated ? (
|
{isAuthenticated ? (
|
||||||
<Link
|
<Link
|
||||||
href="/account"
|
href="/account"
|
||||||
@ -287,12 +287,20 @@ export function PublicShell({ children }: PublicShellProps) {
|
|||||||
My Account
|
My Account
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
|
<div className="hidden md:flex items-center gap-2">
|
||||||
<Link
|
<Link
|
||||||
href="/auth/login"
|
href="/auth/login"
|
||||||
className="hidden md:inline-flex items-center justify-center rounded-full bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground shadow hover:bg-primary/90 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
className="inline-flex items-center justify-center rounded-full border border-input bg-background px-4 py-2 text-sm font-semibold text-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
Sign in
|
Sign in
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/auth/get-started"
|
||||||
|
className="inline-flex items-center justify-center rounded-full bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground shadow hover:bg-primary/90 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
Get Started
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mobile Menu Button */}
|
{/* Mobile Menu Button */}
|
||||||
@ -361,7 +369,7 @@ export function PublicShell({ children }: PublicShellProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-border/40 pt-4">
|
<div className="border-t border-border/40 pt-4 space-y-3">
|
||||||
{isAuthenticated ? (
|
{isAuthenticated ? (
|
||||||
<Link
|
<Link
|
||||||
href="/account"
|
href="/account"
|
||||||
@ -371,13 +379,22 @@ export function PublicShell({ children }: PublicShellProps) {
|
|||||||
My Account
|
My Account
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
<Link
|
<Link
|
||||||
href="/auth/login"
|
href="/auth/get-started"
|
||||||
onClick={closeMobileMenu}
|
onClick={closeMobileMenu}
|
||||||
className="flex items-center justify-center rounded-full bg-primary px-6 py-3 text-base font-semibold text-primary-foreground shadow hover:bg-primary/90 transition-colors"
|
className="flex items-center justify-center rounded-full bg-primary px-6 py-3 text-base font-semibold text-primary-foreground shadow hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
Get Started
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/auth/login"
|
||||||
|
onClick={closeMobileMenu}
|
||||||
|
className="flex items-center justify-center rounded-full border border-input bg-background px-6 py-3 text-base font-semibold text-foreground shadow-sm hover:bg-accent transition-colors"
|
||||||
>
|
>
|
||||||
Sign in
|
Sign in
|
||||||
</Link>
|
</Link>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@ -235,10 +235,10 @@ export function LoginForm({
|
|||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Don't have an account?{" "}
|
Don't have an account?{" "}
|
||||||
<Link
|
<Link
|
||||||
href={`/auth/signup${redirectQuery}`}
|
href={`/auth/get-started${redirectQuery}`}
|
||||||
className="font-medium text-primary hover:text-primary-hover transition-colors duration-200"
|
className="font-medium text-primary hover:text-primary-hover transition-colors duration-200"
|
||||||
>
|
>
|
||||||
Sign up
|
Get started
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
|||||||
@ -24,7 +24,7 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
Check,
|
Check,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Spinner } from "@/components/atoms/Spinner";
|
import { Spinner } from "@/components/atoms";
|
||||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@ -45,7 +45,7 @@ function useInView(options: IntersectionObserverInit = {}) {
|
|||||||
|
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
([entry]) => {
|
([entry]) => {
|
||||||
if (entry.isIntersecting) {
|
if (entry?.isIntersecting) {
|
||||||
setIsInView(true);
|
setIsInView(true);
|
||||||
observer.disconnect(); // triggerOnce
|
observer.disconnect(); // triggerOnce
|
||||||
}
|
}
|
||||||
@ -87,6 +87,14 @@ interface FormErrors {
|
|||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FormTouched {
|
||||||
|
subject?: boolean;
|
||||||
|
name?: boolean;
|
||||||
|
email?: boolean;
|
||||||
|
phone?: boolean;
|
||||||
|
message?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const CONTACT_SUBJECTS = [
|
const CONTACT_SUBJECTS = [
|
||||||
{ value: "", label: "Select a topic*" },
|
{ value: "", label: "Select a topic*" },
|
||||||
{ value: "internet", label: "Internet Service Inquiry" },
|
{ value: "internet", label: "Internet Service Inquiry" },
|
||||||
@ -277,7 +285,7 @@ export function PublicLandingView() {
|
|||||||
message: "",
|
message: "",
|
||||||
});
|
});
|
||||||
const [formErrors, setFormErrors] = useState<FormErrors>({});
|
const [formErrors, setFormErrors] = useState<FormErrors>({});
|
||||||
const [formTouched, setFormTouched] = useState<Record<string, boolean>>({});
|
const [formTouched, setFormTouched] = useState<FormTouched>({});
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [submitStatus, setSubmitStatus] = useState<"idle" | "success" | "error">("idle");
|
const [submitStatus, setSubmitStatus] = useState<"idle" | "success" | "error">("idle");
|
||||||
|
|
||||||
@ -366,12 +374,18 @@ export function PublicLandingView() {
|
|||||||
|
|
||||||
// Touch swipe handlers
|
// Touch swipe handlers
|
||||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||||
touchStartXRef.current = e.touches[0].clientX;
|
const touch = e.touches[0];
|
||||||
touchEndXRef.current = e.touches[0].clientX;
|
if (touch) {
|
||||||
|
touchStartXRef.current = touch.clientX;
|
||||||
|
touchEndXRef.current = touch.clientX;
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleTouchMove = useCallback((e: React.TouchEvent) => {
|
const handleTouchMove = useCallback((e: React.TouchEvent) => {
|
||||||
touchEndXRef.current = e.touches[0].clientX;
|
const touch = e.touches[0];
|
||||||
|
if (touch) {
|
||||||
|
touchEndXRef.current = touch.clientX;
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleTouchEnd = useCallback(() => {
|
const handleTouchEnd = useCallback(() => {
|
||||||
@ -437,7 +451,9 @@ export function PublicLandingView() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Simulate API call - replace with actual endpoint
|
// Simulate API call - replace with actual endpoint
|
||||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
await new Promise(resolve => {
|
||||||
|
setTimeout(resolve, 1500);
|
||||||
|
});
|
||||||
|
|
||||||
// Success
|
// Success
|
||||||
setSubmitStatus("success");
|
setSubmitStatus("success");
|
||||||
@ -495,7 +511,9 @@ export function PublicLandingView() {
|
|||||||
|
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
([entry]) => {
|
([entry]) => {
|
||||||
|
if (entry) {
|
||||||
setShowStickyCTA(!entry.isIntersecting);
|
setShowStickyCTA(!entry.isIntersecting);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ threshold: 0 }
|
{ threshold: 0 }
|
||||||
);
|
);
|
||||||
@ -1101,12 +1119,16 @@ export function PublicLandingView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab content */}
|
{/* Tab content */}
|
||||||
|
{(() => {
|
||||||
|
const currentDownload = supportDownloads[remoteSupportTab];
|
||||||
|
if (!currentDownload) return null;
|
||||||
|
return (
|
||||||
<div className="p-8 lg:p-12">
|
<div className="p-8 lg:p-12">
|
||||||
<div className="flex items-start gap-8 lg:gap-12">
|
<div className="flex items-start gap-8 lg:gap-12">
|
||||||
<div className="h-32 w-32 lg:h-40 lg:w-40 rounded-2xl flex items-center justify-center flex-shrink-0 transition-colors bg-gradient-to-br from-sky-50 to-cyan-100">
|
<div className="h-32 w-32 lg:h-40 lg:w-40 rounded-2xl flex items-center justify-center flex-shrink-0 transition-colors bg-gradient-to-br from-sky-50 to-cyan-100">
|
||||||
<Image
|
<Image
|
||||||
src={supportDownloads[remoteSupportTab].image}
|
src={currentDownload.image}
|
||||||
alt={supportDownloads[remoteSupportTab].title}
|
alt={currentDownload.title}
|
||||||
width={96}
|
width={96}
|
||||||
height={96}
|
height={96}
|
||||||
className="object-contain w-20 h-20 lg:w-24 lg:h-24"
|
className="object-contain w-20 h-20 lg:w-24 lg:h-24"
|
||||||
@ -1114,16 +1136,16 @@ export function PublicLandingView() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-bold uppercase tracking-wide mb-2 text-sky-600">
|
<p className="text-sm font-bold uppercase tracking-wide mb-2 text-sky-600">
|
||||||
{supportDownloads[remoteSupportTab].useCase}
|
{currentDownload.useCase}
|
||||||
</p>
|
</p>
|
||||||
<h3 className="text-2xl lg:text-3xl font-bold text-foreground mb-4">
|
<h3 className="text-2xl lg:text-3xl font-bold text-foreground mb-4">
|
||||||
{supportDownloads[remoteSupportTab].title}
|
{currentDownload.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-base lg:text-lg text-muted-foreground leading-relaxed mb-6">
|
<p className="text-base lg:text-lg text-muted-foreground leading-relaxed mb-6">
|
||||||
{supportDownloads[remoteSupportTab].description}
|
{currentDownload.description}
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href={supportDownloads[remoteSupportTab].href}
|
href={currentDownload.href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center gap-2 rounded-full px-8 py-3.5 text-base font-semibold text-white transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg bg-gradient-to-r from-sky-500 to-cyan-600 hover:from-sky-600 hover:to-cyan-700 shadow-sky-200"
|
className="inline-flex items-center gap-2 rounded-full px-8 py-3.5 text-base font-semibold text-white transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg bg-gradient-to-r from-sky-500 to-cyan-600 hover:from-sky-600 hover:to-cyan-700 shadow-sky-200"
|
||||||
@ -1147,6 +1169,8 @@ export function PublicLandingView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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
|
// Simple loading check: show skeleton until we have data or an error
|
||||||
const isLoading = !servicesCatalog && !error;
|
const isLoading = !servicesCatalog && !error;
|
||||||
const servicesBasePath = useServicesBasePath();
|
const servicesBasePath = useServicesBasePath();
|
||||||
const defaultCtaPath = `${servicesBasePath}/internet/configure`;
|
const defaultCtaPath = `${servicesBasePath}/internet/check-availability`;
|
||||||
const ctaPath = propCtaPath ?? defaultCtaPath;
|
const ctaPath = propCtaPath ?? defaultCtaPath;
|
||||||
|
|
||||||
const internetFeatures: HighlightFeature[] = [
|
const internetFeatures: HighlightFeature[] = [
|
||||||
@ -835,7 +835,7 @@ export function PublicInternetPlansContent({
|
|||||||
maxMonthlyPrice: data.maxPrice,
|
maxMonthlyPrice: data.maxPrice,
|
||||||
description: getTierDescription(tier),
|
description: getTierDescription(tier),
|
||||||
features: getTierFeatures(tier),
|
features: getTierFeatures(tier),
|
||||||
pricingNote: tier === "Platinum" ? "+ equipment fees" : undefined,
|
...(tier === "Platinum" && { pricingNote: "+ equipment fees" }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -892,8 +892,25 @@ export function PublicInternetPlansContent({
|
|||||||
|
|
||||||
// Ensure all three offering types exist with default data
|
// Ensure all three offering types exist with default data
|
||||||
for (const config of offeringTypeConfigs) {
|
for (const config of offeringTypeConfigs) {
|
||||||
if (!offeringData[config.id]) {
|
|
||||||
const defaults = defaultOfferingPrices[config.id];
|
const defaults = defaultOfferingPrices[config.id];
|
||||||
|
if (!defaults) continue;
|
||||||
|
|
||||||
|
const existingData = offeringData[config.id];
|
||||||
|
if (existingData) {
|
||||||
|
// Fill in missing tiers with defaults
|
||||||
|
if (!existingData.tierData["Silver"]) {
|
||||||
|
existingData.tierData["Silver"] = { price: defaults.silver };
|
||||||
|
}
|
||||||
|
if (!existingData.tierData["Gold"]) {
|
||||||
|
existingData.tierData["Gold"] = { price: defaults.gold };
|
||||||
|
}
|
||||||
|
if (!existingData.tierData["Platinum"]) {
|
||||||
|
existingData.tierData["Platinum"] = { price: defaults.platinum };
|
||||||
|
}
|
||||||
|
// Recalculate min price
|
||||||
|
const allPrices = Object.values(existingData.tierData).map(t => t.price);
|
||||||
|
existingData.minPrice = Math.min(...allPrices);
|
||||||
|
} else {
|
||||||
offeringData[config.id] = {
|
offeringData[config.id] = {
|
||||||
minPrice: defaults.silver,
|
minPrice: defaults.silver,
|
||||||
tierData: {
|
tierData: {
|
||||||
@ -902,21 +919,6 @@ export function PublicInternetPlansContent({
|
|||||||
Platinum: { price: defaults.platinum },
|
Platinum: { price: defaults.platinum },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} else {
|
|
||||||
// Fill in missing tiers with defaults
|
|
||||||
const defaults = defaultOfferingPrices[config.id];
|
|
||||||
if (!offeringData[config.id].tierData.Silver) {
|
|
||||||
offeringData[config.id].tierData.Silver = { price: defaults.silver };
|
|
||||||
}
|
|
||||||
if (!offeringData[config.id].tierData.Gold) {
|
|
||||||
offeringData[config.id].tierData.Gold = { price: defaults.gold };
|
|
||||||
}
|
|
||||||
if (!offeringData[config.id].tierData.Platinum) {
|
|
||||||
offeringData[config.id].tierData.Platinum = { price: defaults.platinum };
|
|
||||||
}
|
|
||||||
// Recalculate min price
|
|
||||||
const allPrices = Object.values(offeringData[config.id].tierData).map(t => t.price);
|
|
||||||
offeringData[config.id].minPrice = Math.min(...allPrices);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -931,7 +933,7 @@ export function PublicInternetPlansContent({
|
|||||||
monthlyPrice: tierInfo.price,
|
monthlyPrice: tierInfo.price,
|
||||||
description: getTierDescription(tier),
|
description: getTierDescription(tier),
|
||||||
features: getTierFeatures(tier),
|
features: getTierFeatures(tier),
|
||||||
pricingNote: tier === "Platinum" ? "+ equipment fees" : undefined,
|
...(tier === "Platinum" && { pricingNote: "+ equipment fees" }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
result[offeringType] = {
|
result[offeringType] = {
|
||||||
@ -983,7 +985,7 @@ export function PublicInternetPlansContent({
|
|||||||
tiers={consolidatedPlanData.tiers}
|
tiers={consolidatedPlanData.tiers}
|
||||||
ctaPath={ctaPath}
|
ctaPath={ctaPath}
|
||||||
ctaLabel={ctaLabel}
|
ctaLabel={ctaLabel}
|
||||||
onCtaClick={onCtaClick}
|
{...(onCtaClick && { onCtaClick })}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -187,7 +187,8 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
|||||||
const msg = err.message.toLowerCase();
|
const msg = err.message.toLowerCase();
|
||||||
// Check for rate limiting errors
|
// Check for rate limiting errors
|
||||||
if (msg.includes("30 minutes") || msg.includes("must be requested")) {
|
if (msg.includes("30 minutes") || msg.includes("must be requested")) {
|
||||||
errorMessage = "Please wait 30 minutes between voice/network/plan changes before trying again.";
|
errorMessage =
|
||||||
|
"Please wait 30 minutes between voice/network/plan changes before trying again.";
|
||||||
} else if (msg.includes("another") && msg.includes("in progress")) {
|
} else if (msg.includes("another") && msg.includes("in progress")) {
|
||||||
errorMessage = "Another operation is in progress. Please wait a moment.";
|
errorMessage = "Another operation is in progress. Please wait a moment.";
|
||||||
} else {
|
} else {
|
||||||
@ -316,28 +317,28 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
|||||||
label="Voice Mail"
|
label="Voice Mail"
|
||||||
subtitle={simInfo.details.voiceMailEnabled ? "Enabled" : "Disabled"}
|
subtitle={simInfo.details.voiceMailEnabled ? "Enabled" : "Disabled"}
|
||||||
checked={simInfo.details.voiceMailEnabled || false}
|
checked={simInfo.details.voiceMailEnabled || false}
|
||||||
loading={featureLoading.voiceMail}
|
loading={featureLoading.voiceMail ?? false}
|
||||||
onChange={checked => void updateFeature("voiceMail", checked)}
|
onChange={checked => void updateFeature("voiceMail", checked)}
|
||||||
/>
|
/>
|
||||||
<StatusToggle
|
<StatusToggle
|
||||||
label="Network Type"
|
label="Network Type"
|
||||||
subtitle={simInfo.details.networkType === "5G" ? "5G" : "4G"}
|
subtitle={simInfo.details.networkType === "5G" ? "5G" : "4G"}
|
||||||
checked={simInfo.details.networkType === "5G"}
|
checked={simInfo.details.networkType === "5G"}
|
||||||
loading={featureLoading.networkType}
|
loading={featureLoading.networkType ?? false}
|
||||||
onChange={checked => void updateFeature("networkType", checked ? "5G" : "4G")}
|
onChange={checked => void updateFeature("networkType", checked ? "5G" : "4G")}
|
||||||
/>
|
/>
|
||||||
<StatusToggle
|
<StatusToggle
|
||||||
label="Call Waiting"
|
label="Call Waiting"
|
||||||
subtitle={simInfo.details.callWaitingEnabled ? "Enabled" : "Disabled"}
|
subtitle={simInfo.details.callWaitingEnabled ? "Enabled" : "Disabled"}
|
||||||
checked={simInfo.details.callWaitingEnabled || false}
|
checked={simInfo.details.callWaitingEnabled || false}
|
||||||
loading={featureLoading.callWaiting}
|
loading={featureLoading.callWaiting ?? false}
|
||||||
onChange={checked => void updateFeature("callWaiting", checked)}
|
onChange={checked => void updateFeature("callWaiting", checked)}
|
||||||
/>
|
/>
|
||||||
<StatusToggle
|
<StatusToggle
|
||||||
label="International Roaming"
|
label="International Roaming"
|
||||||
subtitle={simInfo.details.internationalRoamingEnabled ? "Enabled" : "Disabled"}
|
subtitle={simInfo.details.internationalRoamingEnabled ? "Enabled" : "Disabled"}
|
||||||
checked={simInfo.details.internationalRoamingEnabled || false}
|
checked={simInfo.details.internationalRoamingEnabled || false}
|
||||||
loading={featureLoading.internationalRoaming}
|
loading={featureLoading.internationalRoaming ?? false}
|
||||||
onChange={checked => void updateFeature("internationalRoaming", checked)}
|
onChange={checked => void updateFeature("internationalRoaming", checked)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -469,7 +470,14 @@ type StatusToggleProps = {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function StatusToggle({ label, subtitle, checked, onChange, loading, disabled }: StatusToggleProps) {
|
function StatusToggle({
|
||||||
|
label,
|
||||||
|
subtitle,
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
loading,
|
||||||
|
disabled,
|
||||||
|
}: StatusToggleProps) {
|
||||||
const isDisabled = disabled || loading;
|
const isDisabled = disabled || loading;
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
@ -479,13 +487,17 @@ function StatusToggle({ label, subtitle, checked, onChange, loading, disabled }:
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`p-4 bg-card border border-border rounded-lg ${isDisabled ? "opacity-60" : ""}`}>
|
<div
|
||||||
|
className={`p-4 bg-card border border-border rounded-lg ${isDisabled ? "opacity-60" : ""}`}
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-foreground">{label}</p>
|
<p className="font-medium text-foreground">{label}</p>
|
||||||
{subtitle && <p className="text-xs text-muted-foreground mt-0.5">{subtitle}</p>}
|
{subtitle && <p className="text-xs text-muted-foreground mt-0.5">{subtitle}</p>}
|
||||||
</div>
|
</div>
|
||||||
<label className={`relative inline-flex items-center ${isDisabled ? "cursor-not-allowed" : "cursor-pointer"}`}>
|
<label
|
||||||
|
className={`relative inline-flex items-center ${isDisabled ? "cursor-not-allowed" : "cursor-pointer"}`}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={checked}
|
checked={checked}
|
||||||
@ -493,7 +505,9 @@ function StatusToggle({ label, subtitle, checked, onChange, loading, disabled }:
|
|||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
className="sr-only peer"
|
className="sr-only peer"
|
||||||
/>
|
/>
|
||||||
<div className={`w-11 h-6 bg-muted peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-ring/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-background after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-background after:border-border after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary ${loading ? "animate-pulse" : ""}`}></div>
|
<div
|
||||||
|
className={`w-11 h-6 bg-muted peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-ring/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-background after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-background after:border-border after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary ${loading ? "animate-pulse" : ""}`}
|
||||||
|
></div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -264,7 +264,7 @@ export function PublicContactView() {
|
|||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
|
||||||
<FormField
|
<FormField
|
||||||
label="Name"
|
label="Name"
|
||||||
error={form.touched.name ? form.errors.name : undefined}
|
error={form.touched["name"] ? form.errors["name"] : undefined}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
@ -278,7 +278,7 @@ export function PublicContactView() {
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Email"
|
label="Email"
|
||||||
error={form.touched.email ? form.errors.email : undefined}
|
error={form.touched["email"] ? form.errors["email"] : undefined}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
@ -293,7 +293,10 @@ export function PublicContactView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
|
||||||
<FormField label="Phone" error={form.touched.phone ? form.errors.phone : undefined}>
|
<FormField
|
||||||
|
label="Phone"
|
||||||
|
error={form.touched["phone"] ? form.errors["phone"] : undefined}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
value={form.values.phone ?? ""}
|
value={form.values.phone ?? ""}
|
||||||
onChange={e => form.setValue("phone", e.target.value)}
|
onChange={e => form.setValue("phone", e.target.value)}
|
||||||
@ -305,7 +308,7 @@ export function PublicContactView() {
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Subject"
|
label="Subject"
|
||||||
error={form.touched.subject ? form.errors.subject : undefined}
|
error={form.touched["subject"] ? form.errors["subject"] : undefined}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
@ -320,7 +323,7 @@ export function PublicContactView() {
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Message"
|
label="Message"
|
||||||
error={form.touched.message ? form.errors.message : undefined}
|
error={form.touched["message"] ? form.errors["message"] : undefined}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@ -115,8 +115,9 @@ export function mapProductToFreebitPlanCode(
|
|||||||
const gbMatch = source.match(/(\d+)\s*G(?:B)?/i);
|
const gbMatch = source.match(/(\d+)\s*G(?:B)?/i);
|
||||||
if (gbMatch?.[1]) {
|
if (gbMatch?.[1]) {
|
||||||
const tier = gbMatch[1];
|
const tier = gbMatch[1];
|
||||||
if (tier in PLAN_CODE_MAPPING) {
|
const planCode = PLAN_CODE_MAPPING[tier];
|
||||||
return PLAN_CODE_MAPPING[tier];
|
if (planCode) {
|
||||||
|
return planCode;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -124,8 +125,9 @@ export function mapProductToFreebitPlanCode(
|
|||||||
const skuMatch = source.match(/[-_](\d+)[-_]?(?:gb|g)?/i);
|
const skuMatch = source.match(/[-_](\d+)[-_]?(?:gb|g)?/i);
|
||||||
if (skuMatch?.[1]) {
|
if (skuMatch?.[1]) {
|
||||||
const tier = skuMatch[1];
|
const tier = skuMatch[1];
|
||||||
if (tier in PLAN_CODE_MAPPING) {
|
const planCode = PLAN_CODE_MAPPING[tier];
|
||||||
return PLAN_CODE_MAPPING[tier];
|
if (planCode) {
|
||||||
|
return planCode;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user