2025-08-23 17:24:37 +09:00
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 > ;
2025-08-23 17:24:37 +09:00
opportunityId? : string ;
}
2025-08-20 18:02:50 +09:00
@Injectable ( )
export class OrdersService {
2025-08-23 17:24:37 +09:00
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 ,
2025-08-23 17:24:37 +09:00
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 ,
2025-08-23 17:24:37 +09:00
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 ,
2025-08-23 17:24:37 +09:00
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 ,
2025-08-23 17:24:37 +09:00
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 ,
2025-08-23 17:24:37 +09:00
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 ,
2025-08-23 17:24:37 +09:00
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 ,
2025-08-23 17:24:37 +09:00
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 ,
} ) ;
}
}
2025-08-20 18:02:50 +09:00
}