340 lines
13 KiB
TypeScript
340 lines
13 KiB
TypeScript
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";
|
|
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,
|
|
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)",
|
|
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)",
|
|
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)",
|
|
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`,
|
|
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 || ""}`,
|
|
sku: body.selections.skuService as string | undefined,
|
|
billingCycle: "monthly",
|
|
quantity: 1,
|
|
});
|
|
items.push({
|
|
itemType: "Installation",
|
|
productHint: "VPN Activation Fee",
|
|
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,
|
|
});
|
|
}
|
|
}
|
|
}
|