Add sharp library for image processing and enhance provisioning logic

- Added sharp library to package.json for image manipulation capabilities.
- Updated pnpm-lock.yaml to include sharp dependency.
- Enhanced HealthController to include provisioning queue job counts in health checks.
- Improved error handling and logging in ProvisioningProcessor for better diagnostics.
- Refactored order fulfillment validation to utilize new WhmcsPaymentService for payment method checks.
- Updated documentation to reflect changes in the provisioning workflow and added new integration overview.
This commit is contained in:
T. Narantuya 2025-09-06 13:58:54 +09:00
parent 57c8fb0cab
commit 7e053a666c
20 changed files with 729 additions and 106 deletions

View File

@ -21,6 +21,7 @@ export class HealthController {
private readonly prisma: PrismaService,
private readonly config: ConfigService,
@InjectQueue(QUEUE_NAMES.EMAIL) private readonly emailQueue: Queue,
@InjectQueue(QUEUE_NAMES.PROVISIONING) private readonly provisioningQueue: Queue,
private readonly cache: CacheService
) {}
@ -51,6 +52,12 @@ export class HealthController {
"failed",
"delayed"
);
const provisioningQueueInfo = await this.provisioningQueue.getJobCounts(
"waiting",
"active",
"failed",
"delayed"
);
// Check Redis availability by a simple set/get on a volatile key
const nonceProbeKey = "health:nonce:probe";
@ -70,6 +77,7 @@ export class HealthController {
version: "1.0.0",
queues: {
email: emailQueueInfo,
provisioning: provisioningQueueInfo,
},
integrations: {
redis: redisStatus,

View File

@ -36,19 +36,43 @@ export class ProvisioningProcessor extends WorkerHost {
const fields = getSalesforceFieldMap();
const order = await this.salesforceService.getOrder(sfOrderId);
const status = (order?.[fields.order.activationStatus] as string) || "";
const lastErrorCodeField = fields.order.lastErrorCode;
const lastErrorCode = lastErrorCodeField
? ((order?.[lastErrorCodeField] as string) || "")
: "";
if (status !== "Activating") {
this.logger.log("Skipping provisioning job: Order not in Activating state", {
sfOrderId,
currentStatus: status,
});
await this.commitReplay(job);
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);
// Guard: Avoid thrashing on known user-actionable failures
if (lastErrorCode === "PAYMENT_METHOD_MISSING") {
this.logger.log("Skipping provisioning job: Awaiting payment method addition", {
sfOrderId,
currentStatus: status,
lastErrorCode,
});
await this.commitReplay(job);
return;
}
// Commit processed replay id for Pub/Sub resume (only after success)
if (typeof job.data.pubsubReplayId === "number") {
try {
// Execute the same orchestration used by the webhook path, but without payload validation
await this.orchestrator.executeFulfillment(sfOrderId, {}, idempotencyKey);
this.logger.log("Provisioning job completed", { sfOrderId });
} finally {
// Commit processed replay id for Pub/Sub resume (commit regardless of success to avoid replay storms)
await this.commitReplay(job);
}
}
private async commitReplay(job: { data: ProvisioningJobData }): Promise<void> {
if (typeof job.data.pubsubReplayId !== "number") return;
try {
const channel = this.config.get<string>(
"SF_PROVISION_EVENT_CHANNEL",
"/event/Order_Fulfilment_Requested__e"
@ -58,8 +82,10 @@ export class ProvisioningProcessor extends WorkerHost {
if (job.data.pubsubReplayId > prev) {
await this.cache.set(replayKey, String(job.data.pubsubReplayId));
}
} catch (e) {
this.logger.warn("Failed to commit Pub/Sub replay id", {
error: e instanceof Error ? e.message : String(e),
});
}
this.logger.log("Provisioning job completed", { sfOrderId });
}
}

View File

@ -19,17 +19,32 @@ export class ProvisioningQueueService {
) {}
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,
});
const jobId =
typeof job.pubsubReplayId === "number"
? `sf:${job.sfOrderId}:replay:${job.pubsubReplayId}`
: `sf:${job.sfOrderId}`;
try {
await this.queue.add("provision", job, {
jobId,
removeOnComplete: 100,
removeOnFail: 100,
attempts: 1, // No automatic retries; Salesforce is source of truth for retry
});
// Use info level so it's visible in default logs
this.logger.log("Queued provisioning job", {
sfOrderId: job.sfOrderId,
idempotencyKey: job.idempotencyKey,
correlationId: job.correlationId,
pubsubReplayId: job.pubsubReplayId,
});
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
if (/already exists/i.test(msg)) {
this.logger.debug("Duplicate provisioning job ignored", { sfOrderId: job.sfOrderId, jobId });
return;
}
throw err;
}
}
async depth(): Promise<number> {

View File

@ -1,7 +1,7 @@
import { Injectable, BadRequestException, ConflictException, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { SalesforceService } from "../../vendors/salesforce/salesforce.service";
import { WhmcsOrderService } from "../../vendors/whmcs/services/whmcs-order.service";
import { WhmcsPaymentService } from "../../vendors/whmcs/services/whmcs-payment.service";
import { MappingsService } from "../../mappings/mappings.service";
import { getErrorMessage } from "../../common/utils/error.util";
import { SalesforceOrder } from "../types/salesforce-order.types";
@ -23,7 +23,7 @@ export class OrderFulfillmentValidator {
constructor(
@Inject(Logger) private readonly logger: Logger,
private readonly salesforceService: SalesforceService,
private readonly whmcsOrderService: WhmcsOrderService,
private readonly whmcsPaymentService: WhmcsPaymentService,
private readonly mappingsService: MappingsService
) {}
@ -62,16 +62,24 @@ export class OrderFulfillmentValidator {
};
}
// 3. Get WHMCS client ID from account mapping
const clientId = await this.getWhmcsClientId(sfOrder.Account.Id);
// 3. Get WHMCS client mapping
const accountId = (sfOrder as unknown as { AccountId?: unknown })?.AccountId;
if (typeof accountId !== "string" || !accountId) {
throw new BadRequestException("Salesforce order is missing AccountId");
}
const mapping = await this.mappingsService.findBySfAccountId(accountId);
if (!mapping?.whmcsClientId) {
throw new BadRequestException(`No WHMCS client mapping found for account ${accountId}`);
}
const clientId = mapping.whmcsClientId;
// 4. Validate payment method exists
await this.validatePaymentMethod(clientId);
await this.validatePaymentMethod(clientId, mapping.userId);
this.logger.log("Fulfillment validation completed successfully", {
sfOrderId,
clientId,
accountId: sfOrder.Account.Id,
accountId,
});
return {
@ -117,7 +125,7 @@ export class OrderFulfillmentValidator {
];
return typeof v === "string" ? v : undefined;
})(),
accountId: salesforceOrder.Account.Id,
accountId: (salesforceOrder as unknown as { AccountId?: unknown })?.AccountId,
});
return salesforceOrder;
@ -127,35 +135,25 @@ export class OrderFulfillmentValidator {
* Get WHMCS client ID from Salesforce account ID using mappings
*/
private async getWhmcsClientId(sfAccountId: string): Promise<number> {
try {
// Use existing mappings service to get client ID
const mapping = await this.mappingsService.findBySfAccountId(sfAccountId);
if (!mapping?.whmcsClientId) {
throw new BadRequestException(`No WHMCS client mapping found for account ${sfAccountId}`);
}
this.logger.log("WHMCS client mapping found", {
sfAccountId,
whmcsClientId: mapping.whmcsClientId,
});
return mapping.whmcsClientId;
} catch (error) {
this.logger.error("Failed to get WHMCS client mapping", {
error: getErrorMessage(error),
sfAccountId,
});
throw new BadRequestException(`Failed to find WHMCS client for account ${sfAccountId}`);
// Deprecated: retained for compatibility if referenced elsewhere
const mapping = await this.mappingsService.findBySfAccountId(sfAccountId);
if (!mapping?.whmcsClientId) {
throw new BadRequestException(`No WHMCS client mapping found for account ${sfAccountId}`);
}
return mapping.whmcsClientId;
}
/**
* Validate client has payment method in WHMCS
*/
private async validatePaymentMethod(clientId: number): Promise<void> {
private async validatePaymentMethod(clientId: number, userId: string): Promise<void> {
try {
const hasPaymentMethod = await this.whmcsOrderService.hasPaymentMethod(clientId);
// Centralized helper; aligns with frontend detection and allows fresh reads
const hasPaymentMethod = await this.whmcsPaymentService.hasPaymentMethod(
clientId,
userId,
{ fresh: true }
);
if (!hasPaymentMethod) {
throw new ConflictException(
@ -172,6 +170,7 @@ export class OrderFulfillmentValidator {
this.logger.error("Payment method validation failed", {
error: getErrorMessage(error),
clientId,
userId,
});
throw new ConflictException("Unable to validate payment method - fulfillment cannot proceed");

View File

@ -166,6 +166,7 @@ export class OrderOrchestrator {
id: order.Id,
orderNumber: order.OrderNumber,
status: order.Status,
accountId: (order as unknown as { AccountId?: unknown })?.AccountId as string | undefined,
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)
@ -263,7 +264,7 @@ export class OrderOrchestrator {
quantity: item.Quantity,
unitPrice: item.UnitPrice,
totalPrice: item.TotalPrice,
billingCycle: String(item.PricebookEntry?.Product2?.Billing_Cycle__c || ""),
billingCycle: String((p2?.[fields.product.billingCycle] as string | undefined) || ""),
});
return acc;
},

View File

@ -22,6 +22,7 @@ export interface OrderDetailsDto {
orderType?: string;
effectiveDate: string;
totalAmount: number;
accountId?: string;
accountName?: string;
createdDate: string;
lastModifiedDate: string;

View File

@ -19,17 +19,17 @@ type SubscribeCallback = (
interface PubSubClient {
connect(): Promise<void>;
subscribe(topic: string, cb: SubscribeCallback, numRequested: number): Promise<void>;
subscribe(topic: string, cb: SubscribeCallback, numRequested?: number): Promise<void>;
subscribeFromReplayId(
topic: string,
cb: SubscribeCallback,
numRequested: number,
numRequested: number | null,
replayId: number
): Promise<void>;
subscribeFromEarliestEvent(
topic: string,
cb: SubscribeCallback,
numRequested: number
numRequested?: number
): Promise<void>;
requestAdditionalEvents(topic: string, numRequested: number): Promise<void>;
close(): Promise<void>;
@ -106,36 +106,36 @@ export class SalesforcePubSubSubscriber implements OnModuleInit, OnModuleDestroy
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;
const type = callbackType;
const typeNorm = String(type || "").toLowerCase();
const 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>;
if (typeNorm === "data" || typeNorm === "event") {
const event = data as Record<string, unknown>;
// Basic breadcrumb to confirm we are handling data callbacks
this.logger.debug("SF Pub/Sub data callback received", {
topic,
argTypes,
hasPayload: ((): boolean => {
if (!event || typeof event !== "object") return false;
const maybePayload = event["payload"];
return typeof maybePayload === "object" && maybePayload !== null;
})(),
});
const payload = ((): Record<string, unknown> | undefined => {
const p = event?.["payload"];
return typeof p === "object" && p != null ? (p as Record<string, unknown>) : undefined;
const p = event["payload"];
if (typeof p === "object" && p !== null) {
return p as Record<string, unknown>;
}
return 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 });
this.logger.warn("Pub/Sub event missing OrderId__c; skipping", { argTypes, topic, payloadKeys: payload ? Object.keys(payload) : [] });
const depth = await this.provisioningQueue.depth();
if (depth < maxQueue) {
await client.requestAdditionalEvents(topic, 1);
@ -161,20 +161,16 @@ export class SalesforcePubSubSubscriber implements OnModuleInit, OnModuleDestroy
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") {
// Do not request more here; rely on 'lastevent' to top-up
} else if (typeNorm === "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;
} else if (typeNorm === "grpckeepalive") {
const latestVal = (data as { latestReplayId?: unknown })?.latestReplayId;
const latest = typeof latestVal === "number" ? latestVal : undefined;
if (typeof latest === "number") {
await this.cache.set(sfLatestSeenKey(this.channel), {
@ -182,6 +178,24 @@ export class SalesforcePubSubSubscriber implements OnModuleInit, OnModuleDestroy
at: Date.now(),
});
}
} else if (typeNorm === "grpcstatus" || typeNorm === "end") {
// No-op; informational
} else if (typeNorm === "error") {
this.logger.warn("SF Pub/Sub stream error", { topic, data });
} else {
// Unknown callback type: log once with minimal context
const maybeEvent = data as Record<string, unknown> | undefined;
const hasPayload = ((): boolean => {
if (!maybeEvent || typeof maybeEvent !== "object") return false;
const p = maybeEvent["payload"];
return typeof p === "object" && p !== null;
})();
this.logger.debug("SF Pub/Sub callback ignored (unknown type)", {
type,
topic,
argTypes,
hasPayload,
});
}
} catch (err) {
this.logger.error("Pub/Sub subscribe callback failed", {

View File

@ -145,7 +145,9 @@ export class SalesforceService implements OnModuleInit {
const fields = getSalesforceFieldMap();
const result = (await this.connection.query(
`SELECT Id, Status, ${fields.order.activationStatus}, ${fields.order.whmcsOrderId}, AccountId
`SELECT Id, Status, ${fields.order.activationStatus}, ${fields.order.whmcsOrderId},
${fields.order.lastErrorCode}, ${fields.order.lastErrorMessage},
AccountId, Account.Name
FROM Order
WHERE Id = '${orderId}'
LIMIT 1`

View File

@ -32,6 +32,9 @@ export interface WhmcsApiConfig {
timeout?: number;
retryAttempts?: number;
retryDelay?: number;
// Optional elevated admin credentials for privileged actions (eg. AcceptOrder)
adminUsername?: string;
adminPasswordHash?: string; // MD5 hash of admin password
}
@Injectable()
@ -50,6 +53,10 @@ export class WhmcsConnectionService {
timeout: this.configService.get<number>("WHMCS_API_TIMEOUT", 30000),
retryAttempts: this.configService.get<number>("WHMCS_API_RETRY_ATTEMPTS", 1),
retryDelay: this.configService.get<number>("WHMCS_API_RETRY_DELAY", 1000),
adminUsername: this.configService.get<string | undefined>("WHMCS_ADMIN_USERNAME"),
adminPasswordHash:
this.configService.get<string | undefined>("WHMCS_ADMIN_PASSWORD_MD5") ||
this.configService.get<string | undefined>("WHMCS_ADMIN_PASSWORD_HASH"),
};
// Optional API Access Key (used by some WHMCS deployments alongside API Credentials)
this.accessKey = this.configService.get<string | undefined>("WHMCS_API_ACCESS_KEY");
@ -82,14 +89,26 @@ export class WhmcsConnectionService {
): Promise<T> {
const url = `${this.config.baseUrl}/includes/api.php`;
// Use WHMCS API Credential fields (identifier/secret). Do not send as username/password.
// WHMCS expects `identifier` and `secret` when authenticating with API Credentials.
const baseParams: Record<string, string> = {
action,
identifier: this.config.identifier,
secret: this.config.secret,
responsetype: "json",
};
// Choose authentication strategy.
// Prefer elevated admin credentials for privileged actions (AcceptOrder), if provided.
const useAdminAuth =
action.toLowerCase() === "acceptorder" &&
!!this.config.adminUsername &&
!!this.config.adminPasswordHash;
const baseParams: Record<string, string> = useAdminAuth
? {
action,
username: this.config.adminUsername!,
password: this.config.adminPasswordHash!,
responsetype: "json",
}
: {
action,
identifier: this.config.identifier,
secret: this.config.secret,
responsetype: "json",
};
if (this.accessKey) {
baseParams.accesskey = this.accessKey;
}
@ -105,6 +124,7 @@ export class WhmcsConnectionService {
this.logger.debug(`WHMCS API Request [${action}] attempt ${attempt}`, {
action,
params: this.sanitizeLogParams(params),
authMode: useAdminAuth ? "admin" : "api_credentials",
});
const formData = new URLSearchParams(requestParams);
@ -149,6 +169,7 @@ export class WhmcsConnectionService {
message: errorResponse.message,
errorcode: errorResponse.errorcode,
params: this.sanitizeLogParams(params),
authModeTried: useAdminAuth ? "admin" : "api_credentials",
});
throw new Error(`WHMCS API Error: ${errorResponse.message}`);
}
@ -214,6 +235,27 @@ export class WhmcsConnectionService {
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null) continue;
// Handle arrays using PHP-style indexed parameters: key[0]=..., key[1]=...
if (Array.isArray(value)) {
value.forEach((v, i) => {
const idxKey = `${key}[${i}]`;
if (v === undefined || v === null) return;
const t = typeof v;
if (t === "string") {
sanitized[idxKey] = v as string;
} else if (t === "number" || t === "boolean" || t === "bigint") {
sanitized[idxKey] = (v as number | boolean | bigint).toString();
} else if (t === "object") {
try {
sanitized[idxKey] = JSON.stringify(v);
} catch {
sanitized[idxKey] = "";
}
}
});
continue;
}
const typeOfValue = typeof value;
if (typeOfValue === "string") {
sanitized[key] = value as string;
@ -224,6 +266,7 @@ export class WhmcsConnectionService {
) {
sanitized[key] = (value as number | boolean | bigint).toString();
} else if (typeOfValue === "object") {
// For plain objects, fall back to JSON string (only used for non-array fields)
try {
sanitized[key] = JSON.stringify(value);
} catch {

View File

@ -97,6 +97,10 @@ export class WhmcsOrderService {
// Call WHMCS AcceptOrder API
const response = (await this.connection.acceptOrder({
orderid: orderId.toString(),
// Ensure module provisioning is executed even if product config is different
autosetup: true,
// Suppress customer emails to remain consistent with earlier noemail flag
sendemail: false,
})) as Record<string, unknown>;
if (response.result !== "success") {
@ -180,15 +184,20 @@ export class WhmcsOrderService {
}
// Check if client has any payment methods
const paymentMethods = (response.paymethods as { paymethod?: unknown[] })?.paymethod || [];
const hasValidMethod = Array.isArray(paymentMethods)
? paymentMethods.length > 0
: Boolean(paymentMethods);
const paymethodsNode = (response.paymethods as { paymethod?: unknown } | undefined)?.paymethod;
const totalResults = Number((response as { totalresults?: unknown })?.totalresults ?? 0) || 0;
const methodCount = Array.isArray(paymethodsNode)
? paymethodsNode.length
: paymethodsNode && typeof paymethodsNode === "object"
? 1
: 0;
const hasValidMethod = methodCount > 0 || totalResults > 0;
this.logger.log("Payment method check completed", {
clientId,
hasPaymentMethod: hasValidMethod,
methodCount: Array.isArray(paymentMethods) ? paymentMethods.length : hasValidMethod ? 1 : 0,
methodCount,
totalResults,
});
return hasValidMethod;

View File

@ -24,13 +24,19 @@ export class WhmcsPaymentService {
/**
* Get payment methods for a client
*/
async getPaymentMethods(clientId: number, userId: string): Promise<PaymentMethodList> {
async getPaymentMethods(
clientId: number,
userId: string,
options?: { fresh?: boolean }
): Promise<PaymentMethodList> {
try {
// Try cache first
const cached = await this.cacheService.getPaymentMethods(userId);
if (cached) {
this.logger.debug(`Cache hit for payment methods: user ${userId}`);
return cached;
// Try cache first unless fresh requested
if (!options?.fresh) {
const cached = await this.cacheService.getPaymentMethods(userId);
if (cached) {
this.logger.debug(`Cache hit for payment methods: user ${userId}`);
return cached;
}
}
const response = await this.connectionService.getPayMethods({ clientid: clientId });
@ -68,7 +74,9 @@ export class WhmcsPaymentService {
.filter((method): method is PaymentMethod => method !== null);
const result: PaymentMethodList = { paymentMethods: methods, totalCount: methods.length };
await this.cacheService.setPaymentMethods(userId, result);
if (!options?.fresh) {
await this.cacheService.setPaymentMethods(userId, result);
}
return result;
} catch (error) {
this.logger.error(`Failed to fetch payment methods for client ${clientId}`, {
@ -79,6 +87,22 @@ export class WhmcsPaymentService {
}
}
/**
* Centralized check used by both UI-aligned code paths and worker validation.
* Returns true when the transformed list has at least one saved payment method.
* Pass { fresh: true } to bypass cache for provisioning-time checks.
*/
async hasPaymentMethod(
clientId: number,
userId: string,
options?: { fresh?: boolean }
): Promise<boolean> {
const list = await this.getPaymentMethods(clientId, userId, options);
const count = list?.totalCount || 0;
this.logger.debug("hasPaymentMethod check", { clientId, userId, count });
return count > 0;
}
/**
* Get available payment gateways
*/

View File

@ -31,6 +31,7 @@ import { WhmcsOrderService } from "./services/whmcs-order.service";
WhmcsDataTransformer,
WhmcsCacheService,
WhmcsOrderService,
WhmcsPaymentService,
],
})
export class WhmcsModule {}

View File

@ -0,0 +1,150 @@
# Portal Integration Overview — Salesforce + WHMCS
This document explains how the portal integrates Salesforce (catalog, orders, provisioning control) and WHMCS (billing, invoices, subscriptions, payments). It covers the main flows endtoend, what checks occur, and where the logic lives in code.
## System Overview
- Components
- Frontend: Next.js portal (`apps/portal`)
- Backend (BFF): NestJS (`apps/bff`), orchestrates Salesforce + WHMCS
- Billing: WHMCS (invoices, payment methods, subscriptions)
- CRM/Control Plane: Salesforce (catalog via Product2, Orders, provisioning trigger)
- Infra: PostgreSQL (mappings), Redis (cache), BullMQ (queues)
- Sources of truth
- Salesforce: Product catalog, Pricebook pricing, Orders, provisioning status and tracking
- WHMCS: Customer profile, payment methods, subscriptions, invoices
- BFF: Orchestration + ID mappings only; no customer data authority
- Key environment flags (validation schema)
- `SF_EVENTS_ENABLED`, `SF_PROVISION_EVENT_CHANNEL`, `SF_*` for Salesforce; `WHMCS_*` for WHMCS; `PORTAL_PRICEBOOK_ID/PORTAL_PRICEBOOK_NAME` for catalog/pricing. See env sample for full list (env/portal-backend.env.sample:1).
## Identity & Mapping
- Purpose: link a portal user to a WHMCS client and Salesforce Account.
- Persistence: `idMapping` table via `MappingsService` (apps/bff/src/mappings/mappings.service.ts:1).
- Lookups
- By user → WHMCS client ID (apps/bff/src/mappings/mappings.service.ts:148)
- By Salesforce Account → WHMCS client ID (apps/bff/src/mappings/mappings.service.ts:63)
- Usage: Most flows start by resolving mapping to enforce access and route calls correctly (e.g., invoices, orders).
## Catalog (Shown in Portal)
- Source of truth: Salesforce Product2 + PricebookEntry.
- Field mapping is configurable via env (apps/bff/src/common/config/field-map.ts:1). Important fields include:
- `Product2.StockKeepingUnit` (SKU), portal visibility flags, `Item_Class__c`, billing cycle, and WHMCS product id/name.
- Base query helper uses a “Portal” pricebook (ID or name) and builds SOQL with visible/accessible filters (apps/bff/src/catalog/services/base-catalog.service.ts:1).
- SIM catalog example: returns plans, activation fees, addons; optionally personalizes based on existing WHMCS services (apps/bff/src/catalog/services/sim-catalog.service.ts:1).
- Principle: Frontend selects explicit SKUs; backend validates SKUs exist in the portal pricebook and creates Salesforce OrderItems accordingly. See docs/PRODUCT-CATALOG-ARCHITECTURE.md:1.
## Invoices & Payments
- Endpoints: `GET /invoices`, `GET /invoices/:id`, `GET /invoices/:id/subscriptions`, `POST /invoices/:id/sso-link`, `POST /invoices/:id/payment-link` (apps/bff/src/invoices/invoices.controller.ts:1).
- Service flow: resolve mapping → fetch from WHMCS via `WhmcsService` → transform/cache → return (apps/bff/src/invoices/invoices.service.ts:24).
- List/paginate via WHMCS GetInvoices; details enriched with line items and `serviceId` links (apps/bff/src/vendors/whmcs/services/whmcs-invoice.service.ts:1).
- Subscriptions listed via WHMCS GetClientsProducts; transformed and cached (apps/bff/src/vendors/whmcs/services/whmcs-subscription.service.ts:1).
- Payment methods/gateways via WHMCS; cached in Redis; also used for gating order creation/provisioning (apps/bff/src/vendors/whmcs/services/whmcs-payment.service.ts:1).
- SSO links: invoice view/download/pay and payment-page with preselected method/gateway (apps/bff/src/vendors/whmcs/services/whmcs-payment.service.ts:168).
## Orders — Creation (Portal ➝ Salesforce)
- Entry point: `OrderOrchestrator.createOrder()` (apps/bff/src/orders/services/order-orchestrator.service.ts:36).
- Steps
1) Validate request, user mapping, and business rules via `OrderValidator.validateCompleteOrder()` (apps/bff/src/orders/services/order-validator.service.ts:296):
- Format: `orderType`, nonempty `skus[]` (apps/bff/src/orders/services/order-validator.service.ts:24)
- Mapping present: user ↔ WHMCS client ↔ SF Account (apps/bff/src/orders/services/order-validator.service.ts:79)
- Payment method exists in WHMCS (gating) (apps/bff/src/orders/services/order-validator.service.ts:96)
- SKU existence in portal pricebook (apps/bff/src/orders/services/order-validator.service.ts:197)
- Business rules by type (SIM, VPN, Internet) e.g. SIM requires activation fee (apps/bff/src/orders/services/order-validator.service.ts:225)
- Internet duplication guard: one active Internet service per account (apps/bff/src/orders/services/order-validator.service.ts:166)
2) Build Order header fields including activation fields and address snapshot (apps/bff/src/orders/services/order-builder.service.ts:22)
- Address snapshot always sets BillTo* fields; sets `Address_Changed__c` if user supplied a different address at checkout (apps/bff/src/orders/services/order-builder.service.ts:88)
3) Create Salesforce Order and then OrderItems by SKU using the pricebook entry and unit price (apps/bff/src/orders/services/order-item-builder.service.ts:20)
- Result: Returns `sfOrderId` with status Created for operator review/approval in Salesforce.
## Orders — Provisioning (Salesforce ➝ WHMCS)
- Trigger: Salesforce publishes a Platform Event (recordtriggered flow) on approval. The BFF subscriber listens when `SF_EVENTS_ENABLED=true` and enqueues provisioning (apps/bff/src/vendors/salesforce/events/pubsub.subscriber.ts:58).
- Queue: BullMQ `provisioning` queue with idempotent job IDs (apps/bff/src/orders/queue/provisioning.queue.ts:1).
- Processor guardrails (apps/bff/src/orders/queue/provisioning.processor.ts:26):
- Only process when Order `Activation_Status__c` is `Activating` (apps/bff/src/orders/queue/provisioning.processor.ts:35)
- Skip if last error is `PAYMENT_METHOD_MISSING` to avoid thrash (apps/bff/src/orders/queue/provisioning.processor.ts:52)
- Commit Salesforce Pub/Sub replay IDs for exactlyonce handling (apps/bff/src/orders/queue/provisioning.processor.ts:73)
- Orchestration steps (apps/bff/src/orders/services/order-fulfillment-orchestrator.service.ts:57):
- Validate request: not already provisioned (checks `WHMCS_Order_ID__c`), ensure client has payment method; resolve mapping (apps/bff/src/orders/services/order-fulfillment-validator.service.ts:23)
- Set SF activation status to `Activating` (apps/bff/src/orders/services/order-fulfillment-orchestrator.service.ts:98)
- Load SF Order details + OrderItems, map each to WHMCS items using the Product2 mapping (`WH_Product_ID__c`) and billing cycle (apps/bff/src/orders/services/order-whmcs-mapper.service.ts:1)
- Create WHMCS order (AddOrder) with Stripe as payment method; optional promo code and tracking notes (apps/bff/src/vendors/whmcs/services/whmcs-order.service.ts:20)
- Accept/provision order (AcceptOrder), capture service IDs and invoice ID returned (apps/bff/src/vendors/whmcs/services/whmcs-order.service.ts:60)
- Update SF: `Status=Completed`, `Activation_Status__c=Activated`, and write back `WHMCS_Order_ID__c` (apps/bff/src/orders/services/order-fulfillment-orchestrator.service.ts:117)
- Error handling: On failure, set `Status=Pending Review`, `Activation_Status__c=Failed`, and write concise error code/message for operator triage (apps/bff/src/orders/services/order-fulfillment-orchestrator.service.ts:146).
## Subscriptions (Shown in Portal)
- Data comes from WHMCS products/services via `GetClientsProducts` and is transformed into a standard Subscription list (apps/bff/src/vendors/whmcs/services/whmcs-subscription.service.ts:1).
- Cached per user; supports status filtering; invoice items link to `serviceId` to show related subscriptions (apps/bff/src/vendors/whmcs/transformers/whmcs-data.transformer.ts:35).
## Payments & SSO
- Payment methods summary drives UI gating and provisioning validation (apps/bff/src/vendors/whmcs/services/whmcs-payment.service.ts:44).
- SSO flows
- General WHMCS SSO (dashboard/settings) via `CreateSsoToken`
- Invoice view/download/pay SSO (apps/bff/src/vendors/whmcs/services/whmcs-payment.service.ts:168)
- Payment link with preselected saved method or gateway (apps/bff/src/vendors/whmcs/services/whmcs-payment.service.ts:168)
## Caching & Performance
- Redis cache is used for: invoices (lists and by ID), subscriptions, payment methods, payment gateways, user mappings (apps/bff/src/common/cache/cache.service.ts:1).
- Cache invalidation helpers exist per domain (e.g., `invalidatePaymentMethodsCache`, `invalidateInvoiceCache`).
- HTTP pagination/limits enforced in controller input validation for invoices (apps/bff/src/invoices/invoices.controller.ts:1).
## Field Map (Configurable)
- All SF field API names are envdriven and wrapped by `getSalesforceFieldMap()` (apps/bff/src/common/config/field-map.ts:1).
- Key paths used in flows
- Order activation: type/scheduledAt/status
- WHMCS IDs: `Order.WHMCS_Order_ID__c`, `OrderItem.WHMCS_Service_ID__c`
- Address snapshot: BillingStreet/City/State/Postal/Country + `Address_Changed__c`
## Environment & Health
- Env validation schema lists required/optional vars and sensible defaults (apps/bff/src/common/config/env.validation.ts:1). Sample file provides production-ready defaults (env/portal-backend.env.sample:1).
- Health endpoints expose DB/queue/feature flags and Salesforce events status (apps/bff/src/health/health.controller.ts:1).
## EndtoEnd Flow Summary
1) User links account at signup; mapping stored. Adds payment method in WHMCS via SSO.
2) Portal shows catalog from Salesforce Product2 + Pricebook. User selects options; frontend builds explicit SKUs.
3) `POST /orders` triggers validation (mapping, payment method exists, SKUs exist, business rules) → creates SF Order + OrderItems with address snapshot.
4) Operator approves in Salesforce; Platform Event published.
5) BFF subscriber enqueues provisioning; worker validates, maps items to WHMCS, creates/accepts WHMCS order, and updates Salesforce with status/IDs.
6) Portal pages show subscriptions and invoices from WHMCS with SSO links for payment.
## Key Checks & Guards
- Payment method required before order creation and before provisioning.
- SKU validity in pricebook; missing UnitPrice is a hard error.
- Business rules per order type (SIM requires activation fee, etc.).
- Internet duplication guard to prevent multiple active Internet services.
- Idempotency: If `WHMCS_Order_ID__c` is set, fulfillment shortcircuits as already provisioned.
- Queue processor only acts in `Activating` state; skips when awaiting useractionable errors.
## Where to Look in Code
- Order creation orchestration: apps/bff/src/orders/services/order-orchestrator.service.ts:36
- Validation (format/mapping/PM/SKUs/rules): apps/bff/src/orders/services/order-validator.service.ts:24
- Order header + address snapshot: apps/bff/src/orders/services/order-builder.service.ts:22
- OrderItems from SKUs: apps/bff/src/orders/services/order-item-builder.service.ts:20
- Fulfillment orchestration: apps/bff/src/orders/services/order-fulfillment-orchestrator.service.ts:57
- Salesforce events subscriber: apps/bff/src/vendors/salesforce/events/pubsub.subscriber.ts:58
- Provisioning queue processor: apps/bff/src/orders/queue/provisioning.processor.ts:26
- Invoices service: apps/bff/src/invoices/invoices.service.ts:24
- Subscriptions service: apps/bff/src/vendors/whmcs/services/whmcs-subscription.service.ts:1
- Payment/SSO service: apps/bff/src/vendors/whmcs/services/whmcs-payment.service.ts:1
---
For deeper details, see:
- docs/PORTAL-ORDERING-PROVISIONING.md
- docs/ORDER-FULFILLMENT-COMPLETE-GUIDE.md
- docs/PRODUCT-CATALOG-ARCHITECTURE.md
- docs/ADDRESS_SYSTEM.md

View File

@ -0,0 +1,167 @@
# Customer Portal — Simple Overview (NonTechnical)
Use these slide-ready bullets to explain, in plain language, how our portal works with Salesforce and WHMCS.
## 1) What The Portal Does
- Purpose: One place for customers to browse plans, place orders, and view/pay bills.
- Connects two company systems: Salesforce (service control) and WHMCS (billing).
- Goal: Smooth experience for customers, clear control and visibility for our team.
## 2) The Two Systems We Connect
- Salesforce: Our “control center” for products and orders. Staff review and approve here.
- WHMCS: Our “billing system” for invoices, payment methods, and subscriptions.
- The Portal: The “bridge” customers use. It talks to both safely in the background.
## 3) How It Fits Together (Big Picture)
- Customer uses the Portal → chooses products → places an order.
- Portal creates the order in Salesforce for review/approval.
- After approval, the Portal provisions the order in WHMCS.
- WHMCS then holds the subscription and generates the invoices customers can pay.
## 4) Catalog (What Customers Can Buy)
- We show the catalog from Salesforce so prices and products are consistent.
- Products are organized (e.g., Internet, SIM, VPN) with clear names and monthly/onetime fees.
- The Portal only shows whats approved for online sale.
## 5) Customer Journey (At a Glance)
1. Sign up and link your account.
2. Add a payment method (credit card or gateway) — onetime step.
3. Browse the catalog and select a plan.
4. Place an order (Portal sends it to Salesforce).
5. Staff reviews/approves in Salesforce, and the Portal activates it in WHMCS.
6. Customer sees subscriptions and invoices in the Portal and pays securely.
## 5a) Sign Up & Account Linking (Plain English)
- What we ask for: email, password, name, and your Customer Number (Salesforce number) — this lets us find your existing account.
- What happens: we create a billing profile in WHMCS and securely link three IDs together behind the scenes:
- Portal user ↔ WHMCS client ↔ Salesforce account.
- Why this matters: from then on, the Portal always shows the right invoices, payment methods, and subscriptions for that customer.
## 5b) Address — Where It Lives and How We Use It
- Required at signup: we capture a full billing address and store it in WHMCS (our billing source of truth).
- Always available: the Portal pulls your current address from WHMCS so checkout is smooth.
- Snapshotted on every order: we copy the current address into the Salesforce order so staff can review what was used at the time.
- Internet orders: we ask you to explicitly confirm the installation address (technician visit). If you change it, we mark the order “address changed” for staff visibility.
- Updates later: when customers update address in the Portal, we sync it to WHMCS so billing and future orders are correct.
## 5c) Payment Methods — Why We Require One First
- Fewer failures: we only allow ordering after a payment method is on file.
- How customers add it: the Portal opens WHMCSs secure payment page (single signon); cards are stored by WHMCS, not by the Portal.
- Provisioning also checks: even after approval, we recheck payment to avoid failed activations.
## 6) Ordering Flow (Simple)
- Before ordering, the Portal checks: “Do you have a saved payment method?”
- The Portal sends your chosen items to Salesforce as an order for review.
- After approval, the Portal creates the service in WHMCS and finishes activation.
## 6a) Business Rules We Enforce (Simple)
- Internet: must include a service plan; installation options are clearly shown; we prevent duplicate active Internet services for the same account.
- SIM: must include a SIM plan and a onetime activation fee; optional addons (e.g., voicemail) can be added; for number transfer (MNP), we collect the reservation details.
- VPN: must include the VPN activation fee; regions/options are chosen up front.
- Product validity: only products that are approved and priced are allowed to be ordered.
## 7) Billing & Payments (Simple)
- Invoices: Created by WHMCS and shown in the Portal.
- Payment: The Portal opens a secure payment page (SSO) directly in WHMCS.
- Subscriptions: Ongoing services (e.g., monthly plans) displayed in the Portal.
## 8) BuiltIn Safeguards (What We Check Automatically)
- Payment method required: prevents failed activations and billing issues.
- Product validity: only approved, priced items can be ordered.
- Duplicate protection: e.g., avoids ordering a second Internet line by mistake.
- Status tracking: Salesforce and WHMCS stay in sync (Created → Activating → Activated).
## 8a) Behind the Scenes (Safe & Repeatable)
- Approvals: staff review orders in Salesforce; on approval, the Portal activates the order in WHMCS.
- Single signon (SSO): the Portal uses expiring links to WHMCS for invoices and payments; we dont handle card numbers directly.
- Clear errors: if something blocks activation (e.g., missing payment method), we pause and show a short, humanreadable note to staff and a clear status to the customer.
## 9) If Something Goes Wrong
- The order is paused with a clear status (e.g., “Awaiting payment method”).
- Staff sees a short error code and message in Salesforce to resolve quickly.
- Customers keep seeing clear status updates in the Portal.
## 10) Why This Is Better
- For customers: Clear catalog, simple checkout, easy invoice payments.
- For staff: Single review step in Salesforce, automated activation, fewer manual tasks.
- For the business: Consistent pricing, faster timetoactivate, fewer errors.
---
# Suggested Slide Deck (Titles + Bullets + Notes)
1. Title — “Customer Portal: How It Works”
- Bullets: One place to buy, manage, and pay.
- Notes: We connect Salesforce (control) and WHMCS (billing).
2. The Systems
- Bullets: Salesforce = control; WHMCS = billing; Portal = bridge.
- Notes: Keep the mental model: control center vs. billing system.
3. Big Picture Flow
- Bullets: Choose → Order in Salesforce → Approve → Activate in WHMCS → Pay.
- Notes: Emphasize approvals happen in Salesforce; invoices in WHMCS.
4. The Catalog
- Bullets: Products come from Salesforce; only approved offers appear.
- Notes: Ensures one source of truth for products and prices.
5. Customer Journey
- Bullets: Sign up → Add payment → Choose plan → Order → Approve → Activate → Pay.
- Notes: This is what a typical customer sees endtoend.
6. Ordering Checks
- Bullets: Payment method required; valid items only; no duplicates.
- Notes: Prevents surprises and support tickets later.
7. Billing & Payments
- Bullets: Invoices from WHMCS; pay via secure SSO; subscriptions visible.
- Notes: We never store card numbers in the Portal.
8. Status & Errors
- Bullets: Clear statuses; short error messages for staff.
- Notes: Faster turnaround and fewer escalations.
9. Benefits
- Bullets: Better customer experience; less manual work; consistent pricing.
- Notes: Close with impact on activation time and error reduction.
10. Q&A
- Bullets: —
- Notes: Keep backup slides with examples/screenshots.
---
Tip: Pair these slides with a simple swimlane diagram (Customer, Portal, Salesforce, WHMCS) showing the handoffs at order and activation.
References for deeper reading (optional for presenters)
- Address flow details: docs/ADDRESS_SYSTEM.md
- Technical overview: docs/PORTAL-INTEGRATION-OVERVIEW.md
Diagram (PNG/SVG for slides)
- Swimlane visual: docs/assets/portal-swimlane.svg
---
## 11) Migration (Moving Existing WHMCS Users Into the Portal)
Goal: Let existing customers with WHMCS billing accounts start using the new Portal without losing history.
How it works (plain English):
- Check your email: if you already have a WHMCS billing account, choose “Link account”.
- One-time check: enter your current WHMCS password once. We only verify it directly with WHMCS to prove ownership — we dont copy it.
- Autolinking: we read your Customer Number from WHMCS and find the matching Salesforce account. Then we create your Portal account and link all three IDs.
- Set a new Portal password: we ask you to create a new password for the Portal (your WHMCS password stays in WHMCS).
- After that: log in with your new Portal password; continue to manage payment methods and invoices via secure SSO into WHMCS.
Why setting a NEW Portal password is better than reusing the old one:
- Separation of risk: WHMCS and the Portal are different systems. Separate passwords reduce the blast radius if one is compromised.
- Stronger policy & protections: the Portal enforces modern hashing, lockouts, and audit logs tailored to our app. We dont control WHMCSs password rules.
- Least privilege: the Portal never stores or proxies your WHMCS password. We only validate it once during linking, then discard it.
- Future flexibility: lets us improve Portal security (e.g., MFA, rotation rules) without affecting WHMCS.
- Clear SSO flow: customers use the Portal to reach WHMCS billing pages securely without sharing credentials.
Simple talking points for the migration slide:
- “Link your existing billing account by confirming your current password once.”
- “Create a new Portal password — safer, independent, and futureproof.”
- “Nothing is lost — subscriptions and invoices automatically appear once linked.”

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

View File

@ -0,0 +1,122 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="720" viewBox="0 0 1200 720">
<defs>
<style>
.lane { fill: #f7f9fc; stroke: #d0d7de; }
.lane-header { fill: #e6eef8; stroke: #b6c6d6; font: 600 16px sans-serif; }
.step { fill: #ffffff; stroke: #94a3b8; rx: 8; }
.step-text { font: 12px sans-serif; fill: #0f172a; }
.label { font: 12px sans-serif; fill: #334155; }
.arrow { stroke: #475569; stroke-width: 2; fill: none; marker-end: url(#arrowhead); }
.num { fill: #2563eb; font: 600 12px sans-serif; }
</style>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#475569" />
</marker>
</defs>
<!-- Lanes -->
<rect x="30" y="20" width="260" height="680" class="lane"/>
<rect x="330" y="20" width="260" height="680" class="lane"/>
<rect x="630" y="20" width="260" height="680" class="lane"/>
<rect x="930" y="20" width="260" height="680" class="lane"/>
<!-- Headers -->
<rect x="30" y="20" width="260" height="40" class="lane-header"/>
<rect x="330" y="20" width="260" height="40" class="lane-header"/>
<rect x="630" y="20" width="260" height="40" class="lane-header"/>
<rect x="930" y="20" width="260" height="40" class="lane-header"/>
<text x="160" y="46" text-anchor="middle" class="lane-header">Customer</text>
<text x="460" y="46" text-anchor="middle" class="lane-header">Portal (BFF)</text>
<text x="760" y="46" text-anchor="middle" class="lane-header">Salesforce</text>
<text x="1060" y="46" text-anchor="middle" class="lane-header">WHMCS</text>
<!-- Steps: Customer -->
<rect x="50" y="90" width="220" height="56" class="step"/>
<text x="60" y="110" class="step-text">1) Sign Up &amp; Link Account</text>
<text x="60" y="126" class="step-text">Provide Customer Number</text>
<rect x="50" y="170" width="220" height="56" class="step"/>
<text x="60" y="190" class="step-text">2) Add Payment Method</text>
<text x="60" y="206" class="step-text">Secure SSO to WHMCS</text>
<rect x="50" y="250" width="220" height="56" class="step"/>
<text x="60" y="270" class="step-text">3) Browse Catalog</text>
<text x="60" y="286" class="step-text">Select Plan &amp; Addons</text>
<rect x="50" y="510" width="220" height="56" class="step"/>
<text x="60" y="530" class="step-text">7) View Invoices</text>
<text x="60" y="546" class="step-text">Pay via SSO</text>
<!-- Steps: Portal -->
<rect x="350" y="120" width="220" height="56" class="step"/>
<text x="360" y="140" class="step-text">Validate mapping &amp; address</text>
<text x="360" y="156" class="step-text">Create WHMCS client</text>
<rect x="350" y="200" width="220" height="56" class="step"/>
<text x="360" y="220" class="step-text">Open WHMCS payment page</text>
<text x="360" y="236" class="step-text">(SSO)</text>
<rect x="350" y="300" width="220" height="56" class="step"/>
<text x="360" y="320" class="step-text">Create Order in SF</text>
<text x="360" y="336" class="step-text">(snapshot address)</text>
<rect x="350" y="380" width="220" height="56" class="step"/>
<text x="360" y="400" class="step-text">Provision in WHMCS</text>
<text x="360" y="416" class="step-text">after approval</text>
<rect x="350" y="510" width="220" height="56" class="step"/>
<text x="360" y="530" class="step-text">Show subs &amp; invoices</text>
<!-- Steps: Salesforce -->
<rect x="650" y="300" width="220" height="56" class="step"/>
<text x="660" y="320" class="step-text">Review &amp; Approve</text>
<text x="660" y="336" class="step-text">Order</text>
<!-- Steps: WHMCS -->
<rect x="950" y="170" width="220" height="56" class="step"/>
<text x="960" y="190" class="step-text">Store Payment Methods</text>
<rect x="950" y="380" width="220" height="56" class="step"/>
<text x="960" y="400" class="step-text">Create Services &amp; Invoice</text>
<!-- Arrows + labels -->
<!-- Signup -> Portal create WHMCS client -->
<path d="M270 118 C 300 118, 320 118, 350 118" class="arrow"/>
<text x="310" y="110" class="label" text-anchor="middle">Customer Number</text>
<!-- Add payment -> Portal -> WHMCS -->
<path d="M270 198 C 300 198, 320 198, 350 198" class="arrow"/>
<path d="M570 228 C 600 228, 900 198, 950 198" class="arrow"/>
<text x="760" y="188" class="label" text-anchor="middle">SSO Payment Page</text>
<!-- Browse -> Create Order (Portal) -->
<path d="M270 278 C 300 278, 320 318, 350 318" class="arrow"/>
<text x="310" y="300" class="label" text-anchor="middle">Selected items</text>
<!-- Portal -> SF (create order) -->
<path d="M570 328 C 600 328, 620 328, 650 328" class="arrow"/>
<text x="610" y="320" class="label" text-anchor="middle">Order + address</text>
<!-- SF approve -> Portal provision -->
<path d="M760 356 C 760 390, 570 408, 570 408" class="arrow"/>
<text x="680" y="388" class="label">Approved</text>
<!-- Portal provision -> WHMCS -->
<path d="M570 408 C 770 408, 900 408, 950 408" class="arrow"/>
<text x="760" y="398" class="label" text-anchor="middle">AddOrder + Accept</text>
<!-- WHMCS -> Portal (subs & invoices) -->
<path d="M950 538 C 900 538, 600 538, 570 538" class="arrow"/>
<text x="760" y="528" class="label" text-anchor="middle">Subscriptions + Invoices</text>
<!-- Portal -> Customer (display) -->
<path d="M350 538 C 320 538, 300 538, 270 538" class="arrow"/>
<!-- Portal -> WHMCS (pay SSO) -->
<path d="M350 566 C 600 566, 900 566, 950 566" class="arrow"/>
<text x="760" y="560" class="label" text-anchor="middle">Pay Invoice (SSO)</text>
<!-- Legend -->
<text x="30" y="700" class="label">Legend: SSO = secure single sign-on; SF = Salesforce</text>
</svg>

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

@ -59,6 +59,7 @@
"globals": "^16.3.0",
"husky": "^9.1.7",
"prettier": "^3.6.2",
"sharp": "^0.34.3",
"typescript": "^5.9.2",
"typescript-eslint": "^8.40.0"
},

10
pnpm-lock.yaml generated
View File

@ -39,6 +39,9 @@ importers:
prettier:
specifier: ^3.6.2
version: 3.6.2
sharp:
specifier: ^0.34.3
version: 0.34.3
typescript:
specifier: ^5.9.2
version: 5.9.2
@ -7298,13 +7301,11 @@ snapshots:
dependencies:
color-name: 1.1.4
simple-swizzle: 0.2.2
optional: true
color@4.2.3:
dependencies:
color-convert: 2.0.1
color-string: 1.9.1
optional: true
colorette@2.0.20: {}
@ -8418,8 +8419,7 @@ snapshots:
is-arrayish@0.2.1: {}
is-arrayish@0.3.2:
optional: true
is-arrayish@0.3.2: {}
is-async-function@2.1.1:
dependencies:
@ -9977,7 +9977,6 @@ snapshots:
'@img/sharp-win32-arm64': 0.34.3
'@img/sharp-win32-ia32': 0.34.3
'@img/sharp-win32-x64': 0.34.3
optional: true
shebang-command@2.0.0:
dependencies:
@ -10020,7 +10019,6 @@ snapshots:
simple-swizzle@0.2.2:
dependencies:
is-arrayish: 0.3.2
optional: true
slash@3.0.0: {}

42
scripts/tools/svg2png.mjs Normal file
View File

@ -0,0 +1,42 @@
#!/usr/bin/env node
/**
* Simple SVG -> PNG converter using sharp.
* Usage: node svg2png.mjs <input.svg> <output.png> <width> <height>
*/
import fs from 'node:fs/promises';
import path from 'node:path';
import sharp from 'sharp';
async function main() {
const [,, inPath, outPath, wArg, hArg] = process.argv;
if (!inPath || !outPath || !wArg || !hArg) {
console.error('Usage: node svg2png.mjs <input.svg> <output.png> <width> <height>');
process.exit(1);
}
const width = Number(wArg);
const height = Number(hArg);
if (!width || !height) {
console.error('Width and height must be numbers');
process.exit(1);
}
const absIn = path.resolve(inPath);
const absOut = path.resolve(outPath);
const svg = await fs.readFile(absIn);
// Render with background white to avoid transparency issues in slides
const png = await sharp(svg, { density: 300 })
.resize(width, height, { fit: 'contain', background: { r: 255, g: 255, b: 255, alpha: 1 } })
.png({ compressionLevel: 9 })
.toBuffer();
await fs.mkdir(path.dirname(absOut), { recursive: true });
await fs.writeFile(absOut, png);
console.log(`Wrote ${absOut} (${width}x${height})`);
}
main().catch((err) => {
console.error('svg2png failed:', err?.message || err);
process.exit(1);
});