Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
93e131e
feat: Add iris-pay skeleton module skeleton
sashko9807 Jun 26, 2024
3908d71
feat: Add initial iris-pay endpoints
sashko9807 Jun 26, 2024
d35a046
feat: Add IRIS checkout controller
sashko9807 Jul 1, 2024
0a0daae
feat: IrisPay integration
sashko9807 Jul 21, 2025
020159d
Merge branch 'master' of https://github.com/sashko9807/podkrepibg-api…
sashko9807 Mar 18, 2026
378528f
refactor: Move payment session cookie to BE
sashko9807 Mar 23, 2026
5d98c9f
feat: Track in backend whether the jwt cookie has been consumed
sashko9807 Apr 1, 2026
f264b49
Merge branch 'master' of https://github.com/sashko9807/podkrepibg-api…
sashko9807 Apr 2, 2026
00cdd71
refactor: IRISPay payment
sashko9807 Apr 19, 2026
4a9c5f1
chore: Fix incorrect webhook endpoint
sashko9807 Apr 19, 2026
6f4b4e1
chore: Improve irispay security
sashko9807 Apr 20, 2026
f9cee02
chore: Add prefix for IRIS_PAY payments
sashko9807 Apr 20, 2026
b3c1898
feat: Use Iris API call to check for existing iris customers
sashko9807 May 3, 2026
f3a93ad
Merge branch 'master' of https://github.com/sashko9807/podkrepibg-api…
sashko9807 May 3, 2026
fe8429e
refactor: Abstract IRIS API calls into their own class
sashko9807 May 3, 2026
6ce3718
chore: Protect iris endpoints behind beta-tester roles
sashko9807 May 3, 2026
b3f768e
feat: Add admin endpoint to toggle beta-tester roles
sashko9807 May 4, 2026
fb00e71
chore: Disable url validation
sashko9807 May 6, 2026
1e63d98
feat: Move session consumption to db
sashko9807 May 9, 2026
a1956a5
chore: Revert back redirectUrl validation
sashko9807 May 12, 2026
d2d3d12
Merge branch 'master' into iris-pay-integrations
sashko9807 May 12, 2026
5030d68
Revert: Change regarding stripe's webhook validation
sashko9807 May 13, 2026
305b0c8
Merge branch 'iris-pay-integrations' of https://github.com/sashko9807…
sashko9807 May 13, 2026
640e816
feat: Create beta-tester role if not found
sashko9807 May 13, 2026
4b46f14
chore: Remove redundant PG_PAYLOAD_SECRET references
sashko9807 May 13, 2026
4076858
chore: Remove redundant dto
sashko9807 May 13, 2026
332487d
chore: Cleanup redudnat entity
sashko9807 May 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -111,4 +116,4 @@ CACHE_TTL=30000
## AdminEmail ##
##############
CAMPAIGN_COORDINATOR_EMAIL=campaign_coordinators@podkrepi.bg
CORPORATE_DONORS_EMAIL=
CORPORATE_DONORS_EMAIL=
13 changes: 13 additions & 0 deletions apps/api/src/account/account.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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)
}
}
4 changes: 4 additions & 0 deletions apps/api/src/account/account.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
10 changes: 10 additions & 0 deletions apps/api/src/account/dto/toggle-beta-tester.dto.ts
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 2 additions & 0 deletions apps/api/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import { AppController } from './app.controller'
import { CustomAuthGuard } from './custom-auth.guard'
import configuration from '../config/configuration'
import { PrismaService } from '../prisma/prisma.service'

Check warning on line 14 in apps/api/src/app/app.module.ts

View workflow job for this annotation

GitHub Actions / Run API tests

'PrismaService' is defined but never used
import { AccountModule } from '../account/account.module'
import { HealthModule } from '../health/health.module'
import { SupportModule } from '../support/support.module'
Expand Down Expand Up @@ -64,6 +64,7 @@
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: [
Expand Down Expand Up @@ -135,6 +136,7 @@
LoggerModule,
CampaignApplicationModule,
StripeModule,
IrisPayModule,
],
controllers: [AppController],
providers: [
Expand Down
23 changes: 21 additions & 2 deletions apps/api/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 || '',
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 },
},
})
4 changes: 4 additions & 0 deletions apps/api/src/config/validation.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export class ConnectPaymentSessionDto {
jti: string
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export class CreatePaymentSessionDto {
jti: string
expiresAt: Date
}
3 changes: 3 additions & 0 deletions apps/api/src/domain/generated/paymentSession/dto/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './connect-paymentSession.dto'
export * from './create-paymentSession.dto'
export * from './update-paymentSession.dto'
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export class UpdatePaymentSessionDto {
expiresAt?: Date
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './paymentSession.entity'
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export class PaymentSession {
jti: string
consumedAt: Date
expiresAt: Date
}
2 changes: 1 addition & 1 deletion apps/api/src/donations/helpers/donation-status-updates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
65 changes: 65 additions & 0 deletions apps/api/src/iris-pay/README.md
Original file line number Diff line number Diff line change
@@ -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"
}
}'
```
4 changes: 4 additions & 0 deletions apps/api/src/iris-pay/decorators/payment-step.decorator.ts
Original file line number Diff line number Diff line change
@@ -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)
53 changes: 53 additions & 0 deletions apps/api/src/iris-pay/dto/create-iris-customer.ts
Original file line number Diff line number Diff line change
@@ -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
}
66 changes: 66 additions & 0 deletions apps/api/src/iris-pay/dto/create-iris-pay.dto.ts
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading