Assist_Design/apps/bff/src/orders/orders.service.ts

340 lines
13 KiB
TypeScript
Raw Normal View History

import { BadRequestException, Injectable, NotFoundException, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { SalesforceConnection } from "../vendors/salesforce/services/salesforce-connection.service";
import { MappingsService } from "../mappings/mappings.service";
import { getErrorMessage } from "../common/utils/error.util";
import { WhmcsConnectionService } from "../vendors/whmcs/services/whmcs-connection.service";
interface CreateOrderBody {
orderType: "Internet" | "eSIM" | "SIM" | "VPN" | "Other";
2025-08-23 18:02:05 +09:00
selections: Record<string, unknown>;
opportunityId?: string;
}
@Injectable()
export class OrdersService {
constructor(
@Inject(Logger) private readonly logger: Logger,
private readonly sf: SalesforceConnection,
private readonly mappings: MappingsService,
private readonly whmcs: WhmcsConnectionService
) {}
async create(userId: string, body: CreateOrderBody) {
this.logger.log({ userId, orderType: body.orderType }, "Creating order request received");
// 1) Validate mapping
const mapping = await this.mappings.findByUserId(userId);
if (!mapping?.sfAccountId || !mapping?.whmcsClientId) {
this.logger.warn({ userId, mapping }, "Missing SF/WHMCS mapping for user");
throw new BadRequestException("User is not fully linked to Salesforce/WHMCS");
}
// 2) Guards: ensure payment method exists and single Internet per account (if Internet)
try {
// Check client has at least one payment method (best-effort; will be enforced again at provision time)
const pay = await this.whmcs.getPayMethods({ clientid: mapping.whmcsClientId });
if (
!pay?.paymethods ||
!Array.isArray(pay.paymethods.paymethod) ||
pay.paymethods.paymethod.length === 0
) {
this.logger.warn({ userId }, "No WHMCS payment method on file");
throw new BadRequestException("A payment method is required before ordering");
}
} catch (e) {
this.logger.warn(
{ err: getErrorMessage(e) },
"Payment method check soft-failed; proceeding cautiously"
);
}
if (body.orderType === "Internet") {
try {
const products = await this.whmcs.getClientsProducts({ clientid: mapping.whmcsClientId });
const existing = products?.products?.product || [];
const hasInternet = existing.some((p: any) =>
String(p.groupname || "")
.toLowerCase()
.includes("internet")
);
if (hasInternet) {
throw new BadRequestException("An Internet service already exists for this account");
}
} catch (e) {
this.logger.warn({ err: getErrorMessage(e) }, "Internet duplicate check soft-failed");
}
}
// 3) Determine Portal pricebook
const pricebook = await this.findPortalPricebookId();
if (!pricebook) {
throw new NotFoundException("Portal pricebook not found or inactive");
}
// 4) Build Order fields from selections (header)
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
const orderFields: any = {
AccountId: mapping.sfAccountId,
EffectiveDate: today,
Status: "Pending Review",
Pricebook2Id: pricebook,
Order_Type__c: body.orderType,
...(body.opportunityId ? { OpportunityId: body.opportunityId } : {}),
};
// Activation
if (body.selections.activationType)
orderFields.Activation_Type__c = body.selections.activationType;
if (body.selections.scheduledAt)
orderFields.Activation_Scheduled_At__c = body.selections.scheduledAt;
orderFields.Activation_Status__c = "Not Started";
// Internet config
if (body.orderType === "Internet") {
if (body.selections.tier) orderFields.Internet_Plan_Tier__c = body.selections.tier;
if (body.selections.mode) orderFields.Access_Mode__c = body.selections.mode;
if (body.selections.speed) orderFields.Service_Speed__c = body.selections.speed;
if (body.selections.install) orderFields.Installment_Plan__c = body.selections.install;
if (typeof body.selections.weekend !== "undefined")
orderFields.Weekend_Install__c =
body.selections.weekend === "true" || body.selections.weekend === true;
if (body.selections.install === "12-Month") orderFields.Installment_Months__c = 12;
if (body.selections.install === "24-Month") orderFields.Installment_Months__c = 24;
}
// SIM/eSIM config
if (body.orderType === "eSIM" || body.orderType === "SIM") {
if (body.selections.simType)
orderFields.SIM_Type__c = body.selections.simType === "eSIM" ? "eSIM" : "Physical SIM";
if (body.selections.eid) orderFields.EID__c = body.selections.eid;
if (body.selections.isMnp === "true" || body.selections.isMnp === true) {
orderFields.MNP_Application__c = true;
if (body.selections.mnpNumber)
orderFields.MNP_Reservation_Number__c = body.selections.mnpNumber;
if (body.selections.mnpExpiry) orderFields.MNP_Expiry_Date__c = body.selections.mnpExpiry;
if (body.selections.mnpPhone) orderFields.MNP_Phone_Number__c = body.selections.mnpPhone;
}
}
// 5) Create Order in Salesforce
try {
const created = await this.sf.sobject("Order").create(orderFields);
if (!created?.id) {
throw new Error("Salesforce did not return Order Id");
}
this.logger.log({ orderId: created.id }, "Salesforce Order created");
// 6) Create OrderItems from header configuration
await this.createOrderItems(created.id, body);
return { sfOrderId: created.id, status: "Pending Review" };
} catch (error) {
this.logger.error(
{ err: getErrorMessage(error), orderFields },
"Failed to create Salesforce Order"
);
throw error;
}
}
async get(userId: string, sfOrderId: string) {
try {
const soql = `SELECT Id, Status, Activation_Status__c, Activation_Type__c, Activation_Scheduled_At__c, WHMCS_Order_ID__c FROM Order WHERE Id='${sfOrderId}'`;
const res = await this.sf.query(soql);
if (!res.records?.length) throw new NotFoundException("Order not found");
const o = res.records[0];
return {
sfOrderId: o.Id,
status: o.Status,
activationStatus: o.Activation_Status__c,
activationType: o.Activation_Type__c,
scheduledAt: o.Activation_Scheduled_At__c,
whmcsOrderId: o.WHMCS_Order_ID__c,
};
} catch (error) {
this.logger.error(
{ err: getErrorMessage(error), sfOrderId },
"Failed to fetch order summary"
);
throw error;
}
}
async provision(userId: string, sfOrderId: string) {
this.logger.log({ userId, sfOrderId }, "Provision request received");
// 1) Fetch Order details from Salesforce
const soql = `SELECT Id, Status, AccountId, Activation_Type__c, Activation_Scheduled_At__c, Order_Type__c, WHMCS_Order_ID__c FROM Order WHERE Id='${sfOrderId}'`;
const res = await this.sf.query(soql);
if (!res.records?.length) throw new NotFoundException("Order not found");
const order = res.records[0];
// 2) Validate allowed state
if (
order.Status !== "Activated" &&
order.Status !== "Accepted" &&
order.Status !== "Pending Review"
) {
throw new BadRequestException("Order is not in a provisionable state");
}
// 3) Log and return a placeholder; actual WHMCS AddOrder/AcceptOrder will be wired by Flow trigger
this.logger.log(
{ sfOrderId, orderType: order.Order_Type__c },
"Provisioning not yet implemented; placeholder success"
);
return { sfOrderId, status: "Accepted", message: "Provisioning queued" };
}
private async findPortalPricebookId(): Promise<string | null> {
try {
const name = process.env.PORTAL_PRICEBOOK_NAME || "Portal";
const soql = `SELECT Id, Name FROM Pricebook2 WHERE IsActive = true AND Name LIKE '%${name}%' LIMIT 1`;
const result = await this.sf.query(soql);
if (result.records?.length) return result.records[0].Id;
// fallback to Standard Price Book
const std = await this.sf.query(
"SELECT Id FROM Pricebook2 WHERE IsStandard = true AND IsActive = true LIMIT 1"
);
return std.records?.[0]?.Id || null;
} catch (error) {
this.logger.error({ err: getErrorMessage(error) }, "Failed to find pricebook");
return null;
}
}
private async findPricebookEntryId(
pricebookId: string,
product2NameLike: string
): Promise<string | null> {
const soql = `SELECT Id FROM PricebookEntry WHERE Pricebook2Id='${pricebookId}' AND IsActive=true AND Product2.Name LIKE '%${product2NameLike.replace("'", "")}%' LIMIT 1`;
const res = await this.sf.query(soql);
return res.records?.[0]?.Id || null;
}
private async findPricebookEntryBySku(pricebookId: string, sku: string): Promise<string | null> {
if (!sku) return null;
const skuField = process.env.PRODUCT_SKU_FIELD || "SKU__c";
const safeSku = sku.replace(/'/g, "\\'");
const soql = `SELECT Id FROM PricebookEntry WHERE Pricebook2Id='${pricebookId}' AND IsActive=true AND Product2.${skuField} = '${safeSku}' LIMIT 1`;
const res = await this.sf.query(soql);
return res.records?.[0]?.Id || null;
}
private async createOpportunity(
accountId: string,
body: CreateOrderBody
): Promise<string | null> {
try {
const now = new Date();
const name = `${body.orderType} Service ${now.toISOString().slice(0, 10)}`;
const opp = await this.sf.sobject("Opportunity").create({
Name: name,
AccountId: accountId,
StageName: "Qualification",
CloseDate: now.toISOString().slice(0, 10),
Description: `Created from portal for ${body.orderType}`,
});
return opp?.id || null;
} catch (e) {
this.logger.error({ err: getErrorMessage(e) }, "Failed to create Opportunity");
return null;
}
}
private async createOrderItems(orderId: string, body: CreateOrderBody): Promise<void> {
// Minimal SKU resolution using Product2.Name LIKE; in production, prefer Product2 external codes
const pricebookId = await this.findPortalPricebookId();
if (!pricebookId) return;
const items: Array<{
itemType: string;
productHint?: string;
sku?: string;
billingCycle: string;
quantity: number;
}> = [];
if (body.orderType === "Internet") {
// Service line
const svcHint = `Internet ${body.selections.tier || ""} ${body.selections.mode || ""}`.trim();
items.push({
itemType: "Service",
productHint: svcHint,
2025-08-23 18:02:05 +09:00
sku: body.selections.skuService as string | undefined,
billingCycle: "monthly",
quantity: 1,
});
// Installation line
const install = body.selections.install as string;
if (install === "One-time") {
items.push({
itemType: "Installation",
productHint: "Installation Fee (Single)",
2025-08-23 18:02:05 +09:00
sku: body.selections.skuInstall as string | undefined,
billingCycle: "onetime",
quantity: 1,
});
} else if (install === "12-Month") {
items.push({
itemType: "Installation",
productHint: "Installation Fee (12-Month)",
2025-08-23 18:02:05 +09:00
sku: body.selections.skuInstall as string | undefined,
billingCycle: "monthly",
quantity: 1,
});
} else if (install === "24-Month") {
items.push({
itemType: "Installation",
productHint: "Installation Fee (24-Month)",
2025-08-23 18:02:05 +09:00
sku: body.selections.skuInstall as string | undefined,
billingCycle: "monthly",
quantity: 1,
});
}
} else if (body.orderType === "eSIM" || body.orderType === "SIM") {
items.push({
itemType: "Service",
productHint: `${body.orderType} Plan`,
2025-08-23 18:02:05 +09:00
sku: body.selections.skuService as string | undefined,
billingCycle: "monthly",
quantity: 1,
});
} else if (body.orderType === "VPN") {
items.push({
itemType: "Service",
productHint: `VPN ${body.selections.region || ""}`,
2025-08-23 18:02:05 +09:00
sku: body.selections.skuService as string | undefined,
billingCycle: "monthly",
quantity: 1,
});
items.push({
itemType: "Installation",
productHint: "VPN Activation Fee",
2025-08-23 18:02:05 +09:00
sku: body.selections.skuInstall as string | undefined,
billingCycle: "onetime",
quantity: 1,
});
}
for (const it of items) {
if (!it.sku) {
this.logger.warn({ itemType: it.itemType }, "Missing SKU for order item");
throw new BadRequestException("Missing SKU for order item");
}
const pbe = await this.findPricebookEntryBySku(pricebookId, it.sku);
if (!pbe) {
this.logger.error({ sku: it.sku }, "PricebookEntry not found for SKU");
throw new NotFoundException(`PricebookEntry not found for SKU ${it.sku}`);
}
await this.sf.sobject("OrderItem").create({
OrderId: orderId,
PricebookEntryId: pbe,
Quantity: it.quantity,
UnitPrice: null, // Salesforce will use the PBE price; null keeps pricebook price
Billing_Cycle__c: it.billingCycle.toLowerCase() === "onetime" ? "Onetime" : "Monthly",
Item_Type__c: it.itemType,
});
}
}
}