From 86fe1ec213388b0dee7b5d5955dc882be0900f8c Mon Sep 17 00:00:00 2001 From: Bernd Date: Fri, 27 Mar 2026 16:33:48 +0100 Subject: [PATCH 1/3] feat: add pending onboardings endpoint and onboarding status to compliance search - Add GET /support/pending-onboardings endpoint for open company onboardings - Add OnboardingStatus enum (Open/Completed/Rejected) to search results - Add KYC log result field to support DTO --- .../generic/kyc/services/kyc-file.service.ts | 7 +- .../generic/kyc/services/kyc.service.ts | 69 ++- .../generic/support/dto/onboarding-pdf.dto.ts | 37 ++ .../support/dto/user-data-support.dto.ts | 14 + .../generic/support/support.controller.ts | 31 ++ .../generic/support/support.service.ts | 483 +++++++++++++++++- .../user/models/user-data/user-data.entity.ts | 2 +- 7 files changed, 636 insertions(+), 7 deletions(-) create mode 100644 src/subdomains/generic/support/dto/onboarding-pdf.dto.ts 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..10aa7c1038 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'; @@ -29,6 +29,7 @@ import { BankDataService } from '../../user/models/bank-data/bank-data.service'; import { RecommendationService } from '../../user/models/recommendation/recommendation.service'; import { UserDataRelationState } from '../../user/models/user-data-relation/dto/user-data-relation.enum'; import { UserDataRelationService } from '../../user/models/user-data-relation/user-data-relation.service'; +import { OnboardingStatus } from '../../support/dto/user-data-support.dto'; import { AccountType } from '../../user/models/user-data/account-type.enum'; import { KycIdentificationType } from '../../user/models/user-data/kyc-identification-type.enum'; import { UserData } from '../../user/models/user-data/user-data.entity'; @@ -1805,4 +1806,70 @@ 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 getDfxApprovalStatuses(userDataIds: number[]): Promise> { + if (userDataIds.length === 0) return new Map(); + + const steps = await this.kycStepRepo.find({ + where: { + userData: { id: In(userDataIds) }, + name: KycStepName.DFX_APPROVAL, + status: In([ReviewStatus.COMPLETED, ReviewStatus.FAILED]), + }, + relations: ['userData'], + }); + + 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; + } } 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.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.service.ts b/src/subdomains/generic/support/support.service.ts index bf1cec9434..8eb40e1640 100644 --- a/src/subdomains/generic/support/support.service.ts +++ b/src/subdomains/generic/support/support.service.ts @@ -37,6 +37,9 @@ 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 { FileSubType, FileType } from '../kyc/dto/kyc-file.dto'; +import { ContentType } from '../kyc/enums/content-type.enum'; +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,6 +116,7 @@ export class SupportService { private readonly recommendationService: RecommendationService, private readonly ipLogService: IpLogService, private readonly supportIssueService: SupportIssueService, + private readonly kycDocumentService: KycDocumentService, ) {} async generateIpLogPdf(userDataId: number): Promise { @@ -221,6 +229,446 @@ export class SupportService { pdf.y = y + 10; } + async generateTransactionPdf(userDataId: number): Promise { + const transactions = await this.transactionService.getTransactionsByUserDataId(userDataId); + + 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); + + // 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[]): 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 = this.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; + } + + 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'); + + // Load KycFiles and KycSteps + const [kycFiles, kycSteps] = await Promise.all([ + this.kycFileService.getUserDataKycFiles(userDataId), + this.kycService.getStepsByUserData(userDataId), + ]); + + // Generate PDF + const pdfData = await this.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 }; + } + + private async 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 '-'; + } + } + async getUserDataDetails(id: number): Promise { const userData = await this.userDataService.getUserData(id, { wallet: true, bankDatas: true }); if (!userData) throw new NotFoundException(`User not found`); @@ -429,6 +877,7 @@ export class SupportService { return { id: log.id, type: log.type, + result: log.result, comment: log.comment, created: log.created, }; @@ -578,15 +1027,37 @@ 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.kycService.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 getUserDatasByKey(key: string): Promise<{ type: ComplianceSearchType; userDatas: UserData[] }> { @@ -689,7 +1160,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 +1171,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..01ffaf1096 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,7 @@ 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); From edfcb4f5cedd79f119be17dafd77858ea9c90f0d Mon Sep 17 00:00:00 2001 From: Bernd Date: Fri, 27 Mar 2026 16:44:18 +0100 Subject: [PATCH 2/3] fix: format user-data entity --- .../generic/user/models/user-data/user-data.entity.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 01ffaf1096..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', 'amlAccountType', 'complexOrgStructure', 'highRisk']; +export const UserDataComplianceUpdateCols = [ + 'kycStatus', + 'depositLimit', + 'amlAccountType', + 'complexOrgStructure', + 'highRisk', +]; export function KycCompleted(kycStatus?: KycStatus): boolean { return KycCompletedStates.includes(kycStatus); From 2f14a6f940480be3ea27043c5eeb6f6f43980799 Mon Sep 17 00:00:00 2001 From: David May Date: Wed, 1 Apr 2026 13:49:24 +0200 Subject: [PATCH 3/3] chore: code restructuring --- .../generic/kyc/services/kyc.service.ts | 20 +- .../generic/support/support-pdf.service.ts | 528 +++++++++++++++++ .../generic/support/support.module.ts | 3 +- .../generic/support/support.service.ts | 533 +----------------- 4 files changed, 559 insertions(+), 525 deletions(-) create mode 100644 src/subdomains/generic/support/support-pdf.service.ts diff --git a/src/subdomains/generic/kyc/services/kyc.service.ts b/src/subdomains/generic/kyc/services/kyc.service.ts index 10aa7c1038..5c8956678c 100644 --- a/src/subdomains/generic/kyc/services/kyc.service.ts +++ b/src/subdomains/generic/kyc/services/kyc.service.ts @@ -29,7 +29,6 @@ import { BankDataService } from '../../user/models/bank-data/bank-data.service'; import { RecommendationService } from '../../user/models/recommendation/recommendation.service'; import { UserDataRelationState } from '../../user/models/user-data-relation/dto/user-data-relation.enum'; import { UserDataRelationService } from '../../user/models/user-data-relation/user-data-relation.service'; -import { OnboardingStatus } from '../../support/dto/user-data-support.dto'; import { AccountType } from '../../user/models/user-data/account-type.enum'; import { KycIdentificationType } from '../../user/models/user-data/kyc-identification-type.enum'; import { UserData } from '../../user/models/user-data/user-data.entity'; @@ -1847,10 +1846,10 @@ export class KycService { return results; } - async getDfxApprovalStatuses(userDataIds: number[]): Promise> { - if (userDataIds.length === 0) return new Map(); + async getDfxApprovalSteps(userDataIds: number[]): Promise { + if (userDataIds.length === 0) return []; - const steps = await this.kycStepRepo.find({ + return this.kycStepRepo.find({ where: { userData: { id: In(userDataIds) }, name: KycStepName.DFX_APPROVAL, @@ -1858,18 +1857,5 @@ export class KycService { }, relations: ['userData'], }); - - 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; } } 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.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 8eb40e1640..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,8 +35,10 @@ 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'; @@ -117,247 +117,19 @@ export class SupportService { 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 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; + return this.supportPdfService.generateIpLogPdf(userDataId, ipLogs); } async generateTransactionPdf(userDataId: number): Promise { const transactions = await this.transactionService.getTransactionsByUserDataId(userDataId); - - 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); - - // 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[]): 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 = this.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; + return this.supportPdfService.generateTransactionPdf(userDataId, transactions, (tx) => + this.toTransactionSupportInfo(tx), + ); } async generateAndSaveOnboardingPdf( @@ -381,7 +153,7 @@ export class SupportService { ]); // Generate PDF - const pdfData = await this.createOnboardingPdf(userData, kycFiles, kycSteps, dto); + const pdfData = await this.supportPdfService.createOnboardingPdf(userData, kycFiles, kycSteps, dto); // Save as KycFile const fileName = `GwG_Onboarding_${userDataId}_${Date.now()}.pdf`; @@ -399,276 +171,6 @@ export class SupportService { return { pdfData, fileName }; } - private async 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 '-'; - } - } - async getUserDataDetails(id: number): Promise { const userData = await this.userDataService.getUserData(id, { wallet: true, bankDatas: true }); if (!userData) throw new NotFoundException(`User not found`); @@ -1030,7 +532,7 @@ export class SupportService { 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.kycService.getDfxApprovalStatuses(orgUserIds); + const onboardingStatuses = await this.getDfxApprovalStatuses(orgUserIds); return { type: searchResult.type, @@ -1060,6 +562,23 @@ export class SupportService { //*** 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) };