Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -572,6 +573,16 @@ export class BuyCrypto extends IEntity {
return [this.id, update];
}

resume(): UpdateResult<BuyCrypto> {
const update: Partial<BuyCrypto> = {
status: BuyCryptoStatus.CREATED,
};

Object.assign(this, update);

return [this.id, update];
}

complete(payoutFee: number, outputAmountBaseUnits?: bigint | null): UpdateResult<BuyCrypto> {
const update: Partial<BuyCrypto> = {
outputDate: new Date(),
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
return this.transactionService.resume(+id);
}

@Post(':txId/riskAssessment')
@ApiBearerAuth()
@UseGuards(AuthGuard(), RoleGuard(UserRole.ADMIN), UserActiveGuard())
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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)', () => {
Expand Down
Loading