2025-09-02 13:52:13 +09:00
|
|
|
import { Injectable, Inject, NotFoundException, BadRequestException } from "@nestjs/common";
|
2025-08-21 15:24:40 +09:00
|
|
|
import { Logger } from "nestjs-pino";
|
2025-10-07 17:38:39 +09:00
|
|
|
import type { User as PrismaUser } from "@prisma/client";
|
|
|
|
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
|
|
|
|
import { normalizeAndValidateEmail, validateUuidV4OrThrow } from "@bff/core/utils/validation.util";
|
2025-09-17 18:43:43 +09:00
|
|
|
import { PrismaService } from "@bff/infra/database/prisma.service";
|
2025-10-03 16:37:52 +09:00
|
|
|
import {
|
2025-10-07 17:38:39 +09:00
|
|
|
updateCustomerProfileRequestSchema,
|
2025-10-03 16:37:52 +09:00
|
|
|
type AuthenticatedUser,
|
2025-10-07 17:38:39 +09:00
|
|
|
type UpdateCustomerProfileRequest,
|
2025-10-03 16:37:52 +09:00
|
|
|
} from "@customer-portal/domain/auth";
|
|
|
|
|
import type { Subscription } from "@customer-portal/domain/subscriptions";
|
|
|
|
|
import type { Invoice } from "@customer-portal/domain/billing";
|
2025-10-07 17:38:39 +09:00
|
|
|
import type { Activity, DashboardSummary, NextInvoice } from "@customer-portal/domain/dashboard";
|
2025-09-17 18:43:43 +09:00
|
|
|
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
|
|
|
|
|
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service";
|
|
|
|
|
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
|
|
|
|
|
2025-10-07 17:38:39 +09:00
|
|
|
// Use a subset of PrismaUser for auth-related updates only
|
2025-09-25 11:44:10 +09:00
|
|
|
type UserUpdateData = Partial<
|
|
|
|
|
Pick<
|
|
|
|
|
PrismaUser,
|
|
|
|
|
| "passwordHash"
|
|
|
|
|
| "failedLoginAttempts"
|
|
|
|
|
| "lastLoginAt"
|
|
|
|
|
| "lockedUntil"
|
|
|
|
|
>
|
|
|
|
|
>;
|
2025-08-20 18:02:50 +09:00
|
|
|
|
|
|
|
|
@Injectable()
|
|
|
|
|
export class UsersService {
|
|
|
|
|
constructor(
|
|
|
|
|
private prisma: PrismaService,
|
|
|
|
|
private whmcsService: WhmcsService,
|
|
|
|
|
private salesforceService: SalesforceService,
|
|
|
|
|
private mappingsService: MappingsService,
|
2025-08-22 17:02:49 +09:00
|
|
|
@Inject(Logger) private readonly logger: Logger
|
2025-08-20 18:02:50 +09:00
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
private validateEmail(email: string): string {
|
2025-09-17 18:43:43 +09:00
|
|
|
return normalizeAndValidateEmail(email);
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private validateUserId(id: string): string {
|
2025-09-17 18:43:43 +09:00
|
|
|
return validateUuidV4OrThrow(id);
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
|
2025-10-07 17:38:39 +09:00
|
|
|
/**
|
|
|
|
|
* Find user by email - returns authenticated user with full profile from WHMCS
|
|
|
|
|
*/
|
|
|
|
|
async findByEmail(email: string): Promise<AuthenticatedUser | null> {
|
2025-08-20 18:02:50 +09:00
|
|
|
const validEmail = this.validateEmail(email);
|
|
|
|
|
|
|
|
|
|
try {
|
2025-08-21 15:24:40 +09:00
|
|
|
const user = await this.prisma.user.findUnique({
|
|
|
|
|
where: { email: validEmail },
|
|
|
|
|
});
|
2025-10-07 17:38:39 +09:00
|
|
|
|
|
|
|
|
if (!user) return null;
|
|
|
|
|
|
|
|
|
|
// Return full profile with WHMCS data
|
|
|
|
|
return this.getProfile(user.id);
|
2025-08-20 18:02:50 +09:00
|
|
|
} catch (error) {
|
2025-08-21 15:24:40 +09:00
|
|
|
this.logger.error("Failed to find user by email", {
|
|
|
|
|
error: getErrorMessage(error),
|
|
|
|
|
});
|
2025-10-07 17:38:39 +09:00
|
|
|
throw new BadRequestException("Unable to retrieve user profile");
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Internal method for auth service - returns raw user with sensitive fields
|
2025-09-19 16:34:10 +09:00
|
|
|
async findByEmailInternal(email: string): Promise<PrismaUser | null> {
|
2025-08-20 18:02:50 +09:00
|
|
|
const validEmail = this.validateEmail(email);
|
|
|
|
|
|
|
|
|
|
try {
|
2025-08-21 15:24:40 +09:00
|
|
|
return await this.prisma.user.findUnique({
|
|
|
|
|
where: { email: validEmail },
|
|
|
|
|
});
|
2025-08-20 18:02:50 +09:00
|
|
|
} catch (error) {
|
2025-08-21 15:24:40 +09:00
|
|
|
this.logger.error("Failed to find user by email (internal)", {
|
|
|
|
|
error: getErrorMessage(error),
|
|
|
|
|
});
|
2025-10-07 17:38:39 +09:00
|
|
|
throw new BadRequestException("Unable to retrieve user information");
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-02 13:52:13 +09:00
|
|
|
// Internal method for auth service - returns raw user by ID with sensitive fields
|
2025-09-19 16:34:10 +09:00
|
|
|
async findByIdInternal(id: string): Promise<PrismaUser | null> {
|
2025-09-02 13:52:13 +09:00
|
|
|
const validId = this.validateUserId(id);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
return await this.prisma.user.findUnique({ where: { id: validId } });
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.logger.error("Failed to find user by ID (internal)", {
|
|
|
|
|
error: getErrorMessage(error),
|
|
|
|
|
});
|
2025-10-07 17:38:39 +09:00
|
|
|
throw new BadRequestException("Unable to retrieve user information");
|
2025-09-02 13:52:13 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-07 17:38:39 +09:00
|
|
|
/**
|
|
|
|
|
* Get user profile - primary method for fetching authenticated user with full WHMCS data
|
|
|
|
|
*/
|
2025-09-26 15:51:07 +09:00
|
|
|
async findById(id: string): Promise<AuthenticatedUser | null> {
|
2025-08-20 18:02:50 +09:00
|
|
|
const validId = this.validateUserId(id);
|
|
|
|
|
|
|
|
|
|
try {
|
2025-08-21 15:24:40 +09:00
|
|
|
const user = await this.prisma.user.findUnique({
|
|
|
|
|
where: { id: validId },
|
|
|
|
|
});
|
2025-08-20 18:02:50 +09:00
|
|
|
if (!user) return null;
|
|
|
|
|
|
2025-10-07 17:38:39 +09:00
|
|
|
return await this.getProfile(validId);
|
2025-08-20 18:02:50 +09:00
|
|
|
} catch (error) {
|
2025-08-21 15:24:40 +09:00
|
|
|
this.logger.error("Failed to find user by ID", {
|
|
|
|
|
error: getErrorMessage(error),
|
|
|
|
|
});
|
2025-10-07 17:38:39 +09:00
|
|
|
throw new BadRequestException("Unable to retrieve user profile");
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-07 17:38:39 +09:00
|
|
|
/**
|
|
|
|
|
* Get complete customer profile from WHMCS (single source of truth)
|
|
|
|
|
* Includes profile fields + address + auth state
|
|
|
|
|
*/
|
|
|
|
|
async getProfile(userId: string): Promise<AuthenticatedUser> {
|
2025-08-20 18:02:50 +09:00
|
|
|
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
2025-10-07 17:38:39 +09:00
|
|
|
if (!user) throw new NotFoundException("User not found");
|
2025-08-20 18:02:50 +09:00
|
|
|
|
|
|
|
|
const mapping = await this.mappingsService.findByUserId(userId);
|
2025-10-07 17:38:39 +09:00
|
|
|
if (!mapping?.whmcsClientId) {
|
|
|
|
|
throw new NotFoundException("WHMCS client mapping not found");
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
|
2025-10-07 17:38:39 +09:00
|
|
|
try {
|
|
|
|
|
// Fetch complete client data from WHMCS (source of truth)
|
|
|
|
|
const client = await this.whmcsService.getClientDetails(mapping.whmcsClientId);
|
|
|
|
|
|
|
|
|
|
// Map WHMCS client to CustomerProfile with auth state
|
|
|
|
|
const profile: AuthenticatedUser = {
|
|
|
|
|
id: user.id,
|
|
|
|
|
email: client.email,
|
|
|
|
|
firstname: client.firstname || null,
|
|
|
|
|
lastname: client.lastname || null,
|
|
|
|
|
fullname: client.fullname || null,
|
|
|
|
|
companyname: client.companyName || null,
|
|
|
|
|
phonenumber: client.phoneNumber || null,
|
|
|
|
|
language: client.language || null,
|
|
|
|
|
currencyCode: client.currencyCode || null,
|
|
|
|
|
|
|
|
|
|
// Address from WHMCS
|
|
|
|
|
address: client.address || undefined,
|
|
|
|
|
|
|
|
|
|
// Auth state from portal DB
|
|
|
|
|
role: user.role,
|
|
|
|
|
emailVerified: user.emailVerified,
|
|
|
|
|
mfaEnabled: user.mfaSecret !== null,
|
|
|
|
|
lastLoginAt: user.lastLoginAt?.toISOString(),
|
|
|
|
|
|
|
|
|
|
createdAt: user.createdAt.toISOString(),
|
|
|
|
|
updatedAt: user.updatedAt.toISOString(),
|
|
|
|
|
};
|
2025-08-20 18:02:50 +09:00
|
|
|
|
2025-10-07 17:38:39 +09:00
|
|
|
return profile;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.logger.error("Failed to fetch client profile from WHMCS", {
|
|
|
|
|
error: getErrorMessage(error),
|
|
|
|
|
userId,
|
|
|
|
|
whmcsClientId: mapping.whmcsClientId,
|
|
|
|
|
});
|
|
|
|
|
throw new BadRequestException("Unable to retrieve customer profile from billing system");
|
|
|
|
|
}
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
|
2025-10-07 17:38:39 +09:00
|
|
|
/**
|
|
|
|
|
* Create user (auth state only in portal DB)
|
|
|
|
|
*/
|
|
|
|
|
async create(userData: Partial<PrismaUser>): Promise<AuthenticatedUser> {
|
2025-08-23 18:02:05 +09:00
|
|
|
const validEmail = this.validateEmail(userData.email!);
|
2025-08-20 18:02:50 +09:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const normalizedData = { ...userData, email: validEmail };
|
2025-08-21 15:24:40 +09:00
|
|
|
const createdUser = await this.prisma.user.create({
|
|
|
|
|
data: normalizedData,
|
|
|
|
|
});
|
2025-10-07 17:38:39 +09:00
|
|
|
|
|
|
|
|
// Return full profile from WHMCS
|
|
|
|
|
return this.getProfile(createdUser.id);
|
2025-08-20 18:02:50 +09:00
|
|
|
} catch (error) {
|
2025-08-21 15:24:40 +09:00
|
|
|
this.logger.error("Failed to create user", {
|
|
|
|
|
error: getErrorMessage(error),
|
|
|
|
|
});
|
2025-10-07 17:38:39 +09:00
|
|
|
throw new BadRequestException("Unable to create user account");
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-07 17:38:39 +09:00
|
|
|
/**
|
|
|
|
|
* Update user auth state (password, login attempts, etc.)
|
|
|
|
|
* For profile updates, use updateProfile instead
|
|
|
|
|
*/
|
|
|
|
|
async update(id: string, userData: UserUpdateData): Promise<AuthenticatedUser> {
|
2025-08-20 18:02:50 +09:00
|
|
|
const validId = this.validateUserId(id);
|
|
|
|
|
const sanitizedData = this.sanitizeUserData(userData);
|
|
|
|
|
|
|
|
|
|
try {
|
2025-10-07 17:38:39 +09:00
|
|
|
await this.prisma.user.update({
|
2025-08-20 18:02:50 +09:00
|
|
|
where: { id: validId },
|
|
|
|
|
data: sanitizedData,
|
|
|
|
|
});
|
|
|
|
|
|
2025-10-07 17:38:39 +09:00
|
|
|
// Return fresh profile from WHMCS
|
|
|
|
|
return this.getProfile(validId);
|
2025-08-20 18:02:50 +09:00
|
|
|
} catch (error) {
|
2025-08-21 15:24:40 +09:00
|
|
|
this.logger.error("Failed to update user", {
|
|
|
|
|
error: getErrorMessage(error),
|
|
|
|
|
});
|
2025-10-07 17:38:39 +09:00
|
|
|
throw new BadRequestException("Unable to update user information");
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-07 17:38:39 +09:00
|
|
|
/**
|
|
|
|
|
* Update customer profile in WHMCS (single source of truth)
|
|
|
|
|
* Can update profile fields AND/OR address fields in one call
|
|
|
|
|
*/
|
|
|
|
|
async updateProfile(userId: string, update: UpdateCustomerProfileRequest): Promise<AuthenticatedUser> {
|
|
|
|
|
const validId = this.validateUserId(userId);
|
|
|
|
|
const parsed = updateCustomerProfileRequestSchema.parse(update);
|
2025-10-03 17:33:39 +09:00
|
|
|
|
|
|
|
|
try {
|
2025-10-07 17:38:39 +09:00
|
|
|
const mapping = await this.mappingsService.findByUserId(validId);
|
|
|
|
|
if (!mapping) {
|
|
|
|
|
throw new NotFoundException("User mapping not found");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update in WHMCS (all fields optional)
|
|
|
|
|
await this.whmcsService.updateClient(mapping.whmcsClientId, parsed);
|
|
|
|
|
|
|
|
|
|
this.logger.log({ userId: validId }, "Successfully updated customer profile in WHMCS");
|
2025-10-03 17:33:39 +09:00
|
|
|
|
2025-10-07 17:38:39 +09:00
|
|
|
// Return fresh profile
|
|
|
|
|
return this.getProfile(validId);
|
2025-10-03 17:33:39 +09:00
|
|
|
} catch (error) {
|
2025-10-07 17:38:39 +09:00
|
|
|
const msg = getErrorMessage(error);
|
|
|
|
|
this.logger.error({ userId: validId, error: msg }, "Failed to update customer profile in WHMCS");
|
|
|
|
|
|
|
|
|
|
if (msg.includes("WHMCS API Error")) {
|
|
|
|
|
throw new BadRequestException(msg.replace("WHMCS API Error: ", ""));
|
|
|
|
|
}
|
|
|
|
|
if (msg.includes("HTTP ")) {
|
|
|
|
|
throw new BadRequestException("Upstream WHMCS error. Please try again.");
|
|
|
|
|
}
|
|
|
|
|
if (msg.includes("Missing required WHMCS configuration")) {
|
|
|
|
|
throw new BadRequestException("Billing system not configured. Please contact support.");
|
|
|
|
|
}
|
|
|
|
|
throw new BadRequestException("Unable to update profile.");
|
2025-10-03 17:33:39 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-19 16:34:10 +09:00
|
|
|
private sanitizeUserData(userData: UserUpdateData): Partial<PrismaUser> {
|
|
|
|
|
const sanitized: Partial<PrismaUser> = {};
|
2025-10-07 17:38:39 +09:00
|
|
|
|
|
|
|
|
// Handle authentication-related fields only
|
2025-08-22 17:02:49 +09:00
|
|
|
if (userData.passwordHash !== undefined) sanitized.passwordHash = userData.passwordHash;
|
2025-08-21 15:24:40 +09:00
|
|
|
if (userData.failedLoginAttempts !== undefined)
|
|
|
|
|
sanitized.failedLoginAttempts = userData.failedLoginAttempts;
|
2025-08-22 17:02:49 +09:00
|
|
|
if (userData.lastLoginAt !== undefined) sanitized.lastLoginAt = userData.lastLoginAt;
|
|
|
|
|
if (userData.lockedUntil !== undefined) sanitized.lockedUntil = userData.lockedUntil;
|
2025-08-21 15:24:40 +09:00
|
|
|
|
2025-08-20 18:02:50 +09:00
|
|
|
return sanitized;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-07 17:38:39 +09:00
|
|
|
async getUserSummary(userId: string): Promise<DashboardSummary> {
|
2025-08-20 18:02:50 +09:00
|
|
|
try {
|
|
|
|
|
// Verify user exists
|
|
|
|
|
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
|
|
|
|
if (!user) {
|
2025-10-07 17:38:39 +09:00
|
|
|
throw new NotFoundException("User not found");
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if user has WHMCS mapping
|
|
|
|
|
const mapping = await this.mappingsService.findByUserId(userId);
|
|
|
|
|
if (!mapping?.whmcsClientId) {
|
|
|
|
|
this.logger.warn(`No WHMCS mapping found for user ${userId}`);
|
2025-10-07 17:38:39 +09:00
|
|
|
|
|
|
|
|
// Get currency from WHMCS profile if available
|
|
|
|
|
let currency = "JPY"; // Default
|
|
|
|
|
try {
|
|
|
|
|
const profile = await this.getProfile(userId);
|
|
|
|
|
currency = profile.currencyCode || currency;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.logger.warn("Could not fetch currency from profile", { userId });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const summary: DashboardSummary = {
|
2025-08-20 18:02:50 +09:00
|
|
|
stats: {
|
|
|
|
|
activeSubscriptions: 0,
|
|
|
|
|
unpaidInvoices: 0,
|
|
|
|
|
openCases: 0,
|
2025-10-07 17:38:39 +09:00
|
|
|
currency,
|
2025-08-20 18:02:50 +09:00
|
|
|
},
|
|
|
|
|
nextInvoice: null,
|
2025-08-21 15:24:40 +09:00
|
|
|
recentActivity: [],
|
2025-08-20 18:02:50 +09:00
|
|
|
};
|
2025-10-07 17:38:39 +09:00
|
|
|
return summary;
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fetch live data from WHMCS in parallel
|
|
|
|
|
const [subscriptionsData, invoicesData] = await Promise.allSettled([
|
|
|
|
|
this.whmcsService.getSubscriptions(mapping.whmcsClientId, userId),
|
2025-08-21 15:24:40 +09:00
|
|
|
this.whmcsService.getInvoices(mapping.whmcsClientId, userId, {
|
|
|
|
|
limit: 50,
|
|
|
|
|
}),
|
2025-08-20 18:02:50 +09:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// Process subscriptions
|
|
|
|
|
let activeSubscriptions = 0;
|
2025-08-23 18:02:05 +09:00
|
|
|
let recentSubscriptions: Array<{
|
2025-10-07 17:38:39 +09:00
|
|
|
id: number;
|
2025-08-23 18:02:05 +09:00
|
|
|
status: string;
|
|
|
|
|
registrationDate: string;
|
|
|
|
|
productName: string;
|
|
|
|
|
}> = [];
|
2025-08-21 15:24:40 +09:00
|
|
|
if (subscriptionsData.status === "fulfilled") {
|
2025-09-01 15:11:42 +09:00
|
|
|
const subscriptions: Subscription[] = subscriptionsData.value.subscriptions;
|
|
|
|
|
activeSubscriptions = subscriptions.filter(
|
|
|
|
|
(sub: Subscription) => sub.status === "Active"
|
|
|
|
|
).length;
|
2025-08-20 18:02:50 +09:00
|
|
|
recentSubscriptions = subscriptions
|
2025-09-01 15:11:42 +09:00
|
|
|
.filter((sub: Subscription) => sub.status === "Active")
|
2025-09-25 17:42:36 +09:00
|
|
|
.sort((a: Subscription, b: Subscription) => {
|
|
|
|
|
const aTime = a.registrationDate
|
|
|
|
|
? new Date(a.registrationDate).getTime()
|
|
|
|
|
: Number.NEGATIVE_INFINITY;
|
|
|
|
|
const bTime = b.registrationDate
|
|
|
|
|
? new Date(b.registrationDate).getTime()
|
|
|
|
|
: Number.NEGATIVE_INFINITY;
|
|
|
|
|
return bTime - aTime;
|
|
|
|
|
})
|
2025-08-23 18:02:05 +09:00
|
|
|
.slice(0, 3)
|
2025-09-01 15:11:42 +09:00
|
|
|
.map((sub: Subscription) => ({
|
2025-10-07 17:38:39 +09:00
|
|
|
id: sub.id,
|
2025-08-23 18:02:05 +09:00
|
|
|
status: sub.status,
|
|
|
|
|
registrationDate: sub.registrationDate,
|
|
|
|
|
productName: sub.productName,
|
|
|
|
|
}));
|
2025-08-20 18:02:50 +09:00
|
|
|
} else {
|
2025-08-21 15:24:40 +09:00
|
|
|
this.logger.error(
|
|
|
|
|
`Failed to fetch subscriptions for user ${userId}:`,
|
2025-08-22 17:02:49 +09:00
|
|
|
subscriptionsData.reason
|
2025-08-21 15:24:40 +09:00
|
|
|
);
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Process invoices
|
|
|
|
|
let unpaidInvoices = 0;
|
2025-10-07 17:38:39 +09:00
|
|
|
let nextInvoice: NextInvoice | null = null;
|
2025-08-23 18:02:05 +09:00
|
|
|
let recentInvoices: Array<{
|
2025-10-07 17:38:39 +09:00
|
|
|
id: number;
|
2025-08-23 18:02:05 +09:00
|
|
|
status: string;
|
|
|
|
|
dueDate?: string;
|
|
|
|
|
total: number;
|
|
|
|
|
number: string;
|
|
|
|
|
issuedAt?: string;
|
|
|
|
|
paidDate?: string;
|
|
|
|
|
}> = [];
|
2025-08-21 15:24:40 +09:00
|
|
|
if (invoicesData.status === "fulfilled") {
|
2025-09-01 15:11:42 +09:00
|
|
|
const invoices: Invoice[] = invoicesData.value.invoices;
|
2025-08-21 15:24:40 +09:00
|
|
|
|
2025-08-20 18:02:50 +09:00
|
|
|
// Count unpaid invoices
|
2025-08-21 15:24:40 +09:00
|
|
|
unpaidInvoices = invoices.filter(
|
2025-09-01 15:11:42 +09:00
|
|
|
(inv: Invoice) => inv.status === "Unpaid" || inv.status === "Overdue"
|
2025-08-21 15:24:40 +09:00
|
|
|
).length;
|
|
|
|
|
|
2025-08-20 18:02:50 +09:00
|
|
|
// Find next due invoice
|
|
|
|
|
const upcomingInvoices = invoices
|
2025-09-01 15:11:42 +09:00
|
|
|
.filter(
|
|
|
|
|
(inv: Invoice) => (inv.status === "Unpaid" || inv.status === "Overdue") && inv.dueDate
|
|
|
|
|
)
|
2025-09-25 17:42:36 +09:00
|
|
|
.sort((a: Invoice, b: Invoice) => {
|
|
|
|
|
const aTime = a.dueDate ? new Date(a.dueDate).getTime() : Number.POSITIVE_INFINITY;
|
|
|
|
|
const bTime = b.dueDate ? new Date(b.dueDate).getTime() : Number.POSITIVE_INFINITY;
|
|
|
|
|
return aTime - bTime;
|
|
|
|
|
});
|
2025-08-21 15:24:40 +09:00
|
|
|
|
2025-08-20 18:02:50 +09:00
|
|
|
if (upcomingInvoices.length > 0) {
|
|
|
|
|
const invoice = upcomingInvoices[0];
|
|
|
|
|
nextInvoice = {
|
2025-10-07 17:38:39 +09:00
|
|
|
id: invoice.id,
|
|
|
|
|
dueDate: invoice.dueDate!,
|
2025-08-20 18:02:50 +09:00
|
|
|
amount: invoice.total,
|
2025-10-07 17:38:39 +09:00
|
|
|
currency: invoice.currency ?? "JPY",
|
2025-08-20 18:02:50 +09:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Recent invoices for activity
|
|
|
|
|
recentInvoices = invoices
|
2025-09-25 17:42:36 +09:00
|
|
|
.sort((a: Invoice, b: Invoice) => {
|
|
|
|
|
const aTime = a.issuedAt ? new Date(a.issuedAt).getTime() : Number.NEGATIVE_INFINITY;
|
|
|
|
|
const bTime = b.issuedAt ? new Date(b.issuedAt).getTime() : Number.NEGATIVE_INFINITY;
|
|
|
|
|
return bTime - aTime;
|
|
|
|
|
})
|
2025-08-23 18:02:05 +09:00
|
|
|
.slice(0, 5)
|
2025-09-01 15:11:42 +09:00
|
|
|
.map((inv: Invoice) => ({
|
2025-10-07 17:38:39 +09:00
|
|
|
id: inv.id,
|
2025-08-23 18:02:05 +09:00
|
|
|
status: inv.status,
|
|
|
|
|
dueDate: inv.dueDate,
|
|
|
|
|
total: inv.total,
|
|
|
|
|
number: inv.number,
|
|
|
|
|
issuedAt: inv.issuedAt,
|
|
|
|
|
}));
|
2025-08-20 18:02:50 +09:00
|
|
|
} else {
|
2025-08-22 17:02:49 +09:00
|
|
|
this.logger.error(`Failed to fetch invoices for user ${userId}`, {
|
|
|
|
|
reason: getErrorMessage(invoicesData.reason),
|
|
|
|
|
});
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build activity feed
|
|
|
|
|
const activities: Activity[] = [];
|
|
|
|
|
|
|
|
|
|
// Add invoice activities
|
2025-08-22 17:02:49 +09:00
|
|
|
recentInvoices.forEach(invoice => {
|
2025-08-21 15:24:40 +09:00
|
|
|
if (invoice.status === "Paid") {
|
2025-08-20 18:02:50 +09:00
|
|
|
activities.push({
|
|
|
|
|
id: `invoice-paid-${invoice.id}`,
|
2025-08-21 15:24:40 +09:00
|
|
|
type: "invoice_paid",
|
2025-08-20 18:02:50 +09:00
|
|
|
title: `Invoice #${invoice.number} paid`,
|
|
|
|
|
description: `Payment of ¥${invoice.total.toLocaleString()} processed`,
|
2025-08-22 17:02:49 +09:00
|
|
|
date: invoice.paidDate || invoice.issuedAt || new Date().toISOString(),
|
2025-10-07 17:38:39 +09:00
|
|
|
relatedId: invoice.id,
|
2025-08-20 18:02:50 +09:00
|
|
|
});
|
2025-08-22 17:02:49 +09:00
|
|
|
} else if (invoice.status === "Unpaid" || invoice.status === "Overdue") {
|
2025-08-20 18:02:50 +09:00
|
|
|
activities.push({
|
|
|
|
|
id: `invoice-created-${invoice.id}`,
|
2025-08-21 15:24:40 +09:00
|
|
|
type: "invoice_created",
|
2025-08-20 18:02:50 +09:00
|
|
|
title: `Invoice #${invoice.number} created`,
|
|
|
|
|
description: `Amount: ¥${invoice.total.toLocaleString()}`,
|
2025-08-23 18:02:05 +09:00
|
|
|
date: invoice.issuedAt || new Date().toISOString(),
|
2025-10-07 17:38:39 +09:00
|
|
|
relatedId: invoice.id,
|
2025-08-20 18:02:50 +09:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Add subscription activities
|
2025-08-22 17:02:49 +09:00
|
|
|
recentSubscriptions.forEach(subscription => {
|
2025-08-20 18:02:50 +09:00
|
|
|
activities.push({
|
|
|
|
|
id: `service-activated-${subscription.id}`,
|
2025-08-21 15:24:40 +09:00
|
|
|
type: "service_activated",
|
2025-08-20 18:02:50 +09:00
|
|
|
title: `${subscription.productName} activated`,
|
2025-08-21 15:24:40 +09:00
|
|
|
description: "Service successfully provisioned",
|
2025-08-20 18:02:50 +09:00
|
|
|
date: subscription.registrationDate,
|
2025-10-07 17:38:39 +09:00
|
|
|
relatedId: subscription.id,
|
2025-08-20 18:02:50 +09:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Sort activities by date and take top 10
|
2025-08-22 17:02:49 +09:00
|
|
|
activities.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
2025-08-20 18:02:50 +09:00
|
|
|
const recentActivity = activities.slice(0, 10);
|
|
|
|
|
|
|
|
|
|
this.logger.log(`Generated dashboard summary for user ${userId}`, {
|
|
|
|
|
activeSubscriptions,
|
|
|
|
|
unpaidInvoices,
|
|
|
|
|
activitiesCount: recentActivity.length,
|
2025-08-21 15:24:40 +09:00
|
|
|
hasNextInvoice: !!nextInvoice,
|
2025-08-20 18:02:50 +09:00
|
|
|
});
|
|
|
|
|
|
2025-10-07 17:38:39 +09:00
|
|
|
// Get currency from client data
|
|
|
|
|
let currency = "JPY"; // Default
|
|
|
|
|
try {
|
|
|
|
|
const client = await this.whmcsService.getClientDetails(mapping.whmcsClientId);
|
|
|
|
|
currency = client.currencyCode || currency;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.logger.warn("Could not fetch currency from WHMCS client", { userId });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const summary: DashboardSummary = {
|
2025-08-20 18:02:50 +09:00
|
|
|
stats: {
|
|
|
|
|
activeSubscriptions,
|
|
|
|
|
unpaidInvoices,
|
2025-10-07 17:38:39 +09:00
|
|
|
openCases: 0, // Support cases not implemented yet
|
|
|
|
|
currency,
|
2025-08-20 18:02:50 +09:00
|
|
|
},
|
|
|
|
|
nextInvoice,
|
2025-08-21 15:24:40 +09:00
|
|
|
recentActivity,
|
2025-08-20 18:02:50 +09:00
|
|
|
};
|
2025-10-07 17:38:39 +09:00
|
|
|
return summary;
|
2025-08-20 18:02:50 +09:00
|
|
|
} catch (error) {
|
2025-08-22 17:02:49 +09:00
|
|
|
this.logger.error(`Failed to get user summary for ${userId}`, {
|
|
|
|
|
error: getErrorMessage(error),
|
|
|
|
|
});
|
2025-10-07 17:38:39 +09:00
|
|
|
throw new BadRequestException("Unable to retrieve dashboard summary");
|
2025-08-28 16:57:57 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|