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:
parent
df742e50bc
commit
5c67fc34ea
@ -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();
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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" }
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user