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
Original file line number Diff line number Diff line change
Expand Up @@ -487,7 +487,12 @@ export class BuyCryptoPreparationService {
const chargebackAllowedBy = 'API';

if (entity.bankTx) {
await this.buyCryptoService.refundBankTx(entity, { chargebackAllowedDate, chargebackAllowedBy });
if (
Util.includesSameName(entity.userData.verifiedName, entity.creditorData.name) ||
Util.includesSameName(entity.userData.completeName, entity.creditorData.name) ||
(!entity.userData.verifiedName && !entity.userData.completeName)
)
await this.buyCryptoService.refundBankTx(entity, { chargebackAllowedDate, chargebackAllowedBy });
} else if (entity.cryptoInput) {
await this.buyCryptoService.refundCryptoInput(entity, { chargebackAllowedDate, chargebackAllowedBy });
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -559,7 +559,7 @@ export class BuyCryptoService {
)
throw new BadRequestException('IBAN not valid or BIC not available');

const creditorData = dto.creditorData ?? buyCrypto.creditorData;
const creditorData = buyCrypto.creditorData ?? dto.creditorData;
if ((dto.chargebackAllowedDate || dto.chargebackAllowedDateUser) && !creditorData)
throw new BadRequestException('Creditor data is required for chargeback');

Expand Down
27 changes: 14 additions & 13 deletions src/subdomains/core/history/controllers/transaction.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,8 @@ export class TransactionController {
const refundData = this.refundList.get(transaction.id);
if (!refundData) throw new BadRequestException('Request refund data first');
if (!this.isRefundDataValid(refundData)) throw new BadRequestException('Refund data request invalid');
if (refundData.refundTarget && dto.refundTarget)
throw new BadRequestException('RefundTarget is already set with refundData');

await this.executeRefund(transaction, transaction.targetEntity, refundData, dto);

Expand Down Expand Up @@ -597,7 +599,7 @@ export class TransactionController {
if (!dto.creditorData) throw new BadRequestException('Creditor data is required for bank refunds');

return this.buyCryptoService.refundBankTx(targetEntity, {
refundIban: dto.refundTarget ?? refundData.refundTarget,
refundIban: refundData.refundTarget ?? dto.refundTarget,
creditorData: dto.creditorData,
chargebackReferenceAmount: refundData.refundPrice.invert().convert(refundData.refundAmount),
...refundDto,
Expand All @@ -615,19 +617,18 @@ export class TransactionController {
private async getRefundTarget(transaction: Transaction): Promise<string | undefined> {
if (transaction.refundTargetEntity instanceof BuyFiat) return transaction.refundTargetEntity.chargebackAddress;

// For bank transactions, always return the original IBAN - refund must go to the sender
if (transaction.bankTx?.iban) return transaction.bankTx.iban;

// For BuyCrypto with checkout (card), return masked card number
if (transaction.refundTargetEntity instanceof BuyCrypto && transaction.refundTargetEntity.checkoutTx)
return `${transaction.refundTargetEntity.checkoutTx.cardBin}****${transaction.refundTargetEntity.checkoutTx.cardLast4}`;

// For other cases, return existing chargeback IBAN
if (transaction.refundTargetEntity instanceof BankTx) return transaction.bankTx?.iban;
if (transaction.refundTargetEntity instanceof BuyCrypto) return transaction.refundTargetEntity.chargebackIban;
if (transaction.refundTargetEntity instanceof BankTxReturn) return transaction.refundTargetEntity.chargebackIban;
try {
if (transaction.bankTx && (await this.validateIban(transaction.bankTx.iban))) return transaction.bankTx.iban;
} catch (_) {
return transaction.refundTargetEntity instanceof BankTx
? undefined
: transaction.refundTargetEntity?.chargebackIban;
}

return undefined;
if (transaction.refundTargetEntity instanceof BuyCrypto)
return transaction.refundTargetEntity.checkoutTx
? `${transaction.refundTargetEntity.checkoutTx.cardBin}****${transaction.refundTargetEntity.checkoutTx.cardLast4}`
: transaction.refundTargetEntity.chargebackIban;
}

private async validateIban(iban: string): Promise<boolean> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export class ExchangeObserver extends MetricObserver<ExchangeData[]> {
const xtDeurBtcPrice = await this.pricingService.getPriceFrom(PriceSource.XT, 'BTC', 'DEURO');
const xtDepsUsdtPrice = await this.pricingService.getPriceFrom(PriceSource.XT, 'USDT', 'DEPS');
const xtDepsBtcPrice = await this.pricingService.getPriceFrom(PriceSource.XT, 'BTC', 'DEPS');
const xtJusdUsdtPrice = await this.pricingService.getPriceFrom(PriceSource.XT, 'USDT', 'JUSD');

const referenceDeurUsdtPrice = await this.pricingService.getPrice(
usdt,
Expand All @@ -83,6 +84,7 @@ export class ExchangeObserver extends MetricObserver<ExchangeData[]> {
const referenceDeurBtcPrice = await this.pricingService.getPrice(btc, PriceCurrency.EUR, PriceValidity.VALID_ONLY);
const referenceDepsUsdtPrice = await this.pricingService.getPriceFrom(PriceSource.DEURO, 'USDT', 'DEPS');
const referenceDepsBtcPrice = await this.pricingService.getPriceFrom(PriceSource.DEURO, 'BTC', 'DEPS');
const referenceJusdUsdtPrice = await this.pricingService.getPriceFrom(PriceSource.JUICE, 'USDT', 'JUSD');

return [
{
Expand All @@ -101,6 +103,10 @@ export class ExchangeObserver extends MetricObserver<ExchangeData[]> {
name: 'XT-DEPS-BTC',
deviation: Util.round(xtDepsBtcPrice.price / referenceDepsBtcPrice.price - 1, 3),
},
{
name: 'XT-JUSD-USDT',
deviation: Util.round(xtJusdUsdtPrice.price / referenceJusdUsdtPrice.price - 1, 3),
},
];
}

Expand Down
1 change: 1 addition & 0 deletions src/subdomains/generic/kyc/services/kyc.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ export class KycService {
KycError.NATIONALITY_NOT_MATCHING,
KycError.IP_COUNTRY_MISMATCH,
KycError.COUNTRY_IP_COUNTRY_MISMATCH,
KycError.RESIDENCE_PERMIT_CHECK_REQUIRED,
].includes(e),
)
)
Expand Down
12 changes: 11 additions & 1 deletion src/subdomains/generic/user/models/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { MailKey, MailTranslationKey } from 'src/subdomains/supporting/notificat
import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service';
import { FeeService } from 'src/subdomains/supporting/payment/services/fee.service';
import { CustodyProviderService } from '../custody-provider/custody-provider.service';
import { RecommendationMethod, RecommendationType } from '../recommendation/recommendation.entity';
import { RecommendationService } from '../recommendation/recommendation.service';
import { UserData } from '../user-data/user-data.entity';
import { KycType, TradeApprovalReason, UserDataStatus } from '../user-data/user-data.enum';
Expand Down Expand Up @@ -179,7 +180,16 @@ export class AuthService {

if (dto.recommendationCode) await this.confirmRecommendationCode(dto.recommendationCode, user.userData);

if (!user.userData.tradeApprovalDate) await this.checkPendingRecommendation(user.userData, wallet);
if (!user.userData.tradeApprovalDate) {
await this.checkPendingRecommendation(user.userData, wallet);
} else {
const recommendation = await this.recommendationService.getUserDataRecommendation(user.userData.id, {
isConfirmed: true,
type: RecommendationType.INVITATION,
method: RecommendationMethod.MAIL,
});
await this.recommendationService.setRecommenderRefCode(recommendation);
}

await this.checkIpBlacklistFor(user.userData, userIp);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ 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';
import { IsNull, MoreThan } from 'typeorm';
import { FindOptionsWhere, IsNull, MoreThan } from 'typeorm';
import { UserData } from '../user-data/user-data.entity';
import { KycLevel, KycType, TradeApprovalReason, UserDataStatus } from '../user-data/user-data.enum';
import { UserDataService } from '../user-data/user-data.service';
Expand Down Expand Up @@ -75,9 +75,6 @@ export class RecommendationService {
})
: undefined;

if (recommended?.tradeApprovalDate)
await this.userDataService.createTradeApprovalLog(recommended, TradeApprovalReason.MAIL_INVITATION);

const entity = await this.createRecommendationInternal(
RecommendationType.INVITATION,
dto.recommendedMail ? RecommendationMethod.MAIL : RecommendationMethod.RECOMMENDATION_CODE,
Expand All @@ -102,6 +99,12 @@ export class RecommendationService {
isConfirmed: true,
confirmationDate: new Date(),
});

if (recommended.tradeApprovalDate) {
await this.userDataService.createTradeApprovalLog(recommended, TradeApprovalReason.MAIL_INVITATION);

await this.setRecommenderRefCode(entity);
}
}

if (dto.recommendedMail) await this.sendInvitationMail(entity);
Expand Down Expand Up @@ -228,20 +231,24 @@ export class RecommendationService {
TradeApprovalReason.RECOMMENDATION_CONFIRMED,
);

const refCode =
entity.kycStep && entity.method === RecommendationMethod.REF_CODE
? entity.kycStep.getResult<KycRecommendationData>().key
: (entity.recommender.users.find((u) => u.ref)?.ref ?? Config.defaultRef);

for (const user of entity.recommended.users ??
(await this.userService.getAllUserDataUsers(entity.recommended.id))) {
if (user.usedRef === Config.defaultRef) await this.userService.updateUserInternal(user, { usedRef: refCode });
}
await this.setRecommenderRefCode(entity);
}

return this.recommendationRepo.save(entity);
}

async setRecommenderRefCode(entity: Recommendation): Promise<void> {
const refCode =
entity.kycStep && entity.method === RecommendationMethod.REF_CODE
? entity.kycStep.getResult<KycRecommendationData>().key
: (entity.recommender.users.find((u) => u.ref)?.ref ?? Config.defaultRef);

for (const user of entity.recommended.users ??
(await this.userService.getAllUserDataUsers(entity.recommended.id))) {
if (user.usedRef === Config.defaultRef) await this.userService.updateUserInternal(user, { usedRef: refCode });
}
}

async getAndCheckRecommendationByCode(code: string): Promise<Recommendation> {
const entity = await this.recommendationRepo.findOne({
where: { code },
Expand All @@ -260,13 +267,26 @@ export class RecommendationService {
return entity;
}

async getAllRecommendationForUserData(userDataId: number): Promise<Recommendation[]> {
async getAllRecommendationForUserData(recommenderId: number): Promise<Recommendation[]> {
return this.recommendationRepo.find({
where: { recommender: { id: userDataId } },
where: { recommender: { id: recommenderId } },
relations: { recommended: true, recommender: true },
});
}

async getUserDataRecommendation(
userDataId: number,
where: FindOptionsWhere<Recommendation>,
): Promise<Recommendation> {
return this.recommendationRepo.findOne({
where: { recommended: { id: userDataId }, ...where },
relations: {
recommended: { users: true },
recommender: { users: true },
},
});
}

async checkAndConfirmRecommendInvitation(recommendedId: number): Promise<Recommendation> {
const entity = await this.recommendationRepo.findOne({
where: { recommended: { id: recommendedId }, isConfirmed: IsNull(), expirationDate: MoreThan(new Date()) },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,15 @@ import { AccountType } from '../account-type.enum';
import { DfxPhoneTransform, IsDfxPhone } from '../is-dfx-phone.validator';
import { KycIdentificationType } from '../kyc-identification-type.enum';
import { UserData } from '../user-data.entity';
import { KycLevel, KycStatus, LegalEntity, RiskStatus, SignatoryPower, UserDataStatus } from '../user-data.enum';
import {
KycLevel,
KycStatus,
LegalEntity,
PhoneCallStatus,
RiskStatus,
SignatoryPower,
UserDataStatus,
} from '../user-data.enum';

export class UpdateUserDataDto {
@IsOptional()
Expand Down Expand Up @@ -316,4 +324,8 @@ export class UpdateUserDataDto {
@IsDate()
@Type(() => Date)
phoneCallIpCountryCheckDate?: Date;

@IsOptional()
@IsEnum(PhoneCallStatus)
phoneCallStatus?: PhoneCallStatus;
}
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ describe('BankTxReturnService - refundBankTx Creditor Data', () => {
);
});

it('should use dto creditor data when provided (override)', async () => {
it('should use chargeback creditor if set', async () => {
const dto = {
chargebackAllowedDate: new Date(),
chargebackAllowedBy: 'Admin',
Expand All @@ -127,12 +127,12 @@ describe('BankTxReturnService - refundBankTx Creditor Data', () => {
mockBankTxReturn.id,
false,
expect.objectContaining({
name: 'Override Name',
address: 'Override Address',
houseNumber: '99',
zip: '9999',
city: 'Override City',
country: 'DE',
name: 'Max Mustermann',
address: 'Hauptstrasse',
houseNumber: '42',
zip: '3000',
city: 'Bern',
country: 'CH',
}),
);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,6 @@ export class BankTxReturnService {

const entities = await this.bankTxReturnRepo.find({
where: [
{
...baseWhere,
userData: IsNull(),
},
{
...baseWhere,
userData: {
Expand All @@ -71,10 +67,15 @@ export class BankTxReturnService {

for (const entity of entities) {
try {
await this.refundBankTx(entity, {
chargebackAllowedDate: new Date(),
chargebackAllowedBy: 'API',
});
if (
Util.includesSameName(entity.userData.verifiedName, entity.creditorData.name) ||
Util.includesSameName(entity.userData.completeName, entity.creditorData.name) ||
(!entity.userData.verifiedName && !entity.userData.completeName)
)
await this.refundBankTx(entity, {
chargebackAllowedDate: new Date(),
chargebackAllowedBy: 'API',
});
} catch (e) {
this.logger.error(`Failed to chargeback bank-tx-return ${entity.id}:`, e);
}
Expand Down Expand Up @@ -210,7 +211,7 @@ export class BankTxReturnService {
)
throw new BadRequestException('IBAN not valid or BIC not available');

const creditorData = dto.creditorData ?? bankTxReturn.creditorData;
const creditorData = bankTxReturn.creditorData ?? dto.creditorData;
if ((dto.chargebackAllowedDate || dto.chargebackAllowedDateUser) && !creditorData)
throw new BadRequestException('Creditor data is required for chargeback');

Expand Down
5 changes: 3 additions & 2 deletions src/subdomains/supporting/payment/entities/fee.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,8 +207,9 @@ export class Fee extends IEntity {
);
}

verifyForUser(accountType: AccountType, wallet?: Wallet): void {
if (this.isExpired()) throw new BadRequestException('Discount code is expired');
verifyForUser(accountType: AccountType, wallet?: Wallet, userDataId?: number): void {
if (!this.active) throw new BadRequestException('Fee is not active');
if (this.isExpired(userDataId)) throw new BadRequestException('Discount code is expired');
if (this.accountType && this.accountType !== accountType)
throw new BadRequestException('Account Type not matching');
if (this.wallet && wallet && this.wallet.id !== wallet.id) throw new BadRequestException('Wallet not matching');
Expand Down
2 changes: 2 additions & 0 deletions src/subdomains/supporting/payment/services/fee.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,8 @@ export class FeeService {
async addFeeInternal(userData: UserData, feeId: number): Promise<void> {
const cachedFee = await this.getFee(feeId);

cachedFee.verifyForUser(userData.accountType, userData.wallet, userData.id);

await this.feeRepo.update(...cachedFee.increaseUsage(userData.accountType));

await this.userDataService.addFee(userData, cachedFee.id);
Expand Down
Loading