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
28 changes: 28 additions & 0 deletions src/aml/ADMIN_ALERT_EMAIL_FIX.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# AML alert email config key fix

## What was wrong

`src/aml/aml.service.ts` read `ADMIN_EMAIL` from config to decide whether to
send an AML alert email. That variable is not documented anywhere:
`.env.example` and `README.md` only define `ADMIN_ALERT_EMAIL`, and
`src/alerts/admin-alert.service.ts` (the other admin-alert code path in the
repo) already uses `ADMIN_ALERT_EMAIL`.

Net effect: in any environment set up per the documented `.env.example`
(`ADMIN_ALERT_EMAIL` set, `ADMIN_EMAIL` unset), `AmlService.createFlag()`
silently skipped `notificationsService.enqueueEmail()` — AML alert emails
never sent, with no warning logged.

## What changed

- `src/aml/aml.service.ts`: `createFlag()` now reads `ADMIN_ALERT_EMAIL`
(matching `AdminAlertService`) instead of `ADMIN_EMAIL`.
- Added a `logger.warn` when `ADMIN_ALERT_EMAIL` is unset, so a missing
config value is visible in logs instead of failing silently.

## Why not just document `ADMIN_EMAIL` instead

`ADMIN_EMAIL` was never intentionally a separate variable — it appeared
nowhere else in the codebase, docs, or `.env.example`. Aligning on the
already-documented `ADMIN_ALERT_EMAIL` key avoids adding a second,
redundant admin-contact setting.
8 changes: 7 additions & 1 deletion src/aml/aml.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,19 @@ export class AmlService {
await this.amlRepo.save(flag);
this.logger.warn(`AML flag created: ${reason} for merchant ${merchantId}`);

const adminEmail = this.configService.get<string>('ADMIN_EMAIL');
// Use the same config key as AdminAlertService (src/alerts/admin-alert.service.ts)
// so AML alerts actually send in environments configured per .env.example / README.
const adminEmail = this.configService.get<string>('ADMIN_ALERT_EMAIL');
if (adminEmail) {
await this.notificationsService.enqueueEmail({
recipient: adminEmail,
subject: `[AML Alert] New flag: ${reason}`,
text: `A new AML flag has been raised.\n\nReason: ${reason}\nMerchant ID: ${merchantId}\nPayment ID: ${paymentId}\nDetails: ${JSON.stringify(metadata, null, 2)}\n\nPlease review at /admin/aml.`,
});
} else {
this.logger.warn(
`ADMIN_ALERT_EMAIL is not configured — skipping AML alert email for flag ${reason} (merchant ${merchantId})`,
);
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/blockchain-wallet/entities/blockchain-wallet.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import {
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
} from 'typeorm';
import { Exclude } from 'class-transformer';
import { encryptedColumnTransformer } from '../../security/encrypted-column.transformer';

@Entity('blockchain_wallets')
@Index('IDX_BLOCKCHAIN_WALLET_LAST_SYNCED_AT', ['lastSyncedAt'])
export class BlockchainWallet {
@PrimaryGeneratedColumn('uuid')
id: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class BackfillMissingEntityIndexes1772400000000
implements MigrationInterface
{
name = 'BackfillMissingEntityIndexes1772400000000';

public async up(queryRunner: QueryRunner): Promise<void> {
const settlementsTableExists = await queryRunner.hasTable('settlements');
if (settlementsTableExists) {
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_SETTLEMENT_MERCHANT_ID"
ON "settlements" ("merchantId")
`);

await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_SETTLEMENT_STATUS"
ON "settlements" ("status")
`);

await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_SETTLEMENT_MERCHANT_STATUS"
ON "settlements" ("merchantId", "status")
`);
}

const webhooksTableExists = await queryRunner.hasTable('webhooks');
if (webhooksTableExists) {
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_WEBHOOK_MERCHANT_ID"
ON "webhooks" ("merchantId")
`);

await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_WEBHOOK_MERCHANT_ACTIVE"
ON "webhooks" ("merchantId", "isActive")
`);
}

const blockchainWalletsTableExists = await queryRunner.hasTable(
'blockchain_wallets',
);
if (blockchainWalletsTableExists) {
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_BLOCKCHAIN_WALLET_LAST_SYNCED_AT"
ON "blockchain_wallets" ("lastSyncedAt")
`);
}
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS "IDX_BLOCKCHAIN_WALLET_LAST_SYNCED_AT"`,
);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_WEBHOOK_MERCHANT_ACTIVE"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_WEBHOOK_MERCHANT_ID"`);
await queryRunner.query(
`DROP INDEX IF EXISTS "IDX_SETTLEMENT_MERCHANT_STATUS"`,
);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_SETTLEMENT_STATUS"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_SETTLEMENT_MERCHANT_ID"`);
}
}
28 changes: 28 additions & 0 deletions src/payment/IDEMPOTENCY_TTL_FIX.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Idempotency interceptor TTL fix

## What was wrong

`src/payment/idempotency.interceptor.ts` called:

```ts
this.cacheService.set(cacheKey, { ... }, { ttl: IDEMPOTENCY_TTL });
```

but `CacheService.set()` (`src/cache/cache.service.ts`) has the signature
`set(key, value, options?: { ttlSeconds?: number })` and reads
`options?.ttlSeconds ?? 86400`. The `ttl` property the interceptor passed
was never read, so the call silently fell back to `CacheService`'s
hardcoded 86400s default.

This "worked" only because `IDEMPOTENCY_TTL` (86_400s) happened to equal
the fallback default. Changing `IDEMPOTENCY_TTL` independently would have
had no effect on the actual cache TTL, with no error or warning.

## What changed

- `src/payment/idempotency.interceptor.ts`: pass `{ ttlSeconds: IDEMPOTENCY_TTL }`
instead of `{ ttl: IDEMPOTENCY_TTL }`.
- Added `src/payment/idempotency.interceptor.spec.ts` with a unit test that
asserts `CacheService.set` is called with `{ ttlSeconds: 86_400 }`, so a
future regression to the wrong option key fails the test instead of
silently falling back to the default.
51 changes: 51 additions & 0 deletions src/payment/idempotency.interceptor.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { of } from 'rxjs';
import { IdempotencyInterceptor } from './idempotency.interceptor';
import { CacheService } from '../cache/cache.service';

describe('IdempotencyInterceptor', () => {
let interceptor: IdempotencyInterceptor;
let cacheService: Partial<CacheService>;

const buildContext = (idempotencyKey?: string) => {
const response = { statusCode: 201 };
const request = { headers: idempotencyKey ? { 'idempotency-key': idempotencyKey } : {} };
return {
switchToHttp: () => ({
getRequest: () => request,
getResponse: () => response,
}),
} as any;
};

beforeEach(() => {
cacheService = {
get: jest.fn().mockResolvedValue(undefined),
set: jest.fn().mockResolvedValue(undefined),
};
interceptor = new IdempotencyInterceptor(cacheService as CacheService);
});

it('passes ttlSeconds (not ttl) through to CacheService.set so the configured TTL actually applies', async () => {
const context = buildContext('key-123');
const next = { handle: () => of({ ok: true }) };

const result$ = await interceptor.intercept(context, next as any);
await new Promise((resolve) => result$.subscribe({ complete: resolve, next: resolve }));

expect(cacheService.set).toHaveBeenCalledWith(
'idempotency:payment:key-123',
expect.objectContaining({ status: 201, body: { ok: true } }),
{ ttlSeconds: 86_400 },
);
});

it('skips caching when no idempotency key is present', async () => {
const context = buildContext();
const next = { handle: jest.fn().mockReturnValue(of({ ok: true })) };

await interceptor.intercept(context, next as any);

expect(next.handle).toHaveBeenCalled();
expect(cacheService.set).not.toHaveBeenCalled();
});
});
2 changes: 1 addition & 1 deletion src/payment/idempotency.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export class IdempotencyInterceptor implements NestInterceptor {
await this.cacheService.set(
cacheKey,
{ status: response.statusCode ?? HttpStatus.CREATED, body },
{ ttl: IDEMPOTENCY_TTL },
{ ttlSeconds: IDEMPOTENCY_TTL },
);
}),
);
Expand Down
2 changes: 2 additions & 0 deletions src/payments/payments.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { WebhooksModule } from '../webhooks/webhooks.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { MerchantsModule } from '../merchants/merchants.module';
import { SorobanService } from '../blockchain-wallet/soroban.service';
import { AmlModule } from '../aml/aml.module';

@Module({
imports: [
Expand All @@ -21,6 +22,7 @@ import { SorobanService } from '../blockchain-wallet/soroban.service';
NotificationsModule,
MerchantsModule,
ConfigModule,
forwardRef(() => AmlModule),
],
controllers: [PaymentsController, PublicPaymentController],
providers: [PaymentsService, IdempotencyInterceptor, SorobanService],
Expand Down
43 changes: 43 additions & 0 deletions src/payments/payments.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { WebhooksService } from '../webhooks/webhooks.service';
import { NotificationsService } from '../notifications/notifications.service';
import { MerchantsService } from '../merchants/merchants.service';
import { AnalyticsService } from '../analytics/analytics.service';
import { AmlService } from '../aml/aml.service';

describe('PaymentsService', () => {
let service: PaymentsService;
Expand All @@ -19,6 +20,7 @@ describe('PaymentsService', () => {
let notifications: NotificationsService;
let merchants: MerchantsService;
let analytics: AnalyticsService;
let aml: AmlService;

const mockMerchant = {
id: 'merchant-123',
Expand Down Expand Up @@ -79,6 +81,12 @@ describe('PaymentsService', () => {
clearCacheForMerchant: jest.fn(),
},
},
{
provide: AmlService,
useValue: {
checkAndFlag: jest.fn(),
},
},
],
}).compile();

Expand All @@ -90,6 +98,7 @@ describe('PaymentsService', () => {
notifications = module.get<NotificationsService>(NotificationsService);
merchants = module.get<MerchantsService>(MerchantsService);
analytics = module.get<AnalyticsService>(AnalyticsService);
aml = module.get<AmlService>(AmlService);
});

describe('refund', () => {
Expand Down Expand Up @@ -189,5 +198,39 @@ describe('PaymentsService', () => {
expect(analytics.clearCacheForMerchant).toHaveBeenCalledWith('merchant-999');
expect(analytics.clearCacheForMerchant).toHaveBeenCalledTimes(1);
});

it('should run the AML high-value/high-velocity check on confirmation', async () => {
const payment = {
id: 'payment-321',
merchantId: 'merchant-321',
amountUsd: 15000,
status: PaymentStatus.PENDING,
customerWalletAddress: null,
} as any;

jest.spyOn(repo, 'findOne').mockResolvedValue(payment);
jest.spyOn(repo, 'save').mockImplementation(async (p) => p);

const result = await service.confirmPayment('payment-321', 'GDEF789');

expect(aml.checkAndFlag).toHaveBeenCalledWith(result);
expect(aml.checkAndFlag).toHaveBeenCalledTimes(1);
});

it('should not block confirmation if the AML check throws', async () => {
const payment = {
id: 'payment-654',
merchantId: 'merchant-654',
status: PaymentStatus.PENDING,
customerWalletAddress: null,
} as any;

jest.spyOn(repo, 'findOne').mockResolvedValue(payment);
jest.spyOn(repo, 'save').mockImplementation(async (p) => p);
jest.spyOn(aml, 'checkAndFlag').mockRejectedValue(new Error('aml down'));

const result = await service.confirmPayment('payment-654', 'GABCXYZ');
expect(result.status).toBe(PaymentStatus.CONFIRMED);
});
});
});
9 changes: 9 additions & 0 deletions src/payments/payments.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { MerchantsService } from '../merchants/merchants.service';
import { PaginatedResponseDto } from '../common/dto/pagination.dto';
import { SorobanService, PaymentExpiredError } from '../blockchain-wallet/soroban.service';
import { AnalyticsService } from '../analytics/analytics.service';
import { AmlService } from '../aml/aml.service';

// Events emitted per payment in a batch — mirrors contract PaymentCreated events
export interface PaymentCreatedEvent {
Expand All @@ -39,6 +40,7 @@ export class PaymentsService {
private merchants: MerchantsService,
private soroban: SorobanService,
private analytics: AnalyticsService,
private aml: AmlService,
) {}

async create(merchantId: string, dto: CreatePaymentDto): Promise<Payment> {
Expand Down Expand Up @@ -120,6 +122,13 @@ export class PaymentsService {
// Invalidate merchant analytics caches since funnel and comparison data may have changed.
this.analytics.clearCacheForMerchant(payment.merchantId);

// Run AML high-value/high-velocity checks now that the payment is confirmed.
try {
await this.aml.checkAndFlag(saved);
} catch (err) {
this.logger.error(`AML check failed for payment ${saved.id}: ${err.message}`);
}

return saved;
}

Expand Down
4 changes: 4 additions & 0 deletions src/settlements/entities/settlement.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
ManyToOne,
JoinColumn,
OneToMany,
Index,
} from 'typeorm';
import { Merchant } from '../../merchants/entities/merchant.entity';
import { Payment } from '../../payments/entities/payment.entity';
Expand All @@ -20,6 +21,9 @@ export enum SettlementStatus {
}

@Entity('settlements')
@Index('IDX_SETTLEMENT_MERCHANT_ID', ['merchantId'])
@Index('IDX_SETTLEMENT_STATUS', ['status'])
@Index('IDX_SETTLEMENT_MERCHANT_STATUS', ['merchantId', 'status'])
export class Settlement {
@PrimaryGeneratedColumn('uuid')
id: string;
Expand Down
3 changes: 3 additions & 0 deletions src/webhooks/entities/webhook.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@ import {
UpdateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { Exclude } from 'class-transformer';
import { Merchant } from '../../merchants/entities/merchant.entity';
import { encryptedColumnTransformer } from '../../security/encrypted-column.transformer';

@Entity('webhooks')
@Index('IDX_WEBHOOK_MERCHANT_ID', ['merchantId'])
@Index('IDX_WEBHOOK_MERCHANT_ACTIVE', ['merchantId', 'isActive'])
export class Webhook {
@PrimaryGeneratedColumn('uuid')
id: string;
Expand Down