- Enhance safeOperation with rethrow and fallbackMessage options for CRITICAL operations - Migrate all 19 withErrorHandling calls across 5 services to safeOperation - Remove safeAsync from error.util.ts - Delete error-handler.util.ts (withErrorHandling, withErrorSuppression, withErrorLogging) - Update barrel exports in core/utils/index.ts
698 lines
24 KiB
TypeScript
698 lines
24 KiB
TypeScript
import {
|
|
Injectable,
|
|
Inject,
|
|
NotFoundException,
|
|
BadRequestException,
|
|
ConflictException,
|
|
} from "@nestjs/common";
|
|
import { ConfigService } from "@nestjs/config";
|
|
import { Logger } from "nestjs-pino";
|
|
import { randomUUID } from "crypto";
|
|
import type { User as PrismaUser } from "@prisma/client";
|
|
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
|
import {
|
|
addressSchema,
|
|
combineToUser,
|
|
type Address,
|
|
type User,
|
|
} from "@customer-portal/domain/customer";
|
|
import {
|
|
type BilingualAddress,
|
|
prepareWhmcsAddressFields,
|
|
prepareSalesforceContactAddressFields,
|
|
} from "@customer-portal/domain/address";
|
|
import {
|
|
getCustomFieldValue,
|
|
mapPrismaUserToUserAuth,
|
|
} from "@customer-portal/domain/customer/providers";
|
|
import {
|
|
updateCustomerProfileRequestSchema,
|
|
type UpdateCustomerProfileRequest,
|
|
} from "@customer-portal/domain/auth";
|
|
import type { Subscription } from "@customer-portal/domain/subscriptions";
|
|
import type { Invoice } from "@customer-portal/domain/billing";
|
|
import type { Activity, DashboardSummary, NextInvoice } from "@customer-portal/domain/dashboard";
|
|
import { dashboardSummarySchema } from "@customer-portal/domain/dashboard";
|
|
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
|
import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js";
|
|
import { WhmcsInvoiceService } from "@bff/integrations/whmcs/services/whmcs-invoice.service.js";
|
|
import { WhmcsSubscriptionService } from "@bff/integrations/whmcs/services/whmcs-subscription.service.js";
|
|
import { SalesforceFacade } from "@bff/integrations/salesforce/facades/salesforce.facade.js";
|
|
import { safeOperation, OperationCriticality } from "@bff/core/utils/safe-operation.util.js";
|
|
import { parseUuidOrThrow } from "@bff/core/utils/validation.util.js";
|
|
import { UserAuthRepository } from "./user-auth.repository.js";
|
|
import { AddressReconcileQueueService } from "../queue/address-reconcile.queue.js";
|
|
import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js";
|
|
|
|
const ERROR_INVALID_USER_ID = "Invalid user ID format";
|
|
const ERROR_USER_NOT_FOUND = "User not found";
|
|
const ERROR_USER_MAPPING_NOT_FOUND = "User mapping not found";
|
|
const DEFAULT_CURRENCY = "JPY";
|
|
|
|
interface RecentSubscription {
|
|
id: number;
|
|
status: string;
|
|
registrationDate: string;
|
|
productName: string;
|
|
}
|
|
|
|
interface RecentInvoice {
|
|
id: number;
|
|
status: string;
|
|
dueDate?: string | undefined;
|
|
total: number;
|
|
number: string;
|
|
issuedAt?: string | undefined;
|
|
paidDate?: string | undefined;
|
|
currency?: string | null | undefined;
|
|
}
|
|
|
|
function getDateTimestamp(dateStr: string | undefined | null, fallback: number): number {
|
|
return dateStr ? new Date(dateStr).getTime() : fallback;
|
|
}
|
|
|
|
function processSubscriptionsData(subscriptions: Subscription[]): {
|
|
activeCount: number;
|
|
recentSubscriptions: RecentSubscription[];
|
|
} {
|
|
const activeSubscriptions = subscriptions.filter(sub => sub.status === "Active");
|
|
const activeCount = activeSubscriptions.length;
|
|
|
|
const recentSubscriptions = activeSubscriptions
|
|
.sort((a, b) => {
|
|
const aTime = getDateTimestamp(a.registrationDate, Number.NEGATIVE_INFINITY);
|
|
const bTime = getDateTimestamp(b.registrationDate, Number.NEGATIVE_INFINITY);
|
|
return bTime - aTime;
|
|
})
|
|
.slice(0, 3)
|
|
.map(sub => ({
|
|
id: sub.id,
|
|
status: sub.status,
|
|
registrationDate: sub.registrationDate,
|
|
productName: sub.productName,
|
|
}));
|
|
|
|
return { activeCount, recentSubscriptions };
|
|
}
|
|
|
|
function findNextInvoice(invoices: Invoice[]): NextInvoice | null {
|
|
const upcomingInvoices = invoices
|
|
.filter(inv => (inv.status === "Unpaid" || inv.status === "Overdue") && inv.dueDate)
|
|
.sort((a, b) => {
|
|
const aTime = getDateTimestamp(a.dueDate, Number.POSITIVE_INFINITY);
|
|
const bTime = getDateTimestamp(b.dueDate, Number.POSITIVE_INFINITY);
|
|
return aTime - bTime;
|
|
});
|
|
|
|
const invoice = upcomingInvoices[0];
|
|
if (!invoice?.dueDate) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
id: invoice.id,
|
|
dueDate: invoice.dueDate,
|
|
amount: invoice.total,
|
|
currency: invoice.currency ?? DEFAULT_CURRENCY,
|
|
};
|
|
}
|
|
|
|
function processInvoicesData(invoices: Invoice[]): RecentInvoice[] {
|
|
return invoices
|
|
.sort((a, b) => {
|
|
const aTime = getDateTimestamp(a.issuedAt, Number.NEGATIVE_INFINITY);
|
|
const bTime = getDateTimestamp(b.issuedAt, Number.NEGATIVE_INFINITY);
|
|
return bTime - aTime;
|
|
})
|
|
.slice(0, 5)
|
|
.map(inv => ({
|
|
id: inv.id,
|
|
status: inv.status,
|
|
dueDate: inv.dueDate,
|
|
total: inv.total,
|
|
number: inv.number,
|
|
issuedAt: inv.issuedAt,
|
|
currency: inv.currency ?? null,
|
|
}));
|
|
}
|
|
|
|
function buildInvoiceActivities(invoices: RecentInvoice[]): Activity[] {
|
|
const activities: Activity[] = [];
|
|
|
|
for (const invoice of invoices) {
|
|
const baseMetadata: Record<string, unknown> = {
|
|
amount: invoice.total,
|
|
currency: invoice.currency ?? DEFAULT_CURRENCY,
|
|
};
|
|
if (invoice.dueDate) {
|
|
baseMetadata["dueDate"] = invoice.dueDate;
|
|
}
|
|
if (invoice.number) {
|
|
baseMetadata["invoiceNumber"] = invoice.number;
|
|
}
|
|
|
|
if (invoice.status === "Paid") {
|
|
activities.push({
|
|
id: `invoice-paid-${invoice.id}`,
|
|
type: "invoice_paid",
|
|
title: `Invoice #${invoice.number} paid`,
|
|
description: `Payment of ¥${invoice.total.toLocaleString()} processed`,
|
|
date: invoice.paidDate || invoice.issuedAt || new Date().toISOString(),
|
|
relatedId: invoice.id,
|
|
metadata: baseMetadata,
|
|
});
|
|
} else if (invoice.status === "Unpaid" || invoice.status === "Overdue") {
|
|
activities.push({
|
|
id: `invoice-created-${invoice.id}`,
|
|
type: "invoice_created",
|
|
title: `Invoice #${invoice.number} created`,
|
|
description: `Amount: ¥${invoice.total.toLocaleString()}`,
|
|
date: invoice.issuedAt || new Date().toISOString(),
|
|
relatedId: invoice.id,
|
|
metadata: { ...baseMetadata, status: invoice.status },
|
|
});
|
|
}
|
|
}
|
|
|
|
return activities;
|
|
}
|
|
|
|
function buildSubscriptionActivities(subscriptions: RecentSubscription[]): Activity[] {
|
|
return subscriptions.map(subscription => {
|
|
const metadata: Record<string, unknown> = {
|
|
productName: subscription.productName,
|
|
status: subscription.status,
|
|
};
|
|
if (subscription.registrationDate) {
|
|
metadata["registrationDate"] = subscription.registrationDate;
|
|
}
|
|
|
|
return {
|
|
id: `service-activated-${subscription.id}`,
|
|
type: "service_activated",
|
|
title: `${subscription.productName} activated`,
|
|
description: "Service successfully provisioned",
|
|
date: subscription.registrationDate,
|
|
relatedId: subscription.id,
|
|
metadata,
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* User Profile Aggregator
|
|
*
|
|
* Combines user data from multiple sources (Portal DB, WHMCS, Salesforce)
|
|
* to build comprehensive user profiles. Handles profile reads and
|
|
* coordinated updates across systems.
|
|
*/
|
|
@Injectable()
|
|
export class UserProfileAggregator {
|
|
private readonly userAuthRepository: UserAuthRepository;
|
|
private readonly mappingsService: MappingsService;
|
|
private readonly whmcsClientService: WhmcsClientService;
|
|
private readonly whmcsInvoiceService: WhmcsInvoiceService;
|
|
private readonly whmcsSubscriptionService: WhmcsSubscriptionService;
|
|
private readonly salesforceService: SalesforceFacade;
|
|
private readonly configService: ConfigService;
|
|
private readonly addressReconcileQueue: AddressReconcileQueueService;
|
|
private readonly auditService: AuditService;
|
|
private readonly logger: Logger;
|
|
|
|
// eslint-disable-next-line max-params
|
|
constructor(
|
|
userAuthRepository: UserAuthRepository,
|
|
mappingsService: MappingsService,
|
|
whmcsClientService: WhmcsClientService,
|
|
whmcsInvoiceService: WhmcsInvoiceService,
|
|
whmcsSubscriptionService: WhmcsSubscriptionService,
|
|
salesforceService: SalesforceFacade,
|
|
configService: ConfigService,
|
|
addressReconcileQueue: AddressReconcileQueueService,
|
|
auditService: AuditService,
|
|
@Inject(Logger) logger: Logger
|
|
) {
|
|
this.userAuthRepository = userAuthRepository;
|
|
this.mappingsService = mappingsService;
|
|
this.whmcsClientService = whmcsClientService;
|
|
this.whmcsInvoiceService = whmcsInvoiceService;
|
|
this.whmcsSubscriptionService = whmcsSubscriptionService;
|
|
this.salesforceService = salesforceService;
|
|
this.configService = configService;
|
|
this.addressReconcileQueue = addressReconcileQueue;
|
|
this.auditService = auditService;
|
|
this.logger = logger;
|
|
}
|
|
|
|
async findById(userId: string): Promise<User | null> {
|
|
const validId = parseUuidOrThrow(userId, ERROR_INVALID_USER_ID);
|
|
const user = await this.userAuthRepository.findById(validId);
|
|
if (!user) {
|
|
return null;
|
|
}
|
|
return this.getProfileForUser(user);
|
|
}
|
|
|
|
async getProfile(userId: string): Promise<User> {
|
|
const validId = parseUuidOrThrow(userId, ERROR_INVALID_USER_ID);
|
|
const user = await this.userAuthRepository.findById(validId);
|
|
if (!user) {
|
|
throw new NotFoundException(ERROR_USER_NOT_FOUND);
|
|
}
|
|
return this.getProfileForUser(user);
|
|
}
|
|
|
|
async getAddress(userId: string): Promise<Address | null> {
|
|
const profile = await this.getProfile(userId);
|
|
return profile.address ?? null;
|
|
}
|
|
|
|
// eslint-disable-next-line no-restricted-syntax -- Mutation is intentional for this aggregator
|
|
async updateAddress(userId: string, addressUpdate: Partial<Address>): Promise<Address> {
|
|
const validId = parseUuidOrThrow(userId, ERROR_INVALID_USER_ID);
|
|
const parsed = addressSchema.partial().parse(addressUpdate ?? {});
|
|
const hasUpdates = Object.values(parsed).some(value => value !== undefined);
|
|
|
|
if (!hasUpdates) {
|
|
throw new BadRequestException("No address fields provided for update");
|
|
}
|
|
|
|
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(validId);
|
|
|
|
return safeOperation(
|
|
async () => {
|
|
await this.whmcsClientService.updateClientAddress(whmcsClientId, parsed);
|
|
await this.whmcsClientService.invalidateUserCache(validId);
|
|
|
|
this.logger.log("Successfully updated customer address in WHMCS", {
|
|
userId: validId,
|
|
whmcsClientId,
|
|
});
|
|
|
|
const refreshedProfile = await this.getProfile(validId);
|
|
if (refreshedProfile.address) {
|
|
return refreshedProfile.address;
|
|
}
|
|
|
|
const refreshedAddress = await this.whmcsClientService.getClientAddress(whmcsClientId);
|
|
return addressSchema.parse(refreshedAddress ?? {});
|
|
},
|
|
{
|
|
criticality: OperationCriticality.CRITICAL,
|
|
context: `Update address for user ${validId}`,
|
|
logger: this.logger,
|
|
rethrow: [NotFoundException, BadRequestException],
|
|
fallbackMessage: "Unable to update address",
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Update address with bilingual data (Japanese + English)
|
|
* Dual-write: English to WHMCS, Japanese to Salesforce
|
|
*
|
|
* @param userId - User ID
|
|
* @param bilingualAddress - Address data with both Japanese and English fields
|
|
* @returns Updated address (from WHMCS, source of truth)
|
|
*/
|
|
// eslint-disable-next-line no-restricted-syntax -- Mutation is intentional for this aggregator
|
|
async updateBilingualAddress(
|
|
userId: string,
|
|
bilingualAddress: BilingualAddress
|
|
): Promise<Address> {
|
|
const validId = parseUuidOrThrow(userId, ERROR_INVALID_USER_ID);
|
|
|
|
return safeOperation(
|
|
async () => {
|
|
const mapping = await this.mappingsService.findByUserId(validId);
|
|
if (!mapping?.whmcsClientId) {
|
|
throw new NotFoundException(ERROR_USER_MAPPING_NOT_FOUND);
|
|
}
|
|
|
|
// 1. Update WHMCS with English address (source of truth)
|
|
const whmcsFields = prepareWhmcsAddressFields(bilingualAddress);
|
|
await this.whmcsClientService.updateClientAddress(mapping.whmcsClientId, whmcsFields);
|
|
await this.whmcsClientService.invalidateUserCache(validId);
|
|
|
|
this.logger.log("Successfully updated customer address in WHMCS", {
|
|
userId: validId,
|
|
whmcsClientId: mapping.whmcsClientId,
|
|
});
|
|
|
|
// 2. Update Salesforce with Japanese address (secondary, non-blocking)
|
|
if (mapping.sfAccountId) {
|
|
await this.syncSalesforceAddress(
|
|
validId,
|
|
mapping.sfAccountId,
|
|
mapping.whmcsClientId,
|
|
bilingualAddress
|
|
);
|
|
} else {
|
|
this.logger.debug("No Salesforce mapping found, skipping Japanese address sync", {
|
|
userId: validId,
|
|
});
|
|
}
|
|
|
|
return this.fetchRefreshedAddress(validId, mapping.whmcsClientId);
|
|
},
|
|
{
|
|
criticality: OperationCriticality.CRITICAL,
|
|
context: `Update bilingual address for user ${validId}`,
|
|
logger: this.logger,
|
|
rethrow: [NotFoundException, BadRequestException],
|
|
fallbackMessage: "Unable to update address",
|
|
}
|
|
);
|
|
}
|
|
|
|
private async syncSalesforceAddress(
|
|
userId: string,
|
|
sfAccountId: string,
|
|
whmcsClientId: number,
|
|
bilingualAddress: BilingualAddress
|
|
): Promise<void> {
|
|
const sfFields = prepareSalesforceContactAddressFields(bilingualAddress);
|
|
const correlationId = randomUUID();
|
|
const addressData = {
|
|
mailingStreet: sfFields.MailingStreet,
|
|
mailingCity: sfFields.MailingCity,
|
|
mailingState: sfFields.MailingState,
|
|
mailingPostalCode: sfFields.MailingPostalCode,
|
|
mailingCountry: sfFields.MailingCountry,
|
|
buildingName: sfFields.BuildingName__c,
|
|
roomNumber: sfFields.RoomNumber__c,
|
|
};
|
|
|
|
try {
|
|
await this.salesforceService.updateContactAddress(sfAccountId, addressData);
|
|
|
|
this.logger.log("Successfully updated Japanese address in Salesforce", {
|
|
userId,
|
|
sfAccountId,
|
|
correlationId,
|
|
});
|
|
|
|
await this.auditService.log({
|
|
userId,
|
|
action: AuditAction.ADDRESS_UPDATE,
|
|
resource: "address",
|
|
details: { sfAccountId, whmcsClientId, correlationId },
|
|
success: true,
|
|
});
|
|
} catch (sfError) {
|
|
const errorMessage = extractErrorMessage(sfError);
|
|
|
|
this.logger.warn("Failed to update Salesforce address - queueing reconciliation", {
|
|
userId,
|
|
sfAccountId,
|
|
error: errorMessage,
|
|
correlationId,
|
|
});
|
|
|
|
await this.addressReconcileQueue.enqueueReconciliation({
|
|
userId,
|
|
sfAccountId,
|
|
address: addressData,
|
|
originalError: errorMessage,
|
|
correlationId,
|
|
});
|
|
|
|
await this.auditService.log({
|
|
userId,
|
|
action: AuditAction.ADDRESS_RECONCILE_QUEUED,
|
|
resource: "address",
|
|
details: { sfAccountId, whmcsClientId, originalError: errorMessage, correlationId },
|
|
success: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
private async fetchRefreshedAddress(userId: string, whmcsClientId: number): Promise<Address> {
|
|
const refreshedProfile = await this.getProfile(userId);
|
|
if (refreshedProfile.address) {
|
|
return refreshedProfile.address;
|
|
}
|
|
|
|
const refreshedAddress = await this.whmcsClientService.getClientAddress(whmcsClientId);
|
|
return addressSchema.parse(refreshedAddress ?? {});
|
|
}
|
|
|
|
// eslint-disable-next-line no-restricted-syntax -- Mutation is intentional for this aggregator
|
|
async updateProfile(userId: string, update: UpdateCustomerProfileRequest): Promise<User> {
|
|
const validId = parseUuidOrThrow(userId, ERROR_INVALID_USER_ID);
|
|
const parsed = updateCustomerProfileRequestSchema.parse(update);
|
|
|
|
return safeOperation(
|
|
async () => {
|
|
// Explicitly disallow name changes from portal
|
|
if (parsed.firstname !== undefined || parsed.lastname !== undefined) {
|
|
throw new BadRequestException("Name cannot be changed from the portal.");
|
|
}
|
|
|
|
const mapping = await this.mappingsService.findByUserId(validId);
|
|
if (!mapping) {
|
|
throw new NotFoundException(ERROR_USER_MAPPING_NOT_FOUND);
|
|
}
|
|
|
|
// Email changes must update both Portal DB and WHMCS, and must be unique in Portal.
|
|
if (parsed.email) {
|
|
const currentUser = await this.userAuthRepository.findById(validId);
|
|
if (!currentUser) {
|
|
throw new NotFoundException(ERROR_USER_NOT_FOUND);
|
|
}
|
|
const newEmail = parsed.email;
|
|
|
|
const existing = await this.userAuthRepository.findByEmail(newEmail);
|
|
if (existing && existing.id !== validId) {
|
|
throw new ConflictException("That email address is already in use.");
|
|
}
|
|
|
|
// Update WHMCS first (source of truth for billing profile), then update Portal DB.
|
|
await this.whmcsClientService.updateClient(mapping.whmcsClientId, { email: newEmail });
|
|
await this.userAuthRepository.updateEmail(validId, newEmail);
|
|
}
|
|
|
|
// Allow phone/company/language updates through to WHMCS
|
|
// Exclude email/firstname/lastname from WHMCS update (handled separately above or disallowed)
|
|
const { email, firstname, lastname, ...whmcsUpdate } = parsed;
|
|
void email; // Email is handled above in a separate flow
|
|
void firstname; // Name changes are explicitly disallowed
|
|
void lastname;
|
|
if (Object.keys(whmcsUpdate).length > 0) {
|
|
await this.whmcsClientService.updateClient(mapping.whmcsClientId, whmcsUpdate);
|
|
}
|
|
|
|
this.logger.log({ userId: validId }, "Successfully updated customer profile in WHMCS");
|
|
|
|
return this.getProfile(validId);
|
|
},
|
|
{
|
|
criticality: OperationCriticality.CRITICAL,
|
|
context: `Update profile for user ${validId}`,
|
|
logger: this.logger,
|
|
rethrow: [NotFoundException, BadRequestException],
|
|
fallbackMessage: "Unable to update profile",
|
|
}
|
|
);
|
|
}
|
|
|
|
async getUserSummary(userId: string): Promise<DashboardSummary> {
|
|
return safeOperation(async () => this.buildUserSummary(userId), {
|
|
criticality: OperationCriticality.CRITICAL,
|
|
context: `Get user summary for ${userId}`,
|
|
logger: this.logger,
|
|
rethrow: [NotFoundException, BadRequestException],
|
|
fallbackMessage: "Unable to retrieve dashboard summary",
|
|
});
|
|
}
|
|
|
|
private async buildUserSummary(userId: string): Promise<DashboardSummary> {
|
|
const user = await this.userAuthRepository.findById(userId);
|
|
if (!user) {
|
|
throw new NotFoundException(ERROR_USER_NOT_FOUND);
|
|
}
|
|
|
|
const mapping = await this.mappingsService.findByUserId(userId);
|
|
if (!mapping?.whmcsClientId) {
|
|
this.logger.warn(`No WHMCS mapping found for user ${userId}`);
|
|
return this.buildEmptySummary();
|
|
}
|
|
|
|
const [subscriptionsData, invoicesData, unpaidInvoicesData] = await Promise.allSettled([
|
|
this.whmcsSubscriptionService.getSubscriptions(mapping.whmcsClientId, userId),
|
|
this.whmcsInvoiceService.getInvoices(mapping.whmcsClientId, userId, { limit: 10 }),
|
|
this.whmcsInvoiceService.getInvoices(mapping.whmcsClientId, userId, {
|
|
status: "Unpaid",
|
|
limit: 1,
|
|
}),
|
|
]);
|
|
|
|
const { activeCount, recentSubscriptions } = this.extractSubscriptionData(
|
|
subscriptionsData,
|
|
userId
|
|
);
|
|
const { unpaidCount, nextInvoice, recentInvoices } = this.extractInvoiceData(
|
|
invoicesData,
|
|
unpaidInvoicesData,
|
|
userId
|
|
);
|
|
|
|
const activities = [
|
|
...buildInvoiceActivities(recentInvoices),
|
|
...buildSubscriptionActivities(recentSubscriptions),
|
|
]
|
|
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
|
.slice(0, 10);
|
|
|
|
this.logger.log(`Generated dashboard summary for user ${userId}`, {
|
|
activeSubscriptions: activeCount,
|
|
unpaidInvoices: unpaidCount,
|
|
activitiesCount: activities.length,
|
|
hasNextInvoice: !!nextInvoice,
|
|
});
|
|
|
|
const currency = await this.fetchClientCurrency(mapping.whmcsClientId, userId);
|
|
|
|
return dashboardSummarySchema.parse({
|
|
stats: {
|
|
activeSubscriptions: activeCount,
|
|
unpaidInvoices: unpaidCount,
|
|
openCases: 0,
|
|
currency,
|
|
},
|
|
nextInvoice,
|
|
recentActivity: activities,
|
|
});
|
|
}
|
|
|
|
private buildEmptySummary(): DashboardSummary {
|
|
return {
|
|
stats: {
|
|
activeSubscriptions: 0,
|
|
unpaidInvoices: 0,
|
|
openCases: 0,
|
|
currency: DEFAULT_CURRENCY,
|
|
},
|
|
nextInvoice: null,
|
|
recentActivity: [],
|
|
};
|
|
}
|
|
|
|
private extractSubscriptionData(
|
|
subscriptionsData: PromiseSettledResult<{ subscriptions: Subscription[] }>,
|
|
userId: string
|
|
): { activeCount: number; recentSubscriptions: RecentSubscription[] } {
|
|
if (subscriptionsData.status === "fulfilled") {
|
|
return processSubscriptionsData(subscriptionsData.value.subscriptions);
|
|
}
|
|
|
|
this.logger.error(
|
|
`Failed to fetch subscriptions for user ${userId}:`,
|
|
subscriptionsData.reason
|
|
);
|
|
return { activeCount: 0, recentSubscriptions: [] };
|
|
}
|
|
|
|
private extractInvoiceData(
|
|
invoicesData: PromiseSettledResult<{ invoices: Invoice[]; pagination: { totalItems: number } }>,
|
|
unpaidInvoicesData: PromiseSettledResult<{ pagination: { totalItems: number } }>,
|
|
userId: string
|
|
): { unpaidCount: number; nextInvoice: NextInvoice | null; recentInvoices: RecentInvoice[] } {
|
|
let unpaidCount = 0;
|
|
|
|
if (unpaidInvoicesData.status === "fulfilled") {
|
|
unpaidCount = unpaidInvoicesData.value.pagination.totalItems;
|
|
} else {
|
|
this.logger.error(`Failed to fetch unpaid invoices count for user ${userId}`, {
|
|
reason: extractErrorMessage(unpaidInvoicesData.reason),
|
|
});
|
|
}
|
|
|
|
if (invoicesData.status !== "fulfilled") {
|
|
this.logger.error(`Failed to fetch invoices for user ${userId}`, {
|
|
reason: extractErrorMessage(invoicesData.reason),
|
|
});
|
|
return { unpaidCount, nextInvoice: null, recentInvoices: [] };
|
|
}
|
|
|
|
const invoices = invoicesData.value.invoices;
|
|
|
|
if (unpaidInvoicesData.status === "rejected") {
|
|
unpaidCount = invoices.filter(
|
|
inv => inv.status === "Unpaid" || inv.status === "Overdue"
|
|
).length;
|
|
}
|
|
|
|
return {
|
|
unpaidCount,
|
|
nextInvoice: findNextInvoice(invoices),
|
|
recentInvoices: processInvoicesData(invoices),
|
|
};
|
|
}
|
|
|
|
private async fetchClientCurrency(whmcsClientId: number, userId: string): Promise<string> {
|
|
try {
|
|
const client = await this.whmcsClientService.getClientDetails(whmcsClientId);
|
|
const resolvedCurrency =
|
|
typeof client.currency_code === "string" && client.currency_code.trim().length > 0
|
|
? client.currency_code
|
|
: null;
|
|
return resolvedCurrency ?? DEFAULT_CURRENCY;
|
|
} catch (error) {
|
|
this.logger.warn("Could not fetch currency from WHMCS client", {
|
|
userId,
|
|
error: extractErrorMessage(error),
|
|
});
|
|
return DEFAULT_CURRENCY;
|
|
}
|
|
}
|
|
|
|
private async getProfileForUser(user: PrismaUser): Promise<User> {
|
|
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(user.id);
|
|
|
|
return safeOperation(
|
|
async () => {
|
|
const whmcsClient = await this.whmcsClientService.getClientDetails(whmcsClientId);
|
|
const userAuth = mapPrismaUserToUserAuth(user);
|
|
const base = combineToUser(userAuth, whmcsClient);
|
|
|
|
// Portal-visible identifiers (read-only). These are stored in WHMCS custom fields.
|
|
const customerNumberFieldId = this.configService.get<string>(
|
|
"WHMCS_CUSTOMER_NUMBER_FIELD_ID",
|
|
"198"
|
|
);
|
|
const dobFieldId = this.configService.get<string>("WHMCS_DOB_FIELD_ID");
|
|
const genderFieldId = this.configService.get<string>("WHMCS_GENDER_FIELD_ID");
|
|
|
|
const rawSfNumber = customerNumberFieldId
|
|
? getCustomFieldValue(whmcsClient.customfields, customerNumberFieldId)
|
|
: undefined;
|
|
const rawDob = dobFieldId
|
|
? getCustomFieldValue(whmcsClient.customfields, dobFieldId)
|
|
: undefined;
|
|
const rawGender = genderFieldId
|
|
? getCustomFieldValue(whmcsClient.customfields, genderFieldId)
|
|
: undefined;
|
|
|
|
const sfNumber = rawSfNumber?.trim() ? rawSfNumber.trim() : null;
|
|
const dateOfBirth = rawDob?.trim() ? rawDob.trim() : null;
|
|
const gender = rawGender?.trim() ? rawGender.trim() : null;
|
|
|
|
return {
|
|
...base,
|
|
sfNumber,
|
|
dateOfBirth,
|
|
gender,
|
|
};
|
|
},
|
|
{
|
|
criticality: OperationCriticality.CRITICAL,
|
|
context: `Fetch client profile from WHMCS for user ${user.id}`,
|
|
logger: this.logger,
|
|
rethrow: [NotFoundException, BadRequestException],
|
|
fallbackMessage: "Unable to retrieve customer profile from billing system",
|
|
}
|
|
);
|
|
}
|
|
}
|