diff --git a/README.md b/README.md index 8f0f65f..5e5ec52 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,94 @@ Nest is an MIT-licensed open source project. It can grow thanks to the sponsors - Website - [https://nestjs.com](https://nestjs.com/) - Twitter - [@nestframework](https://twitter.com/nestframework) +## Payment Request Validation + +The backend supports validation and normalization of payment requests (including direct fields, serialized JSON strings, and URI schemes). + +### Endpoint +`POST /v1/payment-requests/validate` + +### Curl Examples + +#### 1. Direct Structured Request (Valid) +```bash +curl -X POST http://localhost:3000/v1/payment-requests/validate \ + -H "Content-Type: application/json" \ + -d '{ + "asset": "USDC", + "recipient": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e", + "amount": 250.0 + }' +``` + +Response: +```json +{ + "valid": true, + "normalizedPayload": { + "asset": "USDC", + "recipient": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e", + "amount": 250 + } +} +``` + +#### 2. URI String Payment Request (Valid) +```bash +curl -X POST http://localhost:3000/v1/payment-requests/validate \ + -H "Content-Type: application/json" \ + -d '{ + "payload": "ethereum:0x742d35Cc6634C0532925a3b844Bc454e4438f44e?amount=150.0&asset=USDC" + }' +``` + +Response: +```json +{ + "valid": true, + "normalizedPayload": { + "asset": "USDC", + "recipient": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e", + "amount": 150 + } +} +``` + +#### 3. Request with Validation Errors (Invalid) +```bash +curl -X POST http://localhost:3000/v1/payment-requests/validate \ + -H "Content-Type: application/json" \ + -d '{ + "asset": "DOGE", + "recipient": "0xInvalidEthAddress", + "amount": -100 + }' +``` + +Response: +```json +{ + "valid": false, + "errors": [ + { + "code": "INVALID_ASSET", + "message": "Asset 'DOGE' is not supported. Supported assets are: USDC, USDT, ETH, BTC, SOL, USD, EUR.", + "field": "asset" + }, + { + "code": "INVALID_RECIPIENT", + "message": "Recipient address starts with 0x but is not a valid Ethereum address.", + "field": "recipient" + }, + { + "code": "INVALID_AMOUNT", + "message": "Amount must be greater than zero.", + "field": "amount" + } + ] +} +``` + ## License Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE). diff --git a/package-lock.json b/package-lock.json index af6f39b..f7b1149 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@nestjs/config": "^4.0.4", "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.0.1", - "@prisma/client": "^7.8.0", + "@prisma/client": "^6.3.0", "@supabase/supabase-js": "^2.104.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" @@ -34,6 +34,7 @@ "globals": "^16.0.0", "jest": "^30.0.0", "prettier": "^3.4.2", + "prisma": "^6.3.0", "source-map-support": "^0.5.21", "supertest": "^7.0.0", "ts-jest": "^29.2.5", @@ -2571,19 +2572,17 @@ } }, "node_modules/@prisma/client": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.8.0.tgz", - "integrity": "sha512-HFp3Dawv/3sU3JtlPha90IB+48lS7zHiH4LKZPjmcE8YH5P9DOXGPvo8dqOtO7MqLDd1p2hOWMcFlRT1DMblHw==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.3.0.tgz", + "integrity": "sha512-BY3Fi28PUSk447Bpv22LhZp4HgNPo7NsEN+EteM1CLDnLjig5863jpW+3c3HHLFmml+nB/eJv1CjSriFZ8z7Cg==", + "hasInstallScript": true, "license": "Apache-2.0", - "dependencies": { - "@prisma/client-runtime-utils": "7.8.0" - }, "engines": { - "node": "^20.19 || ^22.12 || >=24.0" + "node": ">=18.18" }, "peerDependencies": { "prisma": "*", - "typescript": ">=5.4.0" + "typescript": ">=5.1.0" }, "peerDependenciesMeta": { "prisma": { @@ -2594,12 +2593,56 @@ } } }, - "node_modules/@prisma/client-runtime-utils": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.8.0.tgz", - "integrity": "sha512-5NQZztQ0oY/ADFkmd9gPuweH5A1/CCY8YQPorLLO0Mu6a87mY5gsnDkzmFmIHs9NFaLnZojzgddFVN4RpKYrdw==", + "node_modules/@prisma/debug": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.3.0.tgz", + "integrity": "sha512-m1lQv//0Rc5RG8TBpNUuLCxC35Ghi5XfpPmL83Gh04/GICHD2J5H2ndMlaljrUNaQDF9dOxIuFAYP1rE9wkXkg==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.3.0.tgz", + "integrity": "sha512-RXqYhlZb9sx/xkUfYIZuEPn7sT0WgTxNOuEYQ7AGw3IMpP9QGVEDVsluc/GcNkM8NTJszeqk8AplJzI9lm7Jxw==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.3.0", + "@prisma/engines-version": "6.3.0-17.acc0b9dd43eb689cbd20c9470515d719db10d0b0", + "@prisma/fetch-engine": "6.3.0", + "@prisma/get-platform": "6.3.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "6.3.0-17.acc0b9dd43eb689cbd20c9470515d719db10d0b0", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.3.0-17.acc0b9dd43eb689cbd20c9470515d719db10d0b0.tgz", + "integrity": "sha512-R/ZcMuaWZT2UBmgX3Ko6PAV3f8//ZzsjRIG1eKqp3f2rqEqVtCv+mtzuH2rBPUC9ujJ5kCb9wwpxeyCkLcHVyA==", + "devOptional": true, "license": "Apache-2.0" }, + "node_modules/@prisma/fetch-engine": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.3.0.tgz", + "integrity": "sha512-GBy0iT4f1mH31ePzfcpVSUa7JLRTeq4914FG2vR3LqDwRweSm4ja1o5flGDz+eVIa/BNYfkBvRRxv4D6ve6Eew==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.3.0", + "@prisma/engines-version": "6.3.0-17.acc0b9dd43eb689cbd20c9470515d719db10d0b0", + "@prisma/get-platform": "6.3.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.3.0.tgz", + "integrity": "sha512-V8zZ1d0xfyi6FjpNP4AcYuwSpGcdmu35OXWnTPm8IW594PYALzKXHwIa9+o0f+Lo9AecFWrwrwaoYe56UNfTtQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.3.0" + } + }, "node_modules/@sinclair/typebox": { "version": "0.34.49", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", @@ -5848,7 +5891,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -8349,6 +8391,34 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/prisma": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.3.0.tgz", + "integrity": "sha512-y+Zh3Qg+xGCWyyrNUUNaFW/OltaV/yXYuTa0WRgYkz5LGyifmAsgpv94I47+qGRocZrMGcbF2A/78/oO2zgifA==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/engines": "6.3.0" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=18.18" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", diff --git a/package.json b/package.json index 15e50bf..3c4db22 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "@nestjs/config": "^4.0.4", "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.0.1", - "@prisma/client": "^7.8.0", + "@prisma/client": "^6.3.0", "@supabase/supabase-js": "^2.104.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" @@ -45,6 +45,7 @@ "globals": "^16.0.0", "jest": "^30.0.0", "prettier": "^3.4.2", + "prisma": "^6.3.0", "source-map-support": "^0.5.21", "supertest": "^7.0.0", "ts-jest": "^29.2.5", diff --git a/src/app.module.ts b/src/app.module.ts index f91547d..2ff9b74 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -7,6 +7,7 @@ import { SupabaseModule } from './supabase/supabase.module'; import { UsersModule } from './users/users.module'; import { PaymentsModule } from './payments/payments.module'; import { TransactionsModule } from './transactions/transactions.module'; +import { PaymentRequestsModule } from './payment-requests/payment-requests.module'; @Module({ imports: [ @@ -18,6 +19,7 @@ import { TransactionsModule } from './transactions/transactions.module'; UsersModule, PaymentsModule, TransactionsModule, + PaymentRequestsModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/payment-requests/dto/validate-payment-request.dto.ts b/src/payment-requests/dto/validate-payment-request.dto.ts new file mode 100644 index 0000000..0444050 --- /dev/null +++ b/src/payment-requests/dto/validate-payment-request.dto.ts @@ -0,0 +1,7 @@ +export class ValidatePaymentRequestDto { + asset?: string; + recipient?: string; + amount?: number | string; + expiresAt?: string | number; + payload?: any; +} diff --git a/src/payment-requests/payment-requests.controller.spec.ts b/src/payment-requests/payment-requests.controller.spec.ts new file mode 100644 index 0000000..3714f42 --- /dev/null +++ b/src/payment-requests/payment-requests.controller.spec.ts @@ -0,0 +1,41 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PaymentRequestsController } from './payment-requests.controller'; +import { PaymentRequestsService } from './payment-requests.service'; + +describe('PaymentRequestsController', () => { + let controller: PaymentRequestsController; + let service: PaymentRequestsService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [PaymentRequestsController], + providers: [PaymentRequestsService], + }).compile(); + + controller = module.get( + PaymentRequestsController, + ); + service = module.get(PaymentRequestsService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('validate', () => { + it('should call service.validate and return result', () => { + const dto = { + asset: 'USDC', + recipient: '0x742d35Cc6634C0532925a3b844Bc454e4438f44e', + amount: 100, + }; + + const spy = jest.spyOn(service, 'validate'); + const result = controller.validate(dto); + + expect(spy).toHaveBeenCalledWith(dto); + expect(result.valid).toBe(true); + expect(result.normalizedPayload?.amount).toBe(100); + }); + }); +}); diff --git a/src/payment-requests/payment-requests.controller.ts b/src/payment-requests/payment-requests.controller.ts new file mode 100644 index 0000000..7e469f1 --- /dev/null +++ b/src/payment-requests/payment-requests.controller.ts @@ -0,0 +1,19 @@ +import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common'; +import { + PaymentRequestsService, + ValidationResult, +} from './payment-requests.service'; +import { ValidatePaymentRequestDto } from './dto/validate-payment-request.dto'; + +@Controller('v1/payment-requests') +export class PaymentRequestsController { + constructor( + private readonly paymentRequestsService: PaymentRequestsService, + ) {} + + @Post('validate') + @HttpCode(HttpStatus.OK) + validate(@Body() dto: ValidatePaymentRequestDto): ValidationResult { + return this.paymentRequestsService.validate(dto); + } +} diff --git a/src/payment-requests/payment-requests.module.ts b/src/payment-requests/payment-requests.module.ts new file mode 100644 index 0000000..df66389 --- /dev/null +++ b/src/payment-requests/payment-requests.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { PaymentRequestsService } from './payment-requests.service'; +import { PaymentRequestsController } from './payment-requests.controller'; + +@Module({ + providers: [PaymentRequestsService], + controllers: [PaymentRequestsController], + exports: [PaymentRequestsService], +}) +export class PaymentRequestsModule {} diff --git a/src/payment-requests/payment-requests.service.spec.ts b/src/payment-requests/payment-requests.service.spec.ts new file mode 100644 index 0000000..d5eb1ba --- /dev/null +++ b/src/payment-requests/payment-requests.service.spec.ts @@ -0,0 +1,161 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PaymentRequestsService } from './payment-requests.service'; + +describe('PaymentRequestsService', () => { + let service: PaymentRequestsService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [PaymentRequestsService], + }).compile(); + + service = module.get(PaymentRequestsService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('validate', () => { + it('should validate a correct direct payload', () => { + const result = service.validate({ + asset: 'USDC', + recipient: '0x742d35Cc6634C0532925a3b844Bc454e4438f44e', + amount: 100.5, + }); + + expect(result.valid).toBe(true); + expect(result.errors).toBeUndefined(); + expect(result.normalizedPayload).toBeDefined(); + expect(result.normalizedPayload?.asset).toBe('USDC'); + expect(result.normalizedPayload?.recipient).toBe( + '0x742d35Cc6634C0532925a3b844Bc454e4438f44e', + ); + expect(result.normalizedPayload?.amount).toBe(100.5); + }); + + it('should normalize asset to uppercase', () => { + const result = service.validate({ + asset: 'usdt', + recipient: '0x742d35Cc6634C0532925a3b844Bc454e4438f44e', + amount: '50', + }); + + expect(result.valid).toBe(true); + expect(result.normalizedPayload?.asset).toBe('USDT'); + expect(result.normalizedPayload?.amount).toBe(50); + }); + + it('should parse valid serialized JSON in payload', () => { + const result = service.validate({ + payload: JSON.stringify({ + asset: 'ETH', + recipient: '0x742d35Cc6634C0532925a3b844Bc454e4438f44e', + amount: 1.5, + }), + }); + + expect(result.valid).toBe(true); + expect(result.normalizedPayload?.asset).toBe('ETH'); + expect(result.normalizedPayload?.amount).toBe(1.5); + }); + + it('should parse valid URI in payload', () => { + const result = service.validate({ + payload: + 'ethereum:0x742d35Cc6634C0532925a3b844Bc454e4438f44e?amount=10.5&asset=USDC', + }); + + expect(result.valid).toBe(true); + expect(result.normalizedPayload?.recipient).toBe( + '0x742d35Cc6634C0532925a3b844Bc454e4438f44e', + ); + expect(result.normalizedPayload?.asset).toBe('USDC'); + expect(result.normalizedPayload?.amount).toBe(10.5); + }); + + it('should reject invalid or unsupported assets', () => { + const result = service.validate({ + asset: 'DOGE', + recipient: '0x742d35Cc6634C0532925a3b844Bc454e4438f44e', + amount: 10, + }); + + expect(result.valid).toBe(false); + expect(result.errors).toBeDefined(); + expect(result.errors?.[0].code).toBe('INVALID_ASSET'); + }); + + it('should reject invalid recipient ethereum addresses', () => { + const result = service.validate({ + asset: 'USDC', + recipient: '0xInvalidEthAddressThatIsTooShort', + amount: 10, + }); + + expect(result.valid).toBe(false); + expect(result.errors).toBeDefined(); + expect(result.errors?.[0].code).toBe('INVALID_RECIPIENT'); + }); + + it('should reject empty recipient addresses', () => { + const result = service.validate({ + asset: 'USDC', + recipient: ' ', + amount: 10, + }); + + expect(result.valid).toBe(false); + expect(result.errors?.[0].code).toBe('INVALID_RECIPIENT'); + }); + + it('should reject invalid amounts', () => { + const result1 = service.validate({ + asset: 'USDC', + recipient: '0x742d35Cc6634C0532925a3b844Bc454e4438f44e', + amount: -5, + }); + + const result2 = service.validate({ + asset: 'USDC', + recipient: '0x742d35Cc6634C0532925a3b844Bc454e4438f44e', + amount: 'not-a-number', + }); + + expect(result1.valid).toBe(false); + expect(result1.errors?.[0].code).toBe('INVALID_AMOUNT'); + + expect(result2.valid).toBe(false); + expect(result2.errors?.[0].code).toBe('INVALID_AMOUNT'); + }); + + it('should reject expired requests with past expiry date', () => { + const result = service.validate({ + asset: 'USDC', + recipient: '0x742d35Cc6634C0532925a3b844Bc454e4438f44e', + amount: 10, + expiresAt: '2020-01-01T00:00:00.000Z', + }); + + expect(result.valid).toBe(false); + expect(result.errors?.[0].code).toBe('EXPIRED_REQUEST'); + }); + + it('should accept non-expired requests with future expiry date', () => { + const futureDate = new Date(); + futureDate.setFullYear(futureDate.getFullYear() + 1); + + const result = service.validate({ + asset: 'USDC', + recipient: '0x742d35Cc6634C0532925a3b844Bc454e4438f44e', + amount: 10, + expiresAt: futureDate.toISOString(), + }); + + expect(result.valid).toBe(true); + expect(result.normalizedPayload?.expiresAt).toBe( + futureDate.toISOString(), + ); + }); + }); +}); diff --git a/src/payment-requests/payment-requests.service.ts b/src/payment-requests/payment-requests.service.ts new file mode 100644 index 0000000..90302c3 --- /dev/null +++ b/src/payment-requests/payment-requests.service.ts @@ -0,0 +1,239 @@ +import { Injectable } from '@nestjs/common'; +import { ValidatePaymentRequestDto } from './dto/validate-payment-request.dto'; + +export class ValidationError { + code: string; + message: string; + field?: string; +} + +export class ValidationResult { + valid: boolean; + normalizedPayload?: { + asset: string; + recipient: string; + amount: number; + expiresAt?: string; + }; + errors?: ValidationError[]; +} + +@Injectable() +export class PaymentRequestsService { + private readonly supportedAssets = new Set([ + 'USDC', + 'USDT', + 'ETH', + 'BTC', + 'SOL', + 'USD', + 'EUR', + ]); + + validate(dto: ValidatePaymentRequestDto): ValidationResult { + let asset: unknown = dto.asset; + let recipient: unknown = dto.recipient; + let amount: unknown = dto.amount; + let expiresAt: unknown = dto.expiresAt; + + // Handle payload if present + if (dto.payload) { + let payloadObj: unknown = dto.payload; + + // If payload is a string, try to parse it + if (typeof payloadObj === 'string') { + const payloadStr: string = payloadObj; + try { + // 1. Try JSON parsing + const parsed: unknown = JSON.parse(payloadStr); + if (parsed && typeof parsed === 'object') { + payloadObj = parsed; + } + } catch { + // 2. Try URI-like parsing (e.g. ethereum:0x... or solana:...) + const match = payloadStr.match( + /^([a-zA-Z0-9+-.]+):([^?]+)(\?(.*))?$/, + ); + if (match) { + const address = match[2]; + const queryString = match[4] || ''; + const params = new URLSearchParams(queryString); + + payloadObj = { + recipient: address, + asset: params.get('asset') || params.get('currency') || undefined, + amount: params.get('amount') || params.get('value') || undefined, + expiresAt: + params.get('expiry') || + params.get('expiresAt') || + params.get('expires') || + undefined, + }; + } else { + // 3. Try query parameters directly + const params = new URLSearchParams(payloadStr); + if ( + params.has('asset') || + params.has('recipient') || + params.has('amount') + ) { + payloadObj = { + asset: params.get('asset') || undefined, + recipient: params.get('recipient') || undefined, + amount: params.get('amount') || undefined, + expiresAt: + params.get('expiresAt') || params.get('expiry') || undefined, + }; + } + } + } + } + + // Merge extracted payload properties if found + if (payloadObj && typeof payloadObj === 'object') { + const record = payloadObj as Record; + if (record.asset !== undefined) asset = record.asset; + if (record.recipient !== undefined) recipient = record.recipient; + if (record.amount !== undefined) amount = record.amount; + if (record.expiresAt !== undefined) expiresAt = record.expiresAt; + } + } + + const errors: ValidationError[] = []; + let finalAsset = ''; + let finalRecipient = ''; + + // 1. Validate Asset + if (typeof asset !== 'string' || asset.trim() === '') { + errors.push({ + code: 'INVALID_ASSET', + message: 'Asset is required and must be a non-empty string.', + field: 'asset', + }); + } else { + const normalizedAsset = asset.trim().toUpperCase(); + if (!this.supportedAssets.has(normalizedAsset)) { + errors.push({ + code: 'INVALID_ASSET', + message: `Asset '${asset}' is not supported. Supported assets are: ${Array.from(this.supportedAssets).join(', ')}.`, + field: 'asset', + }); + } else { + finalAsset = normalizedAsset; // Save normalized + } + } + + // 2. Validate Recipient + if (typeof recipient !== 'string' || recipient.trim() === '') { + errors.push({ + code: 'INVALID_RECIPIENT', + message: 'Recipient is required and must be a non-empty string.', + field: 'recipient', + }); + } else { + const trimmedRecipient = recipient.trim(); + // Ethereum-specific validation + if (trimmedRecipient.startsWith('0x')) { + const ethAddressRegex = /^0x[a-fA-F0-9]{40}$/; + if (!ethAddressRegex.test(trimmedRecipient)) { + errors.push({ + code: 'INVALID_RECIPIENT', + message: + 'Recipient address starts with 0x but is not a valid Ethereum address.', + field: 'recipient', + }); + } else { + finalRecipient = trimmedRecipient; + } + } else if (trimmedRecipient.length < 3) { + errors.push({ + code: 'INVALID_RECIPIENT', + message: 'Recipient must be at least 3 characters long.', + field: 'recipient', + }); + } else { + finalRecipient = trimmedRecipient; + } + } + + // 3. Validate Amount + let parsedAmount = NaN; + if (typeof amount === 'number') { + parsedAmount = amount; + } else if (typeof amount === 'string') { + parsedAmount = parseFloat(amount.trim()); + } + + if (isNaN(parsedAmount)) { + errors.push({ + code: 'INVALID_AMOUNT', + message: 'Amount is required and must be a valid number.', + field: 'amount', + }); + } else if (parsedAmount <= 0) { + errors.push({ + code: 'INVALID_AMOUNT', + message: 'Amount must be greater than zero.', + field: 'amount', + }); + } + + // 4. Validate Expiry / ExpiresAt + let parsedExpiryDate: Date | null = null; + if (expiresAt !== undefined && expiresAt !== null) { + if (typeof expiresAt === 'number') { + // If it looks like a Unix timestamp in seconds (under 10 billion) + if (expiresAt < 10000000000) { + parsedExpiryDate = new Date(expiresAt * 1000); + } else { + parsedExpiryDate = new Date(expiresAt); + } + } else if (typeof expiresAt === 'string') { + const trimmed = expiresAt.trim(); + if (/^\d+$/.test(trimmed)) { + const num = parseInt(trimmed, 10); + if (num < 10000000000) { + parsedExpiryDate = new Date(num * 1000); + } else { + parsedExpiryDate = new Date(num); + } + } else { + parsedExpiryDate = new Date(trimmed); + } + } + + if (!parsedExpiryDate || isNaN(parsedExpiryDate.getTime())) { + errors.push({ + code: 'EXPIRED_REQUEST', + message: 'Expiry date is invalid.', + field: 'expiresAt', + }); + } else if (parsedExpiryDate.getTime() <= Date.now()) { + errors.push({ + code: 'EXPIRED_REQUEST', + message: 'The payment request has expired.', + field: 'expiresAt', + }); + } + } + + if (errors.length > 0) { + return { + valid: false, + errors, + }; + } + + return { + valid: true, + normalizedPayload: { + asset: finalAsset, + recipient: finalRecipient, + amount: parsedAmount, + ...(parsedExpiryDate + ? { expiresAt: parsedExpiryDate.toISOString() } + : {}), + }, + }; + } +} diff --git a/src/payments/payments.module.ts b/src/payments/payments.module.ts index 14964ec..b31c673 100644 --- a/src/payments/payments.module.ts +++ b/src/payments/payments.module.ts @@ -4,6 +4,6 @@ import { PaymentsController } from './payments.controller'; @Module({ providers: [PaymentsService], - controllers: [PaymentsController] + controllers: [PaymentsController], }) export class PaymentsModule {} diff --git a/src/prisma/prisma.service.ts b/src/prisma/prisma.service.ts index bb6565f..7ffd32d 100644 --- a/src/prisma/prisma.service.ts +++ b/src/prisma/prisma.service.ts @@ -2,7 +2,10 @@ import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; @Injectable() -export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { +export class PrismaService + extends PrismaClient + implements OnModuleInit, OnModuleDestroy +{ async onModuleInit() { await this.$connect(); } diff --git a/src/supabase/supabase.service.ts b/src/supabase/supabase.service.ts index a25b710..6876b5b 100644 --- a/src/supabase/supabase.service.ts +++ b/src/supabase/supabase.service.ts @@ -7,8 +7,8 @@ export class SupabaseService { private supabase: SupabaseClient; constructor(private configService: ConfigService) { - const supabaseUrl = this.configService.get('SUPABASE_URL'); - const supabaseKey = this.configService.get('SUPABASE_KEY'); + const supabaseUrl = this.configService.get('SUPABASE_URL') || ''; + const supabaseKey = this.configService.get('SUPABASE_KEY') || ''; this.supabase = createClient(supabaseUrl, supabaseKey); } diff --git a/src/transactions/transactions.module.ts b/src/transactions/transactions.module.ts index df47a3a..e1d5231 100644 --- a/src/transactions/transactions.module.ts +++ b/src/transactions/transactions.module.ts @@ -4,6 +4,6 @@ import { TransactionsController } from './transactions.controller'; @Module({ providers: [TransactionsService], - controllers: [TransactionsController] + controllers: [TransactionsController], }) export class TransactionsModule {} diff --git a/src/users/users.module.ts b/src/users/users.module.ts index 51bec7b..10c6fdd 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -4,6 +4,6 @@ import { UsersController } from './users.controller'; @Module({ providers: [UsersService], - controllers: [UsersController] + controllers: [UsersController], }) export class UsersModule {} diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 36852c5..85ff50d 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -3,6 +3,8 @@ import { INestApplication } from '@nestjs/common'; import request from 'supertest'; import { App } from 'supertest/types'; import { AppModule } from './../src/app.module'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { SupabaseService } from '../src/supabase/supabase.service'; describe('AppController (e2e)', () => { let app: INestApplication; @@ -10,7 +12,17 @@ describe('AppController (e2e)', () => { beforeEach(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], - }).compile(); + }) + .overrideProvider(PrismaService) + .useValue({ + $connect: jest.fn().mockResolvedValue(undefined), + $disconnect: jest.fn().mockResolvedValue(undefined), + }) + .overrideProvider(SupabaseService) + .useValue({ + getClient: jest.fn().mockReturnValue({}), + }) + .compile(); app = moduleFixture.createNestApplication(); await app.init(); diff --git a/test/payment-requests.e2e-spec.ts b/test/payment-requests.e2e-spec.ts new file mode 100644 index 0000000..5a48da1 --- /dev/null +++ b/test/payment-requests.e2e-spec.ts @@ -0,0 +1,158 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { AppModule } from '../src/app.module'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { SupabaseService } from '../src/supabase/supabase.service'; + +interface ValidationResultBody { + valid: boolean; + normalizedPayload?: { + asset: string; + recipient: string; + amount: number; + expiresAt?: string; + }; + errors?: { + code: string; + message: string; + field?: string; + }[]; +} + +describe('PaymentRequests (e2e)', () => { + let app: INestApplication; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }) + .overrideProvider(PrismaService) + .useValue({ + $connect: jest.fn().mockResolvedValue(undefined), + $disconnect: jest.fn().mockResolvedValue(undefined), + }) + .overrideProvider(SupabaseService) + .useValue({ + getClient: jest.fn().mockReturnValue({}), + }) + .compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + afterEach(async () => { + await app.close(); + }); + + describe('POST /v1/payment-requests/validate', () => { + it('should validate a correct direct payment request payload', () => { + return request(app.getHttpServer()) + .post('/v1/payment-requests/validate') + .send({ + asset: 'USDC', + recipient: '0x742d35Cc6634C0532925a3b844Bc454e4438f44e', + amount: 250, + }) + .expect(200) + .expect((res) => { + const body = res.body as ValidationResultBody; + expect(body).toEqual({ + valid: true, + normalizedPayload: { + asset: 'USDC', + recipient: '0x742d35Cc6634C0532925a3b844Bc454e4438f44e', + amount: 250, + }, + }); + }); + }); + + it('should validate a correct payment request with serialized JSON string payload', () => { + return request(app.getHttpServer()) + .post('/v1/payment-requests/validate') + .send({ + payload: JSON.stringify({ + asset: 'BTC', + recipient: '0x742d35Cc6634C0532925a3b844Bc454e4438f44e', + amount: '0.05', + }), + }) + .expect(200) + .expect((res) => { + const body = res.body as ValidationResultBody; + expect(body).toEqual({ + valid: true, + normalizedPayload: { + asset: 'BTC', + recipient: '0x742d35Cc6634C0532925a3b844Bc454e4438f44e', + amount: 0.05, + }, + }); + }); + }); + + it('should validate a correct payment request with URI string payload', () => { + return request(app.getHttpServer()) + .post('/v1/payment-requests/validate') + .send({ + payload: + 'solana:0x742d35Cc6634C0532925a3b844Bc454e4438f44e?amount=12.45&asset=SOL', + }) + .expect(200) + .expect((res) => { + const body = res.body as ValidationResultBody; + expect(body).toEqual({ + valid: true, + normalizedPayload: { + asset: 'SOL', + recipient: '0x742d35Cc6634C0532925a3b844Bc454e4438f44e', + amount: 12.45, + }, + }); + }); + }); + + it('should return invalid status and errors for missing/invalid properties', () => { + return request(app.getHttpServer()) + .post('/v1/payment-requests/validate') + .send({ + asset: 'INVALID_TOKEN_ABC', + recipient: '', + amount: -10, + }) + .expect(200) + .expect((res) => { + const body = res.body as ValidationResultBody; + expect(body.valid).toBe(false); + expect(body.errors).toBeDefined(); + expect(body.errors?.length).toBeGreaterThanOrEqual(3); + + const codes = body.errors?.map((e) => e.code) || []; + expect(codes).toContain('INVALID_ASSET'); + expect(codes).toContain('INVALID_RECIPIENT'); + expect(codes).toContain('INVALID_AMOUNT'); + }); + }); + + it('should return EXPIRED_REQUEST error for an expired request', () => { + return request(app.getHttpServer()) + .post('/v1/payment-requests/validate') + .send({ + asset: 'USDC', + recipient: '0x742d35Cc6634C0532925a3b844Bc454e4438f44e', + amount: 50, + expiresAt: '2020-01-01T00:00:00.000Z', + }) + .expect(200) + .expect((res) => { + const body = res.body as ValidationResultBody; + expect(body.valid).toBe(false); + expect(body.errors).toBeDefined(); + expect(body.errors?.[0].code).toBe('EXPIRED_REQUEST'); + }); + }); + }); +});