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:
tema 2025-09-11 14:52:26 +09:00
parent bccc476283
commit ea2fe81606
4 changed files with 241 additions and 50 deletions

View File

@ -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,

View File

@ -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;
}
/**

View 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;
}
}

View File

@ -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);