Add SIM fulfillment support to order orchestration
- Integrated SimFulfillmentService into OrderFulfillmentOrchestrator to handle SIM-specific fulfillment steps. - Updated initializeSteps method to conditionally include SIM fulfillment based on order type. - Enhanced CheckoutContent to validate required SIM fields for order processing, ensuring necessary data is provided before activation.
This commit is contained in:
parent
bccc476283
commit
ea2fe81606
@ -15,6 +15,7 @@ import { OrderFulfillmentValidator } from "./services/order-fulfillment-validato
|
|||||||
import { OrderWhmcsMapper } from "./services/order-whmcs-mapper.service";
|
import { OrderWhmcsMapper } from "./services/order-whmcs-mapper.service";
|
||||||
import { OrderFulfillmentOrchestrator } from "./services/order-fulfillment-orchestrator.service";
|
import { OrderFulfillmentOrchestrator } from "./services/order-fulfillment-orchestrator.service";
|
||||||
import { OrderFulfillmentErrorService } from "./services/order-fulfillment-error.service";
|
import { OrderFulfillmentErrorService } from "./services/order-fulfillment-error.service";
|
||||||
|
import { SimFulfillmentService } from "./services/sim-fulfillment.service";
|
||||||
import { ProvisioningQueueService } from "./queue/provisioning.queue";
|
import { ProvisioningQueueService } from "./queue/provisioning.queue";
|
||||||
import { ProvisioningProcessor } from "./queue/provisioning.processor";
|
import { ProvisioningProcessor } from "./queue/provisioning.processor";
|
||||||
|
|
||||||
@ -33,6 +34,7 @@ import { ProvisioningProcessor } from "./queue/provisioning.processor";
|
|||||||
OrderWhmcsMapper,
|
OrderWhmcsMapper,
|
||||||
OrderFulfillmentOrchestrator,
|
OrderFulfillmentOrchestrator,
|
||||||
OrderFulfillmentErrorService,
|
OrderFulfillmentErrorService,
|
||||||
|
SimFulfillmentService,
|
||||||
// Async provisioning queue
|
// Async provisioning queue
|
||||||
ProvisioningQueueService,
|
ProvisioningQueueService,
|
||||||
ProvisioningProcessor,
|
ProvisioningProcessor,
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import {
|
|||||||
} from "./order-fulfillment-validator.service";
|
} from "./order-fulfillment-validator.service";
|
||||||
import { OrderWhmcsMapper, OrderItemMappingResult } from "./order-whmcs-mapper.service";
|
import { OrderWhmcsMapper, OrderItemMappingResult } from "./order-whmcs-mapper.service";
|
||||||
import { OrderFulfillmentErrorService } from "./order-fulfillment-error.service";
|
import { OrderFulfillmentErrorService } from "./order-fulfillment-error.service";
|
||||||
|
import { SimFulfillmentService } from "./sim-fulfillment.service";
|
||||||
import { getErrorMessage } from "../../common/utils/error.util";
|
import { getErrorMessage } from "../../common/utils/error.util";
|
||||||
import { getSalesforceFieldMap } from "../../common/config/field-map";
|
import { getSalesforceFieldMap } from "../../common/config/field-map";
|
||||||
|
|
||||||
@ -48,7 +49,8 @@ export class OrderFulfillmentOrchestrator {
|
|||||||
private readonly orderOrchestrator: OrderOrchestrator,
|
private readonly orderOrchestrator: OrderOrchestrator,
|
||||||
private readonly orderFulfillmentValidator: OrderFulfillmentValidator,
|
private readonly orderFulfillmentValidator: OrderFulfillmentValidator,
|
||||||
private readonly orderWhmcsMapper: OrderWhmcsMapper,
|
private readonly orderWhmcsMapper: OrderWhmcsMapper,
|
||||||
private readonly orderFulfillmentErrorService: OrderFulfillmentErrorService
|
private readonly orderFulfillmentErrorService: OrderFulfillmentErrorService,
|
||||||
|
private readonly simFulfillmentService: SimFulfillmentService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -63,7 +65,7 @@ export class OrderFulfillmentOrchestrator {
|
|||||||
sfOrderId,
|
sfOrderId,
|
||||||
idempotencyKey,
|
idempotencyKey,
|
||||||
validation: {} as OrderFulfillmentValidationResult,
|
validation: {} as OrderFulfillmentValidationResult,
|
||||||
steps: this.initializeSteps(),
|
steps: this.initializeSteps(payload.orderType as string),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.logger.log("Starting fulfillment orchestration", {
|
this.logger.log("Starting fulfillment orchestration", {
|
||||||
@ -170,7 +172,24 @@ export class OrderFulfillmentOrchestrator {
|
|||||||
context.whmcsResult = acceptResult;
|
context.whmcsResult = acceptResult;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 7: Update Salesforce with success
|
// Step 7: SIM-specific fulfillment (if applicable)
|
||||||
|
if (context.orderDetails?.orderType === "SIM") {
|
||||||
|
await this.executeStep(context, "sim_fulfillment", async () => {
|
||||||
|
if (!context.orderDetails) {
|
||||||
|
throw new Error("Order details are required for SIM fulfillment");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract configurations from the original payload
|
||||||
|
const configurations = payload.configurations || {};
|
||||||
|
|
||||||
|
await this.simFulfillmentService.fulfillSimOrder({
|
||||||
|
orderDetails: context.orderDetails,
|
||||||
|
configurations,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 8: Update Salesforce with success
|
||||||
await this.executeStep(context, "sf_success_update", async () => {
|
await this.executeStep(context, "sf_success_update", async () => {
|
||||||
const fields = getSalesforceFieldMap();
|
const fields = getSalesforceFieldMap();
|
||||||
await this.salesforceService.updateOrder({
|
await this.salesforceService.updateOrder({
|
||||||
@ -202,16 +221,23 @@ export class OrderFulfillmentOrchestrator {
|
|||||||
/**
|
/**
|
||||||
* Initialize fulfillment steps
|
* Initialize fulfillment steps
|
||||||
*/
|
*/
|
||||||
private initializeSteps(): OrderFulfillmentStep[] {
|
private initializeSteps(orderType?: string): OrderFulfillmentStep[] {
|
||||||
return [
|
const steps: OrderFulfillmentStep[] = [
|
||||||
{ step: "validation", status: "pending" },
|
{ step: "validation", status: "pending" },
|
||||||
{ step: "sf_status_update", status: "pending" },
|
{ step: "sf_status_update", status: "pending" },
|
||||||
{ step: "order_details", status: "pending" },
|
{ step: "order_details", status: "pending" },
|
||||||
{ 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: "sf_success_update", status: "pending" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Add SIM fulfillment step for SIM orders
|
||||||
|
if (orderType === "SIM") {
|
||||||
|
steps.push({ step: "sim_fulfillment", status: "pending" });
|
||||||
|
}
|
||||||
|
|
||||||
|
steps.push({ step: "sf_success_update", status: "pending" });
|
||||||
|
return steps;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
206
apps/bff/src/orders/services/sim-fulfillment.service.ts
Normal file
206
apps/bff/src/orders/services/sim-fulfillment.service.ts
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
import { Injectable, Inject } from "@nestjs/common";
|
||||||
|
import { Logger } from "nestjs-pino";
|
||||||
|
import { FreebititService } from "../../vendors/freebit/freebit.service";
|
||||||
|
import { OrderDetailsDto } from "../types/order-details.dto";
|
||||||
|
import { getSalesforceFieldMap } from "../../common/config/field-map";
|
||||||
|
|
||||||
|
export interface SimFulfillmentRequest {
|
||||||
|
orderDetails: OrderDetailsDto;
|
||||||
|
configurations: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SimFulfillmentService {
|
||||||
|
constructor(
|
||||||
|
private readonly freebit: FreebititService,
|
||||||
|
@Inject(Logger) private readonly logger: Logger
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle SIM-specific fulfillment after WHMCs provisioning
|
||||||
|
*/
|
||||||
|
async fulfillSimOrder(request: SimFulfillmentRequest): Promise<void> {
|
||||||
|
const { orderDetails, configurations } = request;
|
||||||
|
|
||||||
|
this.logger.log("Starting SIM fulfillment", {
|
||||||
|
orderId: orderDetails.id,
|
||||||
|
orderType: orderDetails.orderType,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract SIM-specific configurations
|
||||||
|
const simType = configurations.simType as "eSIM" | "Physical SIM" | undefined;
|
||||||
|
const eid = configurations.eid as string | undefined;
|
||||||
|
const activationType = configurations.activationType as "Immediate" | "Scheduled" | undefined;
|
||||||
|
const scheduledAt = configurations.scheduledAt as string | undefined;
|
||||||
|
const phoneNumber = configurations.mnpPhone as string | undefined;
|
||||||
|
const mnp = this.extractMnpConfig(configurations);
|
||||||
|
const addons = this.extractAddonConfig(configurations);
|
||||||
|
|
||||||
|
// Find the main SIM plan from order items
|
||||||
|
const simPlanItem = orderDetails.items.find(item =>
|
||||||
|
item.product.itemClass === "Plan" || item.product.sku?.toLowerCase().includes("sim")
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!simPlanItem) {
|
||||||
|
throw new Error("No SIM plan found in order items");
|
||||||
|
}
|
||||||
|
|
||||||
|
const planSku = simPlanItem.product.sku;
|
||||||
|
if (!planSku) {
|
||||||
|
throw new Error("SIM plan SKU not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (simType === "eSIM" && (!eid || eid.length < 15)) {
|
||||||
|
throw new Error("EID is required for eSIM and must be valid");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!phoneNumber) {
|
||||||
|
throw new Error("Phone number is required for SIM activation");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform Freebit activation
|
||||||
|
await this.activateSim({
|
||||||
|
account: phoneNumber,
|
||||||
|
eid,
|
||||||
|
planSku,
|
||||||
|
simType: simType || "eSIM",
|
||||||
|
activationType: activationType || "Immediate",
|
||||||
|
scheduledAt,
|
||||||
|
mnp,
|
||||||
|
addons,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log("SIM fulfillment completed successfully", {
|
||||||
|
orderId: orderDetails.id,
|
||||||
|
account: phoneNumber,
|
||||||
|
planSku,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activate SIM via Freebit API
|
||||||
|
*/
|
||||||
|
private async activateSim(params: {
|
||||||
|
account: string;
|
||||||
|
eid?: string;
|
||||||
|
planSku: string;
|
||||||
|
simType: "eSIM" | "Physical SIM";
|
||||||
|
activationType: "Immediate" | "Scheduled";
|
||||||
|
scheduledAt?: string;
|
||||||
|
mnp?: {
|
||||||
|
reserveNumber: string;
|
||||||
|
reserveExpireDate: string;
|
||||||
|
account?: string;
|
||||||
|
firstnameKanji?: string;
|
||||||
|
lastnameKanji?: string;
|
||||||
|
firstnameZenKana?: string;
|
||||||
|
lastnameZenKana?: string;
|
||||||
|
gender?: string;
|
||||||
|
birthday?: string;
|
||||||
|
};
|
||||||
|
addons?: {
|
||||||
|
voiceMail?: boolean;
|
||||||
|
callWaiting?: boolean;
|
||||||
|
};
|
||||||
|
}): Promise<void> {
|
||||||
|
const {
|
||||||
|
account,
|
||||||
|
eid,
|
||||||
|
planSku,
|
||||||
|
simType,
|
||||||
|
activationType,
|
||||||
|
scheduledAt,
|
||||||
|
mnp,
|
||||||
|
addons,
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Activate eSIM if applicable
|
||||||
|
if (simType === "eSIM") {
|
||||||
|
await this.freebit.activateEsimAccountNew({
|
||||||
|
account,
|
||||||
|
eid: eid!,
|
||||||
|
planCode: planSku,
|
||||||
|
contractLine: "5G",
|
||||||
|
shipDate: activationType === "Scheduled" ? scheduledAt : undefined,
|
||||||
|
mnp: mnp ? {
|
||||||
|
reserveNumber: mnp.reserveNumber,
|
||||||
|
reserveExpireDate: mnp.reserveExpireDate,
|
||||||
|
} : undefined,
|
||||||
|
identity: mnp ? {
|
||||||
|
firstnameKanji: mnp.firstnameKanji,
|
||||||
|
lastnameKanji: mnp.lastnameKanji,
|
||||||
|
firstnameZenKana: mnp.firstnameZenKana,
|
||||||
|
lastnameZenKana: mnp.lastnameZenKana,
|
||||||
|
gender: mnp.gender,
|
||||||
|
birthday: mnp.birthday,
|
||||||
|
} : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log("eSIM activated successfully", {
|
||||||
|
account,
|
||||||
|
planSku,
|
||||||
|
scheduled: activationType === "Scheduled",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.logger.warn("Physical SIM activation path is not implemented; skipping Freebit call", {
|
||||||
|
account,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply add-ons (voice options) if selected
|
||||||
|
if (addons && (addons.voiceMail || addons.callWaiting)) {
|
||||||
|
await this.freebit.updateSimFeatures(account, {
|
||||||
|
voiceMailEnabled: !!addons.voiceMail,
|
||||||
|
callWaitingEnabled: !!addons.callWaiting,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log("SIM add-ons applied", {
|
||||||
|
account,
|
||||||
|
voiceMail: addons.voiceMail,
|
||||||
|
callWaiting: addons.callWaiting,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error("SIM activation failed", {
|
||||||
|
account,
|
||||||
|
planSku,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract MNP configuration from order configurations
|
||||||
|
*/
|
||||||
|
private extractMnpConfig(configurations: Record<string, unknown>) {
|
||||||
|
const isMnp = configurations.isMnp;
|
||||||
|
if (!isMnp || isMnp !== "true") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
reserveNumber: configurations.mnpNumber as string | undefined,
|
||||||
|
reserveExpireDate: configurations.mnpExpiry as string | undefined,
|
||||||
|
account: configurations.mvnoAccountNumber as string | undefined,
|
||||||
|
firstnameKanji: configurations.portingFirstName as string | undefined,
|
||||||
|
lastnameKanji: configurations.portingLastName as string | undefined,
|
||||||
|
firstnameZenKana: configurations.portingFirstNameKatakana as string | undefined,
|
||||||
|
lastnameZenKana: configurations.portingLastNameKatakana as string | undefined,
|
||||||
|
gender: configurations.portingGender as string | undefined,
|
||||||
|
birthday: configurations.portingDateOfBirth as string | undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract addon configuration from order configurations
|
||||||
|
*/
|
||||||
|
private extractAddonConfig(configurations: Record<string, unknown>) {
|
||||||
|
// Check if voice addons are present in the configurations
|
||||||
|
// This would need to be determined based on the order items or configurations
|
||||||
|
// For now, return undefined - this can be enhanced based on actual addon detection logic
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -242,57 +242,14 @@ function CheckoutContent() {
|
|||||||
...(Object.keys(configurations).length > 0 && { configurations }),
|
...(Object.keys(configurations).length > 0 && { configurations }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Validate required SIM fields for all order types
|
||||||
if (orderType === "SIM") {
|
if (orderType === "SIM") {
|
||||||
// Validate required SIM fields
|
|
||||||
if (!selections.eid && selections.simType === "eSIM") {
|
if (!selections.eid && selections.simType === "eSIM") {
|
||||||
throw new Error("EID is required for eSIM activation. Please go back and provide your EID.");
|
throw new Error("EID is required for eSIM activation. Please go back and provide your EID.");
|
||||||
}
|
}
|
||||||
if (!selections.phoneNumber && !selections.mnpPhone) {
|
if (!selections.phoneNumber && !selections.mnpPhone) {
|
||||||
throw new Error("Phone number is required for SIM activation. Please go back and provide a phone number.");
|
throw new Error("Phone number is required for SIM activation. Please go back and provide a phone number.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build activation payload for new SIM endpoint
|
|
||||||
const activationPayload: {
|
|
||||||
planSku: string;
|
|
||||||
simType: "eSIM" | "Physical SIM";
|
|
||||||
eid?: string;
|
|
||||||
activationType: "Immediate" | "Scheduled";
|
|
||||||
scheduledAt?: string;
|
|
||||||
msisdn: string;
|
|
||||||
oneTimeAmountJpy: number;
|
|
||||||
monthlyAmountJpy: number;
|
|
||||||
addons?: { voiceMail?: boolean; callWaiting?: boolean };
|
|
||||||
mnp?: { reserveNumber: string; reserveExpireDate: string };
|
|
||||||
} = {
|
|
||||||
planSku: selections.plan,
|
|
||||||
simType: selections.simType as "eSIM" | "Physical SIM",
|
|
||||||
eid: selections.eid,
|
|
||||||
activationType: selections.activationType as "Immediate" | "Scheduled",
|
|
||||||
scheduledAt: selections.scheduledAt,
|
|
||||||
msisdn: selections.phoneNumber || selections.mnpPhone || "",
|
|
||||||
oneTimeAmountJpy: checkoutState.totals.oneTimeTotal, // Activation fee charged immediately
|
|
||||||
monthlyAmountJpy: checkoutState.totals.monthlyTotal, // Monthly subscription fee
|
|
||||||
addons: {
|
|
||||||
voiceMail: (new URLSearchParams(window.location.search).getAll("addonSku") || []).some(
|
|
||||||
sku => sku.toLowerCase().includes("voicemail")
|
|
||||||
),
|
|
||||||
callWaiting: (
|
|
||||||
new URLSearchParams(window.location.search).getAll("addonSku") || []
|
|
||||||
).some(sku => sku.toLowerCase().includes("waiting")),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
if (selections.isMnp === "true") {
|
|
||||||
activationPayload.mnp = {
|
|
||||||
reserveNumber: selections.reservationNumber,
|
|
||||||
reserveExpireDate: selections.expiryDate,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const result = await authenticatedApi.post<{ success: boolean }>(
|
|
||||||
"/subscriptions/sim/orders/activate",
|
|
||||||
activationPayload
|
|
||||||
);
|
|
||||||
router.push(`/orders?status=${result.success ? "success" : "error"}`);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await authenticatedApi.post<{ sfOrderId: string }>("/orders", orderData);
|
const response = await authenticatedApi.post<{ sfOrderId: string }>("/orders", orderData);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user