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 70e8352275..418a18b914 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 @@ -1,5 +1,6 @@ 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 { Active } from 'src/shared/models/active'; import { Asset } from 'src/shared/models/asset/asset.entity'; import { baseUnitsTransformer } from 'src/shared/models/base-units.transformer'; @@ -25,7 +26,7 @@ import { VirtualIban } from 'src/subdomains/supporting/bank/virtual-iban/virtual import { FiatOutput } from 'src/subdomains/supporting/fiat-output/fiat-output.entity'; import { CheckoutTx } from 'src/subdomains/supporting/fiat-payin/entities/checkout-tx.entity'; import { MailTranslationKey } from 'src/subdomains/supporting/notification/factories/mail.factory'; -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 { InternalFeeDto } from 'src/subdomains/supporting/payment/dto/fee.dto'; import { CryptoPaymentMethod, @@ -572,6 +573,16 @@ export class BuyCrypto extends IEntity { return [this.id, update]; } + resume(): UpdateResult { + const update: Partial = { + status: BuyCryptoStatus.CREATED, + }; + + Object.assign(this, update); + + return [this.id, update]; + } + complete(payoutFee: number, outputAmountBaseUnits?: bigint | null): UpdateResult { const update: Partial = { outputDate: new Date(), @@ -903,6 +914,47 @@ export class BuyCrypto extends IEntity { return this.cryptoInput != null; } + // Mirrors resetAmlCheckForReview: checkout/crypto refund or forward already started on related rows. + get checkoutRefundStarted(): boolean { + return ( + this.checkoutTx != null && + [ + CheckoutPaymentStatus.REFUND_PENDING, + CheckoutPaymentStatus.PARTIALLY_REFUNDED, + CheckoutPaymentStatus.REFUNDED, + ].includes(this.checkoutTx.status) + ); + } + + get cryptoReturnStarted(): boolean { + return ( + this.cryptoInput != null && + (this.cryptoInput.action === PayInAction.RETURN || + (this.cryptoInput.status != null && + [PayInStatus.TO_RETURN, PayInStatus.RETURNED, PayInStatus.RETURN_CONFIRMED].includes( + this.cryptoInput.status, + )) || + this.cryptoInput.returnTxId != null) + ); + } + + get cryptoForwardStarted(): boolean { + return ( + this.cryptoInput != null && + (this.cryptoInput.action === PayInAction.FORWARD || + (this.cryptoInput.status != null && + [ + PayInStatus.PREPARING, + PayInStatus.PREPARED, + PayInStatus.SENDING, + PayInStatus.SEND_UNCERTAIN, + PayInStatus.FORWARDED, + PayInStatus.FORWARD_CONFIRMED, + ].includes(this.cryptoInput.status)) || + this.cryptoInput.outTxId != null) + ); + } + // mirror of doAmlCheck's amlCheck-null selection branch (BuyCryptoPreparationService.doAmlCheck) — rows the AML // cron will still pick up and price; keep in sync with that query get isAmlPricingPending(): boolean { diff --git a/src/subdomains/supporting/payment/controllers/transaction-admin.controller.ts b/src/subdomains/supporting/payment/controllers/transaction-admin.controller.ts index 1a15ce1f13..cceef24b07 100644 --- a/src/subdomains/supporting/payment/controllers/transaction-admin.controller.ts +++ b/src/subdomains/supporting/payment/controllers/transaction-admin.controller.ts @@ -36,6 +36,14 @@ export class TransactionAdminController { return this.transactionService.stop(+id); } + @Post(':id/resume') + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.COMPLIANCE), UserActiveGuard()) + @ApiExcludeEndpoint() + async resumeTransaction(@Param('id') id: string): Promise { + return this.transactionService.resume(+id); + } + @Post(':txId/riskAssessment') @ApiBearerAuth() @UseGuards(AuthGuard(), RoleGuard(UserRole.ADMIN), UserActiveGuard()) 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 e5451b2b26..2c52ccdafe 100644 --- a/src/subdomains/supporting/payment/services/__tests__/transaction.service.spec.ts +++ b/src/subdomains/supporting/payment/services/__tests__/transaction.service.spec.ts @@ -1,15 +1,20 @@ import { createMock } from '@golevelup/ts-jest'; -import { ConflictException } from '@nestjs/common'; +import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { CheckoutPaymentStatus } from 'src/integration/checkout/dto/checkout.dto'; 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 { BuyCryptoBatch } from 'src/subdomains/core/buy-crypto/process/entities/buy-crypto-batch.entity'; 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 { 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 { CheckoutTx } from 'src/subdomains/supporting/fiat-payin/entities/checkout-tx.entity'; +import { CryptoInput, PayInAction } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; +import { EntityManager, IsNull } from 'typeorm'; import { UpdateTransactionDto } from '../../dto/update-transaction.dto'; import { Transaction } from '../../entities/transaction.entity'; import { TransactionRepository } from '../../repositories/transaction.repository'; @@ -125,6 +130,237 @@ describe('TransactionService (admin door — amlCheck audit trail)', () => { await expect(service.stop(70)).rejects.toThrow(ConflictException); expect(buyCryptoRepo.save).not.toHaveBeenCalled(); }); + + function mockResumeManager( + buyCrypto: BuyCrypto | null, + updateResult = { affected: 1, raw: [], generatedMaps: [] }, + locked: { checkoutTx?: CheckoutTx; cryptoInput?: CryptoInput } = {}, + ) { + const manager = { + findOne: jest.fn().mockImplementation(async (entityClass: unknown) => { + if (entityClass === BuyCrypto) return buyCrypto; + if (entityClass === CheckoutTx) return locked.checkoutTx ?? buyCrypto?.checkoutTx ?? null; + if (entityClass === CryptoInput) return locked.cryptoInput ?? buyCrypto?.cryptoInput ?? null; + return null; + }), + update: jest.fn().mockResolvedValue(updateResult), + }; + // The auto-mocked repository has no `manager` getter to spy on, so define the property outright. + Object.defineProperty(buyCryptoRepo, 'manager', { + configurable: true, + value: { transaction: (cb: (m: typeof manager) => unknown) => cb(manager) } as unknown as EntityManager, + }); + return manager; + } + + it('resume() sets a stopped BuyCrypto back to Created via a targeted update', async () => { + const buyCrypto = Object.assign(new BuyCrypto(), { + id: 7, + status: BuyCryptoStatus.STOPPED, + amlCheck: CheckStatus.PASS, + }); + const entity = Object.assign(new Transaction(), { id: 99, buyCrypto }); + jest.spyOn(repo, 'findOne').mockResolvedValue(entity); + const manager = mockResumeManager(buyCrypto); + + await service.resume(99); + + expect(repo.findOne).toHaveBeenCalledWith(expect.objectContaining({ relations: { buyCrypto: true } })); + expect(manager.update).toHaveBeenCalledWith( + BuyCrypto, + { + id: 7, + status: BuyCryptoStatus.STOPPED, + amlCheck: CheckStatus.PASS, + isComplete: false, + batch: IsNull(), + txId: IsNull(), + outputAmount: IsNull(), + chargebackOutput: IsNull(), + chargebackAllowedDate: IsNull(), + chargebackAllowedDateUser: IsNull(), + chargebackDate: IsNull(), + chargebackCryptoTxId: IsNull(), + chargebackBankTx: IsNull(), + }, + { status: BuyCryptoStatus.CREATED }, + ); + }); + + it('resume() rejects a transaction that is not stopped', async () => { + const buyCrypto = Object.assign(new BuyCrypto(), { + id: 7, + status: BuyCryptoStatus.COMPLETE, + amlCheck: CheckStatus.PASS, + }); + const entity = Object.assign(new Transaction(), { id: 99, buyCrypto }); + jest.spyOn(repo, 'findOne').mockResolvedValue(entity); + const manager = mockResumeManager(buyCrypto); + + await expect(service.resume(99)).rejects.toThrow(BadRequestException); + expect(manager.update).not.toHaveBeenCalled(); + }); + + it('resume() rejects a stopped transaction whose amlCheck is not Pass', async () => { + const buyCrypto = Object.assign(new BuyCrypto(), { + id: 7, + status: BuyCryptoStatus.STOPPED, + amlCheck: CheckStatus.FAIL, + }); + const entity = Object.assign(new Transaction(), { id: 99, buyCrypto }); + jest.spyOn(repo, 'findOne').mockResolvedValue(entity); + const manager = mockResumeManager(buyCrypto); + + await expect(service.resume(99)).rejects.toThrow(BadRequestException); + expect(manager.update).not.toHaveBeenCalled(); + }); + + it('resume() rejects a transaction without buyCrypto', async () => { + const entity = Object.assign(new Transaction(), { id: 99, buyCrypto: undefined }); + jest.spyOn(repo, 'findOne').mockResolvedValue(entity); + const manager = mockResumeManager(null); + + await expect(service.resume(99)).rejects.toThrow(BadRequestException); + expect(manager.update).not.toHaveBeenCalled(); + }); + + it('resume() rejects an unknown transaction', async () => { + jest.spyOn(repo, 'findOne').mockResolvedValue(null); + const manager = mockResumeManager(null); + + await expect(service.resume(99)).rejects.toThrow(NotFoundException); + expect(manager.update).not.toHaveBeenCalled(); + }); + + it('resume() rejects a stopped transaction that is already assigned to a batch', async () => { + const buyCrypto = Object.assign(new BuyCrypto(), { + id: 7, + status: BuyCryptoStatus.STOPPED, + amlCheck: CheckStatus.PASS, + batch: Object.assign(new BuyCryptoBatch(), { id: 1 }), + }); + const entity = Object.assign(new Transaction(), { id: 99, buyCrypto }); + jest.spyOn(repo, 'findOne').mockResolvedValue(entity); + const manager = mockResumeManager(buyCrypto); + + await expect(service.resume(99)).rejects.toThrow(BadRequestException); + expect(manager.update).not.toHaveBeenCalled(); + }); + + it('resume() rejects a stopped transaction with an existing payout txId', async () => { + const buyCrypto = Object.assign(new BuyCrypto(), { + id: 7, + status: BuyCryptoStatus.STOPPED, + amlCheck: CheckStatus.PASS, + txId: '0xabc', + }); + const entity = Object.assign(new Transaction(), { id: 99, buyCrypto }); + jest.spyOn(repo, 'findOne').mockResolvedValue(entity); + const manager = mockResumeManager(buyCrypto); + + await expect(service.resume(99)).rejects.toThrow(BadRequestException); + expect(manager.update).not.toHaveBeenCalled(); + }); + + it('resume() rejects a stopped transaction with a chargeback in progress', async () => { + const buyCrypto = Object.assign(new BuyCrypto(), { + id: 7, + status: BuyCryptoStatus.STOPPED, + amlCheck: CheckStatus.PASS, + chargebackAllowedDateUser: new Date(), + }); + const entity = Object.assign(new Transaction(), { id: 99, buyCrypto }); + jest.spyOn(repo, 'findOne').mockResolvedValue(entity); + const manager = mockResumeManager(buyCrypto); + + await expect(service.resume(99)).rejects.toThrow(BadRequestException); + expect(manager.update).not.toHaveBeenCalled(); + }); + + it('resume() rejects a stopped transaction whose checkout payment was refunded', async () => { + const buyCrypto = Object.assign(new BuyCrypto(), { + id: 7, + status: BuyCryptoStatus.STOPPED, + amlCheck: CheckStatus.PASS, + checkoutTx: Object.assign(new CheckoutTx(), { id: 3, status: CheckoutPaymentStatus.REFUNDED }), + }); + const entity = Object.assign(new Transaction(), { id: 99, buyCrypto }); + jest.spyOn(repo, 'findOne').mockResolvedValue(entity); + const manager = mockResumeManager(buyCrypto); + + await expect(service.resume(99)).rejects.toThrow(BadRequestException); + expect(manager.update).not.toHaveBeenCalled(); + }); + + it('resume() rejects a stopped transaction whose crypto input is being returned', async () => { + const buyCrypto = Object.assign(new BuyCrypto(), { + id: 7, + status: BuyCryptoStatus.STOPPED, + amlCheck: CheckStatus.PASS, + cryptoInput: Object.assign(new CryptoInput(), { id: 4, action: PayInAction.RETURN }), + }); + const entity = Object.assign(new Transaction(), { id: 99, buyCrypto }); + jest.spyOn(repo, 'findOne').mockResolvedValue(entity); + const manager = mockResumeManager(buyCrypto); + + await expect(service.resume(99)).rejects.toThrow(BadRequestException); + expect(manager.update).not.toHaveBeenCalled(); + }); + + it('resume() rejects when the checkout refund commits between the read and the lock', async () => { + const buyCrypto = Object.assign(new BuyCrypto(), { + id: 7, + status: BuyCryptoStatus.STOPPED, + amlCheck: CheckStatus.PASS, + checkoutTx: Object.assign(new CheckoutTx(), { id: 3, status: CheckoutPaymentStatus.PAID }), + }); + const entity = Object.assign(new Transaction(), { id: 99, buyCrypto }); + jest.spyOn(repo, 'findOne').mockResolvedValue(entity); + const manager = mockResumeManager( + buyCrypto, + { affected: 1, raw: [], generatedMaps: [] }, + { + checkoutTx: Object.assign(new CheckoutTx(), { id: 3, status: CheckoutPaymentStatus.REFUNDED }), + }, + ); + + await expect(service.resume(99)).rejects.toThrow(BadRequestException); + expect(manager.update).not.toHaveBeenCalled(); + }); + + it('resume() rejects when the crypto return starts between the read and the lock', async () => { + const buyCrypto = Object.assign(new BuyCrypto(), { + id: 7, + status: BuyCryptoStatus.STOPPED, + amlCheck: CheckStatus.PASS, + cryptoInput: Object.assign(new CryptoInput(), { id: 4 }), + }); + const entity = Object.assign(new Transaction(), { id: 99, buyCrypto }); + jest.spyOn(repo, 'findOne').mockResolvedValue(entity); + const manager = mockResumeManager( + buyCrypto, + { affected: 1, raw: [], generatedMaps: [] }, + { + cryptoInput: Object.assign(new CryptoInput(), { id: 4, action: PayInAction.RETURN }), + }, + ); + + await expect(service.resume(99)).rejects.toThrow(BadRequestException); + expect(manager.update).not.toHaveBeenCalled(); + }); + + it('resume() rejects when a concurrent change invalidates the stopped state', async () => { + const buyCrypto = Object.assign(new BuyCrypto(), { + id: 7, + status: BuyCryptoStatus.STOPPED, + amlCheck: CheckStatus.PASS, + }); + const entity = Object.assign(new Transaction(), { id: 99, buyCrypto }); + jest.spyOn(repo, 'findOne').mockResolvedValue(entity); + mockResumeManager(buyCrypto, { affected: 0, raw: [], generatedMaps: [] }); + + await expect(service.resume(99)).rejects.toThrow(ConflictException); + }); }); 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 32eb21638c..595328e353 100644 --- a/src/subdomains/supporting/payment/services/transaction.service.ts +++ b/src/subdomains/supporting/payment/services/transaction.service.ts @@ -9,12 +9,15 @@ import { 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'; +import { CheckStatus } from 'src/subdomains/core/aml/enums/check-status.enum'; import { TransactionAmlCheckService } from 'src/subdomains/core/aml/services/transaction-aml-check.service'; -import { BuyCryptoStatus } 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 { BuyCryptoRepository } from 'src/subdomains/core/buy-crypto/process/repositories/buy-crypto.repository'; import { BankDataType } from 'src/subdomains/generic/user/models/bank-data/bank-data.entity'; 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 { CheckoutTx } from 'src/subdomains/supporting/fiat-payin/entities/checkout-tx.entity'; +import { CryptoInput } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; import { Between, Brackets, @@ -148,6 +151,86 @@ export class TransactionService { if (result.affected !== 1) throw new ConflictException('BuyCrypto status changed concurrently'); } + async resume(id: number): Promise { + await this.buyCryptoRepo.manager.transaction(async (manager) => { + const entity = await this.getTransactionById(id, { buyCrypto: true }); + if (!entity) throw new NotFoundException('Transaction not found'); + if (!entity.buyCrypto) throw new BadRequestException('Only BuyCrypto transactions can be resumed'); + + // Lock the row and its refund-bearing relations for the rest of the transaction, so a refund + // that starts while we decide cannot be overtaken (same pattern as resetAmlCheckForReview). + await manager.findOne(BuyCrypto, { + where: { id: entity.buyCrypto.id }, + select: { id: true }, + loadEagerRelations: false, + lock: { mode: 'pessimistic_write' }, + }); + + const buyCrypto = await manager.findOne(BuyCrypto, { + where: { id: entity.buyCrypto.id }, + relations: { batch: true, checkoutTx: true, cryptoInput: true }, + }); + if (!buyCrypto) throw new NotFoundException('BuyCrypto not found'); + + // Re-read the refund-bearing relations UNDER the lock and keep the locked rows: the read above + // may predate a refund that committed in between, and the getters below must see the current state. + if (buyCrypto.checkoutTx) + buyCrypto.checkoutTx = + (await manager.findOne(CheckoutTx, { + where: { id: buyCrypto.checkoutTx.id }, + loadEagerRelations: false, + lock: { mode: 'pessimistic_write' }, + })) ?? undefined; + if (buyCrypto.cryptoInput) + buyCrypto.cryptoInput = + (await manager.findOne(CryptoInput, { + where: { id: buyCrypto.cryptoInput.id }, + loadEagerRelations: false, + lock: { mode: 'pessimistic_write' }, + })) ?? undefined; + + if (buyCrypto.status !== BuyCryptoStatus.STOPPED) throw new BadRequestException('Transaction is not stopped'); + if (buyCrypto.amlCheck !== CheckStatus.PASS) + throw new BadRequestException('Only transactions with passed AML check can be resumed'); + if (buyCrypto.batch || buyCrypto.txId) + throw new BadRequestException('Only transactions without batch and payout can be resumed'); + if ( + buyCrypto.chargebackAllowedDate || + buyCrypto.chargebackAllowedDateUser || + buyCrypto.chargebackDate || + buyCrypto.chargebackCryptoTxId || + buyCrypto.checkoutRefundStarted || + buyCrypto.cryptoReturnStarted || + buyCrypto.cryptoForwardStarted + ) + throw new BadRequestException('Transactions with a refund or forward in progress cannot be resumed'); + + // Two separate mechanisms: the row locks above hold the related checkout / pay-in rows until this + // transaction commits, and the WHERE clause re-checks every buy_crypto invariant in the write itself. + const [buyCryptoId, update] = buyCrypto.resume(); + const result = await manager.update( + BuyCrypto, + { + id: buyCryptoId, + status: BuyCryptoStatus.STOPPED, + amlCheck: CheckStatus.PASS, + isComplete: false, + batch: IsNull(), + txId: IsNull(), + outputAmount: IsNull(), + chargebackOutput: IsNull(), + chargebackAllowedDate: IsNull(), + chargebackAllowedDateUser: IsNull(), + chargebackDate: IsNull(), + chargebackCryptoTxId: IsNull(), + chargebackBankTx: IsNull(), + }, + update, + ); + if (result.affected !== 1) throw new ConflictException('BuyCrypto status changed concurrently'); + }); + } + async getTransactionById( id: number, relations: FindOptionsRelations = {},