diff --git a/src/subdomains/supporting/bank/bank/__tests__/bank.controller.spec.ts b/src/subdomains/supporting/bank/bank/__tests__/bank.controller.spec.ts new file mode 100644 index 0000000000..59db5b0fef --- /dev/null +++ b/src/subdomains/supporting/bank/bank/__tests__/bank.controller.spec.ts @@ -0,0 +1,110 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { BadRequestException, ValidationPipe } from '@nestjs/common'; +import { GUARDS_METADATA, METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants'; +import { RequestMethod } from '@nestjs/common/enums'; +import { THROTTLER_LIMIT, THROTTLER_TTL } from '@nestjs/throttler/dist/throttler.constants'; +import { JwtPayload } from 'src/shared/auth/jwt-payload.interface'; +import { OptionalJwtAuthGuard } from 'src/shared/auth/optional.guard'; +import { RateLimitGuard } from 'src/shared/auth/rate-limit.guard'; +import { UserRole } from 'src/shared/auth/user-role.enum'; +import { olkyEUR } from '../__mocks__/bank.entity.mock'; +import { BankController } from '../bank.controller'; +import { BankService } from '../bank.service'; +import { CheckReceiveIbanDto } from '../dto/receive-iban.dto'; +import { ReceiveIbanStatus } from '../dto/receive-iban.enum'; + +// The receiveIban check runs behind an optional guard, so the controller must forward the account of a +// present JWT and undefined otherwise - that distinction is what makes the service answer LoginRequired. +describe('BankController.checkReceiveIban', () => { + let controller: BankController; + let service: DeepMocked; + + const dto: CheckReceiveIbanDto = { iban: olkyEUR.iban }; + + beforeEach(() => { + service = createMock(); + controller = new BankController(service); + }); + + it('forwards the account of the authenticated customer and wraps the status', async () => { + service.getReceiveIbanStatus.mockResolvedValue(ReceiveIbanStatus.DFX_IBAN); + + const result = await controller.checkReceiveIban({ account: 42, role: UserRole.USER } as JwtPayload, dto); + + expect(service.getReceiveIbanStatus).toHaveBeenCalledWith(dto.iban, 42); + expect(result).toEqual({ status: ReceiveIbanStatus.DFX_IBAN }); + }); + + it('forwards undefined without a JWT', async () => { + service.getReceiveIbanStatus.mockResolvedValue(ReceiveIbanStatus.LOGIN_REQUIRED); + + const result = await controller.checkReceiveIban(undefined, dto); + + expect(service.getReceiveIbanStatus).toHaveBeenCalledWith(dto.iban, undefined); + expect(result).toEqual({ status: ReceiveIbanStatus.LOGIN_REQUIRED }); + }); +}); + +// The two tests above pass the JWT in directly, so they cannot see the decorators that decide whether a JWT +// is ever attached. Removing @UseGuards entirely leaves them green while every logged-in customer would get +// LoginRequired (req.user never set), and swapping in a hard AuthGuard() would 401 every anonymous customer. +describe('BankController.checkReceiveIban routing & security metadata', () => { + const handler = BankController.prototype.checkReceiveIban; + + it('is mounted as PUT bank/receiveIban', () => { + expect(Reflect.getMetadata(PATH_METADATA, BankController)).toBe('bank'); + expect(Reflect.getMetadata(PATH_METADATA, handler)).toBe('receiveIban'); + expect(Reflect.getMetadata(METHOD_METADATA, handler)).toBe(RequestMethod.PUT); + }); + + it('guards the route with RateLimitGuard before OptionalJwtAuthGuard', () => { + const guards = Reflect.getMetadata(GUARDS_METADATA, handler) as unknown[]; + + // Order matters as documented on the route; the optional guard must be the auth guard, because a hard + // AuthGuard() would reject exactly the anonymous callers this endpoint exists to serve. + expect(guards).toEqual([RateLimitGuard, OptionalJwtAuthGuard]); + }); + + it('carries a route-level throttle, which is what gives the guard a limit at all', () => { + // RateLimitGuard resolves `routeOrClassLimit || this.options.limit`, and ThrottlerModule.forRoot() is + // registered without options - so without this decorator nothing would be throttled. + expect(Reflect.getMetadata(THROTTLER_LIMIT, handler)).toBe(60); + expect(Reflect.getMetadata(THROTTLER_TTL, handler)).toBe(60); + }); +}); + +// CheckReceiveIbanDto deliberately carries no @Transform. The Util transform helpers call string methods on +// the raw value - sanitizeString does value.trim() behind a bare truthiness check, trimAll does +// value?.replace(...) - and @Transform runs before @IsString, so a non-string body throws a TypeError that +// the exception filter turns into a 500 on a route reachable without a login. Adding a transform here would +// look like tidying up; these cases pin the boundary behaviour rather than the absence of a decorator. +describe('CheckReceiveIbanDto validation boundary', () => { + // Same configuration as the global pipe in main.ts. + const pipe = new ValidationPipe({ whitelist: true, transformOptions: { exposeUnsetFields: false } }); + const metadata = { type: 'body' as const, metatype: CheckReceiveIbanDto }; + + it.each([123, [], {}, null, undefined, ''])( + 'rejects %p with a BadRequestException, never a TypeError', + async (iban) => { + await expect(pipe.transform({ iban }, metadata)).rejects.toBeInstanceOf(BadRequestException); + }, + ); + + it('passes a plain string through unchanged, leaving normalization to the service', async () => { + await expect(pipe.transform({ iban: ' LI75-0881-1010-5923-K000E ' }, metadata)).resolves.toEqual({ + iban: ' LI75-0881-1010-5923-K000E ', + }); + }); +}); + +// These four strings are the wire contract: the client library carries its own copy of this enum, and the +// support form derives its wording from it. Comparing enum members would let a renamed value pass here and +// break at runtime in the browser instead, so the literals are asserted. +describe('ReceiveIbanStatus wire values', () => { + it('serializes to the strings the consumers expect', () => { + expect(ReceiveIbanStatus.DFX_IBAN).toBe('DfxIban'); + expect(ReceiveIbanStatus.NOT_MATCHED).toBe('NotMatched'); + expect(ReceiveIbanStatus.INVALID_IBAN).toBe('InvalidIban'); + expect(ReceiveIbanStatus.LOGIN_REQUIRED).toBe('LoginRequired'); + }); +}); diff --git a/src/subdomains/supporting/bank/bank/__tests__/bank.service.spec.ts b/src/subdomains/supporting/bank/bank/__tests__/bank.service.spec.ts index 64667c5b93..53d11e3571 100644 --- a/src/subdomains/supporting/bank/bank/__tests__/bank.service.spec.ts +++ b/src/subdomains/supporting/bank/bank/__tests__/bank.service.spec.ts @@ -12,8 +12,12 @@ import { createDefaultUserData } from 'src/subdomains/generic/user/models/user-d import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; import { BankAccountService } from 'src/subdomains/supporting/bank/bank-account/bank-account.service'; +import { createCustomVirtualIban } from 'src/subdomains/supporting/bank/virtual-iban/__mocks__/virtual-iban.entity.mock'; +import { VirtualIban, VirtualIbanStatus } from 'src/subdomains/supporting/bank/virtual-iban/virtual-iban.entity'; +import { VirtualIbanRepository } from 'src/subdomains/supporting/bank/virtual-iban/virtual-iban.repository'; import { FiatPaymentMethod } from 'src/subdomains/supporting/payment/dto/payment-method.enum'; import { + createCustomBank, createDefaultBanks, createDefaultDisabledBanks, yapealCHF, @@ -26,6 +30,7 @@ import { Bank } from '../bank.entity'; import { BankRepository } from '../bank.repository'; import { BankSelectorInput, BankService } from '../bank.service'; import { IbanBankName } from '../dto/bank.dto'; +import { ReceiveIbanStatus } from '../dto/receive-iban.enum'; function createBankSelectorInput( currency = 'EUR', @@ -69,6 +74,7 @@ describe('BankService', () => { { provide: FiatService, useValue: fiatService }, { provide: CountryService, useValue: countryService }, { provide: BankAccountService, useValue: bankAccountService }, + { provide: VirtualIbanRepository, useValue: createMock() }, TestUtil.provideConfig(), ], }).compile(); @@ -225,6 +231,7 @@ describe('Bank (name, currency) collision tie-break', () => { { provide: FiatService, useValue: createMock() }, { provide: CountryService, useValue: createMock() }, { provide: BankAccountService, useValue: createMock() }, + { provide: VirtualIbanRepository, useValue: createMock() }, TestUtil.provideConfig(), ], }).compile(); @@ -369,3 +376,235 @@ describe('Bank (name, currency) collision tie-break', () => { expect(BankService.isBankMatching(asset, 'YAPEAL-UNBOUND-NEWER-IBAN')).toBe(false); }); }); + +describe('BankService.getReceiveIbanStatus', () => { + const accountId = 42; + const otherAccountId = 43; + + // A retired collective account: same IBAN a customer may have transferred to years ago, but receive=false today. + const retiredCollectiveAccount = createCustomBank({ iban: 'CH5604835012345678009', receive: false, send: false }); + const personalIban = 'DE89370400440532013000'; + const expiredPersonalIban = 'AT483200000012345864'; + const foreignPersonalIban = 'CH4431999123000889012'; + + let service: BankService; + let bankRepo: BankRepository; + let virtualIbanRepo: VirtualIbanRepository; + + beforeEach(async () => { + bankRepo = createMock(); + virtualIbanRepo = createMock(); + + const module: TestingModule = await Test.createTestingModule({ + imports: [TestSharedModule], + providers: [ + BankService, + { provide: BankRepository, useValue: bankRepo }, + { provide: VirtualIbanRepository, useValue: virtualIbanRepo }, + TestUtil.provideConfig(), + ], + }).compile(); + + service = module.get(BankService); + }); + + // The only shape getReceiveIbanStatus passes to findCachedBy; keeps the mock typed without `any`. + type AccountScopedWhere = { userData: { id: number } }; + + function setup(banks: Bank[], virtualIbansByAccount: Map = new Map()): void { + jest.spyOn(bankRepo, 'findCached').mockResolvedValue(banks); + jest + .spyOn(virtualIbanRepo, 'findCachedBy') + .mockImplementation( + async (_key: string | number, where: AccountScopedWhere) => virtualIbansByAccount.get(where.userData.id) ?? [], + ); + } + + it('reports a collective account IBAN as a DFX IBAN, without asking for personal IBANs', async () => { + // A collective account hit short-circuits for a logged-in caller too - no account-scoped lookup happens. + setup(createDefaultBanks()); + + await expect(service.getReceiveIbanStatus(olkyEUR.iban, accountId)).resolves.toBe(ReceiveIbanStatus.DFX_IBAN); + expect(virtualIbanRepo.findCachedBy).not.toHaveBeenCalled(); + }); + + it('reports a collective account IBAN as a DFX IBAN without a login, before ever asking for personal IBANs', async () => { + // The bank check must run before the login check, otherwise a logged-out customer gets LoginRequired for + // an IBAN we can already confirm. + setup(createDefaultBanks()); + + await expect(service.getReceiveIbanStatus(olkyEUR.iban)).resolves.toBe(ReceiveIbanStatus.DFX_IBAN); + expect(virtualIbanRepo.findCachedBy).not.toHaveBeenCalled(); + }); + + it('reports a collective account IBAN stored in paper format as a DFX IBAN', async () => { + // The stored side is normalized too, so a row that carries a grouped value still matches. + setup([createCustomBank({ iban: 'LU11 6060 0020 0000 5040' })]); + + await expect(service.getReceiveIbanStatus('LU116060002000005040', accountId)).resolves.toBe( + ReceiveIbanStatus.DFX_IBAN, + ); + }); + + it('reports a personal IBAN stored in paper format as a DFX IBAN', async () => { + // The comparison normalizes stored virtual_iban values as well, so their format need not be guaranteed. + setup( + createDefaultBanks(), + new Map([[accountId, [createCustomVirtualIban({ iban: 'de89 3704 0044 0532 0130 00' })]]]), + ); + + await expect(service.getReceiveIbanStatus(personalIban, accountId)).resolves.toBe(ReceiveIbanStatus.DFX_IBAN); + }); + + it('reports a collective account IBAN with receive=false as a DFX IBAN', async () => { + // A retired or closed account still received DFX money, and a missing transfer can predate it being stood down. + setup([retiredCollectiveAccount]); + + await expect(service.getReceiveIbanStatus(retiredCollectiveAccount.iban, accountId)).resolves.toBe( + ReceiveIbanStatus.DFX_IBAN, + ); + }); + + it('reports a personal IBAN of the requesting account as a DFX IBAN', async () => { + setup(createDefaultBanks(), new Map([[accountId, [createCustomVirtualIban({ iban: personalIban })]]])); + + await expect(service.getReceiveIbanStatus(personalIban, accountId)).resolves.toBe(ReceiveIbanStatus.DFX_IBAN); + expect(virtualIbanRepo.findCachedBy).toHaveBeenCalledWith(`user-${accountId}`, { userData: { id: accountId } }); + }); + + it.each([VirtualIbanStatus.EXPIRED, VirtualIbanStatus.DEACTIVATED, VirtualIbanStatus.RESERVED])( + 'reports a personal IBAN with status %s as a DFX IBAN', + async (status) => { + // An expired personal IBAN was still a real receiving IBAN, so no lifecycle state may be filtered out. + setup( + createDefaultBanks(), + new Map([[accountId, [createCustomVirtualIban({ iban: expiredPersonalIban, active: false, status })]]]), + ); + + await expect(service.getReceiveIbanStatus(expiredPersonalIban, accountId)).resolves.toBe( + ReceiveIbanStatus.DFX_IBAN, + ); + }, + ); + + it('reports a formally invalid IBAN as invalid, without querying any IBAN', async () => { + setup(createDefaultBanks()); + + await expect(service.getReceiveIbanStatus('DE123456', accountId)).resolves.toBe(ReceiveIbanStatus.INVALID_IBAN); + expect(bankRepo.findCached).not.toHaveBeenCalled(); + expect(virtualIbanRepo.findCachedBy).not.toHaveBeenCalled(); + }); + + it('reports a correctly shaped IBAN with a wrong checksum as invalid, not as unmatched', async () => { + // A changed digit keeps the country and length intact, so only the checksum catches it. Answering + // NotMatched here would send a customer looking for a transfer that never left with a typo in the IBAN. + setup(createDefaultBanks()); + + await expect(service.getReceiveIbanStatus('DE89370400440532013001', accountId)).resolves.toBe( + ReceiveIbanStatus.INVALID_IBAN, + ); + }); + + it.each([undefined, null, '', ' '])( + 'reports an unusable input (%p) as invalid instead of throwing', + async (input) => { + // Defensive only: @IsString/@IsNotEmpty reject undefined, null and '' with a 400 before the service is + // reached, so of these only ' ' can actually arrive. The typeof guard in normalizeIban short-circuits + // the non-string cases, and an all-separator string normalizes to '' and is returned as null. + // The cast stays because getReceiveIbanStatus itself declares `iban: string`; it is what lets the test + // reach the guard from outside the type system, which is exactly the situation the guard exists for. + setup(createDefaultBanks()); + + await expect(service.getReceiveIbanStatus(input as string, accountId)).resolves.toBe( + ReceiveIbanStatus.INVALID_IBAN, + ); + }, + ); + + it('reports a valid IBAN that matched neither list as not matched when the customer is logged in', async () => { + setup(createDefaultBanks()); + + await expect(service.getReceiveIbanStatus(foreignPersonalIban, accountId)).resolves.toBe( + ReceiveIbanStatus.NOT_MATCHED, + ); + }); + + it('requires a login for a valid unmatched IBAN, because personal IBANs stay unchecked without one', async () => { + setup(createDefaultBanks()); + + await expect(service.getReceiveIbanStatus(foreignPersonalIban)).resolves.toBe(ReceiveIbanStatus.LOGIN_REQUIRED); + expect(virtualIbanRepo.findCachedBy).not.toHaveBeenCalled(); + }); + + it('recognizes the same IBAN written with grouping spaces and in lower case', async () => { + setup(createDefaultBanks(), new Map([[accountId, [createCustomVirtualIban({ iban: personalIban })]]])); + + await expect(service.getReceiveIbanStatus('lu11 6060 0020 0000 5040', accountId)).resolves.toBe( + ReceiveIbanStatus.DFX_IBAN, + ); + await expect(service.getReceiveIbanStatus('de89 3704 0044 0532 0130 00', accountId)).resolves.toBe( + ReceiveIbanStatus.DFX_IBAN, + ); + }); + + // The invisible separators are written as escape sequences on purpose: it makes them visible in review and + // lowers the risk of an edit or a copy-paste quietly normalizing them into ordinary spaces, which would + // void exactly those cases. + it.each([ + ['an ASCII space', ' '], + ['a hyphen', '-'], + ['a dot', '.'], + ['a slash', '/'], + ['a non-breaking space', '\u00a0'], + ['a narrow non-breaking space', '\u202f'], + ['a zero-width space', '\u200b'], + ['a soft hyphen', '\u00ad'], + ['a tab', '\t'], + ['a line break', '\n'], + ])('recognizes an IBAN grouped with %s', async (_name, separator) => { + setup([frickEUR], new Map([[accountId, [createCustomVirtualIban({ iban: personalIban })]]])); + + const group = (iban: string): string => (iban.match(/.{1,4}/g) ?? []).join(separator); + + await expect(service.getReceiveIbanStatus(group(frickEUR.iban), accountId)).resolves.toBe( + ReceiveIbanStatus.DFX_IBAN, + ); + await expect(service.getReceiveIbanStatus(group(personalIban), accountId)).resolves.toBe( + ReceiveIbanStatus.DFX_IBAN, + ); + }); + + it('recognizes an IBAN pasted with surrounding quotes', async () => { + setup([frickEUR]); + + await expect(service.getReceiveIbanStatus('"LI75 0881 1010 5923 K000E"', accountId)).resolves.toBe( + ReceiveIbanStatus.DFX_IBAN, + ); + }); + + it('does not extract an IBAN out of surrounding ASCII words', async () => { + // Separators are stripped, an ASCII label is not: it survives normalization and makes the value invalid, + // which is what we want - a prefix is indistinguishable from extra characters that corrupt the IBAN. + // The guarantee is ASCII-only by construction: a label in a non-Latin script is stripped like a + // separator and the IBAN is accepted. Harmless, but the reason this test says "ASCII". + setup([frickEUR]); + + await expect(service.getReceiveIbanStatus('IBAN: LI75 0881 1010 5923 K000E', accountId)).resolves.toBe( + ReceiveIbanStatus.INVALID_IBAN, + ); + }); + + it('never reports a personal IBAN of another account as a DFX IBAN', async () => { + setup( + createDefaultBanks(), + new Map([ + [accountId, [createCustomVirtualIban({ iban: personalIban })]], + [otherAccountId, [createCustomVirtualIban({ iban: foreignPersonalIban })]], + ]), + ); + + await expect(service.getReceiveIbanStatus(foreignPersonalIban, accountId)).resolves.toBe( + ReceiveIbanStatus.NOT_MATCHED, + ); + }); +}); diff --git a/src/subdomains/supporting/bank/bank/bank.controller.ts b/src/subdomains/supporting/bank/bank/bank.controller.ts index 2e974f332c..ab4b35682a 100644 --- a/src/subdomains/supporting/bank/bank/bank.controller.ts +++ b/src/subdomains/supporting/bank/bank/bank.controller.ts @@ -1,8 +1,14 @@ -import { Controller, Get } from '@nestjs/common'; -import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import { Body, Controller, Get, Put, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; +import { GetJwt } from 'src/shared/auth/get-jwt.decorator'; +import { JwtPayload } from 'src/shared/auth/jwt-payload.interface'; +import { OptionalJwtAuthGuard } from 'src/shared/auth/optional.guard'; +import { RateLimitGuard } from 'src/shared/auth/rate-limit.guard'; import { BankService } from './bank.service'; import { BankDto } from './dto/bank.dto'; import { BankMapper } from './dto/bank.mapper'; +import { CheckReceiveIbanDto, ReceiveIbanDto } from './dto/receive-iban.dto'; @ApiTags('Bank') @Controller('bank') @@ -16,4 +22,21 @@ export class BankController { return banks.map(BankMapper.toDto); } + + // PUT because the IBAN to check belongs in the body, never in the URL - this is a read, it changes nothing. + @Put('receiveIban') + @ApiBearerAuth() + // RateLimitGuard first; the route-level @Throttle below is what sets the limit. Deliberately more generous + // than the 10/60 on the one-shot endpoints (kyc 2fa/verify, auth mail login): RateLimitGuard buckets IPv4 + // callers by /24, so everyone behind one company NAT shares this counter, and the intended consumer is an + // input field meant to be re-checked while a customer corrects a typo. + @UseGuards(RateLimitGuard, OptionalJwtAuthGuard) + @Throttle(60, 60) + @ApiOkResponse({ type: ReceiveIbanDto }) + async checkReceiveIban( + @GetJwt() jwt: JwtPayload | undefined, + @Body() dto: CheckReceiveIbanDto, + ): Promise { + return { status: await this.bankService.getReceiveIbanStatus(dto.iban, jwt?.account) }; + } } diff --git a/src/subdomains/supporting/bank/bank/bank.service.ts b/src/subdomains/supporting/bank/bank/bank.service.ts index ae5215727d..f93cd8379d 100644 --- a/src/subdomains/supporting/bank/bank/bank.service.ts +++ b/src/subdomains/supporting/bank/bank/bank.service.ts @@ -1,13 +1,16 @@ import { Injectable, OnModuleInit } from '@nestjs/common'; +import * as IbanTools from 'ibantools'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { Asset } from 'src/shared/models/asset/asset.entity'; import { DfxLogger } from 'src/shared/services/dfx-logger'; import { Util } from 'src/shared/utils/util'; import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { VirtualIbanRepository } from 'src/subdomains/supporting/bank/virtual-iban/virtual-iban.repository'; import { FiatPaymentMethod } from '../../payment/dto/payment-method.enum'; import { Bank } from './bank.entity'; import { BankRepository } from './bank.repository'; import { IbanBankName } from './dto/bank.dto'; +import { ReceiveIbanStatus } from './dto/receive-iban.enum'; export interface BankSelectorInput { amount?: number; @@ -21,7 +24,12 @@ export class BankService implements OnModuleInit { private readonly logger = new DfxLogger(BankService); private static ibanCache: Map = new Map(); // key: "bankName-currency", value: iban - constructor(private bankRepo: BankRepository) {} + // The VirtualIbanRepository is injected instead of the VirtualIbanService: that service depends on this + // one, and both live in BankModule, so the service-level dependency would close a provider cycle. + constructor( + private readonly bankRepo: BankRepository, + private readonly virtualIbanRepo: VirtualIbanRepository, + ) {} onModuleInit() { void this.loadIbanCache(); @@ -118,8 +126,59 @@ export class BankService implements OnModuleInit { return expectedIban === accountIban; } + // --- RECEIVE IBAN CHECK --- // + + // Tells the client whether an IBAN typed in by a customer is one that belongs to DFX - not whether it still + // accepts money. Pure input aid for the support form: it enforces nothing, it only lets the frontend phrase + // a helpful hint. + async getReceiveIbanStatus(iban: string, userDataId?: number): Promise { + // normalizeIban strips separator characters and yields null for input that cannot hold an IBAN at all; + // it does not rescue every conceivable input (a `IBAN:` prefix stays invalid, correctly). Both sides of + // every comparison run through it, so a stored value in paper format matches too. + const normalizedIban = BankService.normalizeIban(iban); + if (!normalizedIban || !IbanTools.validateIBAN(normalizedIban).valid) return ReceiveIbanStatus.INVALID_IBAN; + + // Deliberately not filtered by `receive`: a hit on a retired or closed account is still money that went + // to DFX, and a missing transfer can predate the account being stood down. A receive=true filter would + // tell a real customer that their IBAN does not belong to DFX. + const banks = await this.getAllBanks(); + if (banks.some((b) => BankService.normalizeIban(b.iban) === normalizedIban)) return ReceiveIbanStatus.DFX_IBAN; + + // Personal IBANs are only ever checked for the requesting account. The guard is optional, so a global + // lookup would turn this endpoint into an unauthenticated oracle over customer-bound IBANs. Without a + // login the personal IBANs stay unchecked, hence the answer must never be NOT_MATCHED here. + if (!userDataId) return ReceiveIbanStatus.LOGIN_REQUIRED; + + // No lifecycle filter either (active=false, status Expired/Deactivated/Reserved all count): an expired + // personal IBAN was still a real receiving IBAN. Same cache key and filter as + // VirtualIbanService.getVirtualIbansForAccount, so both paths share the cached list. + const virtualIbans = await this.virtualIbanRepo.findCachedBy(`user-${userDataId}`, { + userData: { id: userDataId }, + }); + if (virtualIbans.some((v) => BankService.normalizeIban(v.iban) === normalizedIban)) + return ReceiveIbanStatus.DFX_IBAN; + + return ReceiveIbanStatus.NOT_MATCHED; + } + // --- HELPER METHODS --- // + // An IBAN is ASCII alphanumeric only, so everything else is separator noise: grouping spaces of any kind, + // hyphens, dots, slashes, quotes, and the invisible formatting characters that come along when a value + // is pasted out of a statement PDF or an HTML mail. Removing everything that is not ASCII alphanumeric + // covers every separator by construction, where chasing a deny-list did not - ibantools' own + // electronicFormatIBAN only removes ASCII spaces and hyphens, and \s misses the zero-width family. + // Note this also drops non-ASCII letters and digits, so a label in a non-Latin script is silently + // stripped rather than making the value invalid. Harmless (it can never produce a *different* valid + // IBAN), but it means only ASCII surroundings are reliably rejected. + // The parameter is widened past the callers' types on purpose: this sits on the trust boundary between a + // request body and the comparison, so it answers for anything the type system cannot actually guarantee. + private static normalizeIban(iban: string | null | undefined): string | null { + if (typeof iban !== 'string') return null; + + return iban.replace(/[^A-Za-z0-9]/g, '').toUpperCase() || null; + } + // Picks the bank row that owns attribution for a single (name, currency) key. `banks` must already // be sorted by id descending (newest first). Prefer a row linked to an asset: that binding is the // basis of every per-asset match (isBankMatching) and of the IBAN already present on booked diff --git a/src/subdomains/supporting/bank/bank/dto/receive-iban.dto.ts b/src/subdomains/supporting/bank/bank/dto/receive-iban.dto.ts new file mode 100644 index 0000000000..2d557f5941 --- /dev/null +++ b/src/subdomains/supporting/bank/bank/dto/receive-iban.dto.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; +import { ReceiveIbanStatus } from './receive-iban.enum'; + +// No @Transform(Util.sanitize) here: Util.sanitizeString calls value.trim() behind a mere truthiness check, +// and @Transform runs before @IsString - so a non-string body would throw a TypeError that the exception +// filter reports as a 500 instead of a 400, on an endpoint reachable without a JWT. The same defect class +// (an unguarded string method on an untyped transform value) sits in Util.trim and Util.trimAll too. +// Validation alone rejects a non-string cleanly; the service normalizes the string afterwards. +export class CheckReceiveIbanDto { + @ApiProperty() + @IsNotEmpty() + @IsString() + iban: string; +} + +export class ReceiveIbanDto { + @ApiProperty({ enum: ReceiveIbanStatus }) + status: ReceiveIbanStatus; +} diff --git a/src/subdomains/supporting/bank/bank/dto/receive-iban.enum.ts b/src/subdomains/supporting/bank/bank/dto/receive-iban.enum.ts new file mode 100644 index 0000000000..c4ceb81a22 --- /dev/null +++ b/src/subdomains/supporting/bank/bank/dto/receive-iban.enum.ts @@ -0,0 +1,21 @@ +export enum ReceiveIbanStatus { + // An IBAN that belongs to DFX: either a collective account from the bank table, or a personal deposit IBAN + // of the requesting account. It does not say the account still accepts money - getReceiveIbanStatus ignores + // the bank `receive` flag and every virtual_iban lifecycle state, so a long-closed account matches just as + // well. Phrase the hint as "belongs to us", never as "pay in here". + DFX_IBAN = 'DfxIban', + + // The IBAN could not be attributed for this caller. This does NOT claim that the IBAN does not belong to + // DFX: personal IBANs of other accounts are deliberately never checked, and mergeUserData does not move + // virtual_iban rows to the master, so a customer's own older personal IBAN can land here as well. + NOT_MATCHED = 'NotMatched', + + // The input is not a structurally valid IBAN (country, length or checksum), so there is nothing to look up. + INVALID_IBAN = 'InvalidIban', + + // No collective account matched, and personal IBANs are only ever checked for the authenticated account, so + // without a login the check stays incomplete. Never answered as NOT_MATCHED, which would overstate it. + // Tokens that carry no `account` claim get this too even though they are authenticated - company tokens + // (generateCompanyToken) are wallet-scoped, not account-scoped. Not a case the support form produces. + LOGIN_REQUIRED = 'LoginRequired', +}