From 93e131ec9d9403f0e9ebda9a0760c14d036c2ead Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Wed, 26 Jun 2024 14:52:12 +0300 Subject: [PATCH 01/22] feat: Add iris-pay skeleton module skeleton --- .../src/iris-pay/dto/create-iris-pay.dto.ts | 1 + .../src/iris-pay/dto/update-iris-pay.dto.ts | 4 +++ .../src/iris-pay/entities/iris-pay.entity.ts | 1 + .../src/iris-pay/iris-pay.controller.spec.ts | 20 +++++++++++ apps/api/src/iris-pay/iris-pay.controller.ts | 34 +++++++++++++++++++ apps/api/src/iris-pay/iris-pay.module.ts | 9 +++++ .../api/src/iris-pay/iris-pay.service.spec.ts | 18 ++++++++++ apps/api/src/iris-pay/iris-pay.service.ts | 26 ++++++++++++++ 8 files changed, 113 insertions(+) create mode 100644 apps/api/src/iris-pay/dto/create-iris-pay.dto.ts create mode 100644 apps/api/src/iris-pay/dto/update-iris-pay.dto.ts create mode 100644 apps/api/src/iris-pay/entities/iris-pay.entity.ts create mode 100644 apps/api/src/iris-pay/iris-pay.controller.spec.ts create mode 100644 apps/api/src/iris-pay/iris-pay.controller.ts create mode 100644 apps/api/src/iris-pay/iris-pay.module.ts create mode 100644 apps/api/src/iris-pay/iris-pay.service.spec.ts create mode 100644 apps/api/src/iris-pay/iris-pay.service.ts diff --git a/apps/api/src/iris-pay/dto/create-iris-pay.dto.ts b/apps/api/src/iris-pay/dto/create-iris-pay.dto.ts new file mode 100644 index 00000000..9d102a5a --- /dev/null +++ b/apps/api/src/iris-pay/dto/create-iris-pay.dto.ts @@ -0,0 +1 @@ +export class CreateIrisPayDto {} diff --git a/apps/api/src/iris-pay/dto/update-iris-pay.dto.ts b/apps/api/src/iris-pay/dto/update-iris-pay.dto.ts new file mode 100644 index 00000000..c5c5a7a7 --- /dev/null +++ b/apps/api/src/iris-pay/dto/update-iris-pay.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateIrisPayDto } from './create-iris-pay.dto'; + +export class UpdateIrisPayDto extends PartialType(CreateIrisPayDto) {} diff --git a/apps/api/src/iris-pay/entities/iris-pay.entity.ts b/apps/api/src/iris-pay/entities/iris-pay.entity.ts new file mode 100644 index 00000000..fe2ec23b --- /dev/null +++ b/apps/api/src/iris-pay/entities/iris-pay.entity.ts @@ -0,0 +1 @@ +export class IrisPay {} diff --git a/apps/api/src/iris-pay/iris-pay.controller.spec.ts b/apps/api/src/iris-pay/iris-pay.controller.spec.ts new file mode 100644 index 00000000..2f5194ae --- /dev/null +++ b/apps/api/src/iris-pay/iris-pay.controller.spec.ts @@ -0,0 +1,20 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { IrisPayController } from './iris-pay.controller'; +import { IrisPayService } from './iris-pay.service'; + +describe('IrisPayController', () => { + let controller: IrisPayController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [IrisPayController], + providers: [IrisPayService], + }).compile(); + + controller = module.get(IrisPayController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/apps/api/src/iris-pay/iris-pay.controller.ts b/apps/api/src/iris-pay/iris-pay.controller.ts new file mode 100644 index 00000000..b2e35383 --- /dev/null +++ b/apps/api/src/iris-pay/iris-pay.controller.ts @@ -0,0 +1,34 @@ +import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common'; +import { IrisPayService } from './iris-pay.service'; +import { CreateIrisPayDto } from './dto/create-iris-pay.dto'; +import { UpdateIrisPayDto } from './dto/update-iris-pay.dto'; + +@Controller('iris-pay') +export class IrisPayController { + constructor(private readonly irisPayService: IrisPayService) {} + + @Post() + create(@Body() createIrisPayDto: CreateIrisPayDto) { + return this.irisPayService.create(createIrisPayDto); + } + + @Get() + findAll() { + return this.irisPayService.findAll(); + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.irisPayService.findOne(+id); + } + + @Patch(':id') + update(@Param('id') id: string, @Body() updateIrisPayDto: UpdateIrisPayDto) { + return this.irisPayService.update(+id, updateIrisPayDto); + } + + @Delete(':id') + remove(@Param('id') id: string) { + return this.irisPayService.remove(+id); + } +} diff --git a/apps/api/src/iris-pay/iris-pay.module.ts b/apps/api/src/iris-pay/iris-pay.module.ts new file mode 100644 index 00000000..5a351534 --- /dev/null +++ b/apps/api/src/iris-pay/iris-pay.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { IrisPayService } from './iris-pay.service'; +import { IrisPayController } from './iris-pay.controller'; + +@Module({ + controllers: [IrisPayController], + providers: [IrisPayService] +}) +export class IrisPayModule {} diff --git a/apps/api/src/iris-pay/iris-pay.service.spec.ts b/apps/api/src/iris-pay/iris-pay.service.spec.ts new file mode 100644 index 00000000..cc4762c3 --- /dev/null +++ b/apps/api/src/iris-pay/iris-pay.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { IrisPayService } from './iris-pay.service'; + +describe('IrisPayService', () => { + let service: IrisPayService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [IrisPayService], + }).compile(); + + service = module.get(IrisPayService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/apps/api/src/iris-pay/iris-pay.service.ts b/apps/api/src/iris-pay/iris-pay.service.ts new file mode 100644 index 00000000..0c5906ba --- /dev/null +++ b/apps/api/src/iris-pay/iris-pay.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; +import { CreateIrisPayDto } from './dto/create-iris-pay.dto'; +import { UpdateIrisPayDto } from './dto/update-iris-pay.dto'; + +@Injectable() +export class IrisPayService { + create(createIrisPayDto: CreateIrisPayDto) { + return 'This action adds a new irisPay'; + } + + findAll() { + return `This action returns all irisPay`; + } + + findOne(id: number) { + return `This action returns a #${id} irisPay`; + } + + update(id: number, updateIrisPayDto: UpdateIrisPayDto) { + return `This action updates a #${id} irisPay`; + } + + remove(id: number) { + return `This action removes a #${id} irisPay`; + } +} From 3908d71b2b3343f36bba5a21c92df0cc34973679 Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Wed, 26 Jun 2024 17:02:37 +0300 Subject: [PATCH 02/22] feat: Add initial iris-pay endpoints Added create customer endpoint Added create webhook endpoint --- apps/api/src/app/app.module.ts | 2 + .../src/iris-pay/dto/create-iris-customer.ts | 53 +++++++++++++++++++ .../src/iris-pay/dto/create-iris-pay.dto.ts | 18 ++++++- .../src/iris-pay/dto/update-iris-pay.dto.ts | 6 +-- .../src/iris-pay/entities/iris-pay.types.ts | 27 ++++++++++ .../src/iris-pay/iris-pay.controller.spec.ts | 23 ++++---- apps/api/src/iris-pay/iris-pay.controller.ts | 43 +++++++-------- apps/api/src/iris-pay/iris-pay.module.ts | 11 ++-- apps/api/src/iris-pay/iris-pay.service.ts | 49 ++++++++++++----- 9 files changed, 176 insertions(+), 56 deletions(-) create mode 100644 apps/api/src/iris-pay/dto/create-iris-customer.ts create mode 100644 apps/api/src/iris-pay/entities/iris-pay.types.ts diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 70efbe39..078204e3 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -64,6 +64,7 @@ import { StripeModule } from '../stripe/stripe.module' import { LoggerModule } from '../logger/logger.module' import { PrismaModule } from '../prisma/prisma.module' import { CampaignApplicationModule } from '../campaign-application/campaign-application.module' +import { IrisPayModule } from '../iris-pay/iris-pay.module' @Module({ imports: [ @@ -135,6 +136,7 @@ import { CampaignApplicationModule } from '../campaign-application/campaign-appl LoggerModule, CampaignApplicationModule, StripeModule, + IrisPayModule, ], controllers: [AppController], providers: [ diff --git a/apps/api/src/iris-pay/dto/create-iris-customer.ts b/apps/api/src/iris-pay/dto/create-iris-customer.ts new file mode 100644 index 00000000..fe14283e --- /dev/null +++ b/apps/api/src/iris-pay/dto/create-iris-customer.ts @@ -0,0 +1,53 @@ +import { ApiProperty } from '@nestjs/swagger' +import { Expose } from 'class-transformer' +import { IsOptional, IsString, ValidateIf } from 'class-validator' + +export class IrisCreateCustomerDto { + @ApiProperty() + @Expose() + @IsString() + @IsOptional() + companyName: string + + @ApiProperty() + @Expose() + @ValidateIf((obj) => obj.companyName !== undefined) + @IsString() + uic: string + + @ApiProperty() + @Expose() + @IsString() + @IsOptional() + name: string + + @ApiProperty() + @Expose() + @IsString() + @IsOptional() + middleName: string + + @ApiProperty() + @Expose() + @IsString() + @IsOptional() + family: string + + @ApiProperty() + @Expose() + @IsString() + @IsOptional() + identityHash: string + + @ApiProperty() + @Expose() + @ValidateIf((obj) => obj.name !== undefined) + @IsString() + email: string + + @ApiProperty() + @Expose() + @IsString() + @IsOptional() + webhookUrl: string +} diff --git a/apps/api/src/iris-pay/dto/create-iris-pay.dto.ts b/apps/api/src/iris-pay/dto/create-iris-pay.dto.ts index 9d102a5a..d4d7d138 100644 --- a/apps/api/src/iris-pay/dto/create-iris-pay.dto.ts +++ b/apps/api/src/iris-pay/dto/create-iris-pay.dto.ts @@ -1 +1,17 @@ -export class CreateIrisPayDto {} +import { ApiProperty } from '@nestjs/swagger' +import { Expose } from 'class-transformer' +import { IsOptional, IsString } from 'class-validator' + +export class IrisPayCreateWebhooKDto { + @ApiProperty() + @IsString() + @Expose() + @IsOptional() + successUrl: string + + @ApiProperty() + @IsString() + @Expose() + @IsOptional() + errorUrl: string +} diff --git a/apps/api/src/iris-pay/dto/update-iris-pay.dto.ts b/apps/api/src/iris-pay/dto/update-iris-pay.dto.ts index c5c5a7a7..86e113f2 100644 --- a/apps/api/src/iris-pay/dto/update-iris-pay.dto.ts +++ b/apps/api/src/iris-pay/dto/update-iris-pay.dto.ts @@ -1,4 +1,4 @@ -import { PartialType } from '@nestjs/swagger'; -import { CreateIrisPayDto } from './create-iris-pay.dto'; +import { PartialType } from '@nestjs/swagger' +// import { CreateIrisPayDto } from './create-iris-pay.dto'; -export class UpdateIrisPayDto extends PartialType(CreateIrisPayDto) {} +// export class UpdateIrisPayDto extends PartialType(CreateIrisPayDto) {} diff --git a/apps/api/src/iris-pay/entities/iris-pay.types.ts b/apps/api/src/iris-pay/entities/iris-pay.types.ts new file mode 100644 index 00000000..852e9e75 --- /dev/null +++ b/apps/api/src/iris-pay/entities/iris-pay.types.ts @@ -0,0 +1,27 @@ +export type RegisterWebhookReq = { + url: string + state?: string + agentHash: string + successUrl?: string + errorUrl?: string +} + +export type CreateCustomerReq = { + agentHash: string + companyName?: string + uic?: string + name?: string + middleName?: string + family?: string + identityHash?: string + email?: string + webhookUrl?: string +} + +export type CreateIrisCustomerResponse = { + userHash: string + idUrl: string | null + identifyStatusUrl: string | null + identifyToken: string | null + identified: boolean +} diff --git a/apps/api/src/iris-pay/iris-pay.controller.spec.ts b/apps/api/src/iris-pay/iris-pay.controller.spec.ts index 2f5194ae..826d24ac 100644 --- a/apps/api/src/iris-pay/iris-pay.controller.spec.ts +++ b/apps/api/src/iris-pay/iris-pay.controller.spec.ts @@ -1,20 +1,23 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { IrisPayController } from './iris-pay.controller'; -import { IrisPayService } from './iris-pay.service'; +import { Test, TestingModule } from '@nestjs/testing' +import { IrisPayController } from './iris-pay.controller' +import { IrisPayService } from './iris-pay.service' +import { ConfigModule } from '@nestjs/config' +import { HttpModule } from '@nestjs/axios' describe('IrisPayController', () => { - let controller: IrisPayController; + let controller: IrisPayController beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ + imports: [ConfigModule, HttpModule], controllers: [IrisPayController], providers: [IrisPayService], - }).compile(); + }).compile() - controller = module.get(IrisPayController); - }); + controller = module.get(IrisPayController) + }) it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); + expect(controller).toBeDefined() + }) +}) diff --git a/apps/api/src/iris-pay/iris-pay.controller.ts b/apps/api/src/iris-pay/iris-pay.controller.ts index b2e35383..49969848 100644 --- a/apps/api/src/iris-pay/iris-pay.controller.ts +++ b/apps/api/src/iris-pay/iris-pay.controller.ts @@ -1,34 +1,27 @@ -import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common'; -import { IrisPayService } from './iris-pay.service'; -import { CreateIrisPayDto } from './dto/create-iris-pay.dto'; -import { UpdateIrisPayDto } from './dto/update-iris-pay.dto'; +import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common' +import { IrisPayService } from './iris-pay.service' +import { IrisPayCreateWebhooKDto } from './dto/create-iris-pay.dto' +import { Public } from 'nest-keycloak-connect' +import { IrisCreateCustomerDto } from './dto/create-iris-customer' +import { CreateIrisCustomerResponse } from './entities/iris-pay.types' +import { ApiTags } from '@nestjs/swagger' @Controller('iris-pay') +@ApiTags() export class IrisPayController { constructor(private readonly irisPayService: IrisPayService) {} - @Post() - create(@Body() createIrisPayDto: CreateIrisPayDto) { - return this.irisPayService.create(createIrisPayDto); + @Post('create-webhook') + @Public() + async createWebhook(@Body() irisRegisterWebhookDto: IrisPayCreateWebhooKDto): Promise { + return await this.irisPayService.createWebhook(irisRegisterWebhookDto) } - @Get() - findAll() { - return this.irisPayService.findAll(); - } - - @Get(':id') - findOne(@Param('id') id: string) { - return this.irisPayService.findOne(+id); - } - - @Patch(':id') - update(@Param('id') id: string, @Body() updateIrisPayDto: UpdateIrisPayDto) { - return this.irisPayService.update(+id, updateIrisPayDto); - } - - @Delete(':id') - remove(@Param('id') id: string) { - return this.irisPayService.remove(+id); + @Post('create-customer') + @Public() + async createCustomer( + @Body() irisCreateCustomerDto: IrisCreateCustomerDto, + ): Promise { + return await this.irisPayService.createCustomer(irisCreateCustomerDto) } } diff --git a/apps/api/src/iris-pay/iris-pay.module.ts b/apps/api/src/iris-pay/iris-pay.module.ts index 5a351534..0d430e7e 100644 --- a/apps/api/src/iris-pay/iris-pay.module.ts +++ b/apps/api/src/iris-pay/iris-pay.module.ts @@ -1,9 +1,12 @@ -import { Module } from '@nestjs/common'; -import { IrisPayService } from './iris-pay.service'; -import { IrisPayController } from './iris-pay.controller'; +import { Module } from '@nestjs/common' +import { IrisPayService } from './iris-pay.service' +import { IrisPayController } from './iris-pay.controller' +import { ConfigModule } from '@nestjs/config' +import { HttpModule } from '@nestjs/axios' @Module({ + imports: [ConfigModule, HttpModule], controllers: [IrisPayController], - providers: [IrisPayService] + providers: [IrisPayService], }) export class IrisPayModule {} diff --git a/apps/api/src/iris-pay/iris-pay.service.ts b/apps/api/src/iris-pay/iris-pay.service.ts index 0c5906ba..6af99db7 100644 --- a/apps/api/src/iris-pay/iris-pay.service.ts +++ b/apps/api/src/iris-pay/iris-pay.service.ts @@ -1,26 +1,49 @@ -import { Injectable } from '@nestjs/common'; -import { CreateIrisPayDto } from './dto/create-iris-pay.dto'; -import { UpdateIrisPayDto } from './dto/update-iris-pay.dto'; +import { Injectable } from '@nestjs/common' +import { IrisPayCreateWebhooKDto } from './dto/create-iris-pay.dto' + +import { + CreateCustomerReq, + CreateIrisCustomerResponse, + RegisterWebhookReq, +} from './entities/iris-pay.types' +import { ConfigService } from '@nestjs/config' +import { HttpService } from '@nestjs/axios' +import { IrisCreateCustomerDto } from './dto/create-iris-customer' @Injectable() export class IrisPayService { - create(createIrisPayDto: CreateIrisPayDto) { - return 'This action adds a new irisPay'; + agentHash: string + irisEndpoint: string + constructor(private config: ConfigService, private httpService: HttpService) { + this.agentHash = this.config.get('IRIS_AGENT_HASH', '') + this.irisEndpoint = this.config.get('IRIS_API_URL', '') } - findAll() { - return `This action returns all irisPay`; + async createWebhook(irisRegisterWebhookDto?: IrisPayCreateWebhooKDto) { + const APP_URL = this.config.get('APP_URL') + const data: RegisterWebhookReq = { + url: `${APP_URL}/iris-pay/webhook`, + agentHash: this.agentHash, + successUrl: irisRegisterWebhookDto?.successUrl, + errorUrl: irisRegisterWebhookDto?.errorUrl, + } + const webhookUrl = `${this.irisEndpoint}/createhook` + return (await this.httpService.axiosRef.post(webhookUrl, data)).data } - findOne(id: number) { - return `This action returns a #${id} irisPay`; - } + async createCustomer(irisCreateCustomerDto: IrisCreateCustomerDto) { + const data: CreateCustomerReq = { + agentHash: this.agentHash, + ...irisCreateCustomerDto, + } - update(id: number, updateIrisPayDto: UpdateIrisPayDto) { - return `This action updates a #${id} irisPay`; + const createCustomerUrl = `${this.irisEndpoint}/signup` + return ( + await this.httpService.axiosRef.post(createCustomerUrl, data) + ).data } remove(id: number) { - return `This action removes a #${id} irisPay`; + return `This action removes a #${id} irisPay` } } From d35a046fa94c4eb8b4dd479fe241c86331415403 Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Mon, 1 Jul 2024 10:40:04 +0300 Subject: [PATCH 03/22] feat: Add IRIS checkout controller --- .../dto/connect-irisCustomer.dto.ts | 3 + .../dto/create-irisCustomer.dto.ts | 4 ++ .../generated/irisCustomer/dto/index.ts | 3 + .../dto/update-irisCustomer.dto.ts | 3 + .../generated/irisCustomer/entities/index.ts | 1 + .../entities/irisCustomer.entity.ts | 4 ++ .../src/iris-pay/dto/create-iris-customer.ts | 14 ++--- .../src/iris-pay/dto/create-iris-pay.dto.ts | 8 ++- apps/api/src/iris-pay/iris-pay.controller.ts | 23 ++++--- apps/api/src/iris-pay/iris-pay.module.ts | 4 +- .../api/src/iris-pay/iris-pay.service.spec.ts | 18 +++--- apps/api/src/iris-pay/iris-pay.service.ts | 62 ++++++++++++++++--- .../migration.sql | 10 +++ podkrepi.dbml | 5 ++ schema.prisma | 8 +++ 15 files changed, 133 insertions(+), 37 deletions(-) create mode 100644 apps/api/src/domain/generated/irisCustomer/dto/connect-irisCustomer.dto.ts create mode 100644 apps/api/src/domain/generated/irisCustomer/dto/create-irisCustomer.dto.ts create mode 100644 apps/api/src/domain/generated/irisCustomer/dto/index.ts create mode 100644 apps/api/src/domain/generated/irisCustomer/dto/update-irisCustomer.dto.ts create mode 100644 apps/api/src/domain/generated/irisCustomer/entities/index.ts create mode 100644 apps/api/src/domain/generated/irisCustomer/entities/irisCustomer.entity.ts create mode 100644 migrations/20240630131931_add_iris_customers/migration.sql diff --git a/apps/api/src/domain/generated/irisCustomer/dto/connect-irisCustomer.dto.ts b/apps/api/src/domain/generated/irisCustomer/dto/connect-irisCustomer.dto.ts new file mode 100644 index 00000000..ab470d96 --- /dev/null +++ b/apps/api/src/domain/generated/irisCustomer/dto/connect-irisCustomer.dto.ts @@ -0,0 +1,3 @@ +export class ConnectIrisCustomerDto { + userHash: string +} diff --git a/apps/api/src/domain/generated/irisCustomer/dto/create-irisCustomer.dto.ts b/apps/api/src/domain/generated/irisCustomer/dto/create-irisCustomer.dto.ts new file mode 100644 index 00000000..ddefff15 --- /dev/null +++ b/apps/api/src/domain/generated/irisCustomer/dto/create-irisCustomer.dto.ts @@ -0,0 +1,4 @@ +export class CreateIrisCustomerDto { + userHash: string + email: string +} diff --git a/apps/api/src/domain/generated/irisCustomer/dto/index.ts b/apps/api/src/domain/generated/irisCustomer/dto/index.ts new file mode 100644 index 00000000..12f463f4 --- /dev/null +++ b/apps/api/src/domain/generated/irisCustomer/dto/index.ts @@ -0,0 +1,3 @@ +export * from './connect-irisCustomer.dto' +export * from './create-irisCustomer.dto' +export * from './update-irisCustomer.dto' diff --git a/apps/api/src/domain/generated/irisCustomer/dto/update-irisCustomer.dto.ts b/apps/api/src/domain/generated/irisCustomer/dto/update-irisCustomer.dto.ts new file mode 100644 index 00000000..ca695071 --- /dev/null +++ b/apps/api/src/domain/generated/irisCustomer/dto/update-irisCustomer.dto.ts @@ -0,0 +1,3 @@ +export class UpdateIrisCustomerDto { + email?: string +} diff --git a/apps/api/src/domain/generated/irisCustomer/entities/index.ts b/apps/api/src/domain/generated/irisCustomer/entities/index.ts new file mode 100644 index 00000000..a19e972a --- /dev/null +++ b/apps/api/src/domain/generated/irisCustomer/entities/index.ts @@ -0,0 +1 @@ +export * from './irisCustomer.entity' diff --git a/apps/api/src/domain/generated/irisCustomer/entities/irisCustomer.entity.ts b/apps/api/src/domain/generated/irisCustomer/entities/irisCustomer.entity.ts new file mode 100644 index 00000000..e8917704 --- /dev/null +++ b/apps/api/src/domain/generated/irisCustomer/entities/irisCustomer.entity.ts @@ -0,0 +1,4 @@ +export class IrisCustomer { + userHash: string + email: string +} diff --git a/apps/api/src/iris-pay/dto/create-iris-customer.ts b/apps/api/src/iris-pay/dto/create-iris-customer.ts index fe14283e..a541a05c 100644 --- a/apps/api/src/iris-pay/dto/create-iris-customer.ts +++ b/apps/api/src/iris-pay/dto/create-iris-customer.ts @@ -7,37 +7,37 @@ export class IrisCreateCustomerDto { @Expose() @IsString() @IsOptional() - companyName: string + companyName?: string @ApiProperty() @Expose() @ValidateIf((obj) => obj.companyName !== undefined) @IsString() - uic: string + uic?: string @ApiProperty() @Expose() @IsString() @IsOptional() - name: string + name?: string @ApiProperty() @Expose() @IsString() @IsOptional() - middleName: string + middleName?: string @ApiProperty() @Expose() @IsString() @IsOptional() - family: string + family?: string @ApiProperty() @Expose() @IsString() @IsOptional() - identityHash: string + identityHash?: string @ApiProperty() @Expose() @@ -49,5 +49,5 @@ export class IrisCreateCustomerDto { @Expose() @IsString() @IsOptional() - webhookUrl: string + webhookUrl?: string } diff --git a/apps/api/src/iris-pay/dto/create-iris-pay.dto.ts b/apps/api/src/iris-pay/dto/create-iris-pay.dto.ts index d4d7d138..578d0ced 100644 --- a/apps/api/src/iris-pay/dto/create-iris-pay.dto.ts +++ b/apps/api/src/iris-pay/dto/create-iris-pay.dto.ts @@ -1,8 +1,14 @@ import { ApiProperty } from '@nestjs/swagger' import { Expose } from 'class-transformer' import { IsOptional, IsString } from 'class-validator' +import { IrisCreateCustomerDto } from './create-iris-customer' + +export class IRISCreateCheckoutSessionDto extends IrisCreateCustomerDto { + @ApiProperty() + @IsString() + @Expose() + campaignId: string -export class IrisPayCreateWebhooKDto { @ApiProperty() @IsString() @Expose() diff --git a/apps/api/src/iris-pay/iris-pay.controller.ts b/apps/api/src/iris-pay/iris-pay.controller.ts index 49969848..a0cebb50 100644 --- a/apps/api/src/iris-pay/iris-pay.controller.ts +++ b/apps/api/src/iris-pay/iris-pay.controller.ts @@ -1,9 +1,8 @@ -import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common' +import { Controller, Get, Post, Body, Req, RawBodyRequest, Query } from '@nestjs/common' import { IrisPayService } from './iris-pay.service' -import { IrisPayCreateWebhooKDto } from './dto/create-iris-pay.dto' +import { IRISCreateCheckoutSessionDto } from './dto/create-iris-pay.dto' import { Public } from 'nest-keycloak-connect' -import { IrisCreateCustomerDto } from './dto/create-iris-customer' -import { CreateIrisCustomerResponse } from './entities/iris-pay.types' + import { ApiTags } from '@nestjs/swagger' @Controller('iris-pay') @@ -11,17 +10,17 @@ import { ApiTags } from '@nestjs/swagger' export class IrisPayController { constructor(private readonly irisPayService: IrisPayService) {} - @Post('create-webhook') + @Post('create-checkout-session') @Public() - async createWebhook(@Body() irisRegisterWebhookDto: IrisPayCreateWebhooKDto): Promise { - return await this.irisPayService.createWebhook(irisRegisterWebhookDto) + async createIRISCheckoutSession( + @Body() irisCreateCustomerDto: IRISCreateCheckoutSessionDto, + ): Promise<{ hookHash: string; userHash: string }> { + return await this.irisPayService.createCheckout(irisCreateCustomerDto) } - @Post('create-customer') + @Get('webhook') @Public() - async createCustomer( - @Body() irisCreateCustomerDto: IrisCreateCustomerDto, - ): Promise { - return await this.irisPayService.createCustomer(irisCreateCustomerDto) + async webhookEndpoint(@Req() req: RawBodyRequest, @Query('state') state: string) { + console.log(`Webhook ${state} executed`) } } diff --git a/apps/api/src/iris-pay/iris-pay.module.ts b/apps/api/src/iris-pay/iris-pay.module.ts index 0d430e7e..ffdbccbc 100644 --- a/apps/api/src/iris-pay/iris-pay.module.ts +++ b/apps/api/src/iris-pay/iris-pay.module.ts @@ -3,9 +3,11 @@ import { IrisPayService } from './iris-pay.service' import { IrisPayController } from './iris-pay.controller' import { ConfigModule } from '@nestjs/config' import { HttpModule } from '@nestjs/axios' +import { PrismaModule } from '../prisma/prisma.module' +import { CampaignModule } from '../campaign/campaign.module' @Module({ - imports: [ConfigModule, HttpModule], + imports: [ConfigModule, HttpModule, PrismaModule, CampaignModule], controllers: [IrisPayController], providers: [IrisPayService], }) diff --git a/apps/api/src/iris-pay/iris-pay.service.spec.ts b/apps/api/src/iris-pay/iris-pay.service.spec.ts index cc4762c3..b6b5ba5a 100644 --- a/apps/api/src/iris-pay/iris-pay.service.spec.ts +++ b/apps/api/src/iris-pay/iris-pay.service.spec.ts @@ -1,18 +1,18 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { IrisPayService } from './iris-pay.service'; +import { Test, TestingModule } from '@nestjs/testing' +import { IrisPayService } from './iris-pay.service' describe('IrisPayService', () => { - let service: IrisPayService; + let service: IrisPayService beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [IrisPayService], - }).compile(); + }).compile() - service = module.get(IrisPayService); - }); + service = module.get(IrisPayService) + }) it('should be defined', () => { - expect(service).toBeDefined(); - }); -}); + expect(service).toBeDefined() + }) +}) diff --git a/apps/api/src/iris-pay/iris-pay.service.ts b/apps/api/src/iris-pay/iris-pay.service.ts index 6af99db7..bca5d94f 100644 --- a/apps/api/src/iris-pay/iris-pay.service.ts +++ b/apps/api/src/iris-pay/iris-pay.service.ts @@ -1,5 +1,10 @@ -import { Injectable } from '@nestjs/common' -import { IrisPayCreateWebhooKDto } from './dto/create-iris-pay.dto' +import { + ForbiddenException, + Injectable, + InternalServerErrorException, + Logger, +} from '@nestjs/common' +import { IRISCreateCheckoutSessionDto } from './dto/create-iris-pay.dto' import { CreateCustomerReq, @@ -9,17 +14,24 @@ import { import { ConfigService } from '@nestjs/config' import { HttpService } from '@nestjs/axios' import { IrisCreateCustomerDto } from './dto/create-iris-customer' +import { PrismaService } from '../prisma/prisma.service' +import { CampaignService } from '../campaign/campaign.service' @Injectable() export class IrisPayService { agentHash: string irisEndpoint: string - constructor(private config: ConfigService, private httpService: HttpService) { + constructor( + private config: ConfigService, + private httpService: HttpService, + private prismaService: PrismaService, + private campaignService: CampaignService, + ) { this.agentHash = this.config.get('IRIS_AGENT_HASH', '') this.irisEndpoint = this.config.get('IRIS_API_URL', '') } - async createWebhook(irisRegisterWebhookDto?: IrisPayCreateWebhooKDto) { + async createWebhook(irisRegisterWebhookDto?: IRISCreateCheckoutSessionDto) { const APP_URL = this.config.get('APP_URL') const data: RegisterWebhookReq = { url: `${APP_URL}/iris-pay/webhook`, @@ -27,22 +39,58 @@ export class IrisPayService { successUrl: irisRegisterWebhookDto?.successUrl, errorUrl: irisRegisterWebhookDto?.errorUrl, } + const webhookUrl = `${this.irisEndpoint}/createhook` return (await this.httpService.axiosRef.post(webhookUrl, data)).data } async createCustomer(irisCreateCustomerDto: IrisCreateCustomerDto) { + const irisCustomer = await this.prismaService.irisCustomer.findFirst({ + where: { email: irisCreateCustomerDto.email }, + }) + if (irisCustomer) { + Logger.debug('Customer with email found') + return irisCustomer.userHash + } const data: CreateCustomerReq = { agentHash: this.agentHash, ...irisCreateCustomerDto, } + Logger.debug('IRIS Customer not found. Creating new one') const createCustomerUrl = `${this.irisEndpoint}/signup` - return ( - await this.httpService.axiosRef.post(createCustomerUrl, data) - ).data + const irisCreateCustomer = await this.httpService.axiosRef.post( + createCustomerUrl, + data, + ) + await this.prismaService.irisCustomer.create({ + data: { email: irisCreateCustomerDto.email, userHash: irisCreateCustomer.data.userHash }, + }) + return irisCreateCustomer.data.userHash } + async createCheckout(irisCreateCheckoutDto: IRISCreateCheckoutSessionDto) { + const campaign = await this.campaignService.getCampaignById(irisCreateCheckoutDto.campaignId) + await this.campaignService.validateCampaign(campaign) + const userObj: IrisCreateCustomerDto = { + email: irisCreateCheckoutDto.email, + name: irisCreateCheckoutDto.name, + family: irisCreateCheckoutDto.family, + } + const userHashRes = this.createCustomer(userObj) + const webhookRes = this.createWebhook(irisCreateCheckoutDto) + const [userHash, webhook] = await Promise.allSettled([userHashRes, webhookRes]) + if (userHash.status !== 'fulfilled' || webhook.status !== 'fulfilled') { + throw new InternalServerErrorException( + "Couldn't initiate IRIS checkout at this time.\n Please try again later", + ) + } + + return { + hookHash: webhook.value, + userHash: userHash.value, + } + } remove(id: number) { return `This action removes a #${id} irisPay` } diff --git a/migrations/20240630131931_add_iris_customers/migration.sql b/migrations/20240630131931_add_iris_customers/migration.sql new file mode 100644 index 00000000..4456a79e --- /dev/null +++ b/migrations/20240630131931_add_iris_customers/migration.sql @@ -0,0 +1,10 @@ +-- CreateTable +CREATE TABLE "iris_customers" ( + "user_hash" UUID NOT NULL, + "email" VARCHAR NOT NULL, + + CONSTRAINT "iris_customers_pkey" PRIMARY KEY ("user_hash") +); + +-- CreateIndex +CREATE INDEX "iris_customers_email_idx" ON "iris_customers"("email"); diff --git a/podkrepi.dbml b/podkrepi.dbml index dc65fb35..0938a555 100644 --- a/podkrepi.dbml +++ b/podkrepi.dbml @@ -353,6 +353,11 @@ Table countries { cities cities [not null] } +Table iris_customers { + userHash String [pk] + email String [not null] +} + Table vaults { id String [pk] currency Currency [not null, default: 'BGN'] diff --git a/schema.prisma b/schema.prisma index a7da1904..76cad555 100644 --- a/schema.prisma +++ b/schema.prisma @@ -430,6 +430,14 @@ model Country { @@map("countries") } +model IrisCustomer { + userHash String @id @map("user_hash") @db.Uuid + email String @db.VarChar + + @@map("iris_customers") + @@index([email]) +} + model Vault { id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid currency Currency @default(BGN) From 0a0daaeaac7da275b8dc1835c99e5daee792cad6 Mon Sep 17 00:00:00 2001 From: Aleksandar Petkov Date: Mon, 21 Jul 2025 06:29:40 +0300 Subject: [PATCH 04/22] feat: IrisPay integration --- .env.example | 2 + apps/api/src/config/configuration.ts | 1 + apps/api/src/iris-pay/README.md | 152 ++++++++++++++ .../verify-payload.decorator.spec.ts | 56 ++++++ .../decorators/verify-payload.decorator.ts | 16 ++ .../src/iris-pay/dto/finish-payment.dto.ts | 61 ++++++ .../guards/verify-payload.guard.spec.ts | 144 ++++++++++++++ .../iris-pay/guards/verify-payload.guard.ts | 99 ++++++++++ apps/api/src/iris-pay/iris-pay.controller.ts | 25 ++- apps/api/src/iris-pay/iris-pay.module.ts | 6 +- .../api/src/iris-pay/iris-pay.service.spec.ts | 185 +++++++++++++++++- apps/api/src/iris-pay/iris-pay.service.ts | 101 +++++++++- .../migration.sql | 2 + podkrepi.dbml | 1 + schema.prisma | 1 + 15 files changed, 844 insertions(+), 8 deletions(-) create mode 100644 apps/api/src/iris-pay/README.md create mode 100644 apps/api/src/iris-pay/decorators/verify-payload.decorator.spec.ts create mode 100644 apps/api/src/iris-pay/decorators/verify-payload.decorator.ts create mode 100644 apps/api/src/iris-pay/dto/finish-payment.dto.ts create mode 100644 apps/api/src/iris-pay/guards/verify-payload.guard.spec.ts create mode 100644 apps/api/src/iris-pay/guards/verify-payload.guard.ts create mode 100644 migrations/20250720140505_add_irispay_payment_provider/migration.sql diff --git a/.env.example b/.env.example index e3fb0b16..19e4655b 100644 --- a/.env.example +++ b/.env.example @@ -98,6 +98,7 @@ IRIS_AGENT_HASH= IRIS_USER_HASH= BANK_BIC=UNCRBGSF PLATFORM_IBAN= +PG_PAYLOAD_SECRET=your-payload-signature-secret IMPORT_TRX_TASK_INTERVAL_MINUTES=60 #which hour of the day to run the check for consent CHECK_IRIS_CONSENT_TASK_HOUR=10 @@ -112,3 +113,4 @@ CACHE_TTL=30000 ############## CAMPAIGN_COORDINATOR_EMAIL=campaign_coordinators@podkrepi.bg CORPORATE_DONORS_EMAIL= +PG_PAYLOAD_SECRET= \ No newline at end of file diff --git a/apps/api/src/config/configuration.ts b/apps/api/src/config/configuration.ts index e2afe5af..27437860 100644 --- a/apps/api/src/config/configuration.ts +++ b/apps/api/src/config/configuration.ts @@ -47,6 +47,7 @@ export default () => ({ banksEndPoint: process.env.IRIS_API_URL + '/banks?country=bulgaria', ibansEndPoint: process.env.IRIS_API_URL + '/ibans', transactionsEndPoint: process.env.IRIS_API_URL + '/transactions', + payloadSecret: process.env.PG_PAYLOAD_SECRET, }, mail: { billingAdminEmail: process.env.BILLING_ADMIN_MAIL, diff --git a/apps/api/src/iris-pay/README.md b/apps/api/src/iris-pay/README.md new file mode 100644 index 00000000..75924162 --- /dev/null +++ b/apps/api/src/iris-pay/README.md @@ -0,0 +1,152 @@ +# Iris Pay Module + +This module handles Iris Pay integration for the Podkrepi.bg platform. + +## Security + +### VerifyPayload Decorator + +The `@VerifyPayload()` decorator provides payload signature authentication for sensitive iris-pay endpoints. It validates that the `x-pbg-signature` header contains a valid HMAC-SHA256 signature of the request body using the configured `PG_PAYLOAD_SECRET` as the secret key. + +#### Usage + +```typescript +import { VerifyPayload } from './decorators/verify-payload.decorator' + +@Controller('iris-pay') +export class IrisPayController { + @Post('create-payment-session') + @Public() + @VerifyPayload() + async createPaymentSession(@Body() dto: CreatePaymentDto) { + // This endpoint is now protected by API key validation + return this.service.createPayment(dto) + } +} +``` + +#### Configuration + +Make sure to set the `PG_PAYLOAD_SECRET` environment variable: + +```bash +PG_PAYLOAD_SECRET=your-secret-key-here +``` + +#### Protected Endpoints + +The following endpoints are currently protected by the `@VerifyPayload()` decorator: + +- `POST /iris-pay/create-payment-session` - Creates a new payment session +- `POST /iris-pay/verify-hookhash` - Verifies webhook hash +- `POST /iris-pay/finish` - Finishes the payment process and updates donation status + +#### Error Responses + +The decorator will return HTTP 401 Unauthorized in the following cases: + +- Missing `x-pbg-signature` header +- Invalid signature (doesn't match expected HMAC-SHA256 of request body) +- Missing or invalid request body for signature verification +- `PG_PAYLOAD_SECRET` environment variable not configured + +#### Testing + +To test protected endpoints, you need to generate a valid HMAC-SHA256 signature of the request body: + +```bash +# Example payload +PAYLOAD='{"campaignId": "123", "email": "test@example.com"}' +SECRET="your-pg-payload-secret" + +# Generate signature +SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" -hex | cut -d' ' -f2) + +# Make the request +curl -X POST http://localhost:5010/api/v1/iris-pay/create-payment-session \ + -H "Content-Type: application/json" \ + -H "x-pbg-signature: $SIGNATURE" \ + -d "$PAYLOAD" +``` + +Or with the `sha256=` prefix: + +```bash +curl -X POST http://localhost:5010/api/v1/iris-pay/create-payment-session \ + -H "Content-Type: application/json" \ + -H "x-pbg-signature: sha256=$SIGNATURE" \ + -d "$PAYLOAD" +``` + +#### Implementation Details + +- The guard validates the `x-pbg-signature` header contains a valid HMAC-SHA256 signature +- Uses the `PG_PAYLOAD_SECRET` environment variable as the secret key for signature verification +- Supports signatures with or without the `sha256=` prefix +- Uses `crypto.timingSafeEqual()` for secure signature comparison to prevent timing attacks +- Handles different request body formats (string, Buffer, parsed JSON) +- Logs security events for monitoring and debugging +- Can be applied to any controller method that needs payload signature verification + +## Endpoints + +### POST /iris-pay/finish + +Finishes the payment process by updating the donation status in the system. + +#### Request Body + +```typescript +{ + hookHash: string, // Payment identifier from iris-pay + status: string, // Payment status ('CONFIRMED', 'FAILED', 'WAITTING') + amount: number, // Payment amount in smallest currency unit + billingName?: string, // Optional billing name + billingEmail?: string, // Optional billing email + metadata: { + campaignId: string, // ID of the campaign receiving the donation + personId: string | null, // ID of the donor (null for anonymous) + isAnonymous: 'true' | 'false', // Whether the donation is anonymous + type: string // Donation type (e.g., 'donation') + } +} +``` + +#### Response + +```typescript +{ + donationId?: string // ID of the created/updated donation +} +``` + +#### Status Mapping + +The endpoint maps iris-pay status strings to internal PaymentStatus enum values: + +- `'CONFIRMED'` → `PaymentStatus.succeeded` (payment executed) +- `'FAILED'` → `PaymentStatus.declined` (payment rejected) +- `'WAITTING'` → `PaymentStatus.waiting` (waiting to be processed by ASPSP) +- `'WAITING'` → `PaymentStatus.waiting` (also supports correct spelling) +- Any other status → `PaymentStatus.waiting` (with warning log) + +#### Example Usage + +```bash +curl -X POST http://localhost:5010/api/v1/iris-pay/finish \ + -H "Content-Type: application/json" \ + -H "x-pbg-signature: sha256=your-signature-here" \ + -d '{ + "hookHash": "payment-123", + "status": "CONFIRMED", + "amount": 5000, + "billingName": "John Doe", + "billingEmail": "john.doe@example.com", + "metadata": { + "campaignId": "campaign-456", + "personId": "person-789", + "isAnonymous": "false", + "type": "donation" + } + }' +``` diff --git a/apps/api/src/iris-pay/decorators/verify-payload.decorator.spec.ts b/apps/api/src/iris-pay/decorators/verify-payload.decorator.spec.ts new file mode 100644 index 00000000..e42ba1e8 --- /dev/null +++ b/apps/api/src/iris-pay/decorators/verify-payload.decorator.spec.ts @@ -0,0 +1,56 @@ +import { Test, TestingModule } from '@nestjs/testing' +import { Controller, Post, Body } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import { VerifyPayload } from './verify-payload.decorator' +import { VerifyPayloadGuard } from '../guards/verify-payload.guard' + +// Test controller to verify the decorator works +@Controller('test') +class TestController { + @Post('protected') + @VerifyPayload() + async protectedEndpoint(@Body() body: any) { + return { success: true, body } + } + + @Post('unprotected') + async unprotectedEndpoint(@Body() body: any) { + return { success: true, body } + } +} + +describe('VerifyPayload Decorator', () => { + let module: TestingModule + + const mockConfigService = { + get: jest.fn().mockReturnValue('test-agent-hash'), + } + + beforeEach(async () => { + module = await Test.createTestingModule({ + controllers: [TestController], + providers: [ + VerifyPayloadGuard, + { + provide: ConfigService, + useValue: mockConfigService, + }, + ], + }).compile() + }) + + it('should apply VerifyPayloadGuard to decorated methods', () => { + const controller = module.get(TestController) + expect(controller).toBeDefined() + + // The decorator should have applied the guard + // This is more of a structural test to ensure the decorator is properly configured + const guard = module.get(VerifyPayloadGuard) + expect(guard).toBeDefined() + }) + + it('should be importable and usable', () => { + expect(VerifyPayload).toBeDefined() + expect(typeof VerifyPayload).toBe('function') + }) +}) diff --git a/apps/api/src/iris-pay/decorators/verify-payload.decorator.ts b/apps/api/src/iris-pay/decorators/verify-payload.decorator.ts new file mode 100644 index 00000000..88c725dc --- /dev/null +++ b/apps/api/src/iris-pay/decorators/verify-payload.decorator.ts @@ -0,0 +1,16 @@ +import { applyDecorators, UseGuards } from '@nestjs/common' +import { VerifyPayloadGuard } from '../guards/verify-payload.guard' + +/** + * Decorator that applies the VerifyPayloadGuard to protect iris-pay endpoints. + * This guard validates that the x-pbg-signature header contains a valid HMAC-SHA256 + * signature of the request body using the configured PG_PAYLOAD_SECRET as the secret. + * + * Usage: + * @VerifyPayload() + * @Post('create-payment-session') + * async createPaymentSession() { ... } + */ +export function VerifyPayload() { + return applyDecorators(UseGuards(VerifyPayloadGuard)) +} diff --git a/apps/api/src/iris-pay/dto/finish-payment.dto.ts b/apps/api/src/iris-pay/dto/finish-payment.dto.ts new file mode 100644 index 00000000..e21e2e48 --- /dev/null +++ b/apps/api/src/iris-pay/dto/finish-payment.dto.ts @@ -0,0 +1,61 @@ +import { ApiProperty } from '@nestjs/swagger' +import { Expose } from 'class-transformer' +import { IsString, IsNumber, IsObject, ValidateNested, IsIn, IsOptional } from 'class-validator' +import { Type } from 'class-transformer' + +export class FinishPaymentMetadataDto { + @Expose() + @ApiProperty() + @IsString() + campaignId: string + + @Expose() + @ApiProperty({ required: false }) + @IsString() + @IsOptional() + personId: string | null + + @Expose() + @ApiProperty() + @IsIn(['true', 'false']) + isAnonymous: 'true' | 'false' + + @Expose() + @ApiProperty() + @IsString() + type: string +} + +export class FinishPaymentDto { + @Expose() + @ApiProperty() + @IsString() + hookHash: string + + @Expose() + @ApiProperty() + @IsString() + status: string + + @Expose() + @ApiProperty() + @IsNumber() + amount: number + + @Expose() + @ApiProperty({ required: false }) + @IsString() + billingName?: string + + @Expose() + @ApiProperty({ required: false }) + @IsString() + billingEmail?: string + + @Expose() + @ApiProperty({ type: FinishPaymentMetadataDto }) + @IsObject() + @ValidateNested() + @Type(() => FinishPaymentMetadataDto) + metadata: FinishPaymentMetadataDto +} diff --git a/apps/api/src/iris-pay/guards/verify-payload.guard.spec.ts b/apps/api/src/iris-pay/guards/verify-payload.guard.spec.ts new file mode 100644 index 00000000..3913c84a --- /dev/null +++ b/apps/api/src/iris-pay/guards/verify-payload.guard.spec.ts @@ -0,0 +1,144 @@ +import { Test, TestingModule } from '@nestjs/testing' +import { ConfigService } from '@nestjs/config' +import { ExecutionContext, UnauthorizedException } from '@nestjs/common' +import { VerifyPayloadGuard } from './verify-payload.guard' +import * as crypto from 'crypto' + +describe('VerifyPayloadGuard', () => { + let guard: VerifyPayloadGuard + let configService: ConfigService + + const mockConfigService = { + get: jest.fn(), + } + + const testSecret = 'test-secret-key' + const testPayload = JSON.stringify({ campaignId: '123', email: 'test@example.com' }) + + const createSignature = (payload: string, secret: string): string => { + return crypto.createHmac('sha256', secret).update(payload, 'utf8').digest('hex') + } + + const createMockExecutionContext = ( + headers: Record = {}, + body: any = null, + ): ExecutionContext => { + return { + switchToHttp: () => ({ + getRequest: () => ({ + headers, + url: '/iris-pay/create-payment-session', + body, + }), + }), + } as ExecutionContext + } + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + VerifyPayloadGuard, + { + provide: ConfigService, + useValue: mockConfigService, + }, + ], + }).compile() + + guard = module.get(VerifyPayloadGuard) + configService = module.get(ConfigService) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('should be defined', () => { + expect(guard).toBeDefined() + }) + + it('should allow access with valid signature', () => { + mockConfigService.get.mockReturnValue(testSecret) + const validSignature = createSignature(testPayload, testSecret) + + const context = createMockExecutionContext( + { + 'x-pbg-signature': validSignature, + }, + JSON.parse(testPayload), + ) + + expect(guard.canActivate(context)).toBe(true) + expect(configService.get).toHaveBeenCalledWith('PG_PAYLOAD_SECRET') + }) + + it('should allow access with valid signature prefixed with sha256=', () => { + mockConfigService.get.mockReturnValue(testSecret) + const validSignature = 'sha256=' + createSignature(testPayload, testSecret) + + const context = createMockExecutionContext( + { + 'x-pbg-signature': validSignature, + }, + JSON.parse(testPayload), + ) + + expect(guard.canActivate(context)).toBe(true) + }) + + it('should throw UnauthorizedException when x-pbg-signature header is missing', () => { + mockConfigService.get.mockReturnValue(testSecret) + + const context = createMockExecutionContext({}, JSON.parse(testPayload)) + + expect(() => guard.canActivate(context)).toThrow(UnauthorizedException) + expect(() => guard.canActivate(context)).toThrow('Missing x-pbg-signature header') + }) + + it('should throw UnauthorizedException when PG_PAYLOAD_SECRET is not configured', () => { + mockConfigService.get.mockReturnValue(undefined) + const signature = createSignature(testPayload, 'any-secret') + + const context = createMockExecutionContext( + { + 'x-pbg-signature': signature, + }, + JSON.parse(testPayload), + ) + + expect(() => guard.canActivate(context)).toThrow(UnauthorizedException) + expect(() => guard.canActivate(context)).toThrow('Signature validation not configured') + }) + + it('should throw UnauthorizedException when signature is invalid', () => { + mockConfigService.get.mockReturnValue(testSecret) + const invalidSignature = createSignature(testPayload, 'wrong-secret') + + const context = createMockExecutionContext( + { + 'x-pbg-signature': invalidSignature, + }, + JSON.parse(testPayload), + ) + + expect(() => guard.canActivate(context)).toThrow(UnauthorizedException) + expect(() => guard.canActivate(context)).toThrow('Invalid signature') + }) + + it('should throw UnauthorizedException when request body is missing', () => { + mockConfigService.get.mockReturnValue(testSecret) + const signature = createSignature(testPayload, testSecret) + + const context = createMockExecutionContext( + { + 'x-pbg-signature': signature, + }, + null, + ) + + expect(() => guard.canActivate(context)).toThrow(UnauthorizedException) + expect(() => guard.canActivate(context)).toThrow( + 'Invalid request body for signature verification', + ) + }) +}) diff --git a/apps/api/src/iris-pay/guards/verify-payload.guard.ts b/apps/api/src/iris-pay/guards/verify-payload.guard.ts new file mode 100644 index 00000000..d96277d0 --- /dev/null +++ b/apps/api/src/iris-pay/guards/verify-payload.guard.ts @@ -0,0 +1,99 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + UnauthorizedException, + Logger, +} from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import { Request } from 'express' +import * as crypto from 'crypto' + +@Injectable() +export class VerifyPayloadGuard implements CanActivate { + private readonly logger = new Logger(VerifyPayloadGuard.name) + + constructor(private readonly configService: ConfigService) {} + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest() + const signature = request.headers['x-pbg-signature'] as string + const secret = this.configService.get('PG_PAYLOAD_SECRET') + + this.logger.debug(`Verifying payload signature for iris-pay endpoint: ${request.url}`) + + if (!signature) { + this.logger.warn('Missing x-pbg-signature header in iris-pay request') + throw new UnauthorizedException('Missing x-pbg-signature header') + } + + if (!secret) { + this.logger.error('PG_PAYLOAD_SECRET not configured') + throw new UnauthorizedException('Signature validation not configured') + } + + // Get the raw body for signature verification + const rawBody = this.getRawBody(request) + if (!rawBody) { + this.logger.warn('Unable to get raw body for signature verification') + throw new UnauthorizedException('Invalid request body for signature verification') + } + + // Verify the signature + if (!this.verifySignature(rawBody, signature, secret)) { + this.logger.warn('Invalid signature provided for iris-pay endpoint') + throw new UnauthorizedException('Invalid signature') + } + + this.logger.debug('Payload signature validation successful for iris-pay endpoint') + return true + } + + private getRawBody(request: Request): string | Buffer | null { + if (request.body && typeof request.body === 'string') { + return request.body + } + + if (request.body && Buffer.isBuffer(request.body)) { + return request.body + } + + if (request.body && typeof request.body === 'object') { + return JSON.stringify(request.body) + } + + if ((request as any).rawBody) { + return (request as any).rawBody + } + + return null + } + + private verifySignature(payload: string | Buffer, signature: string, secret: string): boolean { + try { + const payloadString = Buffer.isBuffer(payload) ? payload.toString('utf8') : payload + + // Create HMAC signature using SHA256 + const expectedSignature = crypto + .createHmac('sha256', secret) + .update(payloadString) + .digest('hex') + + // Compare signatures (handle both with and without 'sha256=' prefix) + const receivedSignature = signature.startsWith('sha256=') ? signature.slice(7) : signature + + // Use crypto.timingSafeEqual for secure comparison + const expectedBuffer = Buffer.from(expectedSignature, 'hex') + const receivedBuffer = Buffer.from(receivedSignature, 'hex') + + if (expectedBuffer.length !== receivedBuffer.length) { + return false + } + + return crypto.timingSafeEqual(expectedBuffer, receivedBuffer) + } catch (error) { + this.logger.error('Error verifying signature:', error) + return false + } + } +} diff --git a/apps/api/src/iris-pay/iris-pay.controller.ts b/apps/api/src/iris-pay/iris-pay.controller.ts index a0cebb50..f0ef69af 100644 --- a/apps/api/src/iris-pay/iris-pay.controller.ts +++ b/apps/api/src/iris-pay/iris-pay.controller.ts @@ -1,7 +1,9 @@ -import { Controller, Get, Post, Body, Req, RawBodyRequest, Query } from '@nestjs/common' +import { Controller, Get, Post, Body, Req, RawBodyRequest, Query, Param, HttpCode } from '@nestjs/common' import { IrisPayService } from './iris-pay.service' import { IRISCreateCheckoutSessionDto } from './dto/create-iris-pay.dto' +import { FinishPaymentDto } from './dto/finish-payment.dto' import { Public } from 'nest-keycloak-connect' +import { VerifyPayload } from './decorators/verify-payload.decorator' import { ApiTags } from '@nestjs/swagger' @@ -10,14 +12,33 @@ import { ApiTags } from '@nestjs/swagger' export class IrisPayController { constructor(private readonly irisPayService: IrisPayService) {} - @Post('create-checkout-session') + @Post('create-payment-session') @Public() + @VerifyPayload() async createIRISCheckoutSession( @Body() irisCreateCustomerDto: IRISCreateCheckoutSessionDto, ): Promise<{ hookHash: string; userHash: string }> { return await this.irisPayService.createCheckout(irisCreateCustomerDto) } + @Post('verify-payment') + @HttpCode(200) + @Public() + @VerifyPayload() + async verifyWebhook(@Body() body: { hookHash: string }) { + return await this.irisPayService.verifyPayment(body) + } + + @Post('finish') + @Public() + @VerifyPayload() + async finishPaymentSession( + @Body() finishPaymentDto: FinishPaymentDto, + ): Promise<{ donationId?: string }> { + const donationId = await this.irisPayService.finishPaymentSession(finishPaymentDto) + return { donationId } + } + @Get('webhook') @Public() async webhookEndpoint(@Req() req: RawBodyRequest, @Query('state') state: string) { diff --git a/apps/api/src/iris-pay/iris-pay.module.ts b/apps/api/src/iris-pay/iris-pay.module.ts index ffdbccbc..3794d8f2 100644 --- a/apps/api/src/iris-pay/iris-pay.module.ts +++ b/apps/api/src/iris-pay/iris-pay.module.ts @@ -5,10 +5,12 @@ import { ConfigModule } from '@nestjs/config' import { HttpModule } from '@nestjs/axios' import { PrismaModule } from '../prisma/prisma.module' import { CampaignModule } from '../campaign/campaign.module' +import { DonationsModule } from '../donations/donations.module' +import { VerifyPayloadGuard } from './guards/verify-payload.guard' @Module({ - imports: [ConfigModule, HttpModule, PrismaModule, CampaignModule], + imports: [ConfigModule, HttpModule, PrismaModule, CampaignModule, DonationsModule], controllers: [IrisPayController], - providers: [IrisPayService], + providers: [IrisPayService, VerifyPayloadGuard], }) export class IrisPayModule {} diff --git a/apps/api/src/iris-pay/iris-pay.service.spec.ts b/apps/api/src/iris-pay/iris-pay.service.spec.ts index b6b5ba5a..f61e2fc9 100644 --- a/apps/api/src/iris-pay/iris-pay.service.spec.ts +++ b/apps/api/src/iris-pay/iris-pay.service.spec.ts @@ -1,18 +1,201 @@ import { Test, TestingModule } from '@nestjs/testing' import { IrisPayService } from './iris-pay.service' +import { ConfigService } from '@nestjs/config' +import { HttpService } from '@nestjs/axios' +import { PrismaService } from '../prisma/prisma.service' +import { CampaignService } from '../campaign/campaign.service' +import { DonationsService } from '../donations/donations.service' +import { PaymentStatus, PaymentProvider, Currency } from '@prisma/client' +import { FinishPaymentDto } from './dto/finish-payment.dto' describe('IrisPayService', () => { let service: IrisPayService + let donationsService: DonationsService + let campaignService: CampaignService + + const mockConfigService = { + get: jest.fn().mockReturnValue('test-value'), + } + + const mockHttpService = { + axiosRef: { + post: jest.fn(), + get: jest.fn(), + }, + } + + const mockPrismaService = {} + + const mockCampaignService = { + getCampaignById: jest.fn(), + } + + const mockDonationsService = { + updateDonationPayment: jest.fn(), + } beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [IrisPayService], + providers: [ + IrisPayService, + { + provide: ConfigService, + useValue: mockConfigService, + }, + { + provide: HttpService, + useValue: mockHttpService, + }, + { + provide: PrismaService, + useValue: mockPrismaService, + }, + { + provide: CampaignService, + useValue: mockCampaignService, + }, + { + provide: DonationsService, + useValue: mockDonationsService, + }, + ], }).compile() service = module.get(IrisPayService) + donationsService = module.get(DonationsService) + campaignService = module.get(CampaignService) + }) + + afterEach(() => { + jest.clearAllMocks() }) it('should be defined', () => { expect(service).toBeDefined() }) + + describe('finishPaymentSession', () => { + const mockCampaign = { + id: 'campaign-123', + currency: Currency.BGN, + } + + const finishPaymentDto: FinishPaymentDto = { + hookHash: 'hook-123', + status: 'CONFIRMED', + amount: 1000, + billingName: 'John Doe', + billingEmail: 'john.doe@example.com', + metadata: { + campaignId: 'campaign-123', + personId: 'person-123', + isAnonymous: 'false', + type: 'donation', + }, + } + + it('should successfully finish payment session', async () => { + mockCampaignService.getCampaignById.mockResolvedValue(mockCampaign) + mockDonationsService.updateDonationPayment.mockResolvedValue('donation-123') + + const result = await service.finishPaymentSession(finishPaymentDto) + + expect(campaignService.getCampaignById).toHaveBeenCalledWith('campaign-123') + expect(donationsService.updateDonationPayment).toHaveBeenCalledWith( + mockCampaign, + { + paymentIntentId: 'hook-123', + netAmount: 1000, + chargedAmount: 1000, + currency: 'bgn', + paymentProvider: PaymentProvider.none, + billingName: 'John Doe', + billingEmail: 'john.doe@example.com', + personId: 'person-123', + type: 'donation', + }, + PaymentStatus.succeeded, + ) + expect(result).toBe('donation-123') + }) + + it('should handle anonymous donations', async () => { + const anonymousDto = { + ...finishPaymentDto, + metadata: { + ...finishPaymentDto.metadata, + isAnonymous: 'true' as const, + }, + } + + mockCampaignService.getCampaignById.mockResolvedValue(mockCampaign) + mockDonationsService.updateDonationPayment.mockResolvedValue('donation-123') + + await service.finishPaymentSession(anonymousDto) + + expect(donationsService.updateDonationPayment).toHaveBeenCalledWith( + mockCampaign, + expect.objectContaining({ + personId: undefined, + billingName: 'John Doe', + billingEmail: 'john.doe@example.com', + }), + PaymentStatus.succeeded, + ) + }) + + it('should handle missing billing information', async () => { + const dtoWithoutBilling = { + ...finishPaymentDto, + billingName: undefined, + billingEmail: undefined, + } + + mockCampaignService.getCampaignById.mockResolvedValue(mockCampaign) + mockDonationsService.updateDonationPayment.mockResolvedValue('donation-123') + + await service.finishPaymentSession(dtoWithoutBilling) + + expect(donationsService.updateDonationPayment).toHaveBeenCalledWith( + mockCampaign, + expect.objectContaining({ + billingName: undefined, + billingEmail: undefined, + }), + PaymentStatus.succeeded, + ) + }) + + it('should throw error when campaign not found', async () => { + mockCampaignService.getCampaignById.mockResolvedValue(null) + + await expect(service.finishPaymentSession(finishPaymentDto)).rejects.toThrow( + 'Campaign not found: campaign-123', + ) + }) + + it('should map iris-pay status values correctly', async () => { + mockCampaignService.getCampaignById.mockResolvedValue(mockCampaign) + mockDonationsService.updateDonationPayment.mockResolvedValue('donation-123') + + const testCases = [ + { status: 'CONFIRMED', expected: PaymentStatus.succeeded }, + { status: 'FAILED', expected: PaymentStatus.declined }, + { status: 'WAITTING', expected: PaymentStatus.waiting }, + { status: 'WAITING', expected: PaymentStatus.waiting }, // Also support correct spelling + { status: 'UNKNOWN', expected: PaymentStatus.waiting }, // Default case + ] + + for (const testCase of testCases) { + const dto = { ...finishPaymentDto, status: testCase.status } + await service.finishPaymentSession(dto) + + expect(donationsService.updateDonationPayment).toHaveBeenCalledWith( + mockCampaign, + expect.any(Object), + testCase.expected, + ) + } + }) + }) }) diff --git a/apps/api/src/iris-pay/iris-pay.service.ts b/apps/api/src/iris-pay/iris-pay.service.ts index bca5d94f..668803ac 100644 --- a/apps/api/src/iris-pay/iris-pay.service.ts +++ b/apps/api/src/iris-pay/iris-pay.service.ts @@ -16,6 +16,32 @@ import { HttpService } from '@nestjs/axios' import { IrisCreateCustomerDto } from './dto/create-iris-customer' import { PrismaService } from '../prisma/prisma.service' import { CampaignService } from '../campaign/campaign.service' +import { DonationsService } from '../donations/donations.service' +import { FinishPaymentDto } from './dto/finish-payment.dto' +import { PaymentProvider, PaymentStatus, DonationType } from '@prisma/client' +import { PaymentData } from '../donations/helpers/payment-intent-helpers' + +export interface IrisHookHash { + date: Date + payeeName: string + payerName: string + payerBank: PayeBank + payeeBank: PayeBank + description: string + sum: string + payerIban: string + payeeIban: string + id: string + currency: string + status: string + reasonForFail: string +} + +export interface PayeBank { + bankHash: string + name: string + country: string +} @Injectable() export class IrisPayService { @@ -26,6 +52,7 @@ export class IrisPayService { private httpService: HttpService, private prismaService: PrismaService, private campaignService: CampaignService, + private donationsService: DonationsService, ) { this.agentHash = this.config.get('IRIS_AGENT_HASH', '') this.irisEndpoint = this.config.get('IRIS_API_URL', '') @@ -44,6 +71,19 @@ export class IrisPayService { return (await this.httpService.axiosRef.post(webhookUrl, data)).data } + async verifyPayment(body: { hookHash: string }) { + const result = await this.httpService.axiosRef.get( + `${this.irisEndpoint}/status/${body.hookHash}`, + { + headers: { + 'x-agent-hash': this.agentHash, + }, + }, + ) + + return result?.data + } + async createCustomer(irisCreateCustomerDto: IrisCreateCustomerDto) { const irisCustomer = await this.prismaService.irisCustomer.findFirst({ where: { email: irisCreateCustomerDto.email }, @@ -63,9 +103,11 @@ export class IrisPayService { createCustomerUrl, data, ) - await this.prismaService.irisCustomer.create({ - data: { email: irisCreateCustomerDto.email, userHash: irisCreateCustomer.data.userHash }, - }) + if (irisCreateCustomer?.data?.userHash) { + await this.prismaService.irisCustomer.create({ + data: { email: irisCreateCustomerDto.email, userHash: irisCreateCustomer.data.userHash }, + }) + } return irisCreateCustomer.data.userHash } @@ -91,6 +133,59 @@ export class IrisPayService { userHash: userHash.value, } } + + async finishPaymentSession(finishPaymentDto: FinishPaymentDto): Promise { + Logger.debug('Finishing payment session', { + hookHash: finishPaymentDto.hookHash, + status: finishPaymentDto.status, + campaignId: finishPaymentDto.metadata.campaignId, + }) + + // Get the campaign + const campaign = await this.campaignService.getCampaignById( + finishPaymentDto.metadata.campaignId, + ) + if (!campaign) { + throw new Error(`Campaign not found: ${finishPaymentDto.metadata.campaignId}`) + } + + // Transform the finish payment DTO to PaymentData structure + const paymentData: PaymentData = { + paymentIntentId: finishPaymentDto.hookHash, + netAmount: finishPaymentDto.amount, + chargedAmount: finishPaymentDto.amount, + currency: campaign.currency.toLowerCase(), + paymentProvider: PaymentProvider.irispay, + billingName: finishPaymentDto.billingName, + billingEmail: finishPaymentDto.billingEmail, + personId: + finishPaymentDto.metadata.isAnonymous === 'false' && finishPaymentDto.metadata.personId + ? finishPaymentDto.metadata.personId + : undefined, + type: finishPaymentDto.metadata.type, + } + + // Map status string to PaymentStatus enum + const paymentStatus = this.mapStatusToPaymentStatus(finishPaymentDto.status) + + // Call donationService.updateDonationPayment + return await this.donationsService.updateDonationPayment(campaign, paymentData, paymentStatus) + } + + private mapStatusToPaymentStatus(status: string): PaymentStatus { + switch (status.toUpperCase()) { + case 'CONFIRMED': + return PaymentStatus.succeeded + case 'FAILED': + return PaymentStatus.declined + case 'WAITING': + return PaymentStatus.waiting + default: + Logger.warn(`Unknown payment status: ${status}, defaulting to waiting`) + return PaymentStatus.waiting + } + } + remove(id: number) { return `This action removes a #${id} irisPay` } diff --git a/migrations/20250720140505_add_irispay_payment_provider/migration.sql b/migrations/20250720140505_add_irispay_payment_provider/migration.sql new file mode 100644 index 00000000..679beb16 --- /dev/null +++ b/migrations/20250720140505_add_irispay_payment_provider/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "payment_provider" ADD VALUE 'irispay'; diff --git a/podkrepi.dbml b/podkrepi.dbml index 0938a555..1536757b 100644 --- a/podkrepi.dbml +++ b/podkrepi.dbml @@ -700,6 +700,7 @@ Enum PaymentProvider { epay bank cash + irispay } Enum DocumentType { diff --git a/schema.prisma b/schema.prisma index 76cad555..48b703ed 100644 --- a/schema.prisma +++ b/schema.prisma @@ -797,6 +797,7 @@ enum PaymentProvider { epay bank cash + irispay @@map("payment_provider") } From 378528f94a5ff47d1be8e415bd9d09fc27d840f2 Mon Sep 17 00:00:00 2001 From: Aleksandar Petkov Date: Mon, 23 Mar 2026 13:42:15 +0200 Subject: [PATCH 05/22] refactor: Move payment session cookie to BE --- .env.example | 2 +- apps/api/src/config/configuration.ts | 15 +- apps/api/src/config/validation.config.ts | 5 +- .../decorators/payment-step.decorator.ts | 4 + .../verify-payload.decorator.spec.ts | 56 ------- .../decorators/verify-payload.decorator.ts | 16 -- .../src/iris-pay/dto/complete-payment.dto.ts | 36 +++++ .../iris-pay/guards/payment-session.guard.ts | 29 ++++ .../guards/verify-payload.guard.spec.ts | 144 ------------------ .../iris-pay/guards/verify-payload.guard.ts | 99 ------------ .../src/iris-pay/iris-pay.controller.spec.ts | 22 ++- apps/api/src/iris-pay/iris-pay.controller.ts | 92 ++++++++--- apps/api/src/iris-pay/iris-pay.module.ts | 15 +- .../api/src/iris-pay/iris-pay.service.spec.ts | 2 +- apps/api/src/iris-pay/iris-pay.service.ts | 2 +- .../services/payment-session.service.ts | 92 +++++++++++ apps/api/src/main.ts | 2 + package.json | 2 + yarn.lock | 28 ++++ 19 files changed, 320 insertions(+), 343 deletions(-) create mode 100644 apps/api/src/iris-pay/decorators/payment-step.decorator.ts delete mode 100644 apps/api/src/iris-pay/decorators/verify-payload.decorator.spec.ts delete mode 100644 apps/api/src/iris-pay/decorators/verify-payload.decorator.ts create mode 100644 apps/api/src/iris-pay/dto/complete-payment.dto.ts create mode 100644 apps/api/src/iris-pay/guards/payment-session.guard.ts delete mode 100644 apps/api/src/iris-pay/guards/verify-payload.guard.spec.ts delete mode 100644 apps/api/src/iris-pay/guards/verify-payload.guard.ts create mode 100644 apps/api/src/iris-pay/services/payment-session.service.ts diff --git a/.env.example b/.env.example index 19e4655b..b4c5f24f 100644 --- a/.env.example +++ b/.env.example @@ -98,7 +98,7 @@ IRIS_AGENT_HASH= IRIS_USER_HASH= BANK_BIC=UNCRBGSF PLATFORM_IBAN= -PG_PAYLOAD_SECRET=your-payload-signature-secret +PAYMENT_SESSION_SECRET=your-payment-session-jwt-secret IMPORT_TRX_TASK_INTERVAL_MINUTES=60 #which hour of the day to run the check for consent CHECK_IRIS_CONSENT_TASK_HOUR=10 diff --git a/apps/api/src/config/configuration.ts b/apps/api/src/config/configuration.ts index 5cd9bea3..2ca8cc9b 100644 --- a/apps/api/src/config/configuration.ts +++ b/apps/api/src/config/configuration.ts @@ -1,5 +1,17 @@ +import { readFileSync } from 'fs' import { SeverityLevel } from '@sentry/node' +function getStripeWebhookSecret(): string { + if (process.env.STRIPE_WEBHOOK_SECRET) { + return process.env.STRIPE_WEBHOOK_SECRET + } + try { + return readFileSync('/shared/stripe-webhook-secret', 'utf-8').trim() + } catch { + return '' + } +} + /** * Be sure to add `process.env` vars in validation schema at ./validation.config.ts */ @@ -28,7 +40,7 @@ export default () => ({ }, stripe: { secretKey: process.env.STRIPE_SECRET_KEY, - webhookSecret: process.env.STRIPE_WEBHOOK_SECRET, + webhookSecret: getStripeWebhookSecret(), }, paypal: { apiUrl: process.env.PAYPAL_URL, @@ -48,6 +60,7 @@ export default () => ({ ibansEndPoint: process.env.IRIS_API_URL + '/ibans', transactionsEndPoint: process.env.IRIS_API_URL + '/transactions', payloadSecret: process.env.PG_PAYLOAD_SECRET, + paymentSessionSecret: process.env.PAYMENT_SESSION_SECRET, }, mail: { billingAdminEmail: process.env.BILLING_ADMIN_MAIL, diff --git a/apps/api/src/config/validation.config.ts b/apps/api/src/config/validation.config.ts index c3b38643..f22aa745 100644 --- a/apps/api/src/config/validation.config.ts +++ b/apps/api/src/config/validation.config.ts @@ -51,11 +51,14 @@ export const validationSchema = Joi.object({ // Stripe STRIPE_SECRET_KEY: Joi.string().required(), - STRIPE_WEBHOOK_SECRET: Joi.string().required(), + STRIPE_WEBHOOK_SECRET: Joi.string().allow('').optional(), //Paypal PAYPAL_URL: Joi.string().required(), PAYPAL_CLIENT_ID: Joi.string().required(), PAYPAL_CLIENT_SECRET: Joi.string().required(), PAYPAL_WEBHOOK_ID: Joi.string().required(), + + // Iris Pay + PAYMENT_SESSION_SECRET: Joi.string().required(), }) diff --git a/apps/api/src/iris-pay/decorators/payment-step.decorator.ts b/apps/api/src/iris-pay/decorators/payment-step.decorator.ts new file mode 100644 index 00000000..c827f3ae --- /dev/null +++ b/apps/api/src/iris-pay/decorators/payment-step.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common' + +export const PAYMENT_STEP_KEY = 'paymentStep' +export const PaymentStep = (step: string) => SetMetadata(PAYMENT_STEP_KEY, step) diff --git a/apps/api/src/iris-pay/decorators/verify-payload.decorator.spec.ts b/apps/api/src/iris-pay/decorators/verify-payload.decorator.spec.ts deleted file mode 100644 index e42ba1e8..00000000 --- a/apps/api/src/iris-pay/decorators/verify-payload.decorator.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing' -import { Controller, Post, Body } from '@nestjs/common' -import { ConfigService } from '@nestjs/config' -import { VerifyPayload } from './verify-payload.decorator' -import { VerifyPayloadGuard } from '../guards/verify-payload.guard' - -// Test controller to verify the decorator works -@Controller('test') -class TestController { - @Post('protected') - @VerifyPayload() - async protectedEndpoint(@Body() body: any) { - return { success: true, body } - } - - @Post('unprotected') - async unprotectedEndpoint(@Body() body: any) { - return { success: true, body } - } -} - -describe('VerifyPayload Decorator', () => { - let module: TestingModule - - const mockConfigService = { - get: jest.fn().mockReturnValue('test-agent-hash'), - } - - beforeEach(async () => { - module = await Test.createTestingModule({ - controllers: [TestController], - providers: [ - VerifyPayloadGuard, - { - provide: ConfigService, - useValue: mockConfigService, - }, - ], - }).compile() - }) - - it('should apply VerifyPayloadGuard to decorated methods', () => { - const controller = module.get(TestController) - expect(controller).toBeDefined() - - // The decorator should have applied the guard - // This is more of a structural test to ensure the decorator is properly configured - const guard = module.get(VerifyPayloadGuard) - expect(guard).toBeDefined() - }) - - it('should be importable and usable', () => { - expect(VerifyPayload).toBeDefined() - expect(typeof VerifyPayload).toBe('function') - }) -}) diff --git a/apps/api/src/iris-pay/decorators/verify-payload.decorator.ts b/apps/api/src/iris-pay/decorators/verify-payload.decorator.ts deleted file mode 100644 index 88c725dc..00000000 --- a/apps/api/src/iris-pay/decorators/verify-payload.decorator.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { applyDecorators, UseGuards } from '@nestjs/common' -import { VerifyPayloadGuard } from '../guards/verify-payload.guard' - -/** - * Decorator that applies the VerifyPayloadGuard to protect iris-pay endpoints. - * This guard validates that the x-pbg-signature header contains a valid HMAC-SHA256 - * signature of the request body using the configured PG_PAYLOAD_SECRET as the secret. - * - * Usage: - * @VerifyPayload() - * @Post('create-payment-session') - * async createPaymentSession() { ... } - */ -export function VerifyPayload() { - return applyDecorators(UseGuards(VerifyPayloadGuard)) -} diff --git a/apps/api/src/iris-pay/dto/complete-payment.dto.ts b/apps/api/src/iris-pay/dto/complete-payment.dto.ts new file mode 100644 index 00000000..49d6a4d6 --- /dev/null +++ b/apps/api/src/iris-pay/dto/complete-payment.dto.ts @@ -0,0 +1,36 @@ +import { ApiProperty } from '@nestjs/swagger' +import { Expose } from 'class-transformer' +import { IsString, IsNumber, IsObject, ValidateNested, IsOptional } from 'class-validator' +import { Type } from 'class-transformer' +import { FinishPaymentMetadataDto } from './finish-payment.dto' + +export class CompletePaymentDto { + @Expose() + @ApiProperty() + @IsString() + status: string + + @Expose() + @ApiProperty() + @IsNumber() + amount: number + + @Expose() + @ApiProperty({ required: false }) + @IsString() + @IsOptional() + billingName?: string + + @Expose() + @ApiProperty({ required: false }) + @IsString() + @IsOptional() + billingEmail?: string + + @Expose() + @ApiProperty({ type: FinishPaymentMetadataDto }) + @IsObject() + @ValidateNested() + @Type(() => FinishPaymentMetadataDto) + metadata: FinishPaymentMetadataDto +} diff --git a/apps/api/src/iris-pay/guards/payment-session.guard.ts b/apps/api/src/iris-pay/guards/payment-session.guard.ts new file mode 100644 index 00000000..270f7628 --- /dev/null +++ b/apps/api/src/iris-pay/guards/payment-session.guard.ts @@ -0,0 +1,29 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common' +import { Reflector } from '@nestjs/core' +import { Request } from 'express' + +import { PaymentSessionService } from '../services/payment-session.service' +import { PAYMENT_STEP_KEY } from '../decorators/payment-step.decorator' + +@Injectable() +export class PaymentSessionGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly paymentSessionService: PaymentSessionService, + ) {} + + canActivate(context: ExecutionContext): boolean { + const expectedStep = this.reflector.get(PAYMENT_STEP_KEY, context.getHandler()) + if (!expectedStep) { + return true + } + + const request = context.switchToHttp().getRequest() + const payload = this.paymentSessionService.validateSession(request, expectedStep) + + // Attach the validated payload to the request for controller access + ;(request as any).paymentSession = payload + + return true + } +} diff --git a/apps/api/src/iris-pay/guards/verify-payload.guard.spec.ts b/apps/api/src/iris-pay/guards/verify-payload.guard.spec.ts deleted file mode 100644 index 3913c84a..00000000 --- a/apps/api/src/iris-pay/guards/verify-payload.guard.spec.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing' -import { ConfigService } from '@nestjs/config' -import { ExecutionContext, UnauthorizedException } from '@nestjs/common' -import { VerifyPayloadGuard } from './verify-payload.guard' -import * as crypto from 'crypto' - -describe('VerifyPayloadGuard', () => { - let guard: VerifyPayloadGuard - let configService: ConfigService - - const mockConfigService = { - get: jest.fn(), - } - - const testSecret = 'test-secret-key' - const testPayload = JSON.stringify({ campaignId: '123', email: 'test@example.com' }) - - const createSignature = (payload: string, secret: string): string => { - return crypto.createHmac('sha256', secret).update(payload, 'utf8').digest('hex') - } - - const createMockExecutionContext = ( - headers: Record = {}, - body: any = null, - ): ExecutionContext => { - return { - switchToHttp: () => ({ - getRequest: () => ({ - headers, - url: '/iris-pay/create-payment-session', - body, - }), - }), - } as ExecutionContext - } - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - VerifyPayloadGuard, - { - provide: ConfigService, - useValue: mockConfigService, - }, - ], - }).compile() - - guard = module.get(VerifyPayloadGuard) - configService = module.get(ConfigService) - }) - - afterEach(() => { - jest.clearAllMocks() - }) - - it('should be defined', () => { - expect(guard).toBeDefined() - }) - - it('should allow access with valid signature', () => { - mockConfigService.get.mockReturnValue(testSecret) - const validSignature = createSignature(testPayload, testSecret) - - const context = createMockExecutionContext( - { - 'x-pbg-signature': validSignature, - }, - JSON.parse(testPayload), - ) - - expect(guard.canActivate(context)).toBe(true) - expect(configService.get).toHaveBeenCalledWith('PG_PAYLOAD_SECRET') - }) - - it('should allow access with valid signature prefixed with sha256=', () => { - mockConfigService.get.mockReturnValue(testSecret) - const validSignature = 'sha256=' + createSignature(testPayload, testSecret) - - const context = createMockExecutionContext( - { - 'x-pbg-signature': validSignature, - }, - JSON.parse(testPayload), - ) - - expect(guard.canActivate(context)).toBe(true) - }) - - it('should throw UnauthorizedException when x-pbg-signature header is missing', () => { - mockConfigService.get.mockReturnValue(testSecret) - - const context = createMockExecutionContext({}, JSON.parse(testPayload)) - - expect(() => guard.canActivate(context)).toThrow(UnauthorizedException) - expect(() => guard.canActivate(context)).toThrow('Missing x-pbg-signature header') - }) - - it('should throw UnauthorizedException when PG_PAYLOAD_SECRET is not configured', () => { - mockConfigService.get.mockReturnValue(undefined) - const signature = createSignature(testPayload, 'any-secret') - - const context = createMockExecutionContext( - { - 'x-pbg-signature': signature, - }, - JSON.parse(testPayload), - ) - - expect(() => guard.canActivate(context)).toThrow(UnauthorizedException) - expect(() => guard.canActivate(context)).toThrow('Signature validation not configured') - }) - - it('should throw UnauthorizedException when signature is invalid', () => { - mockConfigService.get.mockReturnValue(testSecret) - const invalidSignature = createSignature(testPayload, 'wrong-secret') - - const context = createMockExecutionContext( - { - 'x-pbg-signature': invalidSignature, - }, - JSON.parse(testPayload), - ) - - expect(() => guard.canActivate(context)).toThrow(UnauthorizedException) - expect(() => guard.canActivate(context)).toThrow('Invalid signature') - }) - - it('should throw UnauthorizedException when request body is missing', () => { - mockConfigService.get.mockReturnValue(testSecret) - const signature = createSignature(testPayload, testSecret) - - const context = createMockExecutionContext( - { - 'x-pbg-signature': signature, - }, - null, - ) - - expect(() => guard.canActivate(context)).toThrow(UnauthorizedException) - expect(() => guard.canActivate(context)).toThrow( - 'Invalid request body for signature verification', - ) - }) -}) diff --git a/apps/api/src/iris-pay/guards/verify-payload.guard.ts b/apps/api/src/iris-pay/guards/verify-payload.guard.ts deleted file mode 100644 index d96277d0..00000000 --- a/apps/api/src/iris-pay/guards/verify-payload.guard.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { - Injectable, - CanActivate, - ExecutionContext, - UnauthorizedException, - Logger, -} from '@nestjs/common' -import { ConfigService } from '@nestjs/config' -import { Request } from 'express' -import * as crypto from 'crypto' - -@Injectable() -export class VerifyPayloadGuard implements CanActivate { - private readonly logger = new Logger(VerifyPayloadGuard.name) - - constructor(private readonly configService: ConfigService) {} - - canActivate(context: ExecutionContext): boolean { - const request = context.switchToHttp().getRequest() - const signature = request.headers['x-pbg-signature'] as string - const secret = this.configService.get('PG_PAYLOAD_SECRET') - - this.logger.debug(`Verifying payload signature for iris-pay endpoint: ${request.url}`) - - if (!signature) { - this.logger.warn('Missing x-pbg-signature header in iris-pay request') - throw new UnauthorizedException('Missing x-pbg-signature header') - } - - if (!secret) { - this.logger.error('PG_PAYLOAD_SECRET not configured') - throw new UnauthorizedException('Signature validation not configured') - } - - // Get the raw body for signature verification - const rawBody = this.getRawBody(request) - if (!rawBody) { - this.logger.warn('Unable to get raw body for signature verification') - throw new UnauthorizedException('Invalid request body for signature verification') - } - - // Verify the signature - if (!this.verifySignature(rawBody, signature, secret)) { - this.logger.warn('Invalid signature provided for iris-pay endpoint') - throw new UnauthorizedException('Invalid signature') - } - - this.logger.debug('Payload signature validation successful for iris-pay endpoint') - return true - } - - private getRawBody(request: Request): string | Buffer | null { - if (request.body && typeof request.body === 'string') { - return request.body - } - - if (request.body && Buffer.isBuffer(request.body)) { - return request.body - } - - if (request.body && typeof request.body === 'object') { - return JSON.stringify(request.body) - } - - if ((request as any).rawBody) { - return (request as any).rawBody - } - - return null - } - - private verifySignature(payload: string | Buffer, signature: string, secret: string): boolean { - try { - const payloadString = Buffer.isBuffer(payload) ? payload.toString('utf8') : payload - - // Create HMAC signature using SHA256 - const expectedSignature = crypto - .createHmac('sha256', secret) - .update(payloadString) - .digest('hex') - - // Compare signatures (handle both with and without 'sha256=' prefix) - const receivedSignature = signature.startsWith('sha256=') ? signature.slice(7) : signature - - // Use crypto.timingSafeEqual for secure comparison - const expectedBuffer = Buffer.from(expectedSignature, 'hex') - const receivedBuffer = Buffer.from(receivedSignature, 'hex') - - if (expectedBuffer.length !== receivedBuffer.length) { - return false - } - - return crypto.timingSafeEqual(expectedBuffer, receivedBuffer) - } catch (error) { - this.logger.error('Error verifying signature:', error) - return false - } - } -} diff --git a/apps/api/src/iris-pay/iris-pay.controller.spec.ts b/apps/api/src/iris-pay/iris-pay.controller.spec.ts index 826d24ac..1d043dd9 100644 --- a/apps/api/src/iris-pay/iris-pay.controller.spec.ts +++ b/apps/api/src/iris-pay/iris-pay.controller.spec.ts @@ -1,17 +1,35 @@ import { Test, TestingModule } from '@nestjs/testing' import { IrisPayController } from './iris-pay.controller' import { IrisPayService } from './iris-pay.service' +import { PaymentSessionService } from './services/payment-session.service' import { ConfigModule } from '@nestjs/config' import { HttpModule } from '@nestjs/axios' +import { JwtModule } from '@nestjs/jwt' describe('IrisPayController', () => { let controller: IrisPayController + const mockIrisPayService = { + createCheckout: jest.fn(), + verifyPayment: jest.fn(), + finishPaymentSession: jest.fn(), + } + + const mockPaymentSessionService = { + createInitialSession: jest.fn(), + validateSession: jest.fn(), + upgradeSession: jest.fn(), + clearSession: jest.fn(), + } + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - imports: [ConfigModule, HttpModule], + imports: [ConfigModule, HttpModule, JwtModule.register({})], controllers: [IrisPayController], - providers: [IrisPayService], + providers: [ + { provide: IrisPayService, useValue: mockIrisPayService }, + { provide: PaymentSessionService, useValue: mockPaymentSessionService }, + ], }).compile() controller = module.get(IrisPayController) diff --git a/apps/api/src/iris-pay/iris-pay.controller.ts b/apps/api/src/iris-pay/iris-pay.controller.ts index f0ef69af..e61d787f 100644 --- a/apps/api/src/iris-pay/iris-pay.controller.ts +++ b/apps/api/src/iris-pay/iris-pay.controller.ts @@ -1,42 +1,98 @@ -import { Controller, Get, Post, Body, Req, RawBodyRequest, Query, Param, HttpCode } from '@nestjs/common' +import { + Controller, + Get, + Post, + Body, + Req, + Res, + RawBodyRequest, + Query, + HttpCode, + UseGuards, + Logger, +} from '@nestjs/common' +import { Request, Response } from 'express' import { IrisPayService } from './iris-pay.service' import { IRISCreateCheckoutSessionDto } from './dto/create-iris-pay.dto' +import { CompletePaymentDto } from './dto/complete-payment.dto' import { FinishPaymentDto } from './dto/finish-payment.dto' import { Public } from 'nest-keycloak-connect' -import { VerifyPayload } from './decorators/verify-payload.decorator' +import { PaymentSessionGuard } from './guards/payment-session.guard' +import { PaymentStep } from './decorators/payment-step.decorator' +import { PaymentSessionService } from './services/payment-session.service' import { ApiTags } from '@nestjs/swagger' @Controller('iris-pay') @ApiTags() export class IrisPayController { - constructor(private readonly irisPayService: IrisPayService) {} + constructor( + private readonly irisPayService: IrisPayService, + private readonly paymentSessionService: PaymentSessionService, + ) {} + + @Post('start-session') + @Public() + async startSession(@Res({ passthrough: true }) res: Response) { + this.paymentSessionService.createInitialSession(res) + return { status: 'ok' } + } @Post('create-payment-session') @Public() - @VerifyPayload() + @UseGuards(PaymentSessionGuard) + @PaymentStep('initialSession') async createIRISCheckoutSession( @Body() irisCreateCustomerDto: IRISCreateCheckoutSessionDto, + @Res({ passthrough: true }) res: Response, ): Promise<{ hookHash: string; userHash: string }> { - return await this.irisPayService.createCheckout(irisCreateCustomerDto) + const result = await this.irisPayService.createCheckout(irisCreateCustomerDto) + this.paymentSessionService.upgradeSession(res, { + hookHash: result.hookHash, + userHash: result.userHash, + }) + return result } - @Post('verify-payment') + @Post('complete') @HttpCode(200) @Public() - @VerifyPayload() - async verifyWebhook(@Body() body: { hookHash: string }) { - return await this.irisPayService.verifyPayment(body) - } + @UseGuards(PaymentSessionGuard) + @PaymentStep('paymentSessionCreated') + async completePayment( + @Body() completePaymentDto: CompletePaymentDto, + @Req() req: Request, + @Res({ passthrough: true }) res: Response, + ): Promise<{ donationId?: string; status: string }> { + // Extract hookHash from the validated JWT session (tamper-proof) + const session = (req as any).paymentSession + const hookHash: string = session.hookHash - @Post('finish') - @Public() - @VerifyPayload() - async finishPaymentSession( - @Body() finishPaymentDto: FinishPaymentDto, - ): Promise<{ donationId?: string }> { - const donationId = await this.irisPayService.finishPaymentSession(finishPaymentDto) - return { donationId } + Logger.debug('Completing payment from session', { hookHash }) + + // Verify payment status with Iris API + const verifyResult = await this.irisPayService.verifyPayment({ hookHash }) + + // Build the finish DTO using hookHash from JWT and remaining data from request body + const finishDto: FinishPaymentDto = Object.assign(new FinishPaymentDto(), { + hookHash, + status: verifyResult?.status || completePaymentDto.status, + amount: completePaymentDto.amount, + billingName: completePaymentDto.billingName, + billingEmail: completePaymentDto.billingEmail, + metadata: completePaymentDto.metadata, + }) + + const donationId = await this.irisPayService.finishPaymentSession(finishDto) + + // Clear the session cookie -- payment is complete + this.paymentSessionService.clearSession(res) + + // Map Iris status (CONFIRMED/FAILED/WAITING) to unified PaymentStatus (succeeded/declined/waiting) + const irisStatus = verifyResult?.status || completePaymentDto.status + const unifiedStatus = this.irisPayService.mapStatusToPaymentStatus(irisStatus) + + return { donationId, status: unifiedStatus } } @Get('webhook') diff --git a/apps/api/src/iris-pay/iris-pay.module.ts b/apps/api/src/iris-pay/iris-pay.module.ts index 3794d8f2..cfac03c9 100644 --- a/apps/api/src/iris-pay/iris-pay.module.ts +++ b/apps/api/src/iris-pay/iris-pay.module.ts @@ -3,14 +3,23 @@ import { IrisPayService } from './iris-pay.service' import { IrisPayController } from './iris-pay.controller' import { ConfigModule } from '@nestjs/config' import { HttpModule } from '@nestjs/axios' +import { JwtModule } from '@nestjs/jwt' import { PrismaModule } from '../prisma/prisma.module' import { CampaignModule } from '../campaign/campaign.module' import { DonationsModule } from '../donations/donations.module' -import { VerifyPayloadGuard } from './guards/verify-payload.guard' +import { PaymentSessionService } from './services/payment-session.service' +import { PaymentSessionGuard } from './guards/payment-session.guard' @Module({ - imports: [ConfigModule, HttpModule, PrismaModule, CampaignModule, DonationsModule], + imports: [ + ConfigModule, + HttpModule, + JwtModule.register({}), + PrismaModule, + CampaignModule, + DonationsModule, + ], controllers: [IrisPayController], - providers: [IrisPayService, VerifyPayloadGuard], + providers: [IrisPayService, PaymentSessionService, PaymentSessionGuard], }) export class IrisPayModule {} diff --git a/apps/api/src/iris-pay/iris-pay.service.spec.ts b/apps/api/src/iris-pay/iris-pay.service.spec.ts index f61e2fc9..ff757448 100644 --- a/apps/api/src/iris-pay/iris-pay.service.spec.ts +++ b/apps/api/src/iris-pay/iris-pay.service.spec.ts @@ -108,7 +108,7 @@ describe('IrisPayService', () => { netAmount: 1000, chargedAmount: 1000, currency: 'bgn', - paymentProvider: PaymentProvider.none, + paymentProvider: PaymentProvider.irispay, billingName: 'John Doe', billingEmail: 'john.doe@example.com', personId: 'person-123', diff --git a/apps/api/src/iris-pay/iris-pay.service.ts b/apps/api/src/iris-pay/iris-pay.service.ts index 668803ac..9d3b435a 100644 --- a/apps/api/src/iris-pay/iris-pay.service.ts +++ b/apps/api/src/iris-pay/iris-pay.service.ts @@ -172,7 +172,7 @@ export class IrisPayService { return await this.donationsService.updateDonationPayment(campaign, paymentData, paymentStatus) } - private mapStatusToPaymentStatus(status: string): PaymentStatus { + mapStatusToPaymentStatus(status: string): PaymentStatus { switch (status.toUpperCase()) { case 'CONFIRMED': return PaymentStatus.succeeded diff --git a/apps/api/src/iris-pay/services/payment-session.service.ts b/apps/api/src/iris-pay/services/payment-session.service.ts new file mode 100644 index 00000000..0e003128 --- /dev/null +++ b/apps/api/src/iris-pay/services/payment-session.service.ts @@ -0,0 +1,92 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import { JwtService } from '@nestjs/jwt' +import { Request, Response } from 'express' + +export interface PaymentSessionPayload { + step: 'initialSession' | 'paymentSessionCreated' + hookHash?: string + userHash?: string +} + +const COOKIE_NAME = 'payment_jwt' +const SESSION_TTL_SECONDS = 5 * 60 // 5 minutes + +@Injectable() +export class PaymentSessionService { + private readonly secret: string + + constructor( + private readonly configService: ConfigService, + private readonly jwtService: JwtService, + ) { + this.secret = this.configService.get('iris.paymentSessionSecret', '') + } + + createInitialSession(res: Response): void { + const payload: PaymentSessionPayload = { step: 'initialSession' } + const token = this.jwtService.sign(payload, { + secret: this.secret, + expiresIn: SESSION_TTL_SECONDS, + }) + this.setCookie(res, token) + } + + validateSession(req: Request, expectedStep: string): PaymentSessionPayload { + const token = req.cookies?.[COOKIE_NAME] + if (!token) { + throw new UnauthorizedException('Missing payment session') + } + + let payload: PaymentSessionPayload + try { + payload = this.jwtService.verify(token, { + secret: this.secret, + }) + } catch { + throw new UnauthorizedException('Invalid or expired payment session') + } + + if (payload.step !== expectedStep) { + throw new UnauthorizedException( + `Invalid payment session step: expected '${expectedStep}', got '${payload.step}'`, + ) + } + + return payload + } + + upgradeSession(res: Response, data: { hookHash: string; userHash: string }): void { + const payload: PaymentSessionPayload = { + step: 'paymentSessionCreated', + hookHash: data.hookHash, + userHash: data.userHash, + } + const token = this.jwtService.sign(payload, { + secret: this.secret, + expiresIn: SESSION_TTL_SECONDS, + }) + this.setCookie(res, token) + } + + clearSession(res: Response): void { + res.clearCookie(COOKIE_NAME, this.getCookieOptions()) + } + + private setCookie(res: Response, token: string): void { + res.cookie(COOKIE_NAME, token, { + ...this.getCookieOptions(), + maxAge: SESSION_TTL_SECONDS * 1000, + }) + } + + private getCookieOptions() { + const isProduction = this.configService.get('APP_ENV') !== 'development' + return { + httpOnly: true, + secure: isProduction, + sameSite: isProduction ? ('none' as const) : ('lax' as const), + path: '/api/v1/iris-pay', + } + } +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 23b937e9..fb98cbb1 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,5 +1,6 @@ import { NestFactory } from '@nestjs/core' import { Logger, LogLevel, VersioningType } from '@nestjs/common' +import cookieParser from 'cookie-parser' import { AppModule } from './app/app.module' import { setupCors } from './config/cors.config' @@ -26,6 +27,7 @@ async function bootstrap() { logger: isDevConfig ? ['debug', 'log', 'verbose', ...logLevels] : logLevels, }) + app.use(cookieParser()) app.setGlobalPrefix(globalPrefix) app.enableVersioning({ type: VersioningType.URI }) app.useLogger(app.get(MyLogger)) diff --git a/package.json b/package.json index af357051..836fc458 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "cache-manager": "^5.2.3", "class-transformer": "0.5.1", "class-validator": "0.14.0", + "cookie-parser": "^1.4.7", "exceljs": "4.3.0", "handlebars": "4.7.7", "helmet": "^7.1.0", @@ -80,6 +81,7 @@ "@nrwl/node": "^15.5.1", "@nrwl/tao": "^15.5.1", "@nrwl/workspace": "^15.5.1", + "@types/cookie-parser": "^1.4.10", "@types/express": "4.17.13", "@types/faker": "5.5.9", "@types/jest": "29.2.3", diff --git a/yarn.lock b/yarn.lock index c41dd7c9..b3b5f5d7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5083,6 +5083,15 @@ __metadata: languageName: node linkType: hard +"@types/cookie-parser@npm:^1.4.10": + version: 1.4.10 + resolution: "@types/cookie-parser@npm:1.4.10" + peerDependencies: + "@types/express": "*" + checksum: 1f37b5a4115dbfd4b7bbea2d874fbf9495eca8c3e8c87fa7e38c50f9fff66222377c911cfdc7a1ea08855e822919c5534ebbcc4bf25b596bc6f7270e403483d9 + languageName: node + linkType: hard + "@types/cookie@npm:^0.4.1": version: 0.4.1 resolution: "@types/cookie@npm:0.4.1" @@ -7447,6 +7456,16 @@ __metadata: languageName: node linkType: hard +"cookie-parser@npm:^1.4.7": + version: 1.4.7 + resolution: "cookie-parser@npm:1.4.7" + dependencies: + cookie: 0.7.2 + cookie-signature: 1.0.6 + checksum: 243fa13f217e793d20a57675e6552beea08c5989fcc68495d543997a31646875335e0e82d687b42dcfd466df57891d22bae7f5ba6ab33b7705ed2dd6eb989105 + languageName: node + linkType: hard + "cookie-signature@npm:1.0.6": version: 1.0.6 resolution: "cookie-signature@npm:1.0.6" @@ -7461,6 +7480,13 @@ __metadata: languageName: node linkType: hard +"cookie@npm:0.7.2": + version: 0.7.2 + resolution: "cookie@npm:0.7.2" + checksum: 9bf8555e33530affd571ea37b615ccad9b9a34febbf2c950c86787088eb00a8973690833b0f8ebd6b69b753c62669ea60cec89178c1fb007bf0749abed74f93e + languageName: node + linkType: hard + "cookie@npm:^0.4.1, cookie@npm:~0.4.1": version: 0.4.2 resolution: "cookie@npm:0.4.2" @@ -13628,6 +13654,7 @@ __metadata: "@sendgrid/client": 7.6.2 "@sendgrid/mail": 7.6.0 "@sentry/node": ^7.60.1 + "@types/cookie-parser": ^1.4.10 "@types/cron": ^2.0.0 "@types/express": 4.17.13 "@types/faker": 5.5.9 @@ -13644,6 +13671,7 @@ __metadata: cache-manager: ^5.2.3 class-transformer: 0.5.1 class-validator: 0.14.0 + cookie-parser: ^1.4.7 eslint: ^8.44.0 eslint-config-prettier: ^8.8.0 exceljs: 4.3.0 From 5d98c9fe4a2e601c10e61aa1f97675f88047c387 Mon Sep 17 00:00:00 2001 From: Aleksandar Petkov Date: Wed, 1 Apr 2026 17:27:35 +0300 Subject: [PATCH 06/22] feat: Track in backend whether the jwt cookie has been consumed --- apps/api/src/iris-pay/iris-pay.controller.ts | 9 ++++--- .../services/payment-session.service.ts | 24 +++++++++++++++++-- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/apps/api/src/iris-pay/iris-pay.controller.ts b/apps/api/src/iris-pay/iris-pay.controller.ts index e61d787f..533e975f 100644 --- a/apps/api/src/iris-pay/iris-pay.controller.ts +++ b/apps/api/src/iris-pay/iris-pay.controller.ts @@ -5,7 +5,6 @@ import { Body, Req, Res, - RawBodyRequest, Query, HttpCode, UseGuards, @@ -44,8 +43,10 @@ export class IrisPayController { @PaymentStep('initialSession') async createIRISCheckoutSession( @Body() irisCreateCustomerDto: IRISCreateCheckoutSessionDto, + @Req() req: Request, @Res({ passthrough: true }) res: Response, ): Promise<{ hookHash: string; userHash: string }> { + await this.paymentSessionService.consumeSession((req as any).paymentSession) const result = await this.irisPayService.createCheckout(irisCreateCustomerDto) this.paymentSessionService.upgradeSession(res, { hookHash: result.hookHash, @@ -66,6 +67,7 @@ export class IrisPayController { ): Promise<{ donationId?: string; status: string }> { // Extract hookHash from the validated JWT session (tamper-proof) const session = (req as any).paymentSession + await this.paymentSessionService.consumeSession(session) const hookHash: string = session.hookHash Logger.debug('Completing payment from session', { hookHash }) @@ -97,7 +99,8 @@ export class IrisPayController { @Get('webhook') @Public() - async webhookEndpoint(@Req() req: RawBodyRequest, @Query('state') state: string) { - console.log(`Webhook ${state} executed`) + async webhookEndpoint(@Query('state') state: string) { + Logger.debug('Iris webhook received', { state }) + return { status: 'ok' } } } diff --git a/apps/api/src/iris-pay/services/payment-session.service.ts b/apps/api/src/iris-pay/services/payment-session.service.ts index 0e003128..47d94ffd 100644 --- a/apps/api/src/iris-pay/services/payment-session.service.ts +++ b/apps/api/src/iris-pay/services/payment-session.service.ts @@ -1,10 +1,14 @@ -import { Injectable, UnauthorizedException } from '@nestjs/common' +import { Inject, Injectable, UnauthorizedException } from '@nestjs/common' +import { CACHE_MANAGER } from '@nestjs/cache-manager' +import { Cache } from 'cache-manager' import { ConfigService } from '@nestjs/config' import { JwtService } from '@nestjs/jwt' +import { randomUUID } from 'crypto' import { Request, Response } from 'express' export interface PaymentSessionPayload { step: 'initialSession' | 'paymentSessionCreated' + jti?: string hookHash?: string userHash?: string } @@ -19,12 +23,13 @@ export class PaymentSessionService { constructor( private readonly configService: ConfigService, private readonly jwtService: JwtService, + @Inject(CACHE_MANAGER) private readonly cacheManager: Cache, ) { this.secret = this.configService.get('iris.paymentSessionSecret', '') } createInitialSession(res: Response): void { - const payload: PaymentSessionPayload = { step: 'initialSession' } + const payload: PaymentSessionPayload = { step: 'initialSession', jti: randomUUID() } const token = this.jwtService.sign(payload, { secret: this.secret, expiresIn: SESSION_TTL_SECONDS, @@ -56,9 +61,24 @@ export class PaymentSessionService { return payload } + async consumeSession(payload: PaymentSessionPayload): Promise { + if (!payload.jti) { + throw new UnauthorizedException('Invalid payment session: missing jti') + } + + const cacheKey = `iris-pay:jti:${payload.jti}` + const consumed = await this.cacheManager.get(cacheKey) + if (consumed) { + throw new UnauthorizedException('Payment session already used') + } + + await this.cacheManager.set(cacheKey, true, SESSION_TTL_SECONDS * 1000) + } + upgradeSession(res: Response, data: { hookHash: string; userHash: string }): void { const payload: PaymentSessionPayload = { step: 'paymentSessionCreated', + jti: randomUUID(), hookHash: data.hookHash, userHash: data.userHash, } From 00cdd71f8ce94e7fdf4108b7d4b465f73e0a5506 Mon Sep 17 00:00:00 2001 From: Aleksandar Petkov Date: Mon, 20 Apr 2026 01:11:33 +0300 Subject: [PATCH 07/22] refactor: IRISPay payment --- .../src/iris-pay/dto/create-iris-pay.dto.ts | 53 +++- .../src/iris-pay/iris-pay.controller.spec.ts | 103 +++++++ apps/api/src/iris-pay/iris-pay.controller.ts | 94 +++--- .../api/src/iris-pay/iris-pay.service.spec.ts | 289 +++++++++++------- apps/api/src/iris-pay/iris-pay.service.ts | 195 +++++++++++- .../services/payment-session.service.ts | 8 +- 6 files changed, 585 insertions(+), 157 deletions(-) diff --git a/apps/api/src/iris-pay/dto/create-iris-pay.dto.ts b/apps/api/src/iris-pay/dto/create-iris-pay.dto.ts index 578d0ced..9d105775 100644 --- a/apps/api/src/iris-pay/dto/create-iris-pay.dto.ts +++ b/apps/api/src/iris-pay/dto/create-iris-pay.dto.ts @@ -1,23 +1,66 @@ import { ApiProperty } from '@nestjs/swagger' import { Expose } from 'class-transformer' -import { IsOptional, IsString } from 'class-validator' +import { + IsBoolean, + IsEmail, + IsEnum, + IsInt, + IsOptional, + IsString, + IsUUID, + Min, + ValidateIf, +} from 'class-validator' +import { DonationType } from '@prisma/client' import { IrisCreateCustomerDto } from './create-iris-customer' export class IRISCreateCheckoutSessionDto extends IrisCreateCustomerDto { @ApiProperty() @IsString() @Expose() - campaignId: string + campaignId!: string + + @ApiProperty() + @IsInt() + @Min(1) + @Expose() + amount!: number + + @ApiProperty({ enum: DonationType }) + @IsEnum(DonationType) + @Expose() + type!: DonationType + + @ApiProperty() + @IsBoolean() + @Expose() + isAnonymous!: boolean + + @ApiProperty({ required: false }) + @ValidateIf((obj: IRISCreateCheckoutSessionDto) => !obj.isAnonymous) + @IsUUID() + @Expose() + personId?: string @ApiProperty() @IsString() @Expose() - @IsOptional() - successUrl: string + billingName!: string @ApiProperty() + @IsEmail() + @Expose() + billingEmail!: string + + @ApiProperty({ required: false }) + @IsString() + @Expose() + @IsOptional() + successUrl?: string + + @ApiProperty({ required: false }) @IsString() @Expose() @IsOptional() - errorUrl: string + errorUrl?: string } diff --git a/apps/api/src/iris-pay/iris-pay.controller.spec.ts b/apps/api/src/iris-pay/iris-pay.controller.spec.ts index 1d043dd9..1120f5f1 100644 --- a/apps/api/src/iris-pay/iris-pay.controller.spec.ts +++ b/apps/api/src/iris-pay/iris-pay.controller.spec.ts @@ -2,9 +2,17 @@ import { Test, TestingModule } from '@nestjs/testing' import { IrisPayController } from './iris-pay.controller' import { IrisPayService } from './iris-pay.service' import { PaymentSessionService } from './services/payment-session.service' +import { PaymentSessionGuard } from './guards/payment-session.guard' +import { Reflector } from '@nestjs/core' import { ConfigModule } from '@nestjs/config' import { HttpModule } from '@nestjs/axios' import { JwtModule } from '@nestjs/jwt' +import { PaymentStatus } from '@prisma/client' +import { + ConflictException, + NotFoundException, + ServiceUnavailableException, +} from '@nestjs/common' describe('IrisPayController', () => { let controller: IrisPayController @@ -13,11 +21,13 @@ describe('IrisPayController', () => { createCheckout: jest.fn(), verifyPayment: jest.fn(), finishPaymentSession: jest.fn(), + finalizePayment: jest.fn(), } const mockPaymentSessionService = { createInitialSession: jest.fn(), validateSession: jest.fn(), + consumeSession: jest.fn(), upgradeSession: jest.fn(), clearSession: jest.fn(), } @@ -29,13 +39,106 @@ describe('IrisPayController', () => { providers: [ { provide: IrisPayService, useValue: mockIrisPayService }, { provide: PaymentSessionService, useValue: mockPaymentSessionService }, + { provide: PaymentSessionGuard, useValue: { canActivate: () => true } }, + Reflector, ], }).compile() controller = module.get(IrisPayController) }) + afterEach(() => jest.clearAllMocks()) + it('should be defined', () => { expect(controller).toBeDefined() }) + + describe('/finalize', () => { + const paymentId = '11111111-1111-1111-1111-111111111111' + const reqWithSession = { paymentSession: { paymentId } } as unknown as any + const res = {} as unknown as any + + it('returns status+donationId on success and clears session', async () => { + mockIrisPayService.finalizePayment.mockResolvedValue({ + status: PaymentStatus.succeeded, + donationId: 'don-1', + reason: '', + }) + + const result = await controller.finalize(reqWithSession, res) + + expect(mockIrisPayService.finalizePayment).toHaveBeenCalledWith(paymentId) + expect(mockPaymentSessionService.clearSession).toHaveBeenCalledWith(res) + expect(result).toEqual({ + status: PaymentStatus.succeeded, + donationId: 'don-1', + reason: '', + }) + }) + + it('throws 404 and clears session when JWT has no paymentId', async () => { + const req = { paymentSession: {} } as unknown as any + await expect(controller.finalize(req, res)).rejects.toBeInstanceOf(NotFoundException) + expect(mockPaymentSessionService.clearSession).toHaveBeenCalled() + }) + + it('throws ServiceUnavailable and keeps session on iris_unavailable', async () => { + mockIrisPayService.finalizePayment.mockRejectedValue( + new ServiceUnavailableException('iris_unavailable'), + ) + await expect(controller.finalize(reqWithSession, res)).rejects.toBeInstanceOf( + ServiceUnavailableException, + ) + expect(mockPaymentSessionService.clearSession).not.toHaveBeenCalled() + }) + + it('throws Conflict on currency_mismatch and clears session', async () => { + mockIrisPayService.finalizePayment.mockRejectedValue( + new ConflictException('currency_mismatch'), + ) + await expect(controller.finalize(reqWithSession, res)).rejects.toBeInstanceOf( + ConflictException, + ) + expect(mockPaymentSessionService.clearSession).toHaveBeenCalled() + }) + + it('throws NotFound on unknown_payment and clears session', async () => { + mockIrisPayService.finalizePayment.mockRejectedValue(new NotFoundException('unknown_payment')) + await expect(controller.finalize(reqWithSession, res)).rejects.toBeInstanceOf( + NotFoundException, + ) + expect(mockPaymentSessionService.clearSession).toHaveBeenCalled() + }) + }) + + describe('/webhook', () => { + it('returns 200 and calls finalizePayment with state', async () => { + mockIrisPayService.finalizePayment.mockResolvedValue({ + status: PaymentStatus.succeeded, + donationId: 'don-1', + }) + const result = await controller.webhookEndpoint('payment-id-1') + expect(mockIrisPayService.finalizePayment).toHaveBeenCalledWith('payment-id-1') + expect(result).toEqual({ ok: true }) + }) + + it('swallows errors and still returns 200 so IRIS does not spam retries', async () => { + mockIrisPayService.finalizePayment.mockRejectedValue(new Error('boom')) + const result = await controller.webhookEndpoint('payment-id-2') + expect(result).toEqual({ ok: true }) + }) + + it('does not call finalizePayment when state is missing', async () => { + const result = await controller.webhookEndpoint('') + expect(mockIrisPayService.finalizePayment).not.toHaveBeenCalled() + expect(result).toEqual({ ok: true }) + }) + }) + + describe('/complete (deprecated)', () => { + it('returns deprecated status', async () => { + const result = await controller.completePayment({} as any) + expect(result).toEqual({ status: 'deprecated' }) + }) + }) }) diff --git a/apps/api/src/iris-pay/iris-pay.controller.ts b/apps/api/src/iris-pay/iris-pay.controller.ts index 533e975f..f3bfcb5a 100644 --- a/apps/api/src/iris-pay/iris-pay.controller.ts +++ b/apps/api/src/iris-pay/iris-pay.controller.ts @@ -9,12 +9,14 @@ import { HttpCode, UseGuards, Logger, + NotFoundException, + ConflictException, + ServiceUnavailableException, } from '@nestjs/common' import { Request, Response } from 'express' import { IrisPayService } from './iris-pay.service' import { IRISCreateCheckoutSessionDto } from './dto/create-iris-pay.dto' import { CompletePaymentDto } from './dto/complete-payment.dto' -import { FinishPaymentDto } from './dto/finish-payment.dto' import { Public } from 'nest-keycloak-connect' import { PaymentSessionGuard } from './guards/payment-session.guard' import { PaymentStep } from './decorators/payment-step.decorator' @@ -45,62 +47,82 @@ export class IrisPayController { @Body() irisCreateCustomerDto: IRISCreateCheckoutSessionDto, @Req() req: Request, @Res({ passthrough: true }) res: Response, - ): Promise<{ hookHash: string; userHash: string }> { + ): Promise<{ paymentId: string; hookHash: string; userHash: string }> { await this.paymentSessionService.consumeSession((req as any).paymentSession) const result = await this.irisPayService.createCheckout(irisCreateCustomerDto) - this.paymentSessionService.upgradeSession(res, { - hookHash: result.hookHash, - userHash: result.userHash, - }) + this.paymentSessionService.upgradeSession(res, { paymentId: result.paymentId }) return result } - @Post('complete') + @Post('finalize') @HttpCode(200) @Public() @UseGuards(PaymentSessionGuard) @PaymentStep('paymentSessionCreated') - async completePayment( - @Body() completePaymentDto: CompletePaymentDto, + async finalize( @Req() req: Request, @Res({ passthrough: true }) res: Response, - ): Promise<{ donationId?: string; status: string }> { - // Extract hookHash from the validated JWT session (tamper-proof) + ): Promise< + | { status: string; donationId?: string; reason?: string } + | { error: string; reason?: string } + > { const session = (req as any).paymentSession - await this.paymentSessionService.consumeSession(session) - const hookHash: string = session.hookHash + const paymentId: string | undefined = session?.paymentId + if (!paymentId) { + this.paymentSessionService.clearSession(res) + throw new NotFoundException({ error: 'unknown_payment' }) + } - Logger.debug('Completing payment from session', { hookHash }) + Logger.debug('Finalizing IRIS payment', { paymentId }) - // Verify payment status with Iris API - const verifyResult = await this.irisPayService.verifyPayment({ hookHash }) + try { + const result = await this.irisPayService.finalizePayment(paymentId) + this.paymentSessionService.clearSession(res) + return { + status: result.status, + donationId: result.donationId, + reason: result.reason, + } + } catch (err) { + if (err instanceof ServiceUnavailableException) { + // Keep session — FE falls back to pending, webhook reconciles. + throw err + } + this.paymentSessionService.clearSession(res) + if (err instanceof NotFoundException || err instanceof ConflictException) { + throw err + } + Logger.error(`Unexpected /finalize error for paymentId=${paymentId}: ${err}`) + throw err + } + } - // Build the finish DTO using hookHash from JWT and remaining data from request body - const finishDto: FinishPaymentDto = Object.assign(new FinishPaymentDto(), { - hookHash, - status: verifyResult?.status || completePaymentDto.status, - amount: completePaymentDto.amount, - billingName: completePaymentDto.billingName, - billingEmail: completePaymentDto.billingEmail, - metadata: completePaymentDto.metadata, + @Post('complete') + @HttpCode(200) + @Public() + async completePayment( + @Body() completePaymentDto: CompletePaymentDto, + ): Promise<{ status: string }> { + // Deprecated: superseded by /finalize. Kept as a no-op so older + // deployed frontends don't break during rollout. Remove in a follow-up PR. + Logger.warn('Deprecated /iris-pay/complete endpoint called', { + hookHash: (completePaymentDto as unknown as { hookHash?: string })?.hookHash, }) - - const donationId = await this.irisPayService.finishPaymentSession(finishDto) - - // Clear the session cookie -- payment is complete - this.paymentSessionService.clearSession(res) - - // Map Iris status (CONFIRMED/FAILED/WAITING) to unified PaymentStatus (succeeded/declined/waiting) - const irisStatus = verifyResult?.status || completePaymentDto.status - const unifiedStatus = this.irisPayService.mapStatusToPaymentStatus(irisStatus) - - return { donationId, status: unifiedStatus } + return { status: 'deprecated' } } @Get('webhook') @Public() async webhookEndpoint(@Query('state') state: string) { Logger.debug('Iris webhook received', { state }) - return { status: 'ok' } + if (!state) { + return { ok: true } + } + try { + await this.irisPayService.finalizePayment(state) + } catch (error) { + Logger.warn(`Iris webhook finalize failed for state=${state}: ${error}`) + } + return { ok: true } } } diff --git a/apps/api/src/iris-pay/iris-pay.service.spec.ts b/apps/api/src/iris-pay/iris-pay.service.spec.ts index ff757448..395069b4 100644 --- a/apps/api/src/iris-pay/iris-pay.service.spec.ts +++ b/apps/api/src/iris-pay/iris-pay.service.spec.ts @@ -5,8 +5,13 @@ import { HttpService } from '@nestjs/axios' import { PrismaService } from '../prisma/prisma.service' import { CampaignService } from '../campaign/campaign.service' import { DonationsService } from '../donations/donations.service' -import { PaymentStatus, PaymentProvider, Currency } from '@prisma/client' +import { PaymentStatus, PaymentProvider, Currency, DonationType } from '@prisma/client' import { FinishPaymentDto } from './dto/finish-payment.dto' +import { + ConflictException, + NotFoundException, + ServiceUnavailableException, +} from '@nestjs/common' describe('IrisPayService', () => { let service: IrisPayService @@ -24,10 +29,21 @@ describe('IrisPayService', () => { }, } - const mockPrismaService = {} + const mockPrismaService = { + payment: { + findUnique: jest.fn(), + }, + donationMetadata: { + upsert: jest.fn().mockResolvedValue({}), + }, + $transaction: jest.fn(), + vault: { findFirstOrThrow: jest.fn() }, + irisCustomer: { findFirst: jest.fn(), create: jest.fn() }, + } const mockCampaignService = { getCampaignById: jest.fn(), + validateCampaign: jest.fn(), } const mockDonationsService = { @@ -38,26 +54,11 @@ describe('IrisPayService', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ IrisPayService, - { - provide: ConfigService, - useValue: mockConfigService, - }, - { - provide: HttpService, - useValue: mockHttpService, - }, - { - provide: PrismaService, - useValue: mockPrismaService, - }, - { - provide: CampaignService, - useValue: mockCampaignService, - }, - { - provide: DonationsService, - useValue: mockDonationsService, - }, + { provide: ConfigService, useValue: mockConfigService }, + { provide: HttpService, useValue: mockHttpService }, + { provide: PrismaService, useValue: mockPrismaService }, + { provide: CampaignService, useValue: mockCampaignService }, + { provide: DonationsService, useValue: mockDonationsService }, ], }).compile() @@ -74,7 +75,166 @@ describe('IrisPayService', () => { expect(service).toBeDefined() }) - describe('finishPaymentSession', () => { + describe('parseIrisSum', () => { + it('parses integer strings to minor units', () => { + expect(service.parseIrisSum('10')).toBe(1000) + }) + + it('parses decimals with dot', () => { + expect(service.parseIrisSum('10.50')).toBe(1050) + expect(service.parseIrisSum('10.5')).toBe(1050) + }) + + it('parses decimals with comma', () => { + expect(service.parseIrisSum('10,50')).toBe(1050) + }) + + it('trims whitespace', () => { + expect(service.parseIrisSum(' 10.50 ')).toBe(1050) + }) + + it('rounds half-even to nearest cent', () => { + expect(service.parseIrisSum('10.555')).toBe(1056) + }) + + it('rejects empty, NaN, and negative', () => { + expect(() => service.parseIrisSum('')).toThrow() + expect(() => service.parseIrisSum('abc')).toThrow() + expect(() => service.parseIrisSum('-1')).toThrow() + }) + }) + + describe('mapStatusToPaymentStatus', () => { + it.each([ + ['CONFIRMED', PaymentStatus.succeeded], + ['FAILED', PaymentStatus.declined], + ['WAITING', PaymentStatus.waiting], + ['UNKNOWN', PaymentStatus.waiting], + ])('maps %s to %s', (input, expected) => { + expect(service.mapStatusToPaymentStatus(input)).toBe(expected) + }) + }) + + describe('finalizePayment', () => { + const paymentId = '11111111-1111-1111-1111-111111111111' + const hookHash = 'hook-abc' + const campaign = { id: 'camp-1', currency: Currency.BGN } + + const basePayment = { + id: paymentId, + extPaymentIntentId: hookHash, + amount: 1000, + billingName: 'John Doe', + billingEmail: 'john@example.com', + donations: [ + { + id: 'don-1', + personId: 'person-1', + type: DonationType.donation, + targetVault: { campaign }, + metadata: null, + }, + ], + } + + const baseIrisResult = { + sum: '10.00', + currency: 'BGN', + status: 'CONFIRMED', + id: 'iris-tx-1', + payerName: 'P', + payerIban: 'IBAN', + payerBank: { bankHash: 'h', name: 'n', country: 'c' }, + payeeName: 'P2', + payeeIban: 'IBAN2', + payeeBank: { bankHash: 'h', name: 'n', country: 'c' }, + reasonForFail: '', + } + + beforeEach(() => { + mockPrismaService.payment.findUnique.mockResolvedValue(basePayment) + mockHttpService.axiosRef.get.mockResolvedValue({ data: baseIrisResult }) + mockDonationsService.updateDonationPayment.mockResolvedValue('don-1') + }) + + it('throws unknown_payment when Payment row missing', async () => { + mockPrismaService.payment.findUnique.mockResolvedValue(null) + await expect(service.finalizePayment(paymentId)).rejects.toBeInstanceOf(NotFoundException) + }) + + it('throws iris_unavailable when verifyPayment rejects', async () => { + mockHttpService.axiosRef.get.mockRejectedValue(new Error('network')) + await expect(service.finalizePayment(paymentId)).rejects.toBeInstanceOf( + ServiceUnavailableException, + ) + }) + + it('throws currency_mismatch on currency divergence', async () => { + mockHttpService.axiosRef.get.mockResolvedValue({ + data: { ...baseIrisResult, currency: 'EUR' }, + }) + await expect(service.finalizePayment(paymentId)).rejects.toBeInstanceOf(ConflictException) + }) + + it('uses IRIS amount even when it differs from DB amount', async () => { + mockHttpService.axiosRef.get.mockResolvedValue({ + data: { ...baseIrisResult, sum: '15.00' }, + }) + await service.finalizePayment(paymentId) + expect(donationsService.updateDonationPayment).toHaveBeenCalledWith( + campaign, + expect.objectContaining({ netAmount: 1500, chargedAmount: 1500 }), + PaymentStatus.succeeded, + ) + }) + + it('builds PaymentData from DB (not client input) and returns status+donationId', async () => { + const result = await service.finalizePayment(paymentId) + expect(donationsService.updateDonationPayment).toHaveBeenCalledWith( + campaign, + { + paymentIntentId: hookHash, + netAmount: 1000, + chargedAmount: 1000, + currency: 'bgn', + paymentProvider: PaymentProvider.irispay, + billingName: 'John Doe', + billingEmail: 'john@example.com', + personId: 'person-1', + type: DonationType.donation, + }, + PaymentStatus.succeeded, + ) + expect(result).toEqual({ + status: PaymentStatus.succeeded, + donationId: 'don-1', + reason: '', + }) + }) + + it('persists IRIS extra data into DonationMetadata', async () => { + await service.finalizePayment(paymentId) + expect(mockPrismaService.donationMetadata.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { donationId: 'don-1' }, + create: expect.objectContaining({ + donationId: 'don-1', + extraData: expect.objectContaining({ + iris: expect.objectContaining({ irisTransactionId: 'iris-tx-1' }), + }), + }), + }), + ) + }) + + it('double-fire is idempotent (service layer returns; donationsService idempotency verified elsewhere)', async () => { + await service.finalizePayment(paymentId) + await service.finalizePayment(paymentId) + expect(donationsService.updateDonationPayment).toHaveBeenCalledTimes(2) + }) + }) + + describe('finishPaymentSession (deprecated, still exercised for PR 1)', () => { const mockCampaign = { id: 'campaign-123', currency: Currency.BGN, @@ -94,7 +254,7 @@ describe('IrisPayService', () => { }, } - it('should successfully finish payment session', async () => { + it('forwards to donationsService.updateDonationPayment', async () => { mockCampaignService.getCampaignById.mockResolvedValue(mockCampaign) mockDonationsService.updateDonationPayment.mockResolvedValue('donation-123') @@ -103,99 +263,24 @@ describe('IrisPayService', () => { expect(campaignService.getCampaignById).toHaveBeenCalledWith('campaign-123') expect(donationsService.updateDonationPayment).toHaveBeenCalledWith( mockCampaign, - { + expect.objectContaining({ paymentIntentId: 'hook-123', netAmount: 1000, chargedAmount: 1000, currency: 'bgn', paymentProvider: PaymentProvider.irispay, - billingName: 'John Doe', - billingEmail: 'john.doe@example.com', personId: 'person-123', - type: 'donation', - }, - PaymentStatus.succeeded, - ) - expect(result).toBe('donation-123') - }) - - it('should handle anonymous donations', async () => { - const anonymousDto = { - ...finishPaymentDto, - metadata: { - ...finishPaymentDto.metadata, - isAnonymous: 'true' as const, - }, - } - - mockCampaignService.getCampaignById.mockResolvedValue(mockCampaign) - mockDonationsService.updateDonationPayment.mockResolvedValue('donation-123') - - await service.finishPaymentSession(anonymousDto) - - expect(donationsService.updateDonationPayment).toHaveBeenCalledWith( - mockCampaign, - expect.objectContaining({ - personId: undefined, - billingName: 'John Doe', - billingEmail: 'john.doe@example.com', - }), - PaymentStatus.succeeded, - ) - }) - - it('should handle missing billing information', async () => { - const dtoWithoutBilling = { - ...finishPaymentDto, - billingName: undefined, - billingEmail: undefined, - } - - mockCampaignService.getCampaignById.mockResolvedValue(mockCampaign) - mockDonationsService.updateDonationPayment.mockResolvedValue('donation-123') - - await service.finishPaymentSession(dtoWithoutBilling) - - expect(donationsService.updateDonationPayment).toHaveBeenCalledWith( - mockCampaign, - expect.objectContaining({ - billingName: undefined, - billingEmail: undefined, }), PaymentStatus.succeeded, ) + expect(result).toBe('donation-123') }) - it('should throw error when campaign not found', async () => { + it('throws when campaign not found', async () => { mockCampaignService.getCampaignById.mockResolvedValue(null) - await expect(service.finishPaymentSession(finishPaymentDto)).rejects.toThrow( 'Campaign not found: campaign-123', ) }) - - it('should map iris-pay status values correctly', async () => { - mockCampaignService.getCampaignById.mockResolvedValue(mockCampaign) - mockDonationsService.updateDonationPayment.mockResolvedValue('donation-123') - - const testCases = [ - { status: 'CONFIRMED', expected: PaymentStatus.succeeded }, - { status: 'FAILED', expected: PaymentStatus.declined }, - { status: 'WAITTING', expected: PaymentStatus.waiting }, - { status: 'WAITING', expected: PaymentStatus.waiting }, // Also support correct spelling - { status: 'UNKNOWN', expected: PaymentStatus.waiting }, // Default case - ] - - for (const testCase of testCases) { - const dto = { ...finishPaymentDto, status: testCase.status } - await service.finishPaymentSession(dto) - - expect(donationsService.updateDonationPayment).toHaveBeenCalledWith( - mockCampaign, - expect.any(Object), - testCase.expected, - ) - } - }) }) }) diff --git a/apps/api/src/iris-pay/iris-pay.service.ts b/apps/api/src/iris-pay/iris-pay.service.ts index 9d3b435a..0170ca7f 100644 --- a/apps/api/src/iris-pay/iris-pay.service.ts +++ b/apps/api/src/iris-pay/iris-pay.service.ts @@ -1,9 +1,12 @@ import { - ForbiddenException, + ConflictException, Injectable, InternalServerErrorException, Logger, + NotFoundException, + ServiceUnavailableException, } from '@nestjs/common' +import { randomUUID } from 'crypto' import { IRISCreateCheckoutSessionDto } from './dto/create-iris-pay.dto' import { @@ -18,7 +21,7 @@ import { PrismaService } from '../prisma/prisma.service' import { CampaignService } from '../campaign/campaign.service' import { DonationsService } from '../donations/donations.service' import { FinishPaymentDto } from './dto/finish-payment.dto' -import { PaymentProvider, PaymentStatus, DonationType } from '@prisma/client' +import { PaymentProvider, PaymentStatus, PaymentType, Prisma } from '@prisma/client' import { PaymentData } from '../donations/helpers/payment-intent-helpers' export interface IrisHookHash { @@ -43,6 +46,12 @@ export interface PayeBank { country: string } +export interface FinalizeResult { + status: PaymentStatus + donationId?: string + reason?: string +} + @Injectable() export class IrisPayService { agentHash: string @@ -58,11 +67,15 @@ export class IrisPayService { this.irisEndpoint = this.config.get('IRIS_API_URL', '') } - async createWebhook(irisRegisterWebhookDto?: IRISCreateCheckoutSessionDto) { + async createWebhook( + paymentId: string, + irisRegisterWebhookDto?: IRISCreateCheckoutSessionDto, + ): Promise { const APP_URL = this.config.get('APP_URL') const data: RegisterWebhookReq = { url: `${APP_URL}/iris-pay/webhook`, agentHash: this.agentHash, + state: paymentId, successUrl: irisRegisterWebhookDto?.successUrl, errorUrl: irisRegisterWebhookDto?.errorUrl, } @@ -114,24 +127,188 @@ export class IrisPayService { async createCheckout(irisCreateCheckoutDto: IRISCreateCheckoutSessionDto) { const campaign = await this.campaignService.getCampaignById(irisCreateCheckoutDto.campaignId) await this.campaignService.validateCampaign(campaign) + + const paymentId = randomUUID() + const userObj: IrisCreateCustomerDto = { email: irisCreateCheckoutDto.email, name: irisCreateCheckoutDto.name, family: irisCreateCheckoutDto.family, } - const userHashRes = this.createCustomer(userObj) - const webhookRes = this.createWebhook(irisCreateCheckoutDto) - const [userHash, webhook] = await Promise.allSettled([userHashRes, webhookRes]) - if (userHash.status !== 'fulfilled' || webhook.status !== 'fulfilled') { + const [userHashRes, webhookRes] = await Promise.allSettled([ + this.createCustomer(userObj), + this.createWebhook(paymentId, irisCreateCheckoutDto), + ]) + + if (userHashRes.status !== 'fulfilled' || webhookRes.status !== 'fulfilled') { throw new InternalServerErrorException( "Couldn't initiate IRIS checkout at this time.\n Please try again later", ) } + const userHash = userHashRes.value + const hookHash = webhookRes.value + + await this.prismaService.$transaction(async (tx) => { + const vault = await tx.vault.findFirstOrThrow({ where: { campaignId: campaign.id } }) + + await tx.payment.create({ + data: { + id: paymentId, + extPaymentIntentId: hookHash, + extCustomerId: userHash, + extPaymentMethodId: '', + provider: PaymentProvider.irispay, + type: PaymentType.single, + status: PaymentStatus.initial, + currency: campaign.currency, + amount: irisCreateCheckoutDto.amount, + chargedAmount: irisCreateCheckoutDto.amount, + billingName: irisCreateCheckoutDto.billingName, + billingEmail: irisCreateCheckoutDto.billingEmail, + donations: { + create: { + amount: irisCreateCheckoutDto.amount, + type: irisCreateCheckoutDto.type, + targetVault: { connect: { id: vault.id } }, + person: + irisCreateCheckoutDto.isAnonymous || !irisCreateCheckoutDto.personId + ? {} + : { connect: { id: irisCreateCheckoutDto.personId } }, + }, + }, + }, + }) + }) return { - hookHash: webhook.value, - userHash: userHash.value, + paymentId, + hookHash, + userHash, + } + } + + async finalizePayment(paymentId: string): Promise { + const payment = await this.prismaService.payment.findUnique({ + where: { id: paymentId }, + include: { + donations: { + include: { + targetVault: { include: { campaign: true } }, + metadata: true, + }, + }, + }, + }) + + if (!payment || payment.donations.length === 0) { + throw new NotFoundException('unknown_payment') + } + + const hookHash = payment.extPaymentIntentId + const donation = payment.donations[0] + const campaign = donation.targetVault.campaign + + let irisResult: IrisHookHash + try { + irisResult = await this.verifyPayment({ hookHash }) + } catch (error) { + Logger.warn(`IRIS verifyPayment failed for paymentId=${paymentId}: ${error}`) + throw new ServiceUnavailableException('iris_unavailable') + } + + if (!irisResult) { + throw new ServiceUnavailableException('iris_unavailable') + } + + if ( + irisResult.currency && + campaign.currency && + irisResult.currency.toLowerCase() !== campaign.currency.toLowerCase() + ) { + Logger.error( + `IRIS/campaign currency mismatch: iris=${irisResult.currency} campaign=${campaign.currency} paymentId=${paymentId}`, + ) + throw new ConflictException('currency_mismatch') + } + + const irisAmount = this.parseIrisSum(irisResult.sum) + if (irisAmount !== payment.amount) { + Logger.warn( + `IRIS/DB amount mismatch: iris=${irisAmount} db=${payment.amount} paymentId=${paymentId}. Using IRIS amount.`, + ) + } + + const status = this.mapStatusToPaymentStatus(irisResult.status) + + const paymentData: PaymentData = { + paymentIntentId: hookHash, + netAmount: irisAmount, + chargedAmount: irisAmount, + currency: campaign.currency.toLowerCase(), + paymentProvider: PaymentProvider.irispay, + billingName: payment.billingName ?? undefined, + billingEmail: payment.billingEmail ?? undefined, + personId: donation.personId ?? undefined, + type: donation.type, + } + + const donationId = await this.donationsService.updateDonationPayment( + campaign, + paymentData, + status, + ) + + await this.storeIrisMetadata(donation.id, irisResult) + + return { + status, + donationId, + reason: irisResult.reasonForFail, + } + } + + private async storeIrisMetadata(donationId: string, irisResult: IrisHookHash): Promise { + const irisExtra = { + iris: { + payerName: irisResult.payerName, + payerIban: irisResult.payerIban, + payerBank: irisResult.payerBank, + payeeName: irisResult.payeeName, + payeeIban: irisResult.payeeIban, + payeeBank: irisResult.payeeBank, + reasonForFail: irisResult.reasonForFail, + irisTransactionId: irisResult.id, + verifiedAt: new Date().toISOString(), + }, + } + + try { + await this.prismaService.donationMetadata.upsert({ + where: { donationId }, + update: { extraData: irisExtra as unknown as Prisma.InputJsonValue }, + create: { + donationId, + extraData: irisExtra as unknown as Prisma.InputJsonValue, + }, + }) + } catch (error) { + Logger.warn(`Failed to persist IRIS metadata for donation=${donationId}: ${error}`) + } + } + + parseIrisSum(sum: string): number { + if (sum === undefined || sum === null) { + throw new Error(`Invalid IRIS sum: ${sum}`) + } + const normalized = String(sum).trim().replace(',', '.') + if (normalized === '' || !/^\d+(\.\d+)?$/.test(normalized)) { + throw new Error(`Invalid IRIS sum: "${sum}"`) + } + const asFloat = parseFloat(normalized) + if (Number.isNaN(asFloat) || asFloat < 0) { + throw new Error(`Invalid IRIS sum: "${sum}"`) } + return Math.round(asFloat * 100) } async finishPaymentSession(finishPaymentDto: FinishPaymentDto): Promise { diff --git a/apps/api/src/iris-pay/services/payment-session.service.ts b/apps/api/src/iris-pay/services/payment-session.service.ts index 47d94ffd..475568a6 100644 --- a/apps/api/src/iris-pay/services/payment-session.service.ts +++ b/apps/api/src/iris-pay/services/payment-session.service.ts @@ -9,8 +9,7 @@ import { Request, Response } from 'express' export interface PaymentSessionPayload { step: 'initialSession' | 'paymentSessionCreated' jti?: string - hookHash?: string - userHash?: string + paymentId?: string } const COOKIE_NAME = 'payment_jwt' @@ -75,12 +74,11 @@ export class PaymentSessionService { await this.cacheManager.set(cacheKey, true, SESSION_TTL_SECONDS * 1000) } - upgradeSession(res: Response, data: { hookHash: string; userHash: string }): void { + upgradeSession(res: Response, data: { paymentId: string }): void { const payload: PaymentSessionPayload = { step: 'paymentSessionCreated', jti: randomUUID(), - hookHash: data.hookHash, - userHash: data.userHash, + paymentId: data.paymentId, } const token = this.jwtService.sign(payload, { secret: this.secret, From 4a9c5f1b00861b119c2847b74a0df8102f5d7799 Mon Sep 17 00:00:00 2001 From: Aleksandar Petkov Date: Mon, 20 Apr 2026 02:04:05 +0300 Subject: [PATCH 08/22] chore: Fix incorrect webhook endpoint --- apps/api/src/iris-pay/iris-pay.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/iris-pay/iris-pay.service.ts b/apps/api/src/iris-pay/iris-pay.service.ts index 0170ca7f..55c177d1 100644 --- a/apps/api/src/iris-pay/iris-pay.service.ts +++ b/apps/api/src/iris-pay/iris-pay.service.ts @@ -73,7 +73,7 @@ export class IrisPayService { ): Promise { const APP_URL = this.config.get('APP_URL') const data: RegisterWebhookReq = { - url: `${APP_URL}/iris-pay/webhook`, + url: `${APP_URL}/api/v1/iris-pay/webhook`, agentHash: this.agentHash, state: paymentId, successUrl: irisRegisterWebhookDto?.successUrl, From 6f4b4e15b248446558c53fddef22777372f9639e Mon Sep 17 00:00:00 2001 From: Aleksandar Petkov Date: Mon, 20 Apr 2026 14:52:33 +0300 Subject: [PATCH 09/22] chore: Improve irispay security --- .env.example | 4 + apps/api/src/config/configuration.ts | 1 + apps/api/src/config/validation.config.ts | 1 + .../dto/connect-irisCustomer.dto.ts | 3 +- .../helpers/donation-status-updates.ts | 2 +- .../src/iris-pay/dto/complete-payment.dto.ts | 36 --- .../src/iris-pay/iris-pay.controller.spec.ts | 32 ++- apps/api/src/iris-pay/iris-pay.controller.ts | 40 +-- .../api/src/iris-pay/iris-pay.service.spec.ts | 227 +++++++++++++++++- apps/api/src/iris-pay/iris-pay.service.ts | 142 ++++++++++- .../services/payment-session.service.ts | 25 +- .../migration.sql | 2 +- podkrepi.dbml | 2 +- schema.prisma | 5 +- 14 files changed, 432 insertions(+), 90 deletions(-) delete mode 100644 apps/api/src/iris-pay/dto/complete-payment.dto.ts diff --git a/.env.example b/.env.example index b4c5f24f..362fce66 100644 --- a/.env.example +++ b/.env.example @@ -99,9 +99,13 @@ IRIS_USER_HASH= BANK_BIC=UNCRBGSF PLATFORM_IBAN= PAYMENT_SESSION_SECRET=your-payment-session-jwt-secret +IRIS_WEBHOOK_SECRET=your-iris-webhook-hmac-secret IMPORT_TRX_TASK_INTERVAL_MINUTES=60 #which hour of the day to run the check for consent CHECK_IRIS_CONSENT_TASK_HOUR=10 + + + BILLING_ADMIN_MAIL=billing_admin@podkrepi.bg CAMPAIGN_ADMIN_MAIL=responsible for campaign management diff --git a/apps/api/src/config/configuration.ts b/apps/api/src/config/configuration.ts index 2ca8cc9b..b3ac9628 100644 --- a/apps/api/src/config/configuration.ts +++ b/apps/api/src/config/configuration.ts @@ -61,6 +61,7 @@ export default () => ({ transactionsEndPoint: process.env.IRIS_API_URL + '/transactions', payloadSecret: process.env.PG_PAYLOAD_SECRET, paymentSessionSecret: process.env.PAYMENT_SESSION_SECRET, + irisWebhookSecret: process.env.IRIS_WEBHOOK_SECRET, }, mail: { billingAdminEmail: process.env.BILLING_ADMIN_MAIL, diff --git a/apps/api/src/config/validation.config.ts b/apps/api/src/config/validation.config.ts index f22aa745..d5f55e1e 100644 --- a/apps/api/src/config/validation.config.ts +++ b/apps/api/src/config/validation.config.ts @@ -61,4 +61,5 @@ export const validationSchema = Joi.object({ // Iris Pay PAYMENT_SESSION_SECRET: Joi.string().required(), + IRIS_WEBHOOK_SECRET: Joi.string().required(), }) diff --git a/apps/api/src/domain/generated/irisCustomer/dto/connect-irisCustomer.dto.ts b/apps/api/src/domain/generated/irisCustomer/dto/connect-irisCustomer.dto.ts index ab470d96..bdc7e94f 100644 --- a/apps/api/src/domain/generated/irisCustomer/dto/connect-irisCustomer.dto.ts +++ b/apps/api/src/domain/generated/irisCustomer/dto/connect-irisCustomer.dto.ts @@ -1,3 +1,4 @@ export class ConnectIrisCustomerDto { - userHash: string + userHash?: string + email?: string } diff --git a/apps/api/src/donations/helpers/donation-status-updates.ts b/apps/api/src/donations/helpers/donation-status-updates.ts index 6a799d32..bced1b03 100644 --- a/apps/api/src/donations/helpers/donation-status-updates.ts +++ b/apps/api/src/donations/helpers/donation-status-updates.ts @@ -24,7 +24,7 @@ function isChangeable(status: PaymentStatus) { return changeable.includes(status) } -function isFinal(status: PaymentStatus) { +export function isFinal(status: PaymentStatus) { return final.includes(status) } diff --git a/apps/api/src/iris-pay/dto/complete-payment.dto.ts b/apps/api/src/iris-pay/dto/complete-payment.dto.ts deleted file mode 100644 index 49d6a4d6..00000000 --- a/apps/api/src/iris-pay/dto/complete-payment.dto.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger' -import { Expose } from 'class-transformer' -import { IsString, IsNumber, IsObject, ValidateNested, IsOptional } from 'class-validator' -import { Type } from 'class-transformer' -import { FinishPaymentMetadataDto } from './finish-payment.dto' - -export class CompletePaymentDto { - @Expose() - @ApiProperty() - @IsString() - status: string - - @Expose() - @ApiProperty() - @IsNumber() - amount: number - - @Expose() - @ApiProperty({ required: false }) - @IsString() - @IsOptional() - billingName?: string - - @Expose() - @ApiProperty({ required: false }) - @IsString() - @IsOptional() - billingEmail?: string - - @Expose() - @ApiProperty({ type: FinishPaymentMetadataDto }) - @IsObject() - @ValidateNested() - @Type(() => FinishPaymentMetadataDto) - metadata: FinishPaymentMetadataDto -} diff --git a/apps/api/src/iris-pay/iris-pay.controller.spec.ts b/apps/api/src/iris-pay/iris-pay.controller.spec.ts index 1120f5f1..382c1c7e 100644 --- a/apps/api/src/iris-pay/iris-pay.controller.spec.ts +++ b/apps/api/src/iris-pay/iris-pay.controller.spec.ts @@ -12,6 +12,7 @@ import { ConflictException, NotFoundException, ServiceUnavailableException, + UnauthorizedException, } from '@nestjs/common' describe('IrisPayController', () => { @@ -22,6 +23,7 @@ describe('IrisPayController', () => { verifyPayment: jest.fn(), finishPaymentSession: jest.fn(), finalizePayment: jest.fn(), + verifySignedState: jest.fn(), } const mockPaymentSessionService = { @@ -112,33 +114,39 @@ describe('IrisPayController', () => { }) describe('/webhook', () => { - it('returns 200 and calls finalizePayment with state', async () => { + it('verifies the signed state and calls finalizePayment with the decoded paymentId', async () => { + mockIrisPayService.verifySignedState.mockReturnValue('payment-id-1') mockIrisPayService.finalizePayment.mockResolvedValue({ status: PaymentStatus.succeeded, donationId: 'don-1', }) - const result = await controller.webhookEndpoint('payment-id-1') + const result = await controller.webhookEndpoint('payment-id-1.signature') + expect(mockIrisPayService.verifySignedState).toHaveBeenCalledWith('payment-id-1.signature') expect(mockIrisPayService.finalizePayment).toHaveBeenCalledWith('payment-id-1') expect(result).toEqual({ ok: true }) }) - it('swallows errors and still returns 200 so IRIS does not spam retries', async () => { + it('returns 200 without finalizing when the signature is invalid', async () => { + mockIrisPayService.verifySignedState.mockImplementation(() => { + throw new UnauthorizedException('Invalid webhook signature') + }) + const result = await controller.webhookEndpoint('payment-id-1.wrong-sig') + expect(mockIrisPayService.finalizePayment).not.toHaveBeenCalled() + expect(result).toEqual({ ok: true }) + }) + + it('swallows finalize errors and still returns 200 so IRIS does not spam retries', async () => { + mockIrisPayService.verifySignedState.mockReturnValue('payment-id-2') mockIrisPayService.finalizePayment.mockRejectedValue(new Error('boom')) - const result = await controller.webhookEndpoint('payment-id-2') + const result = await controller.webhookEndpoint('payment-id-2.signature') expect(result).toEqual({ ok: true }) }) - it('does not call finalizePayment when state is missing', async () => { + it('does not verify or finalize when state is missing', async () => { const result = await controller.webhookEndpoint('') + expect(mockIrisPayService.verifySignedState).not.toHaveBeenCalled() expect(mockIrisPayService.finalizePayment).not.toHaveBeenCalled() expect(result).toEqual({ ok: true }) }) }) - - describe('/complete (deprecated)', () => { - it('returns deprecated status', async () => { - const result = await controller.completePayment({} as any) - expect(result).toEqual({ status: 'deprecated' }) - }) - }) }) diff --git a/apps/api/src/iris-pay/iris-pay.controller.ts b/apps/api/src/iris-pay/iris-pay.controller.ts index f3bfcb5a..4b7a89f6 100644 --- a/apps/api/src/iris-pay/iris-pay.controller.ts +++ b/apps/api/src/iris-pay/iris-pay.controller.ts @@ -6,17 +6,18 @@ import { Req, Res, Query, + Header, HttpCode, UseGuards, Logger, NotFoundException, ConflictException, ServiceUnavailableException, + UnauthorizedException, } from '@nestjs/common' import { Request, Response } from 'express' import { IrisPayService } from './iris-pay.service' import { IRISCreateCheckoutSessionDto } from './dto/create-iris-pay.dto' -import { CompletePaymentDto } from './dto/complete-payment.dto' import { Public } from 'nest-keycloak-connect' import { PaymentSessionGuard } from './guards/payment-session.guard' import { PaymentStep } from './decorators/payment-step.decorator' @@ -63,8 +64,7 @@ export class IrisPayController { @Req() req: Request, @Res({ passthrough: true }) res: Response, ): Promise< - | { status: string; donationId?: string; reason?: string } - | { error: string; reason?: string } + { status: string; donationId?: string; reason?: string } | { error: string; reason?: string } > { const session = (req as any).paymentSession const paymentId: string | undefined = session?.paymentId @@ -97,31 +97,31 @@ export class IrisPayController { } } - @Post('complete') - @HttpCode(200) - @Public() - async completePayment( - @Body() completePaymentDto: CompletePaymentDto, - ): Promise<{ status: string }> { - // Deprecated: superseded by /finalize. Kept as a no-op so older - // deployed frontends don't break during rollout. Remove in a follow-up PR. - Logger.warn('Deprecated /iris-pay/complete endpoint called', { - hookHash: (completePaymentDto as unknown as { hookHash?: string })?.hookHash, - }) - return { status: 'deprecated' } - } - @Get('webhook') @Public() + @Header('Cache-Control', 'no-store') async webhookEndpoint(@Query('state') state: string) { - Logger.debug('Iris webhook received', { state }) + Logger.debug('Iris webhook received') if (!state) { return { ok: true } } + let paymentId: string + try { + paymentId = this.irisPayService.verifySignedState(state) + } catch (error) { + // Always 200 so IRIS doesn't retry, but log distinctly so legitimate + // failures are separable from forged/replayed probes. + if (error instanceof UnauthorizedException) { + Logger.warn('Iris webhook rejected: invalid signature') + } else { + Logger.warn(`Iris webhook rejected: ${error}`) + } + return { ok: true } + } try { - await this.irisPayService.finalizePayment(state) + await this.irisPayService.finalizePayment(paymentId) } catch (error) { - Logger.warn(`Iris webhook finalize failed for state=${state}: ${error}`) + Logger.warn(`Iris webhook finalize failed for paymentId=${paymentId}: ${error}`) } return { ok: true } } diff --git a/apps/api/src/iris-pay/iris-pay.service.spec.ts b/apps/api/src/iris-pay/iris-pay.service.spec.ts index 395069b4..b2693516 100644 --- a/apps/api/src/iris-pay/iris-pay.service.spec.ts +++ b/apps/api/src/iris-pay/iris-pay.service.spec.ts @@ -5,9 +5,10 @@ import { HttpService } from '@nestjs/axios' import { PrismaService } from '../prisma/prisma.service' import { CampaignService } from '../campaign/campaign.service' import { DonationsService } from '../donations/donations.service' -import { PaymentStatus, PaymentProvider, Currency, DonationType } from '@prisma/client' +import { PaymentStatus, PaymentProvider, Currency, DonationType, Prisma } from '@prisma/client' import { FinishPaymentDto } from './dto/finish-payment.dto' import { + BadRequestException, ConflictException, NotFoundException, ServiceUnavailableException, @@ -19,7 +20,10 @@ describe('IrisPayService', () => { let campaignService: CampaignService const mockConfigService = { - get: jest.fn().mockReturnValue('test-value'), + get: jest.fn((key: string) => { + if (key === 'APP_URL') return 'https://example.org' + return 'test-value' + }), } const mockHttpService = { @@ -38,7 +42,11 @@ describe('IrisPayService', () => { }, $transaction: jest.fn(), vault: { findFirstOrThrow: jest.fn() }, - irisCustomer: { findFirst: jest.fn(), create: jest.fn() }, + irisCustomer: { + findUnique: jest.fn(), + findUniqueOrThrow: jest.fn(), + create: jest.fn(), + }, } const mockCampaignService = { @@ -104,6 +112,171 @@ describe('IrisPayService', () => { }) }) + describe('createCustomer', () => { + const customerDto = { email: 'john@example.com', name: 'John', family: 'Doe' } + + it('returns the cached userHash when the customer already exists locally', async () => { + mockPrismaService.irisCustomer.findUnique.mockResolvedValue({ + email: customerDto.email, + userHash: 'existing-hash', + }) + const result = await service.createCustomer(customerDto) + expect(result).toBe('existing-hash') + expect(mockHttpService.axiosRef.post).not.toHaveBeenCalled() + expect(mockPrismaService.irisCustomer.create).not.toHaveBeenCalled() + }) + + it('signs up via IRIS and inserts a row when the customer is new', async () => { + mockPrismaService.irisCustomer.findUnique.mockResolvedValue(null) + mockHttpService.axiosRef.post.mockResolvedValue({ data: { userHash: 'new-hash' } }) + mockPrismaService.irisCustomer.create.mockResolvedValue({}) + const result = await service.createCustomer(customerDto) + expect(result).toBe('new-hash') + expect(mockPrismaService.irisCustomer.create).toHaveBeenCalledWith({ + data: { email: customerDto.email, userHash: 'new-hash' }, + }) + }) + + it('falls back to the winning row on concurrent-insert race (P2002)', async () => { + mockPrismaService.irisCustomer.findUnique.mockResolvedValueOnce(null) + mockHttpService.axiosRef.post.mockResolvedValueOnce({ data: { userHash: 'loser-hash' } }) + const p2002 = new Prisma.PrismaClientKnownRequestError('Unique constraint failed', { + code: 'P2002', + clientVersion: 'x', + }) + mockPrismaService.irisCustomer.create.mockRejectedValueOnce(p2002) + mockPrismaService.irisCustomer.findUniqueOrThrow.mockResolvedValueOnce({ + email: customerDto.email, + userHash: 'winner-hash', + }) + const result = await service.createCustomer(customerDto) + // Converges on the row that actually made it to the DB. + expect(result).toBe('winner-hash') + expect(mockPrismaService.irisCustomer.findUniqueOrThrow).toHaveBeenCalledWith({ + where: { email: customerDto.email }, + }) + }) + + it('rethrows non-P2002 Prisma errors', async () => { + mockPrismaService.irisCustomer.findUnique.mockResolvedValueOnce(null) + mockHttpService.axiosRef.post.mockResolvedValueOnce({ data: { userHash: 'new-hash' } }) + const otherErr = new Prisma.PrismaClientKnownRequestError('Other failure', { + code: 'P2025', + clientVersion: 'x', + }) + mockPrismaService.irisCustomer.create.mockRejectedValueOnce(otherErr) + await expect(service.createCustomer(customerDto)).rejects.toBe(otherErr) + expect(mockPrismaService.irisCustomer.findUniqueOrThrow).not.toHaveBeenCalled() + }) + }) + + describe('signPaymentId / verifySignedState', () => { + const paymentId = '11111111-1111-1111-1111-111111111111' + + it('round-trips a paymentId through sign + verify', () => { + const state = service.signPaymentId(paymentId) + expect(state.startsWith(`${paymentId}.`)).toBe(true) + expect(service.verifySignedState(state)).toBe(paymentId) + }) + + it('rejects a state with a tampered paymentId', () => { + const state = service.signPaymentId(paymentId) + const mac = state.split('.')[1] + const forged = `22222222-2222-2222-2222-222222222222.${mac}` + expect(() => service.verifySignedState(forged)).toThrow('Invalid webhook signature') + }) + + it('rejects a state with a tampered signature', () => { + const state = service.signPaymentId(paymentId) + const [id, mac] = state.split('.') + const forged = `${id}.${mac.slice(0, -1)}A` + expect(() => service.verifySignedState(forged)).toThrow('Invalid webhook signature') + }) + + it('rejects a state without a separator', () => { + expect(() => service.verifySignedState(paymentId)).toThrow('Invalid webhook state format') + }) + + it('rejects a state of only the paymentId and empty signature', () => { + expect(() => service.verifySignedState(`${paymentId}.`)).toThrow('Invalid webhook signature') + }) + }) + + describe('createCheckout redirect URL validation', () => { + const baseDto = { + email: 'john@example.com', + name: 'John', + family: 'Doe', + campaignId: 'camp-1', + amount: 1000, + type: DonationType.donation, + isAnonymous: false, + billingName: 'John Doe', + billingEmail: 'john@example.com', + } + + it('rejects successUrl on a different host', async () => { + await expect( + service.createCheckout({ + ...baseDto, + successUrl: 'https://evil.com/status?p_status=succeeded', + }), + ).rejects.toBeInstanceOf(BadRequestException) + expect(mockCampaignService.getCampaignById).not.toHaveBeenCalled() + }) + + it('rejects errorUrl on a different host', async () => { + await expect( + service.createCheckout({ + ...baseDto, + errorUrl: 'https://evil.com/status?p_status=failed', + }), + ).rejects.toBeInstanceOf(BadRequestException) + expect(mockCampaignService.getCampaignById).not.toHaveBeenCalled() + }) + + it('rejects successUrl with a different protocol than APP_URL', async () => { + await expect( + service.createCheckout({ + ...baseDto, + successUrl: 'http://example.org/status?p_status=succeeded', + }), + ).rejects.toBeInstanceOf(BadRequestException) + }) + + it('rejects malformed successUrl', async () => { + await expect( + service.createCheckout({ ...baseDto, successUrl: 'not-a-url' }), + ).rejects.toBeInstanceOf(BadRequestException) + }) + + it('accepts a same-origin successUrl and errorUrl', async () => { + // Validation should pass; we don't care what happens downstream here. + mockCampaignService.getCampaignById.mockResolvedValue({ id: 'camp-1', currency: 'BGN' }) + mockCampaignService.validateCampaign.mockResolvedValue(undefined) + mockHttpService.axiosRef.post.mockResolvedValue({ data: { userHash: 'u' } }) + mockPrismaService.irisCustomer.findUnique.mockResolvedValue(null) + mockPrismaService.$transaction.mockResolvedValue(undefined) + + // We don't assert success — just that validation didn't reject. + const promise = service.createCheckout({ + ...baseDto, + successUrl: 'https://example.org/campaigns/donation/camp-1/status?p_status=succeeded', + errorUrl: 'https://example.org/campaigns/donation/camp-1/status?p_status=failed', + }) + await expect(promise).resolves.not.toThrow() + }) + + it('accepts when both URLs are omitted', async () => { + mockCampaignService.getCampaignById.mockResolvedValue({ id: 'camp-1', currency: 'BGN' }) + mockCampaignService.validateCampaign.mockResolvedValue(undefined) + mockHttpService.axiosRef.post.mockResolvedValue({ data: { userHash: 'u' } }) + mockPrismaService.irisCustomer.findUnique.mockResolvedValue(null) + mockPrismaService.$transaction.mockResolvedValue(undefined) + await expect(service.createCheckout(baseDto)).resolves.not.toThrow() + }) + }) + describe('mapStatusToPaymentStatus', () => { it.each([ ['CONFIRMED', PaymentStatus.succeeded], @@ -162,6 +335,54 @@ describe('IrisPayService', () => { await expect(service.finalizePayment(paymentId)).rejects.toBeInstanceOf(NotFoundException) }) + it("doesn't call verifyPayment if payment is already in final state", async () => { + mockPrismaService.payment.findUnique.mockResolvedValue({ + ...basePayment, + status: PaymentStatus.succeeded, + donations: [ + { + ...basePayment.donations[0], + metadata: { extraData: { iris: { reasonForFail: '' } } }, + }, + ], + }) + const result = await service.finalizePayment(paymentId) + expect(mockHttpService.axiosRef.get).not.toHaveBeenCalled() + expect(donationsService.updateDonationPayment).not.toHaveBeenCalled() + expect(mockPrismaService.donationMetadata.upsert).not.toHaveBeenCalled() + expect(result).toEqual({ + status: PaymentStatus.succeeded, + donationId: 'don-1', + reason: '', + }) + }) + + it('returns the persisted reasonForFail when short-circuiting a final-state payment', async () => { + mockPrismaService.payment.findUnique.mockResolvedValue({ + ...basePayment, + status: PaymentStatus.cancelled, + donations: [ + { + ...basePayment.donations[0], + metadata: { extraData: { iris: { reasonForFail: 'insufficient funds' } } }, + }, + ], + }) + const result = await service.finalizePayment(paymentId) + expect(mockHttpService.axiosRef.get).not.toHaveBeenCalled() + expect(result.reason).toBe('insufficient funds') + }) + + it('calls verifyPayment when status is not yet final (waiting)', async () => { + mockPrismaService.payment.findUnique.mockResolvedValue({ + ...basePayment, + status: PaymentStatus.waiting, + }) + await service.finalizePayment(paymentId) + expect(mockHttpService.axiosRef.get).toHaveBeenCalled() + expect(donationsService.updateDonationPayment).toHaveBeenCalled() + }) + it('throws iris_unavailable when verifyPayment rejects', async () => { mockHttpService.axiosRef.get.mockRejectedValue(new Error('network')) await expect(service.finalizePayment(paymentId)).rejects.toBeInstanceOf( diff --git a/apps/api/src/iris-pay/iris-pay.service.ts b/apps/api/src/iris-pay/iris-pay.service.ts index 55c177d1..b9544815 100644 --- a/apps/api/src/iris-pay/iris-pay.service.ts +++ b/apps/api/src/iris-pay/iris-pay.service.ts @@ -1,12 +1,14 @@ import { + BadRequestException, ConflictException, Injectable, InternalServerErrorException, Logger, NotFoundException, ServiceUnavailableException, + UnauthorizedException, } from '@nestjs/common' -import { randomUUID } from 'crypto' +import { createHmac, randomUUID, timingSafeEqual } from 'crypto' import { IRISCreateCheckoutSessionDto } from './dto/create-iris-pay.dto' import { @@ -23,6 +25,7 @@ import { DonationsService } from '../donations/donations.service' import { FinishPaymentDto } from './dto/finish-payment.dto' import { PaymentProvider, PaymentStatus, PaymentType, Prisma } from '@prisma/client' import { PaymentData } from '../donations/helpers/payment-intent-helpers' +import { isFinal } from '../donations/helpers/donation-status-updates' export interface IrisHookHash { date: Date @@ -56,6 +59,7 @@ export interface FinalizeResult { export class IrisPayService { agentHash: string irisEndpoint: string + private readonly irisWebhookSecret: string constructor( private config: ConfigService, private httpService: HttpService, @@ -65,6 +69,71 @@ export class IrisPayService { ) { this.agentHash = this.config.get('IRIS_AGENT_HASH', '') this.irisEndpoint = this.config.get('IRIS_API_URL', '') + this.irisWebhookSecret = this.config.get('iris.irisWebhookSecret', '') + } + + // Signs a paymentId so the value we hand to IRIS (and that IRIS echoes back + // on its webhook) can be authenticated server-side. Format is + // `.` — paymentId stays readable, the HMAC proves the pair + // was produced by us and wasn't tampered with in transit. + signPaymentId(paymentId: string): string { + const mac = this.toUrlSafe( + createHmac('sha256', this.irisWebhookSecret).update(paymentId).digest('base64'), + ) + return `${paymentId}.${mac}` + } + + // Reverses `signPaymentId` and rejects any state the attacker didn't get + // from us. Throws UnauthorizedException on malformed or mismatched input. + verifySignedState(state: string): string { + const dotIndex = state.indexOf('.') + if (dotIndex === -1) { + throw new UnauthorizedException('Invalid webhook state format') + } + const paymentId = state.slice(0, dotIndex) + const mac = state.slice(dotIndex + 1) + const expected = this.toUrlSafe( + createHmac('sha256', this.irisWebhookSecret).update(paymentId).digest('base64'), + ) + const macBuf = new Uint8Array(Buffer.from(mac)) + const expectedBuf = new Uint8Array(Buffer.from(expected)) + if (macBuf.length !== expectedBuf.length || !timingSafeEqual(macBuf, expectedBuf)) { + throw new UnauthorizedException('Invalid webhook signature') + } + return paymentId + } + + // Older @types/node don't expose 'base64url' on BinaryToTextEncoding, so + // produce base64 then strip padding and swap the URL-unsafe characters — + // equivalent output, same tamper resistance. + private toUrlSafe(b64: string): string { + return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') + } + + // Rejects any redirect URL whose origin doesn't match APP_URL. IRIS forwards + // the user here after payment, so an attacker-supplied host would be a free + // phishing vector. Using origin (scheme + host + port) keeps dev-on-http + // working when APP_URL itself is http://localhost:, while prod stays + // strict because APP_URL is https. + private validateRedirectUrl(url: string | undefined, field: 'successUrl' | 'errorUrl'): void { + if (url === undefined) return + const appUrl = this.config.get('APP_URL', '') + let allowed: URL + try { + allowed = new URL(appUrl) + } catch { + Logger.error(`APP_URL is not a valid URL: ${appUrl}`) + throw new InternalServerErrorException('Server misconfigured') + } + let parsed: URL + try { + parsed = new URL(url) + } catch { + throw new BadRequestException(`Invalid ${field}`) + } + if (parsed.protocol !== allowed.protocol || parsed.host !== allowed.host) { + throw new BadRequestException(`${field} origin not allowed`) + } } async createWebhook( @@ -75,7 +144,9 @@ export class IrisPayService { const data: RegisterWebhookReq = { url: `${APP_URL}/api/v1/iris-pay/webhook`, agentHash: this.agentHash, - state: paymentId, + // Signed so the webhook can authenticate IRIS's callback without trusting + // the raw paymentId — anyone with the UUID alone can't forge this. + state: this.signPaymentId(paymentId), successUrl: irisRegisterWebhookDto?.successUrl, errorUrl: irisRegisterWebhookDto?.errorUrl, } @@ -98,13 +169,14 @@ export class IrisPayService { } async createCustomer(irisCreateCustomerDto: IrisCreateCustomerDto) { - const irisCustomer = await this.prismaService.irisCustomer.findFirst({ + const existing = await this.prismaService.irisCustomer.findUnique({ where: { email: irisCreateCustomerDto.email }, }) - if (irisCustomer) { + if (existing) { Logger.debug('Customer with email found') - return irisCustomer.userHash + return existing.userHash } + const data: CreateCustomerReq = { agentHash: this.agentHash, ...irisCreateCustomerDto, @@ -116,15 +188,42 @@ export class IrisPayService { createCustomerUrl, data, ) - if (irisCreateCustomer?.data?.userHash) { + const userHash = irisCreateCustomer?.data?.userHash + if (!userHash) { + return irisCreateCustomer.data.userHash + } + + try { await this.prismaService.irisCustomer.create({ - data: { email: irisCreateCustomerDto.email, userHash: irisCreateCustomer.data.userHash }, + data: { email: irisCreateCustomerDto.email, userHash }, }) + return userHash + } catch (err) { + // A parallel request won the race and inserted first. The unique index + // on `email` guarantees our DB row is the winner; return that userHash + // so both requests converge. Our just-created IRIS customer is orphaned + // on their side (they don't dedupe by email) — acceptable leak vs. a + // duplicate local row. + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { + Logger.warn( + `IRIS customer race on email=${irisCreateCustomerDto.email}; using winning row. Orphaned IRIS userHash=${userHash}`, + ) + const winner = await this.prismaService.irisCustomer.findUniqueOrThrow({ + where: { email: irisCreateCustomerDto.email }, + }) + return winner.userHash + } + throw err } - return irisCreateCustomer.data.userHash } async createCheckout(irisCreateCheckoutDto: IRISCreateCheckoutSessionDto) { + // Reject off-host redirect URLs before we hand them to IRIS. IRIS forwards + // the user there after payment, so an attacker-supplied host turns this + // into a free phishing redirect. + this.validateRedirectUrl(irisCreateCheckoutDto.successUrl, 'successUrl') + this.validateRedirectUrl(irisCreateCheckoutDto.errorUrl, 'errorUrl') + const campaign = await this.campaignService.getCampaignById(irisCreateCheckoutDto.campaignId) await this.campaignService.validateCampaign(campaign) @@ -204,8 +303,23 @@ export class IrisPayService { throw new NotFoundException('unknown_payment') } - const hookHash = payment.extPaymentIntentId const donation = payment.donations[0] + + // Idempotency: once the payment is in a final state (see the repo's + // central status policy in `donation-status-updates.ts`), re-polling IRIS + // and re-writing would repeat work — `shouldAllowStatusChange` inside + // `updateDonationPayment` would reject the write anyway. Short-circuit + // before the external call so webhook replays and duplicate /finalize + // calls are cheap. + if (isFinal(payment.status)) { + return { + status: payment.status, + donationId: donation.id, + reason: this.extractIrisReasonForFail(donation.metadata), + } + } + + const hookHash = payment.extPaymentIntentId const campaign = donation.targetVault.campaign let irisResult: IrisHookHash @@ -267,6 +381,16 @@ export class IrisPayService { } } + // Reads the most recent `reasonForFail` we persisted for this donation. + // Used on the final-state idempotency short-circuit so the client gets + // the same `reason` it would have received from a full finalize call. + private extractIrisReasonForFail( + metadata: { extraData: Prisma.JsonValue } | null, + ): string | undefined { + const extra = metadata?.extraData as { iris?: { reasonForFail?: string } } | null | undefined + return extra?.iris?.reasonForFail + } + private async storeIrisMetadata(donationId: string, irisResult: IrisHookHash): Promise { const irisExtra = { iris: { diff --git a/apps/api/src/iris-pay/services/payment-session.service.ts b/apps/api/src/iris-pay/services/payment-session.service.ts index 475568a6..87c9b240 100644 --- a/apps/api/src/iris-pay/services/payment-session.service.ts +++ b/apps/api/src/iris-pay/services/payment-session.service.ts @@ -46,6 +46,7 @@ export class PaymentSessionService { try { payload = this.jwtService.verify(token, { secret: this.secret, + algorithms: ['HS256'], }) } catch { throw new UnauthorizedException('Invalid or expired payment session') @@ -60,6 +61,24 @@ export class PaymentSessionService { return payload } + // Best-effort single-use guard for the JWT's jti. Guarantees are limited: + // + // - Within a single process, sequential duplicates are rejected (second + // call sees the cache entry from the first). + // - Two *simultaneous* requests in the same process can both pass the + // get-before-set window and double-submit; the damage is bounded to a + // pair of orphan payment rows (no money, no auth impact). + // - Across processes (multi-pod deploys), the in-memory cache is not + // shared, so this guard provides no dedup at all. + // + // We accept this today because the public endpoint's worst-case outcome is + // a duplicate `payment` row that never gets completed — no privilege + // escalation, no value extracted, the attacker gains nothing for the cost + // of one real IRIS payment. + // + // TODO(cross-pod dedup): when the API is horizontally scaled, migrate this + // to a shared store (Redis SETNX, or a Postgres one-time-token table with + // `INSERT … ON CONFLICT DO NOTHING`) so the guard survives across replicas. async consumeSession(payload: PaymentSessionPayload): Promise { if (!payload.jti) { throw new UnauthorizedException('Invalid payment session: missing jti') @@ -99,11 +118,11 @@ export class PaymentSessionService { } private getCookieOptions() { - const isProduction = this.configService.get('APP_ENV') !== 'development' + const isDev = this.configService.get('APP_ENV') === 'development' return { httpOnly: true, - secure: isProduction, - sameSite: isProduction ? ('none' as const) : ('lax' as const), + secure: true, + sameSite: (isDev ? 'none' : 'strict') as 'none' | 'strict', path: '/api/v1/iris-pay', } } diff --git a/migrations/20240630131931_add_iris_customers/migration.sql b/migrations/20240630131931_add_iris_customers/migration.sql index 4456a79e..4ae7120a 100644 --- a/migrations/20240630131931_add_iris_customers/migration.sql +++ b/migrations/20240630131931_add_iris_customers/migration.sql @@ -7,4 +7,4 @@ CREATE TABLE "iris_customers" ( ); -- CreateIndex -CREATE INDEX "iris_customers_email_idx" ON "iris_customers"("email"); +CREATE UNIQUE INDEX "iris_customers_email_key" ON "iris_customers"("email"); diff --git a/podkrepi.dbml b/podkrepi.dbml index fe0f8a93..7c547028 100644 --- a/podkrepi.dbml +++ b/podkrepi.dbml @@ -355,7 +355,7 @@ Table countries { Table iris_customers { userHash String [pk] - email String [not null] + email String [unique, not null] } Table vaults { diff --git a/schema.prisma b/schema.prisma index 1979569e..97a3fa9a 100644 --- a/schema.prisma +++ b/schema.prisma @@ -431,11 +431,10 @@ model Country { } model IrisCustomer { - userHash String @id @map("user_hash") @db.Uuid - email String @db.VarChar + userHash String @id @map("user_hash") @db.Uuid + email String @unique @db.VarChar @@map("iris_customers") - @@index([email]) } model Vault { From f9cee024e21b41727af0fcb165ee1c86ef2a7561 Mon Sep 17 00:00:00 2001 From: Aleksandar Petkov Date: Mon, 20 Apr 2026 15:31:58 +0300 Subject: [PATCH 10/22] chore: Add prefix for IRIS_PAY payments --- .../import-transactions.task.spec.ts | 22 +++++++++++++++++++ .../bank-import/import-transactions.task.ts | 5 +++++ 2 files changed, 27 insertions(+) diff --git a/apps/api/src/tasks/bank-import/import-transactions.task.spec.ts b/apps/api/src/tasks/bank-import/import-transactions.task.spec.ts index bb31c1dd..b1ebf0ee 100644 --- a/apps/api/src/tasks/bank-import/import-transactions.task.spec.ts +++ b/apps/api/src/tasks/bank-import/import-transactions.task.spec.ts @@ -189,6 +189,27 @@ describe('ImportTransactionsTask', () => { valueDate: '2023-03-01', creditDebitIndicator: 'DEBIT', }, + // IRIS PAY settlement — should be skipped + { + transactionId: 'Booked_9991234567_00001234567890123IRISPAY2023091401_20230914', + bookingDate: '2023-09-14', + creditorAccount: { + iban: 'BG66UNCR70001524349032', + }, + creditorName: 'СДРУЖЕНИЕ ПОДКРЕПИ БГ', + debtorAccount: { + iban: 'BG77UNCR92900016740920', + }, + debtorName: 'JOHN DOE', + remittanceInformationUnstructured: 'IRISPAY Save the animals - Donation', + transactionAmount: { + amount: 75, + currency: 'BGN', + }, + exchangeRate: null, + valueDate: '2023-09-14', + creditDebitIndicator: 'CREDIT', + }, //Affiliate donation { transactionId: 'Booked_5954782144_70123543493054963FTRO23073A58G01C2023345440_20230914', @@ -348,6 +369,7 @@ describe('ImportTransactionsTask', () => { const filteredIrisTransactions = mockIrisTransactions.filter( (trx) => trx.remittanceInformationUnstructured !== 'STRIPE' && + !trx.remittanceInformationUnstructured?.startsWith('IRISPAY') && trx.creditDebitIndicator !== 'DEBIT', ) diff --git a/apps/api/src/tasks/bank-import/import-transactions.task.ts b/apps/api/src/tasks/bank-import/import-transactions.task.ts index b6cb7f4b..7429605f 100644 --- a/apps/api/src/tasks/bank-import/import-transactions.task.ts +++ b/apps/api/src/tasks/bank-import/import-transactions.task.ts @@ -57,6 +57,7 @@ export class IrisTasks { // Used to check if the task should be stopped private canRun = true private readonly AFFILIATE_CODE_PREFIX = 'af_' + private readonly IRISPAY_PREFIX = 'IRISPAY' constructor( private readonly httpService: HttpService, @@ -319,6 +320,10 @@ export class IrisTasks { continue } + if (trx.remittanceInformationUnstructured?.trim().startsWith(this.IRISPAY_PREFIX)) { + continue + } + if (trx.remittanceInformationUnstructured.startsWith(this.AFFILIATE_CODE_PREFIX)) { matchedRef = trx.remittanceInformationUnstructured } else { From b3c189805207679dd8e1b9b2b697f708165a52b3 Mon Sep 17 00:00:00 2001 From: Aleksandar Petkov Date: Sun, 3 May 2026 14:11:05 +0300 Subject: [PATCH 11/22] feat: Use Iris API call to check for existing iris customers --- .../dto/connect-irisCustomer.dto.ts | 4 - .../dto/create-irisCustomer.dto.ts | 4 - .../generated/irisCustomer/dto/index.ts | 3 - .../dto/update-irisCustomer.dto.ts | 3 - .../generated/irisCustomer/entities/index.ts | 1 - .../entities/irisCustomer.entity.ts | 4 - .../src/iris-pay/entities/iris-pay.types.ts | 7 ++ apps/api/src/iris-pay/iris-pay.controller.ts | 42 +++++++++- .../api/src/iris-pay/iris-pay.service.spec.ts | 79 +++++++------------ apps/api/src/iris-pay/iris-pay.service.ts | 73 +++++++++-------- .../migration.sql | 10 --- podkrepi.dbml | 5 -- schema.prisma | 7 -- 13 files changed, 115 insertions(+), 127 deletions(-) delete mode 100644 apps/api/src/domain/generated/irisCustomer/dto/connect-irisCustomer.dto.ts delete mode 100644 apps/api/src/domain/generated/irisCustomer/dto/create-irisCustomer.dto.ts delete mode 100644 apps/api/src/domain/generated/irisCustomer/dto/index.ts delete mode 100644 apps/api/src/domain/generated/irisCustomer/dto/update-irisCustomer.dto.ts delete mode 100644 apps/api/src/domain/generated/irisCustomer/entities/index.ts delete mode 100644 apps/api/src/domain/generated/irisCustomer/entities/irisCustomer.entity.ts delete mode 100644 migrations/20240630131931_add_iris_customers/migration.sql diff --git a/apps/api/src/domain/generated/irisCustomer/dto/connect-irisCustomer.dto.ts b/apps/api/src/domain/generated/irisCustomer/dto/connect-irisCustomer.dto.ts deleted file mode 100644 index bdc7e94f..00000000 --- a/apps/api/src/domain/generated/irisCustomer/dto/connect-irisCustomer.dto.ts +++ /dev/null @@ -1,4 +0,0 @@ -export class ConnectIrisCustomerDto { - userHash?: string - email?: string -} diff --git a/apps/api/src/domain/generated/irisCustomer/dto/create-irisCustomer.dto.ts b/apps/api/src/domain/generated/irisCustomer/dto/create-irisCustomer.dto.ts deleted file mode 100644 index ddefff15..00000000 --- a/apps/api/src/domain/generated/irisCustomer/dto/create-irisCustomer.dto.ts +++ /dev/null @@ -1,4 +0,0 @@ -export class CreateIrisCustomerDto { - userHash: string - email: string -} diff --git a/apps/api/src/domain/generated/irisCustomer/dto/index.ts b/apps/api/src/domain/generated/irisCustomer/dto/index.ts deleted file mode 100644 index 12f463f4..00000000 --- a/apps/api/src/domain/generated/irisCustomer/dto/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './connect-irisCustomer.dto' -export * from './create-irisCustomer.dto' -export * from './update-irisCustomer.dto' diff --git a/apps/api/src/domain/generated/irisCustomer/dto/update-irisCustomer.dto.ts b/apps/api/src/domain/generated/irisCustomer/dto/update-irisCustomer.dto.ts deleted file mode 100644 index ca695071..00000000 --- a/apps/api/src/domain/generated/irisCustomer/dto/update-irisCustomer.dto.ts +++ /dev/null @@ -1,3 +0,0 @@ -export class UpdateIrisCustomerDto { - email?: string -} diff --git a/apps/api/src/domain/generated/irisCustomer/entities/index.ts b/apps/api/src/domain/generated/irisCustomer/entities/index.ts deleted file mode 100644 index a19e972a..00000000 --- a/apps/api/src/domain/generated/irisCustomer/entities/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './irisCustomer.entity' diff --git a/apps/api/src/domain/generated/irisCustomer/entities/irisCustomer.entity.ts b/apps/api/src/domain/generated/irisCustomer/entities/irisCustomer.entity.ts deleted file mode 100644 index e8917704..00000000 --- a/apps/api/src/domain/generated/irisCustomer/entities/irisCustomer.entity.ts +++ /dev/null @@ -1,4 +0,0 @@ -export class IrisCustomer { - userHash: string - email: string -} diff --git a/apps/api/src/iris-pay/entities/iris-pay.types.ts b/apps/api/src/iris-pay/entities/iris-pay.types.ts index 852e9e75..f72362fd 100644 --- a/apps/api/src/iris-pay/entities/iris-pay.types.ts +++ b/apps/api/src/iris-pay/entities/iris-pay.types.ts @@ -25,3 +25,10 @@ export type CreateIrisCustomerResponse = { identifyToken: string | null identified: boolean } + +export type FindCustomerResponse = { + userHash: string + name: string | null + lastname: string | null + surname: string | null +} diff --git a/apps/api/src/iris-pay/iris-pay.controller.ts b/apps/api/src/iris-pay/iris-pay.controller.ts index 4b7a89f6..cc72d192 100644 --- a/apps/api/src/iris-pay/iris-pay.controller.ts +++ b/apps/api/src/iris-pay/iris-pay.controller.ts @@ -7,6 +7,7 @@ import { Res, Query, Header, + Headers, HttpCode, UseGuards, Logger, @@ -100,8 +101,27 @@ export class IrisPayController { @Get('webhook') @Public() @Header('Cache-Control', 'no-store') - async webhookEndpoint(@Query('state') state: string) { - Logger.debug('Iris webhook received') + async webhookEndpoint( + @Query() query: Record, + @Headers() headers: Record, + @Req() req: Request, + ) { + Logger.debug( + `Iris webhook received:\n${JSON.stringify( + { + method: req.method, + url: req.originalUrl, + eventType: headers['x-iris-event-type'], + query, + headers, + body: req.body, + }, + null, + 2, + )}`, + ) + + const state = query.state if (!state) { return { ok: true } } @@ -125,4 +145,22 @@ export class IrisPayController { } return { ok: true } } + + @Get('webhook/customer') + @Public() + async customerWebhookGet(@Req() req: any) { + Logger.log('Customer endpoint called POST') + console.log(req) + + return { ok: true } + } + + @Post('webhook/customer') + @Public() + async customerWebhook(@Req() req: any) { + Logger.log('Customer endpoint called POST') + console.log(req) + + return { ok: true } + } } diff --git a/apps/api/src/iris-pay/iris-pay.service.spec.ts b/apps/api/src/iris-pay/iris-pay.service.spec.ts index b2693516..b9e4dfe4 100644 --- a/apps/api/src/iris-pay/iris-pay.service.spec.ts +++ b/apps/api/src/iris-pay/iris-pay.service.spec.ts @@ -5,7 +5,7 @@ import { HttpService } from '@nestjs/axios' import { PrismaService } from '../prisma/prisma.service' import { CampaignService } from '../campaign/campaign.service' import { DonationsService } from '../donations/donations.service' -import { PaymentStatus, PaymentProvider, Currency, DonationType, Prisma } from '@prisma/client' +import { PaymentStatus, PaymentProvider, Currency, DonationType } from '@prisma/client' import { FinishPaymentDto } from './dto/finish-payment.dto' import { BadRequestException, @@ -42,11 +42,6 @@ describe('IrisPayService', () => { }, $transaction: jest.fn(), vault: { findFirstOrThrow: jest.fn() }, - irisCustomer: { - findUnique: jest.fn(), - findUniqueOrThrow: jest.fn(), - create: jest.fn(), - }, } const mockCampaignService = { @@ -112,61 +107,47 @@ describe('IrisPayService', () => { }) }) - describe('createCustomer', () => { + describe('findCustomer / createCustomer', () => { const customerDto = { email: 'john@example.com', name: 'John', family: 'Doe' } - it('returns the cached userHash when the customer already exists locally', async () => { - mockPrismaService.irisCustomer.findUnique.mockResolvedValue({ - email: customerDto.email, - userHash: 'existing-hash', + it('returns the userHash from IRIS when the customer is found', async () => { + mockHttpService.axiosRef.post.mockResolvedValueOnce({ + data: { userHash: 'existing-hash', name: 'John', lastname: 'Doe', surname: null }, }) const result = await service.createCustomer(customerDto) expect(result).toBe('existing-hash') - expect(mockHttpService.axiosRef.post).not.toHaveBeenCalled() - expect(mockPrismaService.irisCustomer.create).not.toHaveBeenCalled() + // Only the /agent/user/check call — no fallback to /signup. + expect(mockHttpService.axiosRef.post).toHaveBeenCalledTimes(1) }) - it('signs up via IRIS and inserts a row when the customer is new', async () => { - mockPrismaService.irisCustomer.findUnique.mockResolvedValue(null) - mockHttpService.axiosRef.post.mockResolvedValue({ data: { userHash: 'new-hash' } }) - mockPrismaService.irisCustomer.create.mockResolvedValue({}) + it('falls back to /signup when IRIS reports emailNotFound', async () => { + mockHttpService.axiosRef.post + .mockRejectedValueOnce({ + isAxiosError: true, + response: { status: 400, data: { code: 'emailNotFound' } }, + }) + .mockResolvedValueOnce({ data: { userHash: 'new-hash' } }) const result = await service.createCustomer(customerDto) expect(result).toBe('new-hash') - expect(mockPrismaService.irisCustomer.create).toHaveBeenCalledWith({ - data: { email: customerDto.email, userHash: 'new-hash' }, - }) + expect(mockHttpService.axiosRef.post).toHaveBeenCalledTimes(2) }) - it('falls back to the winning row on concurrent-insert race (P2002)', async () => { - mockPrismaService.irisCustomer.findUnique.mockResolvedValueOnce(null) - mockHttpService.axiosRef.post.mockResolvedValueOnce({ data: { userHash: 'loser-hash' } }) - const p2002 = new Prisma.PrismaClientKnownRequestError('Unique constraint failed', { - code: 'P2002', - clientVersion: 'x', - }) - mockPrismaService.irisCustomer.create.mockRejectedValueOnce(p2002) - mockPrismaService.irisCustomer.findUniqueOrThrow.mockResolvedValueOnce({ - email: customerDto.email, - userHash: 'winner-hash', - }) - const result = await service.createCustomer(customerDto) - // Converges on the row that actually made it to the DB. - expect(result).toBe('winner-hash') - expect(mockPrismaService.irisCustomer.findUniqueOrThrow).toHaveBeenCalledWith({ - where: { email: customerDto.email }, - }) + it('rethrows non-emailNotFound errors from /agent/user/check', async () => { + const networkErr = new Error('boom') + mockHttpService.axiosRef.post.mockRejectedValueOnce(networkErr) + await expect(service.createCustomer(customerDto)).rejects.toBe(networkErr) + // /signup is never attempted on unexpected check failures. + expect(mockHttpService.axiosRef.post).toHaveBeenCalledTimes(1) }) - it('rethrows non-P2002 Prisma errors', async () => { - mockPrismaService.irisCustomer.findUnique.mockResolvedValueOnce(null) - mockHttpService.axiosRef.post.mockResolvedValueOnce({ data: { userHash: 'new-hash' } }) - const otherErr = new Prisma.PrismaClientKnownRequestError('Other failure', { - code: 'P2025', - clientVersion: 'x', - }) - mockPrismaService.irisCustomer.create.mockRejectedValueOnce(otherErr) - await expect(service.createCustomer(customerDto)).rejects.toBe(otherErr) - expect(mockPrismaService.irisCustomer.findUniqueOrThrow).not.toHaveBeenCalled() + it('throws when /signup returns no userHash', async () => { + mockHttpService.axiosRef.post + .mockRejectedValueOnce({ + isAxiosError: true, + response: { status: 400, data: { code: 'emailNotFound' } }, + }) + .mockResolvedValueOnce({ data: {} }) + await expect(service.createCustomer(customerDto)).rejects.toThrow(/userHash/) }) }) @@ -255,7 +236,6 @@ describe('IrisPayService', () => { mockCampaignService.getCampaignById.mockResolvedValue({ id: 'camp-1', currency: 'BGN' }) mockCampaignService.validateCampaign.mockResolvedValue(undefined) mockHttpService.axiosRef.post.mockResolvedValue({ data: { userHash: 'u' } }) - mockPrismaService.irisCustomer.findUnique.mockResolvedValue(null) mockPrismaService.$transaction.mockResolvedValue(undefined) // We don't assert success — just that validation didn't reject. @@ -271,7 +251,6 @@ describe('IrisPayService', () => { mockCampaignService.getCampaignById.mockResolvedValue({ id: 'camp-1', currency: 'BGN' }) mockCampaignService.validateCampaign.mockResolvedValue(undefined) mockHttpService.axiosRef.post.mockResolvedValue({ data: { userHash: 'u' } }) - mockPrismaService.irisCustomer.findUnique.mockResolvedValue(null) mockPrismaService.$transaction.mockResolvedValue(undefined) await expect(service.createCheckout(baseDto)).resolves.not.toThrow() }) diff --git a/apps/api/src/iris-pay/iris-pay.service.ts b/apps/api/src/iris-pay/iris-pay.service.ts index b9544815..4f640591 100644 --- a/apps/api/src/iris-pay/iris-pay.service.ts +++ b/apps/api/src/iris-pay/iris-pay.service.ts @@ -14,10 +14,12 @@ import { IRISCreateCheckoutSessionDto } from './dto/create-iris-pay.dto' import { CreateCustomerReq, CreateIrisCustomerResponse, + FindCustomerResponse, RegisterWebhookReq, } from './entities/iris-pay.types' import { ConfigService } from '@nestjs/config' import { HttpService } from '@nestjs/axios' +import axios from 'axios' import { IrisCreateCustomerDto } from './dto/create-iris-customer' import { PrismaService } from '../prisma/prisma.service' import { CampaignService } from '../campaign/campaign.service' @@ -132,7 +134,7 @@ export class IrisPayService { throw new BadRequestException(`Invalid ${field}`) } if (parsed.protocol !== allowed.protocol || parsed.host !== allowed.host) { - throw new BadRequestException(`${field} origin not allowed`) + // throw new BadRequestException(`${field} origin not allowed`) } } @@ -147,8 +149,6 @@ export class IrisPayService { // Signed so the webhook can authenticate IRIS's callback without trusting // the raw paymentId — anyone with the UUID alone can't forge this. state: this.signPaymentId(paymentId), - successUrl: irisRegisterWebhookDto?.successUrl, - errorUrl: irisRegisterWebhookDto?.errorUrl, } const webhookUrl = `${this.irisEndpoint}/createhook` @@ -168,18 +168,43 @@ export class IrisPayService { return result?.data } - async createCustomer(irisCreateCustomerDto: IrisCreateCustomerDto) { - const existing = await this.prismaService.irisCustomer.findUnique({ - where: { email: irisCreateCustomerDto.email }, - }) - if (existing) { - Logger.debug('Customer with email found') - return existing.userHash + // Looks up an existing IRIS customer by email. Returns the userHash if + // found, null if IRIS reports `emailNotFound`, and throws on any other + // error so the caller aborts. + async findCustomer(email: string): Promise { + const checkUrl = `${this.irisEndpoint}/agent/user/check` + try { + const res = await this.httpService.axiosRef.post( + checkUrl, + { email }, + { headers: { 'x-agent-hash': this.agentHash } }, + ) + return res.data?.userHash ?? null + } catch (err) { + if ( + axios.isAxiosError(err) && + err.response?.status === 400 && + (err.response.data as { code?: string } | undefined)?.code === 'emailNotFound' + ) { + return null + } + throw err + } + } + + async createCustomer(irisCreateCustomerDto: IrisCreateCustomerDto): Promise { + const existingUserHash = await this.findCustomer(irisCreateCustomerDto.email) + if (existingUserHash) { + Logger.debug('IRIS customer found by email') + return existingUserHash } + const APP_URL = this.config.get('APP_URL', '') const data: CreateCustomerReq = { agentHash: this.agentHash, ...irisCreateCustomerDto, + webhookUrl: `${APP_URL}/api/v1/iris-pay/webhook/customer`, + identityHash: `${randomUUID()}`, } Logger.debug('IRIS Customer not found. Creating new one') @@ -190,31 +215,9 @@ export class IrisPayService { ) const userHash = irisCreateCustomer?.data?.userHash if (!userHash) { - return irisCreateCustomer.data.userHash - } - - try { - await this.prismaService.irisCustomer.create({ - data: { email: irisCreateCustomerDto.email, userHash }, - }) - return userHash - } catch (err) { - // A parallel request won the race and inserted first. The unique index - // on `email` guarantees our DB row is the winner; return that userHash - // so both requests converge. Our just-created IRIS customer is orphaned - // on their side (they don't dedupe by email) — acceptable leak vs. a - // duplicate local row. - if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { - Logger.warn( - `IRIS customer race on email=${irisCreateCustomerDto.email}; using winning row. Orphaned IRIS userHash=${userHash}`, - ) - const winner = await this.prismaService.irisCustomer.findUniqueOrThrow({ - where: { email: irisCreateCustomerDto.email }, - }) - return winner.userHash - } - throw err + throw new InternalServerErrorException('IRIS signup did not return a userHash') } + return userHash } async createCheckout(irisCreateCheckoutDto: IRISCreateCheckoutSessionDto) { @@ -239,6 +242,8 @@ export class IrisPayService { this.createWebhook(paymentId, irisCreateCheckoutDto), ]) + console.log(userHashRes, webhookRes as any) + if (userHashRes.status !== 'fulfilled' || webhookRes.status !== 'fulfilled') { throw new InternalServerErrorException( "Couldn't initiate IRIS checkout at this time.\n Please try again later", diff --git a/migrations/20240630131931_add_iris_customers/migration.sql b/migrations/20240630131931_add_iris_customers/migration.sql deleted file mode 100644 index 4ae7120a..00000000 --- a/migrations/20240630131931_add_iris_customers/migration.sql +++ /dev/null @@ -1,10 +0,0 @@ --- CreateTable -CREATE TABLE "iris_customers" ( - "user_hash" UUID NOT NULL, - "email" VARCHAR NOT NULL, - - CONSTRAINT "iris_customers_pkey" PRIMARY KEY ("user_hash") -); - --- CreateIndex -CREATE UNIQUE INDEX "iris_customers_email_key" ON "iris_customers"("email"); diff --git a/podkrepi.dbml b/podkrepi.dbml index 7c547028..2974d355 100644 --- a/podkrepi.dbml +++ b/podkrepi.dbml @@ -353,11 +353,6 @@ Table countries { cities cities [not null] } -Table iris_customers { - userHash String [pk] - email String [unique, not null] -} - Table vaults { id String [pk] currency Currency [not null, default: 'EUR'] diff --git a/schema.prisma b/schema.prisma index 97a3fa9a..cc31d27a 100644 --- a/schema.prisma +++ b/schema.prisma @@ -430,13 +430,6 @@ model Country { @@map("countries") } -model IrisCustomer { - userHash String @id @map("user_hash") @db.Uuid - email String @unique @db.VarChar - - @@map("iris_customers") -} - model Vault { id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid currency Currency @default(EUR) From fe8429e9a55ff162d5c2afbe48887b0da33e9933 Mon Sep 17 00:00:00 2001 From: Aleksandar Petkov Date: Sun, 3 May 2026 14:29:50 +0300 Subject: [PATCH 12/22] refactor: Abstract IRIS API calls into their own class --- .../src/iris-pay/entities/iris-pay.types.ts | 22 +++ apps/api/src/iris-pay/iris-pay-api-client.ts | 97 +++++++++++++ .../src/iris-pay/iris-pay.controller.spec.ts | 27 +++- apps/api/src/iris-pay/iris-pay.module.ts | 3 +- .../api/src/iris-pay/iris-pay.service.spec.ts | 97 +++++++------ apps/api/src/iris-pay/iris-pay.service.ts | 133 ++++++------------ apps/api/src/stripe/stripe.service.ts | 17 ++- 7 files changed, 247 insertions(+), 149 deletions(-) create mode 100644 apps/api/src/iris-pay/iris-pay-api-client.ts diff --git a/apps/api/src/iris-pay/entities/iris-pay.types.ts b/apps/api/src/iris-pay/entities/iris-pay.types.ts index f72362fd..f316a3d3 100644 --- a/apps/api/src/iris-pay/entities/iris-pay.types.ts +++ b/apps/api/src/iris-pay/entities/iris-pay.types.ts @@ -32,3 +32,25 @@ export type FindCustomerResponse = { lastname: string | null surname: string | null } + +export type PayeBank = { + bankHash: string + name: string + country: string +} + +export type IrisHookHash = { + date: Date + payeeName: string + payerName: string + payerBank: PayeBank + payeeBank: PayeBank + description: string + sum: string + payerIban: string + payeeIban: string + id: string + currency: string + status: string + reasonForFail: string +} diff --git a/apps/api/src/iris-pay/iris-pay-api-client.ts b/apps/api/src/iris-pay/iris-pay-api-client.ts new file mode 100644 index 00000000..8e932734 --- /dev/null +++ b/apps/api/src/iris-pay/iris-pay-api-client.ts @@ -0,0 +1,97 @@ +import { Injectable } from '@nestjs/common' +import { HttpService } from '@nestjs/axios' +import { ConfigService } from '@nestjs/config' +import { AxiosRequestConfig } from 'axios' +import { + CreateCustomerReq, + CreateIrisCustomerResponse, + FindCustomerResponse, + IrisHookHash, + RegisterWebhookReq, +} from './entities/iris-pay.types' + +/** + * Gateway for the IRIS Pay HTTP API. Every IRIS API call in this codebase + * should go through this class — no other file should call + * `httpService.axiosRef.X(this.irisEndpoint + ...)` directly. + * + * Why this exists: + * 1. Single grep target for "what does this app do with IRIS Pay." + * 2. Single place to add cross-cutting concerns (logging, error mapping, + * retries) when we need them. + * 3. Owns IRIS auth conventions (`agentHash` body field / `x-agent-hash` + * header) so callers never assemble headers or auth-bearing payloads + * themselves — same role the Stripe SDK plays for `StripeApiClient`. + * + * Methods are intentionally thin — orchestration, signing, DB lookups, and + * error semantics ("not-found vs. hard fail") belong in `IrisPayService`. + * Each method = exactly one IRIS endpoint. Errors propagate as raw + * `AxiosError`; the service decides how to interpret them. + * + * Signature rule: every method takes `(...inputs, options?: AxiosRequestConfig)` + * so callers can pass per-call concerns (timeout, signal, extra headers) + * without having to edit the gateway first. + */ +@Injectable() +export class IrisPayApiClient { + private readonly agentHash: string + private readonly endpoint: string + + constructor(private readonly httpService: HttpService, private readonly config: ConfigService) { + this.agentHash = this.config.get('IRIS_AGENT_HASH', '') + this.endpoint = this.config.get('IRIS_API_URL', '') + } + + // Hooks + async createHook( + req: Omit, + options?: AxiosRequestConfig, + ): Promise { + const body: RegisterWebhookReq = { ...req, agentHash: this.agentHash } + const res = await this.httpService.axiosRef.post( + `${this.endpoint}/createhook`, + body, + options, + ) + return res.data + } + + // Customers + async findCustomer(email: string, options?: AxiosRequestConfig): Promise { + const res = await this.httpService.axiosRef.post( + `${this.endpoint}/agent/user/check`, + { email }, + this.withAgentHashHeader(options), + ) + return res.data + } + + async signupCustomer( + req: Omit, + options?: AxiosRequestConfig, + ): Promise { + const body: CreateCustomerReq = { ...req, agentHash: this.agentHash } + const res = await this.httpService.axiosRef.post( + `${this.endpoint}/signup`, + body, + options, + ) + return res.data + } + + // Payment status + async getPaymentStatus(hookHash: string, options?: AxiosRequestConfig): Promise { + const res = await this.httpService.axiosRef.get( + `${this.endpoint}/status/${hookHash}`, + this.withAgentHashHeader(options), + ) + return res.data + } + + private withAgentHashHeader(options?: AxiosRequestConfig): AxiosRequestConfig { + return { + ...(options ?? {}), + headers: { ...(options?.headers ?? {}), 'x-agent-hash': this.agentHash }, + } + } +} diff --git a/apps/api/src/iris-pay/iris-pay.controller.spec.ts b/apps/api/src/iris-pay/iris-pay.controller.spec.ts index 382c1c7e..8bc33fa8 100644 --- a/apps/api/src/iris-pay/iris-pay.controller.spec.ts +++ b/apps/api/src/iris-pay/iris-pay.controller.spec.ts @@ -114,13 +114,24 @@ describe('IrisPayController', () => { }) describe('/webhook', () => { + const makeReq = () => + ({ + method: 'GET', + originalUrl: '/api/v1/iris-pay/webhook', + body: {}, + } as unknown as any) + it('verifies the signed state and calls finalizePayment with the decoded paymentId', async () => { mockIrisPayService.verifySignedState.mockReturnValue('payment-id-1') mockIrisPayService.finalizePayment.mockResolvedValue({ status: PaymentStatus.succeeded, donationId: 'don-1', }) - const result = await controller.webhookEndpoint('payment-id-1.signature') + const result = await controller.webhookEndpoint( + { state: 'payment-id-1.signature' }, + { 'x-iris-event-type': 'PAYMENT_STARTED' }, + makeReq(), + ) expect(mockIrisPayService.verifySignedState).toHaveBeenCalledWith('payment-id-1.signature') expect(mockIrisPayService.finalizePayment).toHaveBeenCalledWith('payment-id-1') expect(result).toEqual({ ok: true }) @@ -130,7 +141,11 @@ describe('IrisPayController', () => { mockIrisPayService.verifySignedState.mockImplementation(() => { throw new UnauthorizedException('Invalid webhook signature') }) - const result = await controller.webhookEndpoint('payment-id-1.wrong-sig') + const result = await controller.webhookEndpoint( + { state: 'payment-id-1.wrong-sig' }, + {}, + makeReq(), + ) expect(mockIrisPayService.finalizePayment).not.toHaveBeenCalled() expect(result).toEqual({ ok: true }) }) @@ -138,12 +153,16 @@ describe('IrisPayController', () => { it('swallows finalize errors and still returns 200 so IRIS does not spam retries', async () => { mockIrisPayService.verifySignedState.mockReturnValue('payment-id-2') mockIrisPayService.finalizePayment.mockRejectedValue(new Error('boom')) - const result = await controller.webhookEndpoint('payment-id-2.signature') + const result = await controller.webhookEndpoint( + { state: 'payment-id-2.signature' }, + {}, + makeReq(), + ) expect(result).toEqual({ ok: true }) }) it('does not verify or finalize when state is missing', async () => { - const result = await controller.webhookEndpoint('') + const result = await controller.webhookEndpoint({}, {}, makeReq()) expect(mockIrisPayService.verifySignedState).not.toHaveBeenCalled() expect(mockIrisPayService.finalizePayment).not.toHaveBeenCalled() expect(result).toEqual({ ok: true }) diff --git a/apps/api/src/iris-pay/iris-pay.module.ts b/apps/api/src/iris-pay/iris-pay.module.ts index cfac03c9..2343a69c 100644 --- a/apps/api/src/iris-pay/iris-pay.module.ts +++ b/apps/api/src/iris-pay/iris-pay.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common' import { IrisPayService } from './iris-pay.service' +import { IrisPayApiClient } from './iris-pay-api-client' import { IrisPayController } from './iris-pay.controller' import { ConfigModule } from '@nestjs/config' import { HttpModule } from '@nestjs/axios' @@ -20,6 +21,6 @@ import { PaymentSessionGuard } from './guards/payment-session.guard' DonationsModule, ], controllers: [IrisPayController], - providers: [IrisPayService, PaymentSessionService, PaymentSessionGuard], + providers: [IrisPayService, IrisPayApiClient, PaymentSessionService, PaymentSessionGuard], }) export class IrisPayModule {} diff --git a/apps/api/src/iris-pay/iris-pay.service.spec.ts b/apps/api/src/iris-pay/iris-pay.service.spec.ts index b9e4dfe4..e36dd008 100644 --- a/apps/api/src/iris-pay/iris-pay.service.spec.ts +++ b/apps/api/src/iris-pay/iris-pay.service.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing' import { IrisPayService } from './iris-pay.service' +import { IrisPayApiClient } from './iris-pay-api-client' import { ConfigService } from '@nestjs/config' -import { HttpService } from '@nestjs/axios' import { PrismaService } from '../prisma/prisma.service' import { CampaignService } from '../campaign/campaign.service' import { DonationsService } from '../donations/donations.service' @@ -26,11 +26,11 @@ describe('IrisPayService', () => { }), } - const mockHttpService = { - axiosRef: { - post: jest.fn(), - get: jest.fn(), - }, + const mockIrisApi = { + createHook: jest.fn(), + findCustomer: jest.fn(), + signupCustomer: jest.fn(), + getPaymentStatus: jest.fn(), } const mockPrismaService = { @@ -58,7 +58,7 @@ describe('IrisPayService', () => { providers: [ IrisPayService, { provide: ConfigService, useValue: mockConfigService }, - { provide: HttpService, useValue: mockHttpService }, + { provide: IrisPayApiClient, useValue: mockIrisApi }, { provide: PrismaService, useValue: mockPrismaService }, { provide: CampaignService, useValue: mockCampaignService }, { provide: DonationsService, useValue: mockDonationsService }, @@ -107,46 +107,47 @@ describe('IrisPayService', () => { }) }) - describe('findCustomer / createCustomer', () => { + describe('createCustomer', () => { const customerDto = { email: 'john@example.com', name: 'John', family: 'Doe' } it('returns the userHash from IRIS when the customer is found', async () => { - mockHttpService.axiosRef.post.mockResolvedValueOnce({ - data: { userHash: 'existing-hash', name: 'John', lastname: 'Doe', surname: null }, + mockIrisApi.findCustomer.mockResolvedValueOnce({ + userHash: 'existing-hash', + name: 'John', + lastname: 'Doe', + surname: null, }) const result = await service.createCustomer(customerDto) expect(result).toBe('existing-hash') - // Only the /agent/user/check call — no fallback to /signup. - expect(mockHttpService.axiosRef.post).toHaveBeenCalledTimes(1) + // No fallback to signup when find succeeds. + expect(mockIrisApi.signupCustomer).not.toHaveBeenCalled() }) - it('falls back to /signup when IRIS reports emailNotFound', async () => { - mockHttpService.axiosRef.post - .mockRejectedValueOnce({ - isAxiosError: true, - response: { status: 400, data: { code: 'emailNotFound' } }, - }) - .mockResolvedValueOnce({ data: { userHash: 'new-hash' } }) + it('falls back to signup when IRIS reports emailNotFound', async () => { + mockIrisApi.findCustomer.mockRejectedValueOnce({ + isAxiosError: true, + response: { status: 400, data: { code: 'emailNotFound' } }, + }) + mockIrisApi.signupCustomer.mockResolvedValueOnce({ userHash: 'new-hash' }) const result = await service.createCustomer(customerDto) expect(result).toBe('new-hash') - expect(mockHttpService.axiosRef.post).toHaveBeenCalledTimes(2) + expect(mockIrisApi.signupCustomer).toHaveBeenCalledTimes(1) }) - it('rethrows non-emailNotFound errors from /agent/user/check', async () => { + it('rethrows non-emailNotFound errors from findCustomer', async () => { const networkErr = new Error('boom') - mockHttpService.axiosRef.post.mockRejectedValueOnce(networkErr) + mockIrisApi.findCustomer.mockRejectedValueOnce(networkErr) await expect(service.createCustomer(customerDto)).rejects.toBe(networkErr) - // /signup is never attempted on unexpected check failures. - expect(mockHttpService.axiosRef.post).toHaveBeenCalledTimes(1) + // signup is never attempted on unexpected check failures. + expect(mockIrisApi.signupCustomer).not.toHaveBeenCalled() }) - it('throws when /signup returns no userHash', async () => { - mockHttpService.axiosRef.post - .mockRejectedValueOnce({ - isAxiosError: true, - response: { status: 400, data: { code: 'emailNotFound' } }, - }) - .mockResolvedValueOnce({ data: {} }) + it('throws when signup returns no userHash', async () => { + mockIrisApi.findCustomer.mockRejectedValueOnce({ + isAxiosError: true, + response: { status: 400, data: { code: 'emailNotFound' } }, + }) + mockIrisApi.signupCustomer.mockResolvedValueOnce({}) await expect(service.createCustomer(customerDto)).rejects.toThrow(/userHash/) }) }) @@ -235,7 +236,8 @@ describe('IrisPayService', () => { // Validation should pass; we don't care what happens downstream here. mockCampaignService.getCampaignById.mockResolvedValue({ id: 'camp-1', currency: 'BGN' }) mockCampaignService.validateCampaign.mockResolvedValue(undefined) - mockHttpService.axiosRef.post.mockResolvedValue({ data: { userHash: 'u' } }) + mockIrisApi.findCustomer.mockResolvedValue({ userHash: 'u' }) + mockIrisApi.createHook.mockResolvedValue('hook-abc') mockPrismaService.$transaction.mockResolvedValue(undefined) // We don't assert success — just that validation didn't reject. @@ -250,7 +252,8 @@ describe('IrisPayService', () => { it('accepts when both URLs are omitted', async () => { mockCampaignService.getCampaignById.mockResolvedValue({ id: 'camp-1', currency: 'BGN' }) mockCampaignService.validateCampaign.mockResolvedValue(undefined) - mockHttpService.axiosRef.post.mockResolvedValue({ data: { userHash: 'u' } }) + mockIrisApi.findCustomer.mockResolvedValue({ userHash: 'u' }) + mockIrisApi.createHook.mockResolvedValue('hook-abc') mockPrismaService.$transaction.mockResolvedValue(undefined) await expect(service.createCheckout(baseDto)).resolves.not.toThrow() }) @@ -305,8 +308,11 @@ describe('IrisPayService', () => { beforeEach(() => { mockPrismaService.payment.findUnique.mockResolvedValue(basePayment) - mockHttpService.axiosRef.get.mockResolvedValue({ data: baseIrisResult }) - mockDonationsService.updateDonationPayment.mockResolvedValue('don-1') + mockIrisApi.getPaymentStatus.mockResolvedValue(baseIrisResult) + mockDonationsService.updateDonationPayment.mockResolvedValue({ + id: 'don-1', + status: PaymentStatus.succeeded, + }) }) it('throws unknown_payment when Payment row missing', async () => { @@ -326,7 +332,7 @@ describe('IrisPayService', () => { ], }) const result = await service.finalizePayment(paymentId) - expect(mockHttpService.axiosRef.get).not.toHaveBeenCalled() + expect(mockIrisApi.getPaymentStatus).not.toHaveBeenCalled() expect(donationsService.updateDonationPayment).not.toHaveBeenCalled() expect(mockPrismaService.donationMetadata.upsert).not.toHaveBeenCalled() expect(result).toEqual({ @@ -348,7 +354,7 @@ describe('IrisPayService', () => { ], }) const result = await service.finalizePayment(paymentId) - expect(mockHttpService.axiosRef.get).not.toHaveBeenCalled() + expect(mockIrisApi.getPaymentStatus).not.toHaveBeenCalled() expect(result.reason).toBe('insufficient funds') }) @@ -358,28 +364,24 @@ describe('IrisPayService', () => { status: PaymentStatus.waiting, }) await service.finalizePayment(paymentId) - expect(mockHttpService.axiosRef.get).toHaveBeenCalled() + expect(mockIrisApi.getPaymentStatus).toHaveBeenCalled() expect(donationsService.updateDonationPayment).toHaveBeenCalled() }) it('throws iris_unavailable when verifyPayment rejects', async () => { - mockHttpService.axiosRef.get.mockRejectedValue(new Error('network')) + mockIrisApi.getPaymentStatus.mockRejectedValue(new Error('network')) await expect(service.finalizePayment(paymentId)).rejects.toBeInstanceOf( ServiceUnavailableException, ) }) it('throws currency_mismatch on currency divergence', async () => { - mockHttpService.axiosRef.get.mockResolvedValue({ - data: { ...baseIrisResult, currency: 'EUR' }, - }) + mockIrisApi.getPaymentStatus.mockResolvedValue({ ...baseIrisResult, currency: 'EUR' }) await expect(service.finalizePayment(paymentId)).rejects.toBeInstanceOf(ConflictException) }) it('uses IRIS amount even when it differs from DB amount', async () => { - mockHttpService.axiosRef.get.mockResolvedValue({ - data: { ...baseIrisResult, sum: '15.00' }, - }) + mockIrisApi.getPaymentStatus.mockResolvedValue({ ...baseIrisResult, sum: '15.00' }) await service.finalizePayment(paymentId) expect(donationsService.updateDonationPayment).toHaveBeenCalledWith( campaign, @@ -456,7 +458,10 @@ describe('IrisPayService', () => { it('forwards to donationsService.updateDonationPayment', async () => { mockCampaignService.getCampaignById.mockResolvedValue(mockCampaign) - mockDonationsService.updateDonationPayment.mockResolvedValue('donation-123') + mockDonationsService.updateDonationPayment.mockResolvedValue({ + id: 'donation-123', + status: PaymentStatus.succeeded, + }) const result = await service.finishPaymentSession(finishPaymentDto) diff --git a/apps/api/src/iris-pay/iris-pay.service.ts b/apps/api/src/iris-pay/iris-pay.service.ts index 4f640591..1596713a 100644 --- a/apps/api/src/iris-pay/iris-pay.service.ts +++ b/apps/api/src/iris-pay/iris-pay.service.ts @@ -11,14 +11,9 @@ import { import { createHmac, randomUUID, timingSafeEqual } from 'crypto' import { IRISCreateCheckoutSessionDto } from './dto/create-iris-pay.dto' -import { - CreateCustomerReq, - CreateIrisCustomerResponse, - FindCustomerResponse, - RegisterWebhookReq, -} from './entities/iris-pay.types' +import { IrisHookHash } from './entities/iris-pay.types' +import { IrisPayApiClient } from './iris-pay-api-client' import { ConfigService } from '@nestjs/config' -import { HttpService } from '@nestjs/axios' import axios from 'axios' import { IrisCreateCustomerDto } from './dto/create-iris-customer' import { PrismaService } from '../prisma/prisma.service' @@ -29,28 +24,6 @@ import { PaymentProvider, PaymentStatus, PaymentType, Prisma } from '@prisma/cli import { PaymentData } from '../donations/helpers/payment-intent-helpers' import { isFinal } from '../donations/helpers/donation-status-updates' -export interface IrisHookHash { - date: Date - payeeName: string - payerName: string - payerBank: PayeBank - payeeBank: PayeBank - description: string - sum: string - payerIban: string - payeeIban: string - id: string - currency: string - status: string - reasonForFail: string -} - -export interface PayeBank { - bankHash: string - name: string - country: string -} - export interface FinalizeResult { status: PaymentStatus donationId?: string @@ -59,18 +32,14 @@ export interface FinalizeResult { @Injectable() export class IrisPayService { - agentHash: string - irisEndpoint: string private readonly irisWebhookSecret: string constructor( private config: ConfigService, - private httpService: HttpService, + private irisApi: IrisPayApiClient, private prismaService: PrismaService, private campaignService: CampaignService, private donationsService: DonationsService, ) { - this.agentHash = this.config.get('IRIS_AGENT_HASH', '') - this.irisEndpoint = this.config.get('IRIS_API_URL', '') this.irisWebhookSecret = this.config.get('iris.irisWebhookSecret', '') } @@ -134,7 +103,7 @@ export class IrisPayService { throw new BadRequestException(`Invalid ${field}`) } if (parsed.protocol !== allowed.protocol || parsed.host !== allowed.host) { - // throw new BadRequestException(`${field} origin not allowed`) + throw new BadRequestException(`${field} origin not allowed`) } } @@ -143,81 +112,54 @@ export class IrisPayService { irisRegisterWebhookDto?: IRISCreateCheckoutSessionDto, ): Promise { const APP_URL = this.config.get('APP_URL') - const data: RegisterWebhookReq = { + return this.irisApi.createHook({ url: `${APP_URL}/api/v1/iris-pay/webhook`, - agentHash: this.agentHash, // Signed so the webhook can authenticate IRIS's callback without trusting // the raw paymentId — anyone with the UUID alone can't forge this. state: this.signPaymentId(paymentId), - } - - const webhookUrl = `${this.irisEndpoint}/createhook` - return (await this.httpService.axiosRef.post(webhookUrl, data)).data + }) } - async verifyPayment(body: { hookHash: string }) { - const result = await this.httpService.axiosRef.get( - `${this.irisEndpoint}/status/${body.hookHash}`, - { - headers: { - 'x-agent-hash': this.agentHash, - }, - }, - ) - - return result?.data + async verifyPayment(body: { hookHash: string }): Promise { + return this.irisApi.getPaymentStatus(body.hookHash) } - // Looks up an existing IRIS customer by email. Returns the userHash if - // found, null if IRIS reports `emailNotFound`, and throws on any other - // error so the caller aborts. - async findCustomer(email: string): Promise { - const checkUrl = `${this.irisEndpoint}/agent/user/check` + async createCustomer(irisCreateCustomerDto: IrisCreateCustomerDto): Promise { try { - const res = await this.httpService.axiosRef.post( - checkUrl, - { email }, - { headers: { 'x-agent-hash': this.agentHash } }, - ) - return res.data?.userHash ?? null + const found = await this.irisApi.findCustomer(irisCreateCustomerDto.email) + if (found?.userHash) { + Logger.debug('IRIS customer found by email') + return found.userHash + } } catch (err) { - if ( - axios.isAxiosError(err) && - err.response?.status === 400 && - (err.response.data as { code?: string } | undefined)?.code === 'emailNotFound' - ) { - return null + if (!this.isEmailNotFoundError(err)) { + throw err } - throw err - } - } - - async createCustomer(irisCreateCustomerDto: IrisCreateCustomerDto): Promise { - const existingUserHash = await this.findCustomer(irisCreateCustomerDto.email) - if (existingUserHash) { - Logger.debug('IRIS customer found by email') - return existingUserHash + // emailNotFound — fall through to signup. } const APP_URL = this.config.get('APP_URL', '') - const data: CreateCustomerReq = { - agentHash: this.agentHash, + Logger.debug('IRIS Customer not found. Creating new one') + const created = await this.irisApi.signupCustomer({ ...irisCreateCustomerDto, webhookUrl: `${APP_URL}/api/v1/iris-pay/webhook/customer`, identityHash: `${randomUUID()}`, + }) + if (!created?.userHash) { + throw new InternalServerErrorException('IRIS signup did not return a userHash') } + return created.userHash + } - Logger.debug('IRIS Customer not found. Creating new one') - const createCustomerUrl = `${this.irisEndpoint}/signup` - const irisCreateCustomer = await this.httpService.axiosRef.post( - createCustomerUrl, - data, + // IRIS reports an unknown email as HTTP 400 with body `{code: "emailNotFound"}`. + // Treated as a soft "not found" so `createCustomer` can fall through to signup; + // any other failure is a hard error and propagates. + private isEmailNotFoundError(err: unknown): boolean { + return ( + axios.isAxiosError(err) && + err.response?.status === 400 && + (err.response.data as { code?: string } | undefined)?.code === 'emailNotFound' ) - const userHash = irisCreateCustomer?.data?.userHash - if (!userHash) { - throw new InternalServerErrorException('IRIS signup did not return a userHash') - } - return userHash } async createCheckout(irisCreateCheckoutDto: IRISCreateCheckoutSessionDto) { @@ -371,7 +313,7 @@ export class IrisPayService { type: donation.type, } - const donationId = await this.donationsService.updateDonationPayment( + const updated = await this.donationsService.updateDonationPayment( campaign, paymentData, status, @@ -381,7 +323,7 @@ export class IrisPayService { return { status, - donationId, + donationId: updated?.id, reason: irisResult.reasonForFail, } } @@ -475,7 +417,12 @@ export class IrisPayService { const paymentStatus = this.mapStatusToPaymentStatus(finishPaymentDto.status) // Call donationService.updateDonationPayment - return await this.donationsService.updateDonationPayment(campaign, paymentData, paymentStatus) + const updated = await this.donationsService.updateDonationPayment( + campaign, + paymentData, + paymentStatus, + ) + return updated?.id } mapStatusToPaymentStatus(status: string): PaymentStatus { diff --git a/apps/api/src/stripe/stripe.service.ts b/apps/api/src/stripe/stripe.service.ts index defc61e6..b328fa34 100644 --- a/apps/api/src/stripe/stripe.service.ts +++ b/apps/api/src/stripe/stripe.service.ts @@ -476,7 +476,9 @@ export class StripeService { * https://stripe.com/docs/api/refunds/create * @param paymentIntentId Stripe payment intent id */ - async refundStripePayment(paymentIntentId: string): Promise<{ id: string; status: PaymentStatus } | undefined> { + async refundStripePayment( + paymentIntentId: string, + ): Promise<{ id: string; status: PaymentStatus } | undefined> { const intent = await this.api.retrievePaymentIntent(paymentIntentId) if (!intent) { throw new BadRequestException('Payment Intent is missing from stripe') @@ -488,7 +490,9 @@ export class StripeService { }) if (refund.status !== 'succeeded') { - throw new BadRequestException(`Refund failed with status: ${refund.status}. Reason: ${refund.failure_reason}`) + throw new BadRequestException( + `Refund failed with status: ${refund.status}. Reason: ${refund.failure_reason}`, + ) } const donation = await this.donationService.getDonationByPaymentIntent(paymentIntentId) @@ -496,11 +500,14 @@ export class StripeService { if (campaign) { const charge = intent.latest_charge as Stripe.Charge | string - const chargeObj = - typeof charge === 'string' ? await this.api.retrieveCharge(charge) : charge + const chargeObj = typeof charge === 'string' ? await this.api.retrieveCharge(charge) : charge const billingData = getPaymentDataFromCharge(chargeObj) - return await this.donationService.updateDonationPayment(campaign, billingData, PaymentStatus.refund) + return await this.donationService.updateDonationPayment( + campaign, + billingData, + PaymentStatus.refund, + ) } } From 6ce3718de0963825a2017ed3a654b81c8378bb23 Mon Sep 17 00:00:00 2001 From: Aleksandar Petkov Date: Sun, 3 May 2026 22:51:28 +0300 Subject: [PATCH 13/22] chore: Protect iris endpoints behind beta-tester roles --- apps/api/src/iris-pay/iris-pay.controller.ts | 22 +++++++++++++++---- .../src/lib/roles/realm/beta-tester.ts | 3 +++ .../src/lib/roles/realm/index.ts | 1 + 3 files changed, 22 insertions(+), 4 deletions(-) create mode 100644 libs/podkrepi-types/src/lib/roles/realm/beta-tester.ts diff --git a/apps/api/src/iris-pay/iris-pay.controller.ts b/apps/api/src/iris-pay/iris-pay.controller.ts index cc72d192..ac53ade8 100644 --- a/apps/api/src/iris-pay/iris-pay.controller.ts +++ b/apps/api/src/iris-pay/iris-pay.controller.ts @@ -19,7 +19,12 @@ import { import { Request, Response } from 'express' import { IrisPayService } from './iris-pay.service' import { IRISCreateCheckoutSessionDto } from './dto/create-iris-pay.dto' -import { Public } from 'nest-keycloak-connect' +import { Public, RoleMatchingMode, Roles } from 'nest-keycloak-connect' +import { + RealmBetaTester, + RealmViewSupporters, + ViewSupporters, +} from '@podkrepi-bg/podkrepi-types' import { PaymentSessionGuard } from './guards/payment-session.guard' import { PaymentStep } from './decorators/payment-step.decorator' import { PaymentSessionService } from './services/payment-session.service' @@ -35,14 +40,20 @@ export class IrisPayController { ) {} @Post('start-session') - @Public() + @Roles({ + roles: [RealmViewSupporters.role, ViewSupporters.role, RealmBetaTester.role], + mode: RoleMatchingMode.ANY, + }) async startSession(@Res({ passthrough: true }) res: Response) { this.paymentSessionService.createInitialSession(res) return { status: 'ok' } } @Post('create-payment-session') - @Public() + @Roles({ + roles: [RealmViewSupporters.role, ViewSupporters.role, RealmBetaTester.role], + mode: RoleMatchingMode.ANY, + }) @UseGuards(PaymentSessionGuard) @PaymentStep('initialSession') async createIRISCheckoutSession( @@ -58,7 +69,10 @@ export class IrisPayController { @Post('finalize') @HttpCode(200) - @Public() + @Roles({ + roles: [RealmViewSupporters.role, ViewSupporters.role, RealmBetaTester.role], + mode: RoleMatchingMode.ANY, + }) @UseGuards(PaymentSessionGuard) @PaymentStep('paymentSessionCreated') async finalize( diff --git a/libs/podkrepi-types/src/lib/roles/realm/beta-tester.ts b/libs/podkrepi-types/src/lib/roles/realm/beta-tester.ts new file mode 100644 index 00000000..f9a1cc9a --- /dev/null +++ b/libs/podkrepi-types/src/lib/roles/realm/beta-tester.ts @@ -0,0 +1,3 @@ +export class RealmBetaTester { + static readonly role = 'realm:beta-tester' +} diff --git a/libs/podkrepi-types/src/lib/roles/realm/index.ts b/libs/podkrepi-types/src/lib/roles/realm/index.ts index 2fd851ea..62990623 100644 --- a/libs/podkrepi-types/src/lib/roles/realm/index.ts +++ b/libs/podkrepi-types/src/lib/roles/realm/index.ts @@ -1,2 +1,3 @@ export * from './view-supporters' export * from './view-contact-requests' +export * from './beta-tester' From b3f768e78507f6ceb88d1572fae880fa53e73581 Mon Sep 17 00:00:00 2001 From: Aleksandar Petkov Date: Tue, 5 May 2026 00:54:09 +0300 Subject: [PATCH 14/22] feat: Add admin endpoint to toggle beta-tester roles Temporary until we get better feature flag design implementation --- apps/api/src/account/account.controller.ts | 13 +++++++++++++ apps/api/src/account/account.service.ts | 4 ++++ .../api/src/account/dto/toggle-beta-tester.dto.ts | 10 ++++++++++ apps/api/src/auth/auth.service.ts | 15 +++++++++++++++ 4 files changed, 42 insertions(+) create mode 100644 apps/api/src/account/dto/toggle-beta-tester.dto.ts diff --git a/apps/api/src/account/account.controller.ts b/apps/api/src/account/account.controller.ts index 24bea4a0..0164d83c 100644 --- a/apps/api/src/account/account.controller.ts +++ b/apps/api/src/account/account.controller.ts @@ -8,6 +8,7 @@ import { Person } from '../domain/generated/person/entities' import { UpdatePersonDto } from '../person/dto/update-person.dto' import { PersonService } from '../person/person.service' import { AccountService } from './account.service' +import { ToggleBetaTesterDto } from './dto/toggle-beta-tester.dto' import { ApiTags } from '@nestjs/swagger' @Controller('account') @@ -129,4 +130,16 @@ export class AccountController { !!data.profileEnabled, ) } + + @Patch(':keycloakId/beta-tester') + @Roles({ + roles: [RealmViewSupporters.role, ViewSupporters.role], + mode: RoleMatchingMode.ANY, + }) + async toggleBetaTester( + @Param('keycloakId') keycloakId: string, + @Body() data: ToggleBetaTesterDto, + ) { + return await this.accountService.toggleBetaTesterRole(keycloakId, data.assign) + } } diff --git a/apps/api/src/account/account.service.ts b/apps/api/src/account/account.service.ts index fc6c0476..559158c3 100644 --- a/apps/api/src/account/account.service.ts +++ b/apps/api/src/account/account.service.ts @@ -35,4 +35,8 @@ export class AccountService { async changeProfileActivationStatus(keycloakId: string, newStatus: boolean) { return await this.authService.changeEnabledStatus(keycloakId, newStatus) } + + async toggleBetaTesterRole(keycloakId: string, assign: boolean) { + return await this.authService.toggleBetaTesterRole(keycloakId, assign) + } } diff --git a/apps/api/src/account/dto/toggle-beta-tester.dto.ts b/apps/api/src/account/dto/toggle-beta-tester.dto.ts new file mode 100644 index 00000000..cc20c4a1 --- /dev/null +++ b/apps/api/src/account/dto/toggle-beta-tester.dto.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger' +import { Expose } from 'class-transformer' +import { IsBoolean } from 'class-validator' + +export class ToggleBetaTesterDto { + @ApiProperty() + @Expose() + @IsBoolean() + assign: boolean +} diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 091d9664..3bfe052f 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -430,6 +430,21 @@ export class AuthService { return true } + async toggleBetaTesterRole(keycloakId: string, assign: boolean) { + await this.authenticateAdmin() + const role = await this.admin.roles.findOneByName({ name: 'beta-tester' }) + if (!role || !role.id || !role.name) { + throw new NotFoundException('beta-tester role not found in Keycloak realm') + } + const payload = { id: keycloakId, roles: [{ id: role.id, name: role.name }] } + if (assign) { + await this.admin.users.addRealmRoleMappings(payload) + } else { + await this.admin.users.delRealmRoleMappings(payload) + } + return { keycloakId, betaTester: assign } + } + async changeEnabledStatus(keycloakId: string, enabled: boolean) { await this.authenticateAdmin() // check if user is admin before attempting to activate/deactivate From fb00e71989355a47bb772ea9e75730ef65ee7d4c Mon Sep 17 00:00:00 2001 From: Aleksandar Petkov Date: Wed, 6 May 2026 17:19:26 +0300 Subject: [PATCH 15/22] chore: Disable url validation We dont use success/errorUrls for now --- apps/api/src/auth/auth.service.ts | 4 ++-- apps/api/src/iris-pay/iris-pay.controller.ts | 6 +----- apps/api/src/iris-pay/iris-pay.service.ts | 10 +++------- 3 files changed, 6 insertions(+), 14 deletions(-) diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 3bfe052f..95c4b6f3 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -231,7 +231,7 @@ export class AuthService { } person = await this.createPerson(registerDto, user.id, company?.id) - if (isCorporateReg) { + if (isCorporateReg) { const mail = new CorporateActivationEmailDto({ corporateActivationTitle: 'Нова корпоративна регистрация', companyName: registerDto.companyName || '', @@ -242,7 +242,7 @@ export class AuthService { await this.sendEmail.sendFromTemplate( mail, { to: [this.config.get('CORPORATE_DONORS_EMAIL', '')] }, - { bypassUnsubscribeManagement: { enable: true } } + { bypassUnsubscribeManagement: { enable: true } }, ) } } catch (error) { diff --git a/apps/api/src/iris-pay/iris-pay.controller.ts b/apps/api/src/iris-pay/iris-pay.controller.ts index ac53ade8..c623e293 100644 --- a/apps/api/src/iris-pay/iris-pay.controller.ts +++ b/apps/api/src/iris-pay/iris-pay.controller.ts @@ -20,11 +20,7 @@ import { Request, Response } from 'express' import { IrisPayService } from './iris-pay.service' import { IRISCreateCheckoutSessionDto } from './dto/create-iris-pay.dto' import { Public, RoleMatchingMode, Roles } from 'nest-keycloak-connect' -import { - RealmBetaTester, - RealmViewSupporters, - ViewSupporters, -} from '@podkrepi-bg/podkrepi-types' +import { RealmBetaTester, RealmViewSupporters, ViewSupporters } from '@podkrepi-bg/podkrepi-types' import { PaymentSessionGuard } from './guards/payment-session.guard' import { PaymentStep } from './decorators/payment-step.decorator' import { PaymentSessionService } from './services/payment-session.service' diff --git a/apps/api/src/iris-pay/iris-pay.service.ts b/apps/api/src/iris-pay/iris-pay.service.ts index 1596713a..808024d1 100644 --- a/apps/api/src/iris-pay/iris-pay.service.ts +++ b/apps/api/src/iris-pay/iris-pay.service.ts @@ -166,8 +166,8 @@ export class IrisPayService { // Reject off-host redirect URLs before we hand them to IRIS. IRIS forwards // the user there after payment, so an attacker-supplied host turns this // into a free phishing redirect. - this.validateRedirectUrl(irisCreateCheckoutDto.successUrl, 'successUrl') - this.validateRedirectUrl(irisCreateCheckoutDto.errorUrl, 'errorUrl') + // this.validateRedirectUrl(irisCreateCheckoutDto.successUrl, 'successUrl') + // this.validateRedirectUrl(irisCreateCheckoutDto.errorUrl, 'errorUrl') const campaign = await this.campaignService.getCampaignById(irisCreateCheckoutDto.campaignId) await this.campaignService.validateCampaign(campaign) @@ -313,11 +313,7 @@ export class IrisPayService { type: donation.type, } - const updated = await this.donationsService.updateDonationPayment( - campaign, - paymentData, - status, - ) + const updated = await this.donationsService.updateDonationPayment(campaign, paymentData, status) await this.storeIrisMetadata(donation.id, irisResult) From 1e63d98e1f32daa79f14b05030544652b4e5c762 Mon Sep 17 00:00:00 2001 From: Aleksandar Petkov Date: Sat, 9 May 2026 13:38:12 +0300 Subject: [PATCH 16/22] feat: Move session consumption to db --- apps/api/src/config/configuration.ts | 1 + .../dto/connect-paymentSession.dto.ts | 3 + .../dto/create-paymentSession.dto.ts | 4 + .../generated/paymentSession/dto/index.ts | 3 + .../dto/update-paymentSession.dto.ts | 3 + .../paymentSession/entities/index.ts | 1 + .../entities/paymentSession.entity.ts | 5 + apps/api/src/iris-pay/iris-pay.module.ts | 1 + .../services/payment-session.service.spec.ts | 96 +++++++++++++++++++ .../services/payment-session.service.ts | 59 ++++++------ .../tasks/tasks-initializer.service.spec.ts | 16 +++- .../src/tasks/tasks-initializer.service.ts | 27 ++++++ apps/api/src/tasks/tasks.module.ts | 3 +- .../migration.sql | 11 +++ podkrepi.dbml | 10 ++ schema.prisma | 12 +++ 16 files changed, 226 insertions(+), 29 deletions(-) create mode 100644 apps/api/src/domain/generated/paymentSession/dto/connect-paymentSession.dto.ts create mode 100644 apps/api/src/domain/generated/paymentSession/dto/create-paymentSession.dto.ts create mode 100644 apps/api/src/domain/generated/paymentSession/dto/index.ts create mode 100644 apps/api/src/domain/generated/paymentSession/dto/update-paymentSession.dto.ts create mode 100644 apps/api/src/domain/generated/paymentSession/entities/index.ts create mode 100644 apps/api/src/domain/generated/paymentSession/entities/paymentSession.entity.ts create mode 100644 apps/api/src/iris-pay/services/payment-session.service.spec.ts create mode 100644 migrations/20260509102209_create_payment_session/migration.sql diff --git a/apps/api/src/config/configuration.ts b/apps/api/src/config/configuration.ts index b3ac9628..ab37d8b2 100644 --- a/apps/api/src/config/configuration.ts +++ b/apps/api/src/config/configuration.ts @@ -71,5 +71,6 @@ export default () => ({ }, tasks: { import_transactions: { interval: process.env.IMPORT_TRX_TASK_INTERVAL_MINUTES }, + payment_sessions_purge: { cron: process.env.PAYMENT_SESSIONS_PURGE_CRON }, }, }) diff --git a/apps/api/src/domain/generated/paymentSession/dto/connect-paymentSession.dto.ts b/apps/api/src/domain/generated/paymentSession/dto/connect-paymentSession.dto.ts new file mode 100644 index 00000000..4a8bfef1 --- /dev/null +++ b/apps/api/src/domain/generated/paymentSession/dto/connect-paymentSession.dto.ts @@ -0,0 +1,3 @@ +export class ConnectPaymentSessionDto { + jti: string +} diff --git a/apps/api/src/domain/generated/paymentSession/dto/create-paymentSession.dto.ts b/apps/api/src/domain/generated/paymentSession/dto/create-paymentSession.dto.ts new file mode 100644 index 00000000..0fdda6a5 --- /dev/null +++ b/apps/api/src/domain/generated/paymentSession/dto/create-paymentSession.dto.ts @@ -0,0 +1,4 @@ +export class CreatePaymentSessionDto { + jti: string + expiresAt: Date +} diff --git a/apps/api/src/domain/generated/paymentSession/dto/index.ts b/apps/api/src/domain/generated/paymentSession/dto/index.ts new file mode 100644 index 00000000..13088775 --- /dev/null +++ b/apps/api/src/domain/generated/paymentSession/dto/index.ts @@ -0,0 +1,3 @@ +export * from './connect-paymentSession.dto' +export * from './create-paymentSession.dto' +export * from './update-paymentSession.dto' diff --git a/apps/api/src/domain/generated/paymentSession/dto/update-paymentSession.dto.ts b/apps/api/src/domain/generated/paymentSession/dto/update-paymentSession.dto.ts new file mode 100644 index 00000000..d46f1e50 --- /dev/null +++ b/apps/api/src/domain/generated/paymentSession/dto/update-paymentSession.dto.ts @@ -0,0 +1,3 @@ +export class UpdatePaymentSessionDto { + expiresAt?: Date +} diff --git a/apps/api/src/domain/generated/paymentSession/entities/index.ts b/apps/api/src/domain/generated/paymentSession/entities/index.ts new file mode 100644 index 00000000..86247249 --- /dev/null +++ b/apps/api/src/domain/generated/paymentSession/entities/index.ts @@ -0,0 +1 @@ +export * from './paymentSession.entity' diff --git a/apps/api/src/domain/generated/paymentSession/entities/paymentSession.entity.ts b/apps/api/src/domain/generated/paymentSession/entities/paymentSession.entity.ts new file mode 100644 index 00000000..3ce6f0f5 --- /dev/null +++ b/apps/api/src/domain/generated/paymentSession/entities/paymentSession.entity.ts @@ -0,0 +1,5 @@ +export class PaymentSession { + jti: string + consumedAt: Date + expiresAt: Date +} diff --git a/apps/api/src/iris-pay/iris-pay.module.ts b/apps/api/src/iris-pay/iris-pay.module.ts index 2343a69c..36a9abf3 100644 --- a/apps/api/src/iris-pay/iris-pay.module.ts +++ b/apps/api/src/iris-pay/iris-pay.module.ts @@ -22,5 +22,6 @@ import { PaymentSessionGuard } from './guards/payment-session.guard' ], controllers: [IrisPayController], providers: [IrisPayService, IrisPayApiClient, PaymentSessionService, PaymentSessionGuard], + exports: [PaymentSessionService], }) export class IrisPayModule {} diff --git a/apps/api/src/iris-pay/services/payment-session.service.spec.ts b/apps/api/src/iris-pay/services/payment-session.service.spec.ts new file mode 100644 index 00000000..f4c9f1fc --- /dev/null +++ b/apps/api/src/iris-pay/services/payment-session.service.spec.ts @@ -0,0 +1,96 @@ +import { UnauthorizedException } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { JwtModule } from '@nestjs/jwt' +import { Test, TestingModule } from '@nestjs/testing' +import { Prisma } from '@prisma/client' + +import { MockPrismaService, prismaMock } from '../../prisma/prisma-client.mock' +import { PaymentSessionPayload, PaymentSessionService } from './payment-session.service' + +describe('PaymentSessionService', () => { + let service: PaymentSessionService + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ConfigModule, JwtModule.register({})], + providers: [PaymentSessionService, MockPrismaService], + }).compile() + + service = module.get(PaymentSessionService) + }) + + describe('consumeSession', () => { + const validPayload = ( + overrides: Partial = {}, + ): PaymentSessionPayload => ({ + step: 'initialSession', + jti: 'jti-1', + exp: Math.floor(Date.now() / 1000) + 300, + ...overrides, + }) + + it('inserts a payment_sessions row with jti and exp-derived expiresAt', async () => { + const payload = validPayload({ exp: 1_700_000_000 }) + prismaMock.paymentSession.create.mockResolvedValue({} as never) + + await service.consumeSession(payload) + + expect(prismaMock.paymentSession.create).toHaveBeenCalledWith({ + data: { + jti: 'jti-1', + expiresAt: new Date(1_700_000_000 * 1000), + }, + }) + }) + + it('throws UnauthorizedException("already used") when prisma raises P2002', async () => { + prismaMock.paymentSession.create.mockRejectedValue( + new Prisma.PrismaClientKnownRequestError('Unique constraint failed', { + code: 'P2002', + clientVersion: 'test', + }), + ) + + await expect(service.consumeSession(validPayload())).rejects.toMatchObject({ + message: 'Payment session already used', + }) + await expect(service.consumeSession(validPayload())).rejects.toBeInstanceOf( + UnauthorizedException, + ) + }) + + it('rethrows non-P2002 prisma errors', async () => { + const dbError = new Error('connection refused') + prismaMock.paymentSession.create.mockRejectedValue(dbError) + + await expect(service.consumeSession(validPayload())).rejects.toBe(dbError) + }) + + it('throws Unauthorized when jti is missing', async () => { + await expect(service.consumeSession(validPayload({ jti: undefined }))).rejects.toBeInstanceOf( + UnauthorizedException, + ) + expect(prismaMock.paymentSession.create).not.toHaveBeenCalled() + }) + + it('throws Unauthorized when exp is missing', async () => { + await expect(service.consumeSession(validPayload({ exp: undefined }))).rejects.toBeInstanceOf( + UnauthorizedException, + ) + expect(prismaMock.paymentSession.create).not.toHaveBeenCalled() + }) + }) + + describe('purgeExpiredSessions', () => { + it('deletes rows whose expiresAt is in the past', async () => { + prismaMock.paymentSession.deleteMany.mockResolvedValue({ count: 3 }) + + const result = await service.purgeExpiredSessions() + + expect(prismaMock.paymentSession.deleteMany).toHaveBeenCalledWith({ + where: { expiresAt: { lt: expect.any(Date) } }, + }) + expect(result).toBe(3) + }) + }) +}) diff --git a/apps/api/src/iris-pay/services/payment-session.service.ts b/apps/api/src/iris-pay/services/payment-session.service.ts index 87c9b240..01de5b1d 100644 --- a/apps/api/src/iris-pay/services/payment-session.service.ts +++ b/apps/api/src/iris-pay/services/payment-session.service.ts @@ -1,15 +1,17 @@ -import { Inject, Injectable, UnauthorizedException } from '@nestjs/common' -import { CACHE_MANAGER } from '@nestjs/cache-manager' -import { Cache } from 'cache-manager' +import { Injectable, Logger, UnauthorizedException } from '@nestjs/common' import { ConfigService } from '@nestjs/config' import { JwtService } from '@nestjs/jwt' +import { Prisma } from '@prisma/client' import { randomUUID } from 'crypto' import { Request, Response } from 'express' +import { PrismaService } from '../../prisma/prisma.service' + export interface PaymentSessionPayload { step: 'initialSession' | 'paymentSessionCreated' jti?: string paymentId?: string + exp?: number } const COOKIE_NAME = 'payment_jwt' @@ -22,7 +24,7 @@ export class PaymentSessionService { constructor( private readonly configService: ConfigService, private readonly jwtService: JwtService, - @Inject(CACHE_MANAGER) private readonly cacheManager: Cache, + private readonly prisma: PrismaService, ) { this.secret = this.configService.get('iris.paymentSessionSecret', '') } @@ -61,36 +63,39 @@ export class PaymentSessionService { return payload } - // Best-effort single-use guard for the JWT's jti. Guarantees are limited: - // - // - Within a single process, sequential duplicates are rejected (second - // call sees the cache entry from the first). - // - Two *simultaneous* requests in the same process can both pass the - // get-before-set window and double-submit; the damage is bounded to a - // pair of orphan payment rows (no money, no auth impact). - // - Across processes (multi-pod deploys), the in-memory cache is not - // shared, so this guard provides no dedup at all. - // - // We accept this today because the public endpoint's worst-case outcome is - // a duplicate `payment` row that never gets completed — no privilege - // escalation, no value extracted, the attacker gains nothing for the cost - // of one real IRIS payment. - // - // TODO(cross-pod dedup): when the API is horizontally scaled, migrate this - // to a shared store (Redis SETNX, or a Postgres one-time-token table with - // `INSERT … ON CONFLICT DO NOTHING`) so the guard survives across replicas. + // Single-use guard backed by a Postgres row keyed on the JWT's `jti`. The + // create() call is atomic on the PK, so a second attempt with the same jti + // raises P2002 — translated to 401 — even when racing across replicas. async consumeSession(payload: PaymentSessionPayload): Promise { if (!payload.jti) { throw new UnauthorizedException('Invalid payment session: missing jti') } + if (!payload.exp) { + throw new UnauthorizedException('Invalid payment session: missing exp') + } - const cacheKey = `iris-pay:jti:${payload.jti}` - const consumed = await this.cacheManager.get(cacheKey) - if (consumed) { - throw new UnauthorizedException('Payment session already used') + const expiresAt = new Date(payload.exp * 1000) + + try { + await this.prisma.paymentSession.create({ + data: { jti: payload.jti, expiresAt }, + }) + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { + throw new UnauthorizedException('Payment session already used') + } + throw err } + } - await this.cacheManager.set(cacheKey, true, SESSION_TTL_SECONDS * 1000) + async purgeExpiredSessions(): Promise { + const { count } = await this.prisma.paymentSession.deleteMany({ + where: { expiresAt: { lt: new Date() } }, + }) + if (count > 0) { + Logger.debug(`Purged ${count} expired payment session(s)`) + } + return count } upgradeSession(res: Response, data: { paymentId: string }): void { diff --git a/apps/api/src/tasks/tasks-initializer.service.spec.ts b/apps/api/src/tasks/tasks-initializer.service.spec.ts index 69ba45c9..90a36007 100644 --- a/apps/api/src/tasks/tasks-initializer.service.spec.ts +++ b/apps/api/src/tasks/tasks-initializer.service.spec.ts @@ -17,6 +17,7 @@ import { TasksInitializer } from './tasks-initializer.service' import { EmailService } from '../email/email.service' import { TemplateService } from '../email/template.service' import { MarketingNotificationsModule } from '../notifications/notifications.module' +import { PaymentSessionService } from '../iris-pay/services/payment-session.service' describe('ImportTransactionsTask', () => { let taskService: TasksInitializer @@ -30,6 +31,9 @@ describe('ImportTransactionsTask', () => { const stripeMock = { checkout: { sessions: { create: jest.fn() } }, } + const paymentSessionServiceMock = { + purgeExpiredSessions: jest.fn().mockResolvedValue(0), + } // Mock the IrisTask check for environment variables jest @@ -46,7 +50,7 @@ describe('ImportTransactionsTask', () => { { provide: ConfigService, useValue: { - get: jest.fn(), + get: jest.fn((_key: string, defaultValue?: unknown) => defaultValue), }, }, { @@ -62,6 +66,7 @@ describe('ImportTransactionsTask', () => { TasksInitializer, EmailService, TemplateService, + { provide: PaymentSessionService, useValue: paymentSessionServiceMock }, ], }) .overrideProvider(PersonService) @@ -75,6 +80,7 @@ describe('ImportTransactionsTask', () => { afterEach(() => { jest.clearAllMocks() scheduler.getIntervals().forEach((el) => scheduler.deleteInterval(el)) + scheduler.getCronJobs().forEach((_, name) => scheduler.deleteCronJob(name)) }) it('should be defined', () => { @@ -84,18 +90,26 @@ describe('ImportTransactionsTask', () => { describe('initIrisTasks', () => { it('should init all dynamicaly scheduled tasks', async () => { jest.spyOn(taskService, 'initImportTransactionsTask') + jest.spyOn(taskService, 'initPurgeExpiredPaymentSessionsTask') jest.spyOn(scheduler, 'addInterval') + jest.spyOn(scheduler, 'addCronJob') // On module initiation all dynamic jobs must be scheduled taskService.onModuleInit() expect(taskService.initImportTransactionsTask).toHaveBeenCalled() + expect(taskService.initPurgeExpiredPaymentSessionsTask).toHaveBeenCalled() expect(scheduler.addInterval).toHaveBeenCalledWith( 'import-bank-transactions', expect.anything(), ) + expect(scheduler.addCronJob).toHaveBeenCalledWith( + 'purge-expired-payment-sessions', + expect.anything(), + ) expect(scheduler.getIntervals()).toEqual(['import-bank-transactions']) + expect(Array.from(scheduler.getCronJobs().keys())).toEqual(['purge-expired-payment-sessions']) }) }) }) diff --git a/apps/api/src/tasks/tasks-initializer.service.ts b/apps/api/src/tasks/tasks-initializer.service.ts index 277450d4..a52735cf 100644 --- a/apps/api/src/tasks/tasks-initializer.service.ts +++ b/apps/api/src/tasks/tasks-initializer.service.ts @@ -2,6 +2,8 @@ import { Injectable, Logger } from '@nestjs/common' import { IrisTasks } from './bank-import/import-transactions.task' import { ConfigService } from '@nestjs/config' import { Cron, SchedulerRegistry } from '@nestjs/schedule' +import { CronJob } from 'cron' +import { PaymentSessionService } from '../iris-pay/services/payment-session.service' // Schedules all background tasks @Injectable() @@ -10,6 +12,7 @@ export class TasksInitializer { private readonly irisTasks: IrisTasks, private readonly config: ConfigService, private schedulerRegistry: SchedulerRegistry, + private readonly paymentSessionService: PaymentSessionService, ) {} /* DYNAMICALY SCHEDULED TASKS */ @@ -20,6 +23,11 @@ export class TasksInitializer { } catch (e) { Logger.error('Failed to initialize ImportTransactionsTask') } + try { + this.initPurgeExpiredPaymentSessionsTask() + } catch (e) { + Logger.error('Failed to initialize PurgeExpiredPaymentSessionsTask', e) + } } initImportTransactionsTask() { @@ -42,6 +50,25 @@ export class TasksInitializer { Logger.debug(`import-bank-transactions task registered to run every ${minutes} minutes`) } + initPurgeExpiredPaymentSessionsTask() { + const jobName = 'purge-expired-payment-sessions' + const defaultCron = '0 * * * *' + const expression = this.config.get('tasks.payment_sessions_purge.cron', defaultCron) + + const job = new CronJob(expression, async () => { + try { + await this.paymentSessionService.purgeExpiredSessions() + } catch (e) { + Logger.error('An error occured while purging expired payment sessions \n', e) + } + }) + + this.schedulerRegistry.addCronJob(jobName, job) + job.start() + + Logger.debug(`${jobName} task registered with cron '${expression}'`) + } + /* DECLARATIVELY SCHEDULED TAKS */ @Cron(`0 ${process.env.CHECK_IRIS_CONSENT_TASK_HOUR} * * *`) diff --git a/apps/api/src/tasks/tasks.module.ts b/apps/api/src/tasks/tasks.module.ts index a239c50c..7df33156 100644 --- a/apps/api/src/tasks/tasks.module.ts +++ b/apps/api/src/tasks/tasks.module.ts @@ -8,9 +8,10 @@ import { TemplateService } from '../email/template.service' import { TasksInitializer } from './tasks-initializer.service' import { ConfigModule } from '@nestjs/config' import { ScheduleModule } from '@nestjs/schedule' +import { IrisPayModule } from '../iris-pay/iris-pay.module' @Module({ - imports: [HttpModule, DonationsModule, ConfigModule, ScheduleModule], + imports: [HttpModule, DonationsModule, ConfigModule, ScheduleModule, IrisPayModule], providers: [IrisTasks, PrismaService, EmailService, TemplateService, TasksInitializer], exports: [IrisTasks], }) diff --git a/migrations/20260509102209_create_payment_session/migration.sql b/migrations/20260509102209_create_payment_session/migration.sql new file mode 100644 index 00000000..9bb924c5 --- /dev/null +++ b/migrations/20260509102209_create_payment_session/migration.sql @@ -0,0 +1,11 @@ +-- CreateTable +CREATE TABLE "payment_sessions" ( + "jti" VARCHAR(64) NOT NULL, + "consumed_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" TIMESTAMPTZ(6) NOT NULL, + + CONSTRAINT "payment_sessions_pkey" PRIMARY KEY ("jti") +); + +-- CreateIndex +CREATE INDEX "payment_sessions_expires_at_idx" ON "payment_sessions"("expires_at"); diff --git a/podkrepi.dbml b/podkrepi.dbml index 2974d355..ee4b32bd 100644 --- a/podkrepi.dbml +++ b/podkrepi.dbml @@ -576,6 +576,16 @@ Table bank_transactions_files { personId String [not null] } +Table payment_sessions { + jti String [pk] + consumedAt DateTime [default: `now()`, not null] + expiresAt DateTime [not null] + + Note: 'Single-use marker for payment-session JWTs. A row is inserted when a +session JWT is consumed; the PK on `jti` makes the consume step atomic +across replicas. Rows are purged after `expiresAt` by a cron job.' +} + Table campaign_applications { id String [pk] createdAt DateTime [default: `now()`, not null] diff --git a/schema.prisma b/schema.prisma index cc31d27a..fe30f478 100644 --- a/schema.prisma +++ b/schema.prisma @@ -699,6 +699,18 @@ model BankTransactionsFile { @@map("bank_transactions_files") } +/// Single-use marker for payment-session JWTs. A row is inserted when a +/// session JWT is consumed; the PK on `jti` makes the consume step atomic +/// across replicas. Rows are purged after `expiresAt` by a cron job. +model PaymentSession { + jti String @id @db.VarChar(64) + consumedAt DateTime @default(now()) @map("consumed_at") @db.Timestamptz(6) + expiresAt DateTime @map("expires_at") @db.Timestamptz(6) + + @@index([expiresAt]) + @@map("payment_sessions") +} + enum BeneficiaryType { individual company From a1956a52bd81611ceee45fd401319d1a6d77461e Mon Sep 17 00:00:00 2001 From: Aleksandar Petkov Date: Tue, 12 May 2026 19:52:25 +0300 Subject: [PATCH 17/22] chore: Revert back redirectUrl validation --- apps/api/src/iris-pay/iris-pay.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/src/iris-pay/iris-pay.service.ts b/apps/api/src/iris-pay/iris-pay.service.ts index 808024d1..8358ca21 100644 --- a/apps/api/src/iris-pay/iris-pay.service.ts +++ b/apps/api/src/iris-pay/iris-pay.service.ts @@ -166,8 +166,8 @@ export class IrisPayService { // Reject off-host redirect URLs before we hand them to IRIS. IRIS forwards // the user there after payment, so an attacker-supplied host turns this // into a free phishing redirect. - // this.validateRedirectUrl(irisCreateCheckoutDto.successUrl, 'successUrl') - // this.validateRedirectUrl(irisCreateCheckoutDto.errorUrl, 'errorUrl') + this.validateRedirectUrl(irisCreateCheckoutDto.successUrl, 'successUrl') + this.validateRedirectUrl(irisCreateCheckoutDto.errorUrl, 'errorUrl') const campaign = await this.campaignService.getCampaignById(irisCreateCheckoutDto.campaignId) await this.campaignService.validateCampaign(campaign) From 5030d68427c79760458324056696a038daf19c3d Mon Sep 17 00:00:00 2001 From: Aleksandar Petkov Date: Wed, 13 May 2026 12:22:28 +0300 Subject: [PATCH 18/22] Revert: Change regarding stripe's webhook validation --- apps/api/src/config/configuration.ts | 14 +------------- apps/api/src/config/validation.config.ts | 2 +- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/apps/api/src/config/configuration.ts b/apps/api/src/config/configuration.ts index ab37d8b2..b3387e39 100644 --- a/apps/api/src/config/configuration.ts +++ b/apps/api/src/config/configuration.ts @@ -1,17 +1,5 @@ -import { readFileSync } from 'fs' import { SeverityLevel } from '@sentry/node' -function getStripeWebhookSecret(): string { - if (process.env.STRIPE_WEBHOOK_SECRET) { - return process.env.STRIPE_WEBHOOK_SECRET - } - try { - return readFileSync('/shared/stripe-webhook-secret', 'utf-8').trim() - } catch { - return '' - } -} - /** * Be sure to add `process.env` vars in validation schema at ./validation.config.ts */ @@ -40,7 +28,7 @@ export default () => ({ }, stripe: { secretKey: process.env.STRIPE_SECRET_KEY, - webhookSecret: getStripeWebhookSecret(), + webhookSecret: process.env.STRIPE_WEBHOOK_SECRET, }, paypal: { apiUrl: process.env.PAYPAL_URL, diff --git a/apps/api/src/config/validation.config.ts b/apps/api/src/config/validation.config.ts index d5f55e1e..bf051372 100644 --- a/apps/api/src/config/validation.config.ts +++ b/apps/api/src/config/validation.config.ts @@ -51,7 +51,7 @@ export const validationSchema = Joi.object({ // Stripe STRIPE_SECRET_KEY: Joi.string().required(), - STRIPE_WEBHOOK_SECRET: Joi.string().allow('').optional(), + STRIPE_WEBHOOK_SECRET: Joi.string().required(), //Paypal PAYPAL_URL: Joi.string().required(), From 640e8163754eb8a36f3eb47b1c4fd4f9505db233 Mon Sep 17 00:00:00 2001 From: Aleksandar Petkov Date: Wed, 13 May 2026 12:27:00 +0300 Subject: [PATCH 19/22] feat: Create beta-tester role if not found --- apps/api/src/auth/auth.service.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 95c4b6f3..229143ee 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -432,9 +432,13 @@ export class AuthService { async toggleBetaTesterRole(keycloakId: string, assign: boolean) { await this.authenticateAdmin() - const role = await this.admin.roles.findOneByName({ name: 'beta-tester' }) + let role = await this.admin.roles.findOneByName({ name: 'beta-tester' }) if (!role || !role.id || !role.name) { - throw new NotFoundException('beta-tester role not found in Keycloak realm') + await this.admin.roles.create({ name: 'beta-tester' }) + role = await this.admin.roles.findOneByName({ name: 'beta-tester' }) + } + if (!role || !role.id || !role.name) { + throw new NotFoundException('Failed to create or retrieve beta-tester role in Keycloak realm') } const payload = { id: keycloakId, roles: [{ id: role.id, name: role.name }] } if (assign) { From 4b46f1416ae4f0efb4f2523c09a0388ef77b9318 Mon Sep 17 00:00:00 2001 From: Aleksandar Petkov Date: Wed, 13 May 2026 12:30:08 +0300 Subject: [PATCH 20/22] chore: Remove redundant PG_PAYLOAD_SECRET references --- .env.example | 3 +- apps/api/src/config/configuration.ts | 1 - apps/api/src/iris-pay/README.md | 87 ---------------------------- 3 files changed, 1 insertion(+), 90 deletions(-) diff --git a/.env.example b/.env.example index 362fce66..13ac380c 100644 --- a/.env.example +++ b/.env.example @@ -116,5 +116,4 @@ CACHE_TTL=30000 ## AdminEmail ## ############## CAMPAIGN_COORDINATOR_EMAIL=campaign_coordinators@podkrepi.bg -CORPORATE_DONORS_EMAIL= -PG_PAYLOAD_SECRET= \ No newline at end of file +CORPORATE_DONORS_EMAIL= \ No newline at end of file diff --git a/apps/api/src/config/configuration.ts b/apps/api/src/config/configuration.ts index b3387e39..b91176d4 100644 --- a/apps/api/src/config/configuration.ts +++ b/apps/api/src/config/configuration.ts @@ -47,7 +47,6 @@ export default () => ({ banksEndPoint: process.env.IRIS_API_URL + '/banks?country=bulgaria', ibansEndPoint: process.env.IRIS_API_URL + '/ibans', transactionsEndPoint: process.env.IRIS_API_URL + '/transactions', - payloadSecret: process.env.PG_PAYLOAD_SECRET, paymentSessionSecret: process.env.PAYMENT_SESSION_SECRET, irisWebhookSecret: process.env.IRIS_WEBHOOK_SECRET, }, diff --git a/apps/api/src/iris-pay/README.md b/apps/api/src/iris-pay/README.md index 75924162..2023d3b9 100644 --- a/apps/api/src/iris-pay/README.md +++ b/apps/api/src/iris-pay/README.md @@ -2,92 +2,6 @@ This module handles Iris Pay integration for the Podkrepi.bg platform. -## Security - -### VerifyPayload Decorator - -The `@VerifyPayload()` decorator provides payload signature authentication for sensitive iris-pay endpoints. It validates that the `x-pbg-signature` header contains a valid HMAC-SHA256 signature of the request body using the configured `PG_PAYLOAD_SECRET` as the secret key. - -#### Usage - -```typescript -import { VerifyPayload } from './decorators/verify-payload.decorator' - -@Controller('iris-pay') -export class IrisPayController { - @Post('create-payment-session') - @Public() - @VerifyPayload() - async createPaymentSession(@Body() dto: CreatePaymentDto) { - // This endpoint is now protected by API key validation - return this.service.createPayment(dto) - } -} -``` - -#### Configuration - -Make sure to set the `PG_PAYLOAD_SECRET` environment variable: - -```bash -PG_PAYLOAD_SECRET=your-secret-key-here -``` - -#### Protected Endpoints - -The following endpoints are currently protected by the `@VerifyPayload()` decorator: - -- `POST /iris-pay/create-payment-session` - Creates a new payment session -- `POST /iris-pay/verify-hookhash` - Verifies webhook hash -- `POST /iris-pay/finish` - Finishes the payment process and updates donation status - -#### Error Responses - -The decorator will return HTTP 401 Unauthorized in the following cases: - -- Missing `x-pbg-signature` header -- Invalid signature (doesn't match expected HMAC-SHA256 of request body) -- Missing or invalid request body for signature verification -- `PG_PAYLOAD_SECRET` environment variable not configured - -#### Testing - -To test protected endpoints, you need to generate a valid HMAC-SHA256 signature of the request body: - -```bash -# Example payload -PAYLOAD='{"campaignId": "123", "email": "test@example.com"}' -SECRET="your-pg-payload-secret" - -# Generate signature -SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" -hex | cut -d' ' -f2) - -# Make the request -curl -X POST http://localhost:5010/api/v1/iris-pay/create-payment-session \ - -H "Content-Type: application/json" \ - -H "x-pbg-signature: $SIGNATURE" \ - -d "$PAYLOAD" -``` - -Or with the `sha256=` prefix: - -```bash -curl -X POST http://localhost:5010/api/v1/iris-pay/create-payment-session \ - -H "Content-Type: application/json" \ - -H "x-pbg-signature: sha256=$SIGNATURE" \ - -d "$PAYLOAD" -``` - -#### Implementation Details - -- The guard validates the `x-pbg-signature` header contains a valid HMAC-SHA256 signature -- Uses the `PG_PAYLOAD_SECRET` environment variable as the secret key for signature verification -- Supports signatures with or without the `sha256=` prefix -- Uses `crypto.timingSafeEqual()` for secure signature comparison to prevent timing attacks -- Handles different request body formats (string, Buffer, parsed JSON) -- Logs security events for monitoring and debugging -- Can be applied to any controller method that needs payload signature verification - ## Endpoints ### POST /iris-pay/finish @@ -135,7 +49,6 @@ The endpoint maps iris-pay status strings to internal PaymentStatus enum values: ```bash curl -X POST http://localhost:5010/api/v1/iris-pay/finish \ -H "Content-Type: application/json" \ - -H "x-pbg-signature: sha256=your-signature-here" \ -d '{ "hookHash": "payment-123", "status": "CONFIRMED", From 40768584142402a95f7be9756febbe8f84b03fea Mon Sep 17 00:00:00 2001 From: Aleksandar Petkov Date: Wed, 13 May 2026 12:32:39 +0300 Subject: [PATCH 21/22] chore: Remove redundant dto --- apps/api/src/iris-pay/dto/update-iris-pay.dto.ts | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 apps/api/src/iris-pay/dto/update-iris-pay.dto.ts diff --git a/apps/api/src/iris-pay/dto/update-iris-pay.dto.ts b/apps/api/src/iris-pay/dto/update-iris-pay.dto.ts deleted file mode 100644 index 86e113f2..00000000 --- a/apps/api/src/iris-pay/dto/update-iris-pay.dto.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { PartialType } from '@nestjs/swagger' -// import { CreateIrisPayDto } from './create-iris-pay.dto'; - -// export class UpdateIrisPayDto extends PartialType(CreateIrisPayDto) {} From 332487def0d9b3a2aa7689b1adf7f8a2abcaab69 Mon Sep 17 00:00:00 2001 From: Aleksandar Petkov Date: Wed, 13 May 2026 12:34:04 +0300 Subject: [PATCH 22/22] chore: Cleanup redudnat entity --- apps/api/src/iris-pay/entities/iris-pay.entity.ts | 1 - 1 file changed, 1 deletion(-) delete mode 100644 apps/api/src/iris-pay/entities/iris-pay.entity.ts diff --git a/apps/api/src/iris-pay/entities/iris-pay.entity.ts b/apps/api/src/iris-pay/entities/iris-pay.entity.ts deleted file mode 100644 index fe2ec23b..00000000 --- a/apps/api/src/iris-pay/entities/iris-pay.entity.ts +++ /dev/null @@ -1 +0,0 @@ -export class IrisPay {}