From 1aa3e2f1abecce689ac8aa712b3e5fc08beee353 Mon Sep 17 00:00:00 2001 From: Yannick <52333989+Yannick1712@users.noreply.github.com> Date: Wed, 21 Jan 2026 14:40:45 +0100 Subject: [PATCH 1/7] [DEV-4533] kycStep for invitation (#2831) * [DEV-4533] kycStep for invatation * [DEV-4533] fix build * [DEV-4533] Refactoring * [DEV-4533] Refactoring 2 * [DEV-4533] Adapt mapper for mail invitation --- .../generic/kyc/services/kyc-admin.service.ts | 2 +- .../generic/kyc/services/kyc.service.ts | 9 ++++--- .../generic/user/models/kyc/kyc.service.ts | 3 +++ .../mapper/recommendation-dto.mapper.ts | 7 ++++-- .../recommendation/recommendation.service.ts | 25 +++++++++++++++++-- .../models/user-data/user-data.service.ts | 9 +++++-- 6 files changed, 44 insertions(+), 11 deletions(-) diff --git a/src/subdomains/generic/kyc/services/kyc-admin.service.ts b/src/subdomains/generic/kyc/services/kyc-admin.service.ts index 76f5daac66..0d5ff1efc3 100644 --- a/src/subdomains/generic/kyc/services/kyc-admin.service.ts +++ b/src/subdomains/generic/kyc/services/kyc-admin.service.ts @@ -114,7 +114,7 @@ export class KycAdminService { async triggerVideoIdentInternal(userData: UserData): Promise { try { - await this.kycService.getOrCreateStepInternal(userData.kycHash, KycStepName.IDENT, KycStepType.SUMSUB_VIDEO); + await this.kycService.getOrCreateStepInternal(KycStepName.IDENT, userData, undefined, KycStepType.SUMSUB_VIDEO); } catch (e) { this.logger.error(`Failed to trigger video ident internal for userData ${userData.id}:`, e); } diff --git a/src/subdomains/generic/kyc/services/kyc.service.ts b/src/subdomains/generic/kyc/services/kyc.service.ts index e8cadda9be..845b269bbc 100644 --- a/src/subdomains/generic/kyc/services/kyc.service.ts +++ b/src/subdomains/generic/kyc/services/kyc.service.ts @@ -762,7 +762,7 @@ export class KycService { .catch((e) => this.logger.error(`Error during sumsub webhook update for applicant ${dto.applicantId}:`, e)); } - private async updateKycStepAndLog( + async updateKycStepAndLog( kycStep: KycStep, user: UserData, data: KycStepResult, @@ -933,13 +933,14 @@ export class KycService { // --- STEPPING METHODS --- // async getOrCreateStepInternal( - kycHash: string, name: KycStepName, + user?: UserData, + kycHash?: string, type?: KycStepType, sequence?: number, restartCompletedSteps = false, ): Promise<{ user: UserData; step: KycStep }> { - const user = await this.getUser(kycHash); + user = user ?? (await this.getUser(kycHash)); let step = sequence != null @@ -966,7 +967,7 @@ export class KycService { const type = Object.values(KycStepType).find((t) => t.toLowerCase() === stepType?.toLowerCase()); if (!name) throw new BadRequestException('Invalid step name'); - const { user, step } = await this.getOrCreateStepInternal(kycHash, name, type, sequence, true); + const { user, step } = await this.getOrCreateStepInternal(name, undefined, kycHash, type, sequence, true); await this.verify2faIfRequired(user, ip); diff --git a/src/subdomains/generic/user/models/kyc/kyc.service.ts b/src/subdomains/generic/user/models/kyc/kyc.service.ts index 41cff023e0..65add007dd 100644 --- a/src/subdomains/generic/user/models/kyc/kyc.service.ts +++ b/src/subdomains/generic/user/models/kyc/kyc.service.ts @@ -1,6 +1,8 @@ import { BadRequestException, ConflictException, + forwardRef, + Inject, Injectable, NotFoundException, ServiceUnavailableException, @@ -32,6 +34,7 @@ export class KycService { private readonly logger = new DfxLogger(KycService); constructor( + @Inject(forwardRef(() => UserDataService)) private readonly userDataService: UserDataService, private readonly userDataRepo: UserDataRepository, private readonly userRepo: UserRepository, diff --git a/src/subdomains/generic/user/models/recommendation/mapper/recommendation-dto.mapper.ts b/src/subdomains/generic/user/models/recommendation/mapper/recommendation-dto.mapper.ts index e2ba4f58b6..2b4cdfb665 100644 --- a/src/subdomains/generic/user/models/recommendation/mapper/recommendation-dto.mapper.ts +++ b/src/subdomains/generic/user/models/recommendation/mapper/recommendation-dto.mapper.ts @@ -1,5 +1,5 @@ import { RecommendationDto, RecommendationDtoStatus } from '../dto/recommendation.dto'; -import { Recommendation } from '../recommendation.entity'; +import { Recommendation, RecommendationMethod, RecommendationType } from '../recommendation.entity'; export class RecommendationDtoMapper { static entityToDto(recommendation: Recommendation): RecommendationDto { @@ -12,7 +12,10 @@ export class RecommendationDtoMapper { name: recommendation.recommended?.completeName ?? recommendation.recommendedAlias, mail: recommendation.recommended?.mail ?? recommendation.recommendedMail, confirmationDate: recommendation.confirmationDate, - expirationDate: recommendation.expirationDate, + expirationDate: + recommendation.method === RecommendationMethod.MAIL && recommendation.type === RecommendationType.INVITATION + ? undefined + : recommendation.expirationDate, }; return Object.assign(new RecommendationDto(), dto); diff --git a/src/subdomains/generic/user/models/recommendation/recommendation.service.ts b/src/subdomains/generic/user/models/recommendation/recommendation.service.ts index ca5703e0eb..3e4d3309ad 100644 --- a/src/subdomains/generic/user/models/recommendation/recommendation.service.ts +++ b/src/subdomains/generic/user/models/recommendation/recommendation.service.ts @@ -4,6 +4,9 @@ import { DfxLogger } from 'src/shared/services/dfx-logger'; import { Util } from 'src/shared/utils/util'; import { KycRecommendationData } from 'src/subdomains/generic/kyc/dto/input/kyc-data.dto'; import { KycStep } from 'src/subdomains/generic/kyc/entities/kyc-step.entity'; +import { KycStepName } from 'src/subdomains/generic/kyc/enums/kyc-step-name.enum'; +import { ReviewStatus } from 'src/subdomains/generic/kyc/enums/review-status.enum'; +import { KycService } from 'src/subdomains/generic/kyc/services/kyc.service'; import { MailContext, MailType } from 'src/subdomains/supporting/notification/enums'; import { MailKey, MailTranslationKey } from 'src/subdomains/supporting/notification/factories/mail.factory'; import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service'; @@ -26,17 +29,19 @@ export class RecommendationService { @Inject(forwardRef(() => UserDataService)) private readonly userDataService: UserDataService, private readonly userService: UserService, + @Inject(forwardRef(() => KycService)) + private readonly kycService: KycService, ) {} async createRecommendationByRecommender(userDataId: number, dto: CreateRecommendationDto): Promise { - const userData = await this.userDataService.getUserData(userDataId); + const userData = await this.userDataService.getUserData(userDataId, { users: true }); if (!userData) throw new NotFoundException('Account not found'); if (userData.kycLevel < KycLevel.LEVEL_50) throw new BadRequestException('Missing KYC'); if (!userData.tradeApprovalDate) throw new BadRequestException('Trade approval date missing'); const mailUser = dto.recommendedMail ? await this.userDataService - .getUsersByMail(dto.recommendedMail, true) + .getUsersByMail(dto.recommendedMail, true, { users: true, wallet: true, kycSteps: true }) .then((u) => u.find((us) => us.tradeApprovalDate) ?? u?.[0]) : undefined; @@ -80,6 +85,22 @@ export class RecommendationService { dto.recommendedMail, ); + if (recommended) { + const { step } = await this.kycService.getOrCreateStepInternal( + KycStepName.RECOMMENDATION, + recommended, + undefined, + ); + // avoid circular reference + step.userData = undefined; + + await this.kycService.updateKycStepAndLog(step, recommended, { key: entity.code }, ReviewStatus.COMPLETED); + await this.updateRecommendationInternal(entity, { + isConfirmed: true, + confirmationDate: new Date(), + }); + } + if (dto.recommendedMail) await this.sendInvitationMail(entity); return entity; diff --git a/src/subdomains/generic/user/models/user-data/user-data.service.ts b/src/subdomains/generic/user/models/user-data/user-data.service.ts index eff8ad313c..276a7ecd20 100644 --- a/src/subdomains/generic/user/models/user-data/user-data.service.ts +++ b/src/subdomains/generic/user/models/user-data/user-data.service.ts @@ -170,7 +170,11 @@ export class UserDataService { if (!isNaN(masterUserId)) return this.getUserData(masterUserId); } - async getUsersByMail(mail: string, onlyValidUser = true): Promise { + async getUsersByMail( + mail: string, + onlyValidUser = true, + relations: FindOptionsRelations = { users: true, wallet: true }, + ): Promise { return this.userDataRepo.find({ where: { mail, @@ -178,7 +182,7 @@ export class UserDataService { ? In([UserDataStatus.ACTIVE, UserDataStatus.NA, UserDataStatus.KYC_ONLY, UserDataStatus.DEACTIVATED]) : undefined, }, - relations: { users: true, wallet: true }, + relations, }); } @@ -238,6 +242,7 @@ export class UserDataService { language: dto.language ?? (await this.languageService.getLanguageBySymbol(Config.defaults.language)), currency: dto.currency ?? (await this.fiatService.getFiatByName(Config.defaults.currency)), kycHash: randomUUID().toUpperCase(), + kycSteps: [], }); await this.loadRelationsAndVerify(userData, dto); From d0f2cc019550786b1315f0943d8313fdd4064b59 Mon Sep 17 00:00:00 2001 From: David May <85513542+davidleomay@users.noreply.github.com> Date: Wed, 21 Jan 2026 16:06:15 +0100 Subject: [PATCH 2/7] fix: minor improvements (#3013) * fix: improved LM config check * fix: floor source amount (to not exceed balance) * fix(buy): EUR VIBAN improvements - Check for existing VIBAN before enforcing KYC level 50 (fixes #3008) - Support buy-specific VIBANs for EUR transactions (fixes #3006) - Extract buildVirtualIbanResponse helper to reduce duplication (fixes #3007) * fix: added EVM test swap retry * fix: improved Scrypt connection error logging * feat: Scrypt unfiltered log processing * [NOTASK] Small refactoring * [NOTASK] Refactoring 2 --------- Co-authored-by: Yannick1712 <52333989+Yannick1712@users.noreply.github.com> --- .../services/scrypt-websocket-connection.ts | 9 +- src/shared/utils/util.ts | 14 +++ .../core/buy-crypto/routes/buy/buy.service.ts | 102 +++++++----------- .../liquidity-management-rule.entity.ts | 4 + .../services/liquidity-management.service.ts | 12 +-- .../dex/services/base/dex-evm.service.ts | 11 +- .../supporting/log/log-job.service.ts | 96 +++++++++++++++-- .../payment/services/transaction-helper.ts | 2 +- 8 files changed, 162 insertions(+), 88 deletions(-) diff --git a/src/integration/exchange/services/scrypt-websocket-connection.ts b/src/integration/exchange/services/scrypt-websocket-connection.ts index f2d0f9eb81..4371feaac3 100644 --- a/src/integration/exchange/services/scrypt-websocket-connection.ts +++ b/src/integration/exchange/services/scrypt-websocket-connection.ts @@ -209,13 +209,12 @@ export class ScryptWebSocketConnection { }); ws.on('close', (code, reason) => { - this.logger.warn(`Scrypt WebSocket closed (code: ${code}, reason: ${reason})`); - this.handleDisconnection(); + this.handleDisconnection(code, reason); }); }); } - private handleDisconnection(): void { + private handleDisconnection(code?: number, reason?: string): void { const wasConnected = this.connectionState === ConnectionState.CONNECTED; this.connectionState = ConnectionState.DISCONNECTED; this.ws = undefined; @@ -229,7 +228,9 @@ export class ScryptWebSocketConnection { // reconnect if (wasConnected) { - this.logger.warn(`Unexpected disconnection, attempting reconnect in ${this.reconnectDelay}ms`); + this.logger.warn( + `Scrypt WebSocket closed (code: ${code}, reason: ${reason}), attempting reconnect in ${this.reconnectDelay}ms`, + ); setTimeout(() => { void this.connect() diff --git a/src/shared/utils/util.ts b/src/shared/utils/util.ts index a370298998..e6016ff3d5 100644 --- a/src/shared/utils/util.ts +++ b/src/shared/utils/util.ts @@ -38,6 +38,20 @@ export class Util { } } + static floorReadable(amount: number, type: AmountType, assetPrecision?: number): number { + switch (type) { + case AmountType.ASSET: + case AmountType.ASSET_FEE: + return this.floorByPrecision(amount, assetPrecision ?? 5); + + case AmountType.FIAT: + return this.floor(amount, 2); + + case AmountType.FIAT_FEE: + return this.floor(amount, 2); + } + } + static round(amount: number, decimals: number): number { return this.roundToValue(amount, Math.pow(10, -decimals)); } diff --git a/src/subdomains/core/buy-crypto/routes/buy/buy.service.ts b/src/subdomains/core/buy-crypto/routes/buy/buy.service.ts index b52c3c59e2..0ff457b9d6 100644 --- a/src/subdomains/core/buy-crypto/routes/buy/buy.service.ts +++ b/src/subdomains/core/buy-crypto/routes/buy/buy.service.ts @@ -24,6 +24,7 @@ import { UserStatus } from 'src/subdomains/generic/user/models/user/user.enum'; import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; import { Wallet } from 'src/subdomains/generic/user/models/wallet/wallet.entity'; import { BankSelectorInput, BankService } from 'src/subdomains/supporting/bank/bank/bank.service'; +import { VirtualIban } from 'src/subdomains/supporting/bank/virtual-iban/virtual-iban.entity'; import { VirtualIbanService } from 'src/subdomains/supporting/bank/virtual-iban/virtual-iban.service'; import { CryptoPaymentMethod, FiatPaymentMethod } from 'src/subdomains/supporting/payment/dto/payment-method.enum'; import { TransactionRequestType } from 'src/subdomains/supporting/payment/entities/transaction-request.entity'; @@ -355,35 +356,6 @@ export class BuyService { asset?: Asset, wallet?: Wallet, ): Promise { - // EUR: VIBAN is mandatory - if (selector.currency === 'EUR') { - if (selector.userData.kycLevel < KycLevel.LEVEL_50) { - throw new BadRequestException('KycRequired'); - } - - let virtualIban = await this.virtualIbanService.getActiveForUserAndCurrency(selector.userData, selector.currency); - - if (!virtualIban) { - virtualIban = await this.virtualIbanService.createForUser(selector.userData, selector.currency); - } - - const { address } = selector.userData; - return { - name: selector.userData.completeName, - street: address.street, - ...(address.houseNumber && { number: address.houseNumber }), - zip: address.zip, - city: address.city, - country: address.country?.name, - bank: virtualIban.bank.name, - iban: virtualIban.iban, - bic: virtualIban.bank.bic, - sepaInstant: virtualIban.bank.sctInst, - isPersonalIban: true, - reference: this.getBuyReference(buy?.bankUsage, false), - }; - } - // asset-specific personal IBAN if ( buy && @@ -402,43 +374,30 @@ export class BuyService { } if (virtualIban) { - const { address } = selector.userData; - return { - name: selector.userData.completeName, - street: address.street, - ...(address.houseNumber && { number: address.houseNumber }), - zip: address.zip, - city: address.city, - country: address.country?.name, - bank: virtualIban.bank.name, - iban: virtualIban.iban, - bic: virtualIban.bank.bic, - sepaInstant: virtualIban.bank.sctInst, - isPersonalIban: true, - reference: this.getBuyReference(buy?.bankUsage, true), - }; + return this.buildVirtualIbanResponse(virtualIban, selector.userData); } } + // EUR: VIBAN is mandatory + if (selector.currency === 'EUR') { + let virtualIban = await this.virtualIbanService.getActiveForUserAndCurrency(selector.userData, selector.currency); + + if (!virtualIban) { + if (selector.userData.kycLevel < KycLevel.LEVEL_50) { + throw new BadRequestException('KycRequired'); + } + + virtualIban = await this.virtualIbanService.createForUser(selector.userData, selector.currency); + } + + return this.buildVirtualIbanResponse(virtualIban, selector.userData, buy?.bankUsage); + } + // user-level personal IBAN const virtualIban = await this.virtualIbanService.getActiveForUserAndCurrency(selector.userData, selector.currency); if (virtualIban) { - const { address } = selector.userData; - return { - name: selector.userData.completeName, - street: address.street, - ...(address.houseNumber && { number: address.houseNumber }), - zip: address.zip, - city: address.city, - country: address.country?.name, - bank: virtualIban.bank.name, - iban: virtualIban.iban, - bic: virtualIban.bank.bic, - sepaInstant: virtualIban.bank.sctInst, - isPersonalIban: true, - reference: this.getBuyReference(buy?.bankUsage, false), - }; + return this.buildVirtualIbanResponse(virtualIban, selector.userData, buy?.bankUsage); } // normal bank selection @@ -453,13 +412,30 @@ export class BuyService { bic: bank.bic, sepaInstant: bank.sctInst, isPersonalIban: false, - reference: this.getBuyReference(buy?.bankUsage, false), + reference: buy?.bankUsage, }; } - private getBuyReference(bankUsage: string | undefined, isBuySpecificIban: boolean): string | undefined { - // for buy-specific IBANs, no reference is needed - return isBuySpecificIban ? undefined : bankUsage; + private buildVirtualIbanResponse( + virtualIban: VirtualIban, + userData: UserData, + reference?: string, + ): BankInfoDto & { isPersonalIban: boolean; reference?: string } { + const { address } = userData; + return { + name: userData.completeName, + street: address.street, + ...(address.houseNumber && { number: address.houseNumber }), + zip: address.zip, + city: address.city, + country: address.country?.name, + bank: virtualIban.bank.name, + iban: virtualIban.iban, + bic: virtualIban.bank.bic, + sepaInstant: virtualIban.bank.sctInst, + isPersonalIban: true, + reference, + }; } private generateQRCode( diff --git a/src/subdomains/core/liquidity-management/entities/liquidity-management-rule.entity.ts b/src/subdomains/core/liquidity-management/entities/liquidity-management-rule.entity.ts index 8d50943d59..8284736e5e 100644 --- a/src/subdomains/core/liquidity-management/entities/liquidity-management-rule.entity.ts +++ b/src/subdomains/core/liquidity-management/entities/liquidity-management-rule.entity.ts @@ -147,6 +147,10 @@ export class LiquidityManagementRule extends IEntity { : this.redundancyStartAction; } + hasStartAction(optimizationType: LiquidityOptimizationType): boolean { + return this.getStartAction(optimizationType) != null; + } + get target(): Active { return this.targetAsset ?? this.targetFiat; } diff --git a/src/subdomains/core/liquidity-management/services/liquidity-management.service.ts b/src/subdomains/core/liquidity-management/services/liquidity-management.service.ts index 5d8a34195d..73600f9e95 100644 --- a/src/subdomains/core/liquidity-management/services/liquidity-management.service.ts +++ b/src/subdomains/core/liquidity-management/services/liquidity-management.service.ts @@ -66,10 +66,6 @@ export class LiquidityManagementService { ): Promise { const rule = await this.findRuleByAssetOrThrow(assetId); - if (!rule.deficitStartAction) { - throw new BadRequestException(`Rule ${rule.id} does not support liquidity deficit path`); - } - if (targetOptimal) maxAmount = Util.round(maxAmount + rule.optimal, 6); const liquidityState: LiquidityState = { @@ -89,10 +85,6 @@ export class LiquidityManagementService { ): Promise { const rule = await this.findRuleByAssetOrThrow(assetId); - if (!rule.redundancyStartAction) { - throw new BadRequestException(`Rule ${rule.id} does not support liquidity redundancy path`); - } - if (targetOptimal) maxAmount = Util.round(maxAmount - rule.optimal, 6); const liquidityState: LiquidityState = { @@ -177,6 +169,10 @@ export class LiquidityManagementService { throw new ConflictException(`Pipeline for rule ${rule.id} cannot be started (status ${rule.status})`); } + if (!rule.hasStartAction(result.action)) { + throw new BadRequestException(`Rule ${rule.id} does not support ${result.action.toLowerCase()} path`); + } + this.logRuleExecution(rule, result); const newPipeline = LiquidityManagementPipeline.create(rule, result); diff --git a/src/subdomains/supporting/dex/services/base/dex-evm.service.ts b/src/subdomains/supporting/dex/services/base/dex-evm.service.ts index a3afd601c2..e0ea10e1b6 100644 --- a/src/subdomains/supporting/dex/services/base/dex-evm.service.ts +++ b/src/subdomains/supporting/dex/services/base/dex-evm.service.ts @@ -72,9 +72,14 @@ export abstract class DexEvmService implements PurchaseDexService { ): Promise { if (sourceAsset.id === targetAsset.id) return sourceAmount; - return poolFee != null - ? this.#client.testSwapPool(sourceAsset, sourceAmount, targetAsset, poolFee).then((r) => r.targetAmount) - : this.#client.testSwap(sourceAsset, sourceAmount, targetAsset, maxSlippage).then((r) => r.targetAmount); + return Util.retry( + () => + poolFee != null + ? this.#client.testSwapPool(sourceAsset, sourceAmount, targetAsset, poolFee) + : this.#client.testSwap(sourceAsset, sourceAmount, targetAsset, maxSlippage), + 3, + 1000, + ).then((r) => r.targetAmount); } async swap(swapAsset: Asset, swapAmount: number, targetAsset: Asset, maxSlippage: number): Promise { diff --git a/src/subdomains/supporting/log/log-job.service.ts b/src/subdomains/supporting/log/log-job.service.ts index 3156984ce6..0c1d6e46d0 100644 --- a/src/subdomains/supporting/log/log-job.service.ts +++ b/src/subdomains/supporting/log/log-job.service.ts @@ -459,7 +459,7 @@ export class LogJobService { 0, ); - // Olky to Yapeal + // Olky to Yapeal // const pendingOlkyYapealAmount = this.getPendingBankAmount( [curr], recentBankTxFromOlky, @@ -468,7 +468,7 @@ export class LogJobService { yapealEurBank.iban, ); - // Kraken to Yapeal + // Kraken to Yapeal // // filtered lists const pendingChfKrakenYapealPlusAmount = this.getPendingBankAmount( @@ -511,7 +511,7 @@ export class LogJobService { BankTxType.KRAKEN, ); - // Yapeal to Kraken + // Yapeal to Kraken // // filtered lists const pendingYapealKrakenPlusAmount = this.getPendingBankAmount( @@ -554,7 +554,9 @@ export class LogJobService { yapealEurBank.iban, ); - // Yapeal to Scrypt + // Yapeal to Scrypt // + + // filtered lists const pendingYapealScryptPlusAmount = this.getPendingBankAmount( [curr], [...recentChfYapealScryptTx, ...recentEurYapealScryptTx], @@ -573,7 +575,31 @@ export class LogJobService { yapealEurBank.iban, ); - // Scrypt to Yapeal + // unfiltered lists + const pendingYapealScryptPlusAmountUnfiltered = this.getPendingBankAmount( + [curr], + [ + ...chfSenderScryptBankTx.filter((t) => t.id >= financeLogPairIds?.toScrypt?.chf?.bankTxId), + ...eurSenderScryptBankTx.filter((t) => t.id >= financeLogPairIds?.toScrypt?.eur?.bankTxId), + ], + BankTxType.SCRYPT, + ); + const pendingChfYapealScryptMinusAmountUnfiltered = this.getPendingBankAmount( + [curr], + chfReceiverScryptExchangeTx.filter((t) => t.id >= financeLogPairIds?.toScrypt?.chf?.exchangeTxId), + ExchangeTxType.DEPOSIT, + yapealChfBank.iban, + ); + const pendingEurYapealScryptMinusAmountUnfiltered = this.getPendingBankAmount( + [curr], + eurReceiverScryptExchangeTx.filter((t) => t.id >= financeLogPairIds?.toScrypt?.eur?.exchangeTxId), + ExchangeTxType.DEPOSIT, + yapealEurBank.iban, + ); + + // Scrypt to Yapeal // + + // filtered lists const pendingChfScryptYapealPlusAmount = this.getPendingBankAmount( [curr], recentChfScryptYapealTx, @@ -592,6 +618,35 @@ export class LogJobService { BankTxType.SCRYPT, ); + // unfiltered lists + const pendingChfScryptYapealPlusAmountUnfiltered = financeLogPairIds?.fromScrypt?.chf?.exchangeTxId + ? this.getPendingBankAmount( + [curr], + chfSenderScryptExchangeTx.filter((t) => t.id >= financeLogPairIds.fromScrypt.chf.exchangeTxId), + ExchangeTxType.WITHDRAWAL, + yapealChfBank.iban, + ) + : 0; + const pendingEurScryptYapealPlusAmountUnfiltered = financeLogPairIds?.fromScrypt?.eur?.exchangeTxId + ? this.getPendingBankAmount( + [curr], + eurSenderScryptExchangeTx.filter((t) => t.id >= financeLogPairIds.fromScrypt.eur.exchangeTxId), + ExchangeTxType.WITHDRAWAL, + yapealEurBank.iban, + ) + : 0; + const pendingScryptYapealMinusAmountUnfiltered = + financeLogPairIds?.fromScrypt?.chf?.bankTxId || financeLogPairIds?.fromScrypt?.eur?.bankTxId + ? this.getPendingBankAmount( + [curr], + [ + ...chfReceiverScryptBankTx.filter((t) => t.id >= financeLogPairIds.fromScrypt.chf.bankTxId), + ...eurReceiverScryptBankTx.filter((t) => t.id >= financeLogPairIds.fromScrypt.eur.bankTxId), + ], + BankTxType.SCRYPT, + ) + : 0; + const fromKrakenUnfiltered = pendingChfKrakenYapealPlusAmountUnfiltered + pendingEurKrakenYapealPlusAmountUnfiltered + @@ -611,6 +666,15 @@ export class LogJobService { let toScrypt = pendingYapealScryptPlusAmount + pendingChfYapealScryptMinusAmount + pendingEurYapealScryptMinusAmount; + const fromScryptUnfiltered = + pendingChfScryptYapealPlusAmountUnfiltered + + pendingEurScryptYapealPlusAmountUnfiltered + + pendingScryptYapealMinusAmountUnfiltered; + const toScryptUnfiltered = + pendingYapealScryptPlusAmountUnfiltered + + pendingChfYapealScryptMinusAmountUnfiltered + + pendingEurYapealScryptMinusAmountUnfiltered; + const errors = []; if (fromKraken !== fromKrakenUnfiltered) { @@ -627,6 +691,20 @@ export class LogJobService { ${toKraken}, toKrakenUnfilteredAmount: ${toKrakenUnfiltered}`); } + if (fromScrypt !== fromScryptUnfiltered) { + errors.push(`fromScrypt !== fromScryptUnfiltered`); + this.logger.verbose( + `Error in financial log, fromScrypt balance !== fromScryptUnfiltered balance for asset: ${curr.id}, fromScryptAmount: ${fromScrypt}, fromScryptUnfilteredAmount: ${fromScryptUnfiltered}`, + ); + } + + if (toScrypt !== toScryptUnfiltered) { + errors.push(`toScrypt !== toScryptUnfiltered`); + this.logger.verbose( + `Error in financial log, toScrypt balance !== toScryptUnfiltered balance for asset: ${curr.id}, toScryptAmount: ${toScrypt}, toScryptUnfilteredAmount: ${toScryptUnfiltered}`, + ); + } + if (fromKraken < 0) { errors.push(`fromKraken < 0`); this.logger.verbose(`Error in financial log, fromKraken balance < 0 for asset: ${curr.id}, pendingPlusAmount: @@ -672,8 +750,8 @@ export class LogJobService { pendingOlkyYapealAmount + (useUnfilteredTx ? fromKrakenUnfiltered : fromKraken) + (useUnfilteredTx ? toKrakenUnfiltered : toKraken) + - fromScrypt + - toScrypt; + (useUnfilteredTx ? fromScryptUnfiltered : fromScrypt) + + (useUnfilteredTx ? toScryptUnfiltered : toScrypt); const totalPlus = liquidity + totalPlusPending + (totalCustomBalance ?? 0); @@ -758,8 +836,8 @@ export class LogJobService { fromOlky: this.getJsonValue(pendingOlkyYapealAmount, amountType(curr)), fromKraken: this.getJsonValue(useUnfilteredTx ? fromKrakenUnfiltered : fromKraken, amountType(curr)), toKraken: this.getJsonValue(useUnfilteredTx ? toKrakenUnfiltered : toKraken, amountType(curr)), - fromScrypt: this.getJsonValue(fromScrypt, amountType(curr)), - toScrypt: this.getJsonValue(toScrypt, amountType(curr)), + fromScrypt: this.getJsonValue(useUnfilteredTx ? fromScryptUnfiltered : fromScrypt, amountType(curr)), + toScrypt: this.getJsonValue(useUnfilteredTx ? toScryptUnfiltered : toScrypt, amountType(curr)), } : undefined, // monitoring: errors.length diff --git a/src/subdomains/supporting/payment/services/transaction-helper.ts b/src/subdomains/supporting/payment/services/transaction-helper.ts index 1b7f00094b..945827dd88 100644 --- a/src/subdomains/supporting/payment/services/transaction-helper.ts +++ b/src/subdomains/supporting/payment/services/transaction-helper.ts @@ -736,7 +736,7 @@ export class TransactionHelper implements OnModuleInit { timestamp: price.timestamp, exchangeRate: Util.roundReadable(price.price, amountType(from)), rate: targetAmount ? Util.roundReadable(sourceAmount / targetAmount, amountType(from)) : Number.MAX_VALUE, - sourceAmount: Util.roundReadable(sourceAmount, amountType(from)), + sourceAmount: Util.floorReadable(sourceAmount, amountType(from)), estimatedAmount: Util.roundReadable(targetAmount, amountType(to)), exactPrice: price.isValid, priceSteps: price.steps, From e485d5ead2f6644526187cc952174ae0c21fb61c Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 21 Jan 2026 16:41:00 +0100 Subject: [PATCH 3/7] refactor(scripts): use central .env for debug scripts (#3017) * refactor(scripts): use central .env for debug scripts - db-debug.sh and log-debug.sh now use the central .env file - Remove separate .env.db-debug.sample file - Update documentation in script headers DEBUG_ADDRESS, DEBUG_SIGNATURE, and DEBUG_API_URL should now be added to the main .env file instead of a separate config. * feat(setup): auto-save DEBUG credentials to .env Setup script now saves DEBUG_ADDRESS, DEBUG_SIGNATURE, and DEBUG_API_URL to .env after registering the admin user, so debug scripts work immediately. * fix(scripts): read env vars safely without sourcing Avoid sourcing .env file directly to prevent bash keyword conflicts (e.g. mnemonics containing 'else', 'if', etc.) --- scripts/.env.db-debug.sample | 15 --------------- scripts/db-debug.sh | 20 +++++++++++++------- scripts/log-debug.sh | 20 +++++++++++++------- scripts/setup.js | 9 +++++++++ 4 files changed, 35 insertions(+), 29 deletions(-) delete mode 100644 scripts/.env.db-debug.sample diff --git a/scripts/.env.db-debug.sample b/scripts/.env.db-debug.sample deleted file mode 100644 index 2344cb9447..0000000000 --- a/scripts/.env.db-debug.sample +++ /dev/null @@ -1,15 +0,0 @@ -# DFX Debug Database Access Configuration -# -# Copy this file to .env.db-debug and fill in your credentials -# NEVER commit .env.db-debug to git! - -# Your wallet address with DEBUG role -DEBUG_ADDRESS=0x... - -# Signature from signing the DFX login message -DEBUG_SIGNATURE=0x... - -# API URL (optional, defaults to production) -# DEBUG_API_URL=https://api.dfx.swiss/v1 -# DEBUG_API_URL=https://dev.api.dfx.swiss/v1 -# DEBUG_API_URL=http://localhost:3000/v1 diff --git a/scripts/db-debug.sh b/scripts/db-debug.sh index 0c608bded4..ad2c2dadc9 100755 --- a/scripts/db-debug.sh +++ b/scripts/db-debug.sh @@ -10,7 +10,10 @@ # ./scripts/db-debug.sh --asset-history Yapeal/EUR 10 # Show asset balance history # # Environment: -# Copy .env.db-debug.sample to .env.db-debug and fill in your credentials +# Uses the central .env file. Required variables: +# - DEBUG_ADDRESS: Wallet address with DEBUG role +# - DEBUG_SIGNATURE: Signature from signing the DFX login message +# - DEBUG_API_URL (optional): API URL, defaults to https://api.dfx.swiss/v1 # # Requirements: # - curl @@ -102,18 +105,21 @@ esac # --- Load environment --- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ENV_FILE="$SCRIPT_DIR/.env.db-debug" +ENV_FILE="$SCRIPT_DIR/../.env" -if [ -f "$ENV_FILE" ]; then - source "$ENV_FILE" -else +if [ ! -f "$ENV_FILE" ]; then echo "Error: Environment file not found: $ENV_FILE" - echo "Copy .env.db-debug.sample to .env.db-debug and fill in your credentials" + echo "Create .env in the api root directory" exit 1 fi +# Read specific variables (avoid sourcing to prevent bash keyword conflicts) +DEBUG_ADDRESS=$(grep -E "^DEBUG_ADDRESS=" "$ENV_FILE" | cut -d'=' -f2-) +DEBUG_SIGNATURE=$(grep -E "^DEBUG_SIGNATURE=" "$ENV_FILE" | cut -d'=' -f2-) +DEBUG_API_URL=$(grep -E "^DEBUG_API_URL=" "$ENV_FILE" | cut -d'=' -f2-) + if [ -z "$DEBUG_ADDRESS" ] || [ -z "$DEBUG_SIGNATURE" ]; then - echo "Error: DEBUG_ADDRESS and DEBUG_SIGNATURE must be set in $ENV_FILE" + echo "Error: DEBUG_ADDRESS and DEBUG_SIGNATURE must be set in .env" exit 1 fi diff --git a/scripts/log-debug.sh b/scripts/log-debug.sh index 5e6ca86332..ae22da57b1 100755 --- a/scripts/log-debug.sh +++ b/scripts/log-debug.sh @@ -15,25 +15,31 @@ # -h, --hours Time range in hours (default: 1, max: 168) # # Environment: -# Copy .env.db-debug.sample to .env.db-debug and fill in your credentials +# Uses the central .env file. Required variables: +# - DEBUG_ADDRESS: Wallet address with DEBUG role +# - DEBUG_SIGNATURE: Signature from signing the DFX login message +# - DEBUG_API_URL (optional): API URL, defaults to https://api.dfx.swiss/v1 set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ENV_FILE="$SCRIPT_DIR/.env.db-debug" +ENV_FILE="$SCRIPT_DIR/../.env" # Load environment variables -if [ -f "$ENV_FILE" ]; then - source "$ENV_FILE" -else +if [ ! -f "$ENV_FILE" ]; then echo "Error: Environment file not found: $ENV_FILE" - echo "Copy .env.db-debug.sample to .env.db-debug and fill in your credentials" + echo "Create .env in the api root directory" exit 1 fi +# Read specific variables (avoid sourcing to prevent bash keyword conflicts) +DEBUG_ADDRESS=$(grep -E "^DEBUG_ADDRESS=" "$ENV_FILE" | cut -d'=' -f2-) +DEBUG_SIGNATURE=$(grep -E "^DEBUG_SIGNATURE=" "$ENV_FILE" | cut -d'=' -f2-) +DEBUG_API_URL=$(grep -E "^DEBUG_API_URL=" "$ENV_FILE" | cut -d'=' -f2-) + # Validate required variables if [ -z "$DEBUG_ADDRESS" ] || [ -z "$DEBUG_SIGNATURE" ]; then - echo "Error: DEBUG_ADDRESS and DEBUG_SIGNATURE must be set in $ENV_FILE" + echo "Error: DEBUG_ADDRESS and DEBUG_SIGNATURE must be set in .env" exit 1 fi diff --git a/scripts/setup.js b/scripts/setup.js index b0119ff98d..c75d79ab05 100644 --- a/scripts/setup.js +++ b/scripts/setup.js @@ -490,6 +490,15 @@ async function main() { // Register user const authResponse = await registerUser(adminWallet.address, signature); logSuccess(`User registered (ID: ${authResponse.accessToken ? 'received JWT' : 'no token'})`); + + // Save DEBUG credentials for debug scripts + updateEnvFile({ + DEBUG_ADDRESS: adminWallet.address, + DEBUG_SIGNATURE: signature, + DEBUG_API_URL: API_URL + '/v1', + }); + logSuccess('Debug credentials saved to .env'); + registrationSuccess = true; break; From 36cf65307ebba4596dcf24efef1a050300721aeb Mon Sep 17 00:00:00 2001 From: Lam Nguyen <32935491+xlamn@users.noreply.github.com> Date: Wed, 21 Jan 2026 16:58:15 +0100 Subject: [PATCH 4/7] feat: create RealUnit receipt for one transaction endpoint (#3002) * feat: create transaction/:id/receipt endpoint. * chore: put realunit information into config. * chore: clean logo and createTxStatement function. --- src/config/config.ts | 8 ++- src/shared/utils/pdf.util.ts | 56 ++++++++++------ .../custody/services/custody-pdf.service.ts | 4 +- .../balance/services/balance-pdf.service.ts | 4 +- .../payment/services/swiss-qr.service.ts | 64 ++++++++----------- .../controllers/realunit.controller.ts | 46 ++++++++++++- .../supporting/realunit/realunit.service.ts | 12 ++-- 7 files changed, 124 insertions(+), 70 deletions(-) diff --git a/src/config/config.ts b/src/config/config.ts index 654c72fee4..73478a7f0e 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -914,11 +914,17 @@ export class Configuration { }, bank: { recipient: process.env.REALUNIT_BANK_RECIPIENT ?? 'RealUnit Schweiz AG', - address: process.env.REALUNIT_BANK_ADDRESS ?? 'Schochenmühlestrasse 6, 6340 Baar, Switzerland', iban: process.env.REALUNIT_BANK_IBAN ?? 'CH22 0830 7000 5609 4630 9', bic: process.env.REALUNIT_BANK_BIC ?? 'HYPLCH22XXX', name: process.env.REALUNIT_BANK_NAME ?? 'Hypothekarbank Lenzburg', }, + address: { + street: process.env.REALUNIT_ADDRESS_STREET ?? 'Schochenmühlestrasse', + number: process.env.REALUNIT_ADDRESS_NUMBER ?? '6', + zip: process.env.REALUNIT_ADDRESS_ZIP ?? '6340', + city: process.env.REALUNIT_ADDRESS_CITY ?? 'Baar', + country: process.env.REALUNIT_ADDRESS_COUNTRY ?? 'Switzerland', + }, }, ebel2x: { contractAddress: process.env.EBEL2X_CONTRACT_ADDRESS, diff --git a/src/shared/utils/pdf.util.ts b/src/shared/utils/pdf.util.ts index b1386f52c1..3a5aab288d 100644 --- a/src/shared/utils/pdf.util.ts +++ b/src/shared/utils/pdf.util.ts @@ -3,6 +3,7 @@ import PDFDocument from 'pdfkit'; import { Asset } from 'src/shared/models/asset/asset.entity'; import { PdfLanguage } from 'src/subdomains/supporting/balance/dto/input/get-balance-pdf.dto'; import { PriceCurrency } from 'src/subdomains/supporting/pricing/services/pricing.service'; +import { mm2pt } from 'swissqrbill/utils'; import { dfxLogoBall1, dfxLogoBall2, dfxLogoText } from './logos/dfx-logo'; import { realunitLogoColor, realunitLogoPath } from './logos/realunit-logo'; @@ -11,6 +12,11 @@ export enum PdfBrand { REALUNIT = 'REALUNIT', } +export enum LogoSize { + SMALL = 'SMALL', + LARGE = 'LARGE', +} + export interface BalanceEntry { asset: Asset; balance: number; @@ -19,19 +25,43 @@ export interface BalanceEntry { } export class PdfUtil { - static drawLogo(pdf: InstanceType, brand: PdfBrand = PdfBrand.DFX): void { + static drawLogo( + pdf: InstanceType, + brand: PdfBrand = PdfBrand.DFX, + size: LogoSize = LogoSize.SMALL, + ): void { + const { x, y, scale } = this.getLogoConfig(size); + + pdf.save(); + pdf.translate(x, y); + pdf.scale(scale); + if (brand === PdfBrand.REALUNIT) { - this.drawRealUnitLogo(pdf); + this.drawRealUnitLogoPath(pdf); } else { - this.drawDfxLogo(pdf); + this.drawDfxLogoPath(pdf); + } + + pdf.restore(); + + // Extra vertical offset for RealUnit small logo + if (brand === PdfBrand.REALUNIT && size === LogoSize.SMALL) { + pdf.translate(0, 30); } } - private static drawDfxLogo(pdf: InstanceType): void { - pdf.save(); - pdf.translate(50, 30); - pdf.scale(0.12); + private static getLogoConfig(size: LogoSize): { x: number; y: number; scale: number } { + if (size === LogoSize.LARGE) { + return { x: mm2pt(20), y: mm2pt(14), scale: 0.15 }; + } + return { x: 50, y: 30, scale: 0.12 }; + } + + private static drawRealUnitLogoPath(pdf: InstanceType): void { + pdf.path(realunitLogoPath).fill(realunitLogoColor); + } + private static drawDfxLogoPath(pdf: InstanceType): void { const gradient1 = pdf.linearGradient(122.111, 64.6777, 45.9618, 103.949); gradient1 .stop(0.04, '#F5516C') @@ -47,18 +77,6 @@ export class PdfUtil { pdf.path(dfxLogoBall1).fill(gradient1); pdf.path(dfxLogoBall2).fill(gradient2); pdf.path(dfxLogoText).fill('#072440'); - pdf.restore(); - } - - private static drawRealUnitLogo(pdf: InstanceType): void { - pdf.save(); - pdf.translate(50, 30); - pdf.scale(0.12); - pdf.path(realunitLogoPath).fill(realunitLogoColor); - - pdf.restore(); - - pdf.translate(0, 30); } static drawTable( diff --git a/src/subdomains/core/custody/services/custody-pdf.service.ts b/src/subdomains/core/custody/services/custody-pdf.service.ts index feabaabf62..fe9a50bc3b 100644 --- a/src/subdomains/core/custody/services/custody-pdf.service.ts +++ b/src/subdomains/core/custody/services/custody-pdf.service.ts @@ -3,7 +3,7 @@ import { I18nService } from 'nestjs-i18n'; import PDFDocument from 'pdfkit'; import { UserRole } from 'src/shared/auth/user-role.enum'; import { Asset } from 'src/shared/models/asset/asset.entity'; -import { BalanceEntry, PdfUtil } from 'src/shared/utils/pdf.util'; +import { BalanceEntry, LogoSize, PdfBrand, PdfUtil } from 'src/shared/utils/pdf.util'; import { Util } from 'src/shared/utils/util'; import { UserDataService } from 'src/subdomains/generic/user/models/user-data/user-data.service'; import { PdfLanguage } from 'src/subdomains/supporting/balance/dto/input/get-balance-pdf.dto'; @@ -125,7 +125,7 @@ export class CustodyPdfService { resolve(base64PDF); }); - PdfUtil.drawLogo(pdf); + PdfUtil.drawLogo(pdf, PdfBrand.DFX, LogoSize.SMALL); this.drawHeader(pdf, dto, language, verifiedName); PdfUtil.drawTable(pdf, balances, dto.currency, language, this.i18n); PdfUtil.drawFooter(pdf, totalValue, hasIncompleteData, dto.currency, language, this.i18n); diff --git a/src/subdomains/supporting/balance/services/balance-pdf.service.ts b/src/subdomains/supporting/balance/services/balance-pdf.service.ts index 341ba6f4ad..c3367e7445 100644 --- a/src/subdomains/supporting/balance/services/balance-pdf.service.ts +++ b/src/subdomains/supporting/balance/services/balance-pdf.service.ts @@ -7,7 +7,7 @@ import { EvmUtil } from 'src/integration/blockchain/shared/evm/evm.util'; import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; import { AssetService } from 'src/shared/models/asset/asset.service'; import { DfxLogger } from 'src/shared/services/dfx-logger'; -import { BalanceEntry, PdfBrand, PdfUtil } from 'src/shared/utils/pdf.util'; +import { BalanceEntry, LogoSize, PdfBrand, PdfUtil } from 'src/shared/utils/pdf.util'; import { Util } from 'src/shared/utils/util'; import { AssetPricesService } from '../../pricing/services/asset-prices.service'; import { CoinGeckoService } from '../../pricing/services/integration/coin-gecko.service'; @@ -165,7 +165,7 @@ export class BalancePdfService { resolve(base64PDF); }); - PdfUtil.drawLogo(pdf, brand); + PdfUtil.drawLogo(pdf, brand, LogoSize.SMALL); this.drawHeader(pdf, dto, language); PdfUtil.drawTable(pdf, balances, dto.currency, language, this.i18n); PdfUtil.drawFooter(pdf, totalValue, hasIncompleteData, dto.currency, language, this.i18n); diff --git a/src/subdomains/supporting/payment/services/swiss-qr.service.ts b/src/subdomains/supporting/payment/services/swiss-qr.service.ts index 7acc5336ec..35dc122690 100644 --- a/src/subdomains/supporting/payment/services/swiss-qr.service.ts +++ b/src/subdomains/supporting/payment/services/swiss-qr.service.ts @@ -3,6 +3,7 @@ import { I18nService } from 'nestjs-i18n'; import PDFDocument from 'pdfkit'; import { Config } from 'src/config/config'; import { AssetService } from 'src/shared/models/asset/asset.service'; +import { LogoSize, PdfBrand, PdfUtil } from 'src/shared/utils/pdf.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 { PDFColumn, PDFRow, SwissQRBill, Table } from 'swissqrbill/pdf'; @@ -14,15 +15,6 @@ import { TransactionType } from '../dto/transaction.dto'; import { TransactionRequest } from '../entities/transaction-request.entity'; import { Transaction } from '../entities/transaction.entity'; -const dfxLogoBall1 = - 'M86.1582 126.274C109.821 126.274 129.004 107.092 129.004 83.4287C129.004 59.7657 109.821 40.583 86.1582 40.583C62.4952 40.583 43.3126 59.7657 43.3126 83.4287C43.3126 107.092 62.4952 126.274 86.1582 126.274Z'; - -const dfxLogoBall2 = - 'M47.1374 132.146C73.1707 132.146 94.2748 111.042 94.2748 85.009C94.2748 58.9757 73.1707 37.8716 47.1374 37.8716C21.1041 37.8716 0 58.9757 0 85.009C0 111.042 21.1041 132.146 47.1374 132.146Z'; - -const dfxLogoText = - 'M61.5031 0H124.245C170.646 0 208.267 36.5427 208.267 84.0393C208.267 131.536 169.767 170.018 122.288 170.018H61.5031V135.504H114.046C141.825 135.504 164.541 112.789 164.541 85.009C164.541 57.2293 141.825 34.5136 114.046 34.5136H61.5031V0ZM266.25 31.5686V76.4973H338.294V108.066H266.25V170H226.906V0H355.389V31.5686H266.25ZM495.76 170L454.71 110.975L414.396 170H369.216L432.12 83.5365L372.395 0H417.072L456.183 55.1283L494.557 0H537.061L477.803 82.082L541.191 170H495.778H495.76Z'; - enum SupportedInvoiceLanguage { DE = 'DE', EN = 'EN', @@ -88,14 +80,10 @@ export class SwissQRService { return this.generatePdfInvoice(tableData, language, data, true, TransactionType.BUY); } - async createTxStatement({ - statementType, - transactionType, - transaction, - currency, - bankInfo, - reference, - }: TxStatementDetails): Promise { + async createTxStatement( + { statementType, transactionType, transaction, currency, bankInfo, reference }: TxStatementDetails, + brand: PdfBrand = PdfBrand.DFX, + ): Promise { const debtor = this.getDebtor(transaction.userData); if (!debtor) throw new Error('Debtor is required'); @@ -108,15 +96,16 @@ export class SwissQRService { const language = this.isSupportedInvoiceLanguage(userLanguage) ? userLanguage : 'EN'; const tableData = await this.getTableData(statementType, transactionType, transaction, currency); + const defaultCreditor = brand === PdfBrand.REALUNIT ? this.realunitCreditor() : this.dfxCreditor(); const billData: QrBillData = { - creditor: (bankInfo && this.getCreditor(bankInfo)) || (this.dfxCreditor() as unknown as Creditor), + creditor: (bankInfo && this.getCreditor(bankInfo)) || (defaultCreditor as unknown as Creditor), debtor, currency, amount: bankInfo && transaction.buyCrypto?.inputAmount, message: reference, }; - return this.generatePdfInvoice(tableData, language, billData, !!bankInfo, transactionType); + return this.generatePdfInvoice(tableData, language, billData, !!bankInfo, transactionType, brand); } private generatePdfInvoice( @@ -125,6 +114,7 @@ export class SwissQRService { billData: QrBillData, includeQrBill: boolean, transactionType: TransactionType, + brand: PdfBrand = PdfBrand.DFX, ): Promise { return new Promise((resolve, reject) => { try { @@ -141,26 +131,10 @@ export class SwissQRService { }); // Logo - pdf.save(); - pdf.translate(mm2pt(20), mm2pt(14)); - pdf.scale(0.15); - const gradient1 = pdf.linearGradient(122.111, 64.6777, 45.9618, 103.949); - gradient1 - .stop(0.04, '#F5516C') - .stop(0.14, '#C74863') - .stop(0.31, '#853B57') - .stop(0.44, '#55324E') - .stop(0.55, '#382D49') - .stop(0.61, '#2D2B47'); - const gradient2 = pdf.linearGradient(75.8868, 50.7468, 15.2815, 122.952); - gradient2.stop(0.2, '#F5516C').stop(1, '#6B3753'); - pdf.path(dfxLogoBall1).fill(gradient1); - pdf.path(dfxLogoBall2).fill(gradient2); - pdf.path(dfxLogoText).fill('#072440'); - pdf.restore(); - - // Sender address (always DFX AG) - const sender = this.dfxCreditor(); + 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'); @@ -378,6 +352,18 @@ export class SwissQRService { } as Creditor; } + private realunitCreditor(): Creditor { + const { bank, address } = Config.blockchain.realunit; + return { + name: bank.recipient, + address: address.street, + buildingNumber: address.number, + zip: address.zip, + city: address.city, + country: 'CH', + } as Creditor; + } + private isSupportedInvoiceLanguage(lang: string): lang is SupportedInvoiceLanguage { return Object.keys(SupportedInvoiceLanguage).includes(lang); } diff --git a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts index 267c1e6516..11b7df4b03 100644 --- a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts +++ b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts @@ -1,4 +1,16 @@ -import { Body, Controller, Get, HttpStatus, Param, Post, Put, Query, Res, UseGuards } from '@nestjs/common'; +import { + BadRequestException, + Body, + Controller, + Get, + HttpStatus, + Param, + Post, + Put, + Query, + Res, + UseGuards, +} from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { ApiAcceptedResponse, @@ -29,6 +41,9 @@ import { PdfBrand } from 'src/shared/utils/pdf.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 { RealUnitBalancePdfDto } from '../dto/realunit-balance-pdf.dto'; import { RealUnitRegistrationDto, @@ -58,6 +73,8 @@ export class RealUnitController { private readonly realunitService: RealUnitService, private readonly balancePdfService: BalancePdfService, private readonly userService: UserService, + private readonly transactionHelper: TransactionHelper, + private readonly swissQrService: SwissQRService, ) {} @Get('account/:address') @@ -148,6 +165,33 @@ export class RealUnitController { return { pdfData }; } + // --- Receipt PDF Endpoint --- + + @Put('transaction/:id/receipt') + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.USER), UserActiveGuard()) + @ApiOperation({ + description: 'Generates a PDF receipt for a completed RealUnit transaction', + }) + @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 { + const user = await this.userService.getUser(jwt.user, { userData: true }); + + const txStatementDetails = await this.transactionHelper.getTxStatementDetails( + user.userData.id, + +id, + TxStatementType.RECEIPT, + ); + + 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) }; + } + // --- Brokerbot Endpoints --- @Get('brokerbot/info') diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts index 3d539bf539..8d6ff9eb94 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -242,18 +242,18 @@ export class RealUnitService { }); // 5. Override recipient info with RealUnit company address - const { bank: realunitBank } = GetConfig().blockchain.realunit; + const { bank: realunitBank, address: realunitAddress } = GetConfig().blockchain.realunit; const response: RealUnitPaymentInfoDto = { id: buyPaymentInfo.id, routeId: buyPaymentInfo.routeId, timestamp: buyPaymentInfo.timestamp, // Override recipient fields with RealUnit company address name: realunitBank.recipient, - street: 'Schochenmühlestrasse', - number: '6', - zip: '6340', - city: 'Baar', - country: 'Switzerland', + street: realunitAddress.street, + number: realunitAddress.number, + zip: realunitAddress.zip, + city: realunitAddress.city, + country: realunitAddress.country, // Bank info from BuyService iban: buyPaymentInfo.iban, bic: buyPaymentInfo.bic, From 71684b4cd01ae8656bf1dcd244519514efa3fbe9 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 21 Jan 2026 17:05:09 +0100 Subject: [PATCH 5/7] fix(scripts): use /auth endpoint for debug scripts (#3019) * refactor(scripts): use central .env for debug scripts - db-debug.sh and log-debug.sh now use the central .env file - Remove separate .env.db-debug.sample file - Update documentation in script headers DEBUG_ADDRESS, DEBUG_SIGNATURE, and DEBUG_API_URL should now be added to the main .env file instead of a separate config. * feat(setup): auto-save DEBUG credentials to .env Setup script now saves DEBUG_ADDRESS, DEBUG_SIGNATURE, and DEBUG_API_URL to .env after registering the admin user, so debug scripts work immediately. * fix(scripts): read env vars safely without sourcing Avoid sourcing .env file directly to prevent bash keyword conflicts (e.g. mnemonics containing 'else', 'if', etc.) * fix(scripts): use /auth endpoint instead of /auth/signIn --- scripts/db-debug.sh | 2 +- scripts/log-debug.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/db-debug.sh b/scripts/db-debug.sh index ad2c2dadc9..33fb41a7d3 100755 --- a/scripts/db-debug.sh +++ b/scripts/db-debug.sh @@ -127,7 +127,7 @@ API_URL="${DEBUG_API_URL:-https://api.dfx.swiss/v1}" # --- Authenticate --- echo "=== Authenticating to $API_URL ===" -TOKEN_RESPONSE=$(curl -s -X POST "$API_URL/auth/signIn" \ +TOKEN_RESPONSE=$(curl -s -X POST "$API_URL/auth" \ -H "Content-Type: application/json" \ -d "{\"address\":\"$DEBUG_ADDRESS\",\"signature\":\"$DEBUG_SIGNATURE\"}") diff --git a/scripts/log-debug.sh b/scripts/log-debug.sh index ae22da57b1..8250b3ba31 100755 --- a/scripts/log-debug.sh +++ b/scripts/log-debug.sh @@ -66,7 +66,7 @@ done # Get JWT Token echo "=== Authenticating to $API_URL ===" -TOKEN_RESPONSE=$(curl -s -X POST "$API_URL/auth/signIn" \ +TOKEN_RESPONSE=$(curl -s -X POST "$API_URL/auth" \ -H "Content-Type: application/json" \ -d "{\"address\":\"$DEBUG_ADDRESS\",\"signature\":\"$DEBUG_SIGNATURE\"}") From 58cb74d61f3fe42bbf0768da2ad6c28e84d0dc18 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 21 Jan 2026 17:19:12 +0100 Subject: [PATCH 6/7] feat(buy): add automatic VIBAN for CHF users with KYC 50+ (#3020) - CHF users with KYC level 50+ now automatically get a VIBAN - CHF users below KYC 50 continue using normal bank selection - EUR rules remain unchanged (VIBAN mandatory, KYC 50+ required) --- .../core/buy-crypto/routes/buy/buy.service.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/subdomains/core/buy-crypto/routes/buy/buy.service.ts b/src/subdomains/core/buy-crypto/routes/buy/buy.service.ts index 0ff457b9d6..0fcab8ebe6 100644 --- a/src/subdomains/core/buy-crypto/routes/buy/buy.service.ts +++ b/src/subdomains/core/buy-crypto/routes/buy/buy.service.ts @@ -393,6 +393,17 @@ export class BuyService { return this.buildVirtualIbanResponse(virtualIban, selector.userData, buy?.bankUsage); } + // CHF: VIBAN for KYC 50+ + if (selector.currency === 'CHF' && selector.userData.kycLevel >= KycLevel.LEVEL_50) { + let virtualIban = await this.virtualIbanService.getActiveForUserAndCurrency(selector.userData, selector.currency); + + if (!virtualIban) { + virtualIban = await this.virtualIbanService.createForUser(selector.userData, selector.currency); + } + + return this.buildVirtualIbanResponse(virtualIban, selector.userData, buy?.bankUsage); + } + // user-level personal IBAN const virtualIban = await this.virtualIbanService.getActiveForUserAndCurrency(selector.userData, selector.currency); From c6f1eed4fb11a09c4c922e793ee26aa6f34b2ae6 Mon Sep 17 00:00:00 2001 From: David May <85513542+davidleomay@users.noreply.github.com> Date: Wed, 21 Jan 2026 17:58:18 +0100 Subject: [PATCH 7/7] chore: code cleanup (#3021) * chore: code cleanup * fix: improved bank webhook error log --- .../core/buy-crypto/routes/buy/buy.service.ts | 34 +++++-------------- .../bank-transaction-handler.service.ts | 9 +++-- 2 files changed, 16 insertions(+), 27 deletions(-) diff --git a/src/subdomains/core/buy-crypto/routes/buy/buy.service.ts b/src/subdomains/core/buy-crypto/routes/buy/buy.service.ts index 0fcab8ebe6..ead87290f8 100644 --- a/src/subdomains/core/buy-crypto/routes/buy/buy.service.ts +++ b/src/subdomains/core/buy-crypto/routes/buy/buy.service.ts @@ -378,37 +378,21 @@ export class BuyService { } } - // EUR: VIBAN is mandatory - if (selector.currency === 'EUR') { - let virtualIban = await this.virtualIbanService.getActiveForUserAndCurrency(selector.userData, selector.currency); - - if (!virtualIban) { - if (selector.userData.kycLevel < KycLevel.LEVEL_50) { - throw new BadRequestException('KycRequired'); - } + // user-level vIBAN + let virtualIban = await this.virtualIbanService.getActiveForUserAndCurrency(selector.userData, selector.currency); - virtualIban = await this.virtualIbanService.createForUser(selector.userData, selector.currency); - } - - return this.buildVirtualIbanResponse(virtualIban, selector.userData, buy?.bankUsage); + // EUR/CHF: create vIBAN for KYC 50+ + if (!virtualIban && ['EUR', 'CHF'].includes(selector.currency) && selector.userData.kycLevel >= KycLevel.LEVEL_50) { + virtualIban = await this.virtualIbanService.createForUser(selector.userData, selector.currency); } - // CHF: VIBAN for KYC 50+ - if (selector.currency === 'CHF' && selector.userData.kycLevel >= KycLevel.LEVEL_50) { - let virtualIban = await this.virtualIbanService.getActiveForUserAndCurrency(selector.userData, selector.currency); - - if (!virtualIban) { - virtualIban = await this.virtualIbanService.createForUser(selector.userData, selector.currency); - } - + if (virtualIban) { return this.buildVirtualIbanResponse(virtualIban, selector.userData, buy?.bankUsage); } - // user-level personal IBAN - const virtualIban = await this.virtualIbanService.getActiveForUserAndCurrency(selector.userData, selector.currency); - - if (virtualIban) { - return this.buildVirtualIbanResponse(virtualIban, selector.userData, buy?.bankUsage); + // EUR: vIBAN is mandatory + if (selector.currency === 'EUR') { + throw new BadRequestException('KycRequired'); } // normal bank selection diff --git a/src/subdomains/supporting/bank-tx/bank-tx/services/bank-transaction-handler.service.ts b/src/subdomains/supporting/bank-tx/bank-tx/services/bank-transaction-handler.service.ts index 9e04989c3c..5991f605ee 100644 --- a/src/subdomains/supporting/bank-tx/bank-tx/services/bank-transaction-handler.service.ts +++ b/src/subdomains/supporting/bank-tx/bank-tx/services/bank-transaction-handler.service.ts @@ -25,15 +25,20 @@ export class BankTransactionHandler implements OnModuleInit { } private async handleTransaction(event: BankTransactionEvent): Promise { + const { bankTxData } = event; + try { const multiAccounts = await this.specialAccountService.getMultiAccounts(); - await this.bankTxService.create(event.bankTxData, multiAccounts); + await this.bankTxService.create(bankTxData, multiAccounts); } catch (e) { if (e instanceof ConflictException) { return; } - this.logger.error('Failed to handle bank webhook transaction:', e); + this.logger.error( + `Failed to handle bank webhook transaction (transaction ${bankTxData.accountServiceRef} on account ${bankTxData.accountIban}):`, + e, + ); } } }