fix: resolve Order Activation Flow issues

- Fix SF Order locking by deferring Status change to final step
  - executeSfActivatedUpdate now only sets Activation_Status__c
  - executeSfRegistrationComplete sets Status: Processed atomically with WHMCS info

- Add WHMCS custom fields update step (whmcs_custom_fields)
  - AddOrder API expects field IDs, UpdateClientProduct accepts field names
  - New step updates SIM Number, Serial Number, EID after order acceptance

- Add Opportunity WH_Registeration__c field update
  - Sets productselect={serviceId} for WHMCS linking

- Add SIM Inventory assignment fields
  - Assigned_Account__c, Assigned_Order__c, SIM_Type__c now populated

- Remove PA05-18 Semi-Black SIM registration (only Black SIMs used)
  - Changed to direct PA02-01 call with createType=new

- Fix me-status to check for Status: Processed instead of Activated

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Temuulen Ankhbayar 2026-02-05 15:38:59 +09:00
parent df742e50bc
commit 5c67fc34ea
12 changed files with 274 additions and 72 deletions

View File

@ -12,7 +12,12 @@ export interface DevAuthConfig {
skipOtp: boolean; skipOtp: boolean;
} }
export const createDevAuthConfig = (): DevAuthConfig => { /**
* Get the current dev auth configuration.
* This function reads environment variables at call time, not at module load time.
* This is important because .env files are loaded by NestJS ConfigModule after modules are imported.
*/
export const getDevAuthConfig = (): DevAuthConfig => {
const isDevelopment = process.env["NODE_ENV"] !== "production"; const isDevelopment = process.env["NODE_ENV"] !== "production";
return { return {
@ -36,4 +41,7 @@ export const createDevAuthConfig = (): DevAuthConfig => {
}; };
}; };
export const devAuthConfig = createDevAuthConfig(); /**
* @deprecated Use getDevAuthConfig() instead to ensure env vars are read after ConfigModule loads
*/
export const devAuthConfig = getDevAuthConfig();

View File

@ -4,7 +4,7 @@ import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import type { Request, Response, NextFunction } from "express"; import type { Request, Response, NextFunction } from "express";
import { CsrfService } from "../services/csrf.service.js"; import { CsrfService } from "../services/csrf.service.js";
import { devAuthConfig } from "../../config/auth-dev.config.js"; import { getDevAuthConfig } from "../../config/auth-dev.config.js";
interface CsrfRequestBody { interface CsrfRequestBody {
_csrf?: string | string[]; _csrf?: string | string[];
@ -61,8 +61,9 @@ export class CsrfMiddleware implements NestMiddleware {
use(req: CsrfRequest, res: Response, next: NextFunction): void { use(req: CsrfRequest, res: Response, next: NextFunction): void {
// Skip CSRF protection entirely in development if disabled // Skip CSRF protection entirely in development if disabled
if (devAuthConfig.disableCsrf) { const devConfig = getDevAuthConfig();
if (devAuthConfig.enableDebugLogs) { if (devConfig.disableCsrf) {
if (devConfig.enableDebugLogs) {
this.logger.debug("CSRF protection disabled in development", { this.logger.debug("CSRF protection disabled in development", {
method: req.method, method: req.method,
path: req.path, path: req.path,

View File

@ -180,6 +180,7 @@ export class OpportunityMutationService {
const payload: Record<string, unknown> = { const payload: Record<string, unknown> = {
Id: safeOppId, Id: safeOppId,
[OPPORTUNITY_FIELD_MAP.whmcsServiceId]: whmcsServiceId, [OPPORTUNITY_FIELD_MAP.whmcsServiceId]: whmcsServiceId,
[OPPORTUNITY_FIELD_MAP.whmcsRegistrationUrl]: `productselect=${whmcsServiceId}`,
}; };
try { try {

View File

@ -28,6 +28,18 @@ export const SIM_INVENTORY_STATUS = {
export type SimInventoryStatus = (typeof SIM_INVENTORY_STATUS)[keyof typeof SIM_INVENTORY_STATUS]; export type SimInventoryStatus = (typeof SIM_INVENTORY_STATUS)[keyof typeof SIM_INVENTORY_STATUS];
/**
* Optional assignment details when marking a SIM as assigned
*/
export interface SimAssignmentDetails {
/** Salesforce Account ID to assign the SIM to */
accountId?: string;
/** Salesforce Order ID that assigned the SIM */
orderId?: string;
/** SIM Type (eSIM or Physical SIM) */
simType?: string;
}
/** /**
* SIM Inventory record from Salesforce * SIM Inventory record from Salesforce
*/ */
@ -165,19 +177,46 @@ export class SalesforceSIMInventoryService {
/** /**
* Update SIM Inventory status to "Assigned" after successful activation * Update SIM Inventory status to "Assigned" after successful activation
*
* @param simInventoryId - Salesforce ID of the SIM_Inventory__c record
* @param details - Optional assignment details (account, order, SIM type)
*/ */
async markAsAssigned(simInventoryId: string): Promise<void> { async markAsAssigned(simInventoryId: string, details?: SimAssignmentDetails): Promise<void> {
const safeId = assertSalesforceId(simInventoryId, "simInventoryId"); const safeId = assertSalesforceId(simInventoryId, "simInventoryId");
this.logger.log("Marking SIM Inventory as Assigned", { simInventoryId: safeId }); this.logger.log("Marking SIM Inventory as Assigned", {
simInventoryId: safeId,
try { hasAssignmentDetails: !!details,
await this.sf.sobject("SIM_Inventory__c").update?.({ accountId: details?.accountId,
Id: safeId, orderId: details?.orderId,
Status__c: SIM_INVENTORY_STATUS.ASSIGNED, simType: details?.simType,
}); });
this.logger.log("SIM Inventory marked as Assigned", { simInventoryId: safeId }); try {
const updatePayload: Record<string, unknown> = {
Id: safeId,
Status__c: SIM_INVENTORY_STATUS.ASSIGNED,
};
// Add optional assignment fields if provided
if (details?.accountId) {
updatePayload["Assigned_Account__c"] = details.accountId;
}
if (details?.orderId) {
updatePayload["Assigned_Order__c"] = details.orderId;
}
if (details?.simType) {
updatePayload["SIM_Type__c"] = details.simType;
}
await this.sf.sobject("SIM_Inventory__c").update?.(updatePayload as { Id: string });
this.logger.log("SIM Inventory marked as Assigned", {
simInventoryId: safeId,
assignedAccount: details?.accountId,
assignedOrder: details?.orderId,
simType: details?.simType,
});
} catch (error: unknown) { } catch (error: unknown) {
this.logger.error("Failed to update SIM Inventory status", { this.logger.error("Failed to update SIM Inventory status", {
simInventoryId: safeId, simInventoryId: safeId,

View File

@ -184,6 +184,67 @@ export class WhmcsOrderService {
} }
} }
/**
* Update service custom fields after order is accepted
* Uses UpdateClientProduct API which supports field names (not just IDs)
*
* @param serviceId - The WHMCS service ID to update
* @param customFields - Key-value map of custom field names to values
*/
async updateServiceCustomFields(
serviceId: number,
customFields: Record<string, string>
): Promise<void> {
if (!customFields || Object.keys(customFields).length === 0) {
return;
}
this.logger.log("Updating WHMCS service custom fields", {
serviceId,
fieldCount: Object.keys(customFields).length,
fieldNames: Object.keys(customFields),
});
try {
// Serialize custom fields to WHMCS format (base64 encoded PHP serialized array)
const serialized = this.serializeCustomFields(customFields);
await this.connection.makeRequest("UpdateClientProduct", {
serviceid: serviceId,
customfields: serialized,
});
this.logger.log("WHMCS service custom fields updated", {
serviceId,
fields: Object.keys(customFields),
});
} catch (error) {
this.logger.error("Failed to update WHMCS service custom fields", {
error: extractErrorMessage(error),
serviceId,
fieldNames: Object.keys(customFields),
});
throw error;
}
}
/**
* Serialize custom fields to WHMCS format (base64 encoded PHP serialized array)
*/
private serializeCustomFields(data: Record<string, string>): string {
const entries = Object.entries(data).filter(([k, v]) => k && v);
if (entries.length === 0) return "";
const parts = entries.map(([key, value]) => {
const keyBytes = Buffer.byteLength(key, "utf8");
const valueBytes = Buffer.byteLength(value, "utf8");
return `s:${keyBytes}:"${key}";s:${valueBytes}:"${value}";`;
});
const serialized = `a:${entries.length}:{${parts.join("")}}`;
return Buffer.from(serialized).toString("base64");
}
/** /**
* Get order details from WHMCS * Get order details from WHMCS
*/ */

View File

@ -41,7 +41,7 @@ import {
clearTrustedDeviceCookie, clearTrustedDeviceCookie,
getTrustedDeviceToken, getTrustedDeviceToken,
} from "./utils/trusted-device-cookie.util.js"; } from "./utils/trusted-device-cookie.util.js";
import { devAuthConfig } from "@bff/core/config/auth-dev.config.js"; import { getDevAuthConfig } from "@bff/core/config/auth-dev.config.js";
import { TrustedDeviceService } from "../../infra/trusted-device/trusted-device.service.js"; import { TrustedDeviceService } from "../../infra/trusted-device/trusted-device.service.js";
// Import Zod schemas from domain // Import Zod schemas from domain
@ -143,7 +143,7 @@ export class AuthController {
this.applyAuthRateLimitHeaders(req, res); this.applyAuthRateLimitHeaders(req, res);
// In dev mode with SKIP_OTP=true, skip OTP and complete login directly // In dev mode with SKIP_OTP=true, skip OTP and complete login directly
if (devAuthConfig.skipOtp) { if (getDevAuthConfig().skipOtp) {
const loginResult = await this.authOrchestrator.completeLogin( const loginResult = await this.authOrchestrator.completeLogin(
{ id: req.user.id, email: req.user.email, role: req.user.role ?? "USER" }, { id: req.user.id, email: req.user.email, role: req.user.role ?? "USER" },
req req

View File

@ -217,7 +217,7 @@ export class MeStatusAggregator {
o => o =>
o.status === "Draft" || o.status === "Draft" ||
o.status === "Pending" || o.status === "Pending" ||
(o.status === "Activated" && o.activationStatus !== "Completed") (o.status === "Processed" && o.activationStatus !== "Completed")
); );
if (!pendingOrder) { if (!pendingOrder) {

View File

@ -100,12 +100,28 @@ export class FulfillmentStepExecutors {
hasContactIdentity: !!contactIdentity, hasContactIdentity: !!contactIdentity,
}); });
// Build assignment details for SIM Inventory record (only include defined properties)
const assignmentDetails: {
accountId?: string;
orderId?: string;
simType?: string;
} = {
orderId: ctx.sfOrderId,
};
if (ctx.validation?.sfOrder?.AccountId) {
assignmentDetails.accountId = ctx.validation.sfOrder.AccountId;
}
if (ctx.validation?.sfOrder?.SIM_Type__c) {
assignmentDetails.simType = ctx.validation.sfOrder.SIM_Type__c;
}
// Build request with only defined optional properties // Build request with only defined optional properties
const request: Parameters<typeof this.simFulfillmentService.fulfillSimOrder>[0] = { const request: Parameters<typeof this.simFulfillmentService.fulfillSimOrder>[0] = {
orderDetails: ctx.orderDetails, orderDetails: ctx.orderDetails,
configurations, configurations,
voiceMailEnabled, voiceMailEnabled,
callWaitingEnabled, callWaitingEnabled,
assignmentDetails,
}; };
if (assignedPhysicalSimId) { if (assignedPhysicalSimId) {
request.assignedPhysicalSimId = assignedPhysicalSimId; request.assignedPhysicalSimId = assignedPhysicalSimId;
@ -120,7 +136,8 @@ export class FulfillmentStepExecutors {
} }
/** /**
* Update Salesforce order status to "Activated" after SIM fulfillment * Update Salesforce Activation_Status__c after SIM fulfillment
* Note: Status is NOT changed here to avoid locking the Order before WHMCS setup
*/ */
async executeSfActivatedUpdate( async executeSfActivatedUpdate(
ctx: OrderFulfillmentContext, ctx: OrderFulfillmentContext,
@ -130,13 +147,14 @@ export class FulfillmentStepExecutors {
return { skipped: true }; return { skipped: true };
} }
// Only update Activation_Status__c - Status will be set in sf_registration_complete
// to avoid locking the Order before WHMCS info can be written
const result = await this.salesforceFacade.updateOrder({ const result = await this.salesforceFacade.updateOrder({
Id: ctx.sfOrderId, Id: ctx.sfOrderId,
Status: "Activated",
Activation_Status__c: "Activated", Activation_Status__c: "Activated",
}); });
this.sideEffects.publishStatusUpdate(ctx.sfOrderId, { this.sideEffects.publishStatusUpdate(ctx.sfOrderId, {
status: "Activated", status: ctx.validation?.sfOrder?.Status ?? "Pending",
activationStatus: "Activated", activationStatus: "Activated",
stage: "in_progress", stage: "in_progress",
source: "fulfillment", source: "fulfillment",
@ -166,11 +184,15 @@ export class FulfillmentStepExecutors {
// Set phone number as domain (shows in WHMCS Domain field) // Set phone number as domain (shows in WHMCS Domain field)
item.domain = simFulfillmentResult.phoneNumber!; item.domain = simFulfillmentResult.phoneNumber!;
// Also add to custom fields for SIM Number field // Also add to custom fields for SIM Number field
// Note: Field names must match exactly as configured in WHMCS (with spaces)
item.customFields = { item.customFields = {
...item.customFields, ...item.customFields,
SimNumber: simFulfillmentResult.phoneNumber, "SIM Number": simFulfillmentResult.phoneNumber,
...(simFulfillmentResult.serialNumber && { ...(simFulfillmentResult.serialNumber && {
SerialNumber: simFulfillmentResult.serialNumber, "Serial Number": simFulfillmentResult.serialNumber,
}),
...(simFulfillmentResult.eid && {
EID: simFulfillmentResult.eid,
}), }),
}; };
} }
@ -256,33 +278,78 @@ export class FulfillmentStepExecutors {
return { orderId, serviceIds }; return { orderId, serviceIds };
} }
/**
* Update WHMCS service custom fields after order is accepted
* This is necessary because AddOrder expects field IDs, but we use field names
*/
async executeWhmcsCustomFieldsUpdate(
ctx: OrderFulfillmentContext,
whmcsCreateResult?: WhmcsOrderResult,
simFulfillmentResult?: SimFulfillmentResult
): Promise<{ updated: boolean; serviceId?: number }> {
const serviceId = whmcsCreateResult?.serviceIds?.[0];
if (!serviceId) {
this.logger.debug("No WHMCS service ID available for custom fields update", {
sfOrderId: ctx.sfOrderId,
});
return { updated: false };
}
// Build custom fields from SIM fulfillment result
const customFields: Record<string, string> = {};
if (simFulfillmentResult?.phoneNumber) {
customFields["SIM Number"] = simFulfillmentResult.phoneNumber;
}
if (simFulfillmentResult?.serialNumber) {
customFields["Serial Number"] = simFulfillmentResult.serialNumber;
}
if (simFulfillmentResult?.eid) {
customFields["EID"] = simFulfillmentResult.eid;
}
if (Object.keys(customFields).length === 0) {
this.logger.debug("No custom fields to update for WHMCS service", {
sfOrderId: ctx.sfOrderId,
serviceId,
});
return { updated: false, serviceId };
}
this.logger.log("Updating WHMCS service custom fields", {
sfOrderId: ctx.sfOrderId,
serviceId,
fieldNames: Object.keys(customFields),
});
await this.whmcsOrderService.updateServiceCustomFields(serviceId, customFields);
return { updated: true, serviceId };
}
/** /**
* Update Salesforce with WHMCS registration info * Update Salesforce with WHMCS registration info
* This is the final step that sets Status to "Processed"
*/ */
async executeSfRegistrationComplete( async executeSfRegistrationComplete(
ctx: OrderFulfillmentContext, ctx: OrderFulfillmentContext,
whmcsCreateResult?: WhmcsOrderResult, whmcsCreateResult?: WhmcsOrderResult,
simFulfillmentResult?: SimFulfillmentResult // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Parameter kept for interface consistency
_simFulfillmentResult?: SimFulfillmentResult
): Promise<void> { ): Promise<void> {
// For SIM orders that are already "Activated", don't change Status // Set Status to "Processed" and update WHMCS info
// Only update WHMCS info. For non-SIM orders, set Status to "Activated" // Status is set here (not earlier) to avoid locking the Order before WHMCS data is written
const isSIMOrder = ctx.orderDetails?.orderType === "SIM";
const isAlreadyActivated = simFulfillmentResult?.activated === true;
const updatePayload: { Id: string; [key: string]: unknown } = { const updatePayload: { Id: string; [key: string]: unknown } = {
Id: ctx.sfOrderId, Id: ctx.sfOrderId,
Status: "Processed",
Activation_Status__c: "Activated", Activation_Status__c: "Activated",
WHMCS_Order_ID__c: whmcsCreateResult?.orderId?.toString(), 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); const result = await this.salesforceFacade.updateOrder(updatePayload);
this.sideEffects.publishStatusUpdate(ctx.sfOrderId, { this.sideEffects.publishStatusUpdate(ctx.sfOrderId, {
status: "Activated", status: "Processed",
activationStatus: "Activated", activationStatus: "Activated",
stage: "completed", stage: "completed",
source: "fulfillment", source: "fulfillment",
@ -290,7 +357,7 @@ export class FulfillmentStepExecutors {
payload: { payload: {
whmcsOrderId: whmcsCreateResult?.orderId, whmcsOrderId: whmcsCreateResult?.orderId,
whmcsServiceIds: whmcsCreateResult?.serviceIds, whmcsServiceIds: whmcsCreateResult?.serviceIds,
simPhoneNumber: simFulfillmentResult?.phoneNumber, simPhoneNumber: ctx.simFulfillmentResult?.phoneNumber,
}, },
}); });
await this.sideEffects.notifyOrderActivated(ctx.sfOrderId, ctx.validation?.sfOrder?.AccountId); await this.sideEffects.notifyOrderActivated(ctx.sfOrderId, ctx.validation?.sfOrder?.AccountId);

View File

@ -68,10 +68,11 @@ export class FulfillmentStepFactory {
steps.push(this.createSfActivatedUpdateStep(context, state)); steps.push(this.createSfActivatedUpdateStep(context, state));
} }
// Steps 5-9: WHMCS and completion // Steps 5-10: WHMCS and completion
steps.push(this.createMappingStep(context, state)); steps.push(this.createMappingStep(context, state));
steps.push(this.createWhmcsCreateStep(context, state)); steps.push(this.createWhmcsCreateStep(context, state));
steps.push(this.createWhmcsAcceptStep(context, state)); steps.push(this.createWhmcsAcceptStep(context, state));
steps.push(this.createWhmcsCustomFieldsUpdateStep(context, state));
steps.push(this.createSfRegistrationCompleteStep(context, state)); steps.push(this.createSfRegistrationCompleteStep(context, state));
steps.push(this.createOpportunityUpdateStep(context, state)); steps.push(this.createOpportunityUpdateStep(context, state));
@ -130,7 +131,7 @@ export class FulfillmentStepFactory {
): DistributedStep { ): DistributedStep {
return { return {
id: "sf_activated_update", id: "sf_activated_update",
description: "Update Salesforce order status to Activated", description: "Update Salesforce Activation_Status to Activated",
execute: this.createTrackedStep(ctx, "sf_activated_update", async () => { execute: this.createTrackedStep(ctx, "sf_activated_update", async () => {
return this.executors.executeSfActivatedUpdate(ctx, state.simFulfillmentResult); return this.executors.executeSfActivatedUpdate(ctx, state.simFulfillmentResult);
}), }),
@ -201,6 +202,24 @@ export class FulfillmentStepFactory {
}; };
} }
private createWhmcsCustomFieldsUpdateStep(
ctx: OrderFulfillmentContext,
state: StepState
): DistributedStep {
return {
id: "whmcs_custom_fields",
description: "Update WHMCS service custom fields (SIM Number, Serial Number, EID)",
execute: this.createTrackedStep(ctx, "whmcs_custom_fields", async () => {
return this.executors.executeWhmcsCustomFieldsUpdate(
ctx,
state.whmcsCreateResult,
state.simFulfillmentResult
);
}),
critical: false, // Custom fields update failure shouldn't rollback fulfillment
};
}
private createSfRegistrationCompleteStep( private createSfRegistrationCompleteStep(
ctx: OrderFulfillmentContext, ctx: OrderFulfillmentContext,
state: StepState state: StepState

View File

@ -230,6 +230,7 @@ export class OrderFulfillmentOrchestrator {
{ step: "mapping", status: "pending" }, { step: "mapping", status: "pending" },
{ step: "whmcs_create", status: "pending" }, { step: "whmcs_create", status: "pending" },
{ step: "whmcs_accept", status: "pending" }, { step: "whmcs_accept", status: "pending" },
{ step: "whmcs_custom_fields", status: "pending" },
{ step: "sf_registration_complete", status: "pending" }, { step: "sf_registration_complete", status: "pending" },
{ step: "opportunity_update", status: "pending" } { step: "opportunity_update", status: "pending" }
); );

View File

@ -22,6 +22,18 @@ export interface ContactIdentityData {
birthday: string; // YYYYMMDD format birthday: string; // YYYYMMDD format
} }
/**
* Assignment details for Physical SIM inventory
*/
export interface SimAssignmentDetails {
/** Salesforce Account ID to assign the SIM to */
accountId?: string;
/** Salesforce Order ID that assigned the SIM */
orderId?: string;
/** SIM Type (eSIM or Physical SIM) */
simType?: string;
}
export interface SimFulfillmentRequest { export interface SimFulfillmentRequest {
orderDetails: OrderDetails; orderDetails: OrderDetails;
configurations: Record<string, unknown>; configurations: Record<string, unknown>;
@ -33,6 +45,8 @@ export interface SimFulfillmentRequest {
callWaitingEnabled?: boolean; callWaitingEnabled?: boolean;
/** Contact identity data for PA05-05 */ /** Contact identity data for PA05-05 */
contactIdentity?: ContactIdentityData; contactIdentity?: ContactIdentityData;
/** Assignment details for SIM Inventory record (Physical SIM only) */
assignmentDetails?: SimAssignmentDetails;
} }
/** /**
@ -49,6 +63,8 @@ export interface SimFulfillmentResult {
serialNumber?: string; serialNumber?: string;
/** Salesforce SIM Inventory ID */ /** Salesforce SIM Inventory ID */
simInventoryId?: string; simInventoryId?: string;
/** EID for eSIM (for WHMCS custom fields) */
eid?: string;
} }
@Injectable() @Injectable()
@ -67,6 +83,7 @@ export class SimFulfillmentService {
voiceMailEnabled = false, voiceMailEnabled = false,
callWaitingEnabled = false, callWaitingEnabled = false,
contactIdentity, contactIdentity,
assignmentDetails,
} = request; } = request;
const simType = this.readEnum(configurations["simType"], ["eSIM", "Physical SIM"]); const simType = this.readEnum(configurations["simType"], ["eSIM", "Physical SIM"]);
@ -154,6 +171,7 @@ export class SimFulfillmentService {
activated: true, activated: true,
simType: "eSIM", simType: "eSIM",
phoneNumber, phoneNumber,
eid,
}; };
} else { } else {
// Physical SIM activation flow (PA05-18 + PA02-01 + PA05-05) // Physical SIM activation flow (PA05-18 + PA02-01 + PA05-05)
@ -172,6 +190,7 @@ export class SimFulfillmentService {
voiceMailEnabled, voiceMailEnabled,
callWaitingEnabled, callWaitingEnabled,
contactIdentity, contactIdentity,
assignmentDetails,
}); });
this.logger.log("Physical SIM fulfillment completed successfully", { this.logger.log("Physical SIM fulfillment completed successfully", {
@ -259,19 +278,15 @@ export class SimFulfillmentService {
} }
/** /**
* Activate Physical SIM via Freebit PA05-18 + PA02-01 + PA05-05 APIs * Activate Physical SIM (Black SIM) via Freebit PA02-01 + PA05-05 APIs
* *
* Flow for Physical SIMs: * Flow for Physical SIMs (Black 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 PA05-18 (Semi-Black Registration) - MUST be called first! * 4. Call Freebit PA02-01 (Account Registration) with createType="new"
* 5. Call Freebit PA02-01 (Account Registration) with createType="add" * 5. Call Freebit PA05-05 (Voice Options) to configure voice features
* 6. Call Freebit PA05-05 (Voice Options) to configure voice features * 6. Update SIM Inventory status to "Assigned"
* 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;
@ -281,6 +296,7 @@ export class SimFulfillmentService {
voiceMailEnabled: boolean; voiceMailEnabled: boolean;
callWaitingEnabled: boolean; callWaitingEnabled: boolean;
contactIdentity?: ContactIdentityData | undefined; contactIdentity?: ContactIdentityData | undefined;
assignmentDetails?: SimAssignmentDetails | undefined;
}): Promise<{ phoneNumber: string; serialNumber: string }> { }): Promise<{ phoneNumber: string; serialNumber: string }> {
const { const {
orderId, orderId,
@ -290,9 +306,10 @@ export class SimFulfillmentService {
voiceMailEnabled, voiceMailEnabled,
callWaitingEnabled, callWaitingEnabled,
contactIdentity, contactIdentity,
assignmentDetails,
} = params; } = params;
this.logger.log("Starting Physical SIM activation (PA05-18 + PA02-01 + PA05-05)", { this.logger.log("Starting Physical SIM activation (PA02-01 + PA05-05)", {
orderId, orderId,
simInventoryId, simInventoryId,
planSku, planSku,
@ -315,50 +332,27 @@ 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,
productNumber,
planCode, planCode,
}); });
try { try {
// Step 4: Call Freebit PA05-18 (Semi-Black Registration) - MUST be first! // Step 4: Call Freebit PA02-01 (Account Registration) for Black SIM
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", createType: "new",
}); });
await this.freebitFacade.registerAccount({ await this.freebitFacade.registerAccount({
account: accountPhoneNumber, account: accountPhoneNumber,
planCode, planCode,
createType: "add", createType: "new",
}); });
this.logger.log("PA02-01 Account Registration successful", { this.logger.log("PA02-01 Account Registration successful", {
@ -366,7 +360,7 @@ export class SimFulfillmentService {
account: accountPhoneNumber, account: accountPhoneNumber,
}); });
// Step 6: Call Freebit PA05-05 (Voice Options Registration) // Step 5: 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", {
@ -401,8 +395,8 @@ export class SimFulfillmentService {
}); });
} }
// Step 7: Update SIM Inventory status to "Assigned" // Step 6: Update SIM Inventory status to "Assigned" with assignment details
await this.simInventory.markAsAssigned(simInventoryId); await this.simInventory.markAsAssigned(simInventoryId, assignmentDetails);
this.logger.log("Physical SIM activated successfully", { this.logger.log("Physical SIM activated successfully", {
orderId, orderId,

View File

@ -6,3 +6,14 @@ catalog:
"@types/node": ^25.2.0 "@types/node": ^25.2.0
typescript: ^5.9.3 typescript: ^5.9.3
zod: ^4.3.6 zod: ^4.3.6
onlyBuiltDependencies:
- "@nestjs/core"
- "@prisma/engines"
- "@swc/core"
- argon2
- esbuild
- prisma
- protobufjs
- sharp
- ssh2
- unrs-resolver