From 05d3b3c159139026b47af2aefe9ce576c2cf57e3 Mon Sep 17 00:00:00 2001 From: james2177 Date: Mon, 31 Aug 2026 10:40:55 +0100 Subject: [PATCH 1/4] fix(tech-debt): add missing @Index decorators and backfill migration Add @Index decorators to Settlement.merchantId/status, Webhook.merchantId/isActive, and BlockchainWallet.lastSyncedAt to match the indexing strategy already used in group.entity.ts and notification-preference.entity.ts. Add a migration to backfill these indexes on existing tables. --- .../entities/blockchain-wallet.entity.ts | 2 + ...2400000000-BackfillMissingEntityIndexes.ts | 63 +++++++++++++++++++ src/settlements/entities/settlement.entity.ts | 4 ++ src/webhooks/entities/webhook.entity.ts | 3 + 4 files changed, 72 insertions(+) create mode 100644 src/database/migrations/1772400000000-BackfillMissingEntityIndexes.ts 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/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; From 904dc0327efade8698647417fe7242145a414162 Mon Sep 17 00:00:00 2001 From: james2177 Date: Mon, 31 Aug 2026 10:41:54 +0100 Subject: [PATCH 2/4] fix(aml): wire checkAndFlag into payment confirmation path AmlService.checkAndFlag() was never called from anywhere outside the aml module, so high-value/high-velocity AML flags were never created in production. Call it from PaymentsService.confirmPayment() once a payment transitions to CONFIRMED, guarded so an AML failure never blocks the payment confirmation itself. Add unit tests asserting the check runs and that a check failure is non-blocking. --- src/payments/payments.module.ts | 2 ++ src/payments/payments.service.spec.ts | 43 +++++++++++++++++++++++++++ src/payments/payments.service.ts | 9 ++++++ 3 files changed, 54 insertions(+) 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; } From 44dec7f2d0f055cd53cb3d84375028e6ca838f11 Mon Sep 17 00:00:00 2001 From: james2177 Date: Mon, 31 Aug 2026 10:49:25 +0100 Subject: [PATCH 3/4] fix(aml): use ADMIN_ALERT_EMAIL config key instead of undocumented ADMIN_EMAIL aml.service.ts read ADMIN_EMAIL, which appears nowhere in .env.example or README.md, while AdminAlertService and the docs use ADMIN_ALERT_EMAIL. In any environment configured per .env.example, AML alert emails silently never sent. Align on ADMIN_ALERT_EMAIL and log a warning when it's unset. --- src/aml/ADMIN_ALERT_EMAIL_FIX.md | 28 ++++++++++++++++++++++++++++ src/aml/aml.service.ts | 8 +++++++- 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 src/aml/ADMIN_ALERT_EMAIL_FIX.md 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})`, + ); } } From b0009695a5135b946723c940218e166b1b81d421 Mon Sep 17 00:00:00 2001 From: james2177 Date: Mon, 31 Aug 2026 10:50:14 +0100 Subject: [PATCH 4/4] fix(payment): pass ttlSeconds instead of ttl to CacheService.set in idempotency interceptor CacheService.set() reads options.ttlSeconds, but the idempotency interceptor passed options.ttl, which was silently ignored in favor of CacheService's hardcoded 86400s default. Fix the option key and add a unit test asserting the TTL actually reaches the cache store. --- src/payment/IDEMPOTENCY_TTL_FIX.md | 28 +++++++++++ src/payment/idempotency.interceptor.spec.ts | 51 +++++++++++++++++++++ src/payment/idempotency.interceptor.ts | 2 +- 3 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 src/payment/IDEMPOTENCY_TTL_FIX.md create mode 100644 src/payment/idempotency.interceptor.spec.ts 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 }, ); }), );