diff --git a/src/subdomains/generic/kyc/services/kyc-file.service.ts b/src/subdomains/generic/kyc/services/kyc-file.service.ts index b9fe3a029c..d2b7391707 100644 --- a/src/subdomains/generic/kyc/services/kyc-file.service.ts +++ b/src/subdomains/generic/kyc/services/kyc-file.service.ts @@ -15,7 +15,12 @@ export class KycFileService { entity.uid = Util.createUid(Config.prefixes.kycFileUidPrefix); - return this.kycFileRepository.save(entity); + const saved = await this.kycFileRepository.save(entity); + + // Invalidate cache so new files are visible immediately + this.kycFileRepository.invalidateCache(); + + return saved; } async getKycFile(uid: string, relations?: FindOptionsRelations): Promise { diff --git a/src/subdomains/generic/kyc/services/kyc.service.ts b/src/subdomains/generic/kyc/services/kyc.service.ts index 7c1a3c67ba..5c8956678c 100644 --- a/src/subdomains/generic/kyc/services/kyc.service.ts +++ b/src/subdomains/generic/kyc/services/kyc.service.ts @@ -21,7 +21,7 @@ import { Util } from 'src/shared/utils/util'; import { CheckStatus } from 'src/subdomains/core/aml/enums/check-status.enum'; import { PaymentLinkRecipientDto } from 'src/subdomains/core/payment-link/dto/payment-link-recipient.dto'; import { MailFactory, MailTranslationKey } from 'src/subdomains/supporting/notification/factories/mail.factory'; -import { FindOptionsWhere, IsNull, LessThan, MoreThan, Not } from 'typeorm'; +import { FindOptionsWhere, In, IsNull, LessThan, MoreThan, Not } from 'typeorm'; import { MergeReason } from '../../user/models/account-merge/account-merge.entity'; import { AccountMergeService } from '../../user/models/account-merge/account-merge.service'; import { BankDataType } from '../../user/models/bank-data/bank-data.entity'; @@ -1805,4 +1805,57 @@ export class KycService { ); } } + + // --- Company Onboarding Queries --- + + async getPendingCompanyOnboardings(): Promise<{ userDataId: number; date: Date }[]> { + const companyStepNames = [ + KycStepName.LEGAL_ENTITY, + KycStepName.AUTHORITY, + KycStepName.OWNER_DIRECTORY, + KycStepName.SIGNATORY_POWER, + KycStepName.BENEFICIAL_OWNER, + KycStepName.OPERATIONAL_ACTIVITY, + KycStepName.DFX_APPROVAL, + ]; + + const results = await this.kycStepRepo + .createQueryBuilder('step') + .select('step.userDataId', 'userDataId') + .addSelect('MIN(step.updated)', 'date') + .innerJoin('step.userData', 'userData') + .where('step.name IN (:...names)', { names: companyStepNames }) + .andWhere('step.status = :status', { status: ReviewStatus.MANUAL_REVIEW }) + .andWhere('userData.accountType IN (:...accountTypes)', { + accountTypes: [AccountType.ORGANIZATION, AccountType.SOLE_PROPRIETORSHIP], + }) + .andWhere( + `step.userDataId NOT IN ( + SELECT s2.userDataId FROM kyc_step s2 + WHERE s2.name = :approvalName AND s2.status IN (:...doneStatuses) + )`, + { + approvalName: KycStepName.DFX_APPROVAL, + doneStatuses: [ReviewStatus.COMPLETED, ReviewStatus.FAILED], + }, + ) + .groupBy('step.userDataId') + .orderBy('date', 'ASC') + .getRawMany<{ userDataId: number; date: Date }>(); + + return results; + } + + async getDfxApprovalSteps(userDataIds: number[]): Promise { + if (userDataIds.length === 0) return []; + + return this.kycStepRepo.find({ + where: { + userData: { id: In(userDataIds) }, + name: KycStepName.DFX_APPROVAL, + status: In([ReviewStatus.COMPLETED, ReviewStatus.FAILED]), + }, + relations: ['userData'], + }); + } } diff --git a/src/subdomains/generic/support/dto/onboarding-pdf.dto.ts b/src/subdomains/generic/support/dto/onboarding-pdf.dto.ts new file mode 100644 index 0000000000..af6755d6d5 --- /dev/null +++ b/src/subdomains/generic/support/dto/onboarding-pdf.dto.ts @@ -0,0 +1,37 @@ +import { IsString, IsOptional } from 'class-validator'; + +export class GenerateOnboardingPdfDto { + @IsString() + finalDecision: string; + + @IsString() + processedBy: string; + + @IsOptional() + @IsString() + complexOrgStructure?: string; + + @IsOptional() + @IsString() + highRisk?: string; + + @IsOptional() + @IsString() + depositLimit?: string; + + @IsOptional() + @IsString() + amlAccountType?: string; + + @IsOptional() + @IsString() + commentGmeR?: string; + + @IsOptional() + @IsString() + reasonSeatingCompany?: string; + + @IsOptional() + @IsString() + businessActivities?: string; +} diff --git a/src/subdomains/generic/support/dto/user-data-support.dto.ts b/src/subdomains/generic/support/dto/user-data-support.dto.ts index 61d9a876e4..f370a2c1fe 100644 --- a/src/subdomains/generic/support/dto/user-data-support.dto.ts +++ b/src/subdomains/generic/support/dto/user-data-support.dto.ts @@ -11,12 +11,25 @@ export class UserDataSupportInfoResult { bankTx: BankTxSupportInfo[]; } +export enum OnboardingStatus { + OPEN = 'Open', + COMPLETED = 'Completed', + REJECTED = 'Rejected', +} + export class UserDataSupportInfo { id: number; kycStatus: KycStatus; accountType?: AccountType; mail?: string; name?: string; + onboardingStatus?: OnboardingStatus; +} + +export class PendingOnboardingInfo { + id: number; + name?: string; + date: Date; } export class BankTxSupportInfo { @@ -86,6 +99,7 @@ export class KycStepSupportInfo { export class KycLogSupportInfo { id: number; type: string; + result?: string; comment?: string; created: Date; } diff --git a/src/subdomains/generic/support/support-pdf.service.ts b/src/subdomains/generic/support/support-pdf.service.ts new file mode 100644 index 0000000000..5b04c730ca --- /dev/null +++ b/src/subdomains/generic/support/support-pdf.service.ts @@ -0,0 +1,528 @@ +import { Injectable } from '@nestjs/common'; +import PDFDocument from 'pdfkit'; +import { PdfUtil } from 'src/shared/utils/pdf.util'; +import { IpLog } from 'src/shared/models/ip-log/ip-log.entity'; +import { Transaction } from 'src/subdomains/supporting/payment/entities/transaction.entity'; +import { KycStep } from '../kyc/entities/kyc-step.entity'; +import { KycStepName } from '../kyc/enums/kyc-step-name.enum'; +import { UserData } from '../user/models/user-data/user-data.entity'; +import { GenerateOnboardingPdfDto } from './dto/onboarding-pdf.dto'; +import { TransactionSupportInfo } from './dto/user-data-support.dto'; + +@Injectable() +export class SupportPdfService { + generateIpLogPdf(userDataId: number, ipLogs: IpLog[]): Promise { + return new Promise((resolve, reject) => { + try { + const pdf = new PDFDocument({ size: 'A4', margin: 50 }); + const chunks: Buffer[] = []; + + pdf.on('data', (chunk) => chunks.push(chunk)); + pdf.on('end', () => resolve(Buffer.concat(chunks).toString('base64'))); + + PdfUtil.drawLogo(pdf); + + // Header + const marginX = 50; + pdf.moveDown(2); + pdf.fontSize(18).font('Helvetica-Bold').fillColor('#072440'); + pdf.text('IP Log Report', marginX); + pdf.moveDown(0.5); + pdf.fontSize(10).font('Helvetica').fillColor('#333333'); + pdf.text(`User Data ID: ${userDataId}`, marginX); + pdf.text(`Date: ${new Date().toISOString().split('T')[0]}`, marginX); + pdf.text(`Total Entries: ${ipLogs.length}`, marginX); + pdf.moveDown(1); + + // Table + this.drawIpLogTable(pdf, ipLogs); + + // Footer + pdf.moveDown(2); + pdf.fontSize(8).font('Helvetica').fillColor('#999999'); + pdf.text(`Generated by DFX - ${new Date().toISOString()}`, marginX); + + pdf.end(); + } catch (e) { + reject(e); + } + }); + } + + private drawIpLogTable(pdf: InstanceType, ipLogs: IpLog[]): void { + const marginX = 50; + const { width } = pdf.page; + const tableWidth = width - marginX * 2; + + const cols = [ + { header: 'Date', width: tableWidth * 0.2 }, + { header: 'IP', width: tableWidth * 0.2 }, + { header: 'Country', width: tableWidth * 0.12 }, + { header: 'Endpoint', width: tableWidth * 0.36 }, + { header: 'Status', width: tableWidth * 0.12 }, + ]; + + let y = pdf.y; + + // Headers + pdf.fontSize(10).font('Helvetica-Bold').fillColor('#072440'); + let x = marginX; + for (const col of cols) { + pdf.text(col.header, x, y, { width: col.width - 5 }); + x += col.width; + } + + y += 18; + pdf + .moveTo(marginX, y) + .lineTo(width - marginX, y) + .stroke('#CCCCCC'); + y += 8; + + // Rows + pdf.fontSize(9).font('Helvetica').fillColor('#333333'); + + if (ipLogs.length === 0) { + pdf.text('No IP logs found', marginX, y); + } else { + for (const log of ipLogs) { + if (y > pdf.page.height - 80) { + pdf.addPage(); + y = 50; + } + + x = marginX; + const date = log.created ? new Date(log.created).toISOString().replace('T', ' ').substring(0, 19) : '-'; + const endpoint = log.url?.replace('/v1/', '') ?? '-'; + + pdf.fillColor('#333333'); + pdf.text(date, x, y, { width: cols[0].width - 5 }); + x += cols[0].width; + pdf.text(log.ip ?? '-', x, y, { width: cols[1].width - 5 }); + x += cols[1].width; + pdf.text(log.country ?? '-', x, y, { width: cols[2].width - 5 }); + x += cols[2].width; + pdf.text(endpoint, x, y, { width: cols[3].width - 5 }); + x += cols[3].width; + + pdf.fillColor(log.result ? '#28a745' : '#dc3545'); + pdf.text(log.result ? 'Pass' : 'Fail', x, y, { width: cols[4].width - 5 }); + + y += 20; + } + } + + pdf + .moveTo(marginX, y) + .lineTo(width - marginX, y) + .stroke('#CCCCCC'); + pdf.y = y + 10; + } + + generateTransactionPdf( + userDataId: number, + transactions: Transaction[], + toTransactionSupportInfo: (tx: Transaction) => TransactionSupportInfo, + ): Promise { + return new Promise((resolve, reject) => { + try { + const pdf = new PDFDocument({ size: 'A4', layout: 'landscape', margin: 40 }); + const chunks: Buffer[] = []; + + pdf.on('data', (chunk) => chunks.push(chunk)); + pdf.on('end', () => resolve(Buffer.concat(chunks).toString('base64'))); + + PdfUtil.drawLogo(pdf); + + // Header + const marginX = 40; + pdf.moveDown(2); + pdf.fontSize(18).font('Helvetica-Bold').fillColor('#072440'); + pdf.text('Transaction Report', marginX); + pdf.moveDown(0.5); + pdf.fontSize(10).font('Helvetica').fillColor('#333333'); + pdf.text(`User Data ID: ${userDataId}`, marginX); + pdf.text(`Date: ${new Date().toISOString().split('T')[0]}`, marginX); + pdf.text(`Total Entries: ${transactions.length}`, marginX); + pdf.moveDown(1); + + // Table + this.drawTransactionTable(pdf, transactions, toTransactionSupportInfo); + + // Footer + pdf.moveDown(2); + pdf.fontSize(8).font('Helvetica').fillColor('#999999'); + pdf.text(`Generated by DFX - ${new Date().toISOString()}`, marginX); + + pdf.end(); + } catch (e) { + reject(e); + } + }); + } + + private drawTransactionTable( + pdf: InstanceType, + transactions: Transaction[], + toTransactionSupportInfo: (tx: Transaction) => TransactionSupportInfo, + ): void { + const marginX = 40; + const { width } = pdf.page; + const tableWidth = width - marginX * 2; + + const cols = [ + { header: 'ID', width: tableWidth * 0.05 }, + { header: 'UID', width: tableWidth * 0.12 }, + { header: 'Type', width: tableWidth * 0.08 }, + { header: 'Source', width: tableWidth * 0.1 }, + { header: 'Input', width: tableWidth * 0.15 }, + { header: 'CHF', width: tableWidth * 0.1 }, + { header: 'EUR', width: tableWidth * 0.1 }, + { header: 'AML', width: tableWidth * 0.1 }, + { header: 'Chargeback', width: tableWidth * 0.1 }, + { header: 'Created', width: tableWidth * 0.1 }, + ]; + + let y = pdf.y; + + // Headers + pdf.fontSize(8).font('Helvetica-Bold').fillColor('#072440'); + let x = marginX; + for (const col of cols) { + pdf.text(col.header, x, y, { width: col.width - 4 }); + x += col.width; + } + + y += 16; + pdf + .moveTo(marginX, y) + .lineTo(width - marginX, y) + .stroke('#CCCCCC'); + y += 6; + + // Rows + pdf.fontSize(7).font('Helvetica').fillColor('#333333'); + + if (transactions.length === 0) { + pdf.text('No transactions found', marginX, y); + } else { + for (const tx of transactions) { + if (y > pdf.page.height - 60) { + pdf.addPage(); + y = 40; + } + + const info = toTransactionSupportInfo(tx); + const date = info.created ? new Date(info.created).toISOString().split('T')[0] : '-'; + const input = info.inputAmount != null ? `${info.inputAmount.toFixed(2)} ${info.inputAsset ?? ''}` : '-'; + const chargeback = info.chargebackDate ? new Date(info.chargebackDate).toISOString().split('T')[0] : '-'; + + x = marginX; + pdf.fillColor('#333333'); + pdf.text(String(info.id), x, y, { width: cols[0].width - 4 }); + x += cols[0].width; + pdf.text(info.uid ?? '-', x, y, { width: cols[1].width - 4 }); + x += cols[1].width; + pdf.text(info.type ?? '-', x, y, { width: cols[2].width - 4 }); + x += cols[2].width; + pdf.text(info.sourceType ?? '-', x, y, { width: cols[3].width - 4 }); + x += cols[3].width; + pdf.text(input, x, y, { width: cols[4].width - 4 }); + x += cols[4].width; + pdf.text(info.amountInChf?.toFixed(2) ?? '-', x, y, { width: cols[5].width - 4 }); + x += cols[5].width; + pdf.text(info.amountInEur?.toFixed(2) ?? '-', x, y, { width: cols[6].width - 4 }); + x += cols[6].width; + pdf.text(info.amlCheck ?? '-', x, y, { width: cols[7].width - 4 }); + x += cols[7].width; + + if (chargeback !== '-') { + pdf.fillColor('#dc3545'); + } + pdf.text(chargeback, x, y, { width: cols[8].width - 4 }); + x += cols[8].width; + + pdf.fillColor('#333333'); + pdf.text(date, x, y, { width: cols[9].width - 4 }); + + y += 16; + } + } + + pdf + .moveTo(marginX, y) + .lineTo(width - marginX, y) + .stroke('#CCCCCC'); + pdf.y = y + 10; + } + + createOnboardingPdf( + userData: UserData, + kycFiles: { name: string; type: string; subType?: string }[], + kycSteps: KycStep[], + dto: GenerateOnboardingPdfDto, + ): Promise { + return new Promise((resolve, reject) => { + try { + const pdf = new PDFDocument({ size: 'A4', margin: 50 }); + const chunks: Buffer[] = []; + + pdf.on('data', (chunk) => chunks.push(chunk)); + pdf.on('end', () => resolve(Buffer.concat(chunks).toString('base64'))); + + PdfUtil.drawLogo(pdf); + + const marginX = 50; + const { width } = pdf.page; + + // Header + pdf.moveDown(2); + pdf.fontSize(18).font('Helvetica-Bold').fillColor('#072440'); + pdf.text('GwG Kunden Onboarding', marginX); + pdf.moveDown(0.5); + pdf.fontSize(10).font('Helvetica').fillColor('#333333'); + pdf.text(`UserData ID: ${userData.id}`, marginX); + pdf.text(`Datum: ${new Date().toISOString().split('T')[0]}`, marginX); + pdf.moveDown(1); + + // User Data Section + this.drawOnboardingSectionHeader(pdf, 'Benutzerdaten', marginX); + this.drawOnboardingField(pdf, 'Account Type', userData.accountType ?? '-', marginX, width); + this.drawOnboardingField(pdf, 'Name', userData.completeName ?? '-', marginX, width); + this.drawOnboardingField( + pdf, + 'Adresse', + [userData.street, userData.zip, userData.location].filter(Boolean).join(', ') || '-', + marginX, + width, + ); + this.drawOnboardingField( + pdf, + 'Geburtstag', + userData.birthday ? new Date(userData.birthday).toISOString().split('T')[0] : '-', + marginX, + width, + ); + this.drawOnboardingField(pdf, 'E-Mail', userData.mail ?? '-', marginX, width); + this.drawOnboardingField(pdf, 'Telefon', userData.phone ?? '-', marginX, width); + this.drawOnboardingField(pdf, 'Sprache', userData.language?.name ?? '-', marginX, width); + this.drawOnboardingField(pdf, 'Nationalität', userData.nationality?.name ?? '-', marginX, width); + this.drawOnboardingField(pdf, 'PEP Status', userData.pep ? 'Ja' : 'Nein', marginX, width); + this.drawOnboardingField(pdf, 'KYC Hash', userData.kycHash ?? '-', marginX, width); + this.drawOnboardingField( + pdf, + 'Account Opener Authorization', + userData.accountOpenerAuthorization ? 'Ja' : 'Nein', + marginX, + width, + ); + pdf.moveDown(1); + + // Organization Data (if applicable) + if (userData.organization) { + this.drawOnboardingSectionHeader(pdf, 'Organisation', marginX); + this.drawOnboardingField(pdf, 'Name', userData.organizationName ?? '-', marginX, width); + this.drawOnboardingField(pdf, 'Legal Entity', userData.legalEntity ?? '-', marginX, width); + this.drawOnboardingField(pdf, 'Signatory Power', userData.signatoryPower ?? '-', marginX, width); + + // Get operational activity from KycStep + const operationalActivityStep = kycSteps.find((s) => s.name === KycStepName.OPERATIONAL_ACTIVITY); + if (operationalActivityStep?.result) { + try { + const opResult = JSON.parse(operationalActivityStep.result) as Record; + this.drawOnboardingField( + pdf, + 'Operational Activity', + opResult.isOperational != null ? String(opResult.isOperational) : '-', + marginX, + width, + ); + this.drawOnboardingField( + pdf, + 'Website', + opResult.websiteUrl ? String(opResult.websiteUrl) : '-', + marginX, + width, + ); + } catch { + // ignore parse errors + } + } + pdf.moveDown(1); + } + + // KycSteps with result data (Financial Data, Beneficial Owners) + const financialDataStep = kycSteps.find((s) => s.name === KycStepName.FINANCIAL_DATA); + if (financialDataStep?.result) { + try { + const financialData = JSON.parse(financialDataStep.result) as unknown; + this.drawOnboardingSectionHeader(pdf, 'Financial Data', marginX); + this.drawOnboardingKeyValueObject(pdf, financialData, marginX, width); + pdf.moveDown(1); + } catch { + // ignore parse errors + } + } + + const beneficialOwnerStep = kycSteps.find((s) => s.name === KycStepName.BENEFICIAL_OWNER); + if (beneficialOwnerStep?.result) { + try { + const beneficialData = JSON.parse(beneficialOwnerStep.result) as unknown; + this.drawOnboardingSectionHeader(pdf, 'Beneficial Owners', marginX); + this.drawOnboardingKeyValueObject(pdf, beneficialData, marginX, width); + pdf.moveDown(1); + } catch { + // ignore parse errors + } + } + + // Documents Section + this.drawOnboardingSectionHeader(pdf, 'Dokumente', marginX); + const documentTypes = [ + 'Deckblatt', + 'Identifikationsdokument', + 'Formular A', + 'Formular K', + 'Name Checks', + 'Handelsregister', + 'Vollmacht', + 'Aktienbuch', + ]; + for (const docType of documentTypes) { + const file = kycFiles.find((f) => f.name.toLowerCase().includes(docType.toLowerCase())); + this.drawOnboardingField(pdf, docType, file?.name ?? 'nicht vorhanden', marginX, width); + } + pdf.moveDown(1); + + // Compliance Fields + this.drawOnboardingSectionHeader(pdf, 'Compliance Bewertung', marginX); + this.drawOnboardingField(pdf, 'Complex Org Structure', dto.complexOrgStructure ?? '-', marginX, width); + this.drawOnboardingField(pdf, 'HighRisk Einstufung', dto.highRisk ?? '-', marginX, width); + this.drawOnboardingField(pdf, 'Deposit Limit', dto.depositLimit ?? '-', marginX, width); + this.drawOnboardingField(pdf, 'AML Account Type', dto.amlAccountType ?? '-', marginX, width); + pdf.moveDown(1); + + // Text Fields + if (dto.commentGmeR) { + this.drawOnboardingSectionHeader(pdf, 'Kommentar GmeR', marginX); + pdf.fontSize(9).font('Helvetica').fillColor('#333333'); + pdf.text(dto.commentGmeR, marginX, pdf.y, { width: width - marginX * 2 }); + pdf.moveDown(1); + } + + if (dto.reasonSeatingCompany) { + this.drawOnboardingSectionHeader(pdf, 'Sitzgesellschaft Begründung', marginX); + pdf.fontSize(9).font('Helvetica').fillColor('#333333'); + pdf.text(dto.reasonSeatingCompany, marginX, pdf.y, { width: width - marginX * 2 }); + pdf.moveDown(1); + } + + if (dto.businessActivities) { + this.drawOnboardingSectionHeader(pdf, 'Geschäftliche Aktivitäten', marginX); + pdf.fontSize(9).font('Helvetica').fillColor('#333333'); + pdf.text(dto.businessActivities, marginX, pdf.y, { width: width - marginX * 2 }); + pdf.moveDown(1); + } + + // Footer with Final Decision + pdf.moveDown(2); + pdf + .moveTo(marginX, pdf.y) + .lineTo(width - marginX, pdf.y) + .stroke('#CCCCCC'); + pdf.moveDown(1); + + pdf.fontSize(12).font('Helvetica-Bold'); + pdf.fillColor(dto.finalDecision === 'Akzeptiert' ? '#28a745' : '#dc3545'); + pdf.text(`Finaler Entscheid: ${dto.finalDecision}`, marginX); + pdf.moveDown(0.5); + + pdf.fontSize(10).font('Helvetica').fillColor('#333333'); + pdf.text(`Bearbeitet von: ${dto.processedBy}`, marginX); + pdf.text(`UTC Datum: ${new Date().toISOString()}`, marginX); + + pdf.moveDown(2); + pdf.fontSize(8).font('Helvetica').fillColor('#999999'); + pdf.text(`Generated by DFX - ${new Date().toISOString()}`, marginX); + + pdf.end(); + } catch (e) { + reject(e); + } + }); + } + + private drawOnboardingSectionHeader(pdf: InstanceType, title: string, marginX: number): void { + if (pdf.y > pdf.page.height - 100) { + pdf.addPage(); + } + pdf.fontSize(12).font('Helvetica-Bold').fillColor('#072440'); + pdf.text(title, marginX); + pdf.moveDown(0.3); + } + + private drawOnboardingField( + pdf: InstanceType, + label: string, + value: string, + marginX: number, + pageWidth: number, + ): void { + if (pdf.y > pdf.page.height - 60) { + pdf.addPage(); + } + const labelWidth = 180; + const y = pdf.y; + + pdf.fontSize(9).font('Helvetica-Bold').fillColor('#666666'); + pdf.text(label, marginX, y, { width: labelWidth, continued: false }); + + pdf.fontSize(9).font('Helvetica').fillColor('#333333'); + pdf.text(value, marginX + labelWidth, y, { width: pageWidth - marginX * 2 - labelWidth }); + + pdf.y = Math.max(pdf.y, y + 14); + } + + private drawOnboardingKeyValueObject( + pdf: InstanceType, + data: unknown, + marginX: number, + pageWidth: number, + ): void { + // Handle array of {key, value} objects (e.g. FinancialData) + if (Array.isArray(data)) { + for (const item of data) { + if (item && typeof item === 'object' && 'key' in item) { + const key = String((item as { key: unknown }).key); + const rawValue = (item as { value: unknown }).value; + const value = this.formatPdfValue(rawValue); + this.drawOnboardingField(pdf, key, value, marginX, pageWidth); + } + } + return; + } + + // Handle flat object (e.g. BeneficialOwner) + if (typeof data === 'object' && data !== null) { + for (const [key, value] of Object.entries(data as Record)) { + if (value === null || value === undefined) continue; + const displayValue = this.formatPdfValue(value); + this.drawOnboardingField(pdf, key, displayValue, marginX, pageWidth); + } + } + } + + private formatPdfValue(value: unknown): string { + if (value === null || value === undefined) return '-'; + // For primitives (string, number, boolean), just convert to string + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + // For everything else (arrays, objects), use JSON.stringify + try { + return JSON.stringify(value); + } catch { + return '-'; + } + } +} diff --git a/src/subdomains/generic/support/support.controller.ts b/src/subdomains/generic/support/support.controller.ts index b75e4e0544..5f25c690b7 100644 --- a/src/subdomains/generic/support/support.controller.ts +++ b/src/subdomains/generic/support/support.controller.ts @@ -5,6 +5,7 @@ import { Get, NotFoundException, Param, + Post, Put, Query, UseGuards, @@ -16,10 +17,12 @@ import { UserActiveGuard } from 'src/shared/auth/user-active.guard'; import { UserRole } from 'src/shared/auth/user-role.enum'; import { RefundDataDto } from 'src/subdomains/core/history/dto/refund-data.dto'; import { BankRefundDto } from 'src/subdomains/core/history/dto/transaction-refund.dto'; +import { GenerateOnboardingPdfDto } from './dto/onboarding-pdf.dto'; import { TransactionListQuery } from './dto/transaction-list-query.dto'; import { KycFileListEntry, KycFileYearlyStats, + PendingOnboardingInfo, RecommendationGraph, TransactionListEntry, UserDataSupportInfoDetails, @@ -72,6 +75,14 @@ export class SupportController { return this.supportService.getRecommendationGraph(+id); } + @Get('pending-onboardings') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), RoleGuard(UserRole.COMPLIANCE), UserActiveGuard()) + async getPendingOnboardings(): Promise { + return this.supportService.getPendingOnboardings(); + } + @Get(':id/ip-log-pdf') @ApiBearerAuth() @ApiExcludeEndpoint() @@ -81,6 +92,26 @@ export class SupportController { return { pdfData }; } + @Get(':id/transaction-pdf') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), RoleGuard(UserRole.COMPLIANCE), UserActiveGuard()) + async getTransactionPdf(@Param('id') id: string): Promise<{ pdfData: string }> { + const pdfData = await this.supportService.generateTransactionPdf(+id); + return { pdfData }; + } + + @Post(':id/onboarding-pdf') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), RoleGuard(UserRole.COMPLIANCE), UserActiveGuard()) + async generateOnboardingPdf( + @Param('id') id: string, + @Body() dto: GenerateOnboardingPdfDto, + ): Promise<{ pdfData: string; fileName: string }> { + return this.supportService.generateAndSaveOnboardingPdf(+id, dto); + } + @Get(':id') @ApiBearerAuth() @ApiExcludeEndpoint() diff --git a/src/subdomains/generic/support/support.module.ts b/src/subdomains/generic/support/support.module.ts index 43177694ca..8a85d15b35 100644 --- a/src/subdomains/generic/support/support.module.ts +++ b/src/subdomains/generic/support/support.module.ts @@ -11,6 +11,7 @@ import { SupportIssueModule } from 'src/subdomains/supporting/support-issue/supp import { KycModule } from '../kyc/kyc.module'; import { UserModule } from '../user/user.module'; import { SupportController } from './support.controller'; +import { SupportPdfService } from './support-pdf.service'; import { SupportService } from './support.service'; @Module({ @@ -28,7 +29,7 @@ import { SupportService } from './support.service'; forwardRef(() => PaymentModule), ], controllers: [SupportController], - providers: [SupportService], + providers: [SupportService, SupportPdfService], exports: [], }) export class SupportModule {} diff --git a/src/subdomains/generic/support/support.service.ts b/src/subdomains/generic/support/support.service.ts index bf1cec9434..dc33b67a15 100644 --- a/src/subdomains/generic/support/support.service.ts +++ b/src/subdomains/generic/support/support.service.ts @@ -1,6 +1,4 @@ import { BadRequestException, Inject, Injectable, NotFoundException, forwardRef } from '@nestjs/common'; -import PDFDocument from 'pdfkit'; -import { PdfUtil } from 'src/shared/utils/pdf.util'; import { isIP } from 'class-validator'; import * as IbanTools from 'ibantools'; import { Config } from 'src/config/config'; @@ -37,6 +35,11 @@ import { TransactionService } from 'src/subdomains/supporting/payment/services/t import { KycLog } from '../kyc/entities/kyc-log.entity'; import { KycStep } from '../kyc/entities/kyc-step.entity'; import { KycStepName } from '../kyc/enums/kyc-step-name.enum'; +import { ReviewStatus } from '../kyc/enums/review-status.enum'; +import { FileSubType, FileType } from '../kyc/dto/kyc-file.dto'; +import { ContentType } from '../kyc/enums/content-type.enum'; +import { SupportPdfService } from './support-pdf.service'; +import { KycDocumentService } from '../kyc/services/integration/kyc-document.service'; import { KycFileService } from '../kyc/services/kyc-file.service'; import { KycLogService } from '../kyc/services/kyc-log.service'; import { KycService } from '../kyc/services/kyc.service'; @@ -44,10 +47,12 @@ import { BankData } from '../user/models/bank-data/bank-data.entity'; import { BankDataService } from '../user/models/bank-data/bank-data.service'; import { Recommendation } from '../user/models/recommendation/recommendation.entity'; import { RecommendationService } from '../user/models/recommendation/recommendation.service'; +import { AccountType } from '../user/models/user-data/account-type.enum'; import { UserData } from '../user/models/user-data/user-data.entity'; import { UserDataService } from '../user/models/user-data/user-data.service'; import { User } from '../user/models/user/user.entity'; import { UserService } from '../user/models/user/user.service'; +import { GenerateOnboardingPdfDto } from './dto/onboarding-pdf.dto'; import { TransactionListQuery } from './dto/transaction-list-query.dto'; import { BankDataSupportInfo, @@ -69,6 +74,8 @@ import { SellSupportInfo, TransactionListEntry, TransactionSupportInfo, + OnboardingStatus, + PendingOnboardingInfo, UserDataSupportInfo, UserDataSupportInfoDetails, UserDataSupportInfoResult, @@ -109,116 +116,59 @@ export class SupportService { private readonly recommendationService: RecommendationService, private readonly ipLogService: IpLogService, private readonly supportIssueService: SupportIssueService, + private readonly kycDocumentService: KycDocumentService, + private readonly supportPdfService: SupportPdfService, ) {} async generateIpLogPdf(userDataId: number): Promise { const ipLogs = await this.ipLogService.getByUserDataId(userDataId); + return this.supportPdfService.generateIpLogPdf(userDataId, ipLogs); + } - return new Promise((resolve, reject) => { - try { - const pdf = new PDFDocument({ size: 'A4', margin: 50 }); - const chunks: Buffer[] = []; - - pdf.on('data', (chunk) => chunks.push(chunk)); - pdf.on('end', () => resolve(Buffer.concat(chunks).toString('base64'))); - - PdfUtil.drawLogo(pdf); - - // Header - const marginX = 50; - pdf.moveDown(2); - pdf.fontSize(18).font('Helvetica-Bold').fillColor('#072440'); - pdf.text('IP Log Report', marginX); - pdf.moveDown(0.5); - pdf.fontSize(10).font('Helvetica').fillColor('#333333'); - pdf.text(`User Data ID: ${userDataId}`, marginX); - pdf.text(`Date: ${new Date().toISOString().split('T')[0]}`, marginX); - pdf.text(`Total Entries: ${ipLogs.length}`, marginX); - pdf.moveDown(1); - - // Table - this.drawIpLogTable(pdf, ipLogs); - - // Footer - pdf.moveDown(2); - pdf.fontSize(8).font('Helvetica').fillColor('#999999'); - pdf.text(`Generated by DFX - ${new Date().toISOString()}`, marginX); - - pdf.end(); - } catch (e) { - reject(e); - } - }); + async generateTransactionPdf(userDataId: number): Promise { + const transactions = await this.transactionService.getTransactionsByUserDataId(userDataId); + return this.supportPdfService.generateTransactionPdf(userDataId, transactions, (tx) => + this.toTransactionSupportInfo(tx), + ); } - private drawIpLogTable(pdf: InstanceType, ipLogs: IpLog[]): void { - const marginX = 50; - const { width } = pdf.page; - const tableWidth = width - marginX * 2; - - const cols = [ - { header: 'Date', width: tableWidth * 0.2 }, - { header: 'IP', width: tableWidth * 0.2 }, - { header: 'Country', width: tableWidth * 0.12 }, - { header: 'Endpoint', width: tableWidth * 0.36 }, - { header: 'Status', width: tableWidth * 0.12 }, - ]; - - let y = pdf.y; - - // Headers - pdf.fontSize(10).font('Helvetica-Bold').fillColor('#072440'); - let x = marginX; - for (const col of cols) { - pdf.text(col.header, x, y, { width: col.width - 5 }); - x += col.width; - } + async generateAndSaveOnboardingPdf( + userDataId: number, + dto: GenerateOnboardingPdfDto, + ): Promise<{ pdfData: string; fileName: string }> { + // Load UserData with relations + const userData = await this.userDataService.getUserData(userDataId, { + users: true, + country: true, + nationality: true, + language: true, + organization: true, + }); + if (!userData) throw new NotFoundException('User not found'); - y += 18; - pdf - .moveTo(marginX, y) - .lineTo(width - marginX, y) - .stroke('#CCCCCC'); - y += 8; - - // Rows - pdf.fontSize(9).font('Helvetica').fillColor('#333333'); - - if (ipLogs.length === 0) { - pdf.text('No IP logs found', marginX, y); - } else { - for (const log of ipLogs) { - if (y > pdf.page.height - 80) { - pdf.addPage(); - y = 50; - } - - x = marginX; - const date = log.created ? new Date(log.created).toISOString().replace('T', ' ').substring(0, 19) : '-'; - const endpoint = log.url?.replace('/v1/', '') ?? '-'; - - pdf.fillColor('#333333'); - pdf.text(date, x, y, { width: cols[0].width - 5 }); - x += cols[0].width; - pdf.text(log.ip ?? '-', x, y, { width: cols[1].width - 5 }); - x += cols[1].width; - pdf.text(log.country ?? '-', x, y, { width: cols[2].width - 5 }); - x += cols[2].width; - pdf.text(endpoint, x, y, { width: cols[3].width - 5 }); - x += cols[3].width; - - pdf.fillColor(log.result ? '#28a745' : '#dc3545'); - pdf.text(log.result ? 'Pass' : 'Fail', x, y, { width: cols[4].width - 5 }); - - y += 20; - } - } + // Load KycFiles and KycSteps + const [kycFiles, kycSteps] = await Promise.all([ + this.kycFileService.getUserDataKycFiles(userDataId), + this.kycService.getStepsByUserData(userDataId), + ]); - pdf - .moveTo(marginX, y) - .lineTo(width - marginX, y) - .stroke('#CCCCCC'); - pdf.y = y + 10; + // Generate PDF + const pdfData = await this.supportPdfService.createOnboardingPdf(userData, kycFiles, kycSteps, dto); + + // Save as KycFile + const fileName = `GwG_Onboarding_${userDataId}_${Date.now()}.pdf`; + await this.kycDocumentService.uploadUserFile( + userData, + FileType.USER_NOTES, + fileName, + Buffer.from(pdfData, 'base64'), + ContentType.PDF, + true, // isProtected + undefined, // kycStep + FileSubType.ONBOARDING_REPORT, + ); + + return { pdfData, fileName }; } async getUserDataDetails(id: number): Promise { @@ -429,6 +379,7 @@ export class SupportService { return { id: log.id, type: log.type, + result: log.result, comment: log.comment, created: log.created, }; @@ -578,17 +529,56 @@ export class SupportService { ) throw new NotFoundException('No user or bankTx found'); + const uniqueUserDatas = Util.toUniqueList(searchResult.userDatas, 'id').sort((a, b) => a.id - b.id); + + const orgUserIds = uniqueUserDatas.filter((u) => u.accountType === AccountType.ORGANIZATION).map((u) => u.id); + const onboardingStatuses = await this.getDfxApprovalStatuses(orgUserIds); + return { type: searchResult.type, - userDatas: Util.toUniqueList(searchResult.userDatas, 'id') - .sort((a, b) => a.id - b.id) - .map((u) => this.toUserDataDto(u)), + userDatas: uniqueUserDatas.map((u) => this.toUserDataDto(u, onboardingStatuses)), bankTx: bankTx.sort((a, b) => a.id - b.id).map((b) => this.toBankTxDto(b)), }; } + async getPendingOnboardings(): Promise { + const pendingEntries = await this.kycService.getPendingCompanyOnboardings(); + if (pendingEntries.length === 0) return []; + + const userDataIds = pendingEntries.map((e) => e.userDataId); + const userDatas = await Promise.all(userDataIds.map((id) => this.userDataService.getUserData(id))); + + const dateMap = new Map(pendingEntries.map((e) => [e.userDataId, e.date])); + + return userDatas + .filter((ud): ud is UserData => !!ud) + .map((ud) => ({ + id: ud.id, + name: + ud.verifiedName ?? ([ud.firstname, ud.surname, ud.organization?.name].filter(Boolean).join(' ') || undefined), + date: dateMap.get(ud.id) ?? ud.created, + })); + } + //*** HELPER METHODS ***// + private async getDfxApprovalStatuses(userDataIds: number[]): Promise> { + const steps = await this.kycService.getDfxApprovalSteps(userDataIds); + + const result = new Map(); + for (const step of steps) { + if (step.status === ReviewStatus.FAILED) { + result.set(step.userData.id, OnboardingStatus.REJECTED); + } else { + const parsed = step.result ? JSON.parse(step.result as string) : undefined; + const decision = parsed?.complianceReview?.finalDecision; + result.set(step.userData.id, decision === 'Abgelehnt' ? OnboardingStatus.REJECTED : OnboardingStatus.COMPLETED); + } + } + + return result; + } + private async getUserDatasByKey(key: string): Promise<{ type: ComplianceSearchType; userDatas: UserData[] }> { if (key.includes('@')) return { type: ComplianceSearchType.MAIL, userDatas: await this.userDataService.getUsersByMail(key, false) }; @@ -689,7 +679,7 @@ export class SupportService { }); } - private toUserDataDto(userData: UserData): UserDataSupportInfo { + private toUserDataDto(userData: UserData, onboardingStatuses?: Map): UserDataSupportInfo { const name = userData.verifiedName ?? ([userData.firstname, userData.surname, userData.organization?.name].filter(Boolean).join(' ') || undefined); @@ -700,6 +690,10 @@ export class SupportService { accountType: userData.accountType, mail: userData.mail, name, + onboardingStatus: + userData.accountType === AccountType.ORGANIZATION + ? (onboardingStatuses?.get(userData.id) ?? OnboardingStatus.OPEN) + : undefined, }; } diff --git a/src/subdomains/generic/user/models/user-data/user-data.entity.ts b/src/subdomains/generic/user/models/user-data/user-data.entity.ts index 02fa3f1225..63ec2c1b23 100644 --- a/src/subdomains/generic/user/models/user-data/user-data.entity.ts +++ b/src/subdomains/generic/user/models/user-data/user-data.entity.ts @@ -799,7 +799,13 @@ export class UserData extends IEntity { export const KycCompletedStates = [KycStatus.COMPLETED]; export const UserDataSupportUpdateCols = ['status', 'riskStatus', 'recallAgreementAccepted']; -export const UserDataComplianceUpdateCols = ['kycStatus', 'depositLimit']; +export const UserDataComplianceUpdateCols = [ + 'kycStatus', + 'depositLimit', + 'amlAccountType', + 'complexOrgStructure', + 'highRisk', +]; export function KycCompleted(kycStatus?: KycStatus): boolean { return KycCompletedStates.includes(kycStatus);