Skip to content
Open
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
26 changes: 26 additions & 0 deletions migration/1785584900000-AddBuyCryptoVersion.js
Original file line number Diff line number Diff line change
@@ -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"');
}
};
Original file line number Diff line number Diff line change
@@ -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);
});
});
31 changes: 28 additions & 3 deletions src/integration/checkout/services/checkout.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -112,7 +120,24 @@ export class CheckoutService {
return balance.data;
}

async refundPayment(paymentId: string): Promise<CheckoutReverse> {
return (await this.checkout.payments.refund(paymentId)) as CheckoutReverse;
async getPaymentActions(paymentId: string): Promise<CheckoutPaymentAction[]> {
return (await this.checkout.payments.getActions(paymentId)) as CheckoutPaymentAction[];
}

async refundBuyCryptoPayment(
paymentId: string,
buyCryptoId: number,
previousFailedActionId?: string,
): Promise<CheckoutReverse> {
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`;
}
}
18 changes: 13 additions & 5 deletions src/subdomains/core/buy-crypto/process/buy-crypto.controller.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<void> {
return this.buyCryptoService.resetAmlCheck(+id);
async resetAmlCheckForReview(
@GetJwt() jwt: JwtPayload,
@Param('id', ParseIntPipe) id: number,
@Body() dto: ResetBuyCryptoAmlReviewDto,
): Promise<void> {
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')
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -71,6 +71,9 @@ export interface CreditorData {

@Entity()
export class BuyCrypto extends IEntity {
@VersionColumn()
version: number;

// References
@OneToOne(() => BankTx, { nullable: true })
@JoinColumn()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<Record<string, unknown>>;
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({
Expand Down Expand Up @@ -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' }) })];

Expand Down Expand Up @@ -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();

Expand All @@ -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 --- //
Expand Down Expand Up @@ -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')
Expand Down
Loading