Skip to content

Commit abfb18b

Browse files
TaprootFreakxlamndavidleomayYannick1712
authored
Release: develop -> master (#2873)
* fix: use eth chain for sell endpoint. (#2872) * fix: use eth for delegation check. (#2875) * fix: round FiatOutput amount to 2 decimal places (#2876) Add Util.round(amount, 2) when setting FiatOutput.amount in: - create(): after entity creation from DTO - createInternal(): when calculating from buyFiats and before save - update(): before saving DTO amount Fiat amounts must always be rounded to 2 decimal places for correct bank transaction processing. * fix: case-insensitive bankUsage matching in findMatchingBuy (#2877) * fix: case-insensitive bankUsage matching in findMatchingBuy The remittanceInfo was compared case-sensitively with bankUsage, causing transactions with lowercase usage codes (e.g. 6ed3-090b-25a8) to not match their routes (stored as 6ED3-090B-25A8). Add .toUpperCase() to normalized candidate for consistent matching. * fix: correct order of toUpperCase and O-to-0 replacement Move toUpperCase() before replace(/O/g, '0') so that lowercase 'o' is first converted to 'O', then replaced with '0'. This handles edge cases like '6ed3-o90b-25a8' where user types lowercase 'o' instead of '0'. * Refactoring * Refactoring 2 * [NOTASK] add bankDataUserMismatch auto fail * Bank refund checks (#2880) * fix: improved bank refund * fix: enforce creditor data for bank refunds * fix: tests * feat: add Special ZCHF 0.5% fee for userData 363001 (#2886) * feat: add Special ZCHF 0.5% fee for userData 363001 Add migration that: - Creates new 'Special ZCHF 0.5%' fee (type: Special, rate: 0.005) - Applies to all ZCHF chains: Ethereum, Polygon, Arbitrum, Optimism, BSC, Base - Assigns fee to userData 363001 via individualFees field This reduces the fee for all ZCHF buy/sell/swap transactions from the standard Organization rate (1.99%-2.49%) to 0.5%. * fix: correct blockchainFactor and add financialTypes to fee migration - Change blockchainFactor from 1 to 0 (consistent with Fee 67, 111) - Add financialTypes: 'CHF' (consistent with existing ZCHF fees) - Use unique label 'Special ZCHF 0.5% UserData 363001' to avoid collisions - Fix down() migration: use STUFF/LEFT instead of fragile SUBSTRING --------- Co-authored-by: Lam Nguyen <32935491+xlamn@users.noreply.github.com> Co-authored-by: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Co-authored-by: David May <david.leo.may@gmail.com> Co-authored-by: Yannick1712 <52333989+Yannick1712@users.noreply.github.com> Co-authored-by: David May <85513542+davidleomay@users.noreply.github.com>
2 parents 36bf995 + 7a6c0f8 commit abfb18b

15 files changed

Lines changed: 215 additions & 160 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
const { MigrationInterface, QueryRunner } = require("typeorm");
2+
3+
module.exports = class AddSpecialZchfFeeUserData3630011767886374776 {
4+
name = 'AddSpecialZchfFeeUserData3630011767886374776'
5+
6+
async up(queryRunner) {
7+
// 1. Create Special ZCHF 0.5% fee (consistent with Fee 67, 111)
8+
await queryRunner.query(`
9+
INSERT INTO "dbo"."fee" (
10+
"label", "type", "rate", "fixed", "assets", "active",
11+
"blockchainFactor", "payoutRefBonus", "usages", "txUsages", "financialTypes"
12+
) VALUES (
13+
'Special ZCHF 0.5% UserData 363001', 'Special', 0.005, 0, '251;253;255;256;258;259', 1,
14+
0, 1, 0, 0, 'CHF'
15+
)
16+
`);
17+
18+
// 2. Get the new fee ID and add it to userData 363001
19+
await queryRunner.query(`
20+
UPDATE "dbo"."user_data"
21+
SET "individualFees" = CASE
22+
WHEN "individualFees" IS NULL OR "individualFees" = ''
23+
THEN CAST((SELECT id FROM "dbo"."fee" WHERE "label" = 'Special ZCHF 0.5% UserData 363001') AS VARCHAR)
24+
ELSE "individualFees" + ';' + CAST((SELECT id FROM "dbo"."fee" WHERE "label" = 'Special ZCHF 0.5% UserData 363001') AS VARCHAR)
25+
END
26+
WHERE "id" = 363001
27+
`);
28+
}
29+
30+
async down(queryRunner) {
31+
// 1. Get the fee ID
32+
const feeIdResult = await queryRunner.query(`
33+
SELECT id FROM "dbo"."fee" WHERE "label" = 'Special ZCHF 0.5% UserData 363001'
34+
`);
35+
36+
if (feeIdResult.length > 0) {
37+
const feeId = feeIdResult[0].id.toString();
38+
39+
// 2. Remove fee ID from userData 363001 individualFees
40+
// Handle all cases: only fee, first fee, last fee, middle fee
41+
await queryRunner.query(`
42+
UPDATE "dbo"."user_data"
43+
SET "individualFees" = CASE
44+
WHEN "individualFees" = '${feeId}' THEN NULL
45+
WHEN "individualFees" LIKE '${feeId};%' THEN STUFF("individualFees", 1, LEN('${feeId};'), '')
46+
WHEN "individualFees" LIKE '%;${feeId}' THEN LEFT("individualFees", LEN("individualFees") - LEN(';${feeId}'))
47+
WHEN "individualFees" LIKE '%;${feeId};%' THEN REPLACE("individualFees", ';${feeId};', ';')
48+
ELSE "individualFees"
49+
END
50+
WHERE "id" = 363001
51+
`);
52+
}
53+
54+
// 3. Delete the fee
55+
await queryRunner.query(`
56+
DELETE FROM "dbo"."fee" WHERE "label" = 'Special ZCHF 0.5% UserData 363001'
57+
`);
58+
}
59+
}

src/integration/blockchain/shared/evm/delegation/eip7702-delegation.service.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,22 @@
11
import { Injectable } from '@nestjs/common';
2+
import { GetConfig } from 'src/config/config';
3+
import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum';
4+
import { Asset } from 'src/shared/models/asset/asset.entity';
5+
import { DfxLogger } from 'src/shared/services/dfx-logger';
26
import {
7+
Address,
8+
Chain,
39
createPublicClient,
410
createWalletClient,
5-
encodeFunctionData,
611
encodeAbiParameters,
7-
http,
8-
parseAbi,
12+
encodeFunctionData,
913
encodePacked,
1014
Hex,
11-
Address,
12-
Chain,
15+
http,
16+
parseAbi,
1317
} from 'viem';
1418
import { privateKeyToAccount, signTypedData } from 'viem/accounts';
15-
import { mainnet, arbitrum, optimism, polygon, base, bsc, gnosis, sepolia } from 'viem/chains';
16-
import { GetConfig } from 'src/config/config';
17-
import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum';
18-
import { Asset } from 'src/shared/models/asset/asset.entity';
19-
import { DfxLogger } from 'src/shared/services/dfx-logger';
19+
import { arbitrum, base, bsc, gnosis, mainnet, optimism, polygon, sepolia } from 'viem/chains';
2020
import { WalletAccount } from '../domain/wallet-account';
2121
import { EvmUtil } from '../evm.util';
2222
import DELEGATION_MANAGER_ABI from './delegation-manager.abi.json';
@@ -84,7 +84,7 @@ export class Eip7702DelegationService {
8484
* RealUnit app supports eth_sign (unlike MetaMask), so EIP-7702 works
8585
*/
8686
isDelegationSupportedForRealUnit(blockchain: Blockchain): boolean {
87-
return blockchain === Blockchain.BASE && CHAIN_CONFIG[blockchain] !== undefined;
87+
return blockchain === Blockchain.ETHEREUM && CHAIN_CONFIG[blockchain] !== undefined;
8888
}
8989

9090
/**

src/subdomains/core/aml/enums/aml-error.enum.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,11 @@ export const AmlErrorResult: {
200200
amlCheck: CheckStatus.GSHEET,
201201
amlReason: null,
202202
},
203-
[AmlError.BANK_DATA_USER_MISMATCH]: null,
203+
[AmlError.BANK_DATA_USER_MISMATCH]: {
204+
type: AmlErrorType.CRUCIAL,
205+
amlCheck: CheckStatus.FAIL,
206+
amlReason: AmlReason.USER_DATA_MISMATCH,
207+
},
204208
[AmlError.VIRTUAL_IBAN_USER_MISMATCH]: {
205209
type: AmlErrorType.CRUCIAL,
206210
amlCheck: CheckStatus.GSHEET,

src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -533,15 +533,10 @@ export class BuyCrypto extends IEntity {
533533
chargebackOutput?: FiatOutput,
534534
chargebackRemittanceInfo?: string,
535535
blockchainFee?: number,
536-
creditorData?: {
537-
name?: string;
538-
address?: string;
539-
houseNumber?: string;
540-
zip?: string;
541-
city?: string;
542-
country?: string;
543-
},
536+
creditorData?: CreditorData,
544537
): UpdateResult<BuyCrypto> {
538+
const hasCreditorData = creditorData && Object.values(creditorData).some((v) => v != null);
539+
545540
const update: Partial<BuyCrypto> = {
546541
chargebackDate: chargebackAllowedDate ? new Date() : null,
547542
chargebackAllowedDate,
@@ -556,7 +551,7 @@ export class BuyCrypto extends IEntity {
556551
blockchainFee,
557552
isComplete: this.checkoutTx && chargebackAllowedDate ? true : undefined,
558553
status: this.checkoutTx && chargebackAllowedDate ? BuyCryptoStatus.COMPLETE : undefined,
559-
chargebackCreditorData: creditorData ? JSON.stringify(creditorData) : undefined,
554+
chargebackCreditorData: hasCreditorData ? JSON.stringify(creditorData) : undefined,
560555
};
561556

562557
Object.assign(this, update);

src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,10 @@ export class BuyCryptoService {
275275
});
276276

277277
if (dto.chargebackAllowedDate) {
278-
if (entity.bankTx && !entity.chargebackOutput)
278+
if (entity.bankTx && !entity.chargebackOutput) {
279+
if (!dto.chargebackCreditorName && !entity.creditorData)
280+
throw new BadRequestException('Creditor data is required for chargeback');
281+
279282
update.chargebackOutput = await this.fiatOutputService.createInternal(
280283
FiatOutputType.BUY_CRYPTO_FAIL,
281284
{ buyCrypto: entity },
@@ -293,6 +296,7 @@ export class BuyCryptoService {
293296
country: dto.chargebackCreditorCountry ?? entity.creditorData?.country,
294297
},
295298
);
299+
}
296300

297301
if (entity.checkoutTx) {
298302
await this.refundCheckoutTx(entity, { chargebackAllowedDate: new Date(), chargebackAllowedBy: 'GS' });
@@ -541,6 +545,10 @@ export class BuyCryptoService {
541545
)
542546
throw new BadRequestException('IBAN not valid or BIC not available');
543547

548+
const creditorData = dto.creditorData ?? buyCrypto.creditorData;
549+
if ((dto.chargebackAllowedDate || dto.chargebackAllowedDateUser) && !creditorData)
550+
throw new BadRequestException('Creditor data is required for chargeback');
551+
544552
if (dto.chargebackAllowedDate && chargebackAmount)
545553
dto.chargebackOutput = await this.fiatOutputService.createInternal(
546554
FiatOutputType.BUY_CRYPTO_FAIL,
@@ -551,12 +559,7 @@ export class BuyCryptoService {
551559
iban: chargebackIban,
552560
amount: chargebackAmount,
553561
currency: buyCrypto.bankTx?.currency,
554-
name: dto.name ?? buyCrypto.creditorData?.name,
555-
address: dto.address ?? buyCrypto.creditorData?.address,
556-
houseNumber: dto.houseNumber ?? buyCrypto.creditorData?.houseNumber,
557-
zip: dto.zip ?? buyCrypto.creditorData?.zip,
558-
city: dto.city ?? buyCrypto.creditorData?.city,
559-
country: dto.country ?? buyCrypto.creditorData?.country,
562+
...creditorData,
560563
},
561564
);
562565

@@ -570,14 +573,7 @@ export class BuyCryptoService {
570573
dto.chargebackOutput,
571574
buyCrypto.chargebackBankRemittanceInfo,
572575
undefined,
573-
{
574-
name: dto.name,
575-
address: dto.address,
576-
houseNumber: dto.houseNumber,
577-
zip: dto.zip,
578-
city: dto.city,
579-
country: dto.country,
580-
},
576+
creditorData,
581577
),
582578
);
583579
}

src/subdomains/core/history/controllers/transaction.controller.ts

Lines changed: 34 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -390,15 +390,10 @@ export class TransactionController {
390390
@Param('id') id: string,
391391
@Body() dto: TransactionRefundDto,
392392
): Promise<void> {
393-
return this.processRefund(+id, jwt, dto, false);
393+
return this.processRefund(+id, jwt, dto);
394394
}
395395

396-
private async processRefund(
397-
transactionId: number,
398-
jwt: JwtPayload,
399-
dto: TransactionRefundDto,
400-
bankOnly: boolean,
401-
): Promise<void> {
396+
private async processRefund(transactionId: number, jwt: JwtPayload, dto: TransactionRefundDto): Promise<void> {
402397
const transaction = await this.transactionService.getTransactionById(transactionId, {
403398
bankTxReturn: { bankTx: true, chargebackOutput: true },
404399
userData: true,
@@ -412,7 +407,7 @@ export class TransactionController {
412407
checkoutTx: true,
413408
transaction: { userData: true },
414409
});
415-
if (!bankOnly && transaction.type === TransactionTypeInternal.BUY_FIAT)
410+
if (transaction.type === TransactionTypeInternal.BUY_FIAT)
416411
transaction.buyFiat = await this.buyFiatService.getBuyFiatByTransactionId(transaction.id, {
417412
cryptoInput: true,
418413
transaction: { userData: true },
@@ -446,44 +441,32 @@ export class TransactionController {
446441
.then((b) => b.bankTxReturn);
447442
}
448443

449-
// Build refund data with optional bank fields (include all provided fields)
444+
// Build creditorData from BankRefundDto (for backwards compatibility)
450445
const bankDto = dto as BankRefundDto;
451-
const bankFields = {
452-
name: bankDto.name || undefined,
453-
address: bankDto.address || undefined,
454-
houseNumber: bankDto.houseNumber || undefined,
455-
zip: bankDto.zip || undefined,
456-
city: bankDto.city || undefined,
457-
country: bankDto.country || undefined,
458-
};
446+
const creditorData = bankDto.name
447+
? {
448+
name: bankDto.name,
449+
address: bankDto.address,
450+
houseNumber: bankDto.houseNumber,
451+
zip: bankDto.zip,
452+
city: bankDto.city,
453+
country: bankDto.country,
454+
}
455+
: undefined;
459456

460457
if (transaction.targetEntity instanceof BankTxReturn) {
458+
if (!dto.creditorData) throw new BadRequestException('Creditor data is required for bank refunds');
459+
461460
return this.bankTxReturnService.refundBankTx(transaction.targetEntity, {
462461
refundIban: refundData.refundTarget ?? dto.refundTarget,
463-
...bankFields,
462+
creditorData,
464463
...refundDto,
465464
});
466465
}
467466

468467
if (NotRefundableAmlReasons.includes(transaction.targetEntity.amlReason))
469468
throw new BadRequestException('You cannot refund with this reason');
470469

471-
// Bank-only endpoint restrictions
472-
if (bankOnly) {
473-
if (!(transaction.targetEntity instanceof BuyCrypto))
474-
throw new BadRequestException('This endpoint is only for BuyCrypto bank refunds');
475-
476-
if (!transaction.targetEntity.bankTx && !transaction.bankTx)
477-
throw new BadRequestException('This endpoint is only for bank transaction refunds');
478-
479-
return this.buyCryptoService.refundBankTx(transaction.targetEntity, {
480-
refundIban: refundData.refundTarget ?? dto.refundTarget,
481-
...bankFields,
482-
...refundDto,
483-
});
484-
}
485-
486-
// General refund endpoint - handles all types
487470
if (transaction.targetEntity instanceof BuyFiat)
488471
return this.buyFiatService.refundBuyFiatInternal(transaction.targetEntity, {
489472
refundUserAddress: dto.refundTarget,
@@ -499,12 +482,17 @@ export class TransactionController {
499482
if (transaction.targetEntity.checkoutTx)
500483
return this.buyCryptoService.refundCheckoutTx(transaction.targetEntity, { ...refundDto });
501484

485+
// BuyCrypto bank refund
486+
if (!dto.creditorData) throw new BadRequestException('Creditor data is required for bank refunds');
487+
502488
return this.buyCryptoService.refundBankTx(transaction.targetEntity, {
503489
refundIban: refundData.refundTarget ?? dto.refundTarget,
490+
creditorData,
504491
...refundDto,
505492
});
506493
}
507494

495+
// Deprecated - use PUT :id/refund with creditorData instead
508496
@Put(':id/refund/bank')
509497
@ApiBearerAuth()
510498
@UseGuards(
@@ -518,7 +506,18 @@ export class TransactionController {
518506
@Param('id') id: string,
519507
@Body() dto: BankRefundDto,
520508
): Promise<void> {
521-
return this.processRefund(+id, jwt, dto, true);
509+
const refundDto: TransactionRefundDto = {
510+
refundTarget: dto.refundTarget,
511+
creditorData: {
512+
name: dto.name,
513+
address: dto.address,
514+
houseNumber: dto.houseNumber,
515+
zip: dto.zip,
516+
city: dto.city,
517+
country: dto.country,
518+
},
519+
};
520+
return this.processRefund(+id, jwt, refundDto);
522521
}
523522

524523
@Put(':id/invoice')

src/subdomains/core/history/dto/refund-internal.dto.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { IsDate, IsIBAN, IsNumber, IsOptional, IsString, ValidateNested } from '
33
import { CheckoutReverse } from 'src/integration/checkout/services/checkout.service';
44
import { EntityDto } from 'src/shared/dto/entity.dto';
55
import { Util } from 'src/shared/utils/util';
6+
import { CreditorData } from 'src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity';
67
import { User } from 'src/subdomains/generic/user/models/user/user.entity';
78
import { FiatOutput } from 'src/subdomains/supporting/fiat-output/fiat-output.entity';
89

@@ -42,14 +43,7 @@ export class BaseRefund {
4243
export class BankTxRefund extends BaseRefund {
4344
refundIban?: string;
4445
chargebackOutput?: FiatOutput;
45-
46-
// Creditor data for FiatOutput
47-
name?: string;
48-
address?: string;
49-
houseNumber?: string;
50-
zip?: string;
51-
city?: string;
52-
country?: string;
46+
creditorData?: CreditorData;
5347
}
5448

5549
export class CheckoutTxRefund extends BaseRefund {

0 commit comments

Comments
 (0)