350 lines
11 KiB
Markdown
350 lines
11 KiB
Markdown
|
|
# Salesforce-to-Portal Order Communication Guide
|
||
|
|
|
||
|
|
## Overview
|
||
|
|
|
||
|
|
This guide focuses specifically on **secure communication between Salesforce and your Portal for order provisioning**. This is NOT about invoices or billing - it's about the order approval and provisioning workflow.
|
||
|
|
|
||
|
|
## The Order Flow
|
||
|
|
|
||
|
|
```
|
||
|
|
1. Customer places order → Portal creates Salesforce Order (Status: "Pending Review")
|
||
|
|
2. Salesforce operator reviews → Clicks "Provision in WHMCS" Quick Action
|
||
|
|
3. Salesforce calls Portal BFF → POST /orders/{sfOrderId}/provision
|
||
|
|
4. Portal BFF provisions in WHMCS → Updates Salesforce Order status
|
||
|
|
5. Customer sees updated status in Portal
|
||
|
|
```
|
||
|
|
|
||
|
|
## 1. Salesforce → Portal (Order Provisioning)
|
||
|
|
|
||
|
|
### Current Implementation ✅
|
||
|
|
|
||
|
|
Your existing architecture already handles this securely via the **Quick Action** that calls your BFF endpoint:
|
||
|
|
|
||
|
|
- **Endpoint**: `POST /orders/{sfOrderId}/provision`
|
||
|
|
- **Authentication**: Named Credentials + HMAC signature
|
||
|
|
- **Security**: IP allowlisting, idempotency keys, signed headers
|
||
|
|
|
||
|
|
### Enhanced Security Implementation
|
||
|
|
|
||
|
|
Use your existing `EnhancedWebhookSignatureGuard` for the provisioning endpoint:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// apps/bff/src/orders/orders.controller.ts
|
||
|
|
@Post(':sfOrderId/provision')
|
||
|
|
@UseGuards(EnhancedWebhookSignatureGuard)
|
||
|
|
@ApiHeader({ name: "X-SF-Signature", description: "Salesforce HMAC signature" })
|
||
|
|
@ApiHeader({ name: "X-SF-Timestamp", description: "Request timestamp" })
|
||
|
|
@ApiHeader({ name: "X-SF-Nonce", description: "Unique nonce" })
|
||
|
|
@ApiHeader({ name: "Idempotency-Key", description: "Idempotency key" })
|
||
|
|
async provisionOrder(
|
||
|
|
@Param('sfOrderId') sfOrderId: string,
|
||
|
|
@Body() payload: ProvisionOrderRequest,
|
||
|
|
@Headers('idempotency-key') idempotencyKey: string
|
||
|
|
) {
|
||
|
|
return await this.orderOrchestrator.provisionOrder(sfOrderId, payload, idempotencyKey);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Salesforce Apex Implementation
|
||
|
|
|
||
|
|
```apex
|
||
|
|
public class OrderProvisioningService {
|
||
|
|
private static final String WEBHOOK_SECRET = '{!$Credential.Portal_Webhook.Password}';
|
||
|
|
|
||
|
|
@future(callout=true)
|
||
|
|
public static void provisionOrder(String orderId) {
|
||
|
|
try {
|
||
|
|
// Create secure payload
|
||
|
|
Map<String, Object> payload = new Map<String, Object>{
|
||
|
|
'orderId' => orderId,
|
||
|
|
'timestamp' => System.now().format('yyyy-MM-dd\'T\'HH:mm:ss\'Z\''),
|
||
|
|
'nonce' => generateNonce()
|
||
|
|
};
|
||
|
|
|
||
|
|
String jsonPayload = JSON.serialize(payload);
|
||
|
|
String signature = generateHMACSignature(jsonPayload, WEBHOOK_SECRET);
|
||
|
|
|
||
|
|
// Make secure HTTP call
|
||
|
|
HttpRequest req = new HttpRequest();
|
||
|
|
req.setEndpoint('callout:Portal_BFF/orders/' + orderId + '/provision');
|
||
|
|
req.setMethod('POST');
|
||
|
|
req.setHeader('Content-Type', 'application/json');
|
||
|
|
req.setHeader('X-SF-Signature', signature);
|
||
|
|
req.setHeader('X-SF-Timestamp', payload.get('timestamp').toString());
|
||
|
|
req.setHeader('X-SF-Nonce', payload.get('nonce').toString());
|
||
|
|
req.setHeader('Idempotency-Key', 'provision_' + orderId + '_' + System.now().getTime());
|
||
|
|
req.setBody(jsonPayload);
|
||
|
|
req.setTimeout(30000);
|
||
|
|
|
||
|
|
Http http = new Http();
|
||
|
|
HttpResponse res = http.send(req);
|
||
|
|
|
||
|
|
handleProvisioningResponse(orderId, res);
|
||
|
|
|
||
|
|
} catch (Exception e) {
|
||
|
|
updateOrderStatus(orderId, 'Failed', e.getMessage());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private static String generateHMACSignature(String data, String key) {
|
||
|
|
Blob hmacData = Crypto.generateMac('HmacSHA256', Blob.valueOf(data), Blob.valueOf(key));
|
||
|
|
return EncodingUtil.convertToHex(hmacData);
|
||
|
|
}
|
||
|
|
|
||
|
|
private static String generateNonce() {
|
||
|
|
return EncodingUtil.convertToHex(Crypto.generateAesKey(128)).substring(0, 16);
|
||
|
|
}
|
||
|
|
|
||
|
|
private static void handleProvisioningResponse(String orderId, HttpResponse res) {
|
||
|
|
if (res.getStatusCode() == 200) {
|
||
|
|
updateOrderStatus(orderId, 'Provisioned', null);
|
||
|
|
} else {
|
||
|
|
updateOrderStatus(orderId, 'Failed', 'HTTP ' + res.getStatusCode());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private static void updateOrderStatus(String orderId, String status, String errorMessage) {
|
||
|
|
Order ord = [SELECT Id FROM Order WHERE Id = :orderId LIMIT 1];
|
||
|
|
ord.Provisioning_Status__c = status;
|
||
|
|
if (errorMessage != null) {
|
||
|
|
ord.Provisioning_Error_Message__c = errorMessage.left(255); // Truncate if needed
|
||
|
|
}
|
||
|
|
update ord;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## 2. Optional: Portal → Salesforce (Status Updates)
|
||
|
|
|
||
|
|
If you want to send status updates back to Salesforce during provisioning, you can implement a reverse webhook:
|
||
|
|
|
||
|
|
### Portal BFF Implementation
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// apps/bff/src/vendors/salesforce/services/order-status-update.service.ts
|
||
|
|
@Injectable()
|
||
|
|
export class OrderStatusUpdateService {
|
||
|
|
constructor(
|
||
|
|
private salesforceConnection: SalesforceConnection,
|
||
|
|
@Inject(Logger) private logger: Logger
|
||
|
|
) {}
|
||
|
|
|
||
|
|
async updateOrderStatus(
|
||
|
|
sfOrderId: string,
|
||
|
|
status: 'Activating' | 'Provisioned' | 'Failed',
|
||
|
|
details?: {
|
||
|
|
whmcsOrderId?: string;
|
||
|
|
errorCode?: string;
|
||
|
|
errorMessage?: string;
|
||
|
|
}
|
||
|
|
) {
|
||
|
|
try {
|
||
|
|
const updateData: any = {
|
||
|
|
Id: sfOrderId,
|
||
|
|
Provisioning_Status__c: status,
|
||
|
|
Last_Provisioning_At__c: new Date().toISOString(),
|
||
|
|
};
|
||
|
|
|
||
|
|
if (details?.whmcsOrderId) {
|
||
|
|
updateData.WHMCS_Order_ID__c = details.whmcsOrderId;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (status === 'Failed' && details?.errorCode) {
|
||
|
|
updateData.Provisioning_Error_Code__c = details.errorCode;
|
||
|
|
updateData.Provisioning_Error_Message__c = details.errorMessage?.substring(0, 255);
|
||
|
|
}
|
||
|
|
|
||
|
|
await this.salesforceConnection.sobject('Order').update(updateData);
|
||
|
|
|
||
|
|
this.logger.log('Order status updated in Salesforce', {
|
||
|
|
sfOrderId,
|
||
|
|
status,
|
||
|
|
whmcsOrderId: details?.whmcsOrderId,
|
||
|
|
});
|
||
|
|
} catch (error) {
|
||
|
|
this.logger.error('Failed to update order status in Salesforce', {
|
||
|
|
sfOrderId,
|
||
|
|
status,
|
||
|
|
error: error instanceof Error ? error.message : String(error),
|
||
|
|
});
|
||
|
|
// Don't throw - this is a non-critical update
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Usage in Order Orchestrator
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// In your existing OrderOrchestrator service
|
||
|
|
async provisionOrder(sfOrderId: string, payload: any, idempotencyKey: string) {
|
||
|
|
try {
|
||
|
|
// Update status to "Activating"
|
||
|
|
await this.orderStatusUpdateService.updateOrderStatus(sfOrderId, 'Activating');
|
||
|
|
|
||
|
|
// Your existing provisioning logic...
|
||
|
|
const whmcsOrderId = await this.provisionInWhmcs(sfOrderId, payload);
|
||
|
|
|
||
|
|
// Update status to "Provisioned" with WHMCS order ID
|
||
|
|
await this.orderStatusUpdateService.updateOrderStatus(sfOrderId, 'Provisioned', {
|
||
|
|
whmcsOrderId: whmcsOrderId.toString(),
|
||
|
|
});
|
||
|
|
|
||
|
|
return { success: true, whmcsOrderId };
|
||
|
|
} catch (error) {
|
||
|
|
// Update status to "Failed" with error details
|
||
|
|
await this.orderStatusUpdateService.updateOrderStatus(sfOrderId, 'Failed', {
|
||
|
|
errorCode: 'PROVISIONING_ERROR',
|
||
|
|
errorMessage: error instanceof Error ? error.message : String(error),
|
||
|
|
});
|
||
|
|
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## 3. Security Configuration
|
||
|
|
|
||
|
|
### Environment Variables
|
||
|
|
|
||
|
|
```bash
|
||
|
|
# Salesforce webhook security
|
||
|
|
SF_WEBHOOK_SECRET=your_256_bit_secret_key_here
|
||
|
|
SF_WEBHOOK_IP_ALLOWLIST=13.108.0.0/14,204.14.232.0/23
|
||
|
|
WEBHOOK_TIMESTAMP_TOLERANCE=300000 # 5 minutes
|
||
|
|
|
||
|
|
# Monitoring
|
||
|
|
SECURITY_ALERT_WEBHOOK=https://your-monitoring-service.com/alerts
|
||
|
|
```
|
||
|
|
|
||
|
|
### Salesforce Named Credential
|
||
|
|
|
||
|
|
```xml
|
||
|
|
<!-- Named Credential: Portal_BFF -->
|
||
|
|
<NamedCredential>
|
||
|
|
<fullName>Portal_BFF</fullName>
|
||
|
|
<endpoint>https://your-portal-api.com</endpoint>
|
||
|
|
<principalType>Anonymous</principalType>
|
||
|
|
<protocol>HttpsOnly</protocol>
|
||
|
|
<generateAuthorizationHeader>false</generateAuthorizationHeader>
|
||
|
|
</NamedCredential>
|
||
|
|
|
||
|
|
<!-- Named Credential: Portal_Webhook (for the secret) -->
|
||
|
|
<NamedCredential>
|
||
|
|
<fullName>Portal_Webhook</fullName>
|
||
|
|
<endpoint>https://your-portal-api.com</endpoint>
|
||
|
|
<principalType>NamedPrincipal</principalType>
|
||
|
|
<namedCredentialType>Legacy</namedCredentialType>
|
||
|
|
<password>your_256_bit_secret_key_here</password>
|
||
|
|
<username>webhook</username>
|
||
|
|
</NamedCredential>
|
||
|
|
```
|
||
|
|
|
||
|
|
## 4. Customer Experience
|
||
|
|
|
||
|
|
### Portal UI Polling
|
||
|
|
|
||
|
|
The portal should poll for order status updates:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// In your Portal UI
|
||
|
|
export function useOrderStatus(sfOrderId: string) {
|
||
|
|
const [status, setStatus] = useState<OrderStatus>('Pending Review');
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
const pollStatus = async () => {
|
||
|
|
try {
|
||
|
|
const response = await fetch(`/api/orders/${sfOrderId}`);
|
||
|
|
const data = await response.json();
|
||
|
|
setStatus(data.status);
|
||
|
|
|
||
|
|
// Stop polling when order is complete
|
||
|
|
if (['Provisioned', 'Failed'].includes(data.status)) {
|
||
|
|
clearInterval(interval);
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error('Failed to fetch order status:', error);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const interval = setInterval(pollStatus, 5000); // Poll every 5 seconds
|
||
|
|
pollStatus(); // Initial fetch
|
||
|
|
|
||
|
|
return () => clearInterval(interval);
|
||
|
|
}, [sfOrderId]);
|
||
|
|
|
||
|
|
return status;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## 5. Monitoring and Alerting
|
||
|
|
|
||
|
|
### Key Metrics to Monitor
|
||
|
|
|
||
|
|
- **Provisioning Success Rate**: Track successful vs failed provisioning attempts
|
||
|
|
- **Provisioning Latency**: Time from Quick Action to completion
|
||
|
|
- **WHMCS API Errors**: Monitor WHMCS integration health
|
||
|
|
- **Webhook Security Events**: Failed signature validations, old timestamps
|
||
|
|
|
||
|
|
### Alert Conditions
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// Example monitoring service
|
||
|
|
@Injectable()
|
||
|
|
export class OrderProvisioningMonitoringService {
|
||
|
|
async recordProvisioningAttempt(sfOrderId: string, success: boolean, duration: number) {
|
||
|
|
// Record metrics
|
||
|
|
this.metricsService.increment('order.provisioning.attempts', {
|
||
|
|
success: success.toString(),
|
||
|
|
});
|
||
|
|
|
||
|
|
this.metricsService.histogram('order.provisioning.duration', duration);
|
||
|
|
|
||
|
|
// Alert on high failure rate
|
||
|
|
const recentFailureRate = await this.getRecentFailureRate();
|
||
|
|
if (recentFailureRate > 0.1) { // 10% failure rate
|
||
|
|
await this.alertingService.sendAlert('High order provisioning failure rate');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## 6. Testing
|
||
|
|
|
||
|
|
### Security Testing
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
describe('Order Provisioning Security', () => {
|
||
|
|
it('should reject requests without valid HMAC signature', async () => {
|
||
|
|
const response = await request(app)
|
||
|
|
.post('/orders/test-order-id/provision')
|
||
|
|
.send({ orderId: 'test-order-id' })
|
||
|
|
.expect(401);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should reject requests with old timestamps', async () => {
|
||
|
|
const oldTimestamp = new Date(Date.now() - 10 * 60 * 1000).toISOString();
|
||
|
|
const payload = { orderId: 'test-order-id', timestamp: oldTimestamp };
|
||
|
|
const signature = generateHmacSignature(JSON.stringify(payload));
|
||
|
|
|
||
|
|
const response = await request(app)
|
||
|
|
.post('/orders/test-order-id/provision')
|
||
|
|
.set('X-SF-Signature', signature)
|
||
|
|
.set('X-SF-Timestamp', oldTimestamp)
|
||
|
|
.send(payload)
|
||
|
|
.expect(401);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
## Summary
|
||
|
|
|
||
|
|
This focused approach ensures secure communication specifically for your **order provisioning workflow**:
|
||
|
|
|
||
|
|
1. **Salesforce Quick Action** → Secure HTTPS call to Portal BFF
|
||
|
|
2. **Portal BFF** → Processes order, provisions in WHMCS
|
||
|
|
3. **Optional**: Portal sends status updates back to Salesforce
|
||
|
|
4. **Customer** → Sees real-time order status in Portal UI
|
||
|
|
|
||
|
|
The security is handled by your existing infrastructure with enhanced webhook signature validation, making it production-ready and secure [[memory:6689308]].
|