diff --git a/src/aml/ADMIN_ALERT_EMAIL_FIX.md b/src/aml/ADMIN_ALERT_EMAIL_FIX.md new file mode 100644 index 00000000..8e9be575 --- /dev/null +++ b/src/aml/ADMIN_ALERT_EMAIL_FIX.md @@ -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. diff --git a/src/aml/aml.service.ts b/src/aml/aml.service.ts index 64f5a67f..da265f0c 100644 --- a/src/aml/aml.service.ts +++ b/src/aml/aml.service.ts @@ -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('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('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})`, + ); } } diff --git a/src/blockchain-wallet/entities/blockchain-wallet.entity.ts b/src/blockchain-wallet/entities/blockchain-wallet.entity.ts index 4b5671f3..ad63d2dd 100644 --- a/src/blockchain-wallet/entities/blockchain-wallet.entity.ts +++ b/src/blockchain-wallet/entities/blockchain-wallet.entity.ts @@ -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; diff --git a/src/database/migrations/1772400000000-BackfillMissingEntityIndexes.ts b/src/database/migrations/1772400000000-BackfillMissingEntityIndexes.ts new file mode 100644 index 00000000..85fe13df --- /dev/null +++ b/src/database/migrations/1772400000000-BackfillMissingEntityIndexes.ts @@ -0,0 +1,63 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class BackfillMissingEntityIndexes1772400000000 + implements MigrationInterface +{ + name = 'BackfillMissingEntityIndexes1772400000000'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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"`); + } +} diff --git a/src/payment/IDEMPOTENCY_TTL_FIX.md b/src/payment/IDEMPOTENCY_TTL_FIX.md new file mode 100644 index 00000000..d1ba014d --- /dev/null +++ b/src/payment/IDEMPOTENCY_TTL_FIX.md @@ -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. diff --git a/src/payment/idempotency.interceptor.spec.ts b/src/payment/idempotency.interceptor.spec.ts new file mode 100644 index 00000000..d2eeb3b3 --- /dev/null +++ b/src/payment/idempotency.interceptor.spec.ts @@ -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; + + 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(); + }); +}); diff --git a/src/payment/idempotency.interceptor.ts b/src/payment/idempotency.interceptor.ts index c1ec356b..bfe345ab 100644 --- a/src/payment/idempotency.interceptor.ts +++ b/src/payment/idempotency.interceptor.ts @@ -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 }, ); }), ); diff --git a/src/payments/payments.module.ts b/src/payments/payments.module.ts index 782bbf0f..c16be078 100644 --- a/src/payments/payments.module.ts +++ b/src/payments/payments.module.ts @@ -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: [ @@ -21,6 +22,7 @@ import { SorobanService } from '../blockchain-wallet/soroban.service'; NotificationsModule, MerchantsModule, ConfigModule, + forwardRef(() => AmlModule), ], controllers: [PaymentsController, PublicPaymentController], providers: [PaymentsService, IdempotencyInterceptor, SorobanService], diff --git a/src/payments/payments.service.spec.ts b/src/payments/payments.service.spec.ts index ef3c4004..b0d2d1c2 100644 --- a/src/payments/payments.service.spec.ts +++ b/src/payments/payments.service.spec.ts @@ -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; @@ -19,6 +20,7 @@ describe('PaymentsService', () => { let notifications: NotificationsService; let merchants: MerchantsService; let analytics: AnalyticsService; + let aml: AmlService; const mockMerchant = { id: 'merchant-123', @@ -79,6 +81,12 @@ describe('PaymentsService', () => { clearCacheForMerchant: jest.fn(), }, }, + { + provide: AmlService, + useValue: { + checkAndFlag: jest.fn(), + }, + }, ], }).compile(); @@ -90,6 +98,7 @@ describe('PaymentsService', () => { notifications = module.get(NotificationsService); merchants = module.get(MerchantsService); analytics = module.get(AnalyticsService); + aml = module.get(AmlService); }); describe('refund', () => { @@ -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); + }); }); }); diff --git a/src/payments/payments.service.ts b/src/payments/payments.service.ts index d479fbee..c55610fd 100644 --- a/src/payments/payments.service.ts +++ b/src/payments/payments.service.ts @@ -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 { @@ -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 { @@ -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; } diff --git a/src/settlements/entities/settlement.entity.ts b/src/settlements/entities/settlement.entity.ts index 373f8f3e..8c184df6 100644 --- a/src/settlements/entities/settlement.entity.ts +++ b/src/settlements/entities/settlement.entity.ts @@ -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'; @@ -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; diff --git a/src/webhooks/entities/webhook.entity.ts b/src/webhooks/entities/webhook.entity.ts index 837a0984..e7d6d462 100644 --- a/src/webhooks/entities/webhook.entity.ts +++ b/src/webhooks/entities/webhook.entity.ts @@ -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;