From cde72e3f2327582d7a5587af9d6b351f7ca711d6 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:59:41 +0200 Subject: [PATCH 01/16] Add audited KYC Check transition --- .../__tests__/user-data.service.spec.ts | 71 ++++++++++++++++++- .../user-data/dto/set-kyc-status-check.dto.ts | 9 +++ .../models/user-data/user-data.controller.ts | 15 ++++ .../user/models/user-data/user-data.entity.ts | 8 +++ .../models/user-data/user-data.service.ts | 40 ++++++++++- 5 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 src/subdomains/generic/user/models/user-data/dto/set-kyc-status-check.dto.ts diff --git a/src/subdomains/generic/user/models/user-data/__tests__/user-data.service.spec.ts b/src/subdomains/generic/user/models/user-data/__tests__/user-data.service.spec.ts index 6da886d253..ca5102e599 100644 --- a/src/subdomains/generic/user/models/user-data/__tests__/user-data.service.spec.ts +++ b/src/subdomains/generic/user/models/user-data/__tests__/user-data.service.spec.ts @@ -48,10 +48,11 @@ import { VirtualIban, VirtualIbanStatus } from 'src/subdomains/supporting/bank/v import { VirtualIbanIssuanceIntentStatus } from 'src/subdomains/supporting/bank/virtual-iban/virtual-iban-issuance-intent-status.enum'; import { VirtualIbanIssuanceIntent } from 'src/subdomains/supporting/bank/virtual-iban/virtual-iban-issuance-intent.entity'; import { KycStep } from 'src/subdomains/generic/kyc/entities/kyc-step.entity'; +import { KycLogType } from 'src/subdomains/generic/kyc/enums/kyc.enum'; import { KycStepName } from 'src/subdomains/generic/kyc/enums/kyc-step-name.enum'; import { ReviewStatus } from 'src/subdomains/generic/kyc/enums/review-status.enum'; import { UserData } from '../user-data.entity'; -import { KycType, UserDataStatus } from '../user-data.enum'; +import { KycStatus, KycType, UserDataStatus } from '../user-data.enum'; import { UserDataRepository } from '../user-data.repository'; import { MERGE_POST_COMMIT_EFFECT_COMPLETED_MARKER, @@ -1634,4 +1635,72 @@ describe('UserDataService', () => { expect(savedArg.id).toBe(1); }); }); + + describe('setKycStatusCheck', () => { + it('writes the audit log before conditionally updating the status', async () => { + const userData = Object.assign(new UserData(), { id: 42, kycStatus: KycStatus.COMPLETED }); + (mergeManager.findOne as jest.Mock).mockResolvedValue(userData); + (mergeManager.update as jest.Mock).mockResolvedValue({ affected: 1 }); + + await service.setKycStatusCheck(42, KycStatus.COMPLETED, 7); + + expect(kycLogService.createLogInternal).toHaveBeenCalledWith( + userData, + KycLogType.MANUAL, + 'KycStatus changed from Completed to Check by user data 7', + mergeManager, + ); + expect(mergeManager.update).toHaveBeenCalledWith( + UserData, + { id: 42, kycStatus: KycStatus.COMPLETED }, + { kycStatus: KycStatus.CHECK }, + ); + expect(kycLogService.createLogInternal.mock.invocationCallOrder[0]).toBeLessThan( + (mergeManager.update as jest.Mock).mock.invocationCallOrder[0], + ); + expect(kycNotificationService.kycChanged).toHaveBeenCalledWith(userData); + expect((mergeManager.update as jest.Mock).mock.invocationCallOrder[0]).toBeLessThan( + kycNotificationService.kycChanged.mock.invocationCallOrder[0], + ); + }); + + it('does not update the status when the audit log fails', async () => { + const userData = Object.assign(new UserData(), { id: 42, kycStatus: KycStatus.COMPLETED }); + (mergeManager.findOne as jest.Mock).mockResolvedValue(userData); + kycLogService.createLogInternal.mockRejectedValue(new Error('Audit unavailable')); + + await expect(service.setKycStatusCheck(42, KycStatus.COMPLETED, 7)).rejects.toThrow('Audit unavailable'); + + expect(mergeManager.update).not.toHaveBeenCalled(); + }); + + it('rejects a stale expected status before writing an audit log', async () => { + const userData = Object.assign(new UserData(), { id: 42, kycStatus: KycStatus.REJECTED }); + (mergeManager.findOne as jest.Mock).mockResolvedValue(userData); + + await expect(service.setKycStatusCheck(42, KycStatus.COMPLETED, 7)).rejects.toBeInstanceOf(ConflictException); + + expect(kycLogService.createLogInternal).not.toHaveBeenCalled(); + expect(mergeManager.update).not.toHaveBeenCalled(); + }); + + it('rolls back the audit log when a concurrent update wins the conditional write', async () => { + const userData = Object.assign(new UserData(), { id: 42, kycStatus: KycStatus.COMPLETED }); + (mergeManager.findOne as jest.Mock).mockResolvedValue(userData); + (mergeManager.update as jest.Mock).mockResolvedValue({ affected: 0 }); + + await expect(service.setKycStatusCheck(42, KycStatus.COMPLETED, 7)).rejects.toBeInstanceOf(ConflictException); + + expect(kycLogService.createLogInternal).toHaveBeenCalledTimes(1); + expect(kycNotificationService.kycChanged).not.toHaveBeenCalled(); + }); + }); + + it('rejects setting Check through the generic update path before reading the account', async () => { + await expect(service.updateUserData(42, { kycStatus: KycStatus.CHECK })).rejects.toThrow( + 'Use the audited KYC status Check transition', + ); + + expect(userDataRepo.findOne).not.toHaveBeenCalled(); + }); }); diff --git a/src/subdomains/generic/user/models/user-data/dto/set-kyc-status-check.dto.ts b/src/subdomains/generic/user/models/user-data/dto/set-kyc-status-check.dto.ts new file mode 100644 index 0000000000..080cd193ad --- /dev/null +++ b/src/subdomains/generic/user/models/user-data/dto/set-kyc-status-check.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEnum } from 'class-validator'; +import { KycStatus } from '../user-data.enum'; + +export class SetKycStatusCheckDto { + @ApiProperty({ enum: KycStatus }) + @IsEnum(KycStatus) + expectedKycStatus: KycStatus; +} diff --git a/src/subdomains/generic/user/models/user-data/user-data.controller.ts b/src/subdomains/generic/user/models/user-data/user-data.controller.ts index df438e7b7e..9c81ced115 100644 --- a/src/subdomains/generic/user/models/user-data/user-data.controller.ts +++ b/src/subdomains/generic/user/models/user-data/user-data.controller.ts @@ -5,6 +5,7 @@ import { ForbiddenException, Get, Param, + ParseIntPipe, Post, Put, Query, @@ -28,6 +29,7 @@ import { UploadFileDto } from 'src/subdomains/generic/user/models/user-data/dto/ import { FeeService } from 'src/subdomains/supporting/payment/services/fee.service'; import { DownloadUserDataDto } from '../user/dto/download-user-data.dto'; import { CreateUserDataDto } from './dto/create-user-data.dto'; +import { SetKycStatusCheckDto } from './dto/set-kyc-status-check.dto'; import { UpdateUserDataDto } from './dto/update-user-data.dto'; import { UserData, UserDataComplianceUpdateCols, UserDataSupportUpdateCols } from './user-data.entity'; import { UserDataStatus } from './user-data.enum'; @@ -62,6 +64,19 @@ export class UserDataController { return this.userDataService.calculateAuditPeriodNumbers(); } + @Put(':id/kycStatus/check') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), RoleGuard(UserRole.COMPLIANCE), UserActiveGuard()) + async setKycStatusCheck( + @GetJwt() jwt: JwtPayload, + @Param('id', ParseIntPipe) id: number, + @Body() dto: SetKycStatusCheckDto, + ): Promise { + if (jwt.account == null) throw new ForbiddenException('Staff account is missing'); + return this.userDataService.setKycStatusCheck(id, dto.expectedKycStatus, jwt.account); + } + @Put(':id') @ApiBearerAuth() @ApiExcludeEndpoint() diff --git a/src/subdomains/generic/user/models/user-data/user-data.entity.ts b/src/subdomains/generic/user/models/user-data/user-data.entity.ts index 822cfc2740..9d88231daa 100644 --- a/src/subdomains/generic/user/models/user-data/user-data.entity.ts +++ b/src/subdomains/generic/user/models/user-data/user-data.entity.ts @@ -884,6 +884,14 @@ export class UserData extends IEntity { return [UserDataStatus.ACTIVE, UserDataStatus.NA].includes(this.status); } + setKycStatusCheck(): UpdateResult { + const update: Partial = { kycStatus: KycStatus.CHECK }; + + Object.assign(this, update); + + return [this.id, update]; + } + get isPaymentKycStatusEnabled(): boolean { return [KycStatus.COMPLETED, KycStatus.NA].includes(this.kycStatus); } 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 0729bfa974..8e4baa54d3 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 @@ -71,7 +71,14 @@ import { UpdateUserDataDto } from './dto/update-user-data.dto'; import { KycIdentificationType } from './kyc-identification-type.enum'; import { UserDataNotificationService } from './user-data-notification.service'; import { UserData } from './user-data.entity'; -import { KycLevel, PhoneCallStatus, ServiceProvider, TradeApprovalReason, UserDataStatus } from './user-data.enum'; +import { + KycLevel, + KycStatus, + PhoneCallStatus, + ServiceProvider, + TradeApprovalReason, + UserDataStatus, +} from './user-data.enum'; import { UserDataRepository } from './user-data.repository'; export const MergedPrefix = 'Merged into '; @@ -341,6 +348,8 @@ export class UserDataService { } async updateUserData(userDataId: number, dto: UpdateUserDataDto): Promise { + if (dto.kycStatus === KycStatus.CHECK) throw new BadRequestException('Use the audited KYC status Check transition'); + const userData = await this.userDataRepo.findOne({ where: { id: userDataId }, relations: { @@ -428,6 +437,35 @@ export class UserDataService { return userData; } + async setKycStatusCheck(userDataId: number, expectedKycStatus: KycStatus, actorUserDataId: number): Promise { + if (expectedKycStatus === KycStatus.CHECK) throw new BadRequestException('KYC status is already Check'); + + const updatedUserData = await this.userDataRepo.manager.transaction(async (manager) => { + const userData = await manager.findOne(UserData, { + where: { id: userDataId }, + }); + if (!userData) throw new NotFoundException('User data not found'); + if (userData.kycStatus !== expectedKycStatus) + throw new ConflictException(`KYC status changed from ${expectedKycStatus} to ${userData.kycStatus}`); + + const previousKycStatus = userData.kycStatus; + const [id, update] = userData.setKycStatusCheck(); + await this.kycLogService.createLogInternal( + userData, + KycLogType.MANUAL, + `KycStatus changed from ${previousKycStatus} to ${KycStatus.CHECK} by user data ${actorUserDataId}`, + manager, + ); + + const result = await manager.update(UserData, { id, kycStatus: expectedKycStatus }, update); + if (result.affected !== 1) throw new ConflictException('KYC status changed concurrently'); + + return userData; + }); + + await this.kycNotificationService.kycChanged(updatedUserData); + } + async downloadUserData(userDataIds: number[], checkOnly = false): Promise { let count = userDataIds.length; const zip = new JSZip(); From 1cff060f112e585e539e07b002594da34b11aa75 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:59:34 +0200 Subject: [PATCH 02/16] Add safe AML review reset workflow --- .../1785584900000-AddBuyCryptoVersion.js | 26 ++ .../process/buy-crypto.controller.ts | 18 +- .../dto/reset-buy-crypto-aml-review.dto.ts | 15 + .../process/entities/buy-crypto.entity.ts | 5 +- .../buy-crypto-batch.service.spec.ts | 53 +++ .../buy-crypto-preparation.service.spec.ts | 141 +++++- .../__tests__/buy-crypto.service.spec.ts | 315 ++++++++++++- .../services/buy-crypto-batch.service.ts | 130 +++++- .../buy-crypto-notification.service.ts | 8 +- .../buy-crypto-preparation.service.ts | 313 ++++++++++--- .../process/services/buy-crypto.service.ts | 435 +++++++++++++----- .../models/user-data/user-data.controller.ts | 2 +- .../fiat-output/fiat-output.service.ts | 7 +- .../__tests__/transaction.service.spec.ts | 29 +- .../payment/services/transaction.service.ts | 18 +- 15 files changed, 1274 insertions(+), 241 deletions(-) create mode 100644 migration/1785584900000-AddBuyCryptoVersion.js create mode 100644 src/subdomains/core/buy-crypto/process/dto/reset-buy-crypto-aml-review.dto.ts diff --git a/migration/1785584900000-AddBuyCryptoVersion.js b/migration/1785584900000-AddBuyCryptoVersion.js new file mode 100644 index 0000000000..dace20ab31 --- /dev/null +++ b/migration/1785584900000-AddBuyCryptoVersion.js @@ -0,0 +1,26 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * Adds an integer optimistic-concurrency token for BuyCrypto writers. Unlike the `updated` timestamp, + * this token round-trips through JavaScript without losing PostgreSQL microsecond precision. + * + * @class + * @implements {MigrationInterface} + */ +module.exports = class AddBuyCryptoVersion1785584900000 { + name = 'AddBuyCryptoVersion1785584900000'; + + /** @param {QueryRunner} queryRunner */ + async up(queryRunner) { + await queryRunner.query('ALTER TABLE "buy_crypto" ADD "version" integer NOT NULL DEFAULT 1'); + await queryRunner.query('ALTER TABLE "buy_crypto" ALTER COLUMN "version" DROP DEFAULT'); + } + + /** @param {QueryRunner} queryRunner */ + async down(queryRunner) { + await queryRunner.query('ALTER TABLE "buy_crypto" DROP COLUMN "version"'); + } +}; diff --git a/src/subdomains/core/buy-crypto/process/buy-crypto.controller.ts b/src/subdomains/core/buy-crypto/process/buy-crypto.controller.ts index 5df2baa0f9..6a49c4f85e 100644 --- a/src/subdomains/core/buy-crypto/process/buy-crypto.controller.ts +++ b/src/subdomains/core/buy-crypto/process/buy-crypto.controller.ts @@ -1,13 +1,16 @@ -import { Body, Controller, Delete, Param, Post, Put, Query, UseGuards } from '@nestjs/common'; +import { Body, Controller, ForbiddenException, Param, ParseIntPipe, Post, Put, Query, UseGuards } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { ApiBearerAuth, ApiExcludeEndpoint, ApiTags } from '@nestjs/swagger'; import { ScorechainScreening } from 'src/integration/scorechain/entities/scorechain-screening.entity'; +import { GetJwt } from 'src/shared/auth/get-jwt.decorator'; +import { JwtPayload } from 'src/shared/auth/jwt-payload.interface'; 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 { RefundInternalDto } from '../../history/dto/refund-internal.dto'; import { ManualAmlCheckDto } from '../../aml/dto/manual-aml-check.dto'; import { AmlSourceType } from '../../aml/entities/transaction-aml-check.entity'; +import { RefundInternalDto } from '../../history/dto/refund-internal.dto'; +import { ResetBuyCryptoAmlReviewDto } from './dto/reset-buy-crypto-aml-review.dto'; import { UpdateBuyCryptoDto } from './dto/update-buy-crypto.dto'; import { BuyCrypto } from './entities/buy-crypto.entity'; import { BuyCryptoWebhookService } from './services/buy-crypto-webhook.service'; @@ -61,12 +64,17 @@ export class BuyCryptoController { return this.buyCryptoService.update(+id, dto, AmlSourceType.MANUAL_UPDATE); } - @Delete(':id/amlCheck') + @Put(':id/amlCheck/reviewReset') @ApiBearerAuth() @ApiExcludeEndpoint() @UseGuards(AuthGuard(), RoleGuard(UserRole.COMPLIANCE), UserActiveGuard()) - async resetAmlCheck(@Param('id') id: string): Promise { - return this.buyCryptoService.resetAmlCheck(+id); + async resetAmlCheckForReview( + @GetJwt() jwt: JwtPayload, + @Param('id', ParseIntPipe) id: number, + @Body() dto: ResetBuyCryptoAmlReviewDto, + ): Promise { + if (jwt.account === null || jwt.account === undefined) throw new ForbiddenException('Staff account is missing'); + return this.buyCryptoService.resetAmlCheckForReview(id, dto, jwt.account); } @Put(':id/amlCheck') diff --git a/src/subdomains/core/buy-crypto/process/dto/reset-buy-crypto-aml-review.dto.ts b/src/subdomains/core/buy-crypto/process/dto/reset-buy-crypto-aml-review.dto.ts new file mode 100644 index 0000000000..1dec357232 --- /dev/null +++ b/src/subdomains/core/buy-crypto/process/dto/reset-buy-crypto-aml-review.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { AmlReason } from 'src/subdomains/core/aml/enums/aml-reason.enum'; +import { CheckStatus } from 'src/subdomains/core/aml/enums/check-status.enum'; +import { IsEnum, ValidateIf } from 'class-validator'; + +export class ResetBuyCryptoAmlReviewDto { + @ApiProperty({ enum: CheckStatus }) + @IsEnum(CheckStatus) + expectedAmlCheck: CheckStatus; + + @ApiProperty({ enum: AmlReason, nullable: true }) + @ValidateIf((_dto, value) => value !== null) + @IsEnum(AmlReason) + expectedAmlReason: AmlReason | null; +} diff --git a/src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity.ts b/src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity.ts index 6d830d02be..6945bb9944 100644 --- a/src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity.ts +++ b/src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity.ts @@ -37,7 +37,7 @@ import { SpecialExternalAccount } from 'src/subdomains/supporting/payment/entiti import { Transaction } from 'src/subdomains/supporting/payment/entities/transaction.entity'; import { Price, PriceStep } from 'src/subdomains/supporting/pricing/domain/entities/price'; import { PriceCurrency } from 'src/subdomains/supporting/pricing/services/pricing.service'; -import { Column, Entity, Index, JoinColumn, ManyToOne, OneToOne } from 'typeorm'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToOne, VersionColumn } from 'typeorm'; import { AmlReason } from '../../../aml/enums/aml-reason.enum'; import { CheckStatus } from '../../../aml/enums/check-status.enum'; import { ScorechainOutcome } from '../../../aml/enums/scorechain-outcome.enum'; @@ -71,6 +71,9 @@ export interface CreditorData { @Entity() export class BuyCrypto extends IEntity { + @VersionColumn() + version: number; + // References @OneToOne(() => BankTx, { nullable: true }) @JoinColumn() diff --git a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-batch.service.spec.ts b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-batch.service.spec.ts index 51a3c7053a..b2a5c514f9 100644 --- a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-batch.service.spec.ts +++ b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-batch.service.spec.ts @@ -16,10 +16,12 @@ import { FeeService } from 'src/subdomains/supporting/payment/services/fee.servi import { PayoutService } from 'src/subdomains/supporting/payout/services/payout.service'; import { Price } from 'src/subdomains/supporting/pricing/domain/entities/price'; import { PricingService } from 'src/subdomains/supporting/pricing/services/pricing.service'; +import { EntityManager } from 'typeorm'; import { createCustomBuyCryptoBatch } from '../../entities/__mocks__/buy-crypto-batch.entity.mock'; import { createCustomBuyCryptoFee } from '../../entities/__mocks__/buy-crypto-fee.entity.mock'; import { createCustomBuyCrypto, createDefaultBuyCrypto } from '../../entities/__mocks__/buy-crypto.entity.mock'; import { BuyCryptoBatch } from '../../entities/buy-crypto-batch.entity'; +import { BuyCrypto, BuyCryptoStatus } from '../../entities/buy-crypto.entity'; import { BuyCryptoBatchRepository } from '../../repositories/buy-crypto-batch.repository'; import { BuyCryptoRepository } from '../../repositories/buy-crypto.repository'; import { BuyCryptoBatchService } from '../buy-crypto-batch.service'; @@ -93,6 +95,20 @@ describe('BuyCryptoBatchService', () => { expect(dexServiceCheckLiquidity).toBeCalledTimes(1); }); + it('does not save a batch when the transaction changed after the batch calculations', async () => { + const transaction = createCustomBuyCrypto({ version: 5 }); + jest.spyOn(buyCryptoRepo, 'find').mockResolvedValue([transaction]); + (buyCryptoBatchRepo.manager.findOne as jest.Mock).mockResolvedValueOnce(null); + + await service.batchAndOptimizeTransactions(); + + expect(buyCryptoBatchRepo.manager.findOne).toHaveBeenCalledWith( + BuyCrypto, + expect.objectContaining({ where: expect.objectContaining({ id: transaction.id, version: 5 }) }), + ); + expect(buyCryptoBatchRepo.manager.save).not.toHaveBeenCalled(); + }); + it('blocks creating a batch if there already existing batch for an asset', async () => { const transactions = [createCustomBuyCrypto({ outputAsset: createCustomAsset({ dexName: 'dDOGE' }) })]; @@ -215,6 +231,7 @@ describe('BuyCryptoBatchService', () => { entity.transaction.userData.riskStatus = RiskStatus.BLOCKED; jest.spyOn(buyCryptoRepo, 'find').mockResolvedValue([entity]); + jest.spyOn(buyCryptoRepo, 'update').mockResolvedValue({ affected: 1, raw: [], generatedMaps: [] }); await service.batchAndOptimizeTransactions(); @@ -227,6 +244,26 @@ describe('BuyCryptoBatchService', () => { expect(source).toBe(AmlSourceType.RISK_BLOCK_RESET); expect(previousAmlCheck).toBe(CheckStatus.PASS); }); + + it('does not reactivate or audit a risk-blocked transaction that changed after the search', async () => { + const entity = createCustomBuyCrypto({ + id: 2, + amlCheck: CheckStatus.PASS, + status: BuyCryptoStatus.MISSING_LIQUIDITY, + version: 5, + }); + entity.transaction.userData.riskStatus = RiskStatus.BLOCKED; + jest.spyOn(buyCryptoRepo, 'find').mockResolvedValue([entity]); + jest.spyOn(buyCryptoRepo, 'update').mockResolvedValue({ affected: 0, raw: [], generatedMaps: [] }); + + await service.batchAndOptimizeTransactions(); + + expect(buyCryptoRepo.update).toHaveBeenCalledWith( + expect.objectContaining({ id: 2, version: 5, status: BuyCryptoStatus.MISSING_LIQUIDITY }), + expect.objectContaining({ status: BuyCryptoStatus.CREATED }), + ); + expect(transactionAmlCheckService.createFromEntity).not.toHaveBeenCalled(); + }); }); // --- HELPER FUNCTIONS --- // @@ -270,11 +307,27 @@ describe('BuyCryptoBatchService', () => { function setupSpies() { jest.spyOn(buyCryptoRepo, 'find').mockImplementation(async () => [createDefaultBuyCrypto()]); + jest.spyOn(buyCryptoRepo, 'update').mockResolvedValue({ affected: 1, raw: [], generatedMaps: [] }); + jest.spyOn(buyCryptoBatchRepo, 'findOneBy').mockImplementation(async () => null); jest.spyOn(buyCryptoBatchRepo, 'create').mockImplementation(() => createCustomBuyCryptoBatch({ id: undefined })); jest.spyOn(buyCryptoBatchRepo, 'save').mockImplementation(async (e) => e as BuyCryptoBatch); + const manager = { + findOne: jest.fn().mockResolvedValue({ id: 1 }), + save: jest.fn(async (_type: unknown, entity: unknown) => entity), + update: jest.fn().mockResolvedValue({ affected: 1 }), + }; + Object.defineProperty(buyCryptoBatchRepo, 'manager', { + configurable: true, + value: { + ...manager, + transaction: jest.fn(async (run: (entityManager: EntityManager) => unknown) => + run(manager as unknown as EntityManager), + ), + }, + }); exchangeUtilityServiceGetMatchingPrice = jest .spyOn(pricingService, 'getPrice') diff --git a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-preparation.service.spec.ts b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-preparation.service.spec.ts index b2b74efcd1..b60c8fc1ac 100644 --- a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-preparation.service.spec.ts +++ b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-preparation.service.spec.ts @@ -6,6 +6,7 @@ import { ScorechainScreeningService } from 'src/integration/scorechain/services/ import { SiftService } from 'src/integration/sift/services/sift.service'; import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; import { CountryService } from 'src/shared/models/country/country.service'; +import { createCustomFiat } from 'src/shared/models/fiat/__mocks__/fiat.entity.mock'; import { FiatService } from 'src/shared/models/fiat/fiat.service'; import * as processServiceModule from 'src/shared/services/process.service'; import { TestSharedModule } from 'src/shared/utils/test.shared.module'; @@ -17,12 +18,14 @@ import { TransactionAmlCheckService } from 'src/subdomains/core/aml/services/tra import { ScorechainDocumentService } from 'src/subdomains/generic/kyc/services/scorechain-document.service'; import { BankService } from 'src/subdomains/supporting/bank/bank/bank.service'; import { VirtualIbanService } from 'src/subdomains/supporting/bank/virtual-iban/virtual-iban.service'; +import { InternalFeeDto } from 'src/subdomains/supporting/payment/dto/fee.dto'; import { TransactionHelper } from 'src/subdomains/supporting/payment/services/transaction-helper'; import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; +import { Price } from 'src/subdomains/supporting/pricing/domain/entities/price'; import { PricingService } from 'src/subdomains/supporting/pricing/services/pricing.service'; -import { IsNull } from 'typeorm'; +import { EntityManager, IsNull } from 'typeorm'; import { createCustomBuyCrypto } from '../../entities/__mocks__/buy-crypto.entity.mock'; -import { BuyCryptoStatus } from '../../entities/buy-crypto.entity'; +import { BuyCrypto, BuyCryptoStatus } from '../../entities/buy-crypto.entity'; import { BuyCryptoRepository } from '../../repositories/buy-crypto.repository'; import { BuyCryptoNotificationService } from '../buy-crypto-notification.service'; import { BuyCryptoPreparationService } from '../buy-crypto-preparation.service'; @@ -97,6 +100,63 @@ describe('BuyCryptoPreparationService', () => { expect(service).toBeDefined(); }); + describe('refreshFee', () => { + it('does not recreate fee data when AML was reset after the transaction search', async () => { + new ConfigService(); + const entity = createCustomBuyCrypto({ + amlCheck: CheckStatus.PASS, + batch: null, + fee: undefined, + status: BuyCryptoStatus.MISSING_LIQUIDITY, + version: 5, + }); + const fiat = createCustomFiat({ name: 'EUR' }); + const fee: InternalFeeDto = { + fees: [], + min: 0, + rate: 0.01, + fixed: 0, + bank: 0, + bankFixed: 0, + bankPercent: 0, + partner: 0, + network: 0, + total: 1, + payoutRefBonus: false, + }; + const manager = { + findOne: jest + .fn() + .mockResolvedValueOnce({ id: entity.id }) + .mockResolvedValueOnce({ + ...entity, + amlCheck: null, + }), + save: jest.fn(), + update: jest.fn(), + }; + Object.defineProperty(buyCryptoRepo, 'manager', { + configurable: true, + value: { + transaction: jest.fn(async (run: (entityManager: EntityManager) => unknown) => + run(manager as unknown as EntityManager), + ), + }, + }); + jest.spyOn(buyCryptoRepo, 'find').mockResolvedValue([entity]); + jest.spyOn(fiatService, 'getFiatByName').mockResolvedValue(fiat); + jest.spyOn(pricingService, 'getPrice').mockResolvedValue(Price.create('EUR', 'CHF', 1)); + jest.spyOn(transactionHelper, 'getTxFeeInfos').mockResolvedValue(fee); + + await service.refreshFee(); + + expect(manager.findOne).toHaveBeenCalledTimes(2); + expect(manager.save).not.toHaveBeenCalled(); + expect(manager.update).not.toHaveBeenCalled(); + expect(transactionAmlCheckService.createFromEntity).not.toHaveBeenCalled(); + }); + }); + function arrangeAmlCheck(entity: ReturnType) { jest.spyOn(buyCryptoRepo, 'find').mockResolvedValue([entity]); jest.spyOn(fiatService, 'getFiatByName').mockResolvedValue({} as any); @@ -117,6 +177,49 @@ describe('BuyCryptoPreparationService', () => { } as any); // bypass the heavy getAmlResult/getAmlErrors internals; we only test the persistence guard jest.spyOn(entity, 'amlCheckAndFillUp').mockReturnValue([entity.id, { amlCheck: CheckStatus.PASS }] as any); + jest + .spyOn(service as any, 'postProcessAmlVerdict') + .mockImplementation(async (current: BuyCrypto, last30dVolume: number, isFirstRun: boolean) => { + await amlService.postProcessing(current, last30dVolume, isFirstRun); + if (current.amlCheck === CheckStatus.PASS) { + await buyCryptoRepo.update(current.id, { amlPostProcessed: true }); + current.amlPostProcessed = true; + } + return true; + }); + jest + .spyOn(service as any, 'persistAmlDecision') + .mockImplementation( + async ( + current: BuyCrypto, + id: number, + update: Partial, + versionBefore: number, + statusBefore: BuyCryptoStatus, + amlCheckBefore: CheckStatus | undefined, + amlReasonBefore: BuyCrypto['amlReason'], + ) => { + const result = await buyCryptoRepo.update( + { + id, + version: versionBefore, + status: statusBefore, + isComplete: false, + amlCheck: amlCheckBefore == null ? IsNull() : amlCheckBefore, + }, + update, + ); + if (!result.affected) return false; + await transactionAmlCheckService.createFromEntity( + current, + 'BuyCrypto', + AmlSourceType.AML_CHECK_CRON, + amlCheckBefore, + amlReasonBefore, + ); + return true; + }, + ); } describe('screenScorechain (Scorechain AML gate)', () => { @@ -431,6 +534,40 @@ describe('BuyCryptoPreparationService', () => { expect(buyCryptoRepo.update).not.toHaveBeenCalledWith(1, { amlPostProcessed: true }); }); + it('skips PASS post-processing when the transaction changed before the retry acquired its lock', async () => { + const entity = createCustomBuyCrypto({ + id: 1, + version: 5, + status: BuyCryptoStatus.MISSING_LIQUIDITY, + amlCheck: CheckStatus.PASS, + amlPostProcessed: false, + }); + const manager = { + findOne: jest.fn().mockResolvedValue(null), + update: jest.fn(), + }; + Object.defineProperty(buyCryptoRepo, 'manager', { + configurable: true, + value: { + transaction: jest.fn(async (run: (entityManager: EntityManager) => unknown) => + run(manager as unknown as EntityManager), + ), + }, + }); + + await expect((service as any).postProcessAmlVerdict(entity, 100, false)).resolves.toBe(false); + + expect(manager.findOne).toHaveBeenCalledWith( + BuyCrypto, + expect.objectContaining({ + where: expect.objectContaining({ id: 1, version: 5, amlCheck: CheckStatus.PASS }), + lock: { mode: 'pessimistic_write' }, + }), + ); + expect(amlService.postProcessing).not.toHaveBeenCalled(); + expect(manager.update).not.toHaveBeenCalled(); + }); + it('marks amlPostProcessed=true after a first-time PASS is post-processed (normal path)', async () => { const entity = createCustomBuyCrypto({ id: 1, amlCheck: null, cryptoInput: undefined, bankTx: undefined }); arrangeAmlCheck(entity); diff --git a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts index 699469b8b8..036e30e1cb 100644 --- a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts +++ b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts @@ -1,5 +1,5 @@ import { createMock } from '@golevelup/ts-jest'; -import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { CheckoutService } from 'src/integration/checkout/services/checkout.service'; @@ -10,7 +10,8 @@ import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entit import { AssetService } from 'src/shared/models/asset/asset.service'; import { FiatService } from 'src/shared/models/fiat/fiat.service'; import { TestSharedModule } from 'src/shared/utils/test.shared.module'; -import { AmlSourceType } from 'src/subdomains/core/aml/entities/transaction-aml-check.entity'; +import { AmlSourceType, TransactionAmlCheck } from 'src/subdomains/core/aml/entities/transaction-aml-check.entity'; +import { AmlReason } from 'src/subdomains/core/aml/enums/aml-reason.enum'; import { CheckStatus } from 'src/subdomains/core/aml/enums/check-status.enum'; import { AmlService } from 'src/subdomains/core/aml/services/aml.service'; import { TransactionAmlCheckService } from 'src/subdomains/core/aml/services/transaction-aml-check.service'; @@ -21,6 +22,8 @@ import { BuyFiatService } from 'src/subdomains/core/sell-crypto/process/services import { TransactionUtilService } from 'src/subdomains/core/transaction/transaction-util.service'; import { ScorechainDocumentService } from 'src/subdomains/generic/kyc/services/scorechain-document.service'; import { BankDataService } from 'src/subdomains/generic/user/models/bank-data/bank-data.service'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { KycStatus } from 'src/subdomains/generic/user/models/user-data/user-data.enum'; import { UserDataService } from 'src/subdomains/generic/user/models/user-data/user-data.service'; import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; import { BankTxService } from 'src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service'; @@ -28,16 +31,19 @@ import { FiatOutputService } from 'src/subdomains/supporting/fiat-output/fiat-ou import { CheckoutTxService } from 'src/subdomains/supporting/fiat-payin/services/checkout-tx.service'; import { createCustomCryptoInput } from 'src/subdomains/supporting/payin/entities/__mocks__/crypto-input.entity.mock'; import { PayInService } from 'src/subdomains/supporting/payin/services/payin.service'; +import { Transaction } from 'src/subdomains/supporting/payment/entities/transaction.entity'; import { SpecialExternalAccountService } from 'src/subdomains/supporting/payment/services/special-external-account.service'; import { TransactionHelper } from 'src/subdomains/supporting/payment/services/transaction-helper'; import { TransactionRequestService } from 'src/subdomains/supporting/payment/services/transaction-request.service'; import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; +import { EntityManager } from 'typeorm'; import { BuyRepository } from '../../../routes/buy/buy.repository'; import { BuyService } from '../../../routes/buy/buy.service'; import { createCustomBuyHistory } from '../../../routes/buy/dto/__mocks__/buy-history.dto.mock'; import { UpdateBuyCryptoDto } from '../../dto/update-buy-crypto.dto'; import { createCustomBuyCrypto } from '../../entities/__mocks__/buy-crypto.entity.mock'; -import { BuyCrypto } from '../../entities/buy-crypto.entity'; +import { BuyCrypto, BuyCryptoStatus } from '../../entities/buy-crypto.entity'; +import { BuyCryptoFee } from '../../entities/buy-crypto-fees.entity'; import { BuyCryptoRepository } from '../../repositories/buy-crypto.repository'; import { BuyCryptoNotificationService } from '../buy-crypto-notification.service'; import { BuyCryptoWebhookService } from '../buy-crypto-webhook.service'; @@ -329,6 +335,162 @@ describe('BuyCryptoService', () => { }); describe('amlCheck audit trail', () => { + function reviewResetFixture( + kycStatus = KycStatus.CHECK, + amlCheck = CheckStatus.PASS, + ): { entity: BuyCrypto; manager: Record<'findOne' | 'create' | 'save' | 'update' | 'remove', jest.Mock> } { + const entity = createCustomBuyCrypto({ + id: 7, + amlCheck, + amlReason: AmlReason.NA, + batch: null, + status: BuyCryptoStatus.MISSING_LIQUIDITY, + chargebackOutput: undefined, + chargebackAllowedDate: undefined, + isComplete: false, + fee: Object.assign(new BuyCryptoFee(), { + id: 8, + allowedTotalFeeAmount: 0.5, + feeReferenceAsset: { id: 9 }, + }), + transaction: Object.assign(new Transaction(), { + id: 70, + userData: Object.assign(new UserData(), { id: 42 }), + }), + }); + const manager = { + findOne: jest + .fn() + .mockResolvedValueOnce({ id: 7 }) + .mockResolvedValueOnce(entity) + .mockResolvedValueOnce({ id: 42, kycStatus }) + .mockResolvedValueOnce({ id: 8 }) + .mockResolvedValueOnce(entity.fee), + create: jest.fn((_type: unknown, dto: unknown) => dto), + save: jest.fn().mockResolvedValue(undefined), + update: jest.fn().mockResolvedValue({ affected: 1 }), + remove: jest.fn().mockResolvedValue(undefined), + }; + Object.defineProperty(buyCryptoRepo, 'manager', { + configurable: true, + value: { + transaction: jest.fn(async (run: (entityManager: EntityManager) => unknown) => + run(manager as unknown as EntityManager), + ), + }, + }); + return { entity, manager }; + } + + it('atomically audits a review reset with the authenticated actor before the conditional update', async () => { + const { manager } = reviewResetFixture(); + + await service.resetAmlCheckForReview( + 7, + { expectedAmlCheck: CheckStatus.PASS, expectedAmlReason: AmlReason.NA }, + 99, + ); + + expect(manager.create).toHaveBeenCalledWith( + TransactionAmlCheck, + expect.objectContaining({ + entityType: 'BuyCrypto', + entityId: 7, + source: AmlSourceType.MANUAL_RESET, + previousAmlCheck: CheckStatus.PASS, + previousAmlReason: AmlReason.NA, + amlResponsible: 'UserData 99', + comment: expect.any(String), + }), + ); + const auditPayload = JSON.parse(manager.create.mock.calls[0][1].comment as string); + expect(auditPayload).toMatchObject({ + operation: 'BuyCryptoAmlReviewReset', + before: { amlCheck: CheckStatus.PASS, amlReason: AmlReason.NA, status: BuyCryptoStatus.MISSING_LIQUIDITY }, + after: { amlCheck: null, amlReason: null, status: BuyCryptoStatus.CREATED }, + deletedFee: { id: 8, allowedTotalFeeAmount: 0.5, feeReferenceAssetId: 9 }, + }); + expect(manager.save.mock.invocationCallOrder[0]).toBeLessThan(manager.update.mock.invocationCallOrder[0]); + expect(manager.update).toHaveBeenCalledWith( + BuyCrypto, + expect.objectContaining({ + id: 7, + amlCheck: CheckStatus.PASS, + amlReason: AmlReason.NA, + status: BuyCryptoStatus.MISSING_LIQUIDITY, + isComplete: false, + batch: expect.anything(), + chargebackOutput: expect.anything(), + chargebackAllowedDate: expect.anything(), + chargebackAllowedDateUser: expect.anything(), + }), + expect.objectContaining({ amlCheck: null, amlReason: null }), + ); + }); + + it('rejects a review reset until the user KYC status is Check', async () => { + const { manager } = reviewResetFixture(KycStatus.COMPLETED); + + await expect( + service.resetAmlCheckForReview(7, { expectedAmlCheck: CheckStatus.PASS, expectedAmlReason: AmlReason.NA }, 99), + ).rejects.toThrow(ConflictException); + + expect(manager.save).not.toHaveBeenCalled(); + expect(manager.update).not.toHaveBeenCalled(); + }); + + it('does not reactivate a transaction that was stopped before the review reset acquired its lock', async () => { + const { entity, manager } = reviewResetFixture(); + entity.status = BuyCryptoStatus.STOPPED; + + await expect( + service.resetAmlCheckForReview(7, { expectedAmlCheck: CheckStatus.PASS, expectedAmlReason: AmlReason.NA }, 99), + ).rejects.toThrow(BadRequestException); + + expect(manager.save).not.toHaveBeenCalled(); + expect(manager.update).not.toHaveBeenCalled(); + }); + + it('does not mutate or delete state when the immutable audit event cannot be saved', async () => { + const { manager } = reviewResetFixture(); + manager.save.mockRejectedValue(new Error('audit unavailable')); + + await expect( + service.resetAmlCheckForReview(7, { expectedAmlCheck: CheckStatus.PASS, expectedAmlReason: AmlReason.NA }, 99), + ).rejects.toThrow('audit unavailable'); + + expect(manager.update).not.toHaveBeenCalled(); + expect(manager.remove).not.toHaveBeenCalled(); + }); + + it('rejects a stale review reset before writing the audit event', async () => { + const { manager } = reviewResetFixture(KycStatus.CHECK, CheckStatus.FAIL); + + await expect( + service.resetAmlCheckForReview(7, { expectedAmlCheck: CheckStatus.PASS, expectedAmlReason: AmlReason.NA }, 99), + ).rejects.toThrow(ConflictException); + + expect(manager.save).not.toHaveBeenCalled(); + expect(manager.update).not.toHaveBeenCalled(); + }); + + it('rolls back the audit event when the transaction is stopped concurrently', async () => { + const { manager } = reviewResetFixture(); + manager.update.mockResolvedValue({ affected: 0 }); + + await expect( + service.resetAmlCheckForReview(7, { expectedAmlCheck: CheckStatus.PASS, expectedAmlReason: AmlReason.NA }, 99), + ).rejects.toThrow(ConflictException); + + expect(manager.save).toHaveBeenCalledTimes(1); + expect(manager.update).toHaveBeenCalledWith( + BuyCrypto, + expect.objectContaining({ status: BuyCryptoStatus.MISSING_LIQUIDITY }), + expect.objectContaining({ status: BuyCryptoStatus.CREATED }), + ); + expect(manager.remove).not.toHaveBeenCalled(); + }); + it('records a MANUAL_RESET history row (previous verdict → null) when resetAmlCheckInternal clears the check', async () => { const entity = createCustomBuyCrypto({ id: 7, @@ -339,6 +501,7 @@ describe('BuyCryptoService', () => { chargebackAllowedDate: undefined, isComplete: false, }); + jest.spyOn(buyCryptoRepo, 'update').mockResolvedValue({ affected: 1, raw: [], generatedMaps: [] }); await service.resetAmlCheckInternal(entity, AmlSourceType.MANUAL_RESET); @@ -365,9 +528,15 @@ describe('BuyCryptoService', () => { }); jest.spyOn(buyCryptoRepo, 'findOne').mockResolvedValue(entity); jest.spyOn(buyCryptoRepo, 'create').mockImplementation((dto: any) => Object.assign(new BuyCrypto(), dto)); - // save returns exactly what it is handed, so forceUpdate's amlCheck=undefined clobber survives and the - // test actually exercises update()'s in-memory coalesce (not a DB round-trip that would refill it). - jest.spyOn(buyCryptoRepo, 'save').mockImplementation(async (e) => e as BuyCrypto); + const manager = { + create: jest.fn((_type: unknown, value: unknown) => value), + save: jest.fn(async (_type: unknown, value: BuyCrypto) => value), + }; + jest + .spyOn(service as any, 'runWithVersionLock') + .mockImplementation(async (_id: number, _version: number, run: (manager: EntityManager) => unknown) => + run(manager as unknown as EntityManager), + ); await service.update( 11, @@ -376,13 +545,7 @@ describe('BuyCryptoService', () => { ); // the entity handed to the audit trail carries the persisted (unchanged) PENDING verdict, NOT undefined - expect(transactionAmlCheckService.createFromEntity).toHaveBeenCalledWith( - expect.objectContaining({ amlCheck: CheckStatus.PENDING }), - 'BuyCrypto', - AmlSourceType.MANUAL_UPDATE, - CheckStatus.PENDING, - null, - ); + expect(manager.create).not.toHaveBeenCalled(); }); // Companion: an admin PUT that EXPLICITLY sets amlCheck: null is a genuine verdict clear that save() @@ -397,7 +560,15 @@ describe('BuyCryptoService', () => { }); jest.spyOn(buyCryptoRepo, 'findOne').mockResolvedValue(entity); jest.spyOn(buyCryptoRepo, 'create').mockImplementation((dto: any) => Object.assign(new BuyCrypto(), dto)); - jest.spyOn(buyCryptoRepo, 'save').mockImplementation(async (e) => e as BuyCrypto); + const manager = { + create: jest.fn((_type: unknown, value: unknown) => value), + save: jest.fn(async (_type: unknown, value: BuyCrypto) => value), + }; + jest + .spyOn(service as any, 'runWithVersionLock') + .mockImplementation(async (_id: number, _version: number, run: (manager: EntityManager) => unknown) => + run(manager as unknown as EntityManager), + ); await service.update( 13, @@ -406,13 +577,117 @@ describe('BuyCryptoService', () => { ); // the explicit null verdict change survives the coalesce and is handed to the audit trail as null - expect(transactionAmlCheckService.createFromEntity).toHaveBeenCalledWith( - expect.objectContaining({ amlCheck: null }), - 'BuyCrypto', - AmlSourceType.MANUAL_UPDATE, - CheckStatus.PENDING, - null, + expect(manager.create).toHaveBeenCalledWith( + TransactionAmlCheck, + expect.objectContaining({ + entityType: 'BuyCrypto', + entityId: 13, + source: AmlSourceType.MANUAL_UPDATE, + previousAmlCheck: CheckStatus.PENDING, + amlCheck: null, + }), + ); + }); + + it('rejects a generic compliance update before its callback when the locked version changed', async () => { + const manager = { + findOne: jest.fn().mockResolvedValue({ id: 15, version: 6 }), + }; + Object.defineProperty(buyCryptoRepo, 'manager', { + configurable: true, + value: { + transaction: jest.fn(async (run: (entityManager: EntityManager) => unknown) => + run(manager as unknown as EntityManager), + ), + }, + }); + + const sideEffect = jest.fn(); + await expect((service as any).runWithVersionLock(15, 5, sideEffect)).rejects.toThrow(ConflictException); + + expect(manager.findOne).toHaveBeenCalledWith( + BuyCrypto, + expect.objectContaining({ + where: { id: 15 }, + select: { id: true, version: true }, + lock: { mode: 'pessimistic_write' }, + }), + ); + expect(sideEffect).not.toHaveBeenCalled(); + }); + + it('does not run manual AML post-processing after the saved AML state changed', async () => { + const entity = createCustomBuyCrypto({ + id: 17, + version: 8, + status: BuyCryptoStatus.CREATED, + amlCheck: CheckStatus.PASS, + amlReason: AmlReason.NA, + isComplete: false, + }); + const manager = { findOne: jest.fn().mockResolvedValue(null) }; + Object.defineProperty(buyCryptoRepo, 'manager', { + configurable: true, + value: { + transaction: jest.fn(async (run: (entityManager: EntityManager) => unknown) => + run(manager as unknown as EntityManager), + ), + }, + }); + const postProcess = jest.fn(); + + await expect((service as any).runIfAmlStateCurrent(entity, postProcess)).resolves.toBe(false); + + expect(manager.findOne).toHaveBeenCalledWith( + BuyCrypto, + expect.objectContaining({ + where: expect.objectContaining({ id: 17, version: 8, amlCheck: CheckStatus.PASS }), + lock: { mode: 'pessimistic_write' }, + }), + ); + expect(postProcess).not.toHaveBeenCalled(); + }); + + it('requires the dedicated refund endpoint for checkout refunds in a generic compliance update', async () => { + const entity = createCustomBuyCrypto({ + id: 18, + checkoutTx: { id: 22, paymentId: 'pay-22' } as any, + isComplete: false, + }); + jest.spyOn(buyCryptoRepo, 'findOne').mockResolvedValue(entity); + jest.spyOn(buyCryptoRepo, 'create').mockImplementation((dto: any) => Object.assign(new BuyCrypto(), dto)); + jest + .spyOn(service as any, 'runWithVersionLock') + .mockImplementation(async (_id: number, _version: number, run: (manager: EntityManager) => unknown) => + run({} as EntityManager), + ); + + await expect( + service.update( + 18, + Object.assign(new UpdateBuyCryptoDto(), { chargebackAllowedDate: new Date() }), + AmlSourceType.MANUAL_UPDATE, + ), + ).rejects.toThrow('Checkout refunds must use the dedicated refund endpoint'); + + expect(checkoutService.refundPayment).not.toHaveBeenCalled(); + }); + + it('does not reset AML or reactivate a stopped BuyCrypto after a phone call', async () => { + const entity = createCustomBuyCrypto({ + id: 16, + amlCheck: CheckStatus.FAIL, + amlReason: AmlReason.MANUAL_CHECK_PHONE, + status: BuyCryptoStatus.STOPPED, + isComplete: false, + }); + + await expect(service.resetAmlCheckInternal(entity, AmlSourceType.PHONE_CALL_RESET)).rejects.toThrow( + BadRequestException, ); + + expect(buyCryptoRepo.update).not.toHaveBeenCalled(); + expect(transactionAmlCheckService.createFromEntity).not.toHaveBeenCalled(); }); }); diff --git a/src/subdomains/core/buy-crypto/process/services/buy-crypto-batch.service.ts b/src/subdomains/core/buy-crypto/process/services/buy-crypto-batch.service.ts index b806ea8d21..6513d52cf3 100644 --- a/src/subdomains/core/buy-crypto/process/services/buy-crypto-batch.service.ts +++ b/src/subdomains/core/buy-crypto/process/services/buy-crypto-batch.service.ts @@ -6,6 +6,7 @@ import { DfxLogger, LogLevel } from 'src/shared/services/dfx-logger'; import { DisabledProcess, Process } from 'src/shared/services/process.service'; import { AmountType, Util } from 'src/shared/utils/util'; import { AmlSourceType } from 'src/subdomains/core/aml/entities/transaction-aml-check.entity'; +import { CheckStatus } from 'src/subdomains/core/aml/enums/check-status.enum'; import { TransactionAmlCheckService } from 'src/subdomains/core/aml/services/transaction-aml-check.service'; import { LiquidityManagementOrder } from 'src/subdomains/core/liquidity-management/entities/liquidity-management-order.entity'; import { LiquidityManagementPipeline } from 'src/subdomains/core/liquidity-management/entities/liquidity-management-pipeline.entity'; @@ -93,7 +94,20 @@ export class BuyCryptoBatchService { for (const riskyTx of riskyTxs) { const previousAmlCheck = riskyTx.amlCheck; const previousAmlReason = riskyTx.amlReason; - await this.buyCryptoRepo.update(...riskyTx.resetAmlCheck()); + const previousStatus = riskyTx.status; + const previousVersion = riskyTx.version; + const [, update] = riskyTx.resetAmlCheck(); + const result = await this.buyCryptoRepo.update( + { + id: riskyTx.id, + version: previousVersion, + amlCheck: previousAmlCheck, + amlReason: previousAmlReason === null || previousAmlReason === undefined ? IsNull() : previousAmlReason, + status: previousStatus, + }, + update, + ); + if (result.affected !== 1) continue; await this.transactionAmlCheckService.createFromEntity( riskyTx, 'BuyCrypto', @@ -119,7 +133,8 @@ export class BuyCryptoBatchService { const batches = await this.createBatches(txWithReferenceAmount); for (const batch of batches) { - const savedBatch = await this.buyCryptoBatchRepo.save(batch); + const savedBatch = await this.saveBatchIfTransactionsUnchanged(batch); + if (!savedBatch) continue; this.logger.verbose( `Created buy-crypto batch. Batch ID: ${savedBatch.id}. Asset: ${savedBatch.outputAsset.uniqueName}. Transaction(s) count ${batch.transactions.length}`, ); @@ -129,6 +144,35 @@ export class BuyCryptoBatchService { } } + private async saveBatchIfTransactionsUnchanged(batch: BuyCryptoBatch): Promise { + return this.buyCryptoBatchRepo.manager.transaction(async (manager) => { + for (const transaction of batch.transactions) { + const claim = await manager.findOne(BuyCrypto, { + where: { + id: transaction.id, + version: transaction.version, + amlCheck: CheckStatus.PASS, + status: In([ + BuyCryptoStatus.CREATED, + BuyCryptoStatus.WAITING_FOR_LOWER_FEE, + BuyCryptoStatus.PRICE_INVALID, + BuyCryptoStatus.MISSING_LIQUIDITY, + ]), + priceDefinitionAllowedDate: Not(IsNull()), + batch: IsNull(), + isComplete: false, + }, + select: { id: true }, + loadEagerRelations: false, + lock: { mode: 'pessimistic_write' }, + }); + if (!claim) return undefined; + } + + return manager.save(BuyCryptoBatch, batch); + }); + } + private async defineReferenceAmount(transactions: BuyCrypto[]): Promise { for (const tx of transactions) { try { @@ -394,18 +438,44 @@ export class BuyCryptoBatchService { const minDeficit = Util.round(minTargetAmount - availableTargetAmount, 8); const deficit = Util.round(targetAmount - availableTargetAmount, 8); - await this.setMissingLiquidityStatus(transactions); + if (!(await this.setMissingLiquidityStatus(transactions))) return; // order liquidity try { - const asset = oa; - const pipeline = await this.liquidityService.buyLiquidity(asset.id, minDeficit, deficit, true); - this.logger.info(`Missing buy-crypto liquidity. Liquidity management order created: ${pipeline.id}`); + await this.buyCryptoRepo.manager.transaction(async (manager) => { + for (const transaction of transactions) { + const locked = await manager.findOne(BuyCrypto, { + where: { + id: transaction.id, + version: transaction.version, + amlCheck: CheckStatus.PASS, + status: BuyCryptoStatus.MISSING_LIQUIDITY, + batch: IsNull(), + isComplete: false, + }, + select: { id: true }, + loadEagerRelations: false, + lock: { mode: 'pessimistic_write' }, + }); + if (!locked) return; + } - await this.buyCryptoRepo.update( - { id: In(batch.transactions.map((b) => b.id)) }, - { liquidityPipeline: pipeline }, - ); + const pipeline = await this.liquidityService.buyLiquidity(oa.id, minDeficit, deficit, true); + this.logger.info(`Missing buy-crypto liquidity. Liquidity management order created: ${pipeline.id}`); + + const result = await manager.update( + BuyCrypto, + { + id: In(transactions.map((transaction) => transaction.id)), + amlCheck: CheckStatus.PASS, + status: BuyCryptoStatus.MISSING_LIQUIDITY, + batch: IsNull(), + isComplete: false, + }, + { liquidityPipeline: pipeline }, + ); + if (result.affected !== transactions.length) throw new Error('BuyCrypto changed before pipeline assignment'); + }); } catch (e) { this.logger.info(`Failed to order missing liquidity for asset ${oa.uniqueName}:`, e); @@ -490,25 +560,55 @@ export class BuyCryptoBatchService { private async setWaitingForLowerFeeStatus(transactions: BuyCrypto[]): Promise { for (const tx of transactions) { - await this.buyCryptoRepo.update(...tx.waitingForLowerFee()); + const previousStatus = tx.status; + const previousVersion = tx.version; + const [, update] = tx.waitingForLowerFee(); + const result = await this.buyCryptoRepo.update( + { id: tx.id, version: previousVersion, amlCheck: CheckStatus.PASS, status: previousStatus }, + update, + ); + if (result.affected === 1 && previousVersion !== undefined) tx.version = previousVersion + 1; } } private async setPriceInvalidStatus(transactions: BuyCrypto[]): Promise { for (const tx of transactions) { - await this.buyCryptoRepo.update(...tx.setPriceInvalidStatus()); + const previousStatus = tx.status; + const previousVersion = tx.version; + const [, update] = tx.setPriceInvalidStatus(); + const result = await this.buyCryptoRepo.update( + { id: tx.id, version: previousVersion, amlCheck: CheckStatus.PASS, status: previousStatus }, + update, + ); + if (result.affected === 1 && previousVersion !== undefined) tx.version = previousVersion + 1; } } - private async setMissingLiquidityStatus(transactions: BuyCrypto[]): Promise { + private async setMissingLiquidityStatus(transactions: BuyCrypto[]): Promise { for (const tx of transactions) { - await this.buyCryptoRepo.update(...tx.setMissingLiquidityStatus()); + const previousStatus = tx.status; + const previousVersion = tx.version; + const [, update] = tx.setMissingLiquidityStatus(); + const result = await this.buyCryptoRepo.update( + { id: tx.id, version: previousVersion, amlCheck: CheckStatus.PASS, status: previousStatus }, + update, + ); + if (result.affected !== 1) return false; + if (previousVersion !== undefined) tx.version = previousVersion + 1; } + return true; } private async resetTransactionButKeepState(transactions: BuyCrypto[]): Promise { for (const tx of transactions) { - await this.buyCryptoRepo.update(...tx.resetTransactionButKeepState()); + const previousStatus = tx.status; + const previousVersion = tx.version; + const [, update] = tx.resetTransactionButKeepState(); + const result = await this.buyCryptoRepo.update( + { id: tx.id, version: previousVersion, amlCheck: CheckStatus.PASS, status: previousStatus }, + update, + ); + if (result.affected === 1 && previousVersion !== undefined) tx.version = previousVersion + 1; } } } diff --git a/src/subdomains/core/buy-crypto/process/services/buy-crypto-notification.service.ts b/src/subdomains/core/buy-crypto/process/services/buy-crypto-notification.service.ts index f3b348c72c..1890e7a449 100644 --- a/src/subdomains/core/buy-crypto/process/services/buy-crypto-notification.service.ts +++ b/src/subdomains/core/buy-crypto/process/services/buy-crypto-notification.service.ts @@ -13,7 +13,7 @@ import { REALUNIT_WALLET_NAME, } from 'src/subdomains/supporting/notification/realunit-mail-rules'; import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service'; -import { FindOptionsWhere, In, IsNull, Not } from 'typeorm'; +import { EntityManager, FindOptionsWhere, In, IsNull, Not } from 'typeorm'; import { AmlReason, AmlReasonWithoutReason, KycAmlReasons } from '../../../aml/enums/aml-reason.enum'; import { CheckStatus } from '../../../aml/enums/check-status.enum'; import { BuyCryptoBatch } from '../entities/buy-crypto-batch.entity'; @@ -135,7 +135,7 @@ export class BuyCryptoNotificationService { } } - async paymentProcessing(entity: BuyCrypto): Promise { + async paymentProcessing(entity: BuyCrypto, manager?: EntityManager): Promise { try { if (entity.userData.mail) { await this.notificationService.sendMail({ @@ -165,7 +165,9 @@ export class BuyCryptoNotificationService { }); } - await this.buyCryptoRepo.update(...entity.confirmSentMail()); + const [id, update] = entity.confirmSentMail(); + if (manager) await manager.update(BuyCrypto, id, update); + else await this.buyCryptoRepo.update(id, update); } catch (e) { this.logger.error(`Failed to send buy-crypto processing mail ${entity.id}:`, e); } diff --git a/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts b/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts index 6960a36855..5c58322f9c 100644 --- a/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts +++ b/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts @@ -11,7 +11,7 @@ import { FiatService } from 'src/shared/models/fiat/fiat.service'; import { DfxLogger } from 'src/shared/services/dfx-logger'; import { DisabledProcess, Process } from 'src/shared/services/process.service'; import { AmountType, Util } from 'src/shared/utils/util'; -import { AmlSourceType } from 'src/subdomains/core/aml/entities/transaction-aml-check.entity'; +import { AmlSourceType, TransactionAmlCheck } from 'src/subdomains/core/aml/entities/transaction-aml-check.entity'; import { BlockAmlReasons } from 'src/subdomains/core/aml/enums/aml-reason.enum'; import { ScorechainOutcome } from 'src/subdomains/core/aml/enums/scorechain-outcome.enum'; import { AmlService } from 'src/subdomains/core/aml/services/aml.service'; @@ -33,7 +33,7 @@ import { PriceValidity, PricingService, } from 'src/subdomains/supporting/pricing/services/pricing.service'; -import { FindOptionsWhere, In, IsNull, Not } from 'typeorm'; +import { EntityManager, FindOptionsWhere, In, IsNull, Not } from 'typeorm'; import { CheckStatus } from '../../../aml/enums/check-status.enum'; import { BuyCryptoFee } from '../entities/buy-crypto-fees.entity'; import { BuyCrypto, BuyCryptoStatus } from '../entities/buy-crypto.entity'; @@ -214,8 +214,7 @@ export class BuyCryptoPreparationService { // post-processing. Re-run post-processing ONLY; never recompute the verdict here, so a committed // PASS (including a human reviewer's decision) is never reverted, re-screened or re-billed. if (entity.amlCheck === CheckStatus.PASS && !entity.amlPostProcessed) { - await this.amlService.postProcessing(entity, last30dVolume, isFirstRun); - await this.buyCryptoRepo.update(entity.id, { amlPostProcessed: true }); + await this.postProcessAmlVerdict(entity, last30dVolume, isFirstRun); continue; } @@ -261,42 +260,147 @@ export class BuyCryptoPreparationService { // Atomic guard: persist only if amlCheck is unchanged since it was read, so a concurrent manual // reviewer / refund / reset (NULL or PENDING) is never silently overwritten by the cron. - const { affected } = await this.buyCryptoRepo.update( - { id, amlCheck: amlCheckBefore == null ? IsNull() : amlCheckBefore }, - update, - ); - if (!affected) continue; - - await this.transactionAmlCheckService.createFromEntity( + const versionBefore = entity.version; + const statusBefore = entity.status; + const wasUpdated = await this.persistAmlDecision( entity, - 'BuyCrypto', - AmlSourceType.AML_CHECK_CRON, + id, + update, + versionBefore, + statusBefore, amlCheckBefore, amlReasonBefore, ); + if (!wasUpdated) continue; + if (versionBefore !== undefined) entity.version = versionBefore + 1; - await this.amlService.postProcessing(entity, last30dVolume, isFirstRun); + if (!(await this.postProcessAmlVerdict(entity, last30dVolume, isFirstRun, amlCheckBefore))) continue; + } catch (e) { + this.logger.error(`Error during buy-crypto ${entity.id} AML check:`, e); + } + } + } - // postProcessing's compliance side-effects completed → mark the verdict fully handled so the - // PASS-retry branch above stops re-selecting it. A throw above skips this, leaving the flag - // false so the next run retries. - if (entity.amlCheck === CheckStatus.PASS) { - await this.buyCryptoRepo.update(id, { amlPostProcessed: true }); - entity.amlPostProcessed = true; - } + private async postProcessAmlVerdict( + entity: BuyCrypto, + last30dVolume: number, + isFirstRun: boolean, + amlCheckBefore = entity.amlCheck, + ): Promise { + return this.buyCryptoRepo.manager.transaction(async (manager) => { + const locked = await manager.findOne(BuyCrypto, { + where: { + id: entity.id, + version: entity.version, + status: entity.status, + amlCheck: entity.amlCheck, + amlReason: entity.amlReason == null ? IsNull() : entity.amlReason, + isComplete: false, + }, + select: { id: true }, + loadEagerRelations: false, + lock: { mode: 'pessimistic_write' }, + }); + if (!locked) return false; + + await this.amlService.postProcessing(entity, last30dVolume, isFirstRun); + + if (entity.amlCheck === CheckStatus.PASS) { + const result = await manager.update( + BuyCrypto, + { + id: entity.id, + version: entity.version, + status: entity.status, + amlCheck: CheckStatus.PASS, + amlPostProcessed: false, + isComplete: false, + }, + { amlPostProcessed: true }, + ); + if (result.affected !== 1) return false; + entity.amlPostProcessed = true; + if (entity.version !== undefined) entity.version += 1; + } - if (amlCheckBefore !== entity.amlCheck) await this.buyCryptoWebhookService.triggerWebhook(entity); + if (amlCheckBefore !== entity.amlCheck) await this.buyCryptoWebhookService.triggerWebhook(entity); + if (entity.amlCheck === CheckStatus.PASS && amlCheckBefore === CheckStatus.PENDING) + await this.buyCryptoNotificationService.paymentProcessing(entity, manager); + if (entity.amlCheck === CheckStatus.FAIL) + await this.siftService.buyCryptoTransaction(entity, TransactionStatus.FAILURE); - if (entity.amlCheck === CheckStatus.PASS && amlCheckBefore === CheckStatus.PENDING) - await this.buyCryptoNotificationService.paymentProcessing(entity); + return true; + }); + } - // create sift transaction (non-blocking) - if (entity.amlCheck === CheckStatus.FAIL) - void this.siftService.buyCryptoTransaction(entity, TransactionStatus.FAILURE); - } catch (e) { - this.logger.error(`Error during buy-crypto ${entity.id} AML check:`, e); + private async persistAmlDecision( + entity: BuyCrypto, + id: number, + update: Partial, + versionBefore: number, + statusBefore: BuyCryptoStatus, + amlCheckBefore: CheckStatus | undefined, + amlReasonBefore: BuyCrypto['amlReason'], + ): Promise { + return this.buyCryptoRepo.manager.transaction(async (manager) => { + const { affected } = await manager.update( + BuyCrypto, + { + id, + version: versionBefore, + status: statusBefore, + isComplete: false, + amlCheck: amlCheckBefore == null ? IsNull() : amlCheckBefore, + }, + update, + ); + if (!affected) return false; + + if (amlCheckBefore !== entity.amlCheck || amlReasonBefore !== entity.amlReason) { + const audit = manager.create(TransactionAmlCheck, { + transaction: entity.transaction, + entityType: 'BuyCrypto', + entityId: entity.id, + source: AmlSourceType.AML_CHECK_CRON, + previousAmlCheck: amlCheckBefore, + amlCheck: entity.amlCheck, + previousAmlReason: amlReasonBefore, + amlReason: entity.amlReason, + amlResponsible: entity.amlResponsible, + comment: entity.comment, + priceDefinitionAllowedDate: entity.priceDefinitionAllowedDate, + highRisk: entity.highRisk, + }); + await manager.save(TransactionAmlCheck, audit); } - } + return true; + }); + } + + private async saveAmlAudit( + manager: EntityManager, + entity: BuyCrypto, + source: AmlSourceType, + previousAmlCheck?: CheckStatus, + previousAmlReason?: BuyCrypto['amlReason'], + ): Promise { + if (previousAmlCheck === entity.amlCheck && previousAmlReason === entity.amlReason) return; + + const audit = manager.create(TransactionAmlCheck, { + transaction: entity.transaction, + entityType: 'BuyCrypto', + entityId: entity.id, + source, + previousAmlCheck, + amlCheck: entity.amlCheck, + previousAmlReason, + amlReason: entity.amlReason, + amlResponsible: entity.amlResponsible, + comment: entity.comment, + priceDefinitionAllowedDate: entity.priceDefinitionAllowedDate, + highRisk: entity.highRisk, + }); + await manager.save(TransactionAmlCheck, audit); } async refreshFee(): Promise { @@ -379,27 +483,61 @@ export class BuyCryptoPreparationService { entity.outputAsset, maxNetworkFee, ); - const feeConstraints = entity.fee ?? (await this.buyCryptoRepo.saveFee(BuyCryptoFee.create(entity))); - await this.buyCryptoRepo.updateFee(feeConstraints.id, { allowedTotalFeeAmount: maxNetworkFeeInOutAsset }); - const amlCheckBefore = entity.amlCheck; const amlReasonBefore = entity.amlReason; - - await this.buyCryptoRepo.update( - ...entity.setFeeAndFiatReference( - fee, - isFiat(inputReferenceCurrency) ? fee.min : referenceEurPrice.convert(fee.min, 2), - referenceChfPrice.convert(fee.total, 2), - ), + const statusBefore = entity.status; + const versionBefore = entity.version; + const [, feeAndFiatUpdate] = entity.setFeeAndFiatReference( + fee, + isFiat(inputReferenceCurrency) ? fee.min : referenceEurPrice.convert(fee.min, 2), + referenceChfPrice.convert(fee.total, 2), ); - await this.transactionAmlCheckService.createFromEntity( - entity, - 'BuyCrypto', - AmlSourceType.FEE_TOO_HIGH, - amlCheckBefore, - amlReasonBefore, - ); + const wasUpdated = await this.buyCryptoRepo.manager.transaction(async (manager) => { + const locked = await manager.findOne(BuyCrypto, { + where: { id: entity.id }, + select: { id: true }, + loadEagerRelations: false, + lock: { mode: 'pessimistic_write' }, + }); + if (!locked) return false; + + const current = await manager.findOne(BuyCrypto, { + where: { id: entity.id }, + relations: { fee: true }, + }); + if ( + !current || + current.version !== versionBefore || + current.amlCheck !== amlCheckBefore || + current.status !== statusBefore || + current.isComplete || + current.batch + ) + return false; + + const feeConstraints = current.fee ?? (await manager.save(BuyCryptoFee, BuyCryptoFee.create(current))); + await manager.update(BuyCryptoFee, feeConstraints.id, { + allowedTotalFeeAmount: maxNetworkFeeInOutAsset, + }); + + const result = await manager.update( + BuyCrypto, + { + id: entity.id, + version: versionBefore, + amlCheck: amlCheckBefore, + status: statusBefore, + isComplete: false, + batch: IsNull(), + }, + feeAndFiatUpdate, + ); + if (result.affected !== 1) return false; + await this.saveAmlAudit(manager, entity, AmlSourceType.FEE_TOO_HIGH, amlCheckBefore, amlReasonBefore); + return true; + }); + if (!wasUpdated) continue; if (entity.feeAmountChf != null) { await this.transactionService.updateInternal(entity.transaction, { feeAmountInChf: entity.feeAmountChf }); @@ -506,36 +644,69 @@ export class BuyCryptoPreparationService { outputPrice.timestamp, ); - // create fee constraints const maxNetworkFee = chfPrice.invert().convert(Config.maxBlockchainFee); const maxNetworkFeeInOutAsset = await this.convertNetworkFee(inputCurrency, entity.outputAsset, maxNetworkFee); - const feeConstraints = entity.fee ?? (await this.buyCryptoRepo.saveFee(BuyCryptoFee.create(entity))); - await this.buyCryptoRepo.updateFee(feeConstraints.id, { allowedTotalFeeAmount: maxNetworkFeeInOutAsset }); - const amlCheckBefore = entity.amlCheck; const amlReasonBefore = entity.amlReason; - - await this.buyCryptoRepo.update( - ...entity.setPaymentLinkPayment( - eurPrice.convert(entity.inputAmount, 2), - chfPrice.convert(entity.inputAmount, 2), - feeRate, - totalFee, - chfPrice.convert(totalFee, 5), - inputReferenceAmountMinusFee, - outputReferenceAmount, - paymentLinkFee, - [conversionStep, outputStep], - ), + const statusBefore = entity.status; + const versionBefore = entity.version; + const [, paymentLinkUpdate] = entity.setPaymentLinkPayment( + eurPrice.convert(entity.inputAmount, 2), + chfPrice.convert(entity.inputAmount, 2), + feeRate, + totalFee, + chfPrice.convert(totalFee, 5), + inputReferenceAmountMinusFee, + outputReferenceAmount, + paymentLinkFee, + [conversionStep, outputStep], ); - await this.transactionAmlCheckService.createFromEntity( - entity, - 'BuyCrypto', - AmlSourceType.FEE_TOO_HIGH, - amlCheckBefore, - amlReasonBefore, - ); + const wasUpdated = await this.buyCryptoRepo.manager.transaction(async (manager) => { + const locked = await manager.findOne(BuyCrypto, { + where: { id: entity.id }, + select: { id: true }, + loadEagerRelations: false, + lock: { mode: 'pessimistic_write' }, + }); + if (!locked) return false; + + const current = await manager.findOne(BuyCrypto, { + where: { id: entity.id }, + relations: { fee: true }, + }); + if ( + !current || + current.version !== versionBefore || + current.amlCheck !== amlCheckBefore || + current.status !== statusBefore || + current.isComplete || + current.batch + ) + return false; + + const feeConstraints = current.fee ?? (await manager.save(BuyCryptoFee, BuyCryptoFee.create(current))); + await manager.update(BuyCryptoFee, feeConstraints.id, { + allowedTotalFeeAmount: maxNetworkFeeInOutAsset, + }); + + const result = await manager.update( + BuyCrypto, + { + id: entity.id, + version: versionBefore, + amlCheck: amlCheckBefore, + status: statusBefore, + isComplete: false, + batch: IsNull(), + }, + paymentLinkUpdate, + ); + if (result.affected !== 1) return false; + await this.saveAmlAudit(manager, entity, AmlSourceType.FEE_TOO_HIGH, amlCheckBefore, amlReasonBefore); + return true; + }); + if (!wasUpdated) continue; if (entity.feeAmountChf != null) { await this.transactionService.updateInternal(entity.transaction, { feeAmountInChf: entity.feeAmountChf }); diff --git a/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts b/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts index 2b6a6d4b32..7896c721c5 100644 --- a/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts +++ b/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts @@ -21,6 +21,7 @@ import { FiatService } from 'src/shared/models/fiat/fiat.service'; import { DfxLogger } from 'src/shared/services/dfx-logger'; import { DisabledProcess, Process } from 'src/shared/services/process.service'; import { Util } from 'src/shared/utils/util'; +import { AmlSourceType, TransactionAmlCheck } from 'src/subdomains/core/aml/entities/transaction-aml-check.entity'; import { AmlService } from 'src/subdomains/core/aml/services/aml.service'; import { Swap } from 'src/subdomains/core/buy-crypto/routes/swap/swap.entity'; import { SwapService } from 'src/subdomains/core/buy-crypto/routes/swap/swap.service'; @@ -42,6 +43,7 @@ import { BankDataType } from 'src/subdomains/generic/user/models/bank-data/bank- import { BankDataService } from 'src/subdomains/generic/user/models/bank-data/bank-data.service'; import { CreateBankDataDto } from 'src/subdomains/generic/user/models/bank-data/dto/create-bank-data.dto'; import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { KycStatus } from 'src/subdomains/generic/user/models/user-data/user-data.enum'; import { UserDataService } from 'src/subdomains/generic/user/models/user-data/user-data.service'; import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; import { BankTx } from 'src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity'; @@ -60,9 +62,8 @@ import { TransactionHelper } from 'src/subdomains/supporting/payment/services/tr import { TransactionRequestService } from 'src/subdomains/supporting/payment/services/transaction-request.service'; import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; import { PriceValidity } from 'src/subdomains/supporting/pricing/services/pricing.service'; -import { Between, FindOptionsRelations, In, IsNull, MoreThan, Not } from 'typeorm'; +import { Between, EntityManager, FindOptionsRelations, In, IsNull, MoreThan, Not } from 'typeorm'; import { ManualAmlCheckDto } from '../../../aml/dto/manual-aml-check.dto'; -import { AmlSourceType } from '../../../aml/entities/transaction-aml-check.entity'; import { canManualPass, ManualPassBlacklistErrors } from '../../../aml/enums/aml-error.enum'; import { AmlReason, PhoneAmlReasons } from '../../../aml/enums/aml-reason.enum'; import { CheckStatus } from '../../../aml/enums/check-status.enum'; @@ -71,7 +72,9 @@ import { Buy } from '../../routes/buy/buy.entity'; import { BuyRepository } from '../../routes/buy/buy.repository'; import { BuyService } from '../../routes/buy/buy.service'; import { BuyHistoryDto } from '../../routes/buy/dto/buy-history.dto'; +import { ResetBuyCryptoAmlReviewDto } from '../dto/reset-buy-crypto-aml-review.dto'; import { UpdateBuyCryptoDto } from '../dto/update-buy-crypto.dto'; +import { BuyCryptoFee } from '../entities/buy-crypto-fees.entity'; import { BuyCrypto, BuyCryptoEditableAmlCheck, BuyCryptoStatus } from '../entities/buy-crypto.entity'; import { BuyCryptoRepository } from '../repositories/buy-crypto.repository'; import { BuyCryptoNotificationService } from './buy-crypto-notification.service'; @@ -81,6 +84,20 @@ import { BuyCryptoWebhookService } from './buy-crypto-webhook.service'; export class BuyCryptoService implements OnModuleInit { private readonly logger = new DfxLogger(BuyCryptoService); + private static scalarAuditSnapshot(record: object): Record { + return Object.fromEntries( + Object.entries(record) + .filter( + ([, value]) => + value === null || + value === undefined || + value instanceof Date || + ['string', 'number', 'boolean'].includes(typeof value), + ) + .map(([key, value]) => [key, value instanceof Date ? value.toISOString() : value]), + ); + } + constructor( private readonly buyCryptoRepo: BuyCryptoRepository, private readonly buyRepo: BuyRepository, @@ -135,6 +152,7 @@ export class BuyCryptoService implements OnModuleInit { transaction: { userData: { id: userData.id } }, amlCheck: CheckStatus.FAIL, amlReason: In(PhoneAmlReasons), + status: Not(BuyCryptoStatus.STOPPED), isComplete: false, chargebackAllowedDate: IsNull(), }); @@ -313,85 +331,107 @@ export class BuyCryptoService implements OnModuleInit { if (!update.bankData) throw new NotFoundException('BankData not found'); } - if ((dto.bankDataApproved != null || dto.bankDataManualApproved != null) && (update.bankData || entity.bankData)) - await this.bankDataService.updateBankData(update.bankData?.id ?? entity.bankData.id, { - approved: dto.bankDataApproved, - manualApproved: dto.bankDataManualApproved, - }); - - if (dto.amlCheck === CheckStatus.PASS && ManualPassBlacklistErrors.some((b) => entity.comment?.includes(b))) - throw new BadRequestException('Blacklisted aml error cannot set Pass'); - - if (dto.chargebackAllowedDate) { - if (entity.bankTx && !entity.chargebackOutput) { - if (!dto.chargebackCreditorName && !entity.creditorData) - throw new BadRequestException('Creditor data is required for chargeback'); - - const inputCurrency = await this.transactionHelper.getRefundInputCurrency(entity); - const chargebackIban = dto.chargebackIban ?? entity.chargebackIban; - const chargebackCurrency = await this.transactionHelper.getRefundCurrency(inputCurrency, chargebackIban); + let newRoute: Buy | Swap | undefined; + if (dto.buyId) { + if (!entity.buy) throw new BadRequestException(`Cannot update buy route: entity is not a buy`); + newRoute = await this.getBuy(dto.buyId); + } + if (dto.cryptoRouteId) { + if (!entity.cryptoRoute) throw new BadRequestException(`Cannot update crypto route: entity is not a crypto`); + newRoute = await this.getCryptoRoute(dto.cryptoRouteId); + } - update.chargebackOutput = await this.fiatOutputService.createInternal( - FiatOutputType.BUY_CRYPTO_FAIL, - { buyCrypto: entity }, - entity.id, - false, + const expectedVersion = entity.version; + const result = await this.runWithVersionLock(entity.id, expectedVersion, async (manager) => { + if ((dto.bankDataApproved != null || dto.bankDataManualApproved != null) && (update.bankData || entity.bankData)) + await this.bankDataService.updateBankDataInternal( + update.bankData ?? entity.bankData, { - iban: chargebackIban, - amount: entity.chargebackAmount ?? entity.bankTx.amount, - currency: chargebackCurrency.name, - name: dto.chargebackCreditorName ?? entity.creditorData?.name, - address: dto.chargebackCreditorAddress ?? entity.creditorData?.address, - houseNumber: dto.chargebackCreditorHouseNumber ?? entity.creditorData?.houseNumber, - zip: dto.chargebackCreditorZip ?? entity.creditorData?.zip, - city: dto.chargebackCreditorCity ?? entity.creditorData?.city, - country: dto.chargebackCreditorCountry ?? entity.creditorData?.country, + approved: dto.bankDataApproved, + manualApproved: dto.bankDataManualApproved, }, + manager, ); - } - - if (entity.checkoutTx) { - await this.refundCheckoutTx(entity, { chargebackAllowedDate: new Date(), chargebackAllowedBy: 'GS' }); - Object.assign(dto, { isComplete: true, chargebackDate: new Date() }); - } - } - if (dto.chargebackIban && entity.amlCheck === CheckStatus.FAIL) entity.mailSendDate = null; - - update.amlReason = update.amlCheck === CheckStatus.PASS ? AmlReason.NA : update.amlReason; - - const forceUpdate: Partial = { - ...((BuyCryptoEditableAmlCheck.includes(entity.amlCheck) || - (entity.amlCheck === CheckStatus.FAIL && dto.amlCheck === CheckStatus.GSHEET)) && - !entity.isComplete && - (update?.amlCheck !== entity.amlCheck || update.amlReason !== entity.amlReason) - ? { - amlCheck: update.amlCheck, - mailSendDate: null, - amlReason: update.amlReason, - comment: update.comment, - ...(update.amlCheck === CheckStatus.PASS - ? { - priceDefinitionAllowedDate: - update.priceDefinitionAllowedDate ?? entity.priceDefinitionAllowedDate ?? new Date(), - } - : undefined), - } - : undefined), - isComplete: dto.isComplete, - }; + if (dto.amlCheck === CheckStatus.PASS && ManualPassBlacklistErrors.some((b) => entity.comment?.includes(b))) + throw new BadRequestException('Blacklisted aml error cannot set Pass'); - const amlCheckBefore = entity.amlCheck; - const amlReasonBefore = entity.amlReason; + if (dto.chargebackAllowedDate) { + if (entity.checkoutTx) throw new BadRequestException('Checkout refunds must use the dedicated refund endpoint'); + if (entity.bankTx && !entity.chargebackOutput) { + if (!dto.chargebackCreditorName && !entity.creditorData) + throw new BadRequestException('Creditor data is required for chargeback'); + + const inputCurrency = await this.transactionHelper.getRefundInputCurrency(entity); + const chargebackIban = dto.chargebackIban ?? entity.chargebackIban; + const chargebackCurrency = await this.transactionHelper.getRefundCurrency(inputCurrency, chargebackIban); + + update.chargebackOutput = await this.fiatOutputService.createInternal( + FiatOutputType.BUY_CRYPTO_FAIL, + { buyCrypto: entity }, + entity.id, + false, + { + iban: chargebackIban, + amount: entity.chargebackAmount ?? entity.bankTx.amount, + currency: chargebackCurrency.name, + name: dto.chargebackCreditorName ?? entity.creditorData?.name, + address: dto.chargebackCreditorAddress ?? entity.creditorData?.address, + houseNumber: dto.chargebackCreditorHouseNumber ?? entity.creditorData?.houseNumber, + zip: dto.chargebackCreditorZip ?? entity.creditorData?.zip, + city: dto.chargebackCreditorCity ?? entity.creditorData?.city, + country: dto.chargebackCreditorCountry ?? entity.creditorData?.country, + }, + manager, + ); + } + } - entity = await this.buyCryptoRepo.save( - Object.assign(new BuyCrypto(), { - ...update, - ...Util.removeNullFields(entity), - ...forceUpdate, - fee: entity.fee, - }), - ); + if (dto.chargebackIban && entity.amlCheck === CheckStatus.FAIL) entity.mailSendDate = null; + + update.amlReason = update.amlCheck === CheckStatus.PASS ? AmlReason.NA : update.amlReason; + + const forceUpdate: Partial = { + ...((BuyCryptoEditableAmlCheck.includes(entity.amlCheck) || + (entity.amlCheck === CheckStatus.FAIL && dto.amlCheck === CheckStatus.GSHEET)) && + !entity.isComplete && + (update?.amlCheck !== entity.amlCheck || update.amlReason !== entity.amlReason) + ? { + amlCheck: update.amlCheck, + mailSendDate: null, + amlReason: update.amlReason, + comment: update.comment, + ...(update.amlCheck === CheckStatus.PASS + ? { + priceDefinitionAllowedDate: + update.priceDefinitionAllowedDate ?? entity.priceDefinitionAllowedDate ?? new Date(), + } + : undefined), + } + : undefined), + isComplete: dto.isComplete, + ...(dto.chargebackIban && entity.amlCheck === CheckStatus.FAIL ? { mailSendDate: null } : undefined), + }; + + const amlCheckBefore = entity.amlCheck; + const amlReasonBefore = entity.amlReason; + let savedEntity = await manager.save( + BuyCrypto, + Object.assign(new BuyCrypto(), { + ...update, + ...Util.removeNullFields(entity), + ...forceUpdate, + fee: entity.fee, + }), + ); + if (newRoute) savedEntity = await this.changeRoute(savedEntity, newRoute, manager); + savedEntity.amlCheck = savedEntity.amlCheck === undefined ? amlCheckBefore : savedEntity.amlCheck; + savedEntity.amlReason = savedEntity.amlReason === undefined ? amlReasonBefore : savedEntity.amlReason; + await this.saveAmlAudit(manager, savedEntity, amlSource, amlCheckBefore, amlReasonBefore); + return { entity: savedEntity, forceUpdate, amlCheckBefore, amlReasonBefore }; + }); + entity = result.entity; + const { forceUpdate, amlCheckBefore, amlReasonBefore } = result; // `forceUpdate` injects amlCheck/amlReason: undefined when the admin PUT omits them; save() skips // undefined, so the DB keeps the prior verdict. Restore ONLY the undefined-clobber to the persisted @@ -400,18 +440,8 @@ export class BuyCryptoService implements OnModuleInit { entity.amlCheck = entity.amlCheck === undefined ? amlCheckBefore : entity.amlCheck; entity.amlReason = entity.amlReason === undefined ? amlReasonBefore : entity.amlReason; - await this.transactionAmlCheckService.createFromEntity( - entity, - 'BuyCrypto', - amlSource, - amlCheckBefore, - amlReasonBefore, - ); - if (forceUpdate.amlCheck || (!amlCheckBefore && update.amlCheck)) { if (update.amlCheck === CheckStatus.PASS) { - await this.buyCryptoNotificationService.paymentProcessing(entity); - const inputReferenceCurrency = entity.cryptoInput?.asset ?? (await this.fiatService.getFiatByName(entity.inputReferenceAsset)); @@ -426,26 +456,15 @@ export class BuyCryptoService implements OnModuleInit { inputReferenceCurrency, ); - await this.amlService.postProcessing(entity, last30dVolume); + await this.runIfAmlStateCurrent(entity, async (manager) => { + await this.buyCryptoNotificationService.paymentProcessing(entity, manager); + await this.amlService.postProcessing(entity, last30dVolume); + }); } else { - await this.amlService.postProcessing(entity, undefined); + await this.runIfAmlStateCurrent(entity, () => this.amlService.postProcessing(entity, undefined)); } } - // route change - if (dto.buyId) { - if (!entity.buy) throw new BadRequestException(`Cannot update buy route: entity is not a buy`); - - const buy = await this.getBuy(dto.buyId); - await this.changeRoute(entity, buy); - } - if (dto.cryptoRouteId) { - if (!entity.cryptoRoute) throw new BadRequestException(`Cannot update crypto route: entity is not a crypto`); - - const swap = await this.getCryptoRoute(dto.cryptoRouteId); - await this.changeRoute(entity, swap); - } - // create sift transaction (non-blocking) if (forceUpdate.amlCheck === CheckStatus.FAIL) void this.siftService.buyCryptoTransaction(entity, TransactionStatus.FAILURE); @@ -467,7 +486,76 @@ export class BuyCryptoService implements OnModuleInit { return entity; } - private async changeRoute(entity: BuyCrypto, route: Buy | Swap) { + private async runWithVersionLock( + id: number, + expectedVersion: number, + run: (manager: EntityManager) => Promise, + ): Promise { + return this.buyCryptoRepo.manager.transaction(async (manager) => { + const current = await manager.findOne(BuyCrypto, { + where: { id }, + select: { id: true, version: true }, + loadEagerRelations: false, + lock: { mode: 'pessimistic_write' }, + }); + if (!current) throw new NotFoundException('Buy-crypto not found'); + if (current.version !== expectedVersion) throw new ConflictException('BuyCrypto changed concurrently'); + return run(manager); + }); + } + + private async runIfAmlStateCurrent( + entity: BuyCrypto, + run: (manager: EntityManager) => Promise, + ): Promise { + return this.buyCryptoRepo.manager.transaction(async (manager) => { + const current = await manager.findOne(BuyCrypto, { + where: { + id: entity.id, + version: entity.version, + status: entity.status, + amlCheck: entity.amlCheck == null ? IsNull() : entity.amlCheck, + amlReason: entity.amlReason == null ? IsNull() : entity.amlReason, + isComplete: entity.isComplete, + }, + select: { id: true }, + loadEagerRelations: false, + lock: { mode: 'pessimistic_write' }, + }); + if (!current) return false; + + await run(manager); + return true; + }); + } + + private async saveAmlAudit( + manager: EntityManager, + entity: BuyCrypto, + source: AmlSourceType, + previousAmlCheck?: CheckStatus, + previousAmlReason?: AmlReason, + ): Promise { + if (previousAmlCheck === entity.amlCheck && previousAmlReason === entity.amlReason) return; + + const audit = manager.create(TransactionAmlCheck, { + transaction: entity.transaction, + entityType: 'BuyCrypto', + entityId: entity.id, + source, + previousAmlCheck, + amlCheck: entity.amlCheck, + previousAmlReason, + amlReason: entity.amlReason, + amlResponsible: entity.amlResponsible, + comment: entity.comment, + priceDefinitionAllowedDate: entity.priceDefinitionAllowedDate, + highRisk: entity.highRisk, + }); + await manager.save(TransactionAmlCheck, audit); + } + + private async changeRoute(entity: BuyCrypto, route: Buy | Swap, manager: EntityManager): Promise { // check tx status if ( [ @@ -496,17 +584,23 @@ export class BuyCryptoService implements OnModuleInit { entity.resetFees(); - await this.buyCryptoRepo.save(Object.assign(entity, update)); + entity = await manager.save(BuyCrypto, Object.assign(entity, update)); // update depending entities - await this.buyCryptoRepo.updateFee(entity.fee.id, { feeReferenceAsset: route.asset }); + await manager.update(BuyCryptoFee, entity.fee.id, { feeReferenceAsset: route.asset }); if (entity.transaction.user.id !== route.user.id) - entity.transaction = await this.transactionService.updateInternal(entity.transaction, { - user: route.user, - }); + entity.transaction = await this.transactionService.updateInternal( + entity.transaction, + { + user: route.user, + }, + manager, + ); - if (entity.bankTx) await this.bankTxService.getBankTxRepo().setNewUpdateTime(entity.bankTx.id); + if (entity.bankTx) await manager.update(BankTx, entity.bankTx.id, { updated: new Date() }); + + return entity; } async refundBuyCrypto(buyCryptoId: number, dto: RefundInternalDto): Promise { @@ -774,11 +868,101 @@ export class BuyCryptoService implements OnModuleInit { await this.updateRefVolume([...new Set(refs)]); } - async resetAmlCheck(id: number): Promise { - const entity = await this.buyCryptoRepo.findOne({ where: { id }, relations: { chargebackOutput: true } }); - if (!entity) throw new NotFoundException('BuyCrypto not found'); + async resetAmlCheckForReview(id: number, dto: ResetBuyCryptoAmlReviewDto, actorUserDataId: number): Promise { + await this.buyCryptoRepo.manager.transaction(async (manager) => { + const lockedEntity = await manager.findOne(BuyCrypto, { + where: { id }, + select: { id: true }, + loadEagerRelations: false, + lock: { mode: 'pessimistic_write' }, + }); + if (!lockedEntity) throw new NotFoundException('BuyCrypto not found'); + + const entity = await manager.findOne(BuyCrypto, { + where: { id }, + relations: { chargebackOutput: true, fee: true, transaction: { userData: true } }, + }); + if (!entity) throw new NotFoundException('BuyCrypto not found'); + + const userDataId = entity.userData?.id; + if (!userDataId) throw new BadRequestException('BuyCrypto has no user data'); + const userData = await manager.findOne(UserData, { + where: { id: userDataId }, + select: { id: true, kycStatus: true }, + loadEagerRelations: false, + lock: { mode: 'pessimistic_read' }, + }); + if (!userData || userData.kycStatus !== KycStatus.CHECK) + throw new ConflictException('KYC status must be Check before resetting AML'); + if ( + entity.isComplete || + entity.status === BuyCryptoStatus.STOPPED || + entity.batch || + entity.chargebackOutput || + entity.chargebackAllowedDate || + entity.chargebackAllowedDateUser + ) + throw new BadRequestException('BuyCrypto is already complete or payout initiated'); + if (entity.amlCheck !== dto.expectedAmlCheck || (entity.amlReason ?? null) !== dto.expectedAmlReason) + throw new ConflictException('BuyCrypto AML status changed concurrently'); + + const previousAmlCheck = entity.amlCheck; + const previousAmlReason = entity.amlReason; + const previousStatus = entity.status; + let fee = entity.fee; + if (fee) { + await manager.findOne(BuyCryptoFee, { + where: { id: fee.id }, + select: { id: true }, + loadEagerRelations: false, + lock: { mode: 'pessimistic_write' }, + }); + fee = await manager.findOne(BuyCryptoFee, { where: { id: fee.id }, relations: { feeReferenceAsset: true } }); + } + const beforeReset = { ...entity }; + const [, update] = entity.resetAmlCheck(); + const before = Object.fromEntries( + (Object.keys(update) as Array).map((key) => [key, beforeReset[key] ?? null]), + ); + const deletedFee = fee + ? { + ...BuyCryptoService.scalarAuditSnapshot(fee), + feeReferenceAssetId: fee.feeReferenceAsset?.id ?? null, + } + : null; + + const audit = manager.create(TransactionAmlCheck, { + transaction: entity.transaction, + entityType: 'BuyCrypto', + entityId: entity.id, + source: AmlSourceType.MANUAL_RESET, + previousAmlCheck, + previousAmlReason, + amlResponsible: `UserData ${actorUserDataId}`, + comment: JSON.stringify({ operation: 'BuyCryptoAmlReviewReset', before, after: update, deletedFee }), + }); + await manager.save(TransactionAmlCheck, audit); - await this.resetAmlCheckInternal(entity, AmlSourceType.MANUAL_RESET); + const result = await manager.update( + BuyCrypto, + { + id, + amlCheck: dto.expectedAmlCheck, + amlReason: + dto.expectedAmlReason === null || dto.expectedAmlReason === undefined ? IsNull() : dto.expectedAmlReason, + status: previousStatus, + isComplete: false, + batch: IsNull(), + chargebackOutput: IsNull(), + chargebackAllowedDate: IsNull(), + chargebackAllowedDateUser: IsNull(), + }, + update, + ); + if (result.affected !== 1) throw new ConflictException('BuyCrypto AML status changed concurrently'); + + if (fee) await manager.remove(BuyCryptoFee, fee); + }); } // Manual re-trigger of the Scorechain on-chain screening for an existing buy-crypto: screens the @@ -807,8 +991,14 @@ export class BuyCryptoService implements OnModuleInit { } async resetAmlCheckInternal(entity: BuyCrypto, source: AmlSourceType): Promise { - if (entity.isComplete || entity.batch || entity.chargebackOutput?.isComplete || entity.chargebackAllowedDate) - throw new BadRequestException('BuyCrypto is already complete or payout initiated'); + if ( + entity.isComplete || + entity.status === BuyCryptoStatus.STOPPED || + entity.batch || + entity.chargebackOutput?.isComplete || + entity.chargebackAllowedDate + ) + throw new BadRequestException('BuyCrypto is already complete, stopped or payout initiated'); if (!entity.amlCheck) throw new BadRequestException('BuyCrypto AML check is not set'); const fee = entity.fee; @@ -816,8 +1006,23 @@ export class BuyCryptoService implements OnModuleInit { const previousAmlCheck = entity.amlCheck; const previousAmlReason = entity.amlReason; - - await this.buyCryptoRepo.update(...entity.resetAmlCheck()); + const previousStatus = entity.status; + const previousVersion = entity.version; + const [, update] = entity.resetAmlCheck(); + const updateResult = await this.buyCryptoRepo.update( + { + id: entity.id, + version: previousVersion, + amlCheck: previousAmlCheck, + amlReason: previousAmlReason === null || previousAmlReason === undefined ? IsNull() : previousAmlReason, + status: previousStatus, + isComplete: false, + batch: IsNull(), + chargebackAllowedDate: IsNull(), + }, + update, + ); + if (updateResult.affected !== 1) throw new ConflictException('BuyCrypto changed concurrently'); await this.transactionAmlCheckService.createFromEntity( entity, 'BuyCrypto', diff --git a/src/subdomains/generic/user/models/user-data/user-data.controller.ts b/src/subdomains/generic/user/models/user-data/user-data.controller.ts index 9c81ced115..014fd2cdaf 100644 --- a/src/subdomains/generic/user/models/user-data/user-data.controller.ts +++ b/src/subdomains/generic/user/models/user-data/user-data.controller.ts @@ -73,7 +73,7 @@ export class UserDataController { @Param('id', ParseIntPipe) id: number, @Body() dto: SetKycStatusCheckDto, ): Promise { - if (jwt.account == null) throw new ForbiddenException('Staff account is missing'); + if (jwt.account === null || jwt.account === undefined) throw new ForbiddenException('Staff account is missing'); return this.userDataService.setKycStatusCheck(id, dto.expectedKycStatus, jwt.account); } diff --git a/src/subdomains/supporting/fiat-output/fiat-output.service.ts b/src/subdomains/supporting/fiat-output/fiat-output.service.ts index 2304afcaaa..840930e132 100644 --- a/src/subdomains/supporting/fiat-output/fiat-output.service.ts +++ b/src/subdomains/supporting/fiat-output/fiat-output.service.ts @@ -10,6 +10,7 @@ import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data import { Bank } from 'src/subdomains/supporting/bank/bank/bank.entity'; import { IbanBankName } from 'src/subdomains/supporting/bank/bank/dto/bank.dto'; import { VirtualIbanService } from 'src/subdomains/supporting/bank/virtual-iban/virtual-iban.service'; +import { EntityManager } from 'typeorm'; import { BankTxRepeatService } from '../bank-tx/bank-tx-repeat/bank-tx-repeat.service'; import { BankTxReturn } from '../bank-tx/bank-tx-return/bank-tx-return.entity'; import { BankTxReturnService } from '../bank-tx/bank-tx-return/bank-tx-return.service'; @@ -134,6 +135,7 @@ export class FiatOutputService { originEntityId: number, createReport = false, inputCreditorData?: Partial, + manager?: EntityManager, ): Promise { let creditorData: Partial = inputCreditorData ?? {}; @@ -166,7 +168,8 @@ export class FiatOutputService { } } - const entity = this.fiatOutputRepo.create({ + const repo = manager?.getRepository(FiatOutput) ?? this.fiatOutputRepo; + const entity = repo.create({ type, buyCrypto, buyFiats, @@ -185,7 +188,7 @@ export class FiatOutputService { if (createReport) entity.reportCreated = false; - return this.fiatOutputRepo.save(entity); + return repo.save(entity); } private validateRequiredCreditorFields(data: Partial): void { diff --git a/src/subdomains/supporting/payment/services/__tests__/transaction.service.spec.ts b/src/subdomains/supporting/payment/services/__tests__/transaction.service.spec.ts index 59ac1358d0..e5451b2b26 100644 --- a/src/subdomains/supporting/payment/services/__tests__/transaction.service.spec.ts +++ b/src/subdomains/supporting/payment/services/__tests__/transaction.service.spec.ts @@ -1,11 +1,13 @@ import { createMock } from '@golevelup/ts-jest'; +import { ConflictException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { TestSharedModule } from 'src/shared/utils/test.shared.module'; +import { TestUtil } from 'src/shared/utils/test.util'; import { AmlSourceType } from 'src/subdomains/core/aml/entities/transaction-aml-check.entity'; import { CheckStatus } from 'src/subdomains/core/aml/enums/check-status.enum'; import { TransactionAmlCheckService } from 'src/subdomains/core/aml/services/transaction-aml-check.service'; +import { BuyCrypto, BuyCryptoStatus } from 'src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity'; import { BuyCryptoRepository } from 'src/subdomains/core/buy-crypto/process/repositories/buy-crypto.repository'; -import { TestSharedModule } from 'src/shared/utils/test.shared.module'; -import { TestUtil } from 'src/shared/utils/test.util'; import { BankDataService } from 'src/subdomains/generic/user/models/bank-data/bank-data.service'; import { UserDataService } from 'src/subdomains/generic/user/models/user-data/user-data.service'; import { UpdateTransactionDto } from '../../dto/update-transaction.dto'; @@ -100,6 +102,29 @@ describe('TransactionService (admin door — amlCheck audit trail)', () => { expect(transactionAmlCheckService.create).not.toHaveBeenCalled(); }); + + it('stops BuyCrypto with a partial conditional update instead of saving a stale snapshot', async () => { + const buyCrypto = Object.assign(new BuyCrypto(), { id: 7, status: BuyCryptoStatus.MISSING_LIQUIDITY }); + jest.spyOn(repo, 'findOne').mockResolvedValue(Object.assign(new Transaction(), { id: 70, buyCrypto })); + jest.spyOn(buyCryptoRepo, 'update').mockResolvedValue({ affected: 1, raw: [], generatedMaps: [] }); + + await service.stop(70); + + expect(buyCryptoRepo.update).toHaveBeenCalledWith( + { id: 7, status: BuyCryptoStatus.MISSING_LIQUIDITY }, + { status: BuyCryptoStatus.STOPPED }, + ); + expect(buyCryptoRepo.save).not.toHaveBeenCalled(); + }); + + it('rejects stop when a concurrent AML reset changed the BuyCrypto status', async () => { + const buyCrypto = Object.assign(new BuyCrypto(), { id: 7, status: BuyCryptoStatus.MISSING_LIQUIDITY }); + jest.spyOn(repo, 'findOne').mockResolvedValue(Object.assign(new Transaction(), { id: 70, buyCrypto })); + jest.spyOn(buyCryptoRepo, 'update').mockResolvedValue({ affected: 0, raw: [], generatedMaps: [] }); + + await expect(service.stop(70)).rejects.toThrow(ConflictException); + expect(buyCryptoRepo.save).not.toHaveBeenCalled(); + }); }); describe('TransactionService (relation load strategy)', () => { diff --git a/src/subdomains/supporting/payment/services/transaction.service.ts b/src/subdomains/supporting/payment/services/transaction.service.ts index 60d02beaf1..d1ba5e5412 100644 --- a/src/subdomains/supporting/payment/services/transaction.service.ts +++ b/src/subdomains/supporting/payment/services/transaction.service.ts @@ -1,4 +1,11 @@ -import { BadRequestException, Inject, Injectable, NotFoundException, forwardRef } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + Inject, + Injectable, + NotFoundException, + forwardRef, +} from '@nestjs/common'; import { Config } from 'src/config/config'; import { Util } from 'src/shared/utils/util'; import { AmlSourceType } from 'src/subdomains/core/aml/entities/transaction-aml-check.entity'; @@ -112,6 +119,7 @@ export class TransactionService { async updateInternal( entity: Transaction, dto: UpdateTransactionInternalDto | UpdateTransactionDto, + manager?: EntityManager, ): Promise { Object.assign(entity, dto); @@ -123,7 +131,7 @@ export class TransactionService { entity.supportIssues = [...(entity.supportIssues ?? []), ...(entity.request.supportIssues ?? [])]; } - return this.repo.save(entity); + return manager ? manager.save(Transaction, entity) : this.repo.save(entity); } async stop(id: number): Promise { @@ -134,8 +142,10 @@ export class TransactionService { if (entity.buyCrypto.status === BuyCryptoStatus.STOPPED) throw new BadRequestException('Transaction is already stopped'); - entity.buyCrypto.stop(); - await this.buyCryptoRepo.save(entity.buyCrypto); + const previousStatus = entity.buyCrypto.status; + const [buyCryptoId, update] = entity.buyCrypto.stop(); + const result = await this.buyCryptoRepo.update({ id: buyCryptoId, status: previousStatus }, update); + if (result.affected !== 1) throw new ConflictException('BuyCrypto status changed concurrently'); } async getTransactionById( From 0126118c00daea0d3c78548331f9e0856f3b9a6d Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:12:30 +0200 Subject: [PATCH 03/16] Expose BuyCrypto reset eligibility state --- .../generic/support/dto/user-data-support.dto.ts | 4 ++++ src/subdomains/generic/support/support.service.ts | 8 ++++++++ .../supporting/payment/services/transaction.service.ts | 2 +- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/subdomains/generic/support/dto/user-data-support.dto.ts b/src/subdomains/generic/support/dto/user-data-support.dto.ts index ab9a6eeb65..457c8d7879 100644 --- a/src/subdomains/generic/support/dto/user-data-support.dto.ts +++ b/src/subdomains/generic/support/dto/user-data-support.dto.ts @@ -151,6 +151,10 @@ export class TransactionSupportInfo { id: number; uid: string; buyCryptoId?: number; + buyCryptoIsComplete?: boolean; + buyCryptoStatus?: string; + buyCryptoHasBatch?: boolean; + buyCryptoHasChargeback?: boolean; buyFiatId?: number; bankDataId?: number; type?: string; diff --git a/src/subdomains/generic/support/support.service.ts b/src/subdomains/generic/support/support.service.ts index 5d045ed060..aab5525c40 100644 --- a/src/subdomains/generic/support/support.service.ts +++ b/src/subdomains/generic/support/support.service.ts @@ -597,6 +597,14 @@ export class SupportService { id: tx.id, uid: tx.uid, buyCryptoId: tx.buyCrypto?.id, + buyCryptoIsComplete: tx.buyCrypto?.isComplete, + buyCryptoStatus: tx.buyCrypto?.status, + buyCryptoHasBatch: !!tx.buyCrypto?.batch, + buyCryptoHasChargeback: !!( + tx.buyCrypto?.chargebackOutput || + tx.buyCrypto?.chargebackAllowedDate || + tx.buyCrypto?.chargebackAllowedDateUser + ), buyFiatId: tx.buyFiat?.id, bankDataId: tx.buyCrypto?.bankData?.id ?? tx.buyFiat?.bankData?.id, type: tx.type, diff --git a/src/subdomains/supporting/payment/services/transaction.service.ts b/src/subdomains/supporting/payment/services/transaction.service.ts index d1ba5e5412..41720b03bc 100644 --- a/src/subdomains/supporting/payment/services/transaction.service.ts +++ b/src/subdomains/supporting/payment/services/transaction.service.ts @@ -215,7 +215,7 @@ export class TransactionService { ] : base, relations: { - buyCrypto: { cryptoInput: true, outputAsset: true, bankData: true }, + buyCrypto: { cryptoInput: true, outputAsset: true, bankData: true, batch: true, chargebackOutput: true }, buyFiat: { cryptoInput: true, outputAsset: true, bankData: true }, bankTxReturn: true, bankTxRepeat: true, From e7d262e2fb20fece0208943fd08305066d1416e0 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:34:59 +0200 Subject: [PATCH 04/16] Fix EVM payout allowlist address matching --- .../admin/__tests__/admin.service.spec.ts | 45 +++++++++++++++++++ src/subdomains/generic/admin/admin.service.ts | 6 ++- 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 src/subdomains/generic/admin/__tests__/admin.service.spec.ts diff --git a/src/subdomains/generic/admin/__tests__/admin.service.spec.ts b/src/subdomains/generic/admin/__tests__/admin.service.spec.ts new file mode 100644 index 0000000000..0c456df576 --- /dev/null +++ b/src/subdomains/generic/admin/__tests__/admin.service.spec.ts @@ -0,0 +1,45 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { SettingService } from 'src/shared/models/setting/setting.service'; +import { DexService } from 'src/subdomains/supporting/dex/services/dex.service'; +import { PayoutService } from 'src/subdomains/supporting/payout/services/payout.service'; +import { AdminService } from '../admin.service'; +import { PayoutRequestContext, PayoutRequestDto } from '../dto/payout-request.dto'; + +describe('AdminService', () => { + let service: AdminService; + let assetService: DeepMocked; + let dexService: DeepMocked; + let payoutService: DeepMocked; + let settingService: DeepMocked; + + beforeEach(() => { + assetService = createMock(); + dexService = createMock(); + payoutService = createMock(); + settingService = createMock(); + service = new AdminService(assetService, dexService, payoutService, settingService); + + assetService.getAssetById.mockResolvedValue({ id: 1, blockchain: Blockchain.ETHEREUM } as Asset); + dexService.hasOrder.mockResolvedValue(false); + }); + + it('accepts a checksummed EVM payout address from the allowlist', async () => { + const address = '0xfc9caD260D42AA3D682EeDC6710eAee7c4f8F1a4'; + settingService.getObj.mockResolvedValue([address]); + + const request: PayoutRequestDto = { + context: PayoutRequestContext.MANUAL as PayoutRequestDto['context'], + id: 'manual-payout', + amount: 1, + assetId: 1, + address, + }; + + await service.payout(request); + + expect(payoutService.doPayout).toHaveBeenCalledWith(expect.objectContaining({ destinationAddress: address })); + }); +}); diff --git a/src/subdomains/generic/admin/admin.service.ts b/src/subdomains/generic/admin/admin.service.ts index cc81a24fba..53cf071bf6 100644 --- a/src/subdomains/generic/admin/admin.service.ts +++ b/src/subdomains/generic/admin/admin.service.ts @@ -6,6 +6,7 @@ import { ServiceUnavailableException, } from '@nestjs/common'; import { CronExpression } from '@nestjs/schedule'; +import { EvmBlockchains } from 'src/integration/blockchain/shared/util/blockchain.util'; import { AssetService } from 'src/shared/models/asset/asset.service'; import { SettingService } from 'src/shared/models/setting/setting.service'; import { Process } from 'src/shared/services/process.service'; @@ -42,7 +43,10 @@ export class AdminService { const pContext = context as PayoutOrderContext; const allowedAddresses = await this.settingService.getObj('manualPayoutAddresses', []); - if (!allowedAddresses.includes(address.toLowerCase())) + const normalizeAddress = EvmBlockchains.includes(asset.blockchain) + ? (value: string) => value.toLowerCase() + : (value: string) => value; + if (!allowedAddresses.some((allowedAddress) => normalizeAddress(allowedAddress) === normalizeAddress(address))) throw new BadRequestException('Payout address not permitted'); try { From 552add25cc36e350d98e13c37cdbc7cf11afd390 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:52:51 +0200 Subject: [PATCH 05/16] Block AML review resets during refunds --- .../__tests__/buy-crypto.service.spec.ts | 57 ++++++++++++++++--- .../process/services/buy-crypto.service.ts | 44 +++++++++++++- .../admin/__tests__/admin.service.spec.ts | 17 ++++++ .../support/__tests__/support.service.spec.ts | 27 +++++++++ .../generic/support/support.service.ts | 13 ++++- .../payment/services/transaction.service.ts | 9 ++- 6 files changed, 154 insertions(+), 13 deletions(-) diff --git a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts index 036e30e1cb..a60f1b797d 100644 --- a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts +++ b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts @@ -2,6 +2,7 @@ import { createMock } from '@golevelup/ts-jest'; import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { CheckoutPaymentStatus } from 'src/integration/checkout/dto/checkout.dto'; import { CheckoutService } from 'src/integration/checkout/services/checkout.service'; import { ScorechainScreening } from 'src/integration/scorechain/entities/scorechain-screening.entity'; import { ScorechainScreeningService } from 'src/integration/scorechain/services/scorechain-screening.service'; @@ -28,8 +29,10 @@ import { UserDataService } from 'src/subdomains/generic/user/models/user-data/us import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; import { BankTxService } from 'src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service'; import { FiatOutputService } from 'src/subdomains/supporting/fiat-output/fiat-output.service'; +import { CheckoutTx } from 'src/subdomains/supporting/fiat-payin/entities/checkout-tx.entity'; import { CheckoutTxService } from 'src/subdomains/supporting/fiat-payin/services/checkout-tx.service'; import { createCustomCryptoInput } from 'src/subdomains/supporting/payin/entities/__mocks__/crypto-input.entity.mock'; +import { CryptoInput, PayInAction, PayInStatus } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; import { PayInService } from 'src/subdomains/supporting/payin/services/payin.service'; import { Transaction } from 'src/subdomains/supporting/payment/entities/transaction.entity'; import { SpecialExternalAccountService } from 'src/subdomains/supporting/payment/services/special-external-account.service'; @@ -359,13 +362,14 @@ describe('BuyCryptoService', () => { }), }); const manager = { - findOne: jest - .fn() - .mockResolvedValueOnce({ id: 7 }) - .mockResolvedValueOnce(entity) - .mockResolvedValueOnce({ id: 42, kycStatus }) - .mockResolvedValueOnce({ id: 8 }) - .mockResolvedValueOnce(entity.fee), + findOne: jest.fn(async (type: unknown, options: { select?: { id?: boolean } }) => { + if (type === BuyCrypto) return options.select?.id ? { id: 7 } : entity; + if (type === CheckoutTx) return entity.checkoutTx; + if (type === CryptoInput) return entity.cryptoInput; + if (type === UserData) return { id: 42, kycStatus }; + if (type === BuyCryptoFee) return options.select?.id ? { id: 8 } : entity.fee; + return undefined; + }), create: jest.fn((_type: unknown, dto: unknown) => dto), save: jest.fn().mockResolvedValue(undefined), update: jest.fn().mockResolvedValue({ affected: 1 }), @@ -451,6 +455,45 @@ describe('BuyCryptoService', () => { expect(manager.update).not.toHaveBeenCalled(); }); + it('locks and rejects a checkout refund that started before the review reset', async () => { + const { entity, manager } = reviewResetFixture(); + entity.checkoutTx = Object.assign(new CheckoutTx(), { + id: 22, + status: CheckoutPaymentStatus.REFUND_PENDING, + }); + + await expect( + service.resetAmlCheckForReview(7, { expectedAmlCheck: CheckStatus.PASS, expectedAmlReason: AmlReason.NA }, 99), + ).rejects.toThrow(BadRequestException); + + expect(manager.findOne).toHaveBeenCalledWith( + CheckoutTx, + expect.objectContaining({ where: { id: 22 }, lock: { mode: 'pessimistic_write' } }), + ); + expect(manager.save).not.toHaveBeenCalled(); + expect(manager.update).not.toHaveBeenCalled(); + }); + + it('locks and rejects a crypto return that started before the review reset', async () => { + const { entity, manager } = reviewResetFixture(); + entity.cryptoInput = Object.assign(new CryptoInput(), { + id: 23, + action: PayInAction.RETURN, + status: PayInStatus.TO_RETURN, + }); + + await expect( + service.resetAmlCheckForReview(7, { expectedAmlCheck: CheckStatus.PASS, expectedAmlReason: AmlReason.NA }, 99), + ).rejects.toThrow(BadRequestException); + + expect(manager.findOne).toHaveBeenCalledWith( + CryptoInput, + expect.objectContaining({ where: { id: 23 }, lock: { mode: 'pessimistic_write' } }), + ); + expect(manager.save).not.toHaveBeenCalled(); + expect(manager.update).not.toHaveBeenCalled(); + }); + it('does not mutate or delete state when the immutable audit event cannot be saved', async () => { const { manager } = reviewResetFixture(); manager.save.mockRejectedValue(new Error('audit unavailable')); diff --git a/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts b/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts index 7896c721c5..a74b57536e 100644 --- a/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts +++ b/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts @@ -9,6 +9,7 @@ import { } from '@nestjs/common'; import { Config } from 'src/config/config'; import { txExplorerUrl } from 'src/integration/blockchain/shared/util/blockchain.util'; +import { CheckoutPaymentStatus } from 'src/integration/checkout/dto/checkout.dto'; import { CheckoutService } from 'src/integration/checkout/services/checkout.service'; import { toScorechainBlockchain } from 'src/integration/scorechain/dto/scorechain.dto'; import { ScorechainScreening } from 'src/integration/scorechain/entities/scorechain-screening.entity'; @@ -52,7 +53,7 @@ import { FiatOutputType } from 'src/subdomains/supporting/fiat-output/fiat-outpu import { FiatOutputService } from 'src/subdomains/supporting/fiat-output/fiat-output.service'; import { CheckoutTx } from 'src/subdomains/supporting/fiat-payin/entities/checkout-tx.entity'; import { CheckoutTxService } from 'src/subdomains/supporting/fiat-payin/services/checkout-tx.service'; -import { CryptoInput } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; +import { CryptoInput, PayInAction, PayInStatus } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; import { PayInService } from 'src/subdomains/supporting/payin/services/payin.service'; import { UpdateTransactionInternalDto } from 'src/subdomains/supporting/payment/dto/input/update-transaction-internal.dto'; import { TransactionRequest } from 'src/subdomains/supporting/payment/entities/transaction-request.entity'; @@ -880,10 +881,45 @@ export class BuyCryptoService implements OnModuleInit { const entity = await manager.findOne(BuyCrypto, { where: { id }, - relations: { chargebackOutput: true, fee: true, transaction: { userData: true } }, + relations: { + chargebackOutput: true, + checkoutTx: true, + cryptoInput: true, + fee: true, + transaction: { userData: true }, + }, }); if (!entity) throw new NotFoundException('BuyCrypto not found'); + const checkoutTx = entity.checkoutTx + ? await manager.findOne(CheckoutTx, { + where: { id: entity.checkoutTx.id }, + loadEagerRelations: false, + lock: { mode: 'pessimistic_write' }, + }) + : undefined; + const cryptoInput = entity.cryptoInput + ? await manager.findOne(CryptoInput, { + where: { id: entity.cryptoInput.id }, + loadEagerRelations: false, + lock: { mode: 'pessimistic_write' }, + }) + : undefined; + + const checkoutRefundStarted = + checkoutTx != null && + [ + CheckoutPaymentStatus.REFUND_PENDING, + CheckoutPaymentStatus.PARTIALLY_REFUNDED, + CheckoutPaymentStatus.REFUNDED, + ].includes(checkoutTx.status); + const cryptoReturnStarted = + cryptoInput != null && + (cryptoInput.action === PayInAction.RETURN || + (cryptoInput.status != null && + [PayInStatus.TO_RETURN, PayInStatus.RETURNED, PayInStatus.RETURN_CONFIRMED].includes(cryptoInput.status)) || + cryptoInput.returnTxId != null); + const userDataId = entity.userData?.id; if (!userDataId) throw new BadRequestException('BuyCrypto has no user data'); const userData = await manager.findOne(UserData, { @@ -900,7 +936,9 @@ export class BuyCryptoService implements OnModuleInit { entity.batch || entity.chargebackOutput || entity.chargebackAllowedDate || - entity.chargebackAllowedDateUser + entity.chargebackAllowedDateUser || + checkoutRefundStarted || + cryptoReturnStarted ) throw new BadRequestException('BuyCrypto is already complete or payout initiated'); if (entity.amlCheck !== dto.expectedAmlCheck || (entity.amlReason ?? null) !== dto.expectedAmlReason) diff --git a/src/subdomains/generic/admin/__tests__/admin.service.spec.ts b/src/subdomains/generic/admin/__tests__/admin.service.spec.ts index 0c456df576..c466a46c4c 100644 --- a/src/subdomains/generic/admin/__tests__/admin.service.spec.ts +++ b/src/subdomains/generic/admin/__tests__/admin.service.spec.ts @@ -42,4 +42,21 @@ describe('AdminService', () => { expect(payoutService.doPayout).toHaveBeenCalledWith(expect.objectContaining({ destinationAddress: address })); }); + + it('keeps non-EVM payout address matching case-sensitive', async () => { + const allowedAddress = 'bc1qcaseSensitiveAddress'; + assetService.getAssetById.mockResolvedValue({ id: 1, blockchain: Blockchain.BITCOIN } as Asset); + settingService.getObj.mockResolvedValue([allowedAddress]); + + const request: PayoutRequestDto = { + context: PayoutRequestContext.MANUAL as PayoutRequestDto['context'], + id: 'manual-payout', + amount: 1, + assetId: 1, + address: allowedAddress.toLowerCase(), + }; + + await expect(service.payout(request)).rejects.toThrow('Payout address not permitted'); + expect(payoutService.doPayout).not.toHaveBeenCalled(); + }); }); diff --git a/src/subdomains/generic/support/__tests__/support.service.spec.ts b/src/subdomains/generic/support/__tests__/support.service.spec.ts index 1c42c8403f..5046398cac 100644 --- a/src/subdomains/generic/support/__tests__/support.service.spec.ts +++ b/src/subdomains/generic/support/__tests__/support.service.spec.ts @@ -3,6 +3,7 @@ import { NotFoundException } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { Config } from 'src/config/config'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { CheckoutPaymentStatus } from 'src/integration/checkout/dto/checkout.dto'; import { ScorechainScreening } from 'src/integration/scorechain/entities/scorechain-screening.entity'; import { ScorechainScreeningService } from 'src/integration/scorechain/services/scorechain-screening.service'; import { TestUtil } from 'src/shared/utils/test.util'; @@ -11,6 +12,8 @@ import { BuyCryptoService } from 'src/subdomains/core/buy-crypto/process/service import { BuyFiat } from 'src/subdomains/core/sell-crypto/process/buy-fiat.entity'; import { BuyFiatService } from 'src/subdomains/core/sell-crypto/process/services/buy-fiat.service'; import { BankTxService } from 'src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service'; +import { CheckoutTx } from 'src/subdomains/supporting/fiat-payin/entities/checkout-tx.entity'; +import { CryptoInput, PayInAction } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; import { PayInService } from 'src/subdomains/supporting/payin/services/payin.service'; import { Transaction } from 'src/subdomains/supporting/payment/entities/transaction.entity'; import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; @@ -120,6 +123,30 @@ describe('SupportService', () => { expect(service).toBeDefined(); }); + describe('transaction refund state', () => { + it.each([ + { + name: 'checkout refund', + source: { + checkoutTx: Object.assign(new CheckoutTx(), { status: CheckoutPaymentStatus.REFUND_PENDING }), + }, + }, + { + name: 'crypto return', + source: { cryptoInput: Object.assign(new CryptoInput(), { action: PayInAction.RETURN }) }, + }, + ])('reports an in-flight $name as a chargeback', ({ source }) => { + const transaction = Object.assign(new Transaction(), { + id: 1, + buyCrypto: Object.assign(new BuyCrypto(), source), + }); + + const result = (service as any).toTransactionSupportInfo(transaction); + + expect(result.buyCryptoHasChargeback).toBe(true); + }); + }); + describe('getRecommendationGraphNeighbors', () => { it('applies the default skip=0 and take=25 when not provided', async () => { const centerId = 100; diff --git a/src/subdomains/generic/support/support.service.ts b/src/subdomains/generic/support/support.service.ts index aab5525c40..fbc82f849d 100644 --- a/src/subdomains/generic/support/support.service.ts +++ b/src/subdomains/generic/support/support.service.ts @@ -3,6 +3,7 @@ import { isIP } from 'class-validator'; import * as IbanTools from 'ibantools'; import { Config } from 'src/config/config'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { CheckoutPaymentStatus } from 'src/integration/checkout/dto/checkout.dto'; import { addressExplorerUrl, txExplorerUrl } from 'src/integration/blockchain/shared/util/blockchain.util'; import { ScorechainScreeningDtoMapper } from 'src/integration/scorechain/dto/scorechain-screening-dto.mapper'; import { @@ -47,7 +48,7 @@ import { VirtualIban } from 'src/subdomains/supporting/bank/virtual-iban/virtual import { VirtualIbanService } from 'src/subdomains/supporting/bank/virtual-iban/virtual-iban.service'; import { Notification } from 'src/subdomains/supporting/notification/entities/notification.entity'; import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service'; -import { CryptoInput } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; +import { CryptoInput, PayInAction, PayInStatus } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; import { PayInService } from 'src/subdomains/supporting/payin/services/payin.service'; import { Transaction } from 'src/subdomains/supporting/payment/entities/transaction.entity'; import { TransactionHelper } from 'src/subdomains/supporting/payment/services/transaction-helper'; @@ -603,7 +604,15 @@ export class SupportService { buyCryptoHasChargeback: !!( tx.buyCrypto?.chargebackOutput || tx.buyCrypto?.chargebackAllowedDate || - tx.buyCrypto?.chargebackAllowedDateUser + tx.buyCrypto?.chargebackAllowedDateUser || + tx.buyCrypto?.checkoutTx?.status === CheckoutPaymentStatus.REFUND_PENDING || + tx.buyCrypto?.checkoutTx?.status === CheckoutPaymentStatus.PARTIALLY_REFUNDED || + tx.buyCrypto?.checkoutTx?.status === CheckoutPaymentStatus.REFUNDED || + tx.buyCrypto?.cryptoInput?.action === PayInAction.RETURN || + tx.buyCrypto?.cryptoInput?.status === PayInStatus.TO_RETURN || + tx.buyCrypto?.cryptoInput?.status === PayInStatus.RETURNED || + tx.buyCrypto?.cryptoInput?.status === PayInStatus.RETURN_CONFIRMED || + tx.buyCrypto?.cryptoInput?.returnTxId ), buyFiatId: tx.buyFiat?.id, bankDataId: tx.buyCrypto?.bankData?.id ?? tx.buyFiat?.bankData?.id, diff --git a/src/subdomains/supporting/payment/services/transaction.service.ts b/src/subdomains/supporting/payment/services/transaction.service.ts index 41720b03bc..f1006eb25a 100644 --- a/src/subdomains/supporting/payment/services/transaction.service.ts +++ b/src/subdomains/supporting/payment/services/transaction.service.ts @@ -215,7 +215,14 @@ export class TransactionService { ] : base, relations: { - buyCrypto: { cryptoInput: true, outputAsset: true, bankData: true, batch: true, chargebackOutput: true }, + buyCrypto: { + cryptoInput: true, + checkoutTx: true, + outputAsset: true, + bankData: true, + batch: true, + chargebackOutput: true, + }, buyFiat: { cryptoInput: true, outputAsset: true, bankData: true }, bankTxReturn: true, bankTxRepeat: true, From 61bf5da9216b8652a9442c4d2d3e3c05f9dd9f1b Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:57:28 +0200 Subject: [PATCH 06/16] Claim checkout refunds before provider calls --- .../__tests__/buy-crypto.service.spec.ts | 21 +++++++++++++++++++ .../process/services/buy-crypto.service.ts | 4 +++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts index a60f1b797d..13088964e0 100644 --- a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts +++ b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts @@ -338,6 +338,27 @@ describe('BuyCryptoService', () => { }); describe('amlCheck audit trail', () => { + it('persists the checkout refund claim before calling the external provider', async () => { + const buyCrypto = createCustomBuyCrypto({ + id: 7, + inputAmount: 10, + checkoutTx: Object.assign(new CheckoutTx(), { id: 22, paymentId: 'pay-22' }), + }); + jest.spyOn(TransactionUtilService, 'validateRefund').mockImplementation(); + checkoutService.refundPayment.mockResolvedValue({ + action_id: 'action-22', + reference: 'refund-22', + _links: { payment: { href: 'https://example.test/payment/pay-22' } }, + }); + + await service.refundCheckoutTx(buyCrypto, { chargebackAllowedDate: new Date() }); + + expect(checkoutTxService.paymentRefunded).toHaveBeenCalledWith(22); + expect(checkoutTxService.paymentRefunded.mock.invocationCallOrder[0]).toBeLessThan( + checkoutService.refundPayment.mock.invocationCallOrder[0], + ); + }); + function reviewResetFixture( kycStatus = KycStatus.CHECK, amlCheck = CheckStatus.PASS, diff --git a/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts b/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts index a74b57536e..ef1bebe96e 100644 --- a/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts +++ b/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts @@ -645,8 +645,10 @@ export class BuyCryptoService implements OnModuleInit { TransactionUtilService.validateRefund(buyCrypto, { chargebackAmount, assetMismatch: false }); if (dto.chargebackAllowedDate && chargebackAmount) { - dto.chargebackRemittanceInfo = await this.checkoutService.refundPayment(buyCrypto.checkoutTx.paymentId); + // Persist the refund claim before the irreversible provider call. Review resets lock and inspect this row, + // so a concurrent reset cannot treat an already-started checkout refund as an untouched payment. await this.checkoutTxService.paymentRefunded(buyCrypto.checkoutTx.id); + dto.chargebackRemittanceInfo = await this.checkoutService.refundPayment(buyCrypto.checkoutTx.paymentId); } const previousAmlCheck = buyCrypto.amlCheck; From f448d79251db96c06285eea9ba3ee7d5a7cd4dd1 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:00:07 +0200 Subject: [PATCH 07/16] Remove unrelated payout allowlist change --- .../admin/__tests__/admin.service.spec.ts | 62 ------------------- src/subdomains/generic/admin/admin.service.ts | 6 +- 2 files changed, 1 insertion(+), 67 deletions(-) delete mode 100644 src/subdomains/generic/admin/__tests__/admin.service.spec.ts diff --git a/src/subdomains/generic/admin/__tests__/admin.service.spec.ts b/src/subdomains/generic/admin/__tests__/admin.service.spec.ts deleted file mode 100644 index c466a46c4c..0000000000 --- a/src/subdomains/generic/admin/__tests__/admin.service.spec.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; -import { Asset } from 'src/shared/models/asset/asset.entity'; -import { AssetService } from 'src/shared/models/asset/asset.service'; -import { SettingService } from 'src/shared/models/setting/setting.service'; -import { DexService } from 'src/subdomains/supporting/dex/services/dex.service'; -import { PayoutService } from 'src/subdomains/supporting/payout/services/payout.service'; -import { AdminService } from '../admin.service'; -import { PayoutRequestContext, PayoutRequestDto } from '../dto/payout-request.dto'; - -describe('AdminService', () => { - let service: AdminService; - let assetService: DeepMocked; - let dexService: DeepMocked; - let payoutService: DeepMocked; - let settingService: DeepMocked; - - beforeEach(() => { - assetService = createMock(); - dexService = createMock(); - payoutService = createMock(); - settingService = createMock(); - service = new AdminService(assetService, dexService, payoutService, settingService); - - assetService.getAssetById.mockResolvedValue({ id: 1, blockchain: Blockchain.ETHEREUM } as Asset); - dexService.hasOrder.mockResolvedValue(false); - }); - - it('accepts a checksummed EVM payout address from the allowlist', async () => { - const address = '0xfc9caD260D42AA3D682EeDC6710eAee7c4f8F1a4'; - settingService.getObj.mockResolvedValue([address]); - - const request: PayoutRequestDto = { - context: PayoutRequestContext.MANUAL as PayoutRequestDto['context'], - id: 'manual-payout', - amount: 1, - assetId: 1, - address, - }; - - await service.payout(request); - - expect(payoutService.doPayout).toHaveBeenCalledWith(expect.objectContaining({ destinationAddress: address })); - }); - - it('keeps non-EVM payout address matching case-sensitive', async () => { - const allowedAddress = 'bc1qcaseSensitiveAddress'; - assetService.getAssetById.mockResolvedValue({ id: 1, blockchain: Blockchain.BITCOIN } as Asset); - settingService.getObj.mockResolvedValue([allowedAddress]); - - const request: PayoutRequestDto = { - context: PayoutRequestContext.MANUAL as PayoutRequestDto['context'], - id: 'manual-payout', - amount: 1, - assetId: 1, - address: allowedAddress.toLowerCase(), - }; - - await expect(service.payout(request)).rejects.toThrow('Payout address not permitted'); - expect(payoutService.doPayout).not.toHaveBeenCalled(); - }); -}); diff --git a/src/subdomains/generic/admin/admin.service.ts b/src/subdomains/generic/admin/admin.service.ts index 53cf071bf6..cc81a24fba 100644 --- a/src/subdomains/generic/admin/admin.service.ts +++ b/src/subdomains/generic/admin/admin.service.ts @@ -6,7 +6,6 @@ import { ServiceUnavailableException, } from '@nestjs/common'; import { CronExpression } from '@nestjs/schedule'; -import { EvmBlockchains } from 'src/integration/blockchain/shared/util/blockchain.util'; import { AssetService } from 'src/shared/models/asset/asset.service'; import { SettingService } from 'src/shared/models/setting/setting.service'; import { Process } from 'src/shared/services/process.service'; @@ -43,10 +42,7 @@ export class AdminService { const pContext = context as PayoutOrderContext; const allowedAddresses = await this.settingService.getObj('manualPayoutAddresses', []); - const normalizeAddress = EvmBlockchains.includes(asset.blockchain) - ? (value: string) => value.toLowerCase() - : (value: string) => value; - if (!allowedAddresses.some((allowedAddress) => normalizeAddress(allowedAddress) === normalizeAddress(address))) + if (!allowedAddresses.includes(address.toLowerCase())) throw new BadRequestException('Payout address not permitted'); try { From 9897fccaa6daec2c4d9b48c25f97d4fb9a6396bb Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:01:25 +0200 Subject: [PATCH 08/16] Block AML resets after crypto forwarding --- .../__tests__/buy-crypto.service.spec.ts | 20 +++++++++++++++++++ .../process/services/buy-crypto.service.ts | 16 ++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts index 13088964e0..b0e956e624 100644 --- a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts +++ b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts @@ -515,6 +515,26 @@ describe('BuyCryptoService', () => { expect(manager.update).not.toHaveBeenCalled(); }); + it('locks and rejects a crypto input that was already released for forwarding', async () => { + const { entity, manager } = reviewResetFixture(); + entity.cryptoInput = Object.assign(new CryptoInput(), { + id: 24, + action: PayInAction.FORWARD, + status: PayInStatus.ACKNOWLEDGED, + }); + + await expect( + service.resetAmlCheckForReview(7, { expectedAmlCheck: CheckStatus.PASS, expectedAmlReason: AmlReason.NA }, 99), + ).rejects.toThrow(BadRequestException); + + expect(manager.findOne).toHaveBeenCalledWith( + CryptoInput, + expect.objectContaining({ where: { id: 24 }, lock: { mode: 'pessimistic_write' } }), + ); + expect(manager.save).not.toHaveBeenCalled(); + expect(manager.update).not.toHaveBeenCalled(); + }); + it('does not mutate or delete state when the immutable audit event cannot be saved', async () => { const { manager } = reviewResetFixture(); manager.save.mockRejectedValue(new Error('audit unavailable')); diff --git a/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts b/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts index ef1bebe96e..32070d8d67 100644 --- a/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts +++ b/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts @@ -921,6 +921,19 @@ export class BuyCryptoService implements OnModuleInit { (cryptoInput.status != null && [PayInStatus.TO_RETURN, PayInStatus.RETURNED, PayInStatus.RETURN_CONFIRMED].includes(cryptoInput.status)) || cryptoInput.returnTxId != null); + const cryptoForwardStarted = + cryptoInput != null && + (cryptoInput.action === PayInAction.FORWARD || + (cryptoInput.status != null && + [ + PayInStatus.PREPARING, + PayInStatus.PREPARED, + PayInStatus.SENDING, + PayInStatus.SEND_UNCERTAIN, + PayInStatus.FORWARDED, + PayInStatus.FORWARD_CONFIRMED, + ].includes(cryptoInput.status)) || + cryptoInput.outTxId != null); const userDataId = entity.userData?.id; if (!userDataId) throw new BadRequestException('BuyCrypto has no user data'); @@ -940,7 +953,8 @@ export class BuyCryptoService implements OnModuleInit { entity.chargebackAllowedDate || entity.chargebackAllowedDateUser || checkoutRefundStarted || - cryptoReturnStarted + cryptoReturnStarted || + cryptoForwardStarted ) throw new BadRequestException('BuyCrypto is already complete or payout initiated'); if (entity.amlCheck !== dto.expectedAmlCheck || (entity.amlReason ?? null) !== dto.expectedAmlReason) From 7986f493bae019fc115264bee278cb12c73db70d Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:05:08 +0200 Subject: [PATCH 09/16] Claim checkout refunds across payout gates --- .../buy-crypto-batch.service.spec.ts | 19 ++++++++- .../buy-crypto-preparation.service.spec.ts | 17 ++++++++ .../__tests__/buy-crypto.service.spec.ts | 28 ++++++++++--- .../services/buy-crypto-batch.service.ts | 2 + .../buy-crypto-preparation.service.ts | 1 + .../process/services/buy-crypto.service.ts | 40 ++++++++++++++++--- 6 files changed, 94 insertions(+), 13 deletions(-) diff --git a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-batch.service.spec.ts b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-batch.service.spec.ts index b2a5c514f9..09b6082081 100644 --- a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-batch.service.spec.ts +++ b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-batch.service.spec.ts @@ -16,7 +16,7 @@ import { FeeService } from 'src/subdomains/supporting/payment/services/fee.servi import { PayoutService } from 'src/subdomains/supporting/payout/services/payout.service'; import { Price } from 'src/subdomains/supporting/pricing/domain/entities/price'; import { PricingService } from 'src/subdomains/supporting/pricing/services/pricing.service'; -import { EntityManager } from 'typeorm'; +import { EntityManager, IsNull } from 'typeorm'; import { createCustomBuyCryptoBatch } from '../../entities/__mocks__/buy-crypto-batch.entity.mock'; import { createCustomBuyCryptoFee } from '../../entities/__mocks__/buy-crypto-fee.entity.mock'; import { createCustomBuyCrypto, createDefaultBuyCrypto } from '../../entities/__mocks__/buy-crypto.entity.mock'; @@ -66,6 +66,23 @@ describe('BuyCryptoBatchService', () => { expect(dexServiceCheckLiquidity).toBeCalledTimes(0); }); + it('excludes operator and user refund claims from every batching branch', async () => { + const findSpy = jest.spyOn(buyCryptoRepo, 'find').mockResolvedValue([]); + + await service.batchAndOptimizeTransactions(); + + const where = findSpy.mock.calls[0][0].where as Array>; + expect(where).toHaveLength(2); + for (const branch of where) { + expect(branch).toEqual( + expect.objectContaining({ + chargebackAllowedDate: IsNull(), + chargebackAllowedDateUser: IsNull(), + }), + ); + } + }); + it('defines output reference amounts', async () => { const transactions = [ createCustomBuyCrypto({ diff --git a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-preparation.service.spec.ts b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-preparation.service.spec.ts index b60c8fc1ac..aaaa9408d1 100644 --- a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-preparation.service.spec.ts +++ b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-preparation.service.spec.ts @@ -455,6 +455,23 @@ describe('BuyCryptoPreparationService', () => { }); describe('doAmlCheck — concurrent decision guard', () => { + it('excludes operator and user refund claims from every AML selection branch', async () => { + const findSpy = jest.spyOn(buyCryptoRepo, 'find').mockResolvedValue([]); + + await service.doAmlCheck(); + + const where = findSpy.mock.calls[0][0].where as Array>; + expect(where).toHaveLength(3); + for (const branch of where) { + expect(branch).toEqual( + expect.objectContaining({ + chargebackAllowedDate: IsNull(), + chargebackAllowedDateUser: IsNull(), + }), + ); + } + }); + it('skips post-processing (no overwrite) when the conditional update affects 0 rows', async () => { // first-run tx (amlCheck=null) that a reviewer concurrently changed → cron write must not win const entity = createCustomBuyCrypto({ id: 1, amlCheck: null, cryptoInput: undefined, bankTx: undefined }); diff --git a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts index b0e956e624..df712df890 100644 --- a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts +++ b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts @@ -30,7 +30,6 @@ import { UserService } from 'src/subdomains/generic/user/models/user/user.servic import { BankTxService } from 'src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service'; import { FiatOutputService } from 'src/subdomains/supporting/fiat-output/fiat-output.service'; import { CheckoutTx } from 'src/subdomains/supporting/fiat-payin/entities/checkout-tx.entity'; -import { CheckoutTxService } from 'src/subdomains/supporting/fiat-payin/services/checkout-tx.service'; import { createCustomCryptoInput } from 'src/subdomains/supporting/payin/entities/__mocks__/crypto-input.entity.mock'; import { CryptoInput, PayInAction, PayInStatus } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; import { PayInService } from 'src/subdomains/supporting/payin/services/payin.service'; @@ -80,7 +79,6 @@ describe('BuyCryptoService', () => { let transactionService: TransactionService; let siftService: SiftService; let checkoutService: CheckoutService; - let checkoutTxService: CheckoutTxService; let payInService: PayInService; let fiatOutputService: FiatOutputService; let transactionUtilService: TransactionUtilService; @@ -110,7 +108,6 @@ describe('BuyCryptoService', () => { transactionService = createMock(); siftService = createMock(); checkoutService = createMock(); - checkoutTxService = createMock(); payInService = createMock(); fiatOutputService = createMock(); transactionUtilService = createMock(); @@ -143,7 +140,6 @@ describe('BuyCryptoService', () => { { provide: TransactionService, useValue: transactionService }, { provide: SiftService, useValue: siftService }, { provide: CheckoutService, useValue: checkoutService }, - { provide: CheckoutTxService, useValue: checkoutTxService }, { provide: PayInService, useValue: payInService }, { provide: FiatOutputService, useValue: fiatOutputService }, { provide: TransactionUtilService, useValue: transactionUtilService }, @@ -350,11 +346,31 @@ describe('BuyCryptoService', () => { reference: 'refund-22', _links: { payment: { href: 'https://example.test/payment/pay-22' } }, }); + const manager = { + findOne: jest.fn().mockResolvedValueOnce(buyCrypto).mockResolvedValueOnce(buyCrypto.checkoutTx), + update: jest.fn().mockResolvedValue({ affected: 1 }), + }; + Object.defineProperty(buyCryptoRepo, 'manager', { + configurable: true, + value: { + transaction: jest.fn(async (run: (entityManager: EntityManager) => unknown) => + run(manager as unknown as EntityManager), + ), + }, + }); await service.refundCheckoutTx(buyCrypto, { chargebackAllowedDate: new Date() }); - expect(checkoutTxService.paymentRefunded).toHaveBeenCalledWith(22); - expect(checkoutTxService.paymentRefunded.mock.invocationCallOrder[0]).toBeLessThan( + expect(manager.update).toHaveBeenNthCalledWith( + 1, + BuyCrypto, + expect.objectContaining({ id: 7, chargebackAllowedDate: expect.anything() }), + expect.objectContaining({ chargebackAllowedDate: expect.any(Date) }), + ); + expect(manager.update).toHaveBeenNthCalledWith(2, CheckoutTx, 22, { + status: CheckoutPaymentStatus.REFUND_PENDING, + }); + expect(manager.update.mock.invocationCallOrder[1]).toBeLessThan( checkoutService.refundPayment.mock.invocationCallOrder[0], ); }); diff --git a/src/subdomains/core/buy-crypto/process/services/buy-crypto-batch.service.ts b/src/subdomains/core/buy-crypto/process/services/buy-crypto-batch.service.ts index 6513d52cf3..9acff15b59 100644 --- a/src/subdomains/core/buy-crypto/process/services/buy-crypto-batch.service.ts +++ b/src/subdomains/core/buy-crypto/process/services/buy-crypto-batch.service.ts @@ -55,6 +55,8 @@ export class BuyCryptoBatchService { outputAsset: { type: Not(In([AssetType.CUSTOM, AssetType.PRESALE])) }, priceDefinitionAllowedDate: Not(IsNull()), batch: IsNull(), + chargebackAllowedDate: IsNull(), + chargebackAllowedDateUser: IsNull(), inputReferenceAmountMinusFee: Not(IsNull()), status: In([ BuyCryptoStatus.CREATED, diff --git a/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts b/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts index 5c58322f9c..bcc5ab07bc 100644 --- a/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts +++ b/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts @@ -113,6 +113,7 @@ export class BuyCryptoPreparationService { const request: FindOptionsWhere = { inputAmount: Not(IsNull()), inputAsset: Not(IsNull()), + chargebackAllowedDate: IsNull(), chargebackAllowedDateUser: IsNull(), isComplete: false, }; diff --git a/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts b/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts index 32070d8d67..7430300540 100644 --- a/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts +++ b/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts @@ -52,7 +52,6 @@ import { BankTxService } from 'src/subdomains/supporting/bank-tx/bank-tx/service import { FiatOutputType } from 'src/subdomains/supporting/fiat-output/fiat-output.entity'; import { FiatOutputService } from 'src/subdomains/supporting/fiat-output/fiat-output.service'; import { CheckoutTx } from 'src/subdomains/supporting/fiat-payin/entities/checkout-tx.entity'; -import { CheckoutTxService } from 'src/subdomains/supporting/fiat-payin/services/checkout-tx.service'; import { CryptoInput, PayInAction, PayInStatus } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; import { PayInService } from 'src/subdomains/supporting/payin/services/payin.service'; import { UpdateTransactionInternalDto } from 'src/subdomains/supporting/payment/dto/input/update-transaction-internal.dto'; @@ -117,8 +116,6 @@ export class BuyCryptoService implements OnModuleInit { private readonly bankDataService: BankDataService, private readonly transactionRequestService: TransactionRequestService, private readonly transactionService: TransactionService, - @Inject(forwardRef(() => CheckoutTxService)) - private readonly checkoutTxService: CheckoutTxService, private readonly siftService: SiftService, private readonly specialExternalAccountService: SpecialExternalAccountService, private readonly checkoutService: CheckoutService, @@ -645,9 +642,40 @@ export class BuyCryptoService implements OnModuleInit { TransactionUtilService.validateRefund(buyCrypto, { chargebackAmount, assetMismatch: false }); if (dto.chargebackAllowedDate && chargebackAmount) { - // Persist the refund claim before the irreversible provider call. Review resets lock and inspect this row, - // so a concurrent reset cannot treat an already-started checkout refund as an untouched payment. - await this.checkoutTxService.paymentRefunded(buyCrypto.checkoutTx.id); + await this.buyCryptoRepo.manager.transaction(async (manager) => { + const lockedBuyCrypto = await manager.findOne(BuyCrypto, { + where: { id: buyCrypto.id }, + relations: { checkoutTx: true }, + lock: { mode: 'pessimistic_write' }, + }); + if (!lockedBuyCrypto) throw new NotFoundException('BuyCrypto not found'); + + const lockedCheckoutTx = await manager.findOne(CheckoutTx, { + where: { id: buyCrypto.checkoutTx.id }, + loadEagerRelations: false, + lock: { mode: 'pessimistic_write' }, + }); + if (!lockedCheckoutTx) throw new NotFoundException('Checkout TX not found'); + + lockedBuyCrypto.checkoutTx = lockedCheckoutTx; + TransactionUtilService.validateRefund(lockedBuyCrypto, { chargebackAmount, assetMismatch: false }); + + const claim = await manager.update( + BuyCrypto, + { + id: buyCrypto.id, + isComplete: false, + batch: IsNull(), + chargebackAllowedDate: IsNull(), + chargebackAllowedDateUser: IsNull(), + }, + { chargebackAllowedDate: dto.chargebackAllowedDate, chargebackAllowedBy: dto.chargebackAllowedBy }, + ); + if (claim.affected !== 1) throw new ConflictException('BuyCrypto refund state changed concurrently'); + + await manager.update(CheckoutTx, lockedCheckoutTx.id, { status: CheckoutPaymentStatus.REFUND_PENDING }); + }); + dto.chargebackRemittanceInfo = await this.checkoutService.refundPayment(buyCrypto.checkoutTx.paymentId); } From 6844d730c8e77c966b9f685c2db1c9101bbdf499 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:08:06 +0200 Subject: [PATCH 10/16] Preserve user refund claims during AML resets --- .../__tests__/buy-crypto.service.spec.ts | 41 ++++++++++++++++++- .../process/services/buy-crypto.service.ts | 5 ++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts index df712df890..41fa0841a0 100644 --- a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts +++ b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts @@ -38,7 +38,7 @@ import { SpecialExternalAccountService } from 'src/subdomains/supporting/payment import { TransactionHelper } from 'src/subdomains/supporting/payment/services/transaction-helper'; import { TransactionRequestService } from 'src/subdomains/supporting/payment/services/transaction-request.service'; import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; -import { EntityManager } from 'typeorm'; +import { EntityManager, IsNull } from 'typeorm'; import { BuyRepository } from '../../../routes/buy/buy.repository'; import { BuyService } from '../../../routes/buy/buy.service'; import { createCustomBuyHistory } from '../../../routes/buy/dto/__mocks__/buy-history.dto.mock'; @@ -334,6 +334,19 @@ describe('BuyCryptoService', () => { }); describe('amlCheck audit trail', () => { + it('excludes user refund claims from phone-call AML resets', async () => { + const findBySpy = jest.spyOn(buyCryptoRepo, 'findBy').mockResolvedValue([]); + + await service.checkAmlResetTx(Object.assign(new UserData(), { id: 42 })); + + expect(findBySpy).toHaveBeenCalledWith( + expect.objectContaining({ + chargebackAllowedDate: IsNull(), + chargebackAllowedDateUser: IsNull(), + }), + ); + }); + it('persists the checkout refund claim before calling the external provider', async () => { const buyCrypto = createCustomBuyCrypto({ id: 7, @@ -605,6 +618,13 @@ describe('BuyCryptoService', () => { await service.resetAmlCheckInternal(entity, AmlSourceType.MANUAL_RESET); + expect(buyCryptoRepo.update).toHaveBeenCalledWith( + expect.objectContaining({ + chargebackAllowedDate: IsNull(), + chargebackAllowedDateUser: IsNull(), + }), + expect.anything(), + ); expect(transactionAmlCheckService.createFromEntity).toHaveBeenCalledTimes(1); expect(transactionAmlCheckService.createFromEntity).toHaveBeenCalledWith( expect.objectContaining({ id: 7, amlCheck: null }), @@ -789,6 +809,25 @@ describe('BuyCryptoService', () => { expect(buyCryptoRepo.update).not.toHaveBeenCalled(); expect(transactionAmlCheckService.createFromEntity).not.toHaveBeenCalled(); }); + + it('does not reset AML or reactivate a user-refund claim after a phone call', async () => { + const entity = createCustomBuyCrypto({ + id: 17, + amlCheck: CheckStatus.FAIL, + amlReason: AmlReason.MANUAL_CHECK_PHONE, + status: BuyCryptoStatus.CREATED, + chargebackAllowedDate: undefined, + chargebackAllowedDateUser: new Date(), + isComplete: false, + }); + + await expect(service.resetAmlCheckInternal(entity, AmlSourceType.PHONE_CALL_RESET)).rejects.toThrow( + BadRequestException, + ); + + expect(buyCryptoRepo.update).not.toHaveBeenCalled(); + expect(transactionAmlCheckService.createFromEntity).not.toHaveBeenCalled(); + }); }); describe('createFromCryptoInput exact base-unit propagation (#4287 stage 4)', () => { diff --git a/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts b/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts index 7430300540..a518140db9 100644 --- a/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts +++ b/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts @@ -153,6 +153,7 @@ export class BuyCryptoService implements OnModuleInit { status: Not(BuyCryptoStatus.STOPPED), isComplete: false, chargebackAllowedDate: IsNull(), + chargebackAllowedDateUser: IsNull(), }); for (const entity of entities) { @@ -1078,7 +1079,8 @@ export class BuyCryptoService implements OnModuleInit { entity.status === BuyCryptoStatus.STOPPED || entity.batch || entity.chargebackOutput?.isComplete || - entity.chargebackAllowedDate + entity.chargebackAllowedDate || + entity.chargebackAllowedDateUser ) throw new BadRequestException('BuyCrypto is already complete, stopped or payout initiated'); if (!entity.amlCheck) throw new BadRequestException('BuyCrypto AML check is not set'); @@ -1101,6 +1103,7 @@ export class BuyCryptoService implements OnModuleInit { isComplete: false, batch: IsNull(), chargebackAllowedDate: IsNull(), + chargebackAllowedDateUser: IsNull(), }, update, ); From 1d53dbba915f538e932e43a057d799913cdd0d48 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:19:28 +0200 Subject: [PATCH 11/16] Make refund claims durable and atomic --- .../checkout/services/checkout.service.ts | 4 +- .../buy-crypto-preparation.service.spec.ts | 17 ++ .../__tests__/buy-crypto.service.spec.ts | 82 ++++++++++ .../buy-crypto-preparation.service.ts | 16 ++ .../process/services/buy-crypto.service.ts | 147 +++++++++++++++++- .../services/__tests__/payin.service.spec.ts | 25 ++- .../payin/services/payin.service.ts | 24 ++- 7 files changed, 296 insertions(+), 19 deletions(-) diff --git a/src/integration/checkout/services/checkout.service.ts b/src/integration/checkout/services/checkout.service.ts index ae793ffa6f..57e1701f6c 100644 --- a/src/integration/checkout/services/checkout.service.ts +++ b/src/integration/checkout/services/checkout.service.ts @@ -112,7 +112,7 @@ export class CheckoutService { return balance.data; } - async refundPayment(paymentId: string): Promise { - return (await this.checkout.payments.refund(paymentId)) as CheckoutReverse; + async refundPayment(paymentId: string, idempotencyKey?: string): Promise { + return (await this.checkout.payments.refund(paymentId, undefined, idempotencyKey)) as CheckoutReverse; } } diff --git a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-preparation.service.spec.ts b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-preparation.service.spec.ts index aaaa9408d1..e9b22ff866 100644 --- a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-preparation.service.spec.ts +++ b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-preparation.service.spec.ts @@ -639,4 +639,21 @@ describe('BuyCryptoPreparationService', () => { expect(transactionAmlCheckService.createFromEntity).not.toHaveBeenCalled(); }); }); + + describe('chargebackTx — durable Checkout refund recovery', () => { + it('resumes a persisted incomplete Checkout refund claim before processing new claims', async () => { + const entity = createCustomBuyCrypto({ + id: 77, + chargebackAllowedDate: new Date(), + isComplete: false, + checkoutTx: { id: 88 } as any, + }); + jest.spyOn(buyCryptoRepo, 'find').mockResolvedValueOnce([entity]).mockResolvedValueOnce([]); + + await service.chargebackTx(); + + expect(buyCryptoService.resumeCheckoutRefund).toHaveBeenCalledWith(entity); + expect(buyCryptoService.refundCheckoutTx).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts index 41fa0841a0..7ab98cc1fc 100644 --- a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts +++ b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts @@ -371,6 +371,7 @@ describe('BuyCryptoService', () => { ), }, }); + buyCryptoRepo.update.mockResolvedValue({ affected: 1, raw: [], generatedMaps: [] }); await service.refundCheckoutTx(buyCrypto, { chargebackAllowedDate: new Date() }); @@ -386,6 +387,87 @@ describe('BuyCryptoService', () => { expect(manager.update.mock.invocationCallOrder[1]).toBeLessThan( checkoutService.refundPayment.mock.invocationCallOrder[0], ); + expect(checkoutService.refundPayment).toHaveBeenCalledWith('pay-22', 'buy-crypto-7-checkout-refund'); + }); + + it('can safely resume a persisted checkout refund claim with the same idempotency key', async () => { + const buyCrypto = createCustomBuyCrypto({ + id: 8, + inputAmount: 10, + chargebackAmount: 10, + chargebackAllowedDate: new Date(), + isComplete: false, + checkoutTx: Object.assign(new CheckoutTx(), { id: 23, paymentId: 'pay-23' }), + }); + checkoutService.refundPayment.mockResolvedValue({ + action_id: 'action-23', + reference: 'refund-23', + _links: { payment: { href: 'https://example.test/payment/pay-23' } }, + }); + buyCryptoRepo.update.mockResolvedValue({ affected: 1, raw: [], generatedMaps: [] }); + + await service.resumeCheckoutRefund(buyCrypto); + + expect(checkoutService.refundPayment).toHaveBeenCalledWith('pay-23', 'buy-crypto-8-checkout-refund'); + expect(buyCryptoRepo.update).toHaveBeenCalledWith( + expect.objectContaining({ + id: 8, + isComplete: false, + chargebackAllowedDate: buyCrypto.chargebackAllowedDate, + }), + expect.objectContaining({ isComplete: true, status: BuyCryptoStatus.COMPLETE }), + ); + }); + + it('claims CryptoInput return state and BuyCrypto chargeback state in the same transaction', async () => { + const cryptoInput = createCustomCryptoInput({ + id: 24, + asset: createCustomAsset({ blockchain: Blockchain.ETHEREUM }), + }); + const buyCrypto = createCustomBuyCrypto({ + id: 9, + cryptoInput, + chargebackAmount: 1, + isComplete: false, + }); + const refundUser = { address: '0x0000000000000000000000000000000000000001' } as any; + userService.getUserByAddress.mockResolvedValue(refundUser); + transactionHelper.getBlockchainFee.mockResolvedValue(0.01); + jest.spyOn(TransactionUtilService, 'validateRefund').mockImplementation(); + const manager = { + findOne: jest + .fn() + .mockResolvedValueOnce({ id: 9 }) + .mockResolvedValueOnce(buyCrypto) + .mockResolvedValueOnce(cryptoInput), + update: jest.fn().mockResolvedValue({ affected: 1 }), + }; + Object.defineProperty(buyCryptoRepo, 'manager', { + configurable: true, + value: { + transaction: jest.fn(async (run: (entityManager: EntityManager) => unknown) => + run(manager as unknown as EntityManager), + ), + }, + }); + + await service.refundCryptoInput(buyCrypto, { + refundUserAddress: refundUser.address, + chargebackAmount: 1, + chargebackAllowedDate: new Date(), + }); + + expect(payInService.returnPayIn).toHaveBeenCalledWith( + cryptoInput, + refundUser.address, + 1, + manager as unknown as EntityManager, + ); + expect(manager.update).toHaveBeenCalledWith( + BuyCrypto, + expect.objectContaining({ id: 9, chargebackAllowedDate: expect.anything() }), + expect.objectContaining({ chargebackAllowedDate: expect.any(Date), amlCheck: CheckStatus.FAIL }), + ); }); function reviewResetFixture( diff --git a/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts b/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts index bcc5ab07bc..8cd2bf5088 100644 --- a/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts +++ b/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts @@ -743,6 +743,22 @@ export class BuyCryptoPreparationService { } async chargebackTx(): Promise { + const checkoutRefundsInProgress = await this.buyCryptoRepo.find({ + where: { + checkoutTx: { id: Not(IsNull()) }, + chargebackAllowedDate: Not(IsNull()), + isComplete: false, + }, + relations: { checkoutTx: true, transaction: { userData: true } }, + }); + for (const entity of checkoutRefundsInProgress) { + try { + await this.buyCryptoService.resumeCheckoutRefund(entity); + } catch (e) { + this.logger.error(`Failed to resume Checkout chargeback for buy-crypto ${entity.id}:`, e); + } + } + const baseRequest: FindOptionsWhere = { chargebackAllowedDate: IsNull(), chargebackAllowedDateUser: Not(IsNull()), diff --git a/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts b/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts index a518140db9..e8a9f966cc 100644 --- a/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts +++ b/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts @@ -646,7 +646,7 @@ export class BuyCryptoService implements OnModuleInit { await this.buyCryptoRepo.manager.transaction(async (manager) => { const lockedBuyCrypto = await manager.findOne(BuyCrypto, { where: { id: buyCrypto.id }, - relations: { checkoutTx: true }, + loadEagerRelations: false, lock: { mode: 'pessimistic_write' }, }); if (!lockedBuyCrypto) throw new NotFoundException('BuyCrypto not found'); @@ -670,14 +670,24 @@ export class BuyCryptoService implements OnModuleInit { chargebackAllowedDate: IsNull(), chargebackAllowedDateUser: IsNull(), }, - { chargebackAllowedDate: dto.chargebackAllowedDate, chargebackAllowedBy: dto.chargebackAllowedBy }, + { + chargebackAllowedDate: dto.chargebackAllowedDate, + chargebackAllowedBy: dto.chargebackAllowedBy, + chargebackReferenceAmount: chargebackAmount, + chargebackAmount, + chargebackAsset: dto.chargebackCurrency, + }, ); if (claim.affected !== 1) throw new ConflictException('BuyCrypto refund state changed concurrently'); await manager.update(CheckoutTx, lockedCheckoutTx.id, { status: CheckoutPaymentStatus.REFUND_PENDING }); }); - dto.chargebackRemittanceInfo = await this.checkoutService.refundPayment(buyCrypto.checkoutTx.paymentId); + await this.completeCheckoutRefund(buyCrypto, { + ...dto, + chargebackAmount, + }); + return; } const previousAmlCheck = buyCrypto.amlCheck; @@ -706,6 +716,60 @@ export class BuyCryptoService implements OnModuleInit { ); } + async resumeCheckoutRefund(buyCrypto: BuyCrypto): Promise { + if (!buyCrypto.checkoutTx || !buyCrypto.chargebackAllowedDate || buyCrypto.isComplete) return; + + await this.completeCheckoutRefund(buyCrypto, { + chargebackAmount: buyCrypto.chargebackAmount ?? buyCrypto.inputAmount, + chargebackCurrency: buyCrypto.chargebackAsset, + chargebackAllowedDate: buyCrypto.chargebackAllowedDate, + chargebackAllowedDateUser: buyCrypto.chargebackAllowedDateUser, + chargebackAllowedBy: buyCrypto.chargebackAllowedBy, + }); + } + + private async completeCheckoutRefund(buyCrypto: BuyCrypto, dto: CheckoutTxRefund): Promise { + const chargebackAmount = dto.chargebackAmount ?? buyCrypto.chargebackAmount ?? buyCrypto.inputAmount; + const refund = await this.checkoutService.refundPayment( + buyCrypto.checkoutTx.paymentId, + `buy-crypto-${buyCrypto.id}-checkout-refund`, + ); + const previousAmlCheck = buyCrypto.amlCheck; + const previousAmlReason = buyCrypto.amlReason; + const [, update] = buyCrypto.chargebackFillUp( + undefined, + chargebackAmount, + chargebackAmount, + dto.chargebackCurrency, + dto.chargebackAllowedDate, + dto.chargebackAllowedDateUser, + dto.chargebackAllowedBy, + undefined, + refund.reference, + ); + const result = await this.buyCryptoRepo.update( + { + id: buyCrypto.id, + isComplete: false, + chargebackAllowedDate: dto.chargebackAllowedDate, + }, + update, + ); + if (result.affected !== 1) { + const current = await this.buyCryptoRepo.findOneBy({ id: buyCrypto.id }); + if (current?.isComplete) return; + throw new ConflictException('BuyCrypto refund finalization changed concurrently'); + } + + await this.transactionAmlCheckService.createFromEntity( + buyCrypto, + 'BuyCrypto', + AmlSourceType.CHARGEBACK, + previousAmlCheck, + previousAmlReason, + ); + } + async refundCryptoInput(buyCrypto: BuyCrypto, dto: CryptoInputRefund): Promise { if (!dto.refundUserAddress && !dto.refundUserId && !buyCrypto.chargebackIban) throw new BadRequestException('You have to define a chargebackAddress'); @@ -721,14 +785,81 @@ export class BuyCryptoService implements OnModuleInit { TransactionUtilService.validateRefund(buyCrypto, { refundUser, chargebackAmount, assetMismatch: false }); + const refundAddress = refundUser.address ?? buyCrypto.chargebackIban; let blockchainFee: number; if (dto.chargebackAllowedDate && chargebackAmount) { blockchainFee = await this.transactionHelper.getBlockchainFee(buyCrypto.cryptoInput.asset, true); - await this.payInService.returnPayIn( - buyCrypto.cryptoInput, - refundUser.address ?? buyCrypto.chargebackIban, - chargebackAmount, + const { entity, previousAmlCheck, previousAmlReason } = await this.buyCryptoRepo.manager.transaction( + async (manager) => { + const lockedRow = await manager.findOne(BuyCrypto, { + where: { id: buyCrypto.id }, + select: { id: true }, + loadEagerRelations: false, + lock: { mode: 'pessimistic_write' }, + }); + if (!lockedRow) throw new NotFoundException('BuyCrypto not found'); + + const lockedBuyCrypto = await manager.findOne(BuyCrypto, { + where: { id: buyCrypto.id }, + relations: { transaction: { userData: true } }, + }); + if (!lockedBuyCrypto) throw new NotFoundException('BuyCrypto not found'); + + const lockedCryptoInput = await manager.findOne(CryptoInput, { + where: { id: buyCrypto.cryptoInput.id }, + relations: { asset: true, route: { user: true }, transaction: true }, + lock: { mode: 'pessimistic_write' }, + }); + if (!lockedCryptoInput) throw new NotFoundException('CryptoInput not found'); + + lockedBuyCrypto.cryptoInput = lockedCryptoInput; + TransactionUtilService.validateRefund(lockedBuyCrypto, { + refundUser, + chargebackAmount, + assetMismatch: false, + }); + + const previousAmlCheck = lockedBuyCrypto.amlCheck; + const previousAmlReason = lockedBuyCrypto.amlReason; + const [, update] = lockedBuyCrypto.chargebackFillUp( + refundAddress, + chargebackAmount, + chargebackAmount, + dto.chargebackCurrency, + dto.chargebackAllowedDate, + dto.chargebackAllowedDateUser, + dto.chargebackAllowedBy, + undefined, + undefined, + blockchainFee, + ); + + await this.payInService.returnPayIn(lockedCryptoInput, refundAddress, chargebackAmount, manager); + const result = await manager.update( + BuyCrypto, + { + id: lockedBuyCrypto.id, + isComplete: false, + batch: IsNull(), + chargebackAllowedDate: IsNull(), + chargebackAllowedDateUser: IsNull(), + }, + update, + ); + if (result.affected !== 1) throw new ConflictException('BuyCrypto refund state changed concurrently'); + + return { entity: lockedBuyCrypto, previousAmlCheck, previousAmlReason }; + }, + ); + + await this.transactionAmlCheckService.createFromEntity( + entity, + 'BuyCrypto', + AmlSourceType.CHARGEBACK, + previousAmlCheck, + previousAmlReason, ); + return; } const previousAmlCheck = buyCrypto.amlCheck; @@ -736,7 +867,7 @@ export class BuyCryptoService implements OnModuleInit { await this.buyCryptoRepo.update( ...buyCrypto.chargebackFillUp( - refundUser.address ?? buyCrypto.chargebackIban, + refundAddress, chargebackAmount, chargebackAmount, dto.chargebackCurrency, diff --git a/src/subdomains/supporting/payin/services/__tests__/payin.service.spec.ts b/src/subdomains/supporting/payin/services/__tests__/payin.service.spec.ts index 477368b416..8d5225314d 100644 --- a/src/subdomains/supporting/payin/services/__tests__/payin.service.spec.ts +++ b/src/subdomains/supporting/payin/services/__tests__/payin.service.spec.ts @@ -5,10 +5,10 @@ import { Util } from 'src/shared/utils/util'; import { PaymentLinkPaymentService } from 'src/subdomains/core/payment-link/services/payment-link-payment.service'; import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service'; import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; -import { In, IsNull, LessThan, Not } from 'typeorm'; +import { EntityManager, In, IsNull, LessThan, Not } from 'typeorm'; import { RetryPayInSendDto } from '../../dto/retry-payin-send.dto'; import { createCustomCryptoInput } from '../../entities/__mocks__/crypto-input.entity.mock'; -import { PayInAction, PayInStatus, PayInType } from '../../entities/crypto-input.entity'; +import { CryptoInput, PayInAction, PayInStatus, PayInType } from '../../entities/crypto-input.entity'; import { PayInRepository } from '../../repositories/payin.repository'; import { RegisterStrategyRegistry } from '../../strategies/register/impl/base/register.strategy-registry'; import { SendStrategyRegistry } from '../../strategies/send/impl/base/send.strategy-registry'; @@ -125,6 +125,27 @@ describe('PayInService designate-before-broadcast safeguards', () => { }, ); + it('persists a return claim through the supplied transaction manager', async () => { + const payIn = createCustomCryptoInput({ + id: 52, + status: PayInStatus.ACKNOWLEDGED, + action: PayInAction.WAITING, + returnTxId: null, + transaction: undefined, + }); + const manager = { save: jest.fn().mockResolvedValue(payIn) }; + + await service.returnPayIn( + payIn, + '0x0000000000000000000000000000000000000001', + 0.1, + manager as unknown as EntityManager, + ); + + expect(manager.save).toHaveBeenCalledWith(CryptoInput, payIn); + expect(payInRepository.save).not.toHaveBeenCalled(); + }); + it('keeps Sending and SendUncertain in the finance-log pending set', async () => { const findBySpy = jest.spyOn(payInRepository, 'findBy').mockResolvedValue([]); diff --git a/src/subdomains/supporting/payin/services/payin.service.ts b/src/subdomains/supporting/payin/services/payin.service.ts index 8fb2fe4adc..32baf476a2 100644 --- a/src/subdomains/supporting/payin/services/payin.service.ts +++ b/src/subdomains/supporting/payin/services/payin.service.ts @@ -15,7 +15,7 @@ import { Sell } from 'src/subdomains/core/sell-crypto/route/sell.entity'; import { Staking } from 'src/subdomains/core/staking/entities/staking.entity'; import { MailContext, MailType } from 'src/subdomains/supporting/notification/enums'; import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service'; -import { In, IsNull, LessThan, MoreThan, Not } from 'typeorm'; +import { EntityManager, In, IsNull, LessThan, MoreThan, Not } from 'typeorm'; import { DepositRoute } from '../../address-pool/route/deposit-route.entity'; import { TransactionSourceType, TransactionTypeInternal } from '../../payment/entities/transaction.entity'; import { TransactionService } from '../../payment/services/transaction.service'; @@ -239,7 +239,12 @@ export class PayInService { }); } - async returnPayIn(payIn: CryptoInput, returnAddress: string, chargebackAmount: number): Promise { + async returnPayIn( + payIn: CryptoInput, + returnAddress: string, + chargebackAmount: number, + manager?: EntityManager, + ): Promise { if (payIn.action === PayInAction.FORWARD) throw new BadRequestException('CryptoInput already forwarded'); if (CryptoInputInFlightSendStatus.includes(payIn.status)) throw new BadRequestException('CryptoInput send in flight or uncertain'); @@ -249,12 +254,17 @@ export class PayInService { payIn.triggerReturn(BlockchainAddress.create(returnAddress, payIn.asset.blockchain), chargebackAmount); if (payIn.transaction) - await this.transactionService.updateInternal(payIn.transaction, { - type: TransactionTypeInternal.CRYPTO_INPUT_RETURN, - user: payIn.route.user, - }); + await this.transactionService.updateInternal( + payIn.transaction, + { + type: TransactionTypeInternal.CRYPTO_INPUT_RETURN, + user: payIn.route.user, + }, + manager, + ); - await this.payInRepository.save(payIn); + if (manager) await manager.save(CryptoInput, payIn); + else await this.payInRepository.save(payIn); } async ignorePayIn(payIn: CryptoInput, purpose: PayInPurpose, route: DepositRoute): Promise { From 93e44e70c7fde0f8fdc0aecc467e30aac67f6c14 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:22:56 +0200 Subject: [PATCH 12/16] Preserve user refund claims during promotion --- .../buy-crypto-preparation.service.spec.ts | 25 +++++++++++++++++++ .../buy-crypto-preparation.service.ts | 19 +++++++++++--- .../process/services/buy-crypto.service.ts | 6 +++-- 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-preparation.service.spec.ts b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-preparation.service.spec.ts index e9b22ff866..63f5d33f6f 100644 --- a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-preparation.service.spec.ts +++ b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-preparation.service.spec.ts @@ -655,5 +655,30 @@ describe('BuyCryptoPreparationService', () => { expect(buyCryptoService.resumeCheckoutRefund).toHaveBeenCalledWith(entity); expect(buyCryptoService.refundCheckoutTx).not.toHaveBeenCalled(); }); + + it.each([ + { source: 'Checkout', relation: { checkoutTx: { id: 88 } }, method: 'refundCheckoutTx' }, + { source: 'CryptoInput', relation: { cryptoInput: { id: 89 } }, method: 'refundCryptoInput' }, + ])('promotes a $source user refund claim without discarding its timestamp', async ({ relation, method }) => { + const chargebackAllowedDateUser = new Date('2026-08-01T12:00:00.000Z'); + const entity = createCustomBuyCrypto({ + id: 78, + ...relation, + chargebackAllowedDate: undefined, + chargebackAllowedDateUser, + chargebackAmount: 1, + isComplete: false, + }); + jest.spyOn(buyCryptoRepo, 'find').mockResolvedValueOnce([]).mockResolvedValueOnce([entity]); + + await service.chargebackTx(); + + const refund = + method === 'refundCheckoutTx' ? buyCryptoService.refundCheckoutTx : buyCryptoService.refundCryptoInput; + expect(refund).toHaveBeenCalledWith( + entity, + expect.objectContaining({ chargebackAllowedDateUser }), + ); + }); }); }); diff --git a/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts b/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts index 8cd2bf5088..fbc99a794e 100644 --- a/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts +++ b/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts @@ -791,6 +791,7 @@ export class BuyCryptoPreparationService { try { const chargebackAllowedDate = new Date(); const chargebackAllowedBy = 'API'; + const chargebackAllowedDateUser = entity.chargebackAllowedDateUser; if (entity.bankTx) { if ( @@ -798,11 +799,23 @@ export class BuyCryptoPreparationService { Util.includesSameName(entity.userData.completeName, entity.creditorData.name) || (!entity.userData.verifiedName && !entity.userData.completeName) ) - await this.buyCryptoService.refundBankTx(entity, { chargebackAllowedDate, chargebackAllowedBy }); + await this.buyCryptoService.refundBankTx(entity, { + chargebackAllowedDate, + chargebackAllowedDateUser, + chargebackAllowedBy, + }); } else if (entity.cryptoInput) { - await this.buyCryptoService.refundCryptoInput(entity, { chargebackAllowedDate, chargebackAllowedBy }); + await this.buyCryptoService.refundCryptoInput(entity, { + chargebackAllowedDate, + chargebackAllowedDateUser, + chargebackAllowedBy, + }); } else { - await this.buyCryptoService.refundCheckoutTx(entity, { chargebackAllowedDate, chargebackAllowedBy }); + await this.buyCryptoService.refundCheckoutTx(entity, { + chargebackAllowedDate, + chargebackAllowedDateUser, + chargebackAllowedBy, + }); } } catch (e) { this.logger.error(`Failed to chargeback buy-crypto ${entity.id}:`, e); diff --git a/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts b/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts index e8a9f966cc..d68f4f1a03 100644 --- a/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts +++ b/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts @@ -668,7 +668,8 @@ export class BuyCryptoService implements OnModuleInit { isComplete: false, batch: IsNull(), chargebackAllowedDate: IsNull(), - chargebackAllowedDateUser: IsNull(), + chargebackAllowedDateUser: + dto.chargebackAllowedDateUser == null ? IsNull() : dto.chargebackAllowedDateUser, }, { chargebackAllowedDate: dto.chargebackAllowedDate, @@ -842,7 +843,8 @@ export class BuyCryptoService implements OnModuleInit { isComplete: false, batch: IsNull(), chargebackAllowedDate: IsNull(), - chargebackAllowedDateUser: IsNull(), + chargebackAllowedDateUser: + dto.chargebackAllowedDateUser == null ? IsNull() : dto.chargebackAllowedDateUser, }, update, ); From e18505396e310215380a3fba1b5a23527d00d827 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:28:34 +0200 Subject: [PATCH 13/16] Protect refund recovery guarantees with tests --- .../__tests__/checkout.service.spec.ts | 13 +++++ .../buy-crypto-preparation.service.spec.ts | 51 +++++++++++-------- .../__tests__/buy-crypto.service.spec.ts | 30 +++++++++-- .../services/__tests__/payin.service.spec.ts | 12 ++++- 4 files changed, 80 insertions(+), 26 deletions(-) create mode 100644 src/integration/checkout/services/__tests__/checkout.service.spec.ts diff --git a/src/integration/checkout/services/__tests__/checkout.service.spec.ts b/src/integration/checkout/services/__tests__/checkout.service.spec.ts new file mode 100644 index 0000000000..f2c5b5505c --- /dev/null +++ b/src/integration/checkout/services/__tests__/checkout.service.spec.ts @@ -0,0 +1,13 @@ +import { CheckoutService } from '../checkout.service'; + +describe('CheckoutService', () => { + it('forwards the refund idempotency key to the Checkout SDK', async () => { + const service = new CheckoutService(); + const refund = jest.fn().mockResolvedValue({ reference: 'refund-22' }); + (service as any).checkout = { payments: { refund } }; + + await service.refundPayment('pay-22', 'buy-crypto-7-checkout-refund'); + + expect(refund).toHaveBeenCalledWith('pay-22', undefined, 'buy-crypto-7-checkout-refund'); + }); +}); diff --git a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-preparation.service.spec.ts b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-preparation.service.spec.ts index 63f5d33f6f..3a7d20f7b6 100644 --- a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-preparation.service.spec.ts +++ b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-preparation.service.spec.ts @@ -659,26 +659,35 @@ describe('BuyCryptoPreparationService', () => { it.each([ { source: 'Checkout', relation: { checkoutTx: { id: 88 } }, method: 'refundCheckoutTx' }, { source: 'CryptoInput', relation: { cryptoInput: { id: 89 } }, method: 'refundCryptoInput' }, - ])('promotes a $source user refund claim without discarding its timestamp', async ({ relation, method }) => { - const chargebackAllowedDateUser = new Date('2026-08-01T12:00:00.000Z'); - const entity = createCustomBuyCrypto({ - id: 78, - ...relation, - chargebackAllowedDate: undefined, - chargebackAllowedDateUser, - chargebackAmount: 1, - isComplete: false, - }); - jest.spyOn(buyCryptoRepo, 'find').mockResolvedValueOnce([]).mockResolvedValueOnce([entity]); - - await service.chargebackTx(); - - const refund = - method === 'refundCheckoutTx' ? buyCryptoService.refundCheckoutTx : buyCryptoService.refundCryptoInput; - expect(refund).toHaveBeenCalledWith( - entity, - expect.objectContaining({ chargebackAllowedDateUser }), - ); - }); + { + source: 'BankTx', + relation: { bankTx: { id: 90 } }, + method: 'refundBankTx', + refundData: { + chargebackIban: 'CH9300762011623852957', + chargebackCreditorData: JSON.stringify({ name: 'Refund Recipient' }), + }, + }, + ])( + 'promotes a $source user refund claim without discarding its timestamp', + async ({ relation, method, refundData = {} }) => { + const chargebackAllowedDateUser = new Date('2026-08-01T12:00:00.000Z'); + const entity = createCustomBuyCrypto({ + id: 78, + ...relation, + ...refundData, + chargebackAllowedDate: undefined, + chargebackAllowedDateUser, + chargebackAmount: 1, + isComplete: false, + }); + jest.spyOn(buyCryptoRepo, 'find').mockResolvedValueOnce([]).mockResolvedValueOnce([entity]); + + await service.chargebackTx(); + + const refund = buyCryptoService[method as 'refundCheckoutTx' | 'refundCryptoInput' | 'refundBankTx']; + expect(refund).toHaveBeenCalledWith(entity, expect.objectContaining({ chargebackAllowedDateUser })); + }, + ); }); }); diff --git a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts index 7ab98cc1fc..244616020a 100644 --- a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts +++ b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts @@ -348,9 +348,11 @@ describe('BuyCryptoService', () => { }); it('persists the checkout refund claim before calling the external provider', async () => { + const chargebackAllowedDateUser = new Date('2026-08-01T12:00:00.000Z'); const buyCrypto = createCustomBuyCrypto({ id: 7, inputAmount: 10, + chargebackAllowedDateUser, checkoutTx: Object.assign(new CheckoutTx(), { id: 22, paymentId: 'pay-22' }), }); jest.spyOn(TransactionUtilService, 'validateRefund').mockImplementation(); @@ -373,12 +375,16 @@ describe('BuyCryptoService', () => { }); buyCryptoRepo.update.mockResolvedValue({ affected: 1, raw: [], generatedMaps: [] }); - await service.refundCheckoutTx(buyCrypto, { chargebackAllowedDate: new Date() }); + await service.refundCheckoutTx(buyCrypto, { chargebackAllowedDate: new Date(), chargebackAllowedDateUser }); expect(manager.update).toHaveBeenNthCalledWith( 1, BuyCrypto, - expect.objectContaining({ id: 7, chargebackAllowedDate: expect.anything() }), + expect.objectContaining({ + id: 7, + chargebackAllowedDate: expect.anything(), + chargebackAllowedDateUser, + }), expect.objectContaining({ chargebackAllowedDate: expect.any(Date) }), ); expect(manager.update).toHaveBeenNthCalledWith(2, CheckoutTx, 22, { @@ -420,6 +426,7 @@ describe('BuyCryptoService', () => { }); it('claims CryptoInput return state and BuyCrypto chargeback state in the same transaction', async () => { + const chargebackAllowedDateUser = new Date('2026-08-01T12:00:00.000Z'); const cryptoInput = createCustomCryptoInput({ id: 24, asset: createCustomAsset({ blockchain: Blockchain.ETHEREUM }), @@ -427,6 +434,7 @@ describe('BuyCryptoService', () => { const buyCrypto = createCustomBuyCrypto({ id: 9, cryptoInput, + chargebackAllowedDateUser, chargebackAmount: 1, isComplete: false, }); @@ -455,8 +463,20 @@ describe('BuyCryptoService', () => { refundUserAddress: refundUser.address, chargebackAmount: 1, chargebackAllowedDate: new Date(), + chargebackAllowedDateUser, }); + expect(manager.findOne).toHaveBeenNthCalledWith( + 1, + BuyCrypto, + expect.objectContaining({ where: { id: 9 }, lock: { mode: 'pessimistic_write' } }), + ); + expect(manager.findOne).toHaveBeenNthCalledWith( + 3, + CryptoInput, + expect.objectContaining({ where: { id: 24 }, lock: { mode: 'pessimistic_write' } }), + ); + expect(manager.findOne.mock.invocationCallOrder[0]).toBeLessThan(manager.findOne.mock.invocationCallOrder[2]); expect(payInService.returnPayIn).toHaveBeenCalledWith( cryptoInput, refundUser.address, @@ -465,7 +485,11 @@ describe('BuyCryptoService', () => { ); expect(manager.update).toHaveBeenCalledWith( BuyCrypto, - expect.objectContaining({ id: 9, chargebackAllowedDate: expect.anything() }), + expect.objectContaining({ + id: 9, + chargebackAllowedDate: expect.anything(), + chargebackAllowedDateUser, + }), expect.objectContaining({ chargebackAllowedDate: expect.any(Date), amlCheck: CheckStatus.FAIL }), ); }); diff --git a/src/subdomains/supporting/payin/services/__tests__/payin.service.spec.ts b/src/subdomains/supporting/payin/services/__tests__/payin.service.spec.ts index 8d5225314d..4e8d171cef 100644 --- a/src/subdomains/supporting/payin/services/__tests__/payin.service.spec.ts +++ b/src/subdomains/supporting/payin/services/__tests__/payin.service.spec.ts @@ -20,6 +20,7 @@ describe('PayInService designate-before-broadcast safeguards', () => { let service: PayInService; let payInRepository: PayInRepository; let notificationService: NotificationService; + let transactionService: TransactionService; beforeAll(() => { new ConfigService(); @@ -28,11 +29,12 @@ describe('PayInService designate-before-broadcast safeguards', () => { beforeEach(() => { payInRepository = mock(); notificationService = mock(); + transactionService = mock(); service = new PayInService( payInRepository, mock(), mock(), - mock(), + transactionService, mock(), mock(), mock(), @@ -126,12 +128,13 @@ describe('PayInService designate-before-broadcast safeguards', () => { ); it('persists a return claim through the supplied transaction manager', async () => { + const transaction = { id: 53 } as any; const payIn = createCustomCryptoInput({ id: 52, status: PayInStatus.ACKNOWLEDGED, action: PayInAction.WAITING, returnTxId: null, - transaction: undefined, + transaction, }); const manager = { save: jest.fn().mockResolvedValue(payIn) }; @@ -142,6 +145,11 @@ describe('PayInService designate-before-broadcast safeguards', () => { manager as unknown as EntityManager, ); + expect(transactionService.updateInternal).toHaveBeenCalledWith( + transaction, + expect.objectContaining({ user: payIn.route.user }), + manager, + ); expect(manager.save).toHaveBeenCalledWith(CryptoInput, payIn); expect(payInRepository.save).not.toHaveBeenCalled(); }); From f80b1ae73a02cb9bc9daf44da10eda139b9f71be Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:34:57 +0200 Subject: [PATCH 14/16] Fix refund regression test gates --- .../buy-crypto-preparation.service.spec.ts | 6 +++--- .../__tests__/buy-crypto.service.spec.ts | 18 +++++++++--------- .../process/services/buy-crypto.service.ts | 3 +-- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-preparation.service.spec.ts b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-preparation.service.spec.ts index 3a7d20f7b6..9a1d99373f 100644 --- a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-preparation.service.spec.ts +++ b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-preparation.service.spec.ts @@ -657,11 +657,11 @@ describe('BuyCryptoPreparationService', () => { }); it.each([ - { source: 'Checkout', relation: { checkoutTx: { id: 88 } }, method: 'refundCheckoutTx' }, - { source: 'CryptoInput', relation: { cryptoInput: { id: 89 } }, method: 'refundCryptoInput' }, + { source: 'Checkout', relation: { checkoutTx: { id: 88 } as any }, method: 'refundCheckoutTx' }, + { source: 'CryptoInput', relation: { cryptoInput: { id: 89 } as any }, method: 'refundCryptoInput' }, { source: 'BankTx', - relation: { bankTx: { id: 90 } }, + relation: { bankTx: { id: 90 } as any }, method: 'refundBankTx', refundData: { chargebackIban: 'CH9300762011623852957', diff --git a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts index 244616020a..98cf60b48d 100644 --- a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts +++ b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts @@ -356,7 +356,7 @@ describe('BuyCryptoService', () => { checkoutTx: Object.assign(new CheckoutTx(), { id: 22, paymentId: 'pay-22' }), }); jest.spyOn(TransactionUtilService, 'validateRefund').mockImplementation(); - checkoutService.refundPayment.mockResolvedValue({ + const refundPaymentSpy = jest.spyOn(checkoutService, 'refundPayment').mockResolvedValue({ action_id: 'action-22', reference: 'refund-22', _links: { payment: { href: 'https://example.test/payment/pay-22' } }, @@ -373,7 +373,7 @@ describe('BuyCryptoService', () => { ), }, }); - buyCryptoRepo.update.mockResolvedValue({ affected: 1, raw: [], generatedMaps: [] }); + jest.spyOn(buyCryptoRepo, 'update').mockResolvedValue({ affected: 1, raw: [], generatedMaps: [] }); await service.refundCheckoutTx(buyCrypto, { chargebackAllowedDate: new Date(), chargebackAllowedDateUser }); @@ -391,9 +391,9 @@ describe('BuyCryptoService', () => { status: CheckoutPaymentStatus.REFUND_PENDING, }); expect(manager.update.mock.invocationCallOrder[1]).toBeLessThan( - checkoutService.refundPayment.mock.invocationCallOrder[0], + refundPaymentSpy.mock.invocationCallOrder[0], ); - expect(checkoutService.refundPayment).toHaveBeenCalledWith('pay-22', 'buy-crypto-7-checkout-refund'); + expect(refundPaymentSpy).toHaveBeenCalledWith('pay-22', 'buy-crypto-7-checkout-refund'); }); it('can safely resume a persisted checkout refund claim with the same idempotency key', async () => { @@ -405,16 +405,16 @@ describe('BuyCryptoService', () => { isComplete: false, checkoutTx: Object.assign(new CheckoutTx(), { id: 23, paymentId: 'pay-23' }), }); - checkoutService.refundPayment.mockResolvedValue({ + const refundPaymentSpy = jest.spyOn(checkoutService, 'refundPayment').mockResolvedValue({ action_id: 'action-23', reference: 'refund-23', _links: { payment: { href: 'https://example.test/payment/pay-23' } }, }); - buyCryptoRepo.update.mockResolvedValue({ affected: 1, raw: [], generatedMaps: [] }); + jest.spyOn(buyCryptoRepo, 'update').mockResolvedValue({ affected: 1, raw: [], generatedMaps: [] }); await service.resumeCheckoutRefund(buyCrypto); - expect(checkoutService.refundPayment).toHaveBeenCalledWith('pay-23', 'buy-crypto-8-checkout-refund'); + expect(refundPaymentSpy).toHaveBeenCalledWith('pay-23', 'buy-crypto-8-checkout-refund'); expect(buyCryptoRepo.update).toHaveBeenCalledWith( expect.objectContaining({ id: 8, @@ -439,8 +439,8 @@ describe('BuyCryptoService', () => { isComplete: false, }); const refundUser = { address: '0x0000000000000000000000000000000000000001' } as any; - userService.getUserByAddress.mockResolvedValue(refundUser); - transactionHelper.getBlockchainFee.mockResolvedValue(0.01); + jest.spyOn(userService, 'getUserByAddress').mockResolvedValue(refundUser); + jest.spyOn(transactionHelper, 'getBlockchainFee').mockResolvedValue(0.01); jest.spyOn(TransactionUtilService, 'validateRefund').mockImplementation(); const manager = { findOne: jest diff --git a/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts b/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts index d68f4f1a03..249f496f0b 100644 --- a/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts +++ b/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts @@ -668,8 +668,7 @@ export class BuyCryptoService implements OnModuleInit { isComplete: false, batch: IsNull(), chargebackAllowedDate: IsNull(), - chargebackAllowedDateUser: - dto.chargebackAllowedDateUser == null ? IsNull() : dto.chargebackAllowedDateUser, + chargebackAllowedDateUser: dto.chargebackAllowedDateUser == null ? IsNull() : dto.chargebackAllowedDateUser, }, { chargebackAllowedDate: dto.chargebackAllowedDate, From 9b4456568c1d108cb2a42750ed762f90ea5fcc6f Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:40:11 +0200 Subject: [PATCH 15/16] Format refund ordering assertion --- .../process/services/__tests__/buy-crypto.service.spec.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts index 98cf60b48d..40fc280a7f 100644 --- a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts +++ b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts @@ -390,9 +390,7 @@ describe('BuyCryptoService', () => { expect(manager.update).toHaveBeenNthCalledWith(2, CheckoutTx, 22, { status: CheckoutPaymentStatus.REFUND_PENDING, }); - expect(manager.update.mock.invocationCallOrder[1]).toBeLessThan( - refundPaymentSpy.mock.invocationCallOrder[0], - ); + expect(manager.update.mock.invocationCallOrder[1]).toBeLessThan(refundPaymentSpy.mock.invocationCallOrder[0]); expect(refundPaymentSpy).toHaveBeenCalledWith('pay-22', 'buy-crypto-7-checkout-refund'); }); From ee4d4e7443d05ec5ce840cad4f679a61001cb4b1 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 2 Aug 2026 01:00:49 +0200 Subject: [PATCH 16/16] Make AML review resets refund-safe --- .../__tests__/checkout.service.spec.ts | 42 +- .../checkout/services/checkout.service.ts | 31 +- .../buy-crypto-preparation.service.spec.ts | 19 +- .../__tests__/buy-crypto.service.spec.ts | 314 ++++++++++++--- .../buy-crypto-preparation.service.ts | 16 - .../process/services/buy-crypto.service.ts | 372 ++++++++---------- .../support/__tests__/support.service.spec.ts | 39 +- .../support/dto/user-data-support.dto.ts | 1 + .../generic/support/support.service.ts | 55 ++- .../__tests__/fiat-payin-sync.service.spec.ts | 312 +++++++++++++++ .../services/checkout-tx.service.ts | 5 +- .../services/fiat-payin-sync.service.ts | 134 ++++++- .../services/__tests__/payin.service.spec.ts | 25 +- .../payment/services/transaction.service.ts | 1 + 14 files changed, 1015 insertions(+), 351 deletions(-) create mode 100644 src/subdomains/supporting/fiat-payin/services/__tests__/fiat-payin-sync.service.spec.ts diff --git a/src/integration/checkout/services/__tests__/checkout.service.spec.ts b/src/integration/checkout/services/__tests__/checkout.service.spec.ts index f2c5b5505c..3741b5bbf3 100644 --- a/src/integration/checkout/services/__tests__/checkout.service.spec.ts +++ b/src/integration/checkout/services/__tests__/checkout.service.spec.ts @@ -1,13 +1,45 @@ import { CheckoutService } from '../checkout.service'; -describe('CheckoutService', () => { - it('forwards the refund idempotency key to the Checkout SDK', async () => { +describe('CheckoutService refund idempotency', () => { + it('returns the payment actions used for refund reconciliation', async () => { const service = new CheckoutService(); - const refund = jest.fn().mockResolvedValue({ reference: 'refund-22' }); + const actions = [{ id: 'action-1', type: 'Refund', approved: true }]; + const getActions = jest.fn().mockResolvedValue(actions); + (service as any).checkout = { payments: { getActions } }; + + await expect(service.getPaymentActions('payment-1')).resolves.toBe(actions); + expect(getActions).toHaveBeenCalledWith('payment-1'); + }); + + it('uses a stable initial BuyCrypto idempotency key', async () => { + const service = new CheckoutService(); + const refund = jest.fn().mockResolvedValue({ action_id: 'action-1' }); + (service as any).checkout = { payments: { refund } }; + + await service.refundBuyCryptoPayment('payment-1', 130504); + + expect(refund).toHaveBeenCalledWith( + 'payment-1', + { reference: 'bc-130504-refund' }, + 'buy-crypto-130504-checkout-refund', + ); + }); + + it('uses a fresh stable key after a failed refund action', async () => { + const service = new CheckoutService(); + const refund = jest.fn().mockResolvedValue({ action_id: 'action-2' }); (service as any).checkout = { payments: { refund } }; - await service.refundPayment('pay-22', 'buy-crypto-7-checkout-refund'); + await service.refundBuyCryptoPayment('payment-1', 130504, 'action-failed'); + + expect(refund).toHaveBeenCalledWith( + 'payment-1', + { reference: 'bc-130504-refund' }, + 'buy-crypto-refund-130504-action-failed', + ); + }); - expect(refund).toHaveBeenCalledWith('pay-22', undefined, 'buy-crypto-7-checkout-refund'); + it('keeps the workflow reference within the 30-character card-network limit', () => { + expect(CheckoutService.buyCryptoRefundReference(Number.MAX_SAFE_INTEGER)).toHaveLength(26); }); }); diff --git a/src/integration/checkout/services/checkout.service.ts b/src/integration/checkout/services/checkout.service.ts index 57e1701f6c..d1c999a03d 100644 --- a/src/integration/checkout/services/checkout.service.ts +++ b/src/integration/checkout/services/checkout.service.ts @@ -22,10 +22,18 @@ export interface CheckoutBalances { export interface CheckoutReverse { action_id: string; - reference: string; _links: { payment: { href: string } }; } +export interface CheckoutPaymentAction { + id: string; + type: string; + amount?: number; + reference?: string; + approved?: boolean; + processed_on?: string; +} + @Injectable() export class CheckoutService { private readonly reference = 'DFX'; @@ -112,7 +120,24 @@ export class CheckoutService { return balance.data; } - async refundPayment(paymentId: string, idempotencyKey?: string): Promise { - return (await this.checkout.payments.refund(paymentId, undefined, idempotencyKey)) as CheckoutReverse; + async getPaymentActions(paymentId: string): Promise { + return (await this.checkout.payments.getActions(paymentId)) as CheckoutPaymentAction[]; + } + + async refundBuyCryptoPayment( + paymentId: string, + buyCryptoId: number, + previousFailedActionId?: string, + ): Promise { + const attempt = previousFailedActionId ?? 'initial'; + const reference = CheckoutService.buyCryptoRefundReference(buyCryptoId); + const idempotencyKey = previousFailedActionId + ? `buy-crypto-refund-${buyCryptoId}-${attempt}` + : `buy-crypto-${buyCryptoId}-checkout-refund`; + return (await this.checkout.payments.refund(paymentId, { reference }, idempotencyKey)) as CheckoutReverse; + } + + static buyCryptoRefundReference(buyCryptoId: number): string { + return `bc-${buyCryptoId}-refund`; } } diff --git a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-preparation.service.spec.ts b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-preparation.service.spec.ts index 9a1d99373f..8ad49f3b47 100644 --- a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-preparation.service.spec.ts +++ b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-preparation.service.spec.ts @@ -640,22 +640,7 @@ describe('BuyCryptoPreparationService', () => { }); }); - describe('chargebackTx — durable Checkout refund recovery', () => { - it('resumes a persisted incomplete Checkout refund claim before processing new claims', async () => { - const entity = createCustomBuyCrypto({ - id: 77, - chargebackAllowedDate: new Date(), - isComplete: false, - checkoutTx: { id: 88 } as any, - }); - jest.spyOn(buyCryptoRepo, 'find').mockResolvedValueOnce([entity]).mockResolvedValueOnce([]); - - await service.chargebackTx(); - - expect(buyCryptoService.resumeCheckoutRefund).toHaveBeenCalledWith(entity); - expect(buyCryptoService.refundCheckoutTx).not.toHaveBeenCalled(); - }); - + describe('chargebackTx — user refund claim promotion', () => { it.each([ { source: 'Checkout', relation: { checkoutTx: { id: 88 } as any }, method: 'refundCheckoutTx' }, { source: 'CryptoInput', relation: { cryptoInput: { id: 89 } as any }, method: 'refundCryptoInput' }, @@ -681,7 +666,7 @@ describe('BuyCryptoPreparationService', () => { chargebackAmount: 1, isComplete: false, }); - jest.spyOn(buyCryptoRepo, 'find').mockResolvedValueOnce([]).mockResolvedValueOnce([entity]); + jest.spyOn(buyCryptoRepo, 'find').mockResolvedValueOnce([entity]); await service.chargebackTx(); diff --git a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts index 40fc280a7f..e97e4d35d6 100644 --- a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts +++ b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts @@ -28,6 +28,7 @@ import { KycStatus } from 'src/subdomains/generic/user/models/user-data/user-dat import { UserDataService } from 'src/subdomains/generic/user/models/user-data/user-data.service'; import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; import { BankTxService } from 'src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service'; +import { FiatOutputType } from 'src/subdomains/supporting/fiat-output/fiat-output.entity'; import { FiatOutputService } from 'src/subdomains/supporting/fiat-output/fiat-output.service'; import { CheckoutTx } from 'src/subdomains/supporting/fiat-payin/entities/checkout-tx.entity'; import { createCustomCryptoInput } from 'src/subdomains/supporting/payin/entities/__mocks__/crypto-input.entity.mock'; @@ -348,21 +349,22 @@ describe('BuyCryptoService', () => { }); it('persists the checkout refund claim before calling the external provider', async () => { - const chargebackAllowedDateUser = new Date('2026-08-01T12:00:00.000Z'); const buyCrypto = createCustomBuyCrypto({ id: 7, inputAmount: 10, - chargebackAllowedDateUser, checkoutTx: Object.assign(new CheckoutTx(), { id: 22, paymentId: 'pay-22' }), }); jest.spyOn(TransactionUtilService, 'validateRefund').mockImplementation(); - const refundPaymentSpy = jest.spyOn(checkoutService, 'refundPayment').mockResolvedValue({ + const refundPaymentSpy = jest.spyOn(checkoutService, 'refundBuyCryptoPayment').mockResolvedValue({ action_id: 'action-22', - reference: 'refund-22', _links: { payment: { href: 'https://example.test/payment/pay-22' } }, }); const manager = { - findOne: jest.fn().mockResolvedValueOnce(buyCrypto).mockResolvedValueOnce(buyCrypto.checkoutTx), + findOne: jest.fn(async (type: unknown, options: { select?: { id?: boolean } }) => { + if (type === BuyCrypto) return options.select?.id ? { id: 7 } : buyCrypto; + if (type === CheckoutTx) return buyCrypto.checkoutTx; + return undefined; + }), update: jest.fn().mockResolvedValue({ affected: 1 }), }; Object.defineProperty(buyCryptoRepo, 'manager', { @@ -373,17 +375,19 @@ describe('BuyCryptoService', () => { ), }, }); - jest.spyOn(buyCryptoRepo, 'update').mockResolvedValue({ affected: 1, raw: [], generatedMaps: [] }); - await service.refundCheckoutTx(buyCrypto, { chargebackAllowedDate: new Date(), chargebackAllowedDateUser }); + await service.refundCheckoutTx(buyCrypto, { chargebackAllowedDate: new Date() }); expect(manager.update).toHaveBeenNthCalledWith( 1, BuyCrypto, expect.objectContaining({ id: 7, + chargebackOutput: expect.anything(), chargebackAllowedDate: expect.anything(), - chargebackAllowedDateUser, + chargebackDate: expect.anything(), + chargebackCryptoTxId: expect.anything(), + chargebackBankTx: expect.anything(), }), expect.objectContaining({ chargebackAllowedDate: expect.any(Date) }), ); @@ -391,61 +395,85 @@ describe('BuyCryptoService', () => { status: CheckoutPaymentStatus.REFUND_PENDING, }); expect(manager.update.mock.invocationCallOrder[1]).toBeLessThan(refundPaymentSpy.mock.invocationCallOrder[0]); - expect(refundPaymentSpy).toHaveBeenCalledWith('pay-22', 'buy-crypto-7-checkout-refund'); + expect(refundPaymentSpy).toHaveBeenCalledWith('pay-22', 7); }); - it('can safely resume a persisted checkout refund claim with the same idempotency key', async () => { + it('leaves a failed Checkout submission durably pending for the idempotent retry job', async () => { const buyCrypto = createCustomBuyCrypto({ - id: 8, + id: 7, inputAmount: 10, - chargebackAmount: 10, - chargebackAllowedDate: new Date(), - isComplete: false, - checkoutTx: Object.assign(new CheckoutTx(), { id: 23, paymentId: 'pay-23' }), + checkoutTx: Object.assign(new CheckoutTx(), { id: 22, paymentId: 'pay-22' }), }); - const refundPaymentSpy = jest.spyOn(checkoutService, 'refundPayment').mockResolvedValue({ - action_id: 'action-23', - reference: 'refund-23', - _links: { payment: { href: 'https://example.test/payment/pay-23' } }, + jest.spyOn(TransactionUtilService, 'validateRefund').mockImplementation(); + jest.spyOn(checkoutService, 'refundBuyCryptoPayment').mockRejectedValue(new Error('network timeout')); + const manager = { + findOne: jest.fn(async (type: unknown, options: { select?: { id?: boolean } }) => { + if (type === BuyCrypto) return options.select?.id ? { id: 7 } : buyCrypto; + if (type === CheckoutTx) return buyCrypto.checkoutTx; + return undefined; + }), + update: jest.fn().mockResolvedValue({ affected: 1 }), + }; + Object.defineProperty(buyCryptoRepo, 'manager', { + configurable: true, + value: { + transaction: jest.fn(async (run: (entityManager: EntityManager) => unknown) => + run(manager as unknown as EntityManager), + ), + }, }); - jest.spyOn(buyCryptoRepo, 'update').mockResolvedValue({ affected: 1, raw: [], generatedMaps: [] }); - await service.resumeCheckoutRefund(buyCrypto); + await expect(service.refundCheckoutTx(buyCrypto, { chargebackAllowedDate: new Date() })).rejects.toThrow( + 'network timeout', + ); - expect(refundPaymentSpy).toHaveBeenCalledWith('pay-23', 'buy-crypto-8-checkout-refund'); - expect(buyCryptoRepo.update).toHaveBeenCalledWith( + expect(manager.update).toHaveBeenNthCalledWith( + 1, + BuyCrypto, + expect.anything(), expect.objectContaining({ - id: 8, - isComplete: false, - chargebackAllowedDate: buyCrypto.chargebackAllowedDate, + amlCheck: CheckStatus.FAIL, + chargebackAllowedDate: expect.any(Date), + isComplete: true, + status: BuyCryptoStatus.COMPLETE, }), - expect.objectContaining({ isComplete: true, status: BuyCryptoStatus.COMPLETE }), ); + expect(manager.update).toHaveBeenNthCalledWith(2, CheckoutTx, 22, { + status: CheckoutPaymentStatus.REFUND_PENDING, + }); + expect(transactionAmlCheckService.createFromEntity).toHaveBeenCalled(); }); - it('claims CryptoInput return state and BuyCrypto chargeback state in the same transaction', async () => { - const chargebackAllowedDateUser = new Date('2026-08-01T12:00:00.000Z'); + it('claims and schedules a crypto refund atomically before releasing the BuyCrypto lock', async () => { const cryptoInput = createCustomCryptoInput({ - id: 24, - asset: createCustomAsset({ blockchain: Blockchain.ETHEREUM }), + id: 23, + action: PayInAction.WAITING, + status: PayInStatus.ACKNOWLEDGED, }); const buyCrypto = createCustomBuyCrypto({ - id: 9, - cryptoInput, - chargebackAllowedDateUser, + id: 7, + amlCheck: CheckStatus.PENDING, + batch: null, + outputAmount: null, chargebackAmount: 1, - isComplete: false, + cryptoInput, }); - const refundUser = { address: '0x0000000000000000000000000000000000000001' } as any; - jest.spyOn(userService, 'getUserByAddress').mockResolvedValue(refundUser); - jest.spyOn(transactionHelper, 'getBlockchainFee').mockResolvedValue(0.01); + const currentBuyCrypto = createCustomBuyCrypto({ ...buyCrypto, cryptoInput: undefined }); + const refundUser = { + address: '0x0000000000000000000000000000000000000001', + userData: buyCrypto.userData, + blockchains: [cryptoInput.asset.blockchain], + }; + jest.spyOn(userService, 'getUserByAddress').mockResolvedValue(refundUser as any); jest.spyOn(TransactionUtilService, 'validateRefund').mockImplementation(); + jest.spyOn(transactionHelper, 'getBlockchainFee').mockResolvedValue(0.01); + const returnPayInSpy = jest.spyOn(payInService, 'returnPayIn').mockResolvedValue(); const manager = { - findOne: jest - .fn() - .mockResolvedValueOnce({ id: 9 }) - .mockResolvedValueOnce(buyCrypto) - .mockResolvedValueOnce(cryptoInput), + findOne: jest.fn(async (type: unknown, options: { select?: { id?: boolean } }) => { + if (type === BuyCrypto) return options.select?.id ? { id: 7 } : currentBuyCrypto; + if (type === CryptoInput) return cryptoInput; + return undefined; + }), update: jest.fn().mockResolvedValue({ affected: 1 }), }; Object.defineProperty(buyCryptoRepo, 'manager', { @@ -459,39 +487,176 @@ describe('BuyCryptoService', () => { await service.refundCryptoInput(buyCrypto, { refundUserAddress: refundUser.address, - chargebackAmount: 1, chargebackAllowedDate: new Date(), - chargebackAllowedDateUser, }); - expect(manager.findOne).toHaveBeenNthCalledWith( - 1, + expect(manager.update).toHaveBeenCalledWith( BuyCrypto, - expect.objectContaining({ where: { id: 9 }, lock: { mode: 'pessimistic_write' } }), - ); - expect(manager.findOne).toHaveBeenNthCalledWith( - 3, - CryptoInput, - expect.objectContaining({ where: { id: 24 }, lock: { mode: 'pessimistic_write' } }), + expect.objectContaining({ + id: 7, + chargebackAllowedDate: expect.anything(), + chargebackDate: expect.anything(), + chargebackCryptoTxId: expect.anything(), + chargebackBankTx: expect.anything(), + }), + expect.objectContaining({ chargebackAllowedDate: expect.any(Date) }), ); - expect(manager.findOne.mock.invocationCallOrder[0]).toBeLessThan(manager.findOne.mock.invocationCallOrder[2]); - expect(payInService.returnPayIn).toHaveBeenCalledWith( + expect(returnPayInSpy).toHaveBeenCalledWith(cryptoInput, refundUser.address, 1, manager); + expect(manager.update.mock.invocationCallOrder[0]).toBeLessThan(returnPayInSpy.mock.invocationCallOrder[0]); + }); + + it('does not schedule a crypto refund when a concurrent AML reset wins', async () => { + const cryptoInput = createCustomCryptoInput({ id: 23 }); + const buyCrypto = createCustomBuyCrypto({ + id: 7, + amlCheck: CheckStatus.PENDING, + batch: null, + outputAmount: null, + chargebackAmount: 1, cryptoInput, - refundUser.address, - 1, - manager as unknown as EntityManager, + }); + const refundUser = { + address: '0x0000000000000000000000000000000000000001', + userData: buyCrypto.userData, + blockchains: [cryptoInput.asset.blockchain], + }; + jest.spyOn(userService, 'getUserByAddress').mockResolvedValue(refundUser as any); + jest.spyOn(TransactionUtilService, 'validateRefund').mockImplementation(); + jest.spyOn(transactionHelper, 'getBlockchainFee').mockResolvedValue(0.01); + const returnPayInSpy = jest.spyOn(payInService, 'returnPayIn').mockResolvedValue(); + const manager = { + findOne: jest + .fn() + .mockResolvedValueOnce({ id: 7 }) + .mockResolvedValueOnce(buyCrypto) + .mockResolvedValueOnce({ id: 23 }) + .mockResolvedValueOnce(cryptoInput), + update: jest.fn().mockResolvedValue({ affected: 0 }), + }; + Object.defineProperty(buyCryptoRepo, 'manager', { + configurable: true, + value: { + transaction: jest.fn(async (run: (entityManager: EntityManager) => unknown) => + run(manager as unknown as EntityManager), + ), + }, + }); + + await expect( + service.refundCryptoInput(buyCrypto, { + refundUserAddress: refundUser.address, + chargebackAllowedDate: new Date(), + }), + ).rejects.toThrow(ConflictException); + + expect(returnPayInSpy).not.toHaveBeenCalled(); + }); + + it('creates and claims a bank refund in the same BuyCrypto transaction', async () => { + const buyCrypto = createCustomBuyCrypto({ + id: 7, + amlCheck: CheckStatus.PENDING, + batch: null, + outputAmount: null, + inputAsset: 'CHF', + chargebackAmount: 10, + chargebackAsset: 'CHF', + bankTx: { iban: 'CH9300762011623852957' } as any, + }); + jest.spyOn(TransactionUtilService, 'validateRefund').mockImplementation(); + jest.spyOn(transactionUtilService, 'validateChargebackIban').mockResolvedValue(true); + const chargebackOutput = { id: 44 } as any; + const createOutputSpy = jest.spyOn(fiatOutputService, 'createInternal').mockResolvedValue(chargebackOutput); + const manager = { + findOne: jest.fn(async (_type: unknown, options: { select?: { id?: boolean } }) => + options.select?.id ? { id: 7 } : buyCrypto, + ), + update: jest.fn().mockResolvedValue({ affected: 1 }), + }; + Object.defineProperty(buyCryptoRepo, 'manager', { + configurable: true, + value: { + transaction: jest.fn(async (run: (entityManager: EntityManager) => unknown) => + run(manager as unknown as EntityManager), + ), + }, + }); + const chargebackAllowedDate = new Date(); + + await service.refundBankTx(buyCrypto, { + refundIban: 'CH9300762011623852957', + chargebackAllowedDate, + creditorData: { + name: 'Refund Recipient', + address: 'Main Street', + zip: '8000', + city: 'Zurich', + country: 'CH', + }, + }); + + expect(createOutputSpy).toHaveBeenCalledWith( + FiatOutputType.BUY_CRYPTO_FAIL, + { buyCrypto }, + 7, + false, + expect.objectContaining({ iban: 'CH9300762011623852957', amount: 10, currency: 'CHF' }), + manager, ); expect(manager.update).toHaveBeenCalledWith( BuyCrypto, - expect.objectContaining({ - id: 9, - chargebackAllowedDate: expect.anything(), - chargebackAllowedDateUser, - }), - expect.objectContaining({ chargebackAllowedDate: expect.any(Date), amlCheck: CheckStatus.FAIL }), + expect.objectContaining({ id: 7, chargebackOutput: expect.anything() }), + expect.objectContaining({ chargebackAllowedDate, chargebackOutput }), ); }); + it('rolls back a bank refund output when a concurrent AML reset wins', async () => { + const buyCrypto = createCustomBuyCrypto({ + id: 7, + amlCheck: CheckStatus.PENDING, + batch: null, + outputAmount: null, + inputAsset: 'CHF', + chargebackAmount: 10, + chargebackAsset: 'CHF', + bankTx: { iban: 'CH9300762011623852957' } as any, + }); + jest.spyOn(TransactionUtilService, 'validateRefund').mockImplementation(); + jest.spyOn(transactionUtilService, 'validateChargebackIban').mockResolvedValue(true); + jest.spyOn(fiatOutputService, 'createInternal').mockResolvedValue({ id: 44 } as any); + const manager = { + findOne: jest.fn(async (_type: unknown, options: { select?: { id?: boolean } }) => + options.select?.id ? { id: 7 } : buyCrypto, + ), + update: jest.fn().mockResolvedValue({ affected: 0 }), + }; + Object.defineProperty(buyCryptoRepo, 'manager', { + configurable: true, + value: { + transaction: jest.fn(async (run: (entityManager: EntityManager) => unknown) => + run(manager as unknown as EntityManager), + ), + }, + }); + + await expect( + service.refundBankTx(buyCrypto, { + refundIban: 'CH9300762011623852957', + chargebackAllowedDate: new Date(), + creditorData: { + name: 'Refund Recipient', + address: 'Main Street', + zip: '8000', + city: 'Zurich', + country: 'CH', + }, + }), + ).rejects.toThrow(ConflictException); + + expect(buyCryptoRepo.update).not.toHaveBeenCalled(); + expect(transactionAmlCheckService.createFromEntity).not.toHaveBeenCalled(); + }); + function reviewResetFixture( kycStatus = KycStatus.CHECK, amlCheck = CheckStatus.PASS, @@ -581,6 +746,9 @@ describe('BuyCryptoService', () => { chargebackOutput: expect.anything(), chargebackAllowedDate: expect.anything(), chargebackAllowedDateUser: expect.anything(), + chargebackDate: expect.anything(), + chargebackCryptoTxId: expect.anything(), + chargebackBankTx: expect.anything(), }), expect.objectContaining({ amlCheck: null, amlReason: null }), ); @@ -668,6 +836,22 @@ describe('BuyCryptoService', () => { expect(manager.update).not.toHaveBeenCalled(); }); + it.each([ + ['chargeback date', { chargebackDate: new Date() }], + ['crypto return transaction', { chargebackCryptoTxId: 'return-tx' }], + ['bank return transaction', { chargebackBankTx: { id: 42 } }], + ])('rejects a review reset with an established %s marker', async (_name, marker) => { + const { entity, manager } = reviewResetFixture(); + Object.assign(entity, marker); + + await expect( + service.resetAmlCheckForReview(7, { expectedAmlCheck: CheckStatus.PASS, expectedAmlReason: AmlReason.NA }, 99), + ).rejects.toThrow(BadRequestException); + + expect(manager.save).not.toHaveBeenCalled(); + expect(manager.update).not.toHaveBeenCalled(); + }); + it('does not mutate or delete state when the immutable audit event cannot be saved', async () => { const { manager } = reviewResetFixture(); manager.save.mockRejectedValue(new Error('audit unavailable')); @@ -894,7 +1078,7 @@ describe('BuyCryptoService', () => { ), ).rejects.toThrow('Checkout refunds must use the dedicated refund endpoint'); - expect(checkoutService.refundPayment).not.toHaveBeenCalled(); + expect(checkoutService.refundBuyCryptoPayment).not.toHaveBeenCalled(); }); it('does not reset AML or reactivate a stopped BuyCrypto after a phone call', async () => { diff --git a/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts b/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts index fbc99a794e..4d23ab93c8 100644 --- a/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts +++ b/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts @@ -743,22 +743,6 @@ export class BuyCryptoPreparationService { } async chargebackTx(): Promise { - const checkoutRefundsInProgress = await this.buyCryptoRepo.find({ - where: { - checkoutTx: { id: Not(IsNull()) }, - chargebackAllowedDate: Not(IsNull()), - isComplete: false, - }, - relations: { checkoutTx: true, transaction: { userData: true } }, - }); - for (const entity of checkoutRefundsInProgress) { - try { - await this.buyCryptoService.resumeCheckoutRefund(entity); - } catch (e) { - this.logger.error(`Failed to resume Checkout chargeback for buy-crypto ${entity.id}:`, e); - } - } - const baseRequest: FindOptionsWhere = { chargebackAllowedDate: IsNull(), chargebackAllowedDateUser: Not(IsNull()), diff --git a/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts b/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts index 249f496f0b..4d115fad96 100644 --- a/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts +++ b/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts @@ -62,7 +62,7 @@ import { TransactionHelper } from 'src/subdomains/supporting/payment/services/tr import { TransactionRequestService } from 'src/subdomains/supporting/payment/services/transaction-request.service'; import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; import { PriceValidity } from 'src/subdomains/supporting/pricing/services/pricing.service'; -import { Between, EntityManager, FindOptionsRelations, In, IsNull, MoreThan, Not } from 'typeorm'; +import { Between, EntityManager, FindOptionsRelations, FindOptionsWhere, In, IsNull, MoreThan, Not } from 'typeorm'; import { ManualAmlCheckDto } from '../../../aml/dto/manual-aml-check.dto'; import { canManualPass, ManualPassBlacklistErrors } from '../../../aml/enums/aml-error.enum'; import { AmlReason, PhoneAmlReasons } from '../../../aml/enums/aml-reason.enum'; @@ -98,6 +98,24 @@ export class BuyCryptoService implements OnModuleInit { ); } + private static refundClaimWhere(entity: BuyCrypto): FindOptionsWhere { + return { + id: entity.id, + amlCheck: entity.amlCheck, + amlReason: entity.amlReason ?? IsNull(), + status: entity.status, + isComplete: false, + batch: IsNull(), + outputAmount: IsNull(), + chargebackOutput: IsNull(), + chargebackAllowedDate: IsNull(), + chargebackAllowedDateUser: entity.chargebackAllowedDateUser ?? IsNull(), + chargebackDate: IsNull(), + chargebackCryptoTxId: IsNull(), + chargebackBankTx: IsNull(), + }; + } + constructor( private readonly buyCryptoRepo: BuyCryptoRepository, private readonly buyRepo: BuyRepository, @@ -642,59 +660,41 @@ export class BuyCryptoService implements OnModuleInit { TransactionUtilService.validateRefund(buyCrypto, { chargebackAmount, assetMismatch: false }); - if (dto.chargebackAllowedDate && chargebackAmount) { - await this.buyCryptoRepo.manager.transaction(async (manager) => { - const lockedBuyCrypto = await manager.findOne(BuyCrypto, { - where: { id: buyCrypto.id }, - loadEagerRelations: false, - lock: { mode: 'pessimistic_write' }, - }); - if (!lockedBuyCrypto) throw new NotFoundException('BuyCrypto not found'); - - const lockedCheckoutTx = await manager.findOne(CheckoutTx, { - where: { id: buyCrypto.checkoutTx.id }, - loadEagerRelations: false, - lock: { mode: 'pessimistic_write' }, - }); - if (!lockedCheckoutTx) throw new NotFoundException('Checkout TX not found'); + let previousAmlCheck: CheckStatus; + let previousAmlReason: AmlReason; + let refundEntity: BuyCrypto; + let checkoutPaymentId: string; - lockedBuyCrypto.checkoutTx = lockedCheckoutTx; - TransactionUtilService.validateRefund(lockedBuyCrypto, { chargebackAmount, assetMismatch: false }); - - const claim = await manager.update( - BuyCrypto, - { - id: buyCrypto.id, - isComplete: false, - batch: IsNull(), - chargebackAllowedDate: IsNull(), - chargebackAllowedDateUser: dto.chargebackAllowedDateUser == null ? IsNull() : dto.chargebackAllowedDateUser, - }, - { - chargebackAllowedDate: dto.chargebackAllowedDate, - chargebackAllowedBy: dto.chargebackAllowedBy, - chargebackReferenceAmount: chargebackAmount, - chargebackAmount, - chargebackAsset: dto.chargebackCurrency, - }, - ); - if (claim.affected !== 1) throw new ConflictException('BuyCrypto refund state changed concurrently'); + await this.buyCryptoRepo.manager.transaction(async (manager) => { + const lockedBuyCrypto = await manager.findOne(BuyCrypto, { + where: { id: buyCrypto.id }, + select: { id: true }, + loadEagerRelations: false, + lock: { mode: 'pessimistic_write' }, + }); + if (!lockedBuyCrypto) throw new NotFoundException('BuyCrypto not found'); - await manager.update(CheckoutTx, lockedCheckoutTx.id, { status: CheckoutPaymentStatus.REFUND_PENDING }); + const currentBuyCrypto = await manager.findOne(BuyCrypto, { + where: { id: buyCrypto.id }, + relations: { chargebackBankTx: true, chargebackOutput: true, checkoutTx: true }, }); + if (!currentBuyCrypto) throw new NotFoundException('BuyCrypto not found'); + if (!currentBuyCrypto.checkoutTx) throw new NotFoundException('Checkout TX not found'); - await this.completeCheckoutRefund(buyCrypto, { - ...dto, - chargebackAmount, + const lockedCheckoutTx = await manager.findOne(CheckoutTx, { + where: { id: currentBuyCrypto.checkoutTx.id }, + loadEagerRelations: false, + lock: { mode: 'pessimistic_write' }, }); - return; - } + if (!lockedCheckoutTx) throw new NotFoundException('Checkout TX not found'); - const previousAmlCheck = buyCrypto.amlCheck; - const previousAmlReason = buyCrypto.amlReason; + currentBuyCrypto.checkoutTx = lockedCheckoutTx; + TransactionUtilService.validateRefund(currentBuyCrypto, { chargebackAmount, assetMismatch: false }); - await this.buyCryptoRepo.update( - ...buyCrypto.chargebackFillUp( + const claimWhere = BuyCryptoService.refundClaimWhere(currentBuyCrypto); + previousAmlCheck = currentBuyCrypto.amlCheck; + previousAmlReason = currentBuyCrypto.amlReason; + const [, update] = currentBuyCrypto.chargebackFillUp( undefined, chargebackAmount, chargebackAmount, @@ -702,72 +702,33 @@ export class BuyCryptoService implements OnModuleInit { dto.chargebackAllowedDate, dto.chargebackAllowedDateUser, dto.chargebackAllowedBy, - undefined, - dto.chargebackRemittanceInfo?.reference, - ), - ); - - await this.transactionAmlCheckService.createFromEntity( - buyCrypto, - 'BuyCrypto', - AmlSourceType.CHARGEBACK, - previousAmlCheck, - previousAmlReason, - ); - } - - async resumeCheckoutRefund(buyCrypto: BuyCrypto): Promise { - if (!buyCrypto.checkoutTx || !buyCrypto.chargebackAllowedDate || buyCrypto.isComplete) return; + ); + const claim = await manager.update(BuyCrypto, claimWhere, update); + if (claim.affected !== 1) throw new ConflictException('BuyCrypto refund state changed concurrently'); - await this.completeCheckoutRefund(buyCrypto, { - chargebackAmount: buyCrypto.chargebackAmount ?? buyCrypto.inputAmount, - chargebackCurrency: buyCrypto.chargebackAsset, - chargebackAllowedDate: buyCrypto.chargebackAllowedDate, - chargebackAllowedDateUser: buyCrypto.chargebackAllowedDateUser, - chargebackAllowedBy: buyCrypto.chargebackAllowedBy, + if (dto.chargebackAllowedDate && chargebackAmount) + await manager.update(CheckoutTx, lockedCheckoutTx.id, { status: CheckoutPaymentStatus.REFUND_PENDING }); + checkoutPaymentId = lockedCheckoutTx.paymentId; + refundEntity = currentBuyCrypto; }); - } - - private async completeCheckoutRefund(buyCrypto: BuyCrypto, dto: CheckoutTxRefund): Promise { - const chargebackAmount = dto.chargebackAmount ?? buyCrypto.chargebackAmount ?? buyCrypto.inputAmount; - const refund = await this.checkoutService.refundPayment( - buyCrypto.checkoutTx.paymentId, - `buy-crypto-${buyCrypto.id}-checkout-refund`, - ); - const previousAmlCheck = buyCrypto.amlCheck; - const previousAmlReason = buyCrypto.amlReason; - const [, update] = buyCrypto.chargebackFillUp( - undefined, - chargebackAmount, - chargebackAmount, - dto.chargebackCurrency, - dto.chargebackAllowedDate, - dto.chargebackAllowedDateUser, - dto.chargebackAllowedBy, - undefined, - refund.reference, - ); - const result = await this.buyCryptoRepo.update( - { - id: buyCrypto.id, - isComplete: false, - chargebackAllowedDate: dto.chargebackAllowedDate, - }, - update, - ); - if (result.affected !== 1) { - const current = await this.buyCryptoRepo.findOneBy({ id: buyCrypto.id }); - if (current?.isComplete) return; - throw new ConflictException('BuyCrypto refund finalization changed concurrently'); - } await this.transactionAmlCheckService.createFromEntity( - buyCrypto, + refundEntity, 'BuyCrypto', AmlSourceType.CHARGEBACK, previousAmlCheck, previousAmlReason, ); + + if (dto.chargebackAllowedDate && chargebackAmount) { + dto.chargebackRemittanceInfo = await this.checkoutService.refundBuyCryptoPayment( + checkoutPaymentId, + refundEntity.id, + ); + await this.buyCryptoRepo.update(refundEntity.id, { + chargebackRemittanceInfo: dto.chargebackRemittanceInfo.action_id, + }); + } } async refundCryptoInput(buyCrypto: BuyCrypto, dto: CryptoInputRefund): Promise { @@ -785,90 +746,50 @@ export class BuyCryptoService implements OnModuleInit { TransactionUtilService.validateRefund(buyCrypto, { refundUser, chargebackAmount, assetMismatch: false }); - const refundAddress = refundUser.address ?? buyCrypto.chargebackIban; let blockchainFee: number; - if (dto.chargebackAllowedDate && chargebackAmount) { + if (dto.chargebackAllowedDate && chargebackAmount) blockchainFee = await this.transactionHelper.getBlockchainFee(buyCrypto.cryptoInput.asset, true); - const { entity, previousAmlCheck, previousAmlReason } = await this.buyCryptoRepo.manager.transaction( - async (manager) => { - const lockedRow = await manager.findOne(BuyCrypto, { - where: { id: buyCrypto.id }, - select: { id: true }, - loadEagerRelations: false, - lock: { mode: 'pessimistic_write' }, - }); - if (!lockedRow) throw new NotFoundException('BuyCrypto not found'); - - const lockedBuyCrypto = await manager.findOne(BuyCrypto, { - where: { id: buyCrypto.id }, - relations: { transaction: { userData: true } }, - }); - if (!lockedBuyCrypto) throw new NotFoundException('BuyCrypto not found'); - - const lockedCryptoInput = await manager.findOne(CryptoInput, { - where: { id: buyCrypto.cryptoInput.id }, - relations: { asset: true, route: { user: true }, transaction: true }, - lock: { mode: 'pessimistic_write' }, - }); - if (!lockedCryptoInput) throw new NotFoundException('CryptoInput not found'); - - lockedBuyCrypto.cryptoInput = lockedCryptoInput; - TransactionUtilService.validateRefund(lockedBuyCrypto, { - refundUser, - chargebackAmount, - assetMismatch: false, - }); - - const previousAmlCheck = lockedBuyCrypto.amlCheck; - const previousAmlReason = lockedBuyCrypto.amlReason; - const [, update] = lockedBuyCrypto.chargebackFillUp( - refundAddress, - chargebackAmount, - chargebackAmount, - dto.chargebackCurrency, - dto.chargebackAllowedDate, - dto.chargebackAllowedDateUser, - dto.chargebackAllowedBy, - undefined, - undefined, - blockchainFee, - ); - await this.payInService.returnPayIn(lockedCryptoInput, refundAddress, chargebackAmount, manager); - const result = await manager.update( - BuyCrypto, - { - id: lockedBuyCrypto.id, - isComplete: false, - batch: IsNull(), - chargebackAllowedDate: IsNull(), - chargebackAllowedDateUser: - dto.chargebackAllowedDateUser == null ? IsNull() : dto.chargebackAllowedDateUser, - }, - update, - ); - if (result.affected !== 1) throw new ConflictException('BuyCrypto refund state changed concurrently'); + let previousAmlCheck: CheckStatus; + let previousAmlReason: AmlReason; + let refundEntity: BuyCrypto; + await this.buyCryptoRepo.manager.transaction(async (manager) => { + const lockedBuyCrypto = await manager.findOne(BuyCrypto, { + where: { id: buyCrypto.id }, + select: { id: true }, + loadEagerRelations: false, + lock: { mode: 'pessimistic_write' }, + }); + if (!lockedBuyCrypto) throw new NotFoundException('BuyCrypto not found'); - return { entity: lockedBuyCrypto, previousAmlCheck, previousAmlReason }; - }, - ); + const currentBuyCrypto = await manager.findOne(BuyCrypto, { + where: { id: buyCrypto.id }, + relations: { chargebackBankTx: true, chargebackOutput: true, transaction: { userData: true } }, + }); + if (!currentBuyCrypto) throw new NotFoundException('BuyCrypto not found'); - await this.transactionAmlCheckService.createFromEntity( - entity, - 'BuyCrypto', - AmlSourceType.CHARGEBACK, - previousAmlCheck, - previousAmlReason, - ); - return; - } + const lockedCryptoInput = await manager.findOne(CryptoInput, { + where: { id: buyCrypto.cryptoInput.id }, + select: { id: true }, + loadEagerRelations: false, + lock: { mode: 'pessimistic_write' }, + }); + if (!lockedCryptoInput) throw new NotFoundException('CryptoInput not found'); + + const cryptoInput = await manager.findOne(CryptoInput, { + where: { id: buyCrypto.cryptoInput.id }, + relations: { asset: true, route: { user: true }, transaction: true }, + }); + if (!cryptoInput) throw new NotFoundException('CryptoInput not found'); - const previousAmlCheck = buyCrypto.amlCheck; - const previousAmlReason = buyCrypto.amlReason; + currentBuyCrypto.cryptoInput = cryptoInput; + TransactionUtilService.validateRefund(currentBuyCrypto, { refundUser, chargebackAmount, assetMismatch: false }); - await this.buyCryptoRepo.update( - ...buyCrypto.chargebackFillUp( - refundAddress, + const claimWhere = BuyCryptoService.refundClaimWhere(currentBuyCrypto); + previousAmlCheck = currentBuyCrypto.amlCheck; + previousAmlReason = currentBuyCrypto.amlReason; + const [, update] = currentBuyCrypto.chargebackFillUp( + refundUser.address ?? buyCrypto.chargebackIban, chargebackAmount, chargebackAmount, dto.chargebackCurrency, @@ -878,11 +799,22 @@ export class BuyCryptoService implements OnModuleInit { undefined, undefined, blockchainFee, - ), - ); + ); + const claim = await manager.update(BuyCrypto, claimWhere, update); + if (claim.affected !== 1) throw new ConflictException('BuyCrypto refund state changed concurrently'); + + if (dto.chargebackAllowedDate && chargebackAmount) + await this.payInService.returnPayIn( + cryptoInput, + refundUser.address ?? buyCrypto.chargebackIban, + chargebackAmount, + manager, + ); + refundEntity = currentBuyCrypto; + }); await this.transactionAmlCheckService.createFromEntity( - buyCrypto, + refundEntity, 'BuyCrypto', AmlSourceType.CHARGEBACK, previousAmlCheck, @@ -918,25 +850,45 @@ export class BuyCryptoService implements OnModuleInit { if ((dto.chargebackAllowedDate || dto.chargebackAllowedDateUser) && !creditorData) throw new BadRequestException('Creditor data is required for chargeback'); - if (dto.chargebackAllowedDate && chargebackAmount && (dto.chargebackCurrency || buyCrypto.chargebackAsset)) - dto.chargebackOutput = await this.fiatOutputService.createInternal( - FiatOutputType.BUY_CRYPTO_FAIL, - { buyCrypto }, - buyCrypto.id, - false, - { - iban: chargebackIban, - amount: chargebackAmount, - currency: dto.chargebackCurrency ?? buyCrypto.chargebackAsset, - ...creditorData, - }, - ); + let previousAmlCheck: CheckStatus; + let previousAmlReason: AmlReason; + let refundEntity: BuyCrypto; + await this.buyCryptoRepo.manager.transaction(async (manager) => { + const lockedBuyCrypto = await manager.findOne(BuyCrypto, { + where: { id: buyCrypto.id }, + select: { id: true }, + loadEagerRelations: false, + lock: { mode: 'pessimistic_write' }, + }); + if (!lockedBuyCrypto) throw new NotFoundException('BuyCrypto not found'); + + const currentBuyCrypto = await manager.findOne(BuyCrypto, { + where: { id: buyCrypto.id }, + relations: { chargebackBankTx: true, chargebackOutput: true }, + }); + if (!currentBuyCrypto) throw new NotFoundException('BuyCrypto not found'); + + TransactionUtilService.validateRefund(currentBuyCrypto, { + refundIban: chargebackIban, + chargebackAmount, + chargebackReferenceAmount, + assetMismatch: chargebackAsset && chargebackAsset !== currentBuyCrypto.inputAsset, + }); - const previousAmlCheck = buyCrypto.amlCheck; - const previousAmlReason = buyCrypto.amlReason; + if (dto.chargebackAllowedDate && chargebackAmount && chargebackAsset) + dto.chargebackOutput = await this.fiatOutputService.createInternal( + FiatOutputType.BUY_CRYPTO_FAIL, + { buyCrypto: currentBuyCrypto }, + currentBuyCrypto.id, + false, + { iban: chargebackIban, amount: chargebackAmount, currency: chargebackAsset, ...creditorData }, + manager, + ); - await this.buyCryptoRepo.update( - ...buyCrypto.chargebackFillUp( + const claimWhere = BuyCryptoService.refundClaimWhere(currentBuyCrypto); + previousAmlCheck = currentBuyCrypto.amlCheck; + previousAmlReason = currentBuyCrypto.amlReason; + const [, update] = currentBuyCrypto.chargebackFillUp( chargebackIban, chargebackReferenceAmount, chargebackAmount, @@ -945,14 +897,17 @@ export class BuyCryptoService implements OnModuleInit { dto.chargebackAllowedDateUser, dto.chargebackAllowedBy, dto.chargebackOutput, - buyCrypto.chargebackBankRemittanceInfo, + currentBuyCrypto.chargebackBankRemittanceInfo, undefined, creditorData, - ), - ); + ); + const claim = await manager.update(BuyCrypto, claimWhere, update); + if (claim.affected !== 1) throw new ConflictException('BuyCrypto refund state changed concurrently'); + refundEntity = currentBuyCrypto; + }); await this.transactionAmlCheckService.createFromEntity( - buyCrypto, + refundEntity, 'BuyCrypto', AmlSourceType.CHARGEBACK, previousAmlCheck, @@ -1045,6 +1000,7 @@ export class BuyCryptoService implements OnModuleInit { const entity = await manager.findOne(BuyCrypto, { where: { id }, relations: { + chargebackBankTx: true, chargebackOutput: true, checkoutTx: true, cryptoInput: true, @@ -1113,6 +1069,9 @@ export class BuyCryptoService implements OnModuleInit { entity.chargebackOutput || entity.chargebackAllowedDate || entity.chargebackAllowedDateUser || + entity.chargebackDate || + entity.chargebackCryptoTxId || + entity.chargebackBankTx || checkoutRefundStarted || cryptoReturnStarted || cryptoForwardStarted @@ -1171,6 +1130,9 @@ export class BuyCryptoService implements OnModuleInit { chargebackOutput: IsNull(), chargebackAllowedDate: IsNull(), chargebackAllowedDateUser: IsNull(), + chargebackDate: IsNull(), + chargebackCryptoTxId: IsNull(), + chargebackBankTx: IsNull(), }, update, ); diff --git a/src/subdomains/generic/support/__tests__/support.service.spec.ts b/src/subdomains/generic/support/__tests__/support.service.spec.ts index 5046398cac..5599e3b823 100644 --- a/src/subdomains/generic/support/__tests__/support.service.spec.ts +++ b/src/subdomains/generic/support/__tests__/support.service.spec.ts @@ -7,7 +7,7 @@ import { CheckoutPaymentStatus } from 'src/integration/checkout/dto/checkout.dto import { ScorechainScreening } from 'src/integration/scorechain/entities/scorechain-screening.entity'; import { ScorechainScreeningService } from 'src/integration/scorechain/services/scorechain-screening.service'; import { TestUtil } from 'src/shared/utils/test.util'; -import { BuyCrypto } from 'src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity'; +import { BuyCrypto, BuyCryptoStatus } from 'src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity'; import { BuyCryptoService } from 'src/subdomains/core/buy-crypto/process/services/buy-crypto.service'; import { BuyFiat } from 'src/subdomains/core/sell-crypto/process/buy-fiat.entity'; import { BuyFiatService } from 'src/subdomains/core/sell-crypto/process/services/buy-fiat.service'; @@ -135,6 +135,9 @@ describe('SupportService', () => { name: 'crypto return', source: { cryptoInput: Object.assign(new CryptoInput(), { action: PayInAction.RETURN }) }, }, + { name: 'chargeback date', source: { chargebackDate: new Date() } }, + { name: 'crypto return transaction', source: { chargebackCryptoTxId: 'return-tx' } }, + { name: 'bank return transaction', source: { chargebackBankTx: { id: 42 } } }, ])('reports an in-flight $name as a chargeback', ({ source }) => { const transaction = Object.assign(new Transaction(), { id: 1, @@ -144,6 +147,40 @@ describe('SupportService', () => { const result = (service as any).toTransactionSupportInfo(transaction); expect(result.buyCryptoHasChargeback).toBe(true); + expect(result.buyCryptoReviewResetBlocked).toBe(true); + }); + + it('blocks review reset after crypto forwarding without reporting a chargeback', () => { + const transaction = Object.assign(new Transaction(), { + id: 1, + buyCrypto: Object.assign(new BuyCrypto(), { + id: 2, + isComplete: false, + status: BuyCryptoStatus.MISSING_LIQUIDITY, + cryptoInput: Object.assign(new CryptoInput(), { action: PayInAction.FORWARD }), + }), + }); + + const result = (service as any).toTransactionSupportInfo(transaction); + + expect(result.buyCryptoHasChargeback).toBe(false); + expect(result.buyCryptoReviewResetBlocked).toBe(true); + }); + + it('reports an untouched incomplete BuyCrypto as review-reset eligible', () => { + const transaction = Object.assign(new Transaction(), { + id: 1, + buyCrypto: Object.assign(new BuyCrypto(), { + id: 2, + isComplete: false, + status: BuyCryptoStatus.MISSING_LIQUIDITY, + }), + }); + + const result = (service as any).toTransactionSupportInfo(transaction); + + expect(result.buyCryptoHasChargeback).toBe(false); + expect(result.buyCryptoReviewResetBlocked).toBe(false); }); }); diff --git a/src/subdomains/generic/support/dto/user-data-support.dto.ts b/src/subdomains/generic/support/dto/user-data-support.dto.ts index 457c8d7879..40eb03d233 100644 --- a/src/subdomains/generic/support/dto/user-data-support.dto.ts +++ b/src/subdomains/generic/support/dto/user-data-support.dto.ts @@ -155,6 +155,7 @@ export class TransactionSupportInfo { buyCryptoStatus?: string; buyCryptoHasBatch?: boolean; buyCryptoHasChargeback?: boolean; + buyCryptoReviewResetBlocked?: boolean; buyFiatId?: number; bankDataId?: number; type?: string; diff --git a/src/subdomains/generic/support/support.service.ts b/src/subdomains/generic/support/support.service.ts index fbc82f849d..d08be794bf 100644 --- a/src/subdomains/generic/support/support.service.ts +++ b/src/subdomains/generic/support/support.service.ts @@ -20,7 +20,7 @@ import { SettingService } from 'src/shared/models/setting/setting.service'; import { AmountType, Util } from 'src/shared/utils/util'; import { AmlReason, NotRefundableAmlReasons } from 'src/subdomains/core/aml/enums/aml-reason.enum'; import { CheckStatus } from 'src/subdomains/core/aml/enums/check-status.enum'; -import { BuyCrypto } from 'src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity'; +import { BuyCrypto, BuyCryptoStatus } from 'src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity'; import { BuyCryptoService } from 'src/subdomains/core/buy-crypto/process/services/buy-crypto.service'; import { Buy } from 'src/subdomains/core/buy-crypto/routes/buy/buy.entity'; import { BuyService } from 'src/subdomains/core/buy-crypto/routes/buy/buy.service'; @@ -594,6 +594,36 @@ export class SupportService { } private toTransactionSupportInfo(tx: Transaction): TransactionSupportInfo { + const buyCryptoHasChargeback = !!( + tx.buyCrypto?.chargebackOutput || + tx.buyCrypto?.chargebackAllowedDate || + tx.buyCrypto?.chargebackAllowedDateUser || + tx.buyCrypto?.chargebackDate || + tx.buyCrypto?.chargebackCryptoTxId || + tx.buyCrypto?.chargebackBankTx || + tx.buyCrypto?.checkoutTx?.status === CheckoutPaymentStatus.REFUND_PENDING || + tx.buyCrypto?.checkoutTx?.status === CheckoutPaymentStatus.PARTIALLY_REFUNDED || + tx.buyCrypto?.checkoutTx?.status === CheckoutPaymentStatus.REFUNDED || + tx.buyCrypto?.cryptoInput?.action === PayInAction.RETURN || + tx.buyCrypto?.cryptoInput?.status === PayInStatus.TO_RETURN || + tx.buyCrypto?.cryptoInput?.status === PayInStatus.RETURNED || + tx.buyCrypto?.cryptoInput?.status === PayInStatus.RETURN_CONFIRMED || + tx.buyCrypto?.cryptoInput?.returnTxId + ); + const cryptoForwardStarted = !!( + tx.buyCrypto?.cryptoInput?.action === PayInAction.FORWARD || + (tx.buyCrypto?.cryptoInput?.status != null && + [ + PayInStatus.PREPARING, + PayInStatus.PREPARED, + PayInStatus.SENDING, + PayInStatus.SEND_UNCERTAIN, + PayInStatus.FORWARDED, + PayInStatus.FORWARD_CONFIRMED, + ].includes(tx.buyCrypto.cryptoInput.status)) || + tx.buyCrypto?.cryptoInput?.outTxId + ); + return { id: tx.id, uid: tx.uid, @@ -601,19 +631,16 @@ export class SupportService { buyCryptoIsComplete: tx.buyCrypto?.isComplete, buyCryptoStatus: tx.buyCrypto?.status, buyCryptoHasBatch: !!tx.buyCrypto?.batch, - buyCryptoHasChargeback: !!( - tx.buyCrypto?.chargebackOutput || - tx.buyCrypto?.chargebackAllowedDate || - tx.buyCrypto?.chargebackAllowedDateUser || - tx.buyCrypto?.checkoutTx?.status === CheckoutPaymentStatus.REFUND_PENDING || - tx.buyCrypto?.checkoutTx?.status === CheckoutPaymentStatus.PARTIALLY_REFUNDED || - tx.buyCrypto?.checkoutTx?.status === CheckoutPaymentStatus.REFUNDED || - tx.buyCrypto?.cryptoInput?.action === PayInAction.RETURN || - tx.buyCrypto?.cryptoInput?.status === PayInStatus.TO_RETURN || - tx.buyCrypto?.cryptoInput?.status === PayInStatus.RETURNED || - tx.buyCrypto?.cryptoInput?.status === PayInStatus.RETURN_CONFIRMED || - tx.buyCrypto?.cryptoInput?.returnTxId - ), + buyCryptoHasChargeback, + buyCryptoReviewResetBlocked: tx.buyCrypto + ? !!( + tx.buyCrypto.isComplete || + tx.buyCrypto.status === BuyCryptoStatus.STOPPED || + tx.buyCrypto.batch || + buyCryptoHasChargeback || + cryptoForwardStarted + ) + : undefined, buyFiatId: tx.buyFiat?.id, bankDataId: tx.buyCrypto?.bankData?.id ?? tx.buyFiat?.bankData?.id, type: tx.type, diff --git a/src/subdomains/supporting/fiat-payin/services/__tests__/fiat-payin-sync.service.spec.ts b/src/subdomains/supporting/fiat-payin/services/__tests__/fiat-payin-sync.service.spec.ts new file mode 100644 index 0000000000..00aa464e7a --- /dev/null +++ b/src/subdomains/supporting/fiat-payin/services/__tests__/fiat-payin-sync.service.spec.ts @@ -0,0 +1,312 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { CheckoutPayment, CheckoutPaymentStatus } from 'src/integration/checkout/dto/checkout.dto'; +import { CheckoutService } from 'src/integration/checkout/services/checkout.service'; +import { SiftService } from 'src/integration/sift/services/sift.service'; +import { BuyCrypto } from 'src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity'; +import { BuyService } from 'src/subdomains/core/buy-crypto/routes/buy/buy.service'; +import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; +import { EntityManager } from 'typeorm'; +import { CheckoutTx } from '../../entities/checkout-tx.entity'; +import { CheckoutTxRepository } from '../../repositories/checkout-tx.repository'; +import { CheckoutTxService } from '../checkout-tx.service'; +import { FiatPayInSyncService } from '../fiat-payin-sync.service'; + +describe('FiatPayInSyncService refund claims', () => { + let service: FiatPayInSyncService; + let checkoutService: DeepMocked; + let checkoutTxRepo: DeepMocked; + let manager: { + update: jest.Mock; + transaction: jest.Mock; + findOne: jest.Mock; + save: jest.Mock; + }; + + beforeEach(() => { + checkoutService = createMock(); + checkoutTxRepo = createMock(); + manager = { + update: jest.fn().mockResolvedValue({ affected: 1 }), + transaction: jest.fn(async (run: (entityManager: EntityManager) => unknown) => + run(manager as unknown as EntityManager), + ), + findOne: jest.fn(), + save: jest.fn(async (_type, value) => value), + }; + Object.defineProperty(checkoutTxRepo, 'manager', { configurable: true, value: manager }); + service = new FiatPayInSyncService( + checkoutService, + checkoutTxRepo, + createMock(), + createMock(), + createMock(), + createMock(), + ); + checkoutTxRepo.create.mockImplementation((value) => Object.assign(new CheckoutTx(), value)); + checkoutTxRepo.save.mockImplementation(async (value) => value as CheckoutTx); + }); + + function payment(status: CheckoutPaymentStatus): CheckoutPayment { + return { + id: 'payment-1', + requested_on: new Date().toISOString(), + amount: 100, + currency: 'EUR', + status, + approved: true, + reference: 'reference', + description: 'description', + } as CheckoutPayment; + } + + function existingCheckoutTx(status: CheckoutPaymentStatus): CheckoutTx { + return Object.assign(new CheckoutTx(), { + id: 1, + paymentId: 'payment-1', + amount: 1, + status, + transaction: { id: 2 }, + }); + } + + function pendingRefund(chargebackRemittanceInfo: string | null = null): { + buyCrypto: BuyCrypto; + checkoutTx: CheckoutTx; + } { + const buyCrypto = Object.assign(new BuyCrypto(), { id: 130504, chargebackRemittanceInfo }); + buyCrypto.chargebackAllowedDate = new Date('2026-08-01T00:00:00Z'); + const checkoutTx = Object.assign(new CheckoutTx(), { + id: 1, + paymentId: 'payment-1', + amount: 1, + status: CheckoutPaymentStatus.REFUND_PENDING, + buyCrypto, + }); + return { buyCrypto, checkoutTx }; + } + + it('locks and reloads before applying a provider response so a concurrent claim is preserved', async () => { + checkoutTxRepo.findOne.mockResolvedValue({ id: 1 } as CheckoutTx); + manager.findOne + .mockResolvedValueOnce({ id: 1 }) + .mockResolvedValueOnce(existingCheckoutTx(CheckoutPaymentStatus.REFUND_PENDING)); + + const result = await service.createCheckoutTx(payment(CheckoutPaymentStatus.CAPTURED)); + + expect(manager.findOne).toHaveBeenNthCalledWith( + 1, + CheckoutTx, + expect.objectContaining({ lock: { mode: 'pessimistic_write' }, loadEagerRelations: false }), + ); + expect(result.status).toBe(CheckoutPaymentStatus.REFUND_PENDING); + }); + + it('replaces a local refund claim once the provider reports a terminal refund state', async () => { + checkoutTxRepo.findOne.mockResolvedValue({ id: 1 } as CheckoutTx); + manager.findOne + .mockResolvedValueOnce({ id: 1 }) + .mockResolvedValueOnce(existingCheckoutTx(CheckoutPaymentStatus.REFUND_PENDING)); + + const result = await service.createCheckoutTx(payment(CheckoutPaymentStatus.REFUNDED)); + + expect(result.status).toBe(CheckoutPaymentStatus.REFUNDED); + }); + + it.each([ + { + current: CheckoutPaymentStatus.REFUNDED, + stale: CheckoutPaymentStatus.CAPTURED, + expected: CheckoutPaymentStatus.REFUNDED, + }, + { + current: CheckoutPaymentStatus.PARTIALLY_REFUNDED, + stale: CheckoutPaymentStatus.CAPTURED, + expected: CheckoutPaymentStatus.PARTIALLY_REFUNDED, + }, + ])('does not regress $current when a stale $stale response arrives', async ({ current, stale, expected }) => { + checkoutTxRepo.findOne.mockResolvedValue({ id: 1 } as CheckoutTx); + manager.findOne.mockResolvedValueOnce({ id: 1 }).mockResolvedValueOnce(existingCheckoutTx(current)); + + const result = await service.createCheckoutTx(payment(stale)); + + expect(result.status).toBe(expected); + }); + + it('submits an initial refund only when the provider has no refund action and records the action id', async () => { + const { buyCrypto, checkoutTx } = pendingRefund(); + checkoutService.getPaymentActions.mockResolvedValue([]); + checkoutService.refundBuyCryptoPayment.mockResolvedValue({ + action_id: 'action-1', + _links: { payment: { href: 'payment-1' } }, + }); + + await service['retryPendingRefunds']([checkoutTx]); + + expect(checkoutService.refundBuyCryptoPayment).toHaveBeenCalledWith('payment-1', 130504, undefined); + expect(manager.update).toHaveBeenCalledWith(BuyCrypto, 130504, { chargebackRemittanceInfo: 'action-1' }); + expect(buyCrypto.chargebackRemittanceInfo).toBe('action-1'); + }); + + it('adopts an accepted provider refund action without submitting a duplicate', async () => { + const { buyCrypto, checkoutTx } = pendingRefund(); + checkoutService.getPaymentActions.mockResolvedValue([ + { + id: 'action-1', + type: 'Refund', + amount: 100, + reference: 'bc-130504-refund', + approved: true, + }, + ]); + + await service['retryPendingRefunds']([checkoutTx]); + + expect(checkoutService.refundBuyCryptoPayment).not.toHaveBeenCalled(); + expect(manager.update).toHaveBeenCalledWith(BuyCrypto, 130504, { chargebackRemittanceInfo: 'action-1' }); + expect(buyCrypto.chargebackRemittanceInfo).toBe('action-1'); + }); + + it('does not resubmit a refund when the stored action is still accepted', async () => { + const { checkoutTx } = pendingRefund('action-1'); + checkoutService.getPaymentActions.mockResolvedValue([ + { + id: 'action-1', + type: 'refund', + amount: 100, + reference: 'bc-130504-refund', + approved: true, + }, + ]); + + await service['retryPendingRefunds']([checkoutTx]); + + expect(checkoutService.refundBuyCryptoPayment).not.toHaveBeenCalled(); + expect(manager.update).not.toHaveBeenCalled(); + }); + + it('submits a new idempotent attempt after the latest refund action failed', async () => { + const { buyCrypto, checkoutTx } = pendingRefund('action-failed'); + checkoutService.getPaymentActions.mockResolvedValue([ + { + id: 'action-failed', + type: 'Refund', + amount: 100, + reference: 'bc-130504-refund', + approved: false, + processed_on: '2026-08-01T10:00:00Z', + }, + ]); + checkoutService.refundBuyCryptoPayment.mockResolvedValue({ + action_id: 'action-2', + _links: { payment: { href: 'payment-1' } }, + }); + + await service['retryPendingRefunds']([checkoutTx]); + + expect(checkoutService.refundBuyCryptoPayment).toHaveBeenCalledWith('payment-1', 130504, 'action-failed'); + expect(manager.update).toHaveBeenCalledWith(BuyCrypto, 130504, { chargebackRemittanceInfo: 'action-2' }); + expect(buyCrypto.chargebackRemittanceInfo).toBe('action-2'); + }); + + it('does not adopt an unrelated or partial refund action', async () => { + const { checkoutTx } = pendingRefund(); + checkoutService.getPaymentActions.mockResolvedValue([ + { id: 'manual', type: 'Refund', amount: 100, reference: 'manual-refund', approved: true }, + { + id: 'partial', + type: 'Refund', + amount: 50, + reference: 'bc-130504-refund', + approved: true, + }, + { + id: 'old-unreferenced', + type: 'Refund', + amount: 100, + approved: true, + processed_on: '2026-07-31T23:00:00Z', + }, + ]); + checkoutService.refundBuyCryptoPayment.mockResolvedValue({ + action_id: 'action-full', + _links: { payment: { href: 'payment-1' } }, + }); + + await service['retryPendingRefunds']([checkoutTx]); + + expect(checkoutService.refundBuyCryptoPayment).toHaveBeenCalledWith('payment-1', 130504, undefined); + expect(manager.update).toHaveBeenCalledWith(BuyCrypto, 130504, { chargebackRemittanceInfo: 'action-full' }); + }); + + it('adopts a legacy unreferenced full refund created after the local claim', async () => { + const { buyCrypto, checkoutTx } = pendingRefund(); + checkoutService.getPaymentActions.mockResolvedValue([ + { + id: 'legacy-action', + type: 'Refund', + amount: 100, + approved: true, + processed_on: '2026-08-01T00:01:00Z', + }, + ]); + + await service['retryPendingRefunds']([checkoutTx]); + + expect(checkoutService.refundBuyCryptoPayment).not.toHaveBeenCalled(); + expect(manager.update).toHaveBeenCalledWith(BuyCrypto, 130504, { + chargebackRemittanceInfo: 'legacy-action', + }); + expect(buyCrypto.chargebackRemittanceInfo).toBe('legacy-action'); + }); + + it('keeps a recent failed refund in cooldown', async () => { + const { checkoutTx } = pendingRefund('action-failed'); + checkoutService.getPaymentActions.mockResolvedValue([ + { + id: 'action-failed', + type: 'Refund', + amount: 100, + reference: 'bc-130504-refund', + approved: false, + processed_on: new Date().toISOString(), + }, + ]); + + await service['retryPendingRefunds']([checkoutTx]); + + expect(checkoutService.refundBuyCryptoPayment).not.toHaveBeenCalled(); + }); + + it('stops and escalates after three matching refund attempts', async () => { + const { checkoutTx } = pendingRefund('action-3'); + checkoutService.getPaymentActions.mockResolvedValue( + [1, 2, 3].map((attempt) => ({ + id: `action-${attempt}`, + type: 'Refund', + amount: 100, + reference: 'bc-130504-refund', + approved: false, + processed_on: `2026-08-01T0${attempt}:00:00Z`, + })), + ); + const error = jest.spyOn(service['logger'], 'error').mockImplementation(); + + await service['retryPendingRefunds']([checkoutTx]); + + expect(checkoutService.refundBuyCryptoPayment).not.toHaveBeenCalled(); + expect(error).toHaveBeenCalledWith(expect.stringContaining('manual intervention required')); + }); + + it('does not submit when provider action reconciliation fails', async () => { + const { buyCrypto, checkoutTx } = pendingRefund(); + checkoutService.getPaymentActions.mockRejectedValue(new Error('network timeout')); + jest.spyOn(service['logger'], 'error').mockImplementation(); + + await service['retryPendingRefunds']([checkoutTx]); + + expect(checkoutService.refundBuyCryptoPayment).not.toHaveBeenCalled(); + expect(manager.update).not.toHaveBeenCalled(); + expect(checkoutTx.status).toBe(CheckoutPaymentStatus.REFUND_PENDING); + expect(buyCrypto.chargebackRemittanceInfo).toBeNull(); + }); +}); diff --git a/src/subdomains/supporting/fiat-payin/services/checkout-tx.service.ts b/src/subdomains/supporting/fiat-payin/services/checkout-tx.service.ts index acd589cfd1..004f91a97d 100644 --- a/src/subdomains/supporting/fiat-payin/services/checkout-tx.service.ts +++ b/src/subdomains/supporting/fiat-payin/services/checkout-tx.service.ts @@ -57,7 +57,10 @@ export class CheckoutTxService { } async getPendingRefundedList(): Promise { - return this.checkoutTxRepo.find({ where: { status: CheckoutPaymentStatus.REFUND_PENDING } }); + return this.checkoutTxRepo.find({ + where: { status: CheckoutPaymentStatus.REFUND_PENDING }, + relations: { buyCrypto: true }, + }); } async paymentRefunded(entityId: number): Promise { diff --git a/src/subdomains/supporting/fiat-payin/services/fiat-payin-sync.service.ts b/src/subdomains/supporting/fiat-payin/services/fiat-payin-sync.service.ts index c56e71ed63..18a16e2239 100644 --- a/src/subdomains/supporting/fiat-payin/services/fiat-payin-sync.service.ts +++ b/src/subdomains/supporting/fiat-payin/services/fiat-payin-sync.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { CronExpression } from '@nestjs/schedule'; import { CheckoutPayment, CheckoutPaymentStatus } from 'src/integration/checkout/dto/checkout.dto'; -import { CheckoutService } from 'src/integration/checkout/services/checkout.service'; +import { CheckoutPaymentAction, CheckoutService } from 'src/integration/checkout/services/checkout.service'; import { ChargebackReason, ChargebackState, TransactionStatus } from 'src/integration/sift/dto/sift.dto'; import { SiftService } from 'src/integration/sift/services/sift.service'; import { DfxLogger } from 'src/shared/services/dfx-logger'; @@ -9,6 +9,7 @@ import { Process } from 'src/shared/services/process.service'; import { DfxCron } from 'src/shared/utils/cron'; import { Util } from 'src/shared/utils/util'; import { BuyService } from 'src/subdomains/core/buy-crypto/routes/buy/buy.service'; +import { BuyCrypto } from 'src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity'; import { TransactionSourceType } from '../../payment/entities/transaction.entity'; import { TransactionService } from '../../payment/services/transaction.service'; import { CheckoutTx } from '../entities/checkout-tx.entity'; @@ -17,6 +18,9 @@ import { CheckoutTxService } from './checkout-tx.service'; @Injectable() export class FiatPayInSyncService { + private static readonly REFUND_RETRY_COOLDOWN_HOURS = 6; + private static readonly REFUND_MAX_ATTEMPTS = 3; + private readonly logger = new DfxLogger(FiatPayInSyncService); private unavailableWarningLogged = false; @@ -61,6 +65,7 @@ export class FiatPayInSyncService { } const refundedList = await this.checkoutTxService.getPendingRefundedList(); + await this.retryPendingRefunds(refundedList); const refundedPayments = await this.checkoutService.getPaymentList(refundedList); for (const refundedPayment of refundedPayments) { @@ -84,20 +89,131 @@ export class FiatPayInSyncService { async createCheckoutTx(payment: CheckoutPayment): Promise { const tx = this.mapCheckoutPayment(payment); - let entity = await this.checkoutTxRepo.findOne({ + const existing = await this.checkoutTxRepo.findOne({ where: { paymentId: tx.paymentId }, - relations: { buyCrypto: true, transaction: { request: true, user: true } }, + select: { id: true }, + loadEagerRelations: false, }); - if (entity) { + + if (!existing) { + if (!tx.transaction) + tx.transaction = await this.transactionService.create({ sourceType: TransactionSourceType.CHECKOUT_TX }); + + return this.checkoutTxRepo.save(tx); + } + + return this.checkoutTxRepo.manager.transaction(async (manager) => { + const locked = await manager.findOne(CheckoutTx, { + where: { id: existing.id }, + select: { id: true }, + loadEagerRelations: false, + lock: { mode: 'pessimistic_write' }, + }); + if (!locked) throw new Error(`CheckoutTx ${existing.id} disappeared during synchronization`); + + const entity = await manager.findOne(CheckoutTx, { + where: { id: existing.id }, + relations: { buyCrypto: true, transaction: { request: true, user: true } }, + }); + if (!entity) throw new Error(`CheckoutTx ${existing.id} disappeared during synchronization`); + + const currentStatus = entity.status; Object.assign(entity, Util.removeNullFields(tx)); - } else { - entity = tx; + if (currentStatus === CheckoutPaymentStatus.REFUNDED) entity.status = CheckoutPaymentStatus.REFUNDED; + else if ( + currentStatus === CheckoutPaymentStatus.PARTIALLY_REFUNDED && + tx.status !== CheckoutPaymentStatus.REFUNDED + ) + entity.status = CheckoutPaymentStatus.PARTIALLY_REFUNDED; + else if ( + currentStatus === CheckoutPaymentStatus.REFUND_PENDING && + ![CheckoutPaymentStatus.PARTIALLY_REFUNDED, CheckoutPaymentStatus.REFUNDED].includes(tx.status) + ) + entity.status = CheckoutPaymentStatus.REFUND_PENDING; + + if (!entity.transaction) + entity.transaction = await this.transactionService.create({ sourceType: TransactionSourceType.CHECKOUT_TX }); + + return manager.save(CheckoutTx, entity); + }); + } + + private async retryPendingRefunds(checkoutTxs: CheckoutTx[]): Promise { + for (const checkoutTx of checkoutTxs) { + const buyCrypto = checkoutTx.buyCrypto; + if (!buyCrypto) continue; + + try { + const actions = await this.checkoutService.getPaymentActions(checkoutTx.paymentId); + const reference = CheckoutService.buyCryptoRefundReference(buyCrypto.id); + const amount = Math.round(checkoutTx.amount * 100); + const matchingRefunds = actions.filter((action) => + this.isMatchingRefundAction(action, reference, amount, buyCrypto.chargebackAllowedDate), + ); + const latestRefund = this.latestRefundAction(matchingRefunds); + + if (latestRefund && latestRefund.approved !== false) { + if (buyCrypto.chargebackRemittanceInfo !== latestRefund.id) + await this.persistRefundActionId(buyCrypto, latestRefund.id); + continue; + } + + if (latestRefund?.approved === false) { + if (matchingRefunds.length >= FiatPayInSyncService.REFUND_MAX_ATTEMPTS) { + this.logger.error( + `Checkout refund for BuyCrypto ${buyCrypto.id} exhausted ${FiatPayInSyncService.REFUND_MAX_ATTEMPTS} attempts; manual intervention required`, + ); + continue; + } + + if (!latestRefund.processed_on) continue; + const processedOn = new Date(latestRefund.processed_on); + if ( + Number.isNaN(processedOn.getTime()) || + processedOn > Util.hoursBefore(FiatPayInSyncService.REFUND_RETRY_COOLDOWN_HOURS) + ) + continue; + } + + const refund = await this.checkoutService.refundBuyCryptoPayment( + checkoutTx.paymentId, + buyCrypto.id, + latestRefund?.id, + ); + await this.persistRefundActionId(buyCrypto, refund.action_id); + } catch (e) { + this.logger.error(`Failed to reconcile Checkout refund for BuyCrypto ${buyCrypto.id}:`, e); + } } + } + + private latestRefundAction(actions: CheckoutPaymentAction[]): CheckoutPaymentAction | undefined { + return actions.reduce((latest, action) => { + if (!latest) return action; + if (!latest.processed_on || !action.processed_on) return latest; + return new Date(action.processed_on) >= new Date(latest.processed_on) ? action : latest; + }, undefined); + } - if (!entity.transaction) - entity.transaction = await this.transactionService.create({ sourceType: TransactionSourceType.CHECKOUT_TX }); + private isMatchingRefundAction( + action: CheckoutPaymentAction, + reference: string, + amount: number, + claimDate?: Date, + ): boolean { + if (action.type.toLowerCase() !== 'refund' || action.amount !== amount) return false; + if (action.reference === reference) return true; - return this.checkoutTxRepo.save(entity); + if (action.reference || !claimDate || !action.processed_on) return false; + const processedOn = new Date(action.processed_on); + return !Number.isNaN(processedOn.getTime()) && processedOn >= claimDate; + } + + private async persistRefundActionId(buyCrypto: BuyCrypto, actionId: string): Promise { + await this.checkoutTxRepo.manager.update(BuyCrypto, buyCrypto.id, { + chargebackRemittanceInfo: actionId, + }); + buyCrypto.chargebackRemittanceInfo = actionId; } private mapCheckoutPayment(payment: CheckoutPayment): CheckoutTx { diff --git a/src/subdomains/supporting/payin/services/__tests__/payin.service.spec.ts b/src/subdomains/supporting/payin/services/__tests__/payin.service.spec.ts index 4e8d171cef..b1dbc40eb5 100644 --- a/src/subdomains/supporting/payin/services/__tests__/payin.service.spec.ts +++ b/src/subdomains/supporting/payin/services/__tests__/payin.service.spec.ts @@ -4,6 +4,7 @@ import { ConfigService } from 'src/config/config'; import { Util } from 'src/shared/utils/util'; import { PaymentLinkPaymentService } from 'src/subdomains/core/payment-link/services/payment-link-payment.service'; import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service'; +import { TransactionTypeInternal } from 'src/subdomains/supporting/payment/entities/transaction.entity'; import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; import { EntityManager, In, IsNull, LessThan, Not } from 'typeorm'; import { RetryPayInSendDto } from '../../dto/retry-payin-send.dto'; @@ -127,30 +128,24 @@ describe('PayInService designate-before-broadcast safeguards', () => { }, ); - it('persists a return claim through the supplied transaction manager', async () => { - const transaction = { id: 53 } as any; + it('uses the caller transaction when scheduling a return', async () => { const payIn = createCustomCryptoInput({ id: 52, - status: PayInStatus.ACKNOWLEDGED, action: PayInAction.WAITING, - returnTxId: null, - transaction, + status: PayInStatus.ACKNOWLEDGED, + transaction: { id: 53 } as any, + route: { user: { id: 54 } } as any, }); - const manager = { save: jest.fn().mockResolvedValue(payIn) }; + const manager = { save: jest.fn().mockResolvedValue(payIn) } as unknown as EntityManager; - await service.returnPayIn( - payIn, - '0x0000000000000000000000000000000000000001', - 0.1, - manager as unknown as EntityManager, - ); + await service.returnPayIn(payIn, '0x0000000000000000000000000000000000000001', 0.1, manager); + expect(manager.save).toHaveBeenCalledWith(CryptoInput, payIn); expect(transactionService.updateInternal).toHaveBeenCalledWith( - transaction, - expect.objectContaining({ user: payIn.route.user }), + payIn.transaction, + { type: TransactionTypeInternal.CRYPTO_INPUT_RETURN, user: payIn.route.user }, manager, ); - expect(manager.save).toHaveBeenCalledWith(CryptoInput, payIn); expect(payInRepository.save).not.toHaveBeenCalled(); }); diff --git a/src/subdomains/supporting/payment/services/transaction.service.ts b/src/subdomains/supporting/payment/services/transaction.service.ts index f1006eb25a..32eb21638c 100644 --- a/src/subdomains/supporting/payment/services/transaction.service.ts +++ b/src/subdomains/supporting/payment/services/transaction.service.ts @@ -221,6 +221,7 @@ export class TransactionService { outputAsset: true, bankData: true, batch: true, + chargebackBankTx: true, chargebackOutput: true, }, buyFiat: { cryptoInput: true, outputAsset: true, bankData: true },