Merge branch 'main' into Tema-v1

This commit is contained in:
NTumurbars 2025-09-06 10:05:23 +09:00 committed by GitHub
commit 781e4c9b6a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
67 changed files with 1522 additions and 2072 deletions

View File

@ -90,6 +90,12 @@ customer-portal/
- `DATABASE_URL` should use `database:5432`
- `REDIS_URL` should use `cache:6379`
- Set `JWT_SECRET` to a strong value
- Salesforce credentials: `SF_LOGIN_URL`, `SF_CLIENT_ID`, `SF_USERNAME`
- Salesforce private key: set `SF_PRIVATE_KEY_PATH=/app/secrets/sf-private.key` and mount `/app/secrets`
- Webhook secrets: `SF_WEBHOOK_SECRET` (Salesforce), `WHMCS_WEBHOOK_SECRET` (if using WHMCS webhooks)
- Webhook tolerances: `WEBHOOK_TIMESTAMP_TOLERANCE=300000` (ms; optional)
- Optional IP allowlists: `SF_WEBHOOK_IP_ALLOWLIST`, `WHMCS_WEBHOOK_IP_ALLOWLIST` (CSV of IP/CIDR)
- Pricebook: `PORTAL_PRICEBOOK_ID`
### Image Build and Upload
@ -123,6 +129,17 @@ In Plesk → Docker → Images, upload both tar files. Then use `compose-plesk.y
- `/``portal-frontend` port `3000`
- `/api``portal-backend` port `4000`
### Webhook Security (Plesk)
- Endpoint for Salesforce Quick Action:
- `POST /api/orders/{sfOrderId}/fulfill`
- Required backend env (see above). Ensure the same HMAC secret is configured in Salesforce.
- The backend guard enforces:
- HMAC for all webhooks
- Salesforce: timestamp + nonce with Redis-backed replay protection
- WHMCS: timestamp/nonce optional (validated if present)
- Health check `/health` includes `integrations.redis` to verify nonce storage.
Alternatively, load via SSH on the Plesk host:
```bash

View File

@ -189,6 +189,24 @@ SF_PRIVATE_KEY_PATH=./secrets/sf-dev.key
SF_USERNAME=dev@yourcompany.com.sandbox
```
#### Salesforce Pub/Sub (Events)
```env
# Enable Pub/Sub subscription for order provisioning
SF_EVENTS_ENABLED=true
SF_PROVISION_EVENT_CHANNEL=/event/Order_Fulfilment_Requested__e
SF_EVENTS_REPLAY=LATEST # or ALL for retention replay
SF_PUBSUB_ENDPOINT=api.pubsub.salesforce.com:7443
SF_PUBSUB_NUM_REQUESTED=50 # flow control window
```
- Verify subscriber status: `GET /health/sf-events`
- `enabled`: whether Pub/Sub is enabled
- `channel`: topic name
- `replay.lastReplayId`: last committed cursor
- `subscriber.status`: connected | disconnected | unknown
### Development Tools Access
When running `pnpm dev:tools`, you get access to:
@ -247,6 +265,7 @@ When running `pnpm dev:tools`, you get access to:
### Webhooks
- `POST /api/orders/:sfOrderId/fulfill` - Secure Salesforce-initiated order fulfillment
- `POST /api/webhooks/whmcs` - WHMCS action hooks → update mirrors + bust cache
## Frontend Pages
@ -411,3 +430,4 @@ rm -rf node_modules && pnpm install
## License
[Your License Here]
See `docs/RUNBOOK_PROVISIONING.md` for the provisioning runbook.

View File

@ -61,7 +61,8 @@
"rxjs": "^7.8.2",
"speakeasy": "^2.0.0",
"uuid": "^11.1.0",
"zod": "^4.0.17"
"zod": "^4.0.17",
"salesforce-pubsub-api-client": "^5.5.0"
},
"devDependencies": {
"@nestjs/cli": "^11.0.10",
@ -110,4 +111,4 @@
},
"passWithNoTests": true
}
}
}

View File

@ -1,6 +1,3 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
@ -10,67 +7,54 @@ datasource db {
url = env("DATABASE_URL")
}
enum UserRole {
USER
ADMIN
}
model User {
id String @id @default(uuid())
email String @unique
passwordHash String? @map("password_hash")
firstName String? @map("first_name")
lastName String? @map("last_name")
company String?
phone String?
role UserRole @default(USER)
mfaSecret String? @map("mfa_secret")
emailVerified Boolean @default(false) @map("email_verified")
// Security fields
failedLoginAttempts Int @default(0) @map("failed_login_attempts")
lockedUntil DateTime? @map("locked_until")
lastLoginAt DateTime? @map("last_login_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relationships
idMapping IdMapping?
idempotencyKeys IdempotencyKey[]
invoicesMirror InvoiceMirror[]
id String @id @default(uuid())
email String @unique
passwordHash String? @map("password_hash")
firstName String? @map("first_name")
lastName String? @map("last_name")
company String?
phone String?
role UserRole @default(USER)
mfaSecret String? @map("mfa_secret")
emailVerified Boolean @default(false) @map("email_verified")
failedLoginAttempts Int @default(0) @map("failed_login_attempts")
lockedUntil DateTime? @map("locked_until")
lastLoginAt DateTime? @map("last_login_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
auditLogs AuditLog[]
idMapping IdMapping?
idempotencyKeys IdempotencyKey[]
invoicesMirror InvoiceMirror[]
subscriptionsMirror SubscriptionMirror[]
auditLogs AuditLog[]
@@map("users")
}
model IdMapping {
userId String @id @map("user_id")
whmcsClientId Int @unique @map("whmcs_client_id")
sfAccountId String? @map("sf_account_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String @id @map("user_id")
whmcsClientId Int @unique @map("whmcs_client_id")
sfAccountId String? @map("sf_account_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("id_mappings")
}
model InvoiceMirror {
invoiceId Int @id @map("invoice_id")
userId String @map("user_id")
invoiceId Int @id @map("invoice_id")
userId String @map("user_id")
number String
status String
amountCents Int @map("amount_cents")
currency String @db.Char(3)
amountCents Int @map("amount_cents")
currency String @db.Char(3)
dueDate DateTime? @map("due_date") @db.Date
issuedAt DateTime? @map("issued_at")
paidAt DateTime? @map("paid_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, status])
@@index([userId, dueDate])
@ -78,19 +62,18 @@ model InvoiceMirror {
}
model SubscriptionMirror {
serviceId Int @id @map("service_id")
userId String @map("user_id")
productName String @map("product_name")
domain String?
cycle String
status String
nextDue DateTime? @map("next_due")
amountCents Int @map("amount_cents")
currency String @db.Char(3)
registeredAt DateTime @map("registered_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
serviceId Int @id @map("service_id")
userId String @map("user_id")
productName String @map("product_name")
domain String?
cycle String
status String
nextDue DateTime? @map("next_due")
amountCents Int @map("amount_cents")
currency String @db.Char(3)
registeredAt DateTime @map("registered_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, status])
@@index([userId, nextDue])
@ -101,32 +84,54 @@ model IdempotencyKey {
key String @id
userId String @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([createdAt]) // For cleanup jobs
@@index([createdAt])
@@map("idempotency_keys")
}
// Job queue tables (if not using external Redis-only solution)
model Job {
id String @id @default(uuid())
name String
data Json
status JobStatus @default(PENDING)
attempts Int @default(0)
maxRetries Int @default(3) @map("max_retries")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
id String @id @default(uuid())
name String
data Json
status JobStatus @default(PENDING)
attempts Int @default(0)
maxRetries Int @default(3) @map("max_retries")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
processedAt DateTime? @map("processed_at")
failedAt DateTime? @map("failed_at")
error String?
failedAt DateTime? @map("failed_at")
error String?
@@index([status, createdAt])
@@map("jobs")
}
model AuditLog {
id String @id @default(uuid())
userId String? @map("user_id")
action AuditAction
resource String?
details Json?
ipAddress String? @map("ip_address")
userAgent String? @map("user_agent")
success Boolean @default(true)
error String?
createdAt DateTime @default(now()) @map("created_at")
user User? @relation(fields: [userId], references: [id])
@@index([userId, action])
@@index([action, createdAt])
@@index([createdAt])
@@map("audit_logs")
}
enum UserRole {
USER
ADMIN
}
enum JobStatus {
PENDING
PROCESSING
@ -135,27 +140,6 @@ enum JobStatus {
RETRYING
}
// Audit logging for security events
model AuditLog {
id String @id @default(uuid())
userId String? @map("user_id")
action AuditAction
resource String? // e.g., "auth", "user", "invoice"
details Json? // Additional context
ipAddress String? @map("ip_address")
userAgent String? @map("user_agent")
success Boolean @default(true)
error String? // Error message if action failed
createdAt DateTime @default(now()) @map("created_at")
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@index([userId, action])
@@index([action, createdAt])
@@index([createdAt]) // For cleanup jobs
@@map("audit_logs")
}
enum AuditAction {
LOGIN_SUCCESS
LOGIN_FAILED

View File

@ -13,12 +13,14 @@ import { LoggingModule } from "./common/logging/logging.module";
import { PrismaModule } from "./common/prisma/prisma.module";
import { RedisModule } from "./common/redis/redis.module";
import { CacheModule } from "./common/cache/cache.module";
import { QueueModule } from "./common/queue/queue.module";
import { AuditModule } from "./common/audit/audit.module";
import { EmailModule } from "./common/email/email.module";
// External Integration Modules
import { VendorsModule } from "./vendors/vendors.module";
import { JobsModule } from "./jobs/jobs.module";
import { SalesforceEventsModule } from "./vendors/salesforce/events/events.module";
// Feature Modules
import { AuthModule } from "./auth/auth.module";
@ -29,7 +31,6 @@ import { OrdersModule } from "./orders/orders.module";
import { InvoicesModule } from "./invoices/invoices.module";
import { SubscriptionsModule } from "./subscriptions/subscriptions.module";
import { CasesModule } from "./cases/cases.module";
import { WebhooksModule } from "./webhooks/webhooks.module";
// System Modules
import { HealthModule } from "./health/health.module";
@ -63,11 +64,13 @@ import { HealthModule } from "./health/health.module";
PrismaModule,
RedisModule,
CacheModule,
QueueModule,
AuditModule,
EmailModule,
// === EXTERNAL INTEGRATIONS ===
VendorsModule,
SalesforceEventsModule,
JobsModule,
// === FEATURE MODULES ===
@ -79,7 +82,6 @@ import { HealthModule } from "./health/health.module";
InvoicesModule,
SubscriptionsModule,
CasesModule,
WebhooksModule,
// === SYSTEM MODULES ===
HealthModule,

View File

@ -377,15 +377,13 @@ export class AuthService {
sfAccountId: sfAccount.id,
});
// 4. Update WH_Account__c field in Salesforce
const whAccountValue = `#${whmcsClient.clientId} - ${firstName} ${lastName}`;
await this.salesforceService.updateWhAccount(sfAccount.id, whAccountValue);
// 4. Do not update Salesforce Account fields from the portal. Salesforce stays authoritative.
// Log successful signup
await this.auditService.logAuthEvent(
AuditAction.SIGNUP,
user.id,
{ email, whmcsClientId: whmcsClient.clientId, whAccountValue },
{ email, whmcsClientId: whmcsClient.clientId },
request,
true
);

View File

