Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 100 additions & 21 deletions src/subdomains/supporting/payment/services/swiss-qr.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -117,34 +120,105 @@ export class SwissQRService {
);
}

async createMultiTxStatement(details: TxStatementDetails[], brand: PdfBrand = PdfBrand.DFX): Promise<string> {
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<string> {
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<string> {
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(
Expand All @@ -155,6 +229,7 @@ export class SwissQRService {
transactionType: TransactionType,
brand: PdfBrand = PdfBrand.DFX,
debtorName?: string,
skipTermsAndConditions = false,
): Promise<string> {
const { pdf, promise } = this.createPdfWithBase64Promise();

Expand Down Expand Up @@ -291,8 +366,6 @@ export class SwissQRService {
},
];

const termsAndConditions = this.getTermsAndConditions(language);

if (bankInfo) {
rows.push({
columns: [
Expand All @@ -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);
Expand Down Expand Up @@ -369,6 +445,7 @@ export class SwissQRService {
language: string,
billData: QrBillData,
brand: PdfBrand = PdfBrand.DFX,
skipTermsAndConditions = false,
): Promise<string> {
const { pdf, promise } = this.createPdfWithBase64Promise();

Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -156,7 +145,7 @@ export class RealUnitController {
return this.realunitService.getRealUnitInfo();
}

// --- Balance PDF Endpoint ---
// --- PDF Endpoints ---

@Post('balance/pdf')
@ApiBearerAuth()
Expand All @@ -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<PdfDto> {
@ApiBadRequestResponse({ description: 'Transaction not found or not a transfer' })
async generateHistoryReceipt(@GetJwt() jwt: JwtPayload, @Body() dto: RealUnitSingleReceiptPdfDto): Promise<PdfDto> {
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<PdfDto> {
@ApiBadRequestResponse({ description: 'Transaction not found or not a transfer' })
async generateHistoryMultiReceipt(
@GetJwt() jwt: JwtPayload,
@Body() dto: RealUnitMultiReceiptPdfDto,
): Promise<PdfDto> {
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 ---
Expand Down
Loading
Loading