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/integration/checkout/services/__tests__/checkout.service.spec.ts b/src/integration/checkout/services/__tests__/checkout.service.spec.ts new file mode 100644 index 0000000000..3741b5bbf3 --- /dev/null +++ b/src/integration/checkout/services/__tests__/checkout.service.spec.ts @@ -0,0 +1,45 @@ +import { CheckoutService } from '../checkout.service'; + +describe('CheckoutService refund idempotency', () => { + it('returns the payment actions used for refund reconciliation', async () => { + const service = new CheckoutService(); + 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.refundBuyCryptoPayment('payment-1', 130504, 'action-failed'); + + expect(refund).toHaveBeenCalledWith( + 'payment-1', + { reference: 'bc-130504-refund' }, + 'buy-crypto-refund-130504-action-failed', + ); + }); + + 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 ae793ffa6f..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): Promise { - return (await this.checkout.payments.refund(paymentId)) 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/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..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,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, 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'; 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'; @@ -64,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({ @@ -93,6 +112,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 +248,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 +261,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 +324,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..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 @@ -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)', () => { @@ -352,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 }); @@ -431,6 +551,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); @@ -485,4 +639,40 @@ describe('BuyCryptoPreparationService', () => { expect(transactionAmlCheckService.createFromEntity).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' }, + { + source: 'BankTx', + relation: { bankTx: { id: 90 } as any }, + 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([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 699469b8b8..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 @@ -1,7 +1,8 @@ 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 { 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'; @@ -10,7 +11,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,23 +23,30 @@ 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'; +import { FiatOutputType } from 'src/subdomains/supporting/fiat-output/fiat-output.entity'; import { FiatOutputService } from 'src/subdomains/supporting/fiat-output/fiat-output.service'; -import { CheckoutTxService } from 'src/subdomains/supporting/fiat-payin/services/checkout-tx.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'; +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'; 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, 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'; 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'; @@ -71,7 +80,6 @@ describe('BuyCryptoService', () => { let transactionService: TransactionService; let siftService: SiftService; let checkoutService: CheckoutService; - let checkoutTxService: CheckoutTxService; let payInService: PayInService; let fiatOutputService: FiatOutputService; let transactionUtilService: TransactionUtilService; @@ -101,7 +109,6 @@ describe('BuyCryptoService', () => { transactionService = createMock(); siftService = createMock(); checkoutService = createMock(); - checkoutTxService = createMock(); payInService = createMock(); fiatOutputService = createMock(); transactionUtilService = createMock(); @@ -134,7 +141,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 }, @@ -329,6 +335,563 @@ 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, + inputAmount: 10, + checkoutTx: Object.assign(new CheckoutTx(), { id: 22, paymentId: 'pay-22' }), + }); + jest.spyOn(TransactionUtilService, 'validateRefund').mockImplementation(); + const refundPaymentSpy = jest.spyOn(checkoutService, 'refundBuyCryptoPayment').mockResolvedValue({ + action_id: 'action-22', + _links: { payment: { href: 'https://example.test/payment/pay-22' } }, + }); + 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), + ), + }, + }); + + await service.refundCheckoutTx(buyCrypto, { chargebackAllowedDate: new Date() }); + + expect(manager.update).toHaveBeenNthCalledWith( + 1, + BuyCrypto, + expect.objectContaining({ + id: 7, + chargebackOutput: expect.anything(), + chargebackAllowedDate: expect.anything(), + chargebackDate: expect.anything(), + chargebackCryptoTxId: expect.anything(), + chargebackBankTx: 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(refundPaymentSpy.mock.invocationCallOrder[0]); + expect(refundPaymentSpy).toHaveBeenCalledWith('pay-22', 7); + }); + + it('leaves a failed Checkout submission durably pending for the idempotent retry job', async () => { + const buyCrypto = createCustomBuyCrypto({ + id: 7, + inputAmount: 10, + checkoutTx: Object.assign(new CheckoutTx(), { id: 22, paymentId: 'pay-22' }), + }); + 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), + ), + }, + }); + + await expect(service.refundCheckoutTx(buyCrypto, { chargebackAllowedDate: new Date() })).rejects.toThrow( + 'network timeout', + ); + + expect(manager.update).toHaveBeenNthCalledWith( + 1, + BuyCrypto, + expect.anything(), + expect.objectContaining({ + amlCheck: CheckStatus.FAIL, + chargebackAllowedDate: expect.any(Date), + isComplete: true, + status: BuyCryptoStatus.COMPLETE, + }), + ); + expect(manager.update).toHaveBeenNthCalledWith(2, CheckoutTx, 22, { + status: CheckoutPaymentStatus.REFUND_PENDING, + }); + expect(transactionAmlCheckService.createFromEntity).toHaveBeenCalled(); + }); + + it('claims and schedules a crypto refund atomically before releasing the BuyCrypto lock', async () => { + const cryptoInput = createCustomCryptoInput({ + id: 23, + action: PayInAction.WAITING, + status: PayInStatus.ACKNOWLEDGED, + }); + const buyCrypto = createCustomBuyCrypto({ + id: 7, + amlCheck: CheckStatus.PENDING, + batch: null, + outputAmount: null, + chargebackAmount: 1, + cryptoInput, + }); + 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(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', { + configurable: true, + value: { + transaction: jest.fn(async (run: (entityManager: EntityManager) => unknown) => + run(manager as unknown as EntityManager), + ), + }, + }); + + await service.refundCryptoInput(buyCrypto, { + refundUserAddress: refundUser.address, + chargebackAllowedDate: new Date(), + }); + + expect(manager.update).toHaveBeenCalledWith( + BuyCrypto, + expect.objectContaining({ + id: 7, + chargebackAllowedDate: expect.anything(), + chargebackDate: expect.anything(), + chargebackCryptoTxId: expect.anything(), + chargebackBankTx: expect.anything(), + }), + expect.objectContaining({ chargebackAllowedDate: expect.any(Date) }), + ); + 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, + }); + 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: 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, + ): { 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(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 }), + 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(), + chargebackDate: expect.anything(), + chargebackCryptoTxId: expect.anything(), + chargebackBankTx: 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('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('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.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')); + + 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,9 +902,17 @@ describe('BuyCryptoService', () => { chargebackAllowedDate: undefined, isComplete: false, }); + jest.spyOn(buyCryptoRepo, 'update').mockResolvedValue({ affected: 1, raw: [], generatedMaps: [] }); 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 }), @@ -365,9 +936,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 +953,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 +968,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 +985,136 @@ 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.refundBuyCryptoPayment).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(); + }); + + 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(); }); }); 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..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 @@ -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'; @@ -54,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, @@ -93,7 +96,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 +135,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 +146,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 +440,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 +562,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..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 @@ -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'; @@ -113,6 +113,7 @@ export class BuyCryptoPreparationService { const request: FindOptionsWhere = { inputAmount: Not(IsNull()), inputAsset: Not(IsNull()), + chargebackAllowedDate: IsNull(), chargebackAllowedDateUser: IsNull(), isComplete: false, }; @@ -214,8 +215,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 +261,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 +484,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 +645,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 }); @@ -603,6 +775,7 @@ export class BuyCryptoPreparationService { try { const chargebackAllowedDate = new Date(); const chargebackAllowedBy = 'API'; + const chargebackAllowedDateUser = entity.chargebackAllowedDateUser; if (entity.bankTx) { if ( @@ -610,11 +783,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 2b6a6d4b32..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 @@ -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'; @@ -21,6 +22,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 +44,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'; @@ -49,8 +52,7 @@ 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 } 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'; @@ -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, FindOptionsWhere, 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,38 @@ 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]), + ); + } + + 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, @@ -99,8 +134,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, @@ -135,8 +168,10 @@ export class BuyCryptoService implements OnModuleInit { transaction: { userData: { id: userData.id } }, amlCheck: CheckStatus.FAIL, amlReason: In(PhoneAmlReasons), + status: Not(BuyCryptoStatus.STOPPED), isComplete: false, chargebackAllowedDate: IsNull(), + chargebackAllowedDateUser: IsNull(), }); for (const entity of entities) { @@ -313,85 +348,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 +457,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 +473,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 +503,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 +601,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 { @@ -549,16 +660,41 @@ export class BuyCryptoService implements OnModuleInit { TransactionUtilService.validateRefund(buyCrypto, { chargebackAmount, assetMismatch: false }); - if (dto.chargebackAllowedDate && chargebackAmount) { - dto.chargebackRemittanceInfo = await this.checkoutService.refundPayment(buyCrypto.checkoutTx.paymentId); - await this.checkoutTxService.paymentRefunded(buyCrypto.checkoutTx.id); - } + let previousAmlCheck: CheckStatus; + let previousAmlReason: AmlReason; + let refundEntity: BuyCrypto; + let checkoutPaymentId: string; + + 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 previousAmlCheck = buyCrypto.amlCheck; - const previousAmlReason = buyCrypto.amlReason; + 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'); + + const lockedCheckoutTx = await manager.findOne(CheckoutTx, { + where: { id: currentBuyCrypto.checkoutTx.id }, + loadEagerRelations: false, + lock: { mode: 'pessimistic_write' }, + }); + if (!lockedCheckoutTx) throw new NotFoundException('Checkout TX not found'); - await this.buyCryptoRepo.update( - ...buyCrypto.chargebackFillUp( + currentBuyCrypto.checkoutTx = lockedCheckoutTx; + TransactionUtilService.validateRefund(currentBuyCrypto, { chargebackAmount, assetMismatch: false }); + + const claimWhere = BuyCryptoService.refundClaimWhere(currentBuyCrypto); + previousAmlCheck = currentBuyCrypto.amlCheck; + previousAmlReason = currentBuyCrypto.amlReason; + const [, update] = currentBuyCrypto.chargebackFillUp( undefined, chargebackAmount, chargebackAmount, @@ -566,18 +702,33 @@ export class BuyCryptoService implements OnModuleInit { dto.chargebackAllowedDate, dto.chargebackAllowedDateUser, dto.chargebackAllowedBy, - undefined, - dto.chargebackRemittanceInfo?.reference, - ), - ); + ); + 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 manager.update(CheckoutTx, lockedCheckoutTx.id, { status: CheckoutPaymentStatus.REFUND_PENDING }); + checkoutPaymentId = lockedCheckoutTx.paymentId; + refundEntity = currentBuyCrypto; + }); 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 { @@ -596,20 +747,48 @@ export class BuyCryptoService implements OnModuleInit { TransactionUtilService.validateRefund(buyCrypto, { refundUser, chargebackAmount, assetMismatch: false }); let blockchainFee: number; - if (dto.chargebackAllowedDate && chargebackAmount) { + 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 previousAmlCheck = buyCrypto.amlCheck; - const previousAmlReason = buyCrypto.amlReason; + 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, transaction: { userData: true } }, + }); + if (!currentBuyCrypto) throw new NotFoundException('BuyCrypto not found'); + + 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'); - await this.buyCryptoRepo.update( - ...buyCrypto.chargebackFillUp( + currentBuyCrypto.cryptoInput = cryptoInput; + TransactionUtilService.validateRefund(currentBuyCrypto, { refundUser, chargebackAmount, assetMismatch: false }); + + const claimWhere = BuyCryptoService.refundClaimWhere(currentBuyCrypto); + previousAmlCheck = currentBuyCrypto.amlCheck; + previousAmlReason = currentBuyCrypto.amlReason; + const [, update] = currentBuyCrypto.chargebackFillUp( refundUser.address ?? buyCrypto.chargebackIban, chargebackAmount, chargebackAmount, @@ -620,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, @@ -660,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'); - const previousAmlCheck = buyCrypto.amlCheck; - const previousAmlReason = buyCrypto.amlReason; + TransactionUtilService.validateRefund(currentBuyCrypto, { + refundIban: chargebackIban, + chargebackAmount, + chargebackReferenceAmount, + assetMismatch: chargebackAsset && chargebackAsset !== currentBuyCrypto.inputAsset, + }); - await this.buyCryptoRepo.update( - ...buyCrypto.chargebackFillUp( + 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, + ); + + const claimWhere = BuyCryptoService.refundClaimWhere(currentBuyCrypto); + previousAmlCheck = currentBuyCrypto.amlCheck; + previousAmlReason = currentBuyCrypto.amlReason; + const [, update] = currentBuyCrypto.chargebackFillUp( chargebackIban, chargebackReferenceAmount, chargebackAmount, @@ -687,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, @@ -774,11 +987,159 @@ 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: { + chargebackBankTx: true, + 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 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'); + 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 || + entity.chargebackDate || + entity.chargebackCryptoTxId || + entity.chargebackBankTx || + checkoutRefundStarted || + cryptoReturnStarted || + cryptoForwardStarted + ) + 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); + + 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(), + chargebackDate: IsNull(), + chargebackCryptoTxId: IsNull(), + chargebackBankTx: IsNull(), + }, + update, + ); + if (result.affected !== 1) throw new ConflictException('BuyCrypto AML status changed concurrently'); - await this.resetAmlCheckInternal(entity, AmlSourceType.MANUAL_RESET); + 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 +1168,15 @@ 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 || + 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'); const fee = entity.fee; @@ -816,8 +1184,24 @@ 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(), + chargebackAllowedDateUser: IsNull(), + }, + update, + ); + if (updateResult.affected !== 1) throw new ConflictException('BuyCrypto changed concurrently'); await this.transactionAmlCheckService.createFromEntity( entity, 'BuyCrypto', diff --git a/src/subdomains/generic/support/__tests__/support.service.spec.ts b/src/subdomains/generic/support/__tests__/support.service.spec.ts index 1c42c8403f..5599e3b823 100644 --- a/src/subdomains/generic/support/__tests__/support.service.spec.ts +++ b/src/subdomains/generic/support/__tests__/support.service.spec.ts @@ -3,14 +3,17 @@ 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'; -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'; 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,67 @@ 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 }) }, + }, + { 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, + buyCrypto: Object.assign(new BuyCrypto(), source), + }); + + 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); + }); + }); + 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/dto/user-data-support.dto.ts b/src/subdomains/generic/support/dto/user-data-support.dto.ts index ab9a6eeb65..40eb03d233 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,11 @@ export class TransactionSupportInfo { id: number; uid: string; buyCryptoId?: number; + buyCryptoIsComplete?: boolean; + 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 5d045ed060..d08be794bf 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 { @@ -19,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'; @@ -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'; @@ -593,10 +594,53 @@ 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, buyCryptoId: tx.buyCrypto?.id, + buyCryptoIsComplete: tx.buyCrypto?.isComplete, + buyCryptoStatus: tx.buyCrypto?.status, + buyCryptoHasBatch: !!tx.buyCrypto?.batch, + 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/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..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 @@ -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 || jwt.account === undefined) 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(); 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/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 477368b416..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,11 +4,12 @@ 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 { 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'; @@ -20,6 +21,7 @@ describe('PayInService designate-before-broadcast safeguards', () => { let service: PayInService; let payInRepository: PayInRepository; let notificationService: NotificationService; + let transactionService: TransactionService; beforeAll(() => { new ConfigService(); @@ -28,11 +30,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(), @@ -125,6 +128,27 @@ describe('PayInService designate-before-broadcast safeguards', () => { }, ); + it('uses the caller transaction when scheduling a return', async () => { + const payIn = createCustomCryptoInput({ + id: 52, + action: PayInAction.WAITING, + status: PayInStatus.ACKNOWLEDGED, + transaction: { id: 53 } as any, + route: { user: { id: 54 } } as any, + }); + const manager = { save: jest.fn().mockResolvedValue(payIn) } as unknown as EntityManager; + + await service.returnPayIn(payIn, '0x0000000000000000000000000000000000000001', 0.1, manager); + + expect(manager.save).toHaveBeenCalledWith(CryptoInput, payIn); + expect(transactionService.updateInternal).toHaveBeenCalledWith( + payIn.transaction, + { type: TransactionTypeInternal.CRYPTO_INPUT_RETURN, user: payIn.route.user }, + manager, + ); + 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 { 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..32eb21638c 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( @@ -205,7 +215,15 @@ export class TransactionService { ] : base, relations: { - buyCrypto: { cryptoInput: true, outputAsset: true, bankData: true }, + buyCrypto: { + cryptoInput: true, + checkoutTx: true, + outputAsset: true, + bankData: true, + batch: true, + chargebackBankTx: true, + chargebackOutput: true, + }, buyFiat: { cryptoInput: true, outputAsset: true, bankData: true }, bankTxReturn: true, bankTxRepeat: true,