From 7fbdebe96bc8c6b2fde824b55422ee6889489173 Mon Sep 17 00:00:00 2001 From: Lam Nguyen <32935491+xlamn@users.noreply.github.com> Date: Tue, 27 Jan 2026 13:40:16 +0100 Subject: [PATCH 1/2] feat: Realunit receipt for multiple transactions (#3027) * feat: add receipt endpoint for multiple transactions. * refactor: make receipt endpoints consistent. * chore: code duplication. --- src/shared/i18n/de/invoice.json | 6 + src/shared/i18n/en/invoice.json | 6 + src/shared/i18n/fr/invoice.json | 6 + src/shared/i18n/it/invoice.json | 6 + .../payment/services/swiss-qr.service.ts | 654 ++++++++++++------ .../payment/services/transaction-helper.ts | 16 +- .../controllers/realunit.controller.ts | 36 +- ...balance-pdf.dto.ts => realunit-pdf.dto.ts} | 26 +- 8 files changed, 535 insertions(+), 221 deletions(-) rename src/subdomains/supporting/realunit/dto/{realunit-balance-pdf.dto.ts => realunit-pdf.dto.ts} (62%) diff --git a/src/shared/i18n/de/invoice.json b/src/shared/i18n/de/invoice.json index 32a9d51b4f..4757c72137 100644 --- a/src/shared/i18n/de/invoice.json +++ b/src/shared/i18n/de/invoice.json @@ -2,10 +2,16 @@ "title": "Rechnung Nr. {invoiceId}", "credit_title": "Gutschrift Nr. {invoiceId}", "receipt_title": "Transaktionsbestätigung Nr. {invoiceId}", + "multi_receipt_title": "Transaktionsbestätigung", + "section": { + "buy": "Käufe", + "sell": "Verkäufe" + }, "table": { "headers": { "quantity": "Menge", "description": "Beschreibung", + "date": "Datum", "total": "Gesamt" }, "position_row": { diff --git a/src/shared/i18n/en/invoice.json b/src/shared/i18n/en/invoice.json index a144b88e41..3604db25e2 100644 --- a/src/shared/i18n/en/invoice.json +++ b/src/shared/i18n/en/invoice.json @@ -2,10 +2,16 @@ "title": "Invoice No. {invoiceId}", "credit_title": "Credit No. {invoiceId}", "receipt_title": "Transaction Confirmation No. {invoiceId}", + "multi_receipt_title": "Transaction Confirmation", + "section": { + "buy": "Purchases", + "sell": "Sales" + }, "table": { "headers": { "quantity": "Quantity", "description": "Description", + "date": "Date", "total": "Total" }, "position_row": { diff --git a/src/shared/i18n/fr/invoice.json b/src/shared/i18n/fr/invoice.json index 2e4b25bf15..52401ed3a4 100644 --- a/src/shared/i18n/fr/invoice.json +++ b/src/shared/i18n/fr/invoice.json @@ -2,10 +2,16 @@ "title": "Facture No. {invoiceId}", "credit_title": "Crédit No. {invoiceId}", "receipt_title": "Confirmation de Transaction No. {invoiceId}", + "multi_receipt_title": "Confirmation de Transaction", + "section": { + "buy": "Achats", + "sell": "Ventes" + }, "table": { "headers": { "quantity": "Quantité", "description": "Description", + "date": "Date", "total": "Total" }, "position_row": { diff --git a/src/shared/i18n/it/invoice.json b/src/shared/i18n/it/invoice.json index f757eb6146..6e46660b46 100644 --- a/src/shared/i18n/it/invoice.json +++ b/src/shared/i18n/it/invoice.json @@ -2,10 +2,16 @@ "title": "Fattura No. {invoiceId}", "credit_title": "Credito No. {invoiceId}", "receipt_title": "Conferma di Transazione No. {invoiceId}", + "multi_receipt_title": "Conferma di Transazione", + "section": { + "buy": "Acquisti", + "sell": "Vendite" + }, "table": { "headers": { "quantity": "Quantità", "description": "Descrizione", + "date": "Data", "total": "Totale" }, "position_row": { diff --git a/src/subdomains/supporting/payment/services/swiss-qr.service.ts b/src/subdomains/supporting/payment/services/swiss-qr.service.ts index 9c7ba32e00..9d166429d1 100644 --- a/src/subdomains/supporting/payment/services/swiss-qr.service.ts +++ b/src/subdomains/supporting/payment/services/swiss-qr.service.ts @@ -92,22 +92,15 @@ export class SwissQRService { brand: PdfBrand = PdfBrand.DFX, ): Promise { const debtor = this.getDebtor(transaction.userData); + const validatedCurrency = this.validateCurrency(currency); + const language = this.getLanguage(transaction.userData); + const tableData = await this.getTableData(statementType, transactionType, transaction, validatedCurrency, request); - currency = Config.invoice.currencies.includes(currency) ? currency : Config.invoice.defaultCurrency; - if (!this.isSupportedInvoiceCurrency(currency)) { - throw new Error('PDF invoice is only available for CHF and EUR transactions'); - } - - const userLanguage = transaction.userData.language.symbol.toUpperCase(); - const language = this.isSupportedInvoiceLanguage(userLanguage) ? userLanguage : 'EN'; - const tableData = await this.getTableData(statementType, transactionType, transaction, currency, request); - - const defaultCreditor = brand === PdfBrand.REALUNIT ? this.realunitCreditor() : this.dfxCreditor(); const amount = request?.amount ?? transaction.buyCrypto?.inputAmount; const billData: QrBillData = { - creditor: (bankInfo && this.getCreditor(bankInfo)) || (defaultCreditor as unknown as Creditor), + creditor: (bankInfo && this.getCreditor(bankInfo)) || this.getDefaultCreditor(brand), debtor, - currency, + currency: validatedCurrency, amount: bankInfo && amount, message: reference, }; @@ -123,6 +116,36 @@ 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'); + + const firstDetail = details[0]; + const debtor = this.getDebtor(firstDetail.transaction.userData); + if (!debtor) throw new Error('Debtor is required'); + + const validatedCurrency = this.validateCurrency(firstDetail.currency); + const language = this.getLanguage(firstDetail.transaction.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 }); + } + + const billData: QrBillData = { + creditor: this.getDefaultCreditor(brand), + debtor, + currency: validatedCurrency, + }; + + return this.generateMultiPdfInvoice(tableDataWithType, language, billData, brand); + } + private generatePdfInvoice( tableData: SwissQRBillTableData, language: string, @@ -132,232 +155,433 @@ export class SwissQRService { brand: PdfBrand = PdfBrand.DFX, debtorName?: string, ): Promise { - return new Promise((resolve, reject) => { - try { - const pdf = new PDFDocument({ size: 'A4' }); - - // Store PDF as base64 string - const base64 = []; - pdf.on('data', (data) => { - base64.push(data); - }); - pdf.on('end', () => { - const base64PDF = Buffer.concat(base64).toString('base64'); - resolve(base64PDF); - }); + const { pdf, promise } = this.createPdfWithBase64Promise(); + + PdfUtil.drawLogo(pdf, brand, LogoSize.LARGE); + this.drawSenderAddress(pdf, brand); + this.drawDebtorAddress(pdf, billData.debtor, debtorName); + this.drawTitle(pdf, tableData.title); + + // Date + pdf.fontSize(11); + pdf.font('Helvetica'); + pdf.text(`Zug ${tableData.date.getDate()}.${tableData.date.getMonth() + 1}.${tableData.date.getFullYear()}`, { + align: 'right', + width: mm2pt(170), + }); - // Logo - PdfUtil.drawLogo(pdf, brand, LogoSize.LARGE); - - // Sender address - const sender = brand === PdfBrand.REALUNIT ? this.realunitCreditor() : this.dfxCreditor(); - pdf.fontSize(12); - pdf.fillColor('black'); - pdf.font('Helvetica'); - pdf.text( - `${sender.name}\n${sender.address} ${sender.buildingNumber}\n${sender.zip} ${sender.city}`, - mm2pt(20), - mm2pt(35), + // Table + const rows: PDFRow[] = [ + { + backgroundColor: '#4A4D51', + columns: [ { - align: 'left', - height: mm2pt(50), - width: mm2pt(100), + text: this.translate('invoice.table.headers.quantity', language) + (includeQrBill ? ' *' : ''), + width: mm2pt(40), + }, + { + text: this.translate('invoice.table.headers.description', language), + }, + { + text: this.translate('invoice.table.headers.total', language), + width: mm2pt(30), + }, + ], + fontName: 'Helvetica-Bold', + height: 20, + padding: 5, + textColor: '#fff', + verticalAlign: 'center', + }, + { + columns: [ + { + text: `${tableData.quantity}`, + width: mm2pt(40), + }, + { + text: this.translate( + `invoice.table.position_row.${transactionType.toLowerCase()}_description`, + language, + tableData.description, + ), + }, + { + text: `${billData.currency} ${tableData.fiatAmount.toFixed(2)}`, + width: mm2pt(30), + }, + ], + padding: 5, + }, + { + columns: [ + { + text: '', + width: mm2pt(40), }, - ); - - // Debtor address - const displayName = billData.debtor?.name ?? debtorName; - if (displayName) { - pdf.fontSize(12); - pdf.font('Helvetica'); - const addressLine = billData.debtor - ? [billData.debtor.address, billData.debtor.buildingNumber].filter(Boolean).join(' ') - : ''; - const cityLine = billData.debtor ? [billData.debtor.zip, billData.debtor.city].filter(Boolean).join(' ') : ''; - pdf.text([displayName, addressLine, cityLine].filter(Boolean).join('\n'), mm2pt(130), mm2pt(60), { - align: 'left', - height: mm2pt(50), - width: mm2pt(70), - }); - } - - // Title - pdf.fontSize(14); - pdf.font('Helvetica-Bold'); - pdf.text(tableData.title, mm2pt(20), mm2pt(100), { - align: 'left', - width: mm2pt(170), - }); - - // Date - pdf.fontSize(11); - pdf.font('Helvetica'); - pdf.text(`Zug ${tableData.date.getDate()}.${tableData.date.getMonth() + 1}.${tableData.date.getFullYear()}`, { - align: 'right', - width: mm2pt(170), - }); - - // Table - const rows: PDFRow[] = [ { - backgroundColor: '#4A4D51', - columns: [ - { - text: this.translate('invoice.table.headers.quantity', language) + (includeQrBill ? ' *' : ''), - width: mm2pt(40), - }, - { - text: this.translate('invoice.table.headers.description', language), - }, - { - text: this.translate('invoice.table.headers.total', language), - width: mm2pt(30), - }, - ], fontName: 'Helvetica-Bold', - height: 20, - padding: 5, - textColor: '#fff', - verticalAlign: 'center', + text: this.translate('invoice.table.total_row.total_label', language), }, { - columns: [ - { - text: `${tableData.quantity}`, - width: mm2pt(40), - }, - { - text: this.translate( - `invoice.table.position_row.${transactionType.toLowerCase()}_description`, - language, - tableData.description, - ), - }, - { - text: `${billData.currency} ${tableData.fiatAmount.toFixed(2)}`, - width: mm2pt(30), - }, - ], - padding: 5, + fontName: 'Helvetica-Bold', + text: `${billData.currency} ${tableData.fiatAmount.toFixed(2)}`, + width: mm2pt(30), }, + ], + height: 40, + padding: 5, + }, + { + columns: [ { - columns: [ - { - text: '', - width: mm2pt(40), - }, - { - fontName: 'Helvetica-Bold', - text: this.translate('invoice.table.total_row.total_label', language), - }, - { - fontName: 'Helvetica-Bold', - text: `${billData.currency} ${tableData.fiatAmount.toFixed(2)}`, - width: mm2pt(30), - }, - ], - height: 40, - padding: 5, + text: '', + width: mm2pt(40), }, { - columns: [ - { - text: '', - width: mm2pt(40), - }, - { - text: this.translate('invoice.table.vat_row.vat_label', language), - }, - { - text: '0%', - width: mm2pt(30), - }, - ], - padding: 5, + text: this.translate('invoice.table.vat_row.vat_label', language), }, { - columns: [ - { - text: '', - width: mm2pt(40), - }, - { - text: this.translate('invoice.table.vat_row.vat_amount_label', language), - }, - { - text: `${billData.currency} 0.00`, - width: mm2pt(30), - }, - ], - padding: 5, + text: '0%', + width: mm2pt(30), }, + ], + padding: 5, + }, + { + columns: [ { - columns: [ - { - text: '', - width: mm2pt(40), - }, - { - fontName: 'Helvetica-Bold', - text: this.translate( - transactionType === TransactionType.REFERRAL - ? 'invoice.table.credit_total_row.credit_total_label' - : 'invoice.table.invoice_total_row.invoice_total_label', - language, - ), - }, - { - fontName: 'Helvetica-Bold', - text: `${billData.currency} ${tableData.fiatAmount.toFixed(2)}`, - width: mm2pt(30), - }, - ], - height: 40, - padding: 5, + text: '', + width: mm2pt(40), }, - ]; - - // T&Cs - const termsAndConditions: PDFColumn = { - text: this.translate('invoice.terms', language), - textOptions: { lineGap: 2 }, - fontSize: 10, - width: mm2pt(170), - padding: [5, 0, 5, 0], - }; + { + text: this.translate('invoice.table.vat_row.vat_amount_label', language), + }, + { + text: `${billData.currency} 0.00`, + width: mm2pt(30), + }, + ], + padding: 5, + }, + { + columns: [ + { + text: '', + width: mm2pt(40), + }, + { + fontName: 'Helvetica-Bold', + text: this.translate( + transactionType === TransactionType.REFERRAL + ? 'invoice.table.credit_total_row.credit_total_label' + : 'invoice.table.invoice_total_row.invoice_total_label', + language, + ), + }, + { + fontName: 'Helvetica-Bold', + text: `${billData.currency} ${tableData.fiatAmount.toFixed(2)}`, + width: mm2pt(30), + }, + ], + height: 40, + padding: 5, + }, + ]; - // QR-Bill - let qrBill: SwissQRBill = null; - if (includeQrBill) { - rows.push({ - columns: [ - { - text: this.translate('invoice.info', language), - textOptions: { oblique: true, lineGap: 2 }, - fontSize: 10, - width: mm2pt(170), - }, - ], - }); - - rows.push({ columns: [termsAndConditions] }); - qrBill = new SwissQRBill(billData, { language: language as SupportedInvoiceLanguage }); - } else { - rows.push({ columns: [termsAndConditions] }); - } + const termsAndConditions = this.getTermsAndConditions(language); + + // QR-Bill + let qrBill: SwissQRBill = null; + if (includeQrBill) { + rows.push({ + columns: [ + { + text: this.translate('invoice.info', language), + textOptions: { oblique: true, lineGap: 2 }, + fontSize: 10, + width: mm2pt(170), + }, + ], + }); + + rows.push({ columns: [termsAndConditions] }); + qrBill = new SwissQRBill(billData, { language: language as SupportedInvoiceLanguage }); + } else { + rows.push({ columns: [termsAndConditions] }); + } + + const table = new Table({ rows, width: mm2pt(170) }); + table.attachTo(pdf); + qrBill?.attachTo(pdf); + + pdf.end(); + + return promise; + } + + private generateMultiPdfInvoice( + tableDataWithType: { data: SwissQRBillTableData; type: TransactionType }[], + language: string, + billData: QrBillData, + brand: PdfBrand = PdfBrand.DFX, + ): Promise { + const { pdf, promise } = this.createPdfWithBase64Promise(); + + PdfUtil.drawLogo(pdf, brand, LogoSize.LARGE); + this.drawSenderAddress(pdf, brand); + this.drawDebtorAddress(pdf, billData.debtor); + this.drawTitle(pdf, this.translate('invoice.multi_receipt_title', language)); + + const buyTransactions = tableDataWithType.filter((t) => t.type === TransactionType.BUY); + const sellTransactions = tableDataWithType.filter((t) => t.type === TransactionType.SELL); + const buyTotal = buyTransactions.reduce((sum, t) => sum + t.data.fiatAmount, 0); + const sellTotal = sellTransactions.reduce((sum, t) => sum + t.data.fiatAmount, 0); + const grandTotal = sellTotal - buyTotal; + + const rows: PDFRow[] = []; - const table = new Table({ rows, width: mm2pt(170) }); - table.attachTo(pdf); - qrBill?.attachTo(pdf); + if (buyTransactions.length > 0) { + rows.push({ + columns: [ + { + text: this.translate('invoice.section.buy', language), + fontName: 'Helvetica-Bold', + fontSize: 12, + }, + ], + height: 30, + padding: [15, 5, 5, 5], + }); + + rows.push({ + backgroundColor: '#4A4D51', + columns: [ + { text: this.translate('invoice.table.headers.quantity', language), width: mm2pt(30) }, + { text: this.translate('invoice.table.headers.description', language) }, + { text: this.translate('invoice.table.headers.date', language), width: mm2pt(25) }, + { text: this.translate('invoice.table.headers.total', language), width: mm2pt(30) }, + ], + fontName: 'Helvetica-Bold', + height: 20, + padding: 5, + textColor: '#fff', + verticalAlign: 'center', + }); + + for (const { data: tableData } of buyTransactions) { + const txDate = tableData.date; + const formattedDate = `${txDate.getDate()}.${txDate.getMonth() + 1}.${txDate.getFullYear()}`; + rows.push({ + columns: [ + { text: `${tableData.quantity}`, width: mm2pt(30) }, + { text: this.translate('invoice.table.position_row.buy_description', language, tableData.description) }, + { text: formattedDate, width: mm2pt(25) }, + { text: `${billData.currency} ${tableData.fiatAmount.toFixed(2)}`, width: mm2pt(30) }, + ], + padding: 5, + }); + } + + rows.push({ + columns: [ + { text: '', width: mm2pt(30) }, + { + text: this.translate('invoice.table.total_row.total_label', language), + fontName: 'Helvetica-Bold', + }, + { text: '', width: mm2pt(25) }, + { text: `${billData.currency} ${buyTotal.toFixed(2)}`, width: mm2pt(30), fontName: 'Helvetica-Bold' }, + ], + height: 25, + padding: 5, + }); + } - pdf.end(); - } catch (error) { - reject(error); + if (sellTransactions.length > 0) { + rows.push({ + columns: [ + { + text: this.translate('invoice.section.sell', language), + fontName: 'Helvetica-Bold', + fontSize: 12, + }, + ], + height: 30, + padding: [15, 5, 5, 5], + }); + + rows.push({ + backgroundColor: '#4A4D51', + columns: [ + { text: this.translate('invoice.table.headers.quantity', language), width: mm2pt(30) }, + { text: this.translate('invoice.table.headers.description', language) }, + { text: this.translate('invoice.table.headers.date', language), width: mm2pt(25) }, + { text: this.translate('invoice.table.headers.total', language), width: mm2pt(30) }, + ], + fontName: 'Helvetica-Bold', + height: 20, + padding: 5, + textColor: '#fff', + verticalAlign: 'center', + }); + + for (const { data: tableData } of sellTransactions) { + const txDate = tableData.date; + const formattedDate = `${txDate.getDate()}.${txDate.getMonth() + 1}.${txDate.getFullYear()}`; + rows.push({ + columns: [ + { text: `${tableData.quantity}`, width: mm2pt(30) }, + { + text: this.translate('invoice.table.position_row.sell_description', language, tableData.description), + }, + { text: formattedDate, width: mm2pt(25) }, + { text: `${billData.currency} ${tableData.fiatAmount.toFixed(2)}`, width: mm2pt(30) }, + ], + padding: 5, + }); } + + // Sell subtotal + rows.push({ + columns: [ + { text: '', width: mm2pt(30) }, + { + text: this.translate('invoice.table.total_row.total_label', language), + fontName: 'Helvetica-Bold', + }, + { text: '', width: mm2pt(25) }, + { text: `${billData.currency} ${sellTotal.toFixed(2)}`, width: mm2pt(30), fontName: 'Helvetica-Bold' }, + ], + height: 25, + padding: 5, + }); + } + + rows.push({ + columns: [{ text: '' }], + height: 10, + }); + + rows.push({ + columns: [ + { text: '', width: mm2pt(30) }, + { text: this.translate('invoice.table.vat_row.vat_label', language) }, + { text: '', width: mm2pt(25) }, + { text: '0%', width: mm2pt(30) }, + ], + padding: 5, + }); + + rows.push({ + columns: [ + { text: '', width: mm2pt(30) }, + { text: this.translate('invoice.table.vat_row.vat_amount_label', language) }, + { text: '', width: mm2pt(25) }, + { text: `${billData.currency} 0.00`, width: mm2pt(30) }, + ], + padding: 5, }); + + rows.push({ + columns: [ + { text: '', width: mm2pt(30) }, + { + fontName: 'Helvetica-Bold', + text: this.translate('invoice.table.invoice_total_row.invoice_total_label', language), + }, + { text: '', width: mm2pt(25) }, + { fontName: 'Helvetica-Bold', text: `${billData.currency} ${grandTotal.toFixed(2)}`, width: mm2pt(30) }, + ], + height: 40, + padding: 5, + }); + + rows.push({ columns: [this.getTermsAndConditions(language)] }); + + const table = new Table({ rows, width: mm2pt(170) }); + table.attachTo(pdf); + + pdf.end(); + + return promise; + } + + private createPdfWithBase64Promise(): { pdf: typeof PDFDocument.prototype; promise: Promise } { + const pdf = new PDFDocument({ size: 'A4' }); + const base64: Buffer[] = []; + + const promise = new Promise((resolve, reject) => { + pdf.on('data', (data: Buffer) => base64.push(data)); + pdf.on('end', () => resolve(Buffer.concat(base64).toString('base64'))); + pdf.on('error', reject); + }); + + return { pdf, promise }; + } + + private drawSenderAddress(pdf: typeof PDFDocument.prototype, brand: PdfBrand): void { + const sender = this.getDefaultCreditor(brand); + pdf.fontSize(12); + pdf.fillColor('black'); + pdf.font('Helvetica'); + pdf.text( + `${sender.name}\n${sender.address} ${sender.buildingNumber}\n${sender.zip} ${sender.city}`, + mm2pt(20), + mm2pt(35), + { align: 'left', height: mm2pt(50), width: mm2pt(100) }, + ); + } + + private drawDebtorAddress(pdf: typeof PDFDocument.prototype, debtor?: Debtor, fallbackName?: string): void { + const displayName = debtor?.name ?? fallbackName; + if (!displayName) return; + + pdf.fontSize(12); + pdf.font('Helvetica'); + const addressLine = debtor ? [debtor.address, debtor.buildingNumber].filter(Boolean).join(' ') : ''; + const cityLine = debtor ? [debtor.zip, debtor.city].filter(Boolean).join(' ') : ''; + pdf.text([displayName, addressLine, cityLine].filter(Boolean).join('\n'), mm2pt(130), mm2pt(60), { + align: 'left', + height: mm2pt(50), + width: mm2pt(70), + }); + } + + private drawTitle(pdf: typeof PDFDocument.prototype, title: string): void { + pdf.fontSize(14); + pdf.font('Helvetica-Bold'); + pdf.text(title, mm2pt(20), mm2pt(100), { align: 'left', width: mm2pt(170) }); + } + + private getTermsAndConditions(language: string): PDFColumn { + return { + text: this.translate('invoice.terms', language), + textOptions: { lineGap: 2 }, + fontSize: 10, + width: mm2pt(170), + padding: [5, 0, 5, 0], + }; + } + + private validateCurrency(currency: string): SupportedSwissQRBillCurrency { + currency = Config.invoice.currencies.includes(currency) ? currency : Config.invoice.defaultCurrency; + if (!this.isSupportedInvoiceCurrency(currency)) { + throw new Error('PDF invoice is only available for CHF and EUR transactions'); + } + return currency; + } + + private getLanguage(userData: UserData): SupportedInvoiceLanguage { + const userLanguage = userData.language.symbol.toUpperCase(); + return this.isSupportedInvoiceLanguage(userLanguage) ? userLanguage : SupportedInvoiceLanguage.EN; + } + + private getDefaultCreditor(brand: PdfBrand): Creditor { + return (brand === PdfBrand.REALUNIT ? this.realunitCreditor() : this.dfxCreditor()) as unknown as Creditor; } - // --- HELPER METHODS --- // private dfxCreditor(): Creditor { const dfxAddress = Config.bank.dfxAddress; return { diff --git a/src/subdomains/supporting/payment/services/transaction-helper.ts b/src/subdomains/supporting/payment/services/transaction-helper.ts index 04273b6d90..d7efd5964c 100644 --- a/src/subdomains/supporting/payment/services/transaction-helper.ts +++ b/src/subdomains/supporting/payment/services/transaction-helper.ts @@ -46,8 +46,8 @@ import { TxMinSpec, TxSpec } from '../dto/transaction-helper/tx-spec.dto'; import { TxStatementDetails, TxStatementType } from '../dto/transaction-helper/tx-statement-details.dto'; import { TransactionType } from '../dto/transaction.dto'; import { TransactionDirection, TransactionSpecification } from '../entities/transaction-specification.entity'; -import { TransactionSpecificationRepository } from '../repositories/transaction-specification.repository'; import { Transaction } from '../entities/transaction.entity'; +import { TransactionSpecificationRepository } from '../repositories/transaction-specification.repository'; import { TransactionService } from './transaction.service'; @Injectable() @@ -583,6 +583,20 @@ export class TransactionHelper implements OnModuleInit { throw new BadRequestException('Transaction type not supported for invoice generation'); } + async getTxStatementDetailsMulti( + userDataId: number, + txIds: number[], + statementType: TxStatementType, + ): Promise { + const details: TxStatementDetails[] = []; + for (const txId of txIds) { + const txDetails = await this.getTxStatementDetails(userDataId, txId, statementType); + + details.push(txDetails); + } + return details; + } + private async getTxStatementDetailsFromRequest( transaction: Transaction, statementType: TxStatementType, diff --git a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts index 11b7df4b03..91c73adf5e 100644 --- a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts +++ b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts @@ -44,7 +44,11 @@ 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 { RealUnitBalancePdfDto } from '../dto/realunit-balance-pdf.dto'; +import { + RealUnitBalancePdfDto, + RealUnitMultiReceiptPdfDto, + RealUnitSingleReceiptPdfDto, +} from '../dto/realunit-pdf.dto'; import { RealUnitRegistrationDto, RealUnitRegistrationResponseDto, @@ -167,7 +171,7 @@ export class RealUnitController { // --- Receipt PDF Endpoint --- - @Put('transaction/:id/receipt') + @Post('transactions/receipt/single') @ApiBearerAuth() @UseGuards(AuthGuard(), RoleGuard(UserRole.USER), UserActiveGuard()) @ApiOperation({ @@ -176,12 +180,12 @@ export class RealUnitController { @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, @Param('id') id: string): Promise { + async generateReceipt(@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, - +id, + dto.transactionId, TxStatementType.RECEIPT, ); @@ -192,6 +196,30 @@ export class RealUnitController { return { pdfData: await this.swissQrService.createTxStatement(txStatementDetails, PdfBrand.REALUNIT) }; } + @Post('transactions/receipt/multi') + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.USER), UserActiveGuard()) + @ApiOperation({ + description: 'Generates a single PDF receipt for multiple completed RealUnit transactions', + }) + @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 { + const user = await this.userService.getUser(jwt.user, { userData: true }); + + const txStatementDetails = await this.transactionHelper.getTxStatementDetailsMulti( + user.userData.id, + dto.transactionIds, + TxStatementType.RECEIPT, + ); + + if (txStatementDetails.length > 0 && !Config.invoice.currencies.includes(txStatementDetails[0].currency)) { + throw new BadRequestException('PDF receipt is only available for CHF and EUR transactions'); + } + + return { pdfData: await this.swissQrService.createMultiTxStatement(txStatementDetails, PdfBrand.REALUNIT) }; + } + // --- Brokerbot Endpoints --- @Get('brokerbot/info') diff --git a/src/subdomains/supporting/realunit/dto/realunit-balance-pdf.dto.ts b/src/subdomains/supporting/realunit/dto/realunit-pdf.dto.ts similarity index 62% rename from src/subdomains/supporting/realunit/dto/realunit-balance-pdf.dto.ts rename to src/subdomains/supporting/realunit/dto/realunit-pdf.dto.ts index 593c9c5922..eb6ea65f47 100644 --- a/src/subdomains/supporting/realunit/dto/realunit-balance-pdf.dto.ts +++ b/src/subdomains/supporting/realunit/dto/realunit-pdf.dto.ts @@ -1,6 +1,15 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsDate, IsEnum, IsEthereumAddress, IsNotEmpty, IsOptional } from 'class-validator'; +import { + ArrayMinSize, + IsArray, + IsDate, + IsEnum, + IsEthereumAddress, + IsNotEmpty, + IsNumber, + IsOptional, +} 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'; @@ -25,3 +34,18 @@ export class RealUnitBalancePdfDto { @IsEnum(PdfLanguage) language?: PdfLanguage = PdfLanguage.EN; } + +export class RealUnitSingleReceiptPdfDto { + @ApiProperty({ type: Number, description: 'Transaction ID' }) + @IsNumber() + @Type(() => Number) + transactionId: number; +} + +export class RealUnitMultiReceiptPdfDto { + @ApiProperty({ type: [Number], description: 'Array of transaction IDs to include in the receipt' }) + @IsArray() + @IsNumber({}, { each: true }) + @ArrayMinSize(1) + transactionIds: number[]; +} From 6bcf9700f7f16f7c7c8941418627a5f8802bc61d Mon Sep 17 00:00:00 2001 From: David May <85513542+davidleomay@users.noreply.github.com> Date: Wed, 28 Jan 2026 01:38:28 +0100 Subject: [PATCH 2/2] fix: removed Tatum SDK (#3060) --- .../blockchain/cardano/cardano-client.ts | 56 +++++-------- .../blockchain/cardano/dto/cardano.dto.ts | 13 +++ .../blockchain/tron/dto/tron.dto.ts | 17 ++++ .../blockchain/tron/tron-client.ts | 79 +++++++++++-------- 4 files changed, 95 insertions(+), 70 deletions(-) diff --git a/src/integration/blockchain/cardano/cardano-client.ts b/src/integration/blockchain/cardano/cardano-client.ts index 6d6804219a..34aeb21b63 100644 --- a/src/integration/blockchain/cardano/cardano-client.ts +++ b/src/integration/blockchain/cardano/cardano-client.ts @@ -17,7 +17,6 @@ import { Value, Vkeywitnesses, } from '@emurgo/cardano-serialization-lib-nodejs'; -import { ApiVersion, CardanoRosetta, Network as TatumNetwork, TatumSDK } from '@tatumio/tatum'; import blake from 'blakejs'; import { Config } from 'src/config/config'; import { Asset } from 'src/shared/models/asset/asset.entity'; @@ -31,7 +30,13 @@ import { BlockchainClient } from '../shared/util/blockchain-client'; import { CardanoWallet } from './cardano-wallet'; import { CardanoUtil } from './cardano.util'; import { CardanoTransactionMapper } from './dto/cardano-transaction.mapper'; -import { CardanoBlockResponse, CardanoTransactionDto, CardanoTransactionResponse } from './dto/cardano.dto'; +import { + CardanoBalanceResponse, + CardanoBlockResponse, + CardanoInfoResponse, + CardanoTransactionDto, + CardanoTransactionResponse, +} from './dto/cardano.dto'; interface NetworkParameterStaticInfo { min_fee_a: number; @@ -41,9 +46,6 @@ interface NetworkParameterStaticInfo { export class CardanoClient extends BlockchainClient { private readonly wallet: CardanoWallet; - private readonly networkIdentifier = { blockchain: 'cardano', network: 'mainnet' }; - - private tatumSdk: CardanoRosetta; private blockFrostApi: BlockFrostAPI; private readonly networkParameterCache = new AsyncCache( @@ -61,11 +63,8 @@ export class CardanoClient extends BlockchainClient { } async getBlockHeight(): Promise { - const tatumSdk = await this.getTatumSdk(); - const networkStatus = await tatumSdk.rpc.getNetworkStatus({ - networkIdentifier: this.networkIdentifier, - }); - return networkStatus.current_block_identifier.index; + const info = await this.getNetworkInfo(); + return info.tip; } async getNativeCoinBalance(): Promise { @@ -73,13 +72,8 @@ export class CardanoClient extends BlockchainClient { } async getNativeCoinBalanceForAddress(address: string): Promise { - const tatumSdk = await this.getTatumSdk(); - const accountBalance = await tatumSdk.rpc.getAccountBalance({ - networkIdentifier: this.networkIdentifier, - accountIdentifier: { address }, - }); - - const adaBalance = accountBalance.balances.find((b) => b.currency.symbol === 'ADA'); + const balances = await this.getAccountBalances(address); + const adaBalance = balances.find((b) => b.currency.symbol === 'ADA'); if (!adaBalance) return 0; return CardanoUtil.fromLovelaceAmount(adaBalance.value, adaBalance.currency.decimals); @@ -93,20 +87,12 @@ export class CardanoClient extends BlockchainClient { async getTokenBalances(assets: Asset[], address?: string): Promise { const owner = address ?? this.walletAddress; - - const tatumSdk = await this.getTatumSdk(); - const accountBalance = await tatumSdk.rpc.getAccountBalance({ - networkIdentifier: this.networkIdentifier, - accountIdentifier: { address: owner }, - }); + const balances = await this.getAccountBalances(owner); const tokenBalances: BlockchainTokenBalance[] = []; for (const asset of assets) { - // Cardano native tokens use policy_id.asset_name format - const tokenBalance = accountBalance.balances.find( - (b) => b.currency.symbol === asset.chainId || b.currency.metadata?.policyId === asset.chainId, - ); + const tokenBalance = balances.find((b) => b.currency.symbol === asset.chainId); if (tokenBalance) { const balance = CardanoUtil.fromLovelaceAmount(tokenBalance.value, tokenBalance.currency.decimals); @@ -333,16 +319,14 @@ export class CardanoClient extends BlockchainClient { } // --- HELPER METHODS --- // - private async getTatumSdk(): Promise { - if (!this.tatumSdk) { - this.tatumSdk = await TatumSDK.init({ - version: ApiVersion.V3, - network: TatumNetwork.CARDANO_ROSETTA, - apiKey: Config.blockchain.cardano.cardanoTatumApiKey, - }); - } + private async getNetworkInfo(): Promise { + const url = Config.blockchain.cardano.cardanoApiUrl; + return this.http.get(`${url}/info`, this.httpConfig()); + } - return this.tatumSdk; + private async getAccountBalances(address: string): Promise { + const url = Config.blockchain.cardano.cardanoApiUrl; + return this.http.get(`${url}/account/${address}`, this.httpConfig()); } private getBlockFrostAPI(): BlockFrostAPI { diff --git a/src/integration/blockchain/cardano/dto/cardano.dto.ts b/src/integration/blockchain/cardano/dto/cardano.dto.ts index cc9594ee0b..e5b53bf37b 100644 --- a/src/integration/blockchain/cardano/dto/cardano.dto.ts +++ b/src/integration/blockchain/cardano/dto/cardano.dto.ts @@ -1,3 +1,16 @@ +export interface CardanoInfoResponse { + testnet: boolean; + tip: number; +} + +export interface CardanoBalanceResponse { + value: string; + currency: { + symbol: string; + decimals: number; + }; +} + export interface CardanoBlockResponse { hash: string; number: number; diff --git a/src/integration/blockchain/tron/dto/tron.dto.ts b/src/integration/blockchain/tron/dto/tron.dto.ts index 135b38128d..b81ff6ef0f 100644 --- a/src/integration/blockchain/tron/dto/tron.dto.ts +++ b/src/integration/blockchain/tron/dto/tron.dto.ts @@ -1,3 +1,20 @@ +export interface TronAccountResponse { + balance: number; + createTime: number; + trc10: { key: string; value: number }[]; + trc20: Record[]; + freeNetLimit: number; + bandwidth: number; +} + +export interface TronAddressBalance { + asset: string; + type: string; + balance: string; + decimals: number; + tokenAddress?: string; +} + export interface TronChainParameterDto { bandwidthUnitPrice: number; energyUnitPrice: number; diff --git a/src/integration/blockchain/tron/tron-client.ts b/src/integration/blockchain/tron/tron-client.ts index 9c875ab6bc..cf3ffb310c 100644 --- a/src/integration/blockchain/tron/tron-client.ts +++ b/src/integration/blockchain/tron/tron-client.ts @@ -1,5 +1,3 @@ -import { AddressBalance, ApiVersion, Network as TatumNetwork, TatumSDK, Tron } from '@tatumio/tatum'; -import { TronWalletProvider } from '@tatumio/tron-wallet-provider'; import { Config } from 'src/config/config'; import { Asset } from 'src/shared/models/asset/asset.entity'; import { HttpRequestConfig, HttpService } from 'src/shared/services/http.service'; @@ -9,15 +7,19 @@ import { BlockchainSignedTransactionResponse } from '../shared/dto/signed-transa import { WalletAccount } from '../shared/evm/domain/wallet-account'; import { BlockchainClient } from '../shared/util/blockchain-client'; import { TronTransactionMapper } from './dto/tron-transaction.mapper'; -import { TronChainParameterDto, TronTransactionDto, TronTransactionResponse } from './dto/tron.dto'; +import { + TronAccountResponse, + TronAddressBalance, + TronChainParameterDto, + TronTransactionDto, + TronTransactionResponse, +} from './dto/tron.dto'; import { TronWallet } from './tron-wallet'; import { TronUtil } from './tron.util'; export class TronClient extends BlockchainClient { private readonly wallet: TronWallet; - private tatumSdk: Tron; - constructor(private readonly http: HttpService) { super(); @@ -29,8 +31,9 @@ export class TronClient extends BlockchainClient { } async getBlockHeight(): Promise { - const tatumSdk = await this.getTatumSdk(); - return tatumSdk.rpc.getNowBlock().then((nb) => nb.block_header.raw_data.number); + const url = Config.blockchain.tron.tronApiUrl; + const info = await this.http.get<{ blockNumber: number }>(`${url}/info`, this.httpConfig()); + return info.blockNumber; } async getNativeCoinBalance(): Promise { @@ -38,18 +41,9 @@ export class TronClient extends BlockchainClient { } async getNativeCoinBalanceForAddress(address: string): Promise { - const tatumSdk = await this.getTatumSdk(); - const addressBalanceResponse = await tatumSdk.address.getBalance({ address }); - if (addressBalanceResponse.error) - throw new Error( - `Cannot detect native coin balance of address ${address}: ${addressBalanceResponse.error.message.join('; ')}`, - ); - - const allAddressBalanceCoinData = addressBalanceResponse.data.filter( - (d) => d.asset === 'TRX' && Util.equalsIgnoreCase(d.type, 'native'), - ); - - return Util.sum(allAddressBalanceCoinData.map((d) => TronUtil.fromSunAmount(d.balance, d.decimals))); + const balances = await this.getAddressBalances(address); + const trxBalance = balances.find((d) => d.asset === 'TRX' && Util.equalsIgnoreCase(d.type, 'native')); + return trxBalance ? TronUtil.fromSunAmount(trxBalance.balance, trxBalance.decimals) : 0; } async getTokenBalance(asset: Asset, address?: string): Promise { @@ -60,13 +54,10 @@ export class TronClient extends BlockchainClient { async getTokenBalances(assets: Asset[], address?: string): Promise { const owner = address ?? this.walletAddress; + const balances = await this.getAddressBalances(owner); - const tatumSdk = await this.getTatumSdk(); - const addressBalanceResponse = await tatumSdk.address.getBalance({ address: owner }); - if (addressBalanceResponse.error) throw new Error(`Cannot detect token balances of owner ${owner}`); - - const allAddressBalanceTokenMap: Map = new Map( - addressBalanceResponse.data.filter((ab) => ab.tokenAddress).map((ab) => [ab.tokenAddress.toLowerCase(), ab]), + const allAddressBalanceTokenMap: Map = new Map( + balances.filter((ab) => ab.tokenAddress).map((ab) => [ab.tokenAddress.toLowerCase(), ab]), ); const tokenBalances: BlockchainTokenBalance[] = []; @@ -310,17 +301,37 @@ export class TronClient extends BlockchainClient { } // --- HELPER METHODS --- // - private async getTatumSdk(): Promise { - if (!this.tatumSdk) { - this.tatumSdk = await TatumSDK.init({ - version: ApiVersion.V3, - network: TatumNetwork.TRON, - apiKey: Config.blockchain.tron.tronApiKey, - configureWalletProviders: [TronWalletProvider], - }); + private async getAddressBalances(address: string): Promise { + const url = Config.blockchain.tron.tronApiUrl; + const response = await this.http.get(`${url}/account/${address}`, this.httpConfig()); + + const balances: TronAddressBalance[] = []; + + // Native TRX balance (in SUN, 6 decimals) + balances.push({ + asset: 'TRX', + type: 'native', + balance: response.balance?.toString() ?? '0', + decimals: 6, + }); + + // TRC10 tokens + if (response.trc10) { + for (const token of response.trc10) { + balances.push({ asset: token.key, type: 'trc10', balance: token.value.toString(), decimals: 0 }); + } + } + + // TRC20 tokens + if (response.trc20) { + for (const tokenObj of response.trc20) { + for (const [contractAddress, balance] of Object.entries(tokenObj)) { + balances.push({ asset: contractAddress, type: 'trc20', balance, decimals: 6, tokenAddress: contractAddress }); + } + } } - return this.tatumSdk; + return balances; } private httpConfig(): HttpRequestConfig {