@ -1,12 +1,14 @@
import { Injectable, Inject } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Redis } from "ioredis";
import { Logger } from "nestjs-pino";
@Injectable()
export class TokenBlacklistService {
constructor(
@Inject("REDIS_CLIENT") private readonly redis: Redis,
private readonly configService: ConfigService
private readonly configService: ConfigService,
@Inject(Logger) private readonly logger: Logger
) {}
async blacklistToken(token: string, _expiresIn?: number): Promise<void> {
@ -22,16 +24,30 @@ export class TokenBlacklistService {
if (ttl > 0) {
await this.redis.setex(`blacklist:${token}`, ttl, "1");
}
} catch {
} catch (e) {
// If we can't parse the token, blacklist it for the default JWT expiry time
const defaultTtl = this.parseJwtExpiry(this.configService.get("JWT_EXPIRES_IN", "7d"));
await this.redis.setex(`blacklist:${token}`, defaultTtl, "1");
try {
const defaultTtl = this.parseJwtExpiry(this.configService.get("JWT_EXPIRES_IN", "7d"));
await this.redis.setex(`blacklist:${token}`, defaultTtl, "1");
} catch (err) {
this.logger.warn("Failed to write token to Redis blacklist; proceeding without persistence", {
error: err instanceof Error ? err.message : String(err),
});
}
}
}
async isTokenBlacklisted(token: string): Promise<boolean> {
const result = await this.redis.get(`blacklist:${token}`);
return result !== null;
try {
const result = await this.redis.get(`blacklist:${token}`);
return result !== null;
} catch (err) {
// If Redis is unavailable, treat as not blacklisted to avoid blocking auth
this.logger.warn("Redis unavailable during blacklist check; allowing request", {
error: err instanceof Error ? err.message : String(err),
});
return false;
}
}
private parseJwtExpiry(expiresIn: string): number {

View File

@ -35,8 +35,15 @@ export class BaseCatalogService {
const price = pricebookEntries?.records?.[0]?.UnitPrice || 0;
if (price === 0) {
const fields = this.getFields();
const skuField = fields.product.sku;
const sku = record[skuField];
this.logger.warn(
`No price found for product ${String(record["Name"])} (SKU: ${String(record["StockKeepingUnit"])}). Pricebook ID: ${this.portalPriceBookId}. PricebookEntries: ${pricebookEntries?.records?.length || 0} records`
`No price found for product ${String(record["Name"])} (SKU: ${String(
typeof sku === "string" || typeof sku === "number" ? sku : ""
)}). Pricebook ID: ${this.portalPriceBookId}. PricebookEntries: ${
pricebookEntries?.records?.length || 0
} records`
);
}
@ -67,7 +74,7 @@ export class BaseCatalogService {
AND ${fields.product.itemClass} = '${itemClass}'
AND ${fields.product.portalAccessible} = true
${additionalConditions}
ORDER BY Catalog_Order__c NULLS LAST, Name
ORDER BY ${fields.product.displayOrder} NULLS LAST, Name
`;
}

View File

@ -28,10 +28,34 @@ export class CacheService {
}
async delPattern(pattern: string): Promise<void> {
const keys = await this.redis.keys(pattern);
if (keys.length > 0) {
await this.redis.del(...keys);
}
// Use SCAN to avoid blocking Redis for large keyspaces
let cursor = "0";
const batch: string[] = [];
const flush = async () => {
if (batch.length > 0) {
// Use pipeline to delete in bulk
const pipeline = this.redis.pipeline();
for (const k of batch.splice(0, batch.length)) pipeline.del(k);
await pipeline.exec();
}
};
do {
const [next, keys] = (await this.redis.scan(
cursor,
"MATCH",
pattern,
"COUNT",
1000
)) as unknown as [string, string[]];
cursor = next;
if (keys && keys.length) {
batch.push(...keys);
if (batch.length >= 1000) {
await flush();
}
}
} while (cursor !== "0");
await flush();
}
async exists(key: string): Promise<boolean> {

View File

@ -43,6 +43,16 @@ export const envSchema = z.object({
SF_PRIVATE_KEY_PATH: z.string().optional(),
SF_WEBHOOK_SECRET: z.string().optional(),
// Salesforce Platform Events (Async Provisioning)
SF_EVENTS_ENABLED: z.enum(["true", "false"]).default("false"),
SF_PROVISION_EVENT_CHANNEL: z
.string()
.default("/event/Order_Fulfilment_Requested__e"),
SF_EVENTS_REPLAY: z.enum(["LATEST", "ALL"]).default("LATEST"),
SF_PUBSUB_ENDPOINT: z.string().default("api.pubsub.salesforce.com:7443"),
SF_PUBSUB_NUM_REQUESTED: z.string().default("50"),
SF_PUBSUB_QUEUE_MAX: z.string().default("100"),
// Email / SendGrid
SENDGRID_API_KEY: z.string().optional(),
EMAIL_FROM: z.string().email().default("no-reply@example.com"),

View File

@ -72,6 +72,11 @@ export type SalesforceFieldMap = {
// WHMCS integration
whmcsOrderId: string;
// Provisioning diagnostics
lastErrorCode?: string;
lastErrorMessage?: string;
lastAttemptAt?: string;
// Address fields
addressChanged: string;
@ -137,7 +142,8 @@ export function getSalesforceFieldMap(): SalesforceFieldMap {
// Internet fields (independent fields synced with OrderItems)
internetPlanTier: process.env.ORDER_INTERNET_PLAN_TIER_FIELD || "Internet_Plan_Tier__c",
installationType: process.env.ORDER_INSTALLATION_TYPE_FIELD || "Installation_Type__c",
installationType: process.env.ORDER_INSTALLATION_TYPE_FIELD || "Installment_Plan__c",
weekendInstall: process.env.ORDER_WEEKEND_INSTALL_FIELD || "Weekend_Install__c",
accessMode: process.env.ORDER_ACCESS_MODE_FIELD || "Access_Mode__c",
hikariDenwa: process.env.ORDER_HIKARI_DENWA_FIELD || "Hikari_Denwa__c",
@ -171,18 +177,24 @@ export function getSalesforceFieldMap(): SalesforceFieldMap {
// WHMCS integration
whmcsOrderId: process.env.ORDER_WHMCS_ORDER_ID_FIELD || "WHMCS_Order_ID__c",
// Diagnostics (optional fields) — single source of truth: Activation_* fields
lastErrorCode: process.env.ORDER_ACTIVATION_ERROR_CODE_FIELD || "Activation_Error_Code__c",
lastErrorMessage:
process.env.ORDER_ACTIVATION_ERROR_MESSAGE_FIELD || "Activation_Error_Message__c",
lastAttemptAt: process.env.ORDER_ACTIVATION_LAST_ATTEMPT_AT_FIELD || "ActivatedDate",
// Address fields
addressChanged: process.env.ORDER_ADDRESS_CHANGED_FIELD || "Address_Changed__c",
// Billing address snapshot fields
// Billing address snapshot fields — single source of truth: Billing* fields on Order
billing: {
// Default to standard Order BillingAddress components
// Env overrides maintain backward compatibility if orgs used custom fields
street: process.env.ORDER_BILL_TO_STREET_FIELD || "BillingStreet",
city: process.env.ORDER_BILL_TO_CITY_FIELD || "BillingCity",
state: process.env.ORDER_BILL_TO_STATE_FIELD || "BillingState",
postalCode: process.env.ORDER_BILL_TO_POSTAL_CODE_FIELD || "BillingPostalCode",
country: process.env.ORDER_BILL_TO_COUNTRY_FIELD || "BillingCountry",
street: process.env.ORDER_BILLING_STREET_FIELD || "BillingStreet",
city: process.env.ORDER_BILLING_CITY_FIELD || "BillingCity",
state: process.env.ORDER_BILLING_STATE_FIELD || "BillingState",
postalCode: process.env.ORDER_BILLING_POSTAL_CODE_FIELD || "BillingPostalCode",
country: process.env.ORDER_BILLING_COUNTRY_FIELD || "BillingCountry",
},
},
orderItem: {
@ -229,6 +241,9 @@ export function getOrderQueryFields(): string {
fields.order.activationType,
fields.order.activationScheduledAt,
fields.order.activationStatus,
fields.order.lastErrorCode,
fields.order.lastErrorMessage,
fields.order.lastAttemptAt,
// Internet fields
fields.order.internetPlanTier,
fields.order.installationType,
@ -246,3 +261,18 @@ export function getOrderQueryFields(): string {
fields.order.whmcsOrderId,
].join(", ");
}
// Build a nested select list for PricebookEntry.Product2.* fields used in OrderItem queries
export function getOrderItemProduct2Select(additional: string[] = []): string {
const fields = getSalesforceFieldMap();
const base = [
"Id",
"Name",
fields.product.sku,
fields.product.whmcsProductId,
fields.product.itemClass,
fields.product.billingCycle,
];
const all = [...base, ...additional];
return all.map(f => `PricebookEntry.Product2.${f}`).join(", ");
}

View File

@ -7,7 +7,6 @@ import { OrdersModule } from "../../orders/orders.module";
import { InvoicesModule } from "../../invoices/invoices.module";
import { SubscriptionsModule } from "../../subscriptions/subscriptions.module";
import { CasesModule } from "../../cases/cases.module";
import { WebhooksModule } from "../../webhooks/webhooks.module";
/**
* API routing configuration
@ -26,7 +25,6 @@ export const apiRoutes: Routes = [
{ path: "", module: InvoicesModule },
{ path: "", module: SubscriptionsModule },
{ path: "", module: CasesModule },
{ path: "", module: WebhooksModule },
],
},
];

View File

@ -3,12 +3,11 @@ import { ConfigModule } from "@nestjs/config";
import { EmailService } from "./email.service";
import { SendGridEmailProvider } from "./providers/sendgrid.provider";
import { LoggingModule } from "../logging/logging.module";
import { BullModule } from "@nestjs/bullmq";
import { EmailQueueService } from "./queue/email.queue";
import { EmailProcessor } from "./queue/email.processor";
@Module({
imports: [ConfigModule, LoggingModule, BullModule.registerQueue({ name: "email" })],
imports: [ConfigModule, LoggingModule],
providers: [EmailService, SendGridEmailProvider, EmailQueueService, EmailProcessor],
exports: [EmailService, EmailQueueService],
})

View File

@ -3,8 +3,9 @@ import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { EmailService } from "../email.service";
import type { EmailJobData } from "./email.queue";
import { QUEUE_NAMES } from "../../queue/queue.constants";
@Processor("email")
@Processor(QUEUE_NAMES.EMAIL)
@Injectable()
export class EmailProcessor extends WorkerHost {
constructor(

View File

@ -3,13 +3,14 @@ import { InjectQueue } from "@nestjs/bullmq";
import { Queue } from "bullmq";
import { Logger } from "nestjs-pino";
import type { SendEmailOptions } from "../email.service";
import { QUEUE_NAMES } from "../../queue/queue.constants";
export type EmailJobData = SendEmailOptions & { category?: string };
@Injectable()
export class EmailQueueService {
constructor(
@InjectQueue("email") private readonly queue: Queue<EmailJobData>,
@InjectQueue(QUEUE_NAMES.EMAIL) private readonly queue: Queue<EmailJobData>,
@Inject(Logger) private readonly logger: Logger
) {}

View File

@ -0,0 +1,7 @@
export const QUEUE_NAMES = {
EMAIL: "email",
PROVISIONING: "provisioning",
RECONCILE: "reconcile",
} as const;
export type QueueName = (typeof QUEUE_NAMES)[keyof typeof QUEUE_NAMES];

View File

@ -0,0 +1,42 @@
import { Global, Module } from "@nestjs/common";
import { BullModule } from "@nestjs/bullmq";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { QUEUE_NAMES } from "./queue.constants";
function parseRedisConnection(redisUrl: string) {
try {
const url = new URL(redisUrl);
const isTls = url.protocol === "rediss:";
const db = url.pathname && url.pathname !== "/" ? Number(url.pathname.slice(1)) : undefined;
return {
host: url.hostname,
port: Number(url.port || (isTls ? 6380 : 6379)),
password: url.password || undefined,
...(db !== undefined ? { db } : {}),
...(isTls ? { tls: {} } : {}),
} as Record<string, unknown>;
} catch {
// Fallback to localhost
return { host: "localhost", port: 6379 } as Record<string, unknown>;
}
}
@Global()
@Module({
imports: [
ConfigModule,
BullModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
connection: parseRedisConnection(config.get<string>("REDIS_URL", "redis://localhost:6379")),
}),
}),
BullModule.registerQueue(
{ name: QUEUE_NAMES.EMAIL },
{ name: QUEUE_NAMES.PROVISIONING },
{ name: QUEUE_NAMES.RECONCILE }
),
],
exports: [BullModule],
})
export class QueueModule {}

View File

@ -6,6 +6,12 @@ import { InjectQueue } from "@nestjs/bullmq";
import { Queue } from "bullmq";
import { ConfigService } from "@nestjs/config";
import { Public } from "../auth/decorators/public.decorator";
import { CacheService } from "../common/cache/cache.service";
import { QUEUE_NAMES } from "../common/queue/queue.constants";
import {
replayKey as sfReplayKey,
statusKey as sfStatusKey,
} from "../vendors/salesforce/events/event-keys.util";
@ApiTags("Health")
@Controller("health")
@ -14,7 +20,8 @@ export class HealthController {
constructor(
private readonly prisma: PrismaService,
private readonly config: ConfigService,
@InjectQueue("email") private readonly emailQueue: Queue
@InjectQueue(QUEUE_NAMES.EMAIL) private readonly emailQueue: Queue,
private readonly cache: CacheService
) {}
@Get()
@ -45,6 +52,15 @@ export class HealthController {
"delayed"
);
// Check Redis availability by a simple set/get on a volatile key
const nonceProbeKey = "health:nonce:probe";
let redisStatus: "connected" | "degraded" | "unavailable" = "connected";
try {
await this.cache.set(nonceProbeKey, 1, 5);
} catch {
redisStatus = "unavailable";
}
return {
status: "ok",
timestamp: new Date().toISOString(),
@ -55,6 +71,9 @@ export class HealthController {
queues: {
email: emailQueueInfo,
},
integrations: {
redis: redisStatus,
},
features: {
emailEnabled: this.config.get("EMAIL_ENABLED", "true") === "true",
emailQueued: this.config.get("EMAIL_USE_QUEUE", "true") === "true",
@ -116,4 +135,42 @@ export class HealthController {
uptime: process.uptime(),
};
}
@Get("sf-events")
@ApiOperation({ summary: "Salesforce events subscriber status" })
@ApiResponse({ status: 200, description: "Subscriber status and last replay id" })
async getSalesforceEventsStatus() {
const enabled = this.config.get("SF_EVENTS_ENABLED", "false") === "true";
const channel = this.config.get<string>(
"SF_PROVISION_EVENT_CHANNEL",
"/event/Order_Fulfilment_Requested__e"
);
const replayKey = sfReplayKey(channel);
const statusKey = sfStatusKey(channel);
const [lastReplayIdRaw, status] = await Promise.all([
this.cache.get<unknown>(replayKey),
this.cache.get<{ status: string; since: number }>(statusKey),
]);
const lastReplayId =
typeof lastReplayIdRaw === "number"
? lastReplayIdRaw
: typeof lastReplayIdRaw === "string" && lastReplayIdRaw.trim() !== ""
? Number(lastReplayIdRaw)
: null;
return {
enabled,
channel,
replay: {
lastReplayId,
key: replayKey,
},
subscriber: {
status: status?.status || (enabled ? "unknown" : "disabled"),
since: status?.since ? new Date(status.since).toISOString() : null,
key: statusKey,
},
};
}
}

View File

@ -1,11 +1,10 @@
import { Module } from "@nestjs/common";
import { HealthController } from "./health.controller";
import { PrismaModule } from "../common/prisma/prisma.module";
import { BullModule } from "@nestjs/bullmq";
import { ConfigModule } from "@nestjs/config";
@Module({
imports: [PrismaModule, ConfigModule, BullModule.registerQueue({ name: "email" })],
imports: [PrismaModule, ConfigModule],
controllers: [HealthController],
})
export class HealthModule {}

View File

@ -1,24 +1,9 @@
import { Module } from "@nestjs/common";
import { BullModule } from "@nestjs/bullmq";
import { ConfigService } from "@nestjs/config";
import { JobsService } from "./jobs.service";
import { ReconcileProcessor } from "./reconcile.processor";
@Module({
imports: [
BullModule.forRootAsync({
useFactory: (configService: ConfigService) => ({
connection: {
host: configService.get("REDIS_HOST", "localhost"),
port: configService.get("REDIS_PORT", 6379),
},
}),
inject: [ConfigService],
}),
BullModule.registerQueue({
name: "reconcile",
}),
],
imports: [],
providers: [JobsService, ReconcileProcessor],
exports: [JobsService],
})

View File

@ -1,7 +1,8 @@
import { Processor, WorkerHost } from "@nestjs/bullmq";
import { Job } from "bullmq";
import { QUEUE_NAMES } from "../common/queue/queue.constants";
@Processor("reconcile")
@Processor(QUEUE_NAMES.RECONCILE)
export class ReconcileProcessor extends WorkerHost {
async process(_job: Job) {
// TODO: Implement reconciliation logic

View File

@ -46,21 +46,45 @@ export class MappingsService {
// Sanitize input
const sanitizedRequest = this.validator.sanitizeCreateRequest(request);
// Check for conflicts
const existingMappings = await this.getAllMappingsFromDb();
const conflictValidation = this.validator.validateNoConflicts(
sanitizedRequest,
existingMappings
);
// Check for conflicts via DB (faster and race-safe)
const [byUser, byWhmcs, bySf] = await Promise.all([
this.prisma.idMapping.findUnique({ where: { userId: sanitizedRequest.userId } }),
this.prisma.idMapping.findUnique({
where: { whmcsClientId: sanitizedRequest.whmcsClientId },
}),
sanitizedRequest.sfAccountId
? this.prisma.idMapping.findFirst({
where: { sfAccountId: sanitizedRequest.sfAccountId },
})
: Promise.resolve(null),
]);
if (!conflictValidation.isValid) {
throw new ConflictException(`Mapping conflict: ${conflictValidation.errors.join(", ")}`);
if (byUser) {
throw new ConflictException(`User ${sanitizedRequest.userId} already has a mapping`);
}
if (byWhmcs) {
throw new ConflictException(
`WHMCS client ${sanitizedRequest.whmcsClientId} is already mapped to user ${byWhmcs.userId}`
);
}
if (bySf) {
this.logger.warn(
`Salesforce account ${sanitizedRequest.sfAccountId} is already mapped to user ${bySf.userId}`
);
}
// Create in database
const created = await this.prisma.idMapping.create({
data: sanitizedRequest,
});
let created;
try {
created = await this.prisma.idMapping.create({ data: sanitizedRequest });
} catch (e) {
const msg = getErrorMessage(e);
// P2002 = Unique constraint failed
if (msg.includes("P2002") || /unique/i.test(msg)) {
throw new ConflictException("Mapping violates uniqueness constraints");
}
throw e;
}
const mapping: UserIdMapping = {
userId: created.userId,
@ -99,12 +123,11 @@ export class MappingsService {
throw new BadRequestException("Salesforce Account ID is required");
}
// Try cache first (check all cached mappings)
const allCached = await this.getAllMappingsFromDb();
const cachedMapping = allCached.find((m: UserIdMapping) => m.sfAccountId === sfAccountId);
if (cachedMapping) {
// Try cache first
const cached = await this.cacheService.getBySfAccountId(sfAccountId);
if (cached) {
this.logger.debug(`Cache hit for SF account mapping: ${sfAccountId}`);
return cachedMapping;
return cached;
}
// Fetch from database
@ -561,17 +584,6 @@ export class MappingsService {
// Private helper methods
private async getAllMappingsFromDb(): Promise<UserIdMapping[]> {
const dbMappings = await this.prisma.idMapping.findMany();
return dbMappings.map(mapping => ({
userId: mapping.userId,
whmcsClientId: mapping.whmcsClientId,
sfAccountId: mapping.sfAccountId || undefined,
createdAt: mapping.createdAt,
updatedAt: mapping.updatedAt,
}));
}
private sanitizeForLog(data: unknown): Record<string, unknown> {
try {
const plain: unknown = JSON.parse(JSON.stringify(data ?? {}));

View File

@ -1,148 +0,0 @@
import { Controller, Post, Param, Body, Headers, HttpCode, HttpStatus, UseGuards } from "@nestjs/common";
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)
@ApiOperation({
summary: "Fulfill order from Salesforce",
description: "Secure endpoint called by Salesforce Quick Action to fulfill orders in WHMCS. Handles complete flow: SF Order → WHMCS AddOrder/AcceptOrder → SF Status Update"
})
@ApiParam({
name: "sfOrderId",
type: String,
description: "Salesforce Order ID to provision",
example: "8014x000000ABCDXYZ"
})
@ApiHeader({
name: "X-SF-Signature",
description: "HMAC-SHA256 signature of request body using shared secret",
required: true,
example: "a1b2c3d4e5f6..."
})
@ApiHeader({
name: "X-SF-Timestamp",
description: "ISO timestamp of request (max 5 minutes old)",
required: true,
example: "2024-01-15T10:30:00Z"
})
@ApiHeader({
name: "X-SF-Nonce",
description: "Unique nonce to prevent replay attacks",
required: true,
example: "abc123def456"
})
@ApiHeader({
name: "Idempotency-Key",
description: "Unique key for safe retries",
required: true,
example: "provision_8014x000000ABCDXYZ_1705312200000"
})
@ApiResponse({
status: 200,
description: "Order provisioning completed successfully",
schema: {
type: "object",
properties: {
success: { type: "boolean", example: true },
status: { type: "string", enum: ["Provisioned", "Already Provisioned"], example: "Provisioned" },
whmcsOrderId: { type: "string", example: "12345" },
whmcsServiceIds: { type: "array", items: { type: "number" }, example: [67890, 67891] },
message: { type: "string", example: "Order provisioned successfully in WHMCS" }
}
}
})
@ApiResponse({
status: 400,
description: "Invalid request or order not found",
schema: {
type: "object",
properties: {
success: { type: "boolean", example: false },
status: { type: "string", example: "Failed" },
message: { type: "string", example: "Salesforce order not found" },
errorCode: { type: "string", example: "ORDER_NOT_FOUND" }
}
}
})
@ApiResponse({
status: 401,
description: "Invalid signature or authentication"
})
@ApiResponse({
status: 409,
description: "Payment method missing or other conflict",
schema: {
type: "object",
properties: {
success: { type: "boolean", example: false },
status: { type: "string", example: "Failed" },
message: { type: "string", example: "Payment method missing - client must add payment method before provisioning" },
errorCode: { type: "string", example: "PAYMENT_METHOD_MISSING" }
}
}
})
async fulfillOrder(
@Param("sfOrderId") sfOrderId: string,
@Body() payload: OrderFulfillmentRequest,
@Headers("idempotency-key") idempotencyKey: string
) {
this.logger.log("Salesforce order fulfillment request received", {
sfOrderId,
idempotencyKey,
timestamp: payload.timestamp,
hasNonce: Boolean(payload.nonce),
});
try {
const result = await this.orderFulfillmentService.fulfillOrder(
sfOrderId,
payload,
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(),
};
} 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;
}
}
}

View File

@ -1,6 +1,5 @@
import { Module } from "@nestjs/common";
import { OrdersController } from "./orders.controller";
import { OrderFulfillmentController } from "./controllers/order-fulfillment.controller";
import { VendorsModule } from "../vendors/vendors.module";
import { MappingsModule } from "../mappings/mappings.module";
import { UsersModule } from "../users/users.module";
@ -12,15 +11,16 @@ import { OrderItemBuilder } from "./services/order-item-builder.service";
import { OrderOrchestrator } from "./services/order-orchestrator.service";
// Clean modular fulfillment services
import { OrderFulfillmentService } from "./services/order-fulfillment.service";
import { OrderFulfillmentValidator } from "./services/order-fulfillment-validator.service";
import { OrderWhmcsMapper } from "./services/order-whmcs-mapper.service";
import { OrderFulfillmentOrchestrator } from "./services/order-fulfillment-orchestrator.service";
import { OrderFulfillmentErrorService } from "./services/order-fulfillment-error.service";
import { ProvisioningQueueService } from "./queue/provisioning.queue";
import { ProvisioningProcessor } from "./queue/provisioning.processor";
@Module({
imports: [VendorsModule, MappingsModule, UsersModule],
controllers: [OrdersController, OrderFulfillmentController],
controllers: [OrdersController],
providers: [
// Order creation services (modular)
OrderValidator,
@ -33,8 +33,10 @@ import { OrderFulfillmentErrorService } from "./services/order-fulfillment-error
OrderWhmcsMapper,
OrderFulfillmentOrchestrator,
OrderFulfillmentErrorService,
OrderFulfillmentService,
// Async provisioning queue
ProvisioningQueueService,
ProvisioningProcessor,
],
exports: [OrderOrchestrator, OrderFulfillmentService],
exports: [OrderOrchestrator, ProvisioningQueueService],
})
export class OrdersModule {}

View File

@ -0,0 +1,65 @@
import { Processor, WorkerHost } from "@nestjs/bullmq";
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { OrderFulfillmentOrchestrator } from "../services/order-fulfillment-orchestrator.service";
import { SalesforceService } from "../../vendors/salesforce/salesforce.service";
import { getSalesforceFieldMap } from "../../common/config/field-map";
import type { ProvisioningJobData } from "./provisioning.queue";
import { CacheService } from "../../common/cache/cache.service";
import { ConfigService } from "@nestjs/config";
import { QUEUE_NAMES } from "../../common/queue/queue.constants";
import { replayKey as sfReplayKey } from "../../vendors/salesforce/events/event-keys.util";
@Processor(QUEUE_NAMES.PROVISIONING)
@Injectable()
export class ProvisioningProcessor extends WorkerHost {
constructor(
private readonly orchestrator: OrderFulfillmentOrchestrator,
private readonly salesforceService: SalesforceService,
private readonly cache: CacheService,
private readonly config: ConfigService,
@Inject(Logger) private readonly logger: Logger
) {
super();
}
async process(job: { data: ProvisioningJobData }): Promise<void> {
const { sfOrderId, idempotencyKey } = job.data;
this.logger.log("Processing provisioning job", {
sfOrderId,
idempotencyKey,
correlationId: job.data.correlationId,
pubsubReplayId: job.data.pubsubReplayId,
});
// Guard: Only process if Salesforce Order is currently 'Activating'
const fields = getSalesforceFieldMap();
const order = await this.salesforceService.getOrder(sfOrderId);
const status = (order?.[fields.order.activationStatus] as string) || "";
if (status !== "Activating") {
this.logger.log("Skipping provisioning job: Order not in Activating state", {
sfOrderId,
currentStatus: status,
});
return; // Ack + no-op to safely handle duplicate/old events
}
// Execute the same orchestration used by the webhook path, but without payload validation
await this.orchestrator.executeFulfillment(sfOrderId, {}, idempotencyKey);
// Commit processed replay id for Pub/Sub resume (only after success)
if (typeof job.data.pubsubReplayId === "number") {
const channel = this.config.get<string>(
"SF_PROVISION_EVENT_CHANNEL",
"/event/Order_Fulfilment_Requested__e"
);
const replayKey = sfReplayKey(channel);
const prev = Number((await this.cache.get<string>(replayKey)) ?? 0);
if (job.data.pubsubReplayId > prev) {
await this.cache.set(replayKey, String(job.data.pubsubReplayId));
}
}
this.logger.log("Provisioning job completed", { sfOrderId });
}
}

View File

@ -0,0 +1,39 @@
import { Injectable, Inject } from "@nestjs/common";
import { InjectQueue } from "@nestjs/bullmq";
import { Queue } from "bullmq";
import { Logger } from "nestjs-pino";
import { QUEUE_NAMES } from "../../common/queue/queue.constants";
export interface ProvisioningJobData {
sfOrderId: string;
idempotencyKey: string;
correlationId?: string;
pubsubReplayId?: number;
}
@Injectable()
export class ProvisioningQueueService {
constructor(
@InjectQueue(QUEUE_NAMES.PROVISIONING) private readonly queue: Queue<ProvisioningJobData>,
@Inject(Logger) private readonly logger: Logger
) {}
async enqueue(job: ProvisioningJobData): Promise<void> {
await this.queue.add("provision", job, {
removeOnComplete: 100,
removeOnFail: 100,
attempts: 1, // No automatic retries; Salesforce is source of truth for retry
});
this.logger.debug("Queued provisioning job", {
sfOrderId: job.sfOrderId,
idempotencyKey: job.idempotencyKey,
correlationId: job.correlationId,
pubsubReplayId: job.pubsubReplayId,
});
}
async depth(): Promise<number> {
const counts = await this.queue.getJobCounts("waiting", "active", "delayed");
return (counts.waiting || 0) + (counts.active || 0) + (counts.delayed || 0);
}
}

View File

@ -21,7 +21,7 @@ export class OrderFulfillmentErrorService {
*/
determineErrorCode(error: unknown): OrderFulfillmentErrorCode {
const errorMessage = this.getErrorMessage(error);
if (errorMessage.includes("Payment method missing")) {
return OrderFulfillmentErrorCode.PAYMENT_METHOD_MISSING;
}
@ -86,7 +86,7 @@ export class OrderFulfillmentErrorService {
createErrorResponse(error: unknown) {
const errorCode = this.determineErrorCode(error);
const userMessage = this.getUserFriendlyMessage(error, errorCode);
return {
success: false,
status: "Failed" as const,
@ -94,4 +94,16 @@ export class OrderFulfillmentErrorService {
errorCode: errorCode,
};
}
/**
* Extract a short error code for diagnostics (e.g., 429, 503, ETIMEOUT)
*/
getShortCode(error: unknown): string | undefined {
const msg = this.getErrorMessage(error);
const m = msg.match(/HTTP\s+(\d{3})/i);
if (m) return m[1];
if (/timeout/i.test(msg)) return "ETIMEOUT";
if (/network/i.test(msg)) return "ENETWORK";
return undefined;
}
}

View File

@ -1,12 +1,19 @@
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { SalesforceService } from "../../vendors/salesforce/salesforce.service";
import { WhmcsOrderService, WhmcsOrderResult } from "../../vendors/whmcs/services/whmcs-order.service";
import {
WhmcsOrderService,
WhmcsOrderResult,
} from "../../vendors/whmcs/services/whmcs-order.service";
import { OrderOrchestrator } from "./order-orchestrator.service";
import { OrderFulfillmentValidator, OrderFulfillmentValidationResult } from "./order-fulfillment-validator.service";
import {
OrderFulfillmentValidator,
OrderFulfillmentValidationResult,
} from "./order-fulfillment-validator.service";
import { OrderWhmcsMapper, OrderItemMappingResult } from "./order-whmcs-mapper.service";
import { OrderFulfillmentErrorService } from "./order-fulfillment-error.service";
import { getErrorMessage } from "../../common/utils/error.util";
import { getSalesforceFieldMap } from "../../common/config/field-map";
export interface OrderFulfillmentStep {
step: string;
@ -16,18 +23,18 @@ export interface OrderFulfillmentStep {
error?: string;
}
import { OrderDetailsDto } from "../types/order-details.dto";
export interface OrderFulfillmentContext {
sfOrderId: string;
idempotencyKey: string;
validation: OrderFulfillmentValidationResult;
orderDetails?: any; // OrderOrchestrator.getOrder() returns transformed structure
orderDetails?: OrderDetailsDto; // Transformed order with items
mappingResult?: OrderItemMappingResult;
whmcsResult?: WhmcsOrderResult;
steps: OrderFulfillmentStep[];
}
/**
* Orchestrates the complete order fulfillment workflow
* Similar to OrderOrchestrator but for fulfillment operations
@ -40,7 +47,8 @@ export class OrderFulfillmentOrchestrator {
private readonly whmcsOrderService: WhmcsOrderService,
private readonly orderOrchestrator: OrderOrchestrator,
private readonly orderFulfillmentValidator: OrderFulfillmentValidator,
private readonly orderWhmcsMapper: OrderWhmcsMapper
private readonly orderWhmcsMapper: OrderWhmcsMapper,
private readonly orderFulfillmentErrorService: OrderFulfillmentErrorService
) {}
/**
@ -88,10 +96,10 @@ export class OrderFulfillmentOrchestrator {
// Step 2: Update Salesforce status to "Activating"
await this.executeStep(context, "sf_status_update", async () => {
const fields = getSalesforceFieldMap();
await this.salesforceService.updateOrder({
Id: sfOrderId,
Provisioning_Status__c: "Activating",
Last_Provisioning_At__c: new Date().toISOString(),
[fields.order.activationStatus]: "Activating",
});
});
@ -115,10 +123,14 @@ export class OrderFulfillmentOrchestrator {
throw new Error("Order items must be an array");
}
context.mappingResult = await this.orderWhmcsMapper.mapOrderItemsToWhmcs(context.orderDetails.items);
context.mappingResult = this.orderWhmcsMapper.mapOrderItemsToWhmcs(
context.orderDetails.items
);
// Validate mapped items
this.orderWhmcsMapper.validateMappedItems(context.mappingResult.whmcsItems);
// keep async signature for executeStep typing and lint rule
await Promise.resolve();
});
// Step 5: Create order in WHMCS
@ -131,11 +143,14 @@ export class OrderFulfillmentOrchestrator {
const createResult = await this.whmcsOrderService.addOrder({
clientId: context.validation.clientId,
items: context.mappingResult!.whmcsItems,
paymentMethod: "mailin", // Default payment method for provisioning orders
paymentMethod: "stripe", // Use Stripe for provisioning orders
promoCode: "1st Month Free (Monthly Plan)",
sfOrderId,
notes: orderNotes,
noinvoice: true, // Don't create invoice during provisioning
noemail: true, // Don't send emails during provisioning
// Align with Salesforce implementation: suppress invoice email and all emails
// Keep invoice generation enabled (noinvoice=false) unless changed later
noinvoiceemail: true,
noemail: true,
});
context.whmcsResult = {
@ -157,11 +172,12 @@ export class OrderFulfillmentOrchestrator {
// Step 7: Update Salesforce with success
await this.executeStep(context, "sf_success_update", async () => {
const fields = getSalesforceFieldMap();
await this.salesforceService.updateOrder({
Id: sfOrderId,
Provisioning_Status__c: "Provisioned",
WHMCS_Order_ID__c: context.whmcsResult!.orderId.toString(),
Last_Provisioning_At__c: new Date().toISOString(),
Status: "Completed",
[fields.order.activationStatus]: "Activated",
[fields.order.whmcsOrderId]: context.whmcsResult!.orderId.toString(),
});
});
@ -276,8 +292,9 @@ export class OrderFulfillmentOrchestrator {
context: OrderFulfillmentContext,
error: Error
): Promise<void> {
const errorCode = this.determineErrorCode(error);
const errorCode = this.orderFulfillmentErrorService.determineErrorCode(error);
const userMessage = error.message;
const fields = getSalesforceFieldMap();
this.logger.error("Fulfillment orchestration failed", {
sfOrderId: context.sfOrderId,
@ -291,10 +308,15 @@ export class OrderFulfillmentOrchestrator {
try {
await this.salesforceService.updateOrder({
Id: context.sfOrderId,
Provisioning_Status__c: "Failed",
Provisioning_Error_Code__c: errorCode,
Provisioning_Error_Message__c: userMessage.substring(0, 255),
Last_Provisioning_At__c: new Date().toISOString(),
[fields.order.activationStatus]: "Failed",
// Optional diagnostics fields if present in org
...(fields.order.lastErrorCode && {
[fields.order.lastErrorCode]:
this.orderFulfillmentErrorService.getShortCode(error) || String(errorCode),
}),
...(fields.order.lastErrorMessage && {
[fields.order.lastErrorMessage]: userMessage?.substring(0, 255),
}),
});
this.logger.log("Salesforce updated with failure status", {
@ -309,25 +331,6 @@ export class OrderFulfillmentOrchestrator {
}
}
/**
* Determine error code based on error type
*/
private determineErrorCode(error: Error): string {
if (error.message.includes("Payment method missing")) {
return "PAYMENT_METHOD_MISSING";
}
if (error.message.includes("not found")) {
return "ORDER_NOT_FOUND";
}
if (error.message.includes("WHMCS")) {
return "WHMCS_ERROR";
}
if (error.message.includes("mapping")) {
return "MAPPING_ERROR";
}
return "FULFILLMENT_ERROR";
}
/**
* Get fulfillment summary from context
*/

View File

@ -5,6 +5,7 @@ import { WhmcsOrderService } from "../../vendors/whmcs/services/whmcs-order.serv
import { MappingsService } from "../../mappings/mappings.service";
import { getErrorMessage } from "../../common/utils/error.util";
import { SalesforceOrder } from "../types/salesforce-order.types";
import { getSalesforceFieldMap } from "../../common/config/field-map";
export interface OrderFulfillmentValidationResult {
sfOrder: SalesforceOrder;
@ -44,17 +45,20 @@ export class OrderFulfillmentValidator {
const sfOrder = await this.validateSalesforceOrder(sfOrderId);
// 2. Check if already provisioned (idempotency)
if (sfOrder.WHMCS_Order_ID__c) {
const fields = getSalesforceFieldMap();
const rawWhmcs = (sfOrder as unknown as Record<string, unknown>)[fields.order.whmcsOrderId];
const existingWhmcsOrderId = typeof rawWhmcs === "string" ? rawWhmcs : undefined;
if (existingWhmcsOrderId) {
this.logger.log("Order already provisioned", {
sfOrderId,
whmcsOrderId: sfOrder.WHMCS_Order_ID__c,
whmcsOrderId: existingWhmcsOrderId,
});
return {
sfOrder,
clientId: 0, // Not needed for already provisioned
isAlreadyProvisioned: true,
whmcsOrderId: sfOrder.WHMCS_Order_ID__c,
whmcsOrderId: existingWhmcsOrderId,
};
}
@ -88,31 +92,37 @@ export class OrderFulfillmentValidator {
/**
* Validate Salesforce order exists and is in valid state
*/
private async validateSalesforceOrder(sfOrderId: string): Promise<SalesforceOrder> {
const order = await this.salesforceService.getOrder(sfOrderId);
private async validateSalesforceOrder(sfOrderId: string): Promise<SalesforceOrder> {
const order = await this.salesforceService.getOrder(sfOrderId);
if (!order) {
throw new BadRequestException(`Salesforce order ${sfOrderId} not found`);
}
// Cast to SalesforceOrder for type safety
const salesforceOrder = order as unknown as SalesforceOrder;
// Validate order is in a state that can be provisioned
if (salesforceOrder.Status === "Cancelled") {
throw new BadRequestException(`Cannot provision cancelled order ${sfOrderId}`);
}
this.logger.log("Salesforce order validated", {
sfOrderId,
status: salesforceOrder.Status,
activationStatus: salesforceOrder.Activation_Status__c,
accountId: salesforceOrder.Account.Id,
});
return salesforceOrder;
if (!order) {
throw new BadRequestException(`Salesforce order ${sfOrderId} not found`);
}
// Cast to SalesforceOrder for type safety
const salesforceOrder = order as unknown as SalesforceOrder;
// Validate order is in a state that can be provisioned
if (salesforceOrder.Status === "Cancelled") {
throw new BadRequestException(`Cannot provision cancelled order ${sfOrderId}`);
}
const fields = getSalesforceFieldMap();
this.logger.log("Salesforce order validated", {
sfOrderId,
status: salesforceOrder.Status,
activationStatus: ((): string | undefined => {
const v = (salesforceOrder as unknown as Record<string, unknown>)[
fields.order.activationStatus
];
return typeof v === "string" ? v : undefined;
})(),
accountId: salesforceOrder.Account.Id,
});
return salesforceOrder;
}
/**
* Get WHMCS client ID from Salesforce account ID using mappings
*/
@ -167,52 +177,4 @@ export class OrderFulfillmentValidator {
throw new ConflictException("Unable to validate payment method - fulfillment cannot proceed");
}
}
/**
* Validate provisioning request payload format
*/
validateRequestPayload(payload: unknown): {
orderId: string;
timestamp: string;
nonce: string;
} {
if (!payload || typeof payload !== "object") {
throw new BadRequestException("Invalid request payload");
}
const { orderId, timestamp, nonce } = payload as Record<string, unknown>;
if (!orderId || typeof orderId !== "string") {
throw new BadRequestException("Missing or invalid orderId in payload");
}
if (!timestamp || typeof timestamp !== "string") {
throw new BadRequestException("Missing or invalid timestamp in payload");
}
if (!nonce || typeof nonce !== "string") {
throw new BadRequestException("Missing or invalid nonce in payload");
}
// Validate timestamp is recent (additional validation beyond webhook guard)
try {
const requestTime = new Date(timestamp).getTime();
const now = Date.now();
const maxAge = 5 * 60 * 1000; // 5 minutes
if (Math.abs(now - requestTime) > maxAge) {
throw new BadRequestException("Request timestamp is too old");
}
} catch {
throw new BadRequestException("Invalid timestamp format");
}
this.logger.log("Request payload validated", {
orderId,
timestamp,
hasNonce: Boolean(nonce),
});
return { orderId, timestamp, nonce };
}
}

View File

@ -1,96 +0,0 @@
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { OrderFulfillmentOrchestrator } from "./order-fulfillment-orchestrator.service";
import { OrderFulfillmentValidator } from "./order-fulfillment-validator.service";
import { OrderFulfillmentErrorService } from "./order-fulfillment-error.service";
export interface OrderFulfillmentRequest {
orderId: string;
timestamp: string;
nonce: string;
}
export interface OrderFulfillmentResult {
success: boolean;
status: "Already Fulfilled" | "Fulfilled" | "Failed";
whmcsOrderId?: string;
whmcsServiceIds?: number[];
message: string;
errorCode?: string;
}
/**
* Main order fulfillment service - coordinates modular fulfillment components
* Uses clean architecture similar to order creation workflow
*/
@Injectable()
export class OrderFulfillmentService {
constructor(
private readonly orderFulfillmentOrchestrator: OrderFulfillmentOrchestrator,
private readonly orderFulfillmentValidator: OrderFulfillmentValidator,
private readonly orderFulfillmentErrorService: OrderFulfillmentErrorService,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Main fulfillment method called by Salesforce webhook
* Uses modular architecture for clean separation of concerns
*/
async fulfillOrder(
sfOrderId: string,
request: OrderFulfillmentRequest,
idempotencyKey: string
): Promise<OrderFulfillmentResult> {
this.logger.log("Starting order fulfillment workflow", {
sfOrderId,
idempotencyKey,
timestamp: request.timestamp,
});
try {
// 1. Validate request payload format
const validatedPayload = this.orderFulfillmentValidator.validateRequestPayload(request);
// 2. Execute complete fulfillment workflow using orchestrator
const context = await this.orderFulfillmentOrchestrator.executeFulfillment(
sfOrderId,
validatedPayload,
idempotencyKey
);
// 3. Generate result summary from context
const summary = this.orderFulfillmentOrchestrator.getFulfillmentSummary(context);
this.logger.log("Order fulfillment workflow completed", {
sfOrderId,
success: summary.success,
status: summary.status,
whmcsOrderId: summary.whmcsOrderId,
serviceCount: summary.whmcsServiceIds?.length || 0,
completedSteps: summary.steps.filter(s => s.status === "completed").length,
totalSteps: summary.steps.length,
});
return {
success: summary.success,
status: summary.status,
whmcsOrderId: summary.whmcsOrderId,
whmcsServiceIds: summary.whmcsServiceIds,
message: summary.message,
errorCode: summary.success ? undefined : this.orderFulfillmentErrorService.determineErrorCode(summary.message),
};
} catch (error) {
this.logger.error("Order fulfillment workflow failed", {
sfOrderId,
idempotencyKey,
error: error instanceof Error ? error.message : String(error),
});
// Use centralized error handling service
return this.orderFulfillmentErrorService.createErrorResponse(error);
}
}
}

View File

@ -9,7 +9,12 @@ import {
SalesforceOrderItem,
SalesforceQueryResult,
} from "../types/salesforce-order.types";
import { getSalesforceFieldMap } from "../../common/config/field-map";
import { OrderDetailsDto, OrderSummaryDto } from "../types/order-details.dto";
import {
getSalesforceFieldMap,
getOrderQueryFields,
getOrderItemProduct2Select,
} from "../../common/config/field-map";
/**
* Main orchestrator for order operations
@ -95,16 +100,14 @@ export class OrderOrchestrator {
/**
* Get order by ID with order items
*/
async getOrder(orderId: string) {
async getOrder(orderId: string): Promise<OrderDetailsDto | null> {
this.logger.log({ orderId }, "Fetching order details with items");
const fields = getSalesforceFieldMap();
const orderSoql = `
SELECT Id, OrderNumber, Status, ${fields.order.orderType}, EffectiveDate, TotalAmount,
Account.Name, CreatedDate, LastModifiedDate,
Activation_Type__c, Activation_Status__c, Activation_Scheduled_At__c,
WHMCS_Order_ID__c
FROM Order
SELECT ${getOrderQueryFields()}, OrderNumber, TotalAmount,
Account.Name, CreatedDate, LastModifiedDate
FROM Order
WHERE Id = '${orderId}'
LIMIT 1
`;
@ -112,13 +115,8 @@ export class OrderOrchestrator {
const orderItemsSoql = `
SELECT Id, OrderId, Quantity, UnitPrice, TotalPrice,
PricebookEntry.Id,
PricebookEntry.Product2.Id,
PricebookEntry.Product2.Name,
PricebookEntry.Product2.StockKeepingUnit,
PricebookEntry.Product2.WH_Product_ID__c,
PricebookEntry.Product2.Item_Class__c,
PricebookEntry.Product2.Billing_Cycle__c
FROM OrderItem
${getOrderItemProduct2Select()}
FROM OrderItem
WHERE OrderId = '${orderId}'
ORDER BY CreatedDate ASC
`;
@ -136,20 +134,28 @@ export class OrderOrchestrator {
return null;
}
const orderItems = (itemsResult.records || []).map((item: SalesforceOrderItem) => ({
id: item.Id,
quantity: item.Quantity,
unitPrice: item.UnitPrice,
totalPrice: item.TotalPrice,
product: {
id: item.PricebookEntry?.Product2?.Id,
name: item.PricebookEntry?.Product2?.Name,
sku: String(item.PricebookEntry?.Product2?.StockKeepingUnit || ""), // This is the key field that was missing!
whmcsProductId: String(item.PricebookEntry?.Product2?.WH_Product_ID__c || ""),
itemClass: String(item.PricebookEntry?.Product2?.Item_Class__c || ""),
billingCycle: String(item.PricebookEntry?.Product2?.Billing_Cycle__c || ""),
},
}));
const orderItems = (itemsResult.records || []).map((item: SalesforceOrderItem) => {
const p2 = item.PricebookEntry?.Product2 as unknown as Record<string, unknown> | undefined;
return {
id: item.Id,
quantity: item.Quantity,
unitPrice: item.UnitPrice,
totalPrice: item.TotalPrice,
product: {
id: p2?.Id as string | undefined,
name: ((): string | undefined => {
const v = p2?.Name;
return typeof v === "string" ? v : undefined;
})(),
sku: String((p2?.[fields.product.sku] as string | undefined) || ""),
whmcsProductId: String(
(p2?.[fields.product.whmcsProductId] as string | number | undefined) || ""
),
itemClass: String((p2?.[fields.product.itemClass] as string | undefined) || ""),
billingCycle: String((p2?.[fields.product.billingCycle] as string | undefined) || ""),
},
};
});
this.logger.log(
{ orderId, itemCount: orderItems.length },
@ -160,17 +166,34 @@ export class OrderOrchestrator {
id: order.Id,
orderNumber: order.OrderNumber,
status: order.Status,
orderType: order.Type,
orderType:
typeof (order as unknown as Record<string, unknown>)[fields.order.orderType] === "string"
? ((order as unknown as Record<string, unknown>)[fields.order.orderType] as string)
: order.Type,
effectiveDate: order.EffectiveDate,
totalAmount: order.TotalAmount,
accountName: order.Account?.Name,
createdDate: order.CreatedDate,
lastModifiedDate: order.LastModifiedDate,
activationType: order.Activation_Type__c,
activationStatus: order.Activation_Status__c,
scheduledAt: order.Activation_Scheduled_At__c,
whmcsOrderId: order.WHMCS_Order_ID__c,
items: orderItems, // Now includes all the product details with SKUs!
activationType: ((): string | undefined => {
const v = (order as unknown as Record<string, unknown>)[fields.order.activationType];
return typeof v === "string" ? v : undefined;
})(),
activationStatus: ((): string | undefined => {
const v = (order as unknown as Record<string, unknown>)[fields.order.activationStatus];
return typeof v === "string" ? v : undefined;
})(),
scheduledAt: ((): string | undefined => {
const v = (order as unknown as Record<string, unknown>)[
fields.order.activationScheduledAt
];
return typeof v === "string" ? v : undefined;
})(),
whmcsOrderId: ((): string | undefined => {
const v = (order as unknown as Record<string, unknown>)[fields.order.whmcsOrderId];
return typeof v === "string" ? v : undefined;
})(),
items: orderItems,
};
} catch (error) {
this.logger.error({ error, orderId }, "Failed to fetch order with items");
@ -181,7 +204,7 @@ export class OrderOrchestrator {
/**
* Get orders for a user with basic item summary
*/
async getOrdersForUser(userId: string) {
async getOrdersForUser(userId: string): Promise<OrderSummaryDto[]> {
this.logger.log({ userId }, "Fetching user orders with item summaries");
// Get user mapping
@ -189,9 +212,8 @@ export class OrderOrchestrator {
const fields = getSalesforceFieldMap();
const ordersSoql = `
SELECT Id, OrderNumber, Status, ${fields.order.orderType}, EffectiveDate, TotalAmount,
CreatedDate, LastModifiedDate, WHMCS_Order_ID__c
FROM Order
SELECT ${getOrderQueryFields()}, OrderNumber, TotalAmount, CreatedDate, LastModifiedDate
FROM Order
WHERE AccountId = '${userMapping.sfAccountId}'
ORDER BY CreatedDate DESC
LIMIT 50
@ -210,12 +232,11 @@ export class OrderOrchestrator {
// Get order items for all orders in one query
const orderIds = orders.map(o => `'${o.Id}'`).join(",");
const itemsSoql = `
SELECT Id, OrderId, Quantity, UnitPrice, TotalPrice,
PricebookEntry.Product2.Name,
PricebookEntry.Product2.StockKeepingUnit,
PricebookEntry.Product2.Item_Class__c,
PricebookEntry.Product2.Billing_Cycle__c
FROM OrderItem
SELECT Id, OrderId, Quantity,
${getOrderItemProduct2Select()}
FROM OrderItem
WHERE OrderId IN (${orderIds})
ORDER BY OrderId, CreatedDate ASC
`;
@ -229,10 +250,16 @@ export class OrderOrchestrator {
const itemsByOrder = allItems.reduce(
(acc, item: SalesforceOrderItem) => {
if (!acc[item.OrderId]) acc[item.OrderId] = [];
const p2 = item.PricebookEntry?.Product2 as unknown as
| Record<string, unknown>
| undefined;
acc[item.OrderId].push({
name: String(item.PricebookEntry?.Product2?.Name || ""),
sku: String(item.PricebookEntry?.Product2?.StockKeepingUnit || ""),
itemClass: String(item.PricebookEntry?.Product2?.Item_Class__c || ""),
name: ((): string | undefined => {
const v = p2?.Name;
return typeof v === "string" ? v : undefined;
})(),
sku: String((p2?.[fields.product.sku] as string | undefined) || ""),
itemClass: String((p2?.[fields.product.itemClass] as string | undefined) || ""),
quantity: item.Quantity,
unitPrice: item.UnitPrice,
totalPrice: item.TotalPrice,
@ -258,12 +285,18 @@ export class OrderOrchestrator {
id: order.Id,
orderNumber: order.OrderNumber,
status: order.Status,
orderType: order.Type,
orderType:
typeof (order as unknown as Record<string, unknown>)[fields.order.orderType] === "string"
? ((order as unknown as Record<string, unknown>)[fields.order.orderType] as string)
: order.Type,
effectiveDate: order.EffectiveDate,
totalAmount: order.TotalAmount,
createdDate: order.CreatedDate,
lastModifiedDate: order.LastModifiedDate,
whmcsOrderId: order.WHMCS_Order_ID__c,
whmcsOrderId: ((): string | undefined => {
const v = (order as unknown as Record<string, unknown>)[fields.order.whmcsOrderId];
return typeof v === "string" ? v : undefined;
})(),
itemsSummary: itemsByOrder[order.Id] || [], // Include basic item info for order list
}));
} catch (error) {

View File

@ -2,7 +2,7 @@ import { Injectable, BadRequestException, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { WhmcsOrderItem } from "../../vendors/whmcs/services/whmcs-order.service";
import { getErrorMessage } from "../../common/utils/error.util";
import type { OrderItemDto } from "../types/order-details.dto";
export interface OrderItemMappingResult {
whmcsItems: WhmcsOrderItem[];
@ -13,8 +13,6 @@ export interface OrderItemMappingResult {
};
}
/**
* Handles mapping Salesforce OrderItems to WHMCS format
* Similar to OrderItemBuilder but for fulfillment workflow
@ -26,7 +24,7 @@ export class OrderWhmcsMapper {
/**
* Map Salesforce OrderItems to WHMCS format for provisioning
*/
async mapOrderItemsToWhmcs(orderItems: any[]): Promise<OrderItemMappingResult> {
mapOrderItemsToWhmcs(orderItems: OrderItemDto[]): OrderItemMappingResult {
this.logger.log("Starting OrderItems mapping to WHMCS", {
itemCount: orderItems.length,
});
@ -81,7 +79,7 @@ export class OrderWhmcsMapper {
/**
* Map a single Salesforce OrderItem to WHMCS format
*/
private mapSingleOrderItem(item: any, index: number): WhmcsOrderItem {
private mapSingleOrderItem(item: OrderItemDto, index: number): WhmcsOrderItem {
const product = item.product; // This is the transformed structure from OrderOrchestrator
if (!product) {
@ -112,10 +110,6 @@ export class OrderWhmcsMapper {
return whmcsItem;
}
/**
* Create order notes with Salesforce tracking information
*/

View File

@ -0,0 +1,53 @@
export interface OrderItemProductDto {
id?: string;
name?: string;
sku: string;
whmcsProductId: string;
itemClass: string;
billingCycle: string;
}
export interface OrderItemDto {
id: string;
quantity: number;
unitPrice: number;
totalPrice: number;
product: OrderItemProductDto;
}
export interface OrderDetailsDto {
id: string;
orderNumber: string;
status: string;
orderType?: string;
effectiveDate: string;
totalAmount: number;
accountName?: string;
createdDate: string;
lastModifiedDate: string;
activationType?: string;
activationStatus?: string;
scheduledAt?: string;
whmcsOrderId?: string;
items: OrderItemDto[];
}
export interface OrderSummaryItemDto {
name?: string;
sku?: string;
itemClass?: string;
quantity: number;
}
export interface OrderSummaryDto {
id: string;
orderNumber: string;
status: string;
orderType?: string;
effectiveDate: string;
totalAmount: number;
createdDate: string;
lastModifiedDate: string;
whmcsOrderId?: string;
itemsSummary: OrderSummaryItemDto[];
}

View File

@ -10,13 +10,12 @@ export interface SalesforceOrder {
Account: {
Id: string;
Name: string;
[key: string]: unknown;
};
CreatedDate: string;
LastModifiedDate: string;
Activation_Type__c?: string;
Activation_Status__c?: string;
Activation_Scheduled_At__c?: string;
WHMCS_Order_ID__c?: string;
// Allow dynamic access to mapped custom fields via field-map
[key: string]: unknown;
}
export interface SalesforceOrderItem {
@ -32,12 +31,12 @@ export interface SalesforceOrderItem {
Id: string;
Name: string;
ProductCode: string;
StockKeepingUnit?: string;
WH_Product_ID__c?: string;
Item_Class__c?: string;
Billing_Cycle__c?: string;
// Allow dynamic access to mapped custom fields via field-map
[key: string]: unknown;
};
[key: string]: unknown;
};
[key: string]: unknown;
}
export interface SalesforceQueryResult<T> {
@ -62,9 +61,8 @@ export interface OrderCreateRequest {
Status: string;
EffectiveDate: string;
Type: string;
Activation_Type__c?: string;
Activation_Scheduled_At__c?: string;
WHMCS_Order_ID__c?: string;
// Custom fields provided via field-map
[key: string]: unknown;
}
export interface OrderItemCreateRequest {

View File

@ -0,0 +1 @@
declare module "salesforce-pubsub-api-client";

View File

@ -298,12 +298,7 @@ export class UsersService {
data: sanitizedData,
});
// Try to sync to Salesforce (non-blocking)
this.syncToSalesforce(validId, userData).catch(error =>
this.logger.warn("Failed to sync to Salesforce", {
error: getErrorMessage(error),
})
);
// Do not mutate Salesforce Account from the portal. Salesforce remains authoritative.
return this.toUser(updatedUser);
} catch (error) {

View File

@ -0,0 +1,11 @@
export function replayKey(channel: string): string {
return `sf:pe:replay:${channel}`;
}
export function statusKey(channel: string): string {
return `sf:pe:status:${channel}`;
}
export function latestSeenKey(channel: string): string {
return `sf:pe:latestSeen:${channel}`;
}

View File

@ -0,0 +1,11 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { VendorsModule } from "../../vendors.module";
import { OrdersModule } from "../../../orders/orders.module";
import { SalesforcePubSubSubscriber } from "./pubsub.subscriber";
@Module({
imports: [ConfigModule, VendorsModule, OrdersModule],
providers: [SalesforcePubSubSubscriber],
})
export class SalesforceEventsModule {}

View File

@ -0,0 +1,246 @@
import { Injectable, OnModuleInit, OnModuleDestroy, Inject } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
import PubSubApiClientPkg from "salesforce-pubsub-api-client";
import { SalesforceConnection } from "../services/salesforce-connection.service";
import { ProvisioningQueueService } from "../../../orders/queue/provisioning.queue";
import { CacheService } from "../../../common/cache/cache.service";
import {
replayKey as sfReplayKey,
statusKey as sfStatusKey,
latestSeenKey as sfLatestSeenKey,
} from "./event-keys.util";
type SubscribeCallback = (
subscription: { topicName: string },
callbackType: string,
data: unknown
) => void | Promise<void>;
interface PubSubClient {
connect(): Promise<void>;
subscribe(topic: string, cb: SubscribeCallback, numRequested: number): Promise<void>;
subscribeFromReplayId(
topic: string,
cb: SubscribeCallback,
numRequested: number,
replayId: number
): Promise<void>;
subscribeFromEarliestEvent(
topic: string,
cb: SubscribeCallback,
numRequested: number
): Promise<void>;
requestAdditionalEvents(topic: string, numRequested: number): Promise<void>;
close(): Promise<void>;
}
type PubSubCtor = new (opts: {
authType: string;
accessToken: string;
instanceUrl: string;
pubSubEndpoint: string;
}) => PubSubClient;
@Injectable()
export class SalesforcePubSubSubscriber implements OnModuleInit, OnModuleDestroy {
private client: PubSubClient | null = null;
private channel!: string;
constructor(
private readonly config: ConfigService,
private readonly sfConn: SalesforceConnection,
private readonly provisioningQueue: ProvisioningQueueService,
private readonly cache: CacheService,
@Inject(Logger) private readonly logger: Logger
) {}
async onModuleInit(): Promise<void> {
const enabled = this.config.get("SF_EVENTS_ENABLED", "false") === "true";
if (!enabled) {
this.logger.log("Salesforce Pub/Sub subscriber disabled", { enabled });
return;
}
this.channel = this.config.get<string>(
"SF_PROVISION_EVENT_CHANNEL",
"/event/Order_Fulfilment_Requested__e"
);
try {
await this.sfConn.connect();
const accessToken = this.sfConn.getAccessToken();
const instanceUrl = this.sfConn.getInstanceUrl();
if (!accessToken || !instanceUrl) {
throw new Error("Salesforce access token || instance URL unavailable");
}
const endpoint = this.config.get<string>(
"SF_PUBSUB_ENDPOINT",
"api.pubsub.salesforce.com:7443"
);
const maybeCtor: unknown =
(PubSubApiClientPkg as { default?: unknown })?.default ?? (PubSubApiClientPkg as unknown);
const Ctor = maybeCtor as PubSubCtor;
this.client = new Ctor({
authType: "user-supplied",
accessToken,
instanceUrl,
pubSubEndpoint: endpoint,
});
await this.client.connect();
if (!this.client) throw new Error("Pub/Sub client not initialized after connect");
const client = this.client;
const replayKey = sfReplayKey(this.channel);
const storedReplay = await this.cache.get<string>(replayKey);
const replayMode = this.config.get<string>("SF_EVENTS_REPLAY", "LATEST");
const numRequested = Number(this.config.get("SF_PUBSUB_NUM_REQUESTED", "50")) || 50;
const maxQueue = Number(this.config.get("SF_PUBSUB_QUEUE_MAX", "100")) || 100;
await this.cache.set(sfStatusKey(this.channel), {
status: "connecting",
since: Date.now(),
});
const subscribeCallback: SubscribeCallback = async (subscription, callbackType, data) => {
try {
// Normalize library callback signatures
const argTypes = [typeof subscription, typeof callbackType, typeof data];
let type: string | undefined;
let payloadData: unknown;
let topic = (subscription as { topicName?: string })?.topicName || this.channel;
if (typeof callbackType === "string") {
type = callbackType;
payloadData = data;
} else if (typeof subscription === "string") {
type = subscription;
payloadData = callbackType;
topic = this.channel;
} else {
type = "data";
payloadData = data ?? callbackType ?? subscription;
}
if (type === "data") {
const event = payloadData as Record<string, unknown>;
const payload = ((): Record<string, unknown> | undefined => {
const p = event?.["payload"];
return typeof p === "object" && p != null ? (p as Record<string, unknown>) : undefined;
})();
// Only check parsed payload
const orderIdVal = payload?.["OrderId__c"] ?? payload?.["OrderId"];
const orderId = typeof orderIdVal === "string" ? orderIdVal : undefined;
if (!orderId) {
this.logger.warn("Pub/Sub event missing OrderId__c; skipping", { argTypes, topic });
const depth = await this.provisioningQueue.depth();
if (depth < maxQueue) {
await client.requestAdditionalEvents(topic, 1);
}
return;
}
const replayVal = (event as { replayId?: unknown })?.replayId;
const idempotencyKey =
typeof replayVal === "number" || typeof replayVal === "string"
? String(replayVal)
: String(Date.now());
const pubsubReplayId = typeof replayVal === "number" ? replayVal : undefined;
await this.provisioningQueue.enqueue({
sfOrderId: orderId,
idempotencyKey,
pubsubReplayId,
});
this.logger.log("Enqueued provisioning job from SF event", {
sfOrderId: orderId,
replayId: pubsubReplayId,
topic,
});
// Keep sliding window full when queue has room
const depth = await this.provisioningQueue.depth();
if (depth < maxQueue) {
await client.requestAdditionalEvents(topic, 1);
}
} else if (type === "lastEvent") {
const depth = await this.provisioningQueue.depth();
const available = Math.max(0, maxQueue - depth);
const desired = Math.max(0, Math.min(numRequested, available));
if (desired > 0) {
await client.requestAdditionalEvents(topic, desired);
}
} else if (type === "grpcKeepalive") {
const latestVal = (payloadData as { latestReplayId?: unknown })?.latestReplayId;
const latest = typeof latestVal === "number" ? latestVal : undefined;
if (typeof latest === "number") {
await this.cache.set(sfLatestSeenKey(this.channel), {
id: String(latest),
at: Date.now(),
});
}
}
} catch (err) {
this.logger.error("Pub/Sub subscribe callback failed", {
error: err instanceof Error ? err.message : String(err),
});
}
};
if (storedReplay && replayMode !== "ALL") {
await this.client.subscribeFromReplayId(
this.channel,
subscribeCallback,
numRequested,
Number(storedReplay)
);
} else if (replayMode === "ALL") {
await this.client.subscribeFromEarliestEvent(this.channel, subscribeCallback, numRequested);
} else {
await this.client.subscribe(this.channel, subscribeCallback, numRequested);
}
await this.cache.set(sfStatusKey(this.channel), {
status: "connected",
since: Date.now(),
});
this.logger.log("Salesforce Pub/Sub subscription active", { channel: this.channel });
} catch (error) {
this.logger.error("Salesforce Pub/Sub subscription failed", {
error: error instanceof Error ? error.message : String(error),
});
try {
await this.cache.set(sfStatusKey(this.channel || "/event/OrderProvisionRequested__e"), {
status: "disconnected",
since: Date.now(),
});
} catch (cacheErr) {
this.logger.warn("Failed to set SF Pub/Sub disconnected status", {
error: cacheErr instanceof Error ? cacheErr.message : String(cacheErr),
});
}
}
}
async onModuleDestroy(): Promise<void> {
try {
if (this.client) {
await this.client.close();
this.client = null;
}
await this.cache.set(sfStatusKey(this.channel), {
status: "disconnected",
since: Date.now(),
});
} catch (error) {
this.logger.warn("Error closing Salesforce Pub/Sub client", {
error: error instanceof Error ? error.message : String(error),
});
}
}
// keys moved to shared util
}

View File

@ -3,6 +3,7 @@ import { Logger } from "nestjs-pino";
import { ConfigService } from "@nestjs/config";
import { getErrorMessage } from "../../common/utils/error.util";
import { SalesforceConnection } from "./services/salesforce-connection.service";
import { getSalesforceFieldMap } from "../../common/config/field-map";
import {
SalesforceAccountService,
AccountData,
@ -142,8 +143,9 @@ export class SalesforceService implements OnModuleInit {
throw new Error("Salesforce connection not available");
}
const fields = getSalesforceFieldMap();
const result = (await this.connection.query(
`SELECT Id, Status, Provisioning_Status__c, WHMCS_Order_ID__c, AccountId
`SELECT Id, Status, ${fields.order.activationStatus}, ${fields.order.whmcsOrderId}, AccountId
FROM Order
WHERE Id = '${orderId}'
LIMIT 1`

View File

@ -2,6 +2,7 @@ import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { getErrorMessage } from "../../../common/utils/error.util";
import { SalesforceConnection } from "./salesforce-connection.service";
import { SalesforceQueryResult as SfQueryResult } from "../../../orders/types/salesforce-order.types";
export interface AccountData {
name: string;
@ -20,22 +21,12 @@ export interface UpsertResult {
created: boolean;
}
interface SalesforceQueryResult {
records: SalesforceAccount[];
totalSize: number;
}
interface SalesforceAccount {
Id: string;
Name: string;
WH_Account__c?: string;
}
interface _SalesforceCreateResult {
id: string;
success: boolean;
}
@Injectable()
export class SalesforceAccountService {
constructor(
@ -49,7 +40,7 @@ export class SalesforceAccountService {
try {
const result = (await this.connection.query(
`SELECT Id FROM Account WHERE SF_Account_No__c = '${this.safeSoql(customerNumber.trim())}'`
)) as SalesforceQueryResult;
)) as SfQueryResult<SalesforceAccount>;
return result.totalSize > 0 ? { id: result.records[0].Id } : null;
} catch (error) {
this.logger.error("Failed to find account by customer number", {
@ -67,7 +58,7 @@ export class SalesforceAccountService {
try {
const result = (await this.connection.query(
`SELECT Id, Name, WH_Account__c FROM Account WHERE Id = '${this.safeSoql(accountId.trim())}'`
)) as SalesforceQueryResult;
)) as SfQueryResult<SalesforceAccount>;
if (result.totalSize === 0) {
return null;
@ -120,7 +111,7 @@ export class SalesforceAccountService {
try {
const existingAccount = (await this.connection.query(
`SELECT Id FROM Account WHERE Name = '${this.safeSoql(accountData.name.trim())}'`
)) as SalesforceQueryResult;
)) as SfQueryResult<SalesforceAccount>;
const sfData = {
Name: accountData.name.trim(),
@ -168,7 +159,7 @@ export class SalesforceAccountService {
SELECT Id, Name
FROM Account
WHERE Id = '${this.validateId(accountId)}'
`)) as SalesforceQueryResult;
`)) as SfQueryResult<SalesforceAccount>;
return result.totalSize > 0 ? result.records[0] : null;
} catch (error) {

View File

@ -4,6 +4,10 @@ import { getErrorMessage } from "../../../common/utils/error.util";
import { SalesforceConnection } from "./salesforce-connection.service";
import { SupportCase, CreateCaseRequest, CaseType } from "@customer-portal/shared";
import { CaseStatus, CasePriority, CASE_STATUS, CASE_PRIORITY } from "@customer-portal/shared";
import {
SalesforceQueryResult as SfQueryResult,
SalesforceCreateResult as SfCreateResult,
} from "../../../orders/types/salesforce-order.types";
export interface CaseQueryParams {
status?: string;
@ -27,11 +31,6 @@ interface CaseData {
origin?: string;
}
interface SalesforceQueryResult {
records: SalesforceCase[];
totalSize: number;
}
interface SalesforceCase {
Id: string;
CaseNumber: string;
@ -52,11 +51,6 @@ interface SalesforceCase {
};
}
interface SalesforceCreateResult {
id: string;
success: boolean;
}
@Injectable()
export class SalesforceCaseService {
constructor(
@ -92,7 +86,7 @@ export class SalesforceCaseService {
query += ` OFFSET ${params.offset}`;
}
const result = (await this.connection.query(query)) as SalesforceQueryResult;
const result = (await this.connection.query(query)) as SfQueryResult<SalesforceCase>;
const cases = result.records.map(record => this.transformCase(record));
@ -157,7 +151,7 @@ export class SalesforceCaseService {
WHERE Email = '${this.safeSoql(userData.email)}'
AND AccountId = '${userData.accountId}'
LIMIT 1
`)) as SalesforceQueryResult;
`)) as SfQueryResult<SalesforceCase>;
if (existingContact.totalSize > 0) {
return existingContact.records[0].Id;
@ -172,7 +166,7 @@ export class SalesforceCaseService {
};
const sobject = this.connection.sobject("Contact") as unknown as {
create: (data: Record<string, unknown>) => Promise<SalesforceCreateResult>;
create: (data: Record<string, unknown>) => Promise<SfCreateResult>;
};
const result = await sobject.create(contactData);
return result.id;
@ -201,7 +195,7 @@ export class SalesforceCaseService {
};
const sobject = this.connection.sobject("Case") as unknown as {
create: (data: Record<string, unknown>) => Promise<SalesforceCreateResult>;
create: (data: Record<string, unknown>) => Promise<SfCreateResult>;
};
const result = await sobject.create(sfData);
@ -211,7 +205,7 @@ export class SalesforceCaseService {
CreatedDate, LastModifiedDate, ClosedDate, ContactId, AccountId, OwnerId, Owner.Name
FROM Case
WHERE Id = '${result.id}'
`)) as SalesforceQueryResult;
`)) as SfQueryResult<SalesforceCase>;
return createdCase.records[0];
}

View File

@ -12,11 +12,6 @@ export interface SalesforceSObjectApi {
update?: (data: Record<string, unknown> & { Id: string }) => Promise<unknown>;
}
interface _SalesforceRetryableSObjectApi extends SalesforceSObjectApi {
create: (data: Record<string, unknown>) => Promise<{ id?: string }>;
update?: (data: Record<string, unknown> & { Id: string }) => Promise<unknown>;
}
@Injectable()
export class SalesforceConnection {
private connection: jsforce.Connection;
@ -30,6 +25,24 @@ export class SalesforceConnection {
});
}
/**
* Expose jsforce connection for advanced features
* Consumers must ensure connect() has been called and isConnected() is true.
*/
getConnection(): jsforce.Connection {
return this.connection;
}
/** Get the current OAuth access token (after connect()). */
getAccessToken(): string | undefined {
return this.connection?.accessToken as string | undefined;
}
/** Get the current instance URL (after connect()). */
getInstanceUrl(): string | undefined {
return this.connection?.instanceUrl as string | undefined;
}
async connect(): Promise<void> {
const nodeEnv =
this.configService.get<string>("NODE_ENV") || process.env.NODE_ENV || "development";
@ -48,18 +61,37 @@ export class SalesforceConnection {
throw new Error(isProd ? "Salesforce configuration is missing" : devMessage);
}
// Resolve private key strictly relative to repo root and enforce secrets directory
// Use monorepo layout assumption: apps/bff -> repo root is two levels up
const appDir = process.cwd();
const repoRoot = path.resolve(appDir, "../../");
const secretsDir = path.resolve(repoRoot, "secrets");
const resolvedKeyPath = path.resolve(repoRoot, privateKeyPath);
// Resolve private key and enforce allowed secrets directories
// Supports project-root ./secrets, apps/bff ./secrets, and container /app/secrets
const getProjectRoot = () => {
const cwd = process.cwd();
const norm = path.normalize(cwd);
const bffSuffix = path.normalize(path.join("apps", "bff"));
if (norm.endsWith(bffSuffix)) {
return path.resolve(cwd, "../..");
}
return cwd;
};
const isAbsolute = path.isAbsolute(privateKeyPath);
const resolvedKeyPath = isAbsolute
? privateKeyPath
: path.resolve(getProjectRoot(), privateKeyPath);
const allowedBases = [
path.resolve(getProjectRoot(), "secrets"),
path.resolve(process.cwd(), "secrets"),
"/app/secrets",
].map(p => path.normalize(p) + path.sep);
// Enforce the key to be under repo-root/secrets
const normalizedKeyPath = path.normalize(resolvedKeyPath);
const normalizedSecretsDir = path.normalize(secretsDir) + path.sep;
if (!(normalizedKeyPath + path.sep).startsWith(normalizedSecretsDir)) {
const devMsg = `Salesforce private key must be located under the root secrets directory: ${secretsDir}`;
const isUnderAllowedBase = allowedBases.some(base =>
(normalizedKeyPath + path.sep).startsWith(base)
);
if (!isUnderAllowedBase) {
const devMsg = `Salesforce private key must be under one of the allowed secrets directories: ${allowedBases
.map(b => b.replace(/\\$/, ""))
.join(", ")}. Got: ${normalizedKeyPath}`;
throw new Error(isProd ? "Invalid Salesforce private key path" : devMsg);
}
@ -132,6 +164,11 @@ export class SalesforceConnection {
// Expose connection methods with automatic re-authentication
async query(soql: string): Promise<unknown> {
try {
// Ensure we have a base URL and token
if (!this.isConnected()) {
this.logger.warn("Salesforce not connected; attempting to establish connection");
await this.connect();
}
return await this.connection.query(soql);
} catch (error: unknown) {
// Check if this is a session expiration error
@ -183,6 +220,10 @@ export class SalesforceConnection {
return {
create: async (data: Record<string, unknown>) => {
try {
if (!this.isConnected()) {
this.logger.warn("Salesforce not connected; attempting to establish connection");
await this.connect();
}
return await originalSObject.create(data);
} catch (error: unknown) {
if (this.isSessionExpiredError(error)) {
@ -208,6 +249,10 @@ export class SalesforceConnection {
update: async (data: Record<string, unknown> & { Id: string }) => {
try {
if (!this.isConnected()) {
this.logger.warn("Salesforce not connected; attempting to establish connection");
await this.connect();
}
return await originalSObject.update(data);
} catch (error: unknown) {
if (this.isSessionExpiredError(error)) {

View File

@ -48,7 +48,7 @@ export class WhmcsConnectionService {
identifier: this.configService.get<string>("WHMCS_API_IDENTIFIER", ""),
secret: this.configService.get<string>("WHMCS_API_SECRET", ""),
timeout: this.configService.get<number>("WHMCS_API_TIMEOUT", 30000),
retryAttempts: this.configService.get<number>("WHMCS_API_RETRY_ATTEMPTS", 3),
retryAttempts: this.configService.get<number>("WHMCS_API_RETRY_ATTEMPTS", 1),
retryDelay: this.configService.get<number>("WHMCS_API_RETRY_DELAY", 1000),
};
// Optional API Access Key (used by some WHMCS deployments alongside API Credentials)

View File

@ -19,6 +19,7 @@ export interface WhmcsAddOrderParams {
notes?: string;
sfOrderId?: string; // For tracking back to Salesforce
noinvoice?: boolean; // Default false - create invoice
noinvoiceemail?: boolean; // Default false - suppress invoice email (if invoice is created)
noemail?: boolean; // Default false - send emails
}
@ -210,6 +211,8 @@ export class WhmcsOrderService {
clientid: params.clientId,
paymentmethod: params.paymentMethod, // Required by WHMCS API
noinvoice: params.noinvoice ? true : false,
// If invoices are created (noinvoice=false), optionally suppress invoice email
...(params.noinvoiceemail ? { noinvoiceemail: true } : {}),
noemail: params.noemail ? true : false,
};
@ -225,7 +228,7 @@ export class WhmcsOrderService {
const configOptions: string[] = [];
const customFields: string[] = [];
params.items.forEach((item) => {
params.items.forEach(item => {
pids.push(item.productId);
billingCycles.push(item.billingCycle);
quantities.push(item.quantity);
@ -238,7 +241,7 @@ export class WhmcsOrderService {
configOptions.push(""); // Empty string for items without config options
}
// Handle custom fields - WHMCS expects base64 encoded serialized arrays
// Handle custom fields - WHMCS expects base64 encoded serialized arrays
if (item.customFields && Object.keys(item.customFields).length > 0) {
const serialized = this.serializeForWhmcs(item.customFields);
customFields.push(serialized);
@ -251,11 +254,11 @@ export class WhmcsOrderService {
payload.pid = pids;
payload.billingcycle = billingCycles;
payload.qty = quantities;
if (configOptions.some(opt => opt !== "")) {
payload.configoptions = configOptions;
}
if (customFields.some(field => field !== "")) {
payload.customfields = customFields;
}
@ -279,7 +282,7 @@ export class WhmcsOrderService {
try {
// Convert to PHP-style serialized format, then base64 encode
const serialized = this.phpSerialize(data);
return Buffer.from(serialized).toString('base64');
return Buffer.from(serialized).toString("base64");
} catch (error) {
this.logger.warn("Failed to serialize data for WHMCS", {
error: getErrorMessage(error),
@ -301,6 +304,6 @@ export class WhmcsOrderService {
const safeValue = String(value).replace(/"/g, '\\"');
return `s:${safeKey.length}:"${safeKey}";s:${safeValue.length}:"${safeValue}";`;
});
return `a:${entries.length}:{${serializedEntries.join('')}}`;
return `a:${entries.length}:{${serializedEntries.join("")}}`;
}
}

View File

@ -1,204 +0,0 @@
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Request } from "express";
import crypto from "node:crypto";
import { Logger } from "nestjs-pino";
import { Inject } from "@nestjs/common";
interface WebhookRequest extends Request {
webhookMetadata?: {
sourceIp: string;
timestamp: Date;
nonce: string;
signature: string;
};
}
@Injectable()
export class EnhancedWebhookSignatureGuard implements CanActivate {
private readonly nonceStore = new Set<string>(); // In production, use Redis
private readonly maxNonceAge = 5 * 60 * 1000; // 5 minutes
private readonly allowedIps: string[];
constructor(
private configService: ConfigService,
@Inject(Logger) private readonly logger: Logger
) {
// Parse IP allowlist from environment
const ipAllowlist = this.configService.get<string>("SF_WEBHOOK_IP_ALLOWLIST");
this.allowedIps = ipAllowlist ? ipAllowlist.split(",").map(ip => ip.trim()) : [];
}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<WebhookRequest>();
try {
// 1. Verify source IP if allowlist is configured
if (this.allowedIps.length > 0) {
this.verifySourceIp(request);
}
// 2. Extract and verify required headers
const headers = this.extractHeaders(request);
// 3. Verify timestamp (prevent replay attacks)
this.verifyTimestamp(headers.timestamp);
// 4. Verify nonce (prevent duplicate processing)
this.verifyNonce(headers.nonce);
// 5. Verify HMAC signature
this.verifyHmacSignature(request, headers.signature);
// Store metadata for logging/monitoring
request.webhookMetadata = {
sourceIp: request.ip || "unknown",
timestamp: new Date(headers.timestamp),
nonce: headers.nonce,
signature: headers.signature,
};
this.logger.log("Webhook security validation passed", {
sourceIp: request.ip,
nonce: headers.nonce,
timestamp: headers.timestamp,
});
return true;
} catch (error) {
this.logger.warn("Webhook security validation failed", {
sourceIp: request.ip,
error: error instanceof Error ? error.message : String(error),
userAgent: request.headers["user-agent"],
});
throw error;
}
}
private verifySourceIp(request: Request): void {
const clientIp = request.ip || request.connection.remoteAddress || "unknown";
// Check if IP is in allowlist (simplified - in production use proper CIDR matching)
const isAllowed = this.allowedIps.some(allowedIp => {
if (allowedIp.includes("/")) {
// CIDR notation - implement proper CIDR matching
return this.isIpInCidr(clientIp, allowedIp);
}
return clientIp === allowedIp;
});
if (!isAllowed) {
throw new UnauthorizedException(`IP ${clientIp} not in allowlist`);
}
}
private extractHeaders(request: Request) {
const signature =
(request.headers["x-sf-signature"] as string) ||
(request.headers["x-whmcs-signature"] as string);
const timestamp = request.headers["x-sf-timestamp"] as string;
const nonce = request.headers["x-sf-nonce"] as string;
if (!signature) {
throw new UnauthorizedException("Webhook signature is required");
}
if (!timestamp) {
throw new UnauthorizedException("Webhook timestamp is required");
}
if (!nonce) {
throw new UnauthorizedException("Webhook nonce is required");
}
return { signature, timestamp, nonce };
}
private verifyTimestamp(timestamp: string): void {
const requestTime = new Date(timestamp).getTime();
const now = Date.now();
const tolerance = this.configService.get<number>("WEBHOOK_TIMESTAMP_TOLERANCE") || 300000; // 5 minutes
if (isNaN(requestTime)) {
throw new UnauthorizedException("Invalid timestamp format");
}
if (Math.abs(now - requestTime) > tolerance) {
throw new UnauthorizedException("Request timestamp outside acceptable range");
}
}
private verifyNonce(nonce: string): void {
// Check if nonce was already used
if (this.nonceStore.has(nonce)) {
throw new UnauthorizedException("Nonce already used (replay attack detected)");
}
// Add nonce to store
this.nonceStore.add(nonce);
// Clean up old nonces (in production, implement proper TTL with Redis)
this.cleanupOldNonces();
}
private verifyHmacSignature(request: Request, signature: string): void {
// Determine webhook type and get appropriate secret
const isWhmcs = Boolean(request.headers["x-whmcs-signature"]);
const isSalesforce = Boolean(request.headers["x-sf-signature"]);
let secret: string | undefined;
if (isWhmcs) {
secret = this.configService.get<string>("WHMCS_WEBHOOK_SECRET");
} else if (isSalesforce) {
secret = this.configService.get<string>("SF_WEBHOOK_SECRET");
}
if (!secret) {
throw new UnauthorizedException("Webhook secret not configured");
}
// Create signature from request body
const payload = Buffer.from(JSON.stringify(request.body), "utf8");
const key = Buffer.from(secret, "utf8");
const expectedSignature = crypto.createHmac("sha256", key).update(payload).digest("hex");
// Use constant-time comparison to prevent timing attacks
if (!this.constantTimeCompare(signature, expectedSignature)) {
throw new UnauthorizedException("Invalid webhook signature");
}
}
private constantTimeCompare(a: string, b: string): boolean {
if (a.length !== b.length) {
return false;
}
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return result === 0;
}
private isIpInCidr(ip: string, cidr: string): boolean {
// Simplified CIDR check - in production use a proper library like 'ip-range-check'
// This is a basic implementation for IPv4
const [network, prefixLength] = cidr.split("/");
const networkInt = this.ipToInt(network);
const ipInt = this.ipToInt(ip);
const mask = -1 << (32 - parseInt(prefixLength, 10));
return (networkInt & mask) === (ipInt & mask);
}
private ipToInt(ip: string): number {
return ip.split(".").reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>> 0;
}
private cleanupOldNonces(): void {
// In production, implement proper cleanup with Redis TTL
// This is a simplified cleanup for in-memory storage
if (this.nonceStore.size > 10000) {
this.nonceStore.clear();
}
}
}

View File

@ -1,41 +0,0 @@
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Request } from "express";
import crypto from "node:crypto";
@Injectable()
export class WebhookSignatureGuard implements CanActivate {
constructor(private configService: ConfigService) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<Request>();
const signatureHeader =
(request.headers["x-whmcs-signature"] as string | undefined) ||
(request.headers["x-sf-signature"] as string | undefined);
if (!signatureHeader) {
throw new UnauthorizedException("Webhook signature is required");
}
// Get the appropriate secret based on the webhook type
const isWhmcs = Boolean(request.headers["x-whmcs-signature"]);
const secret = isWhmcs
? this.configService.get<string>("WHMCS_WEBHOOK_SECRET")
: this.configService.get<string>("SF_WEBHOOK_SECRET");
if (!secret) {
throw new UnauthorizedException("Webhook secret not configured");
}
// Verify signature
const payload = Buffer.from(JSON.stringify(request.body), "utf8");
const key = Buffer.from(secret, "utf8");
const expectedSignature = crypto.createHmac("sha256", key).update(payload).digest("hex");
if (signatureHeader !== expectedSignature) {
throw new UnauthorizedException("Invalid webhook signature");
}
return true;
}
}

View File

@ -1,8 +0,0 @@
import { z } from "zod";
export const SalesforceWebhookSchema = z.object({
event: z.object({ type: z.string() }).optional(),
sobject: z.object({ Id: z.string() }).optional(),
});
export type SalesforceWebhook = z.infer<typeof SalesforceWebhookSchema>;

View File

@ -1,8 +0,0 @@
import { z } from "zod";
export const WhmcsWebhookSchema = z.object({
action: z.string(),
client_id: z.coerce.number().int().optional(),
});
export type WhmcsWebhook = z.infer<typeof WhmcsWebhookSchema>;

View File

@ -1,57 +0,0 @@
import {
Controller,
Post,
Body,
Headers,
UseGuards,
HttpCode,
HttpStatus,
BadRequestException,
} from "@nestjs/common";
import { WebhooksService } from "./webhooks.service";
import { ApiTags, ApiOperation, ApiResponse, ApiHeader } from "@nestjs/swagger";
import { ThrottlerGuard } from "@nestjs/throttler";
import { WebhookSignatureGuard } from "./guards/webhook-signature.guard";
import { Public } from "../auth/decorators/public.decorator";
@ApiTags("webhooks")
@Controller("webhooks")
@Public() // Webhooks use signature-based authentication, not JWT
@UseGuards(ThrottlerGuard) // Rate limit webhook endpoints
export class WebhooksController {
constructor(private webhooksService: WebhooksService) {}
@Post("whmcs")
@HttpCode(HttpStatus.OK)
@UseGuards(WebhookSignatureGuard)
@ApiOperation({ summary: "WHMCS webhook endpoint" })
@ApiResponse({ status: 200, description: "Webhook processed successfully" })
@ApiResponse({ status: 400, description: "Invalid webhook data" })
@ApiResponse({ status: 401, description: "Invalid signature" })
@ApiHeader({ name: "X-WHMCS-Signature", description: "WHMCS webhook signature" })
handleWhmcsWebhook(@Body() payload: unknown, @Headers("x-whmcs-signature") signature: string) {
try {
this.webhooksService.processWhmcsWebhook(payload, signature);
return { success: true, message: "Webhook processed successfully" };
} catch {
throw new BadRequestException("Failed to process webhook");
}
}
@Post("salesforce")
@HttpCode(HttpStatus.OK)
@UseGuards(WebhookSignatureGuard)
@ApiOperation({ summary: "Salesforce webhook endpoint" })
@ApiResponse({ status: 200, description: "Webhook processed successfully" })
@ApiResponse({ status: 400, description: "Invalid webhook data" })
@ApiResponse({ status: 401, description: "Invalid signature" })
@ApiHeader({ name: "X-SF-Signature", description: "Salesforce webhook signature" })
handleSalesforceWebhook(@Body() payload: unknown, @Headers("x-sf-signature") signature: string) {
try {
this.webhooksService.processSalesforceWebhook(payload, signature);
return { success: true, message: "Webhook processed successfully" };
} catch {
throw new BadRequestException("Failed to process webhook");
}
}
}

View File

@ -1,12 +0,0 @@
import { Module } from "@nestjs/common";
import { WebhooksController } from "./webhooks.controller";
import { WebhooksService } from "./webhooks.service";
import { VendorsModule } from "../vendors/vendors.module";
import { JobsModule } from "../jobs/jobs.module";
@Module({
imports: [VendorsModule, JobsModule],
controllers: [WebhooksController],
providers: [WebhooksService],
})
export class WebhooksModule {}

View File

@ -1,63 +0,0 @@
import { Injectable, BadRequestException, Inject } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
import { WhmcsWebhookSchema, WhmcsWebhook } from "./schemas/whmcs";
import { SalesforceWebhookSchema, SalesforceWebhook } from "./schemas/salesforce";
@Injectable()
export class WebhooksService {
constructor(
private configService: ConfigService,
@Inject(Logger) private readonly logger: Logger
) {}
processWhmcsWebhook(payload: unknown, signature: string): void {
try {
const data: WhmcsWebhook = WhmcsWebhookSchema.parse(payload);
this.logger.log("Processing WHMCS webhook", {
webhookType: data.action,
clientId: data.client_id,
signatureLength: signature?.length || 0,
});
// TODO: Implement WHMCS webhook processing logic
// This should handle various WHMCS events like:
// - Invoice creation/update
// - Payment received
// - Client status changes
// - Service changes
this.logger.log("WHMCS webhook processed successfully");
} catch (error) {
this.logger.error("Failed to process WHMCS webhook", {
error: error instanceof Error ? error.message : String(error),
});
throw new BadRequestException("Failed to process WHMCS webhook");
}
}
processSalesforceWebhook(payload: unknown, signature: string): void {
try {
const data: SalesforceWebhook = SalesforceWebhookSchema.parse(payload);
this.logger.log("Processing Salesforce webhook", {
webhookType: data.event?.type || "unknown",
recordId: data.sobject?.Id,
signatureLength: signature?.length || 0,
});
// TODO: Implement Salesforce webhook processing logic
// This should handle various Salesforce events like:
// - Account updates
// - Contact changes
// - Opportunity updates
// - Custom object changes
this.logger.log("Salesforce webhook processed successfully");
} catch (error) {
this.logger.error("Failed to process Salesforce webhook", {
error: error instanceof Error ? error.message : String(error),
});
throw new BadRequestException("Failed to process Salesforce webhook");
}
}
}

View File

@ -3,8 +3,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// Disable Next DevTools to avoid manifest errors in dev
devtools: { enabled: false },
// Enable standalone output only for production deployment
output: process.env.NODE_ENV === "production" ? "standalone" : undefined,
@ -24,13 +22,7 @@ const nextConfig = {
],
// Turbopack configuration (Next.js 15.5+)
turbopack: {
// Enable Turbopack optimizations
resolveAlias: {
// Path aliases for cleaner imports
"@": "./src",
},
},
// Note: public turbopack options are limited; aliasing is handled via tsconfig/webpack resolutions
// Environment variables validation
env: {

View File

@ -55,7 +55,7 @@ const getDetailedStatusInfo = (
activationType?: string,
scheduledAt?: string
): StatusInfo => {
if (status === "Activated") {
if (activationStatus === "Activated") {
return {
label: "Service Active",
color: "text-green-800",
@ -95,7 +95,7 @@ const getDetailedStatusInfo = (
};
}
if (activationStatus === "In Progress") {
if (activationStatus === "Activating") {
return {
label: "Setting Up Service",
color: "text-purple-800",

View File

@ -80,7 +80,7 @@ export default function OrdersPage() {
const getStatusInfo = (status: string, activationStatus?: string): StatusInfo => {
// Combine order status and activation status for better UX
if (status === "Activated") {
if (activationStatus === "Activated") {
return {
label: "Active",
color: "text-green-800",
@ -99,7 +99,7 @@ export default function OrdersPage() {
};
}
if (activationStatus === "Scheduled" || activationStatus === "In Progress") {
if (activationStatus === "Activating") {
return {
label: "Setting Up",
color: "text-orange-800",

View File

@ -24,7 +24,7 @@ hasPaymentMethod(clientId: number): Promise<boolean>
```
### **2. Order Provisioning Service**
**File**: `/apps/bff/src/orders/services/order-provisioning.service.ts`
**File**: `/apps/bff/src/orders/services/order-fulfillment.service.ts`
- **Purpose**: Orchestrates the complete provisioning flow
- **Features**:
@ -37,15 +37,14 @@ hasPaymentMethod(clientId: number): Promise<boolean>
**Complete Flow**:
1. Validate SF Order → 2. Check Payment Method → 3. Map OrderItems → 4. Create WHMCS Order → 5. Accept WHMCS Order → 6. Update Salesforce
### **3. Separate Salesforce Provisioning Controller**
**File**: `/apps/bff/src/orders/controllers/salesforce-provisioning.controller.ts`
### **3. Salesforce Platform Events Subscriber**
**File**: `/apps/bff/src/vendors/salesforce/events/pubsub.subscriber.ts`
- **Purpose**: Dedicated controller for Salesforce webhook calls
- **Purpose**: Subscribes to `OrderProvisionRequested__e` and enqueues provisioning jobs
- **Features**:
- Enhanced security (HMAC, timestamps, nonces)
- Comprehensive API documentation
- Proper error responses
- Separated from customer-facing order operations
- Durable replay (stores last `replayId` in Redis)
- Decoupled from HTTP/webhooks
- Enqueues to BullMQ `provisioning` queue
### **4. Clean Order Controller**
**File**: `/apps/bff/src/orders/orders.controller.ts`
@ -64,11 +63,11 @@ hasPaymentMethod(clientId: number): Promise<boolean>
## 🔄 **The Complete Flow**
```
1. Salesforce Quick Action → POST /orders/{sfOrderId}/provision
1. Salesforce Flow publishes OrderProvisionRequested__e
2. SalesforceProvisioningController (security validation)
2. PlatformEventsSubscriber enqueues provisioning job
3. OrderProvisioningService (orchestration)
3. OrderFulfillmentOrchestrator (orchestration)
4. WhmcsOrderService (WHMCS operations)
@ -108,7 +107,7 @@ The system now properly handles the Salesforce → WHMCS mapping as specified in
- ✅ **AcceptOrder**: Provisions services and creates subscriptions
- ✅ **Payment validation**: Checks client has payment method
- ✅ **Error handling**: Updates Salesforce on failures
- ✅ **Idempotency**: Prevents duplicate provisioning
- ✅ **Idempotency**: Prevents duplicate fulfillment
## 🎯 **Benefits of New Architecture**
@ -116,15 +115,15 @@ The system now properly handles the Salesforce → WHMCS mapping as specified in
- **Single Responsibility**: Each service has one clear purpose
- **Separation of Concerns**: WHMCS logic separate from Salesforce logic
- **Testability**: Each service can be tested independently
- **Extensibility**: Easy to add new provisioning steps
- **Extensibility**: Easy to add new fulfillment steps
### **Security**:
- **Dedicated Controller**: Focused security for Salesforce webhooks
- **Enhanced Guards**: HMAC, timestamp, nonce validation
- **Event-Driven**: No inbound Salesforce webhooks; Platform Events subscription with JWT auth
- **Scoped Access**: Pub/Sub API via Connected App (JWT)
- **Clean Error Handling**: No sensitive data exposure
### **Reliability**:
- **Idempotency**: Safe retries for provisioning
- **Idempotency**: Safe retries for fulfillment
- **Comprehensive Logging**: Full audit trail
- **Error Recovery**: Proper Salesforce status updates on failures
@ -138,10 +137,10 @@ The system now properly handles the Salesforce → WHMCS mapping as specified in
### **2. Testing**:
```typescript
// Test the complete flow
describe('Order Provisioning', () => {
it('should provision SF order in WHMCS', async () => {
// Test complete flow from SF webhook to WHMCS provisioning
// Test the event-driven flow
describe('Order Provisioning (Platform Events)', () => {
it('enqueues a job when an event arrives', async () => {
// Simulate OrderProvisionRequested__e → assert queue enqueue
});
});
```
@ -156,16 +155,18 @@ describe('Order Provisioning', () => {
```
apps/bff/src/
├── orders/
│ ├── controllers/
│ │ └── salesforce-provisioning.controller.ts # NEW: Dedicated SF webhook
│ ├── queue/
│ │ ├── provisioning.queue.ts # Enqueue jobs
│ │ └── provisioning.processor.ts # Worker runs orchestrator
│ ├── services/
│ │ ├── order-orchestrator.service.ts # CLEANED: Order creation only
│ │ └── order-provisioning.service.ts # NEW: Provisioning orchestration
│ └── orders.controller.ts # CLEANED: Customer operations only
│ │ ├── order-orchestrator.service.ts # Order creation only
│ │ └── order-fulfillment-orchestrator.service.ts # Provisioning orchestration
│ └── orders.controller.ts # Customer operations only
├── vendors/
│ ├── salesforce/
│ │ └── events/pubsub.subscriber.ts # Subscribes via Pub/Sub gRPC
│ └── whmcs/
│ └── services/
│ └── whmcs-order.service.ts # NEW: WHMCS order operations
│ └── services/whmcs-order.service.ts # WHMCS order operations
```
This architecture is now **clean, maintainable, and production-ready** with proper separation of concerns and comprehensive WHMCS integration! 🎉

View File

@ -9,11 +9,10 @@ I've cleanly integrated secure Salesforce-to-Portal communication into your exis
- **Added**: `getOrder()` method for order validation
- **Integration**: Works with your existing Salesforce connection
### 2. **Secured Orders Controller**
- **Enhanced**: Existing `/orders/:sfOrderId/provision` endpoint
- **Added**: `EnhancedWebhookSignatureGuard` for HMAC signature validation
- **Added**: Proper API documentation and error handling
- **Security**: Timestamp, nonce, and idempotency key validation
### 2. **Event-Driven Provisioning**
- **Added**: Salesforce Platform Events subscriber (OrderProvisionRequested__e)
- **Added**: BullMQ provisioning queue + processor
- **Behavior**: Subscribes to SF, enqueues job, runs orchestrator, updates SF
### 3. **Updated OrderOrchestrator**
- **Added**: `provisionOrderFromSalesforce()` method for the real provisioning flow
@ -21,94 +20,35 @@ I've cleanly integrated secure Salesforce-to-Portal communication into your exis
- **Features**: Idempotency, error handling, direct Salesforce updates
- **Logging**: Comprehensive audit trail without sensitive data
## 🔄 The Simple Flow
## 🔄 The Flow (Async)
```
1. Salesforce Quick Action → POST /orders/{sfOrderId}/provision (with HMAC security)
2. Portal BFF validates → Provisions in WHMCS → DIRECTLY updates Salesforce Order
1. Salesforce Flow sets `Activation_Status__c = Activating` and publishes OrderProvisionRequested__e on approval
2. Portal BFF subscribes → guards on `Activation_Status__c = Activating` → enqueues job → provisions in WHMCS → updates Salesforce Order
3. Customer polls Portal → Gets updated order status
```
**No reverse webhooks needed!** The Portal directly updates Salesforce via your existing API connection.
## 🔒 Security Features
- **HMAC SHA-256 signature verification** (using your existing guard pattern)
- **Timestamp validation** (5-minute tolerance)
- **Nonce verification** (prevents replay attacks)
- **Idempotency keys** (safe retries)
- **IP allowlisting** (Salesforce IP ranges)
- **JWT auth to Salesforce** (Connected App, private key JWT)
- **Platform Event permissions** (Permission Set) for Platform Events
- **Idempotency keys** (via event field `IdemKey__c`, safe retries)
- **Comprehensive logging** (no sensitive data exposure)
## 📝 Next Steps
### 1. Salesforce Setup
Create this Apex class for the Quick Action:
```apex
public class OrderProvisioningService {
private static final String WEBHOOK_SECRET = '{!$Credential.Portal_Webhook.Password}';
@future(callout=true)
public static void provisionOrder(String orderId) {
try {
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);
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);
if (res.getStatusCode() != 200) {
throw new Exception('Portal returned: ' + res.getStatusCode());
}
} 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 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);
}
update ord;
}
}
```
Platform Event: OrderProvisionRequested__e (fields: OrderId__c [Text 18], IdemKey__c [Text 80, optional])
Permission Set: grant Platform Event permissions and PE object read to integration user
Flow (RecordTriggered): On Order Status = Approved → Set `Activation_Status__c = Activating` → Create OrderProvisionRequested__e
```
### 2. Environment Variables
```bash
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
SF_EVENTS_ENABLED=true
SF_PROVISION_EVENT_CHANNEL=/event/OrderProvisionRequested__e
SF_EVENTS_REPLAY=LATEST
```
### 3. Complete the TODOs
@ -121,8 +61,8 @@ In `OrderOrchestrator.provisionOrderFromSalesforce()`:
## 🎯 Key Benefits
**Clean integration** with your existing architecture
**No reverse webhooks** - direct Salesforce API updates
**Production-ready security** - HMAC, timestamps, idempotency
**No inbound SF webhooks** - event-driven, durable replay
**Production-ready security** - JWT to Salesforce; event idempotency
**Proper error handling** - updates Salesforce on failures
**Comprehensive logging** - audit trail without sensitive data
**Simple customer experience** - polling for status updates

View File

@ -14,15 +14,17 @@ I've restructured the provisioning system to **match the exact same clean modula
| `OrderBuilder` | `WhmcsOrderMapper` | Transforms/maps data structures |
| `OrderItemBuilder` | *(integrated in mapper)* | Handles item-level processing |
| `OrderOrchestrator` | `ProvisioningOrchestrator` | Coordinates the complete workflow |
| `OrdersController` | `SalesforceProvisioningController` | HTTP endpoint handling |
| `OrdersController` | `PlatformEventsSubscriber` | Event handling (no inbound HTTP) |
## 📁 **Clean File Structure**
```
apps/bff/src/orders/
├── controllers/
│ ├── orders.controller.ts # Customer-facing operations
│ └── salesforce-provisioning.controller.ts # Salesforce webhook operations
│ └── orders.controller.ts # Customer-facing operations
├── queue/
│ ├── provisioning.queue.ts # Enqueue provisioning jobs
│ └── provisioning.processor.ts # Worker processes jobs
├── services/
│ # Order Creation (existing)
│ ├── order-validator.service.ts # Request & business validation
@ -82,8 +84,8 @@ apps/bff/src/orders/
## 🔄 **The Complete Modular Flow**
```
SalesforceProvisioningController
↓ (validates webhook security)
PlatformEventsSubscriber (OrderProvisionRequested__e)
↓ (enqueues job)
OrderProvisioningService
↓ (coordinates workflow)
ProvisioningValidator

View File

@ -13,7 +13,7 @@
### Data Flow
```
Customer → Portal → BFF → Salesforce (Order Creation)
CS Team → Salesforce → BFF → WHMCS (Order Fulfillment)
CS Team → Salesforce (Platform Event) → BFF (Subscriber) → WHMCS (Order Fulfillment)
```
## 🛍️ Complete Customer Journey
@ -104,25 +104,14 @@ FROM Order
WHERE Id = '8014x000000ABCDXYZ'
```
#### 6. Provision Trigger
```javascript
// Salesforce Quick Action calls BFF
// Named Credential: Portal_BFF_Endpoint
// Endpoint: https://portal-api.company.com/orders/{!Order.Id}/fulfill
POST /orders/8014x000000ABCDXYZ/fulfill
Headers: {
"X-SF-Signature": "sha256=a1b2c3d4e5f6...",
"X-SF-Timestamp": "2024-01-15T10:30:00Z",
"X-SF-Nonce": "abc123def456",
"Idempotency-Key": "provision_8014x000000ABCDXYZ_1705312200000"
}
Body: {
"orderId": "8014x000000ABCDXYZ",
"timestamp": "2024-01-15T10:30:00Z",
"nonce": "abc123def456"
}
#### 6. Provision Trigger (Platform Events)
```text
Salesforce RecordTriggered Flow publishes Platform Event: OrderProvisionRequested__e
Fields:
- OrderId__c (Text 18)
- IdemKey__c (Text 80, optional)
```
The portal subscribes to this event, enqueues a job, and performs provisioning.
### Phase 3: Order Fulfillment
@ -312,22 +301,8 @@ class OrderFulfillmentOrchestrator {
## 🔒 Security Implementation
### Webhook Security Headers
```typescript
// Required headers for Salesforce → BFF webhook
{
"X-SF-Signature": "sha256=HMAC-SHA256(secret, body)",
"X-SF-Timestamp": "2024-01-15T10:30:00Z",
"X-SF-Nonce": "unique_random_string",
"Idempotency-Key": "provision_{orderId}_{timestamp}"
}
// Validation rules
├── Signature: HMAC-SHA256 verification with shared secret
├── Timestamp: Max 5 minutes old
├── Nonce: Stored to prevent replay attacks
└── Idempotency: Prevents duplicate provisioning
```
- No inbound Salesforce webhooks are used; provisioning is triggered via Platform Events.
- Portal authenticates to Salesforce via JWT (Connected App) and requires Platform Event permissions.
### Error Codes
```typescript
@ -344,17 +319,17 @@ enum FulfillmentErrorCode {
### Typical Timeline
```
10:30:00.000 - CS clicks "Provision Order"
10:30:00.100 - Webhook received and validated
10:30:00.000 - CS approves Order
10:30:00.050 - Platform Event published (OrderProvisionRequested__e)
10:30:00.080 - BFF subscriber enqueues provisioning job
10:30:00.200 - Salesforce order updated to "Activating"
10:30:00.500 - Order details retrieved and mapped
10:30:01.000 - WHMCS AddOrder API call
10:30:01.500 - WHMCS AcceptOrder API call
10:30:02.000 - Services provisioned in WHMCS
10:30:02.200 - Salesforce updated to "Activated"
10:30:02.300 - Response sent to Salesforce
Total fulfillment time: ~2.3 seconds
Total fulfillment time: ~2.2 seconds (asynchronous trigger)
```
### API Call Performance
@ -366,45 +341,20 @@ Total fulfillment time: ~2.3 seconds ⚡
## 🔧 Configuration Requirements
### Salesforce Setup
```apex
// Quick Action configuration
Global class OrderProvisioningQuickAction {
@InvocableMethod(label='Provision Order' description='Provision order in WHMCS')
public static void provisionOrder(List<Id> orderIds) {
for (Id orderId : orderIds) {
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:Portal_BFF_Endpoint/orders/' + orderId + '/fulfill');
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setHeader('X-SF-Signature', generateHmacSignature(orderId));
req.setHeader('X-SF-Timestamp', Datetime.now().formatGmt('yyyy-MM-dd\'T\'HH:mm:ss\'Z\''));
req.setHeader('X-SF-Nonce', generateNonce());
req.setHeader('Idempotency-Key', 'provision_' + orderId + '_' + Datetime.now().getTime());
req.setBody(JSON.serialize(new Map<String, Object>{
'orderId' => orderId,
'timestamp' => Datetime.now().formatGmt('yyyy-MM-dd\'T\'HH:mm:ss\'Z\''),
'nonce' => generateNonce()
}));
Http http = new Http();
HttpResponse res = http.send(req);
}
}
}
```
### Named Credential
```
Name: Portal_BFF_Endpoint
URL: https://portal-api.company.com
Authentication: Custom (with HMAC signing)
1) Platform Event: OrderProvisionRequested__e (fields: OrderId__c [Text 18], IdemKey__c [Text 80, optional])
2) Permission Set: grant Platform Event permissions and PE object read to the portal integration user
3) Flow (RecordTriggered): On Order Status = Approved → Create OrderProvisionRequested__e with OrderId__c
```
### Environment Variables
```bash
# BFF Configuration
SALESFORCE_WEBHOOK_SECRET=your_hmac_secret_key
# BFF Configuration (Salesforce Platform Events)
SF_EVENTS_ENABLED=true
SF_PROVISION_EVENT_CHANNEL=/event/OrderProvisionRequested__e
SF_EVENTS_REPLAY=LATEST
# WHMCS API
WHMCS_API_IDENTIFIER=your_whmcs_api_id
WHMCS_API_SECRET=your_whmcs_api_secret
WHMCS_API_URL=https://your-whmcs.com/includes/api.php

View File

@ -30,7 +30,7 @@ We require a Customer Number (SF Number) at signup and gate checkout on the pres
- `Order` (header; one per checkout)
- `OrderItem` (child; one per selected product) → references `Product2` (and PricebookEntry)
- Provisioning
- Operator approves in Salesforce → Quick Action calls BFF → BFF reads Order + OrderItems, dereferences `Product2` portal fields, calls WHMCS `AddOrder``AcceptOrder`, then writes back WHMCS IDs to Order/OrderItems
- Operator approves in Salesforce → RecordTriggered Flow publishes Platform Event → BFF reads Order + OrderItems, dereferences `Product2` portal fields, calls WHMCS `AddOrder``AcceptOrder`, then writes back WHMCS IDs to Order/OrderItems
## 1) Customer Experience
@ -52,8 +52,9 @@ We require a Customer Number (SF Number) at signup and gate checkout on the pres
- `POST /orders` creates a Salesforce Order (Pending Review) and stores orchestration state in BFF. Portal shows “Awaiting review”.
5. Review & Provision (operator in Salesforce)
- Operator reviews/approves. Quick Action “Provision in WHMCS” invokes BFF `POST /orders/{sfOrderId}/provision`.
- BFF validates payment method, (for eSIM) calls activation API, then `AddOrder` and `AcceptOrder` in WHMCS, updates Salesforce Order fields/status.
- Recommended (async): Operator reviews/approves. A Record-Triggered Flow publishes Platform Event `OrderProvisionRequested__e`.
- BFF subscribes to the event and enqueues a provisioning job. Worker validates payment method, (for eSIM) calls activation API, then `AddOrder` and `AcceptOrder` in WHMCS, updates Salesforce Order fields/status.
- Legacy (webhook): previously called `POST /orders/{sfOrderId}/fulfill`. This path has been removed.
6. Completion
- Subscriptions and invoices appear in portal (`/subscriptions`, `/billing/invoices`). Pay via WHMCS SSO links.
@ -64,7 +65,7 @@ We will send operational emails at key events (no email validation step required
- Signup success: send Welcome email to customer; CC support.
- eSIM activation: send Activation email to customer; CC support.
- Order provisioned: send Provisioned/Next steps email to customer.
- Order activated: send Activated/Next steps email to customer.
Implementation notes:
@ -176,7 +177,7 @@ Endpoints (BFF)
- Caching & Invalidation
- Cache global catalog 15m; cache personalized results per `sfAccountId` 5m.
- Optional Salesforce webhook to bust cache on `Product_Offering__c` changes.
- Optional Salesforce CDC/Platform Event to bust cache on `Product_Offering__c` changes.
### 2.6 Orders & Provisioning
@ -188,14 +189,10 @@ Endpoints (BFF)
- `GET /orders/:sfOrderId` (new)
- Returns orchestration status and relevant IDs; portal polls for updates.
- `POST /orders/:sfOrderId/provision` (new; invoked from Salesforce only)
- Auth: Named Credentials + signed headers (HMAC with timestamp/nonce) + IP allowlisting; require `Idempotency-Key`.
- Steps:
- Re-check payment method; if missing: set SF `Provisioning_Status__c=Failed`, `Error=Payment Method Missing`; return 409.
- If eSIM: call activation API; on success store masked ICCID/EID; on failure: update SF as Failed and return 502.
- WHMCS `AddOrder` (include `sfOrderId` in notes); then `AcceptOrder` to provision and create invoice/subscription.
- Update Salesforce Order fields and status to Provisioned; persist WHMCS IDs in orchestration record; return summary.
- Send Activation/Provisioned email depending on product and step outcome.
- Async Provisioning (Platform Events)
- Event: `OrderProvisionRequested__e` with `{ OrderId__c, IdemKey__c?, CorrelationId__c? }`
- BFF autosubscribes when `SF_EVENTS_ENABLED=true`; enqueues provisioning job; returns 202 immediately (no inbound SF call).
- Worker performs the same steps as above and updates Salesforce.
## 3) Salesforce
@ -215,11 +212,9 @@ Endpoints (BFF)
### 3.2 Order fields
- Add the following fields to `Order`:
- `Provisioning_Status__c` (Pending Review, Approved, Activating, Provisioned, Failed)
- `Provisioning_Error_Code__c` (short)
- `Provisioning_Error_Message__c` (sanitized)
- `Activation_Status__c` (Pending Review, Activating, Provisioned, Failed)
- `WHMCS_Order_ID__c`
- `ESIM_ICCID__c` (masked), `Last_Provisioning_At__c`, `Attempt_Count__c`
- Optional (if needed): `ESIM_ICCID__c`
#### 3.2.1 Salesforce Order API & Required Fields (to confirm)
@ -227,12 +222,12 @@ Endpoints (BFF)
- Required fields for creation (proposal):
- `AccountId` (from SF Number lookup)
- `EffectiveDate` (today)
- `Status` (set to "Pending Review")
- `Status` (org-specific; code currently sets "Pending Review" — use your org's draft/review value)
- `Description` (optional: include product summary)
- Custom: `Provisioning_Status__c = Pending Review`
- Custom: `Activation_Status__c = Not Started`
- Optional link: `OpportunityId` (if created/available)
- On updates during provisioning:
- Set `Provisioning_Status__c` → Activating → Provisioned/Failed
- Set `Activation_Status__c` → Activating → Activated/Failed
- Store `WHMCS_Order_ID__c`
- For eSIM: masked `ESIM_ICCID__c`
@ -262,7 +257,7 @@ We will build the BFF payload for WHMCS from these line records plus the Order h
- SF `Order.Id` → included in WHMCS `notes` as `sfOrderId=<Id>`
- SF `AccountId` → via portal mapping to `whmcsClientId``AddOrder.clientid`
- SF `Promo_Code__c` (if on header) → `AddOrder.promocode`
- SF `Provisioning_Status__c` controls operator flow; not sent to WHMCS
- SF `Activation_Status__c` controls operator flow; not sent to WHMCS
- Line mapping (per OrderItem)
- Product2 `WHMCS_Product_Id__c``AddOrder.pid[]`
@ -272,15 +267,12 @@ We will build the BFF payload for WHMCS from these line records plus the Order h
- After `AddOrder`:
- Call `AcceptOrder` to provision; capture `orderid` from response
- Update SF `WHMCS_Order_ID__c`; set `Provisioning_Status__c = Provisioned` on success
- On error, set `Provisioning_Status__c = Failed` and write short, sanitized `Provisioning_Error_Code__c` / `Provisioning_Error_Message__c`
- Update SF `WHMCS_Order_ID__c`; set `Activation_Status__c = Activated` on success
- On error, set `Activation_Status__c = Failed`
### 3.3 Quick Action / Flow
### 3.3 Flow (Provisioning Trigger)
- Quick Action “Provision in WHMCS” calls BFF `POST /orders/{sfOrderId}/provision` with headers:
- `Authorization` (Named Credentials)
- `Idempotency-Key` (UUID)
- `X-Timestamp`, `X-Nonce`, `X-Signature` (HMAC of method+path+timestamp+nonce+body)
- RecordTriggered Flow publishes `OrderProvisionRequested__e` on Order approval.
### 3.4 UI
@ -292,7 +284,7 @@ We will build the BFF payload for WHMCS from these line records plus the Order h
- Snapshot only what can change over time: `UnitPrice` and `Quantity` on the line.
- Order construction (by portal at checkout)
- Create `Order` header with `Provisioning_Status__c = Pending Review`.
- Create `Order` header with `Activation_Status__c = Not Started`.
- For each cart item, create a line (either `OrderItem` with custom fields or `Order_Offering__c`) that includes:
- `Product2Id` and `PricebookEntryId`
- `Quantity`, `UnitPrice__c`
@ -305,7 +297,7 @@ We will build the BFF payload for WHMCS from these line records plus the Order h
- Build `AddOrder` payload using the mapping above; place `sfOrderId` in WHMCS `notes`.
- After `AcceptOrder`, write back:
- Header: `WHMCS_Order_ID__c`
- Header: `Provisioning_Status__c = Provisioned` on success; set error fields on failure (sanitized)
- Header: `Activation_Status__c = Activated` on success
- Subscriptions linkage
- The authoritative subscription record lives in WHMCS.
@ -317,8 +309,8 @@ This keeps the mapping clean and centralized in Product2 portal fields, while Or
1. Catalog comes from Salesforce Product2 (filtered/personalized by Account eligibility).
2. Customer signs up with SF Number; portal creates WHMCS client and mapping; address/profile managed in WHMCS.
3. Checkout creates an SF `Order` and child lines (no provisioning yet).
4. Operator approves in SF and clicks Quick Action.
5. SF calls BFF to provision: BFF rechecks payment method (WHMCS), handles eSIM activation if needed, then `AddOrder` + `AcceptOrder` in WHMCS using mappings from Product2 portal fields referenced by the OrderItems.
4. Operator approves in SF; Flow publishes Platform Event.
5. BFF subscriber enqueues job and provisions: recheck payment method (WHMCS), handle eSIM activation if needed, then `AddOrder` + `AcceptOrder` in WHMCS using mappings from Product2 portal fields referenced by the OrderItems.
6. BFF updates SF Order fields (`WHMCS_Order_ID__c`, etc.) and status; emails are sent as required.
7. Customer sees completed order; subscriptions/invoices appear from WHMCS data in the portal.
@ -332,7 +324,7 @@ This keeps the mapping clean and centralized in Product2 portal fields, while Or
- Product detail + Checkout:
- Checkout button disabled until `hasPaymentMethod=true` (via `GET /billing/payment-methods/summary`).
- On submit, call `POST /orders` and redirect to order status page with polling.
- Order status page: shows statuses (Pending Review → Activating → Provisioned/Failed), with links to Subscriptions and Invoices.
- Order status page: shows statuses (Not Started → Activating → Activated/Failed), with links to Subscriptions and Invoices.
### 4.1 eSIM Self-service Actions (Service Detail)
@ -367,11 +359,11 @@ Prerequisites for WHMCS provisioning
1. Auth: require `sfNumber` in `SignupDto` and signup flow; lookup SF Account by Customer Number; align WHMCS custom field.
2. Billing: add `GET /billing/payment-methods/summary` and frontend gating.
3. Catalog UI: `/catalog` + product details pages.
4. Orders API: implement `POST /orders`, `GET /orders/:sfOrderId`, `POST /orders/:sfOrderId/provision`.
5. Salesforce: fields, Quick Action/Flow, Named Credential + signing; LWC for status.
4. Orders API: implement `POST /orders`, `GET /orders/:sfOrderId`.
5. Salesforce: fields, RecordTriggered Flow to publish `OrderProvisionRequested__e`; LWC for status.
6. WHMCS: add wrappers for `AddOrder`, `AcceptOrder`, `GetPayMethods` (if not already exposed).
7. Observability: correlation IDs, metrics, alerts; webhook processing for cache busting (optional).
8. Email: implement `EmailService` with provider; add BullMQ jobs for async sending; add templates for Signup, eSIM Activation, Provisioned.
7. Observability: correlation IDs, metrics, alerts; CDC or Platform Events for cache busting (optional).
8. Email: implement `EmailService` with provider; add BullMQ jobs for async sending; add templates for Signup, eSIM Activation, Activated.
9. eSIM Actions: implement `POST /subscriptions/:id/reissue-esim` and `POST /subscriptions/:id/topup` endpoints with BFF provider calls and WHMCS updates.
10. Future: Cancellations form → Salesforce Cancellations object submission (no immediate service cancel by customer).
@ -421,9 +413,7 @@ Prerequisites for WHMCS provisioning
- `GET /orders/:sfOrderId`
- Response: `{ sfOrderId, status, whmcsOrderId?, whmcsServiceIds?: number[], lastUpdatedAt }`
- `POST /orders/:sfOrderId/provision` (SF only)
- Request headers: `Authorization`, `Idempotency-Key`, `X-Timestamp`, `X-Nonce`, `X-Signature`
- Response: `{ status: 'Provisioned' | 'Failed', whmcsOrderId?, whmcsServiceIds?: number[], errorCode?, errorMessage? }`
// No SF → portal endpoint is required; provisioning is triggered via Platform Events.
- `POST /subscriptions/:id/reissue-esim`
- Request: `{ reason?: string }`
@ -466,7 +456,7 @@ Prerequisites for WHMCS provisioning
- Endpoint(s), auth scheme, required payload, success/failed response shapes.
- Which identifiers to store/mask (ICCID, EID, MSISDN) and masking rules.
5. Provisioning Trigger
- Manual only (Quick Action) or also auto on status change to Approved?
- Trigger via Flow on status change to Approved; optional manual retry publishes event again.
- Retry/backoff limits expected from SF side?
6. Cancellations
- Cancellation object API name in Salesforce; required fields; desired intake fields in portal form; who should be notified.

View File

@ -9,7 +9,7 @@ This roadmap references `PORTAL-ORDERING-PROVISIONING.md` (complete flows and ar
- Pricebook: create "Portal" pricebook; add `PricebookEntry` records for visible Product2 items
- Order fields: add `Provisioning_*`, `WHMCS_*`, `ESIM_ICCID__c`, `Attempt_Count__c`, `Last_Provisioning_At__c`
- OrderItem fields: add `Billing_Cycle__c`, `ConfigOptions_JSON__c`, `WHMCS_Service_ID__c`
- Quick Action: "Provision in WHMCS" to call BFF; configure Named Credentials + HMAC headers
- Platform Event: `OrderProvisionRequested__e`; Flow publishes on Order approval
2. WHMCS setup (Admin)
- Create custom field on Client for Customer Number (note id/name).
@ -18,7 +18,7 @@ This roadmap references `PORTAL-ORDERING-PROVISIONING.md` (complete flows and ar
3. Portal BFF env & security
- Ensure env vars for Salesforce/WHMCS and logging are set; rotate secrets.
- Enable IP allowlisting for Salesforce → BFF; implement HMAC shared secret.
- Enable Platform Events subscriber (`SF_EVENTS_ENABLED=true`); no inbound SF allowlisting required.
## Phase 2 Identity & Billing
@ -48,10 +48,10 @@ This roadmap references `PORTAL-ORDERING-PROVISIONING.md` (complete flows and ar
8. BFF: Orders API
- `POST /orders`: create SF Order + OrderItems (snapshots: Quantity, UnitPrice, Billing_Cycle, ConfigOptions), status Pending Review; return `sfOrderId`.
- `GET /orders/:sfOrderId`: return orchestration status.
- `POST /orders/:sfOrderId/provision`: SF-only; recheck payment method; (eSIM) activate; WHMCS AddOrder → AcceptOrder; update SF with IDs/status; send emails.
- Async Provisioning: triggered by Platform Event `OrderProvisionRequested__e`; worker rechecks payment method; (eSIM) activate; WHMCS AddOrder → AcceptOrder; update SF with IDs/status; send emails.
9. Salesforce: Quick Action/Flow
- Implement button action to call BFF with Named Credentials + HMAC; pass Idempotency-Key.
9. Salesforce: Record-Triggered Flow
- On Order status = Approved, publish `OrderProvisionRequested__e` with `OrderId__c` and optional `IdemKey__c`.
10. Portal UI: Checkout & status
@ -78,7 +78,7 @@ This roadmap references `PORTAL-ORDERING-PROVISIONING.md` (complete flows and ar
14. Idempotency & resilience
- Cart hash idempotency for `POST /orders`.
- Idempotency-Key for `POST /orders/:sfOrderId/provision`.
- Idempotency on Platform Event (`IdemKey__c`); dedupe in worker.
- Include `sfOrderId` in WHMCS `notes` for duplicate protection.
15. Security reviews
@ -91,6 +91,6 @@ This roadmap references `PORTAL-ORDERING-PROVISIONING.md` (complete flows and ar
- WHMCS Client custom field created; product IDs confirmed
- BFF endpoints implemented (auth/billing/catalog/orders/esim)
- Portal pages implemented (signup/address/catalog/detail/checkout/status)
- Quick Action wired and tested end-to-end
- Platform Event Flow wired and tested end-to-end
- Emails tested in dev/staging
- Monitoring and alerts configured

View File

@ -0,0 +1,74 @@
# Provisioning Runbook (Salesforce Platform Events → Portal → WHMCS)
This runbook helps operators diagnose issues in the order fulfillment path.
## Paths & Channels
- Salesforce Platform Event: `OrderProvisionRequested__e`
- Backend health: `GET /health`
## Required Env (Backend)
- `SF_LOGIN_URL`, `SF_CLIENT_ID`, `SF_USERNAME`
- `SF_PRIVATE_KEY_PATH` (prod: `/app/secrets/sf-private.key`)
- `SF_EVENTS_ENABLED=true`
- `SF_PROVISION_EVENT_CHANNEL=/event/OrderProvisionRequested__e`
- `SF_EVENTS_REPLAY=LATEST` (or `ALL`)
- `PORTAL_PRICEBOOK_ID`
## Common Symptoms and Fixes
- No events received
- Verify Flow publishes `OrderProvisionRequested__e` on Order approval
- Confirm the BFF has `SF_EVENTS_ENABLED=true` and valid SF JWT settings
- Check BFF logs for subscription start on the expected channel
- Event replays not advancing
- Ensure Redis is healthy; last `replayId` is stored under `sf:pe:replay:<channel>`
- If needed, set `SF_EVENTS_REPLAY=ALL` for a one-time backfill, then revert to `LATEST`
- 409 Payment method missing
- Customer has no WHMCS payment method
- Ask customer to add a payment method; retry fulfill
- WHMCS Add/Accept errors
- Check product mappings: `Product2.WH_Product_ID__c` and `Billing_Cycle__c`
- Backend logs show the item mapping report; fix missing mappings
- Salesforce status not updated
- Backend updates `Activation_Status__c` and `WHMCS_Order_ID__c` on success
- Verify connected app JWT config and that the API user has Order update permissions
## Verification Steps
1. In SF, create an Order with OrderItems
2. Approve Order → Flow sets `Activation_Status__c = Activating` and publishes `OrderProvisionRequested__e`
3. Check `/health`: database/redis connected, environment correct
4. Tail logs; confirm: Platform Event enqueued → Guard sees status=Activating → WHMCS add → WHMCS accept → Activated
5. Verify SF fields updated and WHMCS order/service IDs exist
## Logging Cheatsheet
- "Platform Event enqueued for provisioning" — subscriber enqueue
- "Starting fulfillment orchestration" — orchestrator start
- Step logs: `validation`, `sf_status_update`, `order_details`, `mapping`, `whmcs_create`, `whmcs_accept`, `sf_success_update`
- On error: orchestrator updates SF with `Activation_Status__c='Failed'`
## Security Notes
- No inbound Salesforce webhooks are used for provisioning.
- BFF authenticates to Salesforce via JWT; grant API access and Platform Event object read via Permission Set.
- No WHMCS webhooks are consumed; the portal uses the WHMCS API for billing operations.
- Health endpoint
- `/health` includes `integrations.redis` probe to confirm queue/replay storage availability.
## Ops: Manual Retry Flow
- Click "Provision / Retry" on the Order in Salesforce.
- If `Activation_Status__c = Activating`, show a toast "Already in progress".
- Else, set `Activation_Status__c = Activating`, clear last error fields, and let the RecordTriggered Flow publish the event.
Portal does not auto-retry jobs. Network/5xx/timeouts will mark the Order Failed with:
- `Activation_Error_Code__c` (e.g., 429, 503, ETIMEOUT)
- `Activation_Error_Message__c` (short reason)

View File

@ -1,5 +1,7 @@
# Salesforce-to-Portal Order Communication Guide
Note: 2025 update — Async-first via Salesforce Platform Events is now the recommended pattern. The legacy webhook path remains referenced only historically and should be phased out.
## 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.
@ -8,237 +10,78 @@ This guide focuses specifically on **secure communication between Salesforce and
```
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
2. Salesforce operator approves Order
3. Salesforce Flow publishes OrderProvisionRequested__e
4. Portal subscriber enqueues a job → provisions in WHMCS → updates Salesforce Order status
5. Customer sees updated status in Portal
```
## 1. Salesforce → Portal (Order Provisioning)
### Current Implementation
### Recommended (2025): Async via Platform Events
Your existing architecture already handles this securely via the **Quick Action** that calls your BFF endpoint:
High-level flow
- **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);
}
```
1. Operator marks Order Approved in Salesforce
2. Record-Triggered Flow publishes Order_Fulfilment_Requested__e
3. Portal subscribes via Pub/Sub API (gRPC) and enqueues a provisioning job
4. Portal provisions in WHMCS and updates Salesforce Order status
5. Portal UI polls BFF for status (unchanged)
```
### Salesforce Apex Implementation
Salesforce Setup
```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;
}
}
```
- Create High-Volume Platform Event: `Order_Fulfilment_Requested__e`
- Fields (example API names):
- `OrderId__c` (Text 18) — required
- `IdemKey__c` (Text 80) — optional (Idempotency key)
- `CorrelationId__c` (Text 80) — optional
- `RequestedBy__c` (Text 80) — optional
- `Version__c` (Number) — optional
- Flow: Record-Triggered on Order when Status changes to Approved
- Actions:
- Update `Activation_Status__c = Activating`
- Create `OrderProvisionRequested__e` and set `OrderId__c = $Record.Id`
- Optionally clear activation error fields
## 2. Optional: Portal Salesforce (Status Updates)
Portal Setup
If you want to send status updates back to Salesforce during provisioning, you can implement a reverse webhook:
- Env
- `SF_EVENTS_ENABLED=true`
- `SF_PROVISION_EVENT_CHANNEL=/event/OrderProvisionRequested__e`
- `SF_EVENTS_REPLAY=LATEST` (or `ALL` to start from earliest retained)
- Subscriber (auto-start, durable replay): `apps/bff/src/vendors/salesforce/events/pubsub.subscriber.ts`
- Subscribes to the channel and enqueues a job to the `provisioning` queue
- Worker: `apps/bff/src/orders/queue/provisioning.processor.ts`
- Guards on `Activation_Status__c = Activating`; provisions in WHMCS and then updates Salesforce
### Portal BFF Implementation
Why async: resilient (replay within retention), decoupled, no inbound allowlisting. No auto-retries from the portal; operators retry from Salesforce by flipping status to Activating.
```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
) {}
### Legacy Implementation (Webhook) — Removed
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(),
};
The old endpoint `POST /orders/{sfOrderId}/fulfill` has been removed. Provisioning is now triggered solely via Platform Events published from Salesforce.
if (details?.whmcsOrderId) {
updateData.WHMCS_Order_ID__c = details.whmcsOrderId;
}
### Enhanced Security Implementation (Legacy Webhook)
if (status === 'Failed' && details?.errorCode) {
updateData.Provisioning_Error_Code__c = details.errorCode;
updateData.Provisioning_Error_Message__c = details.errorMessage?.substring(0, 255);
}
All Quick Actions and Named Credentials previously used to call the portal should be retired.
await this.salesforceConnection.sobject('Order').update(updateData);
### Salesforce Apex Implementation (not needed)
No Apex callout to the portal is required. Use a Record-Triggered Flow to publish the Platform Event.
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
## 2. 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
# Platform Events (BFF)
SF_EVENTS_ENABLED=true
SF_PROVISION_EVENT_CHANNEL=/event/OrderProvisionRequested__e
SF_EVENTS_REPLAY=LATEST # or ALL
```
### 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>
```
Not required for the async path; the portal pulls events from Salesforce.
## 4. Customer Experience
@ -282,9 +125,9 @@ export function useOrderStatus(sfOrderId: string) {
### Key Metrics to Monitor
- **Provisioning Success Rate**: Track successful vs failed provisioning attempts
- **Provisioning Latency**: Time from Quick Action to completion
- **Provisioning Latency**: Time from SF approval to completion
- **WHMCS API Errors**: Monitor WHMCS integration health
- **Webhook Security Events**: Failed signature validations, old timestamps
- **Event Lag**: Time between event publish and job enqueue/complete
### Alert Conditions
@ -311,39 +154,16 @@ export class OrderProvisioningMonitoringService {
## 6. Testing
### Security Testing
### Event Flow 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);
});
});
```
Validate that an `OrderProvisionRequested__e` event enqueues a job and runs the orchestrator. Check logs and `/health/sf-events` for status and cursor.
## 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
1. **Salesforce Flow** → Publishes Platform Event `OrderProvisionRequested__e`
2. **Portal BFF** → Subscribes, queues job, provisions in WHMCS, updates Salesforce
3. **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]].
The security relies on your existing JWT-based Salesforce API integration and ; no inbound webhooks are required.

View File

@ -1,211 +1,39 @@
# Salesforce-to-Portal Security Integration Guide
Note: 2025 update — Salesforce → Portal order provisioning is now event-driven via Platform Events. This document reflects the event-driven model.
## Overview
This guide outlines secure patterns for **Salesforce-to-Portal communication** specifically for the **order provisioning workflow**. Based on your architecture, this focuses on order status updates, not invoice handling.
## Order Provisioning Flow
## Order Provisioning Flow (Async Preferred)
```
Portal Customer → Places Order → Salesforce Order (Pending Review)
Salesforce Operator → Reviews → Clicks "Provision in WHMCS" Quick Action
Salesforce Operator → Reviews/Approves Order
Salesforce → Calls Portal BFF → `/orders/{sfOrderId}/provision`
Salesforce Flow → Publishes OrderProvisionRequested__e
Portal BFF → Provisions in WHMCS → Updates Salesforce Order Status
Portal BFF Subscriber → Provisions in WHMCS → Updates Salesforce Order Status
Portal → Polls Order Status → Shows Customer Updates
Portal → Polls Order Status → Shows Customer Updates
```
## 1. Secure Order Provisioning Communication
### Primary Method: Direct HTTPS Webhook (Recommended for Order Flow)
### Primary Method: Platform Events (Recommended)
Based on your architecture, the **order provisioning flow** uses direct HTTPS calls from Salesforce to your portal BFF. Here's how to secure this:
Use a Record-Triggered Flow to publish `OrderProvisionRequested__e`. The portal subscribes via the Salesforce Streaming API (Pub/Sub) and provisions asynchronously. Inbound webhooks from Salesforce are no longer required.
**Salesforce → Portal BFF Flow:**
If you still need synchronous HTTP for another use case, prefer OAuth2 Named/External Credential and avoid custom signature schemes.
1. **Salesforce Quick Action** calls `POST /orders/{sfOrderId}/provision`
2. **Portal BFF** processes the provisioning request
3. **Optional: Portal → Salesforce** status updates via webhook
### Secure Salesforce Quick Action Setup (Legacy)
### Secure Salesforce Quick Action Setup
Legacy Quick Action + webhook details removed (use Platform Events).
**In Salesforce:**
1. **Named Credential Configuration**
```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>
```
2. **Apex Class for Secure Webhook Calls**
```apex
public class PortalWebhookService {
private static final String WEBHOOK_SECRET = '{!$Credential.Portal_Webhook.Password}';
@future(callout=true)
public static void provisionOrder(String orderId) {
try {
// Prepare 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()
};
// Create HMAC signature
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', generateIdempotencyKey(orderId));
req.setBody(jsonPayload);
req.setTimeout(30000); // 30 second timeout
Http http = new Http();
HttpResponse res = http.send(req);
// Handle response
handleProvisioningResponse(orderId, res);
} catch (Exception e) {
// Log error and update order status
System.debug('Provisioning failed for order ' + orderId + ': ' + e.getMessage());
updateOrderProvisioningStatus(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 String generateIdempotencyKey(String orderId) {
return 'provision_' + orderId + '_' + System.now().getTime();
}
}
### Optional: Portal → Salesforce Status Updates
If you want the portal to send status updates back to Salesforce (e.g., when provisioning completes), you can set up a reverse webhook:
**Portal BFF → Salesforce Webhook Endpoint:**
```typescript
// In your Portal BFF
export class SalesforceStatusUpdateService {
async updateOrderStatus(orderId: string, status: string, details?: any) {
const payload = {
orderId,
status,
timestamp: new Date().toISOString(),
details: this.sanitizeDetails(details)
};
// Send to Salesforce webhook endpoint
await this.sendToSalesforce('/webhook/order-status', payload);
}
}
```
## 2. Portal BFF Security Implementation
### Enhanced Order Provisioning Endpoint
Your portal BFF should implement the `/orders/{sfOrderId}/provision` endpoint with these security measures:
```typescript
// Enhanced order provisioning endpoint
@Post('orders/:sfOrderId/provision')
@UseGuards(EnhancedWebhookSignatureGuard)
async provisionOrder(
@Param('sfOrderId') sfOrderId: string,
@Body() payload: ProvisionOrderRequest,
@Headers('idempotency-key') idempotencyKey: string
) {
// Your existing provisioning logic
return await this.orderOrchestrator.provisionOrder(sfOrderId, payload, idempotencyKey);
}
```
**Enhanced Webhook Security Implementation:**
```typescript
@Injectable()
export class EnhancedWebhookSignatureGuard implements CanActivate {
constructor(private configService: ConfigService) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<Request>();
// 1. Verify HMAC signature (existing)
this.verifyHmacSignature(request);
// 2. Verify timestamp (prevent replay attacks)
this.verifyTimestamp(request);
// 3. Verify nonce (prevent duplicate processing)
this.verifyNonce(request);
// 4. Verify source IP (if using IP allowlisting)
this.verifySourceIp(request);
return true;
}
private verifyTimestamp(request: Request): void {
const timestamp = request.headers['x-sf-timestamp'] as string;
if (!timestamp) {
throw new UnauthorizedException('Timestamp required');
}
const requestTime = new Date(timestamp).getTime();
const now = Date.now();
const maxAge = 5 * 60 * 1000; // 5 minutes
if (Math.abs(now - requestTime) > maxAge) {
throw new UnauthorizedException('Request too old');
}
}
private verifyNonce(request: Request): void {
const nonce = request.headers['x-sf-nonce'] as string;
if (!nonce) {
throw new UnauthorizedException('Nonce required');
}
// Check if nonce was already used (implement nonce store)
// This prevents replay attacks
}
}
```
## 2. Outbound Security: Portal → Salesforce
### Current Implementation (Already Secure ✅)
Your existing JWT-based authentication is excellent:
```typescript
// Your current pattern in salesforce-connection.service.ts
// Uses private key JWT authentication - industry standard
```
@ -322,21 +150,15 @@ export class FieldEncryptionService {
### Salesforce Setup
- [ ] Create Platform Events for portal notifications
- [ ] Set up Named Credentials for portal webhook calls
- [ ] Configure IP allowlisting for portal endpoints
- [ ] Implement HMAC signing in Apex
- [ ] Create audit trails for all portal communications
### Portal Setup
- [ ] Enhance webhook signature verification
- [ ] Implement timestamp and nonce validation
- [ ] Add IP allowlisting for Salesforce
- [ ] Create encrypted payload handling
- [ ] Implement idempotency protection
### Security Measures
- [ ] Rotate webhook secrets regularly
- [ ] Monitor for suspicious webhook activity
- [ ] Implement rate limiting per customer
- [ ] Add comprehensive audit logging
- [ ] Test disaster recovery procedures
@ -374,43 +196,14 @@ export class SecurityMonitoringService {
}
```
## 6. Testing Security
```typescript
describe('Webhook Security', () => {
it('should reject webhooks without valid HMAC signature', async () => {
const invalidPayload = { data: 'test' };
const response = await request(app)
.post('/webhooks/salesforce')
.send(invalidPayload)
.expect(401);
expect(response.body.message).toContain('Invalid webhook signature');
});
it('should reject old timestamps', async () => {
const oldTimestamp = new Date(Date.now() - 10 * 60 * 1000); // 10 minutes ago
const payload = { data: 'test' };
const signature = generateHmacSignature(payload);
const response = await request(app)
.post('/webhooks/salesforce')
.set('X-SF-Signature', signature)
.set('X-SF-Timestamp', oldTimestamp.toISOString())
.send(payload)
.expect(401);
});
});
```
## 7. Production Deployment
### Environment Variables
```bash
# 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 in ms
# Platform Events
SF_EVENTS_ENABLED=true
SF_PROVISION_EVENT_CHANNEL=/event/OrderProvisionRequested__e
SF_EVENTS_REPLAY=LATEST
# Encryption
FIELD_ENCRYPTION_KEY=your_field_encryption_master_key
@ -422,15 +215,6 @@ AUDIT_LOG_RETENTION_DAYS=2555 # 7 years for compliance
```
### Salesforce Named Credential Setup
```xml
<!-- Named Credential: Portal_Webhook -->
<NamedCredential>
<fullName>Portal_Webhook</fullName>
<endpoint>https://your-portal-api.com</endpoint>
<principalType>Anonymous</principalType>
<protocol>HttpsOnly</protocol>
<generateAuthorizationHeader>false</generateAuthorizationHeader>
</NamedCredential>
```
Not required for the event-driven provisioning path (the portal pulls events).
This guide provides a comprehensive, production-ready approach to secure Salesforce-Portal integration that builds on your existing security infrastructure while adding enterprise-grade protection for sensitive data transmission.

View File

@ -1,300 +1,10 @@
# Simple Salesforce-to-Portal Communication Guide
## The Simple Flow (No Reverse Webhooks Needed!)
This guide is deprecated for order provisioning. Use Platform Events (`OrderProvisionRequested__e`) as outlined in `SALESFORCE-ORDER-COMMUNICATION.md`.
```
1. Customer places order → Portal creates Salesforce Order (Pending Review)
2. Salesforce operator → Clicks "Provision in WHMCS" Quick Action
3. Salesforce → Calls Portal BFF → POST /orders/{sfOrderId}/provision
4. Portal BFF → Provisions in WHMCS → DIRECTLY updates Salesforce Order (via existing SF API)
5. Customer → Polls Portal for status updates
```
Modern path (summary)
- Create High-Volume Platform Event `OrderProvisionRequested__e` (fields: `OrderId__c`, optional `IdemKey__c`).
- Record-Triggered Flow on Order approval publishes the event.
- Portal BFF subscribes to the event, enqueues a job, provisions in WHMCS, and updates Salesforce.
**Key insight**: You already have Salesforce API access in your Portal BFF, so you can directly update the Order status. No reverse webhooks needed!
## 1. Salesforce Quick Action Security
### Salesforce Apex (Secure Call to Portal)
```apex
public class OrderProvisioningService {
private static final String WEBHOOK_SECRET = '{!$Credential.Portal_Webhook.Password}';
@future(callout=true)
public static void provisionOrder(String orderId) {
try {
// Simple 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);
// Call Portal BFF
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);
// Simple response handling
if (res.getStatusCode() != 200) {
throw new Exception('Portal returned: ' + res.getStatusCode());
}
} catch (Exception e) {
// Update order status on failure
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 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);
}
update ord;
}
}
```
## 2. Portal BFF Implementation (Simple!)
### Enhanced Security for Provisioning Endpoint
```typescript
// apps/bff/src/orders/orders.controller.ts
@Post(':sfOrderId/provision')
@UseGuards(EnhancedWebhookSignatureGuard) // Your existing guard
@ApiOperation({ summary: "Provision order from Salesforce" })
async provisionOrder(
@Param('sfOrderId') sfOrderId: string,
@Body() payload: { orderId: string; timestamp: string; nonce: string },
@Headers('idempotency-key') idempotencyKey: string
) {
return await this.orderOrchestrator.provisionOrder(sfOrderId, payload, idempotencyKey);
}
```
### Order Orchestrator (Direct Salesforce Updates)
```typescript
// apps/bff/src/orders/services/order-orchestrator.service.ts
@Injectable()
export class OrderOrchestrator {
constructor(
private salesforceService: SalesforceService, // Your existing service
private whmcsService: WhmcsService,
@Inject(Logger) private logger: Logger
) {}
async provisionOrder(sfOrderId: string, payload: any, idempotencyKey: string) {
try {
// 1. Update SF status to "Activating"
await this.updateSalesforceOrderStatus(sfOrderId, 'Activating');
// 2. Your existing provisioning logic
const result = await this.provisionInWhmcs(sfOrderId);
// 3. Update SF status to "Provisioned" with WHMCS ID
await this.updateSalesforceOrderStatus(sfOrderId, 'Provisioned', {
whmcsOrderId: result.whmcsOrderId,
});
this.logger.log('Order provisioned successfully', {
sfOrderId,
whmcsOrderId: result.whmcsOrderId,
});
return {
success: true,
status: 'Provisioned',
whmcsOrderId: result.whmcsOrderId,
};
} catch (error) {
// Update SF status to "Failed"
await this.updateSalesforceOrderStatus(sfOrderId, 'Failed', {
errorCode: 'PROVISIONING_ERROR',
errorMessage: error instanceof Error ? error.message : String(error),
});
this.logger.error('Order provisioning failed', {
sfOrderId,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
// Simple direct Salesforce update (using your existing SF service)
private async updateSalesforceOrderStatus(
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);
}
// Use your existing Salesforce service to update
await this.salesforceService.updateOrder(updateData);
this.logger.log('Salesforce order status updated', {
sfOrderId,
status,
});
} catch (error) {
this.logger.error('Failed to update Salesforce order status', {
sfOrderId,
status,
error: error instanceof Error ? error.message : String(error),
});
// Don't throw - provisioning succeeded, this is just a status update
}
}
}
```
### Add Update Method to Salesforce Service
```typescript
// apps/bff/src/vendors/salesforce/salesforce.service.ts
// Add this method to your existing SalesforceService
async updateOrder(orderData: { Id: string; [key: string]: any }): Promise<void> {
try {
const sobject = this.connection.sobject('Order');
await sobject.update(orderData);
this.logger.log('Order updated in Salesforce', {
orderId: orderData.Id,
fields: Object.keys(orderData).filter(k => k !== 'Id'),
});
} catch (error) {
this.logger.error('Failed to update order in Salesforce', {
orderId: orderData.Id,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
```
## 3. Customer UI (Simple Polling)
```typescript
// Portal UI - simple polling for order status
export function useOrderStatus(sfOrderId: string) {
const [orderStatus, setOrderStatus] = useState<{
status: string;
whmcsOrderId?: string;
error?: string;
}>({ status: 'Pending Review' });
useEffect(() => {
const pollStatus = async () => {
try {
const response = await fetch(`/api/orders/${sfOrderId}`);
const data = await response.json();
setOrderStatus(data);
// Stop polling when 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 orderStatus;
}
```
## 4. Security Configuration
### Environment Variables (Simple)
```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
```
### Salesforce Named Credentials
```xml
<!-- For API calls -->
<NamedCredential>
<fullName>Portal_BFF</fullName>
<endpoint>https://your-portal-api.com</endpoint>
<principalType>Anonymous</principalType>
<protocol>HttpsOnly</protocol>
</NamedCredential>
<!-- For webhook secret -->
<NamedCredential>
<fullName>Portal_Webhook</fullName>
<endpoint>https://your-portal-api.com</endpoint>
<principalType>NamedPrincipal</principalType>
<password>your_256_bit_secret_key_here</password>
<username>webhook</username>
</NamedCredential>
```
## Summary: Why This is Simple
**No reverse webhooks** - Portal directly updates Salesforce via existing API
**One-way communication** - Salesforce → Portal → Direct SF update
**Uses existing infrastructure** - Your SF service, webhook guards, etc.
**Simple customer experience** - Portal polls for status updates
**Production ready** - HMAC security, idempotency, error handling
This follows exactly what your docs specify: Salesforce calls Portal, Portal provisions and updates Salesforce directly. Much cleaner!
No inbound Salesforce webhooks or HMAC headers are required in this architecture.

View File

@ -2,7 +2,6 @@ NODE_ENV=production
# App
APP_BASE_URL=https://asolutions.jp
PORT=4000
BFF_PORT=4000
# Database (PostgreSQL)
@ -34,6 +33,14 @@ SF_CLIENT_ID=
SF_PRIVATE_KEY_PATH=/app/secrets/sf-private.key
SF_USERNAME=
# Salesforce Platform Events (Provisioning)
SF_EVENTS_ENABLED=true
SF_PROVISION_EVENT_CHANNEL=/event/Order_Fulfilment_Requested__e
SF_EVENTS_REPLAY=LATEST
SF_PUBSUB_ENDPOINT=api.pubsub.salesforce.com:7443
SF_PUBSUB_NUM_REQUESTED=50
SF_PUBSUB_QUEUE_MAX=100
# Salesforce Pricing
PORTAL_PRICEBOOK_ID=
@ -54,3 +61,5 @@ EMAIL_TEMPLATE_WELCOME=
# Node Options
NODE_OPTIONS=--max-old-space-size=512
# NOTE: Frontend (Next.js) uses a separate env file (portal-frontend.env)
# Do not include NEXT_PUBLIC_* variables here.

144
pnpm-lock.yaml generated
View File

@ -141,6 +141,9 @@ importers:
rxjs:
specifier: ^7.8.2
version: 7.8.2
salesforce-pubsub-api-client:
specifier: ^5.5.0
version: 5.5.0(@types/node@24.3.0)
speakeasy:
specifier: ^2.0.0
version: 2.0.0
@ -567,6 +570,15 @@ packages:
resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@grpc/grpc-js@1.13.4':
resolution: {integrity: sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==}
engines: {node: '>=12.10.0'}
'@grpc/proto-loader@0.7.15':
resolution: {integrity: sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==}
engines: {node: '>=6'}
hasBin: true
'@heroicons/react@2.2.0':
resolution: {integrity: sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==}
peerDependencies:
@ -989,6 +1001,9 @@ packages:
'@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
'@js-sdsl/ordered-map@4.4.2':
resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==}
'@lukeed/csprng@1.1.0':
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
engines: {node: '>=8'}
@ -1284,6 +1299,36 @@ packages:
'@prisma/get-platform@6.14.0':
resolution: {integrity: sha512-7VjuxKNwjnBhKfqPpMeWiHEa2sVjYzmHdl1slW6STuUCe9QnOY0OY1ljGSvz6wpG4U8DfbDqkG1yofd/1GINww==}
'@protobufjs/aspromise@1.1.2':
resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
'@protobufjs/base64@1.1.2':
resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==}
'@protobufjs/codegen@2.0.4':
resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==}
'@protobufjs/eventemitter@1.1.0':
resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==}
'@protobufjs/fetch@1.1.0':
resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==}
'@protobufjs/float@1.0.2':
resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==}
'@protobufjs/inquire@1.1.0':
resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==}
'@protobufjs/path@1.1.2':
resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==}
'@protobufjs/pool@1.1.0':
resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==}
'@protobufjs/utf8@1.1.0':
resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==}
'@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
@ -1976,6 +2021,9 @@ packages:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
avro-js@1.12.0:
resolution: {integrity: sha512-mBhOjtHHua2MHrrgQ71YKKTGfZpS1sPvgL+QcCQ5SkUyp6qLkeTsCnQXUmATfpiOvoXB6CczzFEqn5UKbPUn3Q==}
axe-core@4.10.3:
resolution: {integrity: sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==}
engines: {node: '>=4'}
@ -3615,6 +3663,9 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
lodash.camelcase@4.3.0:
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
lodash.defaults@4.2.0:
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
@ -3655,6 +3706,9 @@ packages:
resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==}
engines: {node: '>=10'}
long@5.3.2:
resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
@ -4171,6 +4225,10 @@ packages:
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
protobufjs@7.5.4:
resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==}
engines: {node: '>=12.0.0'}
proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
@ -4358,6 +4416,9 @@ packages:
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
salesforce-pubsub-api-client@5.5.0:
resolution: {integrity: sha512-O6ACxhW4CnQpHmDQ1+iNcgUlcRyHW+FS3t+dsYUxK2Ae5w9Ulo56wskJQ7rY6Ye+fMopeZ9Jw+o1k/i4MoWMNg==}
sax@1.4.1:
resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==}
@ -4865,9 +4926,16 @@ packages:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'}
underscore@1.13.7:
resolution: {integrity: sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==}
undici-types@7.10.0:
resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==}
undici@7.15.0:
resolution: {integrity: sha512-7oZJCPvvMvTd0OlqWsIxTuItTpJBpU1tcbVl24FMn3xt3+VSunwUasmfPJRE57oNO1KsZ4PgA1xTdAX4hq8NyQ==}
engines: {node: '>=20.18.1'}
universalify@2.0.1:
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
engines: {node: '>= 10.0.0'}
@ -5382,6 +5450,18 @@ snapshots:
'@eslint/core': 0.15.2
levn: 0.4.1
'@grpc/grpc-js@1.13.4':
dependencies:
'@grpc/proto-loader': 0.7.15
'@js-sdsl/ordered-map': 4.4.2
'@grpc/proto-loader@0.7.15':
dependencies:
lodash.camelcase: 4.3.0
long: 5.3.2
protobufjs: 7.5.4
yargs: 17.7.2
'@heroicons/react@2.2.0(react@19.1.1)':
dependencies:
react: 19.1.1
@ -5867,6 +5947,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@js-sdsl/ordered-map@4.4.2': {}
'@lukeed/csprng@1.1.0': {}
'@microsoft/tsdoc@0.15.1': {}
@ -6150,6 +6232,29 @@ snapshots:
dependencies:
'@prisma/debug': 6.14.0
'@protobufjs/aspromise@1.1.2': {}
'@protobufjs/base64@1.1.2': {}
'@protobufjs/codegen@2.0.4': {}
'@protobufjs/eventemitter@1.1.0': {}
'@protobufjs/fetch@1.1.0':
dependencies:
'@protobufjs/aspromise': 1.1.2
'@protobufjs/inquire': 1.1.0
'@protobufjs/float@1.0.2': {}
'@protobufjs/inquire@1.1.0': {}
'@protobufjs/path@1.1.2': {}
'@protobufjs/pool@1.1.0': {}
'@protobufjs/utf8@1.1.0': {}
'@rtsao/scc@1.1.0': {}
'@rushstack/eslint-patch@1.12.0': {}
@ -6897,6 +7002,10 @@ snapshots:
dependencies:
possible-typed-array-names: 1.1.0
avro-js@1.12.0:
dependencies:
underscore: 1.13.7
axe-core@4.10.3: {}
axios@1.11.0:
@ -8982,6 +9091,8 @@ snapshots:
dependencies:
p-locate: 5.0.0
lodash.camelcase@4.3.0: {}
lodash.defaults@4.2.0: {}
lodash.includes@4.3.0: {}
@ -9011,6 +9122,8 @@ snapshots:
chalk: 4.1.2
is-unicode-supported: 0.1.0
long@5.3.2: {}
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
@ -9512,6 +9625,21 @@ snapshots:
object-assign: 4.1.1
react-is: 16.13.1
protobufjs@7.5.4:
dependencies:
'@protobufjs/aspromise': 1.1.2
'@protobufjs/base64': 1.1.2
'@protobufjs/codegen': 2.0.4
'@protobufjs/eventemitter': 1.1.0
'@protobufjs/fetch': 1.1.0
'@protobufjs/float': 1.0.2
'@protobufjs/inquire': 1.1.0
'@protobufjs/path': 1.1.2
'@protobufjs/pool': 1.1.0
'@protobufjs/utf8': 1.1.0
'@types/node': 24.3.0
long: 5.3.2
proxy-addr@2.0.7:
dependencies:
forwarded: 0.2.0
@ -9704,6 +9832,18 @@ snapshots:
safer-buffer@2.1.2: {}
salesforce-pubsub-api-client@5.5.0(@types/node@24.3.0):
dependencies:
'@grpc/grpc-js': 1.13.4
'@grpc/proto-loader': 0.7.15
avro-js: 1.12.0
jsforce: 3.10.4(@types/node@24.3.0)
undici: 7.15.0
transitivePeerDependencies:
- '@types/node'
- encoding
- supports-color
sax@1.4.1: {}
scheduler@0.26.0: {}
@ -10305,8 +10445,12 @@ snapshots:
has-symbols: 1.1.0
which-boxed-primitive: 1.1.1
underscore@1.13.7: {}
undici-types@7.10.0: {}
undici@7.15.0: {}
universalify@2.0.1: {}
unpipe@1.0.0: {}