From fb3fc115a71f5a53466a9dc64217a312a6fcbbf2 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:40:13 +0200 Subject: [PATCH 01/10] feat(payment): add resume endpoint for stopped buy-crypto transactions --- .../process/entities/buy-crypto.entity.ts | 10 ++++ .../transaction-admin.controller.ts | 8 +++ .../__tests__/transaction.service.spec.ts | 51 ++++++++++++++++++- .../payment/services/transaction.service.ts | 14 +++++ 4 files changed, 82 insertions(+), 1 deletion(-) 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..8127815d7f 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 @@ -572,6 +572,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(), 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..1113ad42cd 100644 --- a/src/subdomains/supporting/payment/services/__tests__/transaction.service.spec.ts +++ b/src/subdomains/supporting/payment/services/__tests__/transaction.service.spec.ts @@ -1,5 +1,5 @@ import { createMock } from '@golevelup/ts-jest'; -import { ConflictException } from '@nestjs/common'; +import { BadRequestException, 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'; @@ -125,6 +125,55 @@ describe('TransactionService (admin door — amlCheck audit trail)', () => { await expect(service.stop(70)).rejects.toThrow(ConflictException); expect(buyCryptoRepo.save).not.toHaveBeenCalled(); }); + + it('resume() sets a stopped BuyCrypto back to Created and saves it', 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); + jest.spyOn(buyCryptoRepo, 'save').mockImplementation(async (e) => e as BuyCrypto); + + await service.resume(99); + + expect(buyCryptoRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ id: 7, 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); + + await expect(service.resume(99)).rejects.toThrow(BadRequestException); + }); + + 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); + + await expect(service.resume(99)).rejects.toThrow(BadRequestException); + expect(buyCryptoRepo.save).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); + + await expect(service.resume(99)).rejects.toThrow(BadRequestException); + }); }); 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..d7bd96fbfa 100644 --- a/src/subdomains/supporting/payment/services/transaction.service.ts +++ b/src/subdomains/supporting/payment/services/transaction.service.ts @@ -9,6 +9,7 @@ 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 { BuyCryptoRepository } from 'src/subdomains/core/buy-crypto/process/repositories/buy-crypto.repository'; @@ -148,6 +149,19 @@ export class TransactionService { if (result.affected !== 1) throw new ConflictException('BuyCrypto status changed concurrently'); } + async resume(id: number): Promise { + 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'); + if (entity.buyCrypto.status !== BuyCryptoStatus.STOPPED) + throw new BadRequestException('Transaction is not stopped'); + if (entity.buyCrypto.amlCheck !== CheckStatus.PASS) + throw new BadRequestException('Only transactions with passed AML check can be resumed'); + + entity.buyCrypto.resume(); + await this.buyCryptoRepo.save(entity.buyCrypto); + } + async getTransactionById( id: number, relations: FindOptionsRelations = {}, From 84b3ae399e753b932f388317a64cb0d29a574df6 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:02:58 +0200 Subject: [PATCH 02/10] fix(payment): guard resume against batched or paid-out transactions, use targeted update --- .../__tests__/transaction.service.spec.ts | 47 ++++++++++++++++--- .../payment/services/transaction.service.ts | 7 +-- 2 files changed, 44 insertions(+), 10 deletions(-) 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 1113ad42cd..81c41f3c6e 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,12 @@ import { createMock } from '@golevelup/ts-jest'; -import { BadRequestException, ConflictException } from '@nestjs/common'; +import { BadRequestException, ConflictException, NotFoundException } 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 { 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'; @@ -126,7 +127,7 @@ describe('TransactionService (admin door — amlCheck audit trail)', () => { expect(buyCryptoRepo.save).not.toHaveBeenCalled(); }); - it('resume() sets a stopped BuyCrypto back to Created and saves it', async () => { + 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, @@ -134,13 +135,10 @@ describe('TransactionService (admin door — amlCheck audit trail)', () => { }); const entity = Object.assign(new Transaction(), { id: 99, buyCrypto }); jest.spyOn(repo, 'findOne').mockResolvedValue(entity); - jest.spyOn(buyCryptoRepo, 'save').mockImplementation(async (e) => e as BuyCrypto); await service.resume(99); - expect(buyCryptoRepo.save).toHaveBeenCalledWith( - expect.objectContaining({ id: 7, status: BuyCryptoStatus.CREATED }), - ); + expect(buyCryptoRepo.update).toHaveBeenCalledWith(7, { status: BuyCryptoStatus.CREATED }); }); it('resume() rejects a transaction that is not stopped', async () => { @@ -165,7 +163,7 @@ describe('TransactionService (admin door — amlCheck audit trail)', () => { jest.spyOn(repo, 'findOne').mockResolvedValue(entity); await expect(service.resume(99)).rejects.toThrow(BadRequestException); - expect(buyCryptoRepo.save).not.toHaveBeenCalled(); + expect(buyCryptoRepo.update).not.toHaveBeenCalled(); }); it('resume() rejects a transaction without buyCrypto', async () => { @@ -174,6 +172,41 @@ describe('TransactionService (admin door — amlCheck audit trail)', () => { await expect(service.resume(99)).rejects.toThrow(BadRequestException); }); + + it('resume() rejects an unknown transaction', async () => { + jest.spyOn(repo, 'findOne').mockResolvedValue(null); + + await expect(service.resume(99)).rejects.toThrow(NotFoundException); + expect(buyCryptoRepo.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); + + await expect(service.resume(99)).rejects.toThrow(BadRequestException); + expect(buyCryptoRepo.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); + + await expect(service.resume(99)).rejects.toThrow(BadRequestException); + expect(buyCryptoRepo.update).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 d7bd96fbfa..6a2dfa7437 100644 --- a/src/subdomains/supporting/payment/services/transaction.service.ts +++ b/src/subdomains/supporting/payment/services/transaction.service.ts @@ -150,16 +150,17 @@ export class TransactionService { } async resume(id: number): Promise { - const entity = await this.getTransactionById(id, { buyCrypto: true }); + const entity = await this.getTransactionById(id, { buyCrypto: { batch: true } }); if (!entity) throw new NotFoundException('Transaction not found'); if (!entity.buyCrypto) throw new BadRequestException('Only BuyCrypto transactions can be resumed'); if (entity.buyCrypto.status !== BuyCryptoStatus.STOPPED) throw new BadRequestException('Transaction is not stopped'); if (entity.buyCrypto.amlCheck !== CheckStatus.PASS) throw new BadRequestException('Only transactions with passed AML check can be resumed'); + if (entity.buyCrypto.batch || entity.buyCrypto.txId) + throw new BadRequestException('Only transactions without batch and payout can be resumed'); - entity.buyCrypto.resume(); - await this.buyCryptoRepo.save(entity.buyCrypto); + await this.buyCryptoRepo.update(...entity.buyCrypto.resume()); } async getTransactionById( From e0b2ed662d84202afe8055c80a0a65d51cab2178 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 4 Aug 2026 00:18:36 +0200 Subject: [PATCH 03/10] fix(payment): re-check resume preconditions atomically and exclude chargebacks --- .../__tests__/transaction.service.spec.ts | 38 ++++++++++++++++++- .../payment/services/transaction.service.ts | 33 +++++++++++++++- 2 files changed, 68 insertions(+), 3 deletions(-) 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 81c41f3c6e..e21bcc31cd 100644 --- a/src/subdomains/supporting/payment/services/__tests__/transaction.service.spec.ts +++ b/src/subdomains/supporting/payment/services/__tests__/transaction.service.spec.ts @@ -135,10 +135,17 @@ describe('TransactionService (admin door — amlCheck audit trail)', () => { }); const entity = Object.assign(new Transaction(), { id: 99, buyCrypto }); jest.spyOn(repo, 'findOne').mockResolvedValue(entity); + jest.spyOn(buyCryptoRepo, 'update').mockResolvedValue({ affected: 1, raw: [], generatedMaps: [] }); await service.resume(99); - expect(buyCryptoRepo.update).toHaveBeenCalledWith(7, { status: BuyCryptoStatus.CREATED }); + expect(repo.findOne).toHaveBeenCalledWith( + expect.objectContaining({ relations: { buyCrypto: { batch: true } } }), + ); + expect(buyCryptoRepo.update).toHaveBeenCalledWith( + expect.objectContaining({ id: 7, status: BuyCryptoStatus.STOPPED, amlCheck: CheckStatus.PASS }), + { status: BuyCryptoStatus.CREATED }, + ); }); it('resume() rejects a transaction that is not stopped', async () => { @@ -151,6 +158,7 @@ describe('TransactionService (admin door — amlCheck audit trail)', () => { jest.spyOn(repo, 'findOne').mockResolvedValue(entity); await expect(service.resume(99)).rejects.toThrow(BadRequestException); + expect(buyCryptoRepo.update).not.toHaveBeenCalled(); }); it('resume() rejects a stopped transaction whose amlCheck is not Pass', async () => { @@ -171,6 +179,7 @@ describe('TransactionService (admin door — amlCheck audit trail)', () => { jest.spyOn(repo, 'findOne').mockResolvedValue(entity); await expect(service.resume(99)).rejects.toThrow(BadRequestException); + expect(buyCryptoRepo.update).not.toHaveBeenCalled(); }); it('resume() rejects an unknown transaction', async () => { @@ -207,6 +216,33 @@ describe('TransactionService (admin door — amlCheck audit trail)', () => { await expect(service.resume(99)).rejects.toThrow(BadRequestException); expect(buyCryptoRepo.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); + + await expect(service.resume(99)).rejects.toThrow(BadRequestException); + expect(buyCryptoRepo.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); + jest.spyOn(buyCryptoRepo, 'update').mockResolvedValue({ 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 6a2dfa7437..e20ba7b864 100644 --- a/src/subdomains/supporting/payment/services/transaction.service.ts +++ b/src/subdomains/supporting/payment/services/transaction.service.ts @@ -159,8 +159,37 @@ export class TransactionService { throw new BadRequestException('Only transactions with passed AML check can be resumed'); if (entity.buyCrypto.batch || entity.buyCrypto.txId) throw new BadRequestException('Only transactions without batch and payout can be resumed'); - - await this.buyCryptoRepo.update(...entity.buyCrypto.resume()); + if ( + entity.buyCrypto.chargebackAllowedDate || + entity.buyCrypto.chargebackAllowedDateUser || + entity.buyCrypto.chargebackDate || + entity.buyCrypto.chargebackCryptoTxId + ) + throw new BadRequestException('Transactions with a chargeback cannot be resumed'); + + // Conditional update: every precondition is re-checked atomically in the WHERE clause, + // so a concurrent state change between the read above and this write cannot slip through + // (same pattern as stop() and BuyCryptoService.refundClaimWhere). + const [buyCryptoId, update] = entity.buyCrypto.resume(); + const result = await this.buyCryptoRepo.update( + { + 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( From cf11adf5a199a973a9356790b199014de4b8bd37 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 4 Aug 2026 00:20:19 +0200 Subject: [PATCH 04/10] style: match prettier line wrapping in resume tests --- .../payment/services/__tests__/transaction.service.spec.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 e21bcc31cd..1b00563032 100644 --- a/src/subdomains/supporting/payment/services/__tests__/transaction.service.spec.ts +++ b/src/subdomains/supporting/payment/services/__tests__/transaction.service.spec.ts @@ -139,9 +139,7 @@ describe('TransactionService (admin door — amlCheck audit trail)', () => { await service.resume(99); - expect(repo.findOne).toHaveBeenCalledWith( - expect.objectContaining({ relations: { buyCrypto: { batch: true } } }), - ); + expect(repo.findOne).toHaveBeenCalledWith(expect.objectContaining({ relations: { buyCrypto: { batch: true } } })); expect(buyCryptoRepo.update).toHaveBeenCalledWith( expect.objectContaining({ id: 7, status: BuyCryptoStatus.STOPPED, amlCheck: CheckStatus.PASS }), { status: BuyCryptoStatus.CREATED }, From 2add294c4301ee88904336caa3578d84fbd282b3 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 4 Aug 2026 00:36:34 +0200 Subject: [PATCH 05/10] test(payment): assert the full resume update criteria --- .../__tests__/transaction.service.spec.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) 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 1b00563032..ed8b785a54 100644 --- a/src/subdomains/supporting/payment/services/__tests__/transaction.service.spec.ts +++ b/src/subdomains/supporting/payment/services/__tests__/transaction.service.spec.ts @@ -11,6 +11,7 @@ import { BuyCrypto, BuyCryptoStatus } from 'src/subdomains/core/buy-crypto/proce 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 { IsNull } from 'typeorm'; import { UpdateTransactionDto } from '../../dto/update-transaction.dto'; import { Transaction } from '../../entities/transaction.entity'; import { TransactionRepository } from '../../repositories/transaction.repository'; @@ -141,7 +142,21 @@ describe('TransactionService (admin door — amlCheck audit trail)', () => { expect(repo.findOne).toHaveBeenCalledWith(expect.objectContaining({ relations: { buyCrypto: { batch: true } } })); expect(buyCryptoRepo.update).toHaveBeenCalledWith( - expect.objectContaining({ id: 7, status: BuyCryptoStatus.STOPPED, amlCheck: CheckStatus.PASS }), + { + 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 }, ); }); From e7b4eda60802f589800a2241f41bc5df79991ecd Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:04:51 +0200 Subject: [PATCH 06/10] fix(payment): block resume when a refund or forward already started --- .../process/entities/buy-crypto.entity.ts | 44 ++++++- .../__tests__/transaction.service.spec.ts | 81 ++++++++++-- .../payment/services/transaction.service.ts | 119 ++++++++++++------ 3 files changed, 190 insertions(+), 54 deletions(-) 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 8127815d7f..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, @@ -913,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/services/__tests__/transaction.service.spec.ts b/src/subdomains/supporting/payment/services/__tests__/transaction.service.spec.ts index ed8b785a54..31b495e92c 100644 --- a/src/subdomains/supporting/payment/services/__tests__/transaction.service.spec.ts +++ b/src/subdomains/supporting/payment/services/__tests__/transaction.service.spec.ts @@ -1,6 +1,7 @@ import { createMock } from '@golevelup/ts-jest'; 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'; @@ -11,7 +12,9 @@ import { BuyCrypto, BuyCryptoStatus } from 'src/subdomains/core/buy-crypto/proce 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 { IsNull } from 'typeorm'; +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'; @@ -128,6 +131,22 @@ describe('TransactionService (admin door — amlCheck audit trail)', () => { expect(buyCryptoRepo.save).not.toHaveBeenCalled(); }); + function mockResumeManager(buyCrypto: BuyCrypto | null, updateResult = { affected: 1, raw: [], generatedMaps: [] }) { + const manager = { + findOne: jest.fn().mockImplementation(async (entityClass: unknown) => { + if (entityClass === BuyCrypto) return buyCrypto; + if (entityClass === CheckoutTx) return buyCrypto?.checkoutTx ?? null; + if (entityClass === CryptoInput) return buyCrypto?.cryptoInput ?? null; + return null; + }), + update: jest.fn().mockResolvedValue(updateResult), + }; + jest.spyOn(buyCryptoRepo, 'manager', 'get').mockReturnValue({ + 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, @@ -136,12 +155,13 @@ describe('TransactionService (admin door — amlCheck audit trail)', () => { }); const entity = Object.assign(new Transaction(), { id: 99, buyCrypto }); jest.spyOn(repo, 'findOne').mockResolvedValue(entity); - jest.spyOn(buyCryptoRepo, 'update').mockResolvedValue({ affected: 1, raw: [], generatedMaps: [] }); + const manager = mockResumeManager(buyCrypto); await service.resume(99); - expect(repo.findOne).toHaveBeenCalledWith(expect.objectContaining({ relations: { buyCrypto: { batch: true } } })); - expect(buyCryptoRepo.update).toHaveBeenCalledWith( + expect(repo.findOne).toHaveBeenCalledWith(expect.objectContaining({ relations: { buyCrypto: true } })); + expect(manager.update).toHaveBeenCalledWith( + BuyCrypto, { id: 7, status: BuyCryptoStatus.STOPPED, @@ -169,9 +189,10 @@ describe('TransactionService (admin door — amlCheck audit trail)', () => { }); 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(buyCryptoRepo.update).not.toHaveBeenCalled(); + expect(manager.update).not.toHaveBeenCalled(); }); it('resume() rejects a stopped transaction whose amlCheck is not Pass', async () => { @@ -182,24 +203,27 @@ describe('TransactionService (admin door — amlCheck audit trail)', () => { }); 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(buyCryptoRepo.update).not.toHaveBeenCalled(); + 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(buyCryptoRepo.update).not.toHaveBeenCalled(); + 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(buyCryptoRepo.update).not.toHaveBeenCalled(); + expect(manager.update).not.toHaveBeenCalled(); }); it('resume() rejects a stopped transaction that is already assigned to a batch', async () => { @@ -211,9 +235,10 @@ describe('TransactionService (admin door — amlCheck audit trail)', () => { }); 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(buyCryptoRepo.update).not.toHaveBeenCalled(); + expect(manager.update).not.toHaveBeenCalled(); }); it('resume() rejects a stopped transaction with an existing payout txId', async () => { @@ -225,9 +250,10 @@ describe('TransactionService (admin door — amlCheck audit trail)', () => { }); 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(buyCryptoRepo.update).not.toHaveBeenCalled(); + expect(manager.update).not.toHaveBeenCalled(); }); it('resume() rejects a stopped transaction with a chargeback in progress', async () => { @@ -239,9 +265,40 @@ describe('TransactionService (admin door — amlCheck audit trail)', () => { }); 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(buyCryptoRepo.update).not.toHaveBeenCalled(); + 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 a concurrent change invalidates the stopped state', async () => { @@ -252,7 +309,7 @@ describe('TransactionService (admin door — amlCheck audit trail)', () => { }); const entity = Object.assign(new Transaction(), { id: 99, buyCrypto }); jest.spyOn(repo, 'findOne').mockResolvedValue(entity); - jest.spyOn(buyCryptoRepo, 'update').mockResolvedValue({ affected: 0, raw: [], generatedMaps: [] }); + mockResumeManager(buyCrypto, { affected: 0, raw: [], generatedMaps: [] }); await expect(service.resume(99)).rejects.toThrow(ConflictException); }); diff --git a/src/subdomains/supporting/payment/services/transaction.service.ts b/src/subdomains/supporting/payment/services/transaction.service.ts index e20ba7b864..ec1ace8d7b 100644 --- a/src/subdomains/supporting/payment/services/transaction.service.ts +++ b/src/subdomains/supporting/payment/services/transaction.service.ts @@ -11,11 +11,13 @@ 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, @@ -150,46 +152,81 @@ export class TransactionService { } async resume(id: number): Promise { - const entity = await this.getTransactionById(id, { buyCrypto: { batch: true } }); - if (!entity) throw new NotFoundException('Transaction not found'); - if (!entity.buyCrypto) throw new BadRequestException('Only BuyCrypto transactions can be resumed'); - if (entity.buyCrypto.status !== BuyCryptoStatus.STOPPED) - throw new BadRequestException('Transaction is not stopped'); - if (entity.buyCrypto.amlCheck !== CheckStatus.PASS) - throw new BadRequestException('Only transactions with passed AML check can be resumed'); - if (entity.buyCrypto.batch || entity.buyCrypto.txId) - throw new BadRequestException('Only transactions without batch and payout can be resumed'); - if ( - entity.buyCrypto.chargebackAllowedDate || - entity.buyCrypto.chargebackAllowedDateUser || - entity.buyCrypto.chargebackDate || - entity.buyCrypto.chargebackCryptoTxId - ) - throw new BadRequestException('Transactions with a chargeback cannot be resumed'); - - // Conditional update: every precondition is re-checked atomically in the WHERE clause, - // so a concurrent state change between the read above and this write cannot slip through - // (same pattern as stop() and BuyCryptoService.refundClaimWhere). - const [buyCryptoId, update] = entity.buyCrypto.resume(); - const result = await this.buyCryptoRepo.update( - { - 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'); + 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'); + + if (buyCrypto.checkoutTx) + await manager.findOne(CheckoutTx, { + where: { id: buyCrypto.checkoutTx.id }, + select: { id: true }, + loadEagerRelations: false, + lock: { mode: 'pessimistic_write' }, + }); + if (buyCrypto.cryptoInput) + await manager.findOne(CryptoInput, { + where: { id: buyCrypto.cryptoInput.id }, + select: { id: true }, + loadEagerRelations: false, + lock: { mode: 'pessimistic_write' }, + }); + + 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'); + + // The locks above cover the related rows; the WHERE clause re-checks every buy_crypto invariant + // in the same statement, so nothing can change between the checks and the write. + 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( From 456bb67b4f0bc71b25f6b9b53fda8e85879eb8bb Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:06:14 +0200 Subject: [PATCH 07/10] test(payment): define the manager property the resume tests stub --- .../services/__tests__/transaction.service.spec.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 31b495e92c..b1188e544f 100644 --- a/src/subdomains/supporting/payment/services/__tests__/transaction.service.spec.ts +++ b/src/subdomains/supporting/payment/services/__tests__/transaction.service.spec.ts @@ -141,9 +141,11 @@ describe('TransactionService (admin door — amlCheck audit trail)', () => { }), update: jest.fn().mockResolvedValue(updateResult), }; - jest.spyOn(buyCryptoRepo, 'manager', 'get').mockReturnValue({ - transaction: (cb: (m: typeof manager) => unknown) => cb(manager), - } as unknown as EntityManager); + // 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; } From 7f4b10109a5a5392fa72416c79f47b13814106fa Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:19:33 +0200 Subject: [PATCH 08/10] fix(payment): evaluate the locked relations, not the pre-lock read --- .../__tests__/transaction.service.spec.ts | 44 +++++++++++++++++-- .../payment/services/transaction.service.ts | 26 ++++++----- 2 files changed, 55 insertions(+), 15 deletions(-) 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 b1188e544f..93f21c0876 100644 --- a/src/subdomains/supporting/payment/services/__tests__/transaction.service.spec.ts +++ b/src/subdomains/supporting/payment/services/__tests__/transaction.service.spec.ts @@ -131,12 +131,16 @@ describe('TransactionService (admin door — amlCheck audit trail)', () => { expect(buyCryptoRepo.save).not.toHaveBeenCalled(); }); - function mockResumeManager(buyCrypto: BuyCrypto | null, updateResult = { affected: 1, raw: [], generatedMaps: [] }) { + 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 buyCrypto?.checkoutTx ?? null; - if (entityClass === CryptoInput) return buyCrypto?.cryptoInput ?? null; + 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), @@ -303,6 +307,40 @@ describe('TransactionService (admin door — amlCheck audit trail)', () => { 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, diff --git a/src/subdomains/supporting/payment/services/transaction.service.ts b/src/subdomains/supporting/payment/services/transaction.service.ts index ec1ace8d7b..5fb20ce577 100644 --- a/src/subdomains/supporting/payment/services/transaction.service.ts +++ b/src/subdomains/supporting/payment/services/transaction.service.ts @@ -172,20 +172,22 @@ export class TransactionService { }); 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) - await manager.findOne(CheckoutTx, { - where: { id: buyCrypto.checkoutTx.id }, - select: { id: true }, - loadEagerRelations: false, - lock: { mode: 'pessimistic_write' }, - }); + buyCrypto.checkoutTx = + (await manager.findOne(CheckoutTx, { + where: { id: buyCrypto.checkoutTx.id }, + loadEagerRelations: false, + lock: { mode: 'pessimistic_write' }, + })) ?? undefined; if (buyCrypto.cryptoInput) - await manager.findOne(CryptoInput, { - where: { id: buyCrypto.cryptoInput.id }, - select: { id: true }, - loadEagerRelations: false, - lock: { mode: 'pessimistic_write' }, - }); + 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) From 4e202abee5ace91312bb4be5b36633ab77d4cdb7 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:20:40 +0200 Subject: [PATCH 09/10] style: apply prettier wrapping to the new resume tests --- .../__tests__/transaction.service.spec.ts | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) 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 93f21c0876..2c52ccdafe 100644 --- a/src/subdomains/supporting/payment/services/__tests__/transaction.service.spec.ts +++ b/src/subdomains/supporting/payment/services/__tests__/transaction.service.spec.ts @@ -316,9 +316,13 @@ describe('TransactionService (admin door — amlCheck audit trail)', () => { }); 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 }), - }); + 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(); @@ -333,9 +337,13 @@ describe('TransactionService (admin door — amlCheck audit trail)', () => { }); 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 }), - }); + 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(); From a1ab2d3b1c7522954b025c35098ea71c934ceec0 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:33:48 +0200 Subject: [PATCH 10/10] docs(payment): name both safeguards the resume guard relies on --- .../supporting/payment/services/transaction.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/subdomains/supporting/payment/services/transaction.service.ts b/src/subdomains/supporting/payment/services/transaction.service.ts index 5fb20ce577..595328e353 100644 --- a/src/subdomains/supporting/payment/services/transaction.service.ts +++ b/src/subdomains/supporting/payment/services/transaction.service.ts @@ -205,8 +205,8 @@ export class TransactionService { ) throw new BadRequestException('Transactions with a refund or forward in progress cannot be resumed'); - // The locks above cover the related rows; the WHERE clause re-checks every buy_crypto invariant - // in the same statement, so nothing can change between the checks and the write. + // 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,