diff --git a/src/subdomains/supporting/payment/services/swiss-qr.service.ts b/src/subdomains/supporting/payment/services/swiss-qr.service.ts index c48fa674b5..c46b7bd483 100644 --- a/src/subdomains/supporting/payment/services/swiss-qr.service.ts +++ b/src/subdomains/supporting/payment/services/swiss-qr.service.ts @@ -3,10 +3,13 @@ import { I18nService } from 'nestjs-i18n'; import PDFDocument from 'pdfkit'; import * as QRCode from 'qrcode'; import { Config } from 'src/config/config'; +import { Asset } from 'src/shared/models/asset/asset.entity'; import { AssetService } from 'src/shared/models/asset/asset.service'; import { LogoSize, PdfBrand, PdfUtil } from 'src/shared/utils/pdf.util'; +import { AmountType, Util } from 'src/shared/utils/util'; import { BankInfoDto } from 'src/subdomains/core/buy-crypto/routes/buy/dto/buy-payment-info.dto'; import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { HistoryEventDto } from 'src/subdomains/supporting/realunit/dto/realunit.dto'; import { PDFColumn, PDFRow, SwissQRBill, Table } from 'swissqrbill/pdf'; import { SwissQRCode } from 'swissqrbill/svg'; import { Creditor, Debtor, Data as QrBillData } from 'swissqrbill/types'; @@ -117,34 +120,105 @@ export class SwissQRService { ); } - async createMultiTxStatement(details: TxStatementDetails[], brand: PdfBrand = PdfBrand.DFX): Promise { - if (details.length === 0) throw new Error('At least one transaction is required'); + async createTxFromBlockchainReceipt( + historyEvent: HistoryEventDto, + userData: UserData, + asset: Asset, + fiatPrice: number, + currency: 'CHF' | 'EUR', + isIncoming: boolean, + brand: PdfBrand = PdfBrand.REALUNIT, + ): Promise { + const debtor = this.getDebtor(userData); + const language = this.getLanguage(userData); + const tokenAmount = Number(historyEvent.transfer.value); + const fiatAmount = Util.roundReadable(tokenAmount * fiatPrice, AmountType.FIAT); + + const tableData: SwissQRBillTableData = { + title: this.translate('invoice.receipt_title', language.toLowerCase(), { + invoiceId: this.shortenTxHash(historyEvent.txHash), + }), + quantity: tokenAmount, + description: { + assetDescription: asset.description ?? asset.name, + assetName: asset.name, + assetBlockchain: asset.blockchain, + }, + fiatAmount, + date: historyEvent.timestamp, + }; + + const billData: QrBillData = { + creditor: this.getDefaultCreditor(brand), + debtor, + currency, + }; + + const transactionType = isIncoming ? TransactionType.BUY : TransactionType.SELL; + return this.generatePdfInvoice( + tableData, + language, + billData, + undefined, + transactionType, + brand, + userData.completeName, + true, + ); + } - const firstDetail = details[0]; - const debtor = this.getDebtor(firstDetail.transaction.userData); - if (!debtor) throw new Error('Debtor is required'); + async createTxFromBlockchainMultiReceipt( + receipts: Array<{ + historyEvent: HistoryEventDto; + fiatPrice: number; + isIncoming: boolean; + }>, + userData: UserData, + asset: Asset, + currency: 'CHF' | 'EUR', + brand: PdfBrand = PdfBrand.REALUNIT, + ): Promise { + if (receipts.length === 0) throw new Error('At least one transaction is required'); - const validatedCurrency = this.validateCurrency(firstDetail.currency); - const language = this.getLanguage(firstDetail.transaction.userData); + const debtor = this.getDebtor(userData); + const language = this.getLanguage(userData); const tableDataWithType: { data: SwissQRBillTableData; type: TransactionType }[] = []; - for (const detail of details) { - const tableData = await this.getTableData( - detail.statementType, - detail.transactionType, - detail.transaction, - validatedCurrency, - ); - tableDataWithType.push({ data: tableData, type: detail.transactionType }); + + for (const { historyEvent, fiatPrice, isIncoming } of receipts) { + const tokenAmount = Number(historyEvent.transfer.value); + const fiatAmount = Util.roundReadable(tokenAmount * fiatPrice, AmountType.FIAT); + + const tableData: SwissQRBillTableData = { + title: this.translate('invoice.receipt_title', language.toLowerCase(), { + invoiceId: this.shortenTxHash(historyEvent.txHash), + }), + quantity: tokenAmount, + description: { + assetDescription: asset.description ?? asset.name, + assetName: asset.name, + assetBlockchain: asset.blockchain, + }, + fiatAmount, + date: historyEvent.timestamp, + }; + + const transactionType = isIncoming ? TransactionType.BUY : TransactionType.SELL; + tableDataWithType.push({ data: tableData, type: transactionType }); } const billData: QrBillData = { creditor: this.getDefaultCreditor(brand), debtor, - currency: validatedCurrency, + currency, }; - return this.generateMultiPdfInvoice(tableDataWithType, language, billData, brand); + return this.generateMultiPdfInvoice(tableDataWithType, language, billData, brand, true); + } + + private shortenTxHash(txHash: string): string { + if (txHash.length <= 16) return txHash; + return `${txHash.slice(0, 8)}...${txHash.slice(-8)}`; } private async generatePdfInvoice( @@ -155,6 +229,7 @@ export class SwissQRService { transactionType: TransactionType, brand: PdfBrand = PdfBrand.DFX, debtorName?: string, + skipTermsAndConditions = false, ): Promise { const { pdf, promise } = this.createPdfWithBase64Promise(); @@ -291,8 +366,6 @@ export class SwissQRService { }, ]; - const termsAndConditions = this.getTermsAndConditions(language); - if (bankInfo) { rows.push({ columns: [ @@ -305,7 +378,10 @@ export class SwissQRService { ], }); } - rows.push({ columns: [termsAndConditions] }); + + if (!skipTermsAndConditions) { + rows.push({ columns: [this.getTermsAndConditions(language)] }); + } const table = new Table({ rows, width: mm2pt(170) }); table.attachTo(pdf); @@ -369,6 +445,7 @@ export class SwissQRService { language: string, billData: QrBillData, brand: PdfBrand = PdfBrand.DFX, + skipTermsAndConditions = false, ): Promise { const { pdf, promise } = this.createPdfWithBase64Promise(); @@ -541,7 +618,9 @@ export class SwissQRService { padding: 5, }); - rows.push({ columns: [this.getTermsAndConditions(language)] }); + if (!skipTermsAndConditions) { + rows.push({ columns: [this.getTermsAndConditions(language)] }); + } const table = new Table({ rows, width: mm2pt(170) }); table.attachTo(pdf); diff --git a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts index aa35f4dfe9..e83c4b8212 100644 --- a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts +++ b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts @@ -1,16 +1,4 @@ -import { - BadRequestException, - Body, - Controller, - Get, - HttpStatus, - Param, - Post, - Put, - Query, - Res, - UseGuards, -} from '@nestjs/common'; +import { Body, Controller, Get, HttpStatus, Param, Post, Put, Query, Res, UseGuards } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { ApiAcceptedResponse, @@ -40,17 +28,18 @@ import { RoleGuard } from 'src/shared/auth/role.guard'; import { UserActiveGuard } from 'src/shared/auth/user-active.guard'; import { UserRole } from 'src/shared/auth/user-role.enum'; import { PdfBrand } from 'src/shared/utils/pdf.util'; +import { Util } from 'src/shared/utils/util'; import { PdfDto } from 'src/subdomains/core/buy-crypto/routes/buy/dto/pdf.dto'; import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; import { BalancePdfService } from '../../balance/services/balance-pdf.service'; -import { TxStatementType } from '../../payment/dto/transaction-helper/tx-statement-details.dto'; import { SwissQRService } from '../../payment/services/swiss-qr.service'; -import { TransactionHelper } from '../../payment/services/transaction-helper'; +import { PriceCurrency, PricingService } from '../../pricing/services/pricing.service'; import { RealUnitAdminQueryDto, RealUnitQuoteDto, RealUnitTransactionDto } from '../dto/realunit-admin.dto'; import { RealUnitBalancePdfDto, RealUnitMultiReceiptPdfDto, RealUnitSingleReceiptPdfDto, + ReceiptCurrency, } from '../dto/realunit-pdf.dto'; import { RealUnitEmailRegistrationDto, @@ -85,8 +74,8 @@ export class RealUnitController { private readonly realunitService: RealUnitService, private readonly balancePdfService: BalancePdfService, private readonly userService: UserService, - private readonly transactionHelper: TransactionHelper, private readonly swissQrService: SwissQRService, + private readonly pricingService: PricingService, ) {} @Get('account/:address') @@ -156,7 +145,7 @@ export class RealUnitController { return this.realunitService.getRealUnitInfo(); } - // --- Balance PDF Endpoint --- + // --- PDF Endpoints --- @Post('balance/pdf') @ApiBearerAuth() @@ -177,55 +166,75 @@ export class RealUnitController { return { pdfData }; } - // --- Receipt PDF Endpoint --- - @Post('transactions/receipt/single') @ApiBearerAuth() @UseGuards(AuthGuard(), RoleGuard(UserRole.USER), UserActiveGuard()) @ApiOperation({ - description: 'Generates a PDF receipt for a completed RealUnit transaction', + summary: 'Generate receipt from blockchain transaction', + description: 'Generates a PDF receipt for any RealUnit transaction found in blockchain history', }) - @ApiParam({ name: 'id', description: 'Transaction ID' }) @ApiOkResponse({ type: PdfDto, description: 'Receipt PDF (base64 encoded)' }) - @ApiBadRequestResponse({ description: 'Transaction not found or not a RealUnit transaction' }) - async generateReceipt(@GetJwt() jwt: JwtPayload, @Body() dto: RealUnitSingleReceiptPdfDto): Promise { + @ApiBadRequestResponse({ description: 'Transaction not found or not a transfer' }) + async generateHistoryReceipt(@GetJwt() jwt: JwtPayload, @Body() dto: RealUnitSingleReceiptPdfDto): Promise { const user = await this.userService.getUser(jwt.user, { userData: true }); - - const txStatementDetails = await this.transactionHelper.getTxStatementDetails( - user.userData.id, - dto.transactionId, - TxStatementType.RECEIPT, + const currency = dto.currency ?? ReceiptCurrency.CHF; + const historyEvent = await this.realunitService.getHistoryEventByTxHash(jwt.address, dto.txHash); + const realuAsset = await this.realunitService.getRealuAsset(); + const price = await this.pricingService.getPriceAt(realuAsset, PriceCurrency[currency], historyEvent.timestamp); + const isIncoming = Util.equalsIgnoreCase(historyEvent.transfer.to, jwt.address); + + const pdfData = await this.swissQrService.createTxFromBlockchainReceipt( + historyEvent, + user.userData, + realuAsset, + price.convert(1), + currency, + isIncoming, + PdfBrand.REALUNIT, ); - if (!Config.invoice.currencies.includes(txStatementDetails.currency)) { - throw new BadRequestException('PDF receipt is only available for CHF and EUR transactions'); - } - - return { pdfData: await this.swissQrService.createTxStatement(txStatementDetails, PdfBrand.REALUNIT) }; + return { pdfData }; } @Post('transactions/receipt/multi') @ApiBearerAuth() @UseGuards(AuthGuard(), RoleGuard(UserRole.USER), UserActiveGuard()) @ApiOperation({ - description: 'Generates a single PDF receipt for multiple completed RealUnit transactions', + summary: 'Generate multi-receipt from blockchain transactions', + description: 'Generates a single PDF receipt for multiple RealUnit transactions found in blockchain history', }) @ApiOkResponse({ type: PdfDto, description: 'Receipt PDF (base64 encoded)' }) - @ApiBadRequestResponse({ description: 'Transaction not found, currency mismatch, or not a RealUnit transaction' }) - async generateMultiReceipt(@GetJwt() jwt: JwtPayload, @Body() dto: RealUnitMultiReceiptPdfDto): Promise { + @ApiBadRequestResponse({ description: 'Transaction not found or not a transfer' }) + async generateHistoryMultiReceipt( + @GetJwt() jwt: JwtPayload, + @Body() dto: RealUnitMultiReceiptPdfDto, + ): Promise { const user = await this.userService.getUser(jwt.user, { userData: true }); - - const txStatementDetails = await this.transactionHelper.getTxStatementDetailsMulti( - user.userData.id, - dto.transactionIds, - TxStatementType.RECEIPT, + const currency = dto.currency ?? ReceiptCurrency.CHF; + const historyEvents = await this.realunitService.getHistoryEventsByTxHashes(jwt.address, dto.txHashes); + const realuAsset = await this.realunitService.getRealuAsset(); + + const receipts = await Promise.all( + historyEvents.map(async (event) => { + const price = await this.pricingService.getPriceAt(realuAsset, PriceCurrency[currency], event.timestamp); + const isIncoming = Util.equalsIgnoreCase(event.transfer.to, jwt.address); + return { + historyEvent: event, + fiatPrice: price.convert(1), + isIncoming, + }; + }), ); - if (txStatementDetails.length > 0 && !Config.invoice.currencies.includes(txStatementDetails[0].currency)) { - throw new BadRequestException('PDF receipt is only available for CHF and EUR transactions'); - } + const pdfData = await this.swissQrService.createTxFromBlockchainMultiReceipt( + receipts, + user.userData, + realuAsset, + currency, + PdfBrand.REALUNIT, + ); - return { pdfData: await this.swissQrService.createMultiTxStatement(txStatementDetails, PdfBrand.REALUNIT) }; + return { pdfData }; } // --- Brokerbot Endpoints --- diff --git a/src/subdomains/supporting/realunit/dto/realunit-pdf.dto.ts b/src/subdomains/supporting/realunit/dto/realunit-pdf.dto.ts index eb6ea65f47..37e231e1e4 100644 --- a/src/subdomains/supporting/realunit/dto/realunit-pdf.dto.ts +++ b/src/subdomains/supporting/realunit/dto/realunit-pdf.dto.ts @@ -7,12 +7,17 @@ import { IsEnum, IsEthereumAddress, IsNotEmpty, - IsNumber, IsOptional, + IsString, } from 'class-validator'; import { PdfLanguage } from 'src/subdomains/supporting/balance/dto/input/get-balance-pdf.dto'; import { PriceCurrency } from 'src/subdomains/supporting/pricing/services/pricing.service'; +export enum ReceiptCurrency { + CHF = 'CHF', + EUR = 'EUR', +} + export class RealUnitBalancePdfDto { @ApiProperty({ description: 'Blockchain address (EVM)' }) @IsNotEmpty() @@ -36,16 +41,34 @@ export class RealUnitBalancePdfDto { } export class RealUnitSingleReceiptPdfDto { - @ApiProperty({ type: Number, description: 'Transaction ID' }) - @IsNumber() - @Type(() => Number) - transactionId: number; + @ApiProperty({ description: 'Transaction hash from blockchain history' }) + @IsNotEmpty() + @IsString() + txHash: string; + + @ApiPropertyOptional({ + description: 'Currency for fiat values on receipt', + enum: ReceiptCurrency, + default: ReceiptCurrency.CHF, + }) + @IsOptional() + @IsEnum(ReceiptCurrency) + currency?: ReceiptCurrency = ReceiptCurrency.CHF; } export class RealUnitMultiReceiptPdfDto { - @ApiProperty({ type: [Number], description: 'Array of transaction IDs to include in the receipt' }) + @ApiProperty({ type: [String], description: 'Array of transaction hashes from blockchain history' }) @IsArray() - @IsNumber({}, { each: true }) + @IsString({ each: true }) @ArrayMinSize(1) - transactionIds: number[]; + txHashes: string[]; + + @ApiPropertyOptional({ + description: 'Currency for fiat values on receipt', + enum: ReceiptCurrency, + default: ReceiptCurrency.CHF, + }) + @IsOptional() + @IsEnum(ReceiptCurrency) + currency?: ReceiptCurrency = ReceiptCurrency.CHF; } diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts index 5ceeb7c73e..e8e8b2d2e7 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -53,6 +53,7 @@ import { PriceCurrency, PriceValidity, PricingService } from '../pricing/service import { AccountHistoryClientResponse, AccountSummaryClientResponse, + HistoryEventType, HoldersClientResponse, TokenInfoClientResponse, } from './dto/client.dto'; @@ -74,6 +75,7 @@ import { AccountHistoryDto, AccountSummaryDto, HistoricalPriceDto, + HistoryEventDto, HoldersDto, RealUnitBuyDto, RealUnitPaymentInfoDto, @@ -147,7 +149,52 @@ export class RealUnitService { return RealUnitDtoMapper.toAccountHistoryDto(clientResponse); } - private async getRealuAsset(): Promise { + async getHistoryEventByTxHash(address: string, txHash: string): Promise { + const normalizedTxHash = txHash.toLowerCase(); + let cursor: string | undefined; + + while (true) { + const history = await this.getAccountHistory(address, 100, cursor); + + const event = history.history.find( + (e) => e.txHash.toLowerCase() === normalizedTxHash && e.eventType === HistoryEventType.TRANSFER, + ); + + if (event) return event; + + if (!history.pageInfo.hasNextPage) break; + cursor = history.pageInfo.endCursor; + } + + throw new NotFoundException('Transaction not found in account history'); + } + + async getHistoryEventsByTxHashes(address: string, txHashes: string[]): Promise { + const normalizedHashes = new Set(txHashes.map((h) => h.toLowerCase())); + const foundEvents: HistoryEventDto[] = []; + let cursor: string | undefined; + + while (foundEvents.length < txHashes.length) { + const history = await this.getAccountHistory(address, 100, cursor); + + for (const event of history.history) { + if ( + normalizedHashes.has(event.txHash.toLowerCase()) && + event.eventType === HistoryEventType.TRANSFER && + !foundEvents.some((e) => e.txHash.toLowerCase() === event.txHash.toLowerCase()) + ) { + foundEvents.push(event); + } + } + + if (!history.pageInfo.hasNextPage) break; + cursor = history.pageInfo.endCursor; + } + + return foundEvents; + } + + async getRealuAsset(): Promise { return this.assetService.getAssetByQuery({ name: this.tokenName, blockchain: this.tokenBlockchain,