- Updated AuthService to directly access address fields and added support for address line 2. - Introduced AddressDto in SignupDto for structured address validation. - Modified OrdersController to utilize CreateOrderDto for improved type safety. - Enhanced OrderBuilder to include address snapshot functionality during order creation. - Updated UsersService to handle address updates and added new methods in WHMCS service for client updates. - Improved address confirmation logic in AddressConfirmation component for internet orders.
209 lines
6.5 KiB
TypeScript
209 lines
6.5 KiB
TypeScript
import { Injectable, BadRequestException, NotFoundException, Inject } from "@nestjs/common";
|
|
import { Logger } from "nestjs-pino";
|
|
import { SalesforceConnection } from "../../vendors/salesforce/services/salesforce-connection.service";
|
|
import { getSalesforceFieldMap } from "../../common/config/field-map";
|
|
// Removed unused import: CreateOrderBody
|
|
|
|
/**
|
|
* Handles building order items from SKU data
|
|
*/
|
|
@Injectable()
|
|
export class OrderItemBuilder {
|
|
constructor(
|
|
@Inject(Logger) private readonly logger: Logger,
|
|
private readonly sf: SalesforceConnection
|
|
) {}
|
|
|
|
/**
|
|
* Create OrderItems directly from SKU array
|
|
*/
|
|
async createOrderItemsFromSKUs(
|
|
orderId: string,
|
|
skus: string[],
|
|
pricebookId: string
|
|
): Promise<void> {
|
|
if (skus.length === 0) {
|
|
throw new BadRequestException("No products specified for order");
|
|
}
|
|
|
|
this.logger.log({ orderId, skus }, "Creating OrderItems from SKU array");
|
|
|
|
// Create OrderItems for each SKU
|
|
for (const sku of skus) {
|
|
const meta = await this.getProductMetaBySku(pricebookId, sku);
|
|
|
|
if (!meta?.pbeId) {
|
|
this.logger.error({ sku }, "PricebookEntry not found for SKU");
|
|
throw new NotFoundException(`Product not found: ${sku}`);
|
|
}
|
|
|
|
if (!meta.unitPrice) {
|
|
this.logger.error({ sku, pbeId: meta.pbeId }, "PricebookEntry missing UnitPrice");
|
|
throw new Error(`PricebookEntry for SKU ${sku} has no UnitPrice set`);
|
|
}
|
|
|
|
this.logger.log(
|
|
{
|
|
sku,
|
|
pbeId: meta.pbeId,
|
|
unitPrice: meta.unitPrice,
|
|
},
|
|
"Creating OrderItem"
|
|
);
|
|
|
|
try {
|
|
// Salesforce requires explicit UnitPrice even with PricebookEntryId
|
|
await this.sf.sobject("OrderItem").create({
|
|
OrderId: orderId,
|
|
PricebookEntryId: meta.pbeId,
|
|
Quantity: 1,
|
|
UnitPrice: meta.unitPrice,
|
|
});
|
|
|
|
this.logger.log({ orderId, sku }, "OrderItem created successfully");
|
|
} catch (error) {
|
|
this.logger.error({ error, orderId, sku }, "Failed to create OrderItem");
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Find Portal pricebook ID
|
|
*/
|
|
async findPortalPricebookId(): Promise<string> {
|
|
const name = process.env.PORTAL_PRICEBOOK_NAME || "Portal";
|
|
const soql = `SELECT Id, Name FROM Pricebook2 WHERE IsActive = true AND Name LIKE '%${name}%' LIMIT 1`;
|
|
|
|
try {
|
|
const result = (await this.sf.query(soql)) as { records?: Array<{ Id?: string }> };
|
|
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"
|
|
)) as { records?: Array<{ Id?: string }> };
|
|
|
|
return std.records?.[0]?.Id || "";
|
|
} catch (error) {
|
|
this.logger.error({ error }, "Failed to find pricebook");
|
|
throw new NotFoundException("Portal pricebook not found or inactive");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get product metadata by SKU
|
|
*/
|
|
async getProductMetaBySku(
|
|
pricebookId: string,
|
|
sku: string
|
|
): Promise<{
|
|
pbeId: string;
|
|
product2Id: string;
|
|
unitPrice?: number;
|
|
itemClass?: string;
|
|
internetOfferingType?: string;
|
|
internetPlanTier?: string;
|
|
vpnRegion?: string;
|
|
} | null> {
|
|
if (!sku) return null;
|
|
|
|
const fields = getSalesforceFieldMap();
|
|
const safeSku = sku.replace(/'/g, "\\'");
|
|
|
|
const soql =
|
|
`SELECT Id, Product2Id, UnitPrice, ` +
|
|
`Product2.${fields.product.itemClass}, ` +
|
|
`Product2.${fields.product.internetOfferingType}, ` +
|
|
`Product2.${fields.product.internetPlanTier}, ` +
|
|
`Product2.${fields.product.vpnRegion} ` +
|
|
`FROM PricebookEntry ` +
|
|
`WHERE Pricebook2Id='${pricebookId}' AND IsActive=true AND Product2.${fields.product.sku}='${safeSku}' ` +
|
|
`LIMIT 1`;
|
|
|
|
try {
|
|
this.logger.debug({ sku, pricebookId }, "Querying PricebookEntry for SKU");
|
|
|
|
const res = (await this.sf.query(soql)) as {
|
|
records?: Array<{
|
|
Id?: string;
|
|
Product2Id?: string;
|
|
UnitPrice?: number;
|
|
Product2?: Record<string, unknown>;
|
|
}>;
|
|
};
|
|
|
|
this.logger.debug(
|
|
{
|
|
sku,
|
|
found: !!res.records?.length,
|
|
hasPrice: !!(res.records?.[0] as { UnitPrice?: number })?.UnitPrice,
|
|
},
|
|
"PricebookEntry query result"
|
|
);
|
|
|
|
const rec = res.records?.[0];
|
|
if (!rec?.Id) return null;
|
|
|
|
return {
|
|
pbeId: rec.Id,
|
|
product2Id: rec.Product2Id || "",
|
|
unitPrice: rec.UnitPrice,
|
|
itemClass: (() => {
|
|
const value = ((rec as Record<string, unknown>).Product2 as Record<string, unknown>)?.[
|
|
fields.product.itemClass
|
|
];
|
|
return typeof value === "string" ? value : "";
|
|
})(),
|
|
internetOfferingType: (() => {
|
|
const value = ((rec as Record<string, unknown>).Product2 as Record<string, unknown>)?.[
|
|
fields.product.internetOfferingType
|
|
];
|
|
return typeof value === "string" ? value : "";
|
|
})(),
|
|
internetPlanTier: (() => {
|
|
const value = ((rec as Record<string, unknown>).Product2 as Record<string, unknown>)?.[
|
|
fields.product.internetPlanTier
|
|
];
|
|
return typeof value === "string" ? value : "";
|
|
})(),
|
|
vpnRegion: (() => {
|
|
const value = ((rec as Record<string, unknown>).Product2 as Record<string, unknown>)?.[
|
|
fields.product.vpnRegion
|
|
];
|
|
return typeof value === "string" ? value : "";
|
|
})(),
|
|
};
|
|
} catch (error) {
|
|
this.logger.error({ error, sku }, "Failed to get product metadata");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get service product metadata from SKU array for order header defaults
|
|
*/
|
|
async getServiceProductMetaFromSKUs(skus: string[], pricebookId: string) {
|
|
// Find main service SKU (usually first, or contains service indicators)
|
|
const serviceSku =
|
|
skus.find(
|
|
sku =>
|
|
!sku.toUpperCase().includes("INSTALL") &&
|
|
!sku.toUpperCase().includes("ACTIVATION") &&
|
|
!sku.toUpperCase().includes("ADDON")
|
|
) || skus[0];
|
|
|
|
// Find installation SKU
|
|
const installSku = skus.find(sku => sku.toUpperCase().includes("INSTALL"));
|
|
|
|
const [serviceProduct, installProduct] = await Promise.all([
|
|
serviceSku ? this.getProductMetaBySku(pricebookId, serviceSku) : null,
|
|
installSku ? this.getProductMetaBySku(pricebookId, installSku) : null,
|
|
]);
|
|
|
|
return { serviceProduct, installProduct };
|
|
}
|
|
}
|