diff --git a/.env.example b/.env.example index e3fb0b166..13ac380c3 100644 --- a/.env.example +++ b/.env.example @@ -98,9 +98,14 @@ IRIS_AGENT_HASH= 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 @@ -111,4 +116,4 @@ CACHE_TTL=30000 ## AdminEmail ## ############## CAMPAIGN_COORDINATOR_EMAIL=campaign_coordinators@podkrepi.bg -CORPORATE_DONORS_EMAIL= +CORPORATE_DONORS_EMAIL= \ No newline at end of file diff --git a/apps/api/src/account/account.controller.ts b/apps/api/src/account/account.controller.ts index 24bea4a0a..0164d83ce 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 fc6c04765..559158c36 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 000000000..cc20c4a1e --- /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/app/app.module.ts b/apps/api/src/app/app.module.ts index 70efbe397..078204e37 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/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 091d9664f..229143ee1 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) { @@ -430,6 +430,25 @@ export class AuthService { return true } + async toggleBetaTesterRole(keycloakId: string, assign: boolean) { + await this.authenticateAdmin() + let role = await this.admin.roles.findOneByName({ name: 'beta-tester' }) + if (!role || !role.id || !role.name) { + 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) { + 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 diff --git a/apps/api/src/config/configuration.ts b/apps/api/src/config/configuration.ts index ba300bc14..b91176d40 100644 --- a/apps/api/src/config/configuration.ts +++ b/apps/api/src/config/configuration.ts @@ -47,6 +47,8 @@ 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', + paymentSessionSecret: process.env.PAYMENT_SESSION_SECRET, + irisWebhookSecret: process.env.IRIS_WEBHOOK_SECRET, }, mail: { billingAdminEmail: process.env.BILLING_ADMIN_MAIL, @@ -56,5 +58,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/config/validation.config.ts b/apps/api/src/config/validation.config.ts index c3b38643e..bf051372d 100644 --- a/apps/api/src/config/validation.config.ts +++ b/apps/api/src/config/validation.config.ts @@ -58,4 +58,8 @@ export const validationSchema = Joi.object({ 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(), + IRIS_WEBHOOK_SECRET: Joi.string().required(), }) 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 000000000..4a8bfef1b --- /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 000000000..0fdda6a5e --- /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 000000000..130887759 --- /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 000000000..d46f1e507 --- /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 000000000..862472498 --- /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 000000000..3ce6f0f5f --- /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/donations/helpers/donation-status-updates.ts b/apps/api/src/donations/helpers/donation-status-updates.ts index 6a799d325..bced1b033 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/README.md b/apps/api/src/iris-pay/README.md new file mode 100644 index 000000000..2023d3b94 --- /dev/null +++ b/apps/api/src/iris-pay/README.md @@ -0,0 +1,65 @@ +# Iris Pay Module + +This module handles Iris Pay integration for the Podkrepi.bg platform. + +## 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" \ + -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/payment-step.decorator.ts b/apps/api/src/iris-pay/decorators/payment-step.decorator.ts new file mode 100644 index 000000000..c827f3aeb --- /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/dto/create-iris-customer.ts b/apps/api/src/iris-pay/dto/create-iris-customer.ts new file mode 100644 index 000000000..a541a05cf --- /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 new file mode 100644 index 000000000..9d1057759 --- /dev/null +++ b/apps/api/src/iris-pay/dto/create-iris-pay.dto.ts @@ -0,0 +1,66 @@ +import { ApiProperty } from '@nestjs/swagger' +import { Expose } from 'class-transformer' +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 + + @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() + billingName!: string + + @ApiProperty() + @IsEmail() + @Expose() + billingEmail!: string + + @ApiProperty({ required: false }) + @IsString() + @Expose() + @IsOptional() + successUrl?: string + + @ApiProperty({ required: false }) + @IsString() + @Expose() + @IsOptional() + errorUrl?: string +} 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 000000000..e21e2e48f --- /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/entities/iris-pay.types.ts b/apps/api/src/iris-pay/entities/iris-pay.types.ts new file mode 100644 index 000000000..f316a3d33 --- /dev/null +++ b/apps/api/src/iris-pay/entities/iris-pay.types.ts @@ -0,0 +1,56 @@ +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 +} + +export type FindCustomerResponse = { + userHash: string + name: string | null + 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/guards/payment-session.guard.ts b/apps/api/src/iris-pay/guards/payment-session.guard.ts new file mode 100644 index 000000000..270f76280 --- /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/iris-pay-api-client.ts b/apps/api/src/iris-pay/iris-pay-api-client.ts new file mode 100644 index 000000000..8e932734b --- /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 new file mode 100644 index 000000000..8bc33fa89 --- /dev/null +++ b/apps/api/src/iris-pay/iris-pay.controller.spec.ts @@ -0,0 +1,171 @@ +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, + UnauthorizedException, +} from '@nestjs/common' + +describe('IrisPayController', () => { + let controller: IrisPayController + + const mockIrisPayService = { + createCheckout: jest.fn(), + verifyPayment: jest.fn(), + finishPaymentSession: jest.fn(), + finalizePayment: jest.fn(), + verifySignedState: jest.fn(), + } + + const mockPaymentSessionService = { + createInitialSession: jest.fn(), + validateSession: jest.fn(), + consumeSession: jest.fn(), + upgradeSession: jest.fn(), + clearSession: jest.fn(), + } + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ConfigModule, HttpModule, JwtModule.register({})], + controllers: [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', () => { + 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( + { 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 }) + }) + + 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( + { state: 'payment-id-1.wrong-sig' }, + {}, + makeReq(), + ) + 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( + { 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({}, {}, 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.controller.ts b/apps/api/src/iris-pay/iris-pay.controller.ts new file mode 100644 index 000000000..c623e2938 --- /dev/null +++ b/apps/api/src/iris-pay/iris-pay.controller.ts @@ -0,0 +1,176 @@ +import { + Controller, + Get, + Post, + Body, + Req, + Res, + Query, + Header, + Headers, + 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 { 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' + +import { ApiTags } from '@nestjs/swagger' + +@Controller('iris-pay') +@ApiTags() +export class IrisPayController { + constructor( + private readonly irisPayService: IrisPayService, + private readonly paymentSessionService: PaymentSessionService, + ) {} + + @Post('start-session') + @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') + @Roles({ + roles: [RealmViewSupporters.role, ViewSupporters.role, RealmBetaTester.role], + mode: RoleMatchingMode.ANY, + }) + @UseGuards(PaymentSessionGuard) + @PaymentStep('initialSession') + async createIRISCheckoutSession( + @Body() irisCreateCustomerDto: IRISCreateCheckoutSessionDto, + @Req() req: Request, + @Res({ passthrough: true }) res: Response, + ): 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, { paymentId: result.paymentId }) + return result + } + + @Post('finalize') + @HttpCode(200) + @Roles({ + roles: [RealmViewSupporters.role, ViewSupporters.role, RealmBetaTester.role], + mode: RoleMatchingMode.ANY, + }) + @UseGuards(PaymentSessionGuard) + @PaymentStep('paymentSessionCreated') + async finalize( + @Req() req: Request, + @Res({ passthrough: true }) res: Response, + ): Promise< + { status: string; donationId?: string; reason?: string } | { error: string; reason?: string } + > { + const session = (req as any).paymentSession + const paymentId: string | undefined = session?.paymentId + if (!paymentId) { + this.paymentSessionService.clearSession(res) + throw new NotFoundException({ error: 'unknown_payment' }) + } + + Logger.debug('Finalizing IRIS payment', { paymentId }) + + 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 + } + } + + @Get('webhook') + @Public() + @Header('Cache-Control', 'no-store') + 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 } + } + 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(paymentId) + } catch (error) { + Logger.warn(`Iris webhook finalize failed for paymentId=${paymentId}: ${error}`) + } + 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.module.ts b/apps/api/src/iris-pay/iris-pay.module.ts new file mode 100644 index 000000000..36a9abf33 --- /dev/null +++ b/apps/api/src/iris-pay/iris-pay.module.ts @@ -0,0 +1,27 @@ +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' +import { JwtModule } from '@nestjs/jwt' +import { PrismaModule } from '../prisma/prisma.module' +import { CampaignModule } from '../campaign/campaign.module' +import { DonationsModule } from '../donations/donations.module' +import { PaymentSessionService } from './services/payment-session.service' +import { PaymentSessionGuard } from './guards/payment-session.guard' + +@Module({ + imports: [ + ConfigModule, + HttpModule, + JwtModule.register({}), + PrismaModule, + CampaignModule, + DonationsModule, + ], + controllers: [IrisPayController], + providers: [IrisPayService, IrisPayApiClient, PaymentSessionService, PaymentSessionGuard], + exports: [PaymentSessionService], +}) +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 000000000..e36dd008e --- /dev/null +++ b/apps/api/src/iris-pay/iris-pay.service.spec.ts @@ -0,0 +1,491 @@ +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 { 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 { FinishPaymentDto } from './dto/finish-payment.dto' +import { + BadRequestException, + ConflictException, + NotFoundException, + ServiceUnavailableException, +} from '@nestjs/common' + +describe('IrisPayService', () => { + let service: IrisPayService + let donationsService: DonationsService + let campaignService: CampaignService + + const mockConfigService = { + get: jest.fn((key: string) => { + if (key === 'APP_URL') return 'https://example.org' + return 'test-value' + }), + } + + const mockIrisApi = { + createHook: jest.fn(), + findCustomer: jest.fn(), + signupCustomer: jest.fn(), + getPaymentStatus: jest.fn(), + } + + const mockPrismaService = { + payment: { + findUnique: jest.fn(), + }, + donationMetadata: { + upsert: jest.fn().mockResolvedValue({}), + }, + $transaction: jest.fn(), + vault: { findFirstOrThrow: jest.fn() }, + } + + const mockCampaignService = { + getCampaignById: jest.fn(), + validateCampaign: jest.fn(), + } + + const mockDonationsService = { + updateDonationPayment: jest.fn(), + } + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + IrisPayService, + { provide: ConfigService, useValue: mockConfigService }, + { provide: IrisPayApiClient, useValue: mockIrisApi }, + { 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('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('createCustomer', () => { + const customerDto = { email: 'john@example.com', name: 'John', family: 'Doe' } + + it('returns the userHash from IRIS when the customer is found', async () => { + mockIrisApi.findCustomer.mockResolvedValueOnce({ + userHash: 'existing-hash', + name: 'John', + lastname: 'Doe', + surname: null, + }) + const result = await service.createCustomer(customerDto) + expect(result).toBe('existing-hash') + // No fallback to signup when find succeeds. + expect(mockIrisApi.signupCustomer).not.toHaveBeenCalled() + }) + + 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(mockIrisApi.signupCustomer).toHaveBeenCalledTimes(1) + }) + + it('rethrows non-emailNotFound errors from findCustomer', async () => { + const networkErr = new Error('boom') + mockIrisApi.findCustomer.mockRejectedValueOnce(networkErr) + await expect(service.createCustomer(customerDto)).rejects.toBe(networkErr) + // signup is never attempted on unexpected check failures. + expect(mockIrisApi.signupCustomer).not.toHaveBeenCalled() + }) + + 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/) + }) + }) + + 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) + 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. + 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) + mockIrisApi.findCustomer.mockResolvedValue({ userHash: 'u' }) + mockIrisApi.createHook.mockResolvedValue('hook-abc') + mockPrismaService.$transaction.mockResolvedValue(undefined) + await expect(service.createCheckout(baseDto)).resolves.not.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) + mockIrisApi.getPaymentStatus.mockResolvedValue(baseIrisResult) + mockDonationsService.updateDonationPayment.mockResolvedValue({ + id: 'don-1', + status: PaymentStatus.succeeded, + }) + }) + + it('throws unknown_payment when Payment row missing', async () => { + mockPrismaService.payment.findUnique.mockResolvedValue(null) + 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(mockIrisApi.getPaymentStatus).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(mockIrisApi.getPaymentStatus).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(mockIrisApi.getPaymentStatus).toHaveBeenCalled() + expect(donationsService.updateDonationPayment).toHaveBeenCalled() + }) + + it('throws iris_unavailable when verifyPayment rejects', async () => { + mockIrisApi.getPaymentStatus.mockRejectedValue(new Error('network')) + await expect(service.finalizePayment(paymentId)).rejects.toBeInstanceOf( + ServiceUnavailableException, + ) + }) + + it('throws currency_mismatch on currency divergence', async () => { + 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 () => { + mockIrisApi.getPaymentStatus.mockResolvedValue({ ...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, + } + + 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('forwards to donationsService.updateDonationPayment', async () => { + mockCampaignService.getCampaignById.mockResolvedValue(mockCampaign) + mockDonationsService.updateDonationPayment.mockResolvedValue({ + id: 'donation-123', + status: PaymentStatus.succeeded, + }) + + const result = await service.finishPaymentSession(finishPaymentDto) + + 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, + personId: 'person-123', + }), + PaymentStatus.succeeded, + ) + expect(result).toBe('donation-123') + }) + + it('throws when campaign not found', async () => { + mockCampaignService.getCampaignById.mockResolvedValue(null) + await expect(service.finishPaymentSession(finishPaymentDto)).rejects.toThrow( + 'Campaign not found: campaign-123', + ) + }) + }) +}) 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 000000000..8358ca219 --- /dev/null +++ b/apps/api/src/iris-pay/iris-pay.service.ts @@ -0,0 +1,441 @@ +import { + BadRequestException, + ConflictException, + Injectable, + InternalServerErrorException, + Logger, + NotFoundException, + ServiceUnavailableException, + UnauthorizedException, +} from '@nestjs/common' +import { createHmac, randomUUID, timingSafeEqual } from 'crypto' +import { IRISCreateCheckoutSessionDto } from './dto/create-iris-pay.dto' + +import { IrisHookHash } from './entities/iris-pay.types' +import { IrisPayApiClient } from './iris-pay-api-client' +import { ConfigService } from '@nestjs/config' +import axios from '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, PaymentType, Prisma } from '@prisma/client' +import { PaymentData } from '../donations/helpers/payment-intent-helpers' +import { isFinal } from '../donations/helpers/donation-status-updates' + +export interface FinalizeResult { + status: PaymentStatus + donationId?: string + reason?: string +} + +@Injectable() +export class IrisPayService { + private readonly irisWebhookSecret: string + constructor( + private config: ConfigService, + private irisApi: IrisPayApiClient, + private prismaService: PrismaService, + private campaignService: CampaignService, + private donationsService: DonationsService, + ) { + 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( + paymentId: string, + irisRegisterWebhookDto?: IRISCreateCheckoutSessionDto, + ): Promise { + const APP_URL = this.config.get('APP_URL') + return this.irisApi.createHook({ + url: `${APP_URL}/api/v1/iris-pay/webhook`, + // 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), + }) + } + + async verifyPayment(body: { hookHash: string }): Promise { + return this.irisApi.getPaymentStatus(body.hookHash) + } + + async createCustomer(irisCreateCustomerDto: IrisCreateCustomerDto): Promise { + try { + const found = await this.irisApi.findCustomer(irisCreateCustomerDto.email) + if (found?.userHash) { + Logger.debug('IRIS customer found by email') + return found.userHash + } + } catch (err) { + if (!this.isEmailNotFoundError(err)) { + throw err + } + // emailNotFound — fall through to signup. + } + + const APP_URL = this.config.get('APP_URL', '') + 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 + } + + // 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' + ) + } + + 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) + + const paymentId = randomUUID() + + const userObj: IrisCreateCustomerDto = { + email: irisCreateCheckoutDto.email, + name: irisCreateCheckoutDto.name, + family: irisCreateCheckoutDto.family, + } + const [userHashRes, webhookRes] = await Promise.allSettled([ + this.createCustomer(userObj), + 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", + ) + } + 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 { + 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 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 + 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 updated = await this.donationsService.updateDonationPayment(campaign, paymentData, status) + + await this.storeIrisMetadata(donation.id, irisResult) + + return { + status, + donationId: updated?.id, + reason: irisResult.reasonForFail, + } + } + + // 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: { + 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 { + 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 + const updated = await this.donationsService.updateDonationPayment( + campaign, + paymentData, + paymentStatus, + ) + return updated?.id + } + + 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/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 000000000..f4c9f1fce --- /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 new file mode 100644 index 000000000..01de5b1d4 --- /dev/null +++ b/apps/api/src/iris-pay/services/payment-session.service.ts @@ -0,0 +1,134 @@ +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' +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, + private readonly prisma: PrismaService, + ) { + this.secret = this.configService.get('iris.paymentSessionSecret', '') + } + + createInitialSession(res: Response): void { + const payload: PaymentSessionPayload = { step: 'initialSession', jti: randomUUID() } + 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, + algorithms: ['HS256'], + }) + } 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 + } + + // 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 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 + } + } + + 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 { + const payload: PaymentSessionPayload = { + step: 'paymentSessionCreated', + jti: randomUUID(), + paymentId: data.paymentId, + } + 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 isDev = this.configService.get('APP_ENV') === 'development' + return { + httpOnly: true, + secure: true, + sameSite: (isDev ? 'none' : 'strict') as 'none' | 'strict', + path: '/api/v1/iris-pay', + } + } +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 23b937e96..fb98cbb13 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/apps/api/src/stripe/stripe.service.ts b/apps/api/src/stripe/stripe.service.ts index defc61e63..b328fa342 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, + ) } } 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 bb31c1dd7..b1ebf0ee9 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 b6cb7f4ba..7429605f5 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 { diff --git a/apps/api/src/tasks/tasks-initializer.service.spec.ts b/apps/api/src/tasks/tasks-initializer.service.spec.ts index 69ba45c90..90a36007f 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 277450d49..a52735cfa 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 a239c50c0..7df33156f 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/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 000000000..f9a1cc9af --- /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 2fd851eaa..629906230 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' 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 000000000..679beb16e --- /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/migrations/20260509102209_create_payment_session/migration.sql b/migrations/20260509102209_create_payment_session/migration.sql new file mode 100644 index 000000000..9bb924c56 --- /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/package.json b/package.json index 7dd74da87..a5ad1cac5 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,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", @@ -81,6 +82,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/podkrepi.dbml b/podkrepi.dbml index 31afe33bf..ee4b32bd4 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] @@ -695,6 +705,7 @@ Enum PaymentProvider { epay bank cash + irispay } Enum DocumentType { diff --git a/schema.prisma b/schema.prisma index 8d7b3546b..fe30f4782 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 @@ -789,6 +801,7 @@ enum PaymentProvider { epay bank cash + irispay @@map("payment_provider") } diff --git a/yarn.lock b/yarn.lock index d50888d7f..63aee3e52 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5230,6 +5230,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" @@ -7604,6 +7613,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" @@ -7618,6 +7637,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" @@ -13923,6 +13949,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 @@ -13939,6 +13966,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