2025-09-02 16:09:54 +09:00
import { Controller , Post , Param , Body , Headers , HttpCode , HttpStatus , UseGuards } from "@nestjs/common" ;
2025-09-02 16:09:17 +09:00
import { ApiTags , ApiOperation , ApiParam , ApiResponse , ApiHeader } from "@nestjs/swagger" ;
import { ThrottlerGuard } from "@nestjs/throttler" ;
import { Logger } from "nestjs-pino" ;
import { Public } from "../../auth/decorators/public.decorator" ;
import { EnhancedWebhookSignatureGuard } from "../../webhooks/guards/enhanced-webhook-signature.guard" ;
import { OrderFulfillmentService } from "../services/order-fulfillment.service" ;
import type { OrderFulfillmentRequest } from "../services/order-fulfillment.service" ;
@ApiTags ( "order-fulfillment" )
@Controller ( "orders" )
@Public ( ) // Salesforce webhook uses signature-based auth, not JWT
export class OrderFulfillmentController {
constructor (
private readonly orderFulfillmentService : OrderFulfillmentService ,
private readonly logger : Logger
) { }
@Post ( ":sfOrderId/fulfill" )
@HttpCode ( HttpStatus . OK )
@UseGuards ( ThrottlerGuard , EnhancedWebhookSignatureGuard )
2025-09-02 16:09:54 +09:00
@ApiOperation ( {
2025-09-02 16:09:17 +09:00
summary : "Fulfill order from Salesforce" ,
2025-09-02 16:09:54 +09:00
description : "Secure endpoint called by Salesforce Quick Action to fulfill orders in WHMCS. Handles complete flow: SF Order → WHMCS AddOrder/AcceptOrder → SF Status Update"
2025-09-02 16:09:17 +09:00
} )
2025-09-02 16:09:54 +09:00
@ApiParam ( {
name : "sfOrderId" ,
type : String ,
2025-09-02 16:09:17 +09:00
description : "Salesforce Order ID to provision" ,
2025-09-02 16:09:54 +09:00
example : "8014x000000ABCDXYZ"
2025-09-02 16:09:17 +09:00
} )
2025-09-02 16:09:54 +09:00
@ApiHeader ( {
name : "X-SF-Signature" ,
description : "HMAC-SHA256 signature of request body using shared secret" ,
2025-09-02 16:09:17 +09:00
required : true ,
2025-09-02 16:09:54 +09:00
example : "a1b2c3d4e5f6..."
2025-09-02 16:09:17 +09:00
} )
2025-09-02 16:09:54 +09:00
@ApiHeader ( {
name : "X-SF-Timestamp" ,
description : "ISO timestamp of request (max 5 minutes old)" ,
2025-09-02 16:09:17 +09:00
required : true ,
2025-09-02 16:09:54 +09:00
example : "2024-01-15T10:30:00Z"
2025-09-02 16:09:17 +09:00
} )
2025-09-02 16:09:54 +09:00
@ApiHeader ( {
name : "X-SF-Nonce" ,
description : "Unique nonce to prevent replay attacks" ,
2025-09-02 16:09:17 +09:00
required : true ,
2025-09-02 16:09:54 +09:00
example : "abc123def456"
2025-09-02 16:09:17 +09:00
} )
2025-09-02 16:09:54 +09:00
@ApiHeader ( {
name : "Idempotency-Key" ,
description : "Unique key for safe retries" ,
2025-09-02 16:09:17 +09:00
required : true ,
2025-09-02 16:09:54 +09:00
example : "provision_8014x000000ABCDXYZ_1705312200000"
2025-09-02 16:09:17 +09:00
} )
2025-09-02 16:09:54 +09:00
@ApiResponse ( {
status : 200 ,
2025-09-02 16:09:17 +09:00
description : "Order provisioning completed successfully" ,
schema : {
type : "object" ,
properties : {
success : { type : "boolean" , example : true } ,
2025-09-02 16:09:54 +09:00
status : { type : "string" , enum : [ "Provisioned" , "Already Provisioned" ] , example : "Provisioned" } ,
2025-09-02 16:09:17 +09:00
whmcsOrderId : { type : "string" , example : "12345" } ,
whmcsServiceIds : { type : "array" , items : { type : "number" } , example : [ 67890 , 67891 ] } ,
2025-09-02 16:09:54 +09:00
message : { type : "string" , example : "Order provisioned successfully in WHMCS" }
}
}
2025-09-02 16:09:17 +09:00
} )
2025-09-02 16:09:54 +09:00
@ApiResponse ( {
status : 400 ,
2025-09-02 16:09:17 +09:00
description : "Invalid request or order not found" ,
schema : {
2025-09-02 16:09:54 +09:00
type : "object" ,
2025-09-02 16:09:17 +09:00
properties : {
success : { type : "boolean" , example : false } ,
status : { type : "string" , example : "Failed" } ,
message : { type : "string" , example : "Salesforce order not found" } ,
2025-09-02 16:09:54 +09:00
errorCode : { type : "string" , example : "ORDER_NOT_FOUND" }
}
}
2025-09-02 16:09:17 +09:00
} )
2025-09-02 16:09:54 +09:00
@ApiResponse ( {
status : 401 ,
description : "Invalid signature or authentication"
2025-09-02 16:09:17 +09:00
} )
2025-09-02 16:09:54 +09:00
@ApiResponse ( {
status : 409 ,
2025-09-02 16:09:17 +09:00
description : "Payment method missing or other conflict" ,
schema : {
type : "object" ,
properties : {
success : { type : "boolean" , example : false } ,
status : { type : "string" , example : "Failed" } ,
2025-09-02 16:09:54 +09:00
message : { type : "string" , example : "Payment method missing - client must add payment method before provisioning" } ,
errorCode : { type : "string" , example : "PAYMENT_METHOD_MISSING" }
}
}
2025-09-02 16:09:17 +09:00
} )
async fulfillOrder (
@Param ( "sfOrderId" ) sfOrderId : string ,
@Body ( ) payload : OrderFulfillmentRequest ,
@Headers ( "idempotency-key" ) idempotencyKey : string
) {
2025-09-02 16:09:54 +09:00
this . logger . log ( "Salesforce order fulfillment request received" , {
2025-09-02 16:09:17 +09:00
sfOrderId ,
idempotencyKey ,
timestamp : payload.timestamp ,
hasNonce : Boolean ( payload . nonce ) ,
} ) ;
try {
const result = await this . orderFulfillmentService . fulfillOrder (
2025-09-02 16:09:54 +09:00
sfOrderId ,
payload ,
2025-09-02 16:09:17 +09:00
idempotencyKey
) ;
this . logger . log ( "Salesforce provisioning completed" , {
sfOrderId ,
success : result.success ,
status : result.status ,
whmcsOrderId : result.whmcsOrderId ,
serviceCount : result.whmcsServiceIds?.length || 0 ,
} ) ;
return {
success : result.success ,
status : result.status ,
whmcsOrderId : result.whmcsOrderId ,
whmcsServiceIds : result.whmcsServiceIds ,
message : result.message ,
. . . ( result . errorCode && { errorCode : result.errorCode } ) ,
timestamp : new Date ( ) . toISOString ( ) ,
} ;
2025-09-02 16:09:54 +09:00
2025-09-02 16:09:17 +09:00
} catch ( error ) {
this . logger . error ( "Salesforce provisioning failed" , {
error : error instanceof Error ? error.message : String ( error ) ,
sfOrderId ,
idempotencyKey ,
} ) ;
// Re-throw to let global exception handler format the response
throw error ;
}
}
2025-09-04 14:17:54 +09:00
// Removed /provision alias to avoid confusion — use /fulfill only
2025-09-02 16:09:17 +09:00
}