Add SIM fulfillment service and integrate into order orchestration
- Introduced SimFulfillmentService to handle SIM-specific order fulfillment, including validation and activation via Freebit API. - Updated OrderFulfillmentOrchestrator to include SIM fulfillment logic, adding a new step for SIM orders and integrating the service. - Enhanced order initialization to accommodate SIM-specific configurations and validation in the checkout process. - Refactored existing order orchestration steps to ensure proper handling of SIM-related fulfillment requirements.
This commit is contained in:
commit
15af220369
@ -15,6 +15,7 @@ import { OrderFulfillmentValidator } from "./services/order-fulfillment-validato
|
||||
import { OrderWhmcsMapper } from "./services/order-whmcs-mapper.service";
|
||||
import { OrderFulfillmentOrchestrator } from "./services/order-fulfillment-orchestrator.service";
|
||||
import { OrderFulfillmentErrorService } from "./services/order-fulfillment-error.service";
|
||||
import { SimFulfillmentService } from "./services/sim-fulfillment.service";
|
||||
import { ProvisioningQueueService } from "./queue/provisioning.queue";
|
||||
import { ProvisioningProcessor } from "./queue/provisioning.processor";
|
||||
|
||||
@ -33,6 +34,7 @@ import { ProvisioningProcessor } from "./queue/provisioning.processor";
|
||||
OrderWhmcsMapper,
|
||||
OrderFulfillmentOrchestrator,
|
||||
OrderFulfillmentErrorService,
|
||||
SimFulfillmentService,
|
||||
// Async provisioning queue
|
||||
ProvisioningQueueService,
|
||||
ProvisioningProcessor,
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
} from "./order-fulfillment-validator.service";
|
||||
import { OrderWhmcsMapper, OrderItemMappingResult } from "./order-whmcs-mapper.service";
|
||||
import { OrderFulfillmentErrorService } from "./order-fulfillment-error.service";
|
||||
import { SimFulfillmentService } from "./sim-fulfillment.service";
|
||||
import { getErrorMessage } from "../../common/utils/error.util";
|
||||
import { getSalesforceFieldMap } from "../../common/config/field-map";
|
||||
|
||||
@ -48,7 +49,8 @@ export class OrderFulfillmentOrchestrator {
|
||||
private readonly orderOrchestrator: OrderOrchestrator,
|
||||
private readonly orderFulfillmentValidator: OrderFulfillmentValidator,
|
||||
private readonly orderWhmcsMapper: OrderWhmcsMapper,
|
||||
private readonly orderFulfillmentErrorService: OrderFulfillmentErrorService
|
||||
private readonly orderFulfillmentErrorService: OrderFulfillmentErrorService,
|
||||
private readonly simFulfillmentService: SimFulfillmentService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -63,7 +65,7 @@ export class OrderFulfillmentOrchestrator {
|
||||
sfOrderId,
|
||||
idempotencyKey,
|
||||
validation: {} as OrderFulfillmentValidationResult,
|
||||
steps: this.initializeSteps(),
|
||||
steps: this.initializeSteps(payload.orderType as string),
|
||||
};
|
||||
|
||||
this.logger.log("Starting fulfillment orchestration", {
|
||||
@ -170,7 +172,24 @@ export class OrderFulfillmentOrchestrator {
|
||||
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 () => {
|
||||
const fields = getSalesforceFieldMap();
|
||||
await this.salesforceService.updateOrder({
|
||||
@ -202,16 +221,23 @@ export class OrderFulfillmentOrchestrator {
|
||||
/**
|
||||
* Initialize fulfillment steps
|
||||
*/
|
||||
private initializeSteps(): OrderFulfillmentStep[] {
|
||||
return [
|
||||
private initializeSteps(orderType?: string): OrderFulfillmentStep[] {
|
||||
const steps: OrderFulfillmentStep[] = [
|
||||
{ step: "validation", status: "pending" },
|
||||
{ step: "sf_status_update", status: "pending" },
|
||||
{ step: "order_details", status: "pending" },
|
||||
{ step: "mapping", status: "pending" },
|
||||
{ step: "whmcs_create", 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 }),
|
||||
};
|
||||
|
||||
// Validate required SIM fields for all order types
|
||||
if (orderType === "SIM") {
|
||||
// Validate required SIM fields
|
||||
if (!selections.eid && selections.simType === "eSIM") {
|
||||
throw new Error("EID is required for eSIM activation. Please go back and provide your EID.");
|
||||
}
|
||||
if (!selections.phoneNumber && !selections.mnpPhone) {
|
||||
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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